1use anyhow::Result;
11use chrono::{DateTime, Local};
12use crossterm::{
13 event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, MouseEventKind},
14 execute,
15 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
16};
17use ratatui::{
18 Frame, Terminal,
19 backend::CrosstermBackend,
20 layout::{Constraint, Direction, Layout},
21 style::{Color, Modifier, Style},
22 text::{Line, Span},
23 widgets::Paragraph,
24};
25use std::io;
26use std::path::Path;
27
28use super::conversation::{ConversationHistory, ConversationManager};
29
30const WHEEL_STEP: usize = 3;
33
34const ROWS_PER_ENTRY: usize = 3;
37
38pub struct SessionEntry {
41 pub history: ConversationHistory,
42 pub size_bytes: u64,
43}
44
45pub fn select_conversation(
49 entries: Vec<SessionEntry>,
50 manager: &ConversationManager,
51 now: DateTime<Local>,
52) -> Result<Option<ConversationHistory>> {
53 if entries.is_empty() {
54 println!("No previous conversations found in this directory.");
55 return Ok(None);
56 }
57
58 enable_raw_mode()?;
59 let mut stdout = io::stdout();
60 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
64 let backend = CrosstermBackend::new(stdout);
65 let mut terminal = Terminal::new(backend)?;
66
67 let mut state = SelectorState::new(entries);
68 let result = run_selector(&mut terminal, &mut state, manager, now);
69
70 disable_raw_mode()?;
71 execute!(
72 terminal.backend_mut(),
73 LeaveAlternateScreen,
74 DisableMouseCapture
75 )?;
76 terminal.show_cursor()?;
77
78 result
79}
80
81pub struct SelectorState {
84 entries: Vec<SessionEntry>,
85 query: String,
86 selected: usize,
88 scroll_offset: usize,
91 viewport_entries: usize,
94 pending_delete: Option<usize>,
96}
97
98impl SelectorState {
99 pub fn new(entries: Vec<SessionEntry>) -> Self {
100 Self {
101 entries,
102 query: String::new(),
103 selected: 0,
104 scroll_offset: 0,
105 viewport_entries: 0,
106 pending_delete: None,
107 }
108 }
109
110 fn filtered(&self) -> Vec<usize> {
115 if self.query.is_empty() {
116 return (0..self.entries.len()).collect();
117 }
118 let needle = self.query.to_lowercase();
119 self.entries
120 .iter()
121 .enumerate()
122 .filter(|(_, e)| entry_matches(&e.history, &needle))
123 .map(|(i, _)| i)
124 .collect()
125 }
126
127 fn current(&self) -> Option<&SessionEntry> {
129 self.filtered()
130 .get(self.selected)
131 .map(|&i| &self.entries[i])
132 }
133
134 fn move_down(&mut self) {
135 let n = self.filtered().len();
136 if n > 0 && self.selected + 1 < n {
137 self.selected += 1;
138 let visible = self.viewport_entries.max(1);
140 if self.selected >= self.scroll_offset + visible {
141 self.scroll_offset = self.selected + 1 - visible;
142 }
143 }
144 }
145
146 fn move_up(&mut self) {
147 if self.selected > 0 {
148 self.selected -= 1;
149 if self.selected < self.scroll_offset {
151 self.scroll_offset = self.selected;
152 }
153 }
154 }
155
156 fn push_query(&mut self, c: char) {
159 self.query.push(c);
160 self.selected = 0;
161 self.scroll_offset = 0;
162 }
163
164 fn pop_query(&mut self) {
165 self.query.pop();
166 self.selected = 0;
167 self.scroll_offset = 0;
168 }
169
170 fn scroll_viewport_down(&mut self) {
173 self.scroll_offset = self.scroll_offset.saturating_add(WHEEL_STEP);
174 }
175
176 fn scroll_viewport_up(&mut self) {
177 self.scroll_offset = self.scroll_offset.saturating_sub(WHEEL_STEP);
178 }
179
180 fn request_delete(&mut self) {
183 self.pending_delete = self.filtered().get(self.selected).copied();
184 }
185
186 fn cancel_delete(&mut self) {
187 self.pending_delete = None;
188 }
189
190 fn take_pending_delete(&mut self) -> Option<usize> {
191 self.pending_delete.take()
192 }
193
194 fn remove_entry(&mut self, entries_idx: usize) {
197 if entries_idx >= self.entries.len() {
198 return;
199 }
200 self.entries.remove(entries_idx);
201 let n = self.filtered().len();
202 if n == 0 {
203 self.selected = 0;
204 } else if self.selected >= n {
205 self.selected = n - 1;
206 }
207 }
208}
209
210fn entry_matches(history: &ConversationHistory, needle: &str) -> bool {
213 history.title.to_lowercase().contains(needle)
214 || history
215 .git_branch
216 .as_deref()
217 .is_some_and(|b| b.to_lowercase().contains(needle))
218}
219
220fn run_selector(
221 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
222 state: &mut SelectorState,
223 manager: &ConversationManager,
224 now: DateTime<Local>,
225) -> Result<Option<ConversationHistory>> {
226 loop {
227 terminal.draw(|f| render(f, state, now))?;
228
229 match event::read()? {
230 Event::Key(key) => {
231 if state.pending_delete.is_some() {
235 if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y')) {
236 if let Some(idx) = state.take_pending_delete() {
237 let id = state.entries[idx].history.id.clone();
238 if manager.delete_conversation(&id).is_ok() {
239 state.remove_entry(idx);
240 if state.entries.is_empty() {
241 return Ok(None);
242 }
243 }
244 }
245 } else {
246 state.cancel_delete();
247 }
248 continue;
249 }
250 match key.code {
251 KeyCode::Esc => return Ok(None),
252 KeyCode::Enter => return Ok(state.current().map(|e| e.history.clone())),
253 KeyCode::Down => state.move_down(),
254 KeyCode::Up => state.move_up(),
255 KeyCode::Delete => state.request_delete(),
258 KeyCode::Backspace => state.pop_query(),
259 KeyCode::Char(c) => state.push_query(c),
262 _ => {},
263 }
264 },
265 Event::Mouse(m) => match m.kind {
267 MouseEventKind::ScrollUp => state.scroll_viewport_up(),
268 MouseEventKind::ScrollDown => state.scroll_viewport_down(),
269 _ => {},
270 },
271 _ => {},
272 }
273 }
274}
275
276const ACCENT: Color = Color::Cyan;
279const META: Color = Color::Gray;
280const DIM: Color = Color::DarkGray;
281
282pub fn render(f: &mut Frame, state: &mut SelectorState, now: DateTime<Local>) {
289 let layout = Layout::default()
290 .direction(Direction::Vertical)
291 .constraints([
292 Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(3), Constraint::Length(1), ]);
301 let [title, _s1, search, _s2, project, _s3, list, hints] = f.area().layout(&layout);
302
303 let filtered = state.filtered();
307 let visible = (list.height as usize / ROWS_PER_ENTRY).max(1);
308 state.viewport_entries = visible;
309 let max_offset = filtered.len().saturating_sub(visible);
310 if state.scroll_offset > max_offset {
311 state.scroll_offset = max_offset;
312 }
313 let scroll_offset = state.scroll_offset;
314 let selected = state.selected;
315 let pending_delete = state.pending_delete;
316
317 f.render_widget(
318 Paragraph::new(Line::from(Span::styled(
319 "Resume session",
320 Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
321 ))),
322 title,
323 );
324
325 let search_line = if state.query.is_empty() {
327 Line::from(Span::styled(" Search…", Style::default().fg(DIM)))
328 } else {
329 Line::from(vec![
330 Span::styled(" ", Style::default()),
331 Span::styled(state.query.clone(), Style::default().fg(Color::White)),
332 Span::styled("▏", Style::default().fg(ACCENT)),
333 ])
334 };
335 f.render_widget(Paragraph::new(search_line), search);
336
337 if let Some(name) = state
339 .entries
340 .first()
341 .map(|e| project_name(&e.history.project_path))
342 {
343 f.render_widget(
344 Paragraph::new(Line::from(Span::styled(
345 name,
346 Style::default().fg(META).add_modifier(Modifier::BOLD),
347 ))),
348 project,
349 );
350 }
351
352 let lines: Vec<Line> = if filtered.is_empty() {
353 vec![Line::from(Span::styled(
354 " No sessions match your search.",
355 Style::default().fg(DIM),
356 ))]
357 } else {
358 filtered
359 .iter()
360 .enumerate()
361 .skip(scroll_offset)
362 .take(visible)
363 .flat_map(|(row, &idx)| {
364 let entry = &state.entries[idx];
365 let is_selected = row == selected;
366 let (marker, title_style) = if is_selected {
367 (
368 "> ",
369 Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
370 )
371 } else {
372 (" ", Style::default().fg(Color::White))
373 };
374 let title_line = Line::from(vec![
375 Span::styled(marker, Style::default().fg(ACCENT)),
376 Span::styled(entry.history.title.clone(), title_style),
377 ]);
378 let meta_line = Line::from(vec![Span::styled(
379 format!(" {}", meta_label(entry, now)),
380 Style::default().fg(META),
381 )]);
382 [title_line, meta_line, Line::from("")]
383 })
384 .collect()
385 };
386 f.render_widget(Paragraph::new(lines), list);
387
388 let hints_line = if let Some(idx) = pending_delete {
390 let name: String = state
391 .entries
392 .get(idx)
393 .map(|e| e.history.title.chars().take(40).collect::<String>())
394 .unwrap_or_default();
395 Line::from(Span::styled(
396 format!("Delete \"{name}\"? y confirms · any other key cancels"),
397 Style::default().fg(Color::Yellow),
398 ))
399 } else {
400 Line::from(Span::styled(
401 "↑↓ select · type to search · del delete · enter resume · esc cancel",
402 Style::default().fg(DIM),
403 ))
404 };
405 f.render_widget(Paragraph::new(hints_line), hints);
406}
407
408fn meta_label(entry: &SessionEntry, now: DateTime<Local>) -> String {
411 let mut bits = vec![humanize_relative(now, entry.history.updated_at)];
412 if let Some(branch) = &entry.history.git_branch
413 && !branch.is_empty()
414 {
415 bits.push(branch.clone());
416 }
417 if entry.history.forked_from.is_some() {
420 bits.push("forked".to_string());
421 }
422 bits.push(humanize_size(entry.size_bytes));
423 bits.join(" · ")
424}
425
426fn project_name(project_path: &str) -> String {
428 Path::new(project_path)
429 .file_name()
430 .map(|n| n.to_string_lossy().into_owned())
431 .filter(|s| !s.is_empty())
432 .unwrap_or_else(|| project_path.to_string())
433}
434
435fn humanize_relative(now: DateTime<Local>, then: DateTime<Local>) -> String {
438 let secs = (now - then).num_seconds().max(0);
439 let (n, unit) = if secs < 45 {
440 return "just now".to_string();
441 } else if secs < 3600 {
442 (secs / 60, "minute")
443 } else if secs < 86_400 {
444 (secs / 3600, "hour")
445 } else if secs < 7 * 86_400 {
446 (secs / 86_400, "day")
447 } else if secs < 30 * 86_400 {
448 (secs / (7 * 86_400), "week")
449 } else {
450 (secs / (30 * 86_400), "month")
451 };
452 let n = n.max(1);
453 if n == 1 {
454 format!("1 {unit} ago")
455 } else {
456 format!("{n} {unit}s ago")
457 }
458}
459
460fn humanize_size(bytes: u64) -> String {
462 const KB: f64 = 1024.0;
463 const MB: f64 = 1024.0 * 1024.0;
464 let b = bytes as f64;
465 if b < KB {
466 format!("{bytes}B")
467 } else if b < MB {
468 format!("{:.1}KB", b / KB)
469 } else {
470 format!("{:.1}MB", b / MB)
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477 use chrono::TimeZone;
478 use ratatui::Terminal;
479 use ratatui::backend::TestBackend;
480 use std::collections::VecDeque;
481
482 fn at(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> DateTime<Local> {
483 Local.with_ymd_and_hms(y, mo, d, h, mi, 0).unwrap()
484 }
485
486 fn history(title: &str, branch: Option<&str>, updated: DateTime<Local>) -> ConversationHistory {
487 ConversationHistory {
488 id: "20260101_000000_000".to_string(),
489 title: title.to_string(),
490 messages: Vec::new(),
491 model_name: "ollama/test".to_string(),
492 project_path: "/home/nsabaj/Development/source-clone".to_string(),
493 created_at: updated,
494 updated_at: updated,
495 compactions: Vec::new(),
496 input_history: VecDeque::new(),
497 git_branch: branch.map(str::to_string),
498 safety_mode: None,
499 plan: None,
500 last_token_usage: None,
501 cumulative_token_usage: crate::domain::TokenUsageTotals::default(),
502 context_usage: None,
503 forked_from: None,
504 parent_session: None,
505 cli_version: None,
506 git_sha: None,
507 tasks: crate::domain::TaskStore::default(),
508 }
509 }
510
511 fn entry(
512 title: &str,
513 branch: Option<&str>,
514 updated: DateTime<Local>,
515 size: u64,
516 ) -> SessionEntry {
517 SessionEntry {
518 history: history(title, branch, updated),
519 size_bytes: size,
520 }
521 }
522
523 #[test]
524 fn humanize_relative_scales_and_pluralizes() {
525 let now = at(2026, 7, 2, 12, 0);
526 assert_eq!(humanize_relative(now, now), "just now");
527 assert_eq!(
528 humanize_relative(now, at(2026, 7, 2, 11, 58)),
529 "2 minutes ago"
530 );
531 assert_eq!(humanize_relative(now, at(2026, 7, 2, 11, 0)), "1 hour ago");
532 assert_eq!(humanize_relative(now, at(2026, 7, 1, 12, 0)), "1 day ago");
533 assert_eq!(humanize_relative(now, at(2026, 6, 29, 12, 0)), "3 days ago");
534 assert_eq!(humanize_relative(now, at(2026, 6, 20, 12, 0)), "1 week ago");
535 assert_eq!(
536 humanize_relative(now, at(2026, 5, 1, 12, 0)),
537 "2 months ago"
538 );
539 assert_eq!(humanize_relative(now, at(2026, 7, 2, 12, 30)), "just now");
541 }
542
543 #[test]
544 fn humanize_size_picks_unit() {
545 assert_eq!(humanize_size(512), "512B");
546 assert_eq!(humanize_size(23_100), "22.6KB");
547 assert_eq!(humanize_size(1_367_426), "1.3MB");
548 }
549
550 #[test]
551 fn filtering_matches_title_and_branch_case_insensitively() {
552 let now = at(2026, 7, 2, 12, 0);
553 let mut state = SelectorState::new(vec![
554 entry("Examine the workspace", Some("main"), now, 100),
555 entry("Fix the parser", Some("feature/parser"), now, 200),
556 entry("Unrelated", Some("main"), now, 300),
557 ]);
558 assert_eq!(state.filtered().len(), 3);
560 state.push_query('E');
562 state.push_query('x');
563 assert_eq!(state.filtered(), vec![0]);
564 state.query.clear();
566 for c in "parser".chars() {
567 state.push_query(c);
568 }
569 assert_eq!(state.filtered(), vec![1]);
570 state.query.clear();
572 for c in "zzz".chars() {
573 state.push_query(c);
574 }
575 assert!(state.filtered().is_empty());
576 assert!(state.current().is_none());
577 }
578
579 #[test]
580 fn navigation_is_clamped_to_filtered_set() {
581 let now = at(2026, 7, 2, 12, 0);
582 let mut state =
583 SelectorState::new(vec![entry("A", None, now, 1), entry("B", None, now, 2)]);
584 state.move_up(); assert_eq!(state.selected, 0);
586 state.move_down();
587 assert_eq!(state.selected, 1);
588 state.move_down(); assert_eq!(state.selected, 1);
590 assert_eq!(state.current().unwrap().history.title, "B");
591 }
592
593 #[test]
594 fn render_shows_claude_code_style_rows() {
595 let now = at(2026, 7, 2, 12, 0);
596 let mut state = SelectorState::new(vec![
597 entry(
598 "Examine the workspace",
599 Some("main"),
600 at(2026, 7, 2, 10, 0),
601 1_367_426,
602 ),
603 entry(
604 "Older session",
605 Some("master"),
606 at(2026, 6, 29, 12, 0),
607 18_400_000,
608 ),
609 ]);
610 let backend = TestBackend::new(80, 24);
611 let mut terminal = Terminal::new(backend).unwrap();
612 terminal.draw(|f| render(f, &mut state, now)).unwrap();
613 let buf = terminal.backend().buffer();
614 let mut text = String::new();
615 for y in 0..buf.area.height {
616 for x in 0..buf.area.width {
617 text.push_str(buf[(x, y)].symbol());
618 }
619 text.push('\n');
620 }
621 assert!(text.contains("Resume session"), "header:\n{text}");
622 assert!(text.contains("Search"), "search placeholder:\n{text}");
623 assert!(text.contains("source-clone"), "project name:\n{text}");
624 assert!(text.contains("Examine the workspace"), "title:\n{text}");
625 assert!(text.contains("2 hours ago · main · 1.3MB"), "meta:\n{text}");
627 assert!(
628 text.contains("3 days ago · master · 17.5MB"),
629 "meta2:\n{text}"
630 );
631 assert!(text.contains("> Examine the workspace"), "marker:\n{text}");
633 assert!(text.contains("esc cancel"), "hints:\n{text}");
634 }
635
636 #[test]
637 fn render_empty_filter_shows_no_match_message() {
638 let now = at(2026, 7, 2, 12, 0);
639 let mut state = SelectorState::new(vec![entry("A", None, now, 1)]);
640 for c in "zzz".chars() {
641 state.push_query(c);
642 }
643 let backend = TestBackend::new(80, 24);
644 let mut terminal = Terminal::new(backend).unwrap();
645 terminal.draw(|f| render(f, &mut state, now)).unwrap();
646 let buf = terminal.backend().buffer();
647 let mut text = String::new();
648 for y in 0..buf.area.height {
649 for x in 0..buf.area.width {
650 text.push_str(buf[(x, y)].symbol());
651 }
652 }
653 assert!(text.contains("No sessions match"), "{text}");
654 }
655
656 fn dump(terminal: &Terminal<TestBackend>) -> String {
658 let buf = terminal.backend().buffer();
659 let mut text = String::new();
660 for y in 0..buf.area.height {
661 for x in 0..buf.area.width {
662 text.push_str(buf[(x, y)].symbol());
663 }
664 text.push('\n');
665 }
666 text
667 }
668
669 #[test]
670 fn arrowing_past_the_viewport_scrolls_to_follow_selection() {
671 let now = at(2026, 7, 2, 12, 0);
672 let mut state = SelectorState::new(
675 (0..8)
676 .map(|i| entry(&format!("Session {i}"), None, now, 100))
677 .collect(),
678 );
679 let backend = TestBackend::new(80, 14);
680 let mut terminal = Terminal::new(backend).unwrap();
681 terminal.draw(|f| render(f, &mut state, now)).unwrap();
683 for _ in 0..7 {
684 state.move_down();
685 }
686 terminal.draw(|f| render(f, &mut state, now)).unwrap();
687 let text = dump(&terminal);
688 assert!(
689 text.contains("> Session 7"),
690 "selection followed into view:\n{text}"
691 );
692 assert!(
693 !text.contains("Session 0"),
694 "top entries scrolled away:\n{text}"
695 );
696 }
697
698 #[test]
699 fn wheel_scrolls_viewport_without_moving_selection() {
700 let now = at(2026, 7, 2, 12, 0);
701 let mut state = SelectorState::new(
702 (0..8)
703 .map(|i| entry(&format!("Session {i}"), None, now, 100))
704 .collect(),
705 );
706 let backend = TestBackend::new(80, 14);
707 let mut terminal = Terminal::new(backend).unwrap();
708 terminal.draw(|f| render(f, &mut state, now)).unwrap();
709
710 state.scroll_viewport_down(); terminal.draw(|f| render(f, &mut state, now)).unwrap();
712 let text = dump(&terminal);
713 assert_eq!(state.selected, 0, "the wheel must not move the selection");
714 assert!(
715 !text.contains("Session 0"),
716 "viewport scrolled past the top:\n{text}"
717 );
718 }
719
720 #[test]
721 fn delete_flow_arms_confirm_then_drops_the_entry() {
722 let now = at(2026, 7, 2, 12, 0);
723 let mut state = SelectorState::new(vec![
724 entry("keep", None, now, 1),
725 entry("gone", None, now, 1),
726 ]);
727 state.move_down(); state.request_delete();
729 assert_eq!(
730 state.pending_delete,
731 Some(1),
732 "armed on the highlighted entry"
733 );
734 let idx = state.take_pending_delete().expect("pending");
737 state.remove_entry(idx);
738 assert_eq!(state.entries.len(), 1);
739 assert_eq!(
740 state.current().map(|e| e.history.title.as_str()),
741 Some("keep"),
742 "selection re-clamped onto the surviving entry"
743 );
744 }
745}