Frame Claude Code's errors into the compact pane

The compact pane is prompt-only because the feed above shows the context.
An error is the exception: the feed cannot show it. A tool_result only
reaches us with the next request, which a turn that died never sends, and
an `API Error` row is Claude Code's own text, never in the stream at all.

So the frame walks further up to take the report in, and the scheduled
ctrl-l wipe is cancelled while one is framed — Ink redraws only the live
frame, so wiping would delete the error 400ms after it appeared.

Detection is text shape, not colour: the tool error renders palette 211
and the API error 220, both theme values. ERROR_SCAN is the only bound,
which is what makes it self-limiting — the error drifts out of the window
as the next turn prints output and the pane shrinks back on its own.
This commit is contained in:
Jonas H
2026-09-07 10:20:52 +02:00
parent 6bb7e7d424
commit 043c99cdeb
4 changed files with 275 additions and 10 deletions

View File

@@ -157,7 +157,10 @@ src/term.rs embedded claude pane: spawns `claude --session-id <uuid>` in a
portable-pty handles, which do not survive an exec).
`scroll` / `follow_live` / `scrolled_rows` give the
**fullscreen** pane the scrollback a plain terminal would —
see the pane-scroll invariant
see the pane-scroll invariant. `shows_error` reports whether
the compact frame is currently holding an error Claude Code
printed, which is what stops the ctrl-l wipe from deleting it —
see the pane-error invariant
src/reload.rs hot reload: ctrl-r `execve`s the binary now on disk *into this
process* — same pid, so the listener socket, the `claude` child
and (via a JSON snapshot) the live feed all cross over. Builds
@@ -502,7 +505,42 @@ agentId: <hex>`), and the real completion is injected into the parent's next
renders the prompt, shrinks when the tool_result echoes back, and schedules
a ctrl-l transcript wipe 400ms after each turn (the pane is prompt-only;
the feed shows the context) — **except in fullscreen**, where that wipe is
*cancelled*, not deferred (see the pane-scroll invariant).
*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
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.
- **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
@@ -595,9 +633,13 @@ 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. Priority when the pane can't hold everything: the panel is
extra app state. Above *that* it walks over an error block
(`text_is_error_row` / `error_block_top` — 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. The framed region drives the pane height too:
line you're typing and an open menu never fall off screen; an overflowing
no-menu frame bottom-anchors anyway, so a report taller than the pane keeps
its tail rather than pushing the box off screen. The framed region drives the pane height too:
`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
when idle (floor `MIN_COMPACT_INNER`, cap = screen 6); `PTY_PAD` keeps the
@@ -729,13 +771,20 @@ agentId: <hex>`), and the real completion is injected into the parent's next
`dev/fake_upstream.py`: it answers every request with canned SSE, so a **real
`claude` child** can be made to render its client-side tool UIs on demand
(`dev/.fake_scenario` =
`ask | plan | todo | taskupdate | agent | websearch | ansi | text`, switchable
`ask | plan | todo | taskupdate | agent | websearch | ansi | toolerror |
basherror | apierror | text`, switchable
mid-run) — this is how the pane's frame detector is developed against what
Ink actually draws. `websearch` also answers the *nested* hosted-tool request
Claude Code makes to run WebSearch (`server_tool_use` +
`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. Its tool ids are minted from 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
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

View File

