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:
61
CLAUDE.md
61
CLAUDE.md
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
|
||||
187
src/term.rs
187
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<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.
|
||||
|
||||
10
src/ui.rs
10
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user