Skip to main content

whycodes_session/
tree.rs

1//! Branches inside one conversation (`/branch`, `/tree`).
2//!
3//! [`Session::messages`] stays the active path: the transcript shown and sent
4//! to the model. Every other path is a [`Branch`], a full root-to-leaf copy,
5//! so compaction, spill and undo on the active path never reach it. The tree
6//! is rebuilt on demand by merging paths whose turns match.
7//!
8//! Turns match by a fingerprint that leaves out tool-result bodies, because
9//! pruning and spilling shorten those in place on the active path. A path
10//! whose prefix was compacted into a summary no longer matches its siblings
11//! and shows as its own line from the root.
12
13use std::hash::{Hash, Hasher};
14
15use serde::{Deserialize, Serialize};
16use whycodes_core::types::{ContentBlock, Message, MessageContent, Role};
17
18use crate::session::Session;
19
20/// Longest label shown for one turn (first line of its user message).
21const LABEL_MAX_CHARS: usize = 72;
22
23/// A stored path other than the active one.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct Branch {
26    /// Set by `/branch <name>`; `None` for a path kept by a switch.
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub name: Option<String>,
29    /// Every message from the root to this branch's leaf.
30    pub messages: Vec<Message>,
31}
32
33/// A node to switch to: the first `end` messages of one path.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub struct TreeTarget {
36    /// 0 is the active path; `n` is `branches[n - 1]`.
37    path: usize,
38    end: usize,
39}
40
41/// One user turn in the tree, in display order.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct TreeRow {
44    /// Indent level; it grows only where the conversation forks.
45    pub depth: usize,
46    /// This turn is one of several alternatives after the same point.
47    pub fork_start: bool,
48    /// First line of the turn's user message.
49    pub label: String,
50    /// Names of branches marked at this turn.
51    pub names: Vec<String>,
52    /// This turn is on the active path.
53    pub on_active_path: bool,
54    /// This turn is the active path's last turn.
55    pub active_leaf: bool,
56    pub target: TreeTarget,
57}
58
59impl TreeRow {
60    /// One-line rendering shared by the TUI picker and the plain REPL.
61    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/// Why a branch command could not run.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum BranchError {
85    /// `/branch` on a conversation with no messages yet.
86    Empty,
87    /// The tree changed since the picker was built.
88    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    /// Mark the current point under `name` (or `branch N`). Returns the name.
104    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    /// The conversation as a tree of user turns, active path first.
120    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    /// Make `target` the active leaf. The old active path is kept as a
133    /// branch unless another path already holds all of it. Returns `false`
134    /// when `target` already is the active path.
135    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        // An unnamed branch reached at its leaf moves into the active slot.
151        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        // Checkpoint indexes point into the old path.
168        self.checkpoint = None;
169        self.set_messages(next);
170        Ok(true)
171    }
172}
173
174/// Stable identity of a message for matching turns across paths. Tool
175/// bodies are left out: prune / shake / spill rewrite them in place.
176fn 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
206/// `a` is `b` or a leading part of it.
207fn 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
218/// `(start, end)` of each user turn. Messages before the first user message
219/// belong to the first turn.
220fn 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    /// Depth-first rows. A lone child stays at its parent's depth; only a
318    /// fork indents, so a long linear conversation stays flat.
319    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;