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