claude expand fixes and line numbers

This commit is contained in:
Jonas H
2026-07-09 08:49:53 +02:00
parent 27b0f791aa
commit b56ea650ab
6 changed files with 531 additions and 75 deletions

View File

@@ -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 <pattern>`, 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 <your-session-name> # 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 12s) 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. 11.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 · <id>`. 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.

View File

@@ -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 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 — 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 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 `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 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 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 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 pane, whose selection box renders *above* the input) top-anchors from row 2
instead so the prompt stays visible. `PaneView::Full` (fullscreen) renders 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 the child's screen verbatim from row 0 with the PTY sized exactly to the

View File

@@ -668,42 +668,56 @@ pub fn record_user_prompt(app: &SharedApp, key: &str, body: &Value) {
.get("tools") .get("tools")
.and_then(Value::as_array) .and_then(Value::as_array)
.is_some_and(|t| !t.is_empty()); .is_some_and(|t| !t.is_empty());
let Some(last) = body let Some(messages) = body.get("messages").and_then(Value::as_array) else {
.get("messages")
.and_then(Value::as_array)
.and_then(|m| m.last())
else {
return; 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
// `<system-reminder>`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; 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<String> = Vec::new(); let mut reminders: Vec<String> = Vec::new();
let mut prompts: Vec<String> = Vec::new(); let mut prompts: Vec<String> = Vec::new();
match last.get("content") { for m in trailing_user.into_iter().rev() {
Some(Value::String(s)) => { match m.get("content") {
let (r, p) = extract_user_text(s); Some(Value::String(s)) => {
reminders.extend(r); let (r, p) = extract_user_text(s);
if !p.is_empty() { reminders.extend(r);
prompts.push(p); 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); Some(Value::Array(blocks)) => {
reminders.extend(r); for b in blocks {
if !p.is_empty() { if b.get("type").and_then(Value::as_str) != Some("text") {
prompts.push(p); 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 = prompts.join("\n");
let text = text.trim(); let text = text.trim();
@@ -1315,6 +1329,68 @@ mod tests {
assert_eq!(rem[0].content, "be careful"); assert_eq!(rem[0].content, "be careful");
} }
#[test]
fn prompt_survives_trailing_reminder_message() {
// Claude Code sometimes appends an injected <system-reminder> 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": "<system-reminder>todo list updated</system-reminder>"}
]}
]}),
);
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] #[test]
fn extract_user_text_splits_reminders_from_prompt() { fn extract_user_text_splits_reminders_from_prompt() {
let (rem, prompt) = extract_user_text("<system-reminder>x</system-reminder>"); let (rem, prompt) = extract_user_text("<system-reminder>x</system-reminder>");

View File

@@ -5,8 +5,9 @@
//! files from a chosen set of turns (branching / cherry-picking). //! files from a chosen set of turns (branching / cherry-picking).
//! //!
//! Claude Code stores one `.jsonl` file per session under //! Claude Code stores one `.jsonl` file per session under
//! `~/.claude/projects/<encoded-cwd>/`. The path encoding replaces every `/` //! `~/.claude/projects/<encoded-cwd>/`. The path encoding replaces every
//! with `-` (the leading `/` becomes a leading `-`). //! character that isn't an ASCII letter or digit with `-` (so `/`, `.`, `_`,
//! spaces … all become `-`) — see `encode_cwd`.
use crate::app::{ use crate::app::{
flatten_result_content, lock_app, strip_injected, Entry, Kind, Session, SharedApp, flatten_result_content, lock_app, strip_injected, Entry, Kind, Session, SharedApp,
@@ -533,14 +534,18 @@ fn push_user_text(entries: &mut Vec<Entry>, text: &str) {
} }
/// Encode the current working directory the same way Claude Code does: /// 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<PathBuf, String> { fn project_dir() -> Result<PathBuf, String> {
let home = std::env::var("HOME").map_err(|_| "HOME is not set".to_string())?; 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 cwd = std::env::current_dir().map_err(|e| format!("cannot read cwd: {e}"))?;
let encoded = cwd let encoded = encode_cwd(
.to_str() cwd.to_str().ok_or_else(|| {
.ok_or_else(|| "cwd is not valid UTF-8; cannot map it to a Claude project dir".to_string())? "cwd is not valid UTF-8; cannot map it to a Claude project dir".to_string()
.replace('/', "-"); })?,
);
let dir = PathBuf::from(home).join(".claude").join("projects").join(encoded); let dir = PathBuf::from(home).join(".claude").join("projects").join(encoded);
if !dir.is_dir() { if !dir.is_dir() {
return Err("no past sessions recorded for this directory".to_string()); return Err("no past sessions recorded for this directory".to_string());
@@ -548,6 +553,15 @@ fn project_dir() -> Result<PathBuf, String> {
Ok(dir) 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`; /// 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. /// falls back to the first non-empty `last-prompt` text; then uuid prefix.
fn read_label(path: &std::path::Path, uuid: &str) -> String { 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] #[test]
fn label_prefers_ai_title() { fn label_prefers_ai_title() {
let p = write_jsonl(&[ let p = write_jsonl(&[

View File

@@ -34,8 +34,9 @@ const PTY_PAD: u16 = 4;
/// input box (top rule + one input line + bottom rule) and the statusLine. /// input box (top rule + one input line + bottom rule) and the statusLine.
const MIN_COMPACT_INNER: u16 = 5; const MIN_COMPACT_INNER: u16 = 5;
/// Fallback inner height when the input box hasn't been located yet (e.g. the /// Fallback inner height when the input box hasn't been located yet (e.g. the
/// startup banner before the prompt is drawn). /// startup banner before the prompt is drawn). The UI seeds its hysteresis
const DEFAULT_COMPACT_INNER: u16 = 7; /// 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 /// How the compact/fullscreen pane frames the child's screen. The compact pane
/// is prompt-only (the feed above shows the transcript): `Compact` dynamically /// 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 /// region (one context row above the box, the input box itself however
/// many lines it has grown to, and the statusLine — or a full `@`/`/` /// 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 /// 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 /// prompt auto-expands as you type and never scrolls out of view.
/// back to a small default before the box has been drawn. Independent of /// Independent of the terminal's row count, so resizing the pane to this
/// the terminal's row count, so resizing the pane to this value can't feed /// value can't feed back into the measurement.
/// back into the measurement. ///
pub fn compact_rows(&self) -> u16 { /// 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<u16> {
let term = self.term.lock().unwrap(); let term = self.term.lock().unwrap();
let screen = term.screen(); let screen = term.screen();
let first = screen.phys_row(0); let first = screen.phys_row(0);
let lines = screen.lines_in_phys_range(first..first + screen.physical_rows); let lines = screen.lines_in_phys_range(first..first + screen.physical_rows);
let rows: Vec<String> = lines.iter().map(row_text).collect(); let rows: Vec<String> = lines.iter().map(row_text).collect();
let n = match compact_frame(&rows) { let (top, bottom) = compact_frame(&rows)?;
Some((top, bottom)) => (bottom - top + 1) as u16, Some(((bottom - top + 1) as u16).max(MIN_COMPACT_INNER))
None => DEFAULT_COMPACT_INNER,
};
n.max(MIN_COMPACT_INNER)
} }
/// Paint a window of the child's screen into `area`. Returns the cursor /// 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); let start = (end + 1).saturating_sub(h).max(2).min(end);
(start, end) (start, end)
} }
PaneView::Compact => { PaneView::Compact => compact_view_range(&rows, last, h),
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)
}
}; };
for (y, line) in lines[start..=end].iter().enumerate() { for (y, line) in lines[start..=end].iter().enumerate() {
if y as u16 >= area.height { 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 /// many lines tall (a long or pasted prompt), which is exactly the auto-expand
/// we want. /// we want.
fn compact_frame(rows: &[String]) -> Option<(usize, usize)> { 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 last = rows.iter().rposition(|t| !t.trim().is_empty())?;
let rules: Vec<usize> = (0..=last).filter(|&i| text_is_rule(&rows[i])).collect(); let rules: Vec<usize> = (0..=last).filter(|&i| text_is_rule(&rows[i])).collect();
if rules.len() < 2 { if rules.len() < 2 {
@@ -438,12 +440,40 @@ fn compact_frame(rows: &[String]) -> Option<(usize, usize)> {
let bot_div = rules[rules.len() - 1]; let bot_div = rules[rules.len() - 1];
let top_div = rules[rules.len() - 2]; let top_div = rules[rules.len() - 2];
let view_top = top_div.saturating_sub(1); let view_top = top_div.saturating_sub(1);
let view_bottom = if last > bot_div && text_is_menu_item(&rows[bot_div + 1]) { // An open `@`/`/` menu replaces the chrome below the bottom rule with a
last // list. Scan the *whole* region under the rule for a menu row, not just the
} else { // one immediately below it: the list can start after a blank separator or a
(bot_div + 1).min(last) // header row, and only the highlighted item carries a recognisable glyph
}; // (unselected file rows are plain indented names), so checking a single row
Some((view_top, view_bottom)) // 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 { impl Drop for EmbeddedTerm {
@@ -656,6 +686,57 @@ mod tests {
assert_eq!(compact_frame(&screen), Some((1, 7))); 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] #[test]
fn no_input_box_yields_none() { fn no_input_box_yields_none() {
// Startup banner only — no rules, so the caller falls back. // Startup banner only — no rules, so the caller falls back.

179
src/ui.rs
View File

@@ -46,8 +46,23 @@ struct EmbedUi {
/// loop diffs it and emits `SetCursorStyle` only on change, so the outer /// loop diffs it and emits `SetCursorStyle` only on change, so the outer
/// terminal mirrors the child (bar in insert mode, block in vim normal). /// terminal mirrors the child (bar in insert mode, block in vim normal).
cursor_shape: Option<crate::term::CursorShape>, cursor_shape: Option<crate::term::CursorShape>,
/// 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 /// Mouse text selection over the whole screen (mimics Claude Code: drag to
/// select with a reversed-video highlight, the underlying screen text is /// select with a reversed-video highlight, the underlying screen text is
/// copied to the system clipboard on release via OSC 52). /// copied to the system clipboard on release via OSC 52).
@@ -66,6 +81,47 @@ impl EmbedUi {
&& self.claude_focused && self.claude_focused
&& self.term.as_ref().is_some_and(|t| !t.exited()) && 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>) -> 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>,
) -> 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 /// 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(), past_embeds: HashSet::new(),
force_resume: None, force_resume: None,
cursor_shape: None, cursor_shape: None,
compact_inner: crate::term::DEFAULT_COMPACT_INNER,
shrink_pending: None,
}; };
let res = event_loop(&mut terminal, app, &mut eui); let res = event_loop(&mut terminal, app, &mut eui);
let _ = execute!( let _ = execute!(
@@ -889,7 +947,11 @@ fn draw(
h.clamp(EMBED_MIN.min(cap), cap) h.clamp(EMBED_MIN.min(cap), cap)
} }
PaneView::Compact => { 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) 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, // Fullscreen shows the child's screen verbatim (PTY sized exactly,
// no chrome crop); the compact/interactive pane crops Claude Code // no chrome crop); the compact/interactive pane crops Claude Code
// chrome, so the PTY gets pad rows to draw what we hide. // 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) { if let Some(pos) = et.render(inner, f.buffer_mut(), pane_view) {
f.set_cursor_position(pos); f.set_cursor_position(pos);
want_cursor = Some(et.cursor_shape()); want_cursor = Some(et.cursor_shape());
@@ -1785,7 +1864,8 @@ fn render_file_tool<'a>(name: &str, input: &Value, out: &mut Vec<Line<'a>>, widt
"⚙ Write ".yellow().bold(), "⚙ Write ".yellow().bold(),
path.to_string().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 true
} }
"edit" => { "edit" => {
@@ -1797,25 +1877,61 @@ fn render_file_tool<'a>(name: &str, input: &Value, out: &mut Vec<Line<'a>>, widt
if input.get("replace_all").and_then(Value::as_bool) == Some(true) { if input.get("replace_all").and_then(Value::as_bool) == Some(true) {
head.push(" (replace_all)".dark_gray()); 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)); out.push(Line::from(head));
push_numbered(out, old, Some(DIFF_DEL), width); let start = start.unwrap_or(1);
push_numbered(out, new, Some(DIFF_ADD), width); // 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 true
} }
_ => false, _ => false,
} }
} }
/// Pushes `text` line by line with a line-number gutter. With a `bg`, the /// Best-effort lookup of the 1-based line number where an edit lands in the
/// whole row (gutter included) is white-on-bg and padded to `width` so the /// file on disk. The tool's own `old_string`/`new_string` are unaware of line
/// background forms a solid block; without one, the gutter is dark gray. /// numbers (they're a plain substring replacement), and the `push_numbered`
fn push_numbered<'a>(out: &mut Vec<Line<'a>>, text: &str, bg: Option<Color>, width: u16) { /// gutter used to always start both diff sides at 1, which didn't match the
let gutter = text.lines().count().max(1).to_string().len(); /// 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<usize> {
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<Line<'a>>,
text: &str,
bg: Option<Color>,
width: u16,
start: usize,
gutter: usize,
) {
for (i, l) in text.lines().enumerate() { for (i, l) in text.lines().enumerate() {
let l = sanitize(l); let l = sanitize(l);
let n = start + i;
match bg { match bg {
Some(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()); let pad = (width as usize).saturating_sub(row.chars().count());
row.extend(std::iter::repeat_n(' ', pad)); row.extend(std::iter::repeat_n(' ', pad));
out.push(Line::from(Span::styled( out.push(Line::from(Span::styled(
@@ -1824,7 +1940,7 @@ fn push_numbered<'a>(out: &mut Vec<Line<'a>>, text: &str, bg: Option<Color>, wid
))); )));
} }
None => out.push(Line::from(vec![ None => out.push(Line::from(vec![
format!("{:>gutter$}", i + 1).dark_gray(), format!("{n:>gutter$}").dark_gray(),
Span::raw(l), Span::raw(l),
])), ])),
} }
@@ -1866,9 +1982,46 @@ fn sanitize_md(s: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { 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 crate::app::{Entry, Kind};
use ratatui::style::Color; 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] #[test]
fn sanitize_md_expands_tabs_keeps_newlines() { fn sanitize_md_expands_tabs_keeps_newlines() {