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.
749 lines
52 KiB
Markdown
749 lines
52 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 (live = first user
|
||
prompt via `live_title`, stub = disk label, 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 label *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)
|
||
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 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).
|
||
- 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. 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. 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), 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 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.
|
||
- 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 | 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. 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 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
|
||
(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.
|