The keybindings had no red thread because there were three focus states and every key had to ask "is this mine or the child's" — hence ctrl-q, because plain q was forwarded. Replace the whole model: unprefixed keys belong to the embedded claude, always, and everything cloak owns sits behind ctrl-space (CT_PREFIX to change), tmux-style, in one table that the which-key popup, the footer hint and the dispatch all read. Esc is the only key with a rule of its own, and it is about reachability rather than modes: it closes the topmost overlay, and with nothing open it goes to the child, so interrupt and Esc-Esc rewind keep working. View state — filters, the lane the feed shows — is a setting, not a mode, and is deliberately not escapable. That removes the focus model entirely, which lets the two feeds collapse into one: the feed renders whichever lane feed_lane names, at full width, and the stream picker chooses it. Subagents used to live in a modal popup holding a second draw_feed with its own cache and scroll model, rendering the same thing twice. The sessions panel loses its half of the screen the same way. Both pickers become bottom strips with the feed readable above them, so walking the list previews each row — which is the view-without-resuming that /resume cannot do, and frees enter to attach the pane. Adds a find bar with in-place highlighting, scrolling to the matching line rather than the containing entry, and drops the two keys that served the old layout.
73 KiB
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/keymap.rs the one binding table: `Act` (what a key does), `Menu`/`Bind`
(the tree the which-key popup renders) and `Prefix` (which key
opens it, `CT_PREFIX`-overridable). The popup, the footer hint
and `ui::run_act` all read this table, so a binding cannot exist
in one and not the others. See the prefix invariant
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; **one** full-width 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).
`draw_feed` renders whichever lane `App::feed_lane` names —
main chain, a subagent, a nested server-tool call — at full
width; the stream picker chooses it. There is no second feed
and no sessions panel: both are overlays now, and both are
bottom strips (`draw_sessions` / `draw_streams` in `list_rect`,
`draw_search` in `search_rect`, `draw_menu` in `bottom_rect`),
which is what freed the whole width. Nothing is a centred box —
`popup_rect` is gone.
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 — main lane only (a
subagent has no user prompts).
`run_act` is the single dispatch point for `keymap::ROOT`;
`search_key` / `filter_key` / `streams_key` / `sessions_key`
are the per-overlay handlers. `draw_sessions` is the old
half-width panel, unchanged in content and moved into an
overlay: 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. `alt_screen` reports the
*alternate* screen — an `$EDITOR` (nvim, `git commit`, a pager)
Claude Code launched into the same pty — which auto-fullscreens
the pane; see the alt-screen invariant
src/reload.rs hot reload: `prefix 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 the key. 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
-
Unprefixed keys belong to the embedded
claude. Always. There is no focus model left — no ctrl-↑/ctrl-↓, no "which panel has the keyboard", no key you have to think about before pressing.q,jand Esc reach Claude Code because nothing else can claim them; everything cloak owns sits behind one prefix (keymap::Prefix, ctrl-space by default,CT_PREFIXto change it), tmux-style.keymap::ROOTis the whole app surface, and it is a table:ui::draw_menurenders it, the footer summarises it andui::run_actdispatches it, so a binding cannot exist in one and not the others — which is what stops the keymap drifting apart again. Four supporting rules:- Two things are ours without a prefix, because a real terminal also keeps
them for itself rather than forwarding: the wheel and
shift+PgUp/PgDn. Both scroll the feed, or the pane's own scrollback
while the pane is fullscreen (the pane-scroll invariant is unchanged; it
just lost its
eui.focused()gate). Feed scrolling is continuous, so putting it behind a prefix would be the one genuinely bad trade. - Esc is about reachability, not modes. It closes the topmost overlay;
with nothing open it goes to the child, because Claude Code interrupts on
Esc and rewinds on Esc-Esc. The test is literally is the pane reachable?
— which is why the two zooms answer differently:
prefix Z(zoom feed) hides the pane, so Esc leaves it, whileprefix z(fullscreen) is nothing but the pane, so Esc passes through and interrupts.App::close_overlayis the one implementation and returns false when there was nothing to close. - View state is not escapable. Filters and
App::feed_laneare settings, not modes: resetting them on a stray Esc would be a surprise, not a rescue. Coming back is an action:prefix .(Act::FollowLive), which undoes all four kinds of pinning at once — a picked session, a picked lane, an expanded turn tree, and the scroll position a search or a prompt jump parked. That last one is the easy one to forget: without re-arming the tail (scroll_col_end(MAIN_LANE, true)) the feed sits where you left it and never catches up, even after the turn ends, which reads as the key doing nothing. This is also why there is noprefix Esc. - Nothing traps you, and the menu has to be visible to prove it. Any
unbound key closes it, the repeatable leaves (
]/[) keep it open soprefix ]]]walks, andprefix prefixsends a literal prefix to the child. Overlays normally anchor to the bottom of the feed, which puts them just above the pane — but in fullscreen the feed area is the single row the layout reserves, so a strip drawn into it is invisible. That madeprefix zread as a trap: the menu sayingzgets you back out was being rendered one row tall behind the pane.drawtherefore anchors overlays to the screen (everything above the footer) wheneverpane_view == PaneView::Full. ctrl-q stays bound globally for one reason only: a terminal that delivers no ctrl-space would otherwise leave the app with no way out. It is not a focus workaround — there is no focus.
- Two things are ours without a prefix, because a real terminal also keeps
them for itself rather than forwarding: the wheel and
shift+PgUp/PgDn. Both scroll the feed, or the pane's own scrollback
while the pane is fullscreen (the pane-scroll invariant is unchanged; it
just lost its
-
A hot reload is an
execveof ourselves, never a restart.reload.rsbuilds nothing and watches nothing: you rebuild however you normally would, thenprefix rswaps each running instance onto the binary now atApp::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, nocargosubprocess and no env var to arm; the key is simply always live, in a debug build and a release one alike. It 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.execvekeeps 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_fdis 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 byCT_RELOAD_HANDOFF). Four rules keep it honest:- Resolve the exe path at startup, never lazily. The file is expected
to have been replaced by the time the key is pressed, and a linker's rename
unlinks the inode we are running from — after which
/proc/self/exereads…/claude-cloak (deleted).reload::exe_pathruns once, inApp::new, and strips that suffix defensively. - Drain before exec. The exec destroys the tokio tasks relaying
in-flight responses, so
proxy::runserveswith_graceful_shutdownand the exec waits for it (App::drain_tx→App::drained, capped byDRAIN_MAX). CountingSession::activeis not the gate — it misses untapped traffic (count_tokens, non-streaming posts) and racesTap::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. - 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
Handoffkeeps the fd numbers in plain fields and leaves the feed as an undecodedserde_json::Value, decoded per session, each one passed throughsanitize_session. Losing the feed must never cost the port or the pane.Entry::lane,Lane::anchor,Lane::first_entryandSession::tool_idsare raw indices that#[serde(default)]does not protect, so they are validated once there rather than defensively at every use site;activecounters are zeroed (nothing streams into a process that no longer exists) andapp::seed_server_tool_seqpushes the process-globalsrvtool-counter past whatever the restored lanes already hold. - Nothing is dropped on the way out. An exec runs no destructors, which
is exactly why
EmbeddedTerm::dropdoes not fire and SIGHUP the child — sotry_reloadborrows the pane and the app rather than taking them, and a failed exec (the key landing mid-link is the realistic case) leaves the old code running with everything intact,close_on_exechaving 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, sodisable_raw_moderuns 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.Instanthas 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_atpoints forward and is simply not carried.
- Resolve the exe path at startup, never lazily. The file is expected
to have been replaced by the time the key is pressed, and a linker's rename
unlinks the inode we are running from — after which
-
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-encodingis 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 buildsuser_…_session_<uuid>;proxy::session_keyhandles both. Concurrent requests (subagents) share a session but eachTaptracks its own current entry index — entries/sessions are append-only, so indices stay stable.session_keytolerates whitespace around the JSON colon (a pretty-printed blob used to fall through to the legacysession_split and yieldid": "…). -
Subagent identity comes from Claude Code's own header, never a heuristic. A subagent's request reports the parent's
session_idand no agent id inmetadata, but Claude Code stampsx-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 theagentIdof its on-disksubagents/agent-<id>.jsonl.proxy.rsreads both headers (AGENT_ID_HEADER/PARENT_AGENT_ID_HEADER) and forwards them untouched — they are Claude Code's, not ours; onlyx-claude-cloak-paneis ours to consume.Session::lane_formaps the id to a lane (appended on first sight, so aLaneIdstays valid forever). Labels come from a separate, later fact:Session::label_lane_from_promptmatches the subagent's opening prompt against an unclaimedAgenttool call'sprompt(byte-identical on the wire) to learn subagent_type/description/ parent, andclose_lane_from_resultscrapesagentId: <hex>out of theAgenttool_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_requestreturnsReqKind::{Turn, ServerTool, Side}from thetoolsarray alone and is the single predicate shared byproxy.rsandrecord_user_prompt. A hosted tool istypepresent +input_schemaabsent — never a version allowlist, so a futureweb_search_20260101still classifies, and the API's explicit{"type":"custom"}spelling is excluded. An empty/absent array stays the side/title request; a mixed array is aTurn. -
Claude Code's
WebSearchis not purely client-side. It issues a nested/v1/messagesdeclaring Anthropic's server-sideweb_searchunder the parent'ssession_idwith no agent-id header — verified on the wire. It gets its own lane via a syntheticsrvtool-<seq>id, which cannot collide with a real agent id because Claude Code's are bare lowercase hex ands/r/v/t/o/l/-are not hex digits (sofinish_lane,close_lane_from_resultand the<task-id>scan can never land on one). It is labelledweb_search · "<query>", finished inTap::drop(one request = one turn), emits no system/tools/side-request lines, and is readable only in theApopup — 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 betamid-conversation-system-2026-04-07and appends the agent-type listing as arole:"system"message after the prompt, so turn 1 of every session reads["user","system"]— verified in bothclaude -pand the interactive CLI. Atake_whileon "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 forui::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 secondKind::Systemline, tracked per lane inLane::last_mid_system_lenwith the same once-then-on-change policy. -
One entry vec, tagged with lanes. Per-lane vecs would double every index site (
Tap::cur), breakFeedCache's positional alignment withSession::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 everyAgentcall 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>. Soclose_lane_from_resultonly ties the lane to its tool call (and finishes it in the non-async wording, kept for older builds), whileSession::apply_task_notifications— called fromrecord_user_prompton the trailing user run, before its early returns — is what stampsLane::finished_at. It does four things per notification: stampsfinished_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 theAgenttool entry viatask-id → lane_of_agent → Lane::anchor(falling back to<tool-use-id> → Lane::tool_use_id), and pushes oneKind::TaskNotestatus line. The disk path resolves the report differently — by<tool-use-id>againstEntryParser::agent_tools, which is never drained — and its lanes are finished byadd_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. Readingfinishedoff 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_promptsplits 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 theAgenttool call it answers — replacing theAsync agent launched…acknowledgement,is_errorset 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::glyphwrites it andui::entry_linescolours the entry from it, soapp.rsstays 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_resultarrives as a completecontent_block_start(no deltas;content_block_stopfollows immediately) and is never echoed back as atool_resultblock in a later request body, soattach_tool_resultscan never fill it in — drop it and it is gone.Tap::handleattaches it to the entrySession::tool_idsmapstool_use_idto, and consumes the id exactly asattach_tool_resultsdoes, so the map stays bounded (a hosted search would otherwise leak one entry per call for the process lifetime). Hits are emitted in the sameLinks: [{title,url}…]wire shape Claude Code's client-sideWebSearchstring result uses, andui::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. Theother =>fallback stays the net for block types nobody has seen, and still sets noself.cur. -
One feed, one lane; the picker chooses which. The feed always renders exactly one lane at full width (
draw_feedfilterse.lane == args.lane) — never interleaved, which is the part of the old design that stands. What is gone is the second feed: subagents used to live in a modal popup holding their owndraw_feed, their ownFeedCacheand their own scroll model, rendering the same thing twice. NowApp::feed_lanenames the lane andprefix apicks it, so reading an agent is the same act as reading the main chain rather than a different mode with different keys.App::stream_list_ofis that picker's order:MAIN_LANEfirst — the way back has to be in the same list as the way out — thenagent_list_of(running first viaLane::running, then idle/finished, each group in spawn order). Every lane is listed, disk lanes included, because this is the only way to read a finished agent's output. A session with no side lanes still gets a picker showingmain, so the key never dead-ends.ui::lane_markstill returns⚙forLane::is_server_tool()(one request, and no<task-notification>will ever confirm it), and the row the feed is on carries the same▶the sessions overlay gives the pane's session. Scroll state is per lane:App::lane_col_of/set_lane_colresolveMAIN_LANEtoscroll/follow(the pair the reload snapshot carries) and everything else tolane_cols, so each stream keeps its place and follows its own tail.validate_lanesruns indrawbefore the feed borrow and falls a danglingfeed_laneback toMAIN_LANE, so the render path never indexesSession::lanesout of bounds. State is session-local:drawclearslane_cols,feed_laneand the picker when the displayed session changes. -
A list overlay never covers the feed it steers, and moving the highlight is the pick. Both pickers —
prefix sandprefix a— share one shape (full width, flush with the bottom of the feed area,LIST_PCT(a third) tall with aLIST_MINfloor) and one interaction: j/k walks the list and the feed above shows each row as you land on it. A centred box would hide the one thing the movement is for, which is whypopup_rectno longer exists.list_rectis that shape;streams_rectshrinks it to what the list holds and keepslist_rectonly as the cap. Only streams can do that — a lane is exactlySTREAM_ROWS(2) rows and a turn often fans out to two or three, so a fixed third is mostly blank space taken from the feed, whereas a session item wraps to an unknown number of rows and there are usually dozens. Past the cap the list scrolls (ListStatekeeps the highlight in view). Three consequences:- Preview is the interaction; there is no separate commit. For streams
that leaves Enter with nothing to do, so Enter and Esc both just close,
keeping what you walked to (
streams_movedoes the work). Sessions keeps an Enter because it has something preview cannot do — attach the pane. - Esc keeps what you were looking at. Nothing here is an edit, so there
is nothing to cancel;
prefix .is the way back and the footer says⇤ pinneduntil you take it. - The feed stays scrollable underneath.
shift+PgUp/PgDn is handled before the overlay block, not after, and the wheel keeps scrolling the feed rather than moving the highlight — the same carve-out as always, now with no exception, because nothing covers the feed any more.
- Preview is the interaction; there is no separate commit. For streams
that leaves Enter with nothing to do, so Enter and Esc both just close,
keeping what you walked to (
-
Search is per entry, and it only finds what is on screen.
App::search_runmatches a lowercased substring against three things per entry —Entry::content, the tool name of aKind::Tool, andToolResult::content— because a tool result is rendered and should therefore be findable. It is not a grep over rendered text: markdown markers, the box-drawing of a table and the system prompt (only its size is stored) are not searchable, and a hit is an entry, not a line. Two rules keep it honest:- Filtered-out entries are skipped. They have no row in the rendered feed, so scrolling to one would land somewhere arbitrary and read as a wrong answer. What you can see is what you can find.
- It is scoped to
feed_lane, like everything else about the feed. Switching streams re-scopes the same query. The UI is a browser find bar, not a mode with a separate commit:prefix /opens a three-rowsearch_rectstrip (same bottom-anchored full-width shape as the sessions overlay — a query living only in the footer reads as nothing happening), typing re-runs from the top, Enter/↓/Tab walk forward and ↑ back, and the strip shows3/12. Esc leaves with the position you landed on. Matches are painted byui::highlight_lines, which post-processes the rendered spans rather than teaching each renderer about search —entry_linesfans out into markdown, a dozen tool renderers and the ANSI parser, and a match has to light up the same way in all of them. Splitting a span keeps its own style and overrides only the colours, so bold/dim/italic survive. The query is therefore part of the render fingerprint (ui::query_hash, FNV-1a): without that, a cached entry would keep serving lines from before the query. Highlighting lasts exactly as long as the bar is open, so there is no stale paint and no:nohlsearchto remember. The jump is line-granular, not entry-granular (ui::match_row): a hit hundreds of rows into a long tool result would otherwise pin that entry's top and show no match at all, which reads as "nothing found" — the reported papercut.match_rowreads the highlight the render pass already applied rather than re-running the query, so one definition of "this line matched" drives both the paint and the scroll, and it leavesMATCH_CONTEXTrows above so you land with the tool header in view. An entry that matched only through its clippedToolResulthas no painted row to aim at; that falls back to the entry top, which is the honest answer.scroll_to_matchis what keeps the turn-tree jump on the old behaviour — it is pinning a prompt, whose match is its first line anyway. A span whose lowercase form differs in length from the original (ß, İ) is left alone — the two byte offsets no longer agree, and a wrong slice is worse than a missed highlight.
-
The feed tails the pane, and pinning is visible.
App::follow_pane(default on) keeps the selection onApp::embed_session,tail -fstyle — but only while no overlay is open and no turn tree is expanded, because those are deliberate navigation and yanking the selection out from under them is exactly what this rule exists to avoid. Picking another session withenterturns it off;prefix ., attaching the pane, and spawning one turn it back on. Whenever the feed is not on the live main chain the footer leads with⇤ pinned · prefix . to follow, because nothing else on screen says "you are not looking at what the pane is doing". -
Lane::runningis a sort key, never a gate: streaming (active > 0), or no finish signal and quiet for less thanLANE_IDLE_MAX(60s); afinished_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 inTap::drop, the prompt minimap, the prompt jumps (prefix ]/[) andSession::model/context are gated onMAIN_LANE;last_system_lenandlast_tools_siglive 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 skipsKind::TaskNotealongsideKind::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-idis not guaranteed to equal the id it reports in request metadata (and a--resumecan mint a fresh one), so the pane is correlated by a token we control:term.rsinjects a per-spawnx-claude-cloak-paneheader (ANTHROPIC_CUSTOM_HEADERS), the proxy reads it (and strips it before forwarding), andTap::newbindsApp::embed_sessionto whatever id that tagged request actually carries (App::bind_embed_sessionrebinds + renames a provisional resume row if they differ). Selection policy follows fromApp::follow_pane(see the tailing invariant): the embed jumps the selection on first bind, and a brand-new external session auto-jumps so a fresh/clearis visible unlessApp::pane_focused(mirrored from the UI each frame — now "the pane is taking keys", i.e. no overlay is up) — never steal the selection from a pane the user is typing in. This is what made ana-spawned session stream into the wrong row before. -
One app instance = one proxy port = at most one embedded claude (
EmbedUi::term/App::embed_token→ learnedApp::embed_session).kill_current_embedis the single teardown path andbind_new_panethe 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 is drawn whenever it exists and is not hidden — not gated on the feed selection any more. That coupling only existed to keep keyboard focus and the visible session in step; with the pane always holding the keyboard there is nothing to keep in step, and decoupling them is the point: the feed can show a past session, or a subagent's lane, while the pane keeps running the live one.prefix phides it,prefix Zcovers it, nothing else. -
The session list merges live sessions (first, indices stable) with this directory's past sessions from
~/.claude/projects/<cwd with / → ->/*.jsonlas dimmed stubs (deduped by uuid — a live session's file is on disk too). The list lives in theprefix soverlay. Viewing there is the hover state, not a key: j/k re-points the feed live as you walk the list (the overlay is a bottom strip, so the feed is right there above it), which is the view-without-resuming that Claude Code's own/resumepicker cannot do — and the reason this overlay still exists at all, since/resumeis a process op and resuming a live external session forks its transcript. That freesenterto be the commit — the thing you almost always want — attaching the pane to the selection: reveal if it's the embedded session,claude --resume <uuid>(kill + respawn) for disk stubs and dead embeds, fresh--session-idspawn when there's nothing.Escis the other way out and it keeps what you were reading (close_overlaypinsfollow_paneto whether the selection is the pane's own session): there is nothing to cancel — the feed pointer is a view setting, not an edit — and snapping back would throw away the only thing walking the list produced.prefix .returns to the pane, and the footer says⇤ pinnedwhenever you are not on it. Live external sessions are guarded — their instance may still run elsewhere and a second--resumewould fork the transcript — but a secondenterwithin 3s forces it (liveness is unknowable: an idle claude sends no traffic;EmbedUi::past_embedsskips the guard for sessions whose instance we killed ourselves).--session-idcannot be combined with--resume(CLI rejects it without--fork-session);--modelcan, and every resume passes it. -
A session's name is Claude Code's, not ours.
sessions::read_metareads the same records its/resumepicker 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 compactionsummary> 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 noai-titlerecord and writeslast-prompta 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_modelreads the model Claude Code recorded for the session's last main-chain assistant message (DiskSession::model, filled by the scanner'sread_meta— subagentisSidechainrecords run their own model and<synthetic>error records carry no model, so both are skipped) andModels::base_for_idmaps that id to a base model name: a known alias (sonnet,opus, … fromModels::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 (--modeltakes full names too). The transcript is authoritative, so a mid-session/modelswitch 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::argis the one place that decides and it answers<base>[1m]whenever the installedclaudeships that variant (Models::long, read out of the binary — the suffix is never assumed, sohaikustayshaiku). Every spawn goes through it:App::spawn_argfor a fresh pane and theapicker,App::resume_argfor 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 fromopusonly byanthropic-beta: …,context-1m-…, and the bodymodeland 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, aWebSearchsub-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_contextand the proxy'santhropic-betaread 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 — fromterm::cc_default_model()(ANTHROPIC_MODEL, then local/project/usersettings.json). With no default configured either, the spawn passes no--modelat all andclaudepicks both. -
Turn tree / branching (lazygit/yazi-style, all inside the
prefix soverlay — which is the only home it has left, and why collapsing sessions into a plain/resumewas not an option):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 shift+PgUp/PgDn do that, and they keep working while the overlay is up). 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).vanchors a contiguous visual range,bmaterializes 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:enterstays 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).
-
A CLI error is pane-only, so the pane keeps it — a tool error is not. The compact pane is prompt-only because the feed above shows the context. The one exception is an error the CLI itself produced (
● API Error: Connection lost mid-response., a retry exhaustion): that is Claude Code's own text, never in the API stream, so the feed cannot show it and the pane is the only place it will ever appear. So two things happen together:compact_frame_exwalks further up to take the message in (over the task panel too — the error came before it), andui::drawcancels the scheduled ctrl-l wipe whenEmbeddedTerm::shows_errorreports 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:- The
⎿gutter disqualifies a row, and that is the whole test. A gutter is a tool talking, and a failed tool is not a CLI error: itstool_resultreaches the feed with the next request, so the feed already shows it and the pane has no reason to grow. Framing it too was the reported bug — tool errors bled through and held the pane open. Sotext_is_error_rowrejects any⎿row (⎿ Error: Exit code 3included, deliberately), strips only the●/⏺bullet, then requiresError:/API Error/ a✗✘✖✕glyph. The colon is load-bearing:● Error handling lives in src/foo.rsis ordinary prose. And it is 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. - The newest prompt row ends the error's relevance. Once the user has
typed something else the error belongs to a previous exchange, so
error_block_topfloors its scan just under the last❯ …transcript row.ERROR_SCAN(12 rows) alone is far too coarse for this — Claude Code's reply to an error is only two rows, so the error stayed inside the window and held the pane open through the whole next turn, which is what the second report was. Above the top rule a❯row can only be an echoed prompt; the box's own❯and a menu's❯marker are both below it, outside the scanned window. ERROR_SCANis the backstop bound, for the case where no prompt follows: the error drifts out of the window as output arrives and the pane shrinks back on its own. No app state is involved — the wipe is simply re-scheduled by the nextTap::dropand fires once the screen is clean. It 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.error_block_toptakes the highest error row in the window, so a long message is framed from its first line; nothing is pulled in above it, because a CLI error names itself.
- The
-
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::scrollmoves a view into wezterm-term's scrollback (3500 rows, the crate default) andrenderreads the window from there instead of the live screen. Four rules keep it honest:- 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). - Only
PaneView::Fullscrolls. The cropped views frame Claude Code's input box, which is always at the live bottom, sodrawcallsfollow_livefor them — one place, instead of at each ofprefix z/prefix Z/prefix p/ session-switch. - 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. - 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
prefix zdropped 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.
- The view is a
-
An editor on the child's alternate screen owns the whole pane. ctrl-g opens the prompt in
$EDITOR(so do/memoryand agit commita tool runs), and that program takes the pty over via the alternate buffer — which Claude Code itself never does (the pane-scroll invariant leans on the same fact). SoEmbeddedTerm::alt_screenmeans exactly one thing: what is on screen is not a prompt to frame, and framing it can only crop it (compact_framelooks for the input box's two rules, which nvim never draws).ui::sync_alt_screentherefore fullscreens the pane for as long as the editor lasts and puts it back after. Four rules:- Edge-triggered, never re-asserted per frame, which is what leaves
prefix zin charge: a manual toggle mid-edit sticks instead of being undone on the next draw, and it clears the restore flag (EmbedUi::alt_fullscreen) so quitting the editor doesn't reverse it. A pane that was already fullscreen stays fullscreen afterwards — only a fullscreen we entered ourselves is undone. - Gated on the pane actually taking keys: opening an overlay mid-edit hands the screen back to the feed, closing it returns to the editor. The gate used to be pane focus; with focus gone, "no overlay is up" is the same condition expressed in the only terms left.
- The ctrl-l wipe is cancelled while the alternate screen is up, for a different reason than fullscreen's: that keystroke is meant for Claude Code's input box, and sending it into nvim is not ours to do. The scroll view also resets on both edges — the two screens index stable rows differently, and the alternate one holds no scrollback at all.
- A hot reload reads the flag off the adopted child before the first
draw, so an editor still open at reload time raises no edge and the
snapshotted
PaneState::alt_fullscreenstays meaningful.
- Edge-triggered, never re-asserted per frame, which is what leaves
-
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. Acitations_deltais not text — appending it would corrupt the entry, so citations are collected on theTapper text block, deduplicated by url, and flushed byflush_citationsatcontent_block_stopand inDrop(a cut stream keeps its sources) as one dimKind::Metaentry. -
ANSI colour stops at a filled block. Anything the feed renders as a filled rectangle owns its own fg/bg:
Kind::Userblocks pick their foreground withcolor_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::spanskeeps the colour everywhere else. Attributes are re-applied after wrapping by walking a source cursor forward per character — safe becausewrap_wordsonly 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_resultfollows the same rule — an unparseableLinks:array returns false and the caller falls back to the raw result rendering.
Gotchas
tui-markdownis pinned=0.3.5: 0.3.7+ moved toratatui-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 insrc/markdown.rs, not by upgrading.wezterm-term/wezterm-surfaceare 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/@agentrow 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, whichrow_textdrops. So a whitespace-free token holding a/counts too, and because the user's own statusLine can look exactly like that,compact_frame_exvotes 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 theN tasks (…)header +✔ ◼ ◻rows (and its… +N pendingoverflow line) directly above the input box, so walking up over that block — tolerating one blank line and single wrapped / activity rows, capped atMAX_TASK_BLOCK— makes task status visible with no extra app state. Above that it walks up to an error the CLI itself printed (text_is_error_row/error_block_top— a⎿gutter means a tool and is skipped; 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 fromui::draw) measures box-height + tail so the pane auto-expands as the prompt gains lines or a menu opens and shrinks back when idle (floorMIN_COMPACT_INNER, cap = screen − 6);PTY_PADkeeps 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::drawpassesf.area().heighttoresizefor 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, socompact_rowscan'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_rangestill 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 atDEFAULT_COMPACT_INNER): the pane grows instantly but shrinks only after the smaller height has held forSHRINK_DELAY(400ms), andcompact_rowsreturnsNoneon 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_frameanchors 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_rowsfeeds 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_rangetop-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. That is now a
small constraint, because there is only one unprefixed key left to place: the
prefix itself (
keymap::Prefix, ctrl-space,CT_PREFIXto change). Its default has to match three spellings — terminals send ctrl-space asNull, as ctrl-@or as a real ctrl-modified space, and which one you get is not knowable in advance — soPrefix::matchesaccepts all three. That is also why the prefix is configurable at all: the only hard requirement is that the installed Claude Code does not want the key, which is a property of the child's version, not of ours. The bindings themselves arekeymap::ROOTand are documented there, not here — duplicating the list is exactly the drift the table exists to stop. What is worth recording is what the actions reach:prefix nopens the model picker and spawns a brand-newclaude --session-id … [--model …](kills any current pane —show_embed_new; saves resume-then-/clear to get a fresh chat). The picker list isModels::choices()— one row per model, at the windowModels::arggives it (sonnet (1M context)→sonnet[1m],haiku→haiku), plus adefaultrow thatApp::spawn_argresolves 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::modelsis seeded byModels::seedand replaced byterm::spawn_model_discovery— a background scan that reads the live model-alias array (["sonnet","opus","haiku","fable",…]) straight out of the installedclaudeELF (single self-contained binary with the JS bundle embedded). No API call, never runs claude — just resolvesclaudeon PATH and greps its bytes for the longest lowercase-token array anchored byopus+sonnet. The same pass collects every quoted"<token>[1m]"literal (term::long_context_tokens) and keeps the base names asModels::long, so only models that really have the variant get the suffix — todayopus/sonnet/fableand a set of full ids, nothaikuormythos.[1m]needs no shell quoting: the pane spawns viaCommandBuilderargv, not a shell. Inside theprefix soverlay (a bottom-anchored third of the screen — see its invariant): j/k/↑/↓ move the session/turn highlight, space/→/← expand/enter/leave the turn tree, Tab/BackTab cycle sessions, v visual range, b branch, enter attaches the pane. Insideprefix a: j/k previews each lane, enter/esc close on the one you walked to. Insideprefix f: space toggles,aall,nnone.prefix /opens the find bar (see its invariant). Every overlay is modal for the keyboard, which is why none of them needs a focus model — but none of them takes the wheel, because none of them covers the feed.prefix ]/[jump the feed to the next/previous user prompt (App::prompt_jump, applied indrawwhere entry heights are cached);prefix >/<step between streams. All four are sticky, so the menu stays up andprefix ]]]walks.CT_DEBUG_KEYS=1shows 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 withoutset -g mouse onswallows them) versus an event delivered to a pane with no scrollback above the live screen. It is also how you find out what your terminal sends for a candidate prefix. - Mouse is captured: the wheel scrolls the feed (or moves an open picker's
highlight — an overlay is modal, so it owns the wheel) — 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 sameWHEEL_ROWSstep either side ofprefix z). 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 oneEvent::Pasteand, when the claude pane has focus, is handed to the child viaEmbeddedTerm::paste(wezterm-term'ssend_pastere-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
drawrecords the child's DECSCUSR shape (EmbeddedTerm::cursor_shape) intoEmbedUi::cursor_shapeand the event loop emitsSetCursorStyleonly on change (so a blinking cursor isn't reset every frame), resetting toDefaultUserShapewhen 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_stylemaps the child's DECSCUSRDefaultto a blinking bar, notDefaultUserShape: 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 explicitSteadyBlock. - ratatui needs feature
unstable-rendered-line-infoforParagraph::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
mainbefore the TUI starts: prefers 8484, falls back to an OS-assigned free port so multiple instances coexist (each pane gets the actual port viaANTHROPIC_BASE_URL).CT_PORTpins 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 to127.0.0.1:<port>/v1/messageswithout auth — a relayed 401 from Anthropic proves the round-trip. The TUI can't run in a non-tty.CT_UPSTREAMpoints 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 isdev/fake_upstream.py: it answers every request with canned SSE, so a realclaudechild 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.websearchalso answers the nested hosted-tool request Claude Code makes to run WebSearch (server_tool_use+web_search_tool_result+citations_delta), andansireturns aBashcall whose output carries real SGR codes, so both paths are exercised by a genuine tool_result rather than a fixture. The three error scenarios are how the pane's error framing is developed against real Ink output, and two of them are negative fixtures:toolerrorcallsReadon a missing path (auto-approved, so no permission prompt) andbasherrorruns a failingBash, both producing the⎿ Error:gutter that must not grow the pane.apierroranswers the turn request with a non-retryable 400 — leaving the side/title calls alone — so Claude Code prints its own● API Error: 400 …row, which must. 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 theagentscenario answers every tool-bearing request withAgentcalls, including a subagent's own, so agents spawn recursively; switch totextonce they are running. Drive it through tmux (.claude/skills/tui-verify) and obey that skill's safety rule: neverpkill/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 —--headlesshas no event loop to pressprefix rin. 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 aKind::Error. -
Subagent lanes: 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-turninput_tokens/output_tokens(SSE-only), and a lane whose notification we never saw falls back to the wire-countedout …. One lane is readable at a time, by design — the feed shows one lane andprefix a/prefix >picks it (the alternative was the split feed this replaced). A lane is never closed, only markedfinished: a background agent (x-app: cli-bg) can wake up again long after its launch result landed, andSendMessagecan revive a finished one. An agent transcript overMAX_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
Agenttool_results in them still hold the reports the parent model saw, and copyingsubagents/would duplicateagentIds across two sessions and contradict the agent files' ownsessionId. 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
Agentcall never passed through us) it stays listed asagent <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_contentrenders 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 atool_referenceas[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+enter. 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.