Lane server tools, lift task notes, parse ANSI
Three streams of data were being lost or mangled in the feed.
WebSearch is not purely client-side: it issues a nested /v1/messages that
declares Anthropic's hosted web_search under the parent session id and with
no agent-id header. Read as a turn start it pushed a fake user prompt,
clobbered the main lane's system/tools signatures and downgraded a [1m]
session to the short window on the next resume. Requests are now classified
three ways (Turn / ServerTool / Side) from the tools array shape alone, and a
nested call gets its own lane, readable only in the A popup. Its result and
citations arrive complete in the stream and are echoed back in no later
request body, so they are attached as the stream delivers them.
An Agent call returns its tool_result immediately ("async agent launched"),
so the real completion is a <task-notification> injected into the parent's
next user turn. Those are lifted out of the prompt: the report moves onto the
Agent entry it answers, the usage totals onto the lane, and the status
becomes one glyph-led note line. A finished agent used to keep reading as
running.
Tool output we do not control carries SGR codes. A self-contained parser maps
them to styles instead of leaving [1m as literal text; filled blocks keep
their own colours and take only the attributes.
Also: a non-2xx upstream response now surfaces as an error entry instead of a
silent stall, tool renderers cover the file/shell/task/prompt/web families,
and the fake upstream answers the nested hosted-tool request so both search
paths run offline.
This commit is contained in:
208
CLAUDE.md
208
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
|
||||
`<task-notification>`, 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 `<system-reminder>` 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<Mutex<App>> 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, `<usage>` totals) lives on `Lane`.
|
||||
Also home to the `<task-notification>` 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
|
||||
`<system-reminder>`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 `<usage>` 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 `<task-notification>` treatment as the live path — notes
|
||||
lifted into Kind::TaskNote, the `<result>` moved onto the `Agent`
|
||||
tool entry it answers (matched by `<tool-use-id>` against
|
||||
`EntryParser::agent_tools`, inline in the note when that call is
|
||||
outside the view), `<usage>` 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-<seq>` 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 `<task-id>` scan can never land on one). It
|
||||
is labelled `web_search · "<query>"`, 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: <hex>`), and the real completion is injected into the parent's next
|
||||
user turn as `<task-notification>` … `<task-id><agent id></task-id>`. 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 `<status>`-less progress
|
||||
ping is not a stop), records the `<usage>` totals on the lane, moves the
|
||||
`<result>` report onto the `Agent` tool entry via
|
||||
`task-id → lane_of_agent → Lane::anchor` (falling back to
|
||||
`<tool-use-id> → Lane::tool_use_id`), and pushes one `Kind::TaskNote` status
|
||||
line. The **disk path resolves the report differently** — by `<tool-use-id>`
|
||||
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 `<task-notification>` out of the prompt (and out of a
|
||||
`<system-reminder>` wrapping one) instead of rendering the raw XML as a
|
||||
full-width orange prompt rectangle. The `<result>` 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
|
||||
`<note>` is elided — byte-identical boilerplate on all 208 real occurrences.
|
||||
`<status>` 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 `<task-notification>` 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: <hex>`), 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: <hex>`), 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: <hex>`), 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: <hex>`), 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/<its-session-id>/`; 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
|
||||
`<task-notification>` 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: <hex>`), 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 <media_type> · <size>]` (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 <name>]`. The bare `[<type>]` 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
|
||||
|
||||
@@ -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()
|
||||
|
||||
478
src/ansi.rs
Normal file
478
src/ansi.rs
Normal file
@@ -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<Color>,
|
||||
bg: Option<Color>,
|
||||
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<Span<'static>> {
|
||||
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<Modifier>) {
|
||||
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<Option<u16>> = 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<u16>], i: usize) -> (Option<Color>, 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<Color>, 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, "");
|
||||
}
|
||||
}
|
||||
1993
src/app.rs
1993
src/app.rs
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
mod ansi;
|
||||
mod app;
|
||||
mod markdown;
|
||||
mod proxy;
|
||||
|
||||
80
src/proxy.rs
80
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<Response> {
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(str::to_string)
|
||||
};
|
||||
let agent = AgentTag {
|
||||
// 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<Response> {
|
||||
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<Response> {
|
||||
}
|
||||
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<Response> {
|
||||
// (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::<Vec<u8>>(64);
|
||||
tokio::spawn(async move {
|
||||
let mut parser = SseParser::default();
|
||||
@@ -225,6 +253,36 @@ async fn forward_inner(ctx: Ctx, req: Request) -> anyhow::Result<Response> {
|
||||
});
|
||||
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::<Vec<u8>>(8);
|
||||
let code = status.as_u16();
|
||||
tokio::spawn(async move {
|
||||
let mut buf: Vec<u8> = 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)?)
|
||||
|
||||
433
src/sessions.rs
433
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<History
|
||||
}
|
||||
}
|
||||
let anchors = std::mem::take(&mut p.agent_tools);
|
||||
let usage = std::mem::take(&mut p.task_usage);
|
||||
let mut view = p.into_view(uuid, Some(leaf), turn_entries)?;
|
||||
splice_agents(&mut view, anchors, &agents);
|
||||
splice_agents(&mut view, anchors, &agents, usage);
|
||||
Some(view)
|
||||
}
|
||||
}
|
||||
@@ -473,15 +474,67 @@ pub(crate) fn load_file_view(
|
||||
p.line(&line);
|
||||
}
|
||||
let anchors = std::mem::take(&mut p.agent_tools);
|
||||
let usage = std::mem::take(&mut p.task_usage);
|
||||
let mut view = p.into_view(uuid, None, Vec::new())?;
|
||||
splice_agents(&mut view, anchors, agents);
|
||||
splice_agents(&mut view, anchors, agents, usage);
|
||||
Some(view)
|
||||
}
|
||||
|
||||
/// Parse one subagent transcript into its own lane. 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) -> (Vec<Entry>, HashMap<String, usize>) {
|
||||
/// Claude Code's own accounting for one whole agent run, scraped from the
|
||||
/// `<usage>` block of a `<task-notification>`. 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<u64>,
|
||||
tool_uses: Option<u64>,
|
||||
duration_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// Agent id → run totals. The key is a notification's `<task-id>`, which is
|
||||
/// also the agent id its lane is registered under and the stem of its
|
||||
/// `subagents/agent-<id>.jsonl` — so the usage a *parent's* file reports finds
|
||||
/// the child's lane without any extra lookup table.
|
||||
type UsageByAgent = HashMap<String, LaneUsage>;
|
||||
|
||||
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 `<usage>` 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 `<usage>` 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<Entry>, HashMap<String, usize>) {
|
||||
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<Entry>, 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 `<task-notification>` 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<String, usize>,
|
||||
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 `<task-notification>`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<String, usize>,
|
||||
/// `<usage>` totals scraped from this file's `<task-notification>`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::<Value>(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<Entry>, 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: <hex>`
|
||||
/// acknowledgement the parent model got at launch time. The disk mirror of
|
||||
/// `Session::attach_task_report`, and a shorter one: the notification names
|
||||
/// the `<tool-use-id>` 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<String, usize>,
|
||||
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 `<task-notification>` is lifted out — of the prompt *and* of each
|
||||
/// injected reminder, since Claude Code sometimes wraps one in a
|
||||
/// `<system-reminder>` — and becomes one `Kind::TaskNote` line, with the
|
||||
/// agent's `<result>` report moved onto its `Agent` tool entry (or kept
|
||||
/// inline when that call is not in this view). `<usage>` 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<Entry>,
|
||||
agent_tools: &HashMap<String, usize>,
|
||||
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<String> = 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 `<task-notification>` in Claude Code's real wire shape.
|
||||
fn completion_note(task_id: &str, tool_id: &str, result: &str) -> String {
|
||||
format!(
|
||||
"<task-notification>\n\
|
||||
<task-id>{task_id}</task-id>\n\
|
||||
<tool-use-id>{tool_id}</tool-use-id>\n\
|
||||
<output-file>/tmp/claude/tasks/{task_id}.output</output-file>\n\
|
||||
<status>completed</status>\n\
|
||||
<summary>Agent \"sweep\" finished</summary>\n\
|
||||
<note>A task-notification fires each time this agent stops.</note>\n\
|
||||
<result>{result}</result>\n\
|
||||
<usage><subagent_tokens>128633</subagent_tokens><tool_uses>63</tool_uses>\
|
||||
<duration_ms>1115197</duration_ms></usage>\n\
|
||||
</task-notification>"
|
||||
)
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
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 `<task-notification>` in a transcript is lifted out of the prompt: a
|
||||
/// one-line `Kind::TaskNote` in front of it, the XML (and the `<note>`
|
||||
/// 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("<task-notification>"), "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 `<tool-use-id>` 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 `<status>`
|
||||
/// 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 = "<task-notification>\n\
|
||||
<task-id>aaa1</task-id>\n\
|
||||
<tool-use-id>toolu_A</tool-use-id>\n\
|
||||
<status>Error: 403: {\"message\":\"Access to model denied.\"}</status>\n\
|
||||
<summary>Agent \"sweep\" failed</summary>\n\
|
||||
<result>partial work</result>\n\
|
||||
</task-notification>";
|
||||
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 = "<task-notification>\n\
|
||||
<task-id>b8s2gso3a</task-id>\n\
|
||||
<summary>Monitor event: \"world skin bench\"</summary>\n\
|
||||
<event>BENCH progress phase=traverse ms=141974</event>\n\
|
||||
</task-notification>";
|
||||
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 `<system-reminder>` (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!("<system-reminder>\n{note}\n</system-reminder>");
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
/// `<usage>` totals reach the agent's own lane — the only token figures an
|
||||
/// on-disk lane can have. Matched by agent id: the notification's
|
||||
/// `<task-id>` is the stem of `subagents/agent-<id>.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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user