276 lines
19 KiB
Markdown
276 lines
19 KiB
Markdown
# claude-cloak
|
||
|
||
TUI that displays Claude Code's API streams token-by-token (thinking, text, tool
|
||
calls) by acting as a pass-through proxy: Claude Code points `ANTHROPIC_BASE_URL`
|
||
at `127.0.0.1:8484`, we forward everything verbatim to `api.anthropic.com` and
|
||
tee SSE responses into the UI. **Never issue API requests of our own** — zero
|
||
extra usage is the core constraint of this project.
|
||
|
||
## Architecture
|
||
|
||
```
|
||
src/main.rs entry; tokio runtime for proxy task, TUI on main thread; --headless mode
|
||
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.
|