use serde_json::Value; use std::collections::HashMap; use std::sync::{Arc, Mutex}; 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; 7] = ["user", "thinking", "text", "tools", "meta", "errors", "system"]; /// 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, pub selected: usize, pub scroll: usize, pub follow: bool, pub status: String, /// Which entry kinds are visible (indices match `FILTER_LABELS`). 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, /// Request to jump the feed scroll to the next (`Some(true)`) or previous /// (`Some(false)`) `Kind::User` prompt — `n`/`N`. Honoured once on the next /// draw (entry heights live in the render cache, so the key handler can't /// compute the offset itself), then cleared. pub prompt_jump: Option, /// Session UUID of the embedded `claude` pane (src/term.rs). This is now /// *learned* from the pane's first tagged request (see `embed_token`), /// not assumed from the `--session-id` we spawned with — Claude Code does /// not guarantee they match. `None` until that first request binds 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, /// The current embedded child's pane token (`EmbeddedTerm::pane_token`), /// echoed back on every request it makes via the `x-claude-cloak-pane` /// header. The tap matches this to recognise the pane's traffic regardless /// of which session id Claude Code reports, then binds `embed_session` to /// that id. Set on spawn, cleared on kill. pub embed_token: Option, /// Mirror of the UI's "the claude pane has keyboard focus" state, written /// by `draw` each frame so the tap (running off-thread) can avoid yanking /// the selection away from a pane the user is actively driving. pub pane_focused: bool, /// True while the embedded session shows a large interactive prompt /// (AskUserQuestion/ExitPlanMode seen in the stream, answer not yet /// echoed back) — the UI gives the pane more rows while set. pub embed_grow: bool, /// Content-based row estimate for the prompt (AskUserQuestion: derived /// from question/option counts). None → percentage fallback. pub embed_grow_rows: Option, /// When set, the UI should send ctrl-l to the embedded claude at this /// instant (a beat after a turn ends, once Claude Code has printed its /// final transcript lines) so the pane stays prompt-only. 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") } /// Rows Claude Code's question UI needs, estimated from the tool input: /// per question (they're tabbed, so take the max) — question text + spacing, /// 2 rows per option (label + description) incl. the implicit "Other", /// submit row for multi-select, hint row. Slightly generous beats clipped. fn ask_question_rows(input: &Value) -> Option { let qs = input.get("questions")?.as_array()?; let per_q = qs .iter() .map(|q| { let opts = q .get("options") .and_then(Value::as_array) .map_or(4, Vec::len) as u16; let multi = q .get("multiSelect") .and_then(Value::as_bool) .unwrap_or(false); 3 + (opts + 1) * 2 + u16::from(multi) + 2 }) .max() .unwrap_or(20); // Tab header row when there are multiple questions. Some(per_q + if qs.len() > 1 { 2 } else { 0 }) } impl App { pub fn new() -> Self { Self { sessions: Vec::new(), selected: 0, scroll: 0, follow: true, status: "starting proxy…".into(), filters: [true; FILTER_LABELS.len()], filter_popup: None, model_popup: None, model_choices: default_model_choices(), show_sessions: false, disk_sessions: Vec::new(), history: HashMap::new(), expanded: None, turn_dirty: false, prompt_jump: None, embed_session: None, embed_token: None, pane_focused: false, embed_grow: false, embed_grow_rows: None, embed_clear_at: None, } } /// Bind the embedded pane to the session id its traffic actually reports. /// Returns true when the binding changed (a fresh bind, or a rebind when /// Claude Code reported a different id than we spawned with) — the caller /// uses that to jump the selection to the pane exactly once, not on every /// request. A provisional row created under the old id (e.g. a resume's /// pre-loaded transcript) is renamed onto the real id so its live traffic /// and on-disk view stay one session. pub fn bind_embed_session(&mut self, key: &str) -> bool { if self.embed_session.as_deref() == Some(key) { return false; } if let Some(prev) = self.embed_session.take() && prev != key && !self.sessions.iter().any(|s| s.key == key) && let Some(s) = self.sessions.iter_mut().find(|s| s.key == prev) { s.key = key.to_string(); } self.embed_session = Some(key.to_string()); true } /// 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::User => 0, Kind::Thinking => 1, Kind::Text => 2, // Tool calls and the available-tool list share the "tools" toggle. Kind::Tool { .. } | Kind::ToolDefs => 3, Kind::Meta => 4, Kind::Error => 5, // Reminders and the system-prompt-size line share the "system" toggle. Kind::Reminder | Kind::System => 6, } } pub struct Session { pub key: String, pub model: String, pub entries: Vec, pub active: usize, pub input_tokens: u64, pub output_tokens: u64, pub last_activity: Instant, /// tool_use id → entry index, so results arriving in the *next* request /// body can be attached to the tool entry they belong to. pub tool_ids: HashMap, /// Char length of the system prompt last surfaced as a `Kind::System` /// entry. The system prompt is re-sent verbatim on every request, so it is /// emitted once (and again only if the count changes) — never duplicated. pub last_system_len: Option, /// Signature (joined tool names) of the tool set last surfaced as a /// `Kind::ToolDefs` entry; re-emitted only when the available tools change. pub last_tools_sig: Option, } 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(), last_system_len: None, last_tools_sig: None, } } } #[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 }, Meta, Error, /// A `` Claude Code injects into a user turn. Kept (shown /// dimmed, like a thinking block) rather than discarded, so the context the /// model actually received is visible. Filtered under the "system" toggle. Reminder, /// The system prompt's character count (the prompt itself is too long to /// show verbatim). Emitted once per session, re-emitted only on change. /// Filtered under the "system" toggle. System, /// The set of tool definitions the model is handed (names + count, not the /// full schemas). Emitted once per session, re-emitted only when the tool /// set changes. Filtered under the "tools" toggle. ToolDefs, } pub struct Entry { pub kind: Kind, pub content: String, pub done: bool, /// Tool entries only: the tool_result echoed back in the next request. pub result: Option, } pub struct ToolResult { pub content: String, pub is_error: bool, } impl Entry { pub(crate) fn meta(content: String) -> Self { Self { kind: Kind::Meta, content, done: true, result: None } } } /// Scan a request body's `messages` for `tool_result` blocks and attach them /// to the tool_use entries recorded in `Session::tool_ids`. Purely passive: /// only reads bytes that were already flowing through the proxy. 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 = 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. let mut answered = false; let Some(s) = a.sessions.iter_mut().find(|s| s.key == key) else { return; }; for m in messages { let Some(blocks) = m.get("content").and_then(Value::as_array) else { continue; }; for b in blocks { if b.get("type").and_then(Value::as_str) != Some("tool_result") { continue; } let Some(id) = b.get("tool_use_id").and_then(Value::as_str) else { continue; }; let Some(idx) = s.tool_ids.remove(id) else { continue; }; if let Some(e) = s.entries.get_mut(idx) { if let Kind::Tool { name } = &e.kind { answered |= is_interactive_tool(name); } 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), }); } } } if is_embed && answered { a.embed_grow = false; a.embed_grow_rows = None; } } /// Split a user text block into its injected `` spans (kept so /// the UI can show them dimmed) and the real prompt the user typed. /// /// Crucially, a reminder is sometimes prepended *inside the same text block* as /// the prompt (this is what swallowed the first message after a resume), so the /// spans are excised in place and the remainder kept — never the whole block /// dropped on a leading tag. Slash-command wrappers are removed outright (pure /// machinery, nothing the user typed). pub(crate) fn extract_user_text(t: &str) -> (Vec, String) { const OPEN: &str = ""; const CLOSE: &str = ""; let mut reminders = Vec::new(); let mut s = t.to_string(); while let Some(start) = s.find(OPEN) { let after = start + OPEN.len(); match s[after..].find(CLOSE) { Some(rel) => { let inner = s[after..after + rel].trim(); if !inner.is_empty() { reminders.push(inner.to_string()); } let end_abs = after + rel + CLOSE.len(); s.replace_range(start..end_abs, ""); } None => { // Unterminated tag: the rest of the block is the reminder. let inner = s[after..].trim(); if !inner.is_empty() { reminders.push(inner.to_string()); } s.truncate(start); break; } } } // The remainder is kept verbatim: slash-command wrappers // ( etc.) are part of what the model actually received, so // the feed shows them. Only the turn-tree *label* path (`strip_injected`) // drops them for a clean one-liner. (reminders, s.trim().to_string()) } /// The user's typed prompt only — used for turn-tree labels, where injected /// reminders *and* slash-command machinery are noise. (The feed itself shows /// everything the model received; this is the label-only projection.) pub(crate) fn strip_injected(t: &str) -> String { extract_user_text(t) .1 .lines() .filter(|l| { let lt = l.trim_start(); !(lt.starts_with("") || lt.starts_with("") || lt.starts_with("") || lt.starts_with(">() .join("\n") .trim() .to_string() } /// Record everything new the model received this request as feed entries: /// the trailing user prompt verbatim (incl. slash-command machinery), any /// injected `` blocks (dimmed, before it), and — for a /// turn-starting request — the system-prompt size and the available tool set /// (each surfaced once, re-emitted only on change; never re-dumped though the /// full system/tools/history are re-sent every request). Purely passive (reads /// bytes already flowing through the proxy). /// /// Only the *trailing* user message counts: tool-loop continuations end in /// tool_result blocks and thus contribute no text, so exactly the turn-starting /// prompt lands here. Side requests (topic detection, title generation, …) carry /// no `tools`; their prompt is still shown, tagged with a `── side request ──` /// divider. Retries/resends are deduped against the last recorded prompt (which /// gates the reminders/divider too, so a resend doesn't double them up). pub fn record_user_prompt(app: &SharedApp, key: &str, body: &Value) { // A turn-starting (main) request carries tools; a side request does not. let has_tools = body .get("tools") .and_then(Value::as_array) .is_some_and(|t| !t.is_empty()); let Some(messages) = body.get("messages").and_then(Value::as_array) else { return; }; // The turn's prompt is the *trailing run* of user-role messages, not just // the last message. Claude Code often appends injected // ``s (and other machinery) as their own `user` messages // *after* the real prompt; inspecting only `messages.last()` then sees a // reminder-only message, yields empty prompt text, and drops the turn // entirely — the "user messages don't show up reliably" bug (it fires only // when CC happens to append such a trailing message). A tool-loop // continuation ends in a single user message of `tool_result` blocks (no // text), so it still contributes nothing and records no spurious entry. let trailing_user: Vec<&Value> = messages .iter() .rev() .take_while(|m| m.get("role").and_then(Value::as_str) == Some("user")) .collect(); if trailing_user.is_empty() { return; } // Gather reminders + prompt across every text block of every trailing user // message. `take_while` walked newest→oldest, so reverse back to document // order for a prompt that legitimately spans multiple messages. let mut reminders: Vec = Vec::new(); let mut prompts: Vec = Vec::new(); for m in trailing_user.into_iter().rev() { match m.get("content") { Some(Value::String(s)) => { let (r, p) = extract_user_text(s); reminders.extend(r); if !p.is_empty() { prompts.push(p); } } Some(Value::Array(blocks)) => { for b in blocks { if b.get("type").and_then(Value::as_str) != Some("text") { continue; } if let Some(t) = b.get("text").and_then(Value::as_str) { let (r, p) = extract_user_text(t); reminders.extend(r); if !p.is_empty() { prompts.push(p); } } } } _ => {} } } let text = prompts.join("\n"); 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; }; // Dedup only *true resends*: a retry re-fires the same request before any // response lands, so the just-recorded prompt is still the tail entry // (its reminders ride directly in front of it). A later turn that merely // repeats the same text ("yes", "continue", "go on") sits behind the // previous turn's assistant/tool entries, so it survives. The old check // scanned back to the most recent User entry and dropped every verbatim // repeat regardless of intervening activity. if s.entries .iter() .rev() .find(|e| e.kind != Kind::Reminder) .is_some_and(|e| e.kind == Kind::User && e.content == text) { return; } if has_tools { // Surface the system-prompt size and the tool set once, then only when // they change — they ride along on every request but are not new data. let sys = system_char_len(body); if sys > 0 && s.last_system_len != Some(sys) { s.last_system_len = Some(sys); s.entries.push(Entry { kind: Kind::System, content: format!("system prompt: {} chars", fmt_count(sys)), done: true, result: None, }); } if let Some((sig, line)) = tools_summary(body) && s.last_tools_sig.as_deref() != Some(sig.as_str()) { s.last_tools_sig = Some(sig); s.entries.push(Entry { kind: Kind::ToolDefs, content: line, done: true, result: None, }); } } else { // A background side request (no tools): tag it so its prompt/response // are not mistaken for part of the main conversation. s.entries.push(Entry::meta("── side request ──".to_string())); } for r in reminders { s.entries.push(Entry { kind: Kind::Reminder, content: r, done: true, result: None, }); } 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. pub fn flatten_result_content(v: Option<&Value>) -> String { match v { Some(Value::String(s)) => s.clone(), Some(Value::Array(blocks)) => blocks .iter() .map(|b| match b.get("type").and_then(Value::as_str) { Some("text") => b .get("text") .and_then(Value::as_str) .unwrap_or_default() .to_string(), other => format!("[{}]", other.unwrap_or("?")), }) .collect::>() .join("\n"), _ => String::new(), } } /// One in-flight API request being observed. Created when a streaming /// /v1/messages request passes through the proxy; handles its SSE events. pub struct Tap { app: SharedApp, sidx: usize, cur: Option, } impl Tap { /// `pane_token` is the `x-claude-cloak-pane` header value when the request /// came from our embedded pane (`None` for external sessions). pub fn new(app: SharedApp, key: String, model: String, pane_token: Option) -> Self { let sidx = { let mut a = lock_app(&app); // Does this request belong to the embedded pane we spawned? If so, // (re)bind the embed to the id its traffic reports — this is how we // learn the real session id instead of trusting `--session-id`. let is_embed = pane_token.is_some() && pane_token == a.embed_token; let newly_bound = is_embed && a.bind_embed_session(&key); let existing = a.sessions.iter().position(|s| s.key == key); let sidx = match existing { Some(i) => i, None => { a.sessions.push(Session::new(key, model.clone())); a.sessions.len() - 1 } }; // Selection / follow policy: // - The embedded pane jumps the selection only the first time it // binds, so the user can read another session's feed while the // pane keeps running without being yanked back every turn. // - A brand-new *external* session auto-jumps so a fresh `/clear` // is immediately visible — unless the user is actively driving // the pane, in which case stealing the selection would hide it // (the bug this whole change fixes). if newly_bound || (existing.is_none() && !is_embed && !a.pane_focused) { a.selected = sidx; a.follow = true; } a.sessions[sidx].active += 1; a.sessions[sidx].last_activity = Instant::now(); sidx }; Self { app, sidx, cur: None } } pub fn handle(&mut self, ev: &str, d: &Value) { 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; let mut grow_rows: Option = None; let s = &mut a.sessions[self.sidx]; s.last_activity = Instant::now(); match ev { "message_start" => { if let Some(m) = d.pointer("/message/model").and_then(Value::as_str) { s.model = m.to_string(); } let u = |k: &str| { d.pointer(&format!("/message/usage/{k}")) .and_then(Value::as_u64) .unwrap_or(0) }; let ctx = u("input_tokens") + u("cache_read_input_tokens") + u("cache_creation_input_tokens"); s.input_tokens = ctx; s.entries .push(Entry::meta(format!("▶ {} · context {}", s.model, fmt_tokens(ctx)))); } "content_block_start" => { let bt = d .pointer("/content_block/type") .and_then(Value::as_str) .unwrap_or(""); let kind = match bt { "thinking" => Kind::Thinking, "redacted_thinking" => Kind::Thinking, "text" => Kind::Text, "tool_use" | "server_tool_use" | "mcp_tool_use" => { // Remember the id so the result (echoed back in the // next request body) can find this entry. if let Some(id) = d.pointer("/content_block/id").and_then(Value::as_str) { s.tool_ids.insert(id.to_string(), s.entries.len()); } Kind::Tool { name: d .pointer("/content_block/name") .and_then(Value::as_str) .unwrap_or("tool") .to_string(), } } 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}]"))); return; } }; let content = if bt == "redacted_thinking" { "[redacted]".to_string() } else { String::new() }; s.entries.push(Entry { kind, content, done: false, result: None }); self.cur = Some(s.entries.len() - 1); } "content_block_delta" => { if let Some(i) = self.cur { let text = match d.pointer("/delta/type").and_then(Value::as_str) { Some("thinking_delta") => d.pointer("/delta/thinking"), Some("text_delta") => d.pointer("/delta/text"), Some("input_json_delta") => d.pointer("/delta/partial_json"), _ => None, }; if let Some(t) = text.and_then(Value::as_str) { s.entries[i].content.push_str(t); } } } "content_block_stop" => { if let Some(i) = self.cur.take() { let e = &mut s.entries[i]; e.done = true; // Tool input arrives as JSON fragments; pretty-print once complete. if let Kind::Tool { name } = &e.kind { interactive = is_interactive_tool(name); let is_ask = name == "AskUserQuestion"; if let Ok(v) = serde_json::from_str::(&e.content) { if is_ask { grow_rows = ask_question_rows(&v); } if let Ok(p) = serde_json::to_string_pretty(&v) { e.content = p; } } } } } "message_delta" => { if let Some(o) = d.pointer("/usage/output_tokens").and_then(Value::as_u64) { s.output_tokens += o; } if let Some(r) = d.pointer("/delta/stop_reason").and_then(Value::as_str) { s.entries.push(Entry::meta(format!("■ {r}"))); } } "error" => { let msg = d .pointer("/error/message") .and_then(Value::as_str) .unwrap_or("unknown stream error"); let etype = d .pointer("/error/type") .and_then(Value::as_str) .unwrap_or("error"); s.entries.push(Entry { kind: Kind::Error, content: format!("✖ {etype}: {msg}"), done: true, result: None, }); } _ => {} } // 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, 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 = 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() { s.entries[i].done = true; } // A turn of the embedded session just finished streaming: schedule a // transcript wipe shortly after Claude Code prints its final lines. // 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)); } } } pub fn fmt_tokens(n: u64) -> String { if n >= 1000 { format!("{:.1}k", n as f64 / 1000.0) } else { n.to_string() } } /// Group a count with thousands separators ("12431" → "12,431"). pub fn fmt_count(n: usize) -> String { let digits = n.to_string(); let bytes = digits.as_bytes(); let mut out = String::with_capacity(digits.len() + digits.len() / 3); for (i, b) in bytes.iter().enumerate() { if i > 0 && (bytes.len() - i).is_multiple_of(3) { out.push(','); } out.push(*b as char); } out } /// Total character length of a request's `system` prompt (string form or an /// array of text blocks). Zero when absent — the model received no system text. fn system_char_len(body: &Value) -> usize { match body.get("system") { Some(Value::String(s)) => s.chars().count(), Some(Value::Array(blocks)) => blocks .iter() .filter_map(|b| b.get("text").and_then(Value::as_str)) .map(|t| t.chars().count()) .sum(), _ => 0, } } /// `(signature, display line)` for a request's tool set, or `None` if it /// declares no tools. The signature (joined names) drives change detection so /// the list is surfaced once and re-emitted only when the available tools shift. fn tools_summary(body: &Value) -> Option<(String, String)> { let names: Vec<&str> = body .get("tools") .and_then(Value::as_array)? .iter() .filter_map(|t| t.get("name").and_then(Value::as_str)) .collect(); if names.is_empty() { return None; } let line = format!("tools ({}): {}", names.len(), names.join(", ")); Some((names.join(","), line)) } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn tool_result_attaches_to_entry() { let app: SharedApp = Arc::new(Mutex::new(App::new())); let mut tap = Tap::new(app.clone(), "abc".into(), "claude-x".into(), None); tap.handle( "content_block_start", &json!({"content_block": {"type": "tool_use", "id": "toolu_01", "name": "Bash"}}), ); tap.handle( "content_block_delta", &json!({"delta": {"type": "input_json_delta", "partial_json": "{\"command\":\"ls\"}"}}), ); tap.handle("content_block_stop", &json!({})); drop(tap); // Next request echoes the result back (string and block-array forms). attach_tool_results( &app, "abc", &json!({"messages": [{"role": "user", "content": [ {"type": "tool_result", "tool_use_id": "toolu_01", "content": [{"type": "text", "text": "file_a\nfile_b"}], "is_error": false} ]}]}), ); let a = app.lock().unwrap(); let e = &a.sessions[0].entries[0]; assert!(matches!(e.kind, Kind::Tool { .. })); let r = e.result.as_ref().expect("result attached"); assert_eq!(r.content, "file_a\nfile_b"); assert!(!r.is_error); assert!(a.sessions[0].tool_ids.is_empty(), "id consumed"); } #[test] fn interactive_tool_toggles_embed_grow() { let app: SharedApp = Arc::new(Mutex::new(App::new())); app.lock().unwrap().embed_session = Some("emb".into()); let mut tap = Tap::new(app.clone(), "emb".into(), "claude-x".into(), None); tap.handle( "content_block_start", &json!({"content_block": {"type": "tool_use", "id": "toolu_q", "name": "AskUserQuestion"}}), ); assert!(!app.lock().unwrap().embed_grow, "not before the block completes"); tap.handle("content_block_stop", &json!({})); assert!(app.lock().unwrap().embed_grow, "grow once the prompt is imminent"); drop(tap); // The user's answer comes back in the next request body → shrink. attach_tool_results( &app, "emb", &json!({"messages": [{"role": "user", "content": [ {"type": "tool_result", "tool_use_id": "toolu_q", "content": "picked A"} ]}]}), ); 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(), None)); 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.) → still surfaced, // tagged with a `── side request ──` divider before its prompt. record_user_prompt( &app, "abc", &json!({"messages": [{"role": "user", "content": "fresh prompt"}]}), ); let a = app.lock().unwrap(); let users: Vec<_> = a.sessions[0] .entries .iter() .filter(|e| e.kind == Kind::User) .collect(); assert_eq!(users.len(), 2, "the side-request prompt is shown too"); assert_eq!(users[1].content, "fresh prompt"); assert!( a.sessions[0] .entries .iter() .any(|e| e.kind == Kind::Meta && e.content.contains("side request")), "the side request is tagged" ); } #[test] fn repeated_prompt_across_turns_is_kept() { // A verbatim repeat ("continue") in a *later* turn must show — only an // immediate resend (same request, nothing streamed since) is deduped. let app: SharedApp = Arc::new(Mutex::new(App::new())); drop(Tap::new(app.clone(), "abc".into(), "claude-x".into(), None)); let body = json!({"tools": [{"name": "Bash"}], "messages": [ {"role": "user", "content": "continue"} ]}); record_user_prompt(&app, "abc", &body); record_user_prompt(&app, "abc", &body); // immediate resend → deduped // Simulate the turn producing a response between the two prompts. { let mut a = app.lock().unwrap(); a.sessions[0].entries.push(Entry { kind: Kind::Text, content: "ok, continuing".into(), done: true, result: None, }); } record_user_prompt(&app, "abc", &body); // new turn, same text → kept let a = app.lock().unwrap(); assert_eq!( a.sessions[0].entries.iter().filter(|e| e.kind == Kind::User).count(), 2 ); } #[test] fn system_size_and_tools_surfaced_once_then_on_change() { let app: SharedApp = Arc::new(Mutex::new(App::new())); drop(Tap::new(app.clone(), "abc".into(), "claude-x".into(), None)); let turn = |sys: &str, tools: Value, prompt: &str| { json!({"system": sys, "tools": tools, "messages": [ {"role": "user", "content": prompt} ]}) }; let tools = json!([{"name": "Bash"}, {"name": "Read"}]); // First turn: system size + tool list + prompt. record_user_prompt(&app, "abc", &turn("0123456789", tools.clone(), "one")); // Second turn, same system + tools: only the new prompt. record_user_prompt(&app, "abc", &turn("0123456789", tools.clone(), "two")); // Third turn, tools changed: re-emit the tool list (system unchanged). record_user_prompt( &app, "abc", &turn("0123456789", json!([{"name": "Bash"}]), "three"), ); let a = app.lock().unwrap(); let kinds: Vec<&Kind> = a.sessions[0].entries.iter().map(|e| &e.kind).collect(); assert_eq!( a.sessions[0].entries.iter().filter(|e| e.kind == Kind::System).count(), 1, "system size emitted once (it never changed)" ); assert_eq!( a.sessions[0].entries.iter().filter(|e| e.kind == Kind::ToolDefs).count(), 2, "tool list re-emitted when the set changed" ); let sys = a.sessions[0] .entries .iter() .find(|e| e.kind == Kind::System) .unwrap(); assert_eq!(sys.content, "system prompt: 10 chars"); let tdef = a.sessions[0] .entries .iter() .find(|e| e.kind == Kind::ToolDefs) .unwrap(); assert_eq!(tdef.content, "tools (2): Bash, Read"); // Order on the first turn: system, tools, then the prompt. assert!(matches!(kinds[0], Kind::System)); assert!(matches!(kinds[1], Kind::ToolDefs)); assert!(matches!(kinds[2], Kind::User)); } #[test] fn slash_command_machinery_shown_in_feed_but_stripped_in_label() { // The model receives the slash-command wrappers, so the feed keeps them // verbatim; only the turn-tree label projection drops them. let raw = "/commit\n-a\nthe rest"; let app: SharedApp = Arc::new(Mutex::new(App::new())); drop(Tap::new(app.clone(), "abc".into(), "claude-x".into(), None)); record_user_prompt( &app, "abc", &json!({"tools": [{"name": "Bash"}], "messages": [ {"role": "user", "content": raw} ]}), ); let a = app.lock().unwrap(); let user = a.sessions[0] .entries .iter() .find(|e| e.kind == Kind::User) .unwrap(); assert!(user.content.contains(""), "feed keeps machinery"); assert_eq!(strip_injected(raw), "the rest", "label drops machinery"); } #[test] fn inline_system_reminder_is_stripped_not_dropped() { // The first message after a resume often arrives with a // prepended *inside the same text block* as the real // prompt — the old whole-block filter dropped it entirely. let app: SharedApp = Arc::new(Mutex::new(App::new())); drop(Tap::new(app.clone(), "abc".into(), "claude-x".into(), None)); record_user_prompt( &app, "abc", &json!({"tools": [{"name": "Bash"}], "messages": [ {"role": "user", "content": [ {"type": "text", "text": "be careful\n\nresume me please"} ]} ]}), ); 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, "the prompt must survive the inline reminder"); assert_eq!(user[0].content, "resume me please"); // The reminder is kept (shown dimmed), recorded just before the prompt. let rem: Vec<_> = a.sessions[0] .entries .iter() .filter(|e| e.kind == Kind::Reminder) .collect(); assert_eq!(rem.len(), 1); assert_eq!(rem[0].content, "be careful"); } #[test] fn prompt_survives_trailing_reminder_message() { // Claude Code sometimes appends an injected as its // *own* trailing user message *after* the real prompt. Inspecting only // messages.last() saw the reminder-only message, got empty text, and // dropped the whole turn — the "user messages don't show up reliably" // bug. The prompt (in the second-to-last message) must still land. let app: SharedApp = Arc::new(Mutex::new(App::new())); drop(Tap::new(app.clone(), "abc".into(), "claude-x".into(), None)); record_user_prompt( &app, "abc", &json!({"tools": [{"name": "Bash"}], "messages": [ {"role": "assistant", "content": "prior reply"}, {"role": "user", "content": [{"type": "text", "text": "what files are here?"}]}, {"role": "user", "content": [ {"type": "text", "text": "todo list updated"} ]} ]}), ); 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, "the prompt must survive a trailing reminder message"); assert_eq!(user[0].content, "what files are here?"); let rem: Vec<_> = a.sessions[0] .entries .iter() .filter(|e| e.kind == Kind::Reminder) .collect(); assert_eq!(rem.len(), 1); assert_eq!(rem[0].content, "todo list updated"); } #[test] fn tool_result_continuation_records_no_prompt() { // A tool-loop continuation ends in a user message of tool_result blocks // (no text). It must not manufacture a spurious User entry even though // the trailing-run scan now looks past the single last message. let app: SharedApp = Arc::new(Mutex::new(App::new())); drop(Tap::new(app.clone(), "abc".into(), "claude-x".into(), None)); record_user_prompt( &app, "abc", &json!({"tools": [{"name": "Bash"}], "messages": [ {"role": "user", "content": "run ls"}, {"role": "assistant", "content": [ {"type": "tool_use", "id": "t1", "name": "Bash", "input": {}} ]}, {"role": "user", "content": [ {"type": "tool_result", "tool_use_id": "t1", "content": "a.txt"} ]} ]}), ); let a = app.lock().unwrap(); let users = a.sessions[0].entries.iter().filter(|e| e.kind == Kind::User).count(); assert_eq!(users, 0, "tool_result continuation must not record a prompt"); } #[test] fn extract_user_text_splits_reminders_from_prompt() { let (rem, prompt) = extract_user_text("x"); assert_eq!(rem, vec!["x"]); assert_eq!(prompt, ""); let (rem, prompt) = extract_user_text("hello"); assert!(rem.is_empty()); assert_eq!(prompt, "hello"); let (rem, prompt) = extract_user_text("pre noise post"); assert_eq!(rem, vec!["noise"]); assert_eq!(prompt, "pre post"); // Unterminated tag → the rest of the block is the reminder. let (rem, prompt) = extract_user_text("keep dropped"); assert_eq!(rem, vec!["dropped"]); assert_eq!(prompt, "keep"); // strip_injected is the prompt-only projection. assert_eq!(strip_injected("a b"), "a"); } #[test] fn ask_rows_scale_with_options() { let two = ask_question_rows(&json!({"questions": [ {"question": "?", "options": [{"label": "a"}, {"label": "b"}]} ]})) .unwrap(); let four = ask_question_rows(&json!({"questions": [ {"question": "?", "multiSelect": true, "options": [{"label": "a"}, {"label": "b"}, {"label": "c"}, {"label": "d"}]} ]})) .unwrap(); assert!(four > two, "more options → more rows ({four} vs {two})"); assert!(ask_question_rows(&json!({"nope": 1})).is_none()); } #[test] fn non_embed_session_never_grows() { let app: SharedApp = Arc::new(Mutex::new(App::new())); app.lock().unwrap().embed_session = Some("emb".into()); let mut tap = Tap::new(app.clone(), "other".into(), "claude-x".into(), None); tap.handle( "content_block_start", &json!({"content_block": {"type": "tool_use", "id": "toolu_q", "name": "AskUserQuestion"}}), ); tap.handle("content_block_stop", &json!({})); assert!(!app.lock().unwrap().embed_grow); } #[test] fn pane_token_binds_and_rebinds_embed_session() { // A resume provisionally binds embed_session to the uuid we spawned // with and pre-loads its row. If Claude Code then reports a *different* // id in the tagged request, the embed must rebind to the real id and // the provisional row is renamed onto it (one session, not two). let app: SharedApp = Arc::new(Mutex::new(App::new())); { let mut a = app.lock().unwrap(); a.embed_token = Some("tok".into()); a.embed_session = Some("provisional".into()); a.sessions.push(Session::new("provisional".into(), "(resumed)".into())); } drop(Tap::new(app.clone(), "real".into(), "m".into(), Some("tok".into()))); let a = app.lock().unwrap(); assert_eq!(a.embed_session.as_deref(), Some("real")); assert_eq!(a.sessions.len(), 1, "provisional row renamed, not duplicated"); assert_eq!(a.sessions[0].key, "real"); assert_eq!(a.selected_key().as_deref(), Some("real"), "selection jumps on bind"); } #[test] fn focused_pane_not_stolen_by_new_external_session() { // The bug this whole change fixes: a brand-new session streaming while // the user is driving the embedded pane must NOT steal the selection // (which hid the focused pane). let app: SharedApp = Arc::new(Mutex::new(App::new())); { let mut a = app.lock().unwrap(); a.embed_token = Some("tok".into()); a.pane_focused = true; } // The pane's own first request binds + selects it. drop(Tap::new(app.clone(), "embed".into(), "m".into(), Some("tok".into()))); assert_eq!(app.lock().unwrap().selected_key().as_deref(), Some("embed")); // A new external session streams: selection must stay on the embed. drop(Tap::new(app.clone(), "external".into(), "m".into(), None)); let a = app.lock().unwrap(); assert_eq!(a.selected_key().as_deref(), Some("embed")); assert_eq!(a.sessions.len(), 2, "external session is still tracked"); } #[test] fn new_session_autojumps_when_pane_unfocused() { // With no pane focused, a fresh session still auto-jumps so a `/clear` // in an external claude is immediately visible. let app: SharedApp = Arc::new(Mutex::new(App::new())); drop(Tap::new(app.clone(), "first".into(), "m".into(), None)); assert_eq!(app.lock().unwrap().selected_key().as_deref(), Some("first")); drop(Tap::new(app.clone(), "second".into(), "m".into(), None)); assert_eq!(app.lock().unwrap().selected_key().as_deref(), Some("second")); } 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())); drop(Tap::new(app.clone(), "abc".into(), "claude-x".into(), None)); attach_tool_results( &app, "abc", &json!({"messages": [{"role": "user", "content": [ {"type": "tool_result", "tool_use_id": "toolu_nope", "content": "x"} ]}]}), ); assert!(app.lock().unwrap().sessions[0].entries.is_empty()); } }