Skip to main content

mermaid_cli/session/
selector.rs

1//! The `--resume` conversation picker: a searchable list of this
2//! directory's past sessions, styled to match the main TUI (borderless,
3//! muted-gray meta text) rather than the old bordered box.
4//!
5//! Structure mirrors the render layer's split: [`SelectorState`] is pure
6//! (query + selection + filtering, unit-tested) and [`render`] draws it to a
7//! `Frame` (asserted via ratatui's `TestBackend`). Only [`select_conversation`]
8//! touches the real terminal.
9
10use anyhow::Result;
11use chrono::{DateTime, Local};
12use crossterm::{
13    event::{self, Event, KeyCode},
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;
29
30/// One row in the picker: a conversation plus its on-disk size (shown in the
31/// meta line; not stored on the history itself).
32pub struct SessionEntry {
33    pub history: ConversationHistory,
34    pub size_bytes: u64,
35}
36
37/// Show the searchable resume picker and return the chosen conversation, or
38/// `None` if the user cancelled. `now` is injected so the relative-time labels
39/// are testable and match the caller's clock.
40pub fn select_conversation(
41    entries: Vec<SessionEntry>,
42    now: DateTime<Local>,
43) -> Result<Option<ConversationHistory>> {
44    if entries.is_empty() {
45        println!("No previous conversations found in this directory.");
46        return Ok(None);
47    }
48
49    enable_raw_mode()?;
50    let mut stdout = io::stdout();
51    execute!(stdout, EnterAlternateScreen)?;
52    let backend = CrosstermBackend::new(stdout);
53    let mut terminal = Terminal::new(backend)?;
54
55    let mut state = SelectorState::new(entries);
56    let result = run_selector(&mut terminal, &mut state, now);
57
58    disable_raw_mode()?;
59    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
60    terminal.show_cursor()?;
61
62    result
63}
64
65/// Pure picker state: the entries, the live search query, and the highlighted
66/// row (an index into the *filtered* view).
67pub struct SelectorState {
68    entries: Vec<SessionEntry>,
69    query: String,
70    /// Selection index within the current filtered set.
71    selected: usize,
72}
73
74impl SelectorState {
75    pub fn new(entries: Vec<SessionEntry>) -> Self {
76        Self {
77            entries,
78            query: String::new(),
79            selected: 0,
80        }
81    }
82
83    /// Indices into `entries` whose title or branch match the query
84    /// (case-insensitive substring). An empty query matches everything.
85    /// Order is preserved from `entries` (already newest-first from the
86    /// caller).
87    fn filtered(&self) -> Vec<usize> {
88        if self.query.is_empty() {
89            return (0..self.entries.len()).collect();
90        }
91        let needle = self.query.to_lowercase();
92        self.entries
93            .iter()
94            .enumerate()
95            .filter(|(_, e)| entry_matches(&e.history, &needle))
96            .map(|(i, _)| i)
97            .collect()
98    }
99
100    /// The entry currently highlighted, if the filtered set is non-empty.
101    fn current(&self) -> Option<&SessionEntry> {
102        self.filtered()
103            .get(self.selected)
104            .map(|&i| &self.entries[i])
105    }
106
107    fn move_down(&mut self) {
108        let n = self.filtered().len();
109        if n > 0 && self.selected + 1 < n {
110            self.selected += 1;
111        }
112    }
113
114    fn move_up(&mut self) {
115        if self.selected > 0 {
116            self.selected -= 1;
117        }
118    }
119
120    /// A typed character extends the query; selection resets to the top so the
121    /// highlight can never point past the shrunken filtered set.
122    fn push_query(&mut self, c: char) {
123        self.query.push(c);
124        self.selected = 0;
125    }
126
127    fn pop_query(&mut self) {
128        self.query.pop();
129        self.selected = 0;
130    }
131}
132
133/// True when the query is a case-insensitive substring of the title or the
134/// git branch. `needle` must already be lowercased.
135fn entry_matches(history: &ConversationHistory, needle: &str) -> bool {
136    history.title.to_lowercase().contains(needle)
137        || history
138            .git_branch
139            .as_deref()
140            .is_some_and(|b| b.to_lowercase().contains(needle))
141}
142
143fn run_selector(
144    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
145    state: &mut SelectorState,
146    now: DateTime<Local>,
147) -> Result<Option<ConversationHistory>> {
148    loop {
149        terminal.draw(|f| render(f, state, now))?;
150
151        if let Event::Key(key) = event::read()? {
152            match key.code {
153                KeyCode::Esc => return Ok(None),
154                KeyCode::Enter => return Ok(state.current().map(|e| e.history.clone())),
155                KeyCode::Down => state.move_down(),
156                KeyCode::Up => state.move_up(),
157                KeyCode::Backspace => state.pop_query(),
158                // Everything printable is search input — there is no vim-style
159                // `j`/`k`/`q` navigation, or it would be swallowed as typing.
160                KeyCode::Char(c) => state.push_query(c),
161                _ => {},
162            }
163        }
164    }
165}
166
167// Palette — kept local (this mini-TUI runs before the themed app is built) but
168// chosen to read like the main UI: cyan accent, gray meta, dim hints.
169const ACCENT: Color = Color::Cyan;
170const META: Color = Color::Gray;
171const DIM: Color = Color::DarkGray;
172
173/// Draw the picker: header, search line, project name, one two-line block per
174/// filtered session, then the key hints.
175pub fn render(f: &mut Frame, state: &SelectorState, now: DateTime<Local>) {
176    let layout = Layout::default()
177        .direction(Direction::Vertical)
178        .constraints([
179            Constraint::Length(1), // "Resume session"
180            Constraint::Length(1), // blank
181            Constraint::Length(1), // search
182            Constraint::Length(1), // blank
183            Constraint::Length(1), // project name
184            Constraint::Length(1), // blank
185            Constraint::Min(3),    // list
186            Constraint::Length(1), // hints
187        ]);
188    let [title, _s1, search, _s2, project, _s3, list, hints] = f.area().layout(&layout);
189
190    f.render_widget(
191        Paragraph::new(Line::from(Span::styled(
192            "Resume session",
193            Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
194        ))),
195        title,
196    );
197
198    // Search line: the query, or a muted placeholder when empty.
199    let search_line = if state.query.is_empty() {
200        Line::from(Span::styled("  Search…", Style::default().fg(DIM)))
201    } else {
202        Line::from(vec![
203            Span::styled("  ", Style::default()),
204            Span::styled(state.query.clone(), Style::default().fg(Color::White)),
205            Span::styled("▏", Style::default().fg(ACCENT)),
206        ])
207    };
208    f.render_widget(Paragraph::new(search_line), search);
209
210    // Project name (this picker is scoped to one directory).
211    if let Some(name) = state
212        .entries
213        .first()
214        .map(|e| project_name(&e.history.project_path))
215    {
216        f.render_widget(
217            Paragraph::new(Line::from(Span::styled(
218                name,
219                Style::default().fg(META).add_modifier(Modifier::BOLD),
220            ))),
221            project,
222        );
223    }
224
225    let filtered = state.filtered();
226    let lines: Vec<Line> = if filtered.is_empty() {
227        vec![Line::from(Span::styled(
228            "  No sessions match your search.",
229            Style::default().fg(DIM),
230        ))]
231    } else {
232        filtered
233            .iter()
234            .enumerate()
235            .flat_map(|(row, &idx)| {
236                let entry = &state.entries[idx];
237                let is_selected = row == state.selected;
238                let (marker, title_style) = if is_selected {
239                    (
240                        "> ",
241                        Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
242                    )
243                } else {
244                    ("  ", Style::default().fg(Color::White))
245                };
246                let title_line = Line::from(vec![
247                    Span::styled(marker, Style::default().fg(ACCENT)),
248                    Span::styled(entry.history.title.clone(), title_style),
249                ]);
250                let meta_line = Line::from(vec![Span::styled(
251                    format!("  {}", meta_label(entry, now)),
252                    Style::default().fg(META),
253                )]);
254                [title_line, meta_line, Line::from("")]
255            })
256            .collect()
257    };
258    f.render_widget(Paragraph::new(lines), list);
259
260    f.render_widget(
261        Paragraph::new(Line::from(Span::styled(
262            "↑↓ select · type to search · enter resume · esc cancel",
263            Style::default().fg(DIM),
264        ))),
265        hints,
266    );
267}
268
269/// The gray meta line under a title: "relative-time · branch · size", with the
270/// branch omitted when unknown.
271fn meta_label(entry: &SessionEntry, now: DateTime<Local>) -> String {
272    let mut bits = vec![humanize_relative(now, entry.history.updated_at)];
273    if let Some(branch) = &entry.history.git_branch
274        && !branch.is_empty()
275    {
276        bits.push(branch.clone());
277    }
278    bits.push(humanize_size(entry.size_bytes));
279    bits.join(" · ")
280}
281
282/// Last path component of a project path, for the picker header.
283fn project_name(project_path: &str) -> String {
284    Path::new(project_path)
285        .file_name()
286        .map(|n| n.to_string_lossy().into_owned())
287        .filter(|s| !s.is_empty())
288        .unwrap_or_else(|| project_path.to_string())
289}
290
291/// Coarse "N units ago" label. Singular/plural aware; caps at months so an
292/// ancient session doesn't read as "412 days ago".
293fn humanize_relative(now: DateTime<Local>, then: DateTime<Local>) -> String {
294    let secs = (now - then).num_seconds().max(0);
295    let (n, unit) = if secs < 45 {
296        return "just now".to_string();
297    } else if secs < 3600 {
298        (secs / 60, "minute")
299    } else if secs < 86_400 {
300        (secs / 3600, "hour")
301    } else if secs < 7 * 86_400 {
302        (secs / 86_400, "day")
303    } else if secs < 30 * 86_400 {
304        (secs / (7 * 86_400), "week")
305    } else {
306        (secs / (30 * 86_400), "month")
307    };
308    let n = n.max(1);
309    if n == 1 {
310        format!("1 {unit} ago")
311    } else {
312        format!("{n} {unit}s ago")
313    }
314}
315
316/// Human-readable byte count: `512B`, `23.1KB`, `1.3MB`.
317fn humanize_size(bytes: u64) -> String {
318    const KB: f64 = 1024.0;
319    const MB: f64 = 1024.0 * 1024.0;
320    let b = bytes as f64;
321    if b < KB {
322        format!("{bytes}B")
323    } else if b < MB {
324        format!("{:.1}KB", b / KB)
325    } else {
326        format!("{:.1}MB", b / MB)
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use chrono::TimeZone;
334    use ratatui::Terminal;
335    use ratatui::backend::TestBackend;
336    use std::collections::VecDeque;
337
338    fn at(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> DateTime<Local> {
339        Local.with_ymd_and_hms(y, mo, d, h, mi, 0).unwrap()
340    }
341
342    fn history(title: &str, branch: Option<&str>, updated: DateTime<Local>) -> ConversationHistory {
343        ConversationHistory {
344            id: "20260101_000000_000".to_string(),
345            title: title.to_string(),
346            messages: Vec::new(),
347            model_name: "ollama/test".to_string(),
348            project_path: "/home/nsabaj/Development/source-clone".to_string(),
349            created_at: updated,
350            updated_at: updated,
351            total_tokens: None,
352            compactions: Vec::new(),
353            input_history: VecDeque::new(),
354            git_branch: branch.map(str::to_string),
355        }
356    }
357
358    fn entry(
359        title: &str,
360        branch: Option<&str>,
361        updated: DateTime<Local>,
362        size: u64,
363    ) -> SessionEntry {
364        SessionEntry {
365            history: history(title, branch, updated),
366            size_bytes: size,
367        }
368    }
369
370    #[test]
371    fn humanize_relative_scales_and_pluralizes() {
372        let now = at(2026, 7, 2, 12, 0);
373        assert_eq!(humanize_relative(now, now), "just now");
374        assert_eq!(
375            humanize_relative(now, at(2026, 7, 2, 11, 58)),
376            "2 minutes ago"
377        );
378        assert_eq!(humanize_relative(now, at(2026, 7, 2, 11, 0)), "1 hour ago");
379        assert_eq!(humanize_relative(now, at(2026, 7, 1, 12, 0)), "1 day ago");
380        assert_eq!(humanize_relative(now, at(2026, 6, 29, 12, 0)), "3 days ago");
381        assert_eq!(humanize_relative(now, at(2026, 6, 20, 12, 0)), "1 week ago");
382        assert_eq!(
383            humanize_relative(now, at(2026, 5, 1, 12, 0)),
384            "2 months ago"
385        );
386        // A clock skew where `then` is in the future must not underflow.
387        assert_eq!(humanize_relative(now, at(2026, 7, 2, 12, 30)), "just now");
388    }
389
390    #[test]
391    fn humanize_size_picks_unit() {
392        assert_eq!(humanize_size(512), "512B");
393        assert_eq!(humanize_size(23_100), "22.6KB");
394        assert_eq!(humanize_size(1_367_426), "1.3MB");
395    }
396
397    #[test]
398    fn filtering_matches_title_and_branch_case_insensitively() {
399        let now = at(2026, 7, 2, 12, 0);
400        let mut state = SelectorState::new(vec![
401            entry("Examine the workspace", Some("main"), now, 100),
402            entry("Fix the parser", Some("feature/parser"), now, 200),
403            entry("Unrelated", Some("main"), now, 300),
404        ]);
405        // Empty query → everything.
406        assert_eq!(state.filtered().len(), 3);
407        // Title substring, case-insensitive.
408        state.push_query('E');
409        state.push_query('x');
410        assert_eq!(state.filtered(), vec![0]);
411        // Branch match.
412        state.query.clear();
413        for c in "parser".chars() {
414            state.push_query(c);
415        }
416        assert_eq!(state.filtered(), vec![1]);
417        // No match → empty, and selection was reset so `current` is None.
418        state.query.clear();
419        for c in "zzz".chars() {
420            state.push_query(c);
421        }
422        assert!(state.filtered().is_empty());
423        assert!(state.current().is_none());
424    }
425
426    #[test]
427    fn navigation_is_clamped_to_filtered_set() {
428        let now = at(2026, 7, 2, 12, 0);
429        let mut state =
430            SelectorState::new(vec![entry("A", None, now, 1), entry("B", None, now, 2)]);
431        state.move_up(); // already at top — no underflow
432        assert_eq!(state.selected, 0);
433        state.move_down();
434        assert_eq!(state.selected, 1);
435        state.move_down(); // at bottom — no overflow past 2 items
436        assert_eq!(state.selected, 1);
437        assert_eq!(state.current().unwrap().history.title, "B");
438    }
439
440    #[test]
441    fn render_shows_claude_code_style_rows() {
442        let now = at(2026, 7, 2, 12, 0);
443        let state = SelectorState::new(vec![
444            entry(
445                "Examine the workspace",
446                Some("main"),
447                at(2026, 7, 2, 10, 0),
448                1_367_426,
449            ),
450            entry(
451                "Older session",
452                Some("master"),
453                at(2026, 6, 29, 12, 0),
454                18_400_000,
455            ),
456        ]);
457        let backend = TestBackend::new(80, 24);
458        let mut terminal = Terminal::new(backend).unwrap();
459        terminal.draw(|f| render(f, &state, now)).unwrap();
460        let buf = terminal.backend().buffer();
461        let mut text = String::new();
462        for y in 0..buf.area.height {
463            for x in 0..buf.area.width {
464                text.push_str(buf[(x, y)].symbol());
465            }
466            text.push('\n');
467        }
468        assert!(text.contains("Resume session"), "header:\n{text}");
469        assert!(text.contains("Search"), "search placeholder:\n{text}");
470        assert!(text.contains("source-clone"), "project name:\n{text}");
471        assert!(text.contains("Examine the workspace"), "title:\n{text}");
472        // The meta line: relative time · branch · size.
473        assert!(text.contains("2 hours ago · main · 1.3MB"), "meta:\n{text}");
474        assert!(
475            text.contains("3 days ago · master · 17.5MB"),
476            "meta2:\n{text}"
477        );
478        // Selected row marker on the first entry.
479        assert!(text.contains("> Examine the workspace"), "marker:\n{text}");
480        assert!(text.contains("esc cancel"), "hints:\n{text}");
481    }
482
483    #[test]
484    fn render_empty_filter_shows_no_match_message() {
485        let now = at(2026, 7, 2, 12, 0);
486        let mut state = SelectorState::new(vec![entry("A", None, now, 1)]);
487        for c in "zzz".chars() {
488            state.push_query(c);
489        }
490        let backend = TestBackend::new(80, 24);
491        let mut terminal = Terminal::new(backend).unwrap();
492        terminal.draw(|f| render(f, &state, now)).unwrap();
493        let buf = terminal.backend().buffer();
494        let mut text = String::new();
495        for y in 0..buf.area.height {
496            for x in 0..buf.area.width {
497                text.push_str(buf[(x, y)].symbol());
498            }
499        }
500        assert!(text.contains("No sessions match"), "{text}");
501    }
502}