Skip to main content

imp_tui/views/
tree.rs

1use imp_llm::truncate_chars_with_suffix;
2use ratatui::buffer::Buffer;
3use ratatui::layout::{Constraint, Direction, Layout, Rect};
4use ratatui::style::{Modifier, Style};
5use ratatui::text::{Line, Span};
6use ratatui::widgets::{Block, Borders, Clear, Widget};
7
8use crate::theme::Theme;
9
10/// A flattened tree node for display.
11#[derive(Debug, Clone)]
12pub struct FlatTreeNode {
13    pub id: String,
14    pub parent_id: Option<String>,
15    pub depth: usize,
16    pub guides: Vec<bool>,
17    pub summary: String,
18    pub full_text: String,
19    pub kind_label: &'static str,
20    pub is_user: bool,
21    pub is_tool: bool,
22    pub is_compaction: bool,
23    pub has_children: bool,
24    pub child_count: usize,
25    pub is_last_child: bool,
26}
27
28/// Filter mode for the tree view.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum TreeFilterMode {
31    All,
32    CurrentBranch,
33    BranchPoints,
34    NoTools,
35    UserOnly,
36}
37
38impl TreeFilterMode {
39    pub fn next(self) -> Self {
40        match self {
41            Self::All => Self::CurrentBranch,
42            Self::CurrentBranch => Self::BranchPoints,
43            Self::BranchPoints => Self::NoTools,
44            Self::NoTools => Self::UserOnly,
45            Self::UserOnly => Self::All,
46        }
47    }
48
49    pub fn label(self) -> &'static str {
50        match self {
51            Self::All => "all",
52            Self::CurrentBranch => "current-branch",
53            Self::BranchPoints => "branch-points",
54            Self::NoTools => "no-tools",
55            Self::UserOnly => "user-only",
56        }
57    }
58}
59
60/// State for the tree view overlay.
61#[derive(Debug, Clone)]
62pub struct TreeViewState {
63    pub nodes: Vec<FlatTreeNode>,
64    pub filter_mode: TreeFilterMode,
65    pub current_id: Option<String>,
66    selected_id: Option<String>,
67}
68
69impl TreeViewState {
70    pub fn new(nodes: Vec<FlatTreeNode>, current_id: Option<String>) -> Self {
71        let selected_id = current_id
72            .as_deref()
73            .and_then(|id| nodes.iter().find(|n| n.id == id))
74            .map(|n| n.id.clone())
75            .or_else(|| nodes.first().map(|n| n.id.clone()));
76
77        Self {
78            nodes,
79            filter_mode: TreeFilterMode::All,
80            current_id,
81            selected_id,
82        }
83    }
84
85    pub fn move_up(&mut self) {
86        let filtered = self.filtered_nodes();
87        if filtered.is_empty() {
88            self.selected_id = None;
89            return;
90        }
91
92        let idx = self.selected_filtered_index_in(&filtered).unwrap_or(0);
93        let next = idx.saturating_sub(1);
94        self.selected_id = Some(filtered[next].id.clone());
95    }
96
97    pub fn move_down(&mut self) {
98        let filtered = self.filtered_nodes();
99        if filtered.is_empty() {
100            self.selected_id = None;
101            return;
102        }
103
104        let idx = self.selected_filtered_index_in(&filtered).unwrap_or(0);
105        let next = (idx + 1).min(filtered.len().saturating_sub(1));
106        self.selected_id = Some(filtered[next].id.clone());
107    }
108
109    pub fn selected_id(&self) -> Option<&str> {
110        self.selected_id.as_deref()
111    }
112
113    pub fn selected_node(&self) -> Option<&FlatTreeNode> {
114        let id = self.selected_id.as_deref()?;
115        self.nodes.iter().find(|node| node.id == id)
116    }
117
118    pub fn cycle_filter(&mut self) {
119        self.filter_mode = self.filter_mode.next();
120        self.reanchor_selection();
121    }
122
123    pub fn filtered_nodes(&self) -> Vec<&FlatTreeNode> {
124        let current_branch_ids = self.current_branch_ids();
125        self.nodes
126            .iter()
127            .filter(|n| match self.filter_mode {
128                TreeFilterMode::All => true,
129                TreeFilterMode::CurrentBranch => current_branch_ids.contains(n.id.as_str()),
130                TreeFilterMode::BranchPoints => n.child_count > 1,
131                TreeFilterMode::NoTools => !n.is_tool,
132                TreeFilterMode::UserOnly => n.is_user,
133            })
134            .collect()
135    }
136
137    pub fn selected_filtered_index(&self) -> Option<usize> {
138        let filtered = self.filtered_nodes();
139        self.selected_filtered_index_in(&filtered)
140    }
141
142    fn reanchor_selection(&mut self) {
143        let filtered = self.filtered_nodes();
144        if filtered.is_empty() {
145            self.selected_id = None;
146            return;
147        }
148
149        if self.selected_filtered_index_in(&filtered).is_some() {
150            return;
151        }
152
153        if let Some(current_id) = self.current_id.as_deref() {
154            if filtered.iter().any(|node| node.id == current_id) {
155                self.selected_id = Some(current_id.to_string());
156                return;
157            }
158        }
159
160        self.selected_id = filtered.first().map(|node| node.id.clone());
161    }
162
163    fn selected_filtered_index_in(&self, filtered: &[&FlatTreeNode]) -> Option<usize> {
164        let selected_id = self.selected_id.as_deref()?;
165        filtered.iter().position(|node| node.id == selected_id)
166    }
167
168    fn current_branch_ids(&self) -> std::collections::HashSet<&str> {
169        let mut ids = std::collections::HashSet::new();
170        let mut current = self.current_id.as_deref();
171
172        while let Some(id) = current {
173            if !ids.insert(id) {
174                break;
175            }
176            current = self
177                .nodes
178                .iter()
179                .find(|node| node.id == id)
180                .and_then(|node| node.parent_id.as_deref());
181        }
182
183        ids
184    }
185}
186
187/// Flatten a session tree into displayable nodes.
188pub fn flatten_tree(tree: &[imp_core::session::TreeNode], depth: usize) -> Vec<FlatTreeNode> {
189    let mut result = Vec::new();
190    flatten_tree_into(tree, depth, Vec::new(), &mut result);
191    result
192}
193
194fn flatten_tree_into(
195    tree: &[imp_core::session::TreeNode],
196    depth: usize,
197    guides: Vec<bool>,
198    out: &mut Vec<FlatTreeNode>,
199) {
200    let len = tree.len();
201
202    for (i, node) in tree.iter().enumerate() {
203        let has_more_siblings = i + 1 < len;
204
205        match &node.entry {
206            imp_core::session::SessionEntry::Message {
207                id,
208                parent_id,
209                message,
210            } => {
211                let text = extract_text(message);
212                let full_text = fallback_message_text(message, text);
213                let truncated = truncate_chars_with_suffix(&full_text, 57, "…");
214                let is_user = message.is_user();
215                let is_tool = message.is_tool_result();
216                out.push(FlatTreeNode {
217                    id: id.clone(),
218                    parent_id: parent_id.clone(),
219                    depth,
220                    guides: guides.clone(),
221                    summary: truncated,
222                    full_text,
223                    kind_label: if is_user {
224                        "user"
225                    } else if is_tool {
226                        "tool"
227                    } else {
228                        "assistant"
229                    },
230                    is_user,
231                    is_tool,
232                    is_compaction: false,
233                    has_children: !node.children.is_empty(),
234                    child_count: node.children.len(),
235                    is_last_child: !has_more_siblings,
236                });
237            }
238            imp_core::session::SessionEntry::Compaction {
239                id,
240                parent_id,
241                summary,
242                ..
243            } => {
244                let full_text = summary.trim().to_string();
245                out.push(FlatTreeNode {
246                    id: id.clone(),
247                    parent_id: parent_id.clone(),
248                    depth,
249                    guides: guides.clone(),
250                    summary: format!("[compaction: {}]", truncate(summary, 40)),
251                    full_text: if full_text.is_empty() {
252                        "(compaction summary)".to_string()
253                    } else {
254                        full_text
255                    },
256                    kind_label: "compaction",
257                    is_user: false,
258                    is_tool: false,
259                    is_compaction: true,
260                    has_children: !node.children.is_empty(),
261                    child_count: node.children.len(),
262                    is_last_child: !has_more_siblings,
263                });
264            }
265            _ => {}
266        }
267
268        if !node.children.is_empty() {
269            let mut child_guides = guides.clone();
270            child_guides.push(has_more_siblings);
271            flatten_tree_into(&node.children, depth + 1, child_guides, out);
272        }
273    }
274}
275
276/// Session tree navigator overlay.
277pub struct TreeView<'a> {
278    state: &'a TreeViewState,
279    theme: &'a Theme,
280}
281
282impl<'a> TreeView<'a> {
283    pub fn new(state: &'a TreeViewState, theme: &'a Theme) -> Self {
284        Self { state, theme }
285    }
286}
287
288impl Widget for TreeView<'_> {
289    fn render(self, area: Rect, buf: &mut Buffer) {
290        if area.height < 3 || area.width < 10 {
291            return;
292        }
293
294        Clear.render(area, buf);
295        let block = Block::default()
296            .title(format!(
297                " Session Tree [{}] ",
298                self.state.filter_mode.label()
299            ))
300            .borders(Borders::ALL)
301            .border_style(self.theme.border_style());
302        let inner = block.inner(area);
303        block.render(area, buf);
304
305        let filtered = self.state.filtered_nodes();
306        if filtered.is_empty() {
307            let line = Line::from(Span::styled(
308                "  No matching nodes",
309                self.theme.muted_style(),
310            ));
311            buf.set_line(inner.x, inner.y, &line, inner.width);
312            return;
313        }
314
315        let has_preview = inner.width >= 96 && inner.height >= 10;
316        let columns = if has_preview {
317            Layout::default()
318                .direction(Direction::Horizontal)
319                .constraints([Constraint::Percentage(54), Constraint::Percentage(46)])
320                .split(inner)
321        } else {
322            Layout::default()
323                .direction(Direction::Horizontal)
324                .constraints([Constraint::Percentage(100), Constraint::Percentage(0)])
325                .split(inner)
326        };
327
328        render_tree_list(columns[0], self.state, &filtered, buf, self.theme);
329        if has_preview {
330            render_tree_preview(columns[1], self.state.selected_node(), buf, self.theme);
331        }
332    }
333}
334
335fn render_tree_list(
336    area: Rect,
337    state: &TreeViewState,
338    filtered: &[&FlatTreeNode],
339    buf: &mut Buffer,
340    theme: &Theme,
341) {
342    if area.height == 0 || area.width == 0 {
343        return;
344    }
345
346    let selected_idx = state.selected_filtered_index().unwrap_or(0);
347    let visible_height = area.height as usize;
348    let scroll = if selected_idx >= visible_height {
349        selected_idx - visible_height + 1
350    } else {
351        0
352    };
353
354    for (row, node) in filtered
355        .iter()
356        .skip(scroll)
357        .take(visible_height)
358        .enumerate()
359    {
360        let y = area.y + row as u16;
361        let is_selected = scroll + row == selected_idx;
362        let is_current = state.current_id.as_deref() == Some(&node.id);
363
364        let marker = if is_current { "● " } else { "  " };
365        let branch = if node.depth == 0 {
366            String::new()
367        } else if node.is_last_child {
368            "└─ ".to_string()
369        } else {
370            "├─ ".to_string()
371        };
372        let guides = render_guides(&node.guides);
373        let kind = format!("[{}] ", kind_tag(node));
374        let suffix = branch_suffix(node);
375
376        let prefix_len = marker.chars().count()
377            + guides.chars().count()
378            + branch.chars().count()
379            + kind.chars().count();
380        let suffix_len = suffix.chars().count();
381        let summary_width = area.width.saturating_sub((prefix_len + suffix_len) as u16) as usize;
382        let summary = truncate_chars_with_suffix(&node.summary, summary_width.max(8), "…");
383
384        let style = if is_selected {
385            theme.selected_style()
386        } else if is_current {
387            theme.accent_style()
388        } else if node.is_tool {
389            theme.muted_style()
390        } else if node.is_compaction {
391            theme.accent_style()
392        } else {
393            Style::default()
394        };
395
396        let line = Line::from(vec![
397            Span::styled(marker.to_string(), theme.accent_style()),
398            Span::styled(guides, theme.muted_style()),
399            Span::styled(branch, theme.muted_style()),
400            Span::styled(kind, Style::default().add_modifier(Modifier::DIM)),
401            Span::styled(summary, style),
402            Span::styled(suffix, theme.muted_style()),
403        ]);
404
405        buf.set_line(area.x, y, &line, area.width);
406    }
407
408    if scroll > 0 {
409        let indicator = Line::from(Span::styled("▲", theme.muted_style()));
410        buf.set_line(area.x + area.width.saturating_sub(1), area.y, &indicator, 1);
411    }
412    if scroll + visible_height < filtered.len() {
413        let indicator = Line::from(Span::styled("▼", theme.muted_style()));
414        buf.set_line(
415            area.x + area.width.saturating_sub(1),
416            area.y + area.height.saturating_sub(1),
417            &indicator,
418            1,
419        );
420    }
421}
422
423fn render_tree_preview(area: Rect, node: Option<&FlatTreeNode>, buf: &mut Buffer, theme: &Theme) {
424    let block = Block::default()
425        .title(" Preview ")
426        .borders(Borders::LEFT)
427        .border_style(theme.muted_style());
428    let inner = block.inner(area);
429    block.render(area, buf);
430
431    let Some(node) = node else {
432        return;
433    };
434
435    let lines = [
436        format!("Kind: {}", node.kind_label),
437        format!("ID: {}", node.id),
438        format!("Parent: {}", node.parent_id.as_deref().unwrap_or("(root)")),
439        format!("Depth: {}", node.depth),
440        format!("Children: {}", node.child_count),
441        format!(
442            "Branching: {}",
443            if node.child_count > 1 {
444                "branch point"
445            } else if node.has_children {
446                "continues"
447            } else {
448                "leaf"
449            }
450        ),
451        String::new(),
452        "Content:".to_string(),
453        node.full_text.clone(),
454        String::new(),
455        "Enter checks out here • f forks here • Ctrl+O cycles all/current/branch/no-tools/user"
456            .to_string(),
457    ];
458
459    let wrapped = wrap_lines(&lines, inner.width as usize, inner.height as usize);
460    for (i, line) in wrapped.iter().enumerate() {
461        if i >= inner.height as usize {
462            break;
463        }
464        let style = if line.is_empty() {
465            theme.muted_style()
466        } else if line == "Content:" {
467            theme.accent_style()
468        } else {
469            theme.muted_style()
470        };
471        let rendered = Line::from(Span::styled(line.clone(), style));
472        buf.set_line(inner.x, inner.y + i as u16, &rendered, inner.width);
473    }
474}
475
476fn branch_suffix(node: &FlatTreeNode) -> String {
477    if node.child_count > 1 {
478        format!("  +{}", node.child_count)
479    } else if node.has_children {
480        "  ↳".to_string()
481    } else {
482        String::new()
483    }
484}
485
486fn kind_tag(node: &FlatTreeNode) -> &'static str {
487    if node.is_compaction {
488        "C"
489    } else if node.is_user {
490        "U"
491    } else if node.is_tool {
492        "T"
493    } else {
494        "A"
495    }
496}
497
498fn render_guides(guides: &[bool]) -> String {
499    let mut out = String::new();
500    for guide in guides {
501        out.push_str(if *guide { "│  " } else { "   " });
502    }
503    out
504}
505
506fn fallback_message_text(msg: &imp_llm::Message, text: String) -> String {
507    let trimmed = text.trim();
508    if !trimmed.is_empty() {
509        return trimmed.to_string();
510    }
511
512    match msg {
513        imp_llm::Message::User(_) => "(user message)".to_string(),
514        imp_llm::Message::Assistant(_) => "(assistant message)".to_string(),
515        imp_llm::Message::ToolResult(_) => "(tool result)".to_string(),
516    }
517}
518
519fn extract_text(msg: &imp_llm::Message) -> String {
520    let blocks = match msg {
521        imp_llm::Message::User(u) => &u.content,
522        imp_llm::Message::Assistant(a) => &a.content,
523        imp_llm::Message::ToolResult(t) => &t.content,
524    };
525    blocks
526        .iter()
527        .find_map(|b| match b {
528            imp_llm::ContentBlock::Text { text } => Some(text.clone()),
529            _ => None,
530        })
531        .unwrap_or_default()
532}
533
534fn truncate(s: &str, max: usize) -> String {
535    truncate_chars_with_suffix(s, max.saturating_sub(1), "…")
536}
537
538fn wrap_lines(lines: &[String], width: usize, max_lines: usize) -> Vec<String> {
539    if width == 0 || max_lines == 0 {
540        return Vec::new();
541    }
542
543    let mut out = Vec::new();
544    for line in lines {
545        if line.is_empty() {
546            out.push(String::new());
547            if out.len() >= max_lines {
548                break;
549            }
550            continue;
551        }
552
553        let mut current = String::new();
554        for word in line.split_whitespace() {
555            let candidate = if current.is_empty() {
556                word.to_string()
557            } else {
558                format!("{current} {word}")
559            };
560
561            if candidate.chars().count() <= width {
562                current = candidate;
563            } else {
564                if !current.is_empty() {
565                    out.push(current);
566                    if out.len() >= max_lines {
567                        return out;
568                    }
569                }
570                current = truncate_chars_with_suffix(word, width, "…");
571            }
572        }
573
574        if !current.is_empty() {
575            out.push(current);
576            if out.len() >= max_lines {
577                break;
578            }
579        }
580    }
581
582    out.truncate(max_lines);
583    out
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589
590    fn node(id: &str, kind: &'static str) -> FlatTreeNode {
591        FlatTreeNode {
592            id: id.to_string(),
593            parent_id: None,
594            depth: 0,
595            guides: Vec::new(),
596            summary: id.to_string(),
597            full_text: id.to_string(),
598            kind_label: kind,
599            is_user: kind == "user",
600            is_tool: kind == "tool",
601            is_compaction: kind == "compaction",
602            has_children: false,
603            child_count: 0,
604            is_last_child: true,
605        }
606    }
607
608    #[test]
609    fn current_branch_filter_keeps_only_current_ancestry() {
610        let mut nodes = vec![
611            node("root", "user"),
612            node("left", "assistant"),
613            node("right", "assistant"),
614        ];
615        nodes[1].parent_id = Some("root".into());
616        nodes[2].parent_id = Some("root".into());
617
618        let mut state = TreeViewState::new(nodes, Some("left".into()));
619        state.cycle_filter(); // current-branch
620
621        let ids: Vec<&str> = state
622            .filtered_nodes()
623            .into_iter()
624            .map(|n| n.id.as_str())
625            .collect();
626        assert_eq!(ids, vec!["root", "left"]);
627    }
628
629    #[test]
630    fn branch_points_filter_only_shows_multi_child_nodes() {
631        let mut nodes = vec![
632            node("root", "user"),
633            node("mid", "assistant"),
634            node("leaf", "assistant"),
635        ];
636        nodes[0].has_children = true;
637        nodes[0].child_count = 2;
638        nodes[1].has_children = true;
639        nodes[1].child_count = 1;
640
641        let mut state = TreeViewState::new(nodes, Some("leaf".into()));
642        state.cycle_filter(); // current-branch
643        state.cycle_filter(); // branch-points
644
645        let ids: Vec<&str> = state
646            .filtered_nodes()
647            .into_iter()
648            .map(|n| n.id.as_str())
649            .collect();
650        assert_eq!(ids, vec!["root"]);
651    }
652
653    #[test]
654    fn selection_is_preserved_when_filter_keeps_same_node_visible() {
655        let nodes = vec![
656            node("u1", "user"),
657            node("a1", "assistant"),
658            node("t1", "tool"),
659        ];
660        let mut state = TreeViewState::new(nodes, Some("a1".into()));
661
662        state.cycle_filter(); // current-branch
663        state.cycle_filter(); // branch-points
664        state.cycle_filter(); // no-tools
665
666        assert_eq!(state.selected_id(), Some("a1"));
667    }
668
669    #[test]
670    fn filter_fallback_prefers_current_id_when_selected_node_disappears() {
671        let nodes = vec![
672            node("u1", "user"),
673            node("a1", "assistant"),
674            node("t1", "tool"),
675        ];
676        let mut state = TreeViewState::new(nodes, Some("u1".into()));
677        state.move_down();
678        state.move_down();
679        assert_eq!(state.selected_id(), Some("t1"));
680
681        state.cycle_filter(); // current-branch
682        state.cycle_filter(); // branch-points
683        state.cycle_filter(); // no-tools
684
685        assert_eq!(state.selected_id(), Some("u1"));
686    }
687
688    #[test]
689    fn render_guides_draws_vertical_connectors_for_open_ancestors() {
690        assert_eq!(render_guides(&[true, false, true]), "│     │  ");
691    }
692}