From a5a60925791ec4b55b1605036249d3db5e0a75df Mon Sep 17 00:00:00 2001 From: Jonas H Date: Thu, 27 Aug 2026 12:44:06 +0200 Subject: [PATCH] Detect the unmarked @ path listing in the pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code marks a fuzzy `@` hit with `+ `, but a path that leaves the project (`@../de`) switches it to a plain directory listing whose rows carry no marker — the selected row differs only in colour, which the pane's text-only read of the screen drops. So the compact frame ended one row under the input box and showed the first hit alone. Recognise a bare path row by shape (a whitespace-free token holding a `/`) and, since a user statusLine can look exactly like that, confirm the list over two rows: the row under the rule must be a menu row or blank (it is shown either way, so a one-hit menu needs no detection), and at least one further row must match too. --- CLAUDE.md | 23 ++++++--- src/term.rs | 142 +++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 146 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3bcbd7c..2bfe6e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -519,13 +519,22 @@ agentId: `), and the real completion is injected into the parent's next and shows one context row above the top rule (the spinner / "✻ Worked…" row) down to the statusLine just under the bottom rule, cropping the persistent 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 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. Above the top rule the + replaced that chrome with a list (`text_is_menu_item`), so the frame extends + to the last non-blank row instead. **A menu row is not reliably marked**, so + that list is recognised by *shape* and confirmed by a second row: a `+ ` + fuzzy hit, a `/command` / `@agent` row and the highlighted `❯` all carry a + marker, but a path that leaves the project (`@../de`) switches CC to a plain + **directory listing** whose rows are bare padded paths — the selected one + differs only in colour, which `row_text` drops. So a whitespace-free token + holding a `/` counts too, and because the user's own statusLine can look + exactly like that, `compact_frame_ex` votes over two rows: the row directly + under the rule must be a menu row *or blank* (it is shown either way — it is + the statusLine's slot — so a one-row menu needs no detection), and at least + one *further* row must match as well (which is what keeps a two-line + statusLine from dragging the whole chrome into the pane). Scanning the whole + tail also matters because only the highlighted row of a fuzzy list is marked, + so checking one row collapsed the pane whenever that row wasn't the selected + item. Above the top rule the frame also swallows an **active task panel** (`text_is_task_row` / `task_block_top`): Claude Code parks the `N tasks (…)` header + `✔ ◼ ◻` rows (and its `… +N pending` overflow line) directly above the input box, so diff --git a/src/term.rs b/src/term.rs index 68c39e1..2ce958f 100644 --- a/src/term.rs +++ b/src/term.rs @@ -530,11 +530,33 @@ fn text_is_rule(t: &str) -> bool { /// A `@`-file / `/`-command menu row. When such a menu is open it replaces the /// statusLine + hint/token/effort chrome with a list directly under the input -/// box's bottom rule. These markers are the Claude Code 2.1.x list glyphs; -/// retune here if a CC update changes them. +/// box's bottom rule. +/// +/// Claude Code 2.1.x draws four shapes here, and **a glyph is not one of the +/// things they share** (captured from a real child): +/// - fuzzy match inside the project (`@bug`, `@src/`): `+ src/debug/`; +/// - a **directory listing** — the typed token holds a path that leaves the +/// project (`@../de`): plain padded paths, `../destinations/`, no marker at +/// all. The selected row differs only in colour, which `row_text` drops, so +/// text alone can never find a glyph here; +/// - a session/agent mention (`@CLA`): `@claude-cloak-a4 message session · …`; +/// - a `/` command, whose description wraps onto plain continuation rows. +/// +/// So the last resort is the *shape* of a path: one whitespace-free token +/// holding a `/`. That is deliberately loose — `compact_frame_ex` votes over +/// two rows, and keeps the statusLine out of the vote, rather than trusting any +/// single row (see the menu detection there). fn text_is_menu_item(t: &str) -> bool { - let t = t.trim_start(); - ["+ ", "* ", "❯ ", "› "].iter().any(|m| t.starts_with(m)) || t.starts_with('/') + let t = t.trim(); + if ["+ ", "* ", "❯ ", "› "].iter().any(|m| t.starts_with(m)) { + return true; + } + // `/command …` and `@agent …` rows carry their description inline. + if t.starts_with('/') || t.starts_with('@') { + return true; + } + // Bare listing row: `../destinations/`, `src/game/foo.gd`. + t.contains('/') && !t.contains(char::is_whitespace) } /// Status glyphs Claude Code prints in front of a task/todo row: pending @@ -664,14 +686,23 @@ fn compact_frame_ex(rows: &[String]) -> Option { None => ctx_top, }; // 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])); + // list. Deciding that takes *two* rows, because a menu row is not reliably + // marked (see `text_is_menu_item`) and the statusLine's text is the user's, + // so it can look like anything — a bare `~/projects/foo` included: + // - the row right under the rule (`head`) must be a menu row or blank. It + // is shown either way (it is the statusLine's slot when no menu is up), + // so a one-row menu needs no detection at all; blank counts because the + // chrome never leaves that row empty, while a menu may separate itself + // from the box. + // - at least one *further* row must look like a menu row too. This is what + // keeps a two-line statusLine (line 2 a bare path) from reading as a + // menu and dragging the whole hint/token/effort chrome into the pane. + // Scanning the whole tail (not just `bot_div + 1`) is also required: only + // the highlighted row of a fuzzy list carries a glyph, so checking one row + // collapsed the pane whenever that row was not the selected one. + let head = bot_div + 1; + let head_ok = last > head && (text_is_menu_item(&rows[head]) || rows[head].trim().is_empty()); + let menu_open = head_ok && (head + 1..=last).any(|i| text_is_menu_item(&rows[i])); let view_bottom = if menu_open { last } else { (bot_div + 1).min(last) }; Some(CompactFrame { top: view_top, ess_top: ctx_top, bottom: view_bottom, menu_open }) } @@ -1238,6 +1269,93 @@ mod tests { assert_eq!(compact_frame(&screen), Some((1, 8))); } + #[test] + fn shows_unmarked_path_listing_menu() { + // Real capture, `@../de` typed in ~/projects/destinations: a path that + // leaves the project switches CC to a *listing*, whose rows carry no + // `+`/`❯` marker at all (the selected one differs only in colour). + // Regression: the pane showed the first hit and cropped the rest. + let screen = rows(&[ + "", "", + &format!("{RULE} minimal ──"), // 2: top rule + "❯ @../de", // 3: input + RULE, // 4: bottom rule + " ../destinations/", // 5: selected (colour only) + " ../destinations-player-host/", // 6 + " ../destinations-terrain-unify/", // 7: last non-blank + "", "", + ]); + assert_eq!(compact_frame(&screen), Some((1, 7))); + } + + #[test] + fn shows_agent_mention_menu() { + // `@CLA` lists sessions/agents: `@name` + an inline description. + let screen = rows(&[ + "", "", + &format!("{RULE} minimal ──"), // 2: top rule + "❯ @CLA", // 3: input + RULE, // 4: bottom rule + " @claude-cloak-a4 message session · active 30s ago", // 5 + " @claude-cloak-10 message session · active 2m ago", // 6 + "", "", + ]); + assert_eq!(compact_frame(&screen), Some((1, 6))); + } + + #[test] + fn single_item_menu_needs_no_detection() { + // One match, and it sits in the statusLine's own slot — always shown. + let screen = rows(&[ + "", "", + &format!("{RULE} minimal ──"), // 2: top rule + "❯ @../destinations-t", // 3: input + RULE, // 4: bottom rule + " ../destinations-terrain-unify/", // 5: the only hit + "", "", + ]); + assert_eq!(compact_frame(&screen), Some((1, 5))); + // A blank separator before a lone item still shows the item. + let screen = rows(&[ + "", "", + &format!("{RULE} minimal ──"), + "❯ @../destinations-t", + RULE, + "", // 5: separator + " ../destinations-terrain-unify/", // 6 + "", "", + ]); + assert_eq!(compact_frame(&screen), Some((1, 6))); + } + + #[test] + fn path_shaped_statusline_is_not_a_menu() { + // The statusLine is the user's own text, so it can be a bare path — + // which is exactly the shape an unmarked menu row has. Detection votes + // over a second row for this reason: a path-ish statusLine (even a + // two-line one) must not drag the hint/token chrome into the pane. + let screen = rows(&[ + "", "", + &format!("{RULE} minimal ──"), // 2: top rule + "❯", // 3: input + RULE, // 4: bottom rule + "~/projects/destinations", // 5: statusLine (kept) + "⏵⏵ bypass permissions (shift+tab)", // 6: chrome (cropped) + " 0 tokens", // 7: chrome (cropped) + ]); + assert_eq!(compact_frame(&screen), Some((1, 5))); + let screen = rows(&[ + "", "", + &format!("{RULE} minimal ──"), + "❯", + RULE, + "Opus 5 · xhigh", // 5: statusLine line 1 (kept) + "~/projects/destinations", // 6: statusLine line 2 (cropped) + "? for shortcuts", // 7 + ]); + assert_eq!(compact_frame(&screen), Some((1, 5))); + } + #[test] fn overflowing_menu_keeps_input_box_visible() { // Regression: when a long `@`/`/` match list doesn't fit in the pane's