diff --git a/CLAUDE.md b/CLAUDE.md index 1b864c4..32b26a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,12 +10,27 @@ extra usage is the core constraint of this project. ``` src/main.rs entry; tokio runtime for proxy task, TUI on main thread; --headless mode -src/proxy.rs axum fallback handler: buffers request body (for session metadata), +src/proxy.rs axum fallback handler: buffers request body (for session metadata, + tool results, and user prompts — `app::record_user_prompt` lifts + the trailing user message into a Kind::User feed entry), forwards via reqwest, streams response back unbuffered, tees SSE src/sse.rs incremental SSE parser; tolerant of chunk splits mid-event/mid-UTF-8 src/app.rs Arc> shared state; Tap = one in-flight tapped request, translates SSE events → session Entries (Drop closes it out) src/ui.rs ratatui rendering @ ~30fps; session list + scrollable feed + (FeedCache: per-entry rendered lines + wrapped heights, only + changed entries re-render; the viewport window of lines is + handed to ratatui so scroll state is usize end-to-end) +src/markdown.rs wraps tui-markdown: renders GFM tables itself (box-drawing, + width-fitted wrapped columns) and strips heading `#` markers — + the pinned tui-markdown 0.3.5 does neither +src/sessions.rs on-disk session history: background scanner thread keeps + App::disk_sessions fresh (~1/s poll, labels re-read only on + mtime change); load_view/load_history rebuild a feed Session + from a JSONL transcript (lazily, on first view); build_tree + parses uuid/parentUuid chains into a TurnTree (one node per + real user prompt; rewinds leave fork points); materialize + writes a new session file from a chosen set of turns src/term.rs embedded claude pane: spawns `claude --session-id ` in a portable-pty routed through the proxy; wezterm-term models the screen (and answers terminal queries); renderer paints cells @@ -29,7 +44,11 @@ UI thread redraws on its own tick (no channel; just the mutex). - **Latency-neutral pass-through**: response bytes are forwarded as-is, never buffered or rewritten. Auth headers pass through untouched. If the tap code - panics or misparses, the proxy must still relay bytes (tee is best-effort). + panics or misparses, the proxy must still relay bytes (tee is best-effort): + chunks are `try_send`-cloned into a bounded channel and parsed on a separate + task (dropped on overflow, never blocking the relay), and the proxy/tap side + locks the app mutex poison-tolerantly (`app::lock_app`) so a UI panic can't + kill forwarding. - **`accept-encoding` is stripped** from forwarded requests so the upstream sends identity encoding we can parse in transit. Don't "fix" that. - Hop-by-hop headers (`content-length`, `transfer-encoding`, etc.) are stripped @@ -39,44 +58,109 @@ UI thread redraws on its own tick (no channel; just the mutex). builds `user_…_session_`; `proxy::session_key` handles both. Concurrent requests (subagents) share a session but each `Tap` tracks its own current entry index — entries/sessions are append-only, so indices stay stable. -- The embedded pane's session is matched by the `--session-id` UUID we - generate; the tap drives pane behavior: grows it for AskUserQuestion / +- **One app instance = one proxy port = at most one embedded claude** + (`EmbedUi::term` / `App::embed_session`, matched by the `--session-id` / + `--resume` UUID we spawn with). Every other live session is an external + claude pointed at our port: observable, never attachable. +- The session list merges live sessions (first, indices stable) with this + directory's past sessions from `~/.claude/projects//*.jsonl` + as dimmed stubs (deduped by uuid — a live session's file is on disk too). + **Tab is viewing only, never a process operation**: selecting a stub + lazy-loads its transcript into `App::history`; tabbing off the embedded + session hides the pane without killing the child (instant to come back). + ctrl-↓ is the commit point that attaches the pane to the selection: + reveal+focus if it's the embedded session, `claude --resume ` + (kill + respawn) for disk stubs and dead embeds, fresh `--session-id` + spawn when there's nothing. Live *external* sessions are guarded — their + instance may still run elsewhere and a second `--resume` would fork the + transcript — but a second ctrl-↓ within 3s forces it (liveness is + unknowable: an idle claude sends no traffic; `EmbedUi::past_embeds` skips + the guard for sessions whose instance we killed ourselves). `--session-id` + cannot be combined with `--resume` (CLI rejects it without `--fork-session`). +- **Turn tree / branching** (lazygit/yazi-style, all in the sessions panel): + `space` (or `→`/`l`) expands the selected session's turn tree — one row per + real user prompt, abandoned rewind branches indented `⑂` under their fork + point, trunk continuing below. j/k/↑/↓ walk sessions *and* turns (they + never scroll the feed; the wheel and PgUp/PgDn/g/G do that). Highlighting + a turn switches the feed to the on-disk transcript along the path through + that turn and pins the turn's prompt to the viewport top (HistoryView + caches per uuid, rebuilt when the leaf changes; FeedCache keys on + leaf+live so views of the same uuid don't share slots). `v` anchors a + contiguous visual range, `b` materializes a **new fully decoupled session + file** — chain root→turn, or exactly the visual range stitched together + (sessionId rewritten, each turn's head re-parented onto the previous + turn's tail, our own ai-title record gives it the `⑂ …` label) — injected + as the selected top stub. Branching never touches a process: ctrl-↓ stays + the only spawn/kill commit point. +- The tap drives pane behavior: grows it for AskUserQuestion / ExitPlanMode (sized from the question's option count) before Claude Code renders the prompt, shrinks when the tool_result echoes back, and schedules a ctrl-l transcript wipe 400ms after each turn (the pane is prompt-only; the feed shows the context). - Tool input streams as raw JSON fragments; pretty-printed only on - `content_block_stop`. Text re-renders markdown every frame, so partial - markdown self-heals. + `content_block_stop`. Streaming text re-renders markdown on every change + (FeedCache fingerprints by content length + done + result), so partial + markdown self-heals; completed entries render from cache. ## Gotchas - `tui-markdown` is pinned `=0.3.5`: later versions use `ratatui-core` (0.30 - alpha types), incompatible with ratatui 0.29. + alpha types), incompatible with ratatui 0.29. Its gaps (no tables, literal + heading markers) are compensated in `src/markdown.rs`, not by upgrading. - `wezterm-term`/`wezterm-surface` are not on crates.io: pinned to a git rev of the wezterm monorepo (keep both revs identical). - The pane's render window crops Claude Code chrome by *position* (`term.rs`: `BOTTOM_CROP`, start ≥ 2, `PTY_PAD`) — tuned to the current Claude Code UI; retune there if an update adds/removes chrome rows. + Cropping only applies to the compact pane: fullscreen renders the child's + screen verbatim from row 0 with the PTY sized exactly to the pane + (`crop` flag on `resize`/`render`). - Keybindings avoid Alt entirely: on layouts like dk_mac_fixed, Alt composes characters (alt-c = ©) and never reaches the app as a modifier. Pane keys: - F2 toggle, ctrl-↓ focus claude, ctrl-↑ focus feed. `CT_DEBUG_KEYS=1` shows - raw key events in the status bar. + F2 toggle, ctrl-↓ attach pane to selected session (resume/spawn/focus), + ctrl-↑ focus feed, ctrl-f fullscreen toggle (only while the pane is + focused), ctrl-q quit (global; needed while the pane is focused, where + plain `q` is forwarded to the child), c attach most-recent past session. + List keys: j/k/↑/↓ move the session/turn highlight, space/→/← expand/ + enter/leave the turn tree, n opens the model picker popup and spawns a + brand-new `claude --session-id … [--model …]` (kills any current pane — + `show_embed_new`; saves resume-then-/clear to get a fresh chat). The picker + list comes from `App::model_choices`: seeded with `default_model_choices`, + then replaced by `term::spawn_model_discovery` — a background scan that + reads the live model-alias array (`["sonnet","opus","haiku","fable",…]`) + straight out of the installed `claude` ELF (single self-contained binary + with the JS bundle embedded). No API call, never runs claude — just resolves + `claude` on PATH and greps its bytes for the longest lowercase-token array + anchored by `opus`+`sonnet`. Tab/BackTab cycle sessions (`p` no longer + mirrors BackTab). v visual range, b branch, Esc unwinds (visual → tree → + quit). The feed + scrolls only via wheel / PgUp / PgDn / g / G. `CT_DEBUG_KEYS=1` shows raw + key events in the status bar. +- Mouse is captured: wheel always scrolls the feed (regardless of focus), and + left-drag selects screen text, copied on release via OSC 52 (like Claude + Code). Native terminal selection therefore needs shift held. - ratatui needs feature `unstable-rendered-line-info` for `Paragraph::line_count` - (used for follow/auto-scroll). + (used to compute cached per-entry wrapped heights for follow/auto-scroll). - reqwest is `default-features = false` + `rustls-tls,stream` — don't enable compression features (would re-add accept-encoding). -- Bind failures (port in use) only surface in the TUI status bar; check for a - stale `claude-thinking` process holding 8484. +- The listener is bound in `main` before the TUI starts: prefers 8484, falls + back to an OS-assigned free port so multiple instances coexist (each pane + gets the actual port via `ANTHROPIC_BASE_URL`). `CT_PORT` pins the port and + turns bind failure into a hard startup error. - Testing: SSE parser has unit tests (`cargo test`). For a live pass-through - check: `--headless`, then POST to `127.0.0.1:8484/v1/messages` without auth — - a relayed 401 from Anthropic proves the round-trip. The TUI can't run in a - non-tty. + check: `--headless` (prints the bound port), then POST to + `127.0.0.1:/v1/messages` without auth — a relayed 401 from Anthropic + proves the round-trip. The TUI can't run in a non-tty. `CT_UPSTREAM` points + the proxy at an alternative upstream (e.g. a local fake SSE server) for + fully offline end-to-end tests with zero API usage. ## Not yet handled (known MVP limits) - Non-streaming requests pass through untapped (e.g. `count_tokens`). -- Sessions are never pruned; long sessions re-render fully each frame. +- Sessions are never pruned (entry memory grows for the process lifetime); + the same goes for viewed disk transcripts (`App::history`). +- Request bodies are fully buffered (up to 512 MB) before forwarding — needed + to read session metadata; adds first-byte latency on huge bodies. - Tool results only appear once the *next* request fires; if the session ends right after a tool call, that result is never seen. Output is what Claude Code sends the model (i.e. post-truncation). @@ -85,3 +169,9 @@ UI thread redraws on its own tick (no channel; just the mutex). on the outer terminal (not done); permission prompts aren't detected for pane growth (not visible in the API stream — would need a Notification hook hitting a local control endpoint). +- Materialized branch files satisfy our own parser (round-trip tested) but + Claude Code's loader tolerance is only verified empirically by resuming + one — if a CC update changes the JSONL schema, retest `b` + ctrl-↓. The + tree itself isn't refreshed while expanded (collapse/re-expand re-reads + the file), and a highlighted turn of a *live* session views its on-disk + transcript, which lags the in-memory feed by however much CC buffers. diff --git a/src/app.rs b/src/app.rs index 0e3c1b5..0dff66e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,8 +5,32 @@ use std::time::Instant; pub type SharedApp = Arc>; +/// Poison-tolerant lock. The proxy/tap path must keep relaying bytes even if +/// the UI thread panicked while holding the mutex (display state may be +/// stale-but-consistent; entries are append-only so nothing dangles). +pub fn lock_app(app: &SharedApp) -> std::sync::MutexGuard<'_, App> { + app.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + /// Display names for the filterable entry kinds, in toggle order. -pub const FILTER_LABELS: [&str; 5] = ["thinking", "text", "tools", "meta", "errors"]; +pub const FILTER_LABELS: [&str; 6] = ["user", "thinking", "text", "tools", "meta", "errors"]; + +/// Fallback model picker entries used until (or unless) `term::discover_models` +/// reads the live alias set out of the installed `claude` binary. Each entry is +/// `(label, --model arg)`; an empty arg means no `--model` flag (Claude Code's +/// configured default). +pub fn default_model_choices() -> Vec<(String, String)> { + [ + ("default", ""), + ("opus", "opus"), + ("sonnet", "sonnet"), + ("haiku", "haiku"), + ("fable", "fable"), + ] + .iter() + .map(|(l, a)| (l.to_string(), a.to_string())) + .collect() +} pub struct App { pub sessions: Vec, @@ -18,10 +42,38 @@ pub struct App { pub filters: [bool; FILTER_LABELS.len()], /// `Some(selected_row)` while the filter popup is open. pub filter_popup: Option, + /// `Some(selected_row)` while the `n` model-picker popup is open; picking a + /// model spawns a fresh embedded session (indexes `model_choices`). + pub model_popup: Option, + /// Model picker entries `(label, --model arg)`. Seeded with + /// `default_model_choices`, then replaced by `term::discover_models` once a + /// background scan reads the live alias set from the `claude` binary. + pub model_choices: Vec<(String, String)>, /// Whether the session list panel is expanded. pub show_sessions: bool, + /// Past sessions on disk for this directory (newest first), maintained by + /// `sessions::spawn_scanner`. Shown in the session list as selectable + /// stubs below the live sessions (deduped by uuid — see `visible_stubs`). + pub disk_sessions: Vec, + /// Lazily loaded transcript views for *viewed* disk sessions, keyed by + /// uuid (rebuilt when the requested tree path changes). Never pruned + /// (same lifetime policy as `sessions`). + pub history: HashMap, + /// The one session whose turn tree is expanded in the session list + /// (lazygit-style accordion: expanding another session collapses this). + pub expanded: Option, + /// Set when the turn highlight moved: the next draw scrolls the feed to + /// the highlighted turn's first entry (heights live in the render cache, + /// so the key handler can't compute the offset itself). + pub turn_dirty: bool, /// Session UUID of the embedded `claude` pane (src/term.rs), so the tap /// can recognise traffic belonging to it. + /// + /// Invariant: one app instance = one proxy port = at most one embedded + /// claude instance. Every other live session in `sessions` is an external + /// claude pointed at our port — observable, but never attachable (the UI + /// can only `--resume` it, which is guarded because the external instance + /// may still be running). pub embed_session: Option, /// True while the embedded session shows a large interactive prompt /// (AskUserQuestion/ExitPlanMode seen in the stream, answer not yet @@ -36,6 +88,18 @@ pub struct App { pub embed_clear_at: Option, } +/// Turn-tree expansion state for one session in the session list. +pub struct Expanded { + pub uuid: String, + pub tree: crate::sessions::TurnTree, + /// Highlighted row as a position into `tree.display` (None = the session + /// row itself is highlighted). + pub sel: Option, + /// Visual-mode anchor (display position); the selection is the + /// contiguous display range anchor..=sel. + pub visual: Option, +} + /// Client-side tools that render a large interactive UI in Claude Code. fn is_interactive_tool(name: &str) -> bool { matches!(name, "AskUserQuestion" | "ExitPlanMode") @@ -76,22 +140,277 @@ impl App { status: "starting proxy…".into(), filters: [true; FILTER_LABELS.len()], filter_popup: None, + model_popup: None, + model_choices: default_model_choices(), show_sessions: true, + disk_sessions: Vec::new(), + history: HashMap::new(), + expanded: None, + turn_dirty: false, embed_session: None, embed_grow: false, embed_grow_rows: None, embed_clear_at: None, } } + + /// Indices into `disk_sessions` that should appear as stubs: everything + /// not already present as a live session (a live session *is* on disk — + /// Claude Code writes the JSONL continuously — so dedupe by uuid). + pub fn visible_stubs(&self) -> Vec { + self.disk_sessions + .iter() + .enumerate() + .filter(|(_, d)| !self.sessions.iter().any(|s| s.key == d.uuid)) + .map(|(i, _)| i) + .collect() + } + + /// Length of the merged selection list: live sessions first (indices + /// stay stable — entries/sessions are append-only), then disk stubs. + pub fn merged_len(&self) -> usize { + self.sessions.len() + self.visible_stubs().len() + } + + /// Session uuid the merged selection currently points at (clamped). + pub fn selected_key(&self) -> Option { + let sel = self.selected.min(self.merged_len().checked_sub(1)?); + if let Some(s) = self.sessions.get(sel) { + return Some(s.key.clone()); + } + self.visible_stubs() + .get(sel - self.sessions.len()) + .map(|&i| self.disk_sessions[i].uuid.clone()) + } + + /// Point the merged selection at `key` (live session or disk stub). + /// Returns false if the key is in neither list. + pub fn select_key(&mut self, key: &str) -> bool { + if let Some(i) = self.sessions.iter().position(|s| s.key == key) { + self.selected = i; + return true; + } + let stubs = self.visible_stubs(); + if let Some(pos) = stubs.iter().position(|&i| self.disk_sessions[i].uuid == key) { + self.selected = self.sessions.len() + pos; + return true; + } + false + } + + /// True while the highlight is on a turn row of the expanded session. + pub fn on_turns(&self) -> bool { + let key = self.selected_key(); + self.expanded + .as_ref() + .is_some_and(|e| key.as_deref() == Some(e.uuid.as_str()) && e.sel.is_some()) + } + + /// Move the turn highlight (and clear visual mode when leaving the turns). + fn set_turn(&mut self, p: Option) { + if let Some(e) = self.expanded.as_mut() { + e.sel = p; + if p.is_none() { + e.visual = None; + } else { + self.turn_dirty = true; + self.follow = false; + } + } + } + + /// One j/k step over the unified list: session rows plus the expanded + /// session's turn rows (rendered directly under it). Clamped at both + /// ends; visual mode pins the highlight inside the turn rows. + pub fn nav(&mut self, down: bool) { + let n = self.merged_len(); + if n == 0 { + return; + } + self.selected = self.selected.min(n - 1); + let key = self.selected_key(); + let on_exp = self + .expanded + .as_ref() + .is_some_and(|e| key.as_deref() == Some(e.uuid.as_str())); + let (len, tsel, vis) = match (&self.expanded, on_exp) { + (Some(e), true) => (e.tree.display.len(), e.sel, e.visual.is_some()), + _ => (0, None, false), + }; + match (down, tsel) { + (true, None) if on_exp && len > 0 => self.set_turn(Some(0)), + (true, Some(p)) if p + 1 < len => self.set_turn(Some(p + 1)), + (true, Some(_)) if vis => {} // visual: clamp inside the turns + (true, _) => { + if self.selected + 1 < n { + self.selected += 1; + self.set_turn(None); + self.follow = true; + } + } + (false, Some(p)) if p > 0 => self.set_turn(Some(p - 1)), + (false, Some(_)) if vis => {} + (false, Some(_)) => { + // Top turn → back to the session row. + self.set_turn(None); + self.follow = true; + } + (false, None) => { + if self.selected > 0 { + self.selected -= 1; + self.follow = true; + // The expanded session's turn rows sit between it and the + // row we came from: entering from below lands on the last + // turn, not the session row. + let k2 = self.selected_key(); + let last = self + .expanded + .as_ref() + .filter(|e| k2.as_deref() == Some(e.uuid.as_str())) + .map(|e| e.tree.display.len()) + .filter(|&l| l > 0) + .map(|l| l - 1); + if last.is_some() { + self.set_turn(last); + } + } + } + } + } + + /// Drop any turn highlight / visual state (tab-style jumps call this so a + /// stale highlight isn't revived when tabbing back onto the expansion). + pub fn clear_turn_focus(&mut self) { + self.set_turn(None); + } + + /// space: expand the selected session's turn tree / collapse it again. + pub fn toggle_expand(&mut self) { + let Some(key) = self.selected_key() else { return }; + if self.expanded.as_ref().is_some_and(|e| e.uuid == key) { + self.expanded = None; + return; + } + self.expand(key); + } + + fn expand(&mut self, key: String) { + match crate::sessions::load_tree(&key) { + Some(tree) => { + self.expanded = Some(Expanded { uuid: key, tree, sel: None, visual: None }); + } + None => self.status = "no turns on disk for this session yet".into(), + } + } + + /// →/l (yazi-style): expand the selected session, or step into its turns. + pub fn tree_right(&mut self) { + let Some(key) = self.selected_key() else { return }; + let on_exp = self.expanded.as_ref().is_some_and(|e| e.uuid == key); + if !on_exp { + self.expand(key); + } else if self + .expanded + .as_ref() + .is_some_and(|e| e.sel.is_none() && !e.tree.display.is_empty()) + { + self.set_turn(Some(0)); + } + } + + /// ←/h: step out of the turns, or collapse the tree. + pub fn tree_left(&mut self) { + match &self.expanded { + Some(e) if e.sel.is_some() => self.set_turn(None), + Some(_) => self.expanded = None, + None => {} + } + } + + /// v: anchor / cancel visual mode on the highlighted turn. + pub fn toggle_visual(&mut self) { + if !self.on_turns() { + self.status = "highlight a turn first (space/→ opens the tree)".into(); + return; + } + if let Some(e) = self.expanded.as_mut() { + e.visual = match e.visual { + Some(_) => None, + None => e.sel, + }; + } + } + + /// b: materialize a new, fully decoupled session from the highlighted + /// turn (its root→turn chain) or the visual selection (stitched range), + /// inject it as the top stub and select it. The user starts it with the + /// usual ctrl-↓ attach — branching itself never touches any process. + pub fn branch_selected(&mut self) -> Result { + let key = self.selected_key(); + let Some(e) = self + .expanded + .as_ref() + .filter(|e| key.as_deref() == Some(e.uuid.as_str())) + else { + return Err("highlight a turn first (space/→ opens the tree)".into()); + }; + let Some(p) = e.sel else { + return Err("highlight a turn first (j/↓ moves into the tree)".into()); + }; + let mut idxs: Vec = match e.visual { + // Visual range in display order → chronological turn order. + Some(av) => { + let (lo, hi) = (av.min(p), av.max(p)); + let mut v = e.tree.display[lo..=hi].to_vec(); + v.sort_unstable(); + v + } + None => e.tree.chain(e.tree.display[p]), + }; + idxs.dedup(); + let title = format!("⑂ {}", e.tree.turns[*idxs.last().unwrap()].label); + let new_uuid = crate::sessions::materialize(&e.tree, &idxs, &title)?; + if let Some(e) = self.expanded.as_mut() { + e.visual = None; + e.sel = None; // the selection moves to the new stub + } + // Surface it immediately (the scanner would take up to ~1s); the next + // scan sees the same file and keeps the selection by uuid. + self.disk_sessions.insert( + 0, + crate::sessions::DiskSession { + uuid: new_uuid.clone(), + label: title, + modified: std::time::SystemTime::now(), + }, + ); + self.select_key(&new_uuid); + Ok(new_uuid) + } + + /// Swap in a fresh scan result, keeping a stub selection pointed at the + /// same session even if the list reordered (scanner thread calls this). + pub fn set_disk_sessions(&mut self, list: Vec) { + let kept = self + .selected + .checked_sub(self.sessions.len()) + .and_then(|i| self.visible_stubs().get(i).copied()) + .map(|i| self.disk_sessions[i].uuid.clone()); + self.disk_sessions = list; + if let Some(uuid) = kept { + self.select_key(&uuid); + } + } } pub fn filter_index(kind: &Kind) -> usize { match kind { - Kind::Thinking => 0, - Kind::Text => 1, - Kind::Tool { .. } => 2, - Kind::Meta => 3, - Kind::Error => 4, + Kind::User => 0, + Kind::Thinking => 1, + Kind::Text => 2, + Kind::Tool { .. } => 3, + Kind::Meta => 4, + Kind::Error => 5, } } @@ -108,8 +427,26 @@ pub struct Session { pub tool_ids: HashMap, } +impl Session { + pub fn new(key: String, model: String) -> Self { + Self { + key, + model, + entries: Vec::new(), + active: 0, + input_tokens: 0, + output_tokens: 0, + last_activity: Instant::now(), + tool_ids: HashMap::new(), + } + } +} + #[derive(PartialEq)] pub enum Kind { + /// A user-submitted prompt, lifted from the request body (the trailing + /// `user` message of a turn-starting request) — not part of the SSE stream. + User, Thinking, Text, Tool { name: String }, @@ -131,7 +468,7 @@ pub struct ToolResult { } impl Entry { - fn meta(content: String) -> Self { + pub(crate) fn meta(content: String) -> Self { Self { kind: Kind::Meta, content, done: true, result: None } } } @@ -143,7 +480,7 @@ pub fn attach_tool_results(app: &SharedApp, key: &str, body: &Value) { let Some(messages) = body.get("messages").and_then(Value::as_array) else { return; }; - let mut a = app.lock().unwrap(); + let mut a = lock_app(app); let is_embed = a.embed_session.as_deref() == Some(key); // Set when the answer to an interactive prompt comes back: the embedded // pane's prompt is gone, so the extra rows can be released. @@ -182,8 +519,76 @@ pub fn attach_tool_results(app: &SharedApp, key: &str, body: &Value) { } } +/// Text blocks Claude Code injects around the user's words; not what the +/// user typed, so they don't belong in a "user prompt" entry. +pub(crate) fn is_injected_block(t: &str) -> bool { + let t = t.trim_start(); + t.starts_with("") + || t.starts_with("") + || t.starts_with(" s.clone(), + Some(Value::Array(blocks)) => blocks + .iter() + .filter(|b| b.get("type").and_then(Value::as_str) == Some("text")) + .filter_map(|b| b.get("text").and_then(Value::as_str)) + .filter(|t| !is_injected_block(t)) + .collect::>() + .join("\n"), + _ => return, + }; + let text = text.trim(); + if text.is_empty() { + return; + } + let mut a = lock_app(app); + let Some(s) = a.sessions.iter_mut().find(|s| s.key == key) else { + return; + }; + if s.entries + .iter() + .rev() + .find(|e| e.kind == Kind::User) + .is_some_and(|e| e.content == text) + { + return; + } + s.entries.push(Entry { + kind: Kind::User, + content: text.to_string(), + done: true, + result: None, + }); +} + /// tool_result content is either a plain string or an array of content blocks. -fn flatten_result_content(v: Option<&Value>) -> String { +pub fn flatten_result_content(v: Option<&Value>) -> String { match v { Some(Value::String(s)) => s.clone(), Some(Value::Array(blocks)) => blocks @@ -213,21 +618,17 @@ pub struct Tap { impl Tap { pub fn new(app: SharedApp, key: String, model: String) -> Self { let sidx = { - let mut a = app.lock().unwrap(); + let mut a = lock_app(&app); let sidx = match a.sessions.iter().position(|s| s.key == key) { Some(i) => i, None => { - a.sessions.push(Session { - key, - model: model.clone(), - entries: Vec::new(), - active: 0, - input_tokens: 0, - output_tokens: 0, - last_activity: Instant::now(), - tool_ids: HashMap::new(), - }); - a.sessions.len() - 1 + a.sessions.push(Session::new(key, model.clone())); + let idx = a.sessions.len() - 1; + // Auto-jump to every new session so a fresh `/clear` in + // Claude Code is immediately visible without manual switching. + a.selected = idx; + a.follow = true; + idx } }; a.sessions[sidx].active += 1; @@ -238,7 +639,7 @@ impl Tap { } pub fn handle(&mut self, ev: &str, d: &Value) { - let mut a = self.app.lock().unwrap(); + let mut a = lock_app(&self.app); // Set when a completed block means the embedded pane is about to show // a big interactive prompt (checked against embed_session below). let mut interactive = false; @@ -288,8 +689,11 @@ impl Tap { } } other => { + // Unknown block type: record a one-line meta entry but + // do NOT set self.cur — we don't want subsequent delta + // events appending content to a done Meta entry, and + // content_block_stop would wrongly try to pretty-print it. s.entries.push(Entry::meta(format!("[{other}]"))); - self.cur = Some(s.entries.len() - 1); return; } }; @@ -361,19 +765,21 @@ impl Tap { } // The embedded pane is about to render an interactive prompt // (wire-order guarantees this fires before Claude Code draws it): - // ask the UI for more rows. + // ask the UI for more rows, and cancel any pending transcript wipe so + // the prompt is not cleared before the user can respond. if interactive && a.embed_session.as_deref() == Some(a.sessions[self.sidx].key.as_str()) { a.embed_grow = true; a.embed_grow_rows = grow_rows; + a.embed_clear_at = None; } } } impl Drop for Tap { fn drop(&mut self) { - let mut a = self.app.lock().unwrap(); + let mut a = lock_app(&self.app); let s = &mut a.sessions[self.sidx]; s.active = s.active.saturating_sub(1); if let Some(i) = self.cur.take() { @@ -381,7 +787,12 @@ impl Drop for Tap { } // A turn of the embedded session just finished streaming: schedule a // transcript wipe shortly after Claude Code prints its final lines. - if a.embed_session.as_deref() == Some(a.sessions[self.sidx].key.as_str()) { + // Do NOT schedule while embed_grow is active — that means an interactive + // prompt (AskUserQuestion / ExitPlanMode) is waiting for user input, and + // a ctrl-l wipe would clear the prompt before the user can answer it. + if a.embed_session.as_deref() == Some(a.sessions[self.sidx].key.as_str()) + && !a.embed_grow + { a.embed_clear_at = Some(Instant::now() + std::time::Duration::from_millis(400)); } } @@ -460,6 +871,52 @@ mod tests { assert!(!app.lock().unwrap().embed_grow); } + #[test] + fn user_prompt_recorded_once_and_injections_skipped() { + let app: SharedApp = Arc::new(Mutex::new(App::new())); + drop(Tap::new(app.clone(), "abc".into(), "claude-x".into())); + let body = json!({"tools": [{"name": "Bash"}], "messages": [ + {"role": "user", "content": [ + {"type": "text", "text": "noise"}, + {"type": "text", "text": "fix the bug"} + ]} + ]}); + record_user_prompt(&app, "abc", &body); + record_user_prompt(&app, "abc", &body); // resend → deduped + { + let a = app.lock().unwrap(); + let user: Vec<_> = a.sessions[0] + .entries + .iter() + .filter(|e| e.kind == Kind::User) + .collect(); + assert_eq!(user.len(), 1); + assert_eq!(user[0].content, "fix the bug"); + } + + // Tool-loop continuation (trailing tool_result, no text) → no entry. + record_user_prompt( + &app, + "abc", + &json!({"tools": [{"name": "Bash"}], "messages": [ + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "ok"} + ]} + ]}), + ); + // Side request without tools (topic detection etc.) → no entry. + record_user_prompt( + &app, + "abc", + &json!({"messages": [{"role": "user", "content": "fresh prompt"}]}), + ); + let a = app.lock().unwrap(); + assert_eq!( + a.sessions[0].entries.iter().filter(|e| e.kind == Kind::User).count(), + 1 + ); + } + #[test] fn ask_rows_scale_with_options() { let two = ask_question_rows(&json!({"questions": [ @@ -488,6 +945,128 @@ mod tests { assert!(!app.lock().unwrap().embed_grow); } + fn ds(uuid: &str) -> crate::sessions::DiskSession { + crate::sessions::DiskSession { + uuid: uuid.into(), + label: uuid.into(), + modified: std::time::SystemTime::now(), + } + } + + #[test] + fn disk_stubs_dedupe_against_live_sessions() { + let mut a = App::new(); + a.sessions.push(Session::new("aaa".into(), "m".into())); + a.set_disk_sessions(vec![ds("aaa"), ds("bbb")]); + // "aaa" is live → only "bbb" appears as a stub. + assert_eq!(a.merged_len(), 2); + assert!(a.select_key("bbb")); + assert_eq!(a.selected, 1); + assert_eq!(a.selected_key().as_deref(), Some("bbb")); + assert!(a.select_key("aaa")); + assert_eq!(a.selected, 0); + assert!(!a.select_key("nope")); + } + + #[test] + fn stub_selection_survives_rescan_reorder() { + let mut a = App::new(); + a.sessions.push(Session::new("live".into(), "m".into())); + a.set_disk_sessions(vec![ds("old"), ds("older")]); + assert!(a.select_key("older")); + // A new session file lands on top → "older" shifts down a slot. + a.set_disk_sessions(vec![ds("new"), ds("old"), ds("older")]); + assert_eq!(a.selected_key().as_deref(), Some("older")); + } + + #[test] + fn selected_key_resolves_live_then_stubs_and_clamps() { + let mut a = App::new(); + assert_eq!(a.selected_key(), None); + a.sessions.push(Session::new("live".into(), "m".into())); + a.set_disk_sessions(vec![ds("disk")]); + a.selected = 99; // clamped to the last merged slot + assert_eq!(a.selected_key().as_deref(), Some("disk")); + } + + /// Three linear turns: a → b → c (no forks). + fn linear_tree() -> crate::sessions::TurnTree { + let rec = |uuid: &str, parent: Option<&str>, text: &str| { + serde_json::json!({ + "type": "user", "uuid": uuid, "parentUuid": parent, + "message": {"role": "user", "content": text} + }) + .to_string() + }; + crate::sessions::build_tree(vec![ + rec("u1", None, "a"), + rec("u2", Some("u1"), "b"), + rec("u3", Some("u2"), "c"), + ]) + } + + fn turn_sel(a: &App) -> Option { + a.expanded.as_ref().and_then(|e| e.sel) + } + + #[test] + fn nav_walks_through_expanded_turns() { + let mut a = App::new(); + a.set_disk_sessions(vec![ds("top"), ds("mid"), ds("bot")]); + a.select_key("mid"); + a.expanded = Some(Expanded { + uuid: "mid".into(), + tree: linear_tree(), + sel: None, + visual: None, + }); + // Down: session row → its three turns → the next session row. + a.nav(true); + assert_eq!(turn_sel(&a), Some(0)); + assert!(a.turn_dirty, "turn highlight schedules a feed scroll"); + a.nav(true); + a.nav(true); + assert_eq!(turn_sel(&a), Some(2)); + a.nav(true); + assert_eq!(turn_sel(&a), None); + assert_eq!(a.selected_key().as_deref(), Some("bot")); + // Up from below re-enters the tree at its *last* turn. + a.nav(false); + assert_eq!(a.selected_key().as_deref(), Some("mid")); + assert_eq!(turn_sel(&a), Some(2)); + a.nav(false); + a.nav(false); + a.nav(false); + assert_eq!(turn_sel(&a), None, "top turn exits to the session row"); + assert_eq!(a.selected_key().as_deref(), Some("mid")); + a.nav(false); + assert_eq!(a.selected_key().as_deref(), Some("top")); + } + + #[test] + fn visual_mode_pins_highlight_inside_turns() { + let mut a = App::new(); + a.set_disk_sessions(vec![ds("only")]); + a.select_key("only"); + a.expanded = Some(Expanded { + uuid: "only".into(), + tree: linear_tree(), + sel: Some(1), + visual: None, + }); + a.toggle_visual(); + assert_eq!(a.expanded.as_ref().unwrap().visual, Some(1)); + a.nav(true); + a.nav(true); // clamped at the last turn, must not leave the tree + assert_eq!(turn_sel(&a), Some(2)); + a.nav(false); + a.nav(false); + a.nav(false); // clamped at the first turn + assert_eq!(turn_sel(&a), Some(0)); + a.toggle_visual(); + assert_eq!(a.expanded.as_ref().unwrap().visual, None); + } + #[test] fn unknown_tool_id_is_ignored() { let app: SharedApp = Arc::new(Mutex::new(App::new())); diff --git a/src/main.rs b/src/main.rs index defb019..8359ba0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,7 @@ mod app; +mod markdown; mod proxy; +mod sessions; mod sse; mod term; mod ui; @@ -8,10 +10,6 @@ use app::App; use std::sync::{Arc, Mutex}; fn main() -> anyhow::Result<()> { - let port: u16 = std::env::var("CT_PORT") - .ok() - .and_then(|p| p.parse().ok()) - .unwrap_or(8484); let headless = std::env::args().any(|a| a == "--headless"); let app = Arc::new(Mutex::new(App::new())); @@ -19,11 +17,24 @@ fn main() -> anyhow::Result<()> { .enable_all() .build()?; + // 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::().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, + }, + } + })?; + let port = listener.local_addr()?.port(); + let papp = app.clone(); let proxy_handle = rt.spawn(async move { - if let Err(e) = proxy::run(papp.clone(), port).await { - let mut a = papp.lock().unwrap(); - a.status = format!("proxy failed: {e}"); + if let Err(e) = proxy::run(papp.clone(), listener).await { + app::lock_app(&papp).status = format!("proxy failed: {e}"); } }); diff --git a/src/markdown.rs b/src/markdown.rs new file mode 100644 index 0000000..a846abb --- /dev/null +++ b/src/markdown.rs @@ -0,0 +1,439 @@ +//! Markdown → ratatui lines, wrapping the pinned `tui-markdown =0.3.5`. +//! +//! tui-markdown 0.3.5 never enables pulldown-cmark's table extension, so GFM +//! pipe tables fall through as raw paragraph text, and it keeps the literal +//! `### ` markers on headings. This module splits table blocks out of the +//! text, renders them itself (box-drawing borders, width-fitted columns, +//! word-wrapped cells — matching Claude Code's own table style), routes the +//! rest through tui-markdown, and strips heading markers from its output. +//! +//! Re-rendered every frame on partial content: a table only renders as a +//! table once its header + separator row have streamed in, so half-received +//! tables show as plain text and self-heal, like the rest of the markdown. + +use ratatui::style::{Modifier, Style, Stylize}; +use ratatui::text::{Line, Span}; + +const MIN_COL: usize = 3; + +pub fn render<'a>(content: &'a str, width: u16) -> Vec> { + // Byte bounds of each line (end excludes the newline). + let mut bounds: Vec<(usize, usize)> = Vec::new(); + let mut start = 0; + for (i, b) in content.bytes().enumerate() { + if b == b'\n' { + bounds.push((start, i)); + start = i + 1; + } + } + bounds.push((start, content.len())); + + let line_at = |i: usize| &content[bounds[i].0..bounds[i].1]; + let mut out: Vec> = Vec::new(); + let mut plain_from = 0usize; + let mut i = 0; + while i < bounds.len() { + let table_start = is_pipe_row(line_at(i)) + && i + 1 < bounds.len() + && is_separator_row(line_at(i + 1)); + if !table_start { + i += 1; + continue; + } + push_plain(&mut out, &content[plain_from..bounds[i].0]); + let mut j = i + 1; + while j + 1 < bounds.len() && is_pipe_row(line_at(j + 1)) { + j += 1; + } + if out.last().is_some_and(|l| l.width() > 0) { + out.push(Line::default()); + } + let rows: Vec<&str> = (i..=j).map(line_at).collect(); + render_table(&rows, width, &mut out); + // Skip past the newline terminating the last table row, so the + // following plain chunk doesn't start with a stray blank line + // (push_plain adds its own separator). + plain_from = (bounds[j].1 + 1).min(content.len()); + i = j + 1; + } + push_plain(&mut out, &content[plain_from.min(content.len())..]); + out +} + +fn push_plain<'a>(out: &mut Vec>, chunk: &'a str) { + if chunk.trim().is_empty() { + return; + } + if out.last().is_some_and(|l| l.width() > 0) { + out.push(Line::default()); + } + for mut line in tui_markdown::from_str(chunk).lines { + strip_heading_marker(&mut line); + out.push(line); + } +} + +/// tui-markdown emits headings as `["### ", "Heading"]` with a line-level +/// heading style; drop the literal marker span, keep the styling. +fn strip_heading_marker(line: &mut Line<'_>) { + let is_marker = line.spans.first().is_some_and(|s| { + let c = s.content.as_ref(); + c.len() >= 2 && c.ends_with(' ') && c[..c.len() - 1].bytes().all(|b| b == b'#') + }); + if is_marker && line.style != Style::default() { + line.spans.remove(0); + } +} + +fn is_pipe_row(line: &str) -> bool { + line.trim_start().starts_with('|') +} + +fn is_separator_row(line: &str) -> bool { + let t = line.trim(); + t.starts_with('|') + && t.contains('-') + && t.chars().all(|c| matches!(c, '|' | '-' | ':' | ' ' | '\t')) +} + +fn split_row(line: &str) -> Vec<&str> { + let t = line.trim(); + let t = t.strip_prefix('|').unwrap_or(t); + let t = t.strip_suffix('|').unwrap_or(t); + t.split('|').map(str::trim).collect() +} + +#[derive(Clone, Copy, PartialEq)] +enum Align { + Left, + Center, + Right, +} + +fn align_of(sep_cell: &str) -> Align { + match (sep_cell.starts_with(':'), sep_cell.ends_with(':')) { + (true, true) => Align::Center, + (false, true) => Align::Right, + _ => Align::Left, + } +} + +/// A table cell, pre-tokenized into wrap units. A "word" can span style +/// boundaries (e.g. `**bold**suffix`) without breaking mid-word. +#[derive(Default)] +struct Cell { + words: Vec, +} + +#[derive(Default)] +struct Word { + parts: Vec>, +} + +impl Word { + fn width(&self) -> usize { + self.parts.iter().map(Span::width).sum() + } +} + +impl Cell { + /// Width when laid out on a single line. + fn natural(&self) -> usize { + let w: usize = self.words.iter().map(Word::width).sum(); + w + self.words.len().saturating_sub(1) + } + + /// Greedy word-wrap to `width`; overlong words hard-break. + fn wrap(&self, width: usize) -> Vec> { + let width = width.max(1); + let mut lines: Vec>> = Vec::new(); + let mut cur: Vec> = Vec::new(); + let mut cur_w = 0usize; + for word in &self.words { + let ww = word.width(); + let sep = usize::from(cur_w > 0); + if cur_w + sep + ww <= width { + if sep == 1 { + cur.push(Span::raw(" ")); + } + cur.extend(word.parts.iter().cloned()); + cur_w += sep + ww; + } else if ww <= width { + lines.push(std::mem::take(&mut cur)); + cur.extend(word.parts.iter().cloned()); + cur_w = ww; + } else { + if !cur.is_empty() { + lines.push(std::mem::take(&mut cur)); + cur_w = 0; + } + for part in &word.parts { + let mut buf = String::new(); + for ch in part.content.chars() { + let chw = Span::raw(ch.to_string()).width(); + if cur_w + chw > width { + if !buf.is_empty() { + cur.push(Span::styled(std::mem::take(&mut buf), part.style)); + } + lines.push(std::mem::take(&mut cur)); + cur_w = 0; + } + buf.push(ch); + cur_w += chw; + } + if !buf.is_empty() { + cur.push(Span::styled(buf, part.style)); + } + } + } + } + if !cur.is_empty() || lines.is_empty() { + lines.push(cur); + } + lines.into_iter().map(Line::from).collect() + } +} + +/// Parse one pipe row into cells with inline markdown rendered. +fn cells_of(row: &str, header: bool) -> Vec { + split_row(row) + .into_iter() + .map(|raw| { + let mut cell = Cell::default(); + let mut open = false; // current word continues across spans + for sp in tui_markdown::from_str(raw) + .lines + .into_iter() + .flat_map(|l| l.spans) + { + let style = if header { + sp.style.add_modifier(Modifier::BOLD) + } else { + sp.style + }; + let mut buf = String::new(); + for ch in sp.content.chars() { + if ch.is_whitespace() { + if !buf.is_empty() { + add_part(&mut cell, &mut open, std::mem::take(&mut buf), style); + } + open = false; + } else { + buf.push(ch); + } + } + if !buf.is_empty() { + add_part(&mut cell, &mut open, buf, style); + } + } + cell + }) + .collect() +} + +fn add_part(cell: &mut Cell, open: &mut bool, text: String, style: Style) { + if !*open { + cell.words.push(Word::default()); + *open = true; + } + cell.words + .last_mut() + .expect("word pushed above") + .parts + .push(Span::styled(text, style)); +} + +fn render_table<'a>(rows: &[&str], width: u16, out: &mut Vec>) { + let header = cells_of(rows[0], true); + let aligns: Vec = split_row(rows[1]).iter().map(|c| align_of(c)).collect(); + let body: Vec> = rows[2..].iter().map(|r| cells_of(r, false)).collect(); + let cols = header + .len() + .max(body.iter().map(Vec::len).max().unwrap_or(0)); + if cols == 0 { + return; + } + + let mut natural = vec![1usize; cols]; + for (k, c) in header.iter().enumerate() { + natural[k] = natural[k].max(c.natural()); + } + for row in &body { + for (k, c) in row.iter().enumerate() { + natural[k] = natural[k].max(c.natural()); + } + } + + // chrome: cols+1 border glyphs + 2 padding spaces per column + let avail = (width as usize).saturating_sub(cols + 1 + 2 * cols); + if avail < cols * MIN_COL { + // Too narrow to draw a table at all; emit the raw rows. + for r in rows { + out.push(Line::from((*r).to_owned())); + } + return; + } + let widths = fit(&natural, avail); + + out.push(border(&widths, '┌', '┬', '┐')); + push_row(out, &header, &widths, &[Align::Center].repeat(cols)); + out.push(border(&widths, '├', '┼', '┤')); + for row in &body { + push_row(out, row, &widths, &aligns); + } + out.push(border(&widths, '└', '┴', '┘')); +} + +/// Fit natural column widths into `avail`, shrinking proportionally with a +/// floor of `MIN_COL`, then nudging to use the space exactly when shrunk. +fn fit(natural: &[usize], avail: usize) -> Vec { + let total: usize = natural.iter().sum(); + if total <= avail { + return natural.to_vec(); + } + let mut w: Vec = natural + .iter() + .map(|&n| (n * avail / total).clamp(MIN_COL, n.max(MIN_COL))) + .collect(); + let mut sum: usize = w.iter().sum(); + while sum > avail { + match (0..w.len()).filter(|&k| w[k] > MIN_COL).max_by_key(|&k| w[k]) { + Some(k) => { + w[k] -= 1; + sum -= 1; + } + None => break, + } + } + while sum < avail { + match (0..w.len()) + .filter(|&k| w[k] < natural[k]) + .max_by_key(|&k| natural[k] - w[k]) + { + Some(k) => { + w[k] += 1; + sum += 1; + } + None => break, + } + } + w +} + +fn border(widths: &[usize], l: char, m: char, r: char) -> Line<'static> { + let mut s = String::new(); + s.push(l); + for (k, w) in widths.iter().enumerate() { + if k > 0 { + s.push(m); + } + for _ in 0..w + 2 { + s.push('─'); + } + } + s.push(r); + Line::from(Span::from(s).dark_gray()) +} + +fn push_row<'a>(out: &mut Vec>, cells: &[Cell], widths: &[usize], aligns: &[Align]) { + let empty = Cell::default(); + let wrapped: Vec>> = (0..widths.len()) + .map(|k| cells.get(k).unwrap_or(&empty).wrap(widths[k])) + .collect(); + let height = wrapped.iter().map(Vec::len).max().unwrap_or(1).max(1); + for r in 0..height { + let mut spans: Vec> = vec![Span::from("│").dark_gray()]; + for k in 0..widths.len() { + spans.push(Span::raw(" ")); + let line = wrapped[k].get(r); + let lw = line.map(Line::width).unwrap_or(0); + let pad = widths[k].saturating_sub(lw); + let (lp, rp) = match aligns.get(k).copied().unwrap_or(Align::Left) { + Align::Left => (0, pad), + Align::Right => (pad, 0), + Align::Center => (pad / 2, pad - pad / 2), + }; + if lp > 0 { + spans.push(Span::raw(" ".repeat(lp))); + } + if let Some(l) = line { + spans.extend(l.spans.iter().cloned()); + } + if rp > 0 { + spans.push(Span::raw(" ".repeat(rp))); + } + spans.push(Span::raw(" ")); + spans.push(Span::from("│").dark_gray()); + } + out.push(Line::from(spans)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn flat(lines: &[Line]) -> Vec { + lines + .iter() + .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect()) + .collect() + } + + #[test] + fn table_renders_with_borders() { + let md = "| A | B |\n|---|---|\n| 1 | 22 |\n"; + let got = flat(&render(md, 40)); + assert_eq!( + got, + vec![ + "┌───┬────┐", + "│ A │ B │", + "├───┼────┤", + "│ 1 │ 22 │", + "└───┴────┘", + ] + ); + } + + #[test] + fn narrow_table_wraps_cells_within_width() { + let md = "| Option | Notes |\n|---|---|\n| Delayed data | Far fewer licensing restrictions apply here |\n"; + let width = 30u16; + let lines = render(md, width); + assert!(lines.iter().all(|l| l.width() <= width as usize)); + // still a table, not raw text + assert!(flat(&lines)[0].starts_with('┌')); + } + + #[test] + fn partial_table_stays_plain_until_separator_arrives() { + // Header only — no separator row yet (mid-stream). + let got = flat(&render("| A | B |", 40)); + assert_eq!(got, vec!["| A | B |"]); + } + + #[test] + fn text_around_table_is_preserved_and_spaced() { + let md = "before\n\n| A |\n|---|\n| 1 |\n\nafter"; + let got = flat(&render(md, 40)); + assert_eq!(got.first().map(String::as_str), Some("before")); + assert_eq!(got.last().map(String::as_str), Some("after")); + assert!(got.contains(&"".to_string())); // blank separator lines + assert!(got.iter().any(|l| l.starts_with('┌'))); + } + + #[test] + fn heading_markers_are_stripped() { + let got = flat(&render("### Your realistic options:", 80)); + assert_eq!(got, vec!["Your realistic options:"]); + } + + #[test] + fn alignment_from_separator() { + let md = "| Lhead | Rhead |\n|:------|------:|\n| a | b |\n"; + let got = flat(&render(md, 30)); + // body row: left col flush left, right col flush right + assert_eq!(got[3], "│ a │ b │"); + } +} diff --git a/src/proxy.rs b/src/proxy.rs index 3132b19..0ecc684 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -1,4 +1,4 @@ -use crate::app::{attach_tool_results, SharedApp, Tap}; +use crate::app::{attach_tool_results, lock_app, record_user_prompt, SharedApp, Tap}; use crate::sse::SseParser; use axum::body::Body; use axum::extract::{Request, State}; @@ -10,14 +10,22 @@ use serde_json::Value; const UPSTREAM: &str = "https://api.anthropic.com"; +/// Upstream base URL; `CT_UPSTREAM` overrides for offline testing against a +/// fake server (the relay itself is identical either way). +fn upstream() -> String { + std::env::var("CT_UPSTREAM").unwrap_or_else(|_| UPSTREAM.to_string()) +} + /// Extract the session UUID from `metadata.user_id`. Claude Code ≥2.1.x /// sends a JSON-ish blob containing `"session_id":""`; older builds /// used `user__account__session_`. fn session_key(u: &str) -> Option<&str> { if let Some(rest) = u.split(r#"session_id":""#).nth(1) { - return rest.split('"').next(); + return rest.split('"').next().filter(|s| !s.is_empty()); } - u.split("session_").nth(1) + // `.filter`: a trailing "session_" would otherwise yield Some("") — an + // empty key that every malformed user_id would then collide on. + u.split("session_").nth(1).filter(|s| !s.is_empty()) } #[derive(Clone)] @@ -26,13 +34,12 @@ struct Ctx { app: SharedApp, } -pub async fn run(app: SharedApp, port: u16) -> anyhow::Result<()> { +pub async fn run(app: SharedApp, listener: tokio::net::TcpListener) -> 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 listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await?; - app.lock().unwrap().status = - format!("proxy http://127.0.0.1:{port} → api.anthropic.com"); + 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?; Ok(()) } @@ -42,7 +49,7 @@ async fn forward(State(ctx): State, req: Request) -> Response { match forward_inner(ctx, req).await { Ok(r) => r, Err(e) => { - app.lock().unwrap().status = format!("upstream error: {e}"); + lock_app(&app).status = format!("upstream error: {e}"); Response::builder() .status(502) .body(Body::from(format!("claude-thinking proxy error: {e}"))) @@ -61,30 +68,32 @@ async fn forward_inner(ctx: Ctx, req: Request) -> anyhow::Result { .path_and_query() .map(|p| p.as_str().to_owned()) .unwrap_or_else(|| "/".into()); - let url = format!("{UPSTREAM}{pq}"); + let url = format!("{}{pq}", upstream()); // Identify streaming /v1/messages requests and attach a tap. let mut tap: Option = None; - if parts.method == Method::POST && pq.starts_with("/v1/messages") { - if let Ok(v) = serde_json::from_slice::(&body_bytes) { - if v.get("stream").and_then(Value::as_bool).unwrap_or(false) { - let model = v - .get("model") - .and_then(Value::as_str) - .unwrap_or("?") - .to_string(); - let key = v - .pointer("/metadata/user_id") - .and_then(Value::as_str) - .and_then(session_key) - .unwrap_or("unknown") - .to_string(); - // Tool results ride along in the request body; surface them - // on the tool entries from the previous turn. - attach_tool_results(&ctx.app, &key, &v); - tap = Some(Tap::new(ctx.app.clone(), key, model)); - } - } + if parts.method == Method::POST + && pq.starts_with("/v1/messages") + && let Ok(v) = serde_json::from_slice::(&body_bytes) + && v.get("stream").and_then(Value::as_bool).unwrap_or(false) + { + let model = v + .get("model") + .and_then(Value::as_str) + .unwrap_or("?") + .to_string(); + let key = v + .pointer("/metadata/user_id") + .and_then(Value::as_str) + .and_then(session_key) + .unwrap_or("unknown") + .to_string(); + // Tool results ride along in the request body; surface them + // on the tool entries from the previous turn. + attach_tool_results(&ctx.app, &key, &v); + tap = Some(Tap::new(ctx.app.clone(), key.clone(), model)); + // After Tap::new: the session must exist for the entry to land. + record_user_prompt(&ctx.app, &key, &v); } let mut rb = ctx.client.request(parts.method.clone(), &url); @@ -120,15 +129,32 @@ async fn forward_inner(ctx: Ctx, req: Request) -> anyhow::Result { } // Stream the body back unbuffered; tee SSE bytes into the parser. + // + // The tap (mutex-protected shared state) is driven from a dedicated + // tokio task so it never delays the forwarded bytes. The stream + // closure clones each chunk into a bounded mpsc channel via try_send + // (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 mut parser = SseParser::default(); - let stream = resp.bytes_stream().map(move |chunk| { - if let Ok(b) = &chunk { - for (ev, data) in parser.feed(b) { + let (tx, mut rx) = tokio::sync::mpsc::channel::>(64); + tokio::spawn(async move { + let mut parser = SseParser::default(); + while let Some(b) = rx.recv().await { + for (ev, data) in parser.feed(&b) { tap.handle(&ev, &data); } } + // rx closed means the stream ended; tap drops here, running + // its Drop impl (marks last entry done, schedules pane clear). + }); + let stream = resp.bytes_stream().map(move |chunk| { + if let Ok(b) = &chunk { + // Best-effort: if the tap task has fallen behind and the + // channel is full, drop the copy rather than stall bytes. + let _ = tx.try_send(b.to_vec()); + } chunk }); Body::from_stream(stream) @@ -155,5 +181,8 @@ mod tests { Some("29bd3436-aaaa-bbbb-cccc-111122223333") ); assert_eq!(session_key("no session here"), None); + // Degenerate inputs must not produce an empty (colliding) key. + assert_eq!(session_key("user_x_session_"), None); + assert_eq!(session_key(r#"{"session_id":""}"#), None); } } diff --git a/src/sessions.rs b/src/sessions.rs new file mode 100644 index 0000000..cdb814c --- /dev/null +++ b/src/sessions.rs @@ -0,0 +1,825 @@ +//! Past-session discovery + the on-disk turn tree: keeps the session list +//! populated with this directory's history, parses a session's JSONL into a +//! tree of *turns* (Claude Code records carry `uuid`/`parentUuid`, and every +//! Esc-Esc rewind leaves a branch point behind), and materializes new session +//! files from a chosen set of turns (branching / cherry-picking). +//! +//! Claude Code stores one `.jsonl` file per session under +//! `~/.claude/projects//`. The path encoding replaces every `/` +//! with `-` (the leading `/` becomes a leading `-`). + +use crate::app::{ + flatten_result_content, is_injected_block, lock_app, Entry, Kind, Session, SharedApp, + ToolResult, +}; +use serde_json::Value; +use std::collections::HashMap; +use std::io::BufRead; +use std::path::PathBuf; +use std::time::SystemTime; + +#[derive(Clone, PartialEq)] +pub struct DiskSession { + pub uuid: String, + /// Best human-readable label: ai-title > last-prompt text > uuid prefix. + pub label: String, + pub modified: SystemTime, +} + +/// 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 are re-read only for files whose mtime changed, so the steady-state +/// cost is one `read_dir` + a stat per file. The app mutex is only taken when +/// the list actually changed. +pub fn spawn_scanner(app: SharedApp) { + std::thread::spawn(move || { + // uuid → (mtime when read, label): skip re-parsing unchanged files. + let mut labels: HashMap = HashMap::new(); + let mut last: Vec = Vec::new(); + loop { + let list = scan(&mut labels).unwrap_or_default(); + if list != last { + last = list.clone(); + lock_app(&app).set_disk_sessions(list); + } + std::thread::sleep(std::time::Duration::from_secs(1)); + } + }); +} + +/// One scan of the project directory, newest first. +fn scan( + labels: &mut HashMap, +) -> Result, String> { + let dir = project_dir()?; + let rd = std::fs::read_dir(&dir) + .map_err(|e| format!("cannot read {}: {e}", dir.display()))?; + let mut sessions: Vec = rd + .flatten() + .filter_map(|e| { + let path = e.path(); + if path.extension().and_then(|x| x.to_str()) != Some("jsonl") { + return None; + } + let uuid = path.file_stem()?.to_str()?.to_string(); + let modified = e.metadata().ok()?.modified().ok()?; + let label = match labels.get(&uuid) { + Some((m, l)) if *m == modified => l.clone(), + _ => { + let l = read_label(&path, &uuid); + labels.insert(uuid.clone(), (modified, l.clone())); + l + } + }; + Some(DiskSession { uuid, label, modified }) + }) + .collect(); + sessions.sort_by(|a, b| b.modified.cmp(&a.modified)); + Ok(sessions) +} + +/// One turn of a session: a real user prompt plus everything that chains off +/// it (assistant blocks, tool results, attachments…) until the next prompt. +pub struct Turn { + /// First line of the user's prompt (injected blocks filtered out). + pub label: String, + /// Indent for tree rendering: abandoned (non-trunk) branches sit one + /// level under their fork point; the most recent continuation stays at + /// its parent's depth. + pub depth: usize, + /// Parent turn (None for the session's first prompt). + pub parent: Option, + /// Raw JSONL lines belonging to this turn, in file order. + pub lines: Vec, +} + +/// 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. +pub struct TurnTree { + pub turns: Vec, + /// Records before/outside any turn (mode, the caveat record, …). + pub preamble: Vec, + /// Turn indices in render order: DFS where abandoned branches are listed + /// (indented) right after their fork point and the trunk continues below. + pub display: Vec, +} + +impl TurnTree { + /// Children of `t` in creation (= chronological) order. + fn children(&self, t: usize) -> impl Iterator + '_ { + (t + 1..self.turns.len()).filter(move |&c| self.turns[c].parent == Some(t)) + } + + /// Follow the most recent child from `t` down to a leaf — the path a + /// `claude --resume` would continue on from that point. + pub fn trunk_leaf(&self, from: usize) -> usize { + let mut t = from; + while let Some(c) = self.children(t).max() { + t = c; + } + t + } + + /// Root → `leaf` chain, inclusive. + pub fn chain(&self, leaf: usize) -> Vec { + let mut v = vec![leaf]; + let mut t = leaf; + while let Some(p) = self.turns[t].parent { + v.push(p); + t = p; + } + v.reverse(); + v + } +} + +/// The user-typed prompt text of a record, if it starts a turn: a `user` +/// record that is not meta/sidechain and carries non-injected text. +fn prompt_text(v: &Value) -> Option { + if v.get("type").and_then(Value::as_str) != Some("user") + || v.get("isSidechain").and_then(Value::as_bool) == Some(true) + || v.get("isMeta").and_then(Value::as_bool) == Some(true) + { + return None; + } + let text = match v.pointer("/message/content") { + Some(Value::String(s)) => s.clone(), + Some(Value::Array(blocks)) => blocks + .iter() + .filter(|b| b.get("type").and_then(Value::as_str) == Some("text")) + .filter_map(|b| b.get("text").and_then(Value::as_str)) + .filter(|t| !is_injected_block(t)) + .collect::>() + .join("\n"), + _ => return None, + }; + let text = text.trim(); + (!text.is_empty() && !is_injected_block(text)).then(|| text.to_string()) +} + +/// Parse a session file into its turn tree. None when the file has no turns +/// (nothing recorded yet, or unreadable). +pub fn load_tree(uuid: &str) -> Option { + let path = project_dir().ok()?.join(format!("{uuid}.jsonl")); + let f = std::fs::File::open(path).ok()?; + let reader = std::io::BufReader::new(f); + let tree = build_tree(reader.lines().map_while(Result::ok)); + (!tree.turns.is_empty()).then_some(tree) +} + +/// Tree construction from raw JSONL lines (separated from I/O for tests). +pub(crate) fn build_tree(lines: impl IntoIterator) -> TurnTree { + let mut turns: Vec = Vec::new(); + let mut preamble: Vec = Vec::new(); + // record uuid → turn it belongs to (None = preamble). + let mut turn_of: HashMap> = HashMap::new(); + // positional bucket for uuid-less records: the previous record's turn. + let mut cur: Option = None; + for line in lines { + let Ok(v) = serde_json::from_str::(&line) else { + continue; + }; + let uuid = v.get("uuid").and_then(Value::as_str).map(str::to_string); + let bucket = match (&uuid, prompt_text(&v)) { + (Some(_), Some(label)) => { + // A real prompt starts a new turn, chained to the turn of its + // parent record (rewinds make this an *earlier* turn). + let parent = v + .get("parentUuid") + .and_then(Value::as_str) + .and_then(|p| turn_of.get(p).copied()) + .flatten(); + turns.push(Turn { + label: one_line(&label), + depth: 0, + parent, + lines: Vec::new(), + }); + Some(turns.len() - 1) + } + (Some(_), None) => match v.get("parentUuid").and_then(Value::as_str) { + // Chain membership when the parent is known; otherwise fall + // back to position (orphaned records exist in old files). + Some(p) => turn_of.get(p).copied().unwrap_or(cur), + None => None, // root record → preamble + }, + (None, _) => cur, // uuid-less metadata rides with its neighbours + }; + match bucket { + Some(t) => turns[t].lines.push(line), + None => preamble.push(line), + } + if let Some(u) = uuid { + turn_of.insert(u, bucket); + } + cur = bucket; + } + // Render order + depths: abandoned branches indent under the fork point + // and are listed first; the most recent child continues at parent depth. + let mut children: Vec> = vec![Vec::new(); turns.len()]; + let mut roots: Vec = Vec::new(); + for (i, t) in turns.iter().enumerate() { + match t.parent { + Some(p) => children[p].push(i), + None => roots.push(i), + } + } + let mut display = Vec::with_capacity(turns.len()); + let mut stack: Vec<(usize, usize)> = roots.iter().rev().map(|&r| (r, 0)).collect(); + while let Some((t, d)) = stack.pop() { + display.push(t); + turns[t].depth = d; + let ch = &children[t]; + for (i, &c) in ch.iter().enumerate().rev() { + stack.push((c, if i + 1 == ch.len() { d } else { d + 1 })); + } + } + TurnTree { turns, preamble, display } +} + +/// Write a brand-new session file made of `turn_idxs` (chronological order) +/// plus the preamble: sessionId rewritten throughout, each turn's first +/// record re-parented onto the previous turn's tail (the first one onto the +/// preamble tail / null), so the result is a self-consistent linear session +/// fully decoupled from its origin. Returns the new session uuid. +pub fn materialize(tree: &TurnTree, turn_idxs: &[usize], title: &str) -> Result { + let dir = project_dir()?; + materialize_in(&dir, tree, turn_idxs, title) +} + +fn materialize_in( + dir: &std::path::Path, + tree: &TurnTree, + turn_idxs: &[usize], + title: &str, +) -> Result { + let new_uuid = uuid::Uuid::new_v4().to_string(); + let mut out = String::new(); + // Our own title record first: `read_label` (and Claude Code's picker) + // prefer it, so the branch gets a meaningful name. + out.push_str( + &serde_json::json!({"type":"ai-title","aiTitle":title,"sessionId":new_uuid}).to_string(), + ); + out.push('\n'); + + fn emit( + line: &str, + new_uuid: &str, + head_of_turn: &mut bool, + tail: &mut Option, + out: &mut String, + ) { + let Ok(mut v) = serde_json::from_str::(line) else { + return; + }; + // The origin's title must not override ours. + if v.get("type").and_then(Value::as_str) == Some("ai-title") { + return; + } + if let Some(o) = v.as_object_mut() { + o.insert("sessionId".into(), Value::String(new_uuid.to_string())); + if let Some(u) = o.get("uuid").and_then(Value::as_str).map(str::to_string) { + if *head_of_turn { + o.insert( + "parentUuid".into(), + tail.clone().map_or(Value::Null, Value::String), + ); + *head_of_turn = false; + } + *tail = Some(u); + } + } + out.push_str(&v.to_string()); + out.push('\n'); + } + + let mut tail: Option = None; + let mut no_rewire = false; // preamble keeps its own chain + for l in &tree.preamble { + emit(l, &new_uuid, &mut no_rewire, &mut tail, &mut out); + } + for &t in turn_idxs { + let turn = tree + .turns + .get(t) + .ok_or_else(|| format!("turn {t} out of range"))?; + let mut head = true; + for l in &turn.lines { + emit(l, &new_uuid, &mut head, &mut tail, &mut out); + } + } + let path = dir.join(format!("{new_uuid}.jsonl")); + std::fs::write(&path, out).map_err(|e| format!("write {}: {e}", path.display()))?; + Ok(new_uuid) +} + +/// A rendered transcript: the feed `Session` plus which tree path it shows. +pub struct HistoryView { + pub session: Session, + /// Some(leaf turn) when rendered along a tree path; None = whole file + /// in raw order (legacy view for un-expanded stubs). + pub leaf: Option, + /// (turn index, first entry index) along the path — feed auto-scroll. + pub turn_entries: Vec<(usize, usize)>, +} + +/// Rebuild a feed `Session` from a past session's JSONL transcript so the +/// feed isn't blank when resuming (no API traffic flows until the next turn). +/// Live taps for the resumed session find this entry by key and append to it. +pub fn load_history(uuid: &str) -> Option { + load_view(uuid, None).map(|h| h.session) +} + +/// Path-aware transcript view: with `path = Some((tree, leaf))` only the +/// preamble + the root→leaf chain is rendered (dead branches excluded) and +/// per-turn entry offsets are recorded; with None, the whole file. +pub fn load_view(uuid: &str, path: Option<(&TurnTree, usize)>) -> Option { + match path { + None => { + let fpath = project_dir().ok()?.join(format!("{uuid}.jsonl")); + load_file_view(&fpath, uuid) + } + Some((tree, leaf)) => { + let mut p = EntryParser::new(); + let mut turn_entries: Vec<(usize, usize)> = Vec::new(); + for l in &tree.preamble { + p.line(l); + } + for &t in &tree.chain(leaf) { + turn_entries.push((t, p.entries.len())); + for l in &tree.turns[t].lines { + p.line(l); + } + } + p.into_view(uuid, Some(leaf), turn_entries) + } + } +} + +/// Whole-file transcript view from an explicit path (the I/O-location seam: +/// tests parse temp files without touching `HOME`). +pub(crate) fn load_file_view(path: &std::path::Path, uuid: &str) -> Option { + let f = std::fs::File::open(path).ok()?; + let mut p = EntryParser::new(); + for line in std::io::BufReader::new(f).lines().map_while(Result::ok) { + p.line(&line); + } + p.into_view(uuid, None, Vec::new()) +} +/// Incremental JSONL-record → feed-`Entry` translation (shared by the whole +/// file and path views). +struct EntryParser { + entries: Vec, + model: String, + /// tool_use id → entry index, to attach results from later user lines. + tool_idx: HashMap, +} + +impl EntryParser { + fn new() -> Self { + Self { + entries: Vec::new(), + model: String::from("(resumed)"), + tool_idx: HashMap::new(), + } + } + + /// Wrap the parsed entries into a `HistoryView` (None when empty). + fn into_view( + self, + uuid: &str, + leaf: Option, + turn_entries: Vec<(usize, usize)>, + ) -> Option { + if self.entries.is_empty() { + return None; + } + Some(HistoryView { + session: Session { + key: uuid.to_string(), + model: self.model, + entries: self.entries, + active: 0, + input_tokens: 0, + output_tokens: 0, + last_activity: std::time::Instant::now(), + tool_ids: HashMap::new(), + }, + leaf, + turn_entries, + }) + } + + fn line(&mut self, line: &str) { + let entries = &mut self.entries; + let tool_idx = &mut self.tool_idx; + let Ok(v) = serde_json::from_str::(line) else { + return; + }; + // Skip subagent transcripts and synthetic/meta user lines. + if v.get("isSidechain").and_then(Value::as_bool) == Some(true) + || v.get("isMeta").and_then(Value::as_bool) == Some(true) + { + return; + } + match v.get("type").and_then(Value::as_str) { + Some("assistant") => { + if let Some(m) = v.pointer("/message/model").and_then(Value::as_str) { + self.model = m.to_string(); + } + let Some(blocks) = v.pointer("/message/content").and_then(Value::as_array) + else { + return; + }; + for b in blocks { + let entry = match b.get("type").and_then(Value::as_str) { + Some("thinking") => Some(Entry { + kind: Kind::Thinking, + content: text_of(b, "thinking"), + done: true, + result: None, + }), + Some("redacted_thinking") => Some(Entry { + kind: Kind::Thinking, + content: "[redacted]".into(), + done: true, + result: None, + }), + Some("text") => Some(Entry { + kind: Kind::Text, + content: text_of(b, "text"), + done: true, + result: None, + }), + Some("tool_use" | "server_tool_use" | "mcp_tool_use") => { + if let Some(id) = b.get("id").and_then(Value::as_str) { + tool_idx.insert(id.to_string(), entries.len()); + } + let input = b.get("input").map_or(String::new(), |i| { + serde_json::to_string_pretty(i).unwrap_or_default() + }); + Some(Entry { + kind: Kind::Tool { + name: b + .get("name") + .and_then(Value::as_str) + .unwrap_or("tool") + .to_string(), + }, + content: input, + done: true, + result: None, + }) + } + _ => None, + }; + if let Some(e) = entry { + entries.push(e); + } + } + } + Some("user") => match v.pointer("/message/content") { + Some(Value::String(s)) => { + if !s.is_empty() { + entries.push(Entry { + kind: Kind::Meta, + content: format!("❯ {s}"), + done: true, + result: None, + }); + } + } + Some(Value::Array(blocks)) => { + for b in blocks { + match b.get("type").and_then(Value::as_str) { + Some("text") => { + let t = text_of(b, "text"); + if !t.is_empty() { + entries.push(Entry { + kind: Kind::Meta, + content: format!("❯ {t}"), + done: true, + result: None, + }); + } + } + Some("tool_result") => { + let Some(idx) = b + .get("tool_use_id") + .and_then(Value::as_str) + .and_then(|id| tool_idx.remove(id)) + else { + continue; + }; + if let Some(e) = entries.get_mut(idx) { + e.result = Some(ToolResult { + content: flatten_result_content(b.get("content")), + is_error: b + .get("is_error") + .and_then(Value::as_bool) + .unwrap_or(false), + }); + } + } + _ => {} + } + } + } + _ => {} + }, + _ => {} + } + } +} + +fn text_of(b: &Value, key: &str) -> String { + b.get(key).and_then(Value::as_str).unwrap_or_default().to_string() +} + +/// Encode the current working directory the same way Claude Code does: +/// each `/` → `-` (the leading `/` becomes a leading `-`). +fn project_dir() -> Result { + let home = std::env::var("HOME").map_err(|_| "HOME is not set".to_string())?; + let cwd = std::env::current_dir().map_err(|e| format!("cannot read cwd: {e}"))?; + let encoded = cwd + .to_str() + .ok_or_else(|| "cwd is not valid UTF-8; cannot map it to a Claude project dir".to_string())? + .replace('/', "-"); + let dir = PathBuf::from(home).join(".claude").join("projects").join(encoded); + if !dir.is_dir() { + return Err("no past sessions recorded for this directory".to_string()); + } + Ok(dir) +} + +/// Read a human-readable label from the JSONL file. Prefers `ai-title`; +/// falls back to the first non-empty `last-prompt` text; then uuid prefix. +fn read_label(path: &std::path::Path, uuid: &str) -> String { + let fallback = || uuid.chars().take(8).collect::(); + let Ok(f) = std::fs::File::open(path) else { + return fallback(); + }; + let reader = std::io::BufReader::new(f); + let mut last_prompt: Option = None; + for line in reader.lines().map_while(Result::ok) { + let Ok(v) = serde_json::from_str::(&line) else { + continue; + }; + match v.get("type").and_then(|t| t.as_str()) { + Some("ai-title") => { + if let Some(t) = v.get("aiTitle").and_then(|t| t.as_str()) + && !t.is_empty() + { + return one_line(t); + } + } + Some("last-prompt") => { + if let Some(p) = v.get("lastPrompt").and_then(|p| p.as_str()) + && !p.is_empty() + { + last_prompt = Some(one_line(p)); + } + } + _ => {} + } + } + last_prompt.unwrap_or_else(fallback) +} + +/// First line only, control characters dropped — labels go into a one-row +/// list item, so embedded newlines/tabs would smear the layout. +fn one_line(s: &str) -> String { + s.lines() + .next() + .unwrap_or("") + .chars() + .filter(|c| !c.is_control()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + /// A user-prompt record (turn start). + fn prompt(uuid: &str, parent: Option<&str>, text: &str) -> String { + serde_json::json!({ + "type": "user", "uuid": uuid, "parentUuid": parent, + "sessionId": "orig", + "message": {"role": "user", "content": text} + }) + .to_string() + } + + /// An assistant text record. + fn reply(uuid: &str, parent: &str, text: &str) -> String { + serde_json::json!({ + "type": "assistant", "uuid": uuid, "parentUuid": parent, + "sessionId": "orig", + "message": {"model": "claude-x", "content": [{"type": "text", "text": text}]} + }) + .to_string() + } + + /// A branched fixture: prompt A → prompt B (abandoned) and, after a + /// rewind onto A's reply, prompt B2 → C (the trunk). + fn branched() -> Vec { + vec![ + r#"{"type":"mode","mode":"normal","sessionId":"orig"}"#.into(), // preamble + prompt("u1", None, "prompt A"), + reply("u2", "u1", "reply A"), + prompt("u3", Some("u2"), "prompt B"), + reply("u4", "u3", "reply B"), + prompt("u5", Some("u2"), "prompt B2"), // rewind: forks off reply A + reply("u6", "u5", "reply B2"), + prompt("u7", Some("u6"), "prompt C"), + reply("u8", "u7", "reply C"), + ] + } + + #[test] + fn tree_groups_turns_and_orders_branches() { + let tree = build_tree(branched()); + assert_eq!(tree.turns.len(), 4); + assert_eq!(tree.preamble.len(), 1, "mode record lands in the preamble"); + let labels: Vec<&str> = tree.turns.iter().map(|t| t.label.as_str()).collect(); + assert_eq!(labels, ["prompt A", "prompt B", "prompt B2", "prompt C"]); + assert_eq!(tree.turns[1].parent, Some(0), "B forks off A"); + assert_eq!(tree.turns[2].parent, Some(0), "B2 forks off A"); + assert_eq!(tree.turns[3].parent, Some(2)); + // Display: A, then the abandoned B indented, then trunk B2 → C. + assert_eq!(tree.display, [0, 1, 2, 3]); + assert_eq!(tree.turns[0].depth, 0); + assert_eq!(tree.turns[1].depth, 1, "abandoned branch indents"); + assert_eq!(tree.turns[2].depth, 0, "most recent child stays on trunk"); + assert_eq!(tree.turns[3].depth, 0); + // Trunk resolution + chains. + assert_eq!(tree.trunk_leaf(0), 3); + assert_eq!(tree.trunk_leaf(1), 1, "dead branch ends at its own leaf"); + assert_eq!(tree.chain(3), [0, 2, 3]); + assert_eq!(tree.chain(1), [0, 1]); + } + + #[test] + fn path_view_excludes_dead_branches_and_offsets_turns() { + let tree = build_tree(branched()); + let h = load_view("some-uuid", Some((&tree, 3))).expect("view"); + let texts: Vec<&str> = h.session.entries.iter().map(|e| e.content.as_str()).collect(); + assert!(texts.iter().any(|t| t.contains("prompt B2"))); + assert!( + !texts.iter().any(|t| *t == "reply B" || t.contains("prompt B\n") || t.ends_with("prompt B")), + "dead branch content must not leak into the path view: {texts:?}" + ); + assert_eq!(h.leaf, Some(3)); + // Turn offsets point at each turn's first entry (its ❯ prompt). + assert_eq!(h.turn_entries.len(), 3); + for &(t, e) in &h.turn_entries { + assert!( + h.session.entries[e].content.contains(&tree.turns[t].label), + "offset {e} should land on the prompt of turn {t}" + ); + } + } + + #[test] + fn materialize_chain_and_stitch() { + let tree = build_tree(branched()); + let dir = std::env::temp_dir().join(format!("ct-mat-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + + // Plain branch: chain through the abandoned B turn. + let new = materialize_in(&dir, &tree, &tree.chain(1), "⑂ prompt B").unwrap(); + let body = std::fs::read_to_string(dir.join(format!("{new}.jsonl"))).unwrap(); + let recs: Vec = body.lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(recs[0]["type"], "ai-title"); + assert_eq!(recs[0]["aiTitle"], "⑂ prompt B"); + for r in &recs { + assert_eq!(r["sessionId"], Value::String(new.clone()), "sessionId rewritten"); + } + assert!(!body.contains("prompt B2"), "other branch excluded"); + let rebuilt = build_tree(body.lines().map(str::to_string)); + assert_eq!(rebuilt.turns.len(), 2); + assert_eq!(rebuilt.turns[1].parent, Some(0), "chain stays linked"); + + // Visual stitch: only the C turn, fully decoupled. + let new2 = materialize_in(&dir, &tree, &[3], "⑂ prompt C").unwrap(); + let body2 = std::fs::read_to_string(dir.join(format!("{new2}.jsonl"))).unwrap(); + let rebuilt2 = build_tree(body2.lines().map(str::to_string)); + assert_eq!(rebuilt2.turns.len(), 1); + assert_eq!(rebuilt2.turns[0].label, "prompt C"); + assert_eq!(rebuilt2.turns[0].parent, None, "first turn re-parented to root"); + assert!(!body2.contains("prompt A"), "unselected turns dropped"); + + std::fs::remove_dir_all(&dir).ok(); + } + + fn write_jsonl(lines: &[&str]) -> std::path::PathBuf { + use std::sync::atomic::{AtomicU32, Ordering}; + static N: AtomicU32 = AtomicU32::new(0); + let path = std::env::temp_dir().join(format!( + "ct-sessions-test-{}-{}.jsonl", + std::process::id(), + N.fetch_add(1, Ordering::Relaxed) + )); + let mut f = std::fs::File::create(&path).unwrap(); + for l in lines { + writeln!(f, "{l}").unwrap(); + } + path + } + + /// Property check against whatever real session files exist for this + /// directory (no-op elsewhere): every turn appears in `display` exactly + /// once, depths are sane, and chains terminate (parents always precede + /// children, so the tree is acyclic by construction — verify anyway). + #[test] + fn real_session_trees_uphold_invariants() { + let Ok(dir) = project_dir() else { return }; + let Ok(rd) = std::fs::read_dir(&dir) else { return }; + for e in rd.flatten() { + let path = e.path(); + if path.extension().and_then(|x| x.to_str()) != Some("jsonl") { + continue; + } + let Ok(body) = std::fs::read_to_string(&path) else { continue }; + let tree = build_tree(body.lines().map(str::to_string)); + let mut seen = vec![false; tree.turns.len()]; + for &t in &tree.display { + assert!(!seen[t], "{path:?}: turn {t} displayed twice"); + seen[t] = true; + } + assert!(seen.iter().all(|&s| s), "{path:?}: turn missing from display"); + for (i, t) in tree.turns.iter().enumerate() { + assert!(!t.label.is_empty(), "{path:?}: empty turn label"); + if let Some(p) = t.parent { + assert!(p < i, "{path:?}: parent {p} not before child {i}"); + } + assert!(!tree.chain(i).is_empty()); + } + } + } + + #[test] + fn label_prefers_ai_title() { + let p = write_jsonl(&[ + r#"{"type":"last-prompt","lastPrompt":"fix the bug"}"#, + r#"{"type":"ai-title","aiTitle":"bug fixing session"}"#, + ]); + assert_eq!(read_label(&p, "deadbeef-0000"), "bug fixing session"); + std::fs::remove_file(p).ok(); + } + + #[test] + fn label_falls_back_to_last_prompt_then_uuid() { + let p = write_jsonl(&[ + r#"{"type":"last-prompt","lastPrompt":"first"}"#, + r#"{"type":"last-prompt","lastPrompt":"latest\nmultiline"}"#, + ]); + assert_eq!(read_label(&p, "deadbeef-0000"), "latest"); + std::fs::remove_file(p).ok(); + + let p = write_jsonl(&[r#"{"type":"user"}"#, "not json"]); + assert_eq!(read_label(&p, "deadbeef-0000"), "deadbeef"); + std::fs::remove_file(p).ok(); + } + + #[test] + fn history_rebuilds_entries_with_tool_results() { + let p = write_jsonl(&[ + r#"{"type":"user","message":{"role":"user","content":"fix the bug"}}"#, + r#"{"type":"assistant","message":{"model":"claude-x","content":[ + {"type":"thinking","thinking":"hmm"}, + {"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls"}} + ]}}"# + .replace('\n', " ") + .leak(), + r#"{"type":"user","message":{"role":"user","content":[ + {"type":"tool_result","tool_use_id":"toolu_1","content":"file_a","is_error":false} + ]}}"# + .replace('\n', " ") + .leak(), + r#"{"type":"assistant","isSidechain":true,"message":{"model":"claude-x","content":[{"type":"text","text":"subagent noise"}]}}"#, + r#"{"type":"assistant","message":{"model":"claude-x","content":[{"type":"text","text":"done"}]}}"#, + ]); + // Parse via the explicit-path seam: no HOME mutation (an unsafe + // set_var would race the env reads of concurrently running tests). + let uuid = "11111111-2222-3333-4444-555555555555"; + let s = load_file_view(&p, uuid).map(|h| h.session); + std::fs::remove_file(&p).ok(); + + let s = s.expect("history loaded"); + assert_eq!(s.key, uuid); + assert_eq!(s.model, "claude-x"); + // ❯ prompt, thinking, tool, text — sidechain line skipped. + assert_eq!(s.entries.len(), 4); + assert_eq!(s.entries[0].content, "❯ fix the bug"); + assert!(matches!(s.entries[1].kind, Kind::Thinking)); + let tool = &s.entries[2]; + assert!(matches!(&tool.kind, Kind::Tool { name } if name == "Bash")); + assert_eq!(tool.result.as_ref().unwrap().content, "file_a"); + assert_eq!(s.entries[3].content, "done"); + assert!(s.entries.iter().all(|e| e.done)); + } +} diff --git a/src/sse.rs b/src/sse.rs index a437583..af456c5 100644 --- a/src/sse.rs +++ b/src/sse.rs @@ -7,9 +7,18 @@ pub struct SseParser { buf: Vec, } +/// Safety valve for a hung/malformed upstream that never sends an event +/// terminator: cap the accumulation buffer so a misbehaving connection can't +/// grow memory without bound. Tap-only — the relayed bytes are unaffected. +const MAX_BUF: usize = 4 * 1024 * 1024; + impl SseParser { pub fn feed(&mut self, chunk: &[u8]) -> Vec<(String, Value)> { self.buf.extend_from_slice(chunk); + if self.buf.len() > MAX_BUF { + self.buf.clear(); + return Vec::new(); + } let mut out = Vec::new(); while let Some((content_end, drain_end)) = find_event_end(&self.buf) { let event: Vec = self.buf.drain(..drain_end).collect(); @@ -20,13 +29,18 @@ impl SseParser { if let Some(v) = line.strip_prefix("event:") { name = v.trim().to_string(); } else if let Some(v) = line.strip_prefix("data:") { + // SSE spec §9.2.6: multiple data lines are concatenated + // with a U+000A LINE FEED between them. + if !data.is_empty() { + data.push('\n'); + } data.push_str(v.strip_prefix(' ').unwrap_or(v)); } } - if !data.is_empty() { - if let Ok(v) = serde_json::from_str(&data) { - out.push((name, v)); - } + if !data.is_empty() + && let Ok(v) = serde_json::from_str(&data) + { + out.push((name, v)); } } out diff --git a/src/term.rs b/src/term.rs index 0e9baca..2128cd9 100644 --- a/src/term.rs +++ b/src/term.rs @@ -25,8 +25,9 @@ use wezterm_term::{ }; /// Rows cropped above the last non-blank screen row — hides Claude Code's -/// persistent hint rows ("? for shortcuts" + permission/mode line). -const BOTTOM_CROP: usize = 2; +/// persistent hint row ("? for shortcuts" / permission mode) while keeping +/// the statusLine that renders directly under the input box. +const BOTTOM_CROP: usize = 1; /// Extra PTY rows beyond the visible pane window, so the child has room to /// draw the rows we crop. const PTY_PAD: u16 = 4; @@ -54,16 +55,22 @@ pub struct EmbeddedTerm { /// Session UUID passed to `claude --session-id`; lets the tap recognise /// which proxied session belongs to this pane. pub session_id: String, - rows: u16, + /// Actual PTY rows (visible rows + pad when cropping is active). + pty_rows: u16, cols: u16, } impl EmbeddedTerm { - /// Spawn `claude` in a fresh PTY, routed through our proxy. - pub fn spawn(port: u16, rows: u16, cols: u16) -> anyhow::Result { + /// Spawn `claude` in a fresh PTY, routed through our proxy. `model`, when + /// non-empty, is passed as `--model ` (a Claude Code alias like + /// `opus`/`sonnet`/`haiku` or a full model name). + pub fn spawn(port: u16, rows: u16, cols: u16, model: &str) -> anyhow::Result { let session_id = uuid::Uuid::new_v4().to_string(); let mut cmd = CommandBuilder::new("claude"); cmd.args(["--session-id", &session_id]); + if !model.is_empty() { + cmd.args(["--model", model]); + } cmd.env("ANTHROPIC_BASE_URL", format!("http://127.0.0.1:{port}")); if let Ok(cwd) = std::env::current_dir() { cmd.cwd(cwd); @@ -71,6 +78,21 @@ impl EmbeddedTerm { Self::spawn_cmd(cmd, session_id, rows, cols) } + /// Spawn `claude --resume ` to continue a past session. + /// A resumed session keeps its original session UUID in request + /// metadata (verified against Claude Code 2.1.x), so the tap correlates + /// traffic via the resumed UUID itself. `--session-id` must NOT be + /// passed alongside `--resume` (rejected without `--fork-session`). + pub fn spawn_resume(port: u16, rows: u16, cols: u16, session_id: &str) -> anyhow::Result { + let mut cmd = CommandBuilder::new("claude"); + cmd.args(["--resume", session_id]); + cmd.env("ANTHROPIC_BASE_URL", format!("http://127.0.0.1:{port}")); + if let Ok(cwd) = std::env::current_dir() { + cmd.cwd(cwd); + } + Self::spawn_cmd(cmd, session_id.to_string(), rows, cols) + } + fn spawn_cmd( cmd: CommandBuilder, session_id: String, @@ -125,7 +147,7 @@ impl EmbeddedTerm { }); } - Ok(Self { term, master: pty.master, killer, exited, session_id, rows, cols }) + Ok(Self { term, master: pty.master, killer, exited, session_id, pty_rows: rows + PTY_PAD, cols }) } pub fn exited(&self) -> bool { @@ -133,16 +155,18 @@ impl EmbeddedTerm { } /// Resize PTY + terminal model for a pane of `rows` visible rows. - /// The PTY gets `PTY_PAD` extra rows: render() crops Claude Code's - /// persistent status/hint rows, so the child needs room to draw them - /// somewhere we don't show. - pub fn resize(&mut self, rows: u16, cols: u16) { - if (rows, cols) == (self.rows, self.cols) || rows == 0 || cols == 0 { + /// With `crop` (the compact pane), the PTY gets `PTY_PAD` extra rows: + /// render() crops Claude Code's persistent status/hint rows, so the + /// child needs room to draw them somewhere we don't show. Without + /// `crop` (fullscreen), the PTY matches the pane exactly so nothing + /// is ever cut off. + pub fn resize(&mut self, rows: u16, cols: u16, crop: bool) { + let rows = rows + if crop { PTY_PAD } else { 0 }; + if (rows, cols) == (self.pty_rows, self.cols) || rows == 0 || cols == 0 { return; } - self.rows = rows; + self.pty_rows = rows; self.cols = cols; - let rows = rows + PTY_PAD; let _ = self.master.resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 }); self.term.lock().unwrap().resize(TerminalSize { rows: rows as usize, @@ -205,27 +229,38 @@ impl EmbeddedTerm { /// Paint a window of the child's screen into `area`. Returns the cursor /// position (absolute buffer coordinates) when the child wants it shown. /// - /// The window is content-anchored rather than the raw top of the screen, - /// tuned to keep the pane prompt-only against Claude Code's UI: + /// With `crop`, the window is content-anchored rather than the raw top of + /// the screen, tuned to keep the pane prompt-only against Claude Code's UI: /// - it *ends* `BOTTOM_CROP` rows above the last non-blank row, hiding - /// the persistent hint rows ("? for shortcuts", permission mode); + /// the persistent hint row ("? for shortcuts", permission mode) but + /// keeping the statusLine just below the input box; /// - it *starts* no higher than row 2 when content allows, hiding the /// status line ("✻ Worked for 1s" / spinner row sits right above the /// input box and stays visible since the window ends near it). - pub fn render(&self, area: Rect, buf: &mut Buffer) -> Option<(u16, u16)> { + /// + /// Without `crop` (fullscreen), the screen is shown verbatim from row 0 + /// so nothing is ever cut off. + pub fn render(&self, area: Rect, buf: &mut Buffer, crop: bool) -> 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 last = lines - .iter() - .rposition(|l| l.visible_cells().any(|c| !c.str().trim().is_empty())) - .unwrap_or(0); - let end = last.saturating_sub(BOTTOM_CROP); - let start = (end + 1) - .saturating_sub(area.height as usize) - .max(2) - .min(end); + let (start, end) = if crop { + let last = lines + .iter() + .rposition(|l| l.visible_cells().any(|c| !c.str().trim().is_empty())) + .unwrap_or(0); + let end = last.saturating_sub(BOTTOM_CROP); + let start = (end + 1) + .saturating_sub(area.height as usize) + .max(2) + .min(end); + (start, end) + } else { + // The PTY is sized to the pane in fullscreen, but a resize may + // not have landed yet — clamp to whatever fits. + (0, lines.len().min(area.height as usize).saturating_sub(1)) + }; for (y, line) in lines[start..=end].iter().enumerate() { if y as u16 >= area.height { break; @@ -296,11 +331,127 @@ fn conv_color(c: ColorAttribute) -> Option { } } +/// 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. +pub fn spawn_model_discovery(app: crate::app::SharedApp) { + std::thread::spawn(move || { + if let Some(aliases) = discover_model_aliases() { + // "default" (no --model flag) first, then the discovered aliases. + let mut choices: Vec<(String, String)> = vec![("default".into(), String::new())]; + choices.extend(aliases.into_iter().map(|a| (a.clone(), a))); + crate::app::lock_app(&app).model_choices = choices; + } + }); +} + +/// Best-effort discovery of the model aliases the installed `claude` accepts +/// (e.g. `opus`, `sonnet`, `haiku`, `fable`), so the `n` picker tracks 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 +/// `["sonnet","opus","haiku","fable"]`. We resolve the `claude` binary on PATH +/// and scan its bytes for the longest such array anchored by `opus` + `sonnet`. +/// 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_aliases() -> Option> { + let bytes = std::fs::read(claude_binary_path()?).ok()?; + longest_alias_array(&bytes) +} + +/// Resolve `claude` on `PATH` to a readable file path (symlinks followed). +fn claude_binary_path() -> Option { + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path) + .map(|d| d.join("claude")) + .find(|c| c.is_file()) + .map(|c| std::fs::canonicalize(&c).unwrap_or(c)) +} + +/// Scan a byte buffer for JSON array literals of short lowercase tokens and +/// return the longest one that contains both `opus` and `sonnet` (the stable +/// anchors of Claude Code's model-alias list). +fn longest_alias_array(bytes: &[u8]) -> Option> { + let mut best: Option> = None; + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'[' + && let Some((arr, end)) = parse_str_array(bytes, i) + { + let anchored = arr.iter().any(|s| s == "opus") && arr.iter().any(|s| s == "sonnet"); + if anchored && best.as_ref().is_none_or(|b| arr.len() > b.len()) { + best = Some(arr); + } + i = end; + continue; + } + i += 1; + } + best +} + +/// Parse `["a","b",...]` of lowercase-`[a-z0-9-]` tokens starting at `start` +/// (which must be `[`). Returns the tokens and the index just past the closing +/// `]`, or None if the bytes there aren't exactly such an array. +fn parse_str_array(bytes: &[u8], start: usize) -> Option<(Vec, usize)> { + let n = bytes.len(); + let mut i = start + 1; // past '[' + let mut out = Vec::new(); + loop { + if i >= n { + return None; + } + if bytes[i] == b']' { + return (!out.is_empty()).then_some((out, i + 1)); + } + if bytes[i] != b'"' { + return None; + } + i += 1; + let tok_start = i; + while i < n && bytes[i] != b'"' { + let c = bytes[i]; + if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-') { + return None; + } + i += 1; + } + let tok = bytes.get(tok_start..i)?; + if tok.is_empty() || tok.len() > 24 { + return None; + } + out.push(String::from_utf8(tok.to_vec()).ok()?); + i += 1; // past closing quote + match bytes.get(i)? { + b',' => i += 1, + b']' => return Some((out, i + 1)), + _ => return None, + } + } +} + #[cfg(test)] mod tests { use super::*; use std::time::{Duration, Instant}; + #[test] + fn picks_longest_anchored_alias_array() { + let bytes = br#"junk["opus","sonnet"]more["sonnet","opus","haiku","fable"]tail"#; + let got = longest_alias_array(bytes).unwrap(); + assert_eq!(got, ["sonnet", "opus", "haiku", "fable"]); + } + + #[test] + fn ignores_arrays_without_both_anchors() { + // Missing "sonnet" → not a model-alias array. + assert!(longest_alias_array(br#"["opus","haiku","fable"]"#).is_none()); + // Non-token content (uppercase/spaces) → rejected. + assert!(longest_alias_array(br#"["Opus","sonnet"]"#).is_none()); + } + /// Full pipeline: PTY spawn → reader thread → wezterm-term model → /// ratatui buffer. Headless-safe: the *child* gets the tty, not us. #[test] @@ -312,7 +463,7 @@ mod tests { let deadline = Instant::now() + Duration::from_secs(5); loop { let mut buf = Buffer::empty(area); - et.render(area, &mut buf); + et.render(area, &mut buf, true); let row: String = (0..area.width).map(|x| buf[(x, 0)].symbol()).collect(); if row.contains("hello-embed") { break; diff --git a/src/ui.rs b/src/ui.rs index caeec64..9a8e5a5 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,23 +1,56 @@ -use crate::app::{filter_index, fmt_tokens, Kind, SharedApp, ToolResult, FILTER_LABELS}; +use crate::app::{ + filter_index, fmt_tokens, Entry, Kind, Session, SharedApp, ToolResult, FILTER_LABELS, +}; use crate::term::EmbeddedTerm; -use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; -use ratatui::layout::{Constraint, Layout, Rect}; -use ratatui::style::{Color, Style, Stylize}; +use ratatui::crossterm::event::{ + self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers, + MouseButton, MouseEventKind, +}; +use ratatui::crossterm::execute; +use ratatui::layout::{Constraint, Layout, Position, Rect}; +use ratatui::style::{Color, Modifier, Style, Stylize}; use ratatui::text::{Line, Span, Text}; use serde_json::Value; use ratatui::widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap}; use ratatui::Frame; -use std::time::Duration; +use std::collections::HashSet; +use std::time::{Duration, Instant}; /// Embedded `claude` pane state (UI-thread only; the shared App carries just /// the session id + grow flag so the proxy tap can talk to it). +/// +/// One app instance hosts at most one embedded claude (`term`). The pane is +/// drawn only while the feed selection is on its session: tabbing away hides +/// it without killing the child, so tabbing back is instant. ctrl-↓ is the +/// commit point that may kill + respawn (`attach_selected`). struct EmbedUi { term: Option, visible: bool, /// Keyboard focus is on the claude pane (vs. the feed above it). /// Directional: ctrl-↓ moves focus into the pane, ctrl-↑ back to the feed. claude_focused: bool, + /// Pane takes (nearly) the whole screen. Toggled with ctrl-f while the + /// pane has focus; cleared when focus leaves it or it is hidden. + fullscreen: bool, port: u16, + /// Sessions that were embedded earlier in this process: their instances + /// are known dead (we killed them), so resuming needs no liveness guard. + past_embeds: HashSet, + /// Armed by ctrl-↓ on a live external session: pressing again on the + /// same session within a few seconds forces the resume (the session may + /// have ended long ago — liveness of external instances is unknowable). + force_resume: Option<(String, Instant)>, +} + +/// Mouse text selection over the whole screen (mimics Claude Code: drag to +/// select with a reversed-video highlight, the underlying screen text is +/// copied to the system clipboard on release via OSC 52). +struct Selection { + start: (u16, u16), // (x, y) anchor cell + end: (u16, u16), // (x, y) current cell (inclusive) + dragging: bool, + /// Set on mouse release: the next draw extracts + copies the text. + copy_pending: bool, } impl EmbedUi { @@ -29,10 +62,152 @@ impl EmbedUi { } } +/// Per-entry feed render cache. Entries are append-only and an entry's +/// content only changes by streaming appends / tool-result attachment, so the +/// rendered lines + wrapped height are cached per entry and rebuilt only when +/// the fingerprint changes. Two effects: the per-frame work done *while +/// holding the app mutex* is proportional to what changed, not to session +/// length (the proxy tap shares that mutex — a slow render must not starve +/// it), and the feed scrolls correctly past u16::MAX wrapped lines because +/// only the visible window of lines is handed to ratatui. +#[derive(Default)] +struct FeedCache { + session_key: String, + width: u16, + /// Tree path (leaf turn) the cached view renders; None = whole file/live. + leaf: Option, + /// Live in-memory session vs on-disk transcript (same uuid, different + /// entry lists — must not share cache slots). + live: bool, + entries: Vec, +} + +struct CachedEntry { + fingerprint: (usize, bool, usize, bool), + lines: Vec>, + /// Rows after wrapping to `FeedCache::width` (incl. trailing separator). + height: usize, +} + +/// Cheap change-detector for a cached entry: content only ever grows (or is +/// swapped for the pretty-printed form along with `done`), results attach +/// once — length + flags capture every mutation the app performs. +fn fingerprint(e: &Entry) -> (usize, bool, usize, bool) { + let (rlen, rerr) = e + .result + .as_ref() + .map_or((usize::MAX, false), |r| (r.content.len(), r.is_error)); + (e.content.len(), e.done, rlen, rerr) +} + +/// Detach a `Line` from the text it borrows so it can outlive the app lock. +fn own_line(l: Line<'_>) -> Line<'static> { + Line { + spans: l + .spans + .into_iter() + .map(|s| Span::styled(s.content.into_owned(), s.style)) + .collect(), + style: l.style, + alignment: l.alignment, + } +} + +/// Background for user-prompt entries (indexed so 256-color terminals work). +const USER_BG: Color = Color::Indexed(17); // deep blue + +/// Render one entry to owned lines, including the blank separator row that +/// follows every entry in the feed. +fn entry_lines(e: &Entry, width: u16) -> Vec> { + let mut lines: Vec> = Vec::new(); + match &e.kind { + Kind::Meta => lines.push(Line::from(e.content.clone()).dark_gray()), + Kind::User => { + let style = Style::new().bg(USER_BG).fg(Color::White); + let w = (width as usize).max(1); + for (i, l) in e.content.lines().enumerate() { + let prefix = if i == 0 { "❯ " } else { " " }; + let mut row = format!("{prefix}{}", sanitize(l)); + // Pad to a multiple of the feed width so every *wrapped* row + // is painted edge-to-edge, not just up to the last character. + let rem = row.chars().count() % w; + if rem != 0 { + row.extend(std::iter::repeat_n(' ', w - rem)); + } + lines.push(Line::from(Span::styled(row, style))); + } + } + Kind::Thinking => { + let head = if e.done { "✻ thought" } else { "✻ thinking…" }; + lines.push(Line::from(head).magenta().italic()); + for l in e.content.lines() { + lines.push(Line::from(l.to_string()).dark_gray().italic()); + } + } + Kind::Text => { + lines.extend(crate::markdown::render(&e.content, width).into_iter().map(own_line)); + } + Kind::Tool { name } => { + // Once the input JSON is complete, every tool gets a + // human-readable rendering; partial streams fall back to + // the raw JSON-fragment view. + let parsed = e + .done + .then(|| serde_json::from_str::(&e.content).ok()) + .flatten(); + match parsed { + Some(v) => render_tool(name, &v, e.result.as_ref(), &mut lines, width), + None => { + let head = if e.done { + format!("⚙ {name}") + } else { + format!("⚙ {name} …") + }; + lines.push(Line::from(head).yellow().bold()); + for l in e.content.lines() { + lines.push(Line::from(format!(" {l}")).cyan()); + } + } + } + } + Kind::Error => { + for l in e.content.lines() { + lines.push(Line::from(l.to_string()).red().bold()); + } + } + } + lines.push(Line::default()); + lines +} + +/// Rows the lines occupy after wrapping to `width` (must match the wrap +/// configuration of the feed Paragraph). +fn wrapped_height(lines: &[Line<'static>], width: u16) -> usize { + Paragraph::new(Text::from(lines.to_vec())) + .wrap(Wrap { trim: false }) + .line_count(width) +} + pub fn run(app: SharedApp, port: u16) -> anyhow::Result<()> { + // Keep the session list populated with this directory's on-disk history. + crate::sessions::spawn_scanner(app.clone()); + // Refresh the `n` model picker from the live `claude` alias set. + crate::term::spawn_model_discovery(app.clone()); let mut terminal = ratatui::init(); - let mut eui = EmbedUi { term: None, visible: false, claude_focused: false, port }; + // Mouse capture: wheel events scroll the feed (regardless of pane focus). + // Trade-off: text selection in the outer terminal now needs shift held. + let _ = execute!(std::io::stdout(), EnableMouseCapture); + let mut eui = EmbedUi { + term: None, + visible: false, + claude_focused: false, + fullscreen: false, + port, + past_embeds: HashSet::new(), + force_resume: None, + }; let res = event_loop(&mut terminal, app, &mut eui); + let _ = execute!(std::io::stdout(), DisableMouseCapture); ratatui::restore(); res } @@ -41,10 +216,13 @@ fn toggle_embed(eui: &mut EmbedUi, app: &SharedApp) { if eui.visible { eui.visible = false; eui.claude_focused = false; + eui.fullscreen = false; // A dead child is dropped on hide so the next toggle respawns. if eui.term.as_ref().is_some_and(|t| t.exited()) { eui.term = None; - app.lock().unwrap().embed_session = None; + if let Some(old) = app.lock().unwrap().embed_session.take() { + eui.past_embeds.insert(old); + } } return; } @@ -52,17 +230,28 @@ fn toggle_embed(eui: &mut EmbedUi, app: &SharedApp) { } /// Show (spawning if needed) the claude pane and give it keyboard focus. +/// Moves the selection onto the embedded session — the pane is only drawn +/// while its session is selected. fn show_embed_pane(eui: &mut EmbedUi, app: &SharedApp) { if eui.term.as_ref().is_some_and(|t| t.exited()) { eui.term = None; } if eui.term.is_none() { // Real dimensions are applied on the first draw via resize(). - match EmbeddedTerm::spawn(eui.port, 20, 80) { + match EmbeddedTerm::spawn(eui.port, 20, 80, "") { Ok(t) => { let mut a = app.lock().unwrap(); - a.embed_session = Some(t.session_id.clone()); + if let Some(old) = a.embed_session.replace(t.session_id.clone()) { + eui.past_embeds.insert(old); + } a.embed_grow = false; + // A fresh session has no traffic yet: give it a live row now + // so the selection (and the pane-visibility rule) has a key + // to point at. Tap::new finds this row by key and reuses it. + if !a.sessions.iter().any(|s| s.key == t.session_id) { + a.sessions + .push(Session::new(t.session_id.clone(), "(embedded)".into())); + } drop(a); eui.term = Some(t); } @@ -72,17 +261,186 @@ fn show_embed_pane(eui: &mut EmbedUi, app: &SharedApp) { } } } + let mut a = app.lock().unwrap(); + if let Some(key) = a.embed_session.clone() { + a.select_key(&key); + a.follow = true; + } + drop(a); eui.visible = true; eui.claude_focused = true; } +/// `n` model picker: always spawn a *fresh* `claude --session-id ` +/// (optionally `--model `), killing any current pane first. Unlike +/// `show_embed_pane` this never reuses an existing child — the point of `n` +/// is to start a brand-new session without resume + /clear. +fn show_embed_new(eui: &mut EmbedUi, app: &SharedApp, model: &str) { + // Drop kills the child process (see EmbeddedTerm::drop). + if eui.term.take().is_some() { + let mut a = app.lock().unwrap(); + if let Some(old) = a.embed_session.take() { + eui.past_embeds.insert(old); + } + a.embed_grow = false; + a.embed_grow_rows = None; + a.embed_clear_at = None; + } + match EmbeddedTerm::spawn(eui.port, 20, 80, model) { + Ok(t) => { + let mut a = app.lock().unwrap(); + if let Some(old) = a.embed_session.replace(t.session_id.clone()) { + eui.past_embeds.insert(old); + } + a.embed_grow = false; + // Give the fresh session a live row so the selection (and the + // pane-visibility rule) has a key to point at (Tap::new reuses it). + if !a.sessions.iter().any(|s| s.key == t.session_id) { + a.sessions + .push(Session::new(t.session_id.clone(), "(embedded)".into())); + } + let key = t.session_id.clone(); + a.select_key(&key); + a.follow = true; + a.clear_turn_focus(); + drop(a); + eui.term = Some(t); + } + Err(e) => { + app.lock().unwrap().status = format!("claude spawn failed: {e}"); + return; + } + } + eui.visible = true; + eui.claude_focused = true; +} + +/// Spawn (or replace) the embedded pane resuming a past session by UUID. +/// Any existing pane (live or dead) is killed and replaced — this is the +/// only expensive path, and it only runs from an explicit ctrl-↓ / `c`. +fn show_embed_resume(eui: &mut EmbedUi, app: &SharedApp, session_id: &str) { + // Drop kills the child process (see EmbeddedTerm::drop). + if eui.term.take().is_some() { + let mut a = app.lock().unwrap(); + if let Some(old) = a.embed_session.take() { + // That instance is dead now: its session needs no liveness guard. + eui.past_embeds.insert(old); + } + a.embed_grow = false; + a.embed_grow_rows = None; + a.embed_clear_at = None; + } + match EmbeddedTerm::spawn_resume(eui.port, 20, 80, session_id) { + Ok(t) => { + let mut a = app.lock().unwrap(); + a.embed_session = Some(t.session_id.clone()); + a.embed_grow = false; + // Promote the session to a live row, pre-filled from the on-disk + // transcript: no API traffic flows until the next turn, so it + // would be blank. Always re-read the file — a cached view may + // have been built along a dead-branch path, while the resumed + // claude continues from the trunk. + a.history.remove(session_id); + if !a.sessions.iter().any(|s| s.key == session_id) + && let Some(s) = crate::sessions::load_history(session_id) + { + a.sessions.push(s); + } + a.select_key(session_id); + a.follow = true; + drop(a); + eui.term = Some(t); + } + Err(e) => { + app.lock().unwrap().status = format!("claude spawn failed: {e}"); + return; + } + } + eui.visible = true; + eui.claude_focused = true; +} + +/// ctrl-↓ / `c`: attach the embedded pane to the *selected* session. The +/// cheap cases (reveal/focus the live pane, spawn the first instance) are +/// instant; only attaching to a different session kills + respawns claude. +fn attach_selected(eui: &mut EmbedUi, app: &SharedApp) { + enum Plan { + Fresh, + Reveal, + Resume(String), + Guard(String), + } + let plan = { + let mut a = app.lock().unwrap(); + a.filter_popup = None; + match a.selected_key() { + // Nothing anywhere yet → fresh `claude --session-id `. + None => Plan::Fresh, + Some(key) if a.embed_session.as_deref() == Some(key.as_str()) => { + if eui.term.as_ref().is_some_and(|t| !t.exited()) { + Plan::Reveal + } else if a + .sessions + .iter() + .find(|s| s.key == key) + .is_some_and(|s| !s.entries.is_empty()) + { + // Pane died on its own session → respawn resuming it. + Plan::Resume(key) + } else { + // Died before any turn: nothing to resume, start fresh. + Plan::Fresh + } + } + Some(key) => { + let live = a.sessions.iter().any(|s| s.key == key); + if live && !eui.past_embeds.contains(&key) { + // External instance on our port; it may still be running + // (an idle claude sends no traffic, so we can't know). + Plan::Guard(key) + } else { + Plan::Resume(key) + } + } + } + }; + match plan { + Plan::Fresh => show_embed_pane(eui, app), + Plan::Reveal => { + eui.visible = true; + eui.claude_focused = true; + } + Plan::Resume(key) => { + eui.force_resume = None; + show_embed_resume(eui, app, &key); + } + Plan::Guard(key) => { + let armed = eui + .force_resume + .as_ref() + .is_some_and(|(k, t)| *k == key && t.elapsed() < Duration::from_secs(3)); + if armed { + eui.force_resume = None; + show_embed_resume(eui, app, &key); + } else { + eui.force_resume = Some((key, Instant::now())); + app.lock().unwrap().status = + "session may be live in another claude instance — ctrl-↓ again to resume anyway" + .into(); + } + } + } +} + fn event_loop( terminal: &mut ratatui::DefaultTerminal, app: SharedApp, eui: &mut EmbedUi, ) -> anyhow::Result<()> { + let mut sel: Option = None; + let mut cache = FeedCache::default(); loop { - terminal.draw(|f| draw(f, &app, eui))?; + terminal.draw(|f| draw(f, &app, eui, &mut sel, &mut cache))?; // Scheduled transcript wipe (set by the tap when an embedded-session // turn finishes): keeps the pane prompt-only. let clear_due = { @@ -94,17 +452,65 @@ fn event_loop( false } }; - if clear_due { - if let Some(et) = &eui.term { - if !et.exited() { - et.clear_screen(); - } - } + if clear_due + && let Some(et) = &eui.term + && !et.exited() + { + et.clear_screen(); } if !event::poll(Duration::from_millis(33))? { continue; } - if let Event::Key(k) = event::read()? { + let ev = event::read()?; + // Wheel scroll always drives the feed, regardless of which pane has + // keyboard focus (the embedded pane gets no mouse forwarding anyway). + // Left drag = text selection; the copy happens on release in draw(). + if let Event::Mouse(m) = &ev { + match m.kind { + MouseEventKind::ScrollUp => { + let mut a = app.lock().unwrap(); + a.follow = false; + a.scroll = a.scroll.saturating_sub(3); + } + MouseEventKind::ScrollDown => { + // follow re-engages automatically when draw() clamps + // the scroll to the bottom. + let mut a = app.lock().unwrap(); + a.follow = false; + a.scroll += 3; + } + MouseEventKind::Down(MouseButton::Left) => { + sel = Some(Selection { + start: (m.column, m.row), + end: (m.column, m.row), + dragging: true, + copy_pending: false, + }); + } + MouseEventKind::Drag(MouseButton::Left) => { + if let Some(s) = sel.as_mut() + && s.dragging + { + s.end = (m.column, m.row); + } + } + MouseEventKind::Up(MouseButton::Left) => { + if let Some(s) = sel.as_mut() + && s.dragging + { + s.dragging = false; + if s.start == s.end { + sel = None; // plain click, nothing to copy + } else { + s.copy_pending = true; + } + } + } + _ => {} + } + continue; + } + if let Event::Key(k) = ev { if k.kind == KeyEventKind::Release { continue; } @@ -119,7 +525,13 @@ fn event_loop( // F2 show/hide the claude pane // ctrl-↓ focus the claude pane (showing it if hidden) // ctrl-↑ focus the feed + // ctrl-f toggle pane fullscreen (only while the pane is focused) let ctrl = k.modifiers.contains(KeyModifiers::CONTROL); + // ctrl-q quits from anywhere — in particular while the claude pane + // is focused, where plain `q` is forwarded to the child. + if ctrl && k.code == KeyCode::Char('q') { + return Ok(()); + } if k.code == KeyCode::F(2) { if k.kind == KeyEventKind::Press { toggle_embed(eui, &app); @@ -128,13 +540,20 @@ fn event_loop( } if ctrl && k.code == KeyCode::Down { if k.kind == KeyEventKind::Press { - app.lock().unwrap().filter_popup = None; - show_embed_pane(eui, &app); + attach_selected(eui, &app); } continue; } if ctrl && k.code == KeyCode::Up { eui.claude_focused = false; + // The feed would be invisible behind a fullscreen pane. + eui.fullscreen = false; + continue; + } + if ctrl && k.code == KeyCode::Char('f') && eui.focused() { + if k.kind == KeyEventKind::Press { + eui.fullscreen = !eui.fullscreen; + } continue; } // While the claude pane has focus, everything else belongs to it. @@ -148,7 +567,7 @@ fn event_loop( continue; } let mut a = app.lock().unwrap(); - let nsess = a.sessions.len(); + let nsess = a.merged_len(); if k.code == KeyCode::Char('c') && ctrl { return Ok(()); } @@ -168,28 +587,88 @@ fn event_loop( } continue; } + // Model picker (opened with `n`): j/k move, Enter spawns a fresh + // session with the chosen model, esc/n/q cancels. + if let Some(msel) = a.model_popup { + let n = a.model_choices.len().max(1); + match k.code { + KeyCode::Up | KeyCode::Char('k') => a.model_popup = Some((msel + n - 1) % n), + KeyCode::Down | KeyCode::Char('j') => a.model_popup = Some((msel + 1) % n), + KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('q') => a.model_popup = None, + KeyCode::Enter => { + a.model_popup = None; + let model = a.model_choices.get(msel).map(|c| c.1.clone()); + drop(a); + if let Some(model) = model { + show_embed_new(eui, &app, &model); + } + } + _ => {} + } + continue; + } match k.code { - KeyCode::Char('q') | KeyCode::Esc => return Ok(()), + KeyCode::Char('q') => return Ok(()), + // Esc unwinds one layer: visual mode → turn tree → quit. + KeyCode::Esc => match a.expanded.as_mut() { + Some(e) if e.visual.is_some() => e.visual = None, + Some(_) => a.expanded = None, + None => return Ok(()), + }, KeyCode::Char('f') => a.filter_popup = Some(0), + // n → pick a model, then spawn a brand-new session (no need to + // resume + /clear just to get a fresh chat). + KeyCode::Char('n') => a.model_popup = Some(0), KeyCode::Char('s') => a.show_sessions = !a.show_sessions, - // n/p mirror tab/backtab: alt-tab is usually grabbed by the - // window manager, so the embed-focused state needs them. - KeyCode::Tab | KeyCode::Char('n') if nsess > 0 => { + // c → attach the most recent past session (like `claude -c`): + // select it (the scanner keeps disk_sessions newest-first), + // then run the normal attach logic. + KeyCode::Char('c') => { + match a.disk_sessions.first().map(|d| d.uuid.clone()) { + None => a.status = "no past sessions found for this directory".into(), + Some(uuid) => { + a.select_key(&uuid); + a.clear_turn_focus(); + drop(a); + attach_selected(eui, &app); + } + } + continue; + } + // Turn tree (sessions panel): space toggles the selected + // session's tree, →/l expands / steps into the turns, ←/h + // steps out / collapses (yazi-style), v anchors a visual + // range, b materializes a new decoupled session from the + // highlighted turn (its chain) or the visual selection. + KeyCode::Char(' ') => a.toggle_expand(), + KeyCode::Right | KeyCode::Char('l') => a.tree_right(), + KeyCode::Left | KeyCode::Char('h') => a.tree_left(), + KeyCode::Char('v') => a.toggle_visual(), + KeyCode::Char('b') => match a.branch_selected() { + Ok(u) => { + a.status = format!( + "branched → {} (ctrl-↓ to start it)", + u.chars().take(8).collect::() + ); + } + Err(e) => a.status = e, + }, + // Tab / BackTab cycle sessions. `n` is the new-session picker; + // `p` is no longer a back-tab mirror. + KeyCode::Tab if nsess > 0 => { a.selected = (a.selected + 1) % nsess; a.follow = true; + a.clear_turn_focus(); } - KeyCode::BackTab | KeyCode::Char('p') if nsess > 0 => { + KeyCode::BackTab if nsess > 0 => { a.selected = (a.selected + nsess - 1) % nsess; a.follow = true; + a.clear_turn_focus(); } - KeyCode::Up | KeyCode::Char('k') => { - a.follow = false; - a.scroll = a.scroll.saturating_sub(1); - } - KeyCode::Down | KeyCode::Char('j') => { - a.follow = false; - a.scroll += 1; - } + // j/k/↑/↓ drive the session/turn highlight, never the feed — + // the feed scrolls with the wheel (or PgUp/PgDn/g/G). + KeyCode::Up | KeyCode::Char('k') => a.nav(false), + KeyCode::Down | KeyCode::Char('j') => a.nav(true), KeyCode::PageUp => { a.follow = false; a.scroll = a.scroll.saturating_sub(20); @@ -209,26 +688,50 @@ fn event_loop( } } -fn draw(f: &mut Frame, app: &SharedApp, eui: &mut EmbedUi) { +fn draw( + f: &mut Frame, + app: &SharedApp, + eui: &mut EmbedUi, + selection: &mut Option, + cache: &mut FeedCache, +) { let mut a = app.lock().unwrap(); // Embedded pane height: nothing when hidden. The pane is meant to be // prompt-only (the feed above shows the context; term.rs crops Claude // Code's status/hint rows), so the default is just tall enough for the - // input box. Interactive prompts grow it: sized from the question's - // option count when the tap could estimate it, 75% as fallback. - const EMBED_COMPACT: u16 = 8; // 6 inner rows + borders - let show_embed = eui.visible && eui.term.is_some(); + // input box plus the statusLine. Interactive prompts grow it: sized from + // the question's option count when the tap could estimate it, 75% as + // fallback. + const EMBED_COMPACT: u16 = 9; // 7 inner rows + borders + // The pane is drawn only while the feed selection is on the embedded + // session: tabbing away hides it (the child keeps running — hide, don't + // kill), tabbing back reveals it instantly. ctrl-↓ attaches elsewhere. + let selected_is_embed = + a.embed_session.is_some() && a.selected_key().as_deref() == a.embed_session.as_deref(); + let show_embed = eui.visible && eui.term.is_some() && selected_is_embed; + if !show_embed { + // An invisible pane must not swallow keystrokes (the selection can + // move under us, e.g. a new session auto-jump). + eui.claude_focused = false; + eui.fullscreen = false; + } let embed_h = if show_embed { let total = f.area().height; - let cap = total.saturating_sub(6).max(1); - if a.embed_grow { - let h = a - .embed_grow_rows - .map(|r| r + 2) // + borders - .unwrap_or((total as u32 * 75 / 100) as u16); - h.clamp(EMBED_COMPACT.min(cap), cap) + if eui.fullscreen { + // Whole screen minus the footer and the 1-row Min(1) the feed + // area keeps (layout below still reserves it). + total.saturating_sub(2) } else { - EMBED_COMPACT.min(cap) + let cap = total.saturating_sub(6).max(1); + if a.embed_grow { + let h = a + .embed_grow_rows + .map(|r| r.saturating_add(3)) // + borders + statusLine row + .unwrap_or((total as u32 * 75 / 100) as u16); + h.clamp(EMBED_COMPACT.min(cap), cap) + } else { + EMBED_COMPACT.min(cap) + } } } else { 0 @@ -239,33 +742,92 @@ fn draw(f: &mut Frame, app: &SharedApp, eui: &mut EmbedUi) { Constraint::Length(1), ]) .areas(f.area()); - let left_width = if a.show_sessions { 26 } else { 0 }; + // The panel needs room for turn labels while a tree is expanded. + let left_width = if a.show_sessions { + if a.expanded.is_some() { + 44.min(main.width / 2) + } else { + 26 + } + } else { + 0 + }; let [left, right] = Layout::horizontal([Constraint::Length(left_width), Constraint::Min(10)]) .areas(main); - let sel = a.selected.min(a.sessions.len().saturating_sub(1)); + let live_n = a.sessions.len(); + let stubs = a.visible_stubs(); + let sel = a.selected.min((live_n + stubs.len()).saturating_sub(1)); a.selected = sel; + let sel_key = a.selected_key(); - // Session list (folded away when toggled off) + // Session list: live sessions first, then this directory's past sessions + // as dimmed stubs (kept fresh by the scanner thread — no I/O here). The + // expanded session's turn rows render directly under its row, abandoned + // branches indented one level under their fork point (⑂). if a.show_sessions { - let items: Vec = a - .sessions - .iter() - .map(|s| { + let mut items: Vec = Vec::new(); + let mut flat_sel = 0usize; + let vis_range = a.expanded.as_ref().and_then(|e| { + let (av, p) = (e.visual?, e.sel?); + Some((av.min(p), av.max(p))) + }); + for m in 0..live_n + stubs.len() { + let (uuid, item) = if m < live_n { + let s = &a.sessions[m]; let dot = if s.active > 0 { "●".green() } else { "○".dark_gray() }; let id: String = s.key.chars().take(8).collect(); - ListItem::new(Line::from(vec![ - dot, - " ".into(), - id.into(), - " ".into(), - short_model(&s.model).cyan(), - ])) - }) - .collect(); + ( + s.key.clone(), + ListItem::new(Line::from(vec![ + dot, + " ".into(), + id.into(), + " ".into(), + short_model(&s.model).cyan(), + ])), + ) + } else { + let d = &a.disk_sessions[stubs[m - live_n]]; + ( + d.uuid.clone(), + ListItem::new(Line::from(format!("· {}", d.label)).dark_gray()), + ) + }; + let on_sel_row = m == sel; + let turn_hl = a + .expanded + .as_ref() + .filter(|e| e.uuid == uuid) + .and_then(|e| e.sel); + if on_sel_row && turn_hl.is_none() { + flat_sel = items.len(); + } + items.push(item); + if let Some(e) = a.expanded.as_ref().filter(|e| e.uuid == uuid) { + for (p, &t) in e.tree.display.iter().enumerate() { + let turn = &e.tree.turns[t]; + let bullet = if turn.depth > 0 { "⑂" } else { "❯" }; + let txt = format!( + " {}{bullet} {}", + " ".repeat(turn.depth.min(6)), + turn.label + ); + let line = if vis_range.is_some_and(|(lo, hi)| p >= lo && p <= hi) { + Line::from(txt).style(Style::new().bg(USER_BG).fg(Color::White)) + } else { + Line::from(txt).dark_gray() + }; + if on_sel_row && turn_hl == Some(p) { + flat_sel = items.len(); + } + items.push(ListItem::new(line)); + } + } + } let mut ls = ListState::default(); - if !a.sessions.is_empty() { - ls.select(Some(sel)); + if !items.is_empty() { + ls.select(Some(flat_sel)); } f.render_stateful_widget( List::new(items) @@ -276,64 +838,149 @@ fn draw(f: &mut Frame, app: &SharedApp, eui: &mut EmbedUi) { ); } + // What the feed shows: a highlighted turn views the on-disk transcript + // along the path *through that turn* (works for live sessions too — pure + // viewing); a stub selection views its whole file; otherwise the live + // in-memory session. Path views are cached per uuid and rebuilt only + // when the requested leaf changes (a few ms even for MB-sized files). + let turn_view: Option = a + .expanded + .as_ref() + .filter(|e| sel_key.as_deref() == Some(e.uuid.as_str())) + .and_then(|e| e.sel.map(|p| e.tree.display[p])); + let hist_view: Option<(String, Option)> = match (&sel_key, turn_view) { + (Some(u), Some(t)) => { + let tree = &a.expanded.as_ref().unwrap().tree; + Some((u.clone(), Some(tree.trunk_leaf(t)))) + } + (Some(u), None) if sel >= live_n => Some((u.clone(), None)), + _ => None, + }; + if let Some((u, leaf)) = &hist_view + && a.history.get(u).is_none_or(|h| h.leaf != *leaf) + { + let aref = &mut *a; + let built = match (*leaf, aref.expanded.as_ref()) { + (Some(l), Some(e)) => crate::sessions::load_view(u, Some((&e.tree, l))), + _ => crate::sessions::load_view(u, None), + }; + let h = built.unwrap_or_else(|| { + let mut s = Session::new(u.clone(), "(disk)".into()); + s.entries.push(Entry::meta("(empty or unreadable transcript)".into())); + crate::sessions::HistoryView { session: s, leaf: *leaf, turn_entries: Vec::new() } + }); + aref.history.insert(u.clone(), h); + } + // Feed let follow = a.follow; let scroll0 = a.scroll; let filters = a.filters; let mut new_scroll = scroll0; let mut new_follow = follow; - if let Some(s) = a.sessions.get(sel) { - let feed_width = right.width.saturating_sub(2); - let mut lines: Vec = Vec::new(); - for e in &s.entries { - if !filters[filter_index(&e.kind)] { - continue; - } - match &e.kind { - Kind::Meta => lines.push(Line::from(e.content.clone()).dark_gray()), - Kind::Thinking => { - let head = if e.done { "✻ thought" } else { "✻ thinking…" }; - lines.push(Line::from(head).magenta().italic()); - for l in e.content.lines() { - lines.push(Line::from(l.to_string()).dark_gray().italic()); - } - } - Kind::Text => { - lines.extend(tui_markdown::from_str(&e.content).lines); - } - Kind::Tool { name } => { - // Once the input JSON is complete, every tool gets a - // human-readable rendering; partial streams fall back to - // the raw JSON-fragment view. - let parsed = e - .done - .then(|| serde_json::from_str::(&e.content).ok()) - .flatten(); - match parsed { - Some(v) => { - render_tool(name, &v, e.result.as_ref(), &mut lines, feed_width) - } - None => { - let head = if e.done { - format!("⚙ {name}") - } else { - format!("⚙ {name} …") - }; - lines.push(Line::from(head).yellow().bold()); - for l in e.content.lines() { - lines.push(Line::from(format!(" {l}")).cyan()); - } - } - } - } - Kind::Error => { - for l in e.content.lines() { - lines.push(Line::from(l.to_string()).red().bold()); - } - } - } - lines.push(Line::default()); + // Scroll the feed to the highlighted turn's first entry, once per + // highlight move (the offset needs the freshly cached entry heights). + let scroll_target: Option = match (a.turn_dirty, &hist_view, turn_view) { + (true, Some((u, _)), Some(t)) => a + .history + .get(u) + .and_then(|h| h.turn_entries.iter().find(|(ti, _)| *ti == t)) + .map(|&(_, e)| e), + _ => None, + }; + a.turn_dirty = false; + let (feed_session, feed_leaf): (Option<&Session>, Option) = match &hist_view { + Some((u, _)) => { + let h = a.history.get(u); + (h.map(|h| &h.session), h.and_then(|h| h.leaf)) } + None => (a.sessions.get(sel), None), + }; + let feed_live = hist_view.is_none(); + if let Some(s) = feed_session { + let feed_width = right.width.saturating_sub(2); + // (In)validate the render cache: a width change, session switch, or + // a different transcript view of the same session (live vs on-disk, + // another tree path) invalidates everything, otherwise only entries + // whose fingerprint changed (the in-flight one, or a tool entry + // whose result attached) are re-rendered. + if cache.session_key != s.key + || cache.width != feed_width + || cache.leaf != feed_leaf + || cache.live != feed_live + { + cache.session_key = s.key.clone(); + cache.width = feed_width; + cache.leaf = feed_leaf; + cache.live = feed_live; + cache.entries.clear(); + } + for (i, e) in s.entries.iter().enumerate() { + let fp = fingerprint(e); + if cache.entries.get(i).is_none_or(|c| c.fingerprint != fp) { + let lines = entry_lines(e, feed_width); + let height = wrapped_height(&lines, feed_width); + let ce = CachedEntry { fingerprint: fp, lines, height }; + if i < cache.entries.len() { + cache.entries[i] = ce; + } else { + cache.entries.push(ce); + } + } + } + + // Visible (filter-passing) entries and their total wrapped height. + let visible: Vec = s + .entries + .iter() + .enumerate() + .filter(|(_, e)| filters[filter_index(&e.kind)]) + .map(|(i, _)| i) + .collect(); + let total: usize = visible.iter().map(|&i| cache.entries[i].height).sum(); + let height = right.height.saturating_sub(2) as usize; + let max_scroll = total.saturating_sub(height); + new_scroll = if follow { max_scroll } else { scroll0.min(max_scroll) }; + if let Some(target) = scroll_target { + // Pin the highlighted turn's first entry to the viewport top. + new_scroll = visible + .iter() + .take_while(|&&i| i < target) + .map(|&i| cache.entries[i].height) + .sum::() + .min(max_scroll); + new_follow = false; + } + // Reaching the bottom re-engages follow automatically. + if new_scroll >= max_scroll { + new_follow = true; + } + + // Window: hand ratatui only the entries intersecting the viewport, + // with the residual offset into the first one. Scroll state stays + // usize end-to-end, so feeds longer than u16::MAX rows keep working. + let mut lines: Vec> = Vec::new(); + let mut acc = 0usize; // wrapped rows before the current entry + let mut skipped = 0usize; // wrapped rows before the first included entry + let mut included = false; + for &i in &visible { + let h = cache.entries[i].height; + if acc + h <= new_scroll { + acc += h; + continue; // entirely above the viewport + } + if acc >= new_scroll + height { + break; // below the viewport + } + if !included { + skipped = acc; + included = true; + } + lines.extend(cache.entries[i].lines.iter().cloned()); + acc += h; + } + let residual = new_scroll.saturating_sub(skipped); + let title = if a.show_sessions { format!( " {} · in {} · out {} ", @@ -352,24 +999,19 @@ fn draw(f: &mut Frame, app: &SharedApp, eui: &mut EmbedUi) { ) }; let p = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }); - let width = right.width.saturating_sub(2); - let height = right.height.saturating_sub(2) as usize; - let max_scroll = p.line_count(width).saturating_sub(height); - new_scroll = if follow { max_scroll } else { scroll0.min(max_scroll) }; - // Reaching the bottom re-engages follow automatically. - if new_scroll >= max_scroll { - new_follow = true; - } f.render_widget( p.block(Block::bordered().title(title)) - .scroll((new_scroll.min(u16::MAX as usize) as u16, 0)), + // residual < first visible entry's height; an entry would + // need >65k wrapped rows of its own to hit the clamp. + .scroll((residual.min(u16::MAX as usize) as u16, 0)), right, ); } else { f.render_widget( - Paragraph::new( - "\n\n waiting for traffic…\n\n make sure claude runs with:\n ANTHROPIC_BASE_URL=http://127.0.0.1:8484", - ) + Paragraph::new(format!( + "\n\n waiting for traffic…\n\n make sure claude runs with:\n ANTHROPIC_BASE_URL=http://127.0.0.1:{}", + eui.port + )) .dark_gray() .block(Block::bordered()), right, @@ -404,21 +1046,31 @@ fn draw(f: &mut Frame, app: &SharedApp, eui: &mut EmbedUi) { inner, ); } else { - et.resize(inner.height, inner.width); - if let Some(pos) = et.render(inner, f.buffer_mut()) { + // Fullscreen shows the child's screen verbatim (no chrome crop, + // PTY sized exactly); the compact pane crops Claude Code chrome. + let crop = !eui.fullscreen; + et.resize(inner.height, inner.width, crop); + if let Some(pos) = et.render(inner, f.buffer_mut(), crop) { f.set_cursor_position(pos); } } } + let visual_on = a.expanded.as_ref().is_some_and(|e| e.visual.is_some()); let keys = if a.filter_popup.is_some() { "space toggle · j/k move · f/esc close" + } else if a.model_popup.is_some() { + "enter new session · j/k move · esc cancel" } else if embed_focused { - "ctrl-↑ feed · F2 hide claude" + "ctrl-↑ feed · ctrl-f fullscreen · ctrl-q quit · F2 hide claude" + } else if visual_on { + "j/k extend · b branch selection · esc cancel" + } else if a.on_turns() { + "j/k turns · v visual · b branch · ←/space close · wheel scrolls feed" } else if show_embed { - "ctrl-↓ claude · q quit · tab session · j/k scroll · f filter · F2 hide" + "ctrl-↓ claude · n new · q quit · j/k move · space tree · f filter · F2 hide" } else { - "q quit · tab session · s sessions · j/k scroll · f filter · g top · G bottom · ctrl-↓/F2 claude" + "q quit · n new · j/k move · space/→ tree · f filter · c continue · ctrl-↓ attach" }; f.render_widget( Paragraph::new(Line::from(format!(" {} | {keys}", a.status)).dark_gray()), @@ -454,6 +1106,100 @@ fn draw(f: &mut Frame, app: &SharedApp, eui: &mut EmbedUi) { &mut ls, ); } + + // Model picker popup (n → choose a model → fresh session). + if let Some(msel) = a.model_popup { + let w = 30u16.min(main.width); + let h = (a.model_choices.len() as u16 + 2).min(main.height); + let area = Rect { + x: main.x + (main.width.saturating_sub(w)) / 2, + y: main.y + (main.height.saturating_sub(h)) / 2, + width: w, + height: h, + }; + f.render_widget(Clear, area); + let items: Vec = a + .model_choices + .iter() + .map(|(label, _)| ListItem::new(format!(" {label}"))) + .collect(); + let mut ls = ListState::default(); + ls.select(Some(msel)); + f.render_stateful_widget( + List::new(items) + .block(Block::bordered().title(" new session ")) + .highlight_style(ratatui::style::Style::new().reversed()), + area, + &mut ls, + ); + } + + drop(a); // release the app lock before the mouse-selection pass + + // Mouse selection: while dragging, paint a reversed-video highlight over + // the (fully rendered) buffer; on release, read the selected cells out of + // the buffer instead and copy them to the clipboard. + if let Some(s) = selection.take() { + // Linear (terminal-style) selection: order endpoints by (row, col). + let (mut from, mut to) = (s.start, s.end); + if (from.1, from.0) > (to.1, to.0) { + std::mem::swap(&mut from, &mut to); + } + let buf = f.buffer_mut(); + let area = buf.area; + let mut copied = String::new(); + for y in from.1..=to.1.min(area.bottom().saturating_sub(1)) { + let x_from = if y == from.1 { from.0 } else { area.left() }; + let x_to = if y == to.1 { to.0 } else { area.right().saturating_sub(1) }; + let mut line = String::new(); + for x in x_from..=x_to.min(area.right().saturating_sub(1)) { + if let Some(c) = buf.cell_mut(Position::new(x, y)) { + if s.copy_pending { + line.push_str(c.symbol()); + } else { + c.set_style(Style::new().add_modifier(Modifier::REVERSED)); + } + } + } + if s.copy_pending { + copied.push_str(line.trim_end()); + copied.push('\n'); + } + } + if s.copy_pending { + let copied = copied.trim_end_matches('\n'); + osc52_copy(copied); + app.lock().unwrap().status = + format!("copied {} chars to clipboard", copied.chars().count()); + // selection stays None: the highlight disappears with the copy. + } else { + *selection = Some(s); + } + } +} + +/// Copy text to the system clipboard via the OSC 52 escape sequence (works +/// over SSH; the outer terminal must support it, as it must for Claude Code). +fn osc52_copy(text: &str) { + use std::io::Write; + let mut out = std::io::stdout(); + let _ = write!(out, "\x1b]52;c;{}\x07", base64(text.as_bytes())); + let _ = out.flush(); +} + +fn base64(data: &[u8]) -> String { + const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut s = String::with_capacity(data.len().div_ceil(3) * 4); + for chunk in data.chunks(3) { + let n = (u32::from(chunk[0]) << 16) + | (u32::from(*chunk.get(1).unwrap_or(&0)) << 8) + | u32::from(*chunk.get(2).unwrap_or(&0)); + s.push(A[(n >> 18) as usize & 63] as char); + s.push(A[(n >> 12) as usize & 63] as char); + s.push(if chunk.len() > 1 { A[(n >> 6) as usize & 63] as char } else { '=' }); + s.push(if chunk.len() > 2 { A[n as usize & 63] as char } else { '=' }); + } + s } fn short_model(m: &str) -> String { @@ -606,8 +1352,10 @@ fn push_result<'a>(out: &mut Vec>, result: Option<&ToolResult>, limit: fn one_line(s: &str) -> String { const MAX: usize = 120; let first = s.lines().next().unwrap_or(""); + let truncated = first.chars().count() > MAX; + let multiline = s.contains('\n'); let clipped: String = first.chars().take(MAX).collect(); - if clipped.len() < s.len() { + if truncated || multiline { format!("{}…", sanitize(&clipped)) } else { sanitize(&clipped) @@ -665,7 +1413,7 @@ fn push_numbered<'a>(out: &mut Vec>, text: &str, bg: Option, wid Some(bg) => { let mut row = format!("{:>gutter$} │ {l}", i + 1); let pad = (width as usize).saturating_sub(row.chars().count()); - row.extend(std::iter::repeat(' ').take(pad)); + row.extend(std::iter::repeat_n(' ', pad)); out.push(Line::from(Span::styled( row, Style::new().bg(bg).fg(Color::White), @@ -692,3 +1440,17 @@ fn sanitize(l: &str) -> String { } s } + +#[cfg(test)] +mod tests { + use super::base64; + + #[test] + fn base64_matches_rfc4648_vectors() { + assert_eq!(base64(b""), ""); + assert_eq!(base64(b"f"), "Zg=="); + assert_eq!(base64(b"fo"), "Zm8="); + assert_eq!(base64(b"foo"), "Zm9v"); + assert_eq!(base64(b"foobar"), "Zm9vYmFy"); + } +}