@@ -8,7 +8,7 @@ ExitPlanMode, TodoWrite/Task*) on demand, so the pane's frame detector in
`src/term.rs` can be developed against what Ink actually draws.
Scenario is picked per turn from `CT_FAKE_SCENARIO`
(ask | plan | todo | taskupdate | agent | websearch | ansi | text).
(ask | plan | todo | taskupdate | agent | websearch | ansi | toolerror | apierror | text).
`websearch` also answers the *nested* request Claude Code makes to run WebSearch:
that call declares Anthropic's server-side `web_search` tool, so it is replied to
@@ -162,6 +162,12 @@ ANSI_INPUT = {
}
# A tool call that *fails* on the child's side, so Claude Code prints its own
# red error row (`⎿ Error: …`) above the input box. `Read` is auto-approved, so
# this needs no permission prompt — the failure comes from the missing file.
TOOL_ERROR_INPUT = {"file_path": "/nonexistent/claude-cloak/does-not-exist.txt"}
ASK_INPUT = {"questions": [{
"question": "The compact pane currently crops the top of this prompt. Which framing "
"should the pane use when an interactive question is on screen, given that "
@@ -269,6 +275,21 @@ class Handler(BaseHTTPRequestHandler):
return
scenario = read_scenario()
# `apierror`: a non-retryable upstream failure, so Claude Code prints
# its own red `API Error: 400 …` row where the turn would have been.
# Only the *turn* request fails — the side/title calls still answer, so
# the pane behaves normally around the error.
if scenario == "apierror" and tools and not hosted and not has_result:
out = json.dumps({"type": "error", "error": {
"type": "invalid_request_error",
"message": "fake upstream rejected this turn on purpose"}}).encode()
self.send_response(400)
self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(out)))
self.end_headers()
self.wfile.write(out)
return
# The nested hosted-tool request answers itself, whatever the scenario.
if hosted:
gen = stream_server_websearch()
@@ -300,6 +321,10 @@ class Handler(BaseHTTPRequestHandler):
"Let me search for that.")
elif scenario == "ansi":
gen = stream_tool("Bash", ANSI_INPUT, "Printing coloured output.")
elif scenario == "basherror":
gen = stream_tool("Bash", {"command": "echo boom >&2; exit 3", "description": "Fail"}, "Running that.")
elif scenario == "toolerror":
gen = stream_tool("Read", TOOL_ERROR_INPUT, "Reading that file.")
elif scenario == "taskupdate":
gen = stream_tool("TaskUpdate", {"taskId": "1", "status": "in_progress"},
"Starting the first task.")

View File

