Skip to main content

pi/
session_picker.rs

1//! Session picker TUI for selecting from available sessions.
2//!
3//! Provides an interactive list for choosing which session to resume.
4
5use std::cmp::Reverse;
6use std::collections::HashMap;
7use std::fmt::Write;
8use std::fs::{self, File};
9use std::io::{BufRead, BufReader};
10use std::path::{Path, PathBuf};
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use bubbletea::{Cmd, KeyMsg, KeyType, Message, Program, quit};
14use serde::Deserialize;
15
16use crate::config::Config;
17use crate::error::{Error, Result};
18use crate::session::{Session, SessionHeader, encode_cwd};
19use crate::session_index::{SessionIndex, SessionMeta};
20use crate::theme::{Theme, TuiStyles};
21
22/// Format a timestamp for display.
23pub fn format_time(timestamp: &str) -> String {
24    chrono::DateTime::parse_from_rfc3339(timestamp).map_or_else(
25        |_| timestamp.to_string(),
26        |dt| dt.format("%Y-%m-%d %H:%M").to_string(),
27    )
28}
29
30/// Truncate a session id by character count for display.
31#[must_use]
32pub fn truncate_session_id(session_id: &str, max_chars: usize) -> &str {
33    if max_chars == 0 {
34        return "";
35    }
36    let end = session_id
37        .char_indices()
38        .nth(max_chars)
39        .map_or(session_id.len(), |(idx, _)| idx);
40    &session_id[..end]
41}
42
43/// The session picker TUI model.
44#[derive(bubbletea::Model)]
45pub struct SessionPicker {
46    sessions: Vec<SessionMeta>,
47    selected: usize,
48    chosen: Option<usize>,
49    cancelled: bool,
50    confirm_delete: Option<usize>,
51    status_message: Option<String>,
52    sessions_root: Option<PathBuf>,
53    styles: TuiStyles,
54}
55
56impl SessionPicker {
57    /// Create a new session picker.
58    #[allow(clippy::missing_const_for_fn)] // sessions: Vec cannot be const
59    #[must_use]
60    pub fn new(sessions: Vec<SessionMeta>) -> Self {
61        let theme = Theme::dark();
62        let styles = theme.tui_styles();
63        Self {
64            sessions,
65            selected: 0,
66            chosen: None,
67            cancelled: false,
68            confirm_delete: None,
69            status_message: None,
70            sessions_root: None,
71            styles,
72        }
73    }
74
75    #[must_use]
76    pub fn with_theme(sessions: Vec<SessionMeta>, theme: &Theme) -> Self {
77        let styles = theme.tui_styles();
78        Self {
79            sessions,
80            selected: 0,
81            chosen: None,
82            cancelled: false,
83            confirm_delete: None,
84            status_message: None,
85            sessions_root: None,
86            styles,
87        }
88    }
89
90    #[must_use]
91    pub fn with_theme_and_root(
92        sessions: Vec<SessionMeta>,
93        theme: &Theme,
94        sessions_root: PathBuf,
95    ) -> Self {
96        let styles = theme.tui_styles();
97        Self {
98            sessions,
99            selected: 0,
100            chosen: None,
101            cancelled: false,
102            confirm_delete: None,
103            status_message: None,
104            sessions_root: Some(sessions_root),
105            styles,
106        }
107    }
108
109    /// Get the selected session path after the picker completes.
110    pub fn selected_path(&self) -> Option<&str> {
111        self.chosen
112            .and_then(|i| self.sessions.get(i))
113            .map(|s| s.path.as_str())
114    }
115
116    /// Check if the picker was cancelled.
117    pub const fn was_cancelled(&self) -> bool {
118        self.cancelled
119    }
120
121    #[allow(clippy::unused_self, clippy::missing_const_for_fn)]
122    fn init(&self) -> Option<Cmd> {
123        None
124    }
125
126    #[allow(clippy::needless_pass_by_value)] // Required by Model trait
127    pub fn update(&mut self, msg: Message) -> Option<Cmd> {
128        if let Some(key) = msg.downcast_ref::<KeyMsg>() {
129            if self.confirm_delete.is_some() {
130                return self.handle_delete_prompt(key);
131            }
132            match key.key_type {
133                KeyType::Up => {
134                    if self.selected > 0 {
135                        self.selected -= 1;
136                    }
137                }
138                KeyType::Down => {
139                    if self.selected < self.sessions.len().saturating_sub(1) {
140                        self.selected += 1;
141                    }
142                }
143                KeyType::Runes if key.runes == ['k'] => {
144                    if self.selected > 0 {
145                        self.selected -= 1;
146                    }
147                }
148                KeyType::Runes if key.runes == ['j'] => {
149                    if self.selected < self.sessions.len().saturating_sub(1) {
150                        self.selected += 1;
151                    }
152                }
153                KeyType::Enter => {
154                    if !self.sessions.is_empty() {
155                        self.chosen = Some(self.selected);
156                    }
157                    return Some(quit());
158                }
159                KeyType::Esc | KeyType::CtrlC => {
160                    self.cancelled = true;
161                    return Some(quit());
162                }
163                KeyType::Runes if key.runes == ['q'] => {
164                    self.cancelled = true;
165                    return Some(quit());
166                }
167                KeyType::CtrlD => {
168                    if !self.sessions.is_empty() {
169                        self.confirm_delete = Some(self.selected);
170                        self.status_message =
171                            Some("Delete session? Press y/n to confirm.".to_string());
172                    }
173                }
174                _ => {}
175            }
176        }
177        None
178    }
179
180    fn handle_delete_prompt(&mut self, key: &KeyMsg) -> Option<Cmd> {
181        match key.key_type {
182            KeyType::Runes if key.runes == ['y'] || key.runes == ['Y'] => {
183                if let Some(index) = self.confirm_delete.take() {
184                    if let Err(err) = self.delete_session_at(index) {
185                        self.status_message = Some(err.to_string());
186                    } else {
187                        self.status_message = Some("Session deleted.".to_string());
188                        if self.sessions.is_empty() {
189                            self.cancelled = true;
190                            return Some(quit());
191                        }
192                    }
193                }
194            }
195            KeyType::Runes if key.runes == ['n'] || key.runes == ['N'] => {
196                self.confirm_delete = None;
197                self.status_message = None;
198            }
199            KeyType::Esc | KeyType::CtrlC => {
200                self.confirm_delete = None;
201                self.status_message = None;
202            }
203            _ => {}
204        }
205        None
206    }
207
208    fn delete_session_at(&mut self, index: usize) -> Result<()> {
209        let Some(meta) = self.sessions.get(index) else {
210            return Ok(());
211        };
212        let path = PathBuf::from(&meta.path);
213        delete_session_file(&path)?;
214        if let Some(root) = self.sessions_root.as_ref() {
215            let index = SessionIndex::for_sessions_root(root);
216            let _ = index.delete_session_path(&path);
217        }
218        self.sessions.remove(index);
219        if self.selected >= self.sessions.len() {
220            self.selected = self.sessions.len().saturating_sub(1);
221        }
222        Ok(())
223    }
224
225    pub fn view(&self) -> String {
226        let mut output = String::new();
227
228        // Header
229        let _ = writeln!(
230            output,
231            "\n  {}\n",
232            self.styles.title.render("Select a session to resume")
233        );
234
235        if self.sessions.is_empty() {
236            let _ = writeln!(
237                output,
238                "  {}",
239                self.styles
240                    .muted
241                    .render("No sessions found for this project.")
242            );
243        } else {
244            // Column headers
245            let _ = writeln!(
246                output,
247                "  {:<20}  {:<30}  {:<8}  {}",
248                self.styles.muted_bold.render("Time"),
249                self.styles.muted_bold.render("Name"),
250                self.styles.muted_bold.render("Messages"),
251                self.styles.muted_bold.render("Session ID")
252            );
253            output.push_str("  ");
254            output.push_str(&"-".repeat(78));
255            output.push('\n');
256
257            // Session rows
258            for (i, session) in self.sessions.iter().enumerate() {
259                let is_selected = i == self.selected;
260
261                let prefix = if is_selected { ">" } else { " " };
262                let time = format_time(&session.timestamp);
263                let name = session
264                    .name
265                    .as_deref()
266                    .unwrap_or("-")
267                    .chars()
268                    .take(28)
269                    .collect::<String>();
270                let messages = session.message_count.to_string();
271                let id = truncate_session_id(&session.id, 8);
272
273                let _ = writeln!(
274                    output,
275                    "{prefix} {}",
276                    if is_selected {
277                        self.styles
278                            .selection
279                            .render(&format!(" {time:<20}  {name:<30}  {messages:<8}  {id}"))
280                    } else {
281                        format!(" {time:<20}  {name:<30}  {messages:<8}  {id}")
282                    }
283                );
284            }
285        }
286
287        // Help text
288        output.push('\n');
289        let _ = writeln!(
290            output,
291            "  {}",
292            self.styles
293                .muted
294                .render("↑/↓/j/k: navigate  Enter: select  Ctrl+D: delete  Esc/q: cancel")
295        );
296        if let Some(message) = &self.status_message {
297            let _ = writeln!(output, "  {}", self.styles.warning_bold.render(message));
298        }
299
300        output
301    }
302}
303
304/// List sessions for the current working directory using the session index.
305pub fn list_sessions_for_cwd() -> Vec<SessionMeta> {
306    let Ok(cwd) = std::env::current_dir() else {
307        return Vec::new();
308    };
309    list_sessions_for_project(&cwd, None)
310}
311
312/// Run the session picker and return the selected session.
313pub async fn pick_session(override_dir: Option<&Path>) -> Option<Session> {
314    let cwd = std::env::current_dir().ok()?;
315    let base_dir = override_dir.map_or_else(Config::sessions_dir, PathBuf::from);
316    let sessions = list_sessions_for_project(&cwd, override_dir);
317
318    if sessions.is_empty() {
319        return None;
320    }
321
322    if sessions.len() == 1 {
323        // Only one session, just open it
324        let mut session = Session::open(&sessions[0].path).await.ok()?;
325        session.session_dir = Some(base_dir);
326        return Some(session);
327    }
328
329    let config = Config::load().unwrap_or_default();
330    let theme = Theme::resolve(&config, &cwd);
331    let picker = SessionPicker::with_theme_and_root(sessions, &theme, base_dir.clone());
332
333    // Run the TUI
334    let result = Program::new(picker).with_alt_screen().run();
335
336    match result {
337        Ok(picker) => {
338            if picker.was_cancelled() {
339                return None;
340            }
341
342            if let Some(path) = picker.selected_path() {
343                let mut session = Session::open(path).await.ok()?;
344                session.session_dir = Some(base_dir);
345                Some(session)
346            } else {
347                None
348            }
349        }
350        Err(_) => None,
351    }
352}
353
354pub fn list_sessions_for_project(cwd: &Path, override_dir: Option<&Path>) -> Vec<SessionMeta> {
355    let base_dir = override_dir.map_or_else(Config::sessions_dir, PathBuf::from);
356    let project_session_dir = base_dir.join(encode_cwd(cwd));
357    if !project_session_dir.exists() {
358        return Vec::new();
359    }
360
361    let cwd_key = cwd.display().to_string();
362    let index = SessionIndex::for_sessions_root(&base_dir);
363    let mut sessions = index.list_sessions(Some(&cwd_key)).unwrap_or_default();
364
365    if sessions.is_empty() && index.reindex_all().is_ok() {
366        sessions = index.list_sessions(Some(&cwd_key)).unwrap_or_default();
367    }
368
369    sessions.retain(|meta| Path::new(&meta.path).exists());
370
371    let scanned = scan_sessions_on_disk(&project_session_dir);
372    if !scanned.is_empty() {
373        let mut by_path: HashMap<String, SessionMeta> = sessions
374            .into_iter()
375            .map(|meta| (meta.path.clone(), meta))
376            .collect();
377
378        for meta in scanned {
379            let should_replace = by_path
380                .get(&meta.path)
381                .is_some_and(|existing| meta.last_modified_ms > existing.last_modified_ms);
382            if should_replace || !by_path.contains_key(&meta.path) {
383                by_path.insert(meta.path.clone(), meta);
384            }
385        }
386
387        sessions = by_path.into_values().collect();
388    }
389
390    sessions.sort_by_key(|m| Reverse(m.last_modified_ms));
391    sessions.truncate(50);
392    sessions
393}
394
395fn scan_sessions_on_disk(project_session_dir: &Path) -> Vec<SessionMeta> {
396    let mut out = Vec::new();
397    let Ok(entries) = fs::read_dir(project_session_dir) else {
398        return out;
399    };
400
401    for entry in entries.flatten() {
402        let path = entry.path();
403        if is_session_file_path(&path) {
404            if let Ok(meta) = build_meta_from_file(&path) {
405                out.push(meta);
406            }
407        }
408    }
409
410    out
411}
412
413fn build_meta_from_file(path: &Path) -> crate::error::Result<SessionMeta> {
414    match path.extension().and_then(|ext| ext.to_str()) {
415        Some("jsonl") => build_meta_from_jsonl(path),
416        #[cfg(feature = "sqlite-sessions")]
417        Some("sqlite") => build_meta_from_sqlite(path),
418        _ => Err(Error::session(format!(
419            "Unsupported session file extension: {}",
420            path.display()
421        ))),
422    }
423}
424
425#[derive(Deserialize)]
426struct PartialEntry {
427    #[serde(default)]
428    r#type: String,
429    #[serde(default)]
430    name: Option<String>,
431}
432
433fn build_meta_from_jsonl(path: &Path) -> crate::error::Result<SessionMeta> {
434    let file = File::open(path)?;
435    let reader = BufReader::new(file);
436    let mut lines = reader.lines();
437
438    let header_line = lines
439        .next()
440        .transpose()?
441        .ok_or_else(|| crate::error::Error::session("Empty session file"))?;
442
443    let header: SessionHeader = serde_json::from_str(&header_line)
444        .map_err(|e| crate::error::Error::session(format!("Parse session header: {e}")))?;
445
446    let mut message_count = 0u64;
447    let mut name = None;
448
449    for line_res in lines {
450        let line = line_res?;
451        if let Ok(entry) = serde_json::from_str::<PartialEntry>(&line) {
452            match entry.r#type.as_str() {
453                "message" => message_count += 1,
454                "session_info" => {
455                    if entry.name.is_some() {
456                        name = entry.name;
457                    }
458                }
459                _ => {}
460            }
461        }
462    }
463
464    let meta = fs::metadata(path)?;
465    let size_bytes = meta.len();
466    let modified = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
467    let millis = modified
468        .duration_since(UNIX_EPOCH)
469        .unwrap_or_default()
470        .as_millis();
471    let last_modified_ms = i64::try_from(millis).unwrap_or(i64::MAX);
472
473    Ok(SessionMeta {
474        path: path.display().to_string(),
475        id: header.id,
476        cwd: header.cwd,
477        timestamp: header.timestamp,
478        message_count,
479        last_modified_ms,
480        size_bytes,
481        name,
482    })
483}
484
485#[cfg(feature = "sqlite-sessions")]
486fn build_meta_from_sqlite(path: &Path) -> crate::error::Result<SessionMeta> {
487    let meta = futures::executor::block_on(async {
488        crate::session_sqlite::load_session_meta(path).await
489    })?;
490    let header = meta.header;
491
492    let sqlite_meta = fs::metadata(path)?;
493    let size_bytes = sqlite_meta.len();
494    let modified = sqlite_meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
495    let millis = modified
496        .duration_since(UNIX_EPOCH)
497        .unwrap_or_default()
498        .as_millis();
499    let last_modified_ms = i64::try_from(millis).unwrap_or(i64::MAX);
500
501    Ok(SessionMeta {
502        path: path.display().to_string(),
503        id: header.id,
504        cwd: header.cwd,
505        timestamp: header.timestamp,
506        message_count: meta.message_count,
507        last_modified_ms,
508        size_bytes,
509        name: meta.name,
510    })
511}
512
513fn is_session_file_path(path: &Path) -> bool {
514    match path.extension().and_then(|ext| ext.to_str()) {
515        Some("jsonl") => true,
516        #[cfg(feature = "sqlite-sessions")]
517        Some("sqlite") => true,
518        _ => false,
519    }
520}
521
522pub(crate) fn delete_session_file(path: &Path) -> Result<()> {
523    delete_session_file_with_trash_cmd(path, "trash")
524}
525
526fn delete_session_file_with_trash_cmd(path: &Path, trash_cmd: &str) -> Result<()> {
527    if try_trash_with_cmd(path, trash_cmd) {
528        return Ok(());
529    }
530    match fs::remove_file(path) {
531        Ok(()) => Ok(()),
532        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
533        Err(err) => Err(Error::session(format!(
534            "Failed to delete session {}: {err}",
535            path.display()
536        ))),
537    }
538}
539
540fn try_trash_with_cmd(path: &Path, trash_cmd: &str) -> bool {
541    match std::process::Command::new(trash_cmd).arg(path).status() {
542        Ok(status) if status.success() => true,
543        Ok(status) => {
544            tracing::warn!(
545                path = %path.display(),
546                exit = status.code().unwrap_or(-1),
547                "trash command failed; falling back to direct file removal"
548            );
549            false
550        }
551        Err(err) if err.kind() == std::io::ErrorKind::NotFound => false,
552        Err(err) => {
553            tracing::warn!(
554                path = %path.display(),
555                error = %err,
556                "trash command invocation failed; falling back to direct file removal"
557            );
558            false
559        }
560    }
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566
567    fn make_meta(path: &Path) -> SessionMeta {
568        SessionMeta {
569            path: path.display().to_string(),
570            id: "sess".to_string(),
571            cwd: "/tmp".to_string(),
572            timestamp: "2025-01-15T10:00:00.000Z".to_string(),
573            message_count: 1,
574            last_modified_ms: 1000,
575            size_bytes: 100,
576            name: None,
577        }
578    }
579
580    fn key_msg(key_type: KeyType, runes: Vec<char>) -> Message {
581        Message::new(KeyMsg {
582            key_type,
583            runes,
584            alt: false,
585            paste: false,
586        })
587    }
588
589    #[test]
590    fn test_format_time() {
591        let ts = "2025-01-15T10:30:00.000Z";
592        let formatted = format_time(ts);
593        assert!(formatted.contains("2025-01-15"));
594        assert!(formatted.contains("10:30"));
595    }
596
597    #[test]
598    fn test_format_time_invalid_returns_input() {
599        let ts = "not-a-timestamp";
600        assert_eq!(format_time(ts), ts);
601    }
602
603    #[test]
604    fn truncate_session_id_handles_unicode_boundaries() {
605        assert_eq!(truncate_session_id("abcdefghijk", 8), "abcdefgh");
606        assert_eq!(truncate_session_id("αβγδεζηθικ", 8), "αβγδεζηθ");
607    }
608
609    #[test]
610    fn test_is_session_file_path() {
611        assert!(is_session_file_path(Path::new("/tmp/sess.jsonl")));
612        assert!(!is_session_file_path(Path::new("/tmp/sess.txt")));
613        assert!(!is_session_file_path(Path::new("/tmp/noext")));
614        #[cfg(feature = "sqlite-sessions")]
615        assert!(is_session_file_path(Path::new("/tmp/sess.sqlite")));
616    }
617
618    #[test]
619    fn test_session_picker_navigation() {
620        let sessions = vec![
621            SessionMeta {
622                path: "/test/a.jsonl".to_string(),
623                id: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee".to_string(),
624                cwd: "/test".to_string(),
625                timestamp: "2025-01-15T10:00:00.000Z".to_string(),
626                message_count: 1,
627                last_modified_ms: 1000,
628                size_bytes: 100,
629                name: None,
630            },
631            SessionMeta {
632                path: "/test/b.jsonl".to_string(),
633                id: "bbbbbbbb-cccc-dddd-eeee-ffffffffffff".to_string(),
634                cwd: "/test".to_string(),
635                timestamp: "2025-01-15T11:00:00.000Z".to_string(),
636                message_count: 2,
637                last_modified_ms: 2000,
638                size_bytes: 200,
639                name: Some("Test session".to_string()),
640            },
641        ];
642
643        let mut picker = SessionPicker::new(sessions);
644        assert_eq!(picker.selected, 0);
645
646        // Navigate down
647        picker.update(key_msg(KeyType::Down, vec![]));
648        assert_eq!(picker.selected, 1);
649
650        // Navigate up
651        picker.update(key_msg(KeyType::Up, vec![]));
652        assert_eq!(picker.selected, 0);
653    }
654
655    #[test]
656    fn test_session_picker_vim_keys() {
657        let sessions = vec![
658            SessionMeta {
659                path: "/test/a.jsonl".to_string(),
660                id: "aaaaaaaa".to_string(),
661                cwd: "/test".to_string(),
662                timestamp: "2025-01-15T10:00:00.000Z".to_string(),
663                message_count: 1,
664                last_modified_ms: 1000,
665                size_bytes: 100,
666                name: None,
667            },
668            SessionMeta {
669                path: "/test/b.jsonl".to_string(),
670                id: "bbbbbbbb".to_string(),
671                cwd: "/test".to_string(),
672                timestamp: "2025-01-15T11:00:00.000Z".to_string(),
673                message_count: 2,
674                last_modified_ms: 2000,
675                size_bytes: 200,
676                name: None,
677            },
678        ];
679
680        let mut picker = SessionPicker::new(sessions);
681        assert_eq!(picker.selected, 0);
682
683        // Navigate down with 'j'
684        picker.update(key_msg(KeyType::Runes, vec!['j']));
685        assert_eq!(picker.selected, 1);
686
687        // Navigate up with 'k'
688        picker.update(key_msg(KeyType::Runes, vec!['k']));
689        assert_eq!(picker.selected, 0);
690    }
691
692    #[test]
693    fn session_picker_delete_prompt_and_cancel() {
694        let tmp = tempfile::tempdir().expect("tempdir");
695        let session_path = tmp.path().join("sess.jsonl");
696        fs::write(&session_path, "test").expect("write session");
697
698        let sessions = vec![make_meta(&session_path)];
699        let mut picker = SessionPicker::new(sessions);
700
701        picker.update(key_msg(KeyType::CtrlD, vec![]));
702        assert!(picker.confirm_delete.is_some());
703
704        picker.update(key_msg(KeyType::Runes, vec!['n']));
705        assert!(picker.confirm_delete.is_none());
706        assert!(session_path.exists());
707    }
708
709    #[test]
710    fn session_picker_delete_confirm_removes_file() {
711        let tmp = tempfile::tempdir().expect("tempdir");
712        let session_path = tmp.path().join("sess.jsonl");
713        fs::write(&session_path, "test").expect("write session");
714
715        let sessions = vec![make_meta(&session_path)];
716        let mut picker = SessionPicker::new(sessions);
717
718        picker.update(key_msg(KeyType::CtrlD, vec![]));
719
720        picker.update(key_msg(KeyType::Runes, vec!['y']));
721
722        assert!(!session_path.exists());
723        assert!(picker.sessions.is_empty());
724    }
725
726    #[test]
727    fn session_picker_navigation_bounds() {
728        let sessions = vec![
729            SessionMeta {
730                path: "/test/a.jsonl".to_string(),
731                id: "aaaaaaaa".to_string(),
732                cwd: "/test".to_string(),
733                timestamp: "2025-01-15T10:00:00.000Z".to_string(),
734                message_count: 1,
735                last_modified_ms: 1000,
736                size_bytes: 100,
737                name: None,
738            },
739            SessionMeta {
740                path: "/test/b.jsonl".to_string(),
741                id: "bbbbbbbb".to_string(),
742                cwd: "/test".to_string(),
743                timestamp: "2025-01-15T11:00:00.000Z".to_string(),
744                message_count: 2,
745                last_modified_ms: 2000,
746                size_bytes: 200,
747                name: None,
748            },
749        ];
750
751        let mut picker = SessionPicker::new(sessions);
752        picker.update(key_msg(KeyType::Up, vec![]));
753        assert_eq!(picker.selected, 0);
754
755        picker.update(key_msg(KeyType::Down, vec![]));
756        picker.update(key_msg(KeyType::Down, vec![]));
757        assert_eq!(picker.selected, 1);
758    }
759
760    #[test]
761    fn session_picker_enter_selects_current_session() {
762        let sessions = vec![
763            SessionMeta {
764                path: "/test/a.jsonl".to_string(),
765                id: "aaaaaaaa".to_string(),
766                cwd: "/test".to_string(),
767                timestamp: "2025-01-15T10:00:00.000Z".to_string(),
768                message_count: 1,
769                last_modified_ms: 1000,
770                size_bytes: 100,
771                name: None,
772            },
773            SessionMeta {
774                path: "/test/b.jsonl".to_string(),
775                id: "bbbbbbbb".to_string(),
776                cwd: "/test".to_string(),
777                timestamp: "2025-01-15T11:00:00.000Z".to_string(),
778                message_count: 2,
779                last_modified_ms: 2000,
780                size_bytes: 200,
781                name: Some("chosen".to_string()),
782            },
783        ];
784
785        let mut picker = SessionPicker::new(sessions);
786        picker.update(key_msg(KeyType::Down, vec![]));
787        picker.update(key_msg(KeyType::Enter, vec![]));
788        assert_eq!(picker.selected_path(), Some("/test/b.jsonl"));
789        assert!(!picker.was_cancelled());
790    }
791
792    #[test]
793    fn session_picker_cancel_keys_mark_cancelled() {
794        let sessions = vec![SessionMeta {
795            path: "/test/a.jsonl".to_string(),
796            id: "aaaaaaaa".to_string(),
797            cwd: "/test".to_string(),
798            timestamp: "2025-01-15T10:00:00.000Z".to_string(),
799            message_count: 1,
800            last_modified_ms: 1000,
801            size_bytes: 100,
802            name: None,
803        }];
804
805        let mut esc_picker = SessionPicker::new(sessions.clone());
806        esc_picker.update(key_msg(KeyType::Esc, vec![]));
807        assert!(esc_picker.was_cancelled());
808
809        let mut q_picker = SessionPicker::new(sessions.clone());
810        q_picker.update(key_msg(KeyType::Runes, vec!['q']));
811        assert!(q_picker.was_cancelled());
812
813        let mut ctrl_c_picker = SessionPicker::new(sessions);
814        ctrl_c_picker.update(key_msg(KeyType::CtrlC, vec![]));
815        assert!(ctrl_c_picker.was_cancelled());
816    }
817
818    #[test]
819    fn session_picker_view_empty_and_populated_states() {
820        let empty_picker = SessionPicker::new(Vec::new());
821        let empty_view = empty_picker.view();
822        assert!(empty_view.contains("Select a session to resume"));
823        assert!(empty_view.contains("No sessions found for this project."));
824
825        let sessions = vec![SessionMeta {
826            path: "/test/a.jsonl".to_string(),
827            id: "aaaaaaaa-bbbb".to_string(),
828            cwd: "/test".to_string(),
829            timestamp: "2025-01-15T10:00:00.000Z".to_string(),
830            message_count: 3,
831            last_modified_ms: 1000,
832            size_bytes: 100,
833            name: Some("demo".to_string()),
834        }];
835        let mut populated = SessionPicker::new(sessions);
836        populated.update(key_msg(KeyType::CtrlD, vec![]));
837        let view = populated.view();
838        assert!(view.contains("Messages"));
839        assert!(view.contains("Session ID"));
840        assert!(view.contains("Delete session? Press y/n to confirm."));
841    }
842
843    #[test]
844    fn session_picker_view_handles_non_ascii_session_ids() {
845        let sessions = vec![SessionMeta {
846            path: "/test/u.jsonl".to_string(),
847            id: "αβγδεζηθι".to_string(),
848            cwd: "/test".to_string(),
849            timestamp: "2025-01-15T10:00:00.000Z".to_string(),
850            message_count: 1,
851            last_modified_ms: 1000,
852            size_bytes: 100,
853            name: Some("unicode".to_string()),
854        }];
855
856        let view = SessionPicker::new(sessions).view();
857        assert!(view.contains("αβγδεζηθ"));
858    }
859
860    // ── selected_path when nothing chosen ──────────────────────────────
861
862    #[test]
863    fn selected_path_returns_none_when_no_selection() {
864        let picker = SessionPicker::new(vec![make_meta(Path::new("/tmp/a.jsonl"))]);
865        assert!(picker.selected_path().is_none());
866        assert!(!picker.was_cancelled());
867    }
868
869    // ── with_theme constructor ─────────────────────────────────────────
870
871    #[test]
872    fn with_theme_constructor_sets_initial_state() {
873        let theme = Theme::dark();
874        let sessions = vec![make_meta(Path::new("/tmp/a.jsonl"))];
875        let picker = SessionPicker::with_theme(sessions, &theme);
876        assert_eq!(picker.selected, 0);
877        assert!(!picker.was_cancelled());
878        assert!(picker.selected_path().is_none());
879    }
880
881    // ── delete last session causes quit ────────────────────────────────
882
883    #[test]
884    fn delete_last_session_sets_cancelled_true() {
885        let tmp = tempfile::tempdir().expect("tempdir");
886        let session_path = tmp.path().join("only.jsonl");
887        fs::write(&session_path, "test").expect("write");
888
889        let mut picker = SessionPicker::new(vec![make_meta(&session_path)]);
890
891        picker.update(key_msg(KeyType::CtrlD, vec![]));
892        let cmd = picker.update(key_msg(KeyType::Runes, vec!['y']));
893        assert!(picker.was_cancelled());
894        assert!(cmd.is_some()); // quit command issued
895    }
896
897    // ── Esc during delete prompt cancels prompt ────────────────────────
898
899    #[test]
900    fn esc_cancels_delete_prompt() {
901        let tmp = tempfile::tempdir().expect("tempdir");
902        let session_path = tmp.path().join("sess.jsonl");
903        fs::write(&session_path, "test").expect("write");
904
905        let mut picker = SessionPicker::new(vec![make_meta(&session_path)]);
906        picker.update(key_msg(KeyType::CtrlD, vec![]));
907        assert!(picker.confirm_delete.is_some());
908
909        picker.update(key_msg(KeyType::Esc, vec![]));
910        assert!(picker.confirm_delete.is_none());
911        assert!(picker.status_message.is_none());
912    }
913
914    // ── enter on empty list still returns quit ─────────────────────────
915
916    #[test]
917    fn enter_on_empty_list_returns_quit() {
918        let mut picker = SessionPicker::new(Vec::new());
919        let cmd = picker.update(key_msg(KeyType::Enter, vec![]));
920        assert!(cmd.is_some()); // quit
921        assert!(picker.selected_path().is_none());
922    }
923
924    // ── ctrl-d on empty list is a noop ─────────────────────────────────
925
926    #[test]
927    fn ctrl_d_on_empty_list_is_noop() {
928        let mut picker = SessionPicker::new(Vec::new());
929        picker.update(key_msg(KeyType::CtrlD, vec![]));
930        assert!(picker.confirm_delete.is_none());
931    }
932
933    // ── build_meta_from_jsonl ──────────────────────────────────────────
934
935    #[test]
936    fn build_meta_from_jsonl_parses_session_file() {
937        let tmp = tempfile::tempdir().expect("tempdir");
938        let session_path = tmp.path().join("test.jsonl");
939        let header = serde_json::json!({
940            "type": "header",
941            "id": "abc123",
942            "cwd": "/work",
943            "timestamp": "2025-06-01T12:00:00.000Z"
944        });
945        let msg1 = serde_json::json!({
946            "type": "message",
947            "timestamp": "2025-06-01T12:00:01.000Z",
948            "message": {"role": "user", "content": "hi"}
949        });
950        let msg2 = serde_json::json!({
951            "type": "message",
952            "timestamp": "2025-06-01T12:00:02.000Z",
953            "message": {"role": "user", "content": "hello again"}
954        });
955        let info = serde_json::json!({
956            "type": "session_info",
957            "timestamp": "2025-06-01T12:00:03.000Z",
958            "name": "My Session"
959        });
960        let content = format!(
961            "{}\n{}\n{}\n{}",
962            serde_json::to_string(&header).unwrap(),
963            serde_json::to_string(&msg1).unwrap(),
964            serde_json::to_string(&msg2).unwrap(),
965            serde_json::to_string(&info).unwrap(),
966        );
967        fs::write(&session_path, content).expect("write");
968
969        let meta = build_meta_from_jsonl(&session_path).expect("parse meta");
970        assert_eq!(meta.id, "abc123");
971        assert_eq!(meta.cwd, "/work");
972        assert_eq!(meta.message_count, 2);
973        assert_eq!(meta.name.as_deref(), Some("My Session"));
974        assert!(meta.size_bytes > 0);
975    }
976
977    #[test]
978    fn build_meta_from_jsonl_empty_file_returns_error() {
979        let tmp = tempfile::tempdir().expect("tempdir");
980        let session_path = tmp.path().join("empty.jsonl");
981        fs::write(&session_path, "").expect("write");
982
983        assert!(build_meta_from_jsonl(&session_path).is_err());
984    }
985
986    // ── is_session_file_path additional cases ──────────────────────────
987
988    #[test]
989    fn is_session_file_path_rejects_common_non_session_extensions() {
990        assert!(!is_session_file_path(Path::new("/tmp/file.json")));
991        assert!(!is_session_file_path(Path::new("/tmp/file.md")));
992        assert!(!is_session_file_path(Path::new("/tmp/file.rs")));
993    }
994
995    // ── scan_sessions_on_disk ──────────────────────────────────────────
996
997    #[test]
998    fn scan_sessions_on_disk_finds_valid_session_files() {
999        let tmp = tempfile::tempdir().expect("tempdir");
1000        let session_path = tmp.path().join("session.jsonl");
1001        let header = serde_json::json!({
1002            "type": "header",
1003            "id": "scan-test",
1004            "cwd": "/work",
1005            "timestamp": "2025-06-01T12:00:00.000Z"
1006        });
1007        fs::write(&session_path, serde_json::to_string(&header).unwrap()).expect("write");
1008
1009        // Also create a non-session file that should be ignored
1010        fs::write(tmp.path().join("notes.txt"), "not a session").expect("write");
1011
1012        let found = scan_sessions_on_disk(tmp.path());
1013        assert_eq!(found.len(), 1);
1014        assert_eq!(found[0].id, "scan-test");
1015    }
1016
1017    #[test]
1018    fn scan_sessions_on_disk_nonexistent_dir_returns_empty() {
1019        let found = scan_sessions_on_disk(Path::new("/nonexistent/dir"));
1020        assert!(found.is_empty());
1021    }
1022
1023    // ── with_theme_and_root constructor ────────────────────────────────
1024
1025    #[test]
1026    fn with_theme_and_root_stores_sessions_root() {
1027        let theme = Theme::dark();
1028        let root = PathBuf::from("/sessions");
1029        let picker = SessionPicker::with_theme_and_root(Vec::new(), &theme, root);
1030        assert!(picker.sessions_root.is_some());
1031    }
1032
1033    // ── delete adjusts selection when at end ───────────────────────────
1034
1035    #[test]
1036    fn delete_adjusts_selection_when_at_end() {
1037        let tmp = tempfile::tempdir().expect("tempdir");
1038        let path_a = tmp.path().join("a.jsonl");
1039        let path_b = tmp.path().join("b.jsonl");
1040        fs::write(&path_a, "test").expect("write a");
1041        fs::write(&path_b, "test").expect("write b");
1042
1043        let mut picker = SessionPicker::new(vec![make_meta(&path_a), make_meta(&path_b)]);
1044
1045        // Navigate to second item
1046        picker.update(key_msg(KeyType::Down, vec![]));
1047        assert_eq!(picker.selected, 1);
1048
1049        // Delete it
1050        picker.update(key_msg(KeyType::CtrlD, vec![]));
1051        picker.update(key_msg(KeyType::Runes, vec!['y']));
1052
1053        // Selection should clamp back to 0
1054        assert_eq!(picker.selected, 0);
1055        assert_eq!(picker.sessions.len(), 1);
1056    }
1057
1058    #[test]
1059    fn delete_session_file_falls_back_when_trash_command_missing() {
1060        let tmp = tempfile::tempdir().expect("tempdir");
1061        let session_path = tmp.path().join("missing-trash-fallback.jsonl");
1062        fs::write(&session_path, "test").expect("write");
1063
1064        let result = delete_session_file_with_trash_cmd(
1065            &session_path,
1066            "__pi_agent_rust_nonexistent_trash_command__",
1067        );
1068        assert!(result.is_ok(), "delete should fall back to remove_file");
1069        assert!(!session_path.exists(), "session file should be deleted");
1070    }
1071
1072    #[cfg(unix)]
1073    #[test]
1074    fn delete_session_file_falls_back_when_trash_exits_non_zero() {
1075        use std::os::unix::fs::PermissionsExt as _;
1076
1077        let tmp = tempfile::tempdir().expect("tempdir");
1078        let session_path = tmp.path().join("failing-trash-fallback.jsonl");
1079        fs::write(&session_path, "test").expect("write");
1080
1081        let trash_script = tmp.path().join("fake-trash.sh");
1082        fs::write(&trash_script, "#!/bin/sh\nexit 2\n").expect("write script");
1083        let mut perms = fs::metadata(&trash_script).expect("metadata").permissions();
1084        perms.set_mode(0o755);
1085        fs::set_permissions(&trash_script, perms).expect("chmod");
1086
1087        let trash_cmd = trash_script.to_string_lossy();
1088        let result = delete_session_file_with_trash_cmd(&session_path, &trash_cmd);
1089        assert!(result.is_ok(), "delete should fall back to remove_file");
1090        assert!(!session_path.exists(), "session file should be deleted");
1091    }
1092
1093    #[cfg(unix)]
1094    #[test]
1095    fn delete_session_file_succeeds_when_trash_deleted_file_then_failed() {
1096        use std::os::unix::fs::PermissionsExt as _;
1097
1098        let tmp = tempfile::tempdir().expect("tempdir");
1099        let session_path = tmp.path().join("trash-deleted-then-failed.jsonl");
1100        fs::write(&session_path, "test").expect("write");
1101
1102        let trash_script = tmp.path().join("fake-trash-delete-then-fail.sh");
1103        fs::write(
1104            &trash_script,
1105            format!("#!/bin/sh\nrm -f \"{}\"\nexit 2\n", session_path.display()),
1106        )
1107        .expect("write script");
1108        let mut perms = fs::metadata(&trash_script).expect("metadata").permissions();
1109        perms.set_mode(0o755);
1110        fs::set_permissions(&trash_script, perms).expect("chmod");
1111
1112        let trash_cmd = trash_script.to_string_lossy();
1113        let result = delete_session_file_with_trash_cmd(&session_path, &trash_cmd);
1114        assert!(
1115            result.is_ok(),
1116            "delete should be idempotent when file is already gone"
1117        );
1118        assert!(!session_path.exists(), "session file should remain deleted");
1119    }
1120
1121    mod proptest_session_picker {
1122        use super::*;
1123        use proptest::prelude::*;
1124
1125        proptest! {
1126            /// `truncate_session_id` never returns more chars than requested.
1127            #[test]
1128            fn truncate_respects_limit(s in "[a-z0-9\\-]{1,40}", max in 0..50usize) {
1129                let result = truncate_session_id(&s, max);
1130                assert!(result.chars().count() <= max);
1131            }
1132
1133            /// `truncate_session_id` is a prefix of the original.
1134            #[test]
1135            fn truncate_is_prefix(s in "[a-z0-9\\-]{1,40}", max in 1..50usize) {
1136                let result = truncate_session_id(&s, max);
1137                assert!(s.starts_with(result));
1138            }
1139
1140            /// `truncate_session_id` with max >= len returns the whole string.
1141            #[test]
1142            fn truncate_large_limit_identity(s in "[a-z0-9\\-]{1,20}") {
1143                let len = s.chars().count();
1144                let result = truncate_session_id(&s, len + 10);
1145                assert_eq!(result, s.as_str());
1146            }
1147
1148            /// `truncate_session_id` with max=0 returns empty.
1149            #[test]
1150            fn truncate_zero_is_empty(s in "\\PC{1,20}") {
1151                assert_eq!(truncate_session_id(&s, 0), "");
1152            }
1153
1154            /// `format_time` never panics on arbitrary strings.
1155            #[test]
1156            fn format_time_never_panics(ts in "\\PC{0,40}") {
1157                let _ = format_time(&ts);
1158            }
1159
1160            /// Valid RFC3339 timestamps format to YYYY-MM-DD HH:MM.
1161            #[test]
1162            fn format_time_valid_rfc3339(
1163                year in 2020..2030u32,
1164                month in 1..12u32,
1165                day in 1..28u32,
1166                hour in 0..23u32,
1167                min in 0..59u32
1168            ) {
1169                let ts = format!("{year}-{month:02}-{day:02}T{hour:02}:{min:02}:00Z");
1170                let result = format_time(&ts);
1171                assert!(result.contains(&format!("{year}-{month:02}-{day:02}")));
1172                assert!(result.contains(&format!("{hour:02}:{min:02}")));
1173            }
1174
1175            /// Invalid timestamps are returned as-is.
1176            #[test]
1177            fn format_time_invalid_passthrough(s in "[a-z]{5,15}") {
1178                assert_eq!(format_time(&s), s);
1179            }
1180
1181            /// `is_session_file_path` accepts .jsonl files.
1182            #[test]
1183            fn is_session_file_path_accepts_jsonl(name in "[a-z]{1,10}") {
1184                let path = format!("/tmp/{name}.jsonl");
1185                assert!(is_session_file_path(Path::new(&path)));
1186            }
1187
1188            /// `is_session_file_path` rejects random extensions.
1189            #[test]
1190            fn is_session_file_path_rejects_other(
1191                name in "[a-z]{1,10}",
1192                ext in "[a-z]{1,5}"
1193            ) {
1194                prop_assume!(ext != "jsonl" && ext != "sqlite");
1195                let path = format!("/tmp/{name}.{ext}");
1196                assert!(!is_session_file_path(Path::new(&path)));
1197            }
1198
1199            /// `is_session_file_path` rejects files without extensions.
1200            #[test]
1201            fn is_session_file_path_rejects_no_ext(name in "[a-z]{1,10}") {
1202                assert!(!is_session_file_path(Path::new(&format!("/tmp/{name}"))));
1203            }
1204
1205            /// `truncate_session_id` handles multi-byte unicode.
1206            #[test]
1207            fn truncate_unicode(max in 0..10usize) {
1208                let s = "\u{1F600}\u{1F601}\u{1F602}\u{1F603}\u{1F604}"; // 5 emoji
1209                let result = truncate_session_id(s, max);
1210                assert!(result.chars().count() <= max);
1211                assert!(s.starts_with(result));
1212            }
1213
1214            /// Truncation is idempotent for a fixed limit.
1215            #[test]
1216            fn truncate_idempotent(s in "\\PC{1,40}", max in 0..40usize) {
1217                let once = truncate_session_id(&s, max);
1218                let twice = truncate_session_id(once, max);
1219                assert_eq!(once, twice);
1220            }
1221
1222            /// Valid RFC3339 formatting is fixed-width (`YYYY-MM-DD HH:MM`).
1223            #[test]
1224            fn format_time_valid_rfc3339_fixed_width(
1225                year in 2020..2030u32,
1226                month in 1..12u32,
1227                day in 1..28u32,
1228                hour in 0..23u32,
1229                min in 0..59u32
1230            ) {
1231                let ts = format!("{year}-{month:02}-{day:02}T{hour:02}:{min:02}:00Z");
1232                let result = format_time(&ts);
1233                assert_eq!(result.len(), 16);
1234            }
1235        }
1236    }
1237}