Compare commits
11 Commits
c7eddf3bde
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15b20a8af1 | ||
|
|
e9e224f856 | ||
|
|
ae454a3d2d | ||
|
|
043c99cdeb | ||
|
|
6bb7e7d424 | ||
|
|
b9d7d2c969 | ||
|
|
73871cb1dc | ||
|
|
a5a6092579 | ||
|
|
1f483ad3d1 | ||
|
|
8907766b5b | ||
|
|
ba6b18e7d9 |
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -353,6 +353,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"futures-util",
|
||||
"libc",
|
||||
"portable-pty",
|
||||
"ratatui",
|
||||
"reqwest",
|
||||
|
||||
@@ -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"
|
||||
|
||||
75
README.md
75
README.md
@@ -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.
|
||||
|
||||
@@ -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
513
src/ansi.rs
Normal 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é"), "aé");
|
||||
// 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
2972
src/app.rs
File diff suppressed because it is too large
Load Diff
297
src/keymap.rs
Normal file
297
src/keymap.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
78
src/main.rs
78
src/main.rs
@@ -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)
|
||||
}
|
||||
|
||||
127
src/proxy.rs
127
src/proxy.rs
@@ -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
630
src/reload.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
576
src/sessions.rs
576
src/sessions.rs
@@ -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 ¬es {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
836
src/term.rs
836
src/term.rs
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user