From f0e5c202f5cdb6aa4ed79e0050826bd7076a5918 Mon Sep 17 00:00:00 2001 From: Jonas H Date: Wed, 17 Jun 2026 12:53:18 +0200 Subject: [PATCH] UX improvements --- CLAUDE.md | 34 +++++- src/app.rs | 195 +++++++++++++++++++++++++++---- src/sessions.rs | 50 ++++---- src/ui.rs | 301 ++++++++++++++++++++++++++++++++++++++---------- 4 files changed, 458 insertions(+), 122 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2b442c0..0b7395e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,8 +12,14 @@ extra usage is the core constraint of this project. src/main.rs entry; tokio runtime for proxy task, TUI on main thread; --headless mode src/proxy.rs axum fallback handler: buffers request body (for session metadata, tool results, and user prompts — `app::record_user_prompt` lifts - the trailing user message into a Kind::User feed entry), - forwards via reqwest, streams response back unbuffered, tees SSE + the trailing user message into a Kind::User feed entry; + `app::extract_user_text` splits a user text block into its + injected `` spans (kept as dimmed Kind::Reminder + entries, never discarded — the prompt survives even when it + shares its block with a reminder, the first-message-after-resume + case) and the real prompt; `strip_injected` is the prompt-only + projection used for turn-tree labels), forwards via reqwest, + streams the response back unbuffered, tees SSE src/sse.rs incremental SSE parser; tolerant of chunk splits mid-event/mid-UTF-8 src/app.rs Arc> shared state; Tap = one in-flight tapped request, translates SSE events → session Entries (Drop closes it out) @@ -21,6 +27,21 @@ src/ui.rs ratatui rendering @ ~30fps; session list + scrollable feed (FeedCache: per-entry rendered lines + wrapped heights, only changed entries re-render; the viewport window of lines is handed to ratatui so scroll state is usize end-to-end). + Focus accent is orange (`ACCENT` = indexed 208): borders, the + scroll thumb and the user-prompt blocks all use it when focused, + dim grey when not. User prompts render as full-width filled + rectangles padded to exactly the inner width (`wrap_words` + + exact pad, never the Paragraph's own wrap, so the box ends flush + with the borders); the fingerprint folds feed-focus in for + `Kind::User` only, so a focus toggle re-renders just those + blocks. `color_on(bg)` picks black/white text by background + luminance (used by the prompt blocks and the edit/diff blocks) so + filled blocks stay legible under any terminal theme. Injected + ``s show as dim `Kind::Reminder` entries + (filter "system"). The feed's right border doubles as a prompt + minimap: `*` markers show where each user message sits in the + whole conversation, with the scroll thumb drawn on top where they + coincide. Sessions panel is a uniform 50% of the main area: each session is a multi-line item — full white title (live = first user prompt via `live_title`, stub = disk label, word-wrapped by @@ -128,7 +149,7 @@ UI thread redraws on its own tick (no channel; just the mutex). focused), ctrl-q quit (global; needed while the pane is focused, where plain `q` is forwarded to the child), c attach most-recent past session. List keys: j/k/↑/↓ move the session/turn highlight, space/→/← expand/ - enter/leave the turn tree, n opens the model picker popup and spawns a + enter/leave the turn tree, a opens the model picker popup and spawns a brand-new `claude --session-id … [--model …]` (kills any current pane — `show_embed_new`; saves resume-then-/clear to get a fresh chat). The picker list comes from `App::model_choices`: seeded with `default_model_choices`, @@ -139,9 +160,10 @@ UI thread redraws on its own tick (no channel; just the mutex). `claude` on PATH and greps its bytes for the longest lowercase-token array anchored by `opus`+`sonnet`. Tab/BackTab cycle sessions (`p` no longer mirrors BackTab). v visual range, b branch, Esc unwinds (visual → tree → - quit). The feed - scrolls only via wheel / PgUp / PgDn / g / G. `CT_DEBUG_KEYS=1` shows raw - key events in the status bar. + quit). n/N jump the feed scroll to the next/previous user prompt + (`App::prompt_jump`, applied in `draw` where entry heights are cached). The + feed scrolls only via wheel / PgUp / PgDn / g / G / n / N. + `CT_DEBUG_KEYS=1` shows raw key events in the status bar. - Mouse is captured: wheel always scrolls the feed (regardless of focus), and left-drag selects screen text, copied on release via OSC 52 (like Claude Code). Native terminal selection therefore needs shift held. diff --git a/src/app.rs b/src/app.rs index 4d03c55..f257ebe 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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, /// 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 `` 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("") - || t.starts_with("") - || t.starts_with("` 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) { + const OPEN: &str = ""; + const CLOSE: &str = ""; + 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("") + || lt.starts_with("") + || lt.starts_with("") + || lt.starts_with(">() + .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 `` 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::>() - .join("\n"), + // Gather reminders + prompt across every text block of the message. + let mut reminders: Vec = Vec::new(); + let mut prompts: Vec = 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 + // 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": + "be careful\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("x"); + 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 noise 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 dropped"); + assert_eq!(rem, vec!["dropped"]); + assert_eq!(prompt, "keep"); + + // strip_injected is the prompt-only projection. + assert_eq!(strip_injected("a b"), "a"); + } + #[test] fn ask_rows_scale_with_options() { let two = ask_question_rows(&json!({"questions": [ diff --git a/src/sessions.rs b/src/sessions.rs index cdb814c..70f2971 100644 --- a/src/sessions.rs +++ b/src/sessions.rs @@ -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 { 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::>() .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, 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 { @@ -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")); diff --git a/src/ui.rs b/src/ui.rs index b5d1f74..64ec5b6 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -89,7 +89,7 @@ struct FeedCache { } struct CachedEntry { - fingerprint: (usize, bool, usize, bool), + fingerprint: (usize, bool, usize, bool, bool), lines: Vec>, /// 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> { +/// follows every entry in the feed. `focused` only affects user-prompt blocks. +fn entry_lines(e: &Entry, width: u16, focused: bool) -> Vec> { let mut lines: Vec> = 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 = 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) = 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 = 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>, text: &str, bg: Option, 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() {