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:
82
CLAUDE.md
82
CLAUDE.md
@@ -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
|
||||
*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: <hex>`), 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: <hex>`), 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
|
||||
|
||||
148
src/term.rs
148
src/term.rs
@@ -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".
|
||||
/// 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<usize> {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user