Frame only the CLI's own errors, not tool errors

A failed tool was never the case worth growing the pane for: its
tool_result reaches the feed with the next request, so the feed above
already shows it. Only an error the CLI produced itself is invisible
there. Framing both meant tool errors bled through and held the pane open.

The `⎿` gutter is now the whole test — it means a tool is talking, so the
row is skipped. That leaves the un-guttered `● API Error: …` shape, which
also needs no lookup of the row above: a CLI error names itself.

Also expire an error at the newest prompt row. ERROR_SCAN alone was far
too coarse, because the CLI's reply to an error is only two rows: the
error stayed inside the window and held the pane through the next turn.
This commit is contained in:
Jonas H
2026-09-10 12:21:42 +02:00
parent ae454a3d2d
commit e9e224f856
2 changed files with 140 additions and 90 deletions

View File

@@ -510,40 +510,48 @@ agentId: <hex>`), and the real completion is injected into the parent's next
the feed shows the context) — **except in fullscreen**, where that wipe is the feed shows the context) — **except in fullscreen**, where that wipe is
*cancelled*, not deferred (see the pane-scroll invariant), and **except *cancelled*, not deferred (see the pane-scroll invariant), and **except
while an error is framed** (see the pane-error invariant). while an error is framed** (see the pane-error invariant).
- **An error Claude Code prints is pane-only, so the pane keeps it.** The - **A CLI error is pane-only, so the pane keeps it — a tool error is not.**
compact pane is prompt-only because the feed above shows the context — an The compact pane is prompt-only because the feed above shows the context.
error is the exception, because the feed *cannot* show it: a `tool_result` The one exception is an error the *CLI itself* produced (`● API Error:
reaches us only with the **next** request, which a turn that died never Connection lost mid-response.`, a retry exhaustion): that is Claude Code's
sends, and a retry exhaustion or an `● API Error: …` row is Claude Code's own text, never in the API stream, so the feed cannot show it and the pane
own text, never in the stream at all. So two things happen together: is the only place it will ever appear. So two things happen together:
`compact_frame_ex` walks further up to take the report in (over the task `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 panel too — the error came before it), and `ui::draw` **cancels** the
scheduled ctrl-l wipe when `EmbeddedTerm::shows_error` reports one, for 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 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 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 bring it back. Cancelled, not deferred — firing it later loses the same
rows. Three rules keep it bounded: rows. Three rules keep it bounded:
1. **Text shape, never colour.** Verified on a real 2.1.26x child, the tool 1. **The `⎿` gutter disqualifies a row, and that is the whole test.** A
error renders palette 211 and the API error 220 — both theme values, so gutter is a *tool* talking, and a failed tool is not a CLI error: its
colour is not a signal. `text_is_error_row` strips the leading `⎿` gutter `tool_result` reaches the feed with the next request, so the feed already
*and* the `●`/`⏺` bullet (`strip_row_marker`; Claude Code pads both with shows it and the pane has no reason to grow. Framing it too was the
U+00A0, which `trim_start` handles) and then requires `Error:` / reported bug — tool errors bled through and held the pane open. So
`API Error` / a `✗✘✖✕` glyph. **The colon is load-bearing**: `● Error `text_is_error_row` rejects any `⎿` row (`⎿ Error: Exit code 3`
handling lives in src/foo.rs` is ordinary prose, and `⎿ Tip: …` — Claude included, deliberately), strips only the `●`/`⏺` bullet, then requires
Code's own idle hint — proves the gutter alone means nothing. `Error:` / `API Error` / a `✗✘✖✕` glyph. **The colon is load-bearing**:
2. **`ERROR_SCAN` (12 rows) is the whole bound**, and it is what makes this `● Error handling lives in src/foo.rs` is ordinary prose. And it is
self-limiting: an error stays framed while it is "what just happened", **text shape, never colour** — verified on a real 2.1.26x child, the tool
drifts out of the window as the next turn prints output, and the pane error renders palette 211 and the API error 220, both theme values.
shrinks back on its own. Nothing else caps the growth and no app state is 2. **The newest prompt row ends the error's relevance.** Once the user has
involved — the wipe is simply re-scheduled by the next `Tap::drop` and typed something else the error belongs to a previous exchange, so
fires once the screen is clean. The window has to be this wide because `error_block_top` floors its scan just under the last ` …` transcript
Claude Code parks a blank row *and* its `✻ Worked…` row between the last row. `ERROR_SCAN` (12 rows) alone is far too coarse for this — Claude
transcript line and the box, so the error is never the context row. Code's reply to an error is only two rows, so the error stayed inside the
3. **The frame starts on the bullet row that owns the gutter.** `● Bash(echo window and held the pane open through the whole *next* turn, which is
boom >&2; exit 3)` is what names the failure; `⎿ Error: Exit code 3` what the second report was. Above the top rule a `` row can only be an
alone does not. And `error_block_top` takes the *highest* error row in the echoed prompt; the box's own `` and a menu's `` marker are both below
window, not the lowest, so a turn that failed several tools shows all of it, outside the scanned window.
them and a wrapped report is framed from its first line. 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 - **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 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 normal screen (verified on the wire: for 2.1.247 tmux reports every mouse
@@ -663,8 +671,9 @@ agentId: <hex>`), and the real completion is injected into the parent's next
(and its `… +N pending` overflow line) directly above the input box, so (and its `… +N pending` overflow line) directly above the input box, so
walking up over that block — tolerating one blank line and single wrapped / 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 activity rows, capped at `MAX_TASK_BLOCK` — makes task status visible with no
extra app state. Above *that* it walks over an error block extra app state. Above *that* it walks up to an error the **CLI itself**
(`text_is_error_row` / `error_block_top` — see the pane-error invariant). 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 Priority when the pane can't hold everything: the panel is
dropped first (`CompactFrame::ess_top`, the one-context-row frame) so the 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 line you're typing and an open menu never fall off screen; an overflowing
@@ -809,12 +818,13 @@ agentId: <hex>`), and the real completion is injected into the parent's next
`web_search_tool_result` + `citations_delta`), and `ansi` returns a `Bash` `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 call whose output carries real SGR codes, so both paths are exercised by a
genuine tool_result rather than a fixture. genuine tool_result rather than a fixture.
The two error scenarios are how the pane's error framing is developed against The three error scenarios are how the pane's error framing is developed
real Ink output: `toolerror` calls `Read` on a missing path (auto-approved, so against real Ink output, and two of them are *negative* fixtures: `toolerror`
no permission prompt) for the `⎿ Error:` gutter shape, `basherror` runs a calls `Read` on a missing path (auto-approved, so no permission prompt) and
failing `Bash` for its wrapped-tail variant, and `apierror` answers the *turn* `basherror` runs a failing `Bash`, both producing the `⎿ Error:` gutter that
request with a non-retryable 400 — leaving the side/title calls alone — so must **not** grow the pane. `apierror` answers the *turn* request with a
Claude Code prints its own `● API Error: 400 …` row. Its tool ids are minted from 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 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 **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 artifact of the fake, not of the proxy. Note the `agent` scenario answers

View File

@@ -734,47 +734,48 @@ fn task_block_top(rows: &[String], from: usize) -> Option<usize> {
/// How far above the input box an error still counts as "what just happened". /// 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 /// 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 /// 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 /// transcript line and the box, and a long message wraps over several rows.
/// wrapped `⎿ Error:` report — while staying small enough that an error from /// It is the backstop bound on how far the frame grows for an error; a prompt
/// an earlier turn scrolls out of it as new output arrives. It is also what /// the user has since sent ends the error's relevance sooner (see
/// bounds the region: nothing else caps how far the frame grows for an error. /// `error_block_top`).
const ERROR_SCAN: usize = 12; const ERROR_SCAN: usize = 12;
/// A row of Claude Code's *own* error reporting, as it prints it above the /// A row of Claude Code's *own* error reporting — an error the CLI itself
/// input box. Two shapes, both captured from a real 2.1.26x child: /// produced, as it prints it above the input box:
/// ///
/// ```text /// ```text
/// ● Bash(echo boom >&2; exit 3) /// ● API Error: Connection lost mid-response. The response above may be
/// ⎿ Error: Exit code 3 <- failed tool: gutter, then the result /// incomplete.
/// boom text, which starts `Error:` /// ```
/// ///
/// ● API Error: 400 fake upstream rejected this turn <- failed turn: bullet, /// **A `⎿` gutter disqualifies the row.** That gutter is a tool talking, and a
/// ``` no gutter /// 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 /// The bullet *is* stripped (Claude Code pads it with U+00A0, which
/// in `Error:` is required: `● Error handling lives in src/foo.rs` is ordinary /// `trim_start` handles), and the colon in `Error:` is required: `● Error
/// assistant prose, and `⎿ Tip: …` (Claude Code's idle hint) proves the /// handling lives in src/foo.rs` is ordinary assistant prose. Neither shape
/// gutter alone means nothing. Neither shape can be recognised by colour — /// can be recognised by colour — the tool error renders palette 211 and the
/// the tool error renders palette 211 and the API error 220, both theme /// API error 220, both theme values, so text shape is the only stable signal.
/// values, so text shape is the only stable signal.
fn text_is_error_row(t: &str) -> bool { 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(['✗', '✘', '✖', '✕']) t.starts_with("Error:") || t.starts_with("API Error") || t.starts_with(['✗', '✘', '✖', '✕'])
} }
/// The bullet Claude Code prints at the head of every transcript block /// A user prompt echoed into the transcript (` resume`). Above the input
/// (`● Bash(…)`; older builds draw `⏺`). A `⎿ Error:` gutter belongs to the /// box's top rule that is the only thing a `` row can be — the box's own ``
/// bullet row above it, which is what names the thing that failed. /// and a menu's `` selection marker both sit *below* the rule, outside the
fn text_is_bullet_row(t: &str) -> bool { /// window `error_block_top` scans.
t.trim_start().starts_with(['●', '⏺']) fn text_is_prompt_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()
} }
/// Topmost row the compact frame extends to in order to show an error Claude /// 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. /// the input box.
/// ///
/// Takes the *highest* error row in the window rather than the lowest, so a /// 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 /// turn that reported several shows all of them, and a report wrapped over
/// framed from its first line instead of its tail. The only step above that /// many rows is framed from its first line instead of its tail. Nothing is
/// row is the bullet row owning a `⎿ Error:` gutter. /// 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<usize> { fn error_block_top(rows: &[String], from: usize) -> Option<usize> {
let floor = from.saturating_sub(ERROR_SCAN); let mut floor = from.saturating_sub(ERROR_SCAN);
let first = (floor..=from).find(|&i| text_is_error_row(&rows[i]))?; if let Some(p) = (floor..=from).rev().find(|&i| text_is_prompt_row(&rows[i])) {
match first.checked_sub(1) { // `p + 1 > from` when the prompt is the last row: an empty range, so
Some(p) if text_is_bullet_row(&rows[p]) => Some(p), // nothing is found and the frame collapses, which is the intent.
_ => Some(first), 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 /// Step one row further up when `i` lands on a blank row, so the frame's top
@@ -1758,15 +1768,16 @@ mod tests {
} }
#[test] #[test]
fn frames_a_failed_tool_with_the_row_that_names_it() { fn a_failed_tool_does_not_grow_the_pane() {
// A failed tool is a bullet row plus a `⎿ Error:` gutter and its // Only the CLI's *own* errors are pane-only. A failed tool reports
// wrapped tail. The frame starts on the bullet row — the error text // through the `⎿` gutter, and its tool_result reaches the feed with
// alone does not say *what* failed. // the next request — so the feed already shows it and the pane stays
// at its plain one-context-row frame.
let screen = rows(&[ let screen = rows(&[
"● Running that.", // 0 "● Running that.", // 0
"", // 1 "", // 1
"● Bash(echo boom >&2; exit 3)", // 2: names the failure "● Bash(echo boom >&2; exit 3)", // 2
" ⎿ Error: Exit code 3", // 3: the error " ⎿ Error: Exit code 3", // 3: a tool talking
" boom", // 4: wrapped tail " boom", // 4: wrapped tail
"", // 5 "", // 5
"✻ Osmosing… (11s)", // 6 "✻ Osmosing… (11s)", // 6
@@ -1776,7 +1787,8 @@ mod tests {
RULE, // 10 RULE, // 10
"Session: ▓▓░ Context | Opus", // 11 "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] #[test]
@@ -1784,32 +1796,60 @@ mod tests {
// The error came before the panel, so it sits *above* it — the frame // 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. // has to take in both, not stop at the panel's top.
let mut screen = task_panel_screen(); 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))); assert_eq!(compact_frame(&screen), Some((0, 14)));
} }
#[test] #[test]
fn ordinary_prose_and_hints_are_not_errors() { fn ordinary_prose_and_hints_are_not_errors() {
// `Error` without a colon is prose, and `⎿` alone is every tool // Only the CLI's own rows count. A `⎿` gutter is a tool talking, so
// result's gutter — Claude Code's own idle tip uses it. Growing the // every gutter shape is out — the idle tip, a plain result, and a
// pane for either would grow it permanently. // 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("● Error handling lives in src/foo.rs"));
assert!(!text_is_error_row(" ⎿ Tip: Say \"fan out subagents\"")); assert!(!text_is_error_row(" ⎿ Tip: Say \"fan out subagents\""));
assert!(!text_is_error_row(" ⎿ Read 20 lines")); 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")); assert!(text_is_error_row("● API Error: 400"));
// Claude Code pads the gutter with a non-breaking space. assert!(text_is_error_row("● API Error: Connection lost mid-response."));
assert!(text_is_error_row("\u{a0}Error: Exit code 3")); // 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. // An idle box with no error keeps the plain one-context-row frame.
assert!(!compact_frame_ex(&task_panel_screen()).unwrap().error); 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] #[test]
fn a_stale_error_scrolls_out_of_the_scan_window() { fn a_stale_error_scrolls_out_of_the_scan_window() {
// The window is what makes this self-limiting: as the next turn prints // 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 // output the error drifts past `ERROR_SCAN` and the pane shrinks back
// instead of staying grown for the rest of the session. // 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((0..ERROR_SCAN + 2).map(|i| format!("● line {i}")));
screen.extend([format!("{RULE} minimal ──"), "".into(), RULE.into(), "Session".into()]); screen.extend([format!("{RULE} minimal ──"), "".into(), RULE.into(), "Session".into()]);
assert!(!compact_frame_ex(&screen).unwrap().error); assert!(!compact_frame_ex(&screen).unwrap().error);