Compare commits

...

6 Commits

Author SHA1 Message Date
Jonas H
b9d7d2c969 Scroll the fullscreen pane's own scrollback
Claude Code grabs no mouse and stays off the alternate screen, so a plain
terminal answers the wheel with its own scrollback and the child never hears
about it. The fullscreen pane is that terminal, so it does the same job: the
view is a stable row index into wezterm-term's scrollback, which pins the rows
you scrolled to while the child keeps writing.

The ctrl-l transcript wipe is cancelled while fullscreen — there the pane is
not prompt-only, and its transcript is the thing being scrolled.
2026-08-27 14:34:48 +02:00
Jonas H
73871cb1dc Run every model at the 1M context window
The window is a beta header, so nothing on disk records it. Observing it on
the wire and replaying it on resume had to survive a session rename, a hot
reload, a WebSearch sub-request and a process restart — every gap fell back
to the short window.

Force it instead: Models::arg is the single decision point, and every spawn
and resume goes through it. A model is still only suffixed when the installed
claude ships that variant, so haiku stays haiku.
2026-08-27 13:06:21 +02:00
Jonas H
a5a6092579 Detect the unmarked @ path listing in the pane
Claude Code marks a fuzzy `@` hit with `+ `, but a path that leaves the
project (`@../de`) switches it to a plain directory listing whose rows
carry no marker — the selected row differs only in colour, which the
pane's text-only read of the screen drops. So the compact frame ended one
row under the input box and showed the first hit alone.

Recognise a bare path row by shape (a whitespace-free token holding a
`/`) and, since a user statusLine can look exactly like that, confirm the
list over two rows: the row under the rule must be a menu row or blank
(it is shown either way, so a one-hit menu needs no detection), and at
least one further row must match too.
2026-08-27 12:44:06 +02:00
Jonas H
1f483ad3d1 Hot-reload onto a rebuilt binary with ctrl-r
Restarting to pick up a code change costs the three things this app
exists to hold: the proxy port, the embedded claude pane, and the live
feed. So ctrl-r does not restart — it execs the binary now on disk into
this same process. execve keeps the pid, the open fds and the child
processes, so the listener socket, the pty and its claude child all
simply carry on; the feed travels in a JSON snapshot.

Build nothing, watch nothing. A build is the user's business, and a
running instance must not decide on its own when to become different
code. Rebuild outside, then press ctrl-r in each instance.

Drain before the exec. It destroys the tokio tasks relaying in-flight
responses, so the proxy stops accepting and finishes what it has first.
The socket stays open throughout (App::listener_fd is a dup), so
requests made during the swap queue in the kernel backlog and are served
by the new image — verified end to end: nothing refused, nothing cut.

Treat the snapshot as advisory. It is written by the old binary and read
by the new one, whose types usually just changed — that is the normal
case, not the edge case. The fd numbers stay in plain fields and the
feed is decoded per session behind a sanitiser, so a schema change costs
the feed and never the port or the pane.
2026-08-27 11:46:15 +02:00
Jonas H
8907766b5b Strip nF escapes so rustfmt output loses its B
rustfmt and `git diff` close every coloured run with `ESC ( B` (designate G0
as ASCII) and write it after the newline, ahead of the SGR reset. With no arm
for nF escapes the parser fell through to "two-char escape", ate `ESC (` and
left the `B` as text, so each diff line in the feed read `B+ added line`.

Intermediates 0x20-0x2f now run to a final 0x30-0x7e, and an unterminated
sequence stops where the CSI arm would.
2026-08-27 10:33:18 +02:00
Jonas H
ba6b18e7d9 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.
2026-08-27 10:24:05 +02:00
13 changed files with 6171 additions and 517 deletions

461
CLAUDE.md
View File

