The compact pane is prompt-only because the feed above shows the context. An error is the exception: the feed cannot show it. A tool_result only reaches us with the next request, which a turn that died never sends, and an `API Error` row is Claude Code's own text, never in the stream at all. So the frame walks further up to take the report in, and the scheduled ctrl-l wipe is cancelled while one is framed — Ink redraws only the live frame, so wiping would delete the error 400ms after it appeared. Detection is text shape, not colour: the tool error renders palette 211 and the API error 220, both theme values. ERROR_SCAN is the only bound, which is what makes it self-limiting — the error drifts out of the window as the next turn prints output and the pane shrinks back on its own.
860 lines
60 KiB
Markdown
860 lines
60 KiB
Markdown
# claude-cloak
|
||
|
||
TUI that displays Claude Code's API streams token-by-token (thinking, text, tool
|
||
calls) by acting as a pass-through proxy: Claude Code points `ANTHROPIC_BASE_URL`
|
||
at `127.0.0.1:8484`, we forward everything verbatim to `api.anthropic.com` and
|
||
tee SSE responses into the UI. **Never issue API requests of our own** — zero
|
||
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.
|
||
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; 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. 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
|
||
first-message-after-resume case) and the real prompt;
|
||
`strip_injected` is the label-only projection (drops reminders
|
||
*and* slash-command machinery) used for turn-tree labels.
|
||
Dedup drops only true resends (the just-recorded prompt is still
|
||
the tail entry), so verbatim repeats in later turns survive.
|
||
Also reads the `x-claude-cloak-pane` header (passed to `Tap::new`
|
||
to bind the embedded pane — see the embed-identity invariant) and
|
||
strips it before forwarding. Forwards via reqwest, streams the
|
||
response back unbuffered, tees SSE
|
||
src/sse.rs incremental SSE parser; tolerant of chunk splits mid-event/mid-UTF-8
|
||
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 — 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, `<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
|
||
handed to ratatui so scroll state is usize end-to-end).
|
||
Focus accent is orange (`ACCENT` = indexed 208): borders, the
|
||
scroll thumb and the user-prompt blocks all use it when focused,
|
||
dim grey when not. User prompts render as full-width filled
|
||
rectangles padded to exactly the inner width (`wrap_words` +
|
||
exact pad, never the Paragraph's own wrap, so the box ends flush
|
||
with the borders); the fingerprint folds feed-focus in for
|
||
`Kind::User` only, so a focus toggle re-renders just those
|
||
blocks. `color_on(bg)` picks black/white text by background
|
||
luminance (used by the prompt blocks and the edit/diff blocks) so
|
||
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, 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
|
||
coincide.
|
||
Subagents never touch this feed: it renders lane
|
||
`MAIN_LANE` only, at full width, whatever the agents are doing.
|
||
They live in the `A` popup (`popup_rect` = 80% of the *feed*
|
||
rect, centred): `draw_agent_list` is the picker,
|
||
`draw_feed` the chosen agent's own stream — same function as the
|
||
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`). 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 (Claude Code's own name
|
||
for the session via `App::cc_title`, live rows and stubs alike;
|
||
`live_title` only covers a live session the transcript has not
|
||
named yet — word-wrapped by
|
||
`wrap_words`) over a dimmed id·model meta row; expanded turn
|
||
rows are indented past the title and `truncate_str`'d to one
|
||
line each.
|
||
src/markdown.rs wraps tui-markdown: renders GFM tables itself (box-drawing,
|
||
width-fitted wrapped columns) and strips heading `#` markers —
|
||
the pinned tui-markdown 0.3.5 does neither
|
||
src/sessions.rs on-disk session history (main chain *and* subagents):
|
||
background scanner thread keeps
|
||
App::disk_sessions fresh (~1/s poll, `read_meta` re-read only on
|
||
mtime change — one pass yields the title (see the session-name
|
||
invariant) *and* the session's
|
||
last main-chain model, which `App::resume_model` turns into the
|
||
`--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.
|
||
`scan_agents` reads the `<session>/subagents/agent-*.meta.json`
|
||
sidecars (cheap: the transcripts themselves can be MBs) and
|
||
`splice_agents` inserts each agent's entries into its own lane
|
||
right after the `Agent` tool call that spawned it
|
||
src/term.rs embedded claude pane: spawns `claude --session-id <uuid>` in a
|
||
portable-pty routed through the proxy; wezterm-term models the
|
||
screen (and answers terminal queries); renderer paints cells
|
||
into the ratatui buffer. Each spawn injects a fresh per-pane
|
||
token via `ANTHROPIC_CUSTOM_HEADERS` (`PANE_TOKEN_HEADER` =
|
||
`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, 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. `shows_error` reports whether
|
||
the compact frame is currently holding an error Claude Code
|
||
printed, which is what stops the ctrl-l wipe from deleting it —
|
||
see the pane-error 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 →
|
||
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):
|
||
chunks are `try_send`-cloned into a bounded channel and parsed on a separate
|
||
task (dropped on overflow, never blocking the relay), and the proxy/tap side
|
||
locks the app mutex poison-tolerantly (`app::lock_app`) so a UI panic can't
|
||
kill forwarding.
|
||
- **`accept-encoding` is stripped** from forwarded requests so the upstream
|
||
sends identity encoding we can parse in transit. Don't "fix" that.
|
||
- Hop-by-hop headers (`content-length`, `transfer-encoding`, etc.) are stripped
|
||
both directions; hyper re-frames.
|
||
- Sessions are keyed by the session UUID in request `metadata.user_id` —
|
||
Claude Code ≥2.1.x sends a JSON blob with `"session_id":"<uuid>"`, older
|
||
builds `user_…_session_<uuid>`; `proxy::session_key` handles both.
|
||
Concurrent requests (subagents) share a session but each `Tap` tracks its own
|
||
current entry index — entries/sessions are append-only, so indices stay stable.
|
||
`session_key` tolerates whitespace around the JSON colon (a pretty-printed
|
||
blob used to fall through to the legacy `session_` split and yield `id": "…`).
|
||
- **Subagent identity comes from Claude Code's own header, never a heuristic.**
|
||
A subagent's request reports the *parent's* `session_id` and no agent id in
|
||
`metadata`, but Claude Code stamps `x-claude-code-agent-id` (and, from spawn
|
||
depth 2, `x-claude-code-parent-agent-id`) on every one of them. That id is
|
||
unique per agent — **including byte-identical sibling prompts**, which do
|
||
occur and which a prompt hash cannot separate — stable across the agent's
|
||
inner-loop turns, and equal to the `agentId` of its on-disk
|
||
`subagents/agent-<id>.jsonl`. `proxy.rs` reads both headers (`AGENT_ID_HEADER`
|
||
/ `PARENT_AGENT_ID_HEADER`) and **forwards them untouched** — they are Claude
|
||
Code's, not ours; only `x-claude-cloak-pane` is ours to consume.
|
||
`Session::lane_for` maps the id to a lane (appended on first sight, so a
|
||
`LaneId` stays valid forever).
|
||
Labels come from a *separate*, later fact: `Session::label_lane_from_prompt`
|
||
matches the subagent's opening prompt against an unclaimed `Agent` tool call's
|
||
`prompt` (byte-identical on the wire) to learn subagent_type/description/
|
||
parent, and `close_lane_from_result` scrapes `agentId: <hex>` out of the
|
||
`Agent` tool_result to tie the lane to that call and mark it finished. A lane
|
||
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
|
||
mutex the tap shares, and lose global wire order. Lane membership is a field;
|
||
showing one lane is a filter (`e.lane == args.lane`), which is also why there
|
||
is no "show everything interleaved" mode.
|
||
- **"The agent finished" is a `<task-notification>`, not the tool_result.**
|
||
Claude Code launches *every* `Agent` call asynchronously: the tool_result
|
||
comes back immediately and says so (`Async agent launched successfully… \
|
||
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::apply_task_notifications` — called from
|
||
`record_user_prompt` on the trailing user run, before its early returns — is
|
||
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.
|
||
- **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`
|
||
(`App::toggle_agent_popup` → `App::agent_popup`) opens the one place they are
|
||
shown: `AgentPopup::List` picks an agent, `AgentPopup::Feed` gives one agent
|
||
the whole popup (80% of the feed rect, `ui::popup_rect`). Opening takes the
|
||
shortest path — a lone agent goes straight to its stream, several land on the
|
||
picker with the first *running* one preselected — and `A` closes whatever is
|
||
open. The popup is **modal**: while it is up it takes every key (and the
|
||
wheel), which is why it needs no focus/column model at all. Esc unwinds one
|
||
layer (feed → picker → closed), `[`/`]` step between agents from inside a
|
||
feed. `App::agent_list_of` orders it: running first (`Lane::running`), then
|
||
idle/finished, each group in spawn order — but **every** lane is listed,
|
||
disk lanes included, because this popup is the only way to read a finished
|
||
agent's output. State is session-local: `draw` clears `agent_popup` and
|
||
`lane_cols` when the displayed session changes, and `validate_agent_popup`
|
||
drops a popup whose lane the displayed session doesn't have (a rebuilt
|
||
on-disk view), so the render path never sees a dangling `LaneId`.
|
||
- **`Lane::running` is a sort key, never a gate**: streaming (`active > 0`), or
|
||
no finish signal and quiet for less than `LANE_IDLE_MAX` (60s); a
|
||
`finished_at` (the `<task-notification>`) or no traffic at all (a lane read
|
||
from disk) means not running. The long idle net matters because a gap between
|
||
an agent's turns (a slow local tool call) looks exactly like "done"; only the
|
||
notification distinguishes them. Being wrong therefore costs an ordering and
|
||
a `⟳`/`·` mark — never a hidden stream, which is what the old row-collapse
|
||
timers could do.
|
||
- **Only the main lane drives the pane and the session header.** `embed_grow`,
|
||
the ctrl-l wipe scheduled in `Tap::drop`, the prompt minimap, `n`/`N` and
|
||
`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. 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
|
||
pane is correlated by a token we control: `term.rs` injects a per-spawn
|
||
`x-claude-cloak-pane` header (`ANTHROPIC_CUSTOM_HEADERS`), the proxy reads it
|
||
(and strips it before forwarding), and `Tap::new` *binds* `App::embed_session`
|
||
to whatever id that tagged request actually carries (`App::bind_embed_session`
|
||
rebinds + renames a provisional resume row if they differ). Selection policy
|
||
follows: the embed jumps the selection only on first bind; a brand-new
|
||
*external* session auto-jumps so a fresh `/clear` is visible **unless**
|
||
`App::pane_focused` (mirrored from the UI each frame) — never steal the
|
||
selection from a pane the user is driving. This is what made an `a`-spawned
|
||
session stream into the wrong row before.
|
||
- **One app instance = one proxy port = at most one embedded claude**
|
||
(`EmbedUi::term` / `App::embed_token` → learned `App::embed_session`).
|
||
`kill_current_embed` is the single teardown path and `bind_new_pane` the
|
||
single registration path, so pane identity + grow/clear flags can't drift
|
||
across the spawn/replace call sites. Every other live session is an external
|
||
claude pointed at our port: observable, never attachable. The pane stays
|
||
visible while it holds keyboard focus even if the selection isn't on its
|
||
session yet (its id is still being learned); only an intentional ctrl-↑ /
|
||
tab-away hides it.
|
||
- The session list merges live sessions (first, indices stable) with this
|
||
directory's past sessions from `~/.claude/projects/<cwd with / → ->/*.jsonl`
|
||
as dimmed stubs (deduped by uuid — a live session's file is on disk too).
|
||
**Tab is viewing only, never a process operation**: selecting a stub
|
||
lazy-loads its transcript into `App::history`; tabbing off the embedded
|
||
session hides the pane without killing the child (instant to come back).
|
||
ctrl-↓ is the commit point that attaches the pane to the selection:
|
||
reveal+focus if it's the embedded session, `claude --resume <uuid>`
|
||
(kill + respawn) for disk stubs and dead embeds, fresh `--session-id`
|
||
spawn when there's nothing. Live *external* sessions are guarded — their
|
||
instance may still run elsewhere and a second `--resume` would fork the
|
||
transcript — but a second ctrl-↓ within 3s forces it (liveness is
|
||
unknowable: an idle claude sends no traffic; `EmbedUi::past_embeds` skips
|
||
the guard for sessions whose instance we killed ourselves). `--session-id`
|
||
cannot be combined with `--resume` (CLI rejects it without `--fork-session`);
|
||
`--model` can, and every resume passes it.
|
||
- **A session's name is Claude Code's, not ours.** `sessions::read_meta` reads
|
||
the same records its `/resume` picker does, in the same order —
|
||
`custom-title` (a manual rename, newest wins) > `ai-title` (Claude's
|
||
generated title, *first* wins so a materialized branch keeps its own `⑂ …`
|
||
label) > `last-prompt` (the newest prompt, which is what an untitled session
|
||
shows there) > a legacy compaction `summary` > the opening user prompt — so a
|
||
row reads the same in both places. It applies to **live** sessions too
|
||
(`App::cc_title`): Claude Code writes the JSONL continuously, so a running
|
||
row and its later disk stub cannot disagree, and the title tracks the newest
|
||
prompt exactly as the picker's does. Two fallbacks stay, for the gap before
|
||
the file names anything: `ui::live_title` (the feed's own first prompt) for a
|
||
live row, `DiskSession::label`'s uuid prefix for a stub. Claude Code 2.1.2x
|
||
writes no `ai-title` record and writes `last-prompt` a while into the
|
||
session, which is why the opening-prompt step exists — without it a young
|
||
session reads as a bare uuid.
|
||
- **A resume continues on the session's own model**, not the CLI default:
|
||
`App::resume_model` reads the model Claude Code recorded for the session's
|
||
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
|
||
`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. 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
|
||
point, trunk continuing below. j/k/↑/↓ walk sessions *and* turns (they
|
||
never scroll the feed; the wheel and PgUp/PgDn/g/G do that). Highlighting
|
||
a turn switches the feed to the on-disk transcript along the path through
|
||
that turn and pins the turn's prompt to the viewport top (HistoryView
|
||
caches per uuid, rebuilt when the leaf changes; FeedCache keys on
|
||
leaf+live so views of the same uuid don't share slots). `v` anchors a
|
||
contiguous visual range, `b` materializes a **new fully decoupled session
|
||
file** — chain root→turn, or exactly the visual range stitched together
|
||
(sessionId rewritten, each turn's head re-parented onto the previous
|
||
turn's tail, our own ai-title record gives it the `⑂ …` label) — injected
|
||
as the selected top stub. Branching never touches a process: ctrl-↓ stays
|
||
the only spawn/kill commit point.
|
||
- The tap drives pane behavior: grows it for AskUserQuestion /
|
||
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) — **except in fullscreen**, where that wipe is
|
||
*cancelled*, not deferred (see the pane-scroll invariant), and **except
|
||
while an error is framed** (see the pane-error invariant).
|
||
- **An error Claude Code prints is pane-only, so the pane keeps it.** The
|
||
compact pane is prompt-only because the feed above shows the context — an
|
||
error is the exception, because the feed *cannot* show it: a `tool_result`
|
||
reaches us only with the **next** request, which a turn that died never
|
||
sends, and a retry exhaustion or an `● API Error: …` row is Claude Code's
|
||
own text, never in the stream at all. So two things happen together:
|
||
`compact_frame_ex` walks further up to take the report in (over the task
|
||
panel too — the error came before it), and `ui::draw` **cancels** the
|
||
scheduled ctrl-l wipe when `EmbeddedTerm::shows_error` reports one, for the
|
||
same reason fullscreen cancels it: Ink redraws only the live frame, so
|
||
wiping would delete the error 400ms after it appeared and nothing would ever
|
||
bring it back. Cancelled, not deferred — firing it later loses the same
|
||
rows. Three rules keep it bounded:
|
||
1. **Text shape, never colour.** Verified on a real 2.1.26x child, the tool
|
||
error renders palette 211 and the API error 220 — both theme values, so
|
||
colour is not a signal. `text_is_error_row` strips the leading `⎿` gutter
|
||
*and* the `●`/`⏺` bullet (`strip_row_marker`; Claude Code pads both with
|
||
U+00A0, which `trim_start` handles) and then requires `Error:` /
|
||
`API Error` / a `✗✘✖✕` glyph. **The colon is load-bearing**: `● Error
|
||
handling lives in src/foo.rs` is ordinary prose, and `⎿ Tip: …` — Claude
|
||
Code's own idle hint — proves the gutter alone means nothing.
|
||
2. **`ERROR_SCAN` (12 rows) is the whole bound**, and it is what makes this
|
||
self-limiting: an error stays framed while it is "what just happened",
|
||
drifts out of the window as the next turn prints output, and the pane
|
||
shrinks back on its own. Nothing else caps the growth and no app state is
|
||
involved — the wipe is simply re-scheduled by the next `Tap::drop` and
|
||
fires once the screen is clean. The window has to be this wide because
|
||
Claude Code parks a blank row *and* its `✻ Worked…` row between the last
|
||
transcript line and the box, so the error is never the context row.
|
||
3. **The frame starts on the bullet row that owns the gutter.** `● Bash(echo
|
||
boom >&2; exit 3)` is what names the failure; `⎿ Error: Exit code 3`
|
||
alone does not. And `error_block_top` takes the *highest* error row in the
|
||
window, not the lowest, so a turn that failed several tools shows all of
|
||
them and a wrapped report is framed from its first line.
|
||
- **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. 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
|
||
|
||
- `tui-markdown` is pinned `=0.3.5`: 0.3.7+ moved to `ratatui-core` (0.30 alpha
|
||
types), incompatible with ratatui 0.29 — 0.3.6 is the last 0.29-compatible
|
||
release, but *no* version (through 0.3.8) enables pulldown-cmark's table
|
||
extension, so upgrading still wouldn't render tables. Its gaps (no tables,
|
||
literal heading markers) are compensated in `src/markdown.rs`, not by
|
||
upgrading.
|
||
- `wezterm-term`/`wezterm-surface` are not on crates.io: pinned to a git rev
|
||
of the wezterm monorepo (keep both revs identical).
|
||
- The compact pane *dynamically frames* Claude Code's input box rather than
|
||
cropping by fixed offsets (`term.rs`: `compact_frame` + `PaneView`). It
|
||
locates the box by its two horizontal-rule borders (`text_is_rule`) — the
|
||
last two `────` rules on screen, since the prompt always sits at the bottom —
|
||
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`), 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
|
||
walking up over that block — tolerating one blank line and single wrapped /
|
||
activity rows, capped at `MAX_TASK_BLOCK` — makes task status visible with no
|
||
extra app state. Above *that* it walks over an error block
|
||
(`text_is_error_row` / `error_block_top` — see the pane-error invariant).
|
||
Priority when the pane can't hold everything: the panel is
|
||
dropped first (`CompactFrame::ess_top`, the one-context-row frame) so the
|
||
line you're typing and an open menu never fall off screen; an overflowing
|
||
no-menu frame bottom-anchors anyway, so a report taller than the pane keeps
|
||
its tail rather than pushing the box off screen. The framed region drives the pane height too:
|
||
`compact_rows` (called from `ui::draw`) measures box-height + tail so the
|
||
pane auto-expands as the prompt gains lines or a menu opens and shrinks back
|
||
when idle (floor `MIN_COMPACT_INNER`, cap = screen − 6); `PTY_PAD` keeps the
|
||
PTY taller than the visible window so the child can still draw the rows we
|
||
crop. **A cropped PTY is sized to the *whole screen height*, not the
|
||
visible pane** (`ui::draw` passes `f.area().height` to `resize` for every
|
||
view except Full): their height is derived by measuring what Ink has already
|
||
drawn, and Ink only ever draws as many rows as the PTY reports, so tying the
|
||
PTY to the (small) visible height is a feedback loop — an `@`/`/` menu or a
|
||
big paste that suddenly needs many more rows than the current PTY+pad never
|
||
gets the room to draw them, so `compact_rows` can't measure the growth and
|
||
the pane stays stuck small. A screen-tall PTY lets Ink lay out the full
|
||
box+menu in one shot; `compact_view_range` still shows only the cropped
|
||
window. When that window is shorter than an open menu it **top-anchors on the
|
||
input box** (crop the menu's tail, never the line you're typing) — the idle
|
||
no-menu case still bottom-anchors on the statusLine. That per-frame measurement is smoothed by hysteresis
|
||
(`EmbedUi::compact_height` / `smooth_compact`, seeded at
|
||
`DEFAULT_COMPACT_INNER`): the pane grows instantly but shrinks only after the
|
||
smaller height has held for `SHRINK_DELAY` (400ms), and `compact_rows`
|
||
returns `None` on a transient mid-repaint (box border caught missing) so the
|
||
last height is kept. Without this the height oscillates every frame during a
|
||
subagent turn or `@`/`/` menu filtering, and each change resizes the PTY →
|
||
Ink repaints → flicker. `PaneView::Interactive` (the tap-grown
|
||
AskUserQuestion / ExitPlanMode pane, whose selection box renders *above* the
|
||
input) is **measured the same way, never estimated**: `interactive_frame`
|
||
anchors on the rule above the header-chip row (`← ☐ Header ✔ Submit →`),
|
||
else the second-to-last rule, and runs to the last non-blank row;
|
||
`EmbeddedTerm::interactive_rows` feeds that height through the same
|
||
hysteresis. `App::ask_question_rows` (the row guess from the tool JSON) is
|
||
only the fallback for the frames before Ink has drawn the box — it can't know
|
||
how far the question text wraps, which is what used to crop the first
|
||
paragraph. Because the Interactive PTY is now screen-tall, Claude Code lays
|
||
the prompt out in full instead of switching to its own truncated form.
|
||
`interactive_view_range` top-anchors on that frame and slides down only far
|
||
enough to keep the `❯` option on screen when the prompt overflows the pane.
|
||
`PaneView::Full` (fullscreen) renders
|
||
the child's screen verbatim from row 0 with the PTY sized exactly to the
|
||
pane. Permission-prompt boxes (rounded borders, not rules, and not in the
|
||
API stream) aren't expanded in the compact pane — consistent with the
|
||
known "permission prompts aren't detected" limit.
|
||
- Keybindings avoid Alt entirely: on layouts like dk_mac_fixed, Alt composes
|
||
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), 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 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
|
||
(`App::prompt_jump`, applied in `draw` where entry heights are cached). The
|
||
feed scrolls only via wheel / PgUp / PgDn / g / G / n / N.
|
||
`A` toggles the subagent popup (the footer leads with `A agents (N)` when
|
||
the displayed session has any — it is the only route to them). Inside it:
|
||
j/k move the picker or scroll the agent feed one line, enter/→ opens the
|
||
highlighted agent, `[`/`]` step to the previous/next agent, PgUp/PgDn/g/G
|
||
scroll, Esc goes feed → picker → closed, `A`/`q` closes outright. Being modal
|
||
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.
|
||
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`
|
||
(wezterm-term's `send_paste` re-wraps it in bracketed markers iff the child
|
||
enabled them) — so Claude Code inserts it as one block instead of
|
||
submitting on the first embedded newline. Paste is ignored when the pane is
|
||
unfocused (nothing else takes text input).
|
||
- Pane cursor shape mirrors the child: each frame `draw` records the child's
|
||
DECSCUSR shape (`EmbeddedTerm::cursor_shape`) into `EmbedUi::cursor_shape`
|
||
and the event loop emits `SetCursorStyle` only on change (so a blinking
|
||
cursor isn't reset every frame), resetting to `DefaultUserShape` when no
|
||
pane cursor is shown / on teardown. Without this the outer terminal kept a
|
||
stale block cursor regardless of Claude Code's insert-vs-vim-normal state.
|
||
`term::cursor_style` maps the child's DECSCUSR `Default` to a blinking *bar*,
|
||
not `DefaultUserShape`: Claude Code's normal input leaves the cursor at the
|
||
terminal default expecting a bar caret, so forwarding the outer terminal's
|
||
own default (often a block) would wrongly show a block in insert mode; vim
|
||
normal mode still sends an explicit `SteadyBlock`.
|
||
- ratatui needs feature `unstable-rendered-line-info` for `Paragraph::line_count`
|
||
(used to compute cached per-entry wrapped heights for follow/auto-scroll).
|
||
- reqwest is `default-features = false` + `rustls-tls,stream` — don't enable
|
||
compression features (would re-add accept-encoding).
|
||
- The listener is bound in `main` before the TUI starts: prefers 8484, falls
|
||
back to an OS-assigned free port so multiple instances coexist (each pane
|
||
gets the actual port via `ANTHROPIC_BASE_URL`). `CT_PORT` pins the port and
|
||
turns bind failure into a hard startup error.
|
||
- Testing: SSE parser has unit tests (`cargo test`). For a live pass-through
|
||
check: `--headless` (prints the bound port), then POST to
|
||
`127.0.0.1:<port>/v1/messages` without auth — a relayed 401 from Anthropic
|
||
proves the round-trip. The TUI can't run in a non-tty. `CT_UPSTREAM` points
|
||
the proxy at an alternative upstream (e.g. a local fake SSE server) for
|
||
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 | websearch | ansi | toolerror |
|
||
basherror | apierror | text`, switchable
|
||
mid-run) — this is how the pane's frame detector is developed against what
|
||
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.
|
||
The two error scenarios are how the pane's error framing is developed against
|
||
real Ink output: `toolerror` calls `Read` on a missing path (auto-approved, so
|
||
no permission prompt) for the `⎿ Error:` gutter shape, `basherror` runs a
|
||
failing `Bash` for its wrapped-tail variant, and `apierror` answers the *turn*
|
||
request with a non-retryable 400 — leaving the side/title calls alone — so
|
||
Claude Code prints its own `● API Error: 400 …` row. 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)
|
||
|
||
- 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
|
||
long after its launch result landed, and `SendMessage` can revive a finished
|
||
one. An agent transcript over `MAX_AGENT_BYTES` (8 MB) is summarised instead
|
||
of parsed, because the view is built while the app mutex is held.
|
||
- Materialized branch files carry no subagent transcripts: the `Agent`
|
||
tool_results in them still hold the reports the parent model saw, and copying
|
||
`subagents/` would duplicate `agentId`s across two sessions and contradict
|
||
the agent files' own `sessionId`. Deliberate — don't "fix" it by copying.
|
||
- A subagent's *first* turn is what labels its lane, so if we attach mid-run
|
||
(the parent's `Agent` call never passed through us) it stays listed as
|
||
`agent <id-prefix>` until its result lands.
|
||
- Sessions are never pruned (entry memory grows for the process lifetime);
|
||
the same goes for viewed disk transcripts (`App::history`).
|
||
- Request bodies are fully buffered (up to 512 MB) before forwarding — needed
|
||
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). 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
|
||
Claude Code's loader tolerance is only verified empirically by resuming
|
||
one — if a CC update changes the JSONL schema, retest `b` + ctrl-↓. The
|
||
tree itself isn't refreshed while expanded (collapse/re-expand re-reads
|
||
the file), and a highlighted turn of a *live* session views its on-disk
|
||
transcript, which lags the in-memory feed by however much CC buffers.
|