Skip to main content

ccs/tui/
state.rs

1use crate::recent::{collect_recent_sessions, detect_session_automation, RecentSession};
2use crate::resume::encode_path_for_claude;
3use crate::search::{
4    group_by_session, search_multiple_paths, RipgrepMatch, SessionGroup, SessionSource,
5};
6use crate::tree::SessionTree;
7use std::collections::{HashMap, HashSet};
8use std::path::Path;
9use std::sync::mpsc::{self, Receiver, Sender};
10use std::thread;
11use std::time::{Duration, Instant};
12
13pub(crate) const DEBOUNCE_MS: u64 = 300;
14const RECENT_SESSIONS_LIMIT: usize = 100;
15
16fn normalize_path_for_prefix_check(path: &str) -> String {
17    let normalized = path.replace('\\', "/");
18    normalized.trim_end_matches(['/', '\\']).to_string()
19}
20
21fn path_is_within_project(file_path: &str, project_path: &str) -> bool {
22    let file_path = normalize_path_for_prefix_check(file_path);
23    let project_path = normalize_path_for_prefix_check(project_path);
24
25    file_path == project_path
26        || file_path
27            .strip_prefix(&project_path)
28            .is_some_and(|rest| rest.starts_with('/'))
29}
30
31fn apply_recent_automation_to_groups(
32    groups: &mut [SessionGroup],
33    recent_sessions: &[RecentSession],
34    automation_cache: &mut HashMap<String, Option<String>>,
35) {
36    let mut automation_by_session_id: HashMap<&str, String> = HashMap::new();
37    for session in recent_sessions {
38        if let Some(automation) = &session.automation {
39            automation_by_session_id
40                .entry(session.session_id.as_str())
41                .or_insert_with(|| automation.clone());
42        }
43        automation_cache
44            .entry(session.file_path.clone())
45            .or_insert_with(|| session.automation.clone());
46    }
47
48    for group in groups {
49        if group.automation.is_some() {
50            automation_cache.insert(group.file_path.clone(), group.automation.clone());
51            continue;
52        }
53
54        if let Some(automation) = automation_by_session_id
55            .get(group.session_id.as_str())
56            .cloned()
57        {
58            automation_cache.insert(group.file_path.clone(), Some(automation.clone()));
59            group.automation = Some(automation);
60            continue;
61        }
62
63        if let Some(cached) = automation_cache.get(&group.file_path) {
64            group.automation = cached.clone();
65            continue;
66        }
67
68        let detected = detect_session_automation(Path::new(&group.file_path));
69        automation_cache.insert(group.file_path.clone(), detected.clone());
70        group.automation = detected;
71    }
72}
73
74/// Filter mode for automated vs manual sessions.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum AutomationFilter {
77    /// Show all sessions (default)
78    All,
79    /// Show only manual (non-automated) sessions
80    Manual,
81    /// Show only automated sessions
82    Auto,
83}
84
85/// Result from background search thread:
86/// (request seq, query, search paths, regex mode, search result)
87pub(crate) type SearchResult = (
88    u64,
89    String,
90    Vec<String>,
91    bool,
92    Result<Vec<RipgrepMatch>, String>,
93);
94
95pub struct App {
96    pub input: String,
97    pub results: Vec<RipgrepMatch>,
98    /// All search result groups (unfiltered)
99    pub(crate) all_groups: Vec<SessionGroup>,
100    /// Search result groups filtered by automation filter
101    pub groups: Vec<SessionGroup>,
102    pub group_cursor: usize,
103    pub sub_cursor: usize,
104    pub expanded: bool,
105    pub searching: bool,
106    pub typing: bool,
107    pub error: Option<String>,
108    pub search_paths: Vec<String>,
109    pub last_query: String,
110    pub results_query: String,
111    pub last_keystroke: Option<Instant>,
112    pub preview_mode: bool,
113    pub should_quit: bool,
114    pub resume_id: Option<String>,
115    pub resume_file_path: Option<String>,
116    /// Session source for resume (CLI or Desktop)
117    pub resume_source: Option<SessionSource>,
118    /// UUID of the selected message (for branch-aware resume)
119    pub resume_uuid: Option<String>,
120    /// Flag to force a full terminal redraw (clears diff optimization artifacts)
121    pub needs_full_redraw: bool,
122    /// Regex search mode (Ctrl+R to toggle)
123    pub regex_mode: bool,
124    /// Track last regex mode used for search
125    pub(crate) last_regex_mode: bool,
126    /// Track last search path scope used for search
127    pub(crate) last_search_paths: Vec<String>,
128    /// Channel to receive search results from background thread
129    pub(crate) search_rx: Receiver<SearchResult>,
130    /// Channel to send search requests to background thread
131    pub(crate) search_tx: Sender<(u64, String, Vec<String>, bool)>,
132    /// Monotonic request sequence to ignore stale async results
133    pub(crate) search_seq: u64,
134    /// Cache: file_path → set of uuids on the latest chain (for fork indicator)
135    pub latest_chains: HashMap<String, HashSet<String>>,
136    /// Tree explorer mode
137    pub tree_mode: bool,
138    /// The loaded session tree
139    pub session_tree: Option<SessionTree>,
140    /// Cursor position in tree rows
141    pub tree_cursor: usize,
142    /// Vertical scroll offset for tree view
143    pub tree_scroll_offset: usize,
144    /// Whether tree is currently loading
145    pub tree_loading: bool,
146    /// Channel to receive loaded tree from background thread
147    pub(crate) tree_load_rx: Option<Receiver<Result<SessionTree, String>>>,
148    /// Whether tree mode was the initial mode (launched with --tree)
149    pub tree_mode_standalone: bool,
150    /// Cursor position in input (byte offset)
151    pub cursor_pos: usize,
152    /// Whether search is scoped to current project only (Ctrl+A toggle)
153    pub project_filter: bool,
154    /// Filter for automated vs manual sessions (Ctrl+H toggle)
155    pub automation_filter: AutomationFilter,
156    /// Cache: file_path -> resolved automation marker (including negative lookups)
157    automation_cache: HashMap<String, Option<String>>,
158    /// All search paths (for "all sessions" mode)
159    pub(crate) all_search_paths: Vec<String>,
160    /// Search path(s) for current project only
161    pub current_project_paths: Vec<String>,
162    /// All recently accessed sessions (unfiltered, loaded once at startup)
163    pub(crate) all_recent_sessions: Vec<RecentSession>,
164    /// Recently accessed sessions shown on startup (filtered by project_filter)
165    pub recent_sessions: Vec<RecentSession>,
166    /// Cursor position in recent sessions list
167    pub recent_cursor: usize,
168    /// Scroll offset for recent sessions list
169    pub recent_scroll_offset: usize,
170    /// Whether recent sessions are still loading
171    pub recent_loading: bool,
172    /// Channel to receive recent sessions from background loader
173    pub(crate) recent_load_rx: Option<Receiver<Vec<RecentSession>>>,
174}
175
176impl App {
177    pub fn new(search_paths: Vec<String>) -> Self {
178        // Create channels for async search
179        let (result_tx, result_rx) = mpsc::channel::<SearchResult>();
180        let (query_tx, query_rx) = mpsc::channel::<(u64, String, Vec<String>, bool)>();
181
182        // Spawn background search thread
183        thread::spawn(move || {
184            while let Ok((seq, query, paths, use_regex)) = query_rx.recv() {
185                let result = search_multiple_paths(&query, &paths, use_regex);
186                let result = (seq, query, paths, use_regex, result);
187                let _ = result_tx.send(result);
188            }
189        });
190
191        // Detect current project path for Ctrl+A filter
192        let current_project_paths = std::env::current_dir()
193            .ok()
194            .and_then(|cwd| cwd.to_str().map(encode_path_for_claude))
195            .map(|encoded| {
196                search_paths
197                    .iter()
198                    .filter_map(|base| {
199                        let candidate = format!("{}/{}", base, encoded);
200                        if std::path::Path::new(&candidate).is_dir() {
201                            Some(candidate)
202                        } else {
203                            None
204                        }
205                    })
206                    .collect::<Vec<_>>()
207            })
208            .unwrap_or_default();
209
210        let all_search_paths = search_paths.clone();
211
212        // Spawn background thread to load recent sessions
213        let (recent_tx, recent_rx) = mpsc::channel::<Vec<RecentSession>>();
214        let recent_paths = search_paths.clone();
215        thread::spawn(move || {
216            let sessions = collect_recent_sessions(&recent_paths, RECENT_SESSIONS_LIMIT);
217            let _ = recent_tx.send(sessions);
218        });
219
220        Self {
221            input: String::new(),
222            results: vec![],
223            all_groups: vec![],
224            groups: vec![],
225            group_cursor: 0,
226            sub_cursor: 0,
227            expanded: false,
228            searching: false,
229            typing: false,
230            error: None,
231            search_paths,
232            last_query: String::new(),
233            results_query: String::new(),
234            last_keystroke: None,
235            preview_mode: false,
236            should_quit: false,
237            resume_id: None,
238            resume_file_path: None,
239            resume_source: None,
240            resume_uuid: None,
241            needs_full_redraw: false,
242            regex_mode: false,
243            last_regex_mode: false,
244            last_search_paths: all_search_paths.clone(),
245            search_rx: result_rx,
246            search_tx: query_tx,
247            search_seq: 0,
248            latest_chains: HashMap::new(),
249            tree_mode: false,
250            session_tree: None,
251            tree_cursor: 0,
252            tree_scroll_offset: 0,
253            tree_loading: false,
254            tree_load_rx: None,
255            tree_mode_standalone: false,
256            cursor_pos: 0,
257            project_filter: false,
258            automation_filter: AutomationFilter::All,
259            automation_cache: HashMap::new(),
260            all_search_paths,
261            current_project_paths,
262            all_recent_sessions: Vec::new(),
263            recent_sessions: Vec::new(),
264            recent_cursor: 0,
265            recent_scroll_offset: 0,
266            recent_loading: true,
267            recent_load_rx: Some(recent_rx),
268        }
269    }
270
271    pub fn on_key(&mut self, c: char) {
272        self.input.insert(self.cursor_pos, c);
273        self.cursor_pos += c.len_utf8();
274        self.typing = true;
275        self.last_keystroke = Some(Instant::now());
276    }
277
278    pub fn on_backspace(&mut self) {
279        if self.cursor_pos > 0 {
280            // Find the previous char boundary
281            let prev = self.input[..self.cursor_pos]
282                .char_indices()
283                .next_back()
284                .map(|(i, _)| i)
285                .unwrap_or(0);
286            self.input.remove(prev);
287            self.cursor_pos = prev;
288            self.typing = true;
289            self.last_keystroke = Some(Instant::now());
290        }
291    }
292
293    pub fn on_delete(&mut self) {
294        if self.cursor_pos < self.input.len() {
295            self.input.remove(self.cursor_pos);
296            self.typing = true;
297            self.last_keystroke = Some(Instant::now());
298        }
299    }
300
301    /// Reset all search result state to idle (no results, no error, no status).
302    /// Shared by `clear_input()` (Ctrl-C) and `tick()` (backspace-to-empty).
303    fn reset_search_state(&mut self) {
304        self.last_query.clear();
305        self.results.clear();
306        self.all_groups.clear();
307        self.groups.clear();
308        self.results_query.clear();
309        self.group_cursor = 0;
310        self.sub_cursor = 0;
311        self.expanded = false;
312        self.preview_mode = false;
313        self.latest_chains.clear();
314        self.searching = false;
315        self.error = None;
316    }
317
318    /// Clear input and reset search state (Ctrl-C behavior)
319    pub fn clear_input(&mut self) {
320        self.input.clear();
321        self.cursor_pos = 0;
322        self.typing = false;
323        self.last_keystroke = None;
324        self.reset_search_state();
325    }
326
327    pub fn move_cursor_left(&mut self) {
328        if self.cursor_pos > 0 {
329            self.cursor_pos = self.input[..self.cursor_pos]
330                .char_indices()
331                .next_back()
332                .map(|(i, _)| i)
333                .unwrap_or(0);
334        }
335    }
336
337    pub fn move_cursor_right(&mut self) {
338        if self.cursor_pos < self.input.len() {
339            self.cursor_pos += self.input[self.cursor_pos..]
340                .chars()
341                .next()
342                .map(|c| c.len_utf8())
343                .unwrap_or(0);
344        }
345    }
346
347    pub fn move_cursor_word_left(&mut self) {
348        let bytes = self.input.as_bytes();
349        let mut pos = self.cursor_pos;
350        // Skip non-alphanumeric
351        while pos > 0 && !bytes[pos - 1].is_ascii_alphanumeric() {
352            pos -= 1;
353        }
354        // Skip alphanumeric
355        while pos > 0 && bytes[pos - 1].is_ascii_alphanumeric() {
356            pos -= 1;
357        }
358        self.cursor_pos = pos;
359    }
360
361    pub fn move_cursor_word_right(&mut self) {
362        let bytes = self.input.as_bytes();
363        let len = bytes.len();
364        let mut pos = self.cursor_pos;
365        // Skip alphanumeric
366        while pos < len && bytes[pos].is_ascii_alphanumeric() {
367            pos += 1;
368        }
369        // Skip non-alphanumeric
370        while pos < len && !bytes[pos].is_ascii_alphanumeric() {
371            pos += 1;
372        }
373        self.cursor_pos = pos;
374    }
375
376    pub fn move_cursor_home(&mut self) {
377        self.cursor_pos = 0;
378    }
379
380    pub fn move_cursor_end(&mut self) {
381        self.cursor_pos = self.input.len();
382    }
383
384    pub fn delete_word_left(&mut self) {
385        if self.cursor_pos == 0 {
386            return;
387        }
388        let old_pos = self.cursor_pos;
389        self.move_cursor_word_left();
390        self.input.drain(self.cursor_pos..old_pos);
391        self.typing = true;
392        self.last_keystroke = Some(Instant::now());
393    }
394
395    pub fn delete_word_right(&mut self) {
396        if self.cursor_pos >= self.input.len() {
397            return;
398        }
399        let old_pos = self.cursor_pos;
400        self.move_cursor_word_right();
401        let new_pos = self.cursor_pos;
402        self.cursor_pos = old_pos;
403        self.input.drain(old_pos..new_pos);
404        self.typing = true;
405        self.last_keystroke = Some(Instant::now());
406    }
407
408    pub fn tick(&mut self) {
409        // Check for recent sessions load results
410        if let Some(ref rx) = self.recent_load_rx {
411            if let Ok(sessions) = rx.try_recv() {
412                self.all_recent_sessions = sessions;
413                apply_recent_automation_to_groups(
414                    &mut self.all_groups,
415                    &self.all_recent_sessions,
416                    &mut self.automation_cache,
417                );
418                self.apply_groups_filter();
419                self.apply_recent_sessions_filter();
420                self.recent_loading = false;
421                self.recent_load_rx = None;
422                // Clamp cursor in case list shrank
423                if !self.recent_sessions.is_empty() {
424                    self.recent_cursor = self
425                        .recent_cursor
426                        .min(self.recent_sessions.len().saturating_sub(1));
427                } else {
428                    self.recent_cursor = 0;
429                }
430            }
431        }
432
433        // Check for tree load results
434        if let Some(ref rx) = self.tree_load_rx {
435            if let Ok(result) = rx.try_recv() {
436                match result {
437                    Ok(tree) => {
438                        self.session_tree = Some(tree);
439                        self.tree_loading = false;
440                        self.needs_full_redraw = true;
441                    }
442                    Err(e) => {
443                        self.error = Some(format!("Tree load error: {}", e));
444                        self.tree_loading = false;
445                        self.tree_mode = false;
446                        self.needs_full_redraw = true;
447                    }
448                }
449                self.tree_load_rx = None;
450            }
451        }
452
453        // Check for search results from background thread
454        while let Ok(result) = self.search_rx.try_recv() {
455            self.handle_search_result(result);
456        }
457
458        // Check if debounce period passed
459        if let Some(last) = self.last_keystroke {
460            if last.elapsed() >= Duration::from_millis(DEBOUNCE_MS) {
461                self.last_keystroke = None;
462                self.typing = false;
463
464                // Re-search if query, regex mode, or search scope changed
465                let query_changed = self.input != self.last_query;
466                let mode_changed = self.regex_mode != self.last_regex_mode;
467                let scope_changed = self.search_paths != self.last_search_paths;
468                if query_changed && self.input.is_empty() {
469                    // User backspaced to empty — reset to idle state
470                    self.reset_search_state();
471                } else if !self.input.is_empty() && (query_changed || mode_changed || scope_changed)
472                {
473                    self.start_search();
474                }
475            }
476        }
477    }
478
479    pub(crate) fn handle_search_result(
480        &mut self,
481        (seq, query, paths, use_regex, result): SearchResult,
482    ) {
483        // Ignore stale async results if query text, mode, path scope, or request sequence changed.
484        if seq != self.search_seq
485            || query != self.input
486            || use_regex != self.regex_mode
487            || paths != self.search_paths
488        {
489            return;
490        }
491
492        match result {
493            Ok(results) => {
494                self.results_query = query;
495                let mut groups = group_by_session(results.clone());
496                apply_recent_automation_to_groups(
497                    &mut groups,
498                    &self.all_recent_sessions,
499                    &mut self.automation_cache,
500                );
501                self.all_groups = groups;
502                self.apply_groups_filter();
503                self.results = results;
504                self.group_cursor = 0;
505                self.sub_cursor = 0;
506                self.expanded = false;
507                self.error = None;
508                self.latest_chains.clear();
509                self.searching = false;
510            }
511            Err(e) => {
512                self.error = Some(e);
513                self.searching = false;
514            }
515        }
516    }
517
518    /// Rebuild `recent_sessions` from `all_recent_sessions` based on current filters.
519    pub(crate) fn apply_recent_sessions_filter(&mut self) {
520        let project_filtered: Vec<_> =
521            if self.project_filter && !self.current_project_paths.is_empty() {
522                self.all_recent_sessions
523                    .iter()
524                    .filter(|s| {
525                        self.current_project_paths
526                            .iter()
527                            .any(|p| path_is_within_project(&s.file_path, p))
528                    })
529                    .cloned()
530                    .collect()
531            } else {
532                self.all_recent_sessions.clone()
533            };
534
535        self.recent_sessions = match self.automation_filter {
536            AutomationFilter::All => project_filtered,
537            AutomationFilter::Manual => project_filtered
538                .into_iter()
539                .filter(|s| s.automation.is_none())
540                .collect(),
541            AutomationFilter::Auto => project_filtered
542                .into_iter()
543                .filter(|s| s.automation.is_some())
544                .collect(),
545        };
546        // Clamp cursor
547        if self.recent_sessions.is_empty() {
548            self.recent_cursor = 0;
549        } else {
550            self.recent_cursor = self
551                .recent_cursor
552                .min(self.recent_sessions.len().saturating_sub(1));
553        }
554    }
555
556    /// Rebuild `groups` from `all_groups` based on automation filter.
557    pub(crate) fn apply_groups_filter(&mut self) {
558        self.groups = match self.automation_filter {
559            AutomationFilter::All => self.all_groups.clone(),
560            AutomationFilter::Manual => self
561                .all_groups
562                .iter()
563                .filter(|g| g.automation.is_none())
564                .cloned()
565                .collect(),
566            AutomationFilter::Auto => self
567                .all_groups
568                .iter()
569                .filter(|g| g.automation.is_some())
570                .cloned()
571                .collect(),
572        };
573    }
574
575    /// Start an async search in the background thread
576    pub(crate) fn start_search(&mut self) {
577        self.search_seq += 1;
578        self.last_query = self.input.clone();
579        self.last_regex_mode = self.regex_mode;
580        self.last_search_paths = self.search_paths.clone();
581        self.searching = true;
582        let _ = self.search_tx.send((
583            self.search_seq,
584            self.input.clone(),
585            self.search_paths.clone(),
586            self.regex_mode,
587        ));
588    }
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594    use crate::search::Message;
595    use chrono::Utc;
596    use std::io::Write;
597    use tempfile::NamedTempFile;
598
599    fn make_recent_session(file_path: &str) -> RecentSession {
600        RecentSession {
601            session_id: file_path.to_string(),
602            file_path: file_path.to_string(),
603            project: "proj".to_string(),
604            source: SessionSource::ClaudeCodeCLI,
605            timestamp: Utc::now(),
606            summary: "summary".to_string(),
607            automation: None,
608        }
609    }
610
611    #[test]
612    fn test_app_new() {
613        let app = App::new(vec!["/test/path".to_string()]);
614
615        assert_eq!(app.search_paths, vec!["/test/path".to_string()]);
616        assert!(app.input.is_empty());
617        assert!(app.groups.is_empty());
618        assert!(!app.should_quit);
619    }
620
621    #[test]
622    fn test_app_initializes_with_empty_recent_sessions() {
623        let app = App::new(vec!["/nonexistent/path".to_string()]);
624        assert!(app.recent_sessions.is_empty());
625        assert_eq!(app.recent_cursor, 0);
626        assert!(app.recent_loading);
627        assert!(app.recent_load_rx.is_some());
628    }
629
630    #[test]
631    fn test_app_receives_recent_sessions_from_background() {
632        // Use a temp dir with a real JSONL file so the background thread finds something
633        let dir = tempfile::TempDir::new().unwrap();
634        let proj_dir = dir.path().join("-Users-user-proj");
635        std::fs::create_dir_all(&proj_dir).unwrap();
636        let session_file = proj_dir.join("sess1.jsonl");
637        std::fs::write(
638            &session_file,
639            r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"hello world"}]},"sessionId":"sess-1","timestamp":"2025-06-01T10:00:00Z"}"#,
640        )
641        .unwrap();
642
643        let mut app = App::new(vec![dir.path().to_str().unwrap().to_string()]);
644
645        // Poll tick() until recent sessions arrive (with timeout)
646        let start = Instant::now();
647        while app.recent_loading && start.elapsed() < Duration::from_secs(5) {
648            app.tick();
649            std::thread::sleep(Duration::from_millis(10));
650        }
651
652        assert!(!app.recent_loading);
653        assert!(app.recent_load_rx.is_none());
654        assert_eq!(app.recent_sessions.len(), 1);
655        assert_eq!(app.recent_sessions[0].session_id, "sess-1");
656        assert_eq!(app.recent_sessions[0].summary, "hello world");
657    }
658
659    #[test]
660    fn test_apply_recent_sessions_filter_matches_mixed_separators() {
661        let mut app = App::new(vec!["/test".to_string()]);
662        app.project_filter = true;
663        app.current_project_paths = vec![r"C:/Users/test/project".to_string()];
664        app.all_recent_sessions = vec![
665            make_recent_session(r"C:\Users\test\project\session.jsonl"),
666            make_recent_session(r"C:\Users\test\project-other\session.jsonl"),
667        ];
668
669        app.apply_recent_sessions_filter();
670
671        assert_eq!(app.recent_sessions.len(), 1);
672        assert_eq!(
673            app.recent_sessions[0].file_path,
674            r"C:\Users\test\project\session.jsonl"
675        );
676    }
677
678    #[test]
679    fn test_handle_search_result_reuses_recent_session_automation() {
680        let mut app = App::new(vec!["/test".to_string()]);
681        app.input = "later".to_string();
682        app.search_seq = 1;
683        app.all_recent_sessions = vec![RecentSession {
684            session_id: "auto-session".to_string(),
685            file_path: "/sessions/auto-session.jsonl".to_string(),
686            project: "proj".to_string(),
687            source: SessionSource::ClaudeCodeCLI,
688            timestamp: Utc::now(),
689            summary: "summary".to_string(),
690            automation: Some("ralphex".to_string()),
691        }];
692
693        let result = RipgrepMatch {
694            file_path: "/sessions/agent-123.jsonl".to_string(),
695            message: Some(Message {
696                session_id: "auto-session".to_string(),
697                role: "assistant".to_string(),
698                content: "Later answer".to_string(),
699                timestamp: Utc::now(),
700                branch: None,
701                line_number: 1,
702                uuid: None,
703                parent_uuid: None,
704            }),
705            source: SessionSource::ClaudeCodeCLI,
706        };
707
708        app.handle_search_result((
709            1,
710            "later".to_string(),
711            app.search_paths.clone(),
712            false,
713            Ok(vec![result]),
714        ));
715
716        assert_eq!(app.all_groups.len(), 1);
717        assert_eq!(app.all_groups[0].automation, Some("ralphex".to_string()));
718    }
719
720    #[test]
721    fn test_handle_search_result_detects_automation_outside_recent_sessions() {
722        let mut session_file = NamedTempFile::new().unwrap();
723        writeln!(session_file, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"When done output <<<RALPHEX:ALL_TASKS_DONE>>>"}}]}},"sessionId":"old-auto","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
724        writeln!(session_file, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Automation reply"}}]}},"sessionId":"old-auto","timestamp":"2025-06-01T10:01:00Z"}}"#).unwrap();
725
726        let mut app = App::new(vec!["/test".to_string()]);
727        app.input = "reply".to_string();
728        app.search_seq = 1;
729        app.automation_filter = AutomationFilter::Auto;
730
731        let result = RipgrepMatch {
732            file_path: session_file.path().to_string_lossy().to_string(),
733            message: Some(Message {
734                session_id: "old-auto".to_string(),
735                role: "assistant".to_string(),
736                content: "Automation reply".to_string(),
737                timestamp: Utc::now(),
738                branch: None,
739                line_number: 2,
740                uuid: None,
741                parent_uuid: None,
742            }),
743            source: SessionSource::ClaudeCodeCLI,
744        };
745
746        app.handle_search_result((
747            1,
748            "reply".to_string(),
749            app.search_paths.clone(),
750            false,
751            Ok(vec![result]),
752        ));
753
754        assert_eq!(app.all_groups.len(), 1);
755        assert_eq!(app.all_groups[0].automation, Some("ralphex".to_string()));
756        assert_eq!(app.groups.len(), 1);
757    }
758
759    #[test]
760    fn test_handle_search_result_ignores_later_quoted_automation_markers() {
761        let mut session_file = NamedTempFile::new().unwrap();
762        writeln!(session_file, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"How can I distinguish ralphex transcripts from manual sessions?"}}]}},"sessionId":"manual-session","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
763        writeln!(session_file, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Let's inspect the markers."}}]}},"sessionId":"manual-session","timestamp":"2025-06-01T10:01:00Z"}}"#).unwrap();
764        writeln!(session_file, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"такие тоже надо детектить <scheduled-task name=\"chezmoi-sync\">"}}]}},"sessionId":"manual-session","timestamp":"2025-06-01T10:02:00Z"}}"#).unwrap();
765
766        let mut app = App::new(vec!["/test".to_string()]);
767        app.input = "detekt".to_string();
768        app.search_seq = 1;
769
770        let result = RipgrepMatch {
771            file_path: session_file.path().to_string_lossy().to_string(),
772            message: Some(Message {
773                session_id: "manual-session".to_string(),
774                role: "user".to_string(),
775                content: r#"такие тоже надо детектить <scheduled-task name="chezmoi-sync">"#
776                    .to_string(),
777                timestamp: Utc::now(),
778                branch: None,
779                line_number: 3,
780                uuid: None,
781                parent_uuid: None,
782            }),
783            source: SessionSource::ClaudeCodeCLI,
784        };
785
786        app.handle_search_result((
787            1,
788            "detekt".to_string(),
789            app.search_paths.clone(),
790            false,
791            Ok(vec![result]),
792        ));
793
794        assert_eq!(app.all_groups.len(), 1);
795        assert_eq!(app.all_groups[0].automation, None);
796    }
797
798    #[test]
799    fn test_path_is_within_project_rejects_sibling_prefixes() {
800        assert!(path_is_within_project(
801            r"C:\Users\test\project\session.jsonl",
802            r"C:/Users/test/project"
803        ));
804        assert!(!path_is_within_project(
805            r"C:\Users\test\project-other\session.jsonl",
806            r"C:/Users/test/project"
807        ));
808    }
809
810    #[test]
811    fn test_on_key() {
812        let mut app = App::new(vec!["/test".to_string()]);
813
814        app.on_key('h');
815        app.on_key('e');
816        app.on_key('l');
817        app.on_key('l');
818        app.on_key('o');
819
820        assert_eq!(app.input, "hello");
821        assert!(app.typing);
822    }
823
824    #[test]
825    fn test_on_backspace() {
826        let mut app = App::new(vec!["/test".to_string()]);
827        app.input = "hello".to_string();
828        app.cursor_pos = 5; // cursor at end
829
830        app.on_backspace();
831
832        assert_eq!(app.input, "hell");
833        assert_eq!(app.cursor_pos, 4);
834    }
835
836    #[test]
837    fn test_clear_input_resets_state() {
838        let mut app = App::new(vec!["/test".to_string()]);
839
840        // Set up state as if a search has completed
841        app.input = "hello".to_string();
842        app.cursor_pos = 5;
843        app.last_query = "hello".to_string();
844        app.results_query = "hello".to_string();
845        app.results = vec![RipgrepMatch {
846            file_path: "/test/file.jsonl".to_string(),
847            message: None,
848            source: SessionSource::ClaudeCodeCLI,
849        }];
850        app.groups = vec![SessionGroup {
851            session_id: "abc123".to_string(),
852            file_path: "/test/file.jsonl".to_string(),
853            matches: vec![],
854            automation: None,
855        }];
856        app.group_cursor = 1;
857        app.sub_cursor = 2;
858        app.expanded = true;
859        app.searching = true;
860        app.typing = true;
861        app.last_keystroke = Some(Instant::now());
862        app.latest_chains.insert("file".to_string(), HashSet::new());
863        app.error = Some("stale error".to_string());
864        app.preview_mode = true;
865
866        app.clear_input();
867
868        assert!(app.input.is_empty(), "input should be cleared");
869        assert!(!app.typing, "typing should be false");
870        assert!(
871            app.last_keystroke.is_none(),
872            "last_keystroke should be None"
873        );
874        assert!(!app.searching, "searching should be false");
875        assert!(app.last_query.is_empty(), "last_query should be cleared");
876        assert!(app.results.is_empty(), "results should be cleared");
877        assert!(app.groups.is_empty(), "groups should be cleared");
878        assert!(
879            app.results_query.is_empty(),
880            "results_query should be cleared"
881        );
882        assert_eq!(app.group_cursor, 0, "group_cursor should be reset");
883        assert_eq!(app.sub_cursor, 0, "sub_cursor should be reset");
884        assert!(!app.expanded, "expanded should be reset");
885        assert!(
886            app.latest_chains.is_empty(),
887            "latest_chains should be cleared"
888        );
889        assert!(app.error.is_none(), "error should be cleared");
890        assert!(!app.preview_mode, "preview_mode should be reset");
891    }
892
893    #[test]
894    fn test_ctrl_c_empty_input_should_quit() {
895        let mut app = App::new(vec!["/test".to_string()]);
896
897        // Empty input — Ctrl-C should signal quit
898        assert!(app.input.is_empty());
899        assert!(!app.should_quit);
900
901        // Simulate the Ctrl-C logic from main.rs
902        if app.input.is_empty() {
903            app.should_quit = true;
904        } else {
905            app.clear_input();
906        }
907
908        assert!(app.should_quit);
909    }
910
911    #[test]
912    fn test_ctrl_c_with_input_clears_not_quits() {
913        let mut app = App::new(vec!["/test".to_string()]);
914
915        app.on_key('t');
916        app.on_key('e');
917        app.on_key('s');
918        app.on_key('t');
919
920        // Simulate the Ctrl-C logic from main.rs
921        if app.input.is_empty() {
922            app.should_quit = true;
923        } else {
924            app.clear_input();
925        }
926
927        assert!(app.input.is_empty());
928        assert!(!app.should_quit);
929    }
930
931    #[test]
932    fn test_on_key_inserts_at_cursor() {
933        let mut app = App::new(vec!["/test".to_string()]);
934        app.on_key('a');
935        app.on_key('c');
936        // input = "ac", cursor at 2
937        app.cursor_pos = 1; // move cursor between 'a' and 'c'
938        app.on_key('b');
939        assert_eq!(app.input, "abc");
940        assert_eq!(app.cursor_pos, 2);
941    }
942
943    #[test]
944    fn test_on_backspace_at_cursor() {
945        let mut app = App::new(vec!["/test".to_string()]);
946        app.input = "abc".to_string();
947        app.cursor_pos = 2; // cursor after 'b'
948        app.on_backspace();
949        assert_eq!(app.input, "ac");
950        assert_eq!(app.cursor_pos, 1);
951    }
952
953    #[test]
954    fn test_on_backspace_at_start_does_nothing() {
955        let mut app = App::new(vec!["/test".to_string()]);
956        app.input = "abc".to_string();
957        app.cursor_pos = 0;
958        app.on_backspace();
959        assert_eq!(app.input, "abc");
960        assert_eq!(app.cursor_pos, 0);
961    }
962
963    #[test]
964    fn test_on_delete_at_cursor() {
965        let mut app = App::new(vec!["/test".to_string()]);
966        app.input = "abc".to_string();
967        app.cursor_pos = 1; // cursor after 'a'
968        app.on_delete();
969        assert_eq!(app.input, "ac");
970        assert_eq!(app.cursor_pos, 1);
971    }
972
973    #[test]
974    fn test_move_cursor_word_left() {
975        let mut app = App::new(vec!["/test".to_string()]);
976        app.input = "hello world foo".to_string();
977        app.cursor_pos = app.input.len(); // at end
978
979        app.move_cursor_word_left();
980        assert_eq!(app.cursor_pos, 12); // before "foo"
981
982        app.move_cursor_word_left();
983        assert_eq!(app.cursor_pos, 6); // before "world"
984
985        app.move_cursor_word_left();
986        assert_eq!(app.cursor_pos, 0); // before "hello"
987
988        // At start, stays at 0
989        app.move_cursor_word_left();
990        assert_eq!(app.cursor_pos, 0);
991    }
992
993    #[test]
994    fn test_move_cursor_word_right() {
995        let mut app = App::new(vec!["/test".to_string()]);
996        app.input = "hello world foo".to_string();
997        app.cursor_pos = 0;
998
999        app.move_cursor_word_right();
1000        assert_eq!(app.cursor_pos, 6); // after "hello "
1001
1002        app.move_cursor_word_right();
1003        assert_eq!(app.cursor_pos, 12); // after "world "
1004
1005        app.move_cursor_word_right();
1006        assert_eq!(app.cursor_pos, 15); // end
1007
1008        // At end, stays
1009        app.move_cursor_word_right();
1010        assert_eq!(app.cursor_pos, 15);
1011    }
1012
1013    #[test]
1014    fn test_delete_word_left() {
1015        let mut app = App::new(vec!["/test".to_string()]);
1016        app.input = "hello world".to_string();
1017        app.cursor_pos = app.input.len();
1018
1019        app.delete_word_left();
1020        assert_eq!(app.input, "hello ");
1021        assert_eq!(app.cursor_pos, 6);
1022
1023        app.delete_word_left();
1024        assert_eq!(app.input, "");
1025        assert_eq!(app.cursor_pos, 0);
1026    }
1027
1028    #[test]
1029    fn test_move_cursor_home_end() {
1030        let mut app = App::new(vec!["/test".to_string()]);
1031        app.input = "hello".to_string();
1032        app.cursor_pos = 3;
1033
1034        app.move_cursor_home();
1035        assert_eq!(app.cursor_pos, 0);
1036
1037        app.move_cursor_end();
1038        assert_eq!(app.cursor_pos, 5);
1039    }
1040
1041    #[test]
1042    fn test_cursor_bounds_empty_input() {
1043        let mut app = App::new(vec!["/test".to_string()]);
1044
1045        // All operations on empty input should not panic
1046        app.move_cursor_left();
1047        app.move_cursor_right();
1048        app.move_cursor_word_left();
1049        app.move_cursor_word_right();
1050        app.move_cursor_home();
1051        app.move_cursor_end();
1052        app.on_backspace();
1053        app.on_delete();
1054        app.delete_word_left();
1055
1056        assert_eq!(app.cursor_pos, 0);
1057        assert!(app.input.is_empty());
1058    }
1059
1060    #[test]
1061    fn test_move_cursor_left_right() {
1062        let mut app = App::new(vec!["/test".to_string()]);
1063        app.input = "abc".to_string();
1064        app.cursor_pos = 3;
1065
1066        app.move_cursor_left();
1067        assert_eq!(app.cursor_pos, 2);
1068
1069        app.move_cursor_left();
1070        assert_eq!(app.cursor_pos, 1);
1071
1072        app.move_cursor_right();
1073        assert_eq!(app.cursor_pos, 2);
1074    }
1075
1076    #[test]
1077    fn test_clear_input_resets_cursor() {
1078        let mut app = App::new(vec!["/test".to_string()]);
1079        app.input = "hello".to_string();
1080        app.cursor_pos = 3;
1081
1082        app.clear_input();
1083
1084        assert_eq!(app.cursor_pos, 0);
1085        assert!(app.input.is_empty());
1086    }
1087
1088    #[test]
1089    fn test_delete_word_right() {
1090        let mut app = App::new(vec!["/test".to_string()]);
1091        app.input = "hello world foo".to_string();
1092        app.cursor_pos = 0;
1093
1094        app.delete_word_right();
1095        assert_eq!(app.input, "world foo");
1096        assert_eq!(app.cursor_pos, 0);
1097
1098        app.delete_word_right();
1099        assert_eq!(app.input, "foo");
1100        assert_eq!(app.cursor_pos, 0);
1101
1102        app.delete_word_right();
1103        assert_eq!(app.input, "");
1104        assert_eq!(app.cursor_pos, 0);
1105    }
1106
1107    #[test]
1108    fn test_delete_word_right_at_end_does_nothing() {
1109        let mut app = App::new(vec!["/test".to_string()]);
1110        app.input = "hello".to_string();
1111        app.cursor_pos = 5;
1112
1113        app.delete_word_right();
1114        assert_eq!(app.input, "hello");
1115        assert_eq!(app.cursor_pos, 5);
1116    }
1117
1118    #[test]
1119    fn test_tick_clears_state_when_query_becomes_empty() {
1120        let mut app = App::new(vec!["/test".to_string()]);
1121
1122        // Simulate: user had typed "hello", search completed, then backspaced to empty
1123        app.input = String::new(); // empty — user backspaced everything
1124        app.last_query = "hello".to_string(); // previous query that produced results
1125        app.results_query = "hello".to_string();
1126        app.results = vec![RipgrepMatch {
1127            file_path: "/test/file.jsonl".to_string(),
1128            message: None,
1129            source: SessionSource::ClaudeCodeCLI,
1130        }];
1131        app.groups = vec![SessionGroup {
1132            session_id: "abc123".to_string(),
1133            file_path: "/test/file.jsonl".to_string(),
1134            matches: vec![],
1135            automation: None,
1136        }];
1137        app.group_cursor = 1;
1138        app.sub_cursor = 2;
1139        app.expanded = true;
1140        app.searching = true;
1141        app.latest_chains.insert("file".to_string(), HashSet::new());
1142        app.error = Some("stale error".to_string());
1143        app.preview_mode = true;
1144
1145        // Set debounce to fire: last keystroke was > DEBOUNCE_MS ago
1146        app.last_keystroke = Some(Instant::now() - Duration::from_millis(DEBOUNCE_MS + 50));
1147        app.typing = true;
1148
1149        app.tick();
1150
1151        assert!(
1152            app.results.is_empty(),
1153            "results should be cleared after tick with empty query"
1154        );
1155        assert!(
1156            app.groups.is_empty(),
1157            "groups should be cleared after tick with empty query"
1158        );
1159        assert!(
1160            app.results_query.is_empty(),
1161            "results_query should be cleared after tick with empty query"
1162        );
1163        assert!(
1164            app.last_query.is_empty(),
1165            "last_query should be updated to empty"
1166        );
1167        assert_eq!(app.group_cursor, 0, "group_cursor should be reset");
1168        assert_eq!(app.sub_cursor, 0, "sub_cursor should be reset");
1169        assert!(!app.expanded, "expanded should be reset");
1170        assert!(!app.typing, "typing should be false after debounce");
1171        assert!(!app.searching, "searching should be false");
1172        assert!(
1173            app.latest_chains.is_empty(),
1174            "latest_chains should be cleared"
1175        );
1176        assert!(app.error.is_none(), "error should be cleared");
1177        assert!(!app.preview_mode, "preview_mode should be reset");
1178    }
1179
1180    #[test]
1181    fn test_delete_word_right_from_middle() {
1182        let mut app = App::new(vec!["/test".to_string()]);
1183        app.input = "hello world".to_string();
1184        app.cursor_pos = 5; // after "hello", on the space
1185
1186        // First delete removes " " (skip non-alnum to next word boundary)
1187        app.delete_word_right();
1188        assert_eq!(app.input, "helloworld");
1189        assert_eq!(app.cursor_pos, 5);
1190
1191        // Second delete removes "world"
1192        app.delete_word_right();
1193        assert_eq!(app.input, "hello");
1194        assert_eq!(app.cursor_pos, 5);
1195    }
1196}