@@ -9,19 +9,31 @@ extra usage is the core constraint of this project.
## Architecture
```
src/main.rs entry; tokio runtime for proxy task, TUI on main thread; --headless mode
src/main.rs entry; tokio runtime for proxy task, TUI on main thread; --headless mode.
Also the *incoming* half of a hot reload: `reload::take_handoff`
decides whether this process was started by a user or exec'd by
its own previous image, and `open_listener` adopts the inherited
accept socket instead of binding a new one
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 +50,23 @@ 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, nF
charset designation, 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 — nor the `B` of the
`ESC ( B` that rustfmt and `git diff` write after every newline
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 +83,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 +104,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
@@ -87,8 +122,15 @@ src/sessions.rs on-disk session history (main chain *and* subagents):
App::disk_sessions fresh (~1/s poll, `read_meta` re-read only on
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
`--model` a resume spawns with — always at `[1m]`);
load_view/load_history rebuild a feed Session
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.
@@ -104,8 +146,20 @@ src/term.rs embedded claude pane: spawns `claude --session-id <uuid>` in a
`x-claude-cloak-pane`), the correlation handle the proxy uses to
recognise the pane's own traffic (see the embed-identity invariant).
`cc_default_model` reads Claude Code's *own* configured default
model out of its settings — the only persisted record of a
`[1m]` pick (see the 1M-context invariant)
model out of its settings, and `spawn_model_discovery` scans the
`claude` binary for `App::models` (the aliases, and which of them
ship a `[1m]` variant — see the 1M invariant). `EmbeddedTerm::adopt`
rebuilds a pane around an inherited pty fd + pid after a hot
reload (`AdoptedMaster` / `PidKiller` stand in for the
portable-pty handles, which do not survive an exec).
`scroll` / `follow_live` / `scrolled_rows` give the
**fullscreen** pane the scrollback a plain terminal would —
see the pane-scroll invariant
src/reload.rs hot reload: ctrl-r `execve`s the binary now on disk *into this
process* — same pid, so the listener socket, the `claude` child
and (via a JSON snapshot) the live feed all cross over. Builds
nothing itself: you rebuild outside and press ctrl-r. See the
hot-reload invariant
```
Data flow: proxy task parses SSE chunks → `Tap::handle()` mutates shared state →
@@ -113,6 +167,65 @@ UI thread redraws on its own tick (no channel; just the mutex).
## Key invariants
- **A hot reload is an `execve` of ourselves, never a restart.** `reload.rs`
builds nothing and watches nothing: you rebuild however you normally would,
then **ctrl-r** swaps each running instance onto the binary now at
`App::exe`. Separating the two is the point — a build is the user's business,
and a running instance must not decide on its own when to become different
code. So there is no watcher thread, no `cargo` subprocess and no env var to
arm; ctrl-r is simply always live, in a debug build and a release one alike.
ctrl-r execs the same **path** it started from, so the reload follows the
file, not the profile: a debug instance reloads onto a rebuilt debug binary,
a release instance onto a rebuilt release one.
`execve` keeps the pid, the open fds and the child processes,
which is the whole reason all three things survive: the **port** (the accept
socket is inherited by fd number — `App::listener_fd` is a *dup*, so axum's
graceful shutdown can drop its own listener without ever closing the socket),
the **pane** (still our child, still on the same pty —
`term::PtyHandoff`/`EmbeddedTerm::adopt`), and the **feed** (a JSON snapshot
in `$TMPDIR`, pointed to by `CT_RELOAD_HANDOFF`). Four rules keep it honest:
1. **Resolve the exe path at startup, never lazily.** The file is *expected*
to have been replaced by the time ctrl-r is pressed, and a linker's rename
unlinks the inode we are running from — after which `/proc/self/exe` reads
`…/claude-cloak (deleted)`. `reload::exe_path` runs once, in `App::new`,
and strips that suffix defensively.
2. **Drain before exec.** The exec destroys the tokio tasks relaying
in-flight responses, so `proxy::run` serves `with_graceful_shutdown` and
the exec waits for it (`App::drain_tx``App::drained`, capped by
`DRAIN_MAX`). Counting `Session::active` is *not* the gate — it misses
untapped traffic (`count_tokens`, non-streaming posts) and races
`Tap::drop`, which runs on the tee task after the relay is done.
Meanwhile the socket stays open, so requests Claude Code makes *during*
the swap queue in the kernel backlog and are served by the new image:
verified end-to-end — nothing refused, nothing truncated.
3. **The snapshot is advisory, and split.** It is written by the old binary
and read by the new one, whose types usually just changed — that is the
normal case, not the edge case. So `Handoff` keeps the fd numbers in plain
fields and leaves the feed as an undecoded `serde_json::Value`, decoded
per session, each one passed through `sanitize_session`. Losing the feed
must never cost the port or the pane. `Entry::lane`, `Lane::anchor`,
`Lane::first_entry` and `Session::tool_ids` are raw indices that
`#[serde(default)]` does *not* protect, so they are validated once there
rather than defensively at every use site; `active` counters are zeroed
(nothing streams into a process that no longer exists) and
`app::seed_server_tool_seq` pushes the process-global `srvtool-` counter
past whatever the restored lanes already hold.
4. **Nothing is dropped on the way out.** An exec runs no destructors, which
is exactly why `EmbeddedTerm::drop` does not fire and SIGHUP the child —
so `try_reload` *borrows* the pane and the app rather than taking them,
and a failed exec (ctrl-r landing mid-link is the realistic case) leaves
the old code running with everything intact, `close_on_exec` having put
the FD_CLOEXEC flags back.
Two things the exec breaks that have to be repaired by hand: crossterm caches
the pre-raw termios in a process global, so `disable_raw_mode` runs *before*
the exec or the new image records raw as the original and hands the shell
back in raw mode; and ratatui diffs its first frame against an empty buffer,
so the incoming image clears the screen once. The alternate screen is
deliberately **not** left — re-entering it is a no-op and avoids a flash.
`Instant` has no epoch, so the three snapshotted ones travel as "ms ago"
(`reload::ms_ago`) — otherwise every restored lane would read as freshly
active. `App::embed_clear_at` points *forward* and is simply not carried.
- **Latency-neutral pass-through**: response bytes are forwarded as-is, never
buffered or rewritten. Auth headers pass through untouched. If the tap code
panics or misparses, the proxy must still relay bytes (tee is best-effort):
@@ -151,6 +264,35 @@ 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 and clobber the main lane's system/tools signatures.
- **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 +306,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 +388,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
@@ -246,29 +436,34 @@ agentId: <hex>`), and the real completion is injected into the parent's next
last main-chain assistant message (`DiskSession::model`, filled by the
scanner's `read_meta` — subagent `isSidechain` records run their own model
and `<synthetic>` error records carry no model, so both are skipped) and
`app::model_arg_for_id` maps that id to a `--model` argument: a known alias
(`sonnet`, `opus`, … from `App::model_choices`) wins over the dated snapshot
`Models::base_for_id` maps that id to a base model name: a known alias
(`sonnet`, `opus`, … from `Models::aliases`) wins over the dated snapshot
id, so a retired snapshot can't pin the pane; an id with no alias inside is
passed through verbatim (`--model` takes full names too). The transcript is
authoritative, so a mid-session `/model` switch is honoured.
- **The 1M context window is a header, and a resume must keep it.**
`--model opus[1m]` differs from `opus` only by `anthropic-beta:
…,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`.
`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 —
it falls back to `term::cc_default_model()`, Claude Code's configured default
(`ANTHROPIC_MODEL`, then local/project/user `settings.json`), which is the
one place a `[1m]` pick is persisted (`/model` writes it there). When that
default names the same base model the resume passes **no `--model` at all**
and inherits it whole, window included; any explicit knowledge overrides it,
including "this session ran the *short* window", which is why an observed
non-1m session is resumed with an explicit `--model opus`. The suffix is only
ever added for an alias that `model_choices` says has a `[1m]` variant.
authoritative, so a mid-session `/model` switch is honoured. Only the
*model* is decided there — the window is not a question (see below).
- **Every model runs the 1M context window, always.** `Models::arg` is the one
place that decides and it answers `<base>[1m]` whenever the installed
`claude` ships that variant (`Models::long`, read out of the binary — the
suffix is never assumed, so `haiku` stays `haiku`). Every spawn goes through
it: `App::spawn_arg` for a fresh pane and the `a` picker, `App::resume_arg`
for a resume. So a resume is `--model <base>[1m]` and nothing infers a
window any more.
The window is a *header*, not a model: `--model opus[1m]` differs from
`opus` only by `anthropic-beta: …,context-1m-…`, and the body `model` and
the transcript record read identically either way. That is why the old
"observe it on the wire, replay it on resume" design existed — and why it
kept losing the window: the observation had to survive a rename, a reload,
a `WebSearch` sub-request and a process restart, and any gap fell back to
the short window. Forcing it removes the failure mode instead of patching
it. `Session::long_context`, `record_long_context` and the proxy's
`anthropic-beta` read are gone with it.
A model still has to be *named* for the suffix to attach, so a session we
know nothing about (no transcript model, no spawn argument) borrows the
model — never the window — from `term::cc_default_model()`
(`ANTHROPIC_MODEL`, then local/project/user `settings.json`). With no
default configured either, the spawn passes no `--model` at all and
`claude` picks both.
- **Turn tree / branching** (lazygit/yazi-style, all in the sessions panel):
`space` (or `→`/`l`) expands the selected session's turn tree — one row per
real user prompt, abandoned rewind branches indented `⑂` under their fork
@@ -288,11 +483,61 @@ agentId: <hex>`), and the real completion is injected into the parent's next
ExitPlanMode (sized from the question's option count) before Claude Code
renders the prompt, shrinks when the tool_result echoes back, and schedules
a ctrl-l transcript wipe 400ms after each turn (the pane is prompt-only;
the feed shows the context).
the feed shows the context)**except in fullscreen**, where that wipe is
*cancelled*, not deferred (see the pane-scroll invariant).
- **In fullscreen the scroll is the pane's own scrollback, never a forwarded
mouse event.** Claude Code enables no mouse tracking and never leaves the
normal screen (verified on the wire: for 2.1.247 tmux reports every mouse
flag clear and `alternate_on=0`), so in a plain terminal the wheel scrolls
*that terminal's* scrollback and the child never hears about it. The
fullscreen pane is that terminal, so it does the same job:
`EmbeddedTerm::scroll` moves a view into wezterm-term's scrollback (3500
rows, the crate default) and `render` reads the window from there instead of
the live screen. Four rules keep it honest:
1. The view is a **`StableRowIndex`, not an offset** — the child keeps
writing while you read, and a terminal pins the rows you scrolled to
rather than sliding them up under you. Reaching the live top re-engages
follow mode instead of pinning to it, and no cursor is reported while
scrolled away (its row does not index that window).
2. **Only `PaneView::Full` scrolls.** The cropped views frame Claude Code's
input box, which is always at the live bottom, so `draw` calls
`follow_live` for them — one place, instead of at each of ctrl-f /
ctrl-↑ / F2 / session-switch.
3. **Any key snaps back to live** (xterm's scroll-on-key) before it is
forwarded, so typing can never leave you reading history while the child
answers off-screen. `shift`+PgUp/PgDn is the exception: a real terminal
keeps those for its own scrollback too, so they page the pane and are not
forwarded.
4. The **ctrl-l wipe is cancelled while fullscreen**, because there the pane
is not prompt-only — its transcript is the whole context, and the thing
being scrolled. Cancelled rather than deferred: firing it later would
delete that history the moment ctrl-f dropped out of fullscreen. A wipe
that already ran *before* you went fullscreen is gone for good though —
Ink redraws only the live frame, so fullscreen shows history from that
point on.
- 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
@@ -311,13 +556,22 @@ agentId: <hex>`), and the real completion is injected into the parent's next
and shows one context row above the top rule (the spinner / "✻ Worked…" row)
down to the statusLine just under the bottom rule, cropping the persistent
hint/token/effort chrome below it. When an `@`/`/` menu is open it has
replaced that chrome with a list (`text_is_menu_item`, CC-2.1.x glyphs —
retune there if an update changes them), so the frame extends to the last
non-blank row instead. The menu is detected by scanning the *whole* region
below the bottom rule for a menu row, not just the row directly under it: the
list can start after a blank/header row and only the highlighted item carries
a glyph (unselected file rows are plain names), so checking one row collapsed
the pane whenever that row wasn't the selected item. Above the top rule the
replaced that chrome with a list (`text_is_menu_item`), so the frame extends
to the last non-blank row instead. **A menu row is not reliably marked**, so
that list is recognised by *shape* and confirmed by a second row: a `+ `
fuzzy hit, a `/command` / `@agent` row and the highlighted `` all carry a
marker, but a path that leaves the project (`@../de`) switches CC to a plain
**directory listing** whose rows are bare padded paths — the selected one
differs only in colour, which `row_text` drops. So a whitespace-free token
holding a `/` counts too, and because the user's own statusLine can look
exactly like that, `compact_frame_ex` votes over two rows: the row directly
under the rule must be a menu row *or blank* (it is shown either way — it is
the statusLine's slot — so a one-row menu needs no detection), and at least
one *further* row must match as well (which is what keeps a two-line
statusLine from dragging the whole chrome into the pane). Scanning the whole
tail also matters because only the highlighted row of a fuzzy list is marked,
so checking one row collapsed the pane whenever that row wasn't the selected
item. Above the top rule the
frame also swallows an **active task panel** (`text_is_task_row` /
`task_block_top`): Claude Code parks the `N tasks (…)` header + `✔ ◼ ◻` rows
(and its `… +N pending` overflow line) directly above the input box, so
@@ -370,25 +624,31 @@ agentId: <hex>`), and the real completion is injected into the parent's next
characters (alt-c = ©) and never reaches the app as a modifier. Pane keys:
F2 toggle, ctrl-↓ attach pane to selected session (resume/spawn/focus),
ctrl-↑ focus feed, ctrl-f fullscreen toggle (only while the pane is
focused), ctrl-q quit (global; needed while the pane is focused, where
plain `q` is forwarded to the child), c attach most-recent past session.
focused), shift+PgUp/PgDn page the pane's scrollback while it is fullscreen
(any other key snaps back to live), ctrl-q quit (global; needed while the
pane is focused, where plain `q` is forwarded to the child), c attach
most-recent past session.
List keys: j/k/↑/↓ move the session/turn highlight, space/→/← expand/
enter/leave the turn tree, a opens the model picker popup and spawns a
brand-new `claude --session-id … [--model …]` (kills any current pane —
`show_embed_new`; saves resume-then-/clear to get a fresh chat). The picker
list comes from `App::model_choices`: seeded with `default_model_choices`,
then replaced by `term::spawn_model_discovery` — a background scan that
reads the live model-alias array (`["sonnet","opus","haiku","fable",…]`)
straight out of the installed `claude` ELF (single self-contained binary
with the JS bundle embedded). No API call, never runs claude — just resolves
`claude` on PATH and greps its bytes for the longest lowercase-token array
anchored by `opus`+`sonnet`. The list then gets the **1M-context variants**
appended (`sonnet[1m]` etc., Claude Code's `--model` spelling for the long
context window): `term::long_context_tokens` collects every quoted
`"<token>[1m]"` literal in the same byte scan, and only aliases that really
have one are offered (today `opus`/`sonnet`/`fable`*not* `haiku` or
`mythos`), so the suffix is never assumed. `[1m]` needs no shell quoting:
the pane spawns via `CommandBuilder` argv, not a shell.
list is `Models::choices()` — one row per model, at the window
`Models::arg` gives it (`sonnet (1M context)``sonnet[1m]`, `haiku`
`haiku`), plus a `default` row that `App::spawn_arg` resolves to Claude
Code's own configured model. There is deliberately **no short-window row**:
every model runs 1M (see the invariant), so offering one would be a lie.
`App::models` is seeded by `Models::seed` and replaced by
`term::spawn_model_discovery` — a background scan that reads the live
model-alias array (`["sonnet","opus","haiku","fable",…]`) straight out of
the installed `claude` ELF (single self-contained binary with the JS bundle
embedded). No API call, never runs claude — just resolves `claude` on PATH
and greps its bytes for the longest lowercase-token array anchored by
`opus`+`sonnet`. The same pass collects every quoted `"<token>[1m]"` literal
(`term::long_context_tokens`) and keeps the base names as `Models::long`, so
only models that really have the variant get the suffix — today
`opus`/`sonnet`/`fable` and a set of full ids, *not* `haiku` or `mythos`.
`[1m]` needs no shell quoting: the pane spawns via `CommandBuilder` argv,
not a shell.
Tab/BackTab cycle sessions (`p` no longer
mirrors BackTab). v visual range, b branch, Esc unwinds (visual → tree →
quit). n/N jump the feed scroll to the next/previous user prompt
@@ -402,10 +662,20 @@ agentId: <hex>`), and the real completion is injected into the parent's next
it also owns the wheel (`ui::wheel`), so no pointer hit-testing is involved.
Switching the displayed session clears `App::lane_cols` and closes the popup,
so a lane id can't inherit another session's scroll offset.
`CT_DEBUG_KEYS=1` shows raw key events in the status bar.
- Mouse is captured: wheel always scrolls the feed (regardless of focus), and
left-drag selects screen text, copied on release via OSC 52 (like Claude
Code). Native terminal selection therefore needs shift held.
ctrl-r hot-reloads onto the binary now on disk (you rebuild outside; this
swaps the running instance onto it) — global like ctrl-q, because it has to
work while the pane holds focus.
`CT_DEBUG_KEYS=1` shows raw key *and* mouse events in the status bar
(`mouse: ScrollUp … fullscreen=true back=0`) — the wheel's two silent
failure modes look identical on screen otherwise: no event delivered at all
(an outer tmux without `set -g mouse on` swallows them) versus an event
delivered to a pane with no scrollback above the live screen.
- Mouse is captured: the wheel scrolls the feed regardless of focus — except
while the pane is fullscreen, where the feed is off screen and the wheel
scrolls the child's scrollback instead (`EmbedUi::scroll_pane`, the same
`WHEEL_ROWS` step either side of ctrl-f). Left-drag selects screen text,
copied on release via OSC 52 (like Claude Code). Native terminal selection
therefore needs shift held.
- Bracketed paste is enabled on the outer terminal (`EnableBracketedPaste`):
a multiline paste arrives as one `Event::Paste` and, when the claude pane
has focus, is handed to the child via `EmbeddedTerm::paste`
@@ -440,20 +710,47 @@ 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
- Hot reload is unix-only (`execve`, fd inheritance, `TIOCSWINSZ`), and only
the UI path offers it — `--headless` has no event loop to press ctrl-r in.
It follows the *path* the instance was started from, so it cannot cross
profiles: reloading a debug instance onto a release build means starting the
release binary instead. `App::history` (viewed disk transcripts), the render
caches and the turn-tree expansion are not snapshotted — all are lazily
rebuilt. The adopted
pane loses Ink's `<Static>` transcript, because the repaint is a SIGWINCH and
Ink only redraws the live frame; for a prompt-only pane that is the intended
end state anyway.
- 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,10 +770,18 @@ 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).
- 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
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 to the child (it asks for none — see the
pane-scroll invariant); the scrollback view is fullscreen-only, and the
cropped views stay live-screen-only by design; shift+enter needs kitty
keyboard protocol pushed on the outer terminal (not done); permission prompts aren't detected for pane growth
(not visible in the API stream — would need a Notification hook hitting a
local control endpoint).
- Materialized branch files satisfy our own parser (round-trip tested) but

