From b56ea650ab421871f0703b4575c1677d6e72678c Mon Sep 17 00:00:00 2001 From: Jonas H Date: Thu, 9 Jul 2026 08:49:53 +0200 Subject: [PATCH] claude expand fixes and line numbers --- .claude/skills/tui-verify/SKILL.md | 98 ++++++++++++++++ CLAUDE.md | 26 ++++- src/app.rs | 126 ++++++++++++++++---- src/sessions.rs | 40 +++++-- src/term.rs | 137 +++++++++++++++++----- src/ui.rs | 179 ++++++++++++++++++++++++++--- 6 files changed, 531 insertions(+), 75 deletions(-) create mode 100644 .claude/skills/tui-verify/SKILL.md diff --git a/.claude/skills/tui-verify/SKILL.md b/.claude/skills/tui-verify/SKILL.md new file mode 100644 index 0000000..5acc10f --- /dev/null +++ b/.claude/skills/tui-verify/SKILL.md @@ -0,0 +1,98 @@ +--- +name: tui-verify +description: Drive and visually verify this TUI (claude-cloak, or any terminal app / embedded claude pane) by running it inside a headless tmux session, sending real keystrokes, and capturing the rendered screen as text. Use whenever you need to confirm a UI/pane/rendering change actually works end-to-end — not just that tests pass — e.g. "verify the pane expands", "does @ show the menu", "check the layout at a small size". Claude Code's own TUI cannot run in a non-tty, so tmux is the way to give it a real terminal. +--- + +# Verifying a TUI with tmux + +The app renders to a real terminal and cannot run in a plain pipe. tmux gives it +a genuine tty in the background, lets you type into it, and lets you read back +exactly what's on screen as plain text — so you can *see* a rendering change +instead of guessing from tests. + +## The safety rule (read first) + +**Never use `pkill`, `killall`, `kill -9 `, or any broad +process-matching kill.** The user runs their own real claude-cloak instances +(including the one hosting your session) on the same machine. `pkill -f +claude-cloak` will kill all of them. Only ever tear down tmux sessions **you +created, by their exact name**: + +```bash +tmux kill-session -t # only your own named session +``` + +Pick a distinctive session name (e.g. `ccverify`) so you never touch anything +else. Before starting, `tmux ls` to see what's already there and avoid name +clashes — never kill a session you didn't create. + +## Core loop + +1. **Build** the thing you're testing (`cargo build --release`). +2. **Launch** it in a detached tmux session, sized to the scenario you care + about. Small sizes (e.g. `-x 100 -y 20`) are what expose layout/overflow + bugs — a big terminal hides them. + + ```bash + tmux new-session -d -s ccverify -x 100 -y 20 "./target/release/claude-cloak" + sleep 2 # give it time to draw the first frame + ``` + +3. **Capture** the rendered screen as text: + + ```bash + tmux capture-pane -t ccverify -p + ``` + + Add `-e` to include ANSI escapes (then `| cat -A`) when you need to inspect + colors/attributes — e.g. which menu row is highlighted: + + ```bash + tmux capture-pane -t ccverify -p -e | cat -A + ``` + +4. **Send keystrokes**, then `sleep` briefly (the app redraws on its own ~30fps + tick — give it 1–2s) and capture again: + + ```bash + tmux send-keys -t ccverify "a" # a literal key + tmux send-keys -t ccverify "Down" "Enter" # named keys + tmux send-keys -t ccverify "C-u" # ctrl-u (clear line) + tmux send-keys -t ccverify "@src" # a literal string + ``` + +5. **Tear down your session by name** when done (see safety rule). + +## send-keys gotchas + +- Each space-separated token is interpreted as a **key name** (`Down`, `Enter`, + `Space`, `C-f`, `Escape`). So `send-keys "Down Down Down"` sends three Down + keys — but if the app has meanwhile changed state (e.g. a menu collapsed to a + single match and closed), the same tokens can land as literal text. If you see + literal `Down Down Down` in an input box, that's this — resend as needed. +- To send a literal string that contains spaces or key-like words, send it as + one quoted argument and/or use `-l` (literal): `tmux send-keys -l -t s "Down"`. +- Control/modifier chords: `C-u`, `C-f`, `C-q`, `S-Tab` (shift-tab). This app + avoids Alt on purpose — don't test Alt chords. +- After any keystroke, **sleep before capturing.** Captures taken mid-repaint + show a transient frame (a half-drawn box), which can look like a bug that + isn't there. 1–1.5s is usually enough. + +## Reading the claude-cloak screen + +- The bottom footer line shows the bound proxy port and the active keybinding + hints — a quick sanity check that the app is alive and in the expected mode. +- The embedded pane is the lower bordered box titled `claude · `. Its + compact framing (`src/term.rs`) shows one context row, the input box (bracketed + by `──── … ──` rules), and either the statusLine (idle) or an `@`/`/` menu. +- To spawn an embedded claude pane for pane tests: press `a` (model picker), + navigate with `Down`, `Enter` to spawn. It resumes-then-clears to a fresh + chat. `ctrl-f` toggles fullscreen (shows the child's raw screen verbatim — + useful to see what Ink actually drew vs. what we crop). + +## Why this beats tests alone + +Unit tests over `compact_frame` / `compact_view_range` pin the framing math, but +they can't catch a PTY-sizing feedback loop where Ink never draws the rows we'd +measure — that only shows up when a real `claude` child renders into a real PTY. +Run the tmux loop for any change to pane sizing, cropping, cursor, or layout. diff --git a/CLAUDE.md b/CLAUDE.md index ed540a4..d86d75f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -185,12 +185,34 @@ UI thread redraws on its own tick (no channel; just the mutex). hint/token/effort chrome below it. When an `@`/`/` menu is open it has replaced that chrome with a list (`text_is_menu_item`, CC-2.1.x glyphs — retune there if an update changes them), so the frame extends to the last - non-blank row instead. The framed region drives the pane height too: + non-blank row instead. The menu is detected by scanning the *whole* region + below the bottom rule for a menu row, not just the row directly under it: the + list can start after a blank/header row and only the highlighted item carries + a glyph (unselected file rows are plain names), so checking one row collapsed + the pane whenever that row wasn't the selected item. The framed region drives the pane height too: `compact_rows` (called from `ui::draw`) measures box-height + tail so the pane auto-expands as the prompt gains lines or a menu opens and shrinks back when idle (floor `MIN_COMPACT_INNER`, cap = screen − 6); `PTY_PAD` keeps the PTY taller than the visible window so the child can still draw the rows we - crop. `PaneView::Interactive` (the tap-grown AskUserQuestion / ExitPlanMode + crop. **The Compact PTY is sized to the *whole screen height*, not the + visible pane** (`ui::draw` passes `f.area().height` to `resize` only for + Compact): Compact's height is derived by measuring what Ink has already + drawn, and Ink only ever draws as many rows as the PTY reports, so tying the + PTY to the (small) visible height is a feedback loop — an `@`/`/` menu or a + big paste that suddenly needs many more rows than the current PTY+pad never + gets the room to draw them, so `compact_rows` can't measure the growth and + the pane stays stuck small. A screen-tall PTY lets Ink lay out the full + box+menu in one shot; `compact_view_range` still shows only the cropped + window. When that window is shorter than an open menu it **top-anchors on the + input box** (crop the menu's tail, never the line you're typing) — the idle + no-menu case still bottom-anchors on the statusLine. That per-frame measurement is smoothed by hysteresis + (`EmbedUi::compact_height` / `smooth_compact`, seeded at + `DEFAULT_COMPACT_INNER`): the pane grows instantly but shrinks only after the + smaller height has held for `SHRINK_DELAY` (400ms), and `compact_rows` + returns `None` on a transient mid-repaint (box border caught missing) so the + last height is kept. Without this the height oscillates every frame during a + subagent turn or `@`/`/` menu filtering, and each change resizes the PTY → + Ink repaints → flicker. `PaneView::Interactive` (the tap-grown AskUserQuestion / ExitPlanMode pane, whose selection box renders *above* the input) top-anchors from row 2 instead so the prompt stays visible. `PaneView::Full` (fullscreen) renders the child's screen verbatim from row 0 with the PTY sized exactly to the diff --git a/src/app.rs b/src/app.rs index 7b4f8df..53e6a4d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -668,42 +668,56 @@ pub fn record_user_prompt(app: &SharedApp, key: &str, body: &Value) { .get("tools") .and_then(Value::as_array) .is_some_and(|t| !t.is_empty()); - let Some(last) = body - .get("messages") - .and_then(Value::as_array) - .and_then(|m| m.last()) - else { + let Some(messages) = body.get("messages").and_then(Value::as_array) else { return; }; - if last.get("role").and_then(Value::as_str) != Some("user") { + // The turn's prompt is the *trailing run* of user-role messages, not just + // the last message. Claude Code often appends injected + // ``s (and other machinery) as their own `user` messages + // *after* the real prompt; inspecting only `messages.last()` then sees a + // reminder-only message, yields empty prompt text, and drops the turn + // entirely — the "user messages don't show up reliably" bug (it fires only + // when CC happens to append such a trailing message). A tool-loop + // continuation ends in a single user message of `tool_result` blocks (no + // text), so it still contributes nothing and records no spurious entry. + let trailing_user: Vec<&Value> = messages + .iter() + .rev() + .take_while(|m| m.get("role").and_then(Value::as_str) == Some("user")) + .collect(); + if trailing_user.is_empty() { return; } - // Gather reminders + prompt across every text block of the message. + // Gather reminders + prompt across every text block of every trailing user + // message. `take_while` walked newest→oldest, so reverse back to document + // order for a prompt that legitimately spans multiple messages. 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; + for m in trailing_user.into_iter().rev() { + match m.get("content") { + Some(Value::String(s)) => { + let (r, p) = extract_user_text(s); + reminders.extend(r); + if !p.is_empty() { + prompts.push(p); } - 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); + } + 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(); @@ -1315,6 +1329,68 @@ mod tests { assert_eq!(rem[0].content, "be careful"); } + #[test] + fn prompt_survives_trailing_reminder_message() { + // Claude Code sometimes appends an injected as its + // *own* trailing user message *after* the real prompt. Inspecting only + // messages.last() saw the reminder-only message, got empty text, and + // dropped the whole turn — the "user messages don't show up reliably" + // bug. The prompt (in the second-to-last message) must still land. + let app: SharedApp = Arc::new(Mutex::new(App::new())); + drop(Tap::new(app.clone(), "abc".into(), "claude-x".into(), None)); + record_user_prompt( + &app, + "abc", + &json!({"tools": [{"name": "Bash"}], "messages": [ + {"role": "assistant", "content": "prior reply"}, + {"role": "user", "content": [{"type": "text", "text": "what files are here?"}]}, + {"role": "user", "content": [ + {"type": "text", "text": "todo list updated"} + ]} + ]}), + ); + 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 a trailing reminder message"); + assert_eq!(user[0].content, "what files are here?"); + 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, "todo list updated"); + } + + #[test] + fn tool_result_continuation_records_no_prompt() { + // A tool-loop continuation ends in a user message of tool_result blocks + // (no text). It must not manufacture a spurious User entry even though + // the trailing-run scan now looks past the single last message. + let app: SharedApp = Arc::new(Mutex::new(App::new())); + drop(Tap::new(app.clone(), "abc".into(), "claude-x".into(), None)); + record_user_prompt( + &app, + "abc", + &json!({"tools": [{"name": "Bash"}], "messages": [ + {"role": "user", "content": "run ls"}, + {"role": "assistant", "content": [ + {"type": "tool_use", "id": "t1", "name": "Bash", "input": {}} + ]}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "a.txt"} + ]} + ]}), + ); + let a = app.lock().unwrap(); + let users = a.sessions[0].entries.iter().filter(|e| e.kind == Kind::User).count(); + assert_eq!(users, 0, "tool_result continuation must not record a prompt"); + } + #[test] fn extract_user_text_splits_reminders_from_prompt() { let (rem, prompt) = extract_user_text("x"); diff --git a/src/sessions.rs b/src/sessions.rs index d5793b8..9e92691 100644 --- a/src/sessions.rs +++ b/src/sessions.rs @@ -5,8 +5,9 @@ //! files from a chosen set of turns (branching / cherry-picking). //! //! Claude Code stores one `.jsonl` file per session under -//! `~/.claude/projects//`. The path encoding replaces every `/` -//! with `-` (the leading `/` becomes a leading `-`). +//! `~/.claude/projects//`. The path encoding replaces every +//! character that isn't an ASCII letter or digit with `-` (so `/`, `.`, `_`, +//! spaces … all become `-`) — see `encode_cwd`. use crate::app::{ flatten_result_content, lock_app, strip_injected, Entry, Kind, Session, SharedApp, @@ -533,14 +534,18 @@ fn push_user_text(entries: &mut Vec, text: &str) { } /// Encode the current working directory the same way Claude Code does: -/// each `/` → `-` (the leading `/` becomes a leading `-`). +/// every character that is not an ASCII letter or digit becomes `-` (so `/`, +/// `.`, `_`, spaces, … all collapse to `-`, and the leading `/` becomes a +/// leading `-`). Consecutive specials are *not* merged — `/.config` maps to +/// `--config`, matching Claude Code's `path.replace(/[^a-zA-Z0-9]/g, '-')`. fn project_dir() -> Result { let home = std::env::var("HOME").map_err(|_| "HOME is not set".to_string())?; let cwd = std::env::current_dir().map_err(|e| format!("cannot read cwd: {e}"))?; - let encoded = cwd - .to_str() - .ok_or_else(|| "cwd is not valid UTF-8; cannot map it to a Claude project dir".to_string())? - .replace('/', "-"); + let encoded = encode_cwd( + cwd.to_str().ok_or_else(|| { + "cwd is not valid UTF-8; cannot map it to a Claude project dir".to_string() + })?, + ); let dir = PathBuf::from(home).join(".claude").join("projects").join(encoded); if !dir.is_dir() { return Err("no past sessions recorded for this directory".to_string()); @@ -548,6 +553,15 @@ fn project_dir() -> Result { Ok(dir) } +/// The path → project-folder mapping, split out so it can be unit-tested +/// without touching `HOME`/cwd. Mirrors Claude Code's +/// `dir.replace(/[^a-zA-Z0-9]/g, '-')`. +fn encode_cwd(path: &str) -> String { + path.chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect() +} + /// Read a human-readable label from the JSONL file. Prefers `ai-title`; /// falls back to the first non-empty `last-prompt` text; then uuid prefix. fn read_label(path: &std::path::Path, uuid: &str) -> String { @@ -756,6 +770,18 @@ mod tests { } } + #[test] + fn encode_cwd_matches_claude_code() { + // Plain path: only slashes fold. + assert_eq!(encode_cwd("/home/jonas/projects/claude-cloak"), "-home-jonas-projects-claude-cloak"); + // Dot folds to `-`; a leading `/.` yields a double dash (no merging). + assert_eq!(encode_cwd("/home/jonas/dotfiles/nvim/.config/nvim"), "-home-jonas-dotfiles-nvim--config-nvim"); + assert_eq!(encode_cwd("/home/jonas/sources/llama.cpp"), "-home-jonas-sources-llama-cpp"); + // Underscore folds too; case and digits are preserved. + assert_eq!(encode_cwd("/home/jonas/Downloads/ldd_wine"), "-home-jonas-Downloads-ldd-wine"); + assert_eq!(encode_cwd("/a/b1_c2.d3"), "-a-b1-c2-d3"); + } + #[test] fn label_prefers_ai_title() { let p = write_jsonl(&[ diff --git a/src/term.rs b/src/term.rs index 1905e2b..8a0e223 100644 --- a/src/term.rs +++ b/src/term.rs @@ -34,8 +34,9 @@ const PTY_PAD: u16 = 4; /// input box (top rule + one input line + bottom rule) and the statusLine. const MIN_COMPACT_INNER: u16 = 5; /// Fallback inner height when the input box hasn't been located yet (e.g. the -/// startup banner before the prompt is drawn). -const DEFAULT_COMPACT_INNER: u16 = 7; +/// startup banner before the prompt is drawn). The UI seeds its hysteresis +/// state with this until `compact_rows` first locates the box. +pub const DEFAULT_COMPACT_INNER: u16 = 7; /// How the compact/fullscreen pane frames the child's screen. The compact pane /// is prompt-only (the feed above shows the transcript): `Compact` dynamically @@ -280,21 +281,24 @@ impl EmbeddedTerm { /// region (one context row above the box, the input box itself however /// many lines it has grown to, and the statusLine — or a full `@`/`/` /// menu when one is open). The UI uses this to size the pane so the - /// prompt auto-expands as you type and never scrolls out of view. Falls - /// back to a small default before the box has been drawn. Independent of - /// the terminal's row count, so resizing the pane to this value can't feed - /// back into the measurement. - pub fn compact_rows(&self) -> u16 { + /// prompt auto-expands as you type and never scrolls out of view. + /// Independent of the terminal's row count, so resizing the pane to this + /// value can't feed back into the measurement. + /// + /// Returns `None` when no input box can be located this frame — the + /// startup banner, but also a *transient* mid-repaint (a subagent turn or + /// a filtering `@`/`/` menu redraws heavily, so a single frame can catch + /// the box mid-rewrite with a border missing). The caller keeps its last + /// known height on `None` rather than snapping to a default, which is what + /// stops the pane from flickering during busy output. + pub fn compact_rows(&self) -> Option { let term = self.term.lock().unwrap(); let screen = term.screen(); let first = screen.phys_row(0); let lines = screen.lines_in_phys_range(first..first + screen.physical_rows); let rows: Vec = lines.iter().map(row_text).collect(); - let n = match compact_frame(&rows) { - Some((top, bottom)) => (bottom - top + 1) as u16, - None => DEFAULT_COMPACT_INNER, - }; - n.max(MIN_COMPACT_INNER) + let (top, bottom) = compact_frame(&rows)?; + Some(((bottom - top + 1) as u16).max(MIN_COMPACT_INNER)) } /// Paint a window of the child's screen into `area`. Returns the cursor @@ -331,16 +335,7 @@ impl EmbeddedTerm { let start = (end + 1).saturating_sub(h).max(2).min(end); (start, end) } - PaneView::Compact => { - let (top, bottom) = compact_frame(&rows) - // No box found yet: bottom-anchor the raw content. - .unwrap_or((last.saturating_sub(h.saturating_sub(1)), last)); - // Bottom-anchor `bottom` (the statusLine / last menu row); - // if the pane can't fit the whole region, drop context rows - // from the top rather than the prompt. - let start = (bottom + 1).saturating_sub(h).max(top).min(bottom); - (start, bottom) - } + PaneView::Compact => compact_view_range(&rows, last, h), }; for (y, line) in lines[start..=end].iter().enumerate() { if y as u16 >= area.height { @@ -430,6 +425,13 @@ fn text_is_menu_item(t: &str) -> bool { /// many lines tall (a long or pasted prompt), which is exactly the auto-expand /// we want. fn compact_frame(rows: &[String]) -> Option<(usize, usize)> { + compact_frame_ex(rows).map(|(top, bottom, _)| (top, bottom)) +} + +/// Same as `compact_frame`, but also reports whether the frame ends on an open +/// `@`/`/` menu (as opposed to the idle statusLine) — `render` needs this to +/// pick which end of the region to sacrifice when it doesn't fit the pane. +fn compact_frame_ex(rows: &[String]) -> Option<(usize, usize, bool)> { let last = rows.iter().rposition(|t| !t.trim().is_empty())?; let rules: Vec = (0..=last).filter(|&i| text_is_rule(&rows[i])).collect(); if rules.len() < 2 { @@ -438,12 +440,40 @@ fn compact_frame(rows: &[String]) -> Option<(usize, usize)> { let bot_div = rules[rules.len() - 1]; let top_div = rules[rules.len() - 2]; let view_top = top_div.saturating_sub(1); - let view_bottom = if last > bot_div && text_is_menu_item(&rows[bot_div + 1]) { - last - } else { - (bot_div + 1).min(last) - }; - Some((view_top, view_bottom)) + // An open `@`/`/` menu replaces the chrome below the bottom rule with a + // list. Scan the *whole* region under the rule for a menu row, not just the + // one immediately below it: the list can start after a blank separator or a + // header row, and only the highlighted item carries a recognisable glyph + // (unselected file rows are plain indented names), so checking a single row + // missed the menu whenever that row happened not to be the selected one. + // The persistent chrome rows (statusLine / hint / tokens / effort) never + // match `text_is_menu_item`, so scanning stays free of false positives. + let menu_open = last > bot_div && (bot_div + 1..=last).any(|i| text_is_menu_item(&rows[i])); + let view_bottom = if menu_open { last } else { (bot_div + 1).min(last) }; + Some((view_top, view_bottom, menu_open)) +} + +/// Pick the `(start, end)` window `render` shows for `PaneView::Compact`, +/// given the pane's available inner height `h`. Delegates to +/// `compact_frame_ex` for *where* the box/menu is, and decides which end to +/// sacrifice when the framed region is taller than the pane: +/// - menu open: top-anchor. The input box (what's being typed) sits at the +/// top of the region and the match list runs to the bottom, so overflow +/// must crop the *menu's tail*, not the input box — bottom-anchoring here +/// would hide the very thing the user is typing behind a wall of filenames. +/// - no menu: bottom-anchor on the statusLine, as before, so a long pasted +/// prompt keeps its tail + cursor visible and only context rows are cropped. +/// - no box located yet (startup banner, or a transient mid-repaint): +/// bottom-anchor the raw content. +fn compact_view_range(rows: &[String], last: usize, h: usize) -> (usize, usize) { + match compact_frame_ex(rows) { + Some((top, bottom, true)) => (top, bottom.min(top + h.saturating_sub(1))), + Some((top, bottom, false)) => { + let start = (bottom + 1).saturating_sub(h).max(top).min(bottom); + (start, bottom) + } + None => (last.saturating_sub(h.saturating_sub(1)), last), + } } impl Drop for EmbeddedTerm { @@ -656,6 +686,57 @@ mod tests { assert_eq!(compact_frame(&screen), Some((1, 7))); } + #[test] + fn shows_menu_when_first_row_isnt_the_selected_item() { + // Real `@` menus render only the highlighted item with a glyph; the + // rows above it are plain indented filenames. A blank separator can + // also sit between the bottom rule and the list. The frame must still + // extend to the whole menu (regression: only rows[bot_div+1] was + // checked, so the pane collapsed unless the first item was selected). + let screen = rows(&[ + "", "", + &format!("{RULE} minimal ──"), // 2: top rule + "❯ @s", // 3: input + RULE, // 4: bottom rule + "", // 5: blank separator + " src/app.rs", // 6: unselected item (no glyph) + "❯ src/ui.rs", // 7: selected item + " src/term.rs", // 8: last non-blank + "", "", + ]); + assert_eq!(compact_frame(&screen), Some((1, 8))); + } + + #[test] + fn overflowing_menu_keeps_input_box_visible() { + // Regression: when a long `@`/`/` match list doesn't fit in the pane's + // (capped) height, the pane must keep the input box on screen and + // truncate the menu's tail — not the reverse. Bottom-anchoring here + // (as the idle statusLine case does) hid the line you're typing behind + // a wall of filenames, which is what this bug report was about. + let screen = rows(&[ + "", "", + &format!("{RULE} minimal ──"), // 2: top rule + "❯ @s", // 3: input — must stay visible + RULE, // 4: bottom rule + "+ src/", // 5 + "+ src/app.rs", // 6 + "+ src/main.rs", // 7 + "+ src/sse.rs", // 8 + "+ src/term.rs", // 9 + "+ src/ui.rs", // 10 + ]); + let last = 10; + // Pane only has room for 5 rows (top-anchored: context row through the + // bottom rule + first item), so the last two menu items get cropped — + // but the input box (rows 1..=4) must still be in view. + let (start, end) = compact_view_range(&screen, last, 5); + assert_eq!((start, end), (1, 5)); + + // A pane tall enough for everything still shows the whole menu. + assert_eq!(compact_view_range(&screen, last, 20), (1, 10)); + } + #[test] fn no_input_box_yields_none() { // Startup banner only — no rules, so the caller falls back. diff --git a/src/ui.rs b/src/ui.rs index 54e6cc4..2fd1ff0 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -46,8 +46,23 @@ struct EmbedUi { /// loop diffs it and emits `SetCursorStyle` only on change, so the outer /// terminal mirrors the child (bar in insert mode, block in vim normal). cursor_shape: Option, + /// Currently-applied compact-pane inner height, smoothed with hysteresis + /// (`compact_height`): grows immediately, shrinks only after the smaller + /// value has held for `SHRINK_DELAY`. Remeasuring Claude Code's live screen + /// every frame otherwise oscillates during subagent turns / `@`/`/` menu + /// filtering, and each change resizes the PTY → Ink repaints → flicker. + compact_inner: u16, + /// A pending shrink target and when it was first seen; adopted once it has + /// been stable for `SHRINK_DELAY`. Reset whenever the pane grows or the + /// measured height matches what's applied. + shrink_pending: Option<(u16, Instant)>, } +/// How long a smaller compact-pane height must persist before the pane +/// actually shrinks. Long enough to ride out the repaint churn of a subagent +/// turn or a filtering `@`/`/` menu, short enough to feel responsive. +const SHRINK_DELAY: Duration = Duration::from_millis(400); + /// Mouse text selection over the whole screen (mimics Claude Code: drag to /// select with a reversed-video highlight, the underlying screen text is /// copied to the system clipboard on release via OSC 52). @@ -66,6 +81,47 @@ impl EmbedUi { && self.claude_focused && self.term.as_ref().is_some_and(|t| !t.exited()) } + + /// Smoothed compact-pane inner height. `measured` is this frame's raw + /// reading (`None` when the input box couldn't be located — a transient + /// mid-repaint, so keep what we have). Grows immediately so the prompt + /// never scrolls out of view, but shrinks only after the smaller height + /// has held for `SHRINK_DELAY`. Without this the height oscillates every + /// frame while a subagent streams or an `@`/`/` menu filters, and each + /// change resizes the PTY, making Claude Code repaint and flicker. + fn compact_height(&mut self, measured: Option) -> u16 { + smooth_compact(&mut self.compact_inner, &mut self.shrink_pending, measured) + } +} + +/// Hysteresis for the compact pane height (see `EmbedUi::compact_height`). +/// Grows `applied` immediately; commits a smaller `measured` only once that +/// target has held for `SHRINK_DELAY`; keeps `applied` unchanged on `None`. +/// Pure over its `&mut` state so it can be unit-tested without an `EmbedUi`. +fn smooth_compact( + applied: &mut u16, + pending: &mut Option<(u16, Instant)>, + measured: Option, +) -> u16 { + if let Some(m) = measured { + if m >= *applied { + *applied = m; + *pending = None; + } else { + // Want to shrink to `m`; only commit once it has been stable. + match *pending { + Some((target, since)) if target == m => { + if since.elapsed() >= SHRINK_DELAY { + *applied = m; + *pending = None; + } + } + // First sight of this smaller target (or the target moved). + _ => *pending = Some((m, Instant::now())), + } + } + } + *applied } /// Per-entry feed render cache. Entries are append-only and an entry's @@ -306,6 +362,8 @@ pub fn run(app: SharedApp, port: u16) -> anyhow::Result<()> { past_embeds: HashSet::new(), force_resume: None, cursor_shape: None, + compact_inner: crate::term::DEFAULT_COMPACT_INNER, + shrink_pending: None, }; let res = event_loop(&mut terminal, app, &mut eui); let _ = execute!( @@ -889,7 +947,11 @@ fn draw( h.clamp(EMBED_MIN.min(cap), cap) } PaneView::Compact => { - let inner = eui.term.as_ref().map_or(EMBED_MIN, EmbeddedTerm::compact_rows); + // Hysteresis smooths the per-frame measurement so the PTY + // isn't resized (→ Ink repaint → flicker) on every wobble + // during subagent turns / `@`/`/` menu filtering. + let measured = eui.term.as_ref().and_then(EmbeddedTerm::compact_rows); + let inner = eui.compact_height(measured); inner.saturating_add(2).clamp(EMBED_MIN.min(cap), cap) } } @@ -1328,7 +1390,24 @@ fn draw( // Fullscreen shows the child's screen verbatim (PTY sized exactly, // no chrome crop); the compact/interactive pane crops Claude Code // chrome, so the PTY gets pad rows to draw what we hide. - et.resize(inner.height, inner.width, pane_view != PaneView::Full); + // + // Compact is the one case where the PTY must NOT track the visible + // pane height: that height is itself derived from measuring what's + // already on the child's screen (`compact_rows`), so tying the PTY + // to it creates a feedback loop — an `@`/`/` menu (or a big paste) + // that suddenly needs many more rows than the *current* PTY+pad + // never gets measured, because Ink only ever draws as many rows as + // the PTY currently reports, so the pane can get stuck small + // forever. Give Compact a PTY roomy enough for the whole screen + // (rendering still only shows the cropped window via + // `compact_view_range`), so Ink always has enough space to draw a + // full box + menu in one shot. + let pty_rows = if pane_view == PaneView::Compact { + f.area().height + } else { + inner.height + }; + et.resize(pty_rows, inner.width, pane_view != PaneView::Full); if let Some(pos) = et.render(inner, f.buffer_mut(), pane_view) { f.set_cursor_position(pos); want_cursor = Some(et.cursor_shape()); @@ -1785,7 +1864,8 @@ fn render_file_tool<'a>(name: &str, input: &Value, out: &mut Vec>, widt "⚙ Write ".yellow().bold(), path.to_string().bold(), ])); - push_numbered(out, content, None, width); + let gutter = content.lines().count().max(1).to_string().len(); + push_numbered(out, content, None, width, 1, gutter); true } "edit" => { @@ -1797,25 +1877,61 @@ fn render_file_tool<'a>(name: &str, input: &Value, out: &mut Vec>, widt if input.get("replace_all").and_then(Value::as_bool) == Some(true) { head.push(" (replace_all)".dark_gray()); } + let start = edit_line_number(path, old, new); + if let Some(n) = start { + head.push(format!(" (line {n})").dark_gray()); + } out.push(Line::from(head)); - push_numbered(out, old, Some(DIFF_DEL), width); - push_numbered(out, new, Some(DIFF_ADD), width); + let start = start.unwrap_or(1); + // Shared gutter width so the old/new blocks line up with each + // other even when one side has more lines than the other. + let last_line = old.lines().count().max(new.lines().count()).max(1); + let gutter = (start + last_line - 1).to_string().len(); + push_numbered(out, old, Some(DIFF_DEL), width, start, gutter); + push_numbered(out, new, Some(DIFF_ADD), width, start, gutter); true } _ => false, } } -/// Pushes `text` line by line with a line-number gutter. With a `bg`, the -/// whole row (gutter included) is white-on-bg and padded to `width` so the -/// background forms a solid block; without one, the gutter is dark gray. -fn push_numbered<'a>(out: &mut Vec>, text: &str, bg: Option, width: u16) { - let gutter = text.lines().count().max(1).to_string().len(); +/// Best-effort lookup of the 1-based line number where an edit lands in the +/// file on disk. The tool's own `old_string`/`new_string` are unaware of line +/// numbers (they're a plain substring replacement), and the `push_numbered` +/// gutter used to always start both diff sides at 1, which didn't match the +/// real file at all. Tries `new_string` first: Claude Code executes tools +/// client-side, not through the proxy, so by render time the edit has +/// virtually always already landed on disk. Falls back to `old_string` +/// (pre-edit state) for the rare case the edit hasn't run yet. `None` +/// (numbering falls back to 1) when the file can't be read or neither string +/// is found, e.g. a later edit already changed the surrounding text. +fn edit_line_number(path: &str, old: &str, new: &str) -> Option { + let content = std::fs::read_to_string(path).ok()?; + let offset = content.find(new).or_else(|| content.find(old))?; + Some(content[..offset].matches('\n').count() + 1) +} + +/// Pushes `text` line by line with a line-number gutter, numbered from +/// `start` (the line it actually occupies in the file, when known — see +/// [`edit_line_number`] — otherwise 1) and right-aligned to a fixed `gutter` +/// width so an old/new diff pair lines up even when one side has more lines +/// than the other. With a `bg`, the whole row (gutter included) is +/// white-on-bg and padded to `width` so the background forms a solid block; +/// without one, the gutter is dark gray. +fn push_numbered<'a>( + out: &mut Vec>, + text: &str, + bg: Option, + width: u16, + start: usize, + gutter: usize, +) { for (i, l) in text.lines().enumerate() { let l = sanitize(l); + let n = start + i; match bg { Some(bg) => { - let mut row = format!("{:>gutter$} │ {l}", i + 1); + let mut row = format!("{n:>gutter$} │ {l}"); let pad = (width as usize).saturating_sub(row.chars().count()); row.extend(std::iter::repeat_n(' ', pad)); out.push(Line::from(Span::styled( @@ -1824,7 +1940,7 @@ fn push_numbered<'a>(out: &mut Vec>, text: &str, bg: Option, wid ))); } None => out.push(Line::from(vec![ - format!("{:>gutter$} │ ", i + 1).dark_gray(), + format!("{n:>gutter$} │ ").dark_gray(), Span::raw(l), ])), } @@ -1866,9 +1982,46 @@ fn sanitize_md(s: &str) -> String { #[cfg(test)] mod tests { - use super::{base64, color_on, entry_lines, sanitize_md, truncate_str, wrap_words}; + use super::{ + base64, color_on, entry_lines, sanitize_md, smooth_compact, truncate_str, wrap_words, + SHRINK_DELAY, + }; use crate::app::{Entry, Kind}; use ratatui::style::Color; + use std::time::Instant; + + /// The compact pane grows on the frame the prompt gets taller, but a + /// smaller reading is held back until it has been stable for SHRINK_DELAY — + /// so the per-frame wobble during subagent turns / menu filtering doesn't + /// resize the PTY (which would make Claude Code repaint and flicker). A + /// transient `None` reading keeps the last height. + #[test] + fn compact_height_grows_fast_shrinks_slow() { + let mut applied = 7u16; + let mut pending = None; + + // Grow: adopted immediately, no pending shrink. + assert_eq!(smooth_compact(&mut applied, &mut pending, Some(12)), 12); + assert!(pending.is_none()); + + // Transient miss: height unchanged. + assert_eq!(smooth_compact(&mut applied, &mut pending, None), 12); + + // A smaller reading is not applied yet — it only arms a pending shrink. + assert_eq!(smooth_compact(&mut applied, &mut pending, Some(6)), 12); + assert!(pending.is_some()); + + // A bounce back up while shrink is pending cancels it. + assert_eq!(smooth_compact(&mut applied, &mut pending, Some(12)), 12); + assert!(pending.is_none()); + + // Re-arm the shrink, then backdate its timestamp past the delay so the + // next matching reading commits it (no real sleep needed). + assert_eq!(smooth_compact(&mut applied, &mut pending, Some(6)), 12); + pending = Some((6, Instant::now() - SHRINK_DELAY - std::time::Duration::from_millis(1))); + assert_eq!(smooth_compact(&mut applied, &mut pending, Some(6)), 6); + assert!(pending.is_none()); + } #[test] fn sanitize_md_expands_tabs_keeps_newlines() {