1use 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
22pub 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#[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#[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 #[allow(clippy::missing_const_for_fn)] #[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 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 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)] 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 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 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 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 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
304pub 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
312pub 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 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 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 picker.update(key_msg(KeyType::Down, vec![]));
648 assert_eq!(picker.selected, 1);
649
650 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 picker.update(key_msg(KeyType::Runes, vec!['j']));
685 assert_eq!(picker.selected, 1);
686
687 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 #[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 #[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 #[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()); }
896
897 #[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 #[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()); assert!(picker.selected_path().is_none());
922 }
923
924 #[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 #[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 #[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 #[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 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 #[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 #[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 picker.update(key_msg(KeyType::Down, vec![]));
1047 assert_eq!(picker.selected, 1);
1048
1049 picker.update(key_msg(KeyType::CtrlD, vec![]));
1051 picker.update(key_msg(KeyType::Runes, vec!['y']));
1052
1053 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 #[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 #[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 #[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 #[test]
1150 fn truncate_zero_is_empty(s in "\\PC{1,20}") {
1151 assert_eq!(truncate_session_id(&s, 0), "");
1152 }
1153
1154 #[test]
1156 fn format_time_never_panics(ts in "\\PC{0,40}") {
1157 let _ = format_time(&ts);
1158 }
1159
1160 #[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 #[test]
1177 fn format_time_invalid_passthrough(s in "[a-z]{5,15}") {
1178 assert_eq!(format_time(&s), s);
1179 }
1180
1181 #[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 #[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 #[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 #[test]
1207 fn truncate_unicode(max in 0..10usize) {
1208 let s = "\u{1F600}\u{1F601}\u{1F602}\u{1F603}\u{1F604}"; let result = truncate_session_id(s, max);
1210 assert!(result.chars().count() <= max);
1211 assert!(s.starts_with(result));
1212 }
1213
1214 #[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 #[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}