1
Cargo.lock generated
View File

@@ -353,6 +353,7 @@ dependencies = [
"anyhow",
"axum",
"futures-util",
"libc",
"portable-pty",
"ratatui",
"reqwest",

View File

@@ -21,3 +21,7 @@ portable-pty = "0.9"
wezterm-term = { git = "https://github.com/wezterm/wezterm", rev = "891bed31b75f7a71b78e8f42ad07ae89bf99a7de" }
wezterm-surface = { git = "https://github.com/wezterm/wezterm", rev = "891bed31b75f7a71b78e8f42ad07ae89bf99a7de" }
uuid = { version = "1", features = ["v4"] }
# Hot reload (src/reload.rs): fd inheritance across execve, PTY ioctls and
# child signalling once the portable-pty handles are gone.
libc = "0.2"

View File

@@ -60,3 +60,36 @@ Sessions are keyed by the Claude Code session ID found in request metadata;
concurrent requests (subagents) tap independently.
`--headless` runs the proxy without the TUI. `CT_PORT` overrides the port (default 8484).
## Hot reload
Swap a running instance onto a newly built binary with **ctrl-r** — without
losing the proxy port, the embedded `claude` pane, or the live feed.
```sh
cargo build # in any terminal, whenever you like
# then press ctrl-r in each running instance
```
claude-cloak never builds anything itself and watches no files. You rebuild the
way you always would; ctrl-r says "run that one now".
ctrl-r execs the same **path** the instance was started from, so a debug
instance reloads onto a rebuilt debug binary and a release instance onto a
rebuilt release one. It does not cross profiles.
### How it survives
The app `execve`s the new binary into its **own process**, so the pid, the open
file descriptors and the child processes all stay. Before the exec the proxy
drains: it stops accepting and lets in-flight responses finish, while the
listening socket stays open so requests made during the swap wait in the kernel
backlog and are served by the new code. Nothing is refused and nothing is
truncated.
The footer shows `⟳ reloading…` while it drains, then `· reload #1` once the new
code is running. A failed exec — ctrl-r pressed while the linker still had the
file open — reports `⚠ reload failed: …` and changes nothing; press it again.
A state snapshot the new types no longer fit costs the feed only — never the
port and never the pane.

View File

@@ -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()

513
src/ansi.rs Normal file
View File

@@ -0,0 +1,513 @@
//! 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, nF escapes (charset designation) 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)
}
// nF escape (`ESC I… F`): intermediate bytes 0x20-0x2f, then a final
// 0x30-0x7e. Charset designation lives here — `ESC ( B` (G0 = ASCII),
// which rustfmt and `git diff` emit after *every* colour reset
// (`\x1b(B\x1b[m`). Consumed as a two-char escape it left a literal `B`
// at the head of each coloured run.
Some(&c) if (0x20..=0x2f).contains(&c) => {
let mut j = i + 2;
while j < b.len() && (0x20..=0x2f).contains(&b[j]) {
j += 1;
}
match b.get(j) {
Some(&f) if (0x30..=0x7e).contains(&f) => (j + 1, None),
// Never terminated (end of text, or a UTF-8 lead byte): stop
// here, exactly as the CSI arm does.
_ => (j, 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");
}
/// Real `cargo fmt` output: each coloured run is closed with `ESC ( B`
/// (G0 = ASCII) *before* the SGR reset. Consumed as a two-char escape that
/// left the `B` behind, so every diff line in the feed read `B+ added line`.
#[test]
fn charset_designation_leaves_no_stray_letter() {
let line = "\u{1b}[32m+ break;\u{1b}(B\u{1b}[m";
assert_eq!(strip(line), "+ break;");
let p = parts(line);
assert_eq!(p.len(), 1, "one green run, no stray letter: {p:?}");
assert_eq!(p[0].1, Some(Color::Green));
// Other nF escapes: line-drawing G1, `ESC # 8` (DECALN), `ESC % G`.
assert_eq!(strip("a\u{1b})0b\u{1b}#8c\u{1b}%Gd"), "abcd");
// Never terminated: the text after it still reaches the reader.
assert_eq!(strip("keep \u{1b}("), "keep ");
assert_eq!(strip("keep \u{1b}(\u{e6}"), "keep \u{e6}");
}
/// 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é"), "");
// 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, "");
}
}

