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.
297pub 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), // "Resume session"
302            Constraint::Length(1), // blank
303            Constraint::Length(1), // search
304            Constraint::Length(1), // blank
305            Constraint::Length(1), // project name
306            Constraint::Length(1), // blank
307            Constraint::Min(3),    // list
308            Constraint::Length(1), // hints
309        ]);
310    let [title, _s1, search, _s2, project, _s3, list, hints] = f.area().layout(&layout);
311
312    // Viewport math first (this mutates `state`, so it must precede the
313    // immutable-borrow draws below): how many 3-line entry blocks fit, recorded
314    // for follow-selection, and the wheel-driven offset clamped to range.
315    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    // Search line: the query, or a muted placeholder when empty.
335    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    // Project name (this picker is scoped to one directory).
347    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    // Hints line, or the delete confirm prompt when one is armed.
378    let hints_line = hints_line(state, pending_delete);
379    f.render_widget(Paragraph::new(hints_line), hints);
380}
381
382/// One session's two-line block (title, meta) plus its blank separator.
383fn 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
407/// The key hints, or the delete confirm prompt when one is armed.
408fn 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
427/// The gray meta line under a title: "relative-time · branch · size", with the
428/// branch omitted when unknown.
429fn 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    // Session lineage: mark a branched-from session (dormant until fork/rewind
437    // lands, but the field is persisted now).
438    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
445/// Last path component of a project path, for the picker header.
446fn 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
454/// Coarse "N units ago" label. Singular/plural aware; caps at months so an
455/// ancient session doesn't read as "412 days ago".
456fn 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
479/// Human-readable byte count: `512B`, `23.1KB`, `1.3MB`.
480fn 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        // `messages`/`revision` are private (the render memo keys off the
505        // revision), so build from the constructor and override the rest.
506        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        // A clock skew where `then` is in the future must not underflow.
548        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        // Empty query → everything.
567        assert_eq!(state.filtered().len(), 3);
568        // Title substring, case-insensitive.
569        state.push_query('E');
570        state.push_query('x');
571        assert_eq!(state.filtered(), vec![0]);
572        // Branch match.
573        state.query.clear();
574        for c in "parser".chars() {
575            state.push_query(c);
576        }
577        assert_eq!(state.filtered(), vec![1]);
578        // No match → empty, and selection was reset so `current` is None.
579        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(); // already at top — no underflow
593        assert_eq!(state.selected, 0);
594        state.move_down();
595        assert_eq!(state.selected, 1);
596        state.move_down(); // at bottom — no overflow past 2 items
597        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        // The meta line: relative time · branch · size.
634        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        // Selected row marker on the first entry.
640        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    /// Render the current buffer to a plain string for assertions.
665    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        // 8 entries × 3 rows; a short terminal fits only ~2, so moving the
681        // selection to the bottom must scroll the window to keep it visible.
682        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        // First frame records the viewport height for the follow clamp.
690        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(); // wheel down: viewport moves, selection doesn't
719        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(); // highlight "gone" (entries index 1)
736        state.request_delete();
737        assert_eq!(
738            state.pending_delete,
739            Some(1),
740            "armed on the highlighted entry"
741        );
742        // The manager's on-disk delete lives in run_selector; here we drive the
743        // state mutation that follows a confirmed delete.
744        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}