Sessions panel now shows on-disk JSONL history for the current directory (background scanner, ~1/s, deduped against live sessions). space/→ expands a session into a lazygit-style turn tree; j/k walks sessions and turns; v anchors a visual range; b materialises a new fully-decoupled branch file. ctrl-↓ is the commit point for attach/resume/spawn; live external sessions get a 3-second liveness guard (past_embeds skips the guard for sessions we killed ourselves). n opens a model-picker popup and spawns a fresh --session-id session; model aliases are auto-discovered by scanning the installed claude binary (no API call, no exec). ctrl-f toggles fullscreen for the embedded pane. Mouse is now captured: wheel always scrolls the feed, left-drag selects screen text copied on release via OSC 52 (like Claude Code). Native selection needs shift held. FeedCache stores per-entry rendered lines + wrapped heights, rebuilt only when the content fingerprint changes — work while holding the app mutex is proportional to what changed, and feed scroll now works past u16::MAX lines. Kind::User entries (deep-blue ❯ prefix) surface user prompts from request bodies via record_user_prompt. src/markdown.rs added: GFM table rendering (box-drawing, width-fitted columns) and heading # marker stripping, compensating tui-markdown 0.3.5 gaps without upgrading. The proxy tap now runs on a dedicated tokio task via a bounded mpsc channel (try_send; drop-on-full) so it never delays forwarded bytes. Listener is bound before the TUI starts so multi-instance port coexistence works from launch; CT_UPSTREAM added for fully offline testing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
826 lines
33 KiB
Rust
826 lines
33 KiB
Rust
//! Past-session discovery + the on-disk turn tree: keeps the session list
|
||
//! populated with this directory's history, parses a session's JSONL into a
|
||
//! tree of *turns* (Claude Code records carry `uuid`/`parentUuid`, and every
|
||
//! Esc-Esc rewind leaves a branch point behind), and materializes new session
|
||
//! files from a chosen set of turns (branching / cherry-picking).
|
||
//!
|
||
//! Claude Code stores one `.jsonl` file per session under
|
||
//! `~/.claude/projects/<encoded-cwd>/`. The path encoding replaces every `/`
|
||
//! with `-` (the leading `/` becomes a leading `-`).
|
||
|
||
use crate::app::{
|
||
flatten_result_content, is_injected_block, lock_app, Entry, Kind, Session, SharedApp,
|
||
ToolResult,
|
||
};
|
||
use serde_json::Value;
|
||
use std::collections::HashMap;
|
||
use std::io::BufRead;
|
||
use std::path::PathBuf;
|
||
use std::time::SystemTime;
|
||
|
||
#[derive(Clone, PartialEq)]
|
||
pub struct DiskSession {
|
||
pub uuid: String,
|
||
/// Best human-readable label: ai-title > last-prompt text > uuid prefix.
|
||
pub label: String,
|
||
pub modified: SystemTime,
|
||
}
|
||
|
||
/// Background scanner: keeps `App::disk_sessions` in sync with the project
|
||
/// directory so the UI never does disk I/O for the session list. Polls ~1/s;
|
||
/// labels are re-read only for files whose mtime changed, so the steady-state
|
||
/// cost is one `read_dir` + a stat per file. The app mutex is only taken when
|
||
/// the list actually changed.
|
||
pub fn spawn_scanner(app: SharedApp) {
|
||
std::thread::spawn(move || {
|
||
// uuid → (mtime when read, label): skip re-parsing unchanged files.
|
||
let mut labels: HashMap<String, (SystemTime, String)> = HashMap::new();
|
||
let mut last: Vec<DiskSession> = Vec::new();
|
||
loop {
|
||
let list = scan(&mut labels).unwrap_or_default();
|
||
if list != last {
|
||
last = list.clone();
|
||
lock_app(&app).set_disk_sessions(list);
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||
}
|
||
});
|
||
}
|
||
|
||
/// One scan of the project directory, newest first.
|
||
fn scan(
|
||
labels: &mut HashMap<String, (SystemTime, String)>,
|
||
) -> Result<Vec<DiskSession>, String> {
|
||
let dir = project_dir()?;
|
||
let rd = std::fs::read_dir(&dir)
|
||
.map_err(|e| format!("cannot read {}: {e}", dir.display()))?;
|
||
let mut sessions: Vec<DiskSession> = rd
|
||
.flatten()
|
||
.filter_map(|e| {
|
||
let path = e.path();
|
||
if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
|
||
return None;
|
||
}
|
||
let uuid = path.file_stem()?.to_str()?.to_string();
|
||
let modified = e.metadata().ok()?.modified().ok()?;
|
||
let label = match labels.get(&uuid) {
|
||
Some((m, l)) if *m == modified => l.clone(),
|
||
_ => {
|
||
let l = read_label(&path, &uuid);
|
||
labels.insert(uuid.clone(), (modified, l.clone()));
|
||
l
|
||
}
|
||
};
|
||
Some(DiskSession { uuid, label, modified })
|
||
})
|
||
.collect();
|
||
sessions.sort_by(|a, b| b.modified.cmp(&a.modified));
|
||
Ok(sessions)
|
||
}
|
||
|
||
/// One turn of a session: a real user prompt plus everything that chains off
|
||
/// it (assistant blocks, tool results, attachments…) until the next prompt.
|
||
pub struct Turn {
|
||
/// First line of the user's prompt (injected blocks filtered out).
|
||
pub label: String,
|
||
/// Indent for tree rendering: abandoned (non-trunk) branches sit one
|
||
/// level under their fork point; the most recent continuation stays at
|
||
/// its parent's depth.
|
||
pub depth: usize,
|
||
/// Parent turn (None for the session's first prompt).
|
||
pub parent: Option<usize>,
|
||
/// Raw JSONL lines belonging to this turn, in file order.
|
||
pub lines: Vec<String>,
|
||
}
|
||
|
||
/// A session's turns as a tree. Built from `uuid`/`parentUuid` chains:
|
||
/// uuid-less records (mode, file-history-snapshot, last-prompt…) attach to
|
||
/// the turn of the record preceding them in the file.
|
||
pub struct TurnTree {
|
||
pub turns: Vec<Turn>,
|
||
/// Records before/outside any turn (mode, the caveat record, …).
|
||
pub preamble: Vec<String>,
|
||
/// Turn indices in render order: DFS where abandoned branches are listed
|
||
/// (indented) right after their fork point and the trunk continues below.
|
||
pub display: Vec<usize>,
|
||
}
|
||
|
||
impl TurnTree {
|
||
/// Children of `t` in creation (= chronological) order.
|
||
fn children(&self, t: usize) -> impl Iterator<Item = usize> + '_ {
|
||
(t + 1..self.turns.len()).filter(move |&c| self.turns[c].parent == Some(t))
|
||
}
|
||
|
||
/// Follow the most recent child from `t` down to a leaf — the path a
|
||
/// `claude --resume` would continue on from that point.
|
||
pub fn trunk_leaf(&self, from: usize) -> usize {
|
||
let mut t = from;
|
||
while let Some(c) = self.children(t).max() {
|
||
t = c;
|
||
}
|
||
t
|
||
}
|
||
|
||
/// Root → `leaf` chain, inclusive.
|
||
pub fn chain(&self, leaf: usize) -> Vec<usize> {
|
||
let mut v = vec![leaf];
|
||
let mut t = leaf;
|
||
while let Some(p) = self.turns[t].parent {
|
||
v.push(p);
|
||
t = p;
|
||
}
|
||
v.reverse();
|
||
v
|
||
}
|
||
}
|
||
|
||
/// The user-typed prompt text of a record, if it starts a turn: a `user`
|
||
/// record that is not meta/sidechain and carries non-injected text.
|
||
fn prompt_text(v: &Value) -> Option<String> {
|
||
if v.get("type").and_then(Value::as_str) != Some("user")
|
||
|| v.get("isSidechain").and_then(Value::as_bool) == Some(true)
|
||
|| v.get("isMeta").and_then(Value::as_bool) == Some(true)
|
||
{
|
||
return None;
|
||
}
|
||
let text = match v.pointer("/message/content") {
|
||
Some(Value::String(s)) => s.clone(),
|
||
Some(Value::Array(blocks)) => blocks
|
||
.iter()
|
||
.filter(|b| b.get("type").and_then(Value::as_str) == Some("text"))
|
||
.filter_map(|b| b.get("text").and_then(Value::as_str))
|
||
.filter(|t| !is_injected_block(t))
|
||
.collect::<Vec<_>>()
|
||
.join("\n"),
|
||
_ => return None,
|
||
};
|
||
let text = text.trim();
|
||
(!text.is_empty() && !is_injected_block(text)).then(|| text.to_string())
|
||
}
|
||
|
||
/// Parse a session file into its turn tree. None when the file has no turns
|
||
/// (nothing recorded yet, or unreadable).
|
||
pub fn load_tree(uuid: &str) -> Option<TurnTree> {
|
||
let path = project_dir().ok()?.join(format!("{uuid}.jsonl"));
|
||
let f = std::fs::File::open(path).ok()?;
|
||
let reader = std::io::BufReader::new(f);
|
||
let tree = build_tree(reader.lines().map_while(Result::ok));
|
||
(!tree.turns.is_empty()).then_some(tree)
|
||
}
|
||
|
||
/// Tree construction from raw JSONL lines (separated from I/O for tests).
|
||
pub(crate) fn build_tree(lines: impl IntoIterator<Item = String>) -> TurnTree {
|
||
let mut turns: Vec<Turn> = Vec::new();
|
||
let mut preamble: Vec<String> = Vec::new();
|
||
// record uuid → turn it belongs to (None = preamble).
|
||
let mut turn_of: HashMap<String, Option<usize>> = HashMap::new();
|
||
// positional bucket for uuid-less records: the previous record's turn.
|
||
let mut cur: Option<usize> = None;
|
||
for line in lines {
|
||
let Ok(v) = serde_json::from_str::<Value>(&line) else {
|
||
continue;
|
||
};
|
||
let uuid = v.get("uuid").and_then(Value::as_str).map(str::to_string);
|
||
let bucket = match (&uuid, prompt_text(&v)) {
|
||
(Some(_), Some(label)) => {
|
||
// A real prompt starts a new turn, chained to the turn of its
|
||
// parent record (rewinds make this an *earlier* turn).
|
||
let parent = v
|
||
.get("parentUuid")
|
||
.and_then(Value::as_str)
|
||
.and_then(|p| turn_of.get(p).copied())
|
||
.flatten();
|
||
turns.push(Turn {
|
||
label: one_line(&label),
|
||
depth: 0,
|
||
parent,
|
||
lines: Vec::new(),
|
||
});
|
||
Some(turns.len() - 1)
|
||
}
|
||
(Some(_), None) => match v.get("parentUuid").and_then(Value::as_str) {
|
||
// Chain membership when the parent is known; otherwise fall
|
||
// back to position (orphaned records exist in old files).
|
||
Some(p) => turn_of.get(p).copied().unwrap_or(cur),
|
||
None => None, // root record → preamble
|
||
},
|
||
(None, _) => cur, // uuid-less metadata rides with its neighbours
|
||
};
|
||
match bucket {
|
||
Some(t) => turns[t].lines.push(line),
|
||
None => preamble.push(line),
|
||
}
|
||
if let Some(u) = uuid {
|
||
turn_of.insert(u, bucket);
|
||
}
|
||
cur = bucket;
|
||
}
|
||
// Render order + depths: abandoned branches indent under the fork point
|
||
// and are listed first; the most recent child continues at parent depth.
|
||
let mut children: Vec<Vec<usize>> = vec![Vec::new(); turns.len()];
|
||
let mut roots: Vec<usize> = Vec::new();
|
||
for (i, t) in turns.iter().enumerate() {
|
||
match t.parent {
|
||
Some(p) => children[p].push(i),
|
||
None => roots.push(i),
|
||
}
|
||
}
|
||
let mut display = Vec::with_capacity(turns.len());
|
||
let mut stack: Vec<(usize, usize)> = roots.iter().rev().map(|&r| (r, 0)).collect();
|
||
while let Some((t, d)) = stack.pop() {
|
||
display.push(t);
|
||
turns[t].depth = d;
|
||
let ch = &children[t];
|
||
for (i, &c) in ch.iter().enumerate().rev() {
|
||
stack.push((c, if i + 1 == ch.len() { d } else { d + 1 }));
|
||
}
|
||
}
|
||
TurnTree { turns, preamble, display }
|
||
}
|
||
|
||
/// Write a brand-new session file made of `turn_idxs` (chronological order)
|
||
/// plus the preamble: sessionId rewritten throughout, each turn's first
|
||
/// record re-parented onto the previous turn's tail (the first one onto the
|
||
/// preamble tail / null), so the result is a self-consistent linear session
|
||
/// fully decoupled from its origin. Returns the new session uuid.
|
||
pub fn materialize(tree: &TurnTree, turn_idxs: &[usize], title: &str) -> Result<String, String> {
|
||
let dir = project_dir()?;
|
||
materialize_in(&dir, tree, turn_idxs, title)
|
||
}
|
||
|
||
fn materialize_in(
|
||
dir: &std::path::Path,
|
||
tree: &TurnTree,
|
||
turn_idxs: &[usize],
|
||
title: &str,
|
||
) -> Result<String, String> {
|
||
let new_uuid = uuid::Uuid::new_v4().to_string();
|
||
let mut out = String::new();
|
||
// Our own title record first: `read_label` (and Claude Code's picker)
|
||
// prefer it, so the branch gets a meaningful name.
|
||
out.push_str(
|
||
&serde_json::json!({"type":"ai-title","aiTitle":title,"sessionId":new_uuid}).to_string(),
|
||
);
|
||
out.push('\n');
|
||
|
||
fn emit(
|
||
line: &str,
|
||
new_uuid: &str,
|
||
head_of_turn: &mut bool,
|
||
tail: &mut Option<String>,
|
||
out: &mut String,
|
||
) {
|
||
let Ok(mut v) = serde_json::from_str::<Value>(line) else {
|
||
return;
|
||
};
|
||
// The origin's title must not override ours.
|
||
if v.get("type").and_then(Value::as_str) == Some("ai-title") {
|
||
return;
|
||
}
|
||
if let Some(o) = v.as_object_mut() {
|
||
o.insert("sessionId".into(), Value::String(new_uuid.to_string()));
|
||
if let Some(u) = o.get("uuid").and_then(Value::as_str).map(str::to_string) {
|
||
if *head_of_turn {
|
||
o.insert(
|
||
"parentUuid".into(),
|
||
tail.clone().map_or(Value::Null, Value::String),
|
||
);
|
||
*head_of_turn = false;
|
||
}
|
||
*tail = Some(u);
|
||
}
|
||
}
|
||
out.push_str(&v.to_string());
|
||
out.push('\n');
|
||
}
|
||
|
||
let mut tail: Option<String> = None;
|
||
let mut no_rewire = false; // preamble keeps its own chain
|
||
for l in &tree.preamble {
|
||
emit(l, &new_uuid, &mut no_rewire, &mut tail, &mut out);
|
||
}
|
||
for &t in turn_idxs {
|
||
let turn = tree
|
||
.turns
|
||
.get(t)
|
||
.ok_or_else(|| format!("turn {t} out of range"))?;
|
||
let mut head = true;
|
||
for l in &turn.lines {
|
||
emit(l, &new_uuid, &mut head, &mut tail, &mut out);
|
||
}
|
||
}
|
||
let path = dir.join(format!("{new_uuid}.jsonl"));
|
||
std::fs::write(&path, out).map_err(|e| format!("write {}: {e}", path.display()))?;
|
||
Ok(new_uuid)
|
||
}
|
||
|
||
/// A rendered transcript: the feed `Session` plus which tree path it shows.
|
||
pub struct HistoryView {
|
||
pub session: Session,
|
||
/// Some(leaf turn) when rendered along a tree path; None = whole file
|
||
/// in raw order (legacy view for un-expanded stubs).
|
||
pub leaf: Option<usize>,
|
||
/// (turn index, first entry index) along the path — feed auto-scroll.
|
||
pub turn_entries: Vec<(usize, usize)>,
|
||
}
|
||
|
||
/// Rebuild a feed `Session` from a past session's JSONL transcript so the
|
||
/// feed isn't blank when resuming (no API traffic flows until the next turn).
|
||
/// Live taps for the resumed session find this entry by key and append to it.
|
||
pub fn load_history(uuid: &str) -> Option<Session> {
|
||
load_view(uuid, None).map(|h| h.session)
|
||
}
|
||
|
||
/// Path-aware transcript view: with `path = Some((tree, leaf))` only the
|
||
/// preamble + the root→leaf chain is rendered (dead branches excluded) and
|
||
/// per-turn entry offsets are recorded; with None, the whole file.
|
||
pub fn load_view(uuid: &str, path: Option<(&TurnTree, usize)>) -> Option<HistoryView> {
|
||
match path {
|
||
None => {
|
||
let fpath = project_dir().ok()?.join(format!("{uuid}.jsonl"));
|
||
load_file_view(&fpath, uuid)
|
||
}
|
||
Some((tree, leaf)) => {
|
||
let mut p = EntryParser::new();
|
||
let mut turn_entries: Vec<(usize, usize)> = Vec::new();
|
||
for l in &tree.preamble {
|
||
p.line(l);
|
||
}
|
||
for &t in &tree.chain(leaf) {
|
||
turn_entries.push((t, p.entries.len()));
|
||
for l in &tree.turns[t].lines {
|
||
p.line(l);
|
||
}
|
||
}
|
||
p.into_view(uuid, Some(leaf), turn_entries)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Whole-file transcript view from an explicit path (the I/O-location seam:
|
||
/// tests parse temp files without touching `HOME`).
|
||
pub(crate) fn load_file_view(path: &std::path::Path, uuid: &str) -> Option<HistoryView> {
|
||
let f = std::fs::File::open(path).ok()?;
|
||
let mut p = EntryParser::new();
|
||
for line in std::io::BufReader::new(f).lines().map_while(Result::ok) {
|
||
p.line(&line);
|
||
}
|
||
p.into_view(uuid, None, Vec::new())
|
||
}
|
||
/// Incremental JSONL-record → feed-`Entry` translation (shared by the whole
|
||
/// file and path views).
|
||
struct EntryParser {
|
||
entries: Vec<Entry>,
|
||
model: String,
|
||
/// tool_use id → entry index, to attach results from later user lines.
|
||
tool_idx: HashMap<String, usize>,
|
||
}
|
||
|
||
impl EntryParser {
|
||
fn new() -> Self {
|
||
Self {
|
||
entries: Vec::new(),
|
||
model: String::from("(resumed)"),
|
||
tool_idx: HashMap::new(),
|
||
}
|
||
}
|
||
|
||
/// Wrap the parsed entries into a `HistoryView` (None when empty).
|
||
fn into_view(
|
||
self,
|
||
uuid: &str,
|
||
leaf: Option<usize>,
|
||
turn_entries: Vec<(usize, usize)>,
|
||
) -> Option<HistoryView> {
|
||
if self.entries.is_empty() {
|
||
return None;
|
||
}
|
||
Some(HistoryView {
|
||
session: Session {
|
||
key: uuid.to_string(),
|
||
model: self.model,
|
||
entries: self.entries,
|
||
active: 0,
|
||
input_tokens: 0,
|
||
output_tokens: 0,
|
||
last_activity: std::time::Instant::now(),
|
||
tool_ids: HashMap::new(),
|
||
},
|
||
leaf,
|
||
turn_entries,
|
||
})
|
||
}
|
||
|
||
fn line(&mut self, line: &str) {
|
||
let entries = &mut self.entries;
|
||
let tool_idx = &mut self.tool_idx;
|
||
let Ok(v) = serde_json::from_str::<Value>(line) else {
|
||
return;
|
||
};
|
||
// Skip subagent transcripts and synthetic/meta user lines.
|
||
if v.get("isSidechain").and_then(Value::as_bool) == Some(true)
|
||
|| v.get("isMeta").and_then(Value::as_bool) == Some(true)
|
||
{
|
||
return;
|
||
}
|
||
match v.get("type").and_then(Value::as_str) {
|
||
Some("assistant") => {
|
||
if let Some(m) = v.pointer("/message/model").and_then(Value::as_str) {
|
||
self.model = m.to_string();
|
||
}
|
||
let Some(blocks) = v.pointer("/message/content").and_then(Value::as_array)
|
||
else {
|
||
return;
|
||
};
|
||
for b in blocks {
|
||
let entry = match b.get("type").and_then(Value::as_str) {
|
||
Some("thinking") => Some(Entry {
|
||
kind: Kind::Thinking,
|
||
content: text_of(b, "thinking"),
|
||
done: true,
|
||
result: None,
|
||
}),
|
||
Some("redacted_thinking") => Some(Entry {
|
||
kind: Kind::Thinking,
|
||
content: "[redacted]".into(),
|
||
done: true,
|
||
result: None,
|
||
}),
|
||
Some("text") => Some(Entry {
|
||
kind: Kind::Text,
|
||
content: text_of(b, "text"),
|
||
done: true,
|
||
result: None,
|
||
}),
|
||
Some("tool_use" | "server_tool_use" | "mcp_tool_use") => {
|
||
if let Some(id) = b.get("id").and_then(Value::as_str) {
|
||
tool_idx.insert(id.to_string(), entries.len());
|
||
}
|
||
let input = b.get("input").map_or(String::new(), |i| {
|
||
serde_json::to_string_pretty(i).unwrap_or_default()
|
||
});
|
||
Some(Entry {
|
||
kind: Kind::Tool {
|
||
name: b
|
||
.get("name")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or("tool")
|
||
.to_string(),
|
||
},
|
||
content: input,
|
||
done: true,
|
||
result: None,
|
||
})
|
||
}
|
||
_ => None,
|
||
};
|
||
if let Some(e) = entry {
|
||
entries.push(e);
|
||
}
|
||
}
|
||
}
|
||
Some("user") => match v.pointer("/message/content") {
|
||
Some(Value::String(s)) => {
|
||
if !s.is_empty() {
|
||
entries.push(Entry {
|
||
kind: Kind::Meta,
|
||
content: format!("❯ {s}"),
|
||
done: true,
|
||
result: None,
|
||
});
|
||
}
|
||
}
|
||
Some(Value::Array(blocks)) => {
|
||
for b in blocks {
|
||
match b.get("type").and_then(Value::as_str) {
|
||
Some("text") => {
|
||
let t = text_of(b, "text");
|
||
if !t.is_empty() {
|
||
entries.push(Entry {
|
||
kind: Kind::Meta,
|
||
content: format!("❯ {t}"),
|
||
done: true,
|
||
result: None,
|
||
});
|
||
}
|
||
}
|
||
Some("tool_result") => {
|
||
let Some(idx) = b
|
||
.get("tool_use_id")
|
||
.and_then(Value::as_str)
|
||
.and_then(|id| tool_idx.remove(id))
|
||
else {
|
||
continue;
|
||
};
|
||
if let Some(e) = entries.get_mut(idx) {
|
||
e.result = Some(ToolResult {
|
||
content: flatten_result_content(b.get("content")),
|
||
is_error: b
|
||
.get("is_error")
|
||
.and_then(Value::as_bool)
|
||
.unwrap_or(false),
|
||
});
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
_ => {}
|
||
},
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn text_of(b: &Value, key: &str) -> String {
|
||
b.get(key).and_then(Value::as_str).unwrap_or_default().to_string()
|
||
}
|
||
|
||
/// Encode the current working directory the same way Claude Code does:
|
||
/// each `/` → `-` (the leading `/` becomes a leading `-`).
|
||
fn project_dir() -> Result<PathBuf, String> {
|
||
let home = std::env::var("HOME").map_err(|_| "HOME is not set".to_string())?;
|
||
let cwd = std::env::current_dir().map_err(|e| format!("cannot read cwd: {e}"))?;
|
||
let encoded = cwd
|
||
.to_str()
|
||
.ok_or_else(|| "cwd is not valid UTF-8; cannot map it to a Claude project dir".to_string())?
|
||
.replace('/', "-");
|
||
let dir = PathBuf::from(home).join(".claude").join("projects").join(encoded);
|
||
if !dir.is_dir() {
|
||
return Err("no past sessions recorded for this directory".to_string());
|
||
}
|
||
Ok(dir)
|
||
}
|
||
|
||
/// Read a human-readable label from the JSONL file. Prefers `ai-title`;
|
||
/// falls back to the first non-empty `last-prompt` text; then uuid prefix.
|
||
fn read_label(path: &std::path::Path, uuid: &str) -> String {
|
||
let fallback = || uuid.chars().take(8).collect::<String>();
|
||
let Ok(f) = std::fs::File::open(path) else {
|
||
return fallback();
|
||
};
|
||
let reader = std::io::BufReader::new(f);
|
||
let mut last_prompt: Option<String> = None;
|
||
for line in reader.lines().map_while(Result::ok) {
|
||
let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) else {
|
||
continue;
|
||
};
|
||
match v.get("type").and_then(|t| t.as_str()) {
|
||
Some("ai-title") => {
|
||
if let Some(t) = v.get("aiTitle").and_then(|t| t.as_str())
|
||
&& !t.is_empty()
|
||
{
|
||
return one_line(t);
|
||
}
|
||
}
|
||
Some("last-prompt") => {
|
||
if let Some(p) = v.get("lastPrompt").and_then(|p| p.as_str())
|
||
&& !p.is_empty()
|
||
{
|
||
last_prompt = Some(one_line(p));
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
last_prompt.unwrap_or_else(fallback)
|
||
}
|
||
|
||
/// First line only, control characters dropped — labels go into a one-row
|
||
/// list item, so embedded newlines/tabs would smear the layout.
|
||
fn one_line(s: &str) -> String {
|
||
s.lines()
|
||
.next()
|
||
.unwrap_or("")
|
||
.chars()
|
||
.filter(|c| !c.is_control())
|
||
.collect()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use std::io::Write;
|
||
|
||
/// A user-prompt record (turn start).
|
||
fn prompt(uuid: &str, parent: Option<&str>, text: &str) -> String {
|
||
serde_json::json!({
|
||
"type": "user", "uuid": uuid, "parentUuid": parent,
|
||
"sessionId": "orig",
|
||
"message": {"role": "user", "content": text}
|
||
})
|
||
.to_string()
|
||
}
|
||
|
||
/// An assistant text record.
|
||
fn reply(uuid: &str, parent: &str, text: &str) -> String {
|
||
serde_json::json!({
|
||
"type": "assistant", "uuid": uuid, "parentUuid": parent,
|
||
"sessionId": "orig",
|
||
"message": {"model": "claude-x", "content": [{"type": "text", "text": text}]}
|
||
})
|
||
.to_string()
|
||
}
|
||
|
||
/// A branched fixture: prompt A → prompt B (abandoned) and, after a
|
||
/// rewind onto A's reply, prompt B2 → C (the trunk).
|
||
fn branched() -> Vec<String> {
|
||
vec![
|
||
r#"{"type":"mode","mode":"normal","sessionId":"orig"}"#.into(), // preamble
|
||
prompt("u1", None, "prompt A"),
|
||
reply("u2", "u1", "reply A"),
|
||
prompt("u3", Some("u2"), "prompt B"),
|
||
reply("u4", "u3", "reply B"),
|
||
prompt("u5", Some("u2"), "prompt B2"), // rewind: forks off reply A
|
||
reply("u6", "u5", "reply B2"),
|
||
prompt("u7", Some("u6"), "prompt C"),
|
||
reply("u8", "u7", "reply C"),
|
||
]
|
||
}
|
||
|
||
#[test]
|
||
fn tree_groups_turns_and_orders_branches() {
|
||
let tree = build_tree(branched());
|
||
assert_eq!(tree.turns.len(), 4);
|
||
assert_eq!(tree.preamble.len(), 1, "mode record lands in the preamble");
|
||
let labels: Vec<&str> = tree.turns.iter().map(|t| t.label.as_str()).collect();
|
||
assert_eq!(labels, ["prompt A", "prompt B", "prompt B2", "prompt C"]);
|
||
assert_eq!(tree.turns[1].parent, Some(0), "B forks off A");
|
||
assert_eq!(tree.turns[2].parent, Some(0), "B2 forks off A");
|
||
assert_eq!(tree.turns[3].parent, Some(2));
|
||
// Display: A, then the abandoned B indented, then trunk B2 → C.
|
||
assert_eq!(tree.display, [0, 1, 2, 3]);
|
||
assert_eq!(tree.turns[0].depth, 0);
|
||
assert_eq!(tree.turns[1].depth, 1, "abandoned branch indents");
|
||
assert_eq!(tree.turns[2].depth, 0, "most recent child stays on trunk");
|
||
assert_eq!(tree.turns[3].depth, 0);
|
||
// Trunk resolution + chains.
|
||
assert_eq!(tree.trunk_leaf(0), 3);
|
||
assert_eq!(tree.trunk_leaf(1), 1, "dead branch ends at its own leaf");
|
||
assert_eq!(tree.chain(3), [0, 2, 3]);
|
||
assert_eq!(tree.chain(1), [0, 1]);
|
||
}
|
||
|
||
#[test]
|
||
fn path_view_excludes_dead_branches_and_offsets_turns() {
|
||
let tree = build_tree(branched());
|
||
let h = load_view("some-uuid", Some((&tree, 3))).expect("view");
|
||
let texts: Vec<&str> = h.session.entries.iter().map(|e| e.content.as_str()).collect();
|
||
assert!(texts.iter().any(|t| t.contains("prompt B2")));
|
||
assert!(
|
||
!texts.iter().any(|t| *t == "reply B" || t.contains("prompt B\n") || t.ends_with("prompt B")),
|
||
"dead branch content must not leak into the path view: {texts:?}"
|
||
);
|
||
assert_eq!(h.leaf, Some(3));
|
||
// Turn offsets point at each turn's first entry (its ❯ prompt).
|
||
assert_eq!(h.turn_entries.len(), 3);
|
||
for &(t, e) in &h.turn_entries {
|
||
assert!(
|
||
h.session.entries[e].content.contains(&tree.turns[t].label),
|
||
"offset {e} should land on the prompt of turn {t}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn materialize_chain_and_stitch() {
|
||
let tree = build_tree(branched());
|
||
let dir = std::env::temp_dir().join(format!("ct-mat-{}", std::process::id()));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
|
||
// Plain branch: chain through the abandoned B turn.
|
||
let new = materialize_in(&dir, &tree, &tree.chain(1), "⑂ prompt B").unwrap();
|
||
let body = std::fs::read_to_string(dir.join(format!("{new}.jsonl"))).unwrap();
|
||
let recs: Vec<Value> = body.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||
assert_eq!(recs[0]["type"], "ai-title");
|
||
assert_eq!(recs[0]["aiTitle"], "⑂ prompt B");
|
||
for r in &recs {
|
||
assert_eq!(r["sessionId"], Value::String(new.clone()), "sessionId rewritten");
|
||
}
|
||
assert!(!body.contains("prompt B2"), "other branch excluded");
|
||
let rebuilt = build_tree(body.lines().map(str::to_string));
|
||
assert_eq!(rebuilt.turns.len(), 2);
|
||
assert_eq!(rebuilt.turns[1].parent, Some(0), "chain stays linked");
|
||
|
||
// Visual stitch: only the C turn, fully decoupled.
|
||
let new2 = materialize_in(&dir, &tree, &[3], "⑂ prompt C").unwrap();
|
||
let body2 = std::fs::read_to_string(dir.join(format!("{new2}.jsonl"))).unwrap();
|
||
let rebuilt2 = build_tree(body2.lines().map(str::to_string));
|
||
assert_eq!(rebuilt2.turns.len(), 1);
|
||
assert_eq!(rebuilt2.turns[0].label, "prompt C");
|
||
assert_eq!(rebuilt2.turns[0].parent, None, "first turn re-parented to root");
|
||
assert!(!body2.contains("prompt A"), "unselected turns dropped");
|
||
|
||
std::fs::remove_dir_all(&dir).ok();
|
||
}
|
||
|
||
fn write_jsonl(lines: &[&str]) -> std::path::PathBuf {
|
||
use std::sync::atomic::{AtomicU32, Ordering};
|
||
static N: AtomicU32 = AtomicU32::new(0);
|
||
let path = std::env::temp_dir().join(format!(
|
||
"ct-sessions-test-{}-{}.jsonl",
|
||
std::process::id(),
|
||
N.fetch_add(1, Ordering::Relaxed)
|
||
));
|
||
let mut f = std::fs::File::create(&path).unwrap();
|
||
for l in lines {
|
||
writeln!(f, "{l}").unwrap();
|
||
}
|
||
path
|
||
}
|
||
|
||
/// Property check against whatever real session files exist for this
|
||
/// directory (no-op elsewhere): every turn appears in `display` exactly
|
||
/// once, depths are sane, and chains terminate (parents always precede
|
||
/// children, so the tree is acyclic by construction — verify anyway).
|
||
#[test]
|
||
fn real_session_trees_uphold_invariants() {
|
||
let Ok(dir) = project_dir() else { return };
|
||
let Ok(rd) = std::fs::read_dir(&dir) else { return };
|
||
for e in rd.flatten() {
|
||
let path = e.path();
|
||
if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
|
||
continue;
|
||
}
|
||
let Ok(body) = std::fs::read_to_string(&path) else { continue };
|
||
let tree = build_tree(body.lines().map(str::to_string));
|
||
let mut seen = vec![false; tree.turns.len()];
|
||
for &t in &tree.display {
|
||
assert!(!seen[t], "{path:?}: turn {t} displayed twice");
|
||
seen[t] = true;
|
||
}
|
||
assert!(seen.iter().all(|&s| s), "{path:?}: turn missing from display");
|
||
for (i, t) in tree.turns.iter().enumerate() {
|
||
assert!(!t.label.is_empty(), "{path:?}: empty turn label");
|
||
if let Some(p) = t.parent {
|
||
assert!(p < i, "{path:?}: parent {p} not before child {i}");
|
||
}
|
||
assert!(!tree.chain(i).is_empty());
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn label_prefers_ai_title() {
|
||
let p = write_jsonl(&[
|
||
r#"{"type":"last-prompt","lastPrompt":"fix the bug"}"#,
|
||
r#"{"type":"ai-title","aiTitle":"bug fixing session"}"#,
|
||
]);
|
||
assert_eq!(read_label(&p, "deadbeef-0000"), "bug fixing session");
|
||
std::fs::remove_file(p).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn label_falls_back_to_last_prompt_then_uuid() {
|
||
let p = write_jsonl(&[
|
||
r#"{"type":"last-prompt","lastPrompt":"first"}"#,
|
||
r#"{"type":"last-prompt","lastPrompt":"latest\nmultiline"}"#,
|
||
]);
|
||
assert_eq!(read_label(&p, "deadbeef-0000"), "latest");
|
||
std::fs::remove_file(p).ok();
|
||
|
||
let p = write_jsonl(&[r#"{"type":"user"}"#, "not json"]);
|
||
assert_eq!(read_label(&p, "deadbeef-0000"), "deadbeef");
|
||
std::fs::remove_file(p).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn history_rebuilds_entries_with_tool_results() {
|
||
let p = write_jsonl(&[
|
||
r#"{"type":"user","message":{"role":"user","content":"fix the bug"}}"#,
|
||
r#"{"type":"assistant","message":{"model":"claude-x","content":[
|
||
{"type":"thinking","thinking":"hmm"},
|
||
{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls"}}
|
||
]}}"#
|
||
.replace('\n', " ")
|
||
.leak(),
|
||
r#"{"type":"user","message":{"role":"user","content":[
|
||
{"type":"tool_result","tool_use_id":"toolu_1","content":"file_a","is_error":false}
|
||
]}}"#
|
||
.replace('\n', " ")
|
||
.leak(),
|
||
r#"{"type":"assistant","isSidechain":true,"message":{"model":"claude-x","content":[{"type":"text","text":"subagent noise"}]}}"#,
|
||
r#"{"type":"assistant","message":{"model":"claude-x","content":[{"type":"text","text":"done"}]}}"#,
|
||
]);
|
||
// Parse via the explicit-path seam: no HOME mutation (an unsafe
|
||
// set_var would race the env reads of concurrently running tests).
|
||
let uuid = "11111111-2222-3333-4444-555555555555";
|
||
let s = load_file_view(&p, uuid).map(|h| h.session);
|
||
std::fs::remove_file(&p).ok();
|
||
|
||
let s = s.expect("history loaded");
|
||
assert_eq!(s.key, uuid);
|
||
assert_eq!(s.model, "claude-x");
|
||
// ❯ prompt, thinking, tool, text — sidechain line skipped.
|
||
assert_eq!(s.entries.len(), 4);
|
||
assert_eq!(s.entries[0].content, "❯ fix the bug");
|
||
assert!(matches!(s.entries[1].kind, Kind::Thinking));
|
||
let tool = &s.entries[2];
|
||
assert!(matches!(&tool.kind, Kind::Tool { name } if name == "Bash"));
|
||
assert_eq!(tool.result.as_ref().unwrap().content, "file_a");
|
||
assert_eq!(s.entries[3].content, "done");
|
||
assert!(s.entries.iter().all(|e| e.done));
|
||
}
|
||
}
|