2461
src/app.rs

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,41 @@
mod ansi;
mod app;
mod markdown;
mod proxy;
mod reload;
mod sessions;
mod sse;
mod term;
mod ui;
use app::App;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
fn main() -> anyhow::Result<()> {
let headless = std::env::args().any(|a| a == "--headless");
let app = Arc::new(Mutex::new(App::new()));
// A hot reload execs the new binary into this same process (see
// reload.rs), so a handoff here means "we are the new code and everything
// the old code owned is still open": the listener socket, the pty of the
// embedded pane, and a snapshot of the feed.
let handoff = reload::take_handoff();
let mut state = App::new();
let mut pane = None;
let mut pane_ui = reload::PaneState::default();
let reloaded = handoff.is_some();
if let Some(h) = handoff {
state.reload_gen = h.generation + 1;
state.listener_fd = h.listener_fd;
// Best-effort: a snapshot the new types no longer fit costs the feed,
// never the port and never the pane (they are plain numbers above).
reload::restore(&mut state, h.app);
pane = h.pane;
pane_ui = h.pane_ui;
}
let app = Arc::new(Mutex::new(state));
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
@@ -20,22 +43,34 @@ fn main() -> anyhow::Result<()> {
// Bind up front so every instance gets its own port: CT_PORT pins it
// (hard error if taken), otherwise prefer 8484 and fall back to an
// OS-assigned free port so multiple instances can coexist.
let listener = rt.block_on(async {
match std::env::var("CT_PORT").ok().and_then(|p| p.parse::<u16>().ok()) {
Some(p) => tokio::net::TcpListener::bind(("127.0.0.1", p)).await,
None => match tokio::net::TcpListener::bind(("127.0.0.1", 8484u16)).await {
Ok(l) => Ok(l),
Err(_) => tokio::net::TcpListener::bind(("127.0.0.1", 0u16)).await,
},
}
})?;
//
// A reload skips all of that and adopts the socket the previous image was
// already accepting on, so the port never closes and Claude Code never
// sees a refused connection.
let inherited = app::lock_app(&app).listener_fd;
let listener = rt.block_on(open_listener(inherited))?;
let port = listener.local_addr()?.port();
// Keep a dup for the *next* reload: the graceful shutdown drops axum's own
// listener, and this fd is what holds the socket open across the exec.
let keep_fd = reload::dup_listener(&listener)?;
let (drain_tx, drain_rx) = tokio::sync::oneshot::channel();
let drained = Arc::new(AtomicBool::new(false));
{
let mut a = app::lock_app(&app);
a.listener_fd = keep_fd;
a.drain_tx = Some(drain_tx);
a.drained = drained.clone();
}
let papp = app.clone();
let proxy_handle = rt.spawn(async move {
if let Err(e) = proxy::run(papp.clone(), listener).await {
if let Err(e) = proxy::run(papp.clone(), listener, drain_rx).await {
app::lock_app(&papp).status = format!("proxy failed: {e}");
}
// `serve` has returned, so every connection task is finished. This is
// the reload's go-ahead.
drained.store(true, Ordering::SeqCst);
});
if headless {
@@ -43,8 +78,26 @@ fn main() -> anyhow::Result<()> {
rt.block_on(proxy_handle)?;
Ok(())
} else {
let r = ui::run(app, port);
let r = ui::run(app, port, pane, pane_ui, reloaded);
rt.shutdown_background();
r
}
}
/// The proxy's accept socket: adopted from the previous image after a hot
/// reload, freshly bound otherwise.
async fn open_listener(inherited: std::os::fd::RawFd) -> anyhow::Result<tokio::net::TcpListener> {
if let Ok(std) = reload::adopt_listener(inherited)
&& let Ok(l) = tokio::net::TcpListener::from_std(std)
{
return Ok(l);
}
let l = match std::env::var("CT_PORT").ok().and_then(|p| p.parse::<u16>().ok()) {
Some(p) => tokio::net::TcpListener::bind(("127.0.0.1", p)).await?,
None => match tokio::net::TcpListener::bind(("127.0.0.1", 8484u16)).await {
Ok(l) => l,
Err(_) => tokio::net::TcpListener::bind(("127.0.0.1", 0u16)).await?,
},
};
Ok(l)
}

View File

@@ -1,6 +1,6 @@
use crate::app::{
AgentTag, SharedApp, Tap, attach_tool_results, lock_app, record_long_context,
record_user_prompt,
AgentTag, ReqKind, SharedApp, Tap, attach_tool_results, classify_request,
label_server_tool_lane, lock_app, next_server_tool_id, record_user_prompt,
};
use crate::sse::SseParser;
use axum::Router;
@@ -23,13 +23,11 @@ const UPSTREAM: &str = "https://api.anthropic.com";
pub const AGENT_ID_HEADER: &str = "x-claude-code-agent-id";
pub const PARENT_AGENT_ID_HEADER: &str = "x-claude-code-parent-agent-id";
/// Claude Code asks for the **1M-context window** with a beta flag, not a
/// different model: `--model opus[1m]` sends `anthropic-beta: …,context-1m-…`
/// while plain `opus` does not, and both report the same `model` in the body
/// (and in the transcript). So this header is the only place the window is
/// observable — `App::resume_model` needs it to resume a session the way it ran.
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).
@@ -64,13 +62,45 @@ struct Ctx {
app: SharedApp,
}
pub async fn run(app: SharedApp, listener: tokio::net::TcpListener) -> anyhow::Result<()> {
/// Serve until `drain` fires, then finish every in-flight response and return.
///
/// The drain signal is the hot-reload handshake (`reload.rs`): axum stops
/// accepting, closes idle keep-alive connections and lets streaming responses
/// run to completion, so the exec that follows can never truncate one. The
/// socket itself stays open the whole time — `App::listener_fd` holds a dup of
/// it — so connections Claude Code opens during the swap queue in the kernel
/// backlog and are served by the new image.
pub async fn run(
app: SharedApp,
listener: tokio::net::TcpListener,
drain: tokio::sync::oneshot::Receiver<()>,
) -> anyhow::Result<()> {
let client = reqwest::Client::builder().build()?;
let ctx = Ctx { client, app: app.clone() };
let router = Router::new().fallback(forward).with_state(ctx);
let port = listener.local_addr()?.port();
lock_app(&app).status = format!("proxy http://127.0.0.1:{port} → api.anthropic.com");
axum::serve(listener, router).await?;
{
// Written here rather than in `main` because this line is the last one
// to land at startup — a reload note set earlier would be overwritten
// by it. The port is the same one across a reload; the counter is the
// only visible sign that the code under it changed.
let mut a = lock_app(&app);
let note = match (a.reload_gen, a.reload_dropped) {
(0, _) => String::new(),
(n, 0) => format!(" · reload #{n}"),
(n, d) => format!(" · reload #{n} ({d} session(s) not restored)"),
};
a.status = format!("proxy http://127.0.0.1:{port} → api.anthropic.com{note}");
}
axum::serve(listener, router)
.with_graceful_shutdown(async move {
// A dropped sender (no reload was ever started) simply never
// resolves into a shutdown — `Err` means "hold the door open".
if drain.await.is_err() {
std::future::pending::<()>().await;
}
})
.await?;
Ok(())
}
@@ -136,9 +166,26 @@ async fn forward_inner(ctx: Ctx, req: Request) -> anyhow::Result<Response> {
.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,17 +193,10 @@ 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.
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())
{
let long = header(BETA_HEADER).is_some_and(|b| b.contains(LONG_CONTEXT_BETA));
record_long_context(&ctx.app, &key, long);
if kind == ReqKind::ServerTool {
label_server_tool_lane(&ctx.app, &key, t.lane(), &v);
}
record_user_prompt(&ctx.app, &key, t.lane(), &v);
tap = Some(t);
}
@@ -177,7 +217,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 +243,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 +266,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)?)

626
src/reload.rs Normal file
View File

@@ -0,0 +1,626 @@
//! Hot reload: `execve` the binary on disk *into this process* instead of
//! restarting.
//!
//! This module builds nothing and watches nothing. You rebuild however you
//! normally would — `cargo build`, `cargo build --release`, a script — and then
//! press **ctrl-r** in each running instance to swap it onto the new binary.
//! Separating the two is the point: a build is your business, and a running
//! instance should not decide on its own when to become different code.
//!
//! `execve` replaces the program image but keeps the pid, the open file
//! descriptors and the child processes. That is the whole trick, and it is what
//! lets all three things the proxy cares about survive:
//!
//! - **the listener** — the accept socket is inherited by fd number, so the
//! port is never closed and never rebound. Claude Code keeps talking to the
//! same `127.0.0.1:<port>` across the swap;
//! - **the embedded `claude` pane** — still our child, still on the same pty
//! (`term::PtyHandoff` / `EmbeddedTerm::adopt`). It is never told anything; it
//! just gets a repaint;
//! - **the live feed** — sessions/entries/lanes travel in a JSON snapshot.
//!
//! Two rules keep it honest:
//!
//! 1. **Drain before the exec.** It destroys the tokio tasks relaying in-flight
//! responses, so the proxy is asked to stop accepting and finish what it has
//! first. The socket stays open throughout, so requests made during the swap
//! queue in the kernel backlog and are served by the new image.
//! 2. **The snapshot is advisory.** It is written by the *old* binary and read
//! by the *new* one, whose types may have just changed — the normal case
//! when the reason you rebuilt was editing `app.rs`. Every restore step is
//! best-effort: a snapshot that no longer fits costs the feed, never the
//! port and never the pane.
//!
//! Nothing here talks to the network, and nothing here runs a subprocess.
use anyhow::Context;
use crate::app::App;
use std::os::fd::{AsRawFd, FromRawFd, RawFd};
use std::path::{Path, PathBuf};
use std::time::Duration;
/// Env var pointing the new image at its snapshot file. Its presence is what
/// distinguishes "started by a reload" from "started by the user".
const HANDOFF_ENV: &str = "CT_RELOAD_HANDOFF";
// ---------------------------------------------------------------------------
// Relative-time serde for the `Instant` fields on Session/Lane
// ---------------------------------------------------------------------------
/// `Instant` has no absolute epoch, so it is snapshotted as "this many ms ago"
/// and rebuilt against the new image's clock. Idle/liveness logic
/// (`Lane::running`, `LANE_IDLE_MAX`) therefore reads the same before and after
/// a reload instead of every lane looking freshly active.
pub mod ms_ago {
use serde::{Deserialize, Deserializer, Serializer};
use std::time::{Duration, Instant};
pub fn serialize<S: Serializer>(v: &Instant, s: S) -> Result<S::Ok, S::Error> {
s.serialize_u64(v.elapsed().as_millis() as u64)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Instant, D::Error> {
let ms = u64::deserialize(d)?;
Ok(back(ms))
}
/// `checked_sub` because a monotonic clock that has not been running long
/// enough cannot represent the age — then "now" is the closest truth.
pub(crate) fn back(ms: u64) -> Instant {
let now = Instant::now();
now.checked_sub(Duration::from_millis(ms)).unwrap_or(now)
}
}
/// `ms_ago` for an `Option<Instant>`.
pub mod ms_ago_opt {
use serde::{Deserialize, Deserializer, Serializer};
use std::time::Instant;
pub fn serialize<S: Serializer>(v: &Option<Instant>, s: S) -> Result<S::Ok, S::Error> {
match v {
Some(i) => s.serialize_some(&(i.elapsed().as_millis() as u64)),
None => s.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Instant>, D::Error> {
Ok(Option::<u64>::deserialize(d)?.map(super::ms_ago::back))
}
}
// ---------------------------------------------------------------------------
// Reload status (owned by App, rendered in the status bar)
// ---------------------------------------------------------------------------
#[derive(Default, Clone, PartialEq)]
pub enum Status {
/// Nothing in progress. ctrl-r starts a reload from here.
#[default]
Idle,
/// The proxy is finishing its in-flight responses; the exec follows.
Draining(std::time::Instant),
/// The exec failed, so the old code is still running and still serving.
/// Purely informational — usually "you pressed ctrl-r mid-build".
Failed(String),
}
impl Status {
/// One-line status-bar rendering, or `None` when there is nothing to say.
pub fn note(&self, in_flight: usize) -> Option<String> {
match self {
Status::Idle => None,
Status::Draining(_) if in_flight > 0 => {
Some(format!("⟳ reloading · draining {in_flight} turn(s)…"))
}
Status::Draining(_) => Some("⟳ reloading…".into()),
Status::Failed(e) => Some(format!("⚠ reload failed: {e}")),
}
}
}
/// Cap on the graceful drain. A stuck upstream response must not pin the
/// reload forever; past this the exec happens anyway and that one response is
/// cut — the same outcome as no drain at all, just far less likely.
pub const DRAIN_MAX: Duration = Duration::from_secs(30);
// ---------------------------------------------------------------------------
// The snapshot handed across the exec
// ---------------------------------------------------------------------------
/// Written by the outgoing image, read by the incoming one. The fd numbers in
/// here are only meaningful because `keep_open` cleared their FD_CLOEXEC.
///
/// Read side only — the write side is `HandoffRef`, which borrows the live
/// state instead of moving it, so a failed exec leaves the running app whole.
#[derive(Default, serde::Deserialize)]
#[serde(default)]
pub struct Handoff {
/// Inherited accept socket. `-1` means "bind a new one" (should not happen).
pub listener_fd: RawFd,
/// The embedded pane, when there was a live one.
pub pane: Option<crate::term::PtyHandoff>,
/// The feed, **left unparsed on purpose**. Schema churn is the normal case
/// for a dev tool — the edit that triggered the reload is usually the one
/// that changed these types — and parsing it inline would let one renamed
/// field take the port and the pane down with the feed. It is decoded
/// separately, session by session, in `restore`.
pub app: serde_json::Value,
pub pane_ui: PaneState,
/// Reloads so far, for the status line.
pub generation: u32,
}
/// The part of `App` worth carrying over. Deliberately a separate struct rather
/// than `#[derive(Serialize)] on App`: popups, caches and lazily loaded disk
/// views are cheap to rebuild and would only add schema churn.
#[derive(Default, serde::Deserialize)]
#[serde(default)]
pub struct AppState {
/// One `Value` per session, decoded individually: a session that no longer
/// fits is skipped instead of discarding the whole feed.
pub sessions: Vec<serde_json::Value>,
/// Session the highlight was on. Preferred over `selected`: disk stubs are
/// rescanned asynchronously, so their indices are not stable across the
/// exec, but their uuids are.
pub selected_key: Option<String>,
pub selected: usize,
pub scroll: usize,
pub follow: bool,
pub filters: Vec<bool>,
pub show_sessions: bool,
pub embed_session: Option<String>,
pub embed_token: Option<String>,
}
/// The pane-related UI state that lives on `EmbedUi`, not on `App`.
#[derive(Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct PaneState {
pub visible: bool,
pub focused: bool,
pub fullscreen: bool,
pub past_embeds: Vec<String>,
pub compact_inner: u16,
}
// ---------------------------------------------------------------------------
// Startup side: did a reload just hand us the process?
// ---------------------------------------------------------------------------
/// Read (and consume) the handoff this process was exec'd with. Returns `None`
/// for a normal start, and also for a snapshot that no longer parses — an
/// expected outcome when the edit that triggered the reload changed the state
/// types, and the reason this is `Option` rather than `Result`.
pub fn take_handoff() -> Option<Handoff> {
let path = std::env::var_os(HANDOFF_ENV)?;
// Consume it either way: a stale file must never be picked up twice.
// Sound here and nowhere else — `main` has not spawned a thread yet.
unsafe { std::env::remove_var(HANDOFF_ENV) };
let raw = std::fs::read(&path).ok();
let _ = std::fs::remove_file(&path);
let parsed = raw.as_ref().and_then(|b| serde_json::from_slice::<Handoff>(b).ok());
if parsed.is_none() {
// Without the fd numbers the inherited socket and pty are unusable
// (still open, but anonymous), so the kernel closes them when we exit.
// A fresh bind is the safe outcome.
eprintln!("claude-cloak: reload handoff unreadable, starting fresh");
}
parsed
}
/// Rebuild a `std::net::TcpListener` from the inherited fd. The caller converts
/// it to a tokio listener inside the runtime.
///
/// The fd is verified to be a listening socket first: the number comes out of a
/// file on disk, and a stale handoff could name an fd this process has since
/// reused for something else entirely.
pub fn adopt_listener(fd: RawFd) -> anyhow::Result<std::net::TcpListener> {
anyhow::ensure!(fd >= 0, "no inherited listener");
anyhow::ensure!(is_listening(fd), "inherited fd {fd} is not a listening socket");
// Put CLOEXEC back: from here on it is a normal socket again, and the next
// reload clears the flag itself.
unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) };
let l = unsafe { std::net::TcpListener::from_raw_fd(fd) };
l.set_nonblocking(true)?;
Ok(l)
}
fn is_listening(fd: RawFd) -> bool {
let mut on: libc::c_int = 0;
let mut len = std::mem::size_of::<libc::c_int>() as libc::socklen_t;
let rc = unsafe {
libc::getsockopt(
fd,
libc::SOL_SOCKET,
libc::SO_ACCEPTCONN,
(&raw mut on).cast(),
&mut len,
)
};
rc == 0 && on != 0
}
/// Duplicate the accept socket so the reload can hold the port open while
/// axum's own listener is dropped by the graceful shutdown.
pub fn dup_listener(l: &tokio::net::TcpListener) -> anyhow::Result<RawFd> {
let fd = unsafe { libc::fcntl(l.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 0) };
if fd < 0 {
return Err(std::io::Error::last_os_error()).context("dup listener");
}
Ok(fd)
}
/// Apply a restored snapshot to a fresh `App`. Every step is best-effort by
/// design — see the module docs.
pub fn restore(app: &mut App, raw: serde_json::Value) {
let s: AppState = serde_json::from_value(raw).unwrap_or_default();
let total = s.sessions.len();
app.sessions = s
.sessions
.into_iter()
.filter_map(|v| serde_json::from_value::<crate::app::Session>(v).ok())
.filter_map(sanitize_session)
.collect();
let kept = app.sessions.len();
app.selected = s.selected;
app.scroll = s.scroll;
app.follow = s.follow;
app.show_sessions = s.show_sessions;
app.embed_session = s.embed_session;
app.embed_token = s.embed_token;
// Length-tolerant: the filter set grows as entry kinds are added, and a
// snapshot from before that must not shift the toggles.
for (i, v) in s.filters.iter().take(app.filters.len()).enumerate() {
app.filters[i] = *v;
}
// The synthetic-lane counter is process-global; restart it past whatever
// the restored sessions already use.
crate::app::seed_server_tool_seq(max_server_tool_seq(&app.sessions) + 1);
app.after_restore(s.selected_key.as_deref());
app.reload_dropped = total - kept;
}
/// Make one restored session safe to render.
///
/// `Entry::lane`, `Lane::first_entry`, `Lane::anchor` and the values of
/// `Session::tool_ids` are all raw indices into vectors that a schema change
/// can shorten. `#[serde(default)]` covers a *missing* field and does nothing
/// for an *inconsistent* one, and the first draw indexes them directly — so
/// they are checked here, once, instead of defensively at every use site.
fn sanitize_session(mut s: crate::app::Session) -> Option<crate::app::Session> {
// `#[serde(default)]` is deliberately forgiving, which means an object that
// is not a session at all still decodes — into an empty one. A live session
// is always keyed (`proxy::forward_inner` falls back to `"unknown"`), so an
// empty key is the tell.
if s.key.is_empty() || s.lanes.is_empty() {
return None;
}
let lanes = s.lanes.len();
let entries = s.entries.len();
s.entries.retain(|e| (e.lane as usize) < lanes);
// Retaining shifts positions, so any index into `entries` is only sound
// when nothing was dropped.
let shifted = s.entries.len() != entries;
let entries = s.entries.len();
// Nothing is streaming into a process that no longer exists. Leaving these
// set would show a permanent running dot and keep `Lane::running` true
// forever — and `Tap::drop` runs on the tee task, so the snapshot can
// legitimately have caught a count that was about to be decremented.
s.active = 0;
for l in &mut s.lanes {
l.active = 0;
if shifted || l.first_entry.is_some_and(|i| i >= entries) {
l.first_entry = None;
}
if shifted || l.anchor.is_some_and(|i| i >= entries) {
l.anchor = None;
}
}
// A tool call whose result can no longer be attached is better than one
// attached to the wrong entry.
s.tool_ids.retain(|_, i| !shifted && *i < entries);
// A half-streamed entry never gets its remaining deltas: close it out so it
// renders as finished markdown instead of a permanently pending block.
if let Some(e) = s.entries.last_mut() {
e.done = true;
}
Some(s)
}
/// Highest `srvtool-<n>` index across the restored sessions.
fn max_server_tool_seq(sessions: &[crate::app::Session]) -> u64 {
sessions
.iter()
.flat_map(|s| s.lane_of_agent.keys())
.filter_map(|k| k.strip_prefix(crate::app::SERVER_TOOL_PREFIX))
.filter_map(|n| n.parse::<u64>().ok())
.max()
.unwrap_or(0)
}
/// Write side of `Handoff`. It *borrows* the live app: the feed can be tens of
/// megabytes, and — more importantly — an exec that fails must leave the
/// running process exactly as it was, which a moved-out `Vec<Session>` would
/// not. The field names mirror `Handoff`/`AppState` exactly; that is the whole
/// contract between the two.
#[derive(serde::Serialize)]
struct HandoffRef<'a> {
listener_fd: RawFd,
pane: &'a Option<crate::term::PtyHandoff>,
app: AppStateRef<'a>,
pane_ui: &'a PaneState,
generation: u32,
}
#[derive(serde::Serialize)]
struct AppStateRef<'a> {
sessions: &'a [crate::app::Session],
selected_key: Option<String>,
selected: usize,
scroll: usize,
follow: bool,
filters: &'a [bool],
show_sessions: bool,
embed_session: &'a Option<String>,
embed_token: &'a Option<String>,
}
impl<'a> AppStateRef<'a> {
fn of(app: &'a App) -> Self {
Self {
sessions: &app.sessions,
selected_key: app.selected_key(),
selected: app.selected,
scroll: app.scroll,
follow: app.follow,
filters: &app.filters,
show_sessions: app.show_sessions,
embed_session: &app.embed_session,
embed_token: &app.embed_token,
}
}
}
// ---------------------------------------------------------------------------
// Commit side: exec ourselves
// ---------------------------------------------------------------------------
/// Clear FD_CLOEXEC so `fd` survives the coming `execve`. Everything else we
/// hold keeps the flag and is closed by the kernel, which is what we want:
/// only the listener and the pty master are meant to cross over.
fn keep_open(fd: RawFd) -> bool {
unsafe { libc::fcntl(fd, libc::F_SETFD, 0) == 0 }
}
/// Undo `keep_open`. Only reached when the exec failed: the fds must not stay
/// inheritable, or the next `claude` we spawn would get a copy of the proxy
/// socket and the pane's pty.
fn close_on_exec(fd: RawFd) {
if fd >= 0 {
unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) };
}
}
/// Replace this process with `exe`, carrying the live state across.
///
/// On success this never returns: the new image continues from `main` with the
/// same pid, the same listener socket and the same `claude` child. It only
/// returns on failure — and then nothing has been consumed, so the caller just
/// keeps running the old code.
pub fn exec_into(
exe: &Path,
app: &App,
pane: Option<crate::term::PtyHandoff>,
pane_ui: &PaneState,
) -> anyhow::Error {
// Only these two fds are meant to cross. Everything else we hold keeps its
// FD_CLOEXEC and is closed by the kernel during the exec — including the
// established connections, which is why the reload waits for a quiet wire.
let listener_fd = if keep_open(app.listener_fd) { app.listener_fd } else { -1 };
let pane = match pane {
Some(p) if keep_open(p.master_fd) => Some(p),
// A pane whose fd cannot be kept open is dropped rather than handed
// over as a dangling number: the new image then simply has no pane.
_ => None,
};
let path = std::env::temp_dir().join(format!("claude-cloak-reload-{}.json", std::process::id()));
let snapshot = HandoffRef {
listener_fd,
pane: &pane,
app: AppStateRef::of(app),
pane_ui,
generation: app.reload_gen,
};
let written = serde_json::to_vec(&snapshot).map_err(anyhow::Error::from).and_then(|b| {
// Written and flushed before the exec: a crash in between would
// otherwise leave a truncated file the new image reads as garbage.
use std::io::Write;
let mut f = std::fs::File::create(&path)?;
f.write_all(&b)?;
f.sync_all()?;
Ok(())
});
let err = match written {
Err(e) => e.context("write reload snapshot"),
Ok(()) => {
// Same arguments we were started with, argv[0] aside.
let args: Vec<String> = std::env::args().skip(1).collect();
use std::os::unix::process::CommandExt;
let e = std::process::Command::new(exe).args(&args).env(HANDOFF_ENV, &path).exec();
// `exec` returns only on failure.
let _ = std::fs::remove_file(&path);
anyhow::Error::from(e).context(format!("exec {}", exe.display()))
}
};
// The reload did not happen, so put the fds back the way we found them and
// let the old code carry on.
close_on_exec(listener_fd);
if let Some(p) = &pane {
close_on_exec(p.master_fd);
}
err
}
// ---------------------------------------------------------------------------
// Which binary to exec
// ---------------------------------------------------------------------------
/// Resolve our own executable **path** (not inode) at startup.
///
/// Read once, in `main`, and then carried across every reload in the handoff —
/// because by the time you press ctrl-r the file has usually been replaced.
/// A linker writes the new binary and renames it over the old one, which
/// unlinks the inode we are running from; Linux then reports `/proc/self/exe`
/// as `…/claude-cloak (deleted)`. Resolving late would exec that literal name
/// and fail, so the suffix is also stripped defensively.
pub fn exe_path() -> PathBuf {
let raw = std::env::current_exe()
.ok()
.or_else(|| std::env::args_os().next().map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from(env!("CARGO_PKG_NAME")));
strip_deleted(raw)
}
fn strip_deleted(p: PathBuf) -> PathBuf {
match p.to_str().and_then(|s| s.strip_suffix(" (deleted)")) {
Some(s) => PathBuf::from(s),
None => p,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{Entry, Kind, Session};
use std::time::Instant;
/// A session as the *old* image would have snapshotted it.
fn live_session() -> Session {
let mut s = Session::new("sess".into(), "opus".into());
s.push(0, Entry { kind: Kind::Text, content: "hi".into(), ..Default::default() });
s.active = 2;
s.lanes[0].active = 2;
s.tool_ids.insert("toolu_1".into(), 0);
s
}
#[test]
fn snapshot_round_trips_through_json() {
let s = live_session();
let json = serde_json::to_string(&s).unwrap();
let back: Session = serde_json::from_str(&json).unwrap();
assert_eq!(back.key, "sess");
assert_eq!(back.entries.len(), 1);
assert_eq!(back.entries[0].content, "hi");
assert_eq!(back.lanes.len(), 1);
}
/// `Instant` is snapshotted as an age, so a lane restored from disk must
/// still look as old as it was — that is what `Lane::running` reads.
#[test]
fn instants_survive_as_ages_not_as_now() {
let mut s = live_session();
let old = Instant::now() - Duration::from_secs(120);
s.last_activity = old;
s.lanes[0].last_event = Some(old);
let back: Session = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
assert!(back.last_activity.elapsed() >= Duration::from_secs(119));
assert!(back.lanes[0].last_event.unwrap().elapsed() >= Duration::from_secs(119));
// …and therefore reads as idle, not as freshly running, once the
// in-flight counters are cleared.
assert!(!sanitize_session(back).unwrap().lanes[0].running());
}
/// Nothing streams into a process that no longer exists.
#[test]
fn sanitize_clears_in_flight_counters() {
let s = sanitize_session(live_session()).unwrap();
assert_eq!(s.active, 0);
assert_eq!(s.lanes[0].active, 0);
assert!(s.entries.last().unwrap().done, "a cut entry is closed out");
}
/// The dangerous case: a snapshot whose entries point at lanes the new
/// binary's session no longer has. Indexing those panics on the first draw.
#[test]
fn sanitize_drops_entries_with_dangling_lanes() {
let mut s = live_session();
s.entries.push(Entry { kind: Kind::Text, content: "ghost".into(), lane: 7, ..Default::default() });
let s = sanitize_session(s).unwrap();
assert_eq!(s.entries.len(), 1);
assert!(s.entries.iter().all(|e| (e.lane as usize) < s.lanes.len()));
// Positions shifted, so index-valued maps are dropped rather than
// left pointing at the wrong entry.
assert!(s.tool_ids.is_empty());
}
#[test]
fn sanitize_drops_a_session_without_lanes() {
let mut s = live_session();
s.lanes.clear();
assert!(sanitize_session(s).is_none());
}
#[test]
fn sanitize_clears_out_of_range_lane_indices() {
let mut s = live_session();
s.lanes[0].anchor = Some(99);
s.lanes[0].first_entry = Some(99);
let s = sanitize_session(s).unwrap();
assert_eq!(s.lanes[0].anchor, None);
assert_eq!(s.lanes[0].first_entry, None);
}
/// The synthetic-lane counter is process-global, so a reload must not
/// restart it at 0 and re-enter a lane the restored session already holds.
#[test]
fn server_tool_seq_is_seeded_past_restored_lanes() {
let mut s = live_session();
s.lane_of_agent.insert(format!("{}4", crate::app::SERVER_TOOL_PREFIX), 0);
s.lane_of_agent.insert("deadbeef".into(), 0);
assert_eq!(max_server_tool_seq(&[s]), 4);
}
/// The binary is expected to have been replaced while we run: a rename
/// over the running image makes Linux report `/proc/self/exe` with a
/// ` (deleted)` suffix, and exec'ing that literal name would fail.
#[test]
fn exe_path_drops_the_deleted_suffix() {
let p = strip_deleted(PathBuf::from("/x/target/debug/claude-cloak (deleted)"));
assert_eq!(p, PathBuf::from("/x/target/debug/claude-cloak"));
// An ordinary path is untouched, including one that merely contains
// the word.
let p = PathBuf::from("/x/deleted/claude-cloak");
assert_eq!(strip_deleted(p.clone()), p);
}
/// A handoff whose feed no longer parses must still surrender the fd
/// numbers: losing the code's state is survivable, losing the port and the
/// pane is not.
#[test]
fn unparseable_feed_keeps_the_port_and_the_pane() {
let raw = serde_json::json!({
"listener_fd": 9,
"pane": {
"master_fd": 11, "child_pid": 4242, "session_id": "s",
"pane_token": "t", "pty_rows": 20, "cols": 80
},
"app": {"sessions": [{"this": "is not a session"}, {"key": "keeper"}], "selected": 3},
"pane_ui": {"visible": true},
"generation": 2,
});
let h: Handoff = serde_json::from_value(raw).unwrap();
assert_eq!(h.listener_fd, 9);
assert_eq!(h.pane.as_ref().unwrap().child_pid, 4242);
assert_eq!(h.generation, 2);
let mut app = App::new();
restore(&mut app, h.app);
let keys: Vec<_> = app.sessions.iter().map(|s| s.key.as_str()).collect();
assert_eq!(keys, ["keeper"], "the junk entry is skipped, the real one kept");
}
}

View File

@@ -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 &notes {
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));
}
}

