1use crate::search::{
2 extract_context, extract_project_from_path, sanitize_content, RipgrepMatch, SessionGroup,
3};
4use crate::tui::render_tree::render_tree_mode;
5use crate::tui::App;
6use ratatui::{
7 layout::{Constraint, Layout},
8 style::{Color, Modifier, Style},
9 text::{Line, Span},
10 widgets::{Block, Borders, List, ListItem, Paragraph},
11 Frame,
12};
13use std::collections::HashSet;
14
15fn search_results_status_text(app: &App) -> Option<String> {
16 if app.results_query.is_empty() {
17 return None;
18 }
19
20 let total_groups = app.all_groups.len().max(app.groups.len());
21 if total_groups == 0 {
22 return Some("No matches found".to_string());
23 }
24
25 let hidden = total_groups.saturating_sub(app.groups.len());
26 if hidden == 0 {
27 return Some(format!(
28 "Found {} matches in {} sessions",
29 app.results.len(),
30 app.groups.len()
31 ));
32 }
33
34 let hidden_text = if app.groups.is_empty() {
35 "all hidden by filter".to_string()
36 } else {
37 format!("{} hidden by filter", hidden)
38 };
39
40 Some(format!(
41 "Found {} matches in {} sessions ({})",
42 app.results.len(),
43 app.groups.len(),
44 hidden_text
45 ))
46}
47
48fn recent_sessions_status_text(app: &App) -> Option<String> {
49 if !app.input.is_empty() {
50 return None;
51 }
52
53 if app.recent_loading {
54 return Some("Loading recent sessions...".to_string());
55 }
56
57 let total = app.all_recent_sessions.len();
58 let shown = app.recent_sessions.len();
59 if shown > 0 {
60 if shown < total {
61 return Some(format!(
62 "{} recent sessions ({} hidden by filter)",
63 shown,
64 total - shown
65 ));
66 }
67 return Some(format!("{} recent sessions", shown));
68 }
69
70 if total > 0 {
71 return Some(format!("0 recent sessions ({} hidden by filter)", total));
72 }
73
74 Some("No recent sessions found".to_string())
75}
76
77pub fn render(frame: &mut Frame, app: &mut App) {
78 if app.tree_mode {
79 render_tree_mode(frame, app);
80 return;
81 }
82
83 let [header_area, input_area, status_area, list_area, help_area] = Layout::vertical([
84 Constraint::Length(2),
85 Constraint::Length(3),
86 Constraint::Length(1),
87 Constraint::Fill(1),
88 Constraint::Length(1),
89 ])
90 .areas(frame.area());
91
92 let header = Paragraph::new(format!(
94 "Claude Code Session Search v{}",
95 env!("CARGO_PKG_VERSION")
96 ))
97 .style(
98 Style::default()
99 .fg(Color::Magenta)
100 .add_modifier(Modifier::BOLD),
101 );
102 frame.render_widget(header, header_area);
103
104 let input_style = if app.typing {
106 Style::default().fg(Color::Yellow)
107 } else {
108 Style::default().fg(Color::White)
109 };
110 use crate::tui::state::AutomationFilter;
111 let mut search_title = String::from("Search");
112 if app.regex_mode {
113 search_title.push_str(" [Regex]");
114 }
115 if app.project_filter {
116 search_title.push_str(" [Project]");
117 }
118 match app.automation_filter {
119 AutomationFilter::All => {}
120 AutomationFilter::Manual => search_title.push_str(" [Manual]"),
121 AutomationFilter::Auto => search_title.push_str(" [Auto]"),
122 }
123 let has_active_filter =
124 app.regex_mode || app.project_filter || app.automation_filter != AutomationFilter::All;
125 let title_style = if has_active_filter {
126 Style::default()
127 .fg(Color::Magenta)
128 .add_modifier(Modifier::BOLD)
129 } else {
130 Style::default()
131 };
132 let input = Paragraph::new(app.input.as_str()).style(input_style).block(
133 Block::default()
134 .borders(Borders::ALL)
135 .title(search_title.as_str())
136 .title_style(title_style),
137 );
138 frame.render_widget(input, input_area);
139 let cursor_x = app.input[..app.cursor_pos].chars().count() as u16;
141 frame.set_cursor_position((input_area.x + 1 + cursor_x, input_area.y + 1));
142
143 let status = if app.typing {
145 Span::styled(
146 "Typing...",
147 Style::default()
148 .fg(Color::Yellow)
149 .add_modifier(Modifier::ITALIC),
150 )
151 } else if app.searching {
152 Span::styled(
153 "Searching...",
154 Style::default()
155 .fg(Color::Yellow)
156 .add_modifier(Modifier::ITALIC),
157 )
158 } else if let Some(ref err) = app.error {
159 Span::styled(format!("Error: {}", err), Style::default().fg(Color::Red))
160 } else if let Some(text) = search_results_status_text(app) {
161 Span::styled(text, Style::default().fg(Color::DarkGray))
162 } else if let Some(text) = recent_sessions_status_text(app) {
163 let style = if app.recent_loading {
164 Style::default()
165 .fg(Color::Yellow)
166 .add_modifier(Modifier::ITALIC)
167 } else {
168 Style::default().fg(Color::DarkGray)
169 };
170 Span::styled(text, style)
171 } else {
172 Span::raw("")
173 };
174 frame.render_widget(Paragraph::new(Line::from(status)), status_area);
175
176 if app.preview_mode {
178 render_preview(frame, app, list_area);
179 } else {
180 render_groups(frame, app, list_area);
181 }
182
183 use crate::tui::state::AutomationFilter as AF;
185 let in_recent_mode = app.in_recent_sessions_mode() && !app.recent_sessions.is_empty();
186 let filter_label = match app.automation_filter {
187 AF::All => "All",
188 AF::Manual => "Manual",
189 AF::Auto => "Auto",
190 };
191 let filter_style = match app.automation_filter {
192 AF::All => Style::default().fg(Color::DarkGray),
193 AF::Manual => Style::default()
194 .fg(Color::Green)
195 .add_modifier(Modifier::BOLD),
196 AF::Auto => Style::default()
197 .fg(Color::Magenta)
198 .add_modifier(Modifier::BOLD),
199 };
200
201 let mut help_spans: Vec<Span> = Vec::new();
202 let dim = Style::default().fg(Color::DarkGray);
203
204 if app.preview_mode {
205 help_spans.push(Span::styled(
206 "[Tab/Ctrl+V/Enter] Close preview [Ctrl+A] Project [Ctrl+H] ",
207 dim,
208 ));
209 help_spans.push(Span::styled(filter_label, filter_style));
210 help_spans.push(Span::styled(" [Ctrl+R] Regex [Esc] Quit", dim));
211 } else if in_recent_mode {
212 help_spans.push(Span::styled(
213 "[↑↓] Navigate [Enter] Resume [Ctrl+A] Project [Ctrl+H] ",
214 dim,
215 ));
216 help_spans.push(Span::styled(filter_label, filter_style));
217 help_spans.push(Span::styled(" [Ctrl+B] Tree [Esc] Quit", dim));
218 } else if !app.groups.is_empty() {
219 help_spans.push(Span::styled("[↑↓] Navigate [→←] Expand [Tab/Ctrl+V] Preview [Enter] Resume [Ctrl+A] Project [Ctrl+H] ", dim));
220 help_spans.push(Span::styled(filter_label, filter_style));
221 help_spans.push(Span::styled(
222 " [Ctrl+B] Tree [Ctrl+R] Regex [Esc] Quit",
223 dim,
224 ));
225 } else {
226 help_spans.push(Span::styled(
227 "[↑↓] Navigate [Tab/Ctrl+V] Preview [Enter] Resume [Ctrl+A] Project [Ctrl+H] ",
228 dim,
229 ));
230 help_spans.push(Span::styled(filter_label, filter_style));
231 help_spans.push(Span::styled(" [Ctrl+R] Regex [Esc] Quit", dim));
232 }
233
234 let help = Paragraph::new(Line::from(help_spans));
235 frame.render_widget(help, help_area);
236}
237
238fn render_groups(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect) {
239 let buf = frame.buffer_mut();
242 for y in area.y..area.y + area.height {
243 for x in area.x..area.x + area.width {
244 if let Some(cell) = buf.cell_mut((x, y)) {
245 cell.set_symbol(" ");
246 cell.set_style(Style::default());
247 }
248 }
249 }
250
251 if app.input.is_empty() && app.groups.is_empty() {
253 render_recent_sessions(frame, app, area);
254 return;
255 }
256
257 let mut items: Vec<ListItem> = vec![];
258
259 for (i, group) in app.groups.iter().enumerate() {
260 let is_selected = i == app.group_cursor;
261 let is_expanded = is_selected && app.expanded;
262
263 let header = render_group_header(group, is_selected, is_expanded);
265 items.push(header);
266
267 if is_expanded {
269 let latest_chain = app.latest_chains.get(&group.file_path);
270 for (j, m) in group.matches.iter().enumerate() {
271 let is_match_selected = j == app.sub_cursor;
272 let sub_item =
273 render_sub_match(m, is_match_selected, &app.results_query, latest_chain);
274 items.push(sub_item);
275 }
276 }
277 }
278
279 let list = List::new(items).block(Block::default().borders(Borders::NONE));
280 frame.render_widget(list, area);
281}
282
283fn render_recent_sessions(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect) {
284 if app.recent_loading || app.recent_sessions.is_empty() {
286 return;
287 }
288
289 let visible_height = area.height as usize;
290 let available_width = area.width as usize;
291 let mut items: Vec<ListItem> = vec![];
292
293 let scroll_offset = if app.recent_cursor >= app.recent_scroll_offset + visible_height {
295 app.recent_cursor
296 .saturating_sub(visible_height.saturating_sub(1))
297 } else if app.recent_cursor < app.recent_scroll_offset {
298 app.recent_cursor
299 } else {
300 app.recent_scroll_offset
301 };
302 app.recent_scroll_offset = scroll_offset;
303
304 let end = (scroll_offset + visible_height).min(app.recent_sessions.len());
305
306 for i in scroll_offset..end {
307 let session = &app.recent_sessions[i];
308 let is_selected = i == app.recent_cursor;
309
310 let date_str = session.timestamp.format("%Y-%m-%d %H:%M").to_string();
311 let project_max = 20;
313 let project_display = truncate_to_width(&session.project, project_max);
314 let is_automated = session.automation.is_some();
315 let auto_prefix = if is_automated { "[A] " } else { "" };
316 let prefix_len =
317 2 + date_str.len() + 2 + project_display.chars().count() + 2 + auto_prefix.len();
318 let summary_max = available_width.saturating_sub(prefix_len);
319 let summary_display = truncate_to_width(&session.summary, summary_max);
320
321 let prefix = if is_selected { "> " } else { " " };
322
323 let mut spans = vec![
324 Span::raw(prefix.to_string()),
325 Span::styled(date_str, Style::default().fg(Color::DarkGray)),
326 Span::raw(" "),
327 Span::styled(project_display, Style::default().fg(Color::Cyan)),
328 Span::raw(" "),
329 ];
330
331 if is_automated {
332 spans.push(Span::styled("[A] ", Style::default().fg(Color::DarkGray)));
333 }
334
335 let summary_color = if is_automated {
336 Color::Gray
337 } else {
338 Color::White
339 };
340
341 if is_selected {
342 spans.push(Span::styled(
343 summary_display,
344 Style::default()
345 .fg(summary_color)
346 .add_modifier(Modifier::BOLD),
347 ));
348 } else {
349 spans.push(Span::styled(
350 summary_display,
351 Style::default().fg(summary_color),
352 ));
353 }
354
355 let style = if is_selected {
356 Style::default().bg(Color::Rgb(75, 0, 130))
357 } else {
358 Style::default()
359 };
360
361 items.push(ListItem::new(Line::from(spans)).style(style));
362 }
363
364 let list = List::new(items).block(Block::default().borders(Borders::NONE));
365 frame.render_widget(list, area);
366}
367
368pub(crate) fn build_group_header_text(group: &SessionGroup, expanded: bool) -> String {
370 let first_match = group.first_match();
371 let (date_str, branch, source) = if let Some(m) = first_match {
372 let source = m.source.display_name();
373 if let Some(ref msg) = m.message {
374 let date = msg.timestamp.format("%Y-%m-%d %H:%M").to_string();
375 let branch = msg.branch.clone().unwrap_or_else(|| "-".to_string());
376 (date, branch, source)
377 } else {
378 ("-".to_string(), "-".to_string(), source)
379 }
380 } else {
381 ("-".to_string(), "-".to_string(), "CLI")
382 };
383
384 let project = extract_project_from_path(&group.file_path);
385 let expand_indicator = if expanded { "▼" } else { "▶" };
386 let session_display = if group.session_id.len() > 8 {
387 &group.session_id[..8]
388 } else {
389 &group.session_id
390 };
391
392 let auto_tag = if group.automation.is_some() {
393 " [A]"
394 } else {
395 ""
396 };
397
398 format!(
399 "{} [{}] {} | {} | {} | {} ({} messages){}",
400 expand_indicator,
401 source,
402 date_str,
403 project,
404 branch,
405 session_display,
406 group.matches.len(),
407 auto_tag
408 )
409}
410
411fn render_group_header<'a>(group: &SessionGroup, selected: bool, expanded: bool) -> ListItem<'a> {
412 let header_text = build_group_header_text(group, expanded);
413
414 let style = if selected && !expanded {
415 Style::default()
416 .fg(Color::Yellow)
417 .bg(Color::Rgb(75, 0, 130))
418 .add_modifier(Modifier::BOLD)
419 } else if selected {
420 Style::default().fg(Color::White)
421 } else {
422 Style::default().fg(Color::DarkGray)
423 };
424
425 let prefix = if selected { "> " } else { " " };
426 ListItem::new(format!("{}{}", prefix, header_text)).style(style)
427}
428
429fn render_sub_match<'a>(
430 m: &RipgrepMatch,
431 selected: bool,
432 query: &str,
433 latest_chain: Option<&HashSet<String>>,
434) -> ListItem<'a> {
435 let (role_str, role_style, content) = if let Some(ref msg) = m.message {
436 let role_style = if msg.role == "user" {
437 Style::default()
438 .fg(Color::Cyan)
439 .add_modifier(Modifier::BOLD)
440 } else {
441 Style::default()
442 .fg(Color::Green)
443 .add_modifier(Modifier::BOLD)
444 };
445 let role_str = if msg.role == "user" {
446 "User:"
447 } else {
448 "Claude:"
449 };
450 let sanitized = sanitize_content(&msg.content);
452 let content = extract_context(&sanitized, query, 30);
453 (role_str.to_string(), role_style, content)
454 } else {
455 ("???:".to_string(), Style::default(), String::new())
456 };
457
458 let is_fork = latest_chain
460 .map(|chain| {
461 m.message
462 .as_ref()
463 .and_then(|msg| msg.uuid.as_deref())
464 .map(|uuid| !chain.contains(uuid))
465 .unwrap_or(false)
466 })
467 .unwrap_or(false);
468
469 let style = if selected {
470 Style::default()
471 .fg(Color::Yellow)
472 .bg(Color::Rgb(75, 0, 130))
473 } else {
474 Style::default().fg(Color::DarkGray)
475 };
476
477 let prefix = if selected { " → " } else { " " };
478
479 let mut spans = vec![Span::styled(prefix, style)];
481 if is_fork {
482 spans.push(Span::styled(
483 "[fork] ",
484 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
485 ));
486 }
487 spans.push(Span::styled(role_str, role_style));
488 spans.push(Span::raw(" "));
489 spans.push(Span::styled(format!("\"{}\"", content), style));
490
491 ListItem::new(Line::from(spans))
492}
493
494pub(crate) fn truncate_to_width(s: &str, max_width: usize) -> String {
497 if max_width == 0 {
498 return String::new();
499 }
500 let char_count = s.chars().count();
501 if char_count <= max_width {
502 s.to_string()
503 } else {
504 let truncated: String = s.chars().take(max_width.saturating_sub(3)).collect();
505 format!("{}...", truncated)
506 }
507}
508
509fn truncate_around_query(content: &str, query: &str, max_chars: usize) -> String {
513 let char_count = content.chars().count();
514
515 if char_count <= max_chars {
516 return content.to_string();
517 }
518
519 let content_lower = content.to_lowercase();
521 let query_lower = query.to_lowercase();
522
523 if let Some(byte_pos) = content_lower.find(&query_lower) {
524 let match_char_pos = content[..byte_pos].chars().count();
526
527 let context_before = max_chars / 3; let context_after = max_chars - context_before; let start_char = match_char_pos.saturating_sub(context_before);
532 let end_char = (match_char_pos + context_after).min(char_count);
533
534 let truncated: String = content
535 .chars()
536 .skip(start_char)
537 .take(end_char - start_char)
538 .collect();
539
540 let mut result = String::new();
541 if start_char > 0 {
542 result.push_str("...\n");
543 }
544 result.push_str(&truncated);
545 if end_char < char_count {
546 result.push_str("\n...(truncated)");
547 }
548 result
549 } else {
550 let truncated: String = content.chars().take(max_chars).collect();
552 format!("{}...\n(truncated)", truncated)
553 }
554}
555
556fn highlight_line<'a>(text: &'a str, query: &str) -> Line<'a> {
558 if query.is_empty() {
559 return Line::raw(text.to_string());
560 }
561
562 let query_lower = query.to_lowercase();
563 let (text_lower, lower_start_map, lower_end_map) = build_lowercase_index(text);
564
565 let highlight_style = Style::default()
566 .fg(Color::Black)
567 .bg(Color::Yellow)
568 .add_modifier(Modifier::BOLD);
569
570 let mut spans = Vec::new();
571 let mut last_end = 0;
572
573 let mut search_start = 0;
575 while let Some((match_start, match_end, next_search_start)) = find_case_insensitive_match(
576 text,
577 &text_lower,
578 &lower_start_map,
579 &lower_end_map,
580 &query_lower,
581 search_start,
582 ) {
583 if match_start > last_end {
585 spans.push(Span::raw(text[last_end..match_start].to_string()));
586 }
587
588 spans.push(Span::styled(
590 text[match_start..match_end].to_string(),
591 highlight_style,
592 ));
593
594 last_end = match_end;
595 search_start = next_search_start;
596 }
597
598 if last_end < text.len() {
600 spans.push(Span::raw(text[last_end..].to_string()));
601 }
602
603 if spans.is_empty() {
604 Line::raw(text.to_string())
605 } else {
606 Line::from(spans)
607 }
608}
609
610fn build_lowercase_index(text: &str) -> (String, Vec<Option<usize>>, Vec<Option<usize>>) {
611 let mut text_lower = String::new();
612 let mut lower_start_map = vec![None; 1];
613 let mut lower_end_map = vec![Some(0); 1];
614 let mut chars = text.char_indices().peekable();
615
616 while let Some((char_start, ch)) = chars.next() {
617 let char_end = chars.peek().map(|(idx, _)| *idx).unwrap_or(text.len());
618 let lower_start = text_lower.len();
619 let lower_chunk = ch.to_lowercase().collect::<String>();
620 text_lower.push_str(&lower_chunk);
621 let lower_end = text_lower.len();
622
623 if lower_start_map.len() <= lower_end {
624 lower_start_map.resize(lower_end + 1, None);
625 }
626 if lower_end_map.len() <= lower_end {
627 lower_end_map.resize(lower_end + 1, None);
628 }
629
630 lower_start_map[lower_start] = Some(char_start);
631 for (offset, _) in lower_chunk.char_indices().skip(1) {
632 lower_end_map[lower_start + offset] = Some(char_end);
633 }
634 lower_end_map[lower_end] = Some(char_end);
635 }
636
637 (text_lower, lower_start_map, lower_end_map)
638}
639
640fn find_case_insensitive_match(
641 text: &str,
642 text_lower: &str,
643 lower_start_map: &[Option<usize>],
644 lower_end_map: &[Option<usize>],
645 query_lower: &str,
646 mut search_start: usize,
647) -> Option<(usize, usize, usize)> {
648 while search_start <= text_lower.len() {
649 let relative_pos = text_lower[search_start..].find(query_lower)?;
650 let lower_match_start = search_start + relative_pos;
651 let lower_match_end = lower_match_start + query_lower.len();
652
653 let match_start = lower_start_map.get(lower_match_start).copied().flatten();
654 let match_end = lower_end_map.get(lower_match_end).copied().flatten();
655
656 if let (Some(match_start), Some(match_end)) = (match_start, match_end) {
657 if text.is_char_boundary(match_start) && text.is_char_boundary(match_end) {
658 return Some((match_start, match_end, lower_match_end));
659 }
660 }
661
662 let next_char = text_lower[lower_match_start..].chars().next()?;
663 search_start = lower_match_start + next_char.len_utf8();
664 }
665
666 None
667}
668
669fn render_preview(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
670 let buf = frame.buffer_mut();
673 for y in area.y..area.y + area.height {
674 for x in area.x..area.x + area.width {
675 if let Some(cell) = buf.cell_mut((x, y)) {
676 cell.set_symbol(" ");
677 cell.set_style(Style::default());
678 }
679 }
680 }
681
682 let Some(m) = app.selected_match() else {
683 let empty = Paragraph::new("")
685 .block(
686 Block::default()
687 .borders(Borders::ALL)
688 .border_style(Style::default().fg(Color::Rgb(98, 98, 255)))
689 .title("Preview"),
690 )
691 .style(Style::default().bg(Color::Reset));
692 frame.render_widget(empty, area);
693 return;
694 };
695
696 let Some(ref msg) = m.message else {
697 let empty = Paragraph::new("")
698 .block(
699 Block::default()
700 .borders(Borders::ALL)
701 .border_style(Style::default().fg(Color::Rgb(98, 98, 255)))
702 .title("Preview"),
703 )
704 .style(Style::default().bg(Color::Reset));
705 frame.render_widget(empty, area);
706 return;
707 };
708
709 let project = extract_project_from_path(&m.file_path);
710 let date_str = msg.timestamp.format("%Y-%m-%d %H:%M:%S").to_string();
711 let branch = msg.branch.clone().unwrap_or_else(|| "-".to_string());
712 let query = &app.results_query;
713
714 let mut lines = vec![
715 Line::from(format!("Session: {}", msg.session_id)),
716 Line::from(format!("Project: {} | Branch: {}", project, branch)),
717 Line::from(format!("Date: {}", date_str)),
718 Line::from(format!("Role: {}", msg.role)),
719 Line::from("─".repeat(60)),
720 Line::raw(""),
721 ];
722
723 let sanitized = sanitize_content(&msg.content);
725 let content = truncate_around_query(&sanitized, query, 2000);
726
727 for line in content.lines() {
729 lines.push(highlight_line(line, query));
730 }
731
732 let preview = Paragraph::new(lines)
733 .block(
734 Block::default()
735 .borders(Borders::ALL)
736 .border_style(Style::default().fg(Color::Rgb(98, 98, 255)))
737 .title("Preview"),
738 )
739 .style(Style::default().fg(Color::White).bg(Color::Reset))
740 .wrap(ratatui::widgets::Wrap { trim: false });
741
742 frame.render_widget(preview, area);
743}
744
745#[cfg(test)]
746mod tests {
747 use super::*;
748 use crate::search::{Message, SessionSource};
749 use chrono::{TimeZone, Utc};
750 use ratatui::backend::TestBackend;
751 use ratatui::Terminal;
752
753 fn buffer_contains(
754 buffer: &ratatui::buffer::Buffer,
755 width: u16,
756 height: u16,
757 needle: &str,
758 ) -> bool {
759 (0..height).any(|y| {
760 let mut line = String::new();
761 for x in 0..width {
762 line.push_str(buffer.cell((x, y)).unwrap().symbol());
763 }
764 line.contains(needle)
765 })
766 }
767
768 fn make_test_app_with_groups() -> App {
769 let mut app = App::new(vec!["/test".to_string()]);
770
771 let msg = Message {
772 session_id: "test-session".to_string(),
773 role: "user".to_string(),
774 content: "Test content for preview".to_string(),
775 timestamp: Utc.with_ymd_and_hms(2025, 1, 9, 10, 0, 0).unwrap(),
776 branch: Some("main".to_string()),
777 line_number: 1,
778 uuid: None,
779 parent_uuid: None,
780 };
781
782 let m = RipgrepMatch {
783 file_path: "/path/to/projects/-Users-test-projects-myapp/session.jsonl".to_string(),
784 message: Some(msg),
785 source: SessionSource::ClaudeCodeCLI,
786 };
787
788 app.groups = vec![SessionGroup {
789 session_id: "test-session".to_string(),
790 file_path: m.file_path.clone(),
791 matches: vec![m],
792 automation: None,
793 }];
794 app.results_query = "test".to_string();
795
796 app
797 }
798
799 #[test]
800 fn test_truncate_around_query_short_content() {
801 let content = "Short content with adb in it";
802 let result = truncate_around_query(content, "adb", 100);
803 assert_eq!(result, content); }
805
806 #[test]
807 fn test_truncate_around_query_centers_on_match() {
808 let prefix = "x".repeat(500);
810 let suffix = "y".repeat(500);
811 let content = format!("{}adb{}", prefix, suffix);
812
813 let result = truncate_around_query(&content, "adb", 100);
814
815 assert!(result.contains("adb"), "Result should contain the query");
817 assert!(result.contains("..."), "Result should show truncation");
819 }
820
821 #[test]
822 fn test_truncate_around_query_at_end() {
823 let prefix = "x".repeat(1000);
825 let content = format!("{}adb", prefix);
826
827 let result = truncate_around_query(&content, "adb", 100);
828
829 assert!(result.contains("adb"), "Result should contain the query");
830 }
831
832 #[test]
833 fn test_truncate_around_query_not_found() {
834 let content = "x".repeat(500);
835 let result = truncate_around_query(&content, "notfound", 100);
836
837 assert!(result.len() <= 120); assert!(result.contains("truncated"));
840 }
841
842 #[test]
843 fn test_highlight_line_basic() {
844 let line = highlight_line("Hello world", "world");
845 assert_eq!(line.spans.len(), 2); }
847
848 #[test]
849 fn test_highlight_line_case_insensitive() {
850 let line = highlight_line("Hello WORLD", "world");
851 assert_eq!(line.spans.len(), 2); }
853
854 #[test]
855 fn test_highlight_line_multiple_matches() {
856 let line = highlight_line("adb shell adb devices", "adb");
857 assert_eq!(line.spans.len(), 4); }
859
860 #[test]
861 fn test_highlight_line_no_match() {
862 let line = highlight_line("Hello world", "xyz");
863 assert_eq!(line.spans.len(), 1); }
865
866 #[test]
867 fn test_highlight_line_empty_query() {
868 let line = highlight_line("Hello world", "");
869 assert_eq!(line.spans.len(), 1);
870 }
871
872 #[test]
873 fn test_highlight_line_handles_unicode_lowercase_expansion() {
874 let line = highlight_line("İstanbul", "i");
875 assert_eq!(line.spans.len(), 2);
876 assert_eq!(line.spans[0].content.as_ref(), "İ");
877 assert_eq!(line.spans[1].content.as_ref(), "stanbul");
878 }
879
880 #[test]
881 fn test_render_does_not_panic() {
882 let backend = TestBackend::new(80, 24);
883 let mut terminal = Terminal::new(backend).unwrap();
884
885 let mut app = App::new(vec!["/test".to_string()]);
886
887 terminal
888 .draw(|frame| render(frame, &mut app))
889 .expect("Render should not panic");
890 }
891
892 #[test]
893 fn test_render_with_groups() {
894 let backend = TestBackend::new(80, 24);
895 let mut terminal = Terminal::new(backend).unwrap();
896
897 let mut app = make_test_app_with_groups();
898
899 terminal
900 .draw(|frame| render(frame, &mut app))
901 .expect("Render with groups should not panic");
902 }
903
904 #[test]
905 fn test_render_preview_mode() {
906 let backend = TestBackend::new(80, 24);
907 let mut terminal = Terminal::new(backend).unwrap();
908
909 let mut app = make_test_app_with_groups();
910 app.preview_mode = true;
911
912 terminal
913 .draw(|frame| render(frame, &mut app))
914 .expect("Preview mode render should not panic");
915 }
916
917 #[test]
918 fn test_render_toggle_preview_clears_area() {
919 let backend = TestBackend::new(80, 24);
920 let mut terminal = Terminal::new(backend).unwrap();
921
922 let mut app = make_test_app_with_groups();
923
924 terminal
926 .draw(|frame| render(frame, &mut app))
927 .expect("Normal render should not panic");
928
929 app.preview_mode = true;
931 terminal
932 .draw(|frame| render(frame, &mut app))
933 .expect("Preview render should not panic");
934
935 app.preview_mode = false;
937 terminal
938 .draw(|frame| render(frame, &mut app))
939 .expect("Toggle back render should not panic");
940
941 let buffer = terminal.backend().buffer();
943
944 for cell in buffer.content() {
946 let ch = cell.symbol();
947 for c in ch.chars() {
950 assert!(
951 !c.is_control() || c.is_whitespace(),
952 "Unexpected control character in buffer: {:?} (U+{:04X})",
953 ch,
954 c as u32
955 );
956 }
957 }
958 }
959
960 #[test]
961 fn test_render_expanded_group() {
962 let backend = TestBackend::new(80, 24);
963 let mut terminal = Terminal::new(backend).unwrap();
964
965 let mut app = make_test_app_with_groups();
966 app.expanded = true;
967
968 terminal
969 .draw(|frame| render(frame, &mut app))
970 .expect("Expanded group render should not panic");
971 }
972
973 #[test]
974 fn test_render_with_cyrillic_content() {
975 let backend = TestBackend::new(80, 24);
976 let mut terminal = Terminal::new(backend).unwrap();
977
978 let mut app = App::new(vec!["/test".to_string()]);
979
980 let msg = Message {
981 session_id: "test-session".to_string(),
982 role: "user".to_string(),
983 content: "Сделаю: 1. Preview режим 2. Индикатор compacted".to_string(),
984 timestamp: Utc.with_ymd_and_hms(2025, 1, 9, 10, 0, 0).unwrap(),
985 branch: Some("main".to_string()),
986 line_number: 1,
987 uuid: None,
988 parent_uuid: None,
989 };
990
991 let m = RipgrepMatch {
992 file_path: "/path/to/session.jsonl".to_string(),
993 message: Some(msg),
994 source: SessionSource::ClaudeCodeCLI,
995 };
996
997 app.groups = vec![SessionGroup {
998 session_id: "test-session".to_string(),
999 file_path: m.file_path.clone(),
1000 matches: vec![m],
1001 automation: None,
1002 }];
1003 app.preview_mode = true;
1004
1005 terminal
1006 .draw(|frame| render(frame, &mut app))
1007 .expect("Cyrillic content render should not panic");
1008 }
1009
1010 #[test]
1011 fn test_render_navigation_clears_properly() {
1012 let backend = TestBackend::new(80, 24);
1013 let mut terminal = Terminal::new(backend).unwrap();
1014
1015 let mut app = App::new(vec!["/test".to_string()]);
1016
1017 for i in 0..3 {
1019 let msg = Message {
1020 session_id: format!("session-{}", i),
1021 role: "user".to_string(),
1022 content: format!("Content for session {}", i),
1023 timestamp: Utc.with_ymd_and_hms(2025, 1, 9, 10, i as u32, 0).unwrap(),
1024 branch: Some("main".to_string()),
1025 line_number: 1,
1026 uuid: None,
1027 parent_uuid: None,
1028 };
1029
1030 let m = RipgrepMatch {
1031 file_path: format!(
1032 "/path/to/projects/-Users-test-projects-app{}/session.jsonl",
1033 i
1034 ),
1035 message: Some(msg),
1036 source: SessionSource::ClaudeCodeCLI,
1037 };
1038
1039 app.groups.push(SessionGroup {
1040 session_id: format!("session-{}", i),
1041 file_path: m.file_path.clone(),
1042 matches: vec![m],
1043 automation: None,
1044 });
1045 }
1046
1047 terminal.draw(|frame| render(frame, &mut app)).unwrap();
1049 app.on_down();
1050 terminal.draw(|frame| render(frame, &mut app)).unwrap();
1051 app.on_down();
1052 terminal.draw(|frame| render(frame, &mut app)).unwrap();
1053 app.on_up();
1054 terminal.draw(|frame| render(frame, &mut app)).unwrap();
1055
1056 }
1058
1059 #[test]
1062 fn test_preview_large_to_small_content_no_artifacts() {
1063 let backend = TestBackend::new(80, 24);
1064 let mut terminal = Terminal::new(backend).unwrap();
1065
1066 let mut app = App::new(vec!["/test".to_string()]);
1067
1068 let large_content = (0..100)
1070 .map(|i| format!("Line {}: This is a long line of text that fills the terminal width with content", i))
1071 .collect::<Vec<_>>()
1072 .join("\n");
1073
1074 let large_msg = Message {
1075 session_id: "test-session".to_string(),
1076 role: "assistant".to_string(),
1077 content: large_content,
1078 timestamp: Utc.with_ymd_and_hms(2025, 1, 9, 10, 0, 0).unwrap(),
1079 branch: Some("main".to_string()),
1080 line_number: 1,
1081 uuid: None,
1082 parent_uuid: None,
1083 };
1084
1085 let small_msg = Message {
1087 session_id: "test-session".to_string(),
1088 role: "user".to_string(),
1089 content: "Short".to_string(),
1090 timestamp: Utc.with_ymd_and_hms(2025, 1, 9, 10, 1, 0).unwrap(),
1091 branch: Some("main".to_string()),
1092 line_number: 2,
1093 uuid: None,
1094 parent_uuid: None,
1095 };
1096
1097 let large_match = RipgrepMatch {
1098 file_path: "/path/to/projects/-Users-test-projects-myapp/session.jsonl".to_string(),
1099 message: Some(large_msg),
1100 source: SessionSource::ClaudeCodeCLI,
1101 };
1102
1103 let small_match = RipgrepMatch {
1104 file_path: "/path/to/projects/-Users-test-projects-myapp/session.jsonl".to_string(),
1105 message: Some(small_msg),
1106 source: SessionSource::ClaudeCodeCLI,
1107 };
1108
1109 app.groups = vec![SessionGroup {
1111 session_id: "test-session".to_string(),
1112 file_path: large_match.file_path.clone(),
1113 matches: vec![large_match, small_match],
1114 automation: None,
1115 }];
1116 app.results_query = "test".to_string();
1117
1118 app.preview_mode = true;
1120 app.expanded = true;
1121 app.sub_cursor = 0; terminal.draw(|frame| render(frame, &mut app)).unwrap();
1125
1126 app.sub_cursor = 1;
1128 terminal.draw(|frame| render(frame, &mut app)).unwrap();
1129
1130 let buffer = terminal.backend().buffer();
1132
1133 for cell in buffer.content() {
1134 let ch = cell.symbol();
1135 for c in ch.chars() {
1136 assert!(
1137 !c.is_control() || c.is_whitespace(),
1138 "Artifact found in buffer: {:?} (U+{:04X})",
1139 ch,
1140 c as u32
1141 );
1142 }
1143 }
1144
1145 let mut non_empty_lines_after_content = 0;
1147 for y in 15..23 {
1148 let mut line_content = String::new();
1149 for x in 0..80 {
1150 let cell = buffer.cell((x, y)).unwrap();
1151 line_content.push_str(cell.symbol());
1152 }
1153 let trimmed = line_content.trim();
1154 if !trimmed.is_empty()
1155 && trimmed != "│"
1156 && !trimmed.chars().all(|c| c == '│' || c == ' ')
1157 {
1158 non_empty_lines_after_content += 1;
1159 }
1160 }
1161
1162 assert!(
1163 non_empty_lines_after_content <= 2,
1164 "Found {} non-empty lines after small content - possible artifacts",
1165 non_empty_lines_after_content
1166 );
1167 }
1168
1169 #[test]
1171 fn test_preview_navigation_varying_sizes_no_artifacts() {
1172 let backend = TestBackend::new(80, 24);
1173 let mut terminal = Terminal::new(backend).unwrap();
1174
1175 let mut app = App::new(vec!["/test".to_string()]);
1176
1177 let sizes = [
1179 (
1180 "Large message with lots of content\n".repeat(50),
1181 "assistant",
1182 ),
1183 ("Tiny".to_string(), "user"),
1184 (
1185 "Medium sized message with some content\n".repeat(10),
1186 "assistant",
1187 ),
1188 ("X".to_string(), "user"),
1189 ];
1190
1191 let mut matches = Vec::new();
1192 for (i, (content, role)) in sizes.iter().enumerate() {
1193 let msg = Message {
1194 session_id: "test-session".to_string(),
1195 role: role.to_string(),
1196 content: content.to_string(),
1197 timestamp: Utc.with_ymd_and_hms(2025, 1, 9, 10, i as u32, 0).unwrap(),
1198 branch: Some("main".to_string()),
1199 line_number: i + 1,
1200 uuid: None,
1201 parent_uuid: None,
1202 };
1203 matches.push(RipgrepMatch {
1204 file_path: "/path/to/projects/-Users-test-projects-app/session.jsonl".to_string(),
1205 message: Some(msg),
1206 source: SessionSource::ClaudeCodeCLI,
1207 });
1208 }
1209
1210 app.groups = vec![SessionGroup {
1211 session_id: "test-session".to_string(),
1212 file_path: "/path/to/projects/-Users-test-projects-app/session.jsonl".to_string(),
1213 matches,
1214 automation: None,
1215 }];
1216 app.results_query = "test".to_string();
1217 app.preview_mode = true;
1218 app.expanded = true;
1219
1220 for i in 0..4 {
1222 app.sub_cursor = i;
1223 terminal.draw(|frame| render(frame, &mut app)).unwrap();
1224
1225 let buffer = terminal.backend().buffer();
1226
1227 for cell in buffer.content() {
1229 let ch = cell.symbol();
1230 for c in ch.chars() {
1231 assert!(
1232 !c.is_control() || c.is_whitespace(),
1233 "Artifact at message {} in buffer: {:?} (U+{:04X})",
1234 i,
1235 ch,
1236 c as u32
1237 );
1238 }
1239 }
1240 }
1241
1242 for i in (0..4).rev() {
1244 app.sub_cursor = i;
1245 terminal.draw(|frame| render(frame, &mut app)).unwrap();
1246
1247 let buffer = terminal.backend().buffer();
1248
1249 for cell in buffer.content() {
1250 let ch = cell.symbol();
1251 for c in ch.chars() {
1252 assert!(
1253 !c.is_control() || c.is_whitespace(),
1254 "Artifact (reverse nav) at message {} in buffer: {:?} (U+{:04X})",
1255 i,
1256 ch,
1257 c as u32
1258 );
1259 }
1260 }
1261 }
1262 }
1263
1264 #[test]
1266 fn test_preview_realistic_tool_output_no_artifacts() {
1267 let backend = TestBackend::new(100, 30);
1268 let mut terminal = Terminal::new(backend).unwrap();
1269
1270 let mut app = App::new(vec!["/test".to_string()]);
1271
1272 let tool_output = r#"12-11 15:25:07.603 211 215 E android.system.suspend@1.0-service: Error opening kernel wakelock stats for: wakeup34: Permission denied
127412-11 15:25:07.603 211 215 E android.system.suspend@1.0-service: Error opening kernel wakelock stats for: wakeup35: Permission denied
127512-11 15:26:16.284 6931 6931 E AndroidRuntime: FATAL EXCEPTION: main
127612-11 15:26:16.284 6931 6931 E AndroidRuntime: Process: com.avito.android.dev, PID: 6931
127712-11 15:26:16.284 6931 6931 E AndroidRuntime: java.lang.RuntimeException: Unable to start activity
127812-11 15:26:16.284 6931 6931 E AndroidRuntime: at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3449)
127912-11 15:26:16.284 6931 6931 E AndroidRuntime: at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3601)
128012-11 15:26:16.284 6931 6931 E AndroidRuntime: at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:85)"#;
1281
1282 let large_msg = Message {
1283 session_id: "test-session".to_string(),
1284 role: "assistant".to_string(),
1285 content: format!("[tool_result]\n{}\n[/tool_result]", tool_output.repeat(5)),
1286 timestamp: Utc.with_ymd_and_hms(2025, 1, 9, 10, 0, 0).unwrap(),
1287 branch: Some("main".to_string()),
1288 line_number: 1,
1289 uuid: None,
1290 parent_uuid: None,
1291 };
1292
1293 let small_msg = Message {
1295 session_id: "test-session".to_string(),
1296 role: "assistant".to_string(),
1297 content: "Вижу ключевую строку.".to_string(),
1298 timestamp: Utc.with_ymd_and_hms(2025, 1, 9, 10, 1, 0).unwrap(),
1299 branch: Some("main".to_string()),
1300 line_number: 2,
1301 uuid: None,
1302 parent_uuid: None,
1303 };
1304
1305 app.groups = vec![SessionGroup {
1306 session_id: "test-session".to_string(),
1307 file_path: "/path/to/session.jsonl".to_string(),
1308 matches: vec![
1309 RipgrepMatch {
1310 file_path: "/path/to/session.jsonl".to_string(),
1311 message: Some(large_msg),
1312 source: SessionSource::ClaudeCodeCLI,
1313 },
1314 RipgrepMatch {
1315 file_path: "/path/to/session.jsonl".to_string(),
1316 message: Some(small_msg),
1317 source: SessionSource::ClaudeCodeCLI,
1318 },
1319 ],
1320 automation: None,
1321 }];
1322 app.results_query = "test".to_string();
1323 app.preview_mode = true;
1324 app.expanded = true;
1325
1326 app.sub_cursor = 0;
1328 terminal.draw(|frame| render(frame, &mut app)).unwrap();
1329
1330 app.sub_cursor = 1;
1332 terminal.draw(|frame| render(frame, &mut app)).unwrap();
1333
1334 let buffer = terminal.backend().buffer();
1335
1336 for y in 15..25 {
1338 let mut line_content = String::new();
1339 for x in 1..99 {
1340 let cell = buffer.cell((x, y)).unwrap();
1341 line_content.push_str(cell.symbol());
1342 }
1343 let trimmed = line_content.trim();
1344
1345 if trimmed.contains("android")
1346 || trimmed.contains("Exception")
1347 || trimmed.contains("12-11")
1348 {
1349 panic!(
1350 "Leftover content from large render on line {}: {:?}",
1351 y, trimmed
1352 );
1353 }
1354 }
1355
1356 for cell in buffer.content() {
1358 let ch = cell.symbol();
1359 for c in ch.chars() {
1360 assert!(
1361 !c.is_control() || c.is_whitespace(),
1362 "Control char artifact: {:?} (U+{:04X})",
1363 ch,
1364 c as u32
1365 );
1366 }
1367 }
1368 }
1369
1370 #[test]
1372 fn test_preview_ansi_content_no_artifacts() {
1373 let backend = TestBackend::new(80, 24);
1374 let mut terminal = Terminal::new(backend).unwrap();
1375
1376 let mut app = App::new(vec!["/test".to_string()]);
1377
1378 let ansi_content = "\x1b[31mE/AndroidRuntime\x1b[0m: FATAL EXCEPTION: main\n\
1380 \x1b[33mProcess: com.example.app\x1b[0m\n\
1381 \x1b[32mjava.lang.NullPointerException\x1b[0m\n\
1382 \x1b[34m at com.example.MainActivity.onCreate\x1b[0m\n\
1383 \x1b[2J\x1b[H\n\
1384 \x1b[?25l\x1b[?25h\n\
1385 Normal text after escapes";
1386
1387 let ansi_msg = Message {
1388 session_id: "test-session".to_string(),
1389 role: "assistant".to_string(),
1390 content: ansi_content.to_string(),
1391 timestamp: Utc.with_ymd_and_hms(2025, 1, 9, 10, 0, 0).unwrap(),
1392 branch: Some("main".to_string()),
1393 line_number: 1,
1394 uuid: None,
1395 parent_uuid: None,
1396 };
1397
1398 let small_msg = Message {
1399 session_id: "test-session".to_string(),
1400 role: "user".to_string(),
1401 content: "ok".to_string(),
1402 timestamp: Utc.with_ymd_and_hms(2025, 1, 9, 10, 1, 0).unwrap(),
1403 branch: Some("main".to_string()),
1404 line_number: 2,
1405 uuid: None,
1406 parent_uuid: None,
1407 };
1408
1409 app.groups = vec![SessionGroup {
1410 session_id: "test-session".to_string(),
1411 file_path: "/path/to/session.jsonl".to_string(),
1412 matches: vec![
1413 RipgrepMatch {
1414 file_path: "/path/to/session.jsonl".to_string(),
1415 message: Some(ansi_msg),
1416 source: SessionSource::ClaudeCodeCLI,
1417 },
1418 RipgrepMatch {
1419 file_path: "/path/to/session.jsonl".to_string(),
1420 message: Some(small_msg),
1421 source: SessionSource::ClaudeCodeCLI,
1422 },
1423 ],
1424 automation: None,
1425 }];
1426 app.results_query = "test".to_string();
1427 app.preview_mode = true;
1428 app.expanded = true;
1429
1430 app.sub_cursor = 0;
1432 terminal.draw(|frame| render(frame, &mut app)).unwrap();
1433
1434 app.sub_cursor = 1;
1436 terminal.draw(|frame| render(frame, &mut app)).unwrap();
1437
1438 let buffer = terminal.backend().buffer();
1440 for cell in buffer.content() {
1441 let ch = cell.symbol();
1442 for c in ch.chars() {
1443 assert!(
1444 !c.is_control() || c.is_whitespace(),
1445 "ANSI artifact in buffer: {:?} (U+{:04X})",
1446 ch,
1447 c as u32
1448 );
1449 assert!(
1451 c != '\x1b',
1452 "ESC character found in buffer - ANSI sequence not stripped"
1453 );
1454 }
1455 }
1456 }
1457
1458 #[test]
1459 fn test_build_group_header_shows_cli_source() {
1460 let msg = Message {
1461 session_id: "test-session".to_string(),
1462 role: "user".to_string(),
1463 content: "Test content".to_string(),
1464 timestamp: Utc.with_ymd_and_hms(2025, 1, 9, 10, 0, 0).unwrap(),
1465 branch: Some("main".to_string()),
1466 line_number: 1,
1467 uuid: None,
1468 parent_uuid: None,
1469 };
1470
1471 let m = RipgrepMatch {
1472 file_path: "/Users/test/.claude/projects/-Users-test-myapp/session.jsonl".to_string(),
1473 message: Some(msg),
1474 source: SessionSource::ClaudeCodeCLI,
1475 };
1476
1477 let group = SessionGroup {
1478 session_id: "test-session".to_string(),
1479 file_path: m.file_path.clone(),
1480 matches: vec![m],
1481 automation: None,
1482 };
1483
1484 let text = build_group_header_text(&group, false);
1485 assert!(
1486 text.contains("[CLI]"),
1487 "Header should contain [CLI] indicator, got: {}",
1488 text
1489 );
1490 }
1491
1492 #[test]
1493 fn test_build_group_header_shows_desktop_source() {
1494 let msg = Message {
1495 session_id: "test-session".to_string(),
1496 role: "user".to_string(),
1497 content: "Test content".to_string(),
1498 timestamp: Utc.with_ymd_and_hms(2025, 1, 9, 10, 0, 0).unwrap(),
1499 branch: Some("main".to_string()),
1500 line_number: 1,
1501 uuid: None,
1502 parent_uuid: None,
1503 };
1504
1505 let m = RipgrepMatch {
1506 file_path: "/Users/test/Library/Application Support/Claude/local-agent-mode-sessions/uuid/uuid/local_123/audit.jsonl".to_string(),
1507 message: Some(msg),
1508 source: SessionSource::ClaudeDesktop,
1509 };
1510
1511 let group = SessionGroup {
1512 session_id: "test-session".to_string(),
1513 file_path: m.file_path.clone(),
1514 matches: vec![m],
1515 automation: None,
1516 };
1517
1518 let text = build_group_header_text(&group, false);
1519 assert!(
1520 text.contains("[Desktop]"),
1521 "Header should contain [Desktop] indicator, got: {}",
1522 text
1523 );
1524 }
1525
1526 #[test]
1527 fn test_render_recent_sessions_loading() {
1528 let backend = TestBackend::new(80, 24);
1529 let mut terminal = Terminal::new(backend).unwrap();
1530
1531 let mut app = App::new(vec!["/test".to_string()]);
1532 app.recent_loading = true;
1533
1534 terminal
1535 .draw(|frame| render(frame, &mut app))
1536 .expect("Render with loading recent sessions should not panic");
1537
1538 let buffer = terminal.backend().buffer();
1539 let mut found_loading = false;
1540 for y in 0..24 {
1541 let mut line = String::new();
1542 for x in 0..80 {
1543 line.push_str(buffer.cell((x, y)).unwrap().symbol());
1544 }
1545 if line.contains("Loading recent sessions") {
1546 found_loading = true;
1547 break;
1548 }
1549 }
1550 assert!(found_loading, "Should show loading indicator");
1551 }
1552
1553 #[test]
1554 fn test_render_recent_sessions_empty() {
1555 let backend = TestBackend::new(80, 24);
1556 let mut terminal = Terminal::new(backend).unwrap();
1557
1558 let mut app = App::new(vec!["/test".to_string()]);
1559 app.recent_loading = false;
1560 app.recent_load_rx = None;
1561
1562 terminal
1563 .draw(|frame| render(frame, &mut app))
1564 .expect("Render with empty recent sessions should not panic");
1565
1566 let buffer = terminal.backend().buffer();
1567 let mut found_empty = false;
1568 for y in 0..24 {
1569 let mut line = String::new();
1570 for x in 0..80 {
1571 line.push_str(buffer.cell((x, y)).unwrap().symbol());
1572 }
1573 if line.contains("No recent sessions found") {
1574 found_empty = true;
1575 break;
1576 }
1577 }
1578 assert!(found_empty, "Should show empty state message");
1579 }
1580
1581 #[test]
1582 fn test_render_recent_sessions_with_data() {
1583 use crate::recent::RecentSession;
1584 use chrono::TimeZone;
1585
1586 let backend = TestBackend::new(100, 24);
1587 let mut terminal = Terminal::new(backend).unwrap();
1588
1589 let mut app = App::new(vec!["/test".to_string()]);
1590 app.recent_loading = false;
1591 app.recent_load_rx = None;
1592 app.recent_sessions = vec![
1593 RecentSession {
1594 session_id: "sess-1".to_string(),
1595 file_path: "/test/session1.jsonl".to_string(),
1596 project: "my-project".to_string(),
1597 source: SessionSource::ClaudeCodeCLI,
1598 timestamp: Utc.with_ymd_and_hms(2025, 6, 1, 10, 30, 0).unwrap(),
1599 summary: "Fix the login bug".to_string(),
1600 automation: None,
1601 },
1602 RecentSession {
1603 session_id: "sess-2".to_string(),
1604 file_path: "/test/session2.jsonl".to_string(),
1605 project: "other-app".to_string(),
1606 source: SessionSource::ClaudeCodeCLI,
1607 timestamp: Utc.with_ymd_and_hms(2025, 5, 31, 9, 0, 0).unwrap(),
1608 summary: "Add new feature".to_string(),
1609 automation: None,
1610 },
1611 ];
1612
1613 terminal
1614 .draw(|frame| render(frame, &mut app))
1615 .expect("Render with recent sessions should not panic");
1616
1617 let buffer = terminal.backend().buffer();
1618 let mut found_project = false;
1619 let mut found_summary = false;
1620 let mut found_status = false;
1621 for y in 0..24 {
1622 let mut line = String::new();
1623 for x in 0..100 {
1624 line.push_str(buffer.cell((x, y)).unwrap().symbol());
1625 }
1626 if line.contains("my-project") {
1627 found_project = true;
1628 }
1629 if line.contains("Fix the login bug") {
1630 found_summary = true;
1631 }
1632 if line.contains("2 recent sessions") {
1633 found_status = true;
1634 }
1635 }
1636 assert!(found_project, "Should show project name");
1637 assert!(found_summary, "Should show session summary");
1638 assert!(found_status, "Should show session count in status bar");
1639 }
1640
1641 #[test]
1642 fn test_render_recent_sessions_help_bar() {
1643 use crate::recent::RecentSession;
1644 use chrono::TimeZone;
1645
1646 let backend = TestBackend::new(100, 24);
1647 let mut terminal = Terminal::new(backend).unwrap();
1648
1649 let mut app = App::new(vec!["/test".to_string()]);
1650 app.recent_loading = false;
1651 app.recent_load_rx = None;
1652 app.recent_sessions = vec![RecentSession {
1653 session_id: "sess-1".to_string(),
1654 file_path: "/test/session1.jsonl".to_string(),
1655 project: "proj".to_string(),
1656 source: SessionSource::ClaudeCodeCLI,
1657 timestamp: Utc.with_ymd_and_hms(2025, 6, 1, 10, 0, 0).unwrap(),
1658 summary: "hello".to_string(),
1659 automation: None,
1660 }];
1661
1662 terminal
1663 .draw(|frame| render(frame, &mut app))
1664 .expect("Render should not panic");
1665
1666 let buffer = terminal.backend().buffer();
1668 let mut last_line = String::new();
1669 for x in 0..100 {
1670 last_line.push_str(buffer.cell((x, 23)).unwrap().symbol());
1671 }
1672 assert!(
1673 last_line.contains("Navigate")
1674 && last_line.contains("Resume")
1675 && last_line.contains("Tree"),
1676 "Help bar should show recent session keybindings, got: {}",
1677 last_line.trim()
1678 );
1679 }
1680
1681 #[test]
1682 fn test_render_search_status_reports_hidden_groups() {
1683 let backend = TestBackend::new(100, 24);
1684 let mut terminal = Terminal::new(backend).unwrap();
1685
1686 let mut app = App::new(vec!["/test".to_string()]);
1687 app.results_query = "later".to_string();
1688 app.results = vec![RipgrepMatch {
1689 file_path: "/test/session.jsonl".to_string(),
1690 message: Some(Message {
1691 session_id: "sess-1".to_string(),
1692 role: "assistant".to_string(),
1693 content: "Later answer".to_string(),
1694 timestamp: Utc.with_ymd_and_hms(2025, 6, 1, 10, 0, 0).unwrap(),
1695 branch: None,
1696 line_number: 1,
1697 uuid: None,
1698 parent_uuid: None,
1699 }),
1700 source: SessionSource::ClaudeCodeCLI,
1701 }];
1702 app.all_groups = vec![SessionGroup {
1703 session_id: "sess-1".to_string(),
1704 file_path: "/test/session.jsonl".to_string(),
1705 matches: app.results.clone(),
1706 automation: Some("ralphex".to_string()),
1707 }];
1708 app.groups = vec![];
1709
1710 terminal
1711 .draw(|frame| render(frame, &mut app))
1712 .expect("Render with hidden groups should not panic");
1713
1714 assert!(buffer_contains(
1715 terminal.backend().buffer(),
1716 100,
1717 24,
1718 "Found 1 matches in 0 sessions (all hidden by filter)"
1719 ));
1720 }
1721
1722 #[test]
1723 fn test_render_recent_sessions_status_reports_hidden_sessions() {
1724 use crate::recent::RecentSession;
1725
1726 let backend = TestBackend::new(100, 24);
1727 let mut terminal = Terminal::new(backend).unwrap();
1728
1729 let mut app = App::new(vec!["/test".to_string()]);
1730 app.recent_loading = false;
1731 app.recent_load_rx = None;
1732 app.automation_filter = crate::tui::state::AutomationFilter::Manual;
1733 app.all_recent_sessions = vec![RecentSession {
1734 session_id: "sess-1".to_string(),
1735 file_path: "/test/session1.jsonl".to_string(),
1736 project: "proj".to_string(),
1737 source: SessionSource::ClaudeCodeCLI,
1738 timestamp: Utc.with_ymd_and_hms(2025, 6, 1, 10, 0, 0).unwrap(),
1739 summary: "Automated session".to_string(),
1740 automation: Some("ralphex".to_string()),
1741 }];
1742 app.recent_sessions = vec![];
1743
1744 terminal
1745 .draw(|frame| render(frame, &mut app))
1746 .expect("Render with hidden recent sessions should not panic");
1747
1748 assert!(buffer_contains(
1749 terminal.backend().buffer(),
1750 100,
1751 24,
1752 "0 recent sessions (1 hidden by filter)"
1753 ));
1754 }
1755}