Files
claude-cloak/CLAUDE.md
2026-06-26 13:10:10 +02:00

19 KiB
Raw Blame History

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-encoding is stripped from forwarded requests so the upstream sends identity encoding we can parse in transit. Don't "fix" that.
  • Hop-by-hop headers (content-length, transfer-encoding, etc.) are stripped both directions; hyper re-frames.
  • Sessions are keyed by the session UUID in request metadata.user_id — Claude Code ≥2.1.x sends a JSON blob with "session_id":"<uuid>", older builds user_…_session_<uuid>; proxy::session_key handles both. Concurrent requests (subagents) share a session but each Tap tracks its own current entry index — entries/sessions are append-only, so indices stay stable.
  • Embed identity is learned from traffic, never assumed from --session-id. Claude Code's interactive --session-id is not guaranteed to equal the id it reports in request metadata (and a --resume can mint a fresh one), so the pane is correlated by a token we control: term.rs injects a per-spawn x-claude-cloak-pane header (ANTHROPIC_CUSTOM_HEADERS), the proxy reads it (and strips it before forwarding), and Tap::new binds App::embed_session to whatever id that tagged request actually carries (App::bind_embed_session rebinds + renames a provisional resume row if they differ). Selection policy follows: the embed jumps the selection only on first bind; a brand-new external session auto-jumps so a fresh /clear is visible unless App::pane_focused (mirrored from the UI each frame) — never steal the selection from a pane the user is driving. This is what made an a-spawned session stream into the wrong row before.
  • One app instance = one proxy port = at most one embedded claude (EmbedUi::term / App::embed_token → learned App::embed_session). kill_current_embed is the single teardown path and bind_new_pane the single registration path, so pane identity + grow/clear flags can't drift across the spawn/replace call sites. Every other live session is an external claude pointed at our port: observable, never attachable. The pane stays visible while it holds keyboard focus even if the selection isn't on its session yet (its id is still being learned); only an intentional ctrl-↑ / tab-away hides it.
  • The session list merges live sessions (first, indices stable) with this directory's past sessions from ~/.claude/projects/<cwd with / → ->/*.jsonl as dimmed stubs (deduped by uuid — a live session's file is on disk too). Tab is viewing only, never a process operation: selecting a stub lazy-loads its transcript into App::history; tabbing off the embedded session hides the pane without killing the child (instant to come back). ctrl-↓ is the commit point that attaches the pane to the selection: reveal+focus if it's the embedded session, claude --resume <uuid> (kill + respawn) for disk stubs and dead embeds, fresh --session-id spawn when there's nothing. Live external sessions are guarded — their instance may still run elsewhere and a second --resume would fork the transcript — but a second ctrl-↓ within 3s forces it (liveness is unknowable: an idle claude sends no traffic; EmbedUi::past_embeds skips the guard for sessions whose instance we killed ourselves). --session-id cannot be combined with --resume (CLI rejects it without --fork-session).
  • Turn tree / branching (lazygit/yazi-style, all in the sessions panel): space (or /l) expands the selected session's turn tree — one row per real user prompt, abandoned rewind branches indented under their fork point, trunk continuing below. j/k/↑/↓ walk sessions and turns (they never scroll the feed; the wheel and PgUp/PgDn/g/G do that). Highlighting a turn switches the feed to the on-disk transcript along the path through that turn and pins the turn's prompt to the viewport top (HistoryView caches per uuid, rebuilt when the leaf changes; FeedCache keys on leaf+live so views of the same uuid don't share slots). v anchors a contiguous visual range, b materializes a new fully decoupled session file — chain root→turn, or exactly the visual range stitched together (sessionId rewritten, each turn's head re-parented onto the previous turn's tail, our own ai-title record gives it the ⑂ … label) — injected as the selected top stub. Branching never touches a process: ctrl-↓ stays the only spawn/kill commit point.
  • The tap drives pane behavior: grows it for AskUserQuestion / ExitPlanMode (sized from the question's option count) before Claude Code renders the prompt, shrinks when the tool_result echoes back, and schedules a ctrl-l transcript wipe 400ms after each turn (the pane is prompt-only; the feed shows the context).
  • Tool input streams as raw JSON fragments; pretty-printed only on content_block_stop. Streaming text re-renders markdown on every change (FeedCache fingerprints by content length + done + result), so partial markdown self-heals; completed entries render from cache.

Gotchas

  • tui-markdown is pinned =0.3.5: later versions use ratatui-core (0.30 alpha types), incompatible with ratatui 0.29. Its gaps (no tables, literal heading markers) are compensated in src/markdown.rs, not by upgrading.
  • wezterm-term/wezterm-surface are not on crates.io: pinned to a git rev of the wezterm monorepo (keep both revs identical).
  • The compact pane dynamically frames Claude Code's input box rather than cropping by fixed offsets (term.rs: compact_frame + PaneView). It locates the box by its two horizontal-rule borders (text_is_rule) — the last two ──── rules on screen, since the prompt always sits at the bottom — and shows one context row above the top rule (the spinner / "✻ Worked…" row) down to the statusLine just under the bottom rule, cropping the persistent hint/token/effort chrome below it. When an @// menu is open it has replaced that chrome with a list (text_is_menu_item, CC-2.1.x glyphs — retune there if an update changes them), so the frame extends to the last non-blank row instead. The framed region drives the pane height too: compact_rows (called from ui::draw) measures box-height + tail so the pane auto-expands as the prompt gains lines or a menu opens and shrinks back when idle (floor MIN_COMPACT_INNER, cap = screen 6); PTY_PAD keeps the PTY taller than the visible window so the child can still draw the rows we crop. 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 q is forwarded to the child), c attach most-recent past session. List keys: j/k/↑/↓ move the session/turn highlight, space/→/← expand/ enter/leave the turn tree, a opens the model picker popup and spawns a brand-new claude --session-id … [--model …] (kills any current pane — show_embed_new; saves resume-then-/clear to get a fresh chat). The picker list comes from App::model_choices: seeded with default_model_choices, then replaced by term::spawn_model_discovery — a background scan that reads the live model-alias array (["sonnet","opus","haiku","fable",…]) straight out of the installed claude ELF (single self-contained binary with the JS bundle embedded). No API call, never runs claude — just resolves claude on PATH and greps its bytes for the longest lowercase-token array anchored by opus+sonnet. Tab/BackTab cycle sessions (p no longer mirrors BackTab). v visual range, b branch, Esc unwinds (visual → tree → quit). n/N jump the feed scroll to the next/previous user prompt (App::prompt_jump, applied in draw where entry heights are cached). The feed scrolls only via wheel / PgUp / PgDn / g / G / n / N. CT_DEBUG_KEYS=1 shows raw key events in the status bar.
  • Mouse is captured: wheel always scrolls the feed (regardless of focus), and left-drag selects screen text, copied on release via OSC 52 (like Claude Code). Native terminal selection therefore needs shift held.
  • Bracketed paste is enabled on the outer terminal (EnableBracketedPaste): a multiline paste arrives as one Event::Paste and, when the claude pane has focus, is handed to the child via EmbeddedTerm::paste (wezterm-term's send_paste re-wraps it in bracketed markers iff the child enabled them) — so Claude Code inserts it as one block instead of submitting on the first embedded newline. Paste is ignored when the pane is unfocused (nothing else takes text input).
  • Pane cursor shape mirrors the child: each frame draw records the child's DECSCUSR shape (EmbeddedTerm::cursor_shape) into EmbedUi::cursor_shape and the event loop emits SetCursorStyle only on change (so a blinking cursor isn't reset every frame), resetting to DefaultUserShape when no pane cursor is shown / on teardown. Without this the outer terminal kept a stale block cursor regardless of Claude Code's insert-vs-vim-normal state. term::cursor_style maps the child's DECSCUSR Default to a blinking bar, not DefaultUserShape: Claude Code's normal input leaves the cursor at the terminal default expecting a bar caret, so forwarding the outer terminal's own default (often a block) would wrongly show a block in insert mode; vim normal mode still sends an explicit SteadyBlock.
  • ratatui needs feature unstable-rendered-line-info for Paragraph::line_count (used to compute cached per-entry wrapped heights for follow/auto-scroll).
  • reqwest is default-features = false + rustls-tls,stream — don't enable compression features (would re-add accept-encoding).
  • The listener is bound in main before the TUI starts: prefers 8484, falls back to an OS-assigned free port so multiple instances coexist (each pane gets the actual port via ANTHROPIC_BASE_URL). CT_PORT pins the port and turns bind failure into a hard startup error.
  • Testing: SSE parser has unit tests (cargo test). For a live pass-through check: --headless (prints the bound port), then POST to 127.0.0.1:<port>/v1/messages without auth — a relayed 401 from Anthropic proves the round-trip. The TUI can't run in a non-tty. CT_UPSTREAM points the proxy at an alternative upstream (e.g. a local fake SSE server) for fully offline end-to-end tests with zero API usage.

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.