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
291#[expect(
298 clippy::too_many_lines,
299 reason = "predates the lint; see .github/baselines/expect_budget.txt"
300)]
301pub fn render(f: &mut Frame, state: &mut SelectorState, now: DateTime<Local>) {
302 let layout = Layout::default()
303 .direction(Direction::Vertical)
304 .constraints([
305 Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(3), Constraint::Length(1), ]);
314 let [title, _s1, search, _s2, project, _s3, list, hints] = f.area().layout(&layout);
315
316 let filtered = state.filtered();
320 let visible = (list.height as usize / ROWS_PER_ENTRY).max(1);
321 state.viewport_entries = visible;
322 let max_offset = filtered.len().saturating_sub(visible);
323 if state.scroll_offset > max_offset {
324 state.scroll_offset = max_offset;
325 }
326 let scroll_offset = state.scroll_offset;
327 let selected = state.selected;
328 let pending_delete = state.pending_delete;
329
330 f.render_widget(
331 Paragraph::new(Line::from(Span::styled(
332 "Resume session",
333 Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
334 ))),
335 title,
336 );
337
338 let search_line = if state.query.is_empty() {
340 Line::from(Span::styled(" Search…", Style::default().fg(DIM)))
341 } else {
342 Line::from(vec![
343 Span::styled(" ", Style::default()),
344 Span::styled(state.query.clone(), Style::default().fg(Color::White)),
345 Span::styled("▏", Style::default().fg(ACCENT)),
346 ])
347 };
348 f.render_widget(Paragraph::new(search_line), search);
349
350 if let Some(name) = state
352 .entries
353 .first()
354 .map(|e| project_name(&e.history.project_path))
355 {
356 f.render_widget(
357 Paragraph::new(Line::from(Span::styled(
358 name,
359 Style::default().fg(META).add_modifier(Modifier::BOLD),
360 ))),
361 project,
362 );
363 }
364
365 let lines: Vec<Line> = if filtered.is_empty() {
366 vec![Line::from(Span::styled(
367 " No sessions match your search.",
368 Style::default().fg(DIM),
369 ))]
370 } else {
371 filtered
372 .iter()
373 .enumerate()
374 .skip(scroll_offset)
375 .take(visible)
376 .flat_map(|(row, &idx)| {
377 let entry = &state.entries[idx];
378 let is_selected = row == selected;
379 let (marker, title_style) = if is_selected {
380 (
381 "> ",
382 Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
383 )
384 } else {
385 (" ", Style::default().fg(Color::White))
386 };
387 let title_line = Line::from(vec![
388 Span::styled(marker, Style::default().fg(ACCENT)),
389 Span::styled(entry.history.title.clone(), title_style),
390 ]);
391 let meta_line = Line::from(vec![Span::styled(
392 format!(" {}", meta_label(entry, now)),
393 Style::default().fg(META),
394 )]);
395 [title_line, meta_line, Line::from("")]
396 })
397 .collect()
398 };
399 f.render_widget(Paragraph::new(lines), list);
400
401 let hints_line = if let Some(idx) = pending_delete {
403 let name: String = state
404 .entries
405 .get(idx)
406 .map(|e| e.history.title.chars().take(40).collect::<String>())
407 .unwrap_or_default();
408 Line::from(Span::styled(
409 format!("Delete \"{name}\"? y confirms · any other key cancels"),
410 Style::default().fg(Color::Yellow),
411 ))
412 } else {
413 Line::from(Span::styled(
414 "↑↓ select · type to search · del delete · enter resume · esc cancel",
415 Style::default().fg(DIM),
416 ))
417 };
418 f.render_widget(Paragraph::new(hints_line), hints);
419}
420
421fn meta_label(entry: &SessionEntry, now: DateTime<Local>) -> String {
424 let mut bits = vec![humanize_relative(now, entry.history.updated_at)];
425 if let Some(branch) = &entry.history.git_branch
426 && !branch.is_empty()
427 {
428 bits.push(branch.clone());
429 }
430 if entry.history.forked_from.is_some() {
433 bits.push("forked".to_string());
434 }
435 bits.push(humanize_size(entry.size_bytes));
436 bits.join(" · ")
437}
438
439fn project_name(project_path: &str) -> String {
441 Path::new(project_path)
442 .file_name()
443 .map(|n| n.to_string_lossy().into_owned())
444 .filter(|s| !s.is_empty())
445 .unwrap_or_else(|| project_path.to_string())
446}
447
448fn humanize_relative(now: DateTime<Local>, then: DateTime<Local>) -> String {
451 let secs = (now - then).num_seconds().max(0);
452 let (n, unit) = if secs < 45 {
453 return "just now".to_string();
454 } else if secs < 3600 {
455 (secs / 60, "minute")
456 } else if secs < 86_400 {
457 (secs / 3600, "hour")
458 } else if secs < 7 * 86_400 {
459 (secs / 86_400, "day")
460 } else if secs < 30 * 86_400 {
461 (secs / (7 * 86_400), "week")
462 } else {
463 (secs / (30 * 86_400), "month")
464 };
465 let n = n.max(1);
466 if n == 1 {
467 format!("1 {unit} ago")
468 } else {
469 format!("{n} {unit}s ago")
470 }
471}
472
473fn humanize_size(bytes: u64) -> String {
475 const KB: f64 = 1024.0;
476 const MB: f64 = 1024.0 * 1024.0;
477 let b = bytes as f64;
478 if b < KB {
479 format!("{bytes}B")
480 } else if b < MB {
481 format!("{:.1}KB", b / KB)
482 } else {
483 format!("{:.1}MB", b / MB)
484 }
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490 use chrono::TimeZone;
491 use ratatui::Terminal;
492 use ratatui::backend::TestBackend;
493 fn at(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> DateTime<Local> {
494 Local.with_ymd_and_hms(y, mo, d, h, mi, 0).unwrap()
495 }
496
497 fn history(title: &str, branch: Option<&str>, updated: DateTime<Local>) -> ConversationHistory {
498 let mut h = ConversationHistory::new(
501 "/home/nsabaj/Development/source-clone".to_string(),
502 "ollama/test".to_string(),
503 updated,
504 );
505 h.id = "20260101_000000_000".to_string();
506 h.title = title.to_string();
507 h.created_at = updated;
508 h.updated_at = updated;
509 h.git_branch = branch.map(str::to_string);
510 h
511 }
512
513 fn entry(
514 title: &str,
515 branch: Option<&str>,
516 updated: DateTime<Local>,
517 size: u64,
518 ) -> SessionEntry {
519 SessionEntry {
520 history: history(title, branch, updated),
521 size_bytes: size,
522 }
523 }
524
525 #[test]
526 fn humanize_relative_scales_and_pluralizes() {
527 let now = at(2026, 7, 2, 12, 0);
528 assert_eq!(humanize_relative(now, now), "just now");
529 assert_eq!(
530 humanize_relative(now, at(2026, 7, 2, 11, 58)),
531 "2 minutes ago"
532 );
533 assert_eq!(humanize_relative(now, at(2026, 7, 2, 11, 0)), "1 hour ago");
534 assert_eq!(humanize_relative(now, at(2026, 7, 1, 12, 0)), "1 day ago");
535 assert_eq!(humanize_relative(now, at(2026, 6, 29, 12, 0)), "3 days ago");
536 assert_eq!(humanize_relative(now, at(2026, 6, 20, 12, 0)), "1 week ago");
537 assert_eq!(
538 humanize_relative(now, at(2026, 5, 1, 12, 0)),
539 "2 months ago"
540 );
541 assert_eq!(humanize_relative(now, at(2026, 7, 2, 12, 30)), "just now");
543 }
544
545 #[test]
546 fn humanize_size_picks_unit() {
547 assert_eq!(humanize_size(512), "512B");
548 assert_eq!(humanize_size(23_100), "22.6KB");
549 assert_eq!(humanize_size(1_367_426), "1.3MB");
550 }
551
552 #[test]
553 fn filtering_matches_title_and_branch_case_insensitively() {
554 let now = at(2026, 7, 2, 12, 0);
555 let mut state = SelectorState::new(vec![
556 entry("Examine the workspace", Some("main"), now, 100),
557 entry("Fix the parser", Some("feature/parser"), now, 200),
558 entry("Unrelated", Some("main"), now, 300),
559 ]);
560 assert_eq!(state.filtered().len(), 3);
562 state.push_query('E');
564 state.push_query('x');
565 assert_eq!(state.filtered(), vec![0]);
566 state.query.clear();
568 for c in "parser".chars() {
569 state.push_query(c);
570 }
571 assert_eq!(state.filtered(), vec![1]);
572 state.query.clear();
574 for c in "zzz".chars() {
575 state.push_query(c);
576 }
577 assert!(state.filtered().is_empty());
578 assert!(state.current().is_none());
579 }
580
581 #[test]
582 fn navigation_is_clamped_to_filtered_set() {
583 let now = at(2026, 7, 2, 12, 0);
584 let mut state =
585 SelectorState::new(vec![entry("A", None, now, 1), entry("B", None, now, 2)]);
586 state.move_up(); assert_eq!(state.selected, 0);
588 state.move_down();
589 assert_eq!(state.selected, 1);
590 state.move_down(); assert_eq!(state.selected, 1);
592 assert_eq!(state.current().unwrap().history.title, "B");
593 }
594
595 #[test]
596 fn render_shows_claude_code_style_rows() {
597 let now = at(2026, 7, 2, 12, 0);
598 let mut state = SelectorState::new(vec![
599 entry(
600 "Examine the workspace",
601 Some("main"),
602 at(2026, 7, 2, 10, 0),
603 1_367_426,
604 ),
605 entry(
606 "Older session",
607 Some("master"),
608 at(2026, 6, 29, 12, 0),
609 18_400_000,
610 ),
611 ]);
612 let backend = TestBackend::new(80, 24);
613 let mut terminal = Terminal::new(backend).unwrap();
614 terminal.draw(|f| render(f, &mut state, now)).unwrap();
615 let buf = terminal.backend().buffer();
616 let mut text = String::new();
617 for y in 0..buf.area.height {
618 for x in 0..buf.area.width {
619 text.push_str(buf[(x, y)].symbol());
620 }
621 text.push('\n');
622 }
623 assert!(text.contains("Resume session"), "header:\n{text}");
624 assert!(text.contains("Search"), "search placeholder:\n{text}");
625 assert!(text.contains("source-clone"), "project name:\n{text}");
626 assert!(text.contains("Examine the workspace"), "title:\n{text}");
627 assert!(text.contains("2 hours ago · main · 1.3MB"), "meta:\n{text}");
629 assert!(
630 text.contains("3 days ago · master · 17.5MB"),
631 "meta2:\n{text}"
632 );
633 assert!(text.contains("> Examine the workspace"), "marker:\n{text}");
635 assert!(text.contains("esc cancel"), "hints:\n{text}");
636 }
637
638 #[test]
639 fn render_empty_filter_shows_no_match_message() {
640 let now = at(2026, 7, 2, 12, 0);
641 let mut state = SelectorState::new(vec![entry("A", None, now, 1)]);
642 for c in "zzz".chars() {
643 state.push_query(c);
644 }
645 let backend = TestBackend::new(80, 24);
646 let mut terminal = Terminal::new(backend).unwrap();
647 terminal.draw(|f| render(f, &mut state, now)).unwrap();
648 let buf = terminal.backend().buffer();
649 let mut text = String::new();
650 for y in 0..buf.area.height {
651 for x in 0..buf.area.width {
652 text.push_str(buf[(x, y)].symbol());
653 }
654 }
655 assert!(text.contains("No sessions match"), "{text}");
656 }
657
658 fn dump(terminal: &Terminal<TestBackend>) -> String {
660 let buf = terminal.backend().buffer();
661 let mut text = String::new();
662 for y in 0..buf.area.height {
663 for x in 0..buf.area.width {
664 text.push_str(buf[(x, y)].symbol());
665 }
666 text.push('\n');
667 }
668 text
669 }
670
671 #[test]
672 fn arrowing_past_the_viewport_scrolls_to_follow_selection() {
673 let now = at(2026, 7, 2, 12, 0);
674 let mut state = SelectorState::new(
677 (0..8)
678 .map(|i| entry(&format!("Session {i}"), None, now, 100))
679 .collect(),
680 );
681 let backend = TestBackend::new(80, 14);
682 let mut terminal = Terminal::new(backend).unwrap();
683 terminal.draw(|f| render(f, &mut state, now)).unwrap();
685 for _ in 0..7 {
686 state.move_down();
687 }
688 terminal.draw(|f| render(f, &mut state, now)).unwrap();
689 let text = dump(&terminal);
690 assert!(
691 text.contains("> Session 7"),
692 "selection followed into view:\n{text}"
693 );
694 assert!(
695 !text.contains("Session 0"),
696 "top entries scrolled away:\n{text}"
697 );
698 }
699
700 #[test]
701 fn wheel_scrolls_viewport_without_moving_selection() {
702 let now = at(2026, 7, 2, 12, 0);
703 let mut state = SelectorState::new(
704 (0..8)
705 .map(|i| entry(&format!("Session {i}"), None, now, 100))
706 .collect(),
707 );
708 let backend = TestBackend::new(80, 14);
709 let mut terminal = Terminal::new(backend).unwrap();
710 terminal.draw(|f| render(f, &mut state, now)).unwrap();
711
712 state.scroll_viewport_down(); terminal.draw(|f| render(f, &mut state, now)).unwrap();
714 let text = dump(&terminal);
715 assert_eq!(state.selected, 0, "the wheel must not move the selection");
716 assert!(
717 !text.contains("Session 0"),
718 "viewport scrolled past the top:\n{text}"
719 );
720 }
721
722 #[test]
723 fn delete_flow_arms_confirm_then_drops_the_entry() {
724 let now = at(2026, 7, 2, 12, 0);
725 let mut state = SelectorState::new(vec![
726 entry("keep", None, now, 1),
727 entry("gone", None, now, 1),
728 ]);
729 state.move_down(); state.request_delete();
731 assert_eq!(
732 state.pending_delete,
733 Some(1),
734 "armed on the highlighted entry"
735 );
736 let idx = state.take_pending_delete().expect("pending");
739 state.remove_entry(idx);
740 assert_eq!(state.entries.len(), 1);
741 assert_eq!(
742 state.current().map(|e| e.history.title.as_str()),
743 Some("keep"),
744 "selection re-clamped onto the surviving entry"
745 );
746 }
747}