diff --git a/CLAUDE.md b/CLAUDE.md index 067300b..1616f83 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -510,40 +510,48 @@ agentId: `), and the real completion is injected into the parent's next the feed shows the context) — **except in fullscreen**, where that wipe is *cancelled*, not deferred (see the pane-scroll invariant), and **except while an error is framed** (see the pane-error invariant). -- **An error Claude Code prints is pane-only, so the pane keeps it.** The - compact pane is prompt-only because the feed above shows the context — an - error is the exception, because the feed *cannot* show it: a `tool_result` - reaches us only with the **next** request, which a turn that died never - sends, and a retry exhaustion or an `● API Error: …` row is Claude Code's - own text, never in the stream at all. So two things happen together: - `compact_frame_ex` walks further up to take the report in (over the task +- **A CLI error is pane-only, so the pane keeps it — a tool error is not.** + The compact pane is prompt-only because the feed above shows the context. + The one exception is an error the *CLI itself* produced (`● API Error: + Connection lost mid-response.`, a retry exhaustion): that is Claude Code's + own text, never in the API stream, so the feed cannot show it and the pane + is the only place it will ever appear. So two things happen together: + `compact_frame_ex` walks further up to take the message in (over the task panel too — the error came before it), and `ui::draw` **cancels** the scheduled ctrl-l wipe when `EmbeddedTerm::shows_error` reports one, for the same reason fullscreen cancels it: Ink redraws only the live frame, so wiping would delete the error 400ms after it appeared and nothing would ever bring it back. Cancelled, not deferred — firing it later loses the same rows. Three rules keep it bounded: - 1. **Text shape, never colour.** Verified on a real 2.1.26x child, the tool - error renders palette 211 and the API error 220 — both theme values, so - colour is not a signal. `text_is_error_row` strips the leading `⎿` gutter - *and* the `●`/`⏺` bullet (`strip_row_marker`; Claude Code pads both with - U+00A0, which `trim_start` handles) and then requires `Error:` / - `API Error` / a `✗✘✖✕` glyph. **The colon is load-bearing**: `● Error - handling lives in src/foo.rs` is ordinary prose, and `⎿ Tip: …` — Claude - Code's own idle hint — proves the gutter alone means nothing. - 2. **`ERROR_SCAN` (12 rows) is the whole bound**, and it is what makes this - self-limiting: an error stays framed while it is "what just happened", - drifts out of the window as the next turn prints output, and the pane - shrinks back on its own. Nothing else caps the growth and no app state is - involved — the wipe is simply re-scheduled by the next `Tap::drop` and - fires once the screen is clean. The window has to be this wide because - Claude Code parks a blank row *and* its `✻ Worked…` row between the last - transcript line and the box, so the error is never the context row. - 3. **The frame starts on the bullet row that owns the gutter.** `● Bash(echo - boom >&2; exit 3)` is what names the failure; `⎿ Error: Exit code 3` - alone does not. And `error_block_top` takes the *highest* error row in the - window, not the lowest, so a turn that failed several tools shows all of - them and a wrapped report is framed from its first line. + 1. **The `⎿` gutter disqualifies a row, and that is the whole test.** A + gutter is a *tool* talking, and a failed tool is not a CLI error: its + `tool_result` reaches the feed with the next request, so the feed already + shows it and the pane has no reason to grow. Framing it too was the + reported bug — tool errors bled through and held the pane open. So + `text_is_error_row` rejects any `⎿` row (`⎿ Error: Exit code 3` + included, deliberately), strips only the `●`/`⏺` bullet, then requires + `Error:` / `API Error` / a `✗✘✖✕` glyph. **The colon is load-bearing**: + `● Error handling lives in src/foo.rs` is ordinary prose. And it is + **text shape, never colour** — verified on a real 2.1.26x child, the tool + error renders palette 211 and the API error 220, both theme values. + 2. **The newest prompt row ends the error's relevance.** Once the user has + typed something else the error belongs to a previous exchange, so + `error_block_top` floors its scan just under the last `❯ …` transcript + row. `ERROR_SCAN` (12 rows) alone is far too coarse for this — Claude + Code's reply to an error is only two rows, so the error stayed inside the + window and held the pane open through the whole *next* turn, which is + what the second report was. Above the top rule a `❯` row can only be an + echoed prompt; the box's own `❯` and a menu's `❯` marker are both below + it, outside the scanned window. + 3. **`ERROR_SCAN` is the backstop bound**, for the case where no prompt + follows: the error drifts out of the window as output arrives and the + pane shrinks back on its own. No app state is involved — the wipe is + simply re-scheduled by the next `Tap::drop` and fires once the screen is + clean. It has to be this wide because Claude Code parks a blank row *and* + its `✻ Worked…` row between the last transcript line and the box, so the + error is never the context row. `error_block_top` takes the *highest* + error row in the window, so a long message is framed from its first line; + nothing is pulled in above it, because a CLI error names itself. - **In fullscreen the scroll is the pane's own scrollback, never a forwarded mouse event.** Claude Code enables no mouse tracking and never leaves the normal screen (verified on the wire: for 2.1.247 tmux reports every mouse @@ -663,8 +671,9 @@ agentId: `), and the real completion is injected into the parent's next (and its `… +N pending` overflow line) directly above the input box, so walking up over that block — tolerating one blank line and single wrapped / activity rows, capped at `MAX_TASK_BLOCK` — makes task status visible with no - extra app state. Above *that* it walks over an error block - (`text_is_error_row` / `error_block_top` — see the pane-error invariant). + extra app state. Above *that* it walks up to an error the **CLI itself** + printed (`text_is_error_row` / `error_block_top` — a `⎿` gutter means a tool + and is skipped; see the pane-error invariant). Priority when the pane can't hold everything: the panel is dropped first (`CompactFrame::ess_top`, the one-context-row frame) so the line you're typing and an open menu never fall off screen; an overflowing @@ -809,12 +818,13 @@ agentId: `), and the real completion is injected into the parent's next `web_search_tool_result` + `citations_delta`), and `ansi` returns a `Bash` call whose output carries real SGR codes, so both paths are exercised by a genuine tool_result rather than a fixture. - The two error scenarios are how the pane's error framing is developed against - real Ink output: `toolerror` calls `Read` on a missing path (auto-approved, so - no permission prompt) for the `⎿ Error:` gutter shape, `basherror` runs a - failing `Bash` for its wrapped-tail variant, and `apierror` answers the *turn* - request with a non-retryable 400 — leaving the side/title calls alone — so - Claude Code prints its own `● API Error: 400 …` row. Its tool ids are minted from a + The three error scenarios are how the pane's error framing is developed + against real Ink output, and two of them are *negative* fixtures: `toolerror` + calls `Read` on a missing path (auto-approved, so no permission prompt) and + `basherror` runs a failing `Bash`, both producing the `⎿ Error:` gutter that + must **not** grow the pane. `apierror` answers the *turn* request with a + non-retryable 400 — leaving the side/title calls alone — so Claude Code + prints its own `● API Error: 400 …` row, which must. Its tool ids are minted from a session-wide counter: Claude Code resends the full history every request, so a **reused tool id makes an old tool_result re-attach to the newest call** — an artifact of the fake, not of the proxy. Note the `agent` scenario answers diff --git a/src/term.rs b/src/term.rs index 97f73de..cacf8c1 100644 --- a/src/term.rs +++ b/src/term.rs @@ -734,47 +734,48 @@ fn task_block_top(rows: &[String], from: usize) -> Option { /// How far above the input box an error still counts as "what just happened". /// The window has to reach well past the single context row — Claude Code /// parks a blank row and its `✻ Worked…` spinner row between the last -/// transcript line and the box, and a failed tool is a `● Bash(…)` row plus a -/// wrapped `⎿ Error:` report — while staying small enough that an error from -/// an earlier turn scrolls out of it as new output arrives. It is also what -/// bounds the region: nothing else caps how far the frame grows for an error. +/// transcript line and the box, and a long message wraps over several rows. +/// It is the backstop bound on how far the frame grows for an error; a prompt +/// the user has since sent ends the error's relevance sooner (see +/// `error_block_top`). const ERROR_SCAN: usize = 12; -/// A row of Claude Code's *own* error reporting, as it prints it above the -/// input box. Two shapes, both captured from a real 2.1.26x child: +/// A row of Claude Code's *own* error reporting — an error the CLI itself +/// produced, as it prints it above the input box: /// /// ```text -/// ● Bash(echo boom >&2; exit 3) -/// ⎿ Error: Exit code 3 <- failed tool: gutter, then the result -/// boom text, which starts `Error:` +/// ● API Error: Connection lost mid-response. The response above may be +/// incomplete. +/// ``` /// -/// ● API Error: 400 fake upstream rejected this turn <- failed turn: bullet, -/// ``` no gutter +/// **A `⎿` gutter disqualifies the row.** That gutter is a tool talking, and a +/// failed tool is *not* a CLI error: its `tool_result` reaches the feed with +/// the next request, so the feed above the pane already shows it and the pane +/// has no reason to grow. Only the CLI's own errors are invisible to the feed +/// (see the pane-error invariant), and they never carry a gutter — so the +/// gutter is the whole test, and `⎿ Error: Exit code 3` is deliberately not +/// a match. /// -/// So the gutter *and* the bullet are stripped before matching, and the colon -/// in `Error:` is required: `● Error handling lives in src/foo.rs` is ordinary -/// assistant prose, and `⎿ Tip: …` (Claude Code's idle hint) proves the -/// gutter alone means nothing. Neither shape can be recognised by colour — -/// the tool error renders palette 211 and the API error 220, both theme -/// values, so text shape is the only stable signal. +/// The bullet *is* stripped (Claude Code pads it with U+00A0, which +/// `trim_start` handles), and the colon in `Error:` is required: `● Error +/// handling lives in src/foo.rs` is ordinary assistant prose. Neither shape +/// can be recognised by colour — the tool error renders palette 211 and the +/// API error 220, both theme values, so text shape is the only stable signal. fn text_is_error_row(t: &str) -> bool { - let t = strip_row_marker(t); + let t = t.trim_start(); + if t.starts_with('⎿') { + return false; + } + let t = t.strip_prefix(['●', '⏺']).unwrap_or(t).trim_start(); t.starts_with("Error:") || t.starts_with("API Error") || t.starts_with(['✗', '✘', '✖', '✕']) } -/// The bullet Claude Code prints at the head of every transcript block -/// (`● Bash(…)`; older builds draw `⏺`). A `⎿ Error:` gutter belongs to the -/// bullet row above it, which is what names the thing that failed. -fn text_is_bullet_row(t: &str) -> bool { - t.trim_start().starts_with(['●', '⏺']) -} - -/// Strip a row's leading gutter or bullet plus the whitespace around it. -/// Claude Code pads both with a non-breaking space, which `trim_start` -/// handles (U+00A0 is `White_Space`). -fn strip_row_marker(t: &str) -> &str { - let t = t.trim_start(); - t.strip_prefix(['⎿', '●', '⏺']).unwrap_or(t).trim_start() +/// A user prompt echoed into the transcript (`❯ resume`). Above the input +/// box's top rule that is the only thing a `❯` row can be — the box's own `❯` +/// and a menu's `❯` selection marker both sit *below* the rule, outside the +/// window `error_block_top` scans. +fn text_is_prompt_row(t: &str) -> bool { + t.trim_start().starts_with('❯') } /// Topmost row the compact frame extends to in order to show an error Claude @@ -782,16 +783,25 @@ fn strip_row_marker(t: &str) -> &str { /// the input box. /// /// Takes the *highest* error row in the window rather than the lowest, so a -/// turn that failed several tools shows all of them and a wrapped report is -/// framed from its first line instead of its tail. The only step above that -/// row is the bullet row owning a `⎿ Error:` gutter. +/// turn that reported several shows all of them, and a report wrapped over +/// many rows is framed from its first line instead of its tail. Nothing is +/// pulled in above that row: a CLI error names itself, unlike a tool error, +/// which needed the `● Bash(…)` row above it and is no longer framed at all. +/// +/// The window stops at the newest **prompt row**, which is the real "this is +/// over" signal: once the user has typed something else, the error belongs to +/// a previous exchange and the pane must go back to being prompt-only. +/// `ERROR_SCAN` alone is far too coarse for that — Claude Code's own reply to +/// an error is two rows, so the error would sit inside the window for the +/// whole of the next turn and hold the pane open through it. fn error_block_top(rows: &[String], from: usize) -> Option { - let floor = from.saturating_sub(ERROR_SCAN); - let first = (floor..=from).find(|&i| text_is_error_row(&rows[i]))?; - match first.checked_sub(1) { - Some(p) if text_is_bullet_row(&rows[p]) => Some(p), - _ => Some(first), + let mut floor = from.saturating_sub(ERROR_SCAN); + if let Some(p) = (floor..=from).rev().find(|&i| text_is_prompt_row(&rows[i])) { + // `p + 1 > from` when the prompt is the last row: an empty range, so + // nothing is found and the frame collapses, which is the intent. + floor = p + 1; } + (floor..=from).find(|&i| text_is_error_row(&rows[i])) } /// Step one row further up when `i` lands on a blank row, so the frame's top @@ -1758,15 +1768,16 @@ mod tests { } #[test] - fn frames_a_failed_tool_with_the_row_that_names_it() { - // A failed tool is a bullet row plus a `⎿ Error:` gutter and its - // wrapped tail. The frame starts on the bullet row — the error text - // alone does not say *what* failed. + fn a_failed_tool_does_not_grow_the_pane() { + // Only the CLI's *own* errors are pane-only. A failed tool reports + // through the `⎿` gutter, and its tool_result reaches the feed with + // the next request — so the feed already shows it and the pane stays + // at its plain one-context-row frame. let screen = rows(&[ "● Running that.", // 0 "", // 1 - "● Bash(echo boom >&2; exit 3)", // 2: names the failure - " ⎿ Error: Exit code 3", // 3: the error + "● Bash(echo boom >&2; exit 3)", // 2 + " ⎿ Error: Exit code 3", // 3: a tool talking " boom", // 4: wrapped tail "", // 5 "✻ Osmosing… (11s)", // 6 @@ -1776,7 +1787,8 @@ mod tests { RULE, // 10 "Session: ▓▓░ Context | Opus", // 11 ]); - assert_eq!(compact_frame(&screen), Some((2, 11))); + assert_eq!(compact_frame(&screen), Some((7, 11))); + assert!(!compact_frame_ex(&screen).unwrap().error); } #[test] @@ -1784,32 +1796,60 @@ mod tests { // The error came before the panel, so it sits *above* it — the frame // has to take in both, not stop at the panel's top. let mut screen = task_panel_screen(); - screen[0] = " ⎿ Error: Exit code 3".into(); + screen[0] = "● API Error: Connection lost mid-response.".into(); assert_eq!(compact_frame(&screen), Some((0, 14))); } #[test] fn ordinary_prose_and_hints_are_not_errors() { - // `Error` without a colon is prose, and `⎿` alone is every tool - // result's gutter — Claude Code's own idle tip uses it. Growing the - // pane for either would grow it permanently. + // Only the CLI's own rows count. A `⎿` gutter is a tool talking, so + // every gutter shape is out — the idle tip, a plain result, and a + // failed tool alike (that last one is the whole point: it is already + // in the feed). `Error` without a colon is prose. assert!(!text_is_error_row("● Error handling lives in src/foo.rs")); assert!(!text_is_error_row(" ⎿ Tip: Say \"fan out subagents\"")); assert!(!text_is_error_row(" ⎿ Read 20 lines")); - assert!(text_is_error_row(" ⎿ Error: File does not exist.")); + assert!(!text_is_error_row(" ⎿ Error: File does not exist.")); + assert!(!text_is_error_row(" ⎿\u{a0}Error: Exit code 3")); assert!(text_is_error_row("● API Error: 400")); - // Claude Code pads the gutter with a non-breaking space. - assert!(text_is_error_row(" ⎿\u{a0}Error: Exit code 3")); + assert!(text_is_error_row("● API Error: Connection lost mid-response.")); + // Claude Code pads the bullet with a non-breaking space too. + assert!(text_is_error_row("●\u{a0}Error: could not reach the API")); // An idle box with no error keeps the plain one-context-row frame. assert!(!compact_frame_ex(&task_panel_screen()).unwrap().error); } + #[test] + fn a_new_prompt_ends_the_errors_relevance() { + // Reported: the pane kept holding an `API Error` open through the + // whole next turn. Claude Code's reply to an error is only two rows, + // so `ERROR_SCAN` alone never expires it — the prompt the user typed + // since is the real signal that the error is history. + let screen = rows(&[ + "● API Error: Connection lost mid-response.", // 0 + "✻ Baked for 3m 55s · done 11.53", // 1 + "❯ resume", // 2: sent since + "· Cascading… (26s)", // 3 + "", // 4 + &format!("{RULE} minimal ──"), // 5: top rule + "❯", // 6 + RULE, // 7 + "Session: ▓▓░ Context | Opus", // 8 + ]); + assert!(!compact_frame_ex(&screen).unwrap().error); + assert_eq!(compact_frame(&screen), Some((4, 8))); + // Without that prompt row the same error is still framed. + let mut fresh = screen.clone(); + fresh[2] = "● Retrying…".into(); + assert_eq!(compact_frame(&fresh), Some((0, 8))); + } + #[test] fn a_stale_error_scrolls_out_of_the_scan_window() { // The window is what makes this self-limiting: as the next turn prints // output the error drifts past `ERROR_SCAN` and the pane shrinks back // instead of staying grown for the rest of the session. - let mut screen = vec![" ⎿ Error: Exit code 3".to_string()]; + let mut screen = vec!["● API Error: Connection lost".to_string()]; screen.extend((0..ERROR_SCAN + 2).map(|i| format!("● line {i}"))); screen.extend([format!("{RULE} minimal ──"), "❯".into(), RULE.into(), "Session".into()]); assert!(!compact_frame_ex(&screen).unwrap().error);