1use std::hash::{Hash, Hasher};
14
15use serde::{Deserialize, Serialize};
16use whycodes_core::types::{ContentBlock, Message, MessageContent, Role};
17
18use crate::session::Session;
19
20const LABEL_MAX_CHARS: usize = 72;
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct Branch {
26 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub name: Option<String>,
29 pub messages: Vec<Message>,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub struct TreeTarget {
36 path: usize,
38 end: usize,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct TreeRow {
44 pub depth: usize,
46 pub fork_start: bool,
48 pub label: String,
50 pub names: Vec<String>,
52 pub on_active_path: bool,
54 pub active_leaf: bool,
56 pub target: TreeTarget,
57}
58
59impl TreeRow {
60 pub fn line(&self) -> String {
62 let marker = if self.active_leaf {
63 '●'
64 } else if self.on_active_path {
65 '•'
66 } else {
67 '○'
68 };
69 let indent = if self.fork_start {
70 format!("{}↳ ", " ".repeat(self.depth.saturating_sub(1)))
71 } else {
72 " ".repeat(self.depth)
73 };
74 let mut out = format!("{indent}{marker} {}", self.label);
75 if !self.names.is_empty() {
76 out.push_str(&format!(" [{}]", self.names.join(", ")));
77 }
78 out
79 }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum BranchError {
85 Empty,
87 Stale,
89}
90
91impl std::fmt::Display for BranchError {
92 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 f.write_str(match self {
94 Self::Empty => "nothing to branch from yet",
95 Self::Stale => "the conversation changed; open /tree again",
96 })
97 }
98}
99
100impl std::error::Error for BranchError {}
101
102impl Session {
103 pub fn mark_branch(&mut self, name: &str) -> Result<String, BranchError> {
105 if self.messages.is_empty() {
106 return Err(BranchError::Empty);
107 }
108 let name = match name.trim() {
109 "" => format!("branch {}", self.branches.len() + 1),
110 given => given.to_string(),
111 };
112 self.branches.push(Branch {
113 name: Some(name.clone()),
114 messages: self.messages.clone(),
115 });
116 Ok(name)
117 }
118
119 pub fn tree_rows(&self) -> Vec<TreeRow> {
121 let mut paths: Vec<(&[Message], Option<&str>)> = vec![(&self.messages, None)];
122 for b in &self.branches {
123 paths.push((&b.messages, b.name.as_deref()));
124 }
125 let mut trie = Trie::default();
126 for (path, (messages, name)) in paths.into_iter().enumerate() {
127 trie.insert(path, messages, name);
128 }
129 trie.rows()
130 }
131
132 pub fn switch_branch(&mut self, target: TreeTarget) -> Result<bool, BranchError> {
136 let source = match target.path {
137 0 => &self.messages,
138 n => match self.branches.get(n - 1) {
139 Some(b) => &b.messages,
140 None => return Err(BranchError::Stale),
141 },
142 };
143 let Some(next) = source.get(..target.end) else {
144 return Err(BranchError::Stale);
145 };
146 let next = next.to_vec();
147 if same_path(&next, &self.messages) {
148 return Ok(false);
149 }
150 let moves = target.path.checked_sub(1).filter(|&i| {
152 let b = &self.branches[i];
153 b.name.is_none() && b.messages.len() == target.end
154 });
155 if let Some(i) = moves {
156 self.branches.remove(i);
157 }
158 let old = std::mem::take(&mut self.messages);
159 let kept =
160 is_prefix(&old, &next) || self.branches.iter().any(|b| is_prefix(&old, &b.messages));
161 if !kept {
162 self.branches.push(Branch {
163 name: None,
164 messages: old,
165 });
166 }
167 self.checkpoint = None;
169 self.set_messages(next);
170 Ok(true)
171 }
172}
173
174fn fingerprint(m: &Message) -> u64 {
177 let mut h = std::collections::hash_map::DefaultHasher::new();
178 std::mem::discriminant(&m.role).hash(&mut h);
179 m.tool_call_id.hash(&mut h);
180 m.name.hash(&mut h);
181 if m.role != Role::Tool {
182 match &m.content {
183 MessageContent::Text(t) => t.hash(&mut h),
184 MessageContent::Blocks(blocks) => {
185 for b in blocks {
186 hash_block(b, &mut h);
187 }
188 }
189 }
190 }
191 h.finish()
192}
193
194fn hash_block(b: &ContentBlock, h: &mut impl Hasher) {
195 std::mem::discriminant(b).hash(h);
196 match b {
197 ContentBlock::Text { text } | ContentBlock::Thinking { text, .. } => text.hash(h),
198 ContentBlock::ToolUse { id, name, .. } => (id, name).hash(h),
199 ContentBlock::ToolResult { tool_use_id, .. } => tool_use_id.hash(h),
200 ContentBlock::RedactedThinking { data } => data.hash(h),
201 ContentBlock::Image { .. } => {}
202 ContentBlock::Compaction { content, .. } => content.hash(h),
203 }
204}
205
206fn is_prefix(a: &[Message], b: &[Message]) -> bool {
208 a.len() <= b.len()
209 && a.iter()
210 .zip(b)
211 .all(|(x, y)| fingerprint(x) == fingerprint(y))
212}
213
214fn same_path(a: &[Message], b: &[Message]) -> bool {
215 a.len() == b.len() && is_prefix(a, b)
216}
217
218fn turns(messages: &[Message]) -> Vec<(usize, usize)> {
221 let mut starts: Vec<usize> = messages
222 .iter()
223 .enumerate()
224 .filter(|(_, m)| m.role == Role::User)
225 .map(|(i, _)| i)
226 .collect();
227 match starts.first_mut() {
228 Some(first) => *first = 0,
229 None if !messages.is_empty() => starts.push(0),
230 None => {}
231 }
232 let ends = starts.iter().skip(1).copied().chain([messages.len()]);
233 starts.iter().copied().zip(ends).collect()
234}
235
236fn turn_label(turn: &[Message]) -> String {
237 let text = turn
238 .iter()
239 .find(|m| m.role == Role::User)
240 .and_then(|m| m.content.as_text())
241 .and_then(|t| t.lines().map(str::trim).find(|l| !l.is_empty()));
242 match text {
243 Some(line) if line.chars().count() > LABEL_MAX_CHARS => {
244 let cut: String = line.chars().take(LABEL_MAX_CHARS - 1).collect();
245 format!("{cut}…")
246 }
247 Some(line) => line.to_string(),
248 None => "(no text)".to_string(),
249 }
250}
251
252struct Node {
253 key: Vec<u64>,
254 row: TreeRow,
255 children: Vec<usize>,
256}
257
258#[derive(Default)]
259struct Trie {
260 nodes: Vec<Node>,
261 roots: Vec<usize>,
262}
263
264impl Trie {
265 fn insert(&mut self, path: usize, messages: &[Message], name: Option<&str>) {
266 let mut parent: Option<usize> = None;
267 for (start, end) in turns(messages) {
268 let key: Vec<u64> = messages[start..end].iter().map(fingerprint).collect();
269 let siblings = match parent {
270 Some(p) => &self.nodes[p].children,
271 None => &self.roots,
272 };
273 let found = siblings.iter().copied().find(|&n| self.nodes[n].key == key);
274 let id = match found {
275 Some(id) => id,
276 None => self.push(parent, key, &messages[start..end], path, end),
277 };
278 self.nodes[id].row.on_active_path |= path == 0;
279 parent = Some(id);
280 }
281 if let Some(leaf) = parent {
282 let row = &mut self.nodes[leaf].row;
283 row.active_leaf |= path == 0;
284 row.names.extend(name.map(str::to_string));
285 }
286 }
287
288 fn push(
289 &mut self,
290 parent: Option<usize>,
291 key: Vec<u64>,
292 turn: &[Message],
293 path: usize,
294 end: usize,
295 ) -> usize {
296 let id = self.nodes.len();
297 self.nodes.push(Node {
298 key,
299 row: TreeRow {
300 depth: 0,
301 fork_start: false,
302 label: turn_label(turn),
303 names: Vec::new(),
304 on_active_path: false,
305 active_leaf: false,
306 target: TreeTarget { path, end },
307 },
308 children: Vec::new(),
309 });
310 match parent {
311 Some(p) => self.nodes[p].children.push(id),
312 None => self.roots.push(id),
313 }
314 id
315 }
316
317 fn rows(mut self) -> Vec<TreeRow> {
320 let fork = self.roots.len() > 1;
321 let mut stack: Vec<(usize, usize, bool)> = self
322 .roots
323 .iter()
324 .rev()
325 .map(|&r| (r, usize::from(fork), fork))
326 .collect();
327 let mut out = Vec::with_capacity(self.nodes.len());
328 while let Some((id, depth, fork_start)) = stack.pop() {
329 let node = &mut self.nodes[id];
330 let fork = node.children.len() > 1;
331 let child_depth = depth + usize::from(fork);
332 stack.extend(node.children.iter().rev().map(|&c| (c, child_depth, fork)));
333 let mut row = std::mem::replace(&mut node.row, empty_row());
334 row.depth = depth;
335 row.fork_start = fork_start;
336 out.push(row);
337 }
338 out
339 }
340}
341
342fn empty_row() -> TreeRow {
343 TreeRow {
344 depth: 0,
345 fork_start: false,
346 label: String::new(),
347 names: Vec::new(),
348 on_active_path: false,
349 active_leaf: false,
350 target: TreeTarget { path: 0, end: 0 },
351 }
352}
353
354#[cfg(test)]
355#[path = "tree_tests.rs"]
356mod tests;