13 KiB
13 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),
forwards via reqwest, streams 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).
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
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. - One app instance = one proxy port = at most one embedded claude
(
EmbedUi::term/App::embed_session, matched by the--session-id/--resumeUUID we spawn with). Every other live session is an external claude pointed at our port: observable, never attachable. - 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: later versions useratatui-core(0.30 alpha types), incompatible with ratatui 0.29. 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 pane's render window crops Claude Code chrome by position
(
term.rs:BOTTOM_CROP, start ≥ 2,PTY_PAD) — tuned to the current Claude Code UI; retune there if an update adds/removes chrome rows. Cropping only applies to the compact pane: fullscreen renders the child's screen verbatim from row 0 with the PTY sized exactly to the pane (cropflag onresize/render). - 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, n 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). The feed scrolls only via wheel / PgUp / PgDn / g / G.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.