From ba6b18e7d9a833bc93725fc76a8b6504ca3eb409 Mon Sep 17 00:00:00 2001 From: Jonas H Date: Thu, 27 Aug 2026 10:24:05 +0200 Subject: [PATCH] Lane server tools, lift task notes, parse ANSI Three streams of data were being lost or mangled in the feed. WebSearch is not purely client-side: it issues a nested /v1/messages that declares Anthropic's hosted web_search under the parent session id and with no agent-id header. Read as a turn start it pushed a fake user prompt, clobbered the main lane's system/tools signatures and downgraded a [1m] session to the short window on the next resume. Requests are now classified three ways (Turn / ServerTool / Side) from the tools array shape alone, and a nested call gets its own lane, readable only in the A popup. Its result and citations arrive complete in the stream and are echoed back in no later request body, so they are attached as the stream delivers them. An Agent call returns its tool_result immediately ("async agent launched"), so the real completion is a injected into the parent's next user turn. Those are lifted out of the prompt: the report moves onto the Agent entry it answers, the usage totals onto the lane, and the status becomes one glyph-led note line. A finished agent used to keep reading as running. Tool output we do not control carries SGR codes. A self-contained parser maps them to styles instead of leaving [1m as literal text; filled blocks keep their own colours and take only the attributes. Also: a non-2xx upstream response now surfaces as an error entry instead of a silent stall, tool renderers cover the file/shell/task/prompt/web families, and the fake upstream answers the nested hosted-tool request so both search paths run offline. --- CLAUDE.md | 208 ++++- dev/fake_upstream.py | 95 +- src/ansi.rs | 478 ++++++++++ src/app.rs | 2025 ++++++++++++++++++++++++++++++++++++++++-- src/main.rs | 1 + src/proxy.rs | 84 +- src/sessions.rs | 433 ++++++++- src/ui.rs | 998 +++++++++++++++++++-- 8 files changed, 4138 insertions(+), 184 deletions(-) create mode 100644 src/ansi.rs diff --git a/CLAUDE.md b/CLAUDE.md index c0011e3..2539a28 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,14 +14,22 @@ src/proxy.rs axum fallback handler: buffers request body (for session metadata tool results, and user prompts — `app::record_user_prompt` lifts the trailing user message into a Kind::User feed entry verbatim (incl. slash-command machinery — the goal is to show everything - the model received, never filter it). On a turn-starting request - (tools present) it also emits the system-prompt *size* as a + the model received, never filter it; the one exception is a + ``, which is *relocated* — see its invariant). + `app::classify_request` sorts every request three ways + (`ReqKind::{Turn, ServerTool, Side}`) — "has tools" alone is not + a turn. On a `Turn` it also emits the system-prompt *size* as a Kind::System line (the prompt itself is too long to show) and the available tool set as Kind::ToolDefs — each once, re-emitted only on change (system/tools/history are re-sent every request but are not new data). A *side* request (no tools — topic/title haiku calls) is still shown, tagged with a `── side request ──` Meta - divider. `app::extract_user_text` splits a user text block into + divider. A `ServerTool` request (WebSearch's nested hosted-tool + call) emits none of those lines and streams into its own lane. + A response that is not a 2xx SSE stream is no longer silent: a + non-2xx pushes a Kind::Error naming the status and the upstream + message, built from the same best-effort tee (never + `resp.bytes().await`). `app::extract_user_text` splits a user text block into its injected `` spans (kept as dimmed Kind::Reminder entries, never discarded — the prompt survives even when it shares its block with a reminder, the @@ -38,10 +46,21 @@ src/sse.rs incremental SSE parser; tolerant of chunk splits mid-event/mid-UT src/app.rs Arc> shared state; Tap = one in-flight tapped request, translates SSE events → session Entries (Drop closes it out). Each Tap belongs to a *lane* (`Lane`/`LaneId`): lane 0 is the - main chain, every subagent gets its own. Entries stay in one + main chain, every subagent — and every nested server-tool call — + gets its own. Entries stay in one append-only Vec tagged with `Entry::lane`; per-agent state (model, tokens, tool count, system/tools signatures, label, - parent, finished) lives on `Lane` + parent, finished, `` totals) lives on `Lane`. + Also home to the `` parser + (`TaskNotification` / `split_task_notifications` / + `task_note_line`), which lifts Claude Code's task notifications + out of the user prompt and turns each into a one-line + Kind::TaskNote +src/ansi.rs self-contained SGR parser (no dependency): CSI `…m` → ratatui + Style; every other escape (other CSI finals, OSC/DCS/APC, + two-char) is stripped. `ui::sanitize`/`sanitize_md` are thin + wrappers over `ansi::strip`/`strip_multiline`, so dropping the + ESC byte no longer leaves `[1m` behind as literal text src/ui.rs ratatui rendering @ ~30fps; session list + scrollable feed (FeedCache: per-entry rendered lines + wrapped heights, only changed entries re-render; the viewport window of lines is @@ -58,7 +77,15 @@ src/ui.rs ratatui rendering @ ~30fps; session list + scrollable feed filled blocks stay legible under any terminal theme. Injected ``s and the system-prompt-size `Kind::System` line show dim under the "system" filter; the `Kind::ToolDefs` - tool-list line shares the "tools" filter with tool calls. The + tool-list line shares the "tools" filter with tool calls, and + `Kind::TaskNote` shares the "meta" filter with `Kind::Meta` + (`FILTER_LABELS` stays 7 wide). `push_result`, `Kind::Reminder` + and `Kind::User` route their text through `ansi`; `render_tool` + covers the file, shell, task/monitor, prompt (AskUserQuestion / + ExitPlanMode) and web tool families, with the generic + `key: value` dump kept as the fallback. A `Kind::Meta` whose + content holds `\n` renders one dim row per line (a `\n` inside a + single ratatui `Line` is not a row break). The feed's right border doubles as a prompt minimap: `*` markers show where each user message sits in the whole conversation, with the scroll thumb drawn on top where they @@ -71,8 +98,10 @@ src/ui.rs ratatui rendering @ ~30fps; session list + scrollable feed main feed, own FeedCache from the `FeedCaches` pool, own scroll/follow from `App::lane_cols`, so it follows its own tail and the border title carries the identity (`⟳ Explore · find the - retry helper · sonnet · out 2.1k · 2/3`). See the - subagent-popup invariant. + retry helper · sonnet · out 2.1k · 2/3`). Picker rows and that + border title prefer a notification's `` totals + (`lane_tokens` / `lane_dur`) over the wire-counted `out …`. See + the subagent-popup invariant. Sessions panel is a uniform 50% of the main area: each session is a multi-line item — full white title (live = first user prompt via `live_title`, stub = disk label, word-wrapped by @@ -88,7 +117,13 @@ src/sessions.rs on-disk session history (main chain *and* subagents): mtime change — one pass yields the label *and* the session's last main-chain model, which `App::resume_model` turns into the `--model` a resume spawns with); load_view/load_history rebuild a feed Session - from a JSONL transcript (lazily, on first view); build_tree + from a JSONL transcript (lazily, on first view) and give it the + same `` treatment as the live path — notes + lifted into Kind::TaskNote, the `` moved onto the `Agent` + tool entry it answers (matched by `` against + `EntryParser::agent_tools`, inline in the note when that call is + outside the view), `` totals applied to the matching lane + in `splice_agents`; build_tree parses uuid/parentUuid chains into a TurnTree (one node per real user prompt; rewinds leave fork points); materialize writes a new session file from a chosen set of turns. @@ -151,6 +186,37 @@ UI thread redraws on its own tick (no channel; just the mutex). must never wait for either: a synchronous agent's result only lands when it has already finished, and the child's first request can beat the parent's next one, so lanes are born anonymous and adopted later. +- **Turn detection is a three-way classification, not "has tools".** + `app::classify_request` returns `ReqKind::{Turn, ServerTool, Side}` from the + `tools` array alone and is the single predicate shared by `proxy.rs` and + `record_user_prompt`. A hosted tool is **`type` present + `input_schema` + absent** — never a version allowlist, so a future `web_search_20260101` still + classifies, and the API's explicit `{"type":"custom"}` spelling is excluded. + An empty/absent array stays the side/title request; a mixed array is a `Turn`. +- **Claude Code's `WebSearch` is not purely client-side.** It issues a *nested* + `/v1/messages` declaring Anthropic's server-side `web_search` under the + parent's `session_id` with **no agent-id header** — verified on the wire. It + gets its own lane via a synthetic `srvtool-` id, which cannot collide + with a real agent id because Claude Code's are bare lowercase hex and + `s`/`r`/`v`/`t`/`o`/`l`/`-` are not hex digits (so `finish_lane`, + `close_lane_from_result` and the `` scan can never land on one). It + is labelled `web_search · ""`, finished in `Tap::drop` (one request = + one turn), emits no system/tools/side-request lines, and is readable only in + the `A` popup — never in the main feed. Treating it as a turn start used to + push a fake user prompt, clobber the main lane's system/tools signatures, and + **silently downgrade a `[1m]` session to the short window** on the next + resume. +- **The trailing user run skips `role:"system"` messages.** Claude Code ≥2.1.247 + sends beta `mid-conversation-system-2026-04-07` and appends the agent-type + listing as a `role:"system"` message *after* the prompt, so turn 1 of every + session reads `["user","system"]` — verified in both `claude -p` and the + interactive CLI. A `take_while` on "user" saw that system message first, + collected nothing and returned early, which dropped the **whole first turn**: + no prompt block, no system/tools lines, no lane labelling, no notification + scan, nothing for `ui::live_title`. Only an assistant message ends the run; a + tool-loop continuation still contributes nothing. The mid-conversation + message's size is surfaced as a second `Kind::System` line, tracked per lane + in `Lane::last_mid_system_len` with the same once-then-on-change policy. - **One entry vec, tagged with lanes.** Per-lane vecs would double every index site (`Tap::cur`), break `FeedCache`'s positional alignment with `Session::entries`, turn the viewport window into a k-way merge inside the @@ -164,13 +230,57 @@ agentId: `), and the real completion is injected into the parent's next user turn as `` … ``. So `close_lane_from_result` only ties the lane to its tool call (and finishes it in the non-async wording, kept for older builds), while - `Session::finish_lanes_from_notifications` — called from + `Session::apply_task_notifications` — called from `record_user_prompt` on the trailing user run, before its early returns — is - what stamps `Lane::finished_at`. Background *bash* tasks share the - notification shape with a short id that matches no lane. Reading `finished` + what stamps `Lane::finished_at`. It does four things per notification: stamps + `finished_at` (**skipped for a monitor event** — a ``-less progress + ping is not a stop), records the `` totals on the lane, moves the + `` report onto the `Agent` tool entry via + `task-id → lane_of_agent → Lane::anchor` (falling back to + ` → Lane::tool_use_id`), and pushes one `Kind::TaskNote` status + line. The **disk path resolves the report differently** — by `` + against `EntryParser::agent_tools`, which is never drained — and its lanes are + finished by `add_lane`, not by the notification. Background *bash* tasks share + the notification shape with a short id that matches no lane; they get a note + and nothing else. Reading `finished` off the tool_result alone is why a finished agent used to keep reading as running. -- **Subagents live in a popup; they never share the feed.** The main feed +- **A task notification is relocated, never dropped.** This is the one + refinement to "show everything the model received": `record_user_prompt` + splits a `` out of the prompt (and out of a + `` wrapping one) instead of rendering the raw XML as a + full-width orange prompt rectangle. The `` report *moves* onto the + `Agent` tool call it answers — replacing the `Async agent launched…` + acknowledgement, `is_error` set on a failed status — and stays inline in the + note only when there is no lane or no anchor to move it to. The status becomes + one line whose **leading glyph is the status channel**: + `app::TaskNotification::glyph` writes it and `ui::entry_lines` colours the + entry from it, so `app.rs` stays the only place a note's text is built. Only + `` is elided — byte-identical boilerplate on all 208 real occurrences. + `` is **not reliably a word** (4 of 208 carry a raw upstream error + body), so a non-word status is clipped to one line and reads as a failure. +- **A server tool's result exists only in the stream.** A + `web_search_tool_result` / `mcp_tool_result` arrives as a *complete* + `content_block_start` (no deltas; `content_block_stop` follows immediately) + and is **never** echoed back as a `tool_result` block in a later request body, + so `attach_tool_results` can never fill it in — drop it and it is gone. + `Tap::handle` attaches it to the entry `Session::tool_ids` maps `tool_use_id` + to, and **consumes the id** exactly as `attach_tool_results` does, so the map + stays bounded (a hosted search would otherwise leak one entry per call for the + process lifetime). Hits are emitted in the *same* `Links: [{title,url}…]` wire + shape Claude Code's client-side `WebSearch` string result uses, and + `ui::render_tool`'s arm is `"websearch" | "web_search"`, so hosted and client + search render through one hit renderer (`push_search_result`) rather than two + that can drift. The `other =>` fallback stays the net for block types nobody + has seen, and still sets no `self.cur`. +- **Subagents live in a popup; they never share the feed.** The popup is no + longer strictly agents — a lane is a subagent *or* a nested server-tool call — + so the footer leads with `A streams (N)`, the picker title reads + `streams · X of Y running`, and `ui::lane_mark` returns `⚙` for + `Lane::is_server_tool()`, replacing the `⟳`/`✓`/`·` three-way for those lanes + (a server-tool lane is one request, and no `` will ever + confirm it; liveness still shows through the accent styling and the + running-first sort). The main feed always renders `MAIN_LANE` at full width, so how many agents run changes nothing about reading the main chain — no split, no rows, no reserved space, no interleaved entries (`draw_feed` filters `e.lane == args.lane`). `A` @@ -202,7 +312,11 @@ agentId: `), and the real completion is injected into the parent's next `Session::model`/context are gated on `MAIN_LANE`; `last_system_len` and `last_tools_sig` live per lane (a subagent's system prompt and restricted tool set differ, so session-wide state re-emitted both lines on every - main↔subagent alternation), and the prompt dedup is scoped to the lane. + main↔subagent alternation), and the prompt dedup is scoped to the lane. That + dedup walk-back skips `Kind::TaskNote` alongside `Kind::Reminder` — both are + turn preamble pushed just above the prompt, and leaving either in the way + stops resends being deduped at all. Notes have their own resend guard against + the lane's tail run. - **Embed identity is learned from traffic, never assumed from `--session-id`.** Claude Code's interactive `--session-id` is *not* guaranteed to equal the id it reports in request metadata (and a `--resume` can mint a fresh one), so the @@ -256,8 +370,10 @@ agentId: `), and the real completion is injected into the parent's next …,context-1m-…` — same body `model`, same transcript record — so no amount of transcript reading can tell them apart. The proxy is the only place that sees it: `proxy.rs` reads `BETA_HEADER` on **main-chain turn requests only** - (a side/title call runs haiku without the flag, a subagent runs its own - model) and `app::record_long_context` stores it as `Session::long_context`. + — gated on `ReqKind::Turn && MAIN_LANE`, because a side/title call runs haiku + without the flag, a subagent runs its own model, and a nested server-tool call + never carries the flag at all — and `app::record_long_context` stores it as + `Session::long_context`. `App::resume_arg` then picks the window: the wire observation wins, else the `[1m]` in `Session::spawn_model` (our own spawn, while it still names the same model), and with **neither** — a session that predates this process — @@ -292,7 +408,26 @@ agentId: `), and the real completion is injected into the parent's next - Tool input streams as raw JSON fragments; pretty-printed only on `content_block_stop`. Streaming text re-renders markdown on every change (FeedCache fingerprints by content length + done + result), so partial - markdown self-heals; completed entries render from cache. + markdown self-heals; completed entries render from cache. A + **`citations_delta` is not text** — appending it would corrupt the entry, so + citations are collected on the `Tap` per text block, deduplicated by url, and + flushed by `flush_citations` at `content_block_stop` *and* in `Drop` (a cut + stream keeps its sources) as one dim `Kind::Meta` entry. +- **ANSI colour stops at a filled block.** Anything the feed renders as a filled + rectangle owns its own fg/bg: `Kind::User` blocks pick their foreground with + `color_on(bg)` so text stays legible under any terminal theme, so ANSI colour + is dropped inside them (`ansi::plain_with_mods`) and only + bold/dim/italic/underline survive; `ansi::spans` keeps the colour everywhere + else. Attributes are re-applied *after* wrapping by walking a source cursor + forward per character — safe because `wrap_words` only ever drops whitespace, + never reorders. +- **The ANSI parser is total.** It sits on the render path of output we do not + control (`cargo`, rustfmt, slash-command stdout), so every failure mode + degrades rather than throws: an unknown SGR parameter is skipped individually, + a CSI with no valid final byte consumes only its parameter bytes (so a + following multi-byte char survives), an unterminated OSC gives up at the + newline. `push_search_result` follows the same rule — an unparseable `Links:` + array returns false and the caller falls back to the raw result rendering. ## Gotchas @@ -440,20 +575,36 @@ agentId: `), and the real completion is injected into the parent's next fully offline end-to-end tests with zero API usage. That fake server is `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 | text`, - switchable + (`dev/.fake_scenario` = + `ask | plan | todo | taskupdate | agent | websearch | ansi | text`, switchable mid-run) — this is how the pane's frame detector is developed against what - Ink actually draws. Drive it through tmux (`.claude/skills/tui-verify`) and + 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 + 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 + *every* tool-bearing request with `Agent` calls, including a subagent's own, + so agents spawn recursively; switch to `text` once they are running. + Drive it through tmux (`.claude/skills/tui-verify`) and obey that skill's safety rule: **never `pkill`/`killall`**, tear down only your own named tmux session. The child writes real task files under `~/.claude/tasks//`; delete that directory afterwards. ## Not yet handled (known MVP limits) -- Non-streaming requests pass through untapped (e.g. `count_tokens`). -- Subagent popup: no per-lane prompt minimap (a subagent has no user prompts), - and lanes loaded from disk have no token counts (a transcript records no - usage) — the picker shows tool counts too. Only one agent is readable at a +- Non-streaming requests pass through untapped (e.g. `count_tokens`), and so + does a **2xx** non-SSE response. A non-2xx now surfaces as a `Kind::Error`. +- Subagent popup: no per-lane prompt minimap (a subagent has no user prompts). + A disk lane *does* now carry the agent's own run totals + (`subagent_tokens`/`tool_uses`/`duration_ms`) whenever its + `` was recorded — a transcript records no *API* usage, but + the notification blocks carry Claude Code's own numbers. What a disk lane + still lacks is per-turn `input_tokens`/`output_tokens` (SSE-only), and a lane + whose notification we never saw falls back to the wire-counted `out …`. + Only one agent is readable at a time (a modal popup, by design: the alternative was the split feed this replaced). A lane is never closed, only marked `finished`: a background agent (`x-app: cli-bg`) can wake up again @@ -473,7 +624,14 @@ agentId: `), and the real completion is injected into the parent's next to read session metadata; adds first-byte latency on huge bodies. - Tool results only appear once the *next* request fires; if the session ends right after a tool call, that result is never seen. Output is what Claude - Code sends the model (i.e. post-truncation). + Code sends the model (i.e. post-truncation). A *server* tool is the exception + — its result rides in the same stream, so it lands immediately. +- Binary tool_result blocks are named, not shown: `flatten_result_content` + renders an image as `[image · ]` (size derived from the + base64 *length*, never a decode — a screenshot is hundreds of KB and this runs + under the app mutex; `source.type == "url"` shows the url) and a + `tool_reference` as `[tool ]`. The bare `[]` placeholder remains + the fallback for everything else. No terminal graphics protocol. - Embedded pane: no mouse forwarding yet; no scrollback view (live screen only); shift+enter needs kitty keyboard protocol pushed on the outer terminal (not done); permission prompts aren't detected for pane growth diff --git a/dev/fake_upstream.py b/dev/fake_upstream.py index 5e664ad..946c98f 100644 --- a/dev/fake_upstream.py +++ b/dev/fake_upstream.py @@ -8,7 +8,12 @@ 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 | text). +(ask | plan | todo | taskupdate | agent | websearch | ansi | 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 +with `server_tool_use` + `web_search_tool_result` + `citations_delta` — the block +types only a hosted tool produces. Each incoming request is logged to `dev/fake_upstream.log` (declared tool names + the trailing user text) so we can see what CC sends. """ @@ -61,6 +66,9 @@ def stream_text(text): yield sse("message_stop", {"type": "message_stop"}) +_TOOL_SEQ = 0 + + def stream_tool(name, tool_input, lead="Working on it."): """A turn that calls one client-side tool.""" yield from stream_tools([(name, tool_input)], lead) @@ -78,8 +86,13 @@ def stream_tools(calls, lead="Working on it."): "delta": {"type": "text_delta", "text": lead}}) yield sse("content_block_stop", {"type": "content_block_stop", "index": 0}) for n, (name, tool_input) in enumerate(calls, start=1): + # Ids must be unique across the whole session, exactly as the real API + # guarantees: Claude Code resends the full history every request, so a + # reused id makes an *old* tool_result re-attach to the newest call. + global _TOOL_SEQ + _TOOL_SEQ += 1 yield sse("content_block_start", {"type": "content_block_start", "index": n, - "content_block": {"type": "tool_use", "id": f"toolu_fake{n}", + "content_block": {"type": "tool_use", "id": f"toolu_fake{_TOOL_SEQ}", "name": name, "input": {}}}) blob = json.dumps(tool_input) for i in range(0, len(blob), 40): @@ -92,6 +105,63 @@ def stream_tools(calls, lead="Working on it."): yield sse("message_stop", {"type": "message_stop"}) +def stream_server_websearch(): + """What a *hosted* web_search turn looks like: `server_tool_use`, then a + complete `web_search_tool_result` block (no deltas — the whole payload + rides in `content_block_start`), then cited text. Claude Code issues this + nested request itself when it runs the client-side `WebSearch` tool.""" + yield sse("message_start", {"type": "message_start", "message": { + "id": "msg_ws", "type": "message", "role": "assistant", "model": MODEL, + "content": [], "stop_reason": None, "stop_sequence": None, + "usage": {"input_tokens": 50, "output_tokens": 1}}}) + yield sse("content_block_start", {"type": "content_block_start", "index": 0, + "content_block": {"type": "server_tool_use", "id": "srvtoolu_fake1", + "name": "web_search", "input": {}}}) + blob = json.dumps({"query": "ratatui scrollbar thumb"}) + yield sse("content_block_delta", {"type": "content_block_delta", "index": 0, + "delta": {"type": "input_json_delta", "partial_json": blob}}) + yield sse("content_block_stop", {"type": "content_block_stop", "index": 0}) + yield sse("content_block_start", {"type": "content_block_start", "index": 1, + "content_block": { + "type": "web_search_tool_result", "tool_use_id": "srvtoolu_fake1", + "content": [ + {"type": "web_search_result", "title": "Ratatui Scrollbar docs", + "url": "https://ratatui.rs/widgets/scrollbar", "page_age": "2 days"}, + {"type": "web_search_result", "title": "Scrollbar example", + "url": "https://ratatui.rs/examples/scrollbar", "page_age": None}, + ]}}) + yield sse("content_block_stop", {"type": "content_block_stop", "index": 1}) + yield sse("content_block_start", {"type": "content_block_start", "index": 2, + "content_block": {"type": "text", "text": ""}}) + for chunk in "Ratatui renders the thumb through its Scrollbar widget. ".split(" "): + yield sse("content_block_delta", {"type": "content_block_delta", "index": 2, + "delta": {"type": "text_delta", "text": chunk + " "}}) + yield sse("content_block_delta", {"type": "content_block_delta", "index": 2, + "delta": {"type": "citations_delta", "citation": { + "type": "web_search_result_location", + "url": "https://ratatui.rs/widgets/scrollbar", + "title": "Ratatui Scrollbar docs", + "cited_text": "Scrollbar renders a thumb over the track."}}}) + yield sse("content_block_stop", {"type": "content_block_stop", "index": 2}) + yield sse("message_delta", {"type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"output_tokens": 30, "server_tool_use": {"web_search_requests": 1}}}) + yield sse("message_stop", {"type": "message_stop"}) + + +# A command whose *output* carries real SGR codes, so the feed's ANSI handling +# is exercised by a genuine tool_result rather than a hand-written fixture. +ANSI_INPUT = { + "command": ( + "printf '\\033[1mbold heading\\033[22m\\n'; " + "printf '\\033[31m- removed line\\033[0m\\n'; " + "printf '\\033[32m+ added line\\033[0m\\n'; " + "printf '\\033[38;5;208m256-colour orange\\033[0m\\n'" + ), + "description": "Print coloured output", +} + + 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 " @@ -163,7 +233,14 @@ class Handler(BaseHTTPRequestHandler): body = json.loads(raw) except Exception: body = {} - tools = [t.get("name") for t in body.get("tools", []) or []] + raw_tools = body.get("tools", []) or [] + tools = [t.get("name") for t in raw_tools] + # A hosted tool carries a `type` and no `input_schema`; that is the + # nested WebSearch call, not a turn start. + hosted = bool(raw_tools) and all( + t.get("input_schema") is None and t.get("type") not in (None, "custom") + for t in raw_tools + ) msgs = body.get("messages", []) or [] tail = json.dumps(msgs[-1])[:300] if msgs else "" # Only the immediate reply to *our* canned tool call ends the turn with @@ -192,8 +269,11 @@ class Handler(BaseHTTPRequestHandler): return scenario = read_scenario() + # The nested hosted-tool request answers itself, whatever the scenario. + if hosted: + gen = stream_server_websearch() # A request with no tools is CC's side/title call — answer with text. - if not tools or has_result: + elif not tools or has_result: gen = stream_text("Done. Ask me anything else.") elif scenario == "ask": gen = stream_tool("AskUserQuestion", ASK_INPUT, "Let me check how you want this framed.") @@ -215,6 +295,11 @@ class Handler(BaseHTTPRequestHandler): gen = stream_tools( [("Agent", a) for a in AGENT_INPUTS], "Delegating this." ) + elif scenario == "websearch": + gen = stream_tool("WebSearch", {"query": "ratatui scrollbar thumb"}, + "Let me search for that.") + elif scenario == "ansi": + gen = stream_tool("Bash", ANSI_INPUT, "Printing coloured output.") elif scenario == "taskupdate": gen = stream_tool("TaskUpdate", {"taskId": "1", "status": "in_progress"}, "Starting the first task.") @@ -241,5 +326,5 @@ class Handler(BaseHTTPRequestHandler): if __name__ == "__main__": port = int(sys.argv[1]) if len(sys.argv) > 1 else 9911 - print(f"fake upstream on 127.0.0.1:{port} scenario={os.environ.get('CT_FAKE_SCENARIO', 'ask')}") + print(f"fake upstream on 127.0.0.1:{port} scenario={read_scenario()}") ThreadingHTTPServer(("127.0.0.1", port), Handler).serve_forever() diff --git a/src/ansi.rs b/src/ansi.rs new file mode 100644 index 0000000..48bf2af --- /dev/null +++ b/src/ansi.rs @@ -0,0 +1,478 @@ +//! ANSI escape-sequence handling for feed text. +//! +//! Plenty of what reaches the feed is real terminal output rather than plain +//! prose: colourised `cargo`/rustfmt results (`\x1b[31m- app.lo…`) and +//! Claude Code's own slash-command stdout, which arrives inside the user turn +//! verbatim (`/model` prints `Set model to \x1b[1mSonnet 5\x1b[22m …`). +//! The feed used to drop the ESC byte as "just another control char" and leave +//! `[1m` behind as literal text. Here the sequences are parsed instead: SGR +//! becomes ratatui styling, every other escape is stripped. +//! +//! Self-contained on purpose — no new dependency — and deliberately total: an +//! unknown parameter, a truncated colour spec or a sequence whose final byte +//! never arrives degrades to "drop what we understood, keep the rest as text". +//! Never a panic, and never a swallowed line: this runs on the render path of +//! output we do not control. + +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::Span; + +const ESC: u8 = 0x1b; +const BEL: u8 = 0x07; + +/// SGR state in force at a point in the text. Colours are optional so that +/// "default foreground" (SGR 39) means *the caller's* base style rather than a +/// hardcoded white — the feed's dim/red/accent bases must keep governing +/// everything the output does not colour itself. +#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)] +struct Sgr { + fg: Option, + bg: Option, + mods: Modifier, +} + +impl Sgr { + /// This state layered on top of `base`. + fn style(self, base: Style) -> Style { + let mut s = base; + if let Some(c) = self.fg { + s = s.fg(c); + } + if let Some(c) = self.bg { + s = s.bg(c); + } + s.add_modifier(self.mods) + } +} + +/// SGR 30-37 / 40-47. +const BASIC: [Color; 8] = [ + Color::Black, + Color::Red, + Color::Green, + Color::Yellow, + Color::Blue, + Color::Magenta, + Color::Cyan, + Color::Gray, +]; + +/// SGR 90-97 / 100-107. +const BRIGHT: [Color; 8] = [ + Color::DarkGray, + Color::LightRed, + Color::LightGreen, + Color::LightYellow, + Color::LightBlue, + Color::LightMagenta, + Color::LightCyan, + Color::White, +]; + +/// Escape-free plain text for one visual row: sequences stripped, tabs expanded +/// to four spaces, every remaining control char (newlines included) dropped. +pub fn strip(text: &str) -> String { + join(runs(text, false)) +} + +/// Like [`strip`] but keeps `\n`, for block content whose newlines carry +/// structure (markdown) and is split into rows further downstream. +pub fn strip_multiline(text: &str) -> String { + join(runs(text, true)) +} + +/// One visual row rendered as styled spans: `base`, with each run's SGR applied +/// on top. Runs are already coalesced, so a line with no escapes yields exactly +/// one span (and an empty line yields none). +pub fn spans(text: &str, base: Style) -> Vec> { + runs(text, false) + .into_iter() + .map(|(s, sgr)| Span::styled(s, sgr.style(base))) + .collect() +} + +/// Escape-free text plus the attribute set in force at each *character* of it. +/// +/// For the one caller that has to lay text out itself before it can style it: +/// the filled user-prompt blocks wrap and pad to an exact width, so they need +/// the attributes back *after* wrapping. Colours are deliberately not returned +/// — a filled block owns its own fg/bg (`ui::color_on` picks a foreground that +/// stays legible on the block's background under any terminal theme), and an +/// arbitrary ANSI colour from slash-command stdout would wreck that contrast. +pub fn plain_with_mods(text: &str) -> (String, Vec) { + let mut plain = String::new(); + let mut mods = Vec::new(); + for (run, sgr) in runs(text, false) { + mods.extend(std::iter::repeat_n(sgr.mods, run.chars().count())); + plain.push_str(&run); + } + (plain, mods) +} + +/// Concatenate runs back into plain text, reusing the first run's buffer. Text +/// with no escapes in it — the overwhelming majority, and this sits on the feed +/// render path — is exactly one run, so it costs no copy at all. +fn join(runs: Vec<(String, Sgr)>) -> String { + let mut it = runs.into_iter().map(|(s, _)| s); + let Some(mut first) = it.next() else { + return String::new(); + }; + for s in it { + first.push_str(&s); + } + first +} + +/// Split `text` into escape-free runs, each paired with the SGR state that +/// applies across it. Text is sanitized as it is collected (tabs expanded, +/// other control chars dropped; `\n` survives only when `keep_newlines`) — +/// a literal tab reaching ratatui becomes a `\t` cell symbol that the terminal +/// renders by jumping to the next tab stop, scattering the row. +fn runs(text: &str, keep_newlines: bool) -> Vec<(String, Sgr)> { + let b = text.as_bytes(); + let mut out: Vec<(String, Sgr)> = Vec::new(); + let mut buf = String::new(); + let mut state = Sgr::default(); + let mut i = 0; + while i < b.len() { + if b[i] == ESC { + let (next, sgr) = escape_at(text, i); + if let Some(params) = sgr { + let mut new = state; + apply_sgr(&mut new, params); + if new != state { + if !buf.is_empty() { + out.push((std::mem::take(&mut buf), state)); + } + state = new; + } + } + i = next; + continue; + } + // ESC is ASCII, so it can never sit inside a multi-byte sequence: + // `i` is always on a char boundary here. + let Some(c) = text[i..].chars().next() else { break }; + i += c.len_utf8(); + match c { + '\t' => buf.push_str(" "), + '\n' if keep_newlines => buf.push('\n'), + c if c.is_control() => {} + c => buf.push(c), + } + } + if !buf.is_empty() { + out.push((buf, state)); + } + out +} + +/// Consume the escape sequence starting at `i` (where `text` holds an ESC). +/// Returns the byte index just past it and, for a CSI ending in `m`, that +/// sequence's parameter string. +/// +/// Everything else is stripped with no styling: other CSI finals (cursor moves, +/// erases), OSC/DCS/SOS/PM/APC strings, and two-char escapes. Damage from +/// malformed input is bounded — a CSI whose final byte never arrives consumes +/// only the parameter bytes it saw, and a string sequence missing its +/// terminator stops at a newline, so at most one line is lost rather than the +/// whole remaining text. +fn escape_at(text: &str, i: usize) -> (usize, Option<&str>) { + let b = text.as_bytes(); + match b.get(i + 1) { + // Lone ESC at the very end of the text. + None => (i + 1, None), + Some(b'[') => { + // CSI: parameter bytes 0x30-0x3f, intermediates 0x20-0x2f, final + // byte 0x40-0x7e. + let mut j = i + 2; + while j < b.len() && (0x30..=0x3f).contains(&b[j]) { + j += 1; + } + let params = &text[i + 2..j]; + while j < b.len() && (0x20..=0x2f).contains(&b[j]) { + j += 1; + } + match b.get(j) { + Some(&f) if (0x40..=0x7e).contains(&f) => (j + 1, (f == b'm').then_some(params)), + // No valid final byte (end of text, or a UTF-8 lead byte): + // the sequence never terminated. Stop here so the rest of the + // line still reaches the reader. + _ => (j, None), + } + } + // OSC / DCS / SOS / PM / APC: a string run terminated by BEL or ST. + Some(&b']' | b'P' | b'X' | b'^' | b'_') => { + let mut j = i + 2; + while j < b.len() { + match b[j] { + BEL => return (j + 1, None), + ESC if b.get(j + 1) == Some(&b'\\') => return (j + 2, None), + // No terminator on this line: give up rather than eat the + // rest of the text (these bytes are all ASCII, so `j` is + // on a char boundary). + b'\n' => return (j, None), + _ => j += 1, + } + } + (b.len(), None) + } + // Two-char escape (`ESC c`); in malformed input `c` may be multi-byte. + Some(_) => { + let n = text[i + 1..].chars().next().map_or(1, char::len_utf8); + (i + 1 + n, None) + } + } +} + +/// Fold one SGR sequence's parameters into `state`. Unknown parameters are +/// skipped individually so the ones around them still take effect. +fn apply_sgr(state: &mut Sgr, params: &str) { + // An omitted parameter means 0 (ECMA-48), so a bare `ESC [ m` is a reset. + // An unparseable one stays `None` and is skipped without stopping the run. + let vals: Vec> = params + .split(';') + .map(|p| if p.is_empty() { Some(0) } else { p.parse().ok() }) + .collect(); + let mut i = 0; + while i < vals.len() { + let Some(v) = vals[i] else { + i += 1; + continue; + }; + let mut step = 1; + match v { + 0 => *state = Sgr::default(), + 1 => state.mods.insert(Modifier::BOLD), + 2 => state.mods.insert(Modifier::DIM), + 3 => state.mods.insert(Modifier::ITALIC), + 4 => state.mods.insert(Modifier::UNDERLINED), + 7 => state.mods.insert(Modifier::REVERSED), + 21 => state.mods.remove(Modifier::BOLD), + // 22 turns off bold *and* dim (they share an "intensity" axis). + 22 => state.mods.remove(Modifier::BOLD | Modifier::DIM), + 23 => state.mods.remove(Modifier::ITALIC), + 24 => state.mods.remove(Modifier::UNDERLINED), + 27 => state.mods.remove(Modifier::REVERSED), + 30..=37 => state.fg = Some(BASIC[usize::from(v - 30)]), + 38 => { + let (c, n) = extended(&vals, i + 1); + if c.is_some() { + state.fg = c; + } + step = n + 1; + } + 39 => state.fg = None, + 40..=47 => state.bg = Some(BASIC[usize::from(v - 40)]), + 48 => { + let (c, n) = extended(&vals, i + 1); + if c.is_some() { + state.bg = c; + } + step = n + 1; + } + 49 => state.bg = None, + 90..=97 => state.fg = Some(BRIGHT[usize::from(v - 90)]), + 100..=107 => state.bg = Some(BRIGHT[usize::from(v - 100)]), + _ => {} + } + i += step; + } +} + +/// Decode the sub-parameters of a `38`/`48` extended colour, starting at the +/// `5` (indexed) or `2` (truecolor) selector. Returns the colour and how many +/// parameters the whole spec occupies — a truncated or out-of-range spec yields +/// no colour but still reports its width, so the parameters after it survive. +fn extended(vals: &[Option], i: usize) -> (Option, usize) { + let at = |k: usize| { + vals.get(i + k) + .copied() + .flatten() + .and_then(|n| u8::try_from(n).ok()) + }; + match vals.get(i).copied().flatten() { + Some(5) => (at(1).map(Color::Indexed), 2), + Some(2) => match (at(1), at(2), at(3)) { + (Some(r), Some(g), Some(b)) => (Some(Color::Rgb(r, g, b)), 4), + _ => (None, 4), + }, + _ => (None, 1), + } +} + +#[cfg(test)] +mod tests { + use super::{plain_with_mods, spans, strip, strip_multiline}; + use ratatui::style::{Color, Modifier, Style}; + + /// Flatten spans to `(text, fg, modifiers)` for terse assertions. + fn parts(text: &str) -> Vec<(String, Option, Modifier)> { + spans(text, Style::default()) + .into_iter() + .map(|s| (s.content.into_owned(), s.style.fg, s.style.add_modifier)) + .collect() + } + + /// The motivating case: a `/model` slash-command result. The markers must + /// be gone from the text and `Sonnet 5` must come out actually bold. + #[test] + fn model_command_output_renders_bold_with_markers_gone() { + let raw = "Set model to \u{1b}[1mSonnet 5\u{1b}[22m and saved as your default\u{1b}[2m\u{1b}[22m"; + assert_eq!( + strip(raw), + "Set model to Sonnet 5 and saved as your default" + ); + + let p = parts(raw); + assert_eq!(p.len(), 3, "plain / bold / plain: {p:?}"); + assert_eq!(p[0].0, "Set model to "); + assert!(!p[0].2.contains(Modifier::BOLD)); + assert_eq!(p[1].0, "Sonnet 5"); + assert!(p[1].2.contains(Modifier::BOLD), "bold between 1m and 22m"); + assert_eq!(p[2].0, " and saved as your default"); + // `2m` then `22m` cancel out: the tail is unstyled, not left dim. + assert!(!p[2].2.intersects(Modifier::BOLD | Modifier::DIM)); + // No stray `[1m` / `[22m` anywhere in the rendered text. + for (t, _, _) in &p { + assert!(!t.contains('['), "escape leaked as literal text: {t:?}"); + } + } + + /// Colourised diff output (`cargo`, rustfmt) — the other everyday source. + #[test] + fn basic_and_bright_colours_map_to_ratatui() { + let p = parts("\u{1b}[31m- removed"); + assert_eq!(p.len(), 1); + assert_eq!(p[0].0, "- removed"); + assert_eq!(p[0].1, Some(Color::Red)); + + // Bright foreground, background, and the 39/49 defaults. + assert_eq!(parts("\u{1b}[92mok")[0].1, Some(Color::LightGreen)); + assert_eq!( + spans("\u{1b}[41mhot", Style::default())[0].style.bg, + Some(Color::Red) + ); + // 39 returns to "whatever the caller's base says", i.e. unset. + assert_eq!(parts("\u{1b}[31ma\u{1b}[39mb")[1].1, None); + } + + /// The base style shows through wherever the output sets nothing itself, + /// and only the properties SGR names are overridden. + #[test] + fn base_style_survives_underneath() { + let base = Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC); + let out = spans("plain \u{1b}[31mred", base); + assert_eq!(out[0].style.fg, Some(Color::DarkGray)); + assert_eq!(out[1].style.fg, Some(Color::Red)); + // The base's italic rides along on both runs. + assert!(out[0].style.add_modifier.contains(Modifier::ITALIC)); + assert!(out[1].style.add_modifier.contains(Modifier::ITALIC)); + } + + #[test] + fn extended_colours_indexed_and_truecolor() { + let p = parts("\u{1b}[38;5;208mX"); + assert_eq!(p[0].0, "X"); + assert_eq!(p[0].1, Some(Color::Indexed(208))); + + let p = parts("\u{1b}[38;2;10;20;30mX"); + assert_eq!(p[0].1, Some(Color::Rgb(10, 20, 30))); + + // Background forms of both. + let bg = |t: &str| spans(t, Style::default())[0].style.bg; + assert_eq!(bg("\u{1b}[48;5;17mX"), Some(Color::Indexed(17))); + assert_eq!(bg("\u{1b}[48;2;1;2;3mX"), Some(Color::Rgb(1, 2, 3))); + + // A 256-colour spec inside a longer run: the parameters after the + // extended colour still apply. + let p = parts("\u{1b}[1;38;5;208;4mX"); + assert_eq!(p[0].1, Some(Color::Indexed(208))); + assert!(p[0].2.contains(Modifier::BOLD | Modifier::UNDERLINED)); + } + + /// OSC (window title, OSC 8 hyperlinks, OSC 52 clipboard) carries no + /// styling: strip the whole sequence, keep the text around it. + #[test] + fn osc_sequences_are_stripped_entirely() { + // BEL-terminated. + assert_eq!(strip("a\u{1b}]0;my title\u{7}b"), "ab"); + // ST-terminated (`ESC \`). + assert_eq!(strip("a\u{1b}]52;c;Zm9v\u{1b}\\b"), "ab"); + // OSC 8 hyperlink wrapper around visible text. + assert_eq!( + strip("\u{1b}]8;;https://x/\u{7}link\u{1b}]8;;\u{7}"), + "link" + ); + // Non-SGR CSI (cursor move, erase) and a two-char escape. + assert_eq!(strip("a\u{1b}[2Kb\u{1b}[10;5Hc\u{1b}=d"), "abcd"); + } + + /// Malformed input must not panic and must not eat the visible text. + #[test] + fn malformed_escapes_keep_the_rest_of_the_line() { + // Unterminated CSI at end of text. + assert_eq!(strip("keep \u{1b}[1"), "keep "); + assert_eq!(strip("keep \u{1b}["), "keep "); + assert_eq!(strip("keep \u{1b}"), "keep "); + // Parameters with no final byte, followed by real (multi-byte) text. + assert_eq!(strip("a\u{1b}[1;2é"), "aé"); + // Truncated extended colours: no colour, but the text survives. + assert_eq!(parts("\u{1b}[38;5mZ")[0].0, "Z"); + assert_eq!(parts("\u{1b}[38;5mZ")[0].1, None); + assert_eq!(parts("\u{1b}[38;2;10;20mZ")[0].1, None); + assert_eq!(parts("\u{1b}[38;9;7mZ")[0].0, "Z"); + // Out-of-range and unknown parameters are skipped one at a time. + assert_eq!(parts("\u{1b}[999;1mZ")[0].0, "Z"); + assert!(parts("\u{1b}[999;1mZ")[0].2.contains(Modifier::BOLD)); + assert_eq!(parts("\u{1b}[38;5;300mZ")[0].1, None); + // An unterminated OSC gives up at the newline instead of swallowing on. + assert_eq!(strip_multiline("\u{1b}]0;no end\nnext line"), "\nnext line"); + } + + /// The sanitizing half of the old `sanitize`/`sanitize_md` pair is intact. + #[test] + fn tabs_expand_and_newlines_follow_the_mode() { + assert_eq!(strip("a\tb"), "a b"); + assert_eq!(strip_multiline("a\tb\nc"), "a b\nc"); + // Newlines are a control char for the single-row form, kept for blocks. + assert_eq!(strip("x\r\ny"), "xy"); + assert_eq!(strip_multiline("x\r\ny"), "x\ny"); + // Styling survives across a kept newline. + assert_eq!(strip_multiline("\u{1b}[1ma\nb"), "a\nb"); + } + + /// One modifier per character of the plain text, colours dropped — what + /// the filled user-prompt blocks need to restyle after wrapping. + #[test] + fn plain_with_mods_is_char_aligned_and_colourless() { + let (plain, mods) = plain_with_mods("ab\u{1b}[1;31mCD\u{1b}[22mef"); + assert_eq!(plain, "abCDef"); + assert_eq!(mods.len(), plain.chars().count()); + assert!(!mods[1].contains(Modifier::BOLD)); + assert!(mods[2].contains(Modifier::BOLD)); + assert!(mods[3].contains(Modifier::BOLD)); + assert!(!mods[4].contains(Modifier::BOLD)); + + // Multi-byte text stays aligned by *character*, not by byte. + let (plain, mods) = plain_with_mods("é\u{1b}[3mü"); + assert_eq!(plain, "éü"); + assert_eq!(mods.len(), 2); + assert!(mods[1].contains(Modifier::ITALIC)); + + // A tab expands to four characters, all carrying its modifier. + let (plain, mods) = plain_with_mods("\u{1b}[4m\tx"); + assert_eq!(plain, " x"); + assert_eq!(mods.len(), 5); + assert!(mods.iter().all(|m| m.contains(Modifier::UNDERLINED))); + } + + #[test] + fn empty_and_escape_only_input_is_harmless() { + assert_eq!(strip(""), ""); + assert_eq!(strip("\u{1b}[0m"), ""); + assert!(spans("", Style::default()).is_empty()); + assert!(spans("\u{1b}[1m\u{1b}[0m", Style::default()).is_empty()); + assert_eq!(plain_with_mods("").0, ""); + } +} diff --git a/src/app.rs b/src/app.rs index f0bce86..9191e80 100644 --- a/src/app.rs +++ b/src/app.rs @@ -767,7 +767,9 @@ pub fn filter_index(kind: &Kind) -> usize { Kind::Text => 2, // Tool calls and the available-tool list share the "tools" toggle. Kind::Tool { .. } | Kind::ToolDefs => 3, - Kind::Meta => 4, + // Task notifications are session bookkeeping, like the meta dividers — + // they reuse that bucket rather than widening `FILTER_LABELS`. + Kind::Meta | Kind::TaskNote => 4, Kind::Error => 5, // Reminders and the system-prompt-size line share the "system" toggle. Kind::Reminder | Kind::System => 6, @@ -780,9 +782,92 @@ pub type LaneId = u16; /// The main chain's lane. Claude Code's own turns always land here. pub const MAIN_LANE: LaneId = 0; +/// What a streaming `/v1/messages` request *is*, decided from its `tools` +/// array alone (the one place the three shapes differ). +/// +/// Claude Code's `WebSearch` is not purely client-side: it issues a **nested** +/// `POST /v1/messages` that declares Anthropic's *server-side* `web_search` +/// tool, under the parent's own `session_id` and with no agent-id header. That +/// request used to look exactly like a turn start (a non-empty `tools` array), +/// which pushed its `Perform a web search for the query: …` message into the +/// main feed as a real prompt, clobbered the lane's system/tools signatures, +/// and — worst — ran `record_long_context` with a header set that never carries +/// the `context-1m` beta, silently downgrading a session that really ran +/// `opus[1m]` to the short window on the next resume. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum ReqKind { + /// A real turn: at least one *client* tool definition is on offer. + Turn, + /// A nested server-tool call: every entry of `tools` is a hosted tool. + ServerTool, + /// No tools at all — Claude Code's topic/title haiku calls. + Side, +} + +/// Is this `tools` entry a **hosted (server-side) tool** rather than a client +/// tool definition? +/// +/// The two shapes on the wire are unambiguous and version-independent: +/// +/// - client tool: `{"name": "Read", "description": …, "input_schema": {…}}` +/// — no `type`, always an `input_schema` (the API's explicit +/// `{"type": "custom", …}` spelling carries one too); +/// - server tool: `{"type": "web_search_20250305", "name": "web_search", +/// "max_uses": 6}` — a `type` naming the hosted tool, and no schema (the +/// server owns it). +/// +/// So the predicate is "has `type`, has no `input_schema`" — deliberately not +/// an allowlist of `web_search_*` / `code_execution_*` versions, so a future +/// `web_search_20260101` (or a hosted tool that does not exist yet) still +/// classifies correctly. +fn is_server_tool(t: &Value) -> bool { + t.get("input_schema").is_none() + && t.get("type") + .and_then(Value::as_str) + .is_some_and(|ty| ty != "custom") +} + +/// Classify a request body — see `ReqKind`. An **empty** `tools` array is a +/// side request, not a server-tool one. +pub fn classify_request(body: &Value) -> ReqKind { + let Some(tools) = body + .get("tools") + .and_then(Value::as_array) + .filter(|t| !t.is_empty()) + else { + return ReqKind::Side; + }; + if tools.iter().all(is_server_tool) { + ReqKind::ServerTool + } else { + ReqKind::Turn + } +} + +/// Prefix of the synthetic agent id minted for a server-tool request's lane. +/// +/// A nested server-tool call carries no `x-claude-code-agent-id`, so it needs a +/// key of our own to get a lane. It **cannot collide with a real agent id**: +/// Claude Code's agent ids are bare lowercase hex (that is exactly what +/// `scrape_agent_id` accepts, and what `` carries), and `s`/`r`/`v`/ +/// `t`/`o`/`l`/`-` are not hex digits — so no `finish_lane`, +/// `close_lane_from_result` or `` lookup can ever land on +/// one of these lanes by accident. +pub const SERVER_TOOL_PREFIX: &str = "srvtool-"; + +/// Mint the next synthetic lane key. One nested server-tool call is one +/// request and one turn, so a *fresh* lane per request is the right grain; the +/// counter is process-global and monotonic, so an id is never reused and a +/// `LaneId` stays valid forever (lanes remain append-only). +pub fn next_server_tool_id() -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + static SEQ: AtomicU64 = AtomicU64::new(0); + format!("{SERVER_TOOL_PREFIX}{}", SEQ.fetch_add(1, Ordering::Relaxed)) +} + /// Footer message while the agent picker is up. fn picker_status(n: usize) -> String { - format!("{n} agents — enter to open, esc to close") + format!("{n} streams — enter to open, esc to close") } /// State of the subagent view (`A`). It is a *modal popup over the feed*, not a @@ -856,11 +941,25 @@ pub struct Lane { pub output_tokens: u64, /// Tool calls this lane made (shown in the popup's agent picker). pub tool_calls: usize, + /// Claude Code's own accounting for the whole agent run, from the + /// `` block of a `` (`Session:: + /// apply_task_notifications`). Authoritative where present — it covers the + /// turns we never saw, which is the only token figure a lane spliced in + /// from disk can ever have. Display data only: `Lane::running` never reads + /// it. + pub subagent_tokens: Option, + pub tool_uses: Option, + pub duration_ms: Option, /// Char length of the system prompt last surfaced as a `Kind::System` /// entry. Per *lane*: a subagent's system prompt and tool set differ from /// the main agent's, so session-wide state re-emitted both lines on every /// alternation between them. pub last_system_len: Option, + /// Char length of the `role: "system"` messages Claude Code appends *into + /// the conversation* (beta `mid-conversation-system-…`), last surfaced as a + /// `Kind::System` entry. Same once-then-on-change policy as + /// `last_system_len`, and per lane for the same reason. + pub last_mid_system_len: Option, /// Signature (joined tool names) of the tool set last surfaced as a /// `Kind::ToolDefs` entry; re-emitted only when the tool set changes. pub last_tools_sig: Option, @@ -895,7 +994,7 @@ impl Lane { self.finished_at.is_none() && last.elapsed() < LANE_IDLE_MAX } - fn new(agent_id: String, model: String, parent: Option, depth: u8) -> Self { + pub(crate) fn new(agent_id: String, model: String, parent: Option, depth: u8) -> Self { Self { agent_id, agent_type: String::new(), @@ -911,7 +1010,11 @@ impl Lane { input_tokens: 0, output_tokens: 0, tool_calls: 0, + subagent_tokens: None, + tool_uses: None, + duration_ms: None, last_system_len: None, + last_mid_system_len: None, last_tools_sig: None, finished_at: None, } @@ -922,6 +1025,13 @@ impl Lane { self.finished_at.is_some() } + /// Is this lane a nested **server-tool** call rather than a subagent? + /// Keyed off the synthetic id, which no real agent id can look like (see + /// `SERVER_TOOL_PREFIX`). + pub fn is_server_tool(&self) -> bool { + self.agent_id.starts_with(SERVER_TOOL_PREFIX) + } + /// Display title: `Explore · find the retry helper`, falling back to the /// agent id while the spawning tool call has not been matched yet. pub fn title(&self) -> String { @@ -1091,7 +1201,13 @@ impl Session { /// Needed because a *synchronous* agent's tool_result — the other source of /// this link — only arrives when the agent has already finished. fn label_lane_from_prompt(&mut self, lane: LaneId, text: &str) { - if lane == MAIN_LANE || !self.lanes[lane as usize].label.is_empty() { + // A server-tool lane names itself from its own `tools` entry + // (`label_server_tool_lane`); it was never spawned by an `Agent` call, + // so it must not claim one. + if lane == MAIN_LANE + || self.lanes[lane as usize].is_server_tool() + || !self.lanes[lane as usize].label.is_empty() + { return; } let claimed: Vec = self.lanes.iter().filter_map(|l| l.anchor).collect(); @@ -1142,21 +1258,121 @@ impl Session { } } - /// Claude Code launches every `Agent` call asynchronously and reports the - /// completion by injecting a `` into the parent's next - /// user turn, where `` *is* the agent id. That notification is the - /// only "this agent is done" signal for an async agent — its tool_result - /// arrived back at launch time. - pub(crate) fn finish_lanes_from_notifications(&mut self, text: &str) { - for part in text.split("").skip(1) { - if let Some(id) = part.split("").next() { - // Background *bash* tasks use the same notification shape with - // a short id, which simply matches no lane. - self.finish_lane(id.trim()); + /// Consume the ``s a user turn carried (already lifted + /// out of the prompt text by `split_task_notifications`, so nothing here + /// re-scans history). Four things happen per notification, in this order: + /// + /// 1. **the lane is finished** — Claude Code launches every `Agent` call + /// asynchronously and reports completion by injecting the notification + /// into the parent's next user turn, where `` *is* the agent + /// id. That is the only "this agent is done" signal for an async agent + /// (its tool_result arrived back at launch time). A *monitor event* is + /// a progress ping, not a stop, so it never finishes anything; and a + /// background *bash* task uses the same shape with a short id that + /// simply matches no lane. + /// 2. **`` is recorded on the lane** (display totals, never a gate). + /// 3. **the `` report is moved onto the `Agent` tool entry** that + /// spawned the agent, replacing the `Async agent launched…` + /// acknowledgement the parent model got at launch time — see + /// `attach_task_report` for the lookup and its fallbacks. + /// 4. **one `Kind::TaskNote` line is pushed** into `lane` (the *parent's* + /// lane, i.e. wherever the notification was read). + /// + /// Nothing the model received is dropped here: the report is *relocated* + /// to the tool call it answers and the status is *reformatted*; only the + /// `` boilerplate (byte-identical on every notification) is elided. + pub(crate) fn apply_task_notifications(&mut self, lane: LaneId, notes: &[TaskNotification]) { + for n in notes { + if !n.is_monitor_event() { + self.finish_lane(&n.task_id); + } + self.record_task_usage(n); + let report = n.result.as_deref().map(str::trim).filter(|r| !r.is_empty()); + let attached = report.is_some_and(|r| self.attach_task_report(n, r)); + let line = task_note_line(n, if attached { None } else { report }); + // Resend guard, the same idea as the prompt dedup below: a retry + // re-sends the whole turn, so an identical note is still sitting in + // this lane's tail run of turn-preamble entries. A genuine second + // notification for the same task-id (the agent was woken again by + // `SendMessage`) sits behind that turn's streamed entries and so + // survives. + let dup = self + .entries + .iter() + .rev() + .filter(|e| e.lane == lane) + .take_while(|e| { + !matches!( + e.kind, + Kind::Text | Kind::Thinking | Kind::Tool { .. } | Kind::Error + ) + }) + .any(|e| e.kind == Kind::TaskNote && e.content == line); + if !dup { + let e = Entry::done(Kind::TaskNote, line); + self.push(lane, e); } } } + /// Store a notification's `` totals on the agent's own lane. Silent + /// when the id matches no lane (a background bash task, or an agent whose + /// launch we never saw). + fn record_task_usage(&mut self, n: &TaskNotification) { + if n.subagent_tokens.is_none() && n.tool_uses.is_none() && n.duration_ms.is_none() { + return; + } + let Some(&lane) = self.lane_of_agent.get(&n.task_id) else { + return; + }; + let l = &mut self.lanes[lane as usize]; + l.subagent_tokens = n.subagent_tokens.or(l.subagent_tokens); + l.tool_uses = n.tool_uses.or(l.tool_uses); + l.duration_ms = n.duration_ms.or(l.duration_ms); + } + + /// Move the agent's final report onto the `Agent` tool entry that spawned + /// it, so the feed reads as one block (call, prompt, report) instead of a + /// launch acknowledgement here and a wall of markdown several turns later. + /// + /// The lookup deliberately does **not** go through `Session::tool_ids`: + /// `attach_tool_results` `remove`s the id when the launch result lands, so + /// by notification time it is already gone. It uses state that outlives + /// that instead — + /// `task-id` → `lane_of_agent` → `Lane::anchor` (the entry index of the + /// `Agent` call, set by `close_lane_from_result`/`label_lane_from_prompt`) + /// — with `` as a second path for a lane that was labelled + /// from its prompt before any result arrived. + /// + /// Returns false when the report has nowhere to go (no lane for the id, or + /// a lane with no anchor because we attached mid-run); the caller then + /// keeps it inline in the note entry rather than losing it. + fn attach_task_report(&mut self, n: &TaskNotification, report: &str) -> bool { + let by_task = self.lane_of_agent.get(&n.task_id).copied(); + let by_tool = || { + n.tool_use_id.as_deref().and_then(|id| { + self.lanes + .iter() + .position(|l| l.tool_use_id.as_deref() == Some(id)) + .map(|i| i as LaneId) + }) + }; + let Some(lane) = by_task.or_else(by_tool) else { + return false; + }; + let Some(anchor) = self.lanes[lane as usize].anchor else { + return false; + }; + let Some(e) = self.entries.get_mut(anchor) else { + return false; + }; + e.result = Some(ToolResult { + content: report.to_string(), + is_error: n.failed(), + }); + true + } + /// The launch/completion result of an `Agent` call names its agent id, so /// the lane can be tied to the tool entry (`anchor`). It only means /// *finished* when it is not an async launch acknowledgement — which, in @@ -1204,6 +1420,276 @@ fn scrape_agent_id(s: &str) -> Option<&str> { Some(&rest[..end]).filter(|h| !h.is_empty()) } +/// One `` — the block Claude Code injects into a user turn +/// when a background task stops (every `Agent` call is launched +/// asynchronously, so this, not the tool_result, is the completion) or when a +/// monitor emits a progress event. +/// +/// Two shapes ride the same tag, told apart by `is_monitor_event`: +/// +/// ```text +/// +/// a0605e66cccc5eae5 agent completion +/// toolu_012FGG… +/// /tmp/…/a0605e66cccc5eae5.output +/// completed +/// Agent "Phase 0 instrumentation" finished +/// +/// … the agent's whole final report, markdown … +/// 128633 +/// 631115197 +/// +/// +/// +/// b8s2gso3a monitor event: short id that +/// Monitor event: "…" matches no lane, no status, +/// BENCH progress phase=traverse … no tool-use-id, no result +/// +/// ``` +/// +/// `` is deliberately **not** a field: it is byte-identical boilerplate +/// on every notification ("A task-notification fires each time this agent +/// stops…"), so keeping it would put two dead lines in the feed 200-odd times +/// a session. Everything else the block carries is kept. +/// +/// Every field but `task_id` is optional — `` in particular is not +/// even reliably a word: an upstream failure puts its raw error body there +/// (`Error: 403: {"message":"Access to model denied.…}`), which +/// `status_word` rejects and `glyph` reads as a failure. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct TaskNotification { + /// The agent id — the same id `Session::lane_of_agent` is keyed by, and + /// the same one `scrape_agent_id` pulls from the launch result. A + /// background *bash* task uses a short id that matches no lane. + pub task_id: String, + /// The parent's `Agent` tool_use id. A second path to the lane, for one + /// labelled from its prompt before any tool_result arrived. + pub tool_use_id: Option, + /// Temp file the task's output was spooled to. Shown only when there is no + /// report to show instead — otherwise it is a long path of pure noise. + pub output_file: Option, + pub status: Option, + pub summary: Option, + /// The agent's final report (markdown). Relocated onto the `Agent` tool + /// entry by `Session::attach_task_report` rather than shown here. + pub result: Option, + /// A monitor's payload line, in place of status/result. + pub event: Option, + pub subagent_tokens: Option, + pub tool_uses: Option, + pub duration_ms: Option, +} + +impl TaskNotification { + /// A monitor progress ping rather than a task stopping: no ``, a + /// payload in ``. It must never finish a lane. + pub fn is_monitor_event(&self) -> bool { + self.status.is_none() && self.event.is_some() + } + + /// `` as a lowercase word, or `None` when it holds something that + /// is not one (an upstream error body — 4 of 208 real notifications). + fn status_word(&self) -> Option { + let s = self.status.as_deref()?.trim(); + (!s.is_empty() + && s.len() <= 24 + && s.chars().all(|c| c.is_ascii_alphabetic() || c == '_')) + .then(|| s.to_ascii_lowercase()) + } + + /// Did the task end badly? Drives the `✖` glyph and the `is_error` flag on + /// the report attached to the `Agent` entry. A `` that is not a + /// word is an error body, so it counts as one. + pub fn failed(&self) -> bool { + self.glyph() == '✖' + } + + /// Leading glyph of the note line. **This is the status channel**: + /// `ui::entry_lines` derives the `Kind::TaskNote` colour from it (✔ green, + /// ✖ red, ◼ yellow, ▸ cyan, anything else dim) so that `app.rs` stays the + /// only place a note's text is built. Change one end, change the other. + pub fn glyph(&self) -> char { + match self.status.as_deref() { + None if self.event.is_some() => '▸', + // Neither a status nor an event: nothing to claim, so stay neutral. + None => '·', + Some(_) => match self.status_word().as_deref() { + Some("completed") => '✔', + Some("killed" | "stopped" | "cancelled" | "canceled") => '◼', + // "failed", and every non-word status (an error body). + _ => '✖', + }, + } + } +} + +/// Body of one `` span, trimmed. `None` when the tag is absent +/// or unterminated. These blocks are machine-generated and well-formed, so +/// plain tag scraping is enough — and it cannot be confused by a `` +/// whose markdown contains angle brackets, since only the exact closing tag +/// ends the span. +fn xml_tag<'a>(s: &'a str, name: &str) -> Option<&'a str> { + let start = s.find(&format!("<{name}>"))? + name.len() + 2; + let rel = s[start..].find(&format!(""))?; + Some(s[start..start + rel].trim()) +} + +/// Parse the *inside* of one `` block. `None` for a block +/// with no `` (malformed — the caller then leaves it in the prompt +/// text verbatim rather than swallowing it). +fn parse_task_notification(inner: &str) -> Option { + let task_id = xml_tag(inner, "task-id").filter(|s| !s.is_empty())?; + let opt = |k: &str| { + xml_tag(inner, k) + .filter(|s| !s.is_empty()) + .map(str::to_string) + }; + let num = |k: &str| xml_tag(inner, k).and_then(|v| v.parse::().ok()); + Some(TaskNotification { + task_id: task_id.to_string(), + tool_use_id: opt("tool-use-id"), + output_file: opt("output-file"), + status: opt("status"), + summary: opt("summary"), + result: opt("result"), + event: opt("event"), + subagent_tokens: num("subagent_tokens"), + tool_uses: num("tool_uses"), + duration_ms: num("duration_ms"), + }) +} + +/// Split every `` out of a user text block, returning the +/// parsed notifications and whatever real prompt text was left around them. +/// +/// A notification can share a turn with something the user actually typed +/// (`"\nWhat did they find?"`), and +/// can also arrive inside a `` — both are handled by running +/// this over each piece. A block that fails to parse is left in the text: this +/// lifts content out of the prompt, it never drops it. +pub(crate) fn split_task_notifications(text: &str) -> (Vec, String) { + const OPEN: &str = ""; + const CLOSE: &str = ""; + const USAGE_CLOSE: &str = ""; + let mut notes = Vec::new(); + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(start) = rest.find(OPEN) { + out.push_str(&rest[..start]); + let after = start + OPEN.len(); + // An unterminated block means the rest of the text is the notification. + let (inner_end, mut end) = match rest[after..].find(CLOSE) { + Some(rel) => (after + rel, after + rel + CLOSE.len()), + None => (rest.len(), rest.len()), + }; + let mut inner = rest[after..inner_end].to_string(); + // `` is sometimes emitted just *after* the closing tag rather + // than inside the block; swallow it there too, so it neither gets left + // behind as stray prompt text nor loses the run's totals. + let tail = rest[end..].trim_start(); + if tail.starts_with("") + && let Some(rel) = tail.find(USAGE_CLOSE) + { + let skipped = rest[end..].len() - tail.len(); + let u_end = end + skipped + rel + USAGE_CLOSE.len(); + inner.push_str(&rest[end + skipped..u_end]); + end = u_end; + } + match parse_task_notification(&inner) { + Some(n) => notes.push(n), + None => out.push_str(&rest[start..end]), + } + rest = &rest[end..]; + } + out.push_str(rest); + (notes, out.trim().to_string()) +} + +/// The single line a `` becomes in the feed: +/// +/// ```text +/// ✔ Explore finished · 129k tok · 63 tools · 18m35s +/// ▸ Monitor event: world skin bench phase transitions +/// BENCH progress phase=traverse ms=141974 studs=1206 skins=123 +/// ``` +/// +/// `inline` is the agent's report, passed only when it could *not* be attached +/// to its `Agent` tool entry (unknown lane, or a lane with no anchor); it then +/// rides along on indented continuation rows instead of being lost. +/// +/// The leading glyph is load-bearing — `ui::entry_lines` colours the entry from +/// it (see `TaskNotification::glyph`). All of the *text* is built here so the +/// render side stays a pure styling pass. +pub(crate) fn task_note_line(n: &TaskNotification, inline: Option<&str>) -> String { + let head = n + .summary + .as_deref() + .map(plain_summary) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| format!("task {}", n.task_id)); + let mut line = format!("{} {head}", n.glyph()); + match n.status_word() { + // The glyph already says "completed"; anything else is worth a word, + // unless the summary happens to say it too ("… was killed"). + Some(w) if w != "completed" && !head.to_ascii_lowercase().contains(&w) => { + line.push_str(&format!(" · {w}")); + } + // Not a word: an upstream error body landed in ``. Keep it, + // collapsed to one line, since it is the only account of the failure. + None if n.status.is_some() => { + line.push_str(&format!(" · {}", clip_line(n.status.as_deref().unwrap_or("")))); + } + _ => {} + } + if let Some(t) = n.subagent_tokens { + line.push_str(&format!(" · {} tok", fmt_tokens(t))); + } + if let Some(u) = n.tool_uses { + line.push_str(&format!(" · {u} tools")); + } + if let Some(d) = n.duration_ms { + line.push_str(&format!(" · {}", fmt_duration(d))); + } + for body in [n.event.as_deref(), inline] { + for l in body.unwrap_or("").lines() { + line.push_str(&format!("\n {l}")); + } + } + // Only with no report to show is the spool file worth its long temp path. + if let Some(f) = n.output_file.as_deref().filter(|_| { + inline.is_none() && n.result.is_none() && n.event.is_none() + }) { + line.push_str(&format!("\n {f}")); + } + line +} + +/// A notification `` as a headline: `Agent "Explore" finished` → +/// `Explore finished`, `Monitor event: "world skin bench"` → `Monitor event: +/// world skin bench`. Claude Code quotes the agent's own name inside a +/// sentence that already has a glyph in front of it, so both the `Agent ` +/// prefix and the quotes are redundant. +fn plain_summary(s: &str) -> String { + let t = s.trim(); + t.strip_prefix("Agent ").unwrap_or(t).replace('"', "") +} + +/// `1115197` → `18m35s`. Deliberately *not* `ui::fmt_ms`: the note's text is +/// built here (see `task_note_line`), and `app.rs` must not reach up into the +/// render layer to do it. +fn fmt_duration(ms: u64) -> String { + let s = ms / 1000; + if s >= 3600 { + format!("{}h{:02}m", s / 3600, (s % 3600) / 60) + } else if s >= 60 { + format!("{}m{:02}s", s / 60, s % 60) + } else if s > 0 { + format!("{s}s") + } else { + format!("{ms}ms") + } +} + #[derive(PartialEq)] pub enum Kind { /// A user-submitted prompt, lifted from the request body (the trailing @@ -1226,6 +1712,13 @@ pub enum Kind { /// full schemas). Emitted once per session, re-emitted only when the tool /// set changes. Filtered under the "tools" toggle. ToolDefs, + /// One ``, lifted out of the user turn it rode in on and + /// reduced to a single status line (`✔ Explore finished · 129k tok · …`) by + /// `task_note_line`. The agent's `` report is *not* here — it is + /// attached to the `Agent` tool entry that spawned the agent (only when + /// that entry can't be found does the report stay inline). Filtered under + /// the "meta" toggle; `ui::entry_lines` colours it from the leading glyph. + TaskNote, } pub struct Entry { @@ -1406,6 +1899,70 @@ pub fn record_long_context(app: &SharedApp, key: &str, long: bool) { } } +/// Display name of a hosted tool: its `name` (`web_search`) when the request +/// gives one, else its versioned `type` with the trailing `_` trimmed +/// (`web_search_20250305` → `web_search`), so the label never carries a version +/// we would have to keep up with. +fn server_tool_name(t: &Value) -> String { + if let Some(n) = t.get("name").and_then(Value::as_str).filter(|n| !n.is_empty()) { + return n.to_string(); + } + let ty = t.get("type").and_then(Value::as_str).unwrap_or("server tool"); + match ty.rsplit_once('_') { + Some((head, tail)) if !head.is_empty() && tail.chars().all(|c| c.is_ascii_digit()) => { + head.to_string() + } + _ => ty.to_string(), + } +} + +/// The query a nested server-tool request asks for. Claude Code phrases it as +/// `Perform a web search for the query: ` in the (single) user message, so +/// the text after the last `query: ` is the query. `None` when it doesn't +/// parse — the lane then falls back to the tool name alone. +fn server_tool_query(body: &Value) -> Option { + let messages = body.get("messages").and_then(Value::as_array)?; + let last = messages.last()?; + let text = match last.get("content") { + Some(Value::String(s)) => s.clone(), + Some(Value::Array(blocks)) => blocks + .iter() + .filter(|b| b.get("type").and_then(Value::as_str) == Some("text")) + .filter_map(|b| b.get("text").and_then(Value::as_str)) + .collect::>() + .join(" "), + _ => return None, + }; + let q = text.rsplit_once("query: ")?.1.trim().to_string(); + (!q.is_empty()).then_some(q) +} + +/// Name the lane a nested server-tool request streams into, so the agent popup +/// reads `web_search · "ratatui scroll thumb"`. Reuses the subagent machinery +/// wholesale: the lane is an ordinary `Lane` (`Session::lane_for` created it +/// from the synthetic id), so `App::agent_list_of` and the popup pick it up +/// with no special case. +pub fn label_server_tool_lane(app: &SharedApp, key: &str, lane: LaneId, body: &Value) { + let mut a = lock_app(app); + let Some(s) = a.sessions.iter_mut().find(|s| s.key == key) else { + return; + }; + let Some(l) = s.lanes.get_mut(lane as usize) else { + return; + }; + let names: Vec = body + .get("tools") + .and_then(Value::as_array) + .map(|ts| ts.iter().map(server_tool_name).collect()) + .unwrap_or_default(); + if !names.is_empty() { + l.agent_type = names.join("+"); + } + if let Some(q) = server_tool_query(body) { + l.label = format!("\"{q}\""); + } +} + /// Record everything new the model received this request as feed entries: /// the trailing user prompt verbatim (incl. slash-command machinery), any /// injected `` blocks (dimmed, before it), and — for a @@ -1421,11 +1978,9 @@ pub fn record_long_context(app: &SharedApp, key: &str, long: bool) { /// divider. Retries/resends are deduped against the last recorded prompt (which /// gates the reminders/divider too, so a resend doesn't double them up). pub fn record_user_prompt(app: &SharedApp, key: &str, lane: LaneId, body: &Value) { - // A turn-starting (main) request carries tools; a side request does not. - let has_tools = body - .get("tools") - .and_then(Value::as_array) - .is_some_and(|t| !t.is_empty()); + // Turn start / nested server-tool call / side request — one predicate, + // shared with the proxy so the two can't disagree about a request. + let kind = classify_request(body); let Some(messages) = body.get("messages").and_then(Value::as_array) else { return; }; @@ -1438,11 +1993,23 @@ pub fn record_user_prompt(app: &SharedApp, key: &str, lane: LaneId, body: &Value // when CC happens to append such a trailing message). A tool-loop // continuation ends in a single user message of `tool_result` blocks (no // text), so it still contributes nothing and records no spurious entry. - let trailing_user: Vec<&Value> = messages - .iter() - .rev() - .take_while(|m| m.get("role").and_then(Value::as_str) == Some("user")) - .collect(); + // + // `role: "system"` messages are *skipped*, not treated as the end of the + // run: Claude Code ≥2.1.247 (beta `mid-conversation-system-…`) appends the + // agent-type listing as a trailing `role: "system"` message, so turn 1 of + // every session reads `["user", "system"]`. A `take_while` on "user" saw + // that system message first, collected nothing and returned early — which + // dropped the whole first turn: no prompt block, no system/tools lines, no + // lane labelling, no `` scan, and nothing for + // `ui::live_title` to show. Only an assistant message ends the run. + let mut trailing_user: Vec<&Value> = Vec::new(); + for m in messages.iter().rev() { + match m.get("role").and_then(Value::as_str) { + Some("system") => continue, + Some("user") => trailing_user.push(m), + _ => break, + } + } if trailing_user.is_empty() { return; } @@ -1477,23 +2044,42 @@ pub fn record_user_prompt(app: &SharedApp, key: &str, lane: LaneId, body: &Value _ => {} } } - let text = prompts.join("\n"); + let joined = prompts.join("\n"); + // Lift every `` out of the prompt — and out of each + // reminder, since Claude Code sometimes wraps one in a + // ``. A notification is machinery, not something the user + // typed, and it is huge: status, usage and the agent's entire final report + // in one blob that otherwise renders as a full-width orange prompt + // rectangle. + // + // This is the one refinement of "show everything the model received, never + // filter it": nothing is dropped, it is *relocated*. The `` report + // moves onto the `Agent` tool entry it answers (or stays inline in the note + // when that entry can't be found), the status becomes a one-line + // `Kind::TaskNote`, and only `` — byte-identical boilerplate on all + // 208 real notifications — is elided. + let (mut notes, text) = split_task_notifications(&joined); + let reminders: Vec = reminders + .into_iter() + .filter_map(|r| { + let (n, rest) = split_task_notifications(&r); + notes.extend(n); + // A reminder that was *only* a notification leaves nothing to show. + (!rest.is_empty()).then_some(rest) + }) + .collect(); let text = text.trim(); let mut a = lock_app(app); let Some(s) = a.sessions.iter_mut().find(|s| s.key == key) else { return; }; - // An async agent's completion rides in on this turn as a - // ``. Read it before any early return below: it is the - // signal that closes that agent's row, and it appears exactly once (only - // the trailing user run is scanned, so history is never re-read). - if text.contains("") { - s.finish_lanes_from_notifications(text); - } - for r in &reminders { - if r.contains("") { - s.finish_lanes_from_notifications(r); - } + // An async agent's completion rides in on this turn. Apply it before any + // early return below: it is the signal that closes that agent's row, and + // a turn very often carries *nothing but* the notification. It appears + // exactly once (only the trailing user run is scanned, so history is never + // re-read); a genuine resend is caught inside. + if !notes.is_empty() { + s.apply_task_notifications(lane, ¬es); } if text.is_empty() { return; @@ -1508,42 +2094,67 @@ pub fn record_user_prompt(app: &SharedApp, key: &str, lane: LaneId, body: &Value // // Scoped to this lane: a subagent streaming between the main agent's // retries would otherwise sit at the tail and defeat the check (and vice - // versa). + // versa). `Kind::TaskNote` is skipped alongside `Kind::Reminder` for the + // same reason: both are turn preamble pushed just above the prompt, so + // leaving either in the way makes the walk-back land on a non-User entry + // and stop deduping resends entirely. if s.entries .iter() .rev() .filter(|e| e.lane == lane) - .find(|e| e.kind != Kind::Reminder) + .find(|e| e.kind != Kind::Reminder && e.kind != Kind::TaskNote) .is_some_and(|e| e.kind == Kind::User && e.content == text) { return; } - if has_tools { - // Surface the system-prompt size and the tool set once, then only when - // they change — they ride along on every request but are not new data. - // Both are tracked per *lane*: a subagent runs a different system - // prompt and a restricted tool set, so session-wide state made every - // main↔subagent alternation look like a change. - let sys = system_char_len(body); - if sys > 0 && s.lanes[lane as usize].last_system_len != Some(sys) { - s.lanes[lane as usize].last_system_len = Some(sys); - let e = Entry::done( - Kind::System, - format!("system prompt: {} chars", fmt_count(sys)), - ); - s.push(lane, e); + match kind { + // Surface the system prompt's size, any mid-conversation `system` + // messages and the tool set once, then only when they change — they + // ride along on every request but are not new data. All three are + // tracked per *lane*: a subagent runs a different system prompt and a + // restricted tool set, so session-wide state made every main↔subagent + // alternation look like a change. + ReqKind::Turn => { + let sys = system_char_len(body); + if sys > 0 && s.lanes[lane as usize].last_system_len != Some(sys) { + s.lanes[lane as usize].last_system_len = Some(sys); + let e = Entry::done( + Kind::System, + format!("system prompt: {} chars", fmt_count(sys)), + ); + s.push(lane, e); + } + // Real context the model received, and the only place it shows: it + // is a message, not the `system` field, and its text is far too + // long to print (the agent-type listing). + let mid = mid_system_char_len(body); + if mid > 0 && s.lanes[lane as usize].last_mid_system_len != Some(mid) { + s.lanes[lane as usize].last_mid_system_len = Some(mid); + let e = Entry::done( + Kind::System, + format!("mid-conversation system: {} chars", fmt_count(mid)), + ); + s.push(lane, e); + } + if let Some((sig, line)) = tools_summary(body) + && s.lanes[lane as usize].last_tools_sig.as_deref() != Some(sig.as_str()) + { + s.lanes[lane as usize].last_tools_sig = Some(sig); + let e = Entry::done(Kind::ToolDefs, line); + s.push(lane, e); + } } - if let Some((sig, line)) = tools_summary(body) - && s.lanes[lane as usize].last_tools_sig.as_deref() != Some(sig.as_str()) - { - s.lanes[lane as usize].last_tools_sig = Some(sig); - let e = Entry::done(Kind::ToolDefs, line); - s.push(lane, e); - } - } else { + // A nested server-tool call: its system prompt (283 chars) and its + // one-entry `tools` array describe the *hosted* call, not the + // conversation, so neither is surfaced — the lane's own title already + // says what it is, and emitting them here is what used to clobber the + // main lane's signatures and force a spurious re-emit next turn. + ReqKind::ServerTool => {} // A background side request (no tools): tag it so its prompt/response // are not mistaken for part of the main conversation. - s.push(lane, Entry::meta("── side request ──".to_string())); + ReqKind::Side => { + s.push(lane, Entry::meta("── side request ──".to_string())); + } } for r in reminders { let e = Entry::done(Kind::Reminder, r); @@ -1569,6 +2180,14 @@ pub fn flatten_result_content(v: Option<&Value>) -> String { .and_then(Value::as_str) .unwrap_or_default() .to_string(), + // A screenshot / pasted picture read back through a tool: a + // bare `[image]` said nothing about *which* image. + Some("image") => image_line(b.get("source")), + // `ToolSearch`'s hits: the tool names are the whole payload. + Some("tool_reference") => format!( + "[tool {}]", + b.get("tool_name").and_then(Value::as_str).unwrap_or("?") + ), other => format!("[{}]", other.unwrap_or("?")), }) .collect::>() @@ -1577,6 +2196,173 @@ pub fn flatten_result_content(v: Option<&Value>) -> String { } } +/// A tool result's image block as one informative placeholder: +/// `[image image/png · 412 KB]` (or the url, for a `source.type` of `url`). +/// +/// The size comes from the base64 *length* — the payload is **never decoded**: +/// a screenshot is hundreds of KB, every tool result of every turn passes +/// through here, and this runs with the app mutex held. +fn image_line(source: Option<&Value>) -> String { + let Some(src) = source else { + return "[image]".to_string(); + }; + let get = |k: &str| src.get(k).and_then(Value::as_str).unwrap_or_default(); + if get("type") == "url" { + let u = get("url"); + return if u.is_empty() { + "[image]".to_string() + } else { + format!("[image {u}]") + }; + } + let media = match get("media_type") { + "" => "image", + m => m, + }; + match get("data") { + "" => format!("[image {media}]"), + d => format!("[image {media} · {}]", fmt_bytes(b64_decoded_len(d))), + } +} + +/// Decoded byte count of a base64 payload from its length alone: every 4 +/// encoded characters carry 3 bytes, less one per `=` of padding. +fn b64_decoded_len(d: &str) -> usize { + let pad = d.bytes().rev().take_while(|&b| b == b'=').take(2).count(); + (d.len() / 4 * 3).saturating_sub(pad) +} + +/// Byte counts as something readable inside a one-line placeholder. +fn fmt_bytes(n: usize) -> String { + const KB: usize = 1024; + if n >= KB * KB { + format!("{:.1} MB", n as f64 / (KB * KB) as f64) + } else if n >= KB { + format!("{} KB", n / KB) + } else { + format!("{n} B") + } +} + +/// Turn a **server**-side tool's result block (`web_search_tool_result`, +/// `mcp_tool_result`) into the `ToolResult` the feed hangs under its tool +/// entry. +/// +/// Search hits are emitted in *exactly* the wire shape Claude Code's own +/// client-side `WebSearch` bakes into its string result — one +/// `Links: [{"title":…,"url":…},…]` line — so the hosted search and the client +/// search render through the same one hit renderer +/// (`ui::push_search_result`). Two formatters for the same data would drift. +pub(crate) fn server_tool_result(block: Option<&Value>) -> ToolResult { + let Some(block) = block else { + return ToolResult { + content: String::new(), + is_error: true, + }; + }; + let content = block.get("content"); + // The error shape puts an object where the content array goes: + // `{"type":"web_search_tool_result_error","error_code":"max_uses_exceeded"}`. + if let Some(code) = content + .and_then(|c| c.get("error_code")) + .and_then(Value::as_str) + { + let ty = content + .and_then(|c| c.get("type")) + .and_then(Value::as_str) + .unwrap_or("server tool"); + let what = ty.strip_suffix("_tool_result_error").unwrap_or(ty); + return ToolResult { + content: format!("{what} error: {code}"), + is_error: true, + }; + } + // `mcp_tool_result` carries its own error flag; a search result has none. + let is_error = block.get("is_error").and_then(Value::as_bool).unwrap_or(false); + let content = match search_links(content) { + Some(l) => l, + None => flatten_result_content(content), + }; + ToolResult { content, is_error } +} + +/// The `web_search_result` hits of a result block as the `Links: […]` line the +/// WebSearch renderer already parses. `None` when the block holds no hits (an +/// MCP result, an empty search), so the caller falls back to plain flattening. +fn search_links(content: Option<&Value>) -> Option { + let hits: Vec = content? + .as_array()? + .iter() + .filter(|b| b.get("type").and_then(Value::as_str) == Some("web_search_result")) + .map(|b| { + let s = |k: &str| { + Value::String( + b.get(k) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + ) + }; + let mut m = serde_json::Map::new(); + m.insert("title".to_string(), s("title")); + m.insert("url".to_string(), s("url")); + Value::Object(m) + }) + .collect(); + (!hits.is_empty()).then(|| format!("Links: {}", Value::Array(hits))) +} + +/// One `citations_delta`, appended to the current text block's source list and +/// **deduplicated by url** (a citation without one — a document citation — +/// falls back to its title). The list is a handful of entries, so a linear +/// scan beats a set, and this runs inside `Tap::handle` under the app mutex. +fn push_citation(into: &mut Vec<(String, String)>, c: &Value) { + let f = |k: &str| c.get(k).and_then(Value::as_str).unwrap_or_default(); + let url = f("url").to_string(); + let title = if f("title").is_empty() { + f("document_title") + } else { + f("title") + } + .to_string(); + if url.is_empty() && title.is_empty() { + return; + } + let dup = into.iter().any(|(u, t)| { + if url.is_empty() { + u.is_empty() && *t == title + } else { + *u == url + } + }); + if !dup { + into.push((url, title)); + } +} + +/// Flush the sources collected off a text block's `citations_delta`s as one +/// dim `Kind::Meta` entry under it. Nothing is emitted when there were none, +/// and the streamed text itself is never touched (a citation is not text). +fn flush_citations(s: &mut Session, lane: LaneId, cites: &mut Vec<(String, String)>) { + if cites.is_empty() { + return; + } + let mut body = format!( + "▸ {} source{}", + cites.len(), + if cites.len() == 1 { "" } else { "s" } + ); + for (url, title) in cites.drain(..) { + body.push_str("\n · "); + match (title.is_empty(), url.is_empty()) { + (false, false) => body.push_str(&format!("{title} — {url}")), + (false, true) => body.push_str(&title), + (true, _) => body.push_str(&url), + } + } + s.push(lane, Entry::meta(body)); +} + /// One in-flight API request being observed. Created when a streaming /// /v1/messages request passes through the proxy; handles its SSE events. /// @@ -1589,6 +2375,10 @@ pub struct Tap { sidx: usize, lane: LaneId, cur: Option, + /// Sources collected off the current text block's `citations_delta`s, as + /// `(url, title)`. Flushed as one dim entry when the block ends (or when + /// the stream is cut), then cleared — see `flush_citations`. + cites: Vec<(String, String)>, } impl Tap { @@ -1642,6 +2432,7 @@ impl Tap { sidx, lane, cur: None, + cites: Vec::new(), } } @@ -1650,6 +2441,17 @@ impl Tap { self.lane } + /// The upstream refused this request (429 / 500 / 529 …). Its body is JSON, + /// not SSE, so nothing else would ever reach the feed and the turn would + /// simply stop with no explanation. `body` is whatever the tee managed to + /// collect — best-effort and capped, never the whole response. + pub fn record_http_error(&mut self, status: u16, body: &[u8]) { + let line = http_error_line(status, body); + let mut a = lock_app(&self.app); + let s = &mut a.sessions[self.sidx]; + s.push(self.lane, Entry::done(Kind::Error, line)); + } + pub fn handle(&mut self, ev: &str, d: &Value) { let mut a = lock_app(&self.app); // Set when a completed block means the embedded pane is about to show @@ -1709,6 +2511,40 @@ impl Tap { .to_string(), } } + // A **server**-side tool's result. The whole payload rides + // in this one event — there are no deltas and + // `content_block_stop` follows immediately — and, unlike a + // client tool's result, it is never echoed back as a + // `tool_result` block in a later request body, so + // `attach_tool_results` can never fill it in. This stream + // is the only place it exists: drop it and it is gone. + "web_search_tool_result" | "mcp_tool_result" => { + let id = d + .pointer("/content_block/tool_use_id") + .and_then(Value::as_str) + .unwrap_or_default(); + // Consume the id exactly as `attach_tool_results` does: + // the entry now holds its result, the map stays bounded + // (nothing else will ever claim this id), and a stray + // later `tool_result` can't clobber the richer form. + let idx = s.tool_ids.remove(id); + match idx.and_then(|i| s.entries.get_mut(i)) { + Some(e) => { + e.result = Some(server_tool_result(d.pointer("/content_block"))); + } + // No matching tool entry — we attached mid-stream, + // so the `server_tool_use` never passed through us. + // Fall back to the unknown-block line rather than + // invent an entry to hang the result on. + None => { + let e = Entry::meta(format!("[{bt}]")); + s.push(lane, e); + } + } + // Already complete: no `self.cur`, for the same reason + // the `other` arm below sets none. + return; + } other => { // Unknown block type: record a one-line meta entry but // do NOT set self.cur — we don't want subsequent delta @@ -1736,6 +2572,16 @@ impl Tap { Some("thinking_delta") => d.pointer("/delta/thinking"), Some("text_delta") => d.pointer("/delta/text"), Some("input_json_delta") => d.pointer("/delta/partial_json"), + // A cited answer streams its sources alongside the + // prose. They are *not* text — appending them would + // corrupt the entry — so they are collected here and + // flushed as one dim source list when the block ends. + Some("citations_delta") => { + if let Some(c) = d.pointer("/delta/citation") { + push_citation(&mut self.cites, c); + } + None + } _ => None, }; if let Some(t) = text.and_then(Value::as_str) { @@ -1761,6 +2607,8 @@ impl Tap { } } } + // Sources cited by the block that just ended, if any. + flush_citations(s, lane, &mut self.cites); } "message_delta" => { if let Some(o) = d.pointer("/usage/output_tokens").and_then(Value::as_u64) { @@ -1812,9 +2660,19 @@ impl Drop for Tap { l.active = l.active.saturating_sub(1); // The turn ended here; `Lane::running` counts from this stamp. l.last_event = Some(Instant::now()); + // A nested server-tool call is one request and one turn, so its lane is + // done the moment the response ends — there is no `` + // coming for it, and without this it would read as running for the full + // `LANE_IDLE_MAX`. + if l.is_server_tool() && l.active == 0 { + l.finished_at.get_or_insert_with(Instant::now); + } if let Some(i) = self.cur.take() { s.entries[i].done = true; } + // A stream cut mid-block still shows whatever sources it had. + let lane = self.lane; + flush_citations(s, lane, &mut self.cites); // A turn of the embedded session just finished streaming: schedule a // transcript wipe shortly after Claude Code prints its final lines. // Do NOT schedule while embed_grow is active — that means an interactive @@ -1832,6 +2690,46 @@ impl Drop for Tap { } } +/// Longest error text put on one feed line; the rest is elided. +const ERR_LINE_MAX: usize = 300; + +/// Collapse whitespace and clip to `ERR_LINE_MAX` — an upstream error body is +/// one line of the feed, not a document. +fn clip_line(s: &str) -> String { + let one = s.split_whitespace().collect::>().join(" "); + if one.chars().count() > ERR_LINE_MAX { + format!("{}…", one.chars().take(ERR_LINE_MAX).collect::()) + } else { + one + } +} + +/// Render a non-2xx upstream response as one `Kind::Error` line: the HTTP +/// status always, plus `error.type` / `error.message` when the body is +/// Anthropic's JSON error shape, else a clipped snippet of whatever came back. +/// +/// Pure, so the formatting is unit-testable without a network round-trip (the +/// proxy side is just "collect a few KB off the existing tee and call this"). +pub fn http_error_line(status: u16, body: &[u8]) -> String { + let raw = String::from_utf8_lossy(body); + let raw = raw.trim(); + if let Ok(v) = serde_json::from_str::(raw) { + let etype = v.pointer("/error/type").and_then(Value::as_str); + let msg = v.pointer("/error/message").and_then(Value::as_str); + match (etype, msg) { + (Some(t), Some(m)) => return format!("✖ HTTP {status} {t}: {}", clip_line(m)), + (Some(t), None) => return format!("✖ HTTP {status} {t}"), + (None, Some(m)) => return format!("✖ HTTP {status}: {}", clip_line(m)), + (None, None) => {} + } + } + if raw.is_empty() { + format!("✖ HTTP {status}") + } else { + format!("✖ HTTP {status}: {}", clip_line(raw)) + } +} + pub fn fmt_tokens(n: u64) -> String { if n >= 1000 { format!("{:.1}k", n as f64 / 1000.0) @@ -1854,10 +2752,10 @@ pub fn fmt_count(n: usize) -> String { out } -/// Total character length of a request's `system` prompt (string form or an -/// array of text blocks). Zero when absent — the model received no system text. -fn system_char_len(body: &Value) -> usize { - match body.get("system") { +/// Character length of a message-ish `content` value: a plain string, or an +/// array of blocks whose `text` fields are summed. Zero for anything else. +fn text_char_len(v: Option<&Value>) -> usize { + match v { Some(Value::String(s)) => s.chars().count(), Some(Value::Array(blocks)) => blocks .iter() @@ -1868,6 +2766,29 @@ fn system_char_len(body: &Value) -> usize { } } +/// Total character length of a request's `system` prompt (string form or an +/// array of text blocks). Zero when absent — the model received no system text. +fn system_char_len(body: &Value) -> usize { + text_char_len(body.get("system")) +} + +/// Total character length of the `role: "system"` messages carried *inside* +/// `messages` — Claude Code ≥2.1.247 sends beta `mid-conversation-system-…` +/// and appends the agent-type listing that way, after the user prompt. Summed +/// across the whole request so the once-then-on-change check fires again when +/// Claude Code injects another one mid-conversation. +fn mid_system_char_len(body: &Value) -> usize { + body.get("messages") + .and_then(Value::as_array) + .map(|ms| { + ms.iter() + .filter(|m| m.get("role").and_then(Value::as_str) == Some("system")) + .map(|m| text_char_len(m.get("content"))) + .sum() + }) + .unwrap_or(0) +} + /// `(signature, display line)` for a request's tool set, or `None` if it /// declares no tools. The signature (joined names) drives change detection so /// the list is surfaced once and re-emitted only when the available tools shift. @@ -2273,6 +3194,414 @@ mod tests { assert_eq!(count(1, &Kind::User), 1); } + /// A hosted-tool entry is told from a client tool definition by *shape*, + /// not by a version allowlist: `type` names the hosted tool and the server + /// owns the schema, so `input_schema` is absent. + #[test] + fn server_tool_requests_are_classified_by_tool_shape() { + // A real main-chain turn (client tool definitions). + assert_eq!( + classify_request(&json!({"tools": [ + {"name": "Read", "input_schema": {"type": "object"}}, + {"name": "WebSearch", "input_schema": {"type": "object"}}, + ]})), + ReqKind::Turn + ); + // Claude Code's nested WebSearch request, verbatim shape. + assert_eq!( + classify_request(&json!({"tools": [ + {"type": "web_search_20250305", "name": "web_search", "max_uses": 6} + ]})), + ReqKind::ServerTool + ); + // A hosted tool we have never seen still classifies (no allowlist). + assert_eq!( + classify_request(&json!({"tools": [{"type": "web_search_20260101", "name": "web_search"}]})), + ReqKind::ServerTool + ); + // The API's explicit client-tool spelling carries a schema. + assert_eq!( + classify_request(&json!({"tools": [ + {"type": "custom", "name": "Read", "input_schema": {"type": "object"}} + ]})), + ReqKind::Turn + ); + // A mixed request is still a turn: it offers client tools. + assert_eq!( + classify_request(&json!({"tools": [ + {"name": "Read", "input_schema": {}}, + {"type": "web_search_20250305", "name": "web_search"}, + ]})), + ReqKind::Turn + ); + // No tools / an *empty* array is the side/title request, not a + // server-tool one. + assert_eq!(classify_request(&json!({"tools": []})), ReqKind::Side); + assert_eq!(classify_request(&json!({"messages": []})), ReqKind::Side); + } + + /// Claude Code's `WebSearch` issues a nested `/v1/messages` declaring + /// Anthropic's *server-side* web_search tool under the parent's own + /// session id and with no agent-id header. It gets its own lane (readable + /// in the `A` popup, invisible to the main feed) and must not touch a + /// single piece of main-chain state — above all not the observed 1M + /// context window, which it would otherwise clear. + #[test] + fn server_tool_request_gets_its_own_lane_and_never_touches_the_main_chain() { + let app: SharedApp = Arc::new(Mutex::new(App::new())); + drop(Tap::new( + app.clone(), + "s".into(), + "claude-opus-5".into(), + None, + &AgentTag::default(), + )); + // A real main-chain turn, running the 1M window. + record_user_prompt( + &app, + "s", + MAIN_LANE, + &json!({"system": "0123456789", "tools": [ + {"name": "Read", "input_schema": {}}, + {"name": "WebSearch", "input_schema": {}}, + ], "messages": [{"role": "user", "content": "search the web for me"}]}), + ); + record_long_context(&app, "s", true); + + // The nested hosted-tool request. `classify_request` is what gates + // `record_long_context` / the system+tools lines in the proxy, so the + // classification *is* the fix; the rest of the pipeline runs verbatim. + let body = json!({ + "model": "claude-opus-5", + "stream": true, + "system": "abcde", + "tools": [{"type": "web_search_20250305", "name": "web_search", "max_uses": 6}], + "messages": [{"role": "user", "content": [ + {"type": "text", "text": "Perform a web search for the query: ratatui scroll thumb"} + ]}], + }); + assert_eq!(classify_request(&body), ReqKind::ServerTool); + let id = next_server_tool_id(); + assert!( + !id.chars().all(|c| c.is_ascii_hexdigit()), + "a synthetic key must be unmistakable for Claude Code's hex agent ids" + ); + let tap = Tap::new( + app.clone(), + "s".into(), + "claude-opus-5".into(), + None, + &AgentTag { id: Some(id), parent: None }, + ); + let lane = tap.lane(); + assert_ne!(lane, MAIN_LANE, "the nested call never joins the main chain"); + label_server_tool_lane(&app, "s", lane, &body); + record_user_prompt(&app, "s", lane, &body); + drop(tap); + + let a = app.lock().unwrap(); + let s = &a.sessions[0]; + // The one that matters: a session that really ran `opus[1m]` must still + // resume with the long window after a web search. + assert_eq!(s.long_context, Some(true), "the observed 1M window survives"); + // Main-chain signatures are untouched, so the next real turn does not + // re-emit its system prompt / tool list. + assert_eq!(s.main().last_system_len, Some(10)); + assert_eq!(s.main().last_tools_sig.as_deref(), Some("Read,WebSearch")); + let count = |l: LaneId, k: &Kind| { + s.entries + .iter() + .filter(|e| e.lane == l && &e.kind == k) + .count() + }; + assert_eq!(count(MAIN_LANE, &Kind::User), 1, "no prompt block in the feed"); + assert_eq!(count(MAIN_LANE, &Kind::System), 1); + assert_eq!(count(MAIN_LANE, &Kind::ToolDefs), 1); + assert_eq!(count(MAIN_LANE, &Kind::Meta), 0, "and no side-request divider"); + // The nested lane carries the query, but none of the turn-level noise. + assert_eq!(count(lane, &Kind::System), 0); + assert_eq!(count(lane, &Kind::ToolDefs), 0); + assert!( + s.entries.iter().any(|e| e.lane == lane + && e.kind == Kind::User + && e.content.contains("ratatui scroll thumb")) + ); + // The popup lists it like any other lane, with a title of its own. + assert_eq!(App::agent_list_of(s), vec![lane]); + assert_eq!( + s.lanes[lane as usize].title(), + "web_search · \"ratatui scroll thumb\"" + ); + // One request, one turn: the lane is done when the response ends. + assert!(s.lanes[lane as usize].finished()); + assert!(!s.lanes[lane as usize].running()); + } + + #[test] + fn server_tool_lane_falls_back_to_the_tool_name_alone() { + let app: SharedApp = Arc::new(Mutex::new(App::new())); + let body = json!({ + // No `name`, and a phrasing the query parser doesn't know. + "tools": [{"type": "web_fetch_20250910"}], + "messages": [{"role": "user", "content": "Fetch https://example.com"}], + }); + assert_eq!(classify_request(&body), ReqKind::ServerTool); + let tap = Tap::new( + app.clone(), + "s".into(), + "m".into(), + None, + &AgentTag { id: Some(next_server_tool_id()), parent: None }, + ); + let lane = tap.lane(); + label_server_tool_lane(&app, "s", lane, &body); + record_user_prompt(&app, "s", lane, &body); + drop(tap); + let a = app.lock().unwrap(); + let s = &a.sessions[0]; + // Version stripped off the `type`, no query → the tool name alone. + assert_eq!(s.lanes[lane as usize].title(), "web_fetch"); + // And it never claims an unrelated `Agent` call's label. + assert!(s.lanes[lane as usize].label.is_empty()); + } + + /// A hosted tool's result exists **only** in this SSE stream: it is never + /// echoed back as a `tool_result` block in a later request body, so + /// `attach_tool_results` can never fill it in. The whole payload rides in + /// `content_block_start`, and it must land on the `server_tool_use` entry + /// it belongs to — as the `Links: […]` shape the client-side `WebSearch` + /// result uses, so both render through one hit renderer. + #[test] + fn web_search_tool_result_attaches_its_hits_to_the_tool_entry() { + let app: SharedApp = Arc::new(Mutex::new(App::new())); + let mut tap = Tap::new( + app.clone(), + "s".into(), + "m".into(), + None, + &AgentTag { id: Some(next_server_tool_id()), parent: None }, + ); + tap.handle( + "content_block_start", + &json!({"index": 0, "content_block": { + "type": "server_tool_use", "id": "srvtoolu_01", "name": "web_search"}}), + ); + tap.handle( + "content_block_delta", + &json!({"delta": {"type": "input_json_delta", + "partial_json": "{\"query\":\"ratatui scrollbar\"}"}}), + ); + tap.handle("content_block_stop", &json!({"index": 0})); + tap.handle( + "content_block_start", + &json!({"index": 1, "content_block": { + "type": "web_search_tool_result", + "tool_use_id": "srvtoolu_01", + "content": [ + {"type": "web_search_result", "title": "Ratatui docs", + "url": "https://ratatui.rs", "page_age": "2 days"}, + {"type": "web_search_result", "title": "Scrollbar example", + "url": "https://ratatui.rs/examples", "page_age": null}, + ]}}), + ); + tap.handle("content_block_stop", &json!({"index": 1})); + let lane = tap.lane(); + drop(tap); + + let a = app.lock().unwrap(); + let s = &a.sessions[0]; + // No extra entry: the payload hangs off the tool call it answers. + let tools: Vec<&Entry> = s + .entries + .iter() + .filter(|e| matches!(&e.kind, Kind::Tool { .. })) + .collect(); + assert_eq!(tools.len(), 1); + assert_eq!(s.lanes[lane as usize].tool_calls, 1, "the result is not a call"); + assert!( + !s.entries.iter().any(|e| e.content == "[web_search_tool_result]"), + "the payload must not fall through to the unknown-block line" + ); + let r = tools[0].result.as_ref().expect("hits attached"); + assert!(!r.is_error); + assert!(r.content.starts_with("Links: ["), "{}", r.content); + assert!(r.content.contains("\"url\":\"https://ratatui.rs\""), "{}", r.content); + assert!(r.content.contains("Scrollbar example"), "{}", r.content); + // The id is consumed, exactly as `attach_tool_results` consumes one: + // nothing will ever claim it again, so leaving it would only leak. + assert!(s.tool_ids.is_empty(), "id consumed"); + } + + /// `content` is an *object*, not an array, when the hosted search fails. + #[test] + fn web_search_tool_result_error_reads_as_an_error_result() { + let app: SharedApp = Arc::new(Mutex::new(App::new())); + let mut tap = Tap::new(app.clone(), "s".into(), "m".into(), None, &AgentTag::default()); + tap.handle( + "content_block_start", + &json!({"content_block": { + "type": "server_tool_use", "id": "srvtoolu_02", "name": "web_search"}}), + ); + tap.handle("content_block_stop", &json!({})); + tap.handle( + "content_block_start", + &json!({"content_block": { + "type": "web_search_tool_result", + "tool_use_id": "srvtoolu_02", + "content": {"type": "web_search_tool_result_error", + "error_code": "max_uses_exceeded"}}}), + ); + drop(tap); + let a = app.lock().unwrap(); + let r = a.sessions[0].entries[0].result.as_ref().expect("error attached"); + assert!(r.is_error); + assert_eq!(r.content, "web_search error: max_uses_exceeded"); + } + + /// An MCP tool's result is an ordinary content-block array plus its own + /// error flag; it reuses `flatten_result_content`, hits and all. + #[test] + fn mcp_tool_result_flattens_its_content_blocks() { + let app: SharedApp = Arc::new(Mutex::new(App::new())); + let mut tap = Tap::new(app.clone(), "s".into(), "m".into(), None, &AgentTag::default()); + tap.handle( + "content_block_start", + &json!({"content_block": { + "type": "mcp_tool_use", "id": "mcptoolu_1", "name": "probe_run"}}), + ); + tap.handle("content_block_stop", &json!({})); + tap.handle( + "content_block_start", + &json!({"content_block": { + "type": "mcp_tool_result", + "tool_use_id": "mcptoolu_1", + "is_error": true, + "content": [{"type": "text", "text": "probe timed out"}]}}), + ); + drop(tap); + let a = app.lock().unwrap(); + let r = a.sessions[0].entries[0].result.as_ref().expect("attached"); + assert!(r.is_error); + assert_eq!(r.content, "probe timed out"); + } + + /// The safety net: a block type nobody has seen still gets its one-line + /// `[]` marker, and must **not** set `cur` — later deltas would + /// otherwise append into a finished `Meta` entry and `content_block_stop` + /// would try to pretty-print it. + #[test] + fn an_unknown_block_type_keeps_its_meta_line_and_sets_no_cursor() { + let app: SharedApp = Arc::new(Mutex::new(App::new())); + let mut tap = Tap::new(app.clone(), "s".into(), "m".into(), None, &AgentTag::default()); + tap.handle( + "content_block_start", + &json!({"content_block": {"type": "quantum_block", "id": "q1"}}), + ); + tap.handle( + "content_block_delta", + &json!({"delta": {"type": "text_delta", "text": "leaked"}}), + ); + tap.handle("content_block_stop", &json!({})); + // A result block whose tool entry never streamed falls back to it too. + tap.handle( + "content_block_start", + &json!({"content_block": { + "type": "web_search_tool_result", "tool_use_id": "unknown", "content": []}}), + ); + drop(tap); + let a = app.lock().unwrap(); + let s = &a.sessions[0]; + let lines: Vec<&str> = s.entries.iter().map(|e| e.content.as_str()).collect(); + assert_eq!(lines, vec!["[quantum_block]", "[web_search_tool_result]"]); + assert!(s.entries.iter().all(|e| e.kind == Kind::Meta)); + assert!( + !s.entries.iter().any(|e| e.content.contains("leaked")), + "a later delta must not append into the meta entry" + ); + } + + /// A cited answer streams its sources as `citations_delta`s beside the + /// prose. They are not text: the prose must survive byte-for-byte, and the + /// sources land as one dim entry with repeated urls collapsed. + #[test] + fn citations_become_one_deduplicated_source_list() { + let app: SharedApp = Arc::new(Mutex::new(App::new())); + let mut tap = Tap::new(app.clone(), "s".into(), "m".into(), None, &AgentTag::default()); + tap.handle( + "content_block_start", + &json!({"index": 2, "content_block": {"type": "text"}}), + ); + let cite = |url: &str, title: &str| { + json!({"delta": {"type": "citations_delta", "citation": { + "type": "web_search_result_location", "url": url, "title": title, + "cited_text": "Scrollbar renders a thumb."}}}) + }; + tap.handle("content_block_delta", &json!({"delta": {"type": "text_delta", "text": "The thumb "}})); + tap.handle("content_block_delta", &cite("https://ratatui.rs", "Ratatui docs")); + tap.handle("content_block_delta", &json!({"delta": {"type": "text_delta", "text": "is drawn."}})); + // Same url again (a second cited span) — one source, not two. + tap.handle("content_block_delta", &cite("https://ratatui.rs", "Ratatui docs")); + tap.handle("content_block_delta", &cite("https://ratatui.rs/examples", "Examples")); + tap.handle("content_block_stop", &json!({"index": 2})); + drop(tap); + + let a = app.lock().unwrap(); + let s = &a.sessions[0]; + assert!(s.entries[0].kind == Kind::Text); + assert_eq!(s.entries[0].content, "The thumb is drawn.", "prose is untouched"); + assert!(s.entries[1].kind == Kind::Meta); + let body = &s.entries[1].content; + assert_eq!(body.lines().next(), Some("▸ 2 sources")); + assert!(body.contains(" · Ratatui docs — https://ratatui.rs"), "{body}"); + assert!(body.contains(" · Examples — https://ratatui.rs/examples"), "{body}"); + assert_eq!(body.lines().count(), 3, "head plus one row per source: {body}"); + // Sources ride the "meta" toggle rather than a filter of their own. + assert_eq!(filter_index(&s.entries[1].kind), 4); + } + + /// Non-text blocks in a tool_result used to flatten to a bare `[]`. + /// An image says which image and how big (from the base64 *length* — never + /// a decode), a `tool_reference` says which tool, and anything else keeps + /// the old placeholder. + #[test] + fn tool_results_name_their_images_and_tool_references() { + // 400 base64 chars, one `=` of padding → 300 - 1 = 299 bytes. + let data = format!("{}=", "A".repeat(399)); + let img = flatten_result_content(Some(&json!([ + {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": data}} + ]))); + assert_eq!(img, "[image image/png · 299 B]"); + // A screenshot-sized payload reads in KB. + let big = flatten_result_content(Some(&json!([ + {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", + "data": "B".repeat(4 * 1024 * 100)}} + ]))); + assert_eq!(big, "[image image/jpeg · 300 KB]"); + // A url source shows the url instead. + assert_eq!( + flatten_result_content(Some(&json!([ + {"type": "image", "source": {"type": "url", "url": "https://ex.com/a.png"}} + ]))), + "[image https://ex.com/a.png]" + ); + // `ToolSearch`'s hits. + assert_eq!( + flatten_result_content(Some(&json!([ + {"type": "tool_reference", "tool_name": "Monitor"}, + {"type": "tool_reference", "tool_name": "TaskOutput"}, + ]))), + "[tool Monitor]\n[tool TaskOutput]" + ); + // Text still flattens verbatim, and the fallback is unchanged. + assert_eq!( + flatten_result_content(Some(&json!([ + {"type": "text", "text": "ok"}, {"type": "quantum"} + ]))), + "ok\n[quantum]" + ); + assert_eq!(fmt_bytes(1_572_864), "1.5 MB"); + } + #[test] fn lane_is_labelled_from_the_agent_tool_call() { let app: SharedApp = Arc::new(Mutex::new(App::new())); @@ -2396,6 +3725,383 @@ summary: '<5-10 word recap>' to continue this agent.)\nThe agent is working in t ); let a = app.lock().unwrap(); assert!(!a.sessions[0].lanes[1].finished()); + // …and it still becomes a note, so the completion is not invisible. + assert_eq!( + a.sessions[0].entries.iter().filter(|e| e.kind == Kind::TaskNote).count(), + 1 + ); + } + + /// Verbatim shape of a full agent completion (elided only in the report + /// body and the `` boilerplate), including the `` block. + const FULL_NOTIFICATION: &str = "\n\ +a1\n\ +toolu_012FGGa5hjqtxro91QCpwit2\n\ +/tmp/claude-1000/x/tasks/a1.output\n\ +completed\n\ +Agent \"Explore\" finished\n\ +A task-notification fires each time this agent stops with no live \ +background children of its own.\n\ +Found it in src/net/retry.rs:42.\nThe helper wraps reqwest.\n\ +12863363\ +1115197\n\ +"; + + /// Body of a turn whose trailing user message is `text`. + fn turn(text: &str) -> Value { + json!({"tools": [{"name": "Bash"}], "messages": [ + {"role": "user", "content": [{"type": "text", "text": text}]} + ]}) + } + + /// A session with one subagent lane (`a1`) whose `Agent` tool call is + /// already anchored — the state a notification actually arrives into: the + /// launch tool_result landed on the *previous* request, so `tool_ids` has + /// already been drained and only `Lane::anchor` remains. + fn session_with_anchored_agent() -> SharedApp { + let app: SharedApp = Arc::new(Mutex::new(App::new())); + // The subagent's own first request is what mints its lane. + drop(Tap::new( + app.clone(), + "s".into(), + "m".into(), + None, + &agent("a1"), + )); + // The parent's `Agent` tool call. + let mut tap = Tap::new( + app.clone(), + "s".into(), + "m".into(), + None, + &AgentTag::default(), + ); + tap.handle( + "content_block_start", + &json!({"content_block": {"type": "tool_use", "id": "toolu_012FGGa5hjqtxro91QCpwit2", "name": "Agent"}}), + ); + tap.handle( + "content_block_delta", + &json!({"delta": {"type": "input_json_delta", "partial_json": + "{\"subagent_type\":\"Explore\",\"description\":\"find the retry helper\"}"}}), + ); + tap.handle("content_block_stop", &json!({})); + drop(tap); + // The launch acknowledgement ties the lane to that entry (`anchor`). + attach_tool_results( + &app, + "s", + &json!({"messages": [{"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "toolu_012FGGa5hjqtxro91QCpwit2", + "content": ASYNC_LAUNCH} + ]}]}), + ); + app + } + + fn notes_of(app: &SharedApp) -> Vec { + app.lock() + .unwrap() + .sessions[0] + .entries + .iter() + .filter(|e| e.kind == Kind::TaskNote) + .map(|e| e.content.clone()) + .collect() + } + + #[test] + fn task_notification_is_lifted_out_of_the_prompt() { + let app = session_with_anchored_agent(); + // A notification can share the turn with something the user typed. + record_user_prompt( + &app, + "s", + MAIN_LANE, + &turn(&format!("{FULL_NOTIFICATION}\nWhat did they find?")), + ); + let a = app.lock().unwrap(); + let users: Vec<_> = a.sessions[0] + .entries + .iter() + .filter(|e| e.kind == Kind::User) + .collect(); + assert_eq!(users.len(), 1); + assert_eq!( + users[0].content, "What did they find?", + "the notification is lifted out, the real prompt survives" + ); + let notes: Vec<&Entry> = a.sessions[0] + .entries + .iter() + .filter(|e| e.kind == Kind::TaskNote) + .collect(); + assert_eq!(notes.len(), 1); + assert_eq!( + notes[0].content, + "✔ Explore finished · 128.6k tok · 63 tools · 18m35s" + ); + assert!( + !notes[0].content.contains("task-notification fires"), + "the boilerplate is dropped: {}", + notes[0].content + ); + // Wire order: the note precedes the prompt it rode in on. + let pos = |k: &Kind| a.sessions[0].entries.iter().position(|e| &e.kind == k).unwrap(); + assert!(pos(&Kind::TaskNote) < pos(&Kind::User)); + } + + #[test] + fn task_report_replaces_the_agent_launch_placeholder() { + let app = session_with_anchored_agent(); + { + let a = app.lock().unwrap(); + let anchor = a.sessions[0].lanes[1].anchor.expect("launch result anchored the lane"); + assert!( + a.sessions[0].entries[anchor] + .result + .as_ref() + .unwrap() + .content + .contains("Async agent launched"), + "…and the `Agent` entry starts out holding the placeholder" + ); + } + record_user_prompt(&app, "s", MAIN_LANE, &turn(FULL_NOTIFICATION)); + { + let a = app.lock().unwrap(); + let anchor = a.sessions[0].lanes[1].anchor.unwrap(); + let r = a.sessions[0].entries[anchor].result.as_ref().unwrap(); + assert_eq!(r.content, "Found it in src/net/retry.rs:42.\nThe helper wraps reqwest."); + assert!(!r.is_error, "a completed run is not an error"); + } + // The report moved: it is not duplicated into the note. + let notes = notes_of(&app); + assert_eq!(notes.len(), 1); + assert!(!notes[0].contains("retry.rs")); + } + + #[test] + fn task_usage_lands_on_the_lane() { + let app = session_with_anchored_agent(); + record_user_prompt(&app, "s", MAIN_LANE, &turn(FULL_NOTIFICATION)); + let a = app.lock().unwrap(); + let l = &a.sessions[0].lanes[1]; + assert_eq!(l.subagent_tokens, Some(128_633)); + assert_eq!(l.tool_uses, Some(63)); + assert_eq!(l.duration_ms, Some(1_115_197)); + assert!(l.finished(), "and the run is closed out"); + assert!(!l.running(), "usage is display data, never a gate"); + } + + #[test] + fn monitor_event_is_a_note_and_touches_no_lane() { + let app = session_with_anchored_agent(); + // No tool-use-id, no status, no result — and a short id matching no lane. + record_user_prompt( + &app, + "s", + MAIN_LANE, + &turn( + "\nb8s2gso3a\n\ +Monitor event: \"world skin bench phase transitions\"\n\ +BENCH progress phase=traverse ms=141974 studs=1206\n\ +", + ), + ); + let notes = notes_of(&app); + assert_eq!(notes.len(), 1); + assert_eq!( + notes[0], + concat!( + "▸ Monitor event: world skin bench phase transitions\n", + " BENCH progress phase=traverse ms=141974 studs=1206", + ) + ); + let a = app.lock().unwrap(); + let l = &a.sessions[0].lanes[1]; + assert!(!l.finished(), "a progress ping is not a stop"); + assert!(l.subagent_tokens.is_none()); + } + + #[test] + fn error_blob_status_reads_as_a_one_line_failure() { + let app = session_with_anchored_agent(); + record_user_prompt( + &app, + "s", + MAIN_LANE, + &turn( + "\na1\n\ +Error: 403: {\"message\":\"Access to model denied.\nPlease make sure you are \ +eligible.\",\"type\":\"AccessDenied.Unpurchased\"}\n\ +Agent \"Explore\" finished\n\ +partial work\n", + ), + ); + let notes = notes_of(&app); + assert_eq!(notes.len(), 1); + assert!(notes[0].starts_with("✖ Explore finished · Error: 403:"), "{}", notes[0]); + assert_eq!(notes[0].lines().count(), 1, "clipped to one line: {}", notes[0]); + assert!(notes[0].contains("Access to model denied. Please make sure")); + let a = app.lock().unwrap(); + let anchor = a.sessions[0].lanes[1].anchor.unwrap(); + assert!( + a.sessions[0].entries[anchor].result.as_ref().unwrap().is_error, + "a non-word status is a failure" + ); + } + + #[test] + fn killed_and_stopped_read_as_interrupted_not_failed() { + for (status, glyph) in [("killed", '◼'), ("stopped", '◼'), ("failed", '✖')] { + let n = parse_task_notification(&format!( + "a1{status}\ +Agent \"Explore\" finished" + )) + .unwrap(); + assert_eq!(n.glyph(), glyph, "{status}"); + assert_eq!( + task_note_line(&n, None), + format!("{glyph} Explore finished · {status}") + ); + } + } + + #[test] + fn unattachable_report_stays_inline_in_the_note() { + // (a) no lane at all — a background task, or an agent we never saw spawn. + let app: SharedApp = Arc::new(Mutex::new(App::new())); + drop(Tap::new( + app.clone(), + "s".into(), + "m".into(), + None, + &AgentTag::default(), + )); + let note = "\nzz9\ncompleted\n\ +Agent \"Explore\" finished\nthe whole report\n\ +"; + record_user_prompt(&app, "s", MAIN_LANE, &turn(note)); + assert_eq!(notes_of(&app)[0], "✔ Explore finished\n the whole report"); + + // (b) the lane exists but was never anchored (we attached mid-run, so + // the parent's `Agent` call never passed through us). + let app2: SharedApp = Arc::new(Mutex::new(App::new())); + drop(Tap::new( + app2.clone(), + "s".into(), + "m".into(), + None, + &agent("a1"), + )); + record_user_prompt( + &app2, + "s", + MAIN_LANE, + &turn(¬e.replace("zz9", "a1")), + ); + assert_eq!(notes_of(&app2)[0], "✔ Explore finished\n the whole report"); + assert!(app2.lock().unwrap().sessions[0].lanes[1].anchor.is_none()); + } + + #[test] + fn notification_inside_a_system_reminder_is_handled() { + let app = session_with_anchored_agent(); + record_user_prompt( + &app, + "s", + MAIN_LANE, + &turn(&format!( + "{FULL_NOTIFICATION}carry on" + )), + ); + let a = app.lock().unwrap(); + assert!(a.sessions[0].lanes[1].finished()); + assert_eq!(a.sessions[0].lanes[1].tool_uses, Some(63)); + assert_eq!( + a.sessions[0].entries.iter().filter(|e| e.kind == Kind::TaskNote).count(), + 1 + ); + assert_eq!( + a.sessions[0].entries.iter().filter(|e| e.kind == Kind::Reminder).count(), + 0, + "a reminder that was only a notification leaves nothing to show" + ); + assert!( + a.sessions[0] + .entries + .iter() + .any(|e| e.kind == Kind::User && e.content == "carry on") + ); + } + + #[test] + fn resent_turn_doubles_neither_the_note_nor_the_prompt() { + let app = session_with_anchored_agent(); + let body = turn(&format!("{FULL_NOTIFICATION}\nWhat did they find?")); + record_user_prompt(&app, "s", MAIN_LANE, &body); + record_user_prompt(&app, "s", MAIN_LANE, &body); // immediate resend + { + let users = app + .lock() + .unwrap() + .sessions[0] + .entries + .iter() + .filter(|e| e.kind == Kind::User) + .count(); + assert_eq!(users, 1, "note entries at the tail must not blind the dedup"); + assert_eq!(notes_of(&app).len(), 1); + } + // A later turn repeating both verbatim survives (the same rule the + // prompt dedup uses: streamed entries sit in between). + { + let mut a = app.lock().unwrap(); + a.sessions[0] + .entries + .push(Entry::done(Kind::Text, "they found it".into())); + } + record_user_prompt(&app, "s", MAIN_LANE, &body); + let users = app + .lock() + .unwrap() + .sessions[0] + .entries + .iter() + .filter(|e| e.kind == Kind::User) + .count(); + assert_eq!(users, 2); + assert_eq!(notes_of(&app).len(), 2); + } + + #[test] + fn split_task_notifications_keeps_what_it_cannot_parse() { + // Trailing `` outside the block is still swallowed and read. + let (n, rest) = split_task_notifications( + "before a1completed\ +12 after", + ); + assert_eq!(n.len(), 1); + assert_eq!(n[0].subagent_tokens, Some(12)); + assert_eq!(rest, "before after"); + // A block with no `` is malformed: left in the prompt verbatim. + let raw = "completed"; + let (n, rest) = split_task_notifications(raw); + assert!(n.is_empty()); + assert_eq!(rest, raw); + // An unterminated block ends the text. + let (n, rest) = split_task_notifications("hi a1"); + assert_eq!(n.len(), 1); + assert_eq!(rest, "hi"); + } + + #[test] + fn fmt_duration_reads_as_time() { + assert_eq!(fmt_duration(1_115_197), "18m35s"); + assert_eq!(fmt_duration(7_265_000), "2h01m"); + assert_eq!(fmt_duration(5_000), "5s"); + assert_eq!(fmt_duration(400), "400ms"); } #[test] @@ -2809,6 +4515,143 @@ summary: '<5-10 word recap>' to continue this agent.)\nThe agent is working in t assert_eq!(users, 0, "tool_result continuation must not record a prompt"); } + /// Claude Code ≥2.1.247 sends beta `mid-conversation-system-…` and appends + /// the agent-type listing as a `role: "system"` message **after** the user + /// prompt, so turn 1 of every session reads `["user", "system"]`. Taking + /// the trailing run with `take_while(role == "user")` therefore collected + /// nothing and returned early — dropping the entire first turn (no prompt + /// block, no system/tools lines, no lane labelling, nothing for + /// `ui::live_title`). + #[test] + fn first_turn_survives_the_trailing_system_message() { + let app: SharedApp = Arc::new(Mutex::new(App::new())); + drop(Tap::new( + app.clone(), + "abc".into(), + "claude-opus-5".into(), + None, + &AgentTag::default(), + )); + const LISTING: &str = "Available agent types for the Agent tool:\n- claude: catch-all"; + let sys_msg = json!({"role": "system", "content": [{"type": "text", "text": LISTING}]}); + + // Turn 1: ["user", "system"]. + record_user_prompt( + &app, + "abc", + MAIN_LANE, + &json!({"system": "0123456789", "tools": [{"name": "Bash"}], "messages": [ + {"role": "user", "content": [{"type": "text", "text": "first prompt"}]}, + sys_msg, + ]}), + ); + { + let a = app.lock().unwrap(); + let s = &a.sessions[0]; + let kinds: Vec<&Kind> = s.entries.iter().map(|e| &e.kind).collect(); + assert!( + s.entries + .iter() + .any(|e| e.kind == Kind::User && e.content == "first prompt"), + "the first prompt of the session must be recorded" + ); + assert!( + kinds.iter().any(|k| matches!(k, Kind::ToolDefs)), + "and the turn's tool list with it" + ); + assert_eq!(s.main().last_system_len, Some(10)); + } + + // Turn 2 after a resume: ["user", "system", "assistant", "user"] — the + // run stops at the assistant message, so only the new prompt is taken. + record_user_prompt( + &app, + "abc", + MAIN_LANE, + &json!({"system": "0123456789", "tools": [{"name": "Bash"}], "messages": [ + {"role": "user", "content": [{"type": "text", "text": "first prompt"}]}, + {"role": "system", "content": [{"type": "text", "text": LISTING}]}, + {"role": "assistant", "content": [{"type": "text", "text": "ok"}]}, + {"role": "user", "content": [{"type": "text", "text": "second prompt"}]}, + ]}), + ); + // A tool-loop continuation still contributes nothing, even when the + // mid-conversation system message trails it. + record_user_prompt( + &app, + "abc", + MAIN_LANE, + &json!({"system": "0123456789", "tools": [{"name": "Bash"}], "messages": [ + {"role": "user", "content": [{"type": "text", "text": "second prompt"}]}, + {"role": "assistant", "content": [ + {"type": "tool_use", "id": "t1", "name": "Bash", "input": {}} + ]}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "a.txt"} + ]}, + {"role": "system", "content": [{"type": "text", "text": LISTING}]}, + ]}), + ); + let a = app.lock().unwrap(); + let users: Vec<&str> = a.sessions[0] + .entries + .iter() + .filter(|e| e.kind == Kind::User) + .map(|e| e.content.as_str()) + .collect(); + assert_eq!(users, vec!["first prompt", "second prompt"]); + } + + /// The mid-conversation `system` message is real context the model + /// received, so its *size* is surfaced under the "system" filter with the + /// same once-then-on-change policy as the system prompt itself. + #[test] + fn mid_conversation_system_size_surfaced_once_then_on_change() { + let app: SharedApp = Arc::new(Mutex::new(App::new())); + drop(Tap::new( + app.clone(), + "abc".into(), + "claude-x".into(), + None, + &AgentTag::default(), + )); + let turn = |listing: &str, prompt: &str| { + json!({"system": "0123456789", "tools": [{"name": "Bash"}], "messages": [ + {"role": "user", "content": [{"type": "text", "text": prompt}]}, + {"role": "system", "content": [{"type": "text", "text": listing}]}, + ]}) + }; + record_user_prompt(&app, "abc", MAIN_LANE, &turn("abcde", "one")); + record_user_prompt(&app, "abc", MAIN_LANE, &turn("abcde", "two")); + record_user_prompt(&app, "abc", MAIN_LANE, &turn("abcdefgh", "three")); + + let a = app.lock().unwrap(); + let mid: Vec<&str> = a.sessions[0] + .entries + .iter() + .filter(|e| e.kind == Kind::System && e.content.starts_with("mid-conversation")) + .map(|e| e.content.as_str()) + .collect(); + assert_eq!( + mid, + vec![ + "mid-conversation system: 5 chars", + "mid-conversation system: 8 chars" + ], + "once, then only when the listing changes" + ); + assert_eq!( + a.sessions[0] + .entries + .iter() + .filter(|e| e.kind == Kind::System && e.content.starts_with("system prompt")) + .count(), + 1, + "the system prompt line is unaffected" + ); + assert_eq!(a.sessions[0].lanes[MAIN_LANE as usize].last_mid_system_len, Some(8)); + } + #[test] fn extract_user_text_splits_reminders_from_prompt() { let (rem, prompt) = extract_user_text("x"); @@ -3090,4 +4933,54 @@ summary: '<5-10 word recap>' to continue this agent.)\nThe agent is working in t ); assert!(app.lock().unwrap().sessions[0].entries.is_empty()); } + + /// A 429/500/529 from upstream answers with JSON, not SSE, so the tap used + /// to close with nothing in the feed: the turn just stopped. The status — + /// and the upstream's own wording when it fits on a line — must show. + #[test] + fn http_error_line_names_the_status_and_the_upstream_error() { + assert_eq!( + http_error_line( + 429, + br#"{"type":"error","error":{"type":"rate_limit_error","message":"Number of request tokens has exceeded your per-minute rate limit."}}"# + ), + "\u{2716} HTTP 429 rate_limit_error: Number of request tokens has exceeded your per-minute rate limit." + ); + assert_eq!( + http_error_line(529, br#"{"error":{"type":"overloaded_error"}}"#), + "\u{2716} HTTP 529 overloaded_error" + ); + // Not Anthropic's JSON shape (a gateway page): clipped to one line. + assert_eq!( + http_error_line(502, b"\n Bad Gateway\n"), + "\u{2716} HTTP 502: Bad Gateway " + ); + // Nothing came back at all — the status is still worth showing. + assert_eq!(http_error_line(500, b""), "\u{2716} HTTP 500"); + // A pathological body never becomes a wall of feed. + let line = http_error_line(500, &vec![b'x'; 4096]); + assert!(line.ends_with('\u{2026}')); + assert_eq!(line.chars().count(), "\u{2716} HTTP 500: ".chars().count() + ERR_LINE_MAX + 1); + } + + /// The error entry belongs to the tap's own lane, so a subagent's (or a + /// server-tool call's) failure shows in its stream, not the main feed. + #[test] + fn refused_request_records_an_error_in_its_own_lane() { + let app: SharedApp = Arc::new(Mutex::new(App::new())); + let mut tap = Tap::new(app.clone(), "s".into(), "m".into(), None, &agent("a1")); + tap.record_http_error(529, br#"{"error":{"type":"overloaded_error","message":"Overloaded"}}"#); + drop(tap); + let a = app.lock().unwrap(); + let s = &a.sessions[0]; + let e = s.entries.iter().find(|e| e.kind == Kind::Error).unwrap(); + assert_eq!(e.lane, 1); + assert!(e.done); + assert_eq!(e.content, "\u{2716} HTTP 529 overloaded_error: Overloaded"); + assert_eq!( + s.entries.iter().filter(|e| e.lane == MAIN_LANE).count(), + 0, + "nothing leaks into the main chain" + ); + } } diff --git a/src/main.rs b/src/main.rs index 5516638..ac752d3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod ansi; mod app; mod markdown; mod proxy; diff --git a/src/proxy.rs b/src/proxy.rs index 89b76db..f341ff3 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -1,5 +1,6 @@ use crate::app::{ - AgentTag, SharedApp, Tap, attach_tool_results, lock_app, record_long_context, + AgentTag, ReqKind, SharedApp, Tap, attach_tool_results, classify_request, + label_server_tool_lane, lock_app, next_server_tool_id, record_long_context, record_user_prompt, }; use crate::sse::SseParser; @@ -31,6 +32,12 @@ pub const PARENT_AGENT_ID_HEADER: &str = "x-claude-code-parent-agent-id"; pub const BETA_HEADER: &str = "anthropic-beta"; pub const LONG_CONTEXT_BETA: &str = "context-1m"; +/// Most of a failed response we keep in order to name the error. An Anthropic +/// error body is a few hundred bytes; the cap exists so a pathological upstream +/// can't make the tee task grow without bound (the relay itself is unaffected +/// either way — it never waits on this). +const ERR_BODY_MAX: usize = 4096; + /// Upstream base URL; `CT_UPSTREAM` overrides for offline testing against a /// fake server (the relay itself is identical either way). fn upstream() -> String { @@ -136,9 +143,26 @@ async fn forward_inner(ctx: Ctx, req: Request) -> anyhow::Result { .filter(|v| !v.is_empty()) .map(str::to_string) }; - let agent = AgentTag { - id: header(AGENT_ID_HEADER), - parent: header(PARENT_AGENT_ID_HEADER), + // Turn start / nested server-tool call / side request. A non-empty + // `tools` array alone used to mean "turn start", which misfiled + // `WebSearch`'s nested hosted-tool request as a main-chain turn. + let kind = classify_request(&v); + let agent = match kind { + // A nested server-tool call carries no `x-claude-code-agent-id`, so + // it gets a synthetic key of ours (never hex, so it can never + // collide with a real agent id) and therefore its own lane: it is + // readable in the `A` popup like a subagent and stays out of the + // main feed. If Claude Code ever *does* stamp the caller's agent id + // on one of these, that agent is this lane's parent — the nested + // call is not the agent itself. + ReqKind::ServerTool => AgentTag { + id: Some(next_server_tool_id()), + parent: header(AGENT_ID_HEADER), + }, + _ => AgentTag { + id: header(AGENT_ID_HEADER), + parent: header(PARENT_AGENT_ID_HEADER), + }, }; // Tool results ride along in the request body; surface them // on the tool entries from the previous turn. @@ -146,14 +170,17 @@ async fn forward_inner(ctx: Ctx, req: Request) -> anyhow::Result { let t = Tap::new(ctx.app.clone(), key.clone(), model, pane_token, &agent); // After Tap::new: the session (and the lane) must exist for the entry // to land. + if kind == ReqKind::ServerTool { + label_server_tool_lane(&ctx.app, &key, t.lane(), &v); + } record_user_prompt(&ctx.app, &key, t.lane(), &v); // Context window of the *main chain*, from this request's betas. Only a - // turn-starting main-chain request counts: a side/title call runs haiku - // without the flag and a subagent runs its own model, so either would - // report a window that is not the session's. - if t.lane() == crate::app::MAIN_LANE - && v.get("tools").and_then(Value::as_array).is_some_and(|t| !t.is_empty()) - { + // real turn-starting main-chain request counts: a side/title call runs + // haiku without the flag, a subagent runs its own model, and a nested + // server-tool call never carries the flag at all — reading any of them + // would report a window that is not the session's (the server-tool case + // silently downgraded a `[1m]` session to the short window on resume). + if kind == ReqKind::Turn && t.lane() == crate::app::MAIN_LANE { let long = header(BETA_HEADER).is_some_and(|b| b.contains(LONG_CONTEXT_BETA)); record_long_context(&ctx.app, &key, long); } @@ -177,7 +204,8 @@ async fn forward_inner(ctx: Ctx, req: Request) -> anyhow::Result { } let resp = rb.body(body_bytes).send().await?; - let mut builder = Response::builder().status(resp.status().as_u16()); + let status = resp.status(); + let mut builder = Response::builder().status(status.as_u16()); let is_sse = resp .headers() .get("content-type") @@ -202,8 +230,8 @@ async fn forward_inner(ctx: Ctx, req: Request) -> anyhow::Result { // (drop-on-full, best-effort per the tee invariant); the tap task // drains that channel independently, so the forwarded byte stream is // never held back by the app mutex. - let body = match (is_sse, tap) { - (true, Some(mut tap)) => { + let body = match tap { + Some(mut tap) if is_sse && status.is_success() => { let (tx, mut rx) = tokio::sync::mpsc::channel::>(64); tokio::spawn(async move { let mut parser = SseParser::default(); @@ -225,6 +253,36 @@ async fn forward_inner(ctx: Ctx, req: Request) -> anyhow::Result { }); Body::from_stream(stream) } + // Upstream refused (429 / 500 / 529 …). The body is JSON, not SSE, so + // the tap used to close with nothing in the feed: the turn just stopped, + // with no status and no message. Surfaced through the *same* best-effort + // tee as SSE — never `resp.bytes().await`, which would buffer the + // response and break latency-neutral pass-through. Chunks are cloned + // with `try_send` into a bounded channel (dropped on overflow), and a + // separate task keeps at most `ERR_BODY_MAX` of them. + Some(mut tap) if !status.is_success() => { + let (tx, mut rx) = tokio::sync::mpsc::channel::>(8); + let code = status.as_u16(); + tokio::spawn(async move { + let mut buf: Vec = Vec::new(); + while let Some(b) = rx.recv().await { + let room = ERR_BODY_MAX.saturating_sub(buf.len()); + if room > 0 { + buf.extend_from_slice(&b[..b.len().min(room)]); + } + } + tap.record_http_error(code, &buf); + }); + let stream = resp.bytes_stream().map(move |chunk| { + if let Ok(b) = &chunk { + let _ = tx.try_send(b.to_vec()); + } + chunk + }); + Body::from_stream(stream) + } + // A 2xx non-SSE response (`count_tokens`, …) stays untapped and + // silent — a documented MVP limit, not an error. _ => Body::from_stream(resp.bytes_stream()), }; Ok(builder.body(body)?) diff --git a/src/sessions.rs b/src/sessions.rs index 8b6d0f3..905427b 100644 --- a/src/sessions.rs +++ b/src/sessions.rs @@ -10,8 +10,8 @@ //! spaces … all become `-`) — see `encode_cwd`. use crate::app::{ - Entry, Kind, LaneId, MAIN_LANE, Session, SharedApp, ToolResult, flatten_result_content, - lock_app, strip_injected, + Entry, Kind, LaneId, MAIN_LANE, Session, SharedApp, TaskNotification, ToolResult, + flatten_result_content, lock_app, split_task_notifications, strip_injected, task_note_line, }; use serde_json::Value; use std::collections::HashMap; @@ -453,8 +453,9 @@ pub fn load_view(uuid: &str, path: Option<(&TurnTree, usize)>) -> Option (Vec, HashMap) { +/// Claude Code's own accounting for one whole agent run, scraped from the +/// `` block of a ``. A transcript records no *API* +/// usage, but it does record the notifications, so this is the only token +/// figure an on-disk lane can ever have (`Lane::subagent_tokens` and friends). +#[derive(Default, Clone, Copy, Debug)] +struct LaneUsage { + tokens: Option, + tool_uses: Option, + duration_ms: Option, +} + +/// Agent id → run totals. The key is a notification's ``, which is +/// also the agent id its lane is registered under and the stem of its +/// `subagents/agent-.jsonl` — so the usage a *parent's* file reports finds +/// the child's lane without any extra lookup table. +type UsageByAgent = HashMap; + +impl LaneUsage { + /// Field-by-field, later totals winning — same policy as the live + /// `Session::record_task_usage` (an agent woken again by `SendMessage` + /// reports afresh). + fn merge(&mut self, o: LaneUsage) { + self.tokens = o.tokens.or(self.tokens); + self.tool_uses = o.tool_uses.or(self.tool_uses); + self.duration_ms = o.duration_ms.or(self.duration_ms); + } + + fn is_empty(&self) -> bool { + self.tokens.is_none() && self.tool_uses.is_none() && self.duration_ms.is_none() + } +} + +/// Remember a notification's `` under its agent id, for `splice_agents` +/// to hand to that agent's lane once the lanes exist. +fn record_task_usage(usage: &mut UsageByAgent, n: &TaskNotification) { + let u = LaneUsage { + tokens: n.subagent_tokens, + tool_uses: n.tool_uses, + duration_ms: n.duration_ms, + }; + if u.is_empty() { + return; + } + usage.entry(n.task_id.clone()).or_default().merge(u); +} + +/// Parse one subagent transcript into its own lane, merging any `` it +/// reports for *its* children into `usage`. Oversized files are summarised +/// rather than parsed: the view is built while the app mutex is held (the +/// proxy tap shares it), so a few MB of JSONL must not stall it. +fn parse_agent_file( + path: &std::path::Path, + lane: LaneId, + usage: &mut UsageByAgent, +) -> (Vec, HashMap) { let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); if size > MAX_AGENT_BYTES { let mb = size / (1024 * 1024); @@ -496,6 +549,9 @@ fn parse_agent_file(path: &std::path::Path, lane: LaneId) -> (Vec, HashMa for line in std::io::BufReader::new(f).lines().map_while(Result::ok) { p.line(&line); } + for (id, u) in p.task_usage { + usage.entry(id).or_default().merge(u); + } (p.entries, p.agent_tools) } @@ -537,10 +593,15 @@ fn insert_entries( /// the highest anchor goes first. A lane whose spawn point is not in this view /// (a path view can exclude that turn) keeps no entries, so it never shows up /// in the agent popup. +/// +/// `usage` carries the `` totals the main chain reported +/// (see `LaneUsage`); each agent file's own notifications are merged in as it +/// is parsed, and the lot is handed to the lanes at the end. fn splice_agents( view: &mut HistoryView, mut anchors: HashMap, agents: &[DiskAgent], + mut usage: UsageByAgent, ) { if agents.is_empty() { return; @@ -566,7 +627,7 @@ fn splice_agents( None, a.spawn_depth, ); - let (entries, anchors) = parse_agent_file(&a.path, lane); + let (entries, anchors) = parse_agent_file(&a.path, lane, &mut usage); Loaded { id: a.agent_id.clone(), lane, @@ -636,11 +697,25 @@ fn splice_agents( l.parent = Some(MAIN_LANE); } view.session.reindex_lanes(); - // Tool counts for the agent picker (a transcript carries no usage, so - // token totals stay zero for on-disk lanes). + // Tool counts for the agent picker, straight off the entries. for (lane, n) in count_tools(&view.session.entries) { view.session.lanes[lane as usize].tool_calls = n; } + // Run totals for the picker and the agent-feed title. A transcript records + // no API usage, but the ``s in it carry Claude Code's + // own accounting, keyed by the agent id the lane is registered under. An + // id that matches no lane (a background *bash* task, or an agent whose + // transcript this session never kept) is simply skipped — the same + // silence as the live `Session::record_task_usage`. + for (id, u) in &usage { + let Some(&lane) = view.session.lane_of_agent.get(id) else { + continue; + }; + let l = &mut view.session.lanes[lane as usize]; + l.subagent_tokens = u.tokens.or(l.subagent_tokens); + l.tool_uses = u.tool_uses.or(l.tool_uses); + l.duration_ms = u.duration_ms.or(l.duration_ms); + } } /// Incremental JSONL-record → feed-`Entry` translation (shared by the whole /// file and path views). @@ -653,6 +728,10 @@ struct EntryParser { /// `tool_idx`, which is *drained* as results attach — the anchor is still /// needed afterwards to splice the subagent's transcript in. agent_tools: HashMap, + /// `` totals scraped from this file's ``s, keyed + /// by the agent id they report on. Collected in the parse pass and applied + /// to the lanes by `splice_agents`, which is where the lanes exist. + task_usage: UsageByAgent, /// Lane every parsed entry is tagged with (0 = the main chain). lane: LaneId, /// Keep `isSidechain` records instead of skipping them. A subagent file @@ -668,6 +747,7 @@ impl EntryParser { model: String::from("(resumed)"), tool_idx: HashMap::new(), agent_tools: HashMap::new(), + task_usage: UsageByAgent::new(), lane: MAIN_LANE, keep_sidechain: false, } @@ -709,6 +789,7 @@ impl EntryParser { let entries = &mut self.entries; let tool_idx = &mut self.tool_idx; let agent_tools = &mut self.agent_tools; + let task_usage = &mut self.task_usage; let Ok(v) = serde_json::from_str::(line) else { return; }; @@ -766,11 +847,19 @@ impl EntryParser { } } Some("user") => match v.pointer("/message/content") { - Some(Value::String(s)) => push_user_text(entries, s, lane), + Some(Value::String(s)) => { + push_user_text(entries, agent_tools, task_usage, s, lane) + } Some(Value::Array(blocks)) => { for b in blocks { match b.get("type").and_then(Value::as_str) { - Some("text") => push_user_text(entries, &text_of(b, "text"), lane), + Some("text") => push_user_text( + entries, + agent_tools, + task_usage, + &text_of(b, "text"), + lane, + ), Some("tool_result") => { let Some(idx) = b .get("tool_use_id") @@ -804,10 +893,83 @@ fn text_of(b: &Value, key: &str) -> String { b.get(key).and_then(Value::as_str).unwrap_or_default().to_string() } -/// Translate a user text block into feed entries: each injected reminder as a -/// dimmed `Kind::Reminder`, then the real prompt as `Kind::User`. -fn push_user_text(entries: &mut Vec, text: &str, lane: LaneId) { +/// Move an agent's final report onto the `Agent` tool entry that spawned it, +/// replacing the `Async agent launched successfully… agentId: ` +/// acknowledgement the parent model got at launch time. The disk mirror of +/// `Session::attach_task_report`, and a shorter one: the notification names +/// the `` of that very call, and `EntryParser::agent_tools` still +/// holds it (unlike `tool_idx`, which is drained when the launch result +/// attaches) — so no lane/anchor round-trip is needed. Both records live in +/// the same file, nested agents included: a depth-2 `Agent` call and the +/// notification answering it both sit in the parent *agent's* transcript. +/// +/// False when the spawn point is not in this parse (a path view can exclude +/// that turn, and 6 of the notifications in the real transcripts name a call +/// recorded nowhere we read); the caller then keeps the report inline in the +/// note rather than losing it. +fn attach_task_report( + entries: &mut [Entry], + agent_tools: &HashMap, + n: &TaskNotification, + report: &str, +) -> bool { + let Some(&idx) = n.tool_use_id.as_deref().and_then(|id| agent_tools.get(id)) else { + return false; + }; + let Some(e) = entries.get_mut(idx) else { + return false; + }; + e.result = Some(ToolResult { + content: report.to_string(), + is_error: n.failed(), + }); + true +} + +/// Translate a user text block into feed entries, exactly as the live path +/// (`app::record_user_prompt`) does, so a session reads the same whether it is +/// streaming or loaded from disk: +/// +/// 1. every `` is lifted out — of the prompt *and* of each +/// injected reminder, since Claude Code sometimes wraps one in a +/// `` — and becomes one `Kind::TaskNote` line, with the +/// agent's `` report moved onto its `Agent` tool entry (or kept +/// inline when that call is not in this view). `` is remembered for +/// the lane; +/// 2. each remaining reminder as a dimmed `Kind::Reminder`; +/// 3. what is left as the real prompt, `Kind::User`. +/// +/// Notes come first, as they do live. Nothing is dropped: a block that fails +/// to parse is handed back inside the text by `split_task_notifications` and +/// so still shows in the prompt. +/// +/// No resend dedup here (the live path's) — a transcript records each turn +/// once, so an identical note twice means the agent really was woken twice. +fn push_user_text( + entries: &mut Vec, + agent_tools: &HashMap, + usage: &mut UsageByAgent, + text: &str, + lane: LaneId, +) { let (reminders, prompt) = crate::app::extract_user_text(text); + let (mut notes, prompt) = split_task_notifications(&prompt); + let reminders: Vec = reminders + .into_iter() + .filter_map(|r| { + let (n, rest) = split_task_notifications(&r); + notes.extend(n); + // A reminder that was *only* a notification leaves nothing to show. + (!rest.is_empty()).then_some(rest) + }) + .collect(); + for n in ¬es { + record_task_usage(usage, n); + let report = n.result.as_deref().map(str::trim).filter(|r| !r.is_empty()); + let attached = report.is_some_and(|r| attach_task_report(entries, agent_tools, n, r)); + let line = task_note_line(n, if attached { None } else { report }); + entries.push(Entry::done(Kind::TaskNote, line).in_lane(lane)); + } for r in reminders { entries.push(Entry::done(Kind::Reminder, r).in_lane(lane)); } @@ -1092,6 +1254,244 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } + /// An `Agent` tool call, and the launch acknowledgement its tool_result + /// carries (Claude Code launches every agent asynchronously). + fn agent_call(uuid: &str, parent: &str, tool_id: &str) -> String { + serde_json::json!({ + "type": "assistant", "uuid": uuid, "parentUuid": parent, + "message": {"model": "claude-x", "content": [{ + "type": "tool_use", "id": tool_id, "name": "Agent", + "input": {"subagent_type": "Explore", "description": "sweep", "prompt": "p"} + }]} + }) + .to_string() + } + + fn launch_result(uuid: &str, parent: &str, tool_id: &str, agent_id: &str) -> String { + serde_json::json!({ + "type": "user", "uuid": uuid, "parentUuid": parent, + "message": {"role": "user", "content": [{ + "type": "tool_result", "tool_use_id": tool_id, + "content": format!("Async agent launched successfully, agentId: {agent_id}") + }]} + }) + .to_string() + } + + /// A completion `` in Claude Code's real wire shape. + fn completion_note(task_id: &str, tool_id: &str, result: &str) -> String { + format!( + "\n\ + {task_id}\n\ + {tool_id}\n\ + /tmp/claude/tasks/{task_id}.output\n\ + completed\n\ + Agent \"sweep\" finished\n\ + A task-notification fires each time this agent stops.\n\ + {result}\n\ + 12863363\ + 1115197\n\ + " + ) + } + + /// The four records of an agent run: prompt, `Agent` call, launch + /// acknowledgement, and the turn its completion notification rode in on. + fn agent_run(note: &str) -> Vec { + vec![ + prompt("u1", None, "go"), + agent_call("a1", "u1", "toolu_A"), + launch_result("u2", "a1", "toolu_A", "aaa1"), + prompt("u3", Some("a1"), &format!("{note}\nwhat did it find?")), + ] + } + + fn view_of(lines: &[String]) -> Session { + let refs: Vec<&str> = lines.iter().map(String::as_str).collect(); + let p = write_jsonl(&refs); + let s = load_file_view(&p, "sess-notif", &[]).map(|h| h.session); + std::fs::remove_file(&p).ok(); + s.expect("view") + } + + fn only_note(s: &Session) -> &Entry { + let mut it = s.entries.iter().filter(|e| e.kind == Kind::TaskNote); + let n = it.next().expect("a Kind::TaskNote entry"); + assert!(it.next().is_none(), "exactly one note expected"); + n + } + + /// A `` in a transcript is lifted out of the prompt: a + /// one-line `Kind::TaskNote` in front of it, the XML (and the `` + /// boilerplate) gone from the user entry — the live path's rendering. + #[test] + fn disk_task_notification_becomes_a_note_beside_the_prompt() { + let s = view_of(&agent_run(&completion_note("aaa1", "toolu_A", "THE REPORT"))); + let note = only_note(&s); + assert!(note.content.starts_with('✔'), "{}", note.content); + assert!(note.content.contains("sweep finished"), "{}", note.content); + assert!(note.content.contains("128.6k tok · 63 tools · 18m35s"), "{}", note.content); + // The report went to the tool call, so it is not repeated inline, and + // the spool path is only shown when there is no report at all. + assert!(!note.content.contains("THE REPORT"), "{}", note.content); + assert!(!note.content.contains(".output"), "{}", note.content); + for e in &s.entries { + assert!(!e.content.contains(""), "raw XML left in {:?}", e.content); + assert!(!e.content.contains("task-notification fires"), "boilerplate kept"); + } + // What the user actually typed survives, as its own entry after the note. + let users: Vec<&str> = s + .entries + .iter() + .filter(|e| e.kind == Kind::User) + .map(|e| e.content.as_str()) + .collect(); + assert_eq!(users, ["go", "what did it find?"]); + let pos = |k: &Kind| s.entries.iter().position(|e| &e.kind == k).unwrap(); + assert!( + pos(&Kind::TaskNote) < s.entries.iter().rposition(|e| e.kind == Kind::User).unwrap(), + "note precedes the prompt it rode in with" + ); + } + + /// The report replaces the `Async agent launched…` placeholder on the + /// `Agent` entry when `` resolves to a call in this view. + #[test] + fn disk_task_report_replaces_the_launch_placeholder() { + let s = view_of(&agent_run(&completion_note("aaa1", "toolu_A", "THE REPORT"))); + let tool = s + .entries + .iter() + .find(|e| matches!(&e.kind, Kind::Tool { name } if name == "Agent")) + .expect("Agent entry"); + let r = tool.result.as_ref().expect("a result"); + assert_eq!(r.content, "THE REPORT"); + assert!(!r.is_error); + } + + /// … and when it resolves to nothing (the spawning turn is outside this + /// view), the report stays inline in the note rather than being lost. + #[test] + fn disk_task_report_stays_inline_when_unresolved() { + let s = view_of(&agent_run(&completion_note("aaa1", "toolu_GONE", "THE REPORT"))); + let note = only_note(&s); + assert!(note.content.contains("\n THE REPORT"), "{}", note.content); + let tool = s + .entries + .iter() + .find(|e| matches!(&e.kind, Kind::Tool { name } if name == "Agent")) + .expect("Agent entry"); + assert!( + tool.result.as_ref().unwrap().content.contains("Async agent launched"), + "the launch acknowledgement is untouched when the report can't be placed" + ); + } + + /// A failed run marks the attached report as an error, and a `` + /// holding a raw error body (4 of the real ones) is kept on the note. + #[test] + fn disk_failed_task_marks_the_report_as_an_error() { + let note = "\n\ + aaa1\n\ + toolu_A\n\ + Error: 403: {\"message\":\"Access to model denied.\"}\n\ + Agent \"sweep\" failed\n\ + partial work\n\ + "; + let s = view_of(&agent_run(note)); + let n = only_note(&s); + assert!(n.content.starts_with('✖'), "{}", n.content); + assert!(n.content.contains("Error: 403"), "{}", n.content); + let tool = s + .entries + .iter() + .find(|e| matches!(&e.kind, Kind::Tool { name } if name == "Agent")) + .expect("Agent entry"); + let r = tool.result.as_ref().unwrap(); + assert_eq!(r.content, "partial work"); + assert!(r.is_error, "a failed run's report is an error result"); + } + + /// A monitor event is a progress ping: a note, no lane touched, no usage. + /// (An on-disk lane is finished by construction — `Session::add_lane` — + /// so what matters here is that the short id claims no lane at all.) + #[test] + fn disk_monitor_event_is_a_note_and_claims_no_lane() { + let note = "\n\ + b8s2gso3a\n\ + Monitor event: \"world skin bench\"\n\ + BENCH progress phase=traverse ms=141974\n\ + "; + let s = view_of(&agent_run(note)); + let n = only_note(&s); + assert!(n.content.starts_with('▸'), "{}", n.content); + assert!(n.content.contains("Monitor event: world skin bench"), "{}", n.content); + assert!(n.content.contains("\n BENCH progress phase=traverse"), "{}", n.content); + assert!(!s.lane_of_agent.contains_key("b8s2gso3a"), "a monitor id is not a lane"); + assert_eq!(s.lanes.len(), 1, "no agent transcripts here, so main only"); + // Its `Agent` call keeps the launch acknowledgement: a monitor event + // reports on nothing that has a report. + let tool = s + .entries + .iter() + .find(|e| matches!(&e.kind, Kind::Tool { name } if name == "Agent")) + .expect("Agent entry"); + assert!(tool.result.as_ref().unwrap().content.contains("Async agent launched")); + } + + /// A notification wrapped in a `` (Claude Code does this) + /// is still lifted, and a reminder that held nothing else vanishes with it. + #[test] + fn disk_notification_inside_a_reminder_is_lifted() { + let note = completion_note("aaa1", "toolu_A", "THE REPORT"); + let wrapped = format!("\n{note}\n"); + let s = view_of(&agent_run(&wrapped)); + let n = only_note(&s); + assert!(n.content.starts_with('✔'), "{}", n.content); + assert!( + !s.entries.iter().any(|e| e.kind == Kind::Reminder), + "a reminder that was only a notification leaves nothing behind" + ); + } + + /// `` totals reach the agent's own lane — the only token figures an + /// on-disk lane can have. Matched by agent id: the notification's + /// `` is the stem of `subagents/agent-.jsonl`. + #[test] + fn disk_task_usage_reaches_the_lane() { + let dir = std::env::temp_dir().join(format!("ct-usage-{}", std::process::id())); + let subs = dir.join("subagents"); + std::fs::create_dir_all(&subs).unwrap(); + let main = dir.join("s-usage.jsonl"); + std::fs::write( + &main, + agent_run(&completion_note("aaa1", "toolu_A", "THE REPORT")).join("\n"), + ) + .unwrap(); + std::fs::write( + subs.join("agent-aaa1.meta.json"), + r#"{"agentType":"Explore","description":"sweep","toolUseId":"toolu_A","spawnDepth":1}"#, + ) + .unwrap(); + std::fs::write( + subs.join("agent-aaa1.jsonl"), + r#"{"type":"assistant","isSidechain":true,"uuid":"s1","parentUuid":null,"message":{"model":"claude-y","content":[{"type":"text","text":"child"}]}}"#, + ) + .unwrap(); + + let agents = scan_agents_in(&subs); + let s = load_file_view(&main, "s-usage", &agents).expect("view").session; + std::fs::remove_dir_all(&dir).ok(); + + let lane = &s.lanes[1]; + assert_eq!(lane.agent_id, "aaa1"); + assert_eq!(lane.subagent_tokens, Some(128633)); + assert_eq!(lane.tool_uses, Some(63)); + assert_eq!(lane.duration_ms, Some(1115197)); + // The main lane never takes an agent's totals. + assert_eq!(s.lanes[MAIN_LANE as usize].subagent_tokens, None); + } + #[test] fn materialize_chain_and_stitch() { let tree = build_tree(branched()); @@ -1265,3 +1665,4 @@ mod tests { assert!(s.entries.iter().all(|e| e.done)); } } + diff --git a/src/ui.rs b/src/ui.rs index 8eff68e..26984c2 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -187,8 +187,13 @@ impl CachedEntry { } /// Cheap change-detector for a cached entry: content only ever grows (or is -/// swapped for the pretty-printed form along with `done`), results attach -/// once — length + flags capture every mutation the app performs. The trailing +/// swapped for the pretty-printed form along with `done`), and a result +/// attaches once — or, for an `Agent` call, is later *replaced* by the report +/// its `` carried (`app::Session::attach_task_report`). +/// Length + flags capture every mutation the app performs; a replacement that +/// happened to be byte-for-byte the same length as the `Async agent launched…` +/// acknowledgement is the one thing this would miss, which costs a stale +/// render of one tool result and no correctness. The trailing /// bool folds in feed focus, but *only* for `Kind::User` entries (their block /// color tracks focus): toggling focus then re-renders just the user blocks, /// not the whole transcript. @@ -285,12 +290,43 @@ fn indexed_rgb(i: u8) -> (u8, u8, u8) { fn entry_lines(e: &Entry, width: u16, focused: bool) -> Vec> { let mut lines: Vec> = Vec::new(); match &e.kind { + // Meta is one dim row — except a citation source list, which is a + // head line plus one row per source. Splitting is the whole difference: + // a `\n` inside a single `Line` is not a row break to ratatui. + Kind::Meta if e.content.contains('\n') => { + for l in e.content.lines() { + lines.push(Line::from(sanitize(l)).dark_gray()); + } + } Kind::Meta => lines.push(Line::from(e.content.clone()).dark_gray()), // Request-context metadata the model received (the system prompt is too // long to show verbatim, so only its size; tools as a name list). Dim, // wrapping is handled by the feed Paragraph. Kind::System => lines.push(Line::from(format!("⚙ {}", e.content)).dark_gray()), Kind::ToolDefs => lines.push(Line::from(format!("🔧 {}", e.content)).dark_gray()), + // A ``, already reduced to its final text by + // `app::task_note_line` — this arm is a pure styling pass, so the + // wording stays in one place. **The leading glyph is the status + // channel** (`app::TaskNotification::glyph` writes it): change the + // glyph set there and this match must follow. Continuation rows (a + // monitor's `` payload, or a report that had no `Agent` entry + // to attach to) are dim under the head line. + Kind::TaskNote => { + let mut rows = e.content.lines(); + if let Some(head) = rows.next() { + let fg = match head.chars().next() { + Some('✔') => Color::Green, + Some('✖') => Color::Red, + Some('◼') => Color::Yellow, + Some('▸') => Color::Cyan, + _ => Color::DarkGray, + }; + lines.push(Line::from(sanitize(head)).fg(fg)); + } + for l in rows { + lines.push(Line::from(sanitize(l)).dark_gray()); + } + } Kind::User => { let style = user_block_style(focused); let w = (width as usize).max(1); @@ -301,12 +337,59 @@ fn entry_lines(e: &Entry, width: u16, focused: bool) -> Vec> { // no trailing blank: the content is right-trimmed first. let mut first = true; for raw in e.content.trim_end().lines() { - let prefixed = format!("{}{}", if first { "❯ " } else { " " }, sanitize(raw)); + // Slash-command stdout rides along inside the prompt verbatim + // (`/model` prints `\x1b[1mSonnet 5\x1b[22m`). This block is a + // *filled* rectangle whose foreground `color_on` picks from the + // background's luminance so it stays legible under any terminal + // theme — an arbitrary ANSI foreground would destroy exactly + // that contrast. So colour is dropped here and only the + // attributes (bold/dim/italic/underline) survive; everywhere + // else in the feed (`ansi::spans`) keeps the colour too. + let (plain, mods) = crate::ansi::plain_with_mods(raw); + let prefix = if first { "❯ " } else { " " }; + let prefixed = format!("{prefix}{plain}"); + // Attributes indexed by character of `prefixed`; the marker + // columns carry none. + let src: Vec = prefixed.chars().collect(); + let mut src_mods = vec![Modifier::empty(); prefix.chars().count()]; + src_mods.extend(mods); + let mut cur = 0usize; for seg in wrap_words(&prefixed, w) { - let mut row = seg; - let pad = w.saturating_sub(row.chars().count()); - row.extend(std::iter::repeat_n(' ', pad)); - lines.push(Line::from(Span::styled(row, style))); + let mut spans: Vec> = Vec::new(); + let mut run = String::new(); + let mut run_mod = Modifier::empty(); + let mut used = 0usize; + for ch in seg.chars() { + // `wrap_words` only ever drops whitespace and never + // reorders, so walking the source forward to the next + // matching char re-aligns the attributes after a wrap. + while cur < src.len() && src[cur] != ch { + cur += 1; + } + let m = src_mods.get(cur).copied().unwrap_or_default(); + cur = cur.saturating_add(1).min(src.len()); + if !run.is_empty() && m != run_mod { + spans.push(Span::styled( + std::mem::take(&mut run), + style.add_modifier(run_mod), + )); + } + if run.is_empty() { + run_mod = m; + } + run.push(ch); + used += 1; + } + if !run.is_empty() { + spans.push(Span::styled(run, style.add_modifier(run_mod))); + } + // Pad to exactly `w` so the block ends flush with the + // borders (the filler carries no ANSI attributes). + let pad = w.saturating_sub(used); + if pad > 0 { + spans.push(Span::styled(" ".repeat(pad), style)); + } + lines.push(Line::from(spans)); } first = false; } @@ -316,9 +399,16 @@ fn entry_lines(e: &Entry, width: u16, focused: bool) -> Vec> { } Kind::Reminder => { // Injected context Claude Code received — shown dim, like thinking. + // Reminders carry client-side machinery verbatim, including + // `` from slash commands, which is genuine + // terminal output: its SGR sequences become styling on top of the + // dim italic base rather than literal `[1m` text. + let base = Style::new().dark_gray().italic(); lines.push(Line::from("⌁ system reminder").dark_gray().italic()); for l in e.content.lines() { - lines.push(Line::from(format!(" {}", sanitize(l))).dark_gray().italic()); + let mut spans = vec![Span::styled(" ", base)]; + spans.extend(crate::ansi::spans(l, base)); + lines.push(Line::from(spans)); } } Kind::Thinking => { @@ -1433,7 +1523,7 @@ fn draw( } else if a.model_popup.is_some() { "enter new session · j/k move · esc cancel" } else if matches!(a.agent_popup, Some(AgentPopup::List(_))) { - "enter watch agent · j/k move · esc/A close" + "enter open · j/k move · esc/A close" } else if a.agent_popup.is_some() { "j/k · PgUp/PgDn · g/G scroll · [/] agent · esc back · A close" } else if embed_focused { @@ -1447,15 +1537,18 @@ fn draw( } else { "q quit · n new · j/k move · space/→ tree · f filter · c continue · ctrl-↓ attach" }; - // `A` only matters when the displayed session has agents at all — and it is - // the *only* way to see one, so the hint leads the footer instead of + // `A` only matters when the displayed session has side lanes at all — and + // it is the *only* way to see one, so the hint leads the footer instead of // trailing it, where the long key list gets cut off on narrow terminals. // Not while the pane has focus: there every key belongs to the child. - let n_agents = a.agent_list().len(); - let keys = if a.agent_popup.is_some() || embed_focused || n_agents == 0 { + // + // "streams", not "agents": a lane is a subagent *or* a nested server-tool + // call (a hosted web search), and the popup lists both. + let n_lanes = a.agent_list().len(); + let keys = if a.agent_popup.is_some() || embed_focused || n_lanes == 0 { keys.to_string() } else { - format!("A agents ({n_agents}) · {keys}") + format!("A streams ({n_lanes}) · {keys}") }; f.render_widget( Paragraph::new(Line::from(format!(" {} | {keys}", a.status)).dark_gray()), @@ -1657,11 +1750,12 @@ fn main_title(s: &Session, show_sessions: bool) -> String { /// walk to. fn lane_title(l: &Lane, pos: Option<(usize, usize)>) -> String { let mut t = format!( - " {} {} · {} · out {}", + " {} {} · {} · {}{}", lane_mark(l), l.title(), short_model(&l.model), - fmt_tokens(l.output_tokens) + lane_tokens(l), + lane_dur(l), ); if let Some((i, n)) = pos.filter(|&(_, n)| n > 1) { t.push_str(&format!(" · {i}/{n}")); @@ -1670,10 +1764,36 @@ fn lane_title(l: &Lane, pos: Option<(usize, usize)>) -> String { t } +/// Token total for a lane. A ``'s `` block is Claude +/// Code's own accounting for the *whole* agent run, so it wins over the +/// `output_tokens` we summed off the wire — which covers only the turns that +/// passed through us, and is zero for a lane spliced in from disk (the +/// transcript records no usage). Without one, the wire count is still shown, +/// labelled `out` to say so. +fn lane_tokens(l: &Lane) -> String { + match l.subagent_tokens { + Some(t) => format!("{} tok", fmt_tokens(t)), + None => format!("out {}", fmt_tokens(l.output_tokens)), + } +} + +/// ` · 18m35s` when a notification reported the run's wall time, else nothing. +fn lane_dur(l: &Lane) -> String { + l.duration_ms.map(|d| format!(" · {}", fmt_ms(d))).unwrap_or_default() +} + /// Activity mark, the same three-way answer the picker sorts on /// (`Lane::running`): running now, known finished, or neither (idle without a /// finish signal, or a lane read from disk). fn lane_mark(l: &Lane) -> &'static str { + // A nested server-tool call is not an agent: the tool glyph the feed + // already uses for a tool call says so at a glance. It is a single request, + // and no `` will ever confirm it, so the three-way + // running/finished/idle answer has nothing to add — the picker still reads + // its liveness from the accent styling and the running-first order. + if l.is_server_tool() { + return "⚙"; + } if l.running() { "⟳" } else if l.finished() { @@ -1701,11 +1821,14 @@ fn draw_agent_list(f: &mut Frame, area: Rect, s: &Session, lanes: &[LaneId], sel Style::new().fg(Color::White) }; let head = format!(" {} {}", lane_mark(l), l.title()); + // Same substitution as `lane_tokens`: a notification's `` + // counted the whole run, our own tally only what we saw. let meta = format!( - " {} · {} tools · out {}", + " {} · {} tools · {}{}", short_model(&l.model), - l.tool_calls, - fmt_tokens(l.output_tokens) + l.tool_uses.unwrap_or(l.tool_calls as u64), + lane_tokens(l), + lane_dur(l), ); ListItem::new(vec![ Line::from(truncate_str(&head, inner_w)).style(style), @@ -1723,7 +1846,7 @@ fn draw_agent_list(f: &mut Frame, area: Rect, s: &Session, lanes: &[LaneId], sel List::new(items) .block( Block::bordered() - .title(format!(" agents · {running} of {} running ", lanes.len())) + .title(format!(" streams · {running} of {} running ", lanes.len())) .border_style(Style::new().fg(ACCENT).bold()), ) .highlight_style(Style::new().reversed()), @@ -2103,7 +2226,8 @@ fn render_tool<'a>( width: u16, ) { let sf = |k: &str| input.get(k).and_then(Value::as_str); - match name.to_ascii_lowercase().as_str() { + let lower = name.to_ascii_lowercase(); + match lower.as_str() { // Diff/content view; success confirmations are noise, only surface failures. "write" | "edit" if render_file_tool(name, input, out, width) => { if result.is_some_and(|r| r.is_error) { @@ -2197,9 +2321,216 @@ fn render_tool<'a>( push_result(out, result, None); } } - // Generic fallback: tool name header, inputs as `key: value` rows. + // Background task queue. The subject is the whole point of the call; + // the description is the fine print, and the ack ("Task #1 created + // successfully: …") is one confirmation line. + "taskcreate" => { + out.push(Line::from(vec![ + "⚙ Task + ".yellow().bold(), + sf("subject").unwrap_or("?").to_string().bold(), + ])); + if let Some(d) = sf("description").filter(|d| !d.trim().is_empty()) { + push_wrapped(out, d, " ", width, Style::new().dark_gray()); + } + push_result(out, result, ok_clip(result, 1)); + } + "taskupdate" => { + let id = sf("taskId").or_else(|| sf("task_id")).unwrap_or("?"); + let status = sf("status").unwrap_or("?"); + // Same palette as `todowrite`, so a status reads identically + // whichever of the two task tools the session is driving. + let (mark, mark_style) = match status { + "completed" => ("☑", Style::new().green()), + "in_progress" => ("◐", Style::new().yellow()), + _ => ("☐", Style::new().dark_gray()), + }; + out.push(Line::from(vec![ + format!("⚙ Task #{id} → ").yellow().bold(), + Span::styled(format!("{mark} {}", sanitize(status)), mark_style), + ])); + // The result only echoes the new state back; failures still matter. + if result.is_some_and(|r| r.is_error) { + push_result(out, result, None); + } + } + // A background watch: the description says what it is for, the command + // is what actually runs — shown like `bash`, cyan and unabridged. + "monitor" => { + out.push(Line::from(vec![ + "⚙ Monitor ".yellow().bold(), + sf("description").unwrap_or_default().to_string().bold(), + ])); + if let Some(cmd) = sf("command") { + for l in cmd.lines() { + out.push(Line::from(format!(" {}", sanitize(l))).cyan()); + } + } + // The websocket form carries a url instead of a command. + if let Some(url) = input.get("ws").and_then(|w| w.get("url")).and_then(Value::as_str) { + out.push(Line::from(format!(" ws {}", sanitize(url))).cyan()); + } + let mut flags: Vec = Vec::new(); + if let Some(ms) = input.get("timeout_ms").and_then(Value::as_u64) { + flags.push(format!("timeout {}", fmt_ms(ms))); + } + if input.get("persistent").and_then(Value::as_bool) == Some(true) { + flags.push("persistent".into()); + } + if !flags.is_empty() { + out.push(Line::from(format!(" {}", flags.join(" · "))).dark_gray()); + } + // "Monitor started (task …). You will be notified…" — one line is + // the whole signal. + push_result(out, result, ok_clip(result, 1)); + } + // Background-task control: the inputs are an id plus a flag or two, so + // one line covers them. The result is the interesting half here + // (TaskOutput returns the task's actual output), so it isn't clipped — + // except TaskStop's one-line ack. + l @ ("taskoutput" | "taskget" | "tasklist" | "taskstop") => { + let label = match l { + "taskoutput" => "TaskOutput", + "taskget" => "TaskGet", + "tasklist" => "TaskList", + _ => "TaskStop", + }; + let id = sf("task_id") + .or_else(|| sf("taskId")) + .or_else(|| sf("shell_id")) + .unwrap_or_default(); + let mut head = vec![ + format!("⚙ {label} ").yellow().bold(), + sanitize(id).bold(), + ]; + let mut opts: Vec = Vec::new(); + if input.get("block").and_then(Value::as_bool) == Some(true) { + opts.push("block".into()); + } + if let Some(t) = input.get("timeout").and_then(Value::as_u64) { + opts.push(format!("timeout {t}s")); + } + if !opts.is_empty() { + head.push(format!(" ({})", opts.join(", ")).dark_gray()); + } + out.push(Line::from(head)); + let clip = if l == "taskstop" { ok_clip(result, 1) } else { None }; + push_result(out, result, clip); + } + // The interactive prompt the tap grows the pane for. Its JSON nests + // questions → options → descriptions; dumping that raw is screens of + // braces, so it is laid out as a list instead. + "askuserquestion" => { + out.push(Line::from("⚙ AskUserQuestion").yellow().bold()); + for q in arr(input, "questions") { + let mut head = Vec::new(); + if let Some(h) = q + .get("header") + .and_then(Value::as_str) + .filter(|h| !h.is_empty()) + { + head.push(format!(" [{}]", sanitize(h)).bold()); + } + if q.get("multiSelect").and_then(Value::as_bool) == Some(true) { + head.push(" multi-select".dark_gray()); + } + if !head.is_empty() { + out.push(Line::from(head)); + } + let text = q.get("question").and_then(Value::as_str).unwrap_or("?"); + push_wrapped(out, text, " ", width, Style::new()); + for o in arr(q, "options") { + let label = o.get("label").and_then(Value::as_str).unwrap_or("?"); + out.push(Line::from(format!(" • {}", sanitize(label))).cyan()); + if let Some(d) = o + .get("description") + .and_then(Value::as_str) + .filter(|d| !d.trim().is_empty()) + { + push_wrapped(out, d, " ", width, Style::new().dark_gray()); + } + } + } + // The answer the user picked. + push_result(out, result, Some(5)); + } + // The plan is markdown the model wrote — headings, numbered steps — so + // render it as such rather than as one long escaped string. + "exitplanmode" => { + out.push(Line::from("⚙ ExitPlanMode").yellow().bold()); + push_markdown(out, sf("plan").unwrap_or_default(), width); + push_result(out, result, Some(3)); + } + "skill" => { + let mut head = vec![ + "⚙ Skill ".yellow().bold(), + sf("skill").unwrap_or("?").to_string().bold(), + ]; + if let Some(a) = sf("args").filter(|a| !a.trim().is_empty()) { + head.push(format!(" {}", one_line(a)).cyan()); + } + out.push(Line::from(head)); + // "Launching skill: " — the instructions themselves land in + // the next turn's context, not in this result. + push_result(out, result, ok_clip(result, 1)); + } + // `websearch` is Claude Code's client-side tool; `web_search` is + // Anthropic's *hosted* one, which the nested server-tool request + // streams (see `app::ReqKind::ServerTool`). Same input key, and + // `app::server_tool_result` emits the same `Links: […]` result shape, + // so one arm renders both. + "websearch" | "web_search" => { + out.push(Line::from(vec![ + "⚙ WebSearch ".yellow().bold(), + format!("\"{}\"", sanitize(sf("query").unwrap_or("?"))).cyan(), + ])); + for key in ["allowed_domains", "blocked_domains"] { + let doms: Vec<&str> = arr(input, key).iter().filter_map(Value::as_str).collect(); + if !doms.is_empty() { + out.push(Line::from(format!(" {key}: {}", doms.join(", "))).dark_gray()); + } + } + let handled = match result { + Some(r) if !r.is_error => push_search_result(out, &r.content, width), + _ => false, + }; + if !handled { + push_result(out, result, None); + } + } + "webfetch" => { + out.push(Line::from(vec![ + "⚙ WebFetch ".yellow().bold(), + sanitize(sf("url").unwrap_or("?")).underlined(), + ])); + if let Some(p) = sf("prompt").filter(|p| !p.trim().is_empty()) { + push_wrapped(out, p, " ", width, Style::new().dark_gray()); + } + // The result is the model's summary of the page: markdown. + match result { + Some(r) if !r.is_error => push_markdown(out, &r.content, width), + _ => push_result(out, result, None), + } + } + // Deferred tool discovery: one line. Its result arrives as + // `tool_reference` content blocks that `app.rs` doesn't flatten yet, so + // whatever string reaches us is rendered as-is. + "toolsearch" => { + let mut head = vec![ + "⚙ ToolSearch ".yellow().bold(), + format!("\"{}\"", sanitize(sf("query").unwrap_or("?"))).cyan(), + ]; + if let Some(n) = input.get("max_results").and_then(Value::as_u64) { + head.push(format!(" max {n}").dark_gray()); + } + out.push(Line::from(head)); + push_result(out, result, Some(10)); + } + // Generic fallback: tool name header, inputs as `key: value` rows. An + // MCP tool's wire name is `mcp____`; split that so the + // server and the tool read as two names instead of one underscore run. _ => { - out.push(Line::from(format!("⚙ {name}")).yellow().bold()); + let head = mcp_header(name).unwrap_or_else(|| format!("⚙ {name}")); + out.push(Line::from(head).yellow().bold()); match input.as_object() { Some(obj) => { for (k, v) in obj { @@ -2224,6 +2555,120 @@ fn render_tool<'a>( } } +/// A JSON array field as a slice — empty when the key is absent or isn't an +/// array, so a shape change upstream renders short instead of panicking. +fn arr<'v>(v: &'v Value, key: &str) -> &'v [Value] { + v.get(key) + .and_then(Value::as_array) + .map_or(&[], Vec::as_slice) +} + +/// Result clip that always shows a failure in full: a chatty success ack +/// ("Task #1 created successfully: …", "Monitor started (task …)") is noise, +/// an error never is. +fn ok_clip(result: Option<&ToolResult>, lines: usize) -> Option { + (!result.is_some_and(|r| r.is_error)).then_some(lines) +} + +/// Push `text` as wrapped rows under `indent`, one source line at a time so +/// paragraph breaks survive the wrap. For the free-text fields of the +/// prompt-shaped tools (a question, an option's description, a task's +/// description) — long enough to need wrapping, not markdown. +fn push_wrapped<'a>(out: &mut Vec>, text: &str, indent: &str, width: u16, style: Style) { + let w = (width as usize) + .saturating_sub(indent.chars().count()) + .max(1); + for src in text.lines() { + for row in wrap_words(&sanitize(src), w) { + out.push(Line::from(Span::styled(format!("{indent}{row}"), style))); + } + } +} + +/// Render `text` as markdown, indented so it reads as a tool's content rather +/// than assistant prose. `ExitPlanMode`'s plan and `WebFetch`'s page summary +/// are both markdown the model wrote; as a raw string they lose every heading +/// and list they were written with. +fn push_markdown<'a>(out: &mut Vec>, text: &str, width: u16) { + const INDENT: usize = 4; + let clean = sanitize_md(text); + let w = width.saturating_sub(INDENT as u16).max(10); + for l in crate::markdown::render(&clean, w) { + let mut l = own_line(l); + l.spans.insert(0, Span::raw(" ".repeat(INDENT))); + out.push(l); + } +} + +/// `mcp____` → `⚙ MCP /`; `None` for every other +/// name, so the caller falls back to the plain `⚙ ` header. +fn mcp_header(name: &str) -> Option { + const PREFIX: &str = "mcp__"; + if !name + .get(..PREFIX.len()) + .is_some_and(|p| p.eq_ignore_ascii_case(PREFIX)) + { + return None; + } + let (server, tool) = name[PREFIX.len()..].split_once("__")?; + (!server.is_empty() && !tool.is_empty()).then(|| format!("⚙ MCP {server}/{tool}")) +} + +/// WebSearch hands back one plain string: a `Web search results for query: …` +/// header, a `Links: [{"title":…,"url":…}]` JSON array, and then the model's +/// prose summary. Split that into one `⎿ title url` row per hit plus the prose +/// underneath. Returns false when there is no parseable, non-empty `Links:` +/// array so the caller can fall back to the raw result rendering — a shape +/// change upstream degrades to what we rendered before, never to a panic. +fn push_search_result<'a>(out: &mut Vec>, content: &str, width: u16) -> bool { + /// Titles are prose and can run long; the url is the load-bearing half. + const TITLE_W: usize = 56; + let found = content.lines().enumerate().find_map(|(i, l)| { + l.trim_start() + .strip_prefix("Links:") + .map(|rest| (i, rest.trim())) + }); + let Some((idx, raw)) = found else { return false }; + let Ok(Value::Array(items)) = serde_json::from_str::(raw) else { + return false; + }; + if items.is_empty() { + return false; + } + let mut first = true; + for it in &items { + let Some(url) = it.get("url").and_then(Value::as_str) else { + continue; + }; + let title = it.get("title").and_then(Value::as_str).unwrap_or_default(); + let prefix = if first { " ⎿ " } else { " " }; + first = false; + out.push(Line::from(vec![ + prefix.dark_gray(), + truncate_str(title, TITLE_W).into(), + " ".into(), + sanitize(url).underlined(), + ])); + } + let prose = content.lines().skip(idx + 1).collect::>().join("\n"); + push_wrapped(out, prose.trim(), " ", width, Style::new().dark_gray()); + true +} + +/// Milliseconds as something readable in a dimmed flag row (`1200000` → +/// `20m00s`): the tool JSON carries a raw millisecond count, which at monitor +/// timeouts is six digits of nothing. +fn fmt_ms(ms: u64) -> String { + let s = ms / 1000; + if s >= 60 { + format!("{}m{:02}s", s / 60, s % 60) + } else if s > 0 { + format!("{s}s") + } else { + format!("{ms}ms") + } +} + /// Renders a tool_result under its tool entry: `⎿`-marked, dimmed (red when /// `is_error`). `limit` clips to the first N lines with a "N more lines" tail. fn push_result<'a>(out: &mut Vec>, result: Option<&ToolResult>, limit: Option) { @@ -2234,16 +2679,23 @@ fn push_result<'a>(out: &mut Vec>, result: Option<&ToolResult>, limit: } return; } + // A tool_result is real terminal output — colourised `cargo`/rustfmt + // diffs, slash-command stdout — so its SGR sequences become styling here + // instead of literal `[31m` text. The dim (or red) base still governs + // everything the output doesn't colour itself, and `39`/`49` fall back to + // it, so an uncoloured result looks exactly as it did before. + let base = if r.is_error { + Style::new().red() + } else { + Style::new().dark_gray() + }; let total = r.content.lines().count(); let shown = limit.map_or(total, |n| n.min(total)); for (i, l) in r.content.lines().take(shown).enumerate() { let prefix = if i == 0 { " ⎿ " } else { " " }; - let line = format!("{prefix}{}", sanitize(l)); - out.push(if r.is_error { - Line::from(line).red() - } else { - Line::from(line).dark_gray() - }); + let mut spans = vec![Span::styled(prefix, base)]; + spans.extend(crate::ansi::spans(l, base)); + out.push(Line::from(spans)); } if shown < total { out.push( @@ -2370,50 +2822,80 @@ fn push_numbered<'a>( } } -/// A literal tab survives ratatui as a `\t` cell symbol that the terminal then -/// renders by jumping to the next tab stop, desyncing the per-cell cursor and -/// scattering everything painted after it (other control chars render -/// zero-width and smear too). For a single visual row: expand tabs, drop the -/// rest. Tab-indented code is the main offender. +/// Plain text for a single visual row. A literal tab survives ratatui as a +/// `\t` cell symbol that the terminal then renders by jumping to the next tab +/// stop, desyncing the per-cell cursor and scattering everything painted after +/// it (other control chars render zero-width and smear too), so tabs expand and +/// the rest are dropped. ANSI escape sequences are *parsed* away by +/// [`crate::ansi`] rather than shedding their ESC byte as one more control char +/// — that used to leave `[1m` behind as literal text. Use `ansi::spans` instead +/// wherever the styling itself is worth keeping. fn sanitize(l: &str) -> String { - let mut s = String::with_capacity(l.len()); - for c in l.chars() { - match c { - '\t' => s.push_str(" "), - c if c.is_control() => {} - c => s.push(c), - } - } - s + crate::ansi::strip(l) } /// Like [`sanitize`] but keeps `\n`, for multi-line content rendered as a block -/// (markdown, where newlines carry structure). Same tab/control handling: the -/// content is split into rows downstream, so a stray tab must already be gone. +/// (markdown, where newlines carry structure). Same tab/control/escape +/// handling: the content is split into rows downstream, so a stray tab must +/// already be gone. fn sanitize_md(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for c in s.chars() { - match c { - '\n' => out.push('\n'), - '\t' => out.push_str(" "), - c if c.is_control() => {} - c => out.push(c), - } - } - out + crate::ansi::strip_multiline(s) } #[cfg(test)] mod tests { use super::{ - SHRINK_DELAY, base64, color_on, entry_lines, popup_rect, sanitize_md, smooth_compact, - truncate_str, wrap_words, + SHRINK_DELAY, base64, color_on, entry_lines, fmt_ms, lane_dur, lane_mark, lane_title, + lane_tokens, mcp_header, popup_rect, sanitize_md, smooth_compact, truncate_str, + user_block_style, wrap_words, }; - use crate::app::{Entry, Kind}; + use crate::app::{Entry, Kind, Lane, MAIN_LANE, ToolResult}; use ratatui::layout::Rect; - use ratatui::style::Color; + use ratatui::style::{Color, Modifier}; + use ratatui::text::Line; use std::time::Instant; + /// A finished tool entry with its input JSON and (optionally) the + /// tool_result that came back — the shape `entry_lines` renders from. + fn tool(name: &str, input: &str, result: Option<&str>) -> Entry { + Entry { + kind: Kind::Tool { name: name.into() }, + content: input.into(), + done: true, + result: result.map(|c| ToolResult { + content: c.into(), + is_error: false, + }), + lane: MAIN_LANE, + } + } + + /// One rendered row as plain text (spans concatenated). + fn flat(l: &Line<'static>) -> String { + l.spans.iter().map(|s| s.content.as_ref()).collect() + } + + /// All rendered rows as plain text, newline-separated. + fn text(lines: &[Line<'static>]) -> String { + lines.iter().map(flat).collect::>().join("\n") + } + + /// Effective foreground of the row containing `needle`: the line's style + /// patched by the span's own, which is how ratatui composes them when it + /// paints (some arms style the whole `Line`, others each `Span`). + fn fg_of(lines: &[Line<'static>], needle: &str) -> Option { + let l = lines + .iter() + .find(|l| flat(l).contains(needle)) + .unwrap_or_else(|| panic!("no rendered row contains {needle:?}")); + let s = l + .spans + .iter() + .find(|s| s.content.contains(needle)) + .unwrap_or_else(|| panic!("{needle:?} straddles spans")); + l.style.patch(s.style).fg + } + /// The subagent popup covers 80% of the *feed* area, centred — never the /// sessions panel, and never so small that a tiny terminal loses the view. #[test] @@ -2529,4 +3011,402 @@ mod tests { assert_eq!(base64(b"foo"), "Zm9v"); assert_eq!(base64(b"foobar"), "Zm9vYmFy"); } + + /// A tool_result is real terminal output. Its SGR sequences must become + /// span styling, not survive as literal `[31m` text, and the dim base must + /// still govern whatever the output didn't colour itself. + #[test] + fn tool_result_ansi_becomes_style_not_literal_text() { + let e = tool( + "Bash", + r#"{"command":"cargo fmt --check"}"#, + Some("\u{1b}[31m- app.lock()\u{1b}[0m\nplain tail"), + ); + let lines = entry_lines(&e, 80, false); + let t = text(&lines); + assert!(t.contains("- app.lock()"), "{t}"); + assert!(!t.contains("[31m"), "escape leaked as literal text: {t}"); + assert_eq!(fg_of(&lines, "app.lock()"), Some(Color::Red)); + assert_eq!(fg_of(&lines, "plain tail"), Some(Color::DarkGray)); + } + + /// `` reaches the feed through the reminder path; + /// its styling applies on top of the dim italic base rather than replacing + /// it (`/model` prints bold, the surrounding reminder stays dim). + #[test] + fn reminder_renders_slash_command_stdout_styling() { + let e = Entry::done( + Kind::Reminder, + "Set model to \u{1b}[1mOpus 5\u{1b}[22m" + .into(), + ); + let lines = entry_lines(&e, 80, false); + let t = text(&lines); + assert!(t.contains("Set model to Opus 5"), "{t}"); + assert!(!t.contains("[1m"), "{t}"); + let bold = lines + .iter() + .flat_map(|l| &l.spans) + .find(|s| s.content.contains("Opus 5")) + .expect("styled run"); + assert!(bold.style.add_modifier.contains(Modifier::BOLD)); + assert!( + bold.style.add_modifier.contains(Modifier::ITALIC), + "reminder base survives underneath" + ); + } + + /// The user-prompt block is a filled rectangle whose foreground `color_on` + /// picks for contrast, so ANSI colour is dropped inside it while the + /// attributes survive — and every row still pads flush to the full width. + #[test] + fn user_prompt_drops_ansi_colour_but_keeps_bold() { + let raw = "Set model to \u{1b}[1;31mSonnet 5\u{1b}[22m and saved as your default"; + let e = Entry::done(Kind::User, raw.into()); + let lines = entry_lines(&e, 40, true); + let t = text(&lines); + assert!(t.contains("Sonnet 5"), "{t}"); + assert!(!t.contains("[1m") && !t.contains("[22m"), "{t}"); + + let bold = lines + .iter() + .flat_map(|l| &l.spans) + .find(|s| s.content.contains("Sonnet")) + .expect("styled run"); + assert!(bold.style.add_modifier.contains(Modifier::BOLD)); + + let block = user_block_style(true); + for l in &lines { + if l.spans.is_empty() { + continue; // the trailing separator row + } + for s in &l.spans { + assert_eq!(s.style.bg, block.bg, "block fill broken by {:?}", s.content); + assert_eq!( + s.style.fg, block.fg, + "ANSI colour reached the filled block: {:?}", + s.content + ); + } + let w: usize = l.spans.iter().map(|s| s.content.chars().count()).sum(); + assert_eq!(w, 40, "row not padded flush: {:?}", flat(l)); + } + } + + /// TaskCreate/TaskUpdate get the subject and the todo status palette, and + /// no raw JSON reaches the feed. + #[test] + fn task_tools_render_subject_and_status_without_json() { + let e = tool( + "TaskCreate", + r#"{"subject":"Add ring-buffer retroactive capture to InteractionRecorder","description":"Keep last N seconds in memory","activeForm":"Adding ring-buffer capture"}"#, + Some("Task #1 created successfully: Add ring-buffer retroactive capture"), + ); + let t = text(&entry_lines(&e, 90, false)); + assert!(t.contains("⚙ Task + Add ring-buffer retroactive capture"), "{t}"); + assert!(t.contains("Keep last N seconds in memory"), "{t}"); + assert!(!t.contains("activeForm") && !t.contains('{'), "raw JSON: {t}"); + + let e = tool( + "TaskUpdate", + r#"{"taskId":"1","status":"in_progress"}"#, + Some("Task #1 updated"), + ); + let lines = entry_lines(&e, 80, false); + let t = text(&lines); + assert!(t.contains("⚙ Task #1 → ◐ in_progress"), "{t}"); + assert!(!t.contains("taskId"), "raw JSON: {t}"); + assert!(!t.contains("updated"), "the success echo is noise: {t}"); + assert_eq!(fg_of(&lines, "in_progress"), Some(Color::Yellow)); + + let e = tool("TaskUpdate", r#"{"taskId":"7","status":"completed"}"#, None); + let lines = entry_lines(&e, 80, false); + assert!(text(&lines).contains("⚙ Task #7 → ☑ completed")); + assert_eq!(fg_of(&lines, "completed"), Some(Color::Green)); + } + + /// Monitor reads like `bash`: description in the header, the command cyan + /// across as many lines as it has, the flags dimmed and in real time units. + #[test] + fn monitor_shows_description_command_and_flags() { + let e = tool( + "Monitor", + r#"{"command":"until grep -q done log; do sleep 1; done\necho ok","description":"world skin bench phase transitions","timeout_ms":1200000,"persistent":false}"#, + Some("Monitor started (task b8s2gso3a, timeout 1200000ms).\nYou will be notified."), + ); + let lines = entry_lines(&e, 100, false); + let t = text(&lines); + assert!( + t.contains("⚙ Monitor world skin bench phase transitions"), + "{t}" + ); + assert!(t.contains("until grep -q done log; do sleep 1; done"), "{t}"); + assert!(t.contains("echo ok"), "command keeps every line: {t}"); + assert!(t.contains("timeout 20m00s"), "{t}"); + assert!(!t.contains("persistent"), "a false flag isn't shown: {t}"); + assert!(t.contains("Monitor started"), "{t}"); + assert!(!t.contains("You will be notified"), "ack collapses: {t}"); + assert_eq!(fg_of(&lines, "until grep"), Some(Color::Cyan)); + } + + /// AskUserQuestion's nested JSON becomes a question with a labelled option + /// list; none of the braces reach the feed. + #[test] + fn ask_user_question_lists_options_without_json() { + let e = tool( + "AskUserQuestion", + r#"{"questions":[{"question":"How should the pane be framed?","header":"Framing","multiSelect":false,"options":[{"label":"Measure the box","description":"Frame from the top border down"},{"label":"Fixed offsets","description":"Crop a constant row count"}]}]}"#, + Some("Measure the box"), + ); + let t = text(&entry_lines(&e, 70, false)); + assert!(t.contains("⚙ AskUserQuestion"), "{t}"); + assert!(t.contains("[Framing]"), "{t}"); + assert!(t.contains("How should the pane be framed?"), "{t}"); + assert!(t.contains("• Measure the box"), "{t}"); + assert!(t.contains("• Fixed offsets"), "{t}"); + assert!(t.contains("Frame from the top border down"), "{t}"); + assert!(t.contains("⎿ Measure the box"), "the answer: {t}"); + assert!( + !t.contains("multiSelect") && !t.contains("\"label\""), + "raw JSON: {t}" + ); + } + + /// The plan is markdown the model wrote, so it renders through + /// `crate::markdown` (which strips the heading markers) — not as a string. + #[test] + fn exit_plan_mode_renders_the_plan_as_markdown() { + let e = tool( + "ExitPlanMode", + // Not a raw string: `"##` would close one mid-heading. + "{\"plan\":\"## Plan\\n\\n1. Size the PTY to the screen\\n2. Measure what Ink drew\"}", + None, + ); + let t = text(&entry_lines(&e, 70, false)); + assert!(t.contains("⚙ ExitPlanMode"), "{t}"); + assert!(t.contains("Plan") && !t.contains("## Plan"), "{t}"); + assert!(t.contains("Size the PTY to the screen"), "{t}"); + assert!(!t.contains("\\n"), "escaped JSON leaked: {t}"); + } + + /// WebSearch's result is one plain string with a `Links:` JSON array baked + /// into it: the hits become rows, the prose stays, the JSON goes. An + /// unparseable array falls back to the raw rendering instead of panicking. + #[test] + fn web_search_splits_links_from_prose() { + let res = concat!( + "Web search results for query: \"ratatui line style\"\n\n", + "Links: [{\"title\":\"Line in ratatui::text\",\"url\":\"https://docs.rs/ratatui/latest/text/Line.html\"},", + "{\"title\":\"Styling text\",\"url\":\"https://ratatui.rs/concepts/text\"}]\n\n", + "Line style is patched by each span's own style." + ); + let e = tool("WebSearch", r#"{"query":"ratatui line style"}"#, Some(res)); + let t = text(&entry_lines(&e, 110, false)); + assert!(t.contains("⚙ WebSearch \"ratatui line style\""), "{t}"); + assert!(t.contains("⎿ Line in ratatui::text"), "{t}"); + assert!(t.contains("https://ratatui.rs/concepts/text"), "{t}"); + assert!(t.contains("Line style is patched"), "prose survives: {t}"); + assert!(!t.contains("Links: ["), "the array itself is gone: {t}"); + assert!(!t.contains("{\"title\""), "raw JSON: {t}"); + + let e = tool("WebSearch", r#"{"query":"q"}"#, Some("Links: not json\nprose")); + let t = text(&entry_lines(&e, 100, false)); + assert!(t.contains("Links: not json"), "falls back raw: {t}"); + } + + /// Anthropic's *hosted* `web_search` (the nested server-tool request) + /// renders through the same arm as Claude Code's client-side `WebSearch`: + /// `app::server_tool_result` emits the same `Links: […]` shape, so the hits + /// reach the screen through one renderer rather than two. + #[test] + fn hosted_web_search_hits_render_like_the_client_tool() { + // The wire block, formatted by the very function the tap calls — so + // this pins app.rs and ui.rs agreeing, not a hand-copied string. + let block: serde_json::Value = serde_json::from_str( + r#"{"type":"web_search_tool_result","tool_use_id":"srvtoolu_01","content":[ + {"type":"web_search_result","title":"Ratatui docs","url":"https://ratatui.rs","page_age":"2 days"}, + {"type":"web_search_result","title":"Scrollbar example","url":"https://ratatui.rs/examples","page_age":null}]}"#, + ) + .unwrap(); + let r = crate::app::server_tool_result(Some(&block)); + assert!(!r.is_error); + let e = tool("web_search", r#"{"query":"ratatui scrollbar"}"#, Some(&r.content)); + let t = text(&entry_lines(&e, 110, false)); + assert!(t.contains("⚙ WebSearch \"ratatui scrollbar\""), "{t}"); + assert!(t.contains("⎿ Ratatui docs"), "{t}"); + assert!(t.contains("https://ratatui.rs/examples"), "{t}"); + assert!(!t.contains("Links: ["), "the array itself is gone: {t}"); + } + + /// The citation source list is one multi-line `Kind::Meta` entry: a `\n` + /// inside a single `Line` is not a row break to ratatui, so it has to be + /// split. A one-line meta is untouched. + #[test] + fn a_multiline_meta_renders_one_dim_row_per_line() { + let e = Entry { + kind: Kind::Meta, + content: "▸ 2 sources\n · Ratatui docs — https://ratatui.rs\n · Examples — https://ratatui.rs/examples".into(), + done: true, + result: None, + lane: MAIN_LANE, + }; + let lines = entry_lines(&e, 80, false); + let t = text(&lines); + assert!(t.starts_with("▸ 2 sources\n"), "{t}"); + assert!(t.contains("· Ratatui docs — https://ratatui.rs\n"), "{t}"); + assert_eq!(fg_of(&lines, "Examples"), Some(Color::DarkGray)); + // Blank separator row aside, one rendered row per source line. + assert_eq!(lines.iter().filter(|l| !flat(l).is_empty()).count(), 3); + } + + /// A nested server-tool call shares the popup with the subagents but is + /// not one, so it must not wear the agent's activity mark. + #[test] + fn a_server_tool_lane_reads_differently_from_an_agent() { + let mut agent = Lane::new("a1".into(), "claude-haiku-4-5".into(), Some(MAIN_LANE), 1); + agent.agent_type = "Explore".into(); + agent.active = 1; + // `srvtool-` is `app::SERVER_TOOL_PREFIX`; no hex agent id can look + // like it, which is what `Lane::is_server_tool` keys off. + let mut srv = Lane::new("srvtool-0".into(), "claude-haiku-4-5".into(), Some(MAIN_LANE), 1); + srv.agent_type = "web_search".into(); + srv.label = "\"ratatui scrollbar\"".into(); + srv.active = 1; + assert_eq!(lane_mark(&agent), "⟳"); + assert_eq!(lane_mark(&srv), "⚙"); + assert_ne!(lane_mark(&agent), lane_mark(&srv)); + // Finishing changes an agent's mark; the server tool keeps its own. + agent.active = 0; + agent.finished_at = Some(std::time::Instant::now()); + srv.active = 0; + srv.finished_at = Some(std::time::Instant::now()); + assert_eq!(lane_mark(&agent), "✓"); + assert_eq!(lane_mark(&srv), "⚙"); + assert!( + lane_title(&srv, None).starts_with(" ⚙ web_search · \"ratatui scrollbar\""), + "{}", + lane_title(&srv, None) + ); + } + + #[test] + fn web_fetch_shows_url_prompt_and_markdown_summary() { + let e = tool( + "WebFetch", + r#"{"url":"https://docs.rs/ratatui","prompt":"How does Line combine with Span styles?"}"#, + Some("## Answer\n\nThe line style is applied first."), + ); + let t = text(&entry_lines(&e, 80, false)); + assert!(t.contains("⚙ WebFetch https://docs.rs/ratatui"), "{t}"); + assert!(t.contains("How does Line combine with Span styles?"), "{t}"); + assert!(t.contains("The line style is applied first."), "{t}"); + assert!(!t.contains("## Answer"), "summary is markdown: {t}"); + } + + #[test] + fn skill_tool_search_and_task_control_render_one_line() { + let e = tool( + "Skill", + r#"{"skill":"optimize-materialization"}"#, + Some("Launching skill: optimize-materialization"), + ); + let t = text(&entry_lines(&e, 80, false)); + assert!(t.contains("⚙ Skill optimize-materialization"), "{t}"); + assert!(t.contains("⎿ Launching skill"), "{t}"); + + let e = tool("ToolSearch", r#"{"query":"select:Monitor","max_results":1}"#, None); + let t = text(&entry_lines(&e, 80, false)); + assert!(t.contains("⚙ ToolSearch \"select:Monitor\""), "{t}"); + assert!(t.contains("max 1"), "{t}"); + + let e = tool( + "TaskOutput", + r#"{"task_id":"b8s2gso3a","block":true}"#, + Some("phase 3 done\nphase 4 done"), + ); + let t = text(&entry_lines(&e, 80, false)); + assert!(t.contains("⚙ TaskOutput b8s2gso3a (block)"), "{t}"); + assert!(t.contains("phase 4 done"), "the output isn't clipped: {t}"); + assert!(!t.contains("task_id"), "raw JSON: {t}"); + + let e = tool("TaskStop", r#"{"task_id":"b8s2gso3a"}"#, None); + assert!(text(&entry_lines(&e, 80, false)).contains("⚙ TaskStop b8s2gso3a")); + } + + #[test] + fn mcp_tool_header_names_server_and_tool() { + let e = tool( + "mcp__pistdio__probe_run", + r#"{"probe":"world_skin","frames":120}"#, + None, + ); + let t = text(&entry_lines(&e, 80, false)); + assert!(t.contains("⚙ MCP pistdio/probe_run"), "{t}"); + assert!(t.contains("probe: world_skin"), "{t}"); + assert!(t.contains("frames: 120"), "{t}"); + + // Everything else keeps the plain `⚙ ` header. + assert_eq!(mcp_header("Bash"), None); + assert_eq!(mcp_header("mcp__only"), None); + assert_eq!(mcp_header("mcp____tool"), None); + assert_eq!( + mcp_header("mcp__a__b__c").as_deref(), + Some("⚙ MCP a/b__c") + ); + } + + /// The glyph `app::task_note_line` writes is the *only* channel the colour + /// comes from — this pins that contract from the render side. + #[test] + fn task_note_colour_comes_from_the_leading_glyph() { + let note = |content: &str| Entry { + kind: Kind::TaskNote, + content: content.into(), + done: true, + result: None, + lane: MAIN_LANE, + }; + for (line, want) in [ + ("✔ Explore finished · 129k tok", Color::Green), + ("✖ Explore finished · Error: 403", Color::Red), + ("◼ Explore finished · killed", Color::Yellow), + ("▸ Monitor event: bench", Color::Cyan), + ("· task zz9", Color::DarkGray), + ] { + let lines = entry_lines(¬e(line), 80, false); + assert_eq!(fg_of(&lines, line), Some(want), "{line}"); + } + // A monitor payload / an unattachable report rides along dimmed. + let lines = entry_lines(¬e("▸ Monitor event: bench\n BENCH phase=traverse"), 80, false); + assert_eq!(fg_of(&lines, "BENCH phase"), Some(Color::DarkGray)); + } + + #[test] + fn lane_titles_prefer_the_notifications_usage_totals() { + let mut l = Lane::new("a1".into(), "claude-sonnet-4-5".into(), Some(MAIN_LANE), 1); + l.agent_type = "Explore".into(); + l.output_tokens = 2_100; + l.tool_calls = 2; + // Without a `` block: what we counted off the wire, labelled so. + assert!(lane_title(&l, None).contains("· out 2.1k "), "{}", lane_title(&l, None)); + assert_eq!(lane_dur(&l), ""); + // With one: Claude Code's totals for the whole run, wall time included. + l.subagent_tokens = Some(128_633); + l.tool_uses = Some(63); + l.duration_ms = Some(1_115_197); + assert_eq!( + lane_title(&l, None), + " · Explore · sonnet-4-5 · 128.6k tok · 18m35s " + ); + assert_eq!(lane_tokens(&l), "128.6k tok"); + } + + #[test] + fn fmt_ms_reads_as_time_not_a_digit_count() { + assert_eq!(fmt_ms(1_200_000), "20m00s"); + assert_eq!(fmt_ms(90_000), "1m30s"); + assert_eq!(fmt_ms(5_000), "5s"); + assert_eq!(fmt_ms(400), "400ms"); + assert_eq!(fmt_ms(0), "0ms"); + } }