UX improvements

This commit is contained in:
Jonas H
2026-06-17 12:53:18 +02:00
parent 8ffd258341
commit f0e5c202f5
4 changed files with 458 additions and 122 deletions

View File

@@ -13,7 +13,8 @@ pub fn lock_app(app: &SharedApp) -> std::sync::MutexGuard<'_, App> {
}
/// Display names for the filterable entry kinds, in toggle order.
pub const FILTER_LABELS: [&str; 6] = ["user", "thinking", "text", "tools", "meta", "errors"];
pub const FILTER_LABELS: [&str; 7] =
["user", "thinking", "text", "tools", "meta", "errors", "system"];
/// Fallback model picker entries used until (or unless) `term::discover_models`
/// reads the live alias set out of the installed `claude` binary. Each entry is
@@ -66,6 +67,11 @@ pub struct App {
/// the highlighted turn's first entry (heights live in the render cache,
/// so the key handler can't compute the offset itself).
pub turn_dirty: bool,
/// Request to jump the feed scroll to the next (`Some(true)`) or previous
/// (`Some(false)`) `Kind::User` prompt — `n`/`N`. Honoured once on the next
/// draw (entry heights live in the render cache, so the key handler can't
/// compute the offset itself), then cleared.
pub prompt_jump: Option<bool>,
/// Session UUID of the embedded `claude` pane (src/term.rs), so the tap
/// can recognise traffic belonging to it.
///
@@ -147,6 +153,7 @@ impl App {
history: HashMap::new(),
expanded: None,
turn_dirty: false,
prompt_jump: None,
embed_session: None,
embed_grow: false,
embed_grow_rows: None,
@@ -411,6 +418,7 @@ pub fn filter_index(kind: &Kind) -> usize {
Kind::Tool { .. } => 3,
Kind::Meta => 4,
Kind::Error => 5,
Kind::Reminder => 6,
}
}
@@ -452,6 +460,10 @@ pub enum Kind {
Tool { name: String },
Meta,
Error,
/// A `<system-reminder>` Claude Code injects into a user turn. Kept (shown
/// dimmed, like a thinking block) rather than discarded, so the context the
/// model actually received is visible. Filtered under the "system" toggle.
Reminder,
}
pub struct Entry {
@@ -519,21 +531,72 @@ pub fn attach_tool_results(app: &SharedApp, key: &str, body: &Value) {
}
}
/// Text blocks Claude Code injects around the user's words; not what the
/// user typed, so they don't belong in a "user prompt" entry.
pub(crate) fn is_injected_block(t: &str) -> bool {
let t = t.trim_start();
t.starts_with("<system-reminder>")
|| t.starts_with("<command-name>")
|| t.starts_with("<local-command")
/// Split a user text block into its injected `<system-reminder>` spans (kept so
/// the UI can show them dimmed) and the real prompt the user typed.
///
/// Crucially, a reminder is sometimes prepended *inside the same text block* as
/// the prompt (this is what swallowed the first message after a resume), so the
/// spans are excised in place and the remainder kept — never the whole block
/// dropped on a leading tag. Slash-command wrappers are removed outright (pure
/// machinery, nothing the user typed).
pub(crate) fn extract_user_text(t: &str) -> (Vec<String>, String) {
const OPEN: &str = "<system-reminder>";
const CLOSE: &str = "</system-reminder>";
let mut reminders = Vec::new();
let mut s = t.to_string();
while let Some(start) = s.find(OPEN) {
let after = start + OPEN.len();
match s[after..].find(CLOSE) {
Some(rel) => {
let inner = s[after..after + rel].trim();
if !inner.is_empty() {
reminders.push(inner.to_string());
}
let end_abs = after + rel + CLOSE.len();
s.replace_range(start..end_abs, "");
}
None => {
// Unterminated tag: the rest of the block is the reminder.
let inner = s[after..].trim();
if !inner.is_empty() {
reminders.push(inner.to_string());
}
s.truncate(start);
break;
}
}
}
// Drop slash-command wrapper lines (these always occupy their own lines).
let prompt = s
.lines()
.filter(|l| {
let lt = l.trim_start();
!(lt.starts_with("<command-name>")
|| lt.starts_with("<command-message>")
|| lt.starts_with("<command-args>")
|| lt.starts_with("<local-command"))
})
.collect::<Vec<_>>()
.join("\n")
.trim()
.to_string();
(reminders, prompt)
}
/// Record a user-submitted prompt from a request body as a feed entry.
/// Purely passive (reads bytes already flowing through the proxy). Only the
/// *trailing* user message counts: tool-loop continuations end in tool_result
/// blocks and thus contribute no text, so exactly the turn-starting prompt
/// lands here. Side requests (topic detection etc.) carry no `tools` and are
/// skipped; retries/resends are deduped against the last recorded prompt.
/// The user's typed prompt only (reminders discarded) — used where injected
/// context is noise, e.g. turn-tree labels.
pub(crate) fn strip_injected(t: &str) -> String {
extract_user_text(t).1
}
/// Record a user-submitted prompt from a request body as a feed entry, plus any
/// injected `<system-reminder>` blocks that rode along (shown dimmed before the
/// prompt). Purely passive (reads bytes already flowing through the proxy).
/// Only the *trailing* user message counts: tool-loop continuations end in
/// tool_result blocks and thus contribute no text, so exactly the turn-starting
/// prompt lands here. Side requests (topic detection etc.) carry no `tools` and
/// are skipped; retries/resends are deduped against the last recorded prompt
/// (the dedup gates the reminders too, so a resend doesn't double them up).
pub fn record_user_prompt(app: &SharedApp, key: &str, body: &Value) {
if body
.get("tools")
@@ -552,17 +615,34 @@ pub fn record_user_prompt(app: &SharedApp, key: &str, body: &Value) {
if last.get("role").and_then(Value::as_str) != Some("user") {
return;
}
let text = match last.get("content") {
Some(Value::String(s)) => s.clone(),
Some(Value::Array(blocks)) => blocks
.iter()
.filter(|b| b.get("type").and_then(Value::as_str) == Some("text"))
.filter_map(|b| b.get("text").and_then(Value::as_str))
.filter(|t| !is_injected_block(t))
.collect::<Vec<_>>()
.join("\n"),
// Gather reminders + prompt across every text block of the message.
let mut reminders: Vec<String> = Vec::new();
let mut prompts: Vec<String> = Vec::new();
match last.get("content") {
Some(Value::String(s)) => {
let (r, p) = extract_user_text(s);
reminders.extend(r);
if !p.is_empty() {
prompts.push(p);
}
}
Some(Value::Array(blocks)) => {
for b in blocks {
if b.get("type").and_then(Value::as_str) != Some("text") {
continue;
}
if let Some(t) = b.get("text").and_then(Value::as_str) {
let (r, p) = extract_user_text(t);
reminders.extend(r);
if !p.is_empty() {
prompts.push(p);
}
}
}
}
_ => return,
};
}
let text = prompts.join("\n");
let text = text.trim();
if text.is_empty() {
return;
@@ -579,6 +659,14 @@ pub fn record_user_prompt(app: &SharedApp, key: &str, body: &Value) {
{
return;
}
for r in reminders {
s.entries.push(Entry {
kind: Kind::Reminder,
content: r,
done: true,
result: None,
});
}
s.entries.push(Entry {
kind: Kind::User,
content: text.to_string(),
@@ -917,6 +1005,65 @@ mod tests {
);
}
#[test]
fn inline_system_reminder_is_stripped_not_dropped() {
// The first message after a resume often arrives with a
// <system-reminder> prepended *inside the same text block* as the real
// prompt — the old whole-block filter dropped it entirely.
let app: SharedApp = Arc::new(Mutex::new(App::new()));
drop(Tap::new(app.clone(), "abc".into(), "claude-x".into()));
record_user_prompt(
&app,
"abc",
&json!({"tools": [{"name": "Bash"}], "messages": [
{"role": "user", "content": [
{"type": "text", "text":
"<system-reminder>be careful</system-reminder>\n\nresume me please"}
]}
]}),
);
let a = app.lock().unwrap();
let user: Vec<_> = a.sessions[0]
.entries
.iter()
.filter(|e| e.kind == Kind::User)
.collect();
assert_eq!(user.len(), 1, "the prompt must survive the inline reminder");
assert_eq!(user[0].content, "resume me please");
// The reminder is kept (shown dimmed), recorded just before the prompt.
let rem: Vec<_> = a.sessions[0]
.entries
.iter()
.filter(|e| e.kind == Kind::Reminder)
.collect();
assert_eq!(rem.len(), 1);
assert_eq!(rem[0].content, "be careful");
}
#[test]
fn extract_user_text_splits_reminders_from_prompt() {
let (rem, prompt) = extract_user_text("<system-reminder>x</system-reminder>");
assert_eq!(rem, vec!["x"]);
assert_eq!(prompt, "");
let (rem, prompt) = extract_user_text("hello");
assert!(rem.is_empty());
assert_eq!(prompt, "hello");
let (rem, prompt) =
extract_user_text("pre <system-reminder>noise</system-reminder> post");
assert_eq!(rem, vec!["noise"]);
assert_eq!(prompt, "pre post");
// Unterminated tag → the rest of the block is the reminder.
let (rem, prompt) = extract_user_text("keep <system-reminder>dropped");
assert_eq!(rem, vec!["dropped"]);
assert_eq!(prompt, "keep");
// strip_injected is the prompt-only projection.
assert_eq!(strip_injected("a <system-reminder>b</system-reminder>"), "a");
}
#[test]
fn ask_rows_scale_with_options() {
let two = ask_question_rows(&json!({"questions": [

View File

@@ -9,7 +9,7 @@
//! with `-` (the leading `/` becomes a leading `-`).
use crate::app::{
flatten_result_content, is_injected_block, lock_app, Entry, Kind, Session, SharedApp,
flatten_result_content, lock_app, strip_injected, Entry, Kind, Session, SharedApp,
ToolResult,
};
use serde_json::Value;
@@ -144,18 +144,18 @@ fn prompt_text(v: &Value) -> Option<String> {
return None;
}
let text = match v.pointer("/message/content") {
Some(Value::String(s)) => s.clone(),
Some(Value::String(s)) => strip_injected(s),
Some(Value::Array(blocks)) => blocks
.iter()
.filter(|b| b.get("type").and_then(Value::as_str) == Some("text"))
.filter_map(|b| b.get("text").and_then(Value::as_str))
.filter(|t| !is_injected_block(t))
.map(strip_injected)
.filter(|t| !t.is_empty())
.collect::<Vec<_>>()
.join("\n"),
_ => return None,
};
let text = text.trim();
(!text.is_empty() && !is_injected_block(text)).then(|| text.to_string())
(!text.is_empty()).then_some(text)
}
/// Parse a session file into its turn tree. None when the file has no turns
@@ -480,30 +480,11 @@ impl EntryParser {
}
}
Some("user") => match v.pointer("/message/content") {
Some(Value::String(s)) => {
if !s.is_empty() {
entries.push(Entry {
kind: Kind::Meta,
content: format!(" {s}"),
done: true,
result: None,
});
}
}
Some(Value::String(s)) => push_user_text(entries, s),
Some(Value::Array(blocks)) => {
for b in blocks {
match b.get("type").and_then(Value::as_str) {
Some("text") => {
let t = text_of(b, "text");
if !t.is_empty() {
entries.push(Entry {
kind: Kind::Meta,
content: format!(" {t}"),
done: true,
result: None,
});
}
}
Some("text") => push_user_text(entries, &text_of(b, "text")),
Some("tool_result") => {
let Some(idx) = b
.get("tool_use_id")
@@ -537,6 +518,18 @@ fn text_of(b: &Value, key: &str) -> String {
b.get(key).and_then(Value::as_str).unwrap_or_default().to_string()
}
/// Translate a user text block into feed entries: each injected reminder as a
/// dimmed `Kind::Reminder`, then the real prompt as `Kind::User`.
fn push_user_text(entries: &mut Vec<Entry>, text: &str) {
let (reminders, prompt) = crate::app::extract_user_text(text);
for r in reminders {
entries.push(Entry { kind: Kind::Reminder, content: r, done: true, result: None });
}
if !prompt.is_empty() {
entries.push(Entry { kind: Kind::User, content: prompt, done: true, result: None });
}
}
/// Encode the current working directory the same way Claude Code does:
/// each `/` → `-` (the leading `/` becomes a leading `-`).
fn project_dir() -> Result<PathBuf, String> {
@@ -812,9 +805,10 @@ mod tests {
let s = s.expect("history loaded");
assert_eq!(s.key, uuid);
assert_eq!(s.model, "claude-x");
// prompt, thinking, tool, text — sidechain line skipped.
// user prompt, thinking, tool, text — sidechain line skipped.
assert_eq!(s.entries.len(), 4);
assert_eq!(s.entries[0].content, " fix the bug");
assert!(matches!(s.entries[0].kind, Kind::User));
assert_eq!(s.entries[0].content, "fix the bug");
assert!(matches!(s.entries[1].kind, Kind::Thinking));
let tool = &s.entries[2];
assert!(matches!(&tool.kind, Kind::Tool { name } if name == "Bash"));

301
src/ui.rs
View File

@@ -89,7 +89,7 @@ struct FeedCache {
}
struct CachedEntry {
fingerprint: (usize, bool, usize, bool),
fingerprint: (usize, bool, usize, bool, bool),
lines: Vec<Line<'static>>,
/// Rows after wrapping to `FeedCache::width` (incl. trailing separator).
height: usize,
@@ -97,13 +97,17 @@ struct CachedEntry {
/// Cheap change-detector for a cached entry: content only ever grows (or is
/// swapped for the pretty-printed form along with `done`), results attach
/// once — length + flags capture every mutation the app performs.
fn fingerprint(e: &Entry) -> (usize, bool, usize, bool) {
/// once — length + flags capture every mutation the app performs. The trailing
/// bool folds in feed focus, but *only* for `Kind::User` entries (their block
/// color tracks focus): toggling focus then re-renders just the user blocks,
/// not the whole transcript.
fn fingerprint(e: &Entry, focused: bool) -> (usize, bool, usize, bool, bool) {
let (rlen, rerr) = e
.result
.as_ref()
.map_or((usize::MAX, false), |r| (r.content.len(), r.is_error));
(e.content.len(), e.done, rlen, rerr)
let focus_bit = matches!(e.kind, Kind::User) && focused;
(e.content.len(), e.done, rlen, rerr, focus_bit)
}
/// Detach a `Line` from the text it borrows so it can outlive the app lock.
@@ -119,28 +123,106 @@ fn own_line(l: Line<'_>) -> Line<'static> {
}
}
/// Background for user-prompt entries (indexed so 256-color terminals work).
/// Background used to highlight a visual-mode turn range in the session list.
const USER_BG: Color = Color::Indexed(17); // deep blue
/// Focus accent — Claude's orange (indexed so 256-color terminals match it).
/// This is the single source of truth for "the feed/pane has keyboard focus":
/// borders, the scroll thumb, the user-message markers and the user-message
/// blocks all use it when focused.
const ACCENT: Color = Color::Indexed(208); // orange
/// Background of a focused user-prompt block (the active accent).
const USER_BG_ACTIVE: Color = Color::Indexed(208); // orange
/// Background of an unfocused user-prompt block (dimmed grey).
const USER_BG_DIM: Color = Color::Indexed(238); // dark grey
/// Style for a user-prompt block: a filled orange box when the feed is focused,
/// a dim grey box when it isn't (focus is folded into the per-entry fingerprint
/// for `Kind::User` only, so toggling focus re-renders just these blocks). The
/// foreground is chosen by `color_on` so the text stays legible on either bg.
fn user_block_style(focused: bool) -> Style {
let bg = if focused { USER_BG_ACTIVE } else { USER_BG_DIM };
let s = Style::new().bg(bg).fg(color_on(bg));
if focused { s.bold() } else { s }
}
/// Pick a legible foreground (black or white) for text drawn on `bg`, from the
/// background's perceived luminance. A filled block sets its own bg, so this
/// keeps the text readable regardless of the user's light/dark terminal theme.
fn color_on(bg: Color) -> Color {
let (r, g, b) = rgb_of(bg);
let luma = 0.299 * f32::from(r) + 0.587 * f32::from(g) + 0.114 * f32::from(b);
if luma > 140.0 { Color::Black } else { Color::White }
}
/// Approximate 8-bit RGB for a ratatui `Color`, enough to judge brightness:
/// RGB passes through; the xterm-256 indexed palette is decoded (16 base + the
/// 6×6×6 cube + the 24-step grey ramp); anything else falls back to mid grey.
fn rgb_of(c: Color) -> (u8, u8, u8) {
match c {
Color::Rgb(r, g, b) => (r, g, b),
Color::Black => (0, 0, 0),
Color::White => (255, 255, 255),
Color::Indexed(i) => indexed_rgb(i),
_ => (128, 128, 128),
}
}
fn indexed_rgb(i: u8) -> (u8, u8, u8) {
const BASE: [(u8, u8, u8); 16] = [
(0, 0, 0), (128, 0, 0), (0, 128, 0), (128, 128, 0),
(0, 0, 128), (128, 0, 128), (0, 128, 128), (192, 192, 192),
(128, 128, 128), (255, 0, 0), (0, 255, 0), (255, 255, 0),
(0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255),
];
match i {
0..=15 => BASE[i as usize],
16..=231 => {
const STEP: [u8; 6] = [0, 95, 135, 175, 215, 255];
let n = i - 16;
(STEP[(n / 36) as usize], STEP[((n / 6) % 6) as usize], STEP[(n % 6) as usize])
}
_ => {
let v = 8 + 10 * (i - 232);
(v, v, v)
}
}
}
/// Render one entry to owned lines, including the blank separator row that
/// follows every entry in the feed.
fn entry_lines(e: &Entry, width: u16) -> Vec<Line<'static>> {
/// follows every entry in the feed. `focused` only affects user-prompt blocks.
fn entry_lines(e: &Entry, width: u16, focused: bool) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
match &e.kind {
Kind::Meta => lines.push(Line::from(e.content.clone()).dark_gray()),
Kind::User => {
let style = Style::new().bg(USER_BG).fg(Color::White);
let style = user_block_style(focused);
let w = (width as usize).max(1);
for (i, l) in e.content.lines().enumerate() {
let prefix = if i == 0 { " " } else { " " };
let mut row = format!("{prefix}{}", sanitize(l));
// Pad to a multiple of the feed width so every *wrapped* row
// is painted edge-to-edge, not just up to the last character.
let rem = row.chars().count() % w;
if rem != 0 {
row.extend(std::iter::repeat_n(' ', w - rem));
// Wrap to the inner feed width ourselves and pad every row to
// *exactly* w, so the block is a clean filled rectangle that ends
// flush with the borders — no reliance on the Paragraph's own
// wrapping (which left a stray, half-empty continuation row), and
// no trailing blank: the content is right-trimmed first.
let mut first = true;
for raw in e.content.trim_end().lines() {
let prefixed = format!("{}{}", if first { " " } else { " " }, sanitize(raw));
for seg in wrap_words(&prefixed, w) {
let mut row = seg;
let pad = w.saturating_sub(row.chars().count());
row.extend(std::iter::repeat_n(' ', pad));
lines.push(Line::from(Span::styled(row, style)));
}
lines.push(Line::from(Span::styled(row, style)));
first = false;
}
if lines.is_empty() {
lines.push(Line::from(Span::styled(" ".repeat(w), style)));
}
}
Kind::Reminder => {
// Injected context Claude Code received — shown dim, like thinking.
lines.push(Line::from("⌁ system reminder").dark_gray().italic());
for l in e.content.lines() {
lines.push(Line::from(format!(" {}", sanitize(l))).dark_gray().italic());
}
}
Kind::Thinking => {
@@ -623,14 +705,14 @@ fn event_loop(
}
continue;
}
// Model picker (opened with `n`): j/k move, Enter spawns a fresh
// session with the chosen model, esc/n/q cancels.
// Model picker (opened with `a`): j/k move, Enter spawns a fresh
// session with the chosen model, esc/a/q cancels.
if let Some(msel) = a.model_popup {
let n = a.model_choices.len().max(1);
match k.code {
KeyCode::Up | KeyCode::Char('k') => a.model_popup = Some((msel + n - 1) % n),
KeyCode::Down | KeyCode::Char('j') => a.model_popup = Some((msel + 1) % n),
KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('q') => a.model_popup = None,
KeyCode::Esc | KeyCode::Char('a') | KeyCode::Char('q') => a.model_popup = None,
KeyCode::Enter => {
a.model_popup = None;
let model = a.model_choices.get(msel).map(|c| c.1.clone());
@@ -652,9 +734,19 @@ fn event_loop(
None => return Ok(()),
},
KeyCode::Char('f') => a.filter_popup = Some(0),
// n → pick a model, then spawn a brand-new session (no need to
// a → pick a model, then spawn a brand-new session (no need to
// resume + /clear just to get a fresh chat).
KeyCode::Char('n') => a.model_popup = Some(0),
KeyCode::Char('a') => a.model_popup = Some(0),
// n / N → jump the feed scroll to the next / previous user
// prompt (honoured on the next draw, where entry heights live).
KeyCode::Char('n') => {
a.follow = false;
a.prompt_jump = Some(true);
}
KeyCode::Char('N') => {
a.follow = false;
a.prompt_jump = Some(false);
}
KeyCode::Char('s') => a.show_sessions = !a.show_sessions,
// c → attach the most recent past session (like `claude -c`):
// select it (the scanner keeps disk_sessions newest-first),
@@ -689,8 +781,9 @@ fn event_loop(
}
Err(e) => a.status = e,
},
// Tab / BackTab cycle sessions. `n` is the new-session picker;
// `p` is no longer a back-tab mirror.
// Tab / BackTab cycle sessions. `a` is the new-session picker;
// `n`/`N` jump between user prompts; `p` is no longer a
// back-tab mirror.
KeyCode::Tab if nsess > 0 => {
a.selected = (a.selected + 1) % nsess;
a.follow = true;
@@ -779,7 +872,7 @@ fn draw(
// dimming tells you at a glance which side ctrl-↑/ctrl-↓ left focus on.
let border_style = |focused: bool| {
if focused {
Style::new().cyan().bold()
Style::new().fg(ACCENT).bold()
} else {
Style::new().dark_gray()
}
@@ -818,25 +911,40 @@ fn draw(
Some((av.min(p), av.max(p)))
});
let white = Style::new().fg(Color::White);
// The session whose `claude` child we spawned and is still alive: the
// one running instance this app owns (cleared the moment the pane
// exits). It gets a bright accent marker + accent title so it reads as
// "running here" at a glance; external live sessions only show a green
// dot while they're actively streaming (their instance may have ended —
// liveness is unknowable), and disk stubs stay dimmed.
let embed_key = a.embed_session.clone();
for m in 0..live_n + stubs.len() {
// Every session is a multi-line item: the full title (white,
// wrapped to the panel width — continuation rows aligned under
// it) followed by a dimmed meta row (status dot + id + model for
// live sessions, just the id for disk stubs).
let (uuid, lead, title, meta) = if m < live_n {
let (uuid, lead, title, meta, title_style) = if m < live_n {
let s = &a.sessions[m];
let lead = if s.active > 0 { "".green() } else { "".dark_gray() };
let is_embed = embed_key.as_deref() == Some(s.key.as_str());
let lead = if is_embed {
"".fg(ACCENT).bold()
} else if s.active > 0 {
"".green()
} else {
"".dark_gray()
};
let id: String = s.key.chars().take(8).collect();
(
s.key.clone(),
lead,
live_title(s),
format!("{id} · {}", short_model(&s.model)),
)
let meta = if is_embed {
format!("{id} · {} · running", short_model(&s.model))
} else {
format!("{id} · {}", short_model(&s.model))
};
let title_style = if is_embed { Style::new().fg(ACCENT).bold() } else { white };
(s.key.clone(), lead, live_title(s), meta, title_style)
} else {
let d = &a.disk_sessions[stubs[m - live_n]];
let id: String = d.uuid.chars().take(8).collect();
(d.uuid.clone(), "· ".dark_gray(), d.label.clone(), id)
(d.uuid.clone(), "· ".dark_gray(), d.label.clone(), id, white)
};
let mut rows: Vec<Line> = Vec::new();
for (i, w) in wrap_words(&sanitize(&title), inner_w.saturating_sub(2))
@@ -844,9 +952,9 @@ fn draw(
.enumerate()
{
if i == 0 {
rows.push(Line::from(vec![lead.clone(), Span::styled(w, white)]));
rows.push(Line::from(vec![lead.clone(), Span::styled(w, title_style)]));
} else {
rows.push(Line::from(Span::styled(format!(" {w}"), white)));
rows.push(Line::from(Span::styled(format!(" {w}"), title_style)));
}
}
rows.push(Line::from(format!(" {meta}")).dark_gray());
@@ -871,7 +979,7 @@ fn draw(
let avail = inner_w.saturating_sub(prefix.chars().count());
let txt = format!("{prefix}{}", truncate_str(&turn.label, avail));
let line = if vis_range.is_some_and(|(lo, hi)| p >= lo && p <= hi) {
Line::from(txt).style(Style::new().bg(USER_BG).fg(Color::White))
Line::from(txt).style(Style::new().bg(USER_BG).fg(color_on(USER_BG)))
} else {
Line::from(txt).dark_gray()
};
@@ -948,6 +1056,9 @@ fn draw(
_ => None,
};
a.turn_dirty = false;
// `n`/`N`: jump to the next/previous user prompt. Taken once, here, before
// the feed borrow so we can clear it (offset computed below from the cache).
let prompt_jump = a.prompt_jump.take();
let (feed_session, feed_leaf): (Option<&Session>, Option<usize>) = match &hist_view {
Some((u, _)) => {
let h = a.history.get(u);
@@ -975,9 +1086,9 @@ fn draw(
cache.entries.clear();
}
for (i, e) in s.entries.iter().enumerate() {
let fp = fingerprint(e);
let fp = fingerprint(e, feed_focused);
if cache.entries.get(i).is_none_or(|c| c.fingerprint != fp) {
let lines = entry_lines(e, feed_width);
let lines = entry_lines(e, feed_width, feed_focused);
let height = wrapped_height(&lines, feed_width);
let ce = CachedEntry { fingerprint: fp, lines, height };
if i < cache.entries.len() {
@@ -1010,6 +1121,26 @@ fn draw(
.min(max_scroll);
new_follow = false;
}
if let Some(down) = prompt_jump {
// Wrapped-row offset of each visible user prompt's first row.
let mut offsets: Vec<usize> = Vec::new();
let mut acc = 0usize;
for &i in &visible {
if matches!(s.entries[i].kind, Kind::User) {
offsets.push(acc);
}
acc += cache.entries[i].height;
}
let pick = if down {
offsets.iter().copied().find(|&o| o > scroll0)
} else {
offsets.iter().rev().copied().find(|&o| o < scroll0)
};
if let Some(o) = pick {
new_scroll = o.min(max_scroll);
new_follow = false;
}
}
// Reaching the bottom re-engages follow automatically.
if new_scroll >= max_scroll {
new_follow = true;
@@ -1066,31 +1197,62 @@ fn draw(
right,
);
// Scroll-position indicator: overdraw a thick segment of the feed's
// right border (heavy vertical `┃`) marking the visible window's
// position within the whole transcript. Shown only when scrollable.
if total > height && height > 0 && right.width >= 2 {
// Thumb height/position proportional to the viewport vs. total.
let thumb = ((height * height) / total).max(1).min(height);
let max_scroll = total - height;
let thumb_top = if max_scroll == 0 {
0
} else {
(new_scroll * (height - thumb)) / max_scroll
};
// Right-border overlay: first `*` markers showing where the user's own
// messages sit in the whole conversation (a minimap of prompts), then
// the scroll thumb painted on top of them where they coincide.
if height > 0 && right.width >= 2 {
let col = right.x + right.width - 1;
// Solid block fully fills the cell (a heavy `┃` line still reads as
// a thin, dim stroke). Brighter than the border so the thumb stands
// out as an indicator: cyan when focused, gray otherwise.
let style = if feed_focused {
Style::new().cyan()
} else {
Style::new().fg(Color::Gray)
// Map a wrapped-row offset within the transcript to a border row.
// When everything fits, offsets are 1:1 with screen rows; once
// scrollable, compress the whole transcript onto the track.
let track_row = |offset: usize| -> u16 {
let r = if total <= height {
offset
} else {
offset * height / total
};
r.min(height - 1) as u16
};
let buf = f.buffer_mut();
for k in 0..thumb {
let y = right.y + 1 + (thumb_top + k) as u16;
buf[(col, y)].set_symbol("").set_style(style);
// Markers track focus like the prompt blocks: orange when focused,
// dim grey when not.
let marker_style = if feed_focused {
Style::new().fg(ACCENT).bold()
} else {
Style::new().fg(Color::DarkGray)
};
let mut acc = 0usize;
{
let buf = f.buffer_mut();
for &i in &visible {
if matches!(s.entries[i].kind, Kind::User) {
let y = right.y + 1 + track_row(acc);
buf[(col, y)].set_symbol("*").set_style(marker_style);
}
acc += cache.entries[i].height;
}
}
// Scroll thumb: a solid block marking the visible window's position
// within the whole transcript, drawn last so it sits *on top* of a
// marker at the same row. Shown only when scrollable.
if total > height {
let thumb = ((height * height) / total).max(1).min(height);
let max_scroll = total - height;
let thumb_top = if max_scroll == 0 {
0
} else {
(new_scroll * (height - thumb)) / max_scroll
};
let style = if feed_focused {
Style::new().fg(ACCENT)
} else {
Style::new().fg(Color::Gray)
};
let buf = f.buffer_mut();
for k in 0..thumb {
let y = right.y + 1 + (thumb_top + k) as u16;
buf[(col, y)].set_symbol("").set_style(style);
}
}
}
} else {
@@ -1627,7 +1789,7 @@ fn push_numbered<'a>(out: &mut Vec<Line<'a>>, text: &str, bg: Option<Color>, wid
row.extend(std::iter::repeat_n(' ', pad));
out.push(Line::from(Span::styled(
row,
Style::new().bg(bg).fg(Color::White),
Style::new().bg(bg).fg(color_on(bg)),
)));
}
None => out.push(Line::from(vec![
@@ -1654,7 +1816,18 @@ fn sanitize(l: &str) -> String {
#[cfg(test)]
mod tests {
use super::{base64, truncate_str, wrap_words};
use super::{base64, color_on, truncate_str, wrap_words};
use ratatui::style::Color;
#[test]
fn color_on_contrasts_with_background() {
// Bright orange / white → black text; dark grey / blue → white text.
assert_eq!(color_on(Color::Indexed(208)), Color::Black);
assert_eq!(color_on(Color::White), Color::Black);
assert_eq!(color_on(Color::Indexed(238)), Color::White);
assert_eq!(color_on(Color::Indexed(17)), Color::White);
assert_eq!(color_on(Color::Rgb(0, 0, 0)), Color::White);
}
#[test]
fn wrap_words_greedy_and_hard_splits() {