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

@@ -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.")