Skip to main content

ccs/tui/
render_search.rs

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    // Header
93    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    // Input
105    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    // Place native terminal cursor at cursor_pos (inside the border: +1 for border offset)
140    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    // Status
144    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    // List of results (grouped view)
177    if app.preview_mode {
178        render_preview(frame, app, list_area);
179    } else {
180        render_groups(frame, app, list_area);
181    }
182
183    // Help — show current filter mode inline with color
184    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    // Clear the area by filling with spaces - more reliable than Clear widget
240    // This handles wide Unicode characters better
241    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    // Show recent sessions when input is empty and no search results
252    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        // Group header
264        let header = render_group_header(group, is_selected, is_expanded);
265        items.push(header);
266
267        // If expanded, show individual messages
268        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    // Loading and empty states are shown in the status bar only
285    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    // Compute visible window based on cursor position and persist back to state
294    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        // Reserve space: "  " prefix + date (16) + "  " + project + "  " + summary
312        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
368/// Build the header text for a session group (testable function)
369pub(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        // Sanitize content before extracting context to remove ANSI codes
451        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    // Determine if this message is on a fork (not on the latest chain)
459    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    // Build the line with styled spans
480    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
494/// Truncate a string to fit within max_width display columns.
495/// Uses char count as approximation (accurate for ASCII/Latin/Cyrillic).
496pub(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
509/// Truncate content around the first query match so the match is visible
510/// If content is shorter than max_chars, returns it unchanged
511/// If query is not found, truncates from the beginning
512fn 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    // Find the first occurrence of query (case-insensitive)
520    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        // Convert byte position to character position
525        let match_char_pos = content[..byte_pos].chars().count();
526
527        // Calculate how much context to show before and after
528        let context_before = max_chars / 3; // ~33% before match
529        let context_after = max_chars - context_before; // ~67% after match
530
531        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        // Query not found, truncate from beginning
551        let truncated: String = content.chars().take(max_chars).collect();
552        format!("{}...\n(truncated)", truncated)
553    }
554}
555
556/// Highlight query matches in a line, returning a Line with styled spans
557fn 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    // Find all occurrences of query (case-insensitive)
574    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        // Add text before the match
584        if match_start > last_end {
585            spans.push(Span::raw(text[last_end..match_start].to_string()));
586        }
587
588        // Add highlighted match (preserving original case)
589        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    // Add remaining text after last match
599    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    // Clear the area by filling with spaces - more reliable than Clear widget
671    // This handles wide Unicode characters better
672    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        // Render empty block if no match selected
684        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    // Content - sanitize to remove ANSI escape codes, then truncate around query match
724    let sanitized = sanitize_content(&msg.content);
725    let content = truncate_around_query(&sanitized, query, 2000);
726
727    // Add content lines with query highlighting
728    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); // No truncation needed
804    }
805
806    #[test]
807    fn test_truncate_around_query_centers_on_match() {
808        // Create long content with "adb" in the middle
809        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        // Result should contain "adb"
816        assert!(result.contains("adb"), "Result should contain the query");
817        // Result should be truncated
818        assert!(result.contains("..."), "Result should show truncation");
819    }
820
821    #[test]
822    fn test_truncate_around_query_at_end() {
823        // Create long content with "adb" at the end
824        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        // Should truncate from beginning
838        assert!(result.len() <= 120); // 100 chars + "...\n(truncated)"
839        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); // "Hello " and "world"
846    }
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); // "Hello " and "WORLD"
852    }
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); // "adb", " shell ", "adb", " devices"
858    }
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); // just "Hello world"
864    }
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        // First render normal mode
925        terminal
926            .draw(|frame| render(frame, &mut app))
927            .expect("Normal render should not panic");
928
929        // Toggle to preview
930        app.preview_mode = true;
931        terminal
932            .draw(|frame| render(frame, &mut app))
933            .expect("Preview render should not panic");
934
935        // Toggle back to normal
936        app.preview_mode = false;
937        terminal
938            .draw(|frame| render(frame, &mut app))
939            .expect("Toggle back render should not panic");
940
941        // The buffer should have valid content without artifacts
942        let buffer = terminal.backend().buffer();
943
944        // Check that there are no obvious artifacts (NUL or other control chars)
945        for cell in buffer.content() {
946            let ch = cell.symbol();
947            // Valid chars: printable chars (including Unicode), whitespace
948            // Invalid: NUL bytes, control characters
949            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        // Create multiple groups
1018        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        // Navigate through groups
1048        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        // All renders should succeed without artifacts
1057    }
1058
1059    /// Test for bug: navigating from large content to small content in preview mode
1060    /// leaves artifacts (scattered characters) on screen.
1061    #[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        // Create a large content message (simulating tool_result with many lines)
1069        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        // Create a small content message
1086        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        // Single group with both messages
1110        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        // Enter preview mode on large content
1119        app.preview_mode = true;
1120        app.expanded = true;
1121        app.sub_cursor = 0; // Start on large message
1122
1123        // Render with large content
1124        terminal.draw(|frame| render(frame, &mut app)).unwrap();
1125
1126        // Navigate down to small content
1127        app.sub_cursor = 1;
1128        terminal.draw(|frame| render(frame, &mut app)).unwrap();
1129
1130        // Check buffer for artifacts
1131        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        // Additional check: after small content, most lines should be empty
1146        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 navigating through multiple messages of varying sizes in preview mode
1170    #[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        // Create messages with varying sizes: large, small, medium, tiny
1178        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        // Navigate through all messages, checking buffer after each
1221        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            // Check for control character artifacts
1228            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        // Navigate backwards and check again
1243        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 with realistic tool_use content (like adb logcat output)
1265    #[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        // Realistic adb logcat output (what the user was viewing)
1273        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        // Small follow-up message (Cyrillic like in user's session)
1294        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        // Render large tool output
1327        app.sub_cursor = 0;
1328        terminal.draw(|frame| render(frame, &mut app)).unwrap();
1329
1330        // Navigate to small content
1331        app.sub_cursor = 1;
1332        terminal.draw(|frame| render(frame, &mut app)).unwrap();
1333
1334        let buffer = terminal.backend().buffer();
1335
1336        // Check for leftover content from the large render
1337        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        // Verify the buffer doesn't contain control characters
1357        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 with content containing ANSI escape sequences (like tool output)
1371    #[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        // Content with ANSI escape sequences (simulating adb logcat output)
1379        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        // Render ANSI content
1431        app.sub_cursor = 0;
1432        terminal.draw(|frame| render(frame, &mut app)).unwrap();
1433
1434        // Navigate to small content
1435        app.sub_cursor = 1;
1436        terminal.draw(|frame| render(frame, &mut app)).unwrap();
1437
1438        // Check buffer - no ANSI artifacts should remain
1439        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                // Also check for escape character specifically
1450                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        // Check that help bar shows recent sessions keybindings
1667        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}