View File

@@ -16,14 +16,16 @@ use ratatui::buffer::Buffer;
use ratatui::crossterm::cursor::SetCursorStyle;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier};
use std::io::Read;
use std::io::{Read, Write};
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
pub use wezterm_surface::CursorShape;
use wezterm_surface::CursorVisibility;
use wezterm_term::color::{ColorAttribute, ColorPalette};
use wezterm_term::{
Intensity, KeyCode, KeyModifiers, Terminal, TerminalConfiguration, TerminalSize, Underline,
Intensity, KeyCode, KeyModifiers, StableRowIndex, Terminal, TerminalConfiguration, TerminalSize,
Underline,
};
/// Extra PTY rows beyond the visible pane window, so the child has room to
@@ -84,6 +86,21 @@ pub struct EmbeddedTerm {
/// Actual PTY rows (visible rows + pad when cropping is active).
pty_rows: u16,
cols: u16,
/// Top row of the *fullscreen* pane's view, as a stable row index —
/// `None` (the normal state) means "follow the live screen".
///
/// Claude Code grabs no mouse and stays off the alternate screen, so in a
/// plain terminal the wheel scrolls that terminal's scrollback and the
/// child never hears about it. The fullscreen pane is that terminal, so it
/// does the same job (see `scroll`). A *stable* index rather than an offset
/// because the child keeps writing while you read: a terminal pins the rows
/// you scrolled to instead of sliding them up under you.
scroll_top: Mutex<Option<StableRowIndex>>,
/// The child's pid. Kept as a plain number because a hot reload
/// (`reload.rs`) execs us: the portable-pty `Child` handle dies with the
/// old image, but we stay the same process, so the *pid* is still ours to
/// wait on and signal after the exec.
child_pid: Option<u32>,
}
/// HTTP header carrying the pane token; the proxy reads it to bind this pane's
@@ -156,6 +173,7 @@ impl EmbeddedTerm {
.context("openpty")?;
let child = pty.slave.spawn_command(cmd).context("spawn child")?;
let killer = child.clone_killer();
let child_pid = child.process_id();
drop(pty.slave);
// The terminal model writes query responses (DSR/DA/XTGETTCAP…) and
@@ -194,13 +212,114 @@ impl EmbeddedTerm {
});
}
Ok(Self { term, master: pty.master, killer, exited, session_id, pane_token, pty_rows: rows + PTY_PAD, cols })
Ok(Self {
term,
master: pty.master,
killer,
exited,
session_id,
pane_token,
pty_rows: rows + PTY_PAD,
cols,
scroll_top: Mutex::new(None),
child_pid,
})
}
/// Take a *running* child back over after a hot reload. The PTY master fd
/// came through the `execve` (see `reload::keep_open`), and the child never
/// noticed: same pid on our side, same pty, same session.
///
/// What does not survive is the wezterm screen model: it is rebuilt empty,
/// so the cells have to come from the child again. The trick is to adopt
/// the pty **one row short** of its real height and let the first
/// `ui::draw` frame restore it — `resize` then sees a changed geometry and
/// issues a real `TIOCSWINSZ`, which Linux only turns into a SIGWINCH when
/// the size actually differs, and Ink answers with a full repaint. Poking
/// the ioctl twice in a row here instead would coalesce into one signal
/// carrying the *unchanged* final size, and redraw nothing.
pub fn adopt(h: PtyHandoff) -> anyhow::Result<Self> {
let rows = h.pty_rows.saturating_sub(1).max(1);
let master = AdoptedMaster { fd: unsafe { OwnedFd::from_raw_fd(h.master_fd) } };
// The fd arrived non-CLOEXEC (that is how it survived the exec). Put
// the flag back so it isn't inherited by anything we spawn from here.
unsafe { libc::fcntl(h.master_fd, libc::F_SETFD, libc::FD_CLOEXEC) };
let writer = master.take_writer().context("pty writer")?;
let _ = master.resize(PtySize { rows, cols: h.cols, pixel_width: 0, pixel_height: 0 });
let term = Arc::new(Mutex::new(Terminal::new(
TerminalSize {
rows: rows as usize,
cols: h.cols as usize,
pixel_width: 0,
pixel_height: 0,
dpi: 0,
},
Arc::new(Config),
"claude-cloak",
env!("CARGO_PKG_VERSION"),
writer,
)));
let exited = Arc::new(AtomicBool::new(false));
let mut reader = master.try_clone_reader().context("pty reader")?;
let pid = h.child_pid;
{
let term = term.clone();
let exited = exited.clone();
std::thread::spawn(move || {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => term.lock().unwrap().advance_bytes(&buf[..n]),
}
}
// Still the child's parent across the exec, so it is still
// ours to reap — the portable-pty `Child` that used to do it
// died with the old image. Retry on EINTR; anything else
// (notably ECHILD) means there is nothing left to wait for.
let mut status = 0;
while unsafe { libc::waitpid(pid as i32, &mut status, 0) } < 0
&& std::io::Error::last_os_error().raw_os_error() == Some(libc::EINTR)
{}
exited.store(true, Ordering::Relaxed);
});
}
Ok(Self {
term,
master: Box::new(master),
killer: Box::new(PidKiller(pid)),
exited,
session_id: h.session_id,
pane_token: h.pane_token,
// Deliberately the short height: the next frame's `resize` restores
// the real one and that is what triggers the repaint.
pty_rows: rows,
cols: h.cols,
scroll_top: Mutex::new(None),
child_pid: Some(pid),
})
}
pub fn exited(&self) -> bool {
self.exited.load(Ordering::Relaxed)
}
/// Describe this pane well enough for the post-exec image to re-adopt it
/// (`adopt`). `None` when the child's pid is unknown or the master has no
/// fd — either way the pane can't survive a reload and is killed instead.
pub fn handoff(&self) -> Option<PtyHandoff> {
Some(PtyHandoff {
master_fd: self.master.as_raw_fd()?,
child_pid: self.child_pid?,
session_id: self.session_id.clone(),
pane_token: self.pane_token.clone(),
pty_rows: self.pty_rows,
cols: self.cols,
})
}
/// Resize PTY + terminal model for a pane of `rows` visible rows.
/// With `crop` (the compact pane), the PTY gets `PTY_PAD` extra rows:
/// render() crops Claude Code's persistent status/hint rows, so the
@@ -244,6 +363,51 @@ impl EmbeddedTerm {
let _ = self.term.lock().unwrap().send_paste(text);
}
/// Scroll the fullscreen pane's view by `delta` rows — negative up, into
/// the child's scrollback. Clamps to what the scrollback still holds;
/// arriving back at the live screen re-engages follow mode rather than
/// pinning to it.
///
/// Deliberately *our* scroll and not a forwarded mouse report: Claude Code
/// enables no mouse tracking and never leaves the normal screen, so a
/// plain terminal scrolls its own scrollback here too and the child sees
/// nothing. The fullscreen pane is that terminal.
pub fn scroll(&self, delta: isize) {
let term = self.term.lock().unwrap();
let screen = term.screen();
// Top row of the live screen, and the oldest row still held.
let live = screen.visible_row_to_stable_row(0);
let oldest = screen.phys_to_stable_row_index(0);
let mut top = self.scroll_top.lock().unwrap();
let next = (top.unwrap_or(live) + delta).clamp(oldest, live);
*top = (next < live).then_some(next);
}
/// One page of the pane, in rows: the child's screen height less a row of
/// overlap, so a page scroll keeps a line of context.
pub fn page_rows(&self) -> isize {
(self.term.lock().unwrap().screen().physical_rows as isize - 1).max(1)
}
/// Snap the view back to the live screen. A terminal does this on a
/// keypress (xterm's scroll-on-key); without it a scrolled-back pane looks
/// frozen the moment you start typing again.
pub fn follow_live(&self) {
*self.scroll_top.lock().unwrap() = None;
}
/// Rows the view sits above the live screen (0 = following). The pane
/// border shows this, so a scrolled-back view is never mistaken for a
/// stalled child.
pub fn scrolled_rows(&self) -> usize {
// `term` before `scroll_top`: the one lock order used here.
let term = self.term.lock().unwrap();
let Some(top) = *self.scroll_top.lock().unwrap() else {
return 0;
};
(term.screen().visible_row_to_stable_row(0) - top).max(0) as usize
}
/// The child's current cursor shape (set via DECSCUSR). We mirror it onto
/// the outer terminal so the pane shows a bar in insert mode and a block
/// only when Claude Code's vim normal mode asks for one.
@@ -340,15 +504,33 @@ impl EmbeddedTerm {
/// - `Interactive`: the AskUserQuestion / ExitPlanMode prompt the tap grew
/// the pane for — framed from its own top border (see
/// `interactive_view_range`) so the question text is never cropped.
/// - `Full` (fullscreen): the screen verbatim from row 0, nothing cut off.
/// - `Full` (fullscreen): the screen verbatim from row 0, nothing cut off
/// or, once the wheel has scrolled the pane back (`scroll`), a window of
/// the child's scrollback instead. Only fullscreen scrolls: the cropped
/// views frame the input box, which is always at the live bottom.
pub fn render(&self, area: Rect, buf: &mut Buffer, view: PaneView) -> Option<(u16, u16)> {
let term = self.term.lock().unwrap();
let screen = term.screen();
let first = screen.phys_row(0);
let lines = screen.lines_in_phys_range(first..first + screen.physical_rows);
let h = area.height as usize;
// Scrolled back: take the window out of the scrollback instead. A
// stable index maps to `None` once its row has been trimmed away, and
// the live top is the floor — a view *at* it is just following.
let live_first = screen.phys_row(0);
let back = (view == PaneView::Full)
.then(|| *self.scroll_top.lock().unwrap())
.flatten()
.and_then(|top| screen.stable_row_to_phys(top))
.filter(|&p| p < live_first);
let (first, count) = match back {
Some(p) => (p, h.min(live_first - p + screen.physical_rows)),
None => (live_first, screen.physical_rows),
};
let lines = screen.lines_in_phys_range(first..first + count);
if lines.is_empty() {
return None; // zero-height pane: nothing to paint or index into
}
let rows: Vec<String> = lines.iter().map(row_text).collect();
let last = rows.iter().rposition(|t| !t.trim().is_empty()).unwrap_or(0);
let h = area.height as usize;
let (start, end) = match view {
PaneView::Full => {
// The PTY is sized to the pane in fullscreen, but a resize may
@@ -400,7 +582,11 @@ impl EmbeddedTerm {
}
let cursor = term.cursor_pos();
let cy = cursor.y as usize;
(cursor.visibility == CursorVisibility::Visible
// A scrolled-back window is not the live screen, so the cursor row the
// child reports does not index into it — show none, exactly like a
// terminal scrolled away from its prompt.
(back.is_none()
&& cursor.visibility == CursorVisibility::Visible
&& (cursor.x as u16) < area.width
&& cy >= start
&& cy <= end
@@ -424,11 +610,33 @@ fn text_is_rule(t: &str) -> bool {
/// A `@`-file / `/`-command menu row. When such a menu is open it replaces the
/// statusLine + hint/token/effort chrome with a list directly under the input
/// box's bottom rule. These markers are the Claude Code 2.1.x list glyphs;
/// retune here if a CC update changes them.
/// box's bottom rule.
///
/// Claude Code 2.1.x draws four shapes here, and **a glyph is not one of the
/// things they share** (captured from a real child):
/// - fuzzy match inside the project (`@bug`, `@src/`): `+ src/debug/`;
/// - a **directory listing** — the typed token holds a path that leaves the
/// project (`@../de`): plain padded paths, `../destinations/`, no marker at
/// all. The selected row differs only in colour, which `row_text` drops, so
/// text alone can never find a glyph here;
/// - a session/agent mention (`@CLA`): `@claude-cloak-a4 message session · …`;
/// - a `/` command, whose description wraps onto plain continuation rows.
///
/// So the last resort is the *shape* of a path: one whitespace-free token
/// holding a `/`. That is deliberately loose — `compact_frame_ex` votes over
/// two rows, and keeps the statusLine out of the vote, rather than trusting any
/// single row (see the menu detection there).
fn text_is_menu_item(t: &str) -> bool {
let t = t.trim_start();
["+ ", "* ", " ", " "].iter().any(|m| t.starts_with(m)) || t.starts_with('/')
let t = t.trim();
if ["+ ", "* ", " ", " "].iter().any(|m| t.starts_with(m)) {
return true;
}
// `/command …` and `@agent …` rows carry their description inline.
if t.starts_with('/') || t.starts_with('@') {
return true;
}
// Bare listing row: `../destinations/`, `src/game/foo.gd`.
t.contains('/') && !t.contains(char::is_whitespace)
}
/// Status glyphs Claude Code prints in front of a task/todo row: pending
@@ -558,14 +766,23 @@ fn compact_frame_ex(rows: &[String]) -> Option<CompactFrame> {
None => ctx_top,
};
// An open `@`/`/` menu replaces the chrome below the bottom rule with a
// list. Scan the *whole* region under the rule for a menu row, not just the
// one immediately below it: the list can start after a blank separator or a
// header row, and only the highlighted item carries a recognisable glyph
// (unselected file rows are plain indented names), so checking a single row
// missed the menu whenever that row happened not to be the selected one.
// The persistent chrome rows (statusLine / hint / tokens / effort) never
// match `text_is_menu_item`, so scanning stays free of false positives.
let menu_open = last > bot_div && (bot_div + 1..=last).any(|i| text_is_menu_item(&rows[i]));
// list. Deciding that takes *two* rows, because a menu row is not reliably
// marked (see `text_is_menu_item`) and the statusLine's text is the user's,
// so it can look like anything — a bare `~/projects/foo` included:
// - the row right under the rule (`head`) must be a menu row or blank. It
// is shown either way (it is the statusLine's slot when no menu is up),
// so a one-row menu needs no detection at all; blank counts because the
// chrome never leaves that row empty, while a menu may separate itself
// from the box.
// - at least one *further* row must look like a menu row too. This is what
// keeps a two-line statusLine (line 2 a bare path) from reading as a
// menu and dragging the whole hint/token/effort chrome into the pane.
// Scanning the whole tail (not just `bot_div + 1`) is also required: only
// the highlighted row of a fuzzy list carries a glyph, so checking one row
// collapsed the pane whenever that row was not the selected one.
let head = bot_div + 1;
let head_ok = last > head && (text_is_menu_item(&rows[head]) || rows[head].trim().is_empty());
let menu_open = head_ok && (head + 1..=last).any(|i| text_is_menu_item(&rows[i]));
let view_bottom = if menu_open { last } else { (bot_div + 1).min(last) };
Some(CompactFrame { top: view_top, ess_top: ctx_top, bottom: view_bottom, menu_open })
}
@@ -680,6 +897,118 @@ fn interactive_view_range(rows: &[String], last: usize, h: usize) -> (usize, usi
(end + 1 - h, end)
}
/// Everything the post-exec image needs to take a running `claude` child back
/// over (`EmbeddedTerm::adopt`). An `execve` keeps our pid, our open fds and
/// our children, so the child never notices the reload — but every Rust-side
/// handle is gone, which is why the pane is rebuilt from a bare fd + pid.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct PtyHandoff {
/// PTY master. `reload::keep_open` clears its FD_CLOEXEC before the exec,
/// so this number is still valid — and still the same pty — afterwards.
pub master_fd: RawFd,
pub child_pid: u32,
pub session_id: String,
pub pane_token: String,
pub pty_rows: u16,
pub cols: u16,
}
/// A PTY master rebuilt from an inherited fd. Implements just enough of
/// `MasterPty` to stand in for portable-pty's own master, so `EmbeddedTerm`
/// keeps one type for both the freshly spawned and the adopted pane.
///
/// Note its writer is a plain `File`: portable-pty's writer sends EOT to the
/// child when dropped, which would end the adopted session on every teardown.
#[derive(Debug)]
struct AdoptedMaster {
fd: OwnedFd,
}
impl AdoptedMaster {
/// Duplicate the master fd for an independent reader/writer handle.
/// `F_DUPFD_CLOEXEC` keeps the clone out of the *next* reload's exec —
/// only the one fd named in the handoff is meant to survive.
fn dup(&self) -> anyhow::Result<std::fs::File> {
let fd = unsafe { libc::fcntl(self.fd.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 0) };
if fd < 0 {
return Err(std::io::Error::last_os_error()).context("dup pty master");
}
Ok(unsafe { std::fs::File::from_raw_fd(fd) })
}
}
impl MasterPty for AdoptedMaster {
fn resize(&self, size: PtySize) -> Result<(), anyhow::Error> {
let ws = libc::winsize {
ws_row: size.rows,
ws_col: size.cols,
ws_xpixel: size.pixel_width,
ws_ypixel: size.pixel_height,
};
let rc = unsafe { libc::ioctl(self.fd.as_raw_fd(), libc::TIOCSWINSZ as _, &ws) };
if rc != 0 {
return Err(std::io::Error::last_os_error()).context("ioctl(TIOCSWINSZ)");
}
Ok(())
}
fn get_size(&self) -> Result<PtySize, anyhow::Error> {
let mut ws: libc::winsize = unsafe { std::mem::zeroed() };
let rc = unsafe { libc::ioctl(self.fd.as_raw_fd(), libc::TIOCGWINSZ as _, &mut ws) };
if rc != 0 {
return Err(std::io::Error::last_os_error()).context("ioctl(TIOCGWINSZ)");
}
Ok(PtySize {
rows: ws.ws_row,
cols: ws.ws_col,
pixel_width: ws.ws_xpixel,
pixel_height: ws.ws_ypixel,
})
}
fn try_clone_reader(&self) -> Result<Box<dyn Read + Send>, anyhow::Error> {
Ok(Box::new(self.dup()?))
}
fn take_writer(&self) -> Result<Box<dyn Write + Send>, anyhow::Error> {
Ok(Box::new(self.dup()?))
}
fn process_group_leader(&self) -> Option<libc::pid_t> {
match unsafe { libc::tcgetpgrp(self.fd.as_raw_fd()) } {
pid if pid > 0 => Some(pid),
_ => None,
}
}
fn as_raw_fd(&self) -> Option<RawFd> {
Some(self.fd.as_raw_fd())
}
fn tty_name(&self) -> Option<std::path::PathBuf> {
None
}
}
/// Signals a child by pid. Stands in for portable-pty's killer, whose handle
/// doesn't survive the exec. SIGHUP matches what portable-pty sends, so an
/// adopted pane dies exactly like a spawned one.
#[derive(Debug)]
struct PidKiller(u32);
impl ChildKiller for PidKiller {
fn kill(&mut self) -> std::io::Result<()> {
if unsafe { libc::kill(self.0 as i32, libc::SIGHUP) } != 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
}
fn clone_killer(&self) -> Box<dyn ChildKiller + Send + Sync> {
Box::new(PidKiller(self.0))
}
}
impl Drop for EmbeddedTerm {
fn drop(&mut self) {
let _ = self.killer.kill();
@@ -720,10 +1049,10 @@ fn conv_color(c: ColorAttribute) -> Option<Color> {
}
}
/// Claude Code's own default model, as a `--model` argument (`opus`,
/// `opus[1m]`, …). Its settings files are the one place the **1M-context**
/// choice is written down — `/model` saves the pick there, suffix and all,
/// while a transcript records the same base model id either way.
/// Claude Code's own default model, as it writes it (`opus`, `opus[1m]`, …).
/// Only the *model* is used: `App::spawn_arg` / `App::resume_arg` strip any
/// suffix and re-apply the 1M window themselves, so a `default` pick or a
/// session we know nothing about still names a model we can attach `[1m]` to.
///
/// Resolved the way Claude Code layers it: `ANTHROPIC_MODEL`, then
/// project-local, project, and user settings. `None` when nothing sets one
@@ -757,21 +1086,21 @@ fn settings_model(path: &std::path::Path) -> Option<String> {
.map(str::to_string)
}
/// Background scan that replaces `App::model_choices` with the live alias set
/// read from the installed `claude` binary (see `discover_model_aliases`).
/// Runs off the UI thread; on failure the seeded fallback list stays in place.
/// Background scan that replaces `App::models` with the live sets read from the
/// installed `claude` binary (see `discover_models`). Runs off the UI thread;
/// on failure the seeded fallback catalog stays in place.
pub fn spawn_model_discovery(app: crate::app::SharedApp) {
std::thread::spawn(move || {
if let Some(choices) = discover_model_choices() {
crate::app::lock_app(&app).model_choices = choices;
if let Some(models) = discover_models() {
crate::app::lock_app(&app).models = models;
}
});
}
/// Best-effort discovery of the models the installed `claude` accepts (aliases
/// like `opus`, `sonnet`, `haiku`, `fable`, plus their `<alias>[1m]`
/// long-context variants), so the `a` picker tracks new models without us
/// hardcoding a list that drifts.
/// like `opus`, `sonnet`, `haiku`, `fable`, and which of them ship a
/// `<alias>[1m]` long-context variant), so the `a` picker and the always-1M
/// policy track new models without us hardcoding a list that drifts.
///
/// Claude Code ships as one self-contained executable with its (minified) JS
/// bundle embedded; the alias set appears verbatim as a JSON array literal like
@@ -779,28 +1108,25 @@ pub fn spawn_model_discovery(app: crate::app::SharedApp) {
/// quoted `"sonnet[1m]"` literal. We resolve the `claude` binary on PATH and
/// read it once. This issues **no API request** (the project's core constraint)
/// and never executes claude. Returns None if the binary can't be found/read or
/// nothing matches — the caller keeps its built-in fallback list.
fn discover_model_choices() -> Option<Vec<(String, String)>> {
/// nothing matches — the caller keeps its built-in fallback catalog.
fn discover_models() -> Option<crate::app::Models> {
let bytes = std::fs::read(claude_binary_path()?).ok()?;
let aliases = longest_alias_array(&bytes)?;
Some(model_choices_from(&bytes, &aliases))
Some(models_from(&bytes, aliases))
}
/// Assemble picker entries `(label, --model arg)`: `default` (no `--model`
/// flag) first, then every alias, then the `<alias>[1m]` long-context variants
/// the binary actually ships (see `long_context_tokens`).
fn model_choices_from(bytes: &[u8], aliases: &[String]) -> Vec<(String, String)> {
let long = long_context_tokens(bytes);
let mut choices: Vec<(String, String)> = vec![("default".into(), String::new())];
choices.extend(aliases.iter().map(|a| (a.clone(), a.clone())));
choices.extend(
aliases
.iter()
.map(|a| format!("{a}[1m]"))
.filter(|v| long.contains(v))
.map(|v| (format!("{v} (1M context)"), v)),
);
choices
/// Build the catalog: every alias, plus every model the binary really ships a
/// `[1m]` variant for — never assumed. `long` keeps the *base* name of each
/// `"<name>[1m]"` literal (see `long_context_tokens`), so it covers full model
/// ids as well as aliases: a session whose transcript names a model with no
/// alias still resumes at the long window.
fn models_from(bytes: &[u8], aliases: Vec<String>) -> crate::app::Models {
let mut long: Vec<String> = long_context_tokens(bytes)
.iter()
.map(|t| crate::app::base_model(t).to_string())
.collect();
long.sort();
crate::app::Models { aliases, long }
}
/// Resolve `claude` on `PATH` to a readable file path (symlinks followed).
@@ -932,14 +1258,20 @@ mod tests {
}
#[test]
fn appends_1m_choices_for_aliases_that_have_them() {
let bytes = br#"["sonnet","opus","haiku"] "sonnet[1m]" "opus[1m]""#;
fn catalog_marks_only_aliases_with_a_real_1m_variant() {
let bytes =
br#"["sonnet","opus","haiku"] "sonnet[1m]" "opus[1m]" "claude-mythos-1[1m]""#;
let aliases = longest_alias_array(bytes).unwrap();
let got = model_choices_from(bytes, &aliases);
let args: Vec<&str> = got.iter().map(|c| c.1.as_str()).collect();
// default (no flag), the plain aliases, then only the real 1M variants.
assert_eq!(args, ["", "sonnet", "opus", "haiku", "sonnet[1m]", "opus[1m]"]);
assert_eq!(got[4].0, "sonnet[1m] (1M context)");
let models = models_from(bytes, aliases);
assert_eq!(models.aliases, ["sonnet", "opus", "haiku"]);
// Full ids count too: a transcript can name a model no alias covers.
assert_eq!(models.long, ["claude-mythos-1", "opus", "sonnet"]);
assert_eq!(models.arg("claude-mythos-1"), "claude-mythos-1[1m]");
assert_eq!(models.arg("haiku"), "haiku");
// Picker rows: every model at the window `Models::arg` gives it —
// 1M where the binary ships one, plain where it does not.
let args: Vec<String> = models.choices().into_iter().map(|c| c.1).collect();
assert_eq!(args, ["", "sonnet[1m]", "opus[1m]", "haiku"]);
}
/// Build a `rows` fixture (visible text per screen row) from string slices.
@@ -1020,6 +1352,93 @@ mod tests {
assert_eq!(compact_frame(&screen), Some((1, 8)));
}
#[test]
fn shows_unmarked_path_listing_menu() {
// Real capture, `@../de` typed in ~/projects/destinations: a path that
// leaves the project switches CC to a *listing*, whose rows carry no
// `+`/`` marker at all (the selected one differs only in colour).
// Regression: the pane showed the first hit and cropped the rest.
let screen = rows(&[
"", "",
&format!("{RULE} minimal ──"), // 2: top rule
" @../de", // 3: input
RULE, // 4: bottom rule
" ../destinations/", // 5: selected (colour only)
" ../destinations-player-host/", // 6
" ../destinations-terrain-unify/", // 7: last non-blank
"", "",
]);
assert_eq!(compact_frame(&screen), Some((1, 7)));
}
#[test]
fn shows_agent_mention_menu() {
// `@CLA` lists sessions/agents: `@name` + an inline description.
let screen = rows(&[
"", "",
&format!("{RULE} minimal ──"), // 2: top rule
" @CLA", // 3: input
RULE, // 4: bottom rule
" @claude-cloak-a4 message session · active 30s ago", // 5
" @claude-cloak-10 message session · active 2m ago", // 6
"", "",
]);
assert_eq!(compact_frame(&screen), Some((1, 6)));
}
#[test]
fn single_item_menu_needs_no_detection() {
// One match, and it sits in the statusLine's own slot — always shown.
let screen = rows(&[
"", "",
&format!("{RULE} minimal ──"), // 2: top rule
" @../destinations-t", // 3: input
RULE, // 4: bottom rule
" ../destinations-terrain-unify/", // 5: the only hit
"", "",
]);
assert_eq!(compact_frame(&screen), Some((1, 5)));
// A blank separator before a lone item still shows the item.
let screen = rows(&[
"", "",
&format!("{RULE} minimal ──"),
" @../destinations-t",
RULE,
"", // 5: separator
" ../destinations-terrain-unify/", // 6
"", "",
]);
assert_eq!(compact_frame(&screen), Some((1, 6)));
}
#[test]
fn path_shaped_statusline_is_not_a_menu() {
// The statusLine is the user's own text, so it can be a bare path —
// which is exactly the shape an unmarked menu row has. Detection votes
// over a second row for this reason: a path-ish statusLine (even a
// two-line one) must not drag the hint/token chrome into the pane.
let screen = rows(&[
"", "",
&format!("{RULE} minimal ──"), // 2: top rule
"", // 3: input
RULE, // 4: bottom rule
"~/projects/destinations", // 5: statusLine (kept)
"⏵⏵ bypass permissions (shift+tab)", // 6: chrome (cropped)
" 0 tokens", // 7: chrome (cropped)
]);
assert_eq!(compact_frame(&screen), Some((1, 5)));
let screen = rows(&[
"", "",
&format!("{RULE} minimal ──"),
"",
RULE,
"Opus 5 · xhigh", // 5: statusLine line 1 (kept)
"~/projects/destinations", // 6: statusLine line 2 (cropped)
"? for shortcuts", // 7
]);
assert_eq!(compact_frame(&screen), Some((1, 5)));
}
#[test]
fn overflowing_menu_keeps_input_box_visible() {
// Regression: when a long `@`/`/` match list doesn't fit in the pane's
@@ -1215,6 +1634,67 @@ mod tests {
assert!(longest_alias_array(br#"["Opus","sonnet"]"#).is_none());
}
/// First rendered row of `view`, trimmed.
fn top_row(et: &EmbeddedTerm, area: Rect, view: PaneView) -> String {
let mut buf = Buffer::empty(area);
et.render(area, &mut buf, view);
(0..area.width).map(|x| buf[(x, 0)].symbol()).collect::<String>().trim().to_string()
}
/// The fullscreen pane scrolls its own scrollback (Claude Code grabs no
/// mouse, so there is nothing to forward to it): a real child writes more
/// rows than the screen holds, and `scroll` has to reach the ones that left
/// it — then hand the view back to the live screen at the bottom.
#[test]
fn fullscreen_pane_scrolls_into_the_scrollback() {
let mut cmd = CommandBuilder::new("sh");
cmd.args(["-c", "seq 1 60; sleep 5"]);
let area = Rect::new(0, 0, 40, 10);
let et = EmbeddedTerm::spawn_cmd(cmd, "test-session".into(), "test-token".into(), 6, 40).unwrap();
// Wait for the tail of the child's output to reach the live screen.
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let mut buf = Buffer::empty(area);
et.render(area, &mut buf, PaneView::Full);
let text: String = (0..area.height)
.flat_map(|y| (0..area.width).map(move |x| (x, y)))
.map(|(x, y)| buf[(x, y)].symbol())
.collect();
if text.contains("60") {
break;
}
assert!(Instant::now() < deadline, "child output never arrived: {text:?}");
std::thread::sleep(Duration::from_millis(50));
}
let live = top_row(&et, area, PaneView::Full);
assert_eq!(et.scrolled_rows(), 0, "starts out following the live screen");
// Up 40 rows: a window of rows that have left the screen.
et.scroll(-40);
assert_eq!(et.scrolled_rows(), 40);
let scrolled = top_row(&et, area, PaneView::Full);
assert_ne!(scrolled, live, "the view did not move");
let n: usize = scrolled.parse().expect("a `seq` line number");
assert!(n < live.parse::<usize>().unwrap(), "scrolled the wrong way: {n}");
// No cursor while scrolled away — its row does not index this window.
let mut buf = Buffer::empty(area);
assert_eq!(et.render(area, &mut buf, PaneView::Full), None);
// A cropped view never scrolls: it frames the input box at the bottom.
assert_eq!(top_row(&et, area, PaneView::Compact), live);
// Past the bottom re-engages follow mode rather than pinning to it.
et.scroll(1000);
assert_eq!(et.scrolled_rows(), 0);
assert_eq!(top_row(&et, area, PaneView::Full), live);
// …as does `follow_live`, from anywhere.
et.scroll(-10);
assert_eq!(et.scrolled_rows(), 10);
et.follow_live();
assert_eq!(et.scrolled_rows(), 0);
assert_eq!(top_row(&et, area, PaneView::Full), live);
}
/// Full pipeline: PTY spawn → reader thread → wezterm-term model →
/// ratatui buffer. Headless-safe: the *child* gets the tty, not us.
#[test]

1263
src/ui.rs

File diff suppressed because it is too large Load Diff