20 KiB
20 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
src/proxy.rs axum fallback handler: buffers request body (for session metadata,
tool results, and user prompts — `app::record_user_prompt` lifts
the trailing user message into a Kind::User feed entry verbatim
(incl. slash-command machinery — the goal is to show everything
the model received, never filter it). On a turn-starting request
(tools present) it also emits the system-prompt *size* as a
Kind::System line (the prompt itself is too long to show) and the
available tool set as Kind::ToolDefs — each once, re-emitted only
on change (system/tools/history are re-sent every request but are
not new data). A *side* request (no tools — topic/title haiku
calls) is still shown, tagged with a `── side request ──` Meta
divider. `app::extract_user_text` splits a user text block into
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)
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. 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.
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: background scanner thread keeps
App::disk_sessions fresh (~1/s poll, labels re-read only on
mtime change); load_view/load_history rebuild a feed Session
from a JSONL transcript (lazily, on first view); 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
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)
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
- 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. - 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: the embed jumps the selection only on first bind; a brand-new external session auto-jumps so a fresh/clearis visible unlessApp::pane_focused(mirrored from the UI each frame) — never steal the selection from a pane the user is driving. 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 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 / → ->/*.jsonlas 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 intoApp::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-idspawn when there's nothing. Live external sessions are guarded — their instance may still run elsewhere and a second--resumewould fork the transcript — but a second ctrl-↓ within 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). - 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).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: 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.
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, CC-2.1.x glyphs — retune there if an update changes them), so the frame extends to the last non-blank row instead. The menu is detected by scanning the whole region below the bottom rule for a menu row, not just the row directly under it: the list can start after a blank/header row and only the highlighted item carries a glyph (unselected file rows are plain names), so checking one row collapsed the pane whenever that row wasn't the selected item. 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. The Compact PTY is sized to the whole screen height, not the visible pane (ui::drawpassesf.area().heighttoresizeonly for Compact): Compact's 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) top-anchors from row 2 instead so the prompt stays visible.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
qis 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-newclaude --session-id … [--model …](kills any current pane —show_embed_new; saves resume-then-/clear to get a fresh chat). The picker list comes fromApp::model_choices: seeded withdefault_model_choices, then 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. Tab/BackTab cycle sessions (pno 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 indrawwhere entry heights are cached). The feed scrolls only via wheel / PgUp / PgDn / g / G / n / N.CT_DEBUG_KEYS=1shows 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 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.
Not yet handled (known MVP limits)
- Non-streaming requests pass through untapped (e.g.
count_tokens). - 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).
- 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.