Compare commits

...

11 Commits

Author SHA1 Message Date
Jonas H
15b20a8af1 Put every app key behind one prefix
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.
2026-09-10 14:16:34 +02:00
Jonas H
e9e224f856 Frame only the CLI's own errors, not tool errors
A failed tool was never the case worth growing the pane for: its
tool_result reaches the feed with the next request, so the feed above
already shows it. Only an error the CLI produced itself is invisible
there. Framing both meant tool errors bled through and held the pane open.

The `⎿` gutter is now the whole test — it means a tool is talking, so the
row is skipped. That leaves the un-guttered `● API Error: …` shape, which
also needs no lookup of the row above: a CLI error names itself.

Also expire an error at the newest prompt row. ERROR_SCAN alone was far
too coarse, because the CLI's reply to an error is only two rows: the
error stayed inside the window and held the pane through the next turn.
2026-09-10 12:21:42 +02:00
Jonas H
ae454a3d2d Fullscreen the pane while an editor holds it
ctrl-g opens the prompt in $EDITOR, which takes the child's pty over via
the alternate screen — something Claude Code never does itself. The
compact frame looks for the input box's two rules, so an editor could
only ever be cropped by it. Follow the alternate screen instead: give
the pane the whole screen for as long as the editor lasts, then put it
back. Edge-triggered, so ctrl-f still wins.
2026-09-07 11:52:43 +02:00
Jonas H
043c99cdeb Frame Claude Code's errors into the compact pane
The compact pane is prompt-only because the feed above shows the context.
An error is the exception: the feed cannot show it. A tool_result only
reaches us with the next request, which a turn that died never sends, and
an `API Error` row is Claude Code's own text, never in the stream at all.

So the frame walks further up to take the report in, and the scheduled
ctrl-l wipe is cancelled while one is framed — Ink redraws only the live
frame, so wiping would delete the error 400ms after it appeared.

Detection is text shape, not colour: the tool error renders palette 211
and the API error 220, both theme values. ERROR_SCAN is the only bound,
which is what makes it self-limiting — the error drifts out of the window
as the next turn prints output and the pane shrinks back on its own.
2026-09-07 10:20:52 +02:00
Jonas H
6bb7e7d424 ai titles for sessions 2026-09-07 08:07:43 +02:00
Jonas H
b9d7d2c969 Scroll the fullscreen pane's own scrollback
Claude Code grabs no mouse and stays off the alternate screen, so a plain
terminal answers the wheel with its own scrollback and the child never hears
about it. The fullscreen pane is that terminal, so it does the same job: the
view is a stable row index into wezterm-term's scrollback, which pins the rows
you scrolled to while the child keeps writing.

The ctrl-l transcript wipe is cancelled while fullscreen — there the pane is
not prompt-only, and its transcript is the thing being scrolled.
2026-08-27 14:34:48 +02:00
Jonas H
73871cb1dc Run every model at the 1M context window
The window is a beta header, so nothing on disk records it. Observing it on
the wire and replaying it on resume had to survive a session rename, a hot
reload, a WebSearch sub-request and a process restart — every gap fell back
to the short window.

Force it instead: Models::arg is the single decision point, and every spawn
and resume goes through it. A model is still only suffixed when the installed
claude ships that variant, so haiku stays haiku.
2026-08-27 13:06:21 +02:00
Jonas H
a5a6092579 Detect the unmarked @ path listing in the pane
Claude Code marks a fuzzy `@` hit with `+ `, but a path that leaves the
project (`@../de`) switches it to a plain directory listing whose rows
carry no marker — the selected row differs only in colour, which the
pane's text-only read of the screen drops. So the compact frame ended one
row under the input box and showed the first hit alone.

Recognise a bare path row by shape (a whitespace-free token holding a
`/`) and, since a user statusLine can look exactly like that, confirm the
list over two rows: the row under the rule must be a menu row or blank
(it is shown either way, so a one-hit menu needs no detection), and at
least one further row must match too.
2026-08-27 12:44:06 +02:00
Jonas H
1f483ad3d1 Hot-reload onto a rebuilt binary with ctrl-r
Restarting to pick up a code change costs the three things this app
exists to hold: the proxy port, the embedded claude pane, and the live
feed. So ctrl-r does not restart — it execs the binary now on disk into
this same process. execve keeps the pid, the open fds and the child
processes, so the listener socket, the pty and its claude child all
simply carry on; the feed travels in a JSON snapshot.

Build nothing, watch nothing. A build is the user's business, and a
running instance must not decide on its own when to become different
code. Rebuild outside, then press ctrl-r in each instance.

Drain before the exec. It destroys the tokio tasks relaying in-flight
responses, so the proxy stops accepting and finishes what it has first.
The socket stays open throughout (App::listener_fd is a dup), so
requests made during the swap queue in the kernel backlog and are served
by the new image — verified end to end: nothing refused, nothing cut.

Treat the snapshot as advisory. 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. The fd numbers stay in plain fields and the
feed is decoded per session behind a sanitiser, so a schema change costs
the feed and never the port or the pane.
2026-08-27 11:46:15 +02:00
Jonas H
8907766b5b Strip nF escapes so rustfmt output loses its B
rustfmt and `git diff` close every coloured run with `ESC ( B` (designate G0
as ASCII) and write it after the newline, ahead of the SGR reset. With no arm
for nF escapes the parser fell through to "two-char escape", ate `ESC (` and
left the `B` as text, so each diff line in the feed read `B+ added line`.

Intermediates 0x20-0x2f now run to a final 0x30-0x7e, and an unterminated
sequence stops where the CSI arm would.
2026-08-27 10:33:18 +02:00
Jonas H
ba6b18e7d9 Lane server tools, lift task notes, parse ANSI
Three streams of data were being lost or mangled in the feed.

WebSearch is not purely client-side: it issues a nested /v1/messages that
declares Anthropic's hosted web_search under the parent session id and with
no agent-id header. Read as a turn start it pushed a fake user prompt,
clobbered the main lane's system/tools signatures and downgraded a [1m]
session to the short window on the next resume. Requests are now classified
three ways (Turn / ServerTool / Side) from the tools array shape alone, and a
nested call gets its own lane, readable only in the A popup. Its result and
citations arrive complete in the stream and are echoed back in no later
request body, so they are attached as the stream delivers them.

An Agent call returns its tool_result immediately ("async agent launched"),
so the real completion is a <task-notification> injected into the parent's
next user turn. Those are lifted out of the prompt: the report moves onto the
Agent entry it answers, the usage totals onto the lane, and the status
becomes one glyph-led note line. A finished agent used to keep reading as
running.

