Files
claude-cloak/src/app.rs
2026-07-09 08:49:53 +02:00

1635 lines
65 KiB
Rust

use serde_json::Value;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Instant;
pub type SharedApp = Arc<Mutex<App>>;
/// 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<Session>,
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<usize>,
/// `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<usize>,
/// 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<crate::sessions::DiskSession>,
/// 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<String, crate::sessions::HistoryView>,
/// The one session whose turn tree is expanded in the session list
/// (lazygit-style accordion: expanding another session collapses this).
pub expanded: Option<Expanded>,
/// 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<bool>,
/// 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<String>,
/// 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<String>,
/// 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<u16>,
/// 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<Instant>,
}
/// 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<usize>,
/// Visual-mode anchor (display position); the selection is the
/// contiguous display range anchor..=sel.
pub visual: Option<usize>,
}
/// 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<u16> {
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<usize> {
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<String> {
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<usize>) {
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<String, String> {
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<usize> = 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<crate::sessions::DiskSession>) {
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<Entry>,
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<String, usize>,
/// 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<usize>,
/// 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<String>,
}
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 `<system-reminder>` 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<ToolResult>,
}
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 `<system-reminder>` 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>, String) {
const OPEN: &str = "<system-reminder>";
const CLOSE: &str = "</system-reminder>";
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
// (<command-name> 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("<command-name>")
|| lt.starts_with("<command-message>")
|| lt.starts_with("<command-args>")
|| lt.starts_with("<local-command"))
})
.collect::<Vec<_>>()
.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 `<system-reminder>` 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
// `<system-reminder>`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<String> = Vec::new();
let mut prompts: Vec<String> = 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::<Vec<_>>()
.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<usize>,
}
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<String>) -> 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<u16> = 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::<Value>(&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": "<system-reminder>noise</system-reminder>"},
{"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 = "<command-name>/commit</command-name>\n<command-args>-a</command-args>\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("<command-name>"), "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
// <system-reminder> 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":
"<system-reminder>be careful</system-reminder>\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 <system-reminder> 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": "<system-reminder>todo list updated</system-reminder>"}
]}
]}),
);
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("<system-reminder>x</system-reminder>");
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 <system-reminder>noise</system-reminder> 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 <system-reminder>dropped");
assert_eq!(rem, vec!["dropped"]);
assert_eq!(prompt, "keep");
// strip_injected is the prompt-only projection.
assert_eq!(strip_injected("a <system-reminder>b</system-reminder>"), "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<usize> {
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());
}
}