From 043c99cdeb1ec49b340b2cf6d130a9dc200cbce0 Mon Sep 17 00:00:00 2001 From: Jonas H Date: Mon, 7 Sep 2026 10:20:52 +0200 Subject: [PATCH] Frame Claude Code's errors into the compact pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CLAUDE.md | 61 ++++++++++++-- dev/fake_upstream.py | 27 ++++++- src/term.rs | 187 ++++++++++++++++++++++++++++++++++++++++++- src/ui.rs | 10 ++- 4 files changed, 275 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a4d668b..07ddb4d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -157,7 +157,10 @@ src/term.rs embedded claude pane: spawns `claude --session-id ` 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: `), 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: `), 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: `), 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 diff --git a/dev/fake_upstream.py b/dev/fake_upstream.py index 946c98f..406638b 100644 --- a/dev/fake_upstream.py +++ b/dev/fake_upstream.py @@ -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.") diff --git a/src/term.rs b/src/term.rs index 9e21fe2..418237a 100644 --- a/src/term.rs +++ b/src/term.rs @@ -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 { 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 { + 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 { 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 { 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 { + 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. diff --git a/src/ui.rs b/src/ui.rs index e370770..ea15944 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -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(); }