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