Tool output we do not control carries SGR codes. A self-contained parser maps
them to styles instead of leaving [1m as literal text; filled blocks keep
their own colours and take only the attributes.

Also: a non-2xx upstream response now surfaces as an error entry instead of a
silent stall, tool renderers cover the file/shell/task/prompt/web families,
and the fake upstream answers the nested hosted-tool request so both search
paths run offline.
2026-08-27 10:24:05 +02:00
14 changed files with 8808 additions and 1347 deletions

879
CLAUDE.md

File diff suppressed because it is too large Load Diff

1
Cargo.lock generated
View File

@@ -353,6 +353,7 @@ dependencies = [
"anyhow",
"axum",
"futures-util",
"libc",
"portable-pty",
"ratatui",
"reqwest",

View File

@@ -21,3 +21,7 @@ portable-pty = "0.9"
wezterm-term = { git = "https://github.com/wezterm/wezterm", rev = "891bed31b75f7a71b78e8f42ad07ae89bf99a7de" }
wezterm-surface = { git = "https://github.com/wezterm/wezterm", rev = "891bed31b75f7a71b78e8f42ad07ae89bf99a7de" }
uuid = { version = "1", features = ["v4"] }
# Hot reload (src/reload.rs): fd inheritance across execve, PTY ioctls and
# child signalling once the portable-pty handles are gone.
libc = "0.2"

View File

@@ -40,13 +40,43 @@ proxy isn't running.
## Keys
| key | action |
Everything you type goes to the embedded `claude`. There is no focus model and
no key you have to think about — `q`, `j` and `Esc` all reach Claude Code,
because the app's own keys live behind a **prefix**, tmux-style.
Press **ctrl-space** and a which-key popup shows what is available. The keys
work immediately; you never have to wait for the popup.
| `^space` + | action |
|---|---|
| `q` / `Esc` | quit |
| `Tab` / `Shift-Tab` | switch session |
| `j`/`k`, arrows, PgUp/PgDn | scroll (disables follow) |
| `f` / `G` / `End` | follow live tail |
| `g` / `Home` | jump to top |
| `s` | sessions — `j`/`k` previews each one in the feed, `enter` opens it in claude, `Esc` keeps reading it. Turn tree with `space`, branch with `b` |
| `a` | streams — `j`/`k` previews the main chain, each subagent, each hosted tool call. Sized to the list, capped at a third |
| `n` | new session (model picker) |
| `c` | continue the most recent session |
| `f` | filter which entry kinds show |
| `/` | find bar — matches highlight in place, `enter`/`↓` next, `↑` prev, with a `3/12` counter |
| `]` `[` | next / previous user prompt |
| `.` | back to the live main chain, tailing it — undoes a picked session, a picked lane, and a parked scroll |
| `z` `Z` | zoom the pane / zoom the feed |
| `r` | hot reload |
| `q` | quit |
Repeatable keys (`]` `[`) keep the popup open, so `^space ]]]` walks.
`^space ^space` sends a literal prefix to the child.
Two keys are the app's without a prefix, for the same reason a terminal keeps
them for itself: the **wheel** and **shift**+PgUp/PgDn scroll the feed (or the
pane's own scrollback while the pane is zoomed). They keep working while a
list is open, so you can read what you highlighted before committing to it.
**Esc closes the topmost overlay. With nothing open it goes to Claude Code**, so
interrupt and Esc-Esc rewind keep working. Filters and the stream you picked are
settings, not modes — Esc never resets them.
`CT_PREFIX` changes the prefix (`CT_PREFIX=ctrl-b`, `ctrl-]`, `f1`, …), for the
case where your Claude Code wants ctrl-space for itself. `CT_DEBUG_KEYS=1` shows
what your terminal actually delivers. ctrl-q quits from anywhere, as a way out
if the prefix never arrives.
## Display
@@ -60,3 +90,36 @@ Sessions are keyed by the Claude Code session ID found in request metadata;
concurrent requests (subagents) tap independently.
`--headless` runs the proxy without the TUI. `CT_PORT` overrides the port (default 8484).
## Hot reload
Swap a running instance onto a newly built binary with **`^space r`** — without
losing the proxy port, the embedded `claude` pane, or the live feed.
```sh
cargo build # in any terminal, whenever you like
# then press ^space r in each running instance
```
claude-cloak never builds anything itself and watches no files. You rebuild the
way you always would; the key says "run that one now".
It execs the same **path** the instance was started from, so a debug
instance reloads onto a rebuilt debug binary and a release instance onto a
rebuilt release one. It does not cross profiles.
### How it survives
The app `execve`s the new binary into its **own process**, so the pid, the open
file descriptors and the child processes all stay. Before the exec the proxy
drains: it stops accepting and lets in-flight responses finish, while the
listening socket stays open so requests made during the swap wait in the kernel
backlog and are served by the new code. Nothing is refused and nothing is
truncated.
The footer shows `⟳ reloading…` while it drains, then `· reload #1` once the new
code is running. A failed exec — pressed while the linker still had the file
open — reports `⚠ reload failed: …` and changes nothing; press it again.
A state snapshot the new types no longer fit costs the feed only — never the
port and never the pane.

View File

@@ -8,7 +8,12 @@ ExitPlanMode, TodoWrite/Task*) on demand, so the pane's frame detector in
`src/term.rs` can be developed against what Ink actually draws.
Scenario is picked per turn from `CT_FAKE_SCENARIO`
(ask | plan | todo | taskupdate | agent | text).
(ask | plan | todo | taskupdate | agent | websearch | ansi | toolerror | apierror | text).
`websearch` also answers the *nested* request Claude Code makes to run WebSearch:
that call declares Anthropic's server-side `web_search` tool, so it is replied to
with `server_tool_use` + `web_search_tool_result` + `citations_delta` — the block
types only a hosted tool produces.
Each incoming request is logged to `dev/fake_upstream.log` (declared tool names
+ the trailing user text) so we can see what CC sends.
"""
@@ -61,6 +66,9 @@ def stream_text(text):
yield sse("message_stop", {"type": "message_stop"})
_TOOL_SEQ = 0
def stream_tool(name, tool_input, lead="Working on it."):
"""A turn that calls one client-side tool."""
yield from stream_tools([(name, tool_input)], lead)
@@ -78,8 +86,13 @@ def stream_tools(calls, lead="Working on it."):
"delta": {"type": "text_delta", "text": lead}})
yield sse("content_block_stop", {"type": "content_block_stop", "index": 0})
for n, (name, tool_input) in enumerate(calls, start=1):
# Ids must be unique across the whole session, exactly as the real API
# guarantees: Claude Code resends the full history every request, so a
# reused id makes an *old* tool_result re-attach to the newest call.
global _TOOL_SEQ
_TOOL_SEQ += 1
yield sse("content_block_start", {"type": "content_block_start", "index": n,
"content_block": {"type": "tool_use", "id": f"toolu_fake{n}",
"content_block": {"type": "tool_use", "id": f"toolu_fake{_TOOL_SEQ}",
"name": name, "input": {}}})
blob = json.dumps(tool_input)
for i in range(0, len(blob), 40):
@@ -92,6 +105,69 @@ def stream_tools(calls, lead="Working on it."):
yield sse("message_stop", {"type": "message_stop"})
def stream_server_websearch():
"""What a *hosted* web_search turn looks like: `server_tool_use`, then a
complete `web_search_tool_result` block (no deltas — the whole payload
rides in `content_block_start`), then cited text. Claude Code issues this
nested request itself when it runs the client-side `WebSearch` tool."""
yield sse("message_start", {"type": "message_start", "message": {
"id": "msg_ws", "type": "message", "role": "assistant", "model": MODEL,
"content": [], "stop_reason": None, "stop_sequence": None,
"usage": {"input_tokens": 50, "output_tokens": 1}}})
yield sse("content_block_start", {"type": "content_block_start", "index": 0,
"content_block": {"type": "server_tool_use", "id": "srvtoolu_fake1",
"name": "web_search", "input": {}}})
blob = json.dumps({"query": "ratatui scrollbar thumb"})
yield sse("content_block_delta", {"type": "content_block_delta", "index": 0,
"delta": {"type": "input_json_delta", "partial_json": blob}})
yield sse("content_block_stop", {"type": "content_block_stop", "index": 0})
yield sse("content_block_start", {"type": "content_block_start", "index": 1,
"content_block": {
"type": "web_search_tool_result", "tool_use_id": "srvtoolu_fake1",
"content": [
{"type": "web_search_result", "title": "Ratatui Scrollbar docs",
"url": "https://ratatui.rs/widgets/scrollbar", "page_age": "2 days"},
{"type": "web_search_result", "title": "Scrollbar example",
"url": "https://ratatui.rs/examples/scrollbar", "page_age": None},
]}})
yield sse("content_block_stop", {"type": "content_block_stop", "index": 1})
yield sse("content_block_start", {"type": "content_block_start", "index": 2,
"content_block": {"type": "text", "text": ""}})
for chunk in "Ratatui renders the thumb through its Scrollbar widget. ".split(" "):
yield sse("content_block_delta", {"type": "content_block_delta", "index": 2,
"delta": {"type": "text_delta", "text": chunk + " "}})
yield sse("content_block_delta", {"type": "content_block_delta", "index": 2,
"delta": {"type": "citations_delta", "citation": {
"type": "web_search_result_location",
"url": "https://ratatui.rs/widgets/scrollbar",
"title": "Ratatui Scrollbar docs",
"cited_text": "Scrollbar renders a thumb over the track."}}})
yield sse("content_block_stop", {"type": "content_block_stop", "index": 2})
yield sse("message_delta", {"type": "message_delta",
"delta": {"stop_reason": "end_turn", "stop_sequence": None},
"usage": {"output_tokens": 30, "server_tool_use": {"web_search_requests": 1}}})
yield sse("message_stop", {"type": "message_stop"})
# A command whose *output* carries real SGR codes, so the feed's ANSI handling
# is exercised by a genuine tool_result rather than a hand-written fixture.
ANSI_INPUT = {
"command": (
"printf '\\033[1mbold heading\\033[22m\\n'; "
"printf '\\033[31m- removed line\\033[0m\\n'; "
"printf '\\033[32m+ added line\\033[0m\\n'; "
"printf '\\033[38;5;208m256-colour orange\\033[0m\\n'"
),
"description": "Print coloured output",
}
# A tool call that *fails* on the child's side, so Claude Code prints its own
# red error row (`⎿ Error: …`) above the input box. `Read` is auto-approved, so
# this needs no permission prompt — the failure comes from the missing file.
TOOL_ERROR_INPUT = {"file_path": "/nonexistent/claude-cloak/does-not-exist.txt"}
ASK_INPUT = {"questions": [{
"question": "The compact pane currently crops the top of this prompt. Which framing "
"should the pane use when an interactive question is on screen, given that "
@@ -163,7 +239,14 @@ class Handler(BaseHTTPRequestHandler):
body = json.loads(raw)
except Exception:
body = {}
tools = [t.get("name") for t in body.get("tools", []) or []]
raw_tools = body.get("tools", []) or []
tools = [t.get("name") for t in raw_tools]
# A hosted tool carries a `type` and no `input_schema`; that is the
# nested WebSearch call, not a turn start.
hosted = bool(raw_tools) and all(
t.get("input_schema") is None and t.get("type") not in (None, "custom")
for t in raw_tools
)
msgs = body.get("messages", []) or []
tail = json.dumps(msgs[-1])[:300] if msgs else ""
# Only the immediate reply to *our* canned tool call ends the turn with
@@ -192,8 +275,26 @@ class Handler(BaseHTTPRequestHandler):
return
scenario = read_scenario()
# `apierror`: a non-retryable upstream failure, so Claude Code prints
# its own red `API Error: 400 …` row where the turn would have been.
# Only the *turn* request fails — the side/title calls still answer, so
# the pane behaves normally around the error.
if scenario == "apierror" and tools and not hosted and not has_result:
out = json.dumps({"type": "error", "error": {
"type": "invalid_request_error",
"message": "fake upstream rejected this turn on purpose"}}).encode()
self.send_response(400)
self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(out)))
self.end_headers()
self.wfile.write(out)
return
# The nested hosted-tool request answers itself, whatever the scenario.
if hosted:
gen = stream_server_websearch()
# A request with no tools is CC's side/title call — answer with text.
if not tools or has_result:
elif not tools or has_result:
gen = stream_text("Done. Ask me anything else.")
elif scenario == "ask":
gen = stream_tool("AskUserQuestion", ASK_INPUT, "Let me check how you want this framed.")
@@ -215,6 +316,15 @@ class Handler(BaseHTTPRequestHandler):
gen = stream_tools(
[("Agent", a) for a in AGENT_INPUTS], "Delegating this."
)
elif scenario == "websearch":
gen = stream_tool("WebSearch", {"query": "ratatui scrollbar thumb"},
"Let me search for that.")
elif scenario == "ansi":
gen = stream_tool("Bash", ANSI_INPUT, "Printing coloured output.")
elif scenario == "basherror":
gen = stream_tool("Bash", {"command": "echo boom >&2; exit 3", "description": "Fail"}, "Running that.")
elif scenario == "toolerror":
gen = stream_tool("Read", TOOL_ERROR_INPUT, "Reading that file.")
elif scenario == "taskupdate":
gen = stream_tool("TaskUpdate", {"taskId": "1", "status": "in_progress"},
"Starting the first task.")
@@ -241,5 +351,5 @@ class Handler(BaseHTTPRequestHandler):
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 9911
print(f"fake upstream on 127.0.0.1:{port} scenario={os.environ.get('CT_FAKE_SCENARIO', 'ask')}")
print(f"fake upstream on 127.0.0.1:{port} scenario={read_scenario()}")
ThreadingHTTPServer(("127.0.0.1", port), Handler).serve_forever()

513
src/ansi.rs Normal file
View File

@@ -0,0 +1,513 @@
//! ANSI escape-sequence handling for feed text.
//!
//! Plenty of what reaches the feed is real terminal output rather than plain
//! prose: colourised `cargo`/rustfmt results (`\x1b[31m- app.lo…`) and
//! Claude Code's own slash-command stdout, which arrives inside the user turn
//! verbatim (`/model` prints `Set model to \x1b[1mSonnet 5\x1b[22m …`).
//! The feed used to drop the ESC byte as "just another control char" and leave
//! `[1m` behind as literal text. Here the sequences are parsed instead: SGR
//! becomes ratatui styling, every other escape is stripped.
//!
//! Self-contained on purpose — no new dependency — and deliberately total: an
//! unknown parameter, a truncated colour spec or a sequence whose final byte
//! never arrives degrades to "drop what we understood, keep the rest as text".
//! Never a panic, and never a swallowed line: this runs on the render path of
//! output we do not control.
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Span;
const ESC: u8 = 0x1b;
const BEL: u8 = 0x07;
/// SGR state in force at a point in the text. Colours are optional so that
/// "default foreground" (SGR 39) means *the caller's* base style rather than a
/// hardcoded white — the feed's dim/red/accent bases must keep governing
/// everything the output does not colour itself.
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
struct Sgr {
fg: Option<Color>,
bg: Option<Color>,
mods: Modifier,
}
impl Sgr {
/// This state layered on top of `base`.
fn style(self, base: Style) -> Style {
let mut s = base;
if let Some(c) = self.fg {
s = s.fg(c);
}
if let Some(c) = self.bg {
s = s.bg(c);
}
s.add_modifier(self.mods)
}
}
/// SGR 30-37 / 40-47.
const BASIC: [Color; 8] = [
Color::Black,
Color::Red,
Color::Green,
Color::Yellow,
Color::Blue,
Color::Magenta,
Color::Cyan,
Color::Gray,
];
/// SGR 90-97 / 100-107.
const BRIGHT: [Color; 8] = [
Color::DarkGray,
Color::LightRed,
Color::LightGreen,
Color::LightYellow,
Color::LightBlue,
Color::LightMagenta,
Color::LightCyan,
Color::White,
];
/// Escape-free plain text for one visual row: sequences stripped, tabs expanded
/// to four spaces, every remaining control char (newlines included) dropped.
pub fn strip(text: &str) -> String {
join(runs(text, false))
}
/// Like [`strip`] but keeps `\n`, for block content whose newlines carry
/// structure (markdown) and is split into rows further downstream.
pub fn strip_multiline(text: &str) -> String {
join(runs(text, true))
}
/// One visual row rendered as styled spans: `base`, with each run's SGR applied
/// on top. Runs are already coalesced, so a line with no escapes yields exactly
/// one span (and an empty line yields none).
pub fn spans(text: &str, base: Style) -> Vec<Span<'static>> {
runs(text, false)
.into_iter()
.map(|(s, sgr)| Span::styled(s, sgr.style(base)))
.collect()
}
/// Escape-free text plus the attribute set in force at each *character* of it.
///
/// For the one caller that has to lay text out itself before it can style it:
/// the filled user-prompt blocks wrap and pad to an exact width, so they need
/// the attributes back *after* wrapping. Colours are deliberately not returned
/// — a filled block owns its own fg/bg (`ui::color_on` picks a foreground that
/// stays legible on the block's background under any terminal theme), and an
/// arbitrary ANSI colour from slash-command stdout would wreck that contrast.
pub fn plain_with_mods(text: &str) -> (String, Vec<Modifier>) {
let mut plain = String::new();
let mut mods = Vec::new();
for (run, sgr) in runs(text, false) {
mods.extend(std::iter::repeat_n(sgr.mods, run.chars().count()));
plain.push_str(&run);
}
(plain, mods)
}
/// Concatenate runs back into plain text, reusing the first run's buffer. Text
/// with no escapes in it — the overwhelming majority, and this sits on the feed
/// render path — is exactly one run, so it costs no copy at all.
fn join(runs: Vec<(String, Sgr)>) -> String {
let mut it = runs.into_iter().map(|(s, _)| s);
let Some(mut first) = it.next() else {
return String::new();
};
for s in it {
first.push_str(&s);
}
first
}
/// Split `text` into escape-free runs, each paired with the SGR state that
/// applies across it. Text is sanitized as it is collected (tabs expanded,
/// other control chars dropped; `\n` survives only when `keep_newlines`) —
/// a literal tab reaching ratatui becomes a `\t` cell symbol that the terminal
/// renders by jumping to the next tab stop, scattering the row.
fn runs(text: &str, keep_newlines: bool) -> Vec<(String, Sgr)> {
let b = text.as_bytes();
let mut out: Vec<(String, Sgr)> = Vec::new();
let mut buf = String::new();
let mut state = Sgr::default();
let mut i = 0;
while i < b.len() {
if b[i] == ESC {
let (next, sgr) = escape_at(text, i);
if let Some(params) = sgr {
let mut new = state;
apply_sgr(&mut new, params);
if new != state {
if !buf.is_empty() {
out.push((std::mem::take(&mut buf), state));
}
state = new;
}
}
i = next;
continue;
}
// ESC is ASCII, so it can never sit inside a multi-byte sequence:
// `i` is always on a char boundary here.
let Some(c) = text[i..].chars().next() else { break };
i += c.len_utf8();
match c {
'\t' => buf.push_str(" "),
'\n' if keep_newlines => buf.push('\n'),
c if c.is_control() => {}
c => buf.push(c),
}
}
if !buf.is_empty() {
out.push((buf, state));
}
out
}
/// Consume the escape sequence starting at `i` (where `text` holds an ESC).
/// Returns the byte index just past it and, for a CSI ending in `m`, that
/// sequence's parameter string.
///
/// Everything else is stripped with no styling: other CSI finals (cursor moves,
/// erases), OSC/DCS/SOS/PM/APC strings, nF escapes (charset designation) and
/// two-char escapes. Damage from
/// malformed input is bounded — a CSI whose final byte never arrives consumes
/// only the parameter bytes it saw, and a string sequence missing its
/// terminator stops at a newline, so at most one line is lost rather than the
/// whole remaining text.
fn escape_at(text: &str, i: usize) -> (usize, Option<&str>) {
let b = text.as_bytes();
match b.get(i + 1) {
// Lone ESC at the very end of the text.
None => (i + 1, None),
Some(b'[') => {
// CSI: parameter bytes 0x30-0x3f, intermediates 0x20-0x2f, final
// byte 0x40-0x7e.
let mut j = i + 2;
while j < b.len() && (0x30..=0x3f).contains(&b[j]) {
j += 1;
}
let params = &text[i + 2..j];
while j < b.len() && (0x20..=0x2f).contains(&b[j]) {
j += 1;
}
match b.get(j) {
Some(&f) if (0x40..=0x7e).contains(&f) => (j + 1, (f == b'm').then_some(params)),
// No valid final byte (end of text, or a UTF-8 lead byte):
// the sequence never terminated. Stop here so the rest of the
// line still reaches the reader.
_ => (j, None),
}
}
// OSC / DCS / SOS / PM / APC: a string run terminated by BEL or ST.
Some(&b']' | b'P' | b'X' | b'^' | b'_') => {
let mut j = i + 2;
while j < b.len() {
match b[j] {
BEL => return (j + 1, None),
ESC if b.get(j + 1) == Some(&b'\\') => return (j + 2, None),
// No terminator on this line: give up rather than eat the
// rest of the text (these bytes are all ASCII, so `j` is
// on a char boundary).
b'\n' => return (j, None),
_ => j += 1,
}
}
(b.len(), None)
}
// nF escape (`ESC I… F`): intermediate bytes 0x20-0x2f, then a final
// 0x30-0x7e. Charset designation lives here — `ESC ( B` (G0 = ASCII),
// which rustfmt and `git diff` emit after *every* colour reset
// (`\x1b(B\x1b[m`). Consumed as a two-char escape it left a literal `B`
// at the head of each coloured run.
Some(&c) if (0x20..=0x2f).contains(&c) => {
let mut j = i + 2;
while j < b.len() && (0x20..=0x2f).contains(&b[j]) {
j += 1;
}
match b.get(j) {
Some(&f) if (0x30..=0x7e).contains(&f) => (j + 1, None),
// Never terminated (end of text, or a UTF-8 lead byte): stop
// here, exactly as the CSI arm does.
_ => (j, None),
}
}
// Two-char escape (`ESC c`); in malformed input `c` may be multi-byte.
Some(_) => {
let n = text[i + 1..].chars().next().map_or(1, char::len_utf8);
(i + 1 + n, None)
}
}
}
/// Fold one SGR sequence's parameters into `state`. Unknown parameters are
/// skipped individually so the ones around them still take effect.
fn apply_sgr(state: &mut Sgr, params: &str) {
// An omitted parameter means 0 (ECMA-48), so a bare `ESC [ m` is a reset.
// An unparseable one stays `None` and is skipped without stopping the run.
let vals: Vec<Option<u16>> = params
.split(';')
.map(|p| if p.is_empty() { Some(0) } else { p.parse().ok() })
.collect();
let mut i = 0;
while i < vals.len() {
let Some(v) = vals[i] else {
i += 1;
continue;
};
let mut step = 1;
match v {
0 => *state = Sgr::default(),
1 => state.mods.insert(Modifier::BOLD),
2 => state.mods.insert(Modifier::DIM),
3 => state.mods.insert(Modifier::ITALIC),
4 => state.mods.insert(Modifier::UNDERLINED),
7 => state.mods.insert(Modifier::REVERSED),
21 => state.mods.remove(Modifier::BOLD),
// 22 turns off bold *and* dim (they share an "intensity" axis).
22 => state.mods.remove(Modifier::BOLD | Modifier::DIM),
23 => state.mods.remove(Modifier::ITALIC),
24 => state.mods.remove(Modifier::UNDERLINED),
27 => state.mods.remove(Modifier::REVERSED),
30..=37 => state.fg = Some(BASIC[usize::from(v - 30)]),
38 => {
let (c, n) = extended(&vals, i + 1);
if c.is_some() {
state.fg = c;
}
step = n + 1;
}
39 => state.fg = None,
40..=47 => state.bg = Some(BASIC[usize::from(v - 40)]),
48 => {
let (c, n) = extended(&vals, i + 1);
if c.is_some() {
state.bg = c;
}
step = n + 1;
}
49 => state.bg = None,
90..=97 => state.fg = Some(BRIGHT[usize::from(v - 90)]),
100..=107 => state.bg = Some(BRIGHT[usize::from(v - 100)]),
_ => {}
}
i += step;
}
}
/// Decode the sub-parameters of a `38`/`48` extended colour, starting at the
/// `5` (indexed) or `2` (truecolor) selector. Returns the colour and how many
/// parameters the whole spec occupies — a truncated or out-of-range spec yields
/// no colour but still reports its width, so the parameters after it survive.
fn extended(vals: &[Option<u16>], i: usize) -> (Option<Color>, usize) {
let at = |k: usize| {
vals.get(i + k)
.copied()
.flatten()
.and_then(|n| u8::try_from(n).ok())
};
match vals.get(i).copied().flatten() {
Some(5) => (at(1).map(Color::Indexed), 2),
Some(2) => match (at(1), at(2), at(3)) {
(Some(r), Some(g), Some(b)) => (Some(Color::Rgb(r, g, b)), 4),
_ => (None, 4),
},
_ => (None, 1),
}
}
#[cfg(test)]
mod tests {
use super::{plain_with_mods, spans, strip, strip_multiline};
use ratatui::style::{Color, Modifier, Style};
/// Flatten spans to `(text, fg, modifiers)` for terse assertions.
fn parts(text: &str) -> Vec<(String, Option<Color>, Modifier)> {
spans(text, Style::default())
.into_iter()
.map(|s| (s.content.into_owned(), s.style.fg, s.style.add_modifier))
.collect()
}
/// The motivating case: a `/model` slash-command result. The markers must
/// be gone from the text and `Sonnet 5` must come out actually bold.
#[test]
fn model_command_output_renders_bold_with_markers_gone() {
let raw = "Set model to \u{1b}[1mSonnet 5\u{1b}[22m and saved as your default\u{1b}[2m\u{1b}[22m";
assert_eq!(
strip(raw),
"Set model to Sonnet 5 and saved as your default"
);
let p = parts(raw);
assert_eq!(p.len(), 3, "plain / bold / plain: {p:?}");
assert_eq!(p[0].0, "Set model to ");
assert!(!p[0].2.contains(Modifier::BOLD));
assert_eq!(p[1].0, "Sonnet 5");
assert!(p[1].2.contains(Modifier::BOLD), "bold between 1m and 22m");
assert_eq!(p[2].0, " and saved as your default");
// `2m` then `22m` cancel out: the tail is unstyled, not left dim.
assert!(!p[2].2.intersects(Modifier::BOLD | Modifier::DIM));
// No stray `[1m` / `[22m` anywhere in the rendered text.
for (t, _, _) in &p {
assert!(!t.contains('['), "escape leaked as literal text: {t:?}");
}
}
/// Colourised diff output (`cargo`, rustfmt) — the other everyday source.
#[test]
fn basic_and_bright_colours_map_to_ratatui() {
let p = parts("\u{1b}[31m- removed");
assert_eq!(p.len(), 1);
assert_eq!(p[0].0, "- removed");
assert_eq!(p[0].1, Some(Color::Red));
// Bright foreground, background, and the 39/49 defaults.
assert_eq!(parts("\u{1b}[92mok")[0].1, Some(Color::LightGreen));
assert_eq!(
spans("\u{1b}[41mhot", Style::default())[0].style.bg,
Some(Color::Red)
);
// 39 returns to "whatever the caller's base says", i.e. unset.
assert_eq!(parts("\u{1b}[31ma\u{1b}[39mb")[1].1, None);
}
/// The base style shows through wherever the output sets nothing itself,
/// and only the properties SGR names are overridden.
#[test]
fn base_style_survives_underneath() {
let base = Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC);
let out = spans("plain \u{1b}[31mred", base);
assert_eq!(out[0].style.fg, Some(Color::DarkGray));
assert_eq!(out[1].style.fg, Some(Color::Red));
// The base's italic rides along on both runs.
assert!(out[0].style.add_modifier.contains(Modifier::ITALIC));
assert!(out[1].style.add_modifier.contains(Modifier::ITALIC));
}
#[test]
fn extended_colours_indexed_and_truecolor() {
let p = parts("\u{1b}[38;5;208mX");
assert_eq!(p[0].0, "X");
assert_eq!(p[0].1, Some(Color::Indexed(208)));
let p = parts("\u{1b}[38;2;10;20;30mX");
assert_eq!(p[0].1, Some(Color::Rgb(10, 20, 30)));
// Background forms of both.
let bg = |t: &str| spans(t, Style::default())[0].style.bg;
assert_eq!(bg("\u{1b}[48;5;17mX"), Some(Color::Indexed(17)));
assert_eq!(bg("\u{1b}[48;2;1;2;3mX"), Some(Color::Rgb(1, 2, 3)));
// A 256-colour spec inside a longer run: the parameters after the
// extended colour still apply.
let p = parts("\u{1b}[1;38;5;208;4mX");
assert_eq!(p[0].1, Some(Color::Indexed(208)));
assert!(p[0].2.contains(Modifier::BOLD | Modifier::UNDERLINED));
}
/// OSC (window title, OSC 8 hyperlinks, OSC 52 clipboard) carries no
/// styling: strip the whole sequence, keep the text around it.
#[test]
fn osc_sequences_are_stripped_entirely() {
// BEL-terminated.
assert_eq!(strip("a\u{1b}]0;my title\u{7}b"), "ab");
// ST-terminated (`ESC \`).
assert_eq!(strip("a\u{1b}]52;c;Zm9v\u{1b}\\b"), "ab");
// OSC 8 hyperlink wrapper around visible text.
assert_eq!(
strip("\u{1b}]8;;https://x/\u{7}link\u{1b}]8;;\u{7}"),
"link"
);
// Non-SGR CSI (cursor move, erase) and a two-char escape.
assert_eq!(strip("a\u{1b}[2Kb\u{1b}[10;5Hc\u{1b}=d"), "abcd");
}
/// Real `cargo fmt` output: each coloured run is closed with `ESC ( B`
/// (G0 = ASCII) *before* the SGR reset. Consumed as a two-char escape that
/// left the `B` behind, so every diff line in the feed read `B+ added line`.
#[test]
fn charset_designation_leaves_no_stray_letter() {
let line = "\u{1b}[32m+ break;\u{1b}(B\u{1b}[m";
assert_eq!(strip(line), "+ break;");
let p = parts(line);
assert_eq!(p.len(), 1, "one green run, no stray letter: {p:?}");
assert_eq!(p[0].1, Some(Color::Green));
// Other nF escapes: line-drawing G1, `ESC # 8` (DECALN), `ESC % G`.
assert_eq!(strip("a\u{1b})0b\u{1b}#8c\u{1b}%Gd"), "abcd");
// Never terminated: the text after it still reaches the reader.
assert_eq!(strip("keep \u{1b}("), "keep ");
assert_eq!(strip("keep \u{1b}(\u{e6}"), "keep \u{e6}");
}
/// Malformed input must not panic and must not eat the visible text.
#[test]
fn malformed_escapes_keep_the_rest_of_the_line() {
// Unterminated CSI at end of text.
assert_eq!(strip("keep \u{1b}[1"), "keep ");
assert_eq!(strip("keep \u{1b}["), "keep ");
assert_eq!(strip("keep \u{1b}"), "keep ");
// Parameters with no final byte, followed by real (multi-byte) text.
assert_eq!(strip("a\u{1b}[1;2é"), "");
// Truncated extended colours: no colour, but the text survives.
assert_eq!(parts("\u{1b}[38;5mZ")[0].0, "Z");
assert_eq!(parts("\u{1b}[38;5mZ")[0].1, None);
assert_eq!(parts("\u{1b}[38;2;10;20mZ")[0].1, None);
assert_eq!(parts("\u{1b}[38;9;7mZ")[0].0, "Z");
// Out-of-range and unknown parameters are skipped one at a time.
assert_eq!(parts("\u{1b}[999;1mZ")[0].0, "Z");
assert!(parts("\u{1b}[999;1mZ")[0].2.contains(Modifier::BOLD));
assert_eq!(parts("\u{1b}[38;5;300mZ")[0].1, None);
// An unterminated OSC gives up at the newline instead of swallowing on.
assert_eq!(strip_multiline("\u{1b}]0;no end\nnext line"), "\nnext line");
}
/// The sanitizing half of the old `sanitize`/`sanitize_md` pair is intact.
#[test]
fn tabs_expand_and_newlines_follow_the_mode() {
assert_eq!(strip("a\tb"), "a b");
assert_eq!(strip_multiline("a\tb\nc"), "a b\nc");
// Newlines are a control char for the single-row form, kept for blocks.
assert_eq!(strip("x\r\ny"), "xy");
assert_eq!(strip_multiline("x\r\ny"), "x\ny");
// Styling survives across a kept newline.
assert_eq!(strip_multiline("\u{1b}[1ma\nb"), "a\nb");
}
/// One modifier per character of the plain text, colours dropped — what
/// the filled user-prompt blocks need to restyle after wrapping.
#[test]
fn plain_with_mods_is_char_aligned_and_colourless() {
let (plain, mods) = plain_with_mods("ab\u{1b}[1;31mCD\u{1b}[22mef");
assert_eq!(plain, "abCDef");
assert_eq!(mods.len(), plain.chars().count());
assert!(!mods[1].contains(Modifier::BOLD));
assert!(mods[2].contains(Modifier::BOLD));
assert!(mods[3].contains(Modifier::BOLD));
assert!(!mods[4].contains(Modifier::BOLD));
// Multi-byte text stays aligned by *character*, not by byte.
let (plain, mods) = plain_with_mods("é\u{1b}[3mü");
assert_eq!(plain, "éü");
assert_eq!(mods.len(), 2);
assert!(mods[1].contains(Modifier::ITALIC));
// A tab expands to four characters, all carrying its modifier.
let (plain, mods) = plain_with_mods("\u{1b}[4m\tx");
assert_eq!(plain, " x");
assert_eq!(mods.len(), 5);
assert!(mods.iter().all(|m| m.contains(Modifier::UNDERLINED)));
}
#[test]
fn empty_and_escape_only_input_is_harmless() {
assert_eq!(strip(""), "");
assert_eq!(strip("\u{1b}[0m"), "");
assert!(spans("", Style::default()).is_empty());
assert!(spans("\u{1b}[1m\u{1b}[0m", Style::default()).is_empty());
assert_eq!(plain_with_mods("").0, "");
}
}

2972
src/app.rs

File diff suppressed because it is too large Load Diff

297
src/keymap.rs Normal file
View File

@@ -0,0 +1,297 @@
//! The one binding table.
//!
//! Every app key lives here, once: the which-key popup renders this table, the
//! footer hint summarises it, and `ui::run_act` dispatches it. A binding that
//! is not in the table cannot be pressed, and one that is in it is documented
//! for free — which is what stops the keymap drifting apart again.
//!
//! # The rule the whole model rests on
//!
//! **Unprefixed keys belong to the embedded `claude`. Always.** There is no
//! focus model, no ctrl-↑/ctrl-↓ dance and no "is this key mine?" question:
//! `q`, `j` and Esc reach Claude Code because nothing else can claim them.
//! Everything cloak owns sits behind [`Prefix`], tmux-style. Two exceptions,
//! both of which a real terminal also keeps for itself rather than forwarding:
//! the **wheel** and **shift**+PgUp/PgDn.
//!
//! Esc is the one key with a rule of its own, and it is a rule about
//! reachability, not about modes: *Esc closes the topmost overlay; with nothing
//! open it goes to the child.* Claude Code uses Esc to interrupt and Esc-Esc to
//! rewind, so eating it unconditionally would break both. View state (a filter
//! set, the lane the feed shows) is deliberately **not** escapable — it is a
//! setting, not a mode, and resetting it on a stray Esc would be a surprise
//! rather than a rescue.
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
/// Something a key does. `Copy`, so dispatch can match on it after the table
/// borrow ends.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Act {
/// Session picker overlay (also the home of the turn tree and `b`ranching).
Sessions,
/// Stream picker overlay: the main chain, every subagent, every nested
/// server-tool call. Picking one points the feed at that lane.
Streams,
/// Model picker → spawn a brand-new `claude --session-id …`.
NewSession,
/// Attach the pane to the most recent past session (`claude -c`).
Continue,
/// Entry-kind filter strip.
Filter,
/// Incremental search over the displayed lane.
Search,
/// Jump the feed to the next / previous user prompt. Repeatable: the menu
/// stays open so `]]]` walks.
NextPrompt,
PrevPrompt,
/// Pane fullscreen. Esc still reaches the child there — it is nothing *but*
/// the pane, so nothing is covering it.
ZoomPane,
/// Hide the pane, feed takes the screen. This *does* cover the pane, so Esc
/// leaves it.
ZoomFeed,
/// Back to the live main chain of the pane's session, tailing it. Undoes
/// every kind of pinning at once — a picked session, a picked lane, and a
/// scroll position parked by a search or a prompt jump.
FollowLive,
Reload,
Quit,
}
impl Act {
/// Whether the popup marks this entry as leading somewhere — an overlay
/// that takes over input. Purely cosmetic (`▸`).
pub fn opens(self) -> bool {
matches!(
self,
Act::Sessions | Act::Streams | Act::NewSession | Act::Filter | Act::Search
)
}
/// Repeatable actions keep the menu up, so the key can be pressed again
/// without re-pressing the prefix. Everything else closes it.
pub fn sticky(self) -> bool {
matches!(self, Act::NextPrompt | Act::PrevPrompt)
}
}
#[derive(Debug)]
pub struct Bind {
pub key: char,
pub label: &'static str,
pub act: Act,
}
/// A menu level. There is one today (`ROOT`); the type exists because the
/// popup renders *a* level and `EmbedUi::menu` holds the open one, not because
/// nesting is planned. A submenu earns its place when a group of keys is both
/// large and rarely used, and no group is either right now.
#[derive(Debug)]
pub struct Menu {
pub title: &'static str,
pub binds: &'static [Bind],
}
impl Menu {
pub fn find(&self, c: char) -> Option<&Bind> {
self.binds.iter().find(|b| b.key == c)
}
}
/// The root menu, in reading order. The popup lays it out in columns.
pub static ROOT: Menu = Menu {
title: "",
binds: &[
Bind { key: 's', label: "sessions", act: Act::Sessions },
Bind { key: 'a', label: "streams", act: Act::Streams },
Bind { key: 'n', label: "new", act: Act::NewSession },
Bind { key: 'c', label: "continue", act: Act::Continue },
Bind { key: 'f', label: "filter", act: Act::Filter },
Bind { key: '/', label: "search", act: Act::Search },
Bind { key: ']', label: "next prompt", act: Act::NextPrompt },
Bind { key: '[', label: "prev prompt", act: Act::PrevPrompt },
Bind { key: '.', label: "follow live", act: Act::FollowLive },
Bind { key: 'z', label: "zoom pane", act: Act::ZoomPane },
Bind { key: 'Z', label: "zoom feed", act: Act::ZoomFeed },
Bind { key: 'r', label: "reload", act: Act::Reload },
Bind { key: 'q', label: "quit", act: Act::Quit },
],
};
// ---------------------------------------------------------------------------
// The prefix
// ---------------------------------------------------------------------------
/// The one key that opens the menu. Configurable because the *only* hard
/// requirement is that the embedded Claude Code does not want it, and that is
/// a property of the child's version, not of ours — so it must be changeable
/// without a rebuild. `CT_PREFIX=ctrl-b`, `CT_PREFIX=ctrl-]`, `CT_PREFIX=f1`.
///
/// Default `ctrl-space`. Terminals disagree about what ctrl-space *is* on the
/// wire (NUL, ctrl-`@`, or a real ctrl-modified space), so that one spelling
/// matches all three — see `matches`. `CT_DEBUG_KEYS=1` shows what actually
/// arrives when a terminal delivers none of them.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Prefix {
code: KeyCode,
mods: KeyModifiers,
/// True for the ctrl-space default, which needs the three-way match.
ctrl_space: bool,
pub label: String,
}
impl Default for Prefix {
fn default() -> Self {
Self::parse("ctrl-space").expect("the default prefix parses")
}
}
impl Prefix {
/// Read `CT_PREFIX`, falling back to the default on an unset or
/// unparseable value (a typo must not leave the app with no menu key).
pub fn from_env() -> Self {
std::env::var("CT_PREFIX")
.ok()
.and_then(|s| Self::parse(&s))
.unwrap_or_default()
}
pub fn parse(spec: &str) -> Option<Self> {
let spec = spec.trim();
let mut mods = KeyModifiers::NONE;
let mut rest = spec;
loop {
let lower = rest.to_ascii_lowercase();
let (m, tail) = if let Some(t) = lower.strip_prefix("ctrl-") {
(KeyModifiers::CONTROL, t.len())
} else if let Some(t) = lower.strip_prefix("shift-") {
(KeyModifiers::SHIFT, t.len())
} else if let Some(t) = lower.strip_prefix("alt-") {
(KeyModifiers::ALT, t.len())
} else {
break;
};
mods |= m;
rest = &rest[rest.len() - tail..];
}
let low = rest.to_ascii_lowercase();
let code = match low.as_str() {
"space" => KeyCode::Char(' '),
"tab" => KeyCode::Tab,
"esc" => KeyCode::Esc,
f if f.starts_with('f') && f[1..].parse::<u8>().is_ok() => {
KeyCode::F(f[1..].parse().ok()?)
}
_ => {
let mut it = rest.chars();
let c = it.next()?;
if it.next().is_some() {
return None;
}
KeyCode::Char(c.to_ascii_lowercase())
}
};
let ctrl_space = code == KeyCode::Char(' ') && mods.contains(KeyModifiers::CONTROL);
Some(Self {
code,
mods,
ctrl_space,
label: pretty(mods, code),
})
}
/// Does this event open the menu?
///
/// ctrl-space is three events depending on the terminal: `Char(' ')` with
/// CONTROL, `Char('@')` with CONTROL (the NUL byte decoded as its caret
/// spelling), and a bare `Null`. All three mean the same keypress, so all
/// three count.
pub fn matches(&self, k: &KeyEvent) -> bool {
if self.ctrl_space {
let ctrl = k.modifiers.contains(KeyModifiers::CONTROL);
return k.code == KeyCode::Null
|| (ctrl && matches!(k.code, KeyCode::Char(' ') | KeyCode::Char('@')));
}
// Compare only the modifiers the spec named: terminals add SHIFT of
// their own accord for capitals and for some ctrl combinations.
let want = self.mods & (KeyModifiers::CONTROL | KeyModifiers::ALT);
let got = k.modifiers & (KeyModifiers::CONTROL | KeyModifiers::ALT);
let code = match k.code {
KeyCode::Char(c) => KeyCode::Char(c.to_ascii_lowercase()),
other => other,
};
code == self.code && got == want
}
}
fn pretty(mods: KeyModifiers, code: KeyCode) -> String {
let mut s = String::new();
if mods.contains(KeyModifiers::CONTROL) {
s.push('^');
}
if mods.contains(KeyModifiers::ALT) {
s.push_str("alt-");
}
match code {
KeyCode::Char(' ') => s.push_str("space"),
KeyCode::Char(c) => s.push(c),
KeyCode::Tab => s.push_str("tab"),
KeyCode::Esc => s.push_str("esc"),
KeyCode::F(n) => s.push_str(&format!("F{n}")),
other => s.push_str(&format!("{other:?}")),
}
s
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::crossterm::event::KeyEventKind;
fn ev(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
KeyEvent {
code,
modifiers: mods,
kind: KeyEventKind::Press,
state: ratatui::crossterm::event::KeyEventState::NONE,
}
}
/// The default has to survive all three ways a terminal spells ctrl-space,
/// because we cannot know which one the user's terminal picks.
#[test]
fn ctrl_space_matches_every_spelling_terminals_use() {
let p = Prefix::default();
assert!(p.matches(&ev(KeyCode::Char(' '), KeyModifiers::CONTROL)));
assert!(p.matches(&ev(KeyCode::Char('@'), KeyModifiers::CONTROL)));
assert!(p.matches(&ev(KeyCode::Null, KeyModifiers::NONE)));
assert!(!p.matches(&ev(KeyCode::Char(' '), KeyModifiers::NONE)), "plain space is the child's");
assert_eq!(p.label, "^space");
}
#[test]
fn prefix_specs_parse_and_reject() {
let p = Prefix::parse("ctrl-b").unwrap();
assert!(p.matches(&ev(KeyCode::Char('b'), KeyModifiers::CONTROL)));
assert!(!p.matches(&ev(KeyCode::Char('b'), KeyModifiers::NONE)));
assert_eq!(p.label, "^b");
assert!(Prefix::parse("f1").unwrap().matches(&ev(KeyCode::F(1), KeyModifiers::NONE)));
assert!(Prefix::parse("ctrl-]").unwrap().matches(&ev(KeyCode::Char(']'), KeyModifiers::CONTROL)));
assert!(Prefix::parse("").is_none());
assert!(Prefix::parse("ctrl-nope").is_none());
}
/// Every key in the table is unique per menu, or one of them is dead.
#[test]
fn no_menu_binds_a_key_twice() {
fn check(m: &Menu) {
let mut seen = Vec::new();
for b in m.binds {
assert!(!seen.contains(&b.key), "{} binds {:?} twice", m.title, b.key);
seen.push(b.key);
}
}
check(&ROOT);
}
}

View File

@@ -1,18 +1,42 @@
mod ansi;
mod app;
mod keymap;
mod markdown;
mod proxy;
mod reload;
mod sessions;
mod sse;
mod term;
mod ui;
use app::App;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
fn main() -> anyhow::Result<()> {
let headless = std::env::args().any(|a| a == "--headless");
let app = Arc::new(Mutex::new(App::new()));
// A hot reload execs the new binary into this same process (see
// reload.rs), so a handoff here means "we are the new code and everything
// the old code owned is still open": the listener socket, the pty of the
// embedded pane, and a snapshot of the feed.
let handoff = reload::take_handoff();
let mut state = App::new();
let mut pane = None;
let mut pane_ui = reload::PaneState::default();
let reloaded = handoff.is_some();
if let Some(h) = handoff {
state.reload_gen = h.generation + 1;
state.listener_fd = h.listener_fd;
// Best-effort: a snapshot the new types no longer fit costs the feed,
// never the port and never the pane (they are plain numbers above).
reload::restore(&mut state, h.app);
pane = h.pane;
pane_ui = h.pane_ui;
}
let app = Arc::new(Mutex::new(state));
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
@@ -20,22 +44,34 @@ fn main() -> anyhow::Result<()> {
// Bind up front so every instance gets its own port: CT_PORT pins it
// (hard error if taken), otherwise prefer 8484 and fall back to an
// OS-assigned free port so multiple instances can coexist.
let listener = rt.block_on(async {
match std::env::var("CT_PORT").ok().and_then(|p| p.parse::<u16>().ok()) {
Some(p) => tokio::net::TcpListener::bind(("127.0.0.1", p)).await,
None => match tokio::net::TcpListener::bind(("127.0.0.1", 8484u16)).await {
Ok(l) => Ok(l),
Err(_) => tokio::net::TcpListener::bind(("127.0.0.1", 0u16)).await,
},
}
})?;
//
// A reload skips all of that and adopts the socket the previous image was
// already accepting on, so the port never closes and Claude Code never
// sees a refused connection.
let inherited = app::lock_app(&app).listener_fd;
let listener = rt.block_on(open_listener(inherited))?;
let port = listener.local_addr()?.port();
// Keep a dup for the *next* reload: the graceful shutdown drops axum's own
// listener, and this fd is what holds the socket open across the exec.
let keep_fd = reload::dup_listener(&listener)?;
let (drain_tx, drain_rx) = tokio::sync::oneshot::channel();
let drained = Arc::new(AtomicBool::new(false));
{
let mut a = app::lock_app(&app);
a.listener_fd = keep_fd;
a.drain_tx = Some(drain_tx);
a.drained = drained.clone();
}
let papp = app.clone();
let proxy_handle = rt.spawn(async move {
if let Err(e) = proxy::run(papp.clone(), listener).await {
if let Err(e) = proxy::run(papp.clone(), listener, drain_rx).await {
app::lock_app(&papp).status = format!("proxy failed: {e}");
}
// `serve` has returned, so every connection task is finished. This is
// the reload's go-ahead.
drained.store(true, Ordering::SeqCst);
});
if headless {
@@ -43,8 +79,26 @@ fn main() -> anyhow::Result<()> {
rt.block_on(proxy_handle)?;
Ok(())
} else {
let r = ui::run(app, port);
let r = ui::run(app, port, pane, pane_ui, reloaded);
rt.shutdown_background();
r
}
}
/// The proxy's accept socket: adopted from the previous image after a hot
/// reload, freshly bound otherwise.
async fn open_listener(inherited: std::os::fd::RawFd) -> anyhow::Result<tokio::net::TcpListener> {
if let Ok(std) = reload::adopt_listener(inherited)
&& let Ok(l) = tokio::net::TcpListener::from_std(std)
{
return Ok(l);
}
let l = match std::env::var("CT_PORT").ok().and_then(|p| p.parse::<u16>().ok()) {
Some(p) => tokio::net::TcpListener::bind(("127.0.0.1", p)).await?,
None => match tokio::net::TcpListener::bind(("127.0.0.1", 8484u16)).await {
Ok(l) => l,
Err(_) => tokio::net::TcpListener::bind(("127.0.0.1", 0u16)).await?,
},
};
Ok(l)
}

View File

@@ -1,6 +1,6 @@
use crate::app::{
AgentTag, SharedApp, Tap, attach_tool_results, lock_app, record_long_context,
record_user_prompt,
AgentTag, ReqKind, SharedApp, Tap, attach_tool_results, classify_request,
label_server_tool_lane, lock_app, next_server_tool_id, record_user_prompt,
};
use crate::sse::SseParser;
use axum::Router;
@@ -23,13 +23,11 @@ const UPSTREAM: &str = "https://api.anthropic.com";
pub const AGENT_ID_HEADER: &str = "x-claude-code-agent-id";
pub const PARENT_AGENT_ID_HEADER: &str = "x-claude-code-parent-agent-id";
/// Claude Code asks for the **1M-context window** with a beta flag, not a
/// different model: `--model opus[1m]` sends `anthropic-beta: …,context-1m-…`
/// while plain `opus` does not, and both report the same `model` in the body
/// (and in the transcript). So this header is the only place the window is
/// observable — `App::resume_model` needs it to resume a session the way it ran.
pub const BETA_HEADER: &str = "anthropic-beta";
pub const LONG_CONTEXT_BETA: &str = "context-1m";
/// Most of a failed response we keep in order to name the error. An Anthropic
/// error body is a few hundred bytes; the cap exists so a pathological upstream
/// can't make the tee task grow without bound (the relay itself is unaffected
/// either way — it never waits on this).
const ERR_BODY_MAX: usize = 4096;
/// Upstream base URL; `CT_UPSTREAM` overrides for offline testing against a
/// fake server (the relay itself is identical either way).
@@ -64,13 +62,45 @@ struct Ctx {
app: SharedApp,
}
pub async fn run(app: SharedApp, listener: tokio::net::TcpListener) -> anyhow::Result<()> {
/// Serve until `drain` fires, then finish every in-flight response and return.
///
/// The drain signal is the hot-reload handshake (`reload.rs`): axum stops
/// accepting, closes idle keep-alive connections and lets streaming responses
/// run to completion, so the exec that follows can never truncate one. The
/// socket itself stays open the whole time — `App::listener_fd` holds a dup of
/// it — so connections Claude Code opens during the swap queue in the kernel
/// backlog and are served by the new image.
pub async fn run(
app: SharedApp,
listener: tokio::net::TcpListener,
drain: tokio::sync::oneshot::Receiver<()>,
) -> anyhow::Result<()> {
let client = reqwest::Client::builder().build()?;
let ctx = Ctx { client, app: app.clone() };
let router = Router::new().fallback(forward).with_state(ctx);
let port = listener.local_addr()?.port();
lock_app(&app).status = format!("proxy http://127.0.0.1:{port} → api.anthropic.com");
axum::serve(listener, router).await?;
{
// Written here rather than in `main` because this line is the last one
// to land at startup — a reload note set earlier would be overwritten
// by it. The port is the same one across a reload; the counter is the
// only visible sign that the code under it changed.
let mut a = lock_app(&app);
let note = match (a.reload_gen, a.reload_dropped) {
(0, _) => String::new(),
(n, 0) => format!(" · reload #{n}"),
(n, d) => format!(" · reload #{n} ({d} session(s) not restored)"),
};
a.status = format!("proxy http://127.0.0.1:{port} → api.anthropic.com{note}");
}
axum::serve(listener, router)
.with_graceful_shutdown(async move {
// A dropped sender (no reload was ever started) simply never
// resolves into a shutdown — `Err` means "hold the door open".
if drain.await.is_err() {
std::future::pending::<()>().await;
}
})
.await?;
Ok(())
}
@@ -136,9 +166,26 @@ async fn forward_inner(ctx: Ctx, req: Request) -> anyhow::Result<Response> {
.filter(|v| !v.is_empty())
.map(str::to_string)
};
let agent = AgentTag {
id: header(AGENT_ID_HEADER),
parent: header(PARENT_AGENT_ID_HEADER),
// Turn start / nested server-tool call / side request. A non-empty
// `tools` array alone used to mean "turn start", which misfiled
// `WebSearch`'s nested hosted-tool request as a main-chain turn.
let kind = classify_request(&v);
let agent = match kind {
// A nested server-tool call carries no `x-claude-code-agent-id`, so
// it gets a synthetic key of ours (never hex, so it can never
// collide with a real agent id) and therefore its own lane: it is
// readable in the `A` popup like a subagent and stays out of the
// main feed. If Claude Code ever *does* stamp the caller's agent id
// on one of these, that agent is this lane's parent — the nested
// call is not the agent itself.
ReqKind::ServerTool => AgentTag {
id: Some(next_server_tool_id()),
parent: header(AGENT_ID_HEADER),
},
_ => AgentTag {
id: header(AGENT_ID_HEADER),
parent: header(PARENT_AGENT_ID_HEADER),
},
};
// Tool results ride along in the request body; surface them
// on the tool entries from the previous turn.
@@ -146,17 +193,10 @@ async fn forward_inner(ctx: Ctx, req: Request) -> anyhow::Result<Response> {
let t = Tap::new(ctx.app.clone(), key.clone(), model, pane_token, &agent);
// After Tap::new: the session (and the lane) must exist for the entry
// to land.
record_user_prompt(&ctx.app, &key, t.lane(), &v);
// Context window of the *main chain*, from this request's betas. Only a
// turn-starting main-chain request counts: a side/title call runs haiku
// without the flag and a subagent runs its own model, so either would
// report a window that is not the session's.
if t.lane() == crate::app::MAIN_LANE
&& v.get("tools").and_then(Value::as_array).is_some_and(|t| !t.is_empty())
{
let long = header(BETA_HEADER).is_some_and(|b| b.contains(LONG_CONTEXT_BETA));
record_long_context(&ctx.app, &key, long);
if kind == ReqKind::ServerTool {
label_server_tool_lane(&ctx.app, &key, t.lane(), &v);
}
record_user_prompt(&ctx.app, &key, t.lane(), &v);
tap = Some(t);
}
@@ -177,7 +217,8 @@ async fn forward_inner(ctx: Ctx, req: Request) -> anyhow::Result<Response> {
}
let resp = rb.body(body_bytes).send().await?;
let mut builder = Response::builder().status(resp.status().as_u16());
let status = resp.status();
let mut builder = Response::builder().status(status.as_u16());
let is_sse = resp
.headers()
.get("content-type")
@@ -202,8 +243,8 @@ async fn forward_inner(ctx: Ctx, req: Request) -> anyhow::Result<Response> {
// (drop-on-full, best-effort per the tee invariant); the tap task
// drains that channel independently, so the forwarded byte stream is
// never held back by the app mutex.
let body = match (is_sse, tap) {
(true, Some(mut tap)) => {
let body = match tap {
Some(mut tap) if is_sse && status.is_success() => {
let (tx, mut rx) = tokio::sync::mpsc::channel::<Vec<u8>>(64);
tokio::spawn(async move {
let mut parser = SseParser::default();
@@ -225,6 +266,36 @@ async fn forward_inner(ctx: Ctx, req: Request) -> anyhow::Result<Response> {
});
Body::from_stream(stream)
}
// Upstream refused (429 / 500 / 529 …). The body is JSON, not SSE, so
// the tap used to close with nothing in the feed: the turn just stopped,
// with no status and no message. Surfaced through the *same* best-effort
// tee as SSE — never `resp.bytes().await`, which would buffer the
// response and break latency-neutral pass-through. Chunks are cloned
// with `try_send` into a bounded channel (dropped on overflow), and a
// separate task keeps at most `ERR_BODY_MAX` of them.
Some(mut tap) if !status.is_success() => {
let (tx, mut rx) = tokio::sync::mpsc::channel::<Vec<u8>>(8);
let code = status.as_u16();
tokio::spawn(async move {
let mut buf: Vec<u8> = Vec::new();
while let Some(b) = rx.recv().await {
let room = ERR_BODY_MAX.saturating_sub(buf.len());
if room > 0 {
buf.extend_from_slice(&b[..b.len().min(room)]);
}
}
tap.record_http_error(code, &buf);
});
let stream = resp.bytes_stream().map(move |chunk| {
if let Ok(b) = &chunk {
let _ = tx.try_send(b.to_vec());
}
chunk
});
Body::from_stream(stream)
}
// A 2xx non-SSE response (`count_tokens`, …) stays untapped and
// silent — a documented MVP limit, not an error.
_ => Body::from_stream(resp.bytes_stream()),
};
Ok(builder.body(body)?)

630
src/reload.rs Normal file
View File

@@ -0,0 +1,630 @@
//! Hot reload: `execve` the binary on disk *into this process* instead of
//! restarting.
//!
//! This module builds nothing and watches nothing. You rebuild however you
//! normally would — `cargo build`, `cargo build --release`, a script — and then
//! press **ctrl-r** in each running instance to swap it onto the new binary.
//! Separating the two is the point: a build is your business, and a running
//! instance should not decide on its own when to become different code.
//!
//! `execve` replaces the program image but keeps the pid, the open file
//! descriptors and the child processes. That is the whole trick, and it is what
//! lets all three things the proxy cares about survive:
//!
//! - **the listener** — the accept socket is inherited by fd number, so the
//! port is never closed and never rebound. Claude Code keeps talking to the
//! same `127.0.0.1:<port>` across the swap;
//! - **the embedded `claude` pane** — still our child, still on the same pty
//! (`term::PtyHandoff` / `EmbeddedTerm::adopt`). It is never told anything; it
//! just gets a repaint;
//! - **the live feed** — sessions/entries/lanes travel in a JSON snapshot.
//!
//! Two rules keep it honest:
//!
//! 1. **Drain before the exec.** It destroys the tokio tasks relaying in-flight
//! responses, so the proxy is asked to stop accepting and finish what it has
//! first. The socket stays open throughout, so requests made during the swap
//! queue in the kernel backlog and are served by the new image.
//! 2. **The snapshot is advisory.** It is written by the *old* binary and read
//! by the *new* one, whose types may have just changed — the normal case
//! when the reason you rebuilt was editing `app.rs`. Every restore step is
//! best-effort: a snapshot that no longer fits costs the feed, never the
//! port and never the pane.
//!
//! Nothing here talks to the network, and nothing here runs a subprocess.
use anyhow::Context;
use crate::app::App;
use std::os::fd::{AsRawFd, FromRawFd, RawFd};
use std::path::{Path, PathBuf};
use std::time::Duration;
/// Env var pointing the new image at its snapshot file. Its presence is what
/// distinguishes "started by a reload" from "started by the user".
const HANDOFF_ENV: &str = "CT_RELOAD_HANDOFF";
// ---------------------------------------------------------------------------
// Relative-time serde for the `Instant` fields on Session/Lane
// ---------------------------------------------------------------------------
/// `Instant` has no absolute epoch, so it is snapshotted as "this many ms ago"
/// and rebuilt against the new image's clock. Idle/liveness logic
/// (`Lane::running`, `LANE_IDLE_MAX`) therefore reads the same before and after
/// a reload instead of every lane looking freshly active.
pub mod ms_ago {
use serde::{Deserialize, Deserializer, Serializer};
use std::time::{Duration, Instant};
pub fn serialize<S: Serializer>(v: &Instant, s: S) -> Result<S::Ok, S::Error> {
s.serialize_u64(v.elapsed().as_millis() as u64)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Instant, D::Error> {
let ms = u64::deserialize(d)?;
Ok(back(ms))
}
/// `checked_sub` because a monotonic clock that has not been running long
/// enough cannot represent the age — then "now" is the closest truth.
pub(crate) fn back(ms: u64) -> Instant {
let now = Instant::now();
now.checked_sub(Duration::from_millis(ms)).unwrap_or(now)
}
}
/// `ms_ago` for an `Option<Instant>`.
pub mod ms_ago_opt {
use serde::{Deserialize, Deserializer, Serializer};
use std::time::Instant;
pub fn serialize<S: Serializer>(v: &Option<Instant>, s: S) -> Result<S::Ok, S::Error> {
match v {
Some(i) => s.serialize_some(&(i.elapsed().as_millis() as u64)),
None => s.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Instant>, D::Error> {
Ok(Option::<u64>::deserialize(d)?.map(super::ms_ago::back))
}
}
// ---------------------------------------------------------------------------
// Reload status (owned by App, rendered in the status bar)
// ---------------------------------------------------------------------------
#[derive(Default, Clone, PartialEq)]
pub enum Status {
/// Nothing in progress. ctrl-r starts a reload from here.
#[default]
Idle,
/// The proxy is finishing its in-flight responses; the exec follows.
Draining(std::time::Instant),
/// The exec failed, so the old code is still running and still serving.
/// Purely informational — usually "you pressed ctrl-r mid-build".
Failed(String),
}
impl Status {
/// One-line status-bar rendering, or `None` when there is nothing to say.
pub fn note(&self, in_flight: usize) -> Option<String> {
match self {
Status::Idle => None,
Status::Draining(_) if in_flight > 0 => {
Some(format!("⟳ reloading · draining {in_flight} turn(s)…"))
}
Status::Draining(_) => Some("⟳ reloading…".into()),
Status::Failed(e) => Some(format!("⚠ reload failed: {e}")),
}
}
}
/// Cap on the graceful drain. A stuck upstream response must not pin the
/// reload forever; past this the exec happens anyway and that one response is
/// cut — the same outcome as no drain at all, just far less likely.
pub const DRAIN_MAX: Duration = Duration::from_secs(30);
// ---------------------------------------------------------------------------
// The snapshot handed across the exec
// ---------------------------------------------------------------------------
/// Written by the outgoing image, read by the incoming one. The fd numbers in
/// here are only meaningful because `keep_open` cleared their FD_CLOEXEC.
///
/// Read side only — the write side is `HandoffRef`, which borrows the live
/// state instead of moving it, so a failed exec leaves the running app whole.
#[derive(Default, serde::Deserialize)]
#[serde(default)]
pub struct Handoff {
/// Inherited accept socket. `-1` means "bind a new one" (should not happen).
pub listener_fd: RawFd,
/// The embedded pane, when there was a live one.
pub pane: Option<crate::term::PtyHandoff>,
/// The feed, **left unparsed on purpose**. Schema churn is the normal case
/// for a dev tool — the edit that triggered the reload is usually the one
/// that changed these types — and parsing it inline would let one renamed
/// field take the port and the pane down with the feed. It is decoded
/// separately, session by session, in `restore`.
pub app: serde_json::Value,
pub pane_ui: PaneState,
/// Reloads so far, for the status line.
pub generation: u32,
}
/// The part of `App` worth carrying over. Deliberately a separate struct rather
/// than `#[derive(Serialize)] on App`: popups, caches and lazily loaded disk
/// views are cheap to rebuild and would only add schema churn.
#[derive(Default, serde::Deserialize)]
#[serde(default)]
pub struct AppState {
/// One `Value` per session, decoded individually: a session that no longer
/// fits is skipped instead of discarding the whole feed.
pub sessions: Vec<serde_json::Value>,
/// Session the highlight was on. Preferred over `selected`: disk stubs are
/// rescanned asynchronously, so their indices are not stable across the
/// exec, but their uuids are.
pub selected_key: Option<String>,
pub selected: usize,
pub scroll: usize,
pub follow: bool,
pub filters: Vec<bool>,
pub show_sessions: bool,
pub embed_session: Option<String>,
pub embed_token: Option<String>,
}
/// The pane-related UI state that lives on `EmbedUi`, not on `App`.
#[derive(Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct PaneState {
pub visible: bool,
pub focused: bool,
pub fullscreen: bool,
/// That fullscreen was entered for an editor on the child's alternate
/// screen, so it is undone when the editor exits (`ui::sync_alt_screen`).
/// Carried because the editor usually outlives the reload.
pub alt_fullscreen: bool,
pub past_embeds: Vec<String>,
pub compact_inner: u16,
}
// ---------------------------------------------------------------------------
// Startup side: did a reload just hand us the process?
// ---------------------------------------------------------------------------
/// Read (and consume) the handoff this process was exec'd with. Returns `None`
/// for a normal start, and also for a snapshot that no longer parses — an
/// expected outcome when the edit that triggered the reload changed the state
/// types, and the reason this is `Option` rather than `Result`.
pub fn take_handoff() -> Option<Handoff> {
let path = std::env::var_os(HANDOFF_ENV)?;
// Consume it either way: a stale file must never be picked up twice.
// Sound here and nowhere else — `main` has not spawned a thread yet.
unsafe { std::env::remove_var(HANDOFF_ENV) };
let raw = std::fs::read(&path).ok();
let _ = std::fs::remove_file(&path);
let parsed = raw.as_ref().and_then(|b| serde_json::from_slice::<Handoff>(b).ok());
if parsed.is_none() {
// Without the fd numbers the inherited socket and pty are unusable
// (still open, but anonymous), so the kernel closes them when we exit.
// A fresh bind is the safe outcome.
eprintln!("claude-cloak: reload handoff unreadable, starting fresh");
}
parsed
}
/// Rebuild a `std::net::TcpListener` from the inherited fd. The caller converts
/// it to a tokio listener inside the runtime.
///
/// The fd is verified to be a listening socket first: the number comes out of a
/// file on disk, and a stale handoff could name an fd this process has since
/// reused for something else entirely.
pub fn adopt_listener(fd: RawFd) -> anyhow::Result<std::net::TcpListener> {
anyhow::ensure!(fd >= 0, "no inherited listener");
anyhow::ensure!(is_listening(fd), "inherited fd {fd} is not a listening socket");
// Put CLOEXEC back: from here on it is a normal socket again, and the next
// reload clears the flag itself.
unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) };
let l = unsafe { std::net::TcpListener::from_raw_fd(fd) };
l.set_nonblocking(true)?;
Ok(l)
}
fn is_listening(fd: RawFd) -> bool {
let mut on: libc::c_int = 0;
let mut len = std::mem::size_of::<libc::c_int>() as libc::socklen_t;
let rc = unsafe {
libc::getsockopt(
fd,
libc::SOL_SOCKET,
libc::SO_ACCEPTCONN,
(&raw mut on).cast(),
&mut len,
)
};
rc == 0 && on != 0
}
/// Duplicate the accept socket so the reload can hold the port open while
/// axum's own listener is dropped by the graceful shutdown.
pub fn dup_listener(l: &tokio::net::TcpListener) -> anyhow::Result<RawFd> {
let fd = unsafe { libc::fcntl(l.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 0) };
if fd < 0 {
return Err(std::io::Error::last_os_error()).context("dup listener");
}
Ok(fd)
}
/// Apply a restored snapshot to a fresh `App`. Every step is best-effort by
/// design — see the module docs.
pub fn restore(app: &mut App, raw: serde_json::Value) {
let s: AppState = serde_json::from_value(raw).unwrap_or_default();
let total = s.sessions.len();
app.sessions = s
.sessions
.into_iter()
.filter_map(|v| serde_json::from_value::<crate::app::Session>(v).ok())
.filter_map(sanitize_session)
.collect();
let kept = app.sessions.len();
app.selected = s.selected;
app.scroll = s.scroll;
app.follow = s.follow;
app.show_sessions = s.show_sessions;
app.embed_session = s.embed_session;
app.embed_token = s.embed_token;
// Length-tolerant: the filter set grows as entry kinds are added, and a
// snapshot from before that must not shift the toggles.
for (i, v) in s.filters.iter().take(app.filters.len()).enumerate() {
app.filters[i] = *v;
}
// The synthetic-lane counter is process-global; restart it past whatever
// the restored sessions already use.
crate::app::seed_server_tool_seq(max_server_tool_seq(&app.sessions) + 1);
app.after_restore(s.selected_key.as_deref());
app.reload_dropped = total - kept;
}
/// Make one restored session safe to render.
///
/// `Entry::lane`, `Lane::first_entry`, `Lane::anchor` and the values of
/// `Session::tool_ids` are all raw indices into vectors that a schema change
/// can shorten. `#[serde(default)]` covers a *missing* field and does nothing
/// for an *inconsistent* one, and the first draw indexes them directly — so
/// they are checked here, once, instead of defensively at every use site.
fn sanitize_session(mut s: crate::app::Session) -> Option<crate::app::Session> {
// `#[serde(default)]` is deliberately forgiving, which means an object that
// is not a session at all still decodes — into an empty one. A live session
// is always keyed (`proxy::forward_inner` falls back to `"unknown"`), so an
// empty key is the tell.
if s.key.is_empty() || s.lanes.is_empty() {
return None;
}
let lanes = s.lanes.len();
let entries = s.entries.len();
s.entries.retain(|e| (e.lane as usize) < lanes);
// Retaining shifts positions, so any index into `entries` is only sound
// when nothing was dropped.
let shifted = s.entries.len() != entries;
let entries = s.entries.len();
// Nothing is streaming into a process that no longer exists. Leaving these
// set would show a permanent running dot and keep `Lane::running` true
// forever — and `Tap::drop` runs on the tee task, so the snapshot can
// legitimately have caught a count that was about to be decremented.
s.active = 0;
for l in &mut s.lanes {
l.active = 0;
if shifted || l.first_entry.is_some_and(|i| i >= entries) {
l.first_entry = None;
}
if shifted || l.anchor.is_some_and(|i| i >= entries) {
l.anchor = None;
}
}
// A tool call whose result can no longer be attached is better than one
// attached to the wrong entry.
s.tool_ids.retain(|_, i| !shifted && *i < entries);
// A half-streamed entry never gets its remaining deltas: close it out so it
// renders as finished markdown instead of a permanently pending block.
if let Some(e) = s.entries.last_mut() {
e.done = true;
}
Some(s)
}
/// Highest `srvtool-<n>` index across the restored sessions.
fn max_server_tool_seq(sessions: &[crate::app::Session]) -> u64 {
sessions
.iter()
.flat_map(|s| s.lane_of_agent.keys())
.filter_map(|k| k.strip_prefix(crate::app::SERVER_TOOL_PREFIX))
.filter_map(|n| n.parse::<u64>().ok())
.max()
.unwrap_or(0)
}
/// Write side of `Handoff`. It *borrows* the live app: the feed can be tens of
/// megabytes, and — more importantly — an exec that fails must leave the
/// running process exactly as it was, which a moved-out `Vec<Session>` would
/// not. The field names mirror `Handoff`/`AppState` exactly; that is the whole
/// contract between the two.
#[derive(serde::Serialize)]
struct HandoffRef<'a> {
listener_fd: RawFd,
pane: &'a Option<crate::term::PtyHandoff>,
app: AppStateRef<'a>,
pane_ui: &'a PaneState,
generation: u32,
}
#[derive(serde::Serialize)]
struct AppStateRef<'a> {
sessions: &'a [crate::app::Session],
selected_key: Option<String>,
selected: usize,
scroll: usize,
follow: bool,
filters: &'a [bool],
show_sessions: bool,
embed_session: &'a Option<String>,
embed_token: &'a Option<String>,
}
impl<'a> AppStateRef<'a> {
fn of(app: &'a App) -> Self {
Self {
sessions: &app.sessions,
selected_key: app.selected_key(),
selected: app.selected,
scroll: app.scroll,
follow: app.follow,
filters: &app.filters,
show_sessions: app.show_sessions,
embed_session: &app.embed_session,
embed_token: &app.embed_token,
}
}
}
// ---------------------------------------------------------------------------
// Commit side: exec ourselves
// ---------------------------------------------------------------------------
/// Clear FD_CLOEXEC so `fd` survives the coming `execve`. Everything else we
/// hold keeps the flag and is closed by the kernel, which is what we want:
/// only the listener and the pty master are meant to cross over.
fn keep_open(fd: RawFd) -> bool {
unsafe { libc::fcntl(fd, libc::F_SETFD, 0) == 0 }
}
/// Undo `keep_open`. Only reached when the exec failed: the fds must not stay
/// inheritable, or the next `claude` we spawn would get a copy of the proxy
/// socket and the pane's pty.
fn close_on_exec(fd: RawFd) {
if fd >= 0 {
unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) };
}
}
/// Replace this process with `exe`, carrying the live state across.
///
/// On success this never returns: the new image continues from `main` with the
/// same pid, the same listener socket and the same `claude` child. It only
/// returns on failure — and then nothing has been consumed, so the caller just
/// keeps running the old code.
pub fn exec_into(
exe: &Path,
app: &App,
pane: Option<crate::term::PtyHandoff>,
pane_ui: &PaneState,
) -> anyhow::Error {
// Only these two fds are meant to cross. Everything else we hold keeps its
// FD_CLOEXEC and is closed by the kernel during the exec — including the
// established connections, which is why the reload waits for a quiet wire.
let listener_fd = if keep_open(app.listener_fd) { app.listener_fd } else { -1 };
let pane = match pane {
Some(p) if keep_open(p.master_fd) => Some(p),
// A pane whose fd cannot be kept open is dropped rather than handed
// over as a dangling number: the new image then simply has no pane.
_ => None,
};
let path = std::env::temp_dir().join(format!("claude-cloak-reload-{}.json", std::process::id()));
let snapshot = HandoffRef {
listener_fd,
pane: &pane,
app: AppStateRef::of(app),
pane_ui,
generation: app.reload_gen,
};
let written = serde_json::to_vec(&snapshot).map_err(anyhow::Error::from).and_then(|b| {
// Written and flushed before the exec: a crash in between would
// otherwise leave a truncated file the new image reads as garbage.
use std::io::Write;
let mut f = std::fs::File::create(&path)?;
f.write_all(&b)?;
f.sync_all()?;
Ok(())
});
let err = match written {
Err(e) => e.context("write reload snapshot"),
Ok(()) => {
// Same arguments we were started with, argv[0] aside.
let args: Vec<String> = std::env::args().skip(1).collect();
use std::os::unix::process::CommandExt;
let e = std::process::Command::new(exe).args(&args).env(HANDOFF_ENV, &path).exec();
// `exec` returns only on failure.
let _ = std::fs::remove_file(&path);
anyhow::Error::from(e).context(format!("exec {}", exe.display()))
}
};
// The reload did not happen, so put the fds back the way we found them and
// let the old code carry on.
close_on_exec(listener_fd);
if let Some(p) = &pane {
close_on_exec(p.master_fd);
}
err
}
// ---------------------------------------------------------------------------
// Which binary to exec
// ---------------------------------------------------------------------------
/// Resolve our own executable **path** (not inode) at startup.
///
/// Read once, in `main`, and then carried across every reload in the handoff —
/// because by the time you press ctrl-r the file has usually been replaced.
/// A linker writes the new binary and renames it over the old one, which
/// unlinks the inode we are running from; Linux then reports `/proc/self/exe`
/// as `…/claude-cloak (deleted)`. Resolving late would exec that literal name
/// and fail, so the suffix is also stripped defensively.
pub fn exe_path() -> PathBuf {
let raw = std::env::current_exe()
.ok()
.or_else(|| std::env::args_os().next().map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from(env!("CARGO_PKG_NAME")));
strip_deleted(raw)
}
fn strip_deleted(p: PathBuf) -> PathBuf {
match p.to_str().and_then(|s| s.strip_suffix(" (deleted)")) {
Some(s) => PathBuf::from(s),
None => p,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{Entry, Kind, Session};
use std::time::Instant;
/// A session as the *old* image would have snapshotted it.
fn live_session() -> Session {
let mut s = Session::new("sess".into(), "opus".into());
s.push(0, Entry { kind: Kind::Text, content: "hi".into(), ..Default::default() });
s.active = 2;
s.lanes[0].active = 2;
s.tool_ids.insert("toolu_1".into(), 0);
s
}
#[test]
fn snapshot_round_trips_through_json() {
let s = live_session();
let json = serde_json::to_string(&s).unwrap();
let back: Session = serde_json::from_str(&json).unwrap();
assert_eq!(back.key, "sess");
assert_eq!(back.entries.len(), 1);
assert_eq!(back.entries[0].content, "hi");
assert_eq!(back.lanes.len(), 1);
}
/// `Instant` is snapshotted as an age, so a lane restored from disk must
/// still look as old as it was — that is what `Lane::running` reads.
#[test]
fn instants_survive_as_ages_not_as_now() {
let mut s = live_session();
let old = Instant::now() - Duration::from_secs(120);
s.last_activity = old;
s.lanes[0].last_event = Some(old);
let back: Session = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
assert!(back.last_activity.elapsed() >= Duration::from_secs(119));
assert!(back.lanes[0].last_event.unwrap().elapsed() >= Duration::from_secs(119));
// …and therefore reads as idle, not as freshly running, once the
// in-flight counters are cleared.
assert!(!sanitize_session(back).unwrap().lanes[0].running());
}
/// Nothing streams into a process that no longer exists.
#[test]
fn sanitize_clears_in_flight_counters() {
let s = sanitize_session(live_session()).unwrap();
assert_eq!(s.active, 0);
assert_eq!(s.lanes[0].active, 0);
assert!(s.entries.last().unwrap().done, "a cut entry is closed out");
}
/// The dangerous case: a snapshot whose entries point at lanes the new
/// binary's session no longer has. Indexing those panics on the first draw.
#[test]
fn sanitize_drops_entries_with_dangling_lanes() {
let mut s = live_session();
s.entries.push(Entry { kind: Kind::Text, content: "ghost".into(), lane: 7, ..Default::default() });
let s = sanitize_session(s).unwrap();
assert_eq!(s.entries.len(), 1);
assert!(s.entries.iter().all(|e| (e.lane as usize) < s.lanes.len()));
// Positions shifted, so index-valued maps are dropped rather than
// left pointing at the wrong entry.
assert!(s.tool_ids.is_empty());
}
#[test]
fn sanitize_drops_a_session_without_lanes() {
let mut s = live_session();
s.lanes.clear();
assert!(sanitize_session(s).is_none());
}
#[test]
fn sanitize_clears_out_of_range_lane_indices() {
let mut s = live_session();
s.lanes[0].anchor = Some(99);
s.lanes[0].first_entry = Some(99);
let s = sanitize_session(s).unwrap();
assert_eq!(s.lanes[0].anchor, None);
assert_eq!(s.lanes[0].first_entry, None);
}
/// The synthetic-lane counter is process-global, so a reload must not
/// restart it at 0 and re-enter a lane the restored session already holds.
#[test]
fn server_tool_seq_is_seeded_past_restored_lanes() {
let mut s = live_session();
s.lane_of_agent.insert(format!("{}4", crate::app::SERVER_TOOL_PREFIX), 0);
s.lane_of_agent.insert("deadbeef".into(), 0);
assert_eq!(max_server_tool_seq(&[s]), 4);
}
/// The binary is expected to have been replaced while we run: a rename
/// over the running image makes Linux report `/proc/self/exe` with a
/// ` (deleted)` suffix, and exec'ing that literal name would fail.
#[test]
fn exe_path_drops_the_deleted_suffix() {
let p = strip_deleted(PathBuf::from("/x/target/debug/claude-cloak (deleted)"));
assert_eq!(p, PathBuf::from("/x/target/debug/claude-cloak"));
// An ordinary path is untouched, including one that merely contains
// the word.
let p = PathBuf::from("/x/deleted/claude-cloak");
assert_eq!(strip_deleted(p.clone()), p);
}
/// A handoff whose feed no longer parses must still surrender the fd
/// numbers: losing the code's state is survivable, losing the port and the
/// pane is not.
#[test]
fn unparseable_feed_keeps_the_port_and_the_pane() {
let raw = serde_json::json!({
"listener_fd": 9,
"pane": {
"master_fd": 11, "child_pid": 4242, "session_id": "s",
"pane_token": "t", "pty_rows": 20, "cols": 80
},
"app": {"sessions": [{"this": "is not a session"}, {"key": "keeper"}], "selected": 3},
"pane_ui": {"visible": true},
"generation": 2,
});
let h: Handoff = serde_json::from_value(raw).unwrap();
assert_eq!(h.listener_fd, 9);
assert_eq!(h.pane.as_ref().unwrap().child_pid, 4242);
assert_eq!(h.generation, 2);
let mut app = App::new();
restore(&mut app, h.app);
let keys: Vec<_> = app.sessions.iter().map(|s| s.key.as_str()).collect();
assert_eq!(keys, ["keeper"], "the junk entry is skipped, the real one kept");
}
}

View File

@@ -10,8 +10,8 @@
//! spaces … all become `-`) — see `encode_cwd`.
use crate::app::{
Entry, Kind, LaneId, MAIN_LANE, Session, SharedApp, ToolResult, flatten_result_content,
lock_app, strip_injected,
Entry, Kind, LaneId, MAIN_LANE, Session, SharedApp, TaskNotification, ToolResult,
flatten_result_content, lock_app, split_task_notifications, strip_injected, task_note_line,
};
use serde_json::Value;
use std::collections::HashMap;
@@ -27,8 +27,11 @@ pub struct DiskSession {
/// always writes the parent's `Agent` records too, so the parent's mtime
/// moves whenever this can change).
pub agents: usize,
/// Best human-readable label: ai-title > last-prompt text > uuid prefix.
pub label: String,
/// Claude Code's own name for the session — its `/resume` picker title,
/// read from the transcript (see `read_meta`). `None` when the file holds
/// none of the records it comes from. A *live* session's file is on disk
/// too, so `App::cc_title` gives a running session the same name.
pub title: Option<String>,
/// API model id of the session's last main-chain assistant message
/// (empty when the file has none yet). `App::resume_model` turns it into
/// the `--model` argument a resume spawns with.
@@ -36,6 +39,15 @@ pub struct DiskSession {
pub modified: SystemTime,
}
impl DiskSession {
/// Row label: Claude Code's own title, else the uuid prefix.
pub fn label(&self) -> String {
self.title
.clone()
.unwrap_or_else(|| self.uuid.chars().take(8).collect())
}
}
/// Background scanner: keeps `App::disk_sessions` in sync with the project
/// directory so the UI never does disk I/O for the session list. Polls ~1/s;
/// labels and models are re-read only for files whose mtime changed, so the
@@ -43,9 +55,9 @@ pub struct DiskSession {
/// only taken when the list actually changed.
pub fn spawn_scanner(app: SharedApp) {
std::thread::spawn(move || {
// uuid → (mtime when read, label, model, subagent count): skip
// uuid → (mtime when read, title, model, subagent count): skip
// re-parsing unchanged files (one pass yields all — see `read_meta`).
let mut meta: HashMap<String, (SystemTime, String, String, usize)> = HashMap::new();
let mut meta: HashMap<String, (SystemTime, Option<String>, String, usize)> = HashMap::new();
let mut last: Vec<DiskSession> = Vec::new();
loop {
let list = scan(&mut meta).unwrap_or_default();
@@ -60,7 +72,7 @@ pub fn spawn_scanner(app: SharedApp) {
/// One scan of the project directory, newest first.
fn scan(
meta: &mut HashMap<String, (SystemTime, String, String, usize)>,
meta: &mut HashMap<String, (SystemTime, Option<String>, String, usize)>,
) -> Result<Vec<DiskSession>, String> {
let dir = project_dir()?;
let rd = std::fs::read_dir(&dir)
@@ -74,21 +86,21 @@ fn scan(
}
let uuid = path.file_stem()?.to_str()?.to_string();
let modified = e.metadata().ok()?.modified().ok()?;
let (label, model, agents) = match meta.get(&uuid) {
Some((m, l, md, n)) if *m == modified => (l.clone(), md.clone(), *n),
let (title, model, agents) = match meta.get(&uuid) {
Some((m, t, md, n)) if *m == modified => (t.clone(), md.clone(), *n),
_ => {
let (label, model) = read_meta(&path, &uuid);
let (title, model) = read_meta(&path);
let agents = scan_agents(&uuid).len();
meta.insert(
uuid.clone(),
(modified, label.clone(), model.clone(), agents),
(modified, title.clone(), model.clone(), agents),
);
(label, model, agents)
(title, model, agents)
}
};
Some(DiskSession {
uuid,
label,
title,
model,
agents,
modified,
@@ -117,6 +129,7 @@ pub struct Turn {
/// A session's turns as a tree. Built from `uuid`/`parentUuid` chains:
/// uuid-less records (mode, file-history-snapshot, last-prompt…) attach to
/// the turn of the record preceding them in the file.
#[derive(Default)]
pub struct TurnTree {
pub turns: Vec<Turn>,
/// Records before/outside any turn (mode, the caveat record, …).
@@ -453,8 +466,9 @@ pub fn load_view(uuid: &str, path: Option<(&TurnTree, usize)>) -> Option<History
}
}
let anchors = std::mem::take(&mut p.agent_tools);
let usage = std::mem::take(&mut p.task_usage);
let mut view = p.into_view(uuid, Some(leaf), turn_entries)?;
splice_agents(&mut view, anchors, &agents);
splice_agents(&mut view, anchors, &agents, usage);
Some(view)
}
}
@@ -473,15 +487,67 @@ pub(crate) fn load_file_view(
p.line(&line);
}
let anchors = std::mem::take(&mut p.agent_tools);
let usage = std::mem::take(&mut p.task_usage);
let mut view = p.into_view(uuid, None, Vec::new())?;
splice_agents(&mut view, anchors, agents);
splice_agents(&mut view, anchors, agents, usage);
Some(view)
}
/// Parse one subagent transcript into its own lane. Oversized files are
/// summarised rather than parsed: the view is built while the app mutex is
/// held (the proxy tap shares it), so a few MB of JSONL must not stall it.
fn parse_agent_file(path: &std::path::Path, lane: LaneId) -> (Vec<Entry>, HashMap<String, usize>) {
/// Claude Code's own accounting for one whole agent run, scraped from the
/// `<usage>` block of a `<task-notification>`. A transcript records no *API*
/// usage, but it does record the notifications, so this is the only token
/// figure an on-disk lane can ever have (`Lane::subagent_tokens` and friends).
#[derive(Default, Clone, Copy, Debug)]
struct LaneUsage {
tokens: Option<u64>,
tool_uses: Option<u64>,
duration_ms: Option<u64>,
}
/// Agent id → run totals. The key is a notification's `<task-id>`, which is
/// also the agent id its lane is registered under and the stem of its
/// `subagents/agent-<id>.jsonl` — so the usage a *parent's* file reports finds
/// the child's lane without any extra lookup table.
type UsageByAgent = HashMap<String, LaneUsage>;
impl LaneUsage {
/// Field-by-field, later totals winning — same policy as the live
/// `Session::record_task_usage` (an agent woken again by `SendMessage`
/// reports afresh).
fn merge(&mut self, o: LaneUsage) {
self.tokens = o.tokens.or(self.tokens);
self.tool_uses = o.tool_uses.or(self.tool_uses);
self.duration_ms = o.duration_ms.or(self.duration_ms);
}
fn is_empty(&self) -> bool {
self.tokens.is_none() && self.tool_uses.is_none() && self.duration_ms.is_none()
}
}
/// Remember a notification's `<usage>` under its agent id, for `splice_agents`
/// to hand to that agent's lane once the lanes exist.
fn record_task_usage(usage: &mut UsageByAgent, n: &TaskNotification) {
let u = LaneUsage {
tokens: n.subagent_tokens,
tool_uses: n.tool_uses,
duration_ms: n.duration_ms,
};
if u.is_empty() {
return;
}
usage.entry(n.task_id.clone()).or_default().merge(u);
}
/// Parse one subagent transcript into its own lane, merging any `<usage>` it
/// reports for *its* children into `usage`. Oversized files are summarised
/// rather than parsed: the view is built while the app mutex is held (the
/// proxy tap shares it), so a few MB of JSONL must not stall it.
fn parse_agent_file(
path: &std::path::Path,
lane: LaneId,
usage: &mut UsageByAgent,
) -> (Vec<Entry>, HashMap<String, usize>) {
let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
if size > MAX_AGENT_BYTES {
let mb = size / (1024 * 1024);
@@ -496,6 +562,9 @@ fn parse_agent_file(path: &std::path::Path, lane: LaneId) -> (Vec<Entry>, HashMa
for line in std::io::BufReader::new(f).lines().map_while(Result::ok) {
p.line(&line);
}
for (id, u) in p.task_usage {
usage.entry(id).or_default().merge(u);
}
(p.entries, p.agent_tools)
}
@@ -537,10 +606,15 @@ fn insert_entries(
/// the highest anchor goes first. A lane whose spawn point is not in this view
/// (a path view can exclude that turn) keeps no entries, so it never shows up
/// in the agent popup.
///
/// `usage` carries the `<task-notification>` totals the main chain reported
/// (see `LaneUsage`); each agent file's own notifications are merged in as it
/// is parsed, and the lot is handed to the lanes at the end.
fn splice_agents(
view: &mut HistoryView,
mut anchors: HashMap<String, usize>,
agents: &[DiskAgent],
mut usage: UsageByAgent,
) {
if agents.is_empty() {
return;
@@ -566,7 +640,7 @@ fn splice_agents(
None,
a.spawn_depth,
);
let (entries, anchors) = parse_agent_file(&a.path, lane);
let (entries, anchors) = parse_agent_file(&a.path, lane, &mut usage);
Loaded {
id: a.agent_id.clone(),
lane,
@@ -636,11 +710,25 @@ fn splice_agents(
l.parent = Some(MAIN_LANE);
}
view.session.reindex_lanes();
// Tool counts for the agent picker (a transcript carries no usage, so
// token totals stay zero for on-disk lanes).
// Tool counts for the agent picker, straight off the entries.
for (lane, n) in count_tools(&view.session.entries) {
view.session.lanes[lane as usize].tool_calls = n;
}
// Run totals for the picker and the agent-feed title. A transcript records
// no API usage, but the `<task-notification>`s in it carry Claude Code's
// own accounting, keyed by the agent id the lane is registered under. An
// id that matches no lane (a background *bash* task, or an agent whose
// transcript this session never kept) is simply skipped — the same
// silence as the live `Session::record_task_usage`.
for (id, u) in &usage {
let Some(&lane) = view.session.lane_of_agent.get(id) else {
continue;
};
let l = &mut view.session.lanes[lane as usize];
l.subagent_tokens = u.tokens.or(l.subagent_tokens);
l.tool_uses = u.tool_uses.or(l.tool_uses);
l.duration_ms = u.duration_ms.or(l.duration_ms);
}
}
/// Incremental JSONL-record → feed-`Entry` translation (shared by the whole
/// file and path views).
@@ -653,6 +741,10 @@ struct EntryParser {
/// `tool_idx`, which is *drained* as results attach — the anchor is still
/// needed afterwards to splice the subagent's transcript in.
agent_tools: HashMap<String, usize>,
/// `<usage>` totals scraped from this file's `<task-notification>`s, keyed
/// by the agent id they report on. Collected in the parse pass and applied
/// to the lanes by `splice_agents`, which is where the lanes exist.
task_usage: UsageByAgent,
/// Lane every parsed entry is tagged with (0 = the main chain).
lane: LaneId,
/// Keep `isSidechain` records instead of skipping them. A subagent file
@@ -668,6 +760,7 @@ impl EntryParser {
model: String::from("(resumed)"),
tool_idx: HashMap::new(),
agent_tools: HashMap::new(),
task_usage: UsageByAgent::new(),
lane: MAIN_LANE,
keep_sidechain: false,
}
@@ -709,6 +802,7 @@ impl EntryParser {
let entries = &mut self.entries;
let tool_idx = &mut self.tool_idx;
let agent_tools = &mut self.agent_tools;
let task_usage = &mut self.task_usage;
let Ok(v) = serde_json::from_str::<Value>(line) else {
return;
};
@@ -766,11 +860,19 @@ impl EntryParser {
}
}
Some("user") => match v.pointer("/message/content") {
Some(Value::String(s)) => push_user_text(entries, s, lane),
Some(Value::String(s)) => {
push_user_text(entries, agent_tools, task_usage, s, lane)
}
Some(Value::Array(blocks)) => {
for b in blocks {
match b.get("type").and_then(Value::as_str) {
Some("text") => push_user_text(entries, &text_of(b, "text"), lane),
Some("text") => push_user_text(
entries,
agent_tools,
task_usage,
&text_of(b, "text"),
lane,
),
Some("tool_result") => {
let Some(idx) = b
.get("tool_use_id")
@@ -804,10 +906,83 @@ fn text_of(b: &Value, key: &str) -> String {
b.get(key).and_then(Value::as_str).unwrap_or_default().to_string()
}
/// Translate a user text block into feed entries: each injected reminder as a
/// dimmed `Kind::Reminder`, then the real prompt as `Kind::User`.
fn push_user_text(entries: &mut Vec<Entry>, text: &str, lane: LaneId) {
/// Move an agent's final report onto the `Agent` tool entry that spawned it,
/// replacing the `Async agent launched successfully… agentId: <hex>`
/// acknowledgement the parent model got at launch time. The disk mirror of
/// `Session::attach_task_report`, and a shorter one: the notification names
/// the `<tool-use-id>` of that very call, and `EntryParser::agent_tools` still
/// holds it (unlike `tool_idx`, which is drained when the launch result
/// attaches) — so no lane/anchor round-trip is needed. Both records live in
/// the same file, nested agents included: a depth-2 `Agent` call and the
/// notification answering it both sit in the parent *agent's* transcript.
///
/// False when the spawn point is not in this parse (a path view can exclude
/// that turn, and 6 of the notifications in the real transcripts name a call
/// recorded nowhere we read); the caller then keeps the report inline in the
/// note rather than losing it.
fn attach_task_report(
entries: &mut [Entry],
agent_tools: &HashMap<String, usize>,
n: &TaskNotification,
report: &str,
) -> bool {
let Some(&idx) = n.tool_use_id.as_deref().and_then(|id| agent_tools.get(id)) else {
return false;
};
let Some(e) = entries.get_mut(idx) else {
return false;
};
e.result = Some(ToolResult {
content: report.to_string(),
is_error: n.failed(),
});
true
}
/// Translate a user text block into feed entries, exactly as the live path
/// (`app::record_user_prompt`) does, so a session reads the same whether it is
/// streaming or loaded from disk:
///
/// 1. every `<task-notification>` is lifted out — of the prompt *and* of each
/// injected reminder, since Claude Code sometimes wraps one in a
/// `<system-reminder>` — and becomes one `Kind::TaskNote` line, with the
/// agent's `<result>` report moved onto its `Agent` tool entry (or kept
/// inline when that call is not in this view). `<usage>` is remembered for
/// the lane;
/// 2. each remaining reminder as a dimmed `Kind::Reminder`;
/// 3. what is left as the real prompt, `Kind::User`.
///
/// Notes come first, as they do live. Nothing is dropped: a block that fails
/// to parse is handed back inside the text by `split_task_notifications` and
/// so still shows in the prompt.
///
/// No resend dedup here (the live path's) — a transcript records each turn
/// once, so an identical note twice means the agent really was woken twice.
fn push_user_text(
entries: &mut Vec<Entry>,
agent_tools: &HashMap<String, usize>,
usage: &mut UsageByAgent,
text: &str,
lane: LaneId,
) {
let (reminders, prompt) = crate::app::extract_user_text(text);
let (mut notes, prompt) = split_task_notifications(&prompt);
let reminders: Vec<String> = reminders
.into_iter()
.filter_map(|r| {
let (n, rest) = split_task_notifications(&r);
notes.extend(n);
// A reminder that was *only* a notification leaves nothing to show.
(!rest.is_empty()).then_some(rest)
})
.collect();
for n in &notes {
record_task_usage(usage, n);
let report = n.result.as_deref().map(str::trim).filter(|r| !r.is_empty());
let attached = report.is_some_and(|r| attach_task_report(entries, agent_tools, n, r));
let line = task_note_line(n, if attached { None } else { report });
entries.push(Entry::done(Kind::TaskNote, line).in_lane(lane));
}
for r in reminders {
entries.push(Entry::done(Kind::Reminder, r).in_lane(lane));
}
@@ -846,28 +1021,51 @@ fn encode_cwd(path: &str) -> String {
}
/// Read a session's list metadata from its JSONL file in one pass:
/// `(label, model)`.
/// `(title, model)`.
///
/// The label prefers `ai-title`; it falls back to the last non-empty
/// `last-prompt` text, then the uuid prefix. The model is the API model id of
/// The title is Claude Code's *own* name for the session: the same records, in
/// the same order, that its `/resume` picker reads, so a row here reads as it
/// does there — `custom-title` (a manual rename) > `ai-title` (Claude's
/// generated title) > `last-prompt` (the newest prompt, which is what an
/// untitled session shows) > a legacy compaction `summary` > the first real
/// user prompt in the file. `None` when the file names the session in none of
/// those ways (nothing recorded yet, or unreadable); the caller decides what
/// to show instead (`DiskSession::label`, `ui::live_title`).
///
/// The newest rename wins, but the *first* `ai-title` does: a branch file we
/// materialized carries our own `⑂ …` title at the top and Claude Code may
/// append its own later. The opening prompt goes through `prompt_text`, so a
/// slash command or an injected block yields nothing rather than machinery.
///
/// The model is the API model id of
/// the last *main-chain* assistant message — what Claude Code itself recorded
/// for the newest turn, so a resume can continue on it. Subagent records
/// (`isSidechain`) carry their own model and are skipped, as are the
/// `<synthetic>` ids Claude Code writes for API-error records.
fn read_meta(path: &std::path::Path, uuid: &str) -> (String, String) {
let fallback = || uuid.chars().take(8).collect::<String>();
fn read_meta(path: &std::path::Path) -> (Option<String>, String) {
let Ok(f) = std::fs::File::open(path) else {
return (fallback(), String::new());
return (None, String::new());
};
let reader = std::io::BufReader::new(f);
let mut custom_title: Option<String> = None;
let mut ai_title: Option<String> = None;
let mut last_prompt: Option<String> = None;
let mut summary: Option<String> = None;
let mut first_prompt: Option<String> = None;
let mut model = String::new();
for line in reader.lines().map_while(Result::ok) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) else {
continue;
};
match v.get("type").and_then(|t| t.as_str()) {
// A `/title` rename: the newest one is the current name.
Some("custom-title") => {
if let Some(t) = v.get("customTitle").and_then(|t| t.as_str())
&& !t.is_empty()
{
custom_title = Some(one_line(t));
}
}
// First title wins (the scan no longer stops there — it still
// needs the model): a branch file we materialized carries our own
// `⑂ …` title first, and Claude Code may append its own later.
@@ -885,6 +1083,22 @@ fn read_meta(path: &std::path::Path, uuid: &str) -> (String, String) {
last_prompt = Some(one_line(p));
}
}
// A pre-2.x compaction summary (`summaryHint` in the picker) —
// still the only name a session compacted back then carries.
Some("summary") => {
if let Some(t) = v.get("summary").and_then(|t| t.as_str())
&& !t.is_empty()
{
summary = Some(one_line(t));
}
}
// Last resort: the prompt the session opened with. Young sessions
// land here — Claude Code writes `last-prompt` a while later.
Some("user") if first_prompt.is_none() => {
if let Some(p) = prompt_text(&v) {
first_prompt = Some(one_line(&p));
}
}
Some("assistant") => {
if v.get("isSidechain").and_then(Value::as_bool) != Some(true)
&& let Some(m) = v.pointer("/message/model").and_then(Value::as_str)
@@ -896,8 +1110,13 @@ fn read_meta(path: &std::path::Path, uuid: &str) -> (String, String) {
_ => {}
}
}
let label = ai_title.or(last_prompt).unwrap_or_else(fallback);
(label, model)
let title = custom_title
.or(ai_title)
.or(last_prompt)
.or(summary)
.or(first_prompt)
.filter(|t| !t.is_empty());
(title, model)
}
/// First line only, control characters dropped — labels go into a one-row
@@ -1092,6 +1311,244 @@ mod tests {
std::fs::remove_dir_all(&dir).ok();
}
/// An `Agent` tool call, and the launch acknowledgement its tool_result
/// carries (Claude Code launches every agent asynchronously).
fn agent_call(uuid: &str, parent: &str, tool_id: &str) -> String {
serde_json::json!({
"type": "assistant", "uuid": uuid, "parentUuid": parent,
"message": {"model": "claude-x", "content": [{
"type": "tool_use", "id": tool_id, "name": "Agent",
"input": {"subagent_type": "Explore", "description": "sweep", "prompt": "p"}
}]}
})
.to_string()
}
fn launch_result(uuid: &str, parent: &str, tool_id: &str, agent_id: &str) -> String {
serde_json::json!({
"type": "user", "uuid": uuid, "parentUuid": parent,
"message": {"role": "user", "content": [{
"type": "tool_result", "tool_use_id": tool_id,
"content": format!("Async agent launched successfully, agentId: {agent_id}")
}]}
})
.to_string()
}
/// A completion `<task-notification>` in Claude Code's real wire shape.
fn completion_note(task_id: &str, tool_id: &str, result: &str) -> String {
format!(
"<task-notification>\n\
<task-id>{task_id}</task-id>\n\
<tool-use-id>{tool_id}</tool-use-id>\n\
<output-file>/tmp/claude/tasks/{task_id}.output</output-file>\n\
<status>completed</status>\n\
<summary>Agent \"sweep\" finished</summary>\n\
<note>A task-notification fires each time this agent stops.</note>\n\
<result>{result}</result>\n\
<usage><subagent_tokens>128633</subagent_tokens><tool_uses>63</tool_uses>\
<duration_ms>1115197</duration_ms></usage>\n\
</task-notification>"
)
}
/// The four records of an agent run: prompt, `Agent` call, launch
/// acknowledgement, and the turn its completion notification rode in on.
fn agent_run(note: &str) -> Vec<String> {
vec![
prompt("u1", None, "go"),
agent_call("a1", "u1", "toolu_A"),
launch_result("u2", "a1", "toolu_A", "aaa1"),
prompt("u3", Some("a1"), &format!("{note}\nwhat did it find?")),
]
}
fn view_of(lines: &[String]) -> Session {
let refs: Vec<&str> = lines.iter().map(String::as_str).collect();
let p = write_jsonl(&refs);
let s = load_file_view(&p, "sess-notif", &[]).map(|h| h.session);
std::fs::remove_file(&p).ok();
s.expect("view")
}
fn only_note(s: &Session) -> &Entry {
let mut it = s.entries.iter().filter(|e| e.kind == Kind::TaskNote);
let n = it.next().expect("a Kind::TaskNote entry");
assert!(it.next().is_none(), "exactly one note expected");
n
}
/// A `<task-notification>` in a transcript is lifted out of the prompt: a
/// one-line `Kind::TaskNote` in front of it, the XML (and the `<note>`
/// boilerplate) gone from the user entry — the live path's rendering.
#[test]
fn disk_task_notification_becomes_a_note_beside_the_prompt() {
let s = view_of(&agent_run(&completion_note("aaa1", "toolu_A", "THE REPORT")));
let note = only_note(&s);
assert!(note.content.starts_with('✔'), "{}", note.content);
assert!(note.content.contains("sweep finished"), "{}", note.content);
assert!(note.content.contains("128.6k tok · 63 tools · 18m35s"), "{}", note.content);
// The report went to the tool call, so it is not repeated inline, and
// the spool path is only shown when there is no report at all.
assert!(!note.content.contains("THE REPORT"), "{}", note.content);
assert!(!note.content.contains(".output"), "{}", note.content);
for e in &s.entries {
assert!(!e.content.contains("<task-notification>"), "raw XML left in {:?}", e.content);
assert!(!e.content.contains("task-notification fires"), "boilerplate kept");
}
// What the user actually typed survives, as its own entry after the note.
let users: Vec<&str> = s
.entries
.iter()
.filter(|e| e.kind == Kind::User)
.map(|e| e.content.as_str())
.collect();
assert_eq!(users, ["go", "what did it find?"]);
let pos = |k: &Kind| s.entries.iter().position(|e| &e.kind == k).unwrap();
assert!(
pos(&Kind::TaskNote) < s.entries.iter().rposition(|e| e.kind == Kind::User).unwrap(),
"note precedes the prompt it rode in with"
);
}
/// The report replaces the `Async agent launched…` placeholder on the
/// `Agent` entry when `<tool-use-id>` resolves to a call in this view.
#[test]
fn disk_task_report_replaces_the_launch_placeholder() {
let s = view_of(&agent_run(&completion_note("aaa1", "toolu_A", "THE REPORT")));
let tool = s
.entries
.iter()
.find(|e| matches!(&e.kind, Kind::Tool { name } if name == "Agent"))
.expect("Agent entry");
let r = tool.result.as_ref().expect("a result");
assert_eq!(r.content, "THE REPORT");
assert!(!r.is_error);
}
/// … and when it resolves to nothing (the spawning turn is outside this
/// view), the report stays inline in the note rather than being lost.
#[test]
fn disk_task_report_stays_inline_when_unresolved() {
let s = view_of(&agent_run(&completion_note("aaa1", "toolu_GONE", "THE REPORT")));
let note = only_note(&s);
assert!(note.content.contains("\n THE REPORT"), "{}", note.content);
let tool = s
.entries
.iter()
.find(|e| matches!(&e.kind, Kind::Tool { name } if name == "Agent"))
.expect("Agent entry");
assert!(
tool.result.as_ref().unwrap().content.contains("Async agent launched"),
"the launch acknowledgement is untouched when the report can't be placed"
);
}
/// A failed run marks the attached report as an error, and a `<status>`
/// holding a raw error body (4 of the real ones) is kept on the note.
#[test]
fn disk_failed_task_marks_the_report_as_an_error() {
let note = "<task-notification>\n\
<task-id>aaa1</task-id>\n\
<tool-use-id>toolu_A</tool-use-id>\n\
<status>Error: 403: {\"message\":\"Access to model denied.\"}</status>\n\
<summary>Agent \"sweep\" failed</summary>\n\
<result>partial work</result>\n\
</task-notification>";
let s = view_of(&agent_run(note));
let n = only_note(&s);
assert!(n.content.starts_with('✖'), "{}", n.content);
assert!(n.content.contains("Error: 403"), "{}", n.content);
let tool = s
.entries
.iter()
.find(|e| matches!(&e.kind, Kind::Tool { name } if name == "Agent"))
.expect("Agent entry");
let r = tool.result.as_ref().unwrap();
assert_eq!(r.content, "partial work");
assert!(r.is_error, "a failed run's report is an error result");
}
/// A monitor event is a progress ping: a note, no lane touched, no usage.
/// (An on-disk lane is finished by construction — `Session::add_lane` —
/// so what matters here is that the short id claims no lane at all.)
#[test]
fn disk_monitor_event_is_a_note_and_claims_no_lane() {
let note = "<task-notification>\n\
<task-id>b8s2gso3a</task-id>\n\
<summary>Monitor event: \"world skin bench\"</summary>\n\
<event>BENCH progress phase=traverse ms=141974</event>\n\
</task-notification>";
let s = view_of(&agent_run(note));
let n = only_note(&s);
assert!(n.content.starts_with('▸'), "{}", n.content);
assert!(n.content.contains("Monitor event: world skin bench"), "{}", n.content);
assert!(n.content.contains("\n BENCH progress phase=traverse"), "{}", n.content);
assert!(!s.lane_of_agent.contains_key("b8s2gso3a"), "a monitor id is not a lane");
assert_eq!(s.lanes.len(), 1, "no agent transcripts here, so main only");
// Its `Agent` call keeps the launch acknowledgement: a monitor event
// reports on nothing that has a report.
let tool = s
.entries
.iter()
.find(|e| matches!(&e.kind, Kind::Tool { name } if name == "Agent"))
.expect("Agent entry");
assert!(tool.result.as_ref().unwrap().content.contains("Async agent launched"));
}
/// A notification wrapped in a `<system-reminder>` (Claude Code does this)
/// is still lifted, and a reminder that held nothing else vanishes with it.
#[test]
fn disk_notification_inside_a_reminder_is_lifted() {
let note = completion_note("aaa1", "toolu_A", "THE REPORT");
let wrapped = format!("<system-reminder>\n{note}\n</system-reminder>");
let s = view_of(&agent_run(&wrapped));
let n = only_note(&s);
assert!(n.content.starts_with('✔'), "{}", n.content);
assert!(
!s.entries.iter().any(|e| e.kind == Kind::Reminder),
"a reminder that was only a notification leaves nothing behind"
);
}
/// `<usage>` totals reach the agent's own lane — the only token figures an
/// on-disk lane can have. Matched by agent id: the notification's
/// `<task-id>` is the stem of `subagents/agent-<id>.jsonl`.
#[test]
fn disk_task_usage_reaches_the_lane() {
let dir = std::env::temp_dir().join(format!("ct-usage-{}", std::process::id()));
let subs = dir.join("subagents");
std::fs::create_dir_all(&subs).unwrap();
let main = dir.join("s-usage.jsonl");
std::fs::write(
&main,
agent_run(&completion_note("aaa1", "toolu_A", "THE REPORT")).join("\n"),
)
.unwrap();
std::fs::write(
subs.join("agent-aaa1.meta.json"),
r#"{"agentType":"Explore","description":"sweep","toolUseId":"toolu_A","spawnDepth":1}"#,
)
.unwrap();
std::fs::write(
subs.join("agent-aaa1.jsonl"),
r#"{"type":"assistant","isSidechain":true,"uuid":"s1","parentUuid":null,"message":{"model":"claude-y","content":[{"type":"text","text":"child"}]}}"#,
)
.unwrap();
let agents = scan_agents_in(&subs);
let s = load_file_view(&main, "s-usage", &agents).expect("view").session;
std::fs::remove_dir_all(&dir).ok();
let lane = &s.lanes[1];
assert_eq!(lane.agent_id, "aaa1");
assert_eq!(lane.subagent_tokens, Some(128633));
assert_eq!(lane.tool_uses, Some(63));
assert_eq!(lane.duration_ms, Some(1115197));
// The main lane never takes an agent's totals.
assert_eq!(s.lanes[MAIN_LANE as usize].subagent_tokens, None);
}
#[test]
fn materialize_chain_and_stitch() {
let tree = build_tree(branched());
@@ -1182,28 +1639,62 @@ mod tests {
assert_eq!(encode_cwd("/a/b1_c2.d3"), "-a-b1-c2-d3");
}
/// The title follows Claude Code's own precedence, so a row here reads as
/// it does in `/resume`.
#[test]
fn label_prefers_ai_title() {
fn title_prefers_a_rename_then_the_ai_title() {
// A rename beats a generated title, and the newest rename wins.
let p = write_jsonl(&[
r#"{"type":"last-prompt","lastPrompt":"fix the bug"}"#,
r#"{"type":"ai-title","aiTitle":"bug fixing session"}"#,
r#"{"type":"custom-title","customTitle":"my name"}"#,
r#"{"type":"custom-title","customTitle":"renamed again"}"#,
]);
assert_eq!(read_meta(&p).0.as_deref(), Some("renamed again"));
std::fs::remove_file(p).ok();
// The *first* ai-title wins: our own `⑂ …` title sits at the top of a
// branch file, and Claude Code may append its own later.
let p = write_jsonl(&[
r#"{"type":"last-prompt","lastPrompt":"fix the bug"}"#,
r#"{"type":"ai-title","aiTitle":"bug fixing session"}"#,
r#"{"type":"ai-title","aiTitle":"retitled later"}"#,
]);
assert_eq!(read_meta(&p, "deadbeef-0000").0, "bug fixing session");
assert_eq!(read_meta(&p).0.as_deref(), Some("bug fixing session"));
std::fs::remove_file(p).ok();
}
#[test]
fn label_falls_back_to_last_prompt_then_uuid() {
fn title_falls_back_to_last_prompt_then_the_opening_prompt() {
// Untitled: the newest prompt, first line only — what the picker shows.
let p = write_jsonl(&[
r#"{"type":"last-prompt","lastPrompt":"first"}"#,
r#"{"type":"last-prompt","lastPrompt":"latest\nmultiline"}"#,
]);
assert_eq!(read_meta(&p, "deadbeef-0000").0, "latest");
assert_eq!(read_meta(&p).0.as_deref(), Some("latest"));
std::fs::remove_file(p).ok();
// No `last-prompt` record yet (a young session): the opening prompt.
let p = write_jsonl(&[
r#"{"type":"user","uuid":"u1","message":{"role":"user","content":"opening prompt"}}"#,
r#"{"type":"user","uuid":"u2","message":{"role":"user","content":"later prompt"}}"#,
]);
assert_eq!(read_meta(&p).0.as_deref(), Some("opening prompt"));
std::fs::remove_file(p).ok();
}
#[test]
fn an_unnamed_session_reads_as_its_uuid_prefix() {
let p = write_jsonl(&[r#"{"type":"user"}"#, "not json"]);
assert_eq!(read_meta(&p, "deadbeef-0000").0, "deadbeef");
assert_eq!(read_meta(&p).0, None);
let d = DiskSession {
uuid: "deadbeef-0000".into(),
title: None,
model: String::new(),
agents: 0,
modified: SystemTime::now(),
};
assert_eq!(d.label(), "deadbeef");
std::fs::remove_file(p).ok();
}
@@ -1217,12 +1708,12 @@ mod tests {
// An API-error record carries a placeholder id, not a model.
r#"{"type":"assistant","message":{"model":"<synthetic>","content":[]}}"#,
]);
assert_eq!(read_meta(&p, "deadbeef-0000").1, "claude-sonnet-4-5-20250929");
assert_eq!(read_meta(&p).1, "claude-sonnet-4-5-20250929");
std::fs::remove_file(p).ok();
// A session with no assistant record yet reports no model.
let p = write_jsonl(&[r#"{"type":"user","message":{"role":"user","content":"hi"}}"#]);
assert_eq!(read_meta(&p, "deadbeef-0000").1, "");
assert_eq!(read_meta(&p).1, "");
std::fs::remove_file(p).ok();
}
@@ -1265,3 +1756,4 @@ mod tests {
assert!(s.entries.iter().all(|e| e.done));
}
}

View File

@@ -16,14 +16,16 @@ use ratatui::buffer::Buffer;
use ratatui::crossterm::cursor::SetCursorStyle;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier};
use std::io::Read;
use std::io::{Read, Write};
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
pub use wezterm_surface::CursorShape;
use wezterm_surface::CursorVisibility;
use wezterm_term::color::{ColorAttribute, ColorPalette};
use wezterm_term::{
Intensity, KeyCode, KeyModifiers, Terminal, TerminalConfiguration, TerminalSize, Underline,
Intensity, KeyCode, KeyModifiers, StableRowIndex, Terminal, TerminalConfiguration, TerminalSize,
Underline,
};
/// Extra PTY rows beyond the visible pane window, so the child has room to
@@ -84,6 +86,21 @@ pub struct EmbeddedTerm {
/// Actual PTY rows (visible rows + pad when cropping is active).
pty_rows: u16,
cols: u16,
/// Top row of the *fullscreen* pane's view, as a stable row index —
/// `None` (the normal state) means "follow the live screen".
///
/// Claude Code grabs no mouse and stays off the alternate screen, 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 (see `scroll`). A *stable* index rather than an offset
/// because the child keeps writing while you read: a terminal pins the rows
/// you scrolled to instead of sliding them up under you.
scroll_top: Mutex<Option<StableRowIndex>>,
/// The child's pid. Kept as a plain number because a hot reload
/// (`reload.rs`) execs us: the portable-pty `Child` handle dies with the
/// old image, but we stay the same process, so the *pid* is still ours to
/// wait on and signal after the exec.
child_pid: Option<u32>,
}
/// HTTP header carrying the pane token; the proxy reads it to bind this pane's
@@ -156,6 +173,7 @@ impl EmbeddedTerm {
.context("openpty")?;
let child = pty.slave.spawn_command(cmd).context("spawn child")?;
let killer = child.clone_killer();
let child_pid = child.process_id();
drop(pty.slave);
// The terminal model writes query responses (DSR/DA/XTGETTCAP…) and
@@ -194,13 +212,114 @@ impl EmbeddedTerm {
});
}
Ok(Self { term, master: pty.master, killer, exited, session_id, pane_token, pty_rows: rows + PTY_PAD, cols })
Ok(Self {
term,
master: pty.master,
killer,
exited,
session_id,
pane_token,
pty_rows: rows + PTY_PAD,
cols,
scroll_top: Mutex::new(None),
child_pid,
})
}
/// Take a *running* child back over after a hot reload. The PTY master fd
/// came through the `execve` (see `reload::keep_open`), and the child never
/// noticed: same pid on our side, same pty, same session.
///
/// What does not survive is the wezterm screen model: it is rebuilt empty,
/// so the cells have to come from the child again. The trick is to adopt
/// the pty **one row short** of its real height and let the first
/// `ui::draw` frame restore it — `resize` then sees a changed geometry and
/// issues a real `TIOCSWINSZ`, which Linux only turns into a SIGWINCH when
/// the size actually differs, and Ink answers with a full repaint. Poking
/// the ioctl twice in a row here instead would coalesce into one signal
/// carrying the *unchanged* final size, and redraw nothing.
pub fn adopt(h: PtyHandoff) -> anyhow::Result<Self> {
let rows = h.pty_rows.saturating_sub(1).max(1);
let master = AdoptedMaster { fd: unsafe { OwnedFd::from_raw_fd(h.master_fd) } };
// The fd arrived non-CLOEXEC (that is how it survived the exec). Put
// the flag back so it isn't inherited by anything we spawn from here.
unsafe { libc::fcntl(h.master_fd, libc::F_SETFD, libc::FD_CLOEXEC) };
let writer = master.take_writer().context("pty writer")?;
let _ = master.resize(PtySize { rows, cols: h.cols, pixel_width: 0, pixel_height: 0 });
let term = Arc::new(Mutex::new(Terminal::new(
TerminalSize {
rows: rows as usize,
cols: h.cols as usize,
pixel_width: 0,
pixel_height: 0,
dpi: 0,
},
Arc::new(Config),
"claude-cloak",
env!("CARGO_PKG_VERSION"),
writer,
)));
let exited = Arc::new(AtomicBool::new(false));
let mut reader = master.try_clone_reader().context("pty reader")?;
let pid = h.child_pid;
{
let term = term.clone();
let exited = exited.clone();
std::thread::spawn(move || {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => term.lock().unwrap().advance_bytes(&buf[..n]),
}
}
// Still the child's parent across the exec, so it is still
// ours to reap — the portable-pty `Child` that used to do it
// died with the old image. Retry on EINTR; anything else
// (notably ECHILD) means there is nothing left to wait for.
let mut status = 0;
while unsafe { libc::waitpid(pid as i32, &mut status, 0) } < 0
&& std::io::Error::last_os_error().raw_os_error() == Some(libc::EINTR)
{}
exited.store(true, Ordering::Relaxed);
});
}
Ok(Self {
term,
master: Box::new(master),
killer: Box::new(PidKiller(pid)),
exited,
session_id: h.session_id,
pane_token: h.pane_token,
// Deliberately the short height: the next frame's `resize` restores
// the real one and that is what triggers the repaint.
pty_rows: rows,
cols: h.cols,
scroll_top: Mutex::new(None),
child_pid: Some(pid),
})
}
pub fn exited(&self) -> bool {
self.exited.load(Ordering::Relaxed)
}
/// Describe this pane well enough for the post-exec image to re-adopt it
/// (`adopt`). `None` when the child's pid is unknown or the master has no
/// fd — either way the pane can't survive a reload and is killed instead.
pub fn handoff(&self) -> Option<PtyHandoff> {
Some(PtyHandoff {
master_fd: self.master.as_raw_fd()?,
child_pid: self.child_pid?,
session_id: self.session_id.clone(),
pane_token: self.pane_token.clone(),
pty_rows: self.pty_rows,
cols: self.cols,
})
}
/// Resize PTY + terminal model for a pane of `rows` visible rows.
/// With `crop` (the compact pane), the PTY gets `PTY_PAD` extra rows:
/// render() crops Claude Code's persistent status/hint rows, so the
@@ -244,6 +363,66 @@ impl EmbeddedTerm {
let _ = self.term.lock().unwrap().send_paste(text);
}
/// Scroll the fullscreen pane's view by `delta` rows — negative up, into
/// the child's scrollback. Clamps to what the scrollback still holds;
/// arriving back at the live screen re-engages follow mode rather than
/// pinning to it.
///
/// Deliberately *our* scroll and not a forwarded mouse report: Claude Code
/// enables no mouse tracking and never leaves the normal screen, so a
/// plain terminal scrolls its own scrollback here too and the child sees
/// nothing. The fullscreen pane is that terminal.
pub fn scroll(&self, delta: isize) {
let term = self.term.lock().unwrap();
let screen = term.screen();
// Top row of the live screen, and the oldest row still held.
let live = screen.visible_row_to_stable_row(0);
let oldest = screen.phys_to_stable_row_index(0);
let mut top = self.scroll_top.lock().unwrap();
let next = (top.unwrap_or(live) + delta).clamp(oldest, live);
*top = (next < live).then_some(next);
}
/// One page of the pane, in rows: the child's screen height less a row of
/// overlap, so a page scroll keeps a line of context.
pub fn page_rows(&self) -> isize {
(self.term.lock().unwrap().screen().physical_rows as isize - 1).max(1)
}
/// Snap the view back to the live screen. A terminal does this on a
/// keypress (xterm's scroll-on-key); without it a scrolled-back pane looks
/// frozen the moment you start typing again.
pub fn follow_live(&self) {
*self.scroll_top.lock().unwrap() = None;
}
/// Rows the view sits above the live screen (0 = following). The pane
/// border shows this, so a scrolled-back view is never mistaken for a
/// stalled child.
pub fn scrolled_rows(&self) -> usize {
// `term` before `scroll_top`: the one lock order used here.
let term = self.term.lock().unwrap();
let Some(top) = *self.scroll_top.lock().unwrap() else {
return 0;
};
(term.screen().visible_row_to_stable_row(0) - top).max(0) as usize
}
/// True while the child sits on the **alternate screen** — i.e. a
/// full-screen program Claude Code launched (an `$EDITOR` like nvim, a
/// `git commit` message, a pager) has taken the terminal over.
///
/// Claude Code itself never leaves the normal screen (verified on the wire:
/// `alternate_on=0` on 2.1.247), so the flag means exactly one thing — what
/// is on screen is not Claude Code's prompt. Framing it as one can only
/// crop it: `compact_frame` locates the input box by its two rules, which
/// an editor does not draw. The UI reads this to fullscreen the pane for as
/// long as the editor lasts (`ui::sync_alt_screen`). The alternate screen
/// also carries no scrollback, so `scroll` is a no-op there by itself.
pub fn alt_screen(&self) -> bool {
self.term.lock().unwrap().is_alt_screen_active()
}
/// The child's current cursor shape (set via DECSCUSR). We mirror it onto
/// the outer terminal so the pane shows a bar in insert mode and a block
/// only when Claude Code's vim normal mode asks for one.
@@ -292,7 +471,9 @@ impl EmbeddedTerm {
/// region (one context row above the box, the input box itself however
/// many lines it has grown to, and the statusLine — or a full `@`/`/`
/// menu when one is open). The UI uses this to size the pane so the
/// prompt auto-expands as you type and never scrolls out of view.
/// prompt auto-expands as you type and never scrolls out of view, and so
/// it grows over an active task panel or an error Claude Code just
/// printed.
/// Independent of the terminal's row count, so resizing the pane to this
/// value can't feed back into the measurement.
///
@@ -307,6 +488,17 @@ impl EmbeddedTerm {
Some(((bottom - top + 1) as u16).max(MIN_COMPACT_INNER))
}
/// True when the compact frame is currently taking in an error Claude Code
/// printed (see `error_block_top`). The UI reads this to *cancel* the
/// scheduled ctrl-l transcript wipe — for the same reason fullscreen
/// cancels it: Ink redraws only the live frame, so a wipe would delete the
/// error 400ms after it appeared and nothing would ever bring it back.
/// Only called on the frame a wipe is actually due, so the screen scan
/// costs nothing the rest of the time.
pub fn shows_error(&self) -> bool {
compact_frame_ex(&self.screen_rows()).is_some_and(|f| f.error)
}
/// Inner rows the pane wants for the interactive prompt Claude Code draws
/// for AskUserQuestion / ExitPlanMode (see `interactive_frame`). Measured
/// from the child's screen for the same reason `compact_rows` is: the tap's
@@ -340,15 +532,33 @@ impl EmbeddedTerm {
/// - `Interactive`: the AskUserQuestion / ExitPlanMode prompt the tap grew
/// the pane for — framed from its own top border (see
/// `interactive_view_range`) so the question text is never cropped.
/// - `Full` (fullscreen): the screen verbatim from row 0, nothing cut off.
/// - `Full` (fullscreen): the screen verbatim from row 0, nothing cut off
/// or, once the wheel has scrolled the pane back (`scroll`), a window of
/// the child's scrollback instead. Only fullscreen scrolls: the cropped
/// views frame the input box, which is always at the live bottom.
pub fn render(&self, area: Rect, buf: &mut Buffer, view: PaneView) -> Option<(u16, u16)> {
let term = self.term.lock().unwrap();
let screen = term.screen();
let first = screen.phys_row(0);
let lines = screen.lines_in_phys_range(first..first + screen.physical_rows);
let h = area.height as usize;
// Scrolled back: take the window out of the scrollback instead. A
// stable index maps to `None` once its row has been trimmed away, and
// the live top is the floor — a view *at* it is just following.
let live_first = screen.phys_row(0);
let back = (view == PaneView::Full)
.then(|| *self.scroll_top.lock().unwrap())
.flatten()
.and_then(|top| screen.stable_row_to_phys(top))
.filter(|&p| p < live_first);
let (first, count) = match back {
Some(p) => (p, h.min(live_first - p + screen.physical_rows)),
None => (live_first, screen.physical_rows),
};
let lines = screen.lines_in_phys_range(first..first + count);
if lines.is_empty() {
return None; // zero-height pane: nothing to paint or index into
}
let rows: Vec<String> = lines.iter().map(row_text).collect();
let last = rows.iter().rposition(|t| !t.trim().is_empty()).unwrap_or(0);
let h = area.height as usize;
let (start, end) = match view {
PaneView::Full => {
// The PTY is sized to the pane in fullscreen, but a resize may
@@ -400,7 +610,11 @@ impl EmbeddedTerm {
}
let cursor = term.cursor_pos();
let cy = cursor.y as usize;
(cursor.visibility == CursorVisibility::Visible
// A scrolled-back window is not the live screen, so the cursor row the
// child reports does not index into it — show none, exactly like a
// terminal scrolled away from its prompt.
(back.is_none()
&& cursor.visibility == CursorVisibility::Visible
&& (cursor.x as u16) < area.width
&& cy >= start
&& cy <= end
@@ -424,11 +638,33 @@ fn text_is_rule(t: &str) -> bool {
/// A `@`-file / `/`-command menu row. When such a menu is open it replaces the
/// statusLine + hint/token/effort chrome with a list directly under the input
/// box's bottom rule. These markers are the Claude Code 2.1.x list glyphs;
/// retune here if a CC update changes them.
/// box's bottom rule.
///
/// Claude Code 2.1.x draws four shapes here, and **a glyph is not one of the
/// things they share** (captured from a real child):
/// - fuzzy match inside the project (`@bug`, `@src/`): `+ src/debug/`;
/// - a **directory listing** — the typed token holds a path that leaves the
/// project (`@../de`): plain padded paths, `../destinations/`, no marker at
/// all. The selected row differs only in colour, which `row_text` drops, so
/// text alone can never find a glyph here;
/// - a session/agent mention (`@CLA`): `@claude-cloak-a4 message session · …`;
/// - a `/` command, whose description wraps onto plain continuation rows.
///
/// So the last resort is the *shape* of a path: one whitespace-free token
/// holding a `/`. That is deliberately loose — `compact_frame_ex` votes over
/// two rows, and keeps the statusLine out of the vote, rather than trusting any
/// single row (see the menu detection there).
fn text_is_menu_item(t: &str) -> bool {
let t = t.trim_start();
["+ ", "* ", " ", " "].iter().any(|m| t.starts_with(m)) || t.starts_with('/')
let t = t.trim();
if ["+ ", "* ", " ", " "].iter().any(|m| t.starts_with(m)) {
return true;
}
// `/command …` and `@agent …` rows carry their description inline.
if t.starts_with('/') || t.starts_with('@') {
return true;
}
// Bare listing row: `../destinations/`, `src/game/foo.gd`.
t.contains('/') && !t.contains(char::is_whitespace)
}
/// Status glyphs Claude Code prints in front of a task/todo row: pending
@@ -495,6 +731,79 @@ fn task_block_top(rows: &[String], from: usize) -> Option<usize> {
Some(i)
}
/// How far above the input box an error still counts as "what just happened".
/// The window has to reach well past the single context row — Claude Code
/// parks a blank row and its `✻ Worked…` spinner row between the last
/// transcript line and the box, and a long message wraps over several rows.
/// It is the backstop bound on how far the frame grows for an error; a prompt
/// the user has since sent ends the error's relevance sooner (see
/// `error_block_top`).
const ERROR_SCAN: usize = 12;
/// A row of Claude Code's *own* error reporting — an error the CLI itself
/// produced, as it prints it above the input box:
///
/// ```text
/// ● API Error: Connection lost mid-response. The response above may be
/// incomplete.
/// ```
///
/// **A `⎿` gutter disqualifies the row.** That gutter is a tool talking, and a
/// failed tool is *not* a CLI error: its `tool_result` reaches the feed with
/// the next request, so the feed above the pane already shows it and the pane
/// has no reason to grow. Only the CLI's own errors are invisible to the feed
/// (see the pane-error invariant), and they never carry a gutter — so the
/// gutter is the whole test, and `⎿ Error: Exit code 3` is deliberately not
/// a match.
///
/// The bullet *is* stripped (Claude Code pads it with U+00A0, which
/// `trim_start` handles), and the colon in `Error:` is required: `● Error
/// handling lives in src/foo.rs` is ordinary assistant prose. Neither shape
/// can be recognised by colour — the tool error renders palette 211 and the
/// API error 220, both theme values, so text shape is the only stable signal.
fn text_is_error_row(t: &str) -> bool {
let t = t.trim_start();
if t.starts_with('⎿') {
return false;
}
let t = t.strip_prefix(['●', '⏺']).unwrap_or(t).trim_start();
t.starts_with("Error:") || t.starts_with("API Error") || t.starts_with(['✗', '✘', '✖', '✕'])
}
/// A user prompt echoed into the transcript (` resume`). Above the input
/// box's top rule that is the only thing a `` row can be — the box's own ``
/// and a menu's `` selection marker both sit *below* the rule, outside the
/// window `error_block_top` scans.
fn text_is_prompt_row(t: &str) -> bool {
t.trim_start().starts_with('')
}
/// Topmost row the compact frame extends to in order to show an error Claude
/// Code just printed, or None when no error row sits within `ERROR_SCAN` of
/// the input box.
///
/// Takes the *highest* error row in the window rather than the lowest, so a
/// turn that reported several shows all of them, and a report wrapped over
/// many rows is framed from its first line instead of its tail. Nothing is
/// pulled in above that row: a CLI error names itself, unlike a tool error,
/// which needed the `● Bash(…)` row above it and is no longer framed at all.
///
/// The window stops at the newest **prompt row**, which is the real "this is
/// over" signal: once the user has typed something else, the error belongs to
/// a previous exchange and the pane must go back to being prompt-only.
/// `ERROR_SCAN` alone is far too coarse for that — Claude Code's own reply to
/// an error is two rows, so the error would sit inside the window for the
/// whole of the next turn and hold the pane open through it.
fn error_block_top(rows: &[String], from: usize) -> Option<usize> {
let mut floor = from.saturating_sub(ERROR_SCAN);
if let Some(p) = (floor..=from).rev().find(|&i| text_is_prompt_row(&rows[i])) {
// `p + 1 > from` when the prompt is the last row: an empty range, so
// nothing is found and the frame collapses, which is the intent.
floor = p + 1;
}
(floor..=from).find(|&i| text_is_error_row(&rows[i]))
}
/// Step one row further up when `i` lands on a blank row, so the frame's top
/// context row carries text (the panel is drawn with a blank `marginTop` row
/// above it, and showing that blank instead of the spinner row wastes a line).
@@ -536,6 +845,10 @@ struct CompactFrame {
bottom: usize,
/// The region ends on an open `@`/`/` menu rather than the statusLine.
menu_open: bool,
/// The region was extended upwards to take in an error Claude Code
/// printed (`error_block_top`). `EmbeddedTerm::shows_error` reports this
/// so the UI can cancel the scheduled ctrl-l wipe.
error: bool,
}
/// Same as `compact_frame`, but keeps the fields `compact_view_range` needs.
@@ -557,17 +870,40 @@ fn compact_frame_ex(rows: &[String]) -> Option<CompactFrame> {
Some(t) => skip_blank_up(rows, t.saturating_sub(1)),
None => ctx_top,
};
// An error Claude Code just printed is the one part of its UI the feed
// above cannot stand in for: a tool_result only reaches us with the *next*
// request, so a turn that dies on the error never sends one, and a retry
// exhaustion or an `API Error` row is Claude Code's own text, never in the
// stream at all. So the frame walks further up to take the report in —
// above the task panel too, since the error came before it.
let err_top = error_block_top(rows, view_top);
let view_top = err_top.unwrap_or(view_top);
// An open `@`/`/` menu replaces the chrome below the bottom rule with a
// list. Scan the *whole* region under the rule for a menu row, not just the
// one immediately below it: the list can start after a blank separator or a
// header row, and only the highlighted item carries a recognisable glyph
// (unselected file rows are plain indented names), so checking a single row
// missed the menu whenever that row happened not to be the selected one.
// The persistent chrome rows (statusLine / hint / tokens / effort) never
// match `text_is_menu_item`, so scanning stays free of false positives.
let menu_open = last > bot_div && (bot_div + 1..=last).any(|i| text_is_menu_item(&rows[i]));
// list. Deciding that takes *two* rows, because a menu row is not reliably
// marked (see `text_is_menu_item`) and the statusLine's text is the user's,
// so it can look like anything — a bare `~/projects/foo` included:
// - the row right under the rule (`head`) must be a menu row or blank. It
// is shown either way (it is the statusLine's slot when no menu is up),
// so a one-row menu needs no detection at all; blank counts because the
// chrome never leaves that row empty, while a menu may separate itself
// from the box.
// - at least one *further* row must look like a menu row too. This is what
// keeps a two-line statusLine (line 2 a bare path) from reading as a
// menu and dragging the whole hint/token/effort chrome into the pane.
// Scanning the whole tail (not just `bot_div + 1`) is also required: only
// the highlighted row of a fuzzy list carries a glyph, so checking one row
// collapsed the pane whenever that row was not the selected one.
let head = bot_div + 1;
let head_ok = last > head && (text_is_menu_item(&rows[head]) || rows[head].trim().is_empty());
let menu_open = head_ok && (head + 1..=last).any(|i| text_is_menu_item(&rows[i]));
let view_bottom = if menu_open { last } else { (bot_div + 1).min(last) };
Some(CompactFrame { top: view_top, ess_top: ctx_top, bottom: view_bottom, menu_open })
Some(CompactFrame {
top: view_top,
ess_top: ctx_top,
bottom: view_bottom,
menu_open,
error: err_top.is_some(),
})
}
/// Pick the `(start, end)` window `render` shows for `PaneView::Compact`,
@@ -680,6 +1016,118 @@ fn interactive_view_range(rows: &[String], last: usize, h: usize) -> (usize, usi
(end + 1 - h, end)
}
/// Everything the post-exec image needs to take a running `claude` child back
/// over (`EmbeddedTerm::adopt`). An `execve` keeps our pid, our open fds and
/// our children, so the child never notices the reload — but every Rust-side
/// handle is gone, which is why the pane is rebuilt from a bare fd + pid.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct PtyHandoff {
/// PTY master. `reload::keep_open` clears its FD_CLOEXEC before the exec,
/// so this number is still valid — and still the same pty — afterwards.
pub master_fd: RawFd,
pub child_pid: u32,
pub session_id: String,
pub pane_token: String,
pub pty_rows: u16,
pub cols: u16,
}
/// A PTY master rebuilt from an inherited fd. Implements just enough of
/// `MasterPty` to stand in for portable-pty's own master, so `EmbeddedTerm`
/// keeps one type for both the freshly spawned and the adopted pane.
///
/// Note its writer is a plain `File`: portable-pty's writer sends EOT to the
/// child when dropped, which would end the adopted session on every teardown.
#[derive(Debug)]
struct AdoptedMaster {
fd: OwnedFd,
}
impl AdoptedMaster {
/// Duplicate the master fd for an independent reader/writer handle.
/// `F_DUPFD_CLOEXEC` keeps the clone out of the *next* reload's exec —
/// only the one fd named in the handoff is meant to survive.
fn dup(&self) -> anyhow::Result<std::fs::File> {
let fd = unsafe { libc::fcntl(self.fd.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 0) };
if fd < 0 {
return Err(std::io::Error::last_os_error()).context("dup pty master");
}
Ok(unsafe { std::fs::File::from_raw_fd(fd) })
}
}
impl MasterPty for AdoptedMaster {
fn resize(&self, size: PtySize) -> Result<(), anyhow::Error> {
let ws = libc::winsize {
ws_row: size.rows,
ws_col: size.cols,
ws_xpixel: size.pixel_width,
ws_ypixel: size.pixel_height,
};
let rc = unsafe { libc::ioctl(self.fd.as_raw_fd(), libc::TIOCSWINSZ as _, &ws) };
if rc != 0 {
return Err(std::io::Error::last_os_error()).context("ioctl(TIOCSWINSZ)");
}
Ok(())
}
fn get_size(&self) -> Result<PtySize, anyhow::Error> {
let mut ws: libc::winsize = unsafe { std::mem::zeroed() };
let rc = unsafe { libc::ioctl(self.fd.as_raw_fd(), libc::TIOCGWINSZ as _, &mut ws) };
if rc != 0 {
return Err(std::io::Error::last_os_error()).context("ioctl(TIOCGWINSZ)");
}
Ok(PtySize {
rows: ws.ws_row,
cols: ws.ws_col,
pixel_width: ws.ws_xpixel,
pixel_height: ws.ws_ypixel,
})
}
fn try_clone_reader(&self) -> Result<Box<dyn Read + Send>, anyhow::Error> {
Ok(Box::new(self.dup()?))
}
fn take_writer(&self) -> Result<Box<dyn Write + Send>, anyhow::Error> {
Ok(Box::new(self.dup()?))
}
fn process_group_leader(&self) -> Option<libc::pid_t> {
match unsafe { libc::tcgetpgrp(self.fd.as_raw_fd()) } {
pid if pid > 0 => Some(pid),
_ => None,
}
}
fn as_raw_fd(&self) -> Option<RawFd> {
Some(self.fd.as_raw_fd())
}
fn tty_name(&self) -> Option<std::path::PathBuf> {
None
}
}
/// Signals a child by pid. Stands in for portable-pty's killer, whose handle
/// doesn't survive the exec. SIGHUP matches what portable-pty sends, so an
/// adopted pane dies exactly like a spawned one.
#[derive(Debug)]
struct PidKiller(u32);
impl ChildKiller for PidKiller {
fn kill(&mut self) -> std::io::Result<()> {
if unsafe { libc::kill(self.0 as i32, libc::SIGHUP) } != 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
}
fn clone_killer(&self) -> Box<dyn ChildKiller + Send + Sync> {
Box::new(PidKiller(self.0))
}
}
impl Drop for EmbeddedTerm {
fn drop(&mut self) {
let _ = self.killer.kill();
@@ -720,10 +1168,10 @@ fn conv_color(c: ColorAttribute) -> Option<Color> {
}
}
/// Claude Code's own default model, as a `--model` argument (`opus`,
/// `opus[1m]`, …). Its settings files are the one place the **1M-context**
/// choice is written down — `/model` saves the pick there, suffix and all,
/// while a transcript records the same base model id either way.
/// Claude Code's own default model, as it writes it (`opus`, `opus[1m]`, …).
/// Only the *model* is used: `App::spawn_arg` / `App::resume_arg` strip any
/// suffix and re-apply the 1M window themselves, so a `default` pick or a
/// session we know nothing about still names a model we can attach `[1m]` to.
///
/// Resolved the way Claude Code layers it: `ANTHROPIC_MODEL`, then
/// project-local, project, and user settings. `None` when nothing sets one
@@ -757,21 +1205,21 @@ fn settings_model(path: &std::path::Path) -> Option<String> {
.map(str::to_string)
}
/// Background scan that replaces `App::model_choices` with the live alias set
/// read from the installed `claude` binary (see `discover_model_aliases`).
/// Runs off the UI thread; on failure the seeded fallback list stays in place.
/// Background scan that replaces `App::models` with the live sets read from the
/// installed `claude` binary (see `discover_models`). Runs off the UI thread;
/// on failure the seeded fallback catalog stays in place.
pub fn spawn_model_discovery(app: crate::app::SharedApp) {
std::thread::spawn(move || {
if let Some(choices) = discover_model_choices() {
crate::app::lock_app(&app).model_choices = choices;
if let Some(models) = discover_models() {
crate::app::lock_app(&app).models = models;
}
});
}
/// Best-effort discovery of the models the installed `claude` accepts (aliases
/// like `opus`, `sonnet`, `haiku`, `fable`, plus their `<alias>[1m]`
/// long-context variants), so the `a` picker tracks new models without us
/// hardcoding a list that drifts.
/// like `opus`, `sonnet`, `haiku`, `fable`, and which of them ship a
/// `<alias>[1m]` long-context variant), so the `a` picker and the always-1M
/// policy track new models without us hardcoding a list that drifts.
///
/// Claude Code ships as one self-contained executable with its (minified) JS
/// bundle embedded; the alias set appears verbatim as a JSON array literal like
@@ -779,28 +1227,25 @@ pub fn spawn_model_discovery(app: crate::app::SharedApp) {
/// quoted `"sonnet[1m]"` literal. We resolve the `claude` binary on PATH and
/// read it once. This issues **no API request** (the project's core constraint)
/// and never executes claude. Returns None if the binary can't be found/read or
/// nothing matches — the caller keeps its built-in fallback list.
fn discover_model_choices() -> Option<Vec<(String, String)>> {
/// nothing matches — the caller keeps its built-in fallback catalog.
fn discover_models() -> Option<crate::app::Models> {
let bytes = std::fs::read(claude_binary_path()?).ok()?;
let aliases = longest_alias_array(&bytes)?;
Some(model_choices_from(&bytes, &aliases))
Some(models_from(&bytes, aliases))
}
/// Assemble picker entries `(label, --model arg)`: `default` (no `--model`
/// flag) first, then every alias, then the `<alias>[1m]` long-context variants
/// the binary actually ships (see `long_context_tokens`).
fn model_choices_from(bytes: &[u8], aliases: &[String]) -> Vec<(String, String)> {
let long = long_context_tokens(bytes);
let mut choices: Vec<(String, String)> = vec![("default".into(), String::new())];
choices.extend(aliases.iter().map(|a| (a.clone(), a.clone())));
choices.extend(
aliases
.iter()
.map(|a| format!("{a}[1m]"))
.filter(|v| long.contains(v))
.map(|v| (format!("{v} (1M context)"), v)),
);
choices
/// Build the catalog: every alias, plus every model the binary really ships a
/// `[1m]` variant for — never assumed. `long` keeps the *base* name of each
/// `"<name>[1m]"` literal (see `long_context_tokens`), so it covers full model
/// ids as well as aliases: a session whose transcript names a model with no
/// alias still resumes at the long window.
fn models_from(bytes: &[u8], aliases: Vec<String>) -> crate::app::Models {
let mut long: Vec<String> = long_context_tokens(bytes)
.iter()
.map(|t| crate::app::base_model(t).to_string())
.collect();
long.sort();
crate::app::Models { aliases, long }
}
/// Resolve `claude` on `PATH` to a readable file path (symlinks followed).
@@ -932,14 +1377,20 @@ mod tests {
}
#[test]
fn appends_1m_choices_for_aliases_that_have_them() {
let bytes = br#"["sonnet","opus","haiku"] "sonnet[1m]" "opus[1m]""#;
fn catalog_marks_only_aliases_with_a_real_1m_variant() {
let bytes =
br#"["sonnet","opus","haiku"] "sonnet[1m]" "opus[1m]" "claude-mythos-1[1m]""#;
let aliases = longest_alias_array(bytes).unwrap();
let got = model_choices_from(bytes, &aliases);
let args: Vec<&str> = got.iter().map(|c| c.1.as_str()).collect();
// default (no flag), the plain aliases, then only the real 1M variants.
assert_eq!(args, ["", "sonnet", "opus", "haiku", "sonnet[1m]", "opus[1m]"]);
assert_eq!(got[4].0, "sonnet[1m] (1M context)");
let models = models_from(bytes, aliases);
assert_eq!(models.aliases, ["sonnet", "opus", "haiku"]);
// Full ids count too: a transcript can name a model no alias covers.
assert_eq!(models.long, ["claude-mythos-1", "opus", "sonnet"]);
assert_eq!(models.arg("claude-mythos-1"), "claude-mythos-1[1m]");
assert_eq!(models.arg("haiku"), "haiku");
// Picker rows: every model at the window `Models::arg` gives it —
// 1M where the binary ships one, plain where it does not.
let args: Vec<String> = models.choices().into_iter().map(|c| c.1).collect();
assert_eq!(args, ["", "sonnet[1m]", "opus[1m]", "haiku"]);
}
/// Build a `rows` fixture (visible text per screen row) from string slices.
@@ -1020,6 +1471,93 @@ mod tests {
assert_eq!(compact_frame(&screen), Some((1, 8)));
}
#[test]
fn shows_unmarked_path_listing_menu() {
// Real capture, `@../de` typed in ~/projects/destinations: a path that
// leaves the project switches CC to a *listing*, whose rows carry no
// `+`/`` marker at all (the selected one differs only in colour).
// Regression: the pane showed the first hit and cropped the rest.
let screen = rows(&[
"", "",
&format!("{RULE} minimal ──"), // 2: top rule
" @../de", // 3: input
RULE, // 4: bottom rule
" ../destinations/", // 5: selected (colour only)
" ../destinations-player-host/", // 6
" ../destinations-terrain-unify/", // 7: last non-blank
"", "",
]);
assert_eq!(compact_frame(&screen), Some((1, 7)));
}
#[test]
fn shows_agent_mention_menu() {
// `@CLA` lists sessions/agents: `@name` + an inline description.
let screen = rows(&[
"", "",
&format!("{RULE} minimal ──"), // 2: top rule
" @CLA", // 3: input
RULE, // 4: bottom rule
" @claude-cloak-a4 message session · active 30s ago", // 5
" @claude-cloak-10 message session · active 2m ago", // 6
"", "",
]);
assert_eq!(compact_frame(&screen), Some((1, 6)));
}
#[test]
fn single_item_menu_needs_no_detection() {
// One match, and it sits in the statusLine's own slot — always shown.
let screen = rows(&[
"", "",
&format!("{RULE} minimal ──"), // 2: top rule
" @../destinations-t", // 3: input
RULE, // 4: bottom rule
" ../destinations-terrain-unify/", // 5: the only hit
"", "",
]);
assert_eq!(compact_frame(&screen), Some((1, 5)));
// A blank separator before a lone item still shows the item.
let screen = rows(&[
"", "",
&format!("{RULE} minimal ──"),
" @../destinations-t",
RULE,
"", // 5: separator
" ../destinations-terrain-unify/", // 6
"", "",
]);
assert_eq!(compact_frame(&screen), Some((1, 6)));
}
#[test]
fn path_shaped_statusline_is_not_a_menu() {
// The statusLine is the user's own text, so it can be a bare path —
// which is exactly the shape an unmarked menu row has. Detection votes
// over a second row for this reason: a path-ish statusLine (even a
// two-line one) must not drag the hint/token chrome into the pane.
let screen = rows(&[
"", "",
&format!("{RULE} minimal ──"), // 2: top rule
"", // 3: input
RULE, // 4: bottom rule
"~/projects/destinations", // 5: statusLine (kept)
"⏵⏵ bypass permissions (shift+tab)", // 6: chrome (cropped)
" 0 tokens", // 7: chrome (cropped)
]);
assert_eq!(compact_frame(&screen), Some((1, 5)));
let screen = rows(&[
"", "",
&format!("{RULE} minimal ──"),
"",
RULE,
"Opus 5 · xhigh", // 5: statusLine line 1 (kept)
"~/projects/destinations", // 6: statusLine line 2 (cropped)
"? for shortcuts", // 7
]);
assert_eq!(compact_frame(&screen), Some((1, 5)));
}
#[test]
fn overflowing_menu_keeps_input_box_visible() {
// Regression: when a long `@`/`/` match list doesn't fit in the pane's
@@ -1201,6 +1739,125 @@ mod tests {
assert_eq!(compact_frame(&screen), Some((2, 6)));
}
/// A turn that died on an API error: Claude Code's own `● API Error:` row,
/// then a blank, then its `✻ …` timing row above the input box — so the
/// single context row the frame used to show landed on the blank.
fn api_error_screen() -> Vec<String> {
rows(&[
" now fail", // 0
"", // 1
"● API Error: 400 upstream rejected this", // 2: the error
"", // 3
"✻ Baked for 0s · done 8.15", // 4: old context row
"", // 5
&format!("{RULE} minimal ──"), // 6: top rule
"", // 7
RULE, // 8
"Session: ▓▓░ Context | Opus", // 9: statusLine
"⏵⏵ bypass permissions", // 10: chrome (cropped)
])
}
#[test]
fn frames_an_api_error_above_the_input_box() {
// The whole point: an `API Error` row is Claude Code's own text and is
// never in the API stream, so the pane is the only place it is ever
// shown. It must be inside the frame, not two rows above it.
assert_eq!(compact_frame(&api_error_screen()), Some((2, 9)));
assert!(compact_frame_ex(&api_error_screen()).unwrap().error);
}
#[test]
fn a_failed_tool_does_not_grow_the_pane() {
// Only the CLI's *own* errors are pane-only. A failed tool reports
// through the `⎿` gutter, and its tool_result reaches the feed with
// the next request — so the feed already shows it and the pane stays
// at its plain one-context-row frame.
let screen = rows(&[
"● Running that.", // 0
"", // 1
"● Bash(echo boom >&2; exit 3)", // 2
" ⎿ Error: Exit code 3", // 3: a tool talking
" boom", // 4: wrapped tail
"", // 5
"✻ Osmosing… (11s)", // 6
"", // 7
&format!("{RULE} minimal ──"), // 8: top rule
"", // 9
RULE, // 10
"Session: ▓▓░ Context | Opus", // 11
]);
assert_eq!(compact_frame(&screen), Some((7, 11)));
assert!(!compact_frame_ex(&screen).unwrap().error);
}
#[test]
fn error_frame_reaches_over_an_active_task_panel() {
// The error came before the panel, so it sits *above* it — the frame
// has to take in both, not stop at the panel's top.
let mut screen = task_panel_screen();
screen[0] = "● API Error: Connection lost mid-response.".into();
assert_eq!(compact_frame(&screen), Some((0, 14)));
}
#[test]
fn ordinary_prose_and_hints_are_not_errors() {
// Only the CLI's own rows count. A `⎿` gutter is a tool talking, so
// every gutter shape is out — the idle tip, a plain result, and a
// failed tool alike (that last one is the whole point: it is already
// in the feed). `Error` without a colon is prose.
assert!(!text_is_error_row("● Error handling lives in src/foo.rs"));
assert!(!text_is_error_row(" ⎿ Tip: Say \"fan out subagents\""));
assert!(!text_is_error_row(" ⎿ Read 20 lines"));
assert!(!text_is_error_row(" ⎿ Error: File does not exist."));
assert!(!text_is_error_row("\u{a0}Error: Exit code 3"));
assert!(text_is_error_row("● API Error: 400"));
assert!(text_is_error_row("● API Error: Connection lost mid-response."));
// Claude Code pads the bullet with a non-breaking space too.
assert!(text_is_error_row("\u{a0}Error: could not reach the API"));
// An idle box with no error keeps the plain one-context-row frame.
assert!(!compact_frame_ex(&task_panel_screen()).unwrap().error);
}
#[test]
fn a_new_prompt_ends_the_errors_relevance() {
// Reported: the pane kept holding an `API Error` open through the
// whole next turn. Claude Code's reply to an error is only two rows,
// so `ERROR_SCAN` alone never expires it — the prompt the user typed
// since is the real signal that the error is history.
let screen = rows(&[
"● API Error: Connection lost mid-response.", // 0
"✻ Baked for 3m 55s · done 11.53", // 1
" resume", // 2: sent since
"· Cascading… (26s)", // 3
"", // 4
&format!("{RULE} minimal ──"), // 5: top rule
"", // 6
RULE, // 7
"Session: ▓▓░ Context | Opus", // 8
]);
assert!(!compact_frame_ex(&screen).unwrap().error);
assert_eq!(compact_frame(&screen), Some((4, 8)));
// Without that prompt row the same error is still framed.
let mut fresh = screen.clone();
fresh[2] = "● Retrying…".into();
assert_eq!(compact_frame(&fresh), Some((0, 8)));
}
#[test]
fn a_stale_error_scrolls_out_of_the_scan_window() {
// The window is what makes this self-limiting: as the next turn prints
// output the error drifts past `ERROR_SCAN` and the pane shrinks back
// instead of staying grown for the rest of the session.
let mut screen = vec!["● API Error: Connection lost".to_string()];
screen.extend((0..ERROR_SCAN + 2).map(|i| format!("● line {i}")));
screen.extend([format!("{RULE} minimal ──"), "".into(), RULE.into(), "Session".into()]);
assert!(!compact_frame_ex(&screen).unwrap().error);
// …while two rows closer it is back in reach.
screen.drain(1..3);
assert_eq!(compact_frame(&screen).unwrap().0, 0);
}
#[test]
fn no_input_box_yields_none() {
// Startup banner only — no rules, so the caller falls back.
@@ -1215,6 +1872,67 @@ mod tests {
assert!(longest_alias_array(br#"["Opus","sonnet"]"#).is_none());
}
/// First rendered row of `view`, trimmed.
fn top_row(et: &EmbeddedTerm, area: Rect, view: PaneView) -> String {
let mut buf = Buffer::empty(area);
et.render(area, &mut buf, view);
(0..area.width).map(|x| buf[(x, 0)].symbol()).collect::<String>().trim().to_string()
}
/// The fullscreen pane scrolls its own scrollback (Claude Code grabs no
/// mouse, so there is nothing to forward to it): a real child writes more
/// rows than the screen holds, and `scroll` has to reach the ones that left
/// it — then hand the view back to the live screen at the bottom.
#[test]
fn fullscreen_pane_scrolls_into_the_scrollback() {
let mut cmd = CommandBuilder::new("sh");
cmd.args(["-c", "seq 1 60; sleep 5"]);
let area = Rect::new(0, 0, 40, 10);
let et = EmbeddedTerm::spawn_cmd(cmd, "test-session".into(), "test-token".into(), 6, 40).unwrap();
// Wait for the tail of the child's output to reach the live screen.
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let mut buf = Buffer::empty(area);
et.render(area, &mut buf, PaneView::Full);
let text: String = (0..area.height)
.flat_map(|y| (0..area.width).map(move |x| (x, y)))
.map(|(x, y)| buf[(x, y)].symbol())
.collect();
if text.contains("60") {
break;
}
assert!(Instant::now() < deadline, "child output never arrived: {text:?}");
std::thread::sleep(Duration::from_millis(50));
}
let live = top_row(&et, area, PaneView::Full);
assert_eq!(et.scrolled_rows(), 0, "starts out following the live screen");
// Up 40 rows: a window of rows that have left the screen.
et.scroll(-40);
assert_eq!(et.scrolled_rows(), 40);
let scrolled = top_row(&et, area, PaneView::Full);
assert_ne!(scrolled, live, "the view did not move");
let n: usize = scrolled.parse().expect("a `seq` line number");
assert!(n < live.parse::<usize>().unwrap(), "scrolled the wrong way: {n}");
// No cursor while scrolled away — its row does not index this window.
let mut buf = Buffer::empty(area);
assert_eq!(et.render(area, &mut buf, PaneView::Full), None);
// A cropped view never scrolls: it frames the input box at the bottom.
assert_eq!(top_row(&et, area, PaneView::Compact), live);
// Past the bottom re-engages follow mode rather than pinning to it.
et.scroll(1000);
assert_eq!(et.scrolled_rows(), 0);
assert_eq!(top_row(&et, area, PaneView::Full), live);
// …as does `follow_live`, from anywhere.
et.scroll(-10);
assert_eq!(et.scrolled_rows(), 10);
et.follow_live();
assert_eq!(et.scrolled_rows(), 0);
assert_eq!(top_row(&et, area, PaneView::Full), live);
}
/// Full pipeline: PTY spawn → reader thread → wezterm-term model →
/// ratatui buffer. Headless-safe: the *child* gets the tty, not us.
#[test]

3047
src/ui.rs

File diff suppressed because it is too large Load Diff