Files
claude-cloak/src/sessions.rs
Jonas H 37cf183377 Add session history browser, turn-tree branching, model picker, mouse capture, and feed cache
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>
2026-06-12 11:27:34 +02:00

826 lines
33 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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));
}
}