use std::hash::{Hash, Hasher};
use serde::{Deserialize, Serialize};
use whycodes_core::types::{ContentBlock, Message, MessageContent, Role};
use crate::session::Session;
const LABEL_MAX_CHARS: usize = 72;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Branch {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub messages: Vec<Message>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TreeTarget {
path: usize,
end: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreeRow {
pub depth: usize,
pub fork_start: bool,
pub label: String,
pub names: Vec<String>,
pub on_active_path: bool,
pub active_leaf: bool,
pub target: TreeTarget,
}
impl TreeRow {
pub fn line(&self) -> String {
let marker = if self.active_leaf {
'●'
} else if self.on_active_path {
'•'
} else {
'○'
};
let indent = if self.fork_start {
format!("{}↳ ", " ".repeat(self.depth.saturating_sub(1)))
} else {
" ".repeat(self.depth)
};
let mut out = format!("{indent}{marker} {}", self.label);
if !self.names.is_empty() {
out.push_str(&format!(" [{}]", self.names.join(", ")));
}
out
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BranchError {
Empty,
Stale,
}
impl std::fmt::Display for BranchError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Empty => "nothing to branch from yet",
Self::Stale => "the conversation changed; open /tree again",
})
}
}
impl std::error::Error for BranchError {}
impl Session {
pub fn mark_branch(&mut self, name: &str) -> Result<String, BranchError> {
if self.messages.is_empty() {
return Err(BranchError::Empty);
}
let name = match name.trim() {
"" => format!("branch {}", self.branches.len() + 1),
given => given.to_string(),
};
self.branches.push(Branch {
name: Some(name.clone()),
messages: self.messages.clone(),
});
Ok(name)
}
pub fn tree_rows(&self) -> Vec<TreeRow> {
let mut paths: Vec<(&[Message], Option<&str>)> = vec![(&self.messages, None)];
for b in &self.branches {
paths.push((&b.messages, b.name.as_deref()));
}
let mut trie = Trie::default();
for (path, (messages, name)) in paths.into_iter().enumerate() {
trie.insert(path, messages, name);
}
trie.rows()
}
pub fn switch_branch(&mut self, target: TreeTarget) -> Result<bool, BranchError> {
let source = match target.path {
0 => &self.messages,
n => match self.branches.get(n - 1) {
Some(b) => &b.messages,
None => return Err(BranchError::Stale),
},
};
let Some(next) = source.get(..target.end) else {
return Err(BranchError::Stale);
};
let next = next.to_vec();
if same_path(&next, &self.messages) {
return Ok(false);
}
let moves = target.path.checked_sub(1).filter(|&i| {
let b = &self.branches[i];
b.name.is_none() && b.messages.len() == target.end
});
if let Some(i) = moves {
self.branches.remove(i);
}
let old = std::mem::take(&mut self.messages);
let kept =
is_prefix(&old, &next) || self.branches.iter().any(|b| is_prefix(&old, &b.messages));
if !kept {
self.branches.push(Branch {
name: None,
messages: old,
});
}
self.checkpoint = None;
self.set_messages(next);
Ok(true)
}
}
fn fingerprint(m: &Message) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
std::mem::discriminant(&m.role).hash(&mut h);
m.tool_call_id.hash(&mut h);
m.name.hash(&mut h);
if m.role != Role::Tool {
match &m.content {
MessageContent::Text(t) => t.hash(&mut h),
MessageContent::Blocks(blocks) => {
for b in blocks {
hash_block(b, &mut h);
}
}
}
}
h.finish()
}
fn hash_block(b: &ContentBlock, h: &mut impl Hasher) {
std::mem::discriminant(b).hash(h);
match b {
ContentBlock::Text { text } | ContentBlock::Thinking { text, .. } => text.hash(h),
ContentBlock::ToolUse { id, name, .. } => (id, name).hash(h),
ContentBlock::ToolResult { tool_use_id, .. } => tool_use_id.hash(h),
ContentBlock::RedactedThinking { data } => data.hash(h),
ContentBlock::Image { .. } => {}
ContentBlock::Compaction { content, .. } => content.hash(h),
}
}
fn is_prefix(a: &[Message], b: &[Message]) -> bool {
a.len() <= b.len()
&& a.iter()
.zip(b)
.all(|(x, y)| fingerprint(x) == fingerprint(y))
}
fn same_path(a: &[Message], b: &[Message]) -> bool {
a.len() == b.len() && is_prefix(a, b)
}
fn turns(messages: &[Message]) -> Vec<(usize, usize)> {
let mut starts: Vec<usize> = messages
.iter()
.enumerate()
.filter(|(_, m)| m.role == Role::User)
.map(|(i, _)| i)
.collect();
match starts.first_mut() {
Some(first) => *first = 0,
None if !messages.is_empty() => starts.push(0),
None => {}
}
let ends = starts.iter().skip(1).copied().chain([messages.len()]);
starts.iter().copied().zip(ends).collect()
}
fn turn_label(turn: &[Message]) -> String {
let text = turn
.iter()
.find(|m| m.role == Role::User)
.and_then(|m| m.content.as_text())
.and_then(|t| t.lines().map(str::trim).find(|l| !l.is_empty()));
match text {
Some(line) if line.chars().count() > LABEL_MAX_CHARS => {
let cut: String = line.chars().take(LABEL_MAX_CHARS - 1).collect();
format!("{cut}…")
}
Some(line) => line.to_string(),
None => "(no text)".to_string(),
}
}
struct Node {
key: Vec<u64>,
row: TreeRow,
children: Vec<usize>,
}
#[derive(Default)]
struct Trie {
nodes: Vec<Node>,
roots: Vec<usize>,
}
impl Trie {
fn insert(&mut self, path: usize, messages: &[Message], name: Option<&str>) {
let mut parent: Option<usize> = None;
for (start, end) in turns(messages) {
let key: Vec<u64> = messages[start..end].iter().map(fingerprint).collect();
let siblings = match parent {
Some(p) => &self.nodes[p].children,
None => &self.roots,
};
let found = siblings.iter().copied().find(|&n| self.nodes[n].key == key);
let id = match found {
Some(id) => id,
None => self.push(parent, key, &messages[start..end], path, end),
};
self.nodes[id].row.on_active_path |= path == 0;
parent = Some(id);
}
if let Some(leaf) = parent {
let row = &mut self.nodes[leaf].row;
row.active_leaf |= path == 0;
row.names.extend(name.map(str::to_string));
}
}
fn push(
&mut self,
parent: Option<usize>,
key: Vec<u64>,
turn: &[Message],
path: usize,
end: usize,
) -> usize {
let id = self.nodes.len();
self.nodes.push(Node {
key,
row: TreeRow {
depth: 0,
fork_start: false,
label: turn_label(turn),
names: Vec::new(),
on_active_path: false,
active_leaf: false,
target: TreeTarget { path, end },
},
children: Vec::new(),
});
match parent {
Some(p) => self.nodes[p].children.push(id),
None => self.roots.push(id),
}
id
}
fn rows(mut self) -> Vec<TreeRow> {
let fork = self.roots.len() > 1;
let mut stack: Vec<(usize, usize, bool)> = self
.roots
.iter()
.rev()
.map(|&r| (r, usize::from(fork), fork))
.collect();
let mut out = Vec::with_capacity(self.nodes.len());
while let Some((id, depth, fork_start)) = stack.pop() {
let node = &mut self.nodes[id];
let fork = node.children.len() > 1;
let child_depth = depth + usize::from(fork);
stack.extend(node.children.iter().rev().map(|&c| (c, child_depth, fork)));
let mut row = std::mem::replace(&mut node.row, empty_row());
row.depth = depth;
row.fork_start = fork_start;
out.push(row);
}
out
}
}
fn empty_row() -> TreeRow {
TreeRow {
depth: 0,
fork_start: false,
label: String::new(),
names: Vec::new(),
on_active_path: false,
active_leaf: false,
target: TreeTarget { path: 0, end: 0 },
}
}
#[cfg(test)]
#[path = "tree_tests.rs"]
mod tests;