1use 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#[derive(Debug, Clone)]
35pub struct MessageEntry {
36 pub index: usize,
38 pub role: String,
40 pub preview: String,
42 pub full_content: String,
44 pub timestamp: Option<SystemTime>,
46 pub item: ResponseItem,
48}
49
50impl MessageEntry {
51 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, item,
86 }
87 }
88
89 pub fn display_text(&self) -> String {
91 format!("#{}: [{}] {}", self.index + 1, self.role, self.preview)
92 }
93}
94
95#[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
144pub struct MessageJump {
146 all_messages: Vec<MessageEntry>,
148 filtered_messages: Vec<MessageEntry>,
150 search_query: String,
152 role_filter: RoleFilter,
154 matcher: Matcher,
156 state: ScrollState,
158 visible: bool,
160 context_lines: usize,
162}
163
164impl MessageJump {
165 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 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 if !self.filtered_messages.is_empty() {
192 self.state.selected_idx = Some(self.filtered_messages.len() - 1);
193 }
194 }
195
196 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 pub const fn is_visible(&self) -> bool {
206 self.visible
207 }
208
209 pub fn set_search_query(&mut self, query: String) {
211 self.search_query = query;
212 self.apply_filters();
213 }
214
215 pub fn search_query(&self) -> &str {
217 &self.search_query
218 }
219
220 pub fn cycle_role_filter(&mut self) {
222 self.role_filter = self.role_filter.cycle_next();
223 self.apply_filters();
224 }
225
226 pub const fn role_filter(&self) -> RoleFilter {
228 self.role_filter
229 }
230
231 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 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 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 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 fn apply_filters(&mut self) {
265 self.filtered_messages.clear();
266
267 for message in &self.all_messages {
268 if !self.role_filter.matches(&message.role) {
270 continue;
271 }
272
273 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 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 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 pub fn calculate_required_height(&self) -> u16 {
302 if !self.visible {
303 return 0;
304 }
305
306 let list_height = self.filtered_messages.len().clamp(1, MAX_POPUP_ROWS) as u16;
308
309 let ui_height = 4; let context_height = if self.selected_message().is_some() {
314 (self.context_lines * 2 + 1) as u16 } 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.render(area, buf);
343
344 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 let chunks = Layout::default()
356 .direction(ratatui::layout::Direction::Vertical)
357 .constraints([
358 Constraint::Length(1), Constraint::Length(1), Constraint::Min(3), Constraint::Length(if self.selected_message().is_some() {
362 (self.context_lines * 2 + 3) as u16
363 } else {
364 0
365 }), ])
367 .split(inner);
368
369 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 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 if chunks.len() > 2 {
393 self.render_message_list(chunks[2], buf);
394 }
395
396 if chunks.len() > 3 && chunks[3].height > 0 {
398 self.render_context_preview(chunks[3], buf);
399 }
400 }
401}
402
403impl MessageJump {
404 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 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, 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 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 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
490fn 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); 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}