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, 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
31/// Entries the mouse wheel scrolls the picker viewport per notch. The wheel
32/// moves the *viewport*; the arrow keys move the *selection*.
33const WHEEL_STEP: usize = 3;
34
35/// Terminal rows each session block occupies (title + meta + a blank spacer).
36/// The viewport fits `list.height / ROWS_PER_ENTRY` entries.
37const ROWS_PER_ENTRY: usize = 3;
38
39/// One row in the picker: a conversation plus its on-disk size (shown in the
40/// meta line; not stored on the history itself).
41pub struct SessionEntry {
42    pub history: ConversationHistory,
43    pub size_bytes: u64,
44}
45
46/// Show the searchable resume picker and return the chosen conversation, or
47/// `None` if the user cancelled. `now` is injected so the relative-time labels
48/// are testable and match the caller's clock.
49///
50/// # Errors
51///
52/// Entering raw mode and the alternate screen, drawing, reading events, and
53/// restoring the terminal afterwards — plus a delete performed from inside the
54/// picker. An empty `entries` list and a user who cancels are both
55/// `Ok(None)`. The terminal is restored before any error from the picker body
56/// is returned, so an `Err` never leaves the shell in raw mode.
57pub 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    // Enable mouse capture so the wheel arrives as real scroll events rather
70    // than the alternate-screen's arrow-key translation (which would otherwise
71    // move the selection instead of scrolling the viewport).
72    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
90/// Pure picker state: the entries, the live search query, and the highlighted
91/// row (an index into the *filtered* view).
92pub struct SelectorState {
93    entries: Vec<SessionEntry>,
94    query: String,
95    /// Selection index within the current filtered set.
96    selected: usize,
97    /// First visible entry (index into the filtered set). The mouse wheel moves
98    /// this freely; arrow-key selection clamps it so `selected` stays visible.
99    scroll_offset: usize,
100    /// Entries that fit in the list viewport — set by `render` each frame so the
101    /// follow-selection clamp in `move_up`/`move_down` knows the window size.
102    viewport_entries: usize,
103    /// When `Some`, a delete of this *entries* index is awaiting a y/N confirm.
104    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    /// Indices into `entries` whose title or branch match the query
120    /// (case-insensitive substring). An empty query matches everything.
121    /// Order is preserved from `entries` (already newest-first from the
122    /// caller).
123    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    /// The entry currently highlighted, if the filtered set is non-empty.
137    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            // Follow: if the selection dropped below the viewport, scroll to it.
148            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            // Follow: if the selection rose above the viewport, scroll to it.
159            if self.selected < self.scroll_offset {
160                self.scroll_offset = self.selected;
161            }
162        }
163    }
164
165    /// A typed character extends the query; selection + viewport reset to the
166    /// top so the highlight can never point past the shrunken filtered set.
167    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    /// Mouse wheel: scroll the viewport without touching the selection. The
180    /// upper bound is clamped in `render`, which knows the viewport height.
181    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    /// Arm a delete of the highlighted entry (awaits a y/N confirm). Stores the
190    /// *entries* index so a filtered-view change can't misredirect it.
191    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    /// Drop an entry after it's deleted on disk, re-clamping the selection into
204    /// the new (smaller) filtered set.
205    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
219/// True when the query is a case-insensitive substring of the title or the
220/// git branch. `needle` must already be lowercased.
221fn 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                // A pending delete captures the next key: `y` confirms, anything
241                // else cancels — intercepted here so neither falls through to
242                // the search box.
243                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                    // Del arms a confirm to delete the highlighted session. Must
265                    // be a non-typing key — printable chars are search input.
266                    KeyCode::Delete => state.request_delete(),
267                    KeyCode::Backspace => state.pop_query(),
268                    // Everything printable is search input — there is no vim-style
269                    // `j`/`k`/`q` navigation, or it would be swallowed as typing.
270                    KeyCode::Char(c) => state.push_query(c),
271                    _ => {},
272                }
273            },
274            // The wheel scrolls the viewport; the selection stays put.
275            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
285// Palette — kept local (this mini-TUI runs before the themed app is built) but
286// chosen to read like the main UI: cyan accent, gray meta, dim hints.
287const ACCENT: Color = Color::Cyan;
288const META: Color = Color::Gray;
289const DIM: Color = Color::DarkGray;
290
291/// Draw the picker: header, search line, project name, one two-line block per
292/// filtered session (windowed by the scroll offset), then the key hints.
293///
294/// Takes `&mut SelectorState` because the viewport height is only known here:
295/// it records how many entries fit (for follow-selection) and clamps the
296/// wheel-driven scroll offset to the valid range.
297#[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), // "Resume session"
306            Constraint::Length(1), // blank
307            Constraint::Length(1), // search
308            Constraint::Length(1), // blank
309            Constraint::Length(1), // project name
310            Constraint::Length(1), // blank
311            Constraint::Min(3),    // list
312            Constraint::Length(1), // hints
313        ]);
314    let [title, _s1, search, _s2, project, _s3, list, hints] = f.area().layout(&layout);
315
316    // Viewport math first (this mutates `state`, so it must precede the
317    // immutable-borrow draws below): how many 3-line entry blocks fit, recorded
318    // for follow-selection, and the wheel-driven offset clamped to range.
319    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    // Search line: the query, or a muted placeholder when empty.
339    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    // Project name (this picker is scoped to one directory).
351    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    // Hints line, or the delete confirm prompt when one is armed.
402    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
421/// The gray meta line under a title: "relative-time · branch · size", with the
422/// branch omitted when unknown.
423fn 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    // Session lineage: mark a branched-from session (dormant until fork/rewind
431    // lands, but the field is persisted now).
432    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
439/// Last path component of a project path, for the picker header.
440fn 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
448/// Coarse "N units ago" label. Singular/plural aware; caps at months so an
449/// ancient session doesn't read as "412 days ago".
450fn 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
473/// Human-readable byte count: `512B`, `23.1KB`, `1.3MB`.
474fn 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        // `messages`/`revision` are private (the render memo keys off the
499        // revision), so build from the constructor and override the rest.
500        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        // A clock skew where `then` is in the future must not underflow.
542        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        // Empty query → everything.
561        assert_eq!(state.filtered().len(), 3);
562        // Title substring, case-insensitive.
563        state.push_query('E');
564        state.push_query('x');
565        assert_eq!(state.filtered(), vec![0]);
566        // Branch match.
567        state.query.clear();
568        for c in "parser".chars() {
569            state.push_query(c);
570        }
571        assert_eq!(state.filtered(), vec![1]);
572        // No match → empty, and selection was reset so `current` is None.
573        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(); // already at top — no underflow
587        assert_eq!(state.selected, 0);
588        state.move_down();
589        assert_eq!(state.selected, 1);
590        state.move_down(); // at bottom — no overflow past 2 items
591        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        // The meta line: relative time · branch · size.
628        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        // Selected row marker on the first entry.
634        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    /// Render the current buffer to a plain string for assertions.
659    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        // 8 entries × 3 rows; a short terminal fits only ~2, so moving the
675        // selection to the bottom must scroll the window to keep it visible.
676        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        // First frame records the viewport height for the follow clamp.
684        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(); // wheel down: viewport moves, selection doesn't
713        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(); // highlight "gone" (entries index 1)
730        state.request_delete();
731        assert_eq!(
732            state.pending_delete,
733            Some(1),
734            "armed on the highlighted entry"
735        );
736        // The manager's on-disk delete lives in run_selector; here we drive the
737        // state mutation that follows a confirmed delete.
738        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}