1use 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#[derive(Debug, Clone)]
34pub struct HistoryNode {
35 pub id: Uuid,
37 pub index: usize,
39 pub item: ResponseItem,
41 pub parent: Option<Uuid>,
43 pub children: Vec<Uuid>,
45 pub timestamp: SystemTime,
47 pub branch_name: Option<String>,
49 pub is_active: bool,
51}
52
53impl HistoryNode {
54 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 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 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#[derive(Debug, Clone)]
115pub struct ConversationTree {
116 nodes: HashMap<Uuid, HistoryNode>,
118 root: Option<Uuid>,
120 active_path: Vec<Uuid>,
122 selected_node: Option<Uuid>,
124}
125
126impl ConversationTree {
127 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 pub fn add_node(&mut self, node: HistoryNode) -> Uuid {
139 let id = node.id;
140
141 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 self.root = Some(id);
149 }
150
151 if node.is_active {
153 self.active_path.push(id);
154 }
155
156 self.nodes.insert(id, node);
157 id
158 }
159
160 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; Some(self.add_node(new_node))
175 }
176
177 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 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 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 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 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 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 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
246pub struct HistoryBrowser {
248 tree: ConversationTree,
250 visible: bool,
252 scroll_offset: usize,
254 max_visible: usize,
256 show_preview: bool,
258 preview_context: usize,
260}
261
262impl HistoryBrowser {
263 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 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 if let Some(last_id) = self.tree.active_path.last() {
289 self.tree.selected_node = Some(*last_id);
290 }
291 }
292
293 pub const fn hide(&mut self) {
295 self.visible = false;
296 self.scroll_offset = 0;
297 }
298
299 pub const fn is_visible(&self) -> bool {
301 self.visible
302 }
303
304 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 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 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 self.scroll_offset = self
338 .scroll_offset
339 .min(total.saturating_sub(self.max_visible));
340 }
341
342 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 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 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 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.render(area, buf);
393
394 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 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 self.render_tree_view(chunks[0], buf);
417
418 if self.show_preview && chunks.len() > 1 {
420 self.render_preview(chunks[1], buf);
421 }
422 }
423}
424
425impl HistoryBrowser {
426 fn render_tree_view(&self, area: Rect, buf: &mut Buffer) {
428 let nodes = self.tree.get_render_nodes();
429
430 let mut lines = Vec::new();
432
433 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 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 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 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 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 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 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 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 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 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 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 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
628fn 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}