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"); + } }