1635 lines
65 KiB
Rust
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());
|
|
}
|
|
}
|