diff --git a/CLAUDE.md b/CLAUDE.md index 0b7395e..db61b9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,14 +12,26 @@ 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; - `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 + the trailing user message into a Kind::User feed entry verbatim + (incl. slash-command machinery — the goal is to show everything + the model received, never filter it). On a turn-starting request + (tools present) it also emits the system-prompt *size* as a + Kind::System line (the prompt itself is too long to show) and the + available tool set as Kind::ToolDefs — each once, re-emitted only + on change (system/tools/history are re-sent every request but are + not new data). A *side* request (no tools — topic/title haiku + calls) is still shown, tagged with a `── side request ──` Meta + divider. `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 label-only projection (drops reminders + *and* slash-command machinery) used for turn-tree labels. + Dedup drops only true resends (the just-recorded prompt is still + the tail entry), so verbatim repeats in later turns survive. + 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) @@ -37,8 +49,10 @@ src/ui.rs ratatui rendering @ ~30fps; session list + scrollable feed 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 + ``s and the system-prompt-size `Kind::System` + line show dim under the "system" filter; the `Kind::ToolDefs` + tool-list line shares the "tools" filter with tool calls. 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. diff --git a/src/app.rs b/src/app.rs index f257ebe..7570396 100644 --- a/src/app.rs +++ b/src/app.rs @@ -415,10 +415,12 @@ pub fn filter_index(kind: &Kind) -> usize { Kind::User => 0, Kind::Thinking => 1, Kind::Text => 2, - Kind::Tool { .. } => 3, + // Tool calls and the available-tool list share the "tools" toggle. + Kind::Tool { .. } | Kind::ToolDefs => 3, Kind::Meta => 4, Kind::Error => 5, - Kind::Reminder => 6, + // Reminders and the system-prompt-size line share the "system" toggle. + Kind::Reminder | Kind::System => 6, } } @@ -433,6 +435,13 @@ pub struct Session { /// tool_use id → entry index, so results arriving in the *next* request /// body can be attached to the tool entry they belong to. pub tool_ids: HashMap, + /// Char length of the system prompt last surfaced as a `Kind::System` + /// entry. The system prompt is re-sent verbatim on every request, so it is + /// emitted once (and again only if the count changes) — never duplicated. + pub last_system_len: Option, + /// Signature (joined tool names) of the tool set last surfaced as a + /// `Kind::ToolDefs` entry; re-emitted only when the available tools change. + pub last_tools_sig: Option, } impl Session { @@ -446,6 +455,8 @@ impl Session { output_tokens: 0, last_activity: Instant::now(), tool_ids: HashMap::new(), + last_system_len: None, + last_tools_sig: None, } } } @@ -464,6 +475,14 @@ pub enum Kind { /// dimmed, like a thinking block) rather than discarded, so the context the /// model actually received is visible. Filtered under the "system" toggle. Reminder, + /// The system prompt's character count (the prompt itself is too long to + /// show verbatim). Emitted once per session, re-emitted only on change. + /// Filtered under the "system" toggle. + System, + /// The set of tool definitions the model is handed (names + count, not the + /// full schemas). Emitted once per session, re-emitted only when the tool + /// set changes. Filtered under the "tools" toggle. + ToolDefs, } pub struct Entry { @@ -566,8 +585,19 @@ pub(crate) fn extract_user_text(t: &str) -> (Vec, String) { } } } - // Drop slash-command wrapper lines (these always occupy their own lines). - let prompt = s + // The remainder is kept verbatim: slash-command wrappers + // ( etc.) are part of what the model actually received, so + // the feed shows them. Only the turn-tree *label* path (`strip_injected`) + // drops them for a clean one-liner. + (reminders, s.trim().to_string()) +} + +/// The user's typed prompt only — used for turn-tree labels, where injected +/// reminders *and* slash-command machinery are noise. (The feed itself shows +/// everything the model received; this is the label-only projection.) +pub(crate) fn strip_injected(t: &str) -> String { + extract_user_text(t) + .1 .lines() .filter(|l| { let lt = l.trim_start(); @@ -579,32 +609,29 @@ pub(crate) fn extract_user_text(t: &str) -> (Vec, String) { .collect::>() .join("\n") .trim() - .to_string(); - (reminders, prompt) + .to_string() } -/// 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). +/// Record everything new the model received this request as feed entries: +/// the trailing user prompt verbatim (incl. slash-command machinery), any +/// injected `` blocks (dimmed, before it), and — for a +/// turn-starting request — the system-prompt size and the available tool set +/// (each surfaced once, re-emitted only on change; never re-dumped though the +/// full system/tools/history are re-sent every request). 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). +/// prompt lands here. Side requests (topic detection, title generation, …) carry +/// no `tools`; their prompt is still shown, tagged with a `── side request ──` +/// divider. Retries/resends are deduped against the last recorded prompt (which +/// gates the reminders/divider too, so a resend doesn't double them up). pub fn record_user_prompt(app: &SharedApp, key: &str, body: &Value) { - if body + // A turn-starting (main) request carries tools; a side request does not. + let has_tools = body .get("tools") .and_then(Value::as_array) - .is_none_or(Vec::is_empty) - { - return; - } + .is_some_and(|t| !t.is_empty()); let Some(last) = body .get("messages") .and_then(Value::as_array) @@ -651,14 +678,50 @@ pub fn record_user_prompt(app: &SharedApp, key: &str, body: &Value) { let Some(s) = a.sessions.iter_mut().find(|s| s.key == key) else { return; }; + // Dedup only *true resends*: a retry re-fires the same request before any + // response lands, so the just-recorded prompt is still the tail entry + // (its reminders ride directly in front of it). A later turn that merely + // repeats the same text ("yes", "continue", "go on") sits behind the + // previous turn's assistant/tool entries, so it survives. The old check + // scanned back to the most recent User entry and dropped every verbatim + // repeat regardless of intervening activity. if s.entries .iter() .rev() - .find(|e| e.kind == Kind::User) - .is_some_and(|e| e.content == text) + .find(|e| e.kind != Kind::Reminder) + .is_some_and(|e| e.kind == Kind::User && e.content == text) { return; } + if has_tools { + // Surface the system-prompt size and the tool set once, then only when + // they change — they ride along on every request but are not new data. + let sys = system_char_len(body); + if sys > 0 && s.last_system_len != Some(sys) { + s.last_system_len = Some(sys); + s.entries.push(Entry { + kind: Kind::System, + content: format!("system prompt: {} chars", fmt_count(sys)), + done: true, + result: None, + }); + } + if let Some((sig, line)) = tools_summary(body) + && s.last_tools_sig.as_deref() != Some(sig.as_str()) + { + s.last_tools_sig = Some(sig); + s.entries.push(Entry { + kind: Kind::ToolDefs, + content: line, + done: true, + result: None, + }); + } + } else { + // A background side request (no tools): tag it so its prompt/response + // are not mistaken for part of the main conversation. + s.entries.push(Entry::meta("── side request ──".to_string())); + } for r in reminders { s.entries.push(Entry { kind: Kind::Reminder, @@ -894,6 +957,51 @@ pub fn fmt_tokens(n: u64) -> String { } } +/// Group a count with thousands separators ("12431" → "12,431"). +pub fn fmt_count(n: usize) -> String { + let digits = n.to_string(); + let bytes = digits.as_bytes(); + let mut out = String::with_capacity(digits.len() + digits.len() / 3); + for (i, b) in bytes.iter().enumerate() { + if i > 0 && (bytes.len() - i).is_multiple_of(3) { + out.push(','); + } + out.push(*b as char); + } + out +} + +/// Total character length of a request's `system` prompt (string form or an +/// array of text blocks). Zero when absent — the model received no system text. +fn system_char_len(body: &Value) -> usize { + match body.get("system") { + Some(Value::String(s)) => s.chars().count(), + Some(Value::Array(blocks)) => blocks + .iter() + .filter_map(|b| b.get("text").and_then(Value::as_str)) + .map(|t| t.chars().count()) + .sum(), + _ => 0, + } +} + +/// `(signature, display line)` for a request's tool set, or `None` if it +/// declares no tools. The signature (joined names) drives change detection so +/// the list is surfaced once and re-emitted only when the available tools shift. +fn tools_summary(body: &Value) -> Option<(String, String)> { + let names: Vec<&str> = body + .get("tools") + .and_then(Value::as_array)? + .iter() + .filter_map(|t| t.get("name").and_then(Value::as_str)) + .collect(); + if names.is_empty() { + return None; + } + let line = format!("tools ({}): {}", names.len(), names.join(", ")); + Some((names.join(","), line)) +} + #[cfg(test)] mod tests { use super::*; @@ -992,19 +1100,135 @@ mod tests { ]} ]}), ); - // Side request without tools (topic detection etc.) → no entry. + // Side request without tools (topic detection etc.) → still surfaced, + // tagged with a `── side request ──` divider before its prompt. record_user_prompt( &app, "abc", &json!({"messages": [{"role": "user", "content": "fresh prompt"}]}), ); let a = app.lock().unwrap(); + let users: Vec<_> = a.sessions[0] + .entries + .iter() + .filter(|e| e.kind == Kind::User) + .collect(); + assert_eq!(users.len(), 2, "the side-request prompt is shown too"); + assert_eq!(users[1].content, "fresh prompt"); + assert!( + a.sessions[0] + .entries + .iter() + .any(|e| e.kind == Kind::Meta && e.content.contains("side request")), + "the side request is tagged" + ); + } + + #[test] + fn repeated_prompt_across_turns_is_kept() { + // A verbatim repeat ("continue") in a *later* turn must show — only an + // immediate resend (same request, nothing streamed since) is deduped. + let app: SharedApp = Arc::new(Mutex::new(App::new())); + drop(Tap::new(app.clone(), "abc".into(), "claude-x".into())); + let body = json!({"tools": [{"name": "Bash"}], "messages": [ + {"role": "user", "content": "continue"} + ]}); + record_user_prompt(&app, "abc", &body); + record_user_prompt(&app, "abc", &body); // immediate resend → deduped + // Simulate the turn producing a response between the two prompts. + { + let mut a = app.lock().unwrap(); + a.sessions[0].entries.push(Entry { + kind: Kind::Text, + content: "ok, continuing".into(), + done: true, + result: None, + }); + } + record_user_prompt(&app, "abc", &body); // new turn, same text → kept + let a = app.lock().unwrap(); assert_eq!( a.sessions[0].entries.iter().filter(|e| e.kind == Kind::User).count(), - 1 + 2 ); } + #[test] + fn system_size_and_tools_surfaced_once_then_on_change() { + let app: SharedApp = Arc::new(Mutex::new(App::new())); + drop(Tap::new(app.clone(), "abc".into(), "claude-x".into())); + let turn = |sys: &str, tools: Value, prompt: &str| { + json!({"system": sys, "tools": tools, "messages": [ + {"role": "user", "content": prompt} + ]}) + }; + let tools = json!([{"name": "Bash"}, {"name": "Read"}]); + + // First turn: system size + tool list + prompt. + record_user_prompt(&app, "abc", &turn("0123456789", tools.clone(), "one")); + // Second turn, same system + tools: only the new prompt. + record_user_prompt(&app, "abc", &turn("0123456789", tools.clone(), "two")); + // Third turn, tools changed: re-emit the tool list (system unchanged). + record_user_prompt( + &app, + "abc", + &turn("0123456789", json!([{"name": "Bash"}]), "three"), + ); + + let a = app.lock().unwrap(); + let kinds: Vec<&Kind> = a.sessions[0].entries.iter().map(|e| &e.kind).collect(); + assert_eq!( + a.sessions[0].entries.iter().filter(|e| e.kind == Kind::System).count(), + 1, + "system size emitted once (it never changed)" + ); + assert_eq!( + a.sessions[0].entries.iter().filter(|e| e.kind == Kind::ToolDefs).count(), + 2, + "tool list re-emitted when the set changed" + ); + let sys = a.sessions[0] + .entries + .iter() + .find(|e| e.kind == Kind::System) + .unwrap(); + assert_eq!(sys.content, "system prompt: 10 chars"); + let tdef = a.sessions[0] + .entries + .iter() + .find(|e| e.kind == Kind::ToolDefs) + .unwrap(); + assert_eq!(tdef.content, "tools (2): Bash, Read"); + // Order on the first turn: system, tools, then the prompt. + assert!(matches!(kinds[0], Kind::System)); + assert!(matches!(kinds[1], Kind::ToolDefs)); + assert!(matches!(kinds[2], Kind::User)); + } + + #[test] + fn slash_command_machinery_shown_in_feed_but_stripped_in_label() { + // The model receives the slash-command wrappers, so the feed keeps them + // verbatim; only the turn-tree label projection drops them. + let raw = "/commit\n-a\nthe rest"; + 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": raw} + ]}), + ); + let a = app.lock().unwrap(); + let user = a.sessions[0] + .entries + .iter() + .find(|e| e.kind == Kind::User) + .unwrap(); + assert!(user.content.contains(""), "feed keeps machinery"); + assert_eq!(strip_injected(raw), "the rest", "label drops machinery"); + } + #[test] fn inline_system_reminder_is_stripped_not_dropped() { // The first message after a resume often arrives with a diff --git a/src/sessions.rs b/src/sessions.rs index 70f2971..d5793b8 100644 --- a/src/sessions.rs +++ b/src/sessions.rs @@ -405,6 +405,8 @@ impl EntryParser { output_tokens: 0, last_activity: std::time::Instant::now(), tool_ids: HashMap::new(), + last_system_len: None, + last_tools_sig: None, }, leaf, turn_entries, diff --git a/src/ui.rs b/src/ui.rs index 64ec5b6..c6d21b3 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -195,6 +195,11 @@ 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()), + // Request-context metadata the model received (the system prompt is too + // long to show verbatim, so only its size; tools as a name list). Dim, + // wrapping is handled by the feed Paragraph. + Kind::System => lines.push(Line::from(format!("⚙ {}", e.content)).dark_gray()), + Kind::ToolDefs => lines.push(Line::from(format!("🔧 {}", e.content)).dark_gray()), Kind::User => { let style = user_block_style(focused); let w = (width as usize).max(1); @@ -229,11 +234,15 @@ fn entry_lines(e: &Entry, width: u16, focused: bool) -> Vec> { let head = if e.done { "✻ thought" } else { "✻ thinking…" }; lines.push(Line::from(head).magenta().italic()); for l in e.content.lines() { - lines.push(Line::from(l.to_string()).dark_gray().italic()); + lines.push(Line::from(sanitize(l)).dark_gray().italic()); } } Kind::Text => { - lines.extend(crate::markdown::render(&e.content, width).into_iter().map(own_line)); + // Sanitize first (preserving newlines): a literal tab in a code + // block would otherwise survive as a `\t` cell symbol and shift the + // terminal cursor to the next tab stop, scattering the line. + let clean = sanitize_md(&e.content); + lines.extend(crate::markdown::render(&clean, width).into_iter().map(own_line)); } Kind::Tool { name } => { // Once the input JSON is complete, every tool gets a @@ -253,14 +262,14 @@ fn entry_lines(e: &Entry, width: u16, focused: bool) -> Vec> { }; lines.push(Line::from(head).yellow().bold()); for l in e.content.lines() { - lines.push(Line::from(format!(" {l}")).cyan()); + lines.push(Line::from(format!(" {}", sanitize(l))).cyan()); } } } } Kind::Error => { for l in e.content.lines() { - lines.push(Line::from(l.to_string()).red().bold()); + lines.push(Line::from(sanitize(l)).red().bold()); } } } @@ -1318,7 +1327,7 @@ fn draw( } else if a.on_turns() { "j/k turns · v visual · b branch · ←/space close · wheel scrolls feed" } else if show_embed { - "ctrl-↓ claude · n new · q quit · j/k move · space tree · f filter · F2 hide" + "ctrl-↓ claude · a add session · q quit · j/k move · space tree · f filter · F2 hide" } else { "q quit · n new · j/k move · space/→ tree · f filter · c continue · ctrl-↓ attach" }; @@ -1800,8 +1809,11 @@ fn push_numbered<'a>(out: &mut Vec>, text: &str, bg: Option, wid } } -/// ratatui renders control chars as zero-width, smearing the layout -/// (tab-indented code was the main offender); expand tabs, drop the rest. +/// A literal tab survives ratatui as a `\t` cell symbol that the terminal then +/// renders by jumping to the next tab stop, desyncing the per-cell cursor and +/// scattering everything painted after it (other control chars render +/// zero-width and smear too). For a single visual row: expand tabs, drop the +/// rest. Tab-indented code is the main offender. fn sanitize(l: &str) -> String { let mut s = String::with_capacity(l.len()); for c in l.chars() { @@ -1814,11 +1826,58 @@ fn sanitize(l: &str) -> String { s } +/// Like [`sanitize`] but keeps `\n`, for multi-line content rendered as a block +/// (markdown, where newlines carry structure). Same tab/control handling: the +/// content is split into rows downstream, so a stray tab must already be gone. +fn sanitize_md(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '\n' => out.push('\n'), + '\t' => out.push_str(" "), + c if c.is_control() => {} + c => out.push(c), + } + } + out +} + #[cfg(test)] mod tests { - use super::{base64, color_on, truncate_str, wrap_words}; + use super::{base64, color_on, entry_lines, sanitize_md, truncate_str, wrap_words}; + use crate::app::{Entry, Kind}; use ratatui::style::Color; + #[test] + fn sanitize_md_expands_tabs_keeps_newlines() { + assert_eq!(sanitize_md("a\tb\nc"), "a b\nc"); + // other control chars (here a CR) are dropped, newlines survive + assert_eq!(sanitize_md("x\r\ny"), "x\ny"); + } + + /// A tab-indented code block in an assistant message must not leave literal + /// tabs (or any other control char) in the rendered cells — a `\t` symbol + /// reaches the terminal verbatim and shifts the cursor, scattering the row. + #[test] + fn feed_text_strips_control_chars() { + let e = Entry { + kind: Kind::Text, + content: "```\n\tif self.queued:\n\t\treturn\n```".into(), + done: true, + result: None, + }; + let lines = entry_lines(&e, 60, false); + for line in &lines { + for span in &line.spans { + assert!( + !span.content.chars().any(|c| c.is_control()), + "rendered span still contains a control char: {:?}", + span.content + ); + } + } + } + #[test] fn color_on_contrasts_with_background() { // Bright orange / white → black text; dark grey / blue → white text.