@@ -456,7 +456,9 @@ impl EmbeddedTerm {
/// region (one context row above the box, the input box itself however
/// 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
/// prompt auto-expands as you type and never scrolls out of view.
/// prompt auto-expands as you type and never scrolls out of view, and so
/// it grows over an active task panel or an error Claude Code just
/// printed.
/// Independent of the terminal's row count, so resizing the pane to this
/// value can't feed back into the measurement.
///
@@ -471,6 +473,17 @@ impl EmbeddedTerm {
Some(((bottom - top + 1) as u16).max(MIN_COMPACT_INNER))
}
/// True when the compact frame is currently taking in an error Claude Code
/// printed (see `error_block_top`). The UI reads this to *cancel* the
/// scheduled ctrl-l transcript wipe — for the same reason fullscreen
/// cancels it: Ink redraws only the live frame, so a wipe would delete the
/// error 400ms after it appeared and nothing would ever bring it back.
/// Only called on the frame a wipe is actually due, so the screen scan
/// costs nothing the rest of the time.
pub fn shows_error(&self) -> bool {
compact_frame_ex(&self.screen_rows()).is_some_and(|f| f.error)
}
/// Inner rows the pane wants for the interactive prompt Claude Code draws
/// for AskUserQuestion / ExitPlanMode (see `interactive_frame`). Measured
/// from the child's screen for the same reason `compact_rows` is: the tap's
@@ -703,6 +716,69 @@ fn task_block_top(rows: &[String], from: usize) -> Option<usize> {
Some(i)
}
/// 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.
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:
///
/// ```text
/// ● Bash(echo boom >&2; exit 3)
/// ⎿ Error: Exit code 3 <- failed tool: gutter, then the result
/// boom text, which starts `Error:`
///
/// ● API Error: 400 fake upstream rejected this turn <- failed turn: bullet,
/// ``` no gutter
///
/// 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.
fn text_is_error_row(t: &str) -> bool {
let t = strip_row_marker(t);
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()
}
/// Topmost row the compact frame extends to in order to show an error Claude
/// Code just printed, or None when no error row sits within `ERROR_SCAN` of
/// 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.
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),
}
}
/// Step one row further up when `i` lands on a blank row, so the frame's top
/// context row carries text (the panel is drawn with a blank `marginTop` row
/// above it, and showing that blank instead of the spinner row wastes a line).
@@ -744,6 +820,10 @@ struct CompactFrame {
bottom: usize,
/// The region ends on an open `@`/`/` menu rather than the statusLine.
menu_open: bool,
/// The region was extended upwards to take in an error Claude Code
/// printed (`error_block_top`). `EmbeddedTerm::shows_error` reports this
/// so the UI can cancel the scheduled ctrl-l wipe.
error: bool,
}
/// Same as `compact_frame`, but keeps the fields `compact_view_range` needs.
@@ -765,6 +845,14 @@ fn compact_frame_ex(rows: &[String]) -> Option<CompactFrame> {
Some(t) => skip_blank_up(rows, t.saturating_sub(1)),
None => ctx_top,
};
// An error Claude Code just printed is the one part of its UI the feed
// above cannot stand in for: a tool_result only reaches us with the *next*
// request, so a turn that dies on the error never sends one, and a retry
// exhaustion or an `API Error` row is Claude Code's own text, never in the
// stream at all. So the frame walks further up to take the report in —
// above the task panel too, since the error came before it.
let err_top = error_block_top(rows, view_top);
let view_top = err_top.unwrap_or(view_top);
// An open `@`/`/` menu replaces the chrome below the bottom rule with a
// 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,
@@ -784,7 +872,13 @@ fn compact_frame_ex(rows: &[String]) -> Option<CompactFrame> {
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 })
Some(CompactFrame {
top: view_top,
ess_top: ctx_top,
bottom: view_bottom,
menu_open,
error: err_top.is_some(),
})
}
/// Pick the `(start, end)` window `render` shows for `PaneView::Compact`,
@@ -1620,6 +1714,95 @@ mod tests {
assert_eq!(compact_frame(&screen), Some((2, 6)));
}
/// A turn that died on an API error: Claude Code's own `● API Error:` row,
/// then a blank, then its `✻ …` timing row above the input box — so the
/// single context row the frame used to show landed on the blank.
fn api_error_screen() -> Vec<String> {
rows(&[
" now fail", // 0
"", // 1
"● API Error: 400 upstream rejected this", // 2: the error
"", // 3
"✻ Baked for 0s · done 8.15", // 4: old context row
"", // 5
&format!("{RULE} minimal ──"), // 6: top rule
"", // 7
RULE, // 8
"Session: ▓▓░ Context | Opus", // 9: statusLine
"⏵⏵ bypass permissions", // 10: chrome (cropped)
])
}
#[test]
fn frames_an_api_error_above_the_input_box() {
// The whole point: an `API Error` row is Claude Code's own text and is
// never in the API stream, so the pane is the only place it is ever
// shown. It must be inside the frame, not two rows above it.
assert_eq!(compact_frame(&api_error_screen()), Some((2, 9)));
assert!(compact_frame_ex(&api_error_screen()).unwrap().error);
}
#[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.
let screen = rows(&[
"● Running that.", // 0
"", // 1
"● Bash(echo boom >&2; exit 3)", // 2: names the failure
" ⎿ Error: Exit code 3", // 3: the error
" boom", // 4: wrapped tail
"", // 5
"✻ Osmosing… (11s)", // 6
"", // 7
&format!("{RULE} minimal ──"), // 8: top rule
"", // 9
RULE, // 10
"Session: ▓▓░ Context | Opus", // 11
]);
assert_eq!(compact_frame(&screen), Some((2, 11)));
}
#[test]
fn error_frame_reaches_over_an_active_task_panel() {
// 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();
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.
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("● API Error: 400"));
// Claude Code pads the gutter with a non-breaking space.
assert!(text_is_error_row("\u{a0}Error: Exit code 3"));
// 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_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()];
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);
// …while two rows closer it is back in reach.
screen.drain(1..3);
assert_eq!(compact_frame(&screen).unwrap().0, 0);
}
#[test]
fn no_input_box_yields_none() {
// Startup banner only — no rules, so the caller falls back.

View File

@@ -902,7 +902,7 @@ fn event_loop(
// the pane is all there is on screen and its transcript is what the
// wheel scrolls back through — so the wipe is cancelled there rather
// than deferred: firing it later would delete that history the moment
// ctrl-f dropped out of fullscreen.
// ctrl-f dropped out of fullscreen
let clear_due = {
let mut a = app.lock().unwrap();
if a.embed_clear_at.is_some_and(|t| std::time::Instant::now() >= t) {
@@ -912,9 +912,17 @@ fn event_loop(
false
}
};
// …and cancelled the same way when Claude Code has an error on screen:
// the pane is the only place its own error rows are ever shown (a
// tool_result reaches the feed with the *next* request, which a failed
// turn never sends, and an `API Error` row is not in the stream at
// all), and Ink redraws only the live frame — so a wipe here would
// lose the error 400ms after it appeared. Dropping the schedule rather
// than deferring it keeps the report until the next turn ends cleanly.
if clear_due
&& let Some(et) = &eui.term
&& !et.exited()
&& !et.shows_error()
{
et.clear_screen();
}