agcodex_tui/features/
history_browser.rs

1//! History Browser widget for visual timeline navigation of conversation
2//!
3//! This widget provides a tree-like visualization of the conversation history,
4//! showing branches and allowing navigation through different conversation paths.
5
6use std::collections::HashMap;
7use std::time::SystemTime;
8
9use agcodex_core::models::ResponseItem;
10use ratatui::buffer::Buffer;
11use ratatui::layout::Alignment;
12use ratatui::layout::Constraint;
13use ratatui::layout::Direction;
14use ratatui::layout::Layout;
15use ratatui::layout::Rect;
16use ratatui::style::Color;
17use ratatui::style::Modifier;
18use ratatui::style::Style;
19use ratatui::symbols;
20use ratatui::text::Line;
21use ratatui::text::Span;
22use ratatui::text::Text;
23use ratatui::widgets::Block;
24use ratatui::widgets::BorderType;
25use ratatui::widgets::Borders;
26use ratatui::widgets::Clear;
27use ratatui::widgets::Paragraph;
28use ratatui::widgets::Widget;
29use ratatui::widgets::WidgetRef;
30use uuid::Uuid;
31
32/// Represents a node in the conversation tree
33#[derive(Debug, Clone)]
34pub struct HistoryNode {
35    /// Unique identifier for this node
36    pub id: Uuid,
37    /// Index in the linear conversation
38    pub index: usize,
39    /// The actual response item
40    pub item: ResponseItem,
41    /// Parent node ID (None for root)
42    pub parent: Option<Uuid>,
43    /// Children node IDs (for branches)
44    pub children: Vec<Uuid>,
45    /// Timestamp of creation
46    pub timestamp: SystemTime,
47    /// Branch name if this is a branch point
48    pub branch_name: Option<String>,
49    /// Whether this node is on the active path
50    pub is_active: bool,
51}
52
53impl HistoryNode {
54    /// Create a new history node
55    pub fn new(index: usize, item: ResponseItem, parent: Option<Uuid>) -> Self {
56        Self {
57            id: Uuid::new_v4(),
58            index,
59            item,
60            parent,
61            children: Vec::new(),
62            timestamp: SystemTime::now(),
63            branch_name: None,
64            is_active: true,
65        }
66    }
67
68    /// Get a preview of the node content
69    pub fn preview(&self) -> String {
70        match &self.item {
71            ResponseItem::Message { role, content, .. } => {
72                let text = content
73                    .iter()
74                    .filter_map(|c| match c {
75                        agcodex_core::models::ContentItem::InputText { text }
76                        | agcodex_core::models::ContentItem::OutputText { text } => {
77                            Some(text.as_str())
78                        }
79                        agcodex_core::models::ContentItem::InputImage { .. } => Some("[Image]"),
80                    })
81                    .collect::<Vec<_>>()
82                    .join(" ");
83
84                let preview = if text.len() > 80 {
85                    format!("{}...", &text[..77])
86                } else {
87                    text
88                };
89
90                format!("[{}] {}", role, preview)
91            }
92            ResponseItem::Reasoning { .. } => "[Reasoning]".to_string(),
93            ResponseItem::FunctionCall { name, .. } => format!("[Function: {}]", name),
94            ResponseItem::LocalShellCall { action, .. } => format!("[Shell: {:?}]", action),
95            ResponseItem::FunctionCallOutput { .. } => "[Function Output]".to_string(),
96            ResponseItem::Other => "[Other]".to_string(),
97        }
98    }
99
100    /// Get the role for coloring
101    pub const fn role(&self) -> &str {
102        match &self.item {
103            ResponseItem::Message { role, .. } => role.as_str(),
104            ResponseItem::Reasoning { .. } => "reasoning",
105            ResponseItem::FunctionCall { .. } => "function",
106            ResponseItem::LocalShellCall { .. } => "shell",
107            ResponseItem::FunctionCallOutput { .. } => "output",
108            ResponseItem::Other => "other",
109        }
110    }
111}
112
113/// Conversation tree structure for history browsing
114#[derive(Debug, Clone)]
115pub struct ConversationTree {
116    /// All nodes indexed by ID
117    nodes: HashMap<Uuid, HistoryNode>,
118    /// Root node ID
119    root: Option<Uuid>,
120    /// Currently active path (node IDs from root to current)
121    active_path: Vec<Uuid>,
122    /// Currently selected node for preview
123    selected_node: Option<Uuid>,
124}
125
126impl ConversationTree {
127    /// Create a new conversation tree
128    pub fn new() -> Self {
129        Self {
130            nodes: HashMap::new(),
131            root: None,
132            active_path: Vec::new(),
133            selected_node: None,
134        }
135    }
136
137    /// Add a node to the tree
138    pub fn add_node(&mut self, node: HistoryNode) -> Uuid {
139        let id = node.id;
140
141        // Update parent's children list
142        if let Some(parent_id) = node.parent {
143            if let Some(parent) = self.nodes.get_mut(&parent_id) {
144                parent.children.push(id);
145            }
146        } else {
147            // This is a root node
148            self.root = Some(id);
149        }
150
151        // Mark as active and add to active path
152        if node.is_active {
153            self.active_path.push(id);
154        }
155
156        self.nodes.insert(id, node);
157        id
158    }
159
160    /// Create a branch from a specific node
161    pub fn create_branch(
162        &mut self,
163        from_node: Uuid,
164        branch_name: String,
165        item: ResponseItem,
166    ) -> Option<Uuid> {
167        let parent_node = self.nodes.get(&from_node)?;
168        let new_index = parent_node.index + 1;
169
170        let mut new_node = HistoryNode::new(new_index, item, Some(from_node));
171        new_node.branch_name = Some(branch_name);
172        new_node.is_active = false; // New branches start inactive
173
174        Some(self.add_node(new_node))
175    }
176
177    /// Switch to a different branch
178    pub fn switch_to_branch(&mut self, target_node: Uuid) -> bool {
179        if !self.nodes.contains_key(&target_node) {
180            return false;
181        }
182
183        // Mark old path as inactive
184        for id in &self.active_path {
185            if let Some(node) = self.nodes.get_mut(id) {
186                node.is_active = false;
187            }
188        }
189
190        // Build new active path from root to target
191        let mut new_path = Vec::new();
192        let mut current = Some(target_node);
193
194        while let Some(node_id) = current {
195            new_path.push(node_id);
196            current = self.nodes.get(&node_id).and_then(|n| n.parent);
197        }
198
199        new_path.reverse();
200
201        // Mark new path as active
202        for id in &new_path {
203            if let Some(node) = self.nodes.get_mut(id) {
204                node.is_active = true;
205            }
206        }
207
208        self.active_path = new_path;
209        true
210    }
211
212    /// Get nodes for rendering (in tree order)
213    pub fn get_render_nodes(&self) -> Vec<(usize, Uuid, bool)> {
214        let mut result = Vec::new();
215        if let Some(root_id) = self.root {
216            self.collect_nodes_recursive(root_id, 0, &mut result);
217        }
218        result
219    }
220
221    /// Recursively collect nodes for rendering
222    fn collect_nodes_recursive(
223        &self,
224        node_id: Uuid,
225        depth: usize,
226        result: &mut Vec<(usize, Uuid, bool)>,
227    ) {
228        if let Some(node) = self.nodes.get(&node_id) {
229            let is_selected = self.selected_node == Some(node_id);
230            result.push((depth, node_id, is_selected));
231
232            // Add children
233            for child_id in &node.children {
234                self.collect_nodes_recursive(*child_id, depth + 1, result);
235            }
236        }
237    }
238}
239
240impl Default for ConversationTree {
241    fn default() -> Self {
242        Self::new()
243    }
244}
245
246/// History Browser widget for visual timeline navigation
247pub struct HistoryBrowser {
248    /// The conversation tree structure
249    tree: ConversationTree,
250    /// Whether the browser is visible
251    visible: bool,
252    /// Scroll offset for the tree view
253    scroll_offset: usize,
254    /// Maximum visible items
255    max_visible: usize,
256    /// Show preview pane
257    show_preview: bool,
258    /// Preview context lines
259    preview_context: usize,
260}
261
262impl HistoryBrowser {
263    /// Create a new history browser
264    pub fn new() -> Self {
265        Self {
266            tree: ConversationTree::new(),
267            visible: false,
268            scroll_offset: 0,
269            max_visible: 20,
270            show_preview: true,
271            preview_context: 3,
272        }
273    }
274
275    /// Show the history browser with conversation items
276    pub fn show(&mut self, items: Vec<ResponseItem>) {
277        self.tree = ConversationTree::new();
278
279        let mut parent: Option<Uuid> = None;
280        for (index, item) in items.into_iter().enumerate() {
281            let node = HistoryNode::new(index, item, parent);
282            parent = Some(self.tree.add_node(node));
283        }
284
285        self.visible = true;
286
287        // Select the last node by default
288        if let Some(last_id) = self.tree.active_path.last() {
289            self.tree.selected_node = Some(*last_id);
290        }
291    }
292
293    /// Hide the history browser
294    pub const fn hide(&mut self) {
295        self.visible = false;
296        self.scroll_offset = 0;
297    }
298
299    /// Check if the browser is visible
300    pub const fn is_visible(&self) -> bool {
301        self.visible
302    }
303
304    /// Move selection up in the tree
305    pub fn move_up(&mut self) {
306        let nodes = self.tree.get_render_nodes();
307        if let Some(current_idx) = nodes.iter().position(|(_, _, selected)| *selected)
308            && current_idx > 0
309        {
310            let (_, prev_node, _) = nodes[current_idx - 1];
311            self.tree.selected_node = Some(prev_node);
312            self.ensure_visible(current_idx - 1, nodes.len());
313        }
314    }
315
316    /// Move selection down in the tree
317    pub fn move_down(&mut self) {
318        let nodes = self.tree.get_render_nodes();
319        if let Some(current_idx) = nodes.iter().position(|(_, _, selected)| *selected)
320            && current_idx < nodes.len() - 1
321        {
322            let (_, next_node, _) = nodes[current_idx + 1];
323            self.tree.selected_node = Some(next_node);
324            self.ensure_visible(current_idx + 1, nodes.len());
325        }
326    }
327
328    /// Ensure selected item is visible
329    fn ensure_visible(&mut self, index: usize, total: usize) {
330        if index < self.scroll_offset {
331            self.scroll_offset = index;
332        } else if index >= self.scroll_offset + self.max_visible {
333            self.scroll_offset = index.saturating_sub(self.max_visible - 1);
334        }
335
336        // Clamp scroll offset
337        self.scroll_offset = self
338            .scroll_offset
339            .min(total.saturating_sub(self.max_visible));
340    }
341
342    /// Get the currently selected node
343    pub fn selected_node(&self) -> Option<&HistoryNode> {
344        self.tree
345            .selected_node
346            .and_then(|id| self.tree.nodes.get(&id))
347    }
348
349    /// Create a branch from the selected node
350    pub fn create_branch_from_selected(&mut self, name: String, item: ResponseItem) -> bool {
351        if let Some(selected_id) = self.tree.selected_node {
352            self.tree.create_branch(selected_id, name, item).is_some()
353        } else {
354            false
355        }
356    }
357
358    /// Switch to the selected branch
359    pub fn switch_to_selected_branch(&mut self) -> bool {
360        if let Some(selected_id) = self.tree.selected_node {
361            self.tree.switch_to_branch(selected_id)
362        } else {
363            false
364        }
365    }
366
367    /// Toggle preview pane
368    pub const fn toggle_preview(&mut self) {
369        self.show_preview = !self.show_preview;
370    }
371}
372
373impl Default for HistoryBrowser {
374    fn default() -> Self {
375        Self::new()
376    }
377}
378
379impl Widget for HistoryBrowser {
380    fn render(self, area: Rect, buf: &mut Buffer) {
381        WidgetRef::render_ref(&self, area, buf);
382    }
383}
384
385impl WidgetRef for HistoryBrowser {
386    fn render_ref(&self, area: Rect, buf: &mut Buffer) {
387        if !self.visible {
388            return;
389        }
390
391        // Clear the background
392        Clear.render(area, buf);
393
394        // Create the main block
395        let block = Block::default()
396            .title("History Browser (Ctrl+H)")
397            .borders(Borders::ALL)
398            .border_type(BorderType::Rounded)
399            .border_style(Style::default().fg(Color::Magenta));
400
401        let inner = block.inner(area);
402        block.render(area, buf);
403
404        // Split into tree view and preview if enabled
405        let chunks = if self.show_preview {
406            Layout::default()
407                .direction(Direction::Horizontal)
408                .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
409                .split(inner)
410                .to_vec()
411        } else {
412            vec![inner]
413        };
414
415        // Render the tree view
416        self.render_tree_view(chunks[0], buf);
417
418        // Render preview if enabled
419        if self.show_preview && chunks.len() > 1 {
420            self.render_preview(chunks[1], buf);
421        }
422    }
423}
424
425impl HistoryBrowser {
426    /// Render the tree view of conversation history
427    fn render_tree_view(&self, area: Rect, buf: &mut Buffer) {
428        let nodes = self.tree.get_render_nodes();
429
430        // Create lines for the tree visualization
431        let mut lines = Vec::new();
432
433        // Add header
434        lines.push(Line::from(vec![
435            Span::styled("↑↓", Style::default().fg(Color::Gray)),
436            Span::raw(" Navigate | "),
437            Span::styled("Enter", Style::default().fg(Color::Gray)),
438            Span::raw(" Jump | "),
439            Span::styled("b", Style::default().fg(Color::Gray)),
440            Span::raw(" Branch | "),
441            Span::styled("p", Style::default().fg(Color::Gray)),
442            Span::raw(" Preview"),
443        ]));
444        lines.push(Line::from(""));
445
446        // Render visible nodes
447        let end = (self.scroll_offset + self.max_visible).min(nodes.len());
448        for (depth, node_id, is_selected) in &nodes[self.scroll_offset..end] {
449            let node = match self.tree.nodes.get(node_id) {
450                Some(n) => n,
451                None => continue,
452            };
453
454            let indent = "  ".repeat(*depth);
455
456            // Choose tree symbols
457            let symbol = if node.children.is_empty() {
458                symbols::line::VERTICAL_RIGHT
459            } else if node.children.len() > 1 {
460                "├┬"
461            } else {
462                symbols::line::VERTICAL_RIGHT
463            };
464
465            // Style based on role and selection
466            let (role_style, preview_style) = if *is_selected {
467                (
468                    get_role_style(node.role()).add_modifier(Modifier::BOLD | Modifier::REVERSED),
469                    Style::default().add_modifier(Modifier::REVERSED),
470                )
471            } else if node.is_active {
472                (
473                    get_role_style(node.role()).add_modifier(Modifier::BOLD),
474                    Style::default(),
475                )
476            } else {
477                (
478                    get_role_style(node.role()).add_modifier(Modifier::DIM),
479                    Style::default().fg(Color::DarkGray),
480                )
481            };
482
483            // Build the line
484            let mut spans = vec![
485                Span::raw(indent),
486                Span::styled(symbol, Style::default().fg(Color::Gray)),
487                Span::raw(" "),
488                Span::styled(format!("#{} ", node.index + 1), role_style),
489            ];
490
491            // Add branch name if present
492            if let Some(branch_name) = &node.branch_name {
493                spans.push(Span::styled(
494                    format!("[{}] ", branch_name),
495                    Style::default().fg(Color::Yellow),
496                ));
497            }
498
499            spans.push(Span::styled(node.preview(), preview_style));
500
501            lines.push(Line::from(spans));
502        }
503
504        // Add scroll indicators if needed
505        if self.scroll_offset > 0 {
506            lines[1] = Line::from(vec![
507                Span::styled("▲ ", Style::default().fg(Color::Yellow)),
508                Span::raw("More above..."),
509            ]);
510        }
511
512        if end < nodes.len() {
513            lines.push(Line::from(vec![
514                Span::styled("▼ ", Style::default().fg(Color::Yellow)),
515                Span::raw("More below..."),
516            ]));
517        }
518
519        let tree_text = Text::from(lines);
520        let tree_paragraph = Paragraph::new(tree_text);
521        tree_paragraph.render(area, buf);
522    }
523
524    /// Render the preview pane
525    fn render_preview(&self, area: Rect, buf: &mut Buffer) {
526        let block = Block::default()
527            .title("Preview")
528            .borders(Borders::ALL)
529            .border_style(Style::default().fg(Color::Gray));
530
531        let inner = block.inner(area);
532        block.render(area, buf);
533
534        if let Some(node) = self.selected_node() {
535            // Get full message content
536            let content = match &node.item {
537                ResponseItem::Message { role, content, .. } => {
538                    let text = content
539                        .iter()
540                        .filter_map(|c| match c {
541                            agcodex_core::models::ContentItem::InputText { text }
542                            | agcodex_core::models::ContentItem::OutputText { text } => {
543                                Some(text.as_str())
544                            }
545                            agcodex_core::models::ContentItem::InputImage { .. } => Some("[Image]"),
546                        })
547                        .collect::<Vec<_>>()
548                        .join("\n");
549
550                    format!("Role: {}\n\n{}", role, text)
551                }
552                ResponseItem::Reasoning { content, .. } => match content {
553                    Some(items) => {
554                        let reasoning_text = items
555                            .iter()
556                            .map(|item| format!("{:?}", item))
557                            .collect::<Vec<_>>()
558                            .join("\n");
559                        format!("Reasoning:\n\n{}", reasoning_text)
560                    }
561                    None => "Reasoning: (empty)".to_string(),
562                },
563                ResponseItem::FunctionCall {
564                    name, arguments, ..
565                } => {
566                    format!("Function Call: {}\nArguments: {}", name, arguments)
567                }
568                ResponseItem::LocalShellCall { action, .. } => {
569                    format!("Shell Command:\n{:?}", action)
570                }
571                ResponseItem::FunctionCallOutput {
572                    call_id, output, ..
573                } => {
574                    format!("Function Output: {}\n\n{}", call_id, output)
575                }
576                ResponseItem::Other => "Other content".to_string(),
577            };
578
579            // Create preview text with word wrapping
580            let wrapped_lines: Vec<String> = content
581                .lines()
582                .flat_map(|line| {
583                    if line.len() <= inner.width as usize {
584                        vec![line.to_string()]
585                    } else {
586                        // Simple word wrap
587                        let mut wrapped = Vec::new();
588                        let mut current = String::new();
589                        for word in line.split_whitespace() {
590                            if current.len() + word.len() + 1 > inner.width as usize
591                                && !current.is_empty()
592                            {
593                                wrapped.push(current.clone());
594                                current.clear();
595                            }
596                            if !current.is_empty() {
597                                current.push(' ');
598                            }
599                            current.push_str(word);
600                        }
601                        if !current.is_empty() {
602                            wrapped.push(current);
603                        }
604                        wrapped
605                    }
606                })
607                .collect();
608
609            // Take only what fits in the preview area
610            let preview_lines: Vec<Line> = wrapped_lines
611                .iter()
612                .take(inner.height as usize)
613                .map(|s| Line::from(s.as_str()))
614                .collect();
615
616            let preview_text = Text::from(preview_lines);
617            let preview_paragraph = Paragraph::new(preview_text).alignment(Alignment::Left);
618            preview_paragraph.render(inner, buf);
619        } else {
620            let no_selection = Paragraph::new("No message selected")
621                .style(Style::default().fg(Color::DarkGray))
622                .alignment(Alignment::Center);
623            no_selection.render(inner, buf);
624        }
625    }
626}
627
628/// Get style for a role
629fn get_role_style(role: &str) -> Style {
630    match role {
631        "user" => Style::default().fg(Color::Green),
632        "assistant" => Style::default().fg(Color::Blue),
633        "system" => Style::default().fg(Color::Yellow),
634        "reasoning" => Style::default().fg(Color::Magenta),
635        "function" | "shell" => Style::default().fg(Color::Cyan),
636        "output" => Style::default().fg(Color::Gray),
637        _ => Style::default(),
638    }
639}
640
641#[cfg(test)]
642mod tests {
643    use super::*;
644    use agcodex_core::models::ContentItem;
645
646    fn create_test_message(role: &str, content: &str) -> ResponseItem {
647        ResponseItem::Message {
648            id: None,
649            role: role.to_string(),
650            content: vec![ContentItem::OutputText {
651                text: content.to_string(),
652            }],
653        }
654    }
655
656    #[test]
657    fn test_history_node_creation() {
658        let item = create_test_message("user", "Test message");
659        let node = HistoryNode::new(0, item, None);
660
661        assert_eq!(node.index, 0);
662        assert!(node.parent.is_none());
663        assert!(node.children.is_empty());
664        assert!(node.is_active);
665    }
666
667    #[test]
668    fn test_conversation_tree_building() {
669        let mut tree = ConversationTree::new();
670
671        let root_item = create_test_message("user", "First message");
672        let root_node = HistoryNode::new(0, root_item, None);
673        let root_id = tree.add_node(root_node);
674
675        assert_eq!(tree.root, Some(root_id));
676        assert_eq!(tree.active_path, vec![root_id]);
677
678        let child_item = create_test_message("assistant", "Response");
679        let child_node = HistoryNode::new(1, child_item, Some(root_id));
680        let child_id = tree.add_node(child_node);
681
682        assert_eq!(tree.active_path, vec![root_id, child_id]);
683        assert_eq!(tree.nodes.get(&root_id).unwrap().children, vec![child_id]);
684    }
685
686    #[test]
687    fn test_branching() {
688        let mut tree = ConversationTree::new();
689
690        let root_item = create_test_message("user", "Root");
691        let root_node = HistoryNode::new(0, root_item, None);
692        let root_id = tree.add_node(root_node);
693
694        let branch_item = create_test_message("assistant", "Branch");
695        let branch_id = tree.create_branch(root_id, "Alternative".to_string(), branch_item);
696
697        assert!(branch_id.is_some());
698        let branch_id = branch_id.unwrap();
699
700        assert_eq!(tree.nodes.get(&root_id).unwrap().children.len(), 1);
701        assert_eq!(
702            tree.nodes.get(&branch_id).unwrap().branch_name,
703            Some("Alternative".to_string())
704        );
705        assert!(!tree.nodes.get(&branch_id).unwrap().is_active);
706    }
707
708    #[test]
709    fn test_history_browser_visibility() {
710        let mut browser = HistoryBrowser::new();
711        assert!(!browser.is_visible());
712
713        browser.show(vec![
714            create_test_message("user", "Hello"),
715            create_test_message("assistant", "Hi"),
716        ]);
717        assert!(browser.is_visible());
718
719        browser.hide();
720        assert!(!browser.is_visible());
721    }
722}