agcodex_tui/widgets/
message_jump.rs

1//! Message Jump widget for navigating to any previous message in the conversation
2
3use std::time::SystemTime;
4
5use agcodex_core::models::ContentItem;
6use agcodex_core::models::ResponseItem;
7use nucleo_matcher::Matcher;
8use nucleo_matcher::Utf32Str;
9use ratatui::buffer::Buffer;
10use ratatui::layout::Alignment;
11use ratatui::layout::Constraint;
12use ratatui::layout::Layout;
13use ratatui::layout::Rect;
14use ratatui::style::Color;
15use ratatui::style::Modifier;
16use ratatui::style::Style;
17use ratatui::text::Line;
18use ratatui::text::Span;
19use ratatui::text::Text;
20use ratatui::widgets::Block;
21use ratatui::widgets::BorderType;
22use ratatui::widgets::Borders;
23use ratatui::widgets::Clear;
24use ratatui::widgets::Paragraph;
25use ratatui::widgets::Widget;
26use ratatui::widgets::WidgetRef;
27
28use crate::bottom_pane::popup_consts::MAX_POPUP_ROWS;
29use crate::bottom_pane::scroll_state::ScrollState;
30use crate::bottom_pane::selection_popup_common::GenericDisplayRow;
31use crate::bottom_pane::selection_popup_common::render_rows;
32
33/// Represents a single message entry in the jump list
34#[derive(Debug, Clone)]
35pub struct MessageEntry {
36    /// Index in the conversation history
37    pub index: usize,
38    /// Role of the message (user, assistant, system)
39    pub role: String,
40    /// Content preview (first 100 chars)
41    pub preview: String,
42    /// Full content for search matching
43    pub full_content: String,
44    /// Timestamp if available
45    pub timestamp: Option<SystemTime>,
46    /// Original ResponseItem reference for context restoration
47    pub item: ResponseItem,
48}
49
50impl MessageEntry {
51    /// Create a new message entry from a ResponseItem
52    pub fn new(index: usize, item: ResponseItem) -> Self {
53        let (role, content) = match &item {
54            ResponseItem::Message { role, content, .. } => {
55                let text = extract_text_content(content);
56                (role.clone(), text)
57            }
58            ResponseItem::Reasoning { .. } => {
59                ("reasoning".to_string(), "Reasoning content".to_string())
60            }
61            ResponseItem::FunctionCall { name, .. } => {
62                ("function".to_string(), format!("Function call: {}", name))
63            }
64            ResponseItem::LocalShellCall { action, .. } => {
65                ("shell".to_string(), format!("Shell: {:?}", action))
66            }
67            ResponseItem::FunctionCallOutput { .. } => {
68                ("function_output".to_string(), "Function output".to_string())
69            }
70            ResponseItem::Other => ("other".to_string(), "Other content".to_string()),
71        };
72
73        let preview = if content.len() > 100 {
74            format!("{}...", &content[..97])
75        } else {
76            content.clone()
77        };
78
79        Self {
80            index,
81            role,
82            preview,
83            full_content: content,
84            timestamp: None, // TODO: Extract timestamp from ResponseItem if available
85            item,
86        }
87    }
88
89    /// Get formatted display text for this message
90    pub fn display_text(&self) -> String {
91        format!("#{}: [{}] {}", self.index + 1, self.role, self.preview)
92    }
93}
94
95/// Filter options for message roles
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum RoleFilter {
98    All,
99    User,
100    Assistant,
101    System,
102    Function,
103    Other,
104}
105
106impl RoleFilter {
107    pub fn matches(&self, role: &str) -> bool {
108        match self {
109            RoleFilter::All => true,
110            RoleFilter::User => role == "user",
111            RoleFilter::Assistant => role == "assistant",
112            RoleFilter::System => role == "system",
113            RoleFilter::Function => role == "function" || role == "function_output",
114            RoleFilter::Other => !matches!(
115                role,
116                "user" | "assistant" | "system" | "function" | "function_output"
117            ),
118        }
119    }
120
121    pub const fn display_name(&self) -> &'static str {
122        match self {
123            RoleFilter::All => "All",
124            RoleFilter::User => "User",
125            RoleFilter::Assistant => "Assistant",
126            RoleFilter::System => "System",
127            RoleFilter::Function => "Function",
128            RoleFilter::Other => "Other",
129        }
130    }
131
132    pub const fn cycle_next(&self) -> Self {
133        match self {
134            RoleFilter::All => RoleFilter::User,
135            RoleFilter::User => RoleFilter::Assistant,
136            RoleFilter::Assistant => RoleFilter::System,
137            RoleFilter::System => RoleFilter::Function,
138            RoleFilter::Function => RoleFilter::Other,
139            RoleFilter::Other => RoleFilter::All,
140        }
141    }
142}
143
144/// Visual state for the message jump popup
145pub struct MessageJump {
146    /// All available messages
147    all_messages: Vec<MessageEntry>,
148    /// Currently filtered and displayed messages
149    filtered_messages: Vec<MessageEntry>,
150    /// Current search query
151    search_query: String,
152    /// Current role filter
153    role_filter: RoleFilter,
154    /// Fuzzy matcher for search
155    matcher: Matcher,
156    /// Selection and scroll state
157    state: ScrollState,
158    /// Whether the popup is currently visible
159    visible: bool,
160    /// Context preview lines (messages before/after selected)
161    context_lines: usize,
162}
163
164impl MessageJump {
165    /// Create a new message jump widget
166    pub fn new() -> Self {
167        Self {
168            all_messages: Vec::new(),
169            filtered_messages: Vec::new(),
170            search_query: String::new(),
171            role_filter: RoleFilter::All,
172            matcher: Matcher::new(nucleo_matcher::Config::DEFAULT),
173            state: ScrollState::new(),
174            visible: false,
175            context_lines: 2,
176        }
177    }
178
179    /// Show the popup and load messages from conversation history
180    pub fn show(&mut self, messages: Vec<ResponseItem>) {
181        self.all_messages = messages
182            .into_iter()
183            .enumerate()
184            .map(|(i, item)| MessageEntry::new(i, item))
185            .collect();
186
187        self.visible = true;
188        self.apply_filters();
189
190        // Select the last message by default
191        if !self.filtered_messages.is_empty() {
192            self.state.selected_idx = Some(self.filtered_messages.len() - 1);
193        }
194    }
195
196    /// Hide the popup
197    pub fn hide(&mut self) {
198        self.visible = false;
199        self.search_query.clear();
200        self.role_filter = RoleFilter::All;
201        self.state.reset();
202    }
203
204    /// Check if the popup is currently visible
205    pub const fn is_visible(&self) -> bool {
206        self.visible
207    }
208
209    /// Update the search query and refresh filtering
210    pub fn set_search_query(&mut self, query: String) {
211        self.search_query = query;
212        self.apply_filters();
213    }
214
215    /// Get the current search query
216    pub fn search_query(&self) -> &str {
217        &self.search_query
218    }
219
220    /// Cycle to the next role filter
221    pub fn cycle_role_filter(&mut self) {
222        self.role_filter = self.role_filter.cycle_next();
223        self.apply_filters();
224    }
225
226    /// Get the current role filter
227    pub const fn role_filter(&self) -> RoleFilter {
228        self.role_filter
229    }
230
231    /// Move selection up
232    pub fn move_up(&mut self) {
233        let len = self.filtered_messages.len();
234        self.state.move_up_wrap(len);
235        self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS));
236    }
237
238    /// Move selection down  
239    pub fn move_down(&mut self) {
240        let len = self.filtered_messages.len();
241        self.state.move_down_wrap(len);
242        self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS));
243    }
244
245    /// Get the currently selected message entry
246    pub fn selected_message(&self) -> Option<&MessageEntry> {
247        self.state
248            .selected_idx
249            .and_then(|idx| self.filtered_messages.get(idx))
250    }
251
252    /// Get context messages around the selected message
253    pub fn get_context_messages(&self) -> Option<Vec<&MessageEntry>> {
254        let selected = self.selected_message()?;
255        let selected_index = selected.index;
256
257        let start = selected_index.saturating_sub(self.context_lines);
258        let end = (selected_index + self.context_lines + 1).min(self.all_messages.len());
259
260        Some(self.all_messages[start..end].iter().collect())
261    }
262
263    /// Apply current search and role filters
264    fn apply_filters(&mut self) {
265        self.filtered_messages.clear();
266
267        for message in &self.all_messages {
268            // Apply role filter
269            if !self.role_filter.matches(&message.role) {
270                continue;
271            }
272
273            // Apply search filter
274            if !self.search_query.is_empty() {
275                let mut haystack_buf = Vec::new();
276                let mut needle_buf = Vec::new();
277                let haystack = Utf32Str::new(&message.full_content, &mut haystack_buf);
278                let needle = Utf32Str::new(&self.search_query, &mut needle_buf);
279
280                if self.matcher.fuzzy_match(haystack, needle).is_none() {
281                    // Also try matching against the display text
282                    let display_text = message.display_text();
283                    let mut display_buf = Vec::new();
284                    let display_haystack = Utf32Str::new(&display_text, &mut display_buf);
285                    if self.matcher.fuzzy_match(display_haystack, needle).is_none() {
286                        continue;
287                    }
288                }
289            }
290
291            self.filtered_messages.push(message.clone());
292        }
293
294        // Update selection state
295        let len = self.filtered_messages.len();
296        self.state.clamp_selection(len);
297        self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS));
298    }
299
300    /// Calculate the required height for the popup
301    pub fn calculate_required_height(&self) -> u16 {
302        if !self.visible {
303            return 0;
304        }
305
306        // Base height for the message list
307        let list_height = self.filtered_messages.len().clamp(1, MAX_POPUP_ROWS) as u16;
308
309        // Add space for search bar and filter indicator
310        let ui_height = 4; // Search bar (1) + filter line (1) + borders (2)
311
312        // Add space for context preview if a message is selected
313        let context_height = if self.selected_message().is_some() {
314            (self.context_lines * 2 + 1) as u16 // Before + selected + after
315        } else {
316            0
317        };
318
319        list_height + ui_height + context_height
320    }
321}
322
323impl Default for MessageJump {
324    fn default() -> Self {
325        Self::new()
326    }
327}
328
329impl Widget for MessageJump {
330    fn render(self, area: Rect, buf: &mut Buffer) {
331        WidgetRef::render_ref(&self, area, buf);
332    }
333}
334
335impl WidgetRef for MessageJump {
336    fn render_ref(&self, area: Rect, buf: &mut Buffer) {
337        if !self.visible {
338            return;
339        }
340
341        // Clear the background
342        Clear.render(area, buf);
343
344        // Create the main block
345        let block = Block::default()
346            .title("Message Jump (Ctrl+J)")
347            .borders(Borders::ALL)
348            .border_type(BorderType::Rounded)
349            .border_style(Style::default().fg(Color::Cyan));
350
351        let inner = block.inner(area);
352        block.render(area, buf);
353
354        // Layout: Search bar, filter indicator, message list, context preview
355        let chunks = Layout::default()
356            .direction(ratatui::layout::Direction::Vertical)
357            .constraints([
358                Constraint::Length(1), // Search bar
359                Constraint::Length(1), // Filter indicator
360                Constraint::Min(3),    // Message list
361                Constraint::Length(if self.selected_message().is_some() {
362                    (self.context_lines * 2 + 3) as u16
363                } else {
364                    0
365                }), // Context preview
366            ])
367            .split(inner);
368
369        // Render search bar
370        let search_text = if self.search_query.is_empty() {
371            Text::from("Type to search messages...").style(Style::default().fg(Color::DarkGray))
372        } else {
373            Text::from(self.search_query.clone())
374        };
375
376        let search_paragraph =
377            Paragraph::new(search_text).block(Block::default().borders(Borders::BOTTOM));
378        search_paragraph.render(chunks[0], buf);
379
380        // Render filter indicator
381        let filter_text = format!(
382            "Filter: {} | {} messages | Use Tab to cycle filters, Enter to jump, Esc to cancel",
383            self.role_filter.display_name(),
384            self.filtered_messages.len()
385        );
386        let filter_paragraph = Paragraph::new(filter_text)
387            .style(Style::default().fg(Color::Yellow))
388            .alignment(Alignment::Center);
389        filter_paragraph.render(chunks[1], buf);
390
391        // Render message list
392        if chunks.len() > 2 {
393            self.render_message_list(chunks[2], buf);
394        }
395
396        // Render context preview
397        if chunks.len() > 3 && chunks[3].height > 0 {
398            self.render_context_preview(chunks[3], buf);
399        }
400    }
401}
402
403impl MessageJump {
404    /// Render the message list with fuzzy matching highlights
405    fn render_message_list(&self, area: Rect, buf: &mut Buffer) {
406        let rows: Vec<GenericDisplayRow> = if self.filtered_messages.is_empty() {
407            Vec::new()
408        } else {
409            self.filtered_messages
410                .iter()
411                .map(|msg| {
412                    let display_text = msg.display_text();
413
414                    // Calculate fuzzy match indices for highlighting
415                    // For now, just do simple substring highlighting
416                    let match_indices = if !self.search_query.is_empty() {
417                        let query_lower = self.search_query.to_lowercase();
418                        let text_lower = display_text.to_lowercase();
419                        if let Some(start) = text_lower.find(&query_lower) {
420                            let indices: Vec<usize> = (start..start + query_lower.len()).collect();
421                            Some(indices)
422                        } else {
423                            None
424                        }
425                    } else {
426                        None
427                    };
428
429                    GenericDisplayRow {
430                        name: display_text,
431                        match_indices,
432                        is_current: false, // TODO: Mark current message if available
433                        description: msg.timestamp.map(|_| "timestamp".to_string()),
434                    }
435                })
436                .collect()
437        };
438
439        render_rows(area, buf, &rows, &self.state, MAX_POPUP_ROWS, false);
440    }
441
442    /// Render context preview showing messages before/after selected
443    fn render_context_preview(&self, area: Rect, buf: &mut Buffer) {
444        let Some(context_messages) = self.get_context_messages() else {
445            return;
446        };
447
448        let block = Block::default()
449            .title("Context Preview")
450            .borders(Borders::ALL)
451            .border_style(Style::default().fg(Color::Gray));
452
453        let inner = block.inner(area);
454        block.render(area, buf);
455
456        let selected_index = self.selected_message().map(|m| m.index);
457
458        let mut lines = Vec::new();
459        for (i, msg) in context_messages.iter().enumerate() {
460            let is_selected = selected_index == Some(msg.index);
461            let style = if is_selected {
462                Style::default()
463                    .fg(Color::Yellow)
464                    .add_modifier(Modifier::BOLD)
465            } else {
466                Style::default().fg(Color::Gray)
467            };
468
469            let prefix = if is_selected { "► " } else { "  " };
470            let line = Line::from(vec![
471                Span::styled(prefix, style),
472                Span::styled(format!("#{}: ", msg.index + 1), style),
473                Span::styled(format!("[{}] ", msg.role), style),
474                Span::styled(&msg.preview, style),
475            ]);
476            lines.push(line);
477
478            // Don't exceed the available height
479            if i >= inner.height as usize {
480                break;
481            }
482        }
483
484        let context_text = Text::from(lines);
485        let context_paragraph = Paragraph::new(context_text);
486        context_paragraph.render(inner, buf);
487    }
488}
489
490/// Extract text content from ContentItem vector
491fn extract_text_content(content: &[ContentItem]) -> String {
492    content
493        .iter()
494        .filter_map(|item| match item {
495            ContentItem::InputText { text } | ContentItem::OutputText { text } => {
496                Some(text.as_str())
497            }
498            ContentItem::InputImage { .. } => Some("[Image]"),
499        })
500        .collect::<Vec<_>>()
501        .join(" ")
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507    use agcodex_core::models::ResponseItem;
508
509    fn create_test_message(role: &str, content: &str) -> ResponseItem {
510        ResponseItem::Message {
511            id: None,
512            role: role.to_string(),
513            content: vec![ContentItem::OutputText {
514                text: content.to_string(),
515            }],
516        }
517    }
518
519    #[test]
520    fn test_message_entry_creation() {
521        let item = create_test_message("user", "Hello, world!");
522        let entry = MessageEntry::new(0, item);
523
524        assert_eq!(entry.index, 0);
525        assert_eq!(entry.role, "user");
526        assert_eq!(entry.preview, "Hello, world!");
527        assert_eq!(entry.full_content, "Hello, world!");
528    }
529
530    #[test]
531    fn test_message_entry_preview_truncation() {
532        let long_content = "a".repeat(150);
533        let item = create_test_message("user", &long_content);
534        let entry = MessageEntry::new(0, item);
535
536        assert_eq!(entry.preview.len(), 100); // 97 chars + "..."
537        assert!(entry.preview.ends_with("..."));
538    }
539
540    #[test]
541    fn test_role_filter_matching() {
542        assert!(RoleFilter::All.matches("user"));
543        assert!(RoleFilter::All.matches("assistant"));
544        assert!(RoleFilter::User.matches("user"));
545        assert!(!RoleFilter::User.matches("assistant"));
546        assert!(RoleFilter::Assistant.matches("assistant"));
547        assert!(!RoleFilter::Assistant.matches("user"));
548    }
549
550    #[test]
551    fn test_role_filter_cycling() {
552        let filter = RoleFilter::All;
553        assert_eq!(filter.cycle_next(), RoleFilter::User);
554
555        let filter = RoleFilter::Other;
556        assert_eq!(filter.cycle_next(), RoleFilter::All);
557    }
558
559    #[test]
560    fn test_message_jump_filtering() {
561        let mut jump = MessageJump::new();
562        let messages = vec![
563            create_test_message("user", "Hello"),
564            create_test_message("assistant", "Hi there"),
565            create_test_message("user", "How are you?"),
566        ];
567
568        jump.show(messages);
569        assert_eq!(jump.filtered_messages.len(), 3);
570
571        jump.role_filter = RoleFilter::User;
572        jump.apply_filters();
573        assert_eq!(jump.filtered_messages.len(), 2);
574
575        jump.set_search_query("Hello".to_string());
576        assert_eq!(jump.filtered_messages.len(), 1);
577    }
578}