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#[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#[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#[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
187pub 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
276pub 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(); 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(); state.cycle_filter(); 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(); state.cycle_filter(); state.cycle_filter(); 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(); state.cycle_filter(); state.cycle_filter(); 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}