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::{ConversationHistory, ConversationManager};
29
30/// Entries the mouse wheel scrolls the picker viewport per notch. The wheel
31/// moves the *viewport*; the arrow keys move the *selection*.
32const WHEEL_STEP: usize = 3;
33
34/// Terminal rows each session block occupies (title + meta + a blank spacer).
35/// The viewport fits `list.height / ROWS_PER_ENTRY` entries.
36const ROWS_PER_ENTRY: usize = 3;
37
38/// One row in the picker: a conversation plus its on-disk size (shown in the
39/// meta line; not stored on the history itself).
40pub struct SessionEntry {
41    pub history: ConversationHistory,
42    pub size_bytes: u64,
43}
44
45/// Show the searchable resume picker and return the chosen conversation, or
46/// `None` if the user cancelled. `now` is injected so the relative-time labels
47/// are testable and match the caller's clock.
48pub fn select_conversation(
49    entries: Vec<SessionEntry>,
50    manager: &ConversationManager,
51    now: DateTime<Local>,
52) -> Result<Option<ConversationHistory>> {
53    if entries.is_empty() {
54        println!("No previous conversations found in this directory.");
55        return Ok(None);
56    }
57
58    enable_raw_mode()?;
59    let mut stdout = io::stdout();
60    // Enable mouse capture so the wheel arrives as real scroll events rather
61    // than the alternate-screen's arrow-key translation (which would otherwise
62    // move the selection instead of scrolling the viewport).
63    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
64    let backend = CrosstermBackend::new(stdout);
65    let mut terminal = Terminal::new(backend)?;
66
67    let mut state = SelectorState::new(entries);
68    let result = run_selector(&mut terminal, &mut state, manager, now);
69
70    disable_raw_mode()?;
71    execute!(
72        terminal.backend_mut(),
73        LeaveAlternateScreen,
74        DisableMouseCapture
75    )?;
76    terminal.show_cursor()?;
77
78    result
79}
80
81/// Pure picker state: the entries, the live search query, and the highlighted
82/// row (an index into the *filtered* view).
83pub struct SelectorState {
84    entries: Vec<SessionEntry>,
85    query: String,
86    /// Selection index within the current filtered set.
87    selected: usize,
88    /// First visible entry (index into the filtered set). The mouse wheel moves
89    /// this freely; arrow-key selection clamps it so `selected` stays visible.
90    scroll_offset: usize,
91    /// Entries that fit in the list viewport — set by `render` each frame so the
92    /// follow-selection clamp in `move_up`/`move_down` knows the window size.
93    viewport_entries: usize,
94    /// When `Some`, a delete of this *entries* index is awaiting a y/N confirm.
95    pending_delete: Option<usize>,
96}
97
98impl SelectorState {
99    pub fn new(entries: Vec<SessionEntry>) -> Self {
100        Self {
101            entries,
102            query: String::new(),
103            selected: 0,
104            scroll_offset: 0,
105            viewport_entries: 0,
106            pending_delete: None,
107        }
108    }
109
110    /// Indices into `entries` whose title or branch match the query
111    /// (case-insensitive substring). An empty query matches everything.
112    /// Order is preserved from `entries` (already newest-first from the
113    /// caller).
114    fn filtered(&self) -> Vec<usize> {
115        if self.query.is_empty() {
116            return (0..self.entries.len()).collect();
117        }
118        let needle = self.query.to_lowercase();
119        self.entries
120            .iter()
121            .enumerate()
122            .filter(|(_, e)| entry_matches(&e.history, &needle))
123            .map(|(i, _)| i)
124            .collect()
125    }
126
127    /// The entry currently highlighted, if the filtered set is non-empty.
128    fn current(&self) -> Option<&SessionEntry> {
129        self.filtered()
130            .get(self.selected)
131            .map(|&i| &self.entries[i])
132    }
133
134    fn move_down(&mut self) {
135        let n = self.filtered().len();
136        if n > 0 && self.selected + 1 < n {
137            self.selected += 1;
138            // Follow: if the selection dropped below the viewport, scroll to it.
139            let visible = self.viewport_entries.max(1);
140            if self.selected >= self.scroll_offset + visible {
141                self.scroll_offset = self.selected + 1 - visible;
142            }
143        }
144    }
145
146    fn move_up(&mut self) {
147        if self.selected > 0 {
148            self.selected -= 1;
149            // Follow: if the selection rose above the viewport, scroll to it.
150            if self.selected < self.scroll_offset {
151                self.scroll_offset = self.selected;
152            }
153        }
154    }
155
156    /// A typed character extends the query; selection + viewport reset to the
157    /// top so the highlight can never point past the shrunken filtered set.
158    fn push_query(&mut self, c: char) {
159        self.query.push(c);
160        self.selected = 0;
161        self.scroll_offset = 0;
162    }
163
164    fn pop_query(&mut self) {
165        self.query.pop();
166        self.selected = 0;
167        self.scroll_offset = 0;
168    }
169
170    /// Mouse wheel: scroll the viewport without touching the selection. The
171    /// upper bound is clamped in `render`, which knows the viewport height.
172    fn scroll_viewport_down(&mut self) {
173        self.scroll_offset = self.scroll_offset.saturating_add(WHEEL_STEP);
174    }
175
176    fn scroll_viewport_up(&mut self) {
177        self.scroll_offset = self.scroll_offset.saturating_sub(WHEEL_STEP);
178    }
179
180    /// Arm a delete of the highlighted entry (awaits a y/N confirm). Stores the
181    /// *entries* index so a filtered-view change can't misredirect it.
182    fn request_delete(&mut self) {
183        self.pending_delete = self.filtered().get(self.selected).copied();
184    }
185
186    fn cancel_delete(&mut self) {
187        self.pending_delete = None;
188    }
189
190    fn take_pending_delete(&mut self) -> Option<usize> {
191        self.pending_delete.take()
192    }
193
194    /// Drop an entry after it's deleted on disk, re-clamping the selection into
195    /// the new (smaller) filtered set.
196    fn remove_entry(&mut self, entries_idx: usize) {
197        if entries_idx >= self.entries.len() {
198            return;
199        }
200        self.entries.remove(entries_idx);
201        let n = self.filtered().len();
202        if n == 0 {
203            self.selected = 0;
204        } else if self.selected >= n {
205            self.selected = n - 1;
206        }
207    }
208}
209
210/// True when the query is a case-insensitive substring of the title or the
211/// git branch. `needle` must already be lowercased.
212fn entry_matches(history: &ConversationHistory, needle: &str) -> bool {
213    history.title.to_lowercase().contains(needle)
214        || history
215            .git_branch
216            .as_deref()
217            .is_some_and(|b| b.to_lowercase().contains(needle))
218}
219
220fn run_selector(
221    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
222    state: &mut SelectorState,
223    manager: &ConversationManager,
224    now: DateTime<Local>,
225) -> Result<Option<ConversationHistory>> {
226    loop {
227        terminal.draw(|f| render(f, state, now))?;
228
229        match event::read()? {
230            Event::Key(key) => {
231                // A pending delete captures the next key: `y` confirms, anything
232                // else cancels — intercepted here so neither falls through to
233                // the search box.
234                if state.pending_delete.is_some() {
235                    if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y')) {
236                        if let Some(idx) = state.take_pending_delete() {
237                            let id = state.entries[idx].history.id.clone();
238                            if manager.delete_conversation(&id).is_ok() {
239                                state.remove_entry(idx);
240                                if state.entries.is_empty() {
241                                    return Ok(None);
242                                }
243                            }
244                        }
245                    } else {
246                        state.cancel_delete();
247                    }
248                    continue;
249                }
250                match key.code {
251                    KeyCode::Esc => return Ok(None),
252                    KeyCode::Enter => return Ok(state.current().map(|e| e.history.clone())),
253                    KeyCode::Down => state.move_down(),
254                    KeyCode::Up => state.move_up(),
255                    // Del arms a confirm to delete the highlighted session. Must
256                    // be a non-typing key — printable chars are search input.
257                    KeyCode::Delete => state.request_delete(),
258                    KeyCode::Backspace => state.pop_query(),
259                    // Everything printable is search input — there is no vim-style
260                    // `j`/`k`/`q` navigation, or it would be swallowed as typing.
261                    KeyCode::Char(c) => state.push_query(c),
262                    _ => {},
263                }
264            },
265            // The wheel scrolls the viewport; the selection stays put.
266            Event::Mouse(m) => match m.kind {
267                MouseEventKind::ScrollUp => state.scroll_viewport_up(),
268                MouseEventKind::ScrollDown => state.scroll_viewport_down(),
269                _ => {},
270            },
271            _ => {},
272        }
273    }
274}
275
276// Palette — kept local (this mini-TUI runs before the themed app is built) but
277// chosen to read like the main UI: cyan accent, gray meta, dim hints.
278const ACCENT: Color = Color::Cyan;
279const META: Color = Color::Gray;
280const DIM: Color = Color::DarkGray;
281
282/// Draw the picker: header, search line, project name, one two-line block per
283/// filtered session (windowed by the scroll offset), then the key hints.
284///
285/// Takes `&mut SelectorState` because the viewport height is only known here:
286/// it records how many entries fit (for follow-selection) and clamps the
287/// wheel-driven scroll offset to the valid range.
288pub fn render(f: &mut Frame, state: &mut SelectorState, now: DateTime<Local>) {
289    let layout = Layout::default()
290        .direction(Direction::Vertical)
291        .constraints([
292            Constraint::Length(1), // "Resume session"
293            Constraint::Length(1), // blank
294            Constraint::Length(1), // search
295            Constraint::Length(1), // blank
296            Constraint::Length(1), // project name
297            Constraint::Length(1), // blank
298            Constraint::Min(3),    // list
299            Constraint::Length(1), // hints
300        ]);
301    let [title, _s1, search, _s2, project, _s3, list, hints] = f.area().layout(&layout);
302
303    // Viewport math first (this mutates `state`, so it must precede the
304    // immutable-borrow draws below): how many 3-line entry blocks fit, recorded
305    // for follow-selection, and the wheel-driven offset clamped to range.
306    let filtered = state.filtered();
307    let visible = (list.height as usize / ROWS_PER_ENTRY).max(1);
308    state.viewport_entries = visible;
309    let max_offset = filtered.len().saturating_sub(visible);
310    if state.scroll_offset > max_offset {
311        state.scroll_offset = max_offset;
312    }
313    let scroll_offset = state.scroll_offset;
314    let selected = state.selected;
315    let pending_delete = state.pending_delete;
316
317    f.render_widget(
318        Paragraph::new(Line::from(Span::styled(
319            "Resume session",
320            Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
321        ))),
322        title,
323    );
324
325    // Search line: the query, or a muted placeholder when empty.
326    let search_line = if state.query.is_empty() {
327        Line::from(Span::styled("  Search…", Style::default().fg(DIM)))
328    } else {
329        Line::from(vec![
330            Span::styled("  ", Style::default()),
331            Span::styled(state.query.clone(), Style::default().fg(Color::White)),
332            Span::styled("▏", Style::default().fg(ACCENT)),
333        ])
334    };
335    f.render_widget(Paragraph::new(search_line), search);
336
337    // Project name (this picker is scoped to one directory).
338    if let Some(name) = state
339        .entries
340        .first()
341        .map(|e| project_name(&e.history.project_path))
342    {
343        f.render_widget(
344            Paragraph::new(Line::from(Span::styled(
345                name,
346                Style::default().fg(META).add_modifier(Modifier::BOLD),
347            ))),
348            project,
349        );
350    }
351
352    let lines: Vec<Line> = if filtered.is_empty() {
353        vec![Line::from(Span::styled(
354            "  No sessions match your search.",
355            Style::default().fg(DIM),
356        ))]
357    } else {
358        filtered
359            .iter()
360            .enumerate()
361            .skip(scroll_offset)
362            .take(visible)
363            .flat_map(|(row, &idx)| {
364                let entry = &state.entries[idx];
365                let is_selected = row == selected;
366                let (marker, title_style) = if is_selected {
367                    (
368                        "> ",
369                        Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
370                    )
371                } else {
372                    ("  ", Style::default().fg(Color::White))
373                };
374                let title_line = Line::from(vec![
375                    Span::styled(marker, Style::default().fg(ACCENT)),
376                    Span::styled(entry.history.title.clone(), title_style),
377                ]);
378                let meta_line = Line::from(vec![Span::styled(
379                    format!("  {}", meta_label(entry, now)),
380                    Style::default().fg(META),
381                )]);
382                [title_line, meta_line, Line::from("")]
383            })
384            .collect()
385    };
386    f.render_widget(Paragraph::new(lines), list);
387
388    // Hints line, or the delete confirm prompt when one is armed.
389    let hints_line = if let Some(idx) = pending_delete {
390        let name: String = state
391            .entries
392            .get(idx)
393            .map(|e| e.history.title.chars().take(40).collect::<String>())
394            .unwrap_or_default();
395        Line::from(Span::styled(
396            format!("Delete \"{name}\"?  y confirms · any other key cancels"),
397            Style::default().fg(Color::Yellow),
398        ))
399    } else {
400        Line::from(Span::styled(
401            "↑↓ select · type to search · del delete · enter resume · esc cancel",
402            Style::default().fg(DIM),
403        ))
404    };
405    f.render_widget(Paragraph::new(hints_line), hints);
406}
407
408/// The gray meta line under a title: "relative-time · branch · size", with the
409/// branch omitted when unknown.
410fn meta_label(entry: &SessionEntry, now: DateTime<Local>) -> String {
411    let mut bits = vec![humanize_relative(now, entry.history.updated_at)];
412    if let Some(branch) = &entry.history.git_branch
413        && !branch.is_empty()
414    {
415        bits.push(branch.clone());
416    }
417    // Session lineage: mark a branched-from session (dormant until fork/rewind
418    // lands, but the field is persisted now).
419    if entry.history.forked_from.is_some() {
420        bits.push("forked".to_string());
421    }
422    bits.push(humanize_size(entry.size_bytes));
423    bits.join(" · ")
424}
425
426/// Last path component of a project path, for the picker header.
427fn project_name(project_path: &str) -> String {
428    Path::new(project_path)
429        .file_name()
430        .map(|n| n.to_string_lossy().into_owned())
431        .filter(|s| !s.is_empty())
432        .unwrap_or_else(|| project_path.to_string())
433}
434
435/// Coarse "N units ago" label. Singular/plural aware; caps at months so an
436/// ancient session doesn't read as "412 days ago".
437fn humanize_relative(now: DateTime<Local>, then: DateTime<Local>) -> String {
438    let secs = (now - then).num_seconds().max(0);
439    let (n, unit) = if secs < 45 {
440        return "just now".to_string();
441    } else if secs < 3600 {
442        (secs / 60, "minute")
443    } else if secs < 86_400 {
444        (secs / 3600, "hour")
445    } else if secs < 7 * 86_400 {
446        (secs / 86_400, "day")
447    } else if secs < 30 * 86_400 {
448        (secs / (7 * 86_400), "week")
449    } else {
450        (secs / (30 * 86_400), "month")
451    };
452    let n = n.max(1);
453    if n == 1 {
454        format!("1 {unit} ago")
455    } else {
456        format!("{n} {unit}s ago")
457    }
458}
459
460/// Human-readable byte count: `512B`, `23.1KB`, `1.3MB`.
461fn humanize_size(bytes: u64) -> String {
462    const KB: f64 = 1024.0;
463    const MB: f64 = 1024.0 * 1024.0;
464    let b = bytes as f64;
465    if b < KB {
466        format!("{bytes}B")
467    } else if b < MB {
468        format!("{:.1}KB", b / KB)
469    } else {
470        format!("{:.1}MB", b / MB)
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477    use chrono::TimeZone;
478    use ratatui::Terminal;
479    use ratatui::backend::TestBackend;
480    use std::collections::VecDeque;
481
482    fn at(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> DateTime<Local> {
483        Local.with_ymd_and_hms(y, mo, d, h, mi, 0).unwrap()
484    }
485
486    fn history(title: &str, branch: Option<&str>, updated: DateTime<Local>) -> ConversationHistory {
487        ConversationHistory {
488            id: "20260101_000000_000".to_string(),
489            title: title.to_string(),
490            messages: Vec::new(),
491            model_name: "ollama/test".to_string(),
492            project_path: "/home/nsabaj/Development/source-clone".to_string(),
493            created_at: updated,
494            updated_at: updated,
495            compactions: Vec::new(),
496            input_history: VecDeque::new(),
497            git_branch: branch.map(str::to_string),
498            safety_mode: None,
499            plan: None,
500            last_token_usage: None,
501            cumulative_token_usage: crate::domain::TokenUsageTotals::default(),
502            context_usage: None,
503            forked_from: None,
504            parent_session: None,
505            cli_version: None,
506            git_sha: None,
507            tasks: crate::domain::TaskStore::default(),
508        }
509    }
510
511    fn entry(
512        title: &str,
513        branch: Option<&str>,
514        updated: DateTime<Local>,
515        size: u64,
516    ) -> SessionEntry {
517        SessionEntry {
518            history: history(title, branch, updated),
519            size_bytes: size,
520        }
521    }
522
523    #[test]
524    fn humanize_relative_scales_and_pluralizes() {
525        let now = at(2026, 7, 2, 12, 0);
526        assert_eq!(humanize_relative(now, now), "just now");
527        assert_eq!(
528            humanize_relative(now, at(2026, 7, 2, 11, 58)),
529            "2 minutes ago"
530        );
531        assert_eq!(humanize_relative(now, at(2026, 7, 2, 11, 0)), "1 hour ago");
532        assert_eq!(humanize_relative(now, at(2026, 7, 1, 12, 0)), "1 day ago");
533        assert_eq!(humanize_relative(now, at(2026, 6, 29, 12, 0)), "3 days ago");
534        assert_eq!(humanize_relative(now, at(2026, 6, 20, 12, 0)), "1 week ago");
535        assert_eq!(
536            humanize_relative(now, at(2026, 5, 1, 12, 0)),
537            "2 months ago"
538        );
539        // A clock skew where `then` is in the future must not underflow.
540        assert_eq!(humanize_relative(now, at(2026, 7, 2, 12, 30)), "just now");
541    }
542
543    #[test]
544    fn humanize_size_picks_unit() {
545        assert_eq!(humanize_size(512), "512B");
546        assert_eq!(humanize_size(23_100), "22.6KB");
547        assert_eq!(humanize_size(1_367_426), "1.3MB");
548    }
549
550    #[test]
551    fn filtering_matches_title_and_branch_case_insensitively() {
552        let now = at(2026, 7, 2, 12, 0);
553        let mut state = SelectorState::new(vec![
554            entry("Examine the workspace", Some("main"), now, 100),
555            entry("Fix the parser", Some("feature/parser"), now, 200),
556            entry("Unrelated", Some("main"), now, 300),
557        ]);
558        // Empty query → everything.
559        assert_eq!(state.filtered().len(), 3);
560        // Title substring, case-insensitive.
561        state.push_query('E');
562        state.push_query('x');
563        assert_eq!(state.filtered(), vec![0]);
564        // Branch match.
565        state.query.clear();
566        for c in "parser".chars() {
567            state.push_query(c);
568        }
569        assert_eq!(state.filtered(), vec![1]);
570        // No match → empty, and selection was reset so `current` is None.
571        state.query.clear();
572        for c in "zzz".chars() {
573            state.push_query(c);
574        }
575        assert!(state.filtered().is_empty());
576        assert!(state.current().is_none());
577    }
578
579    #[test]
580    fn navigation_is_clamped_to_filtered_set() {
581        let now = at(2026, 7, 2, 12, 0);
582        let mut state =
583            SelectorState::new(vec![entry("A", None, now, 1), entry("B", None, now, 2)]);
584        state.move_up(); // already at top — no underflow
585        assert_eq!(state.selected, 0);
586        state.move_down();
587        assert_eq!(state.selected, 1);
588        state.move_down(); // at bottom — no overflow past 2 items
589        assert_eq!(state.selected, 1);
590        assert_eq!(state.current().unwrap().history.title, "B");
591    }
592
593    #[test]
594    fn render_shows_claude_code_style_rows() {
595        let now = at(2026, 7, 2, 12, 0);
596        let mut state = SelectorState::new(vec![
597            entry(
598                "Examine the workspace",
599                Some("main"),
600                at(2026, 7, 2, 10, 0),
601                1_367_426,
602            ),
603            entry(
604                "Older session",
605                Some("master"),
606                at(2026, 6, 29, 12, 0),
607                18_400_000,
608            ),
609        ]);
610        let backend = TestBackend::new(80, 24);
611        let mut terminal = Terminal::new(backend).unwrap();
612        terminal.draw(|f| render(f, &mut state, now)).unwrap();
613        let buf = terminal.backend().buffer();
614        let mut text = String::new();
615        for y in 0..buf.area.height {
616            for x in 0..buf.area.width {
617                text.push_str(buf[(x, y)].symbol());
618            }
619            text.push('\n');
620        }
621        assert!(text.contains("Resume session"), "header:\n{text}");
622        assert!(text.contains("Search"), "search placeholder:\n{text}");
623        assert!(text.contains("source-clone"), "project name:\n{text}");
624        assert!(text.contains("Examine the workspace"), "title:\n{text}");
625        // The meta line: relative time · branch · size.
626        assert!(text.contains("2 hours ago · main · 1.3MB"), "meta:\n{text}");
627        assert!(
628            text.contains("3 days ago · master · 17.5MB"),
629            "meta2:\n{text}"
630        );
631        // Selected row marker on the first entry.
632        assert!(text.contains("> Examine the workspace"), "marker:\n{text}");
633        assert!(text.contains("esc cancel"), "hints:\n{text}");
634    }
635
636    #[test]
637    fn render_empty_filter_shows_no_match_message() {
638        let now = at(2026, 7, 2, 12, 0);
639        let mut state = SelectorState::new(vec![entry("A", None, now, 1)]);
640        for c in "zzz".chars() {
641            state.push_query(c);
642        }
643        let backend = TestBackend::new(80, 24);
644        let mut terminal = Terminal::new(backend).unwrap();
645        terminal.draw(|f| render(f, &mut state, now)).unwrap();
646        let buf = terminal.backend().buffer();
647        let mut text = String::new();
648        for y in 0..buf.area.height {
649            for x in 0..buf.area.width {
650                text.push_str(buf[(x, y)].symbol());
651            }
652        }
653        assert!(text.contains("No sessions match"), "{text}");
654    }
655
656    /// Render the current buffer to a plain string for assertions.
657    fn dump(terminal: &Terminal<TestBackend>) -> String {
658        let buf = terminal.backend().buffer();
659        let mut text = String::new();
660        for y in 0..buf.area.height {
661            for x in 0..buf.area.width {
662                text.push_str(buf[(x, y)].symbol());
663            }
664            text.push('\n');
665        }
666        text
667    }
668
669    #[test]
670    fn arrowing_past_the_viewport_scrolls_to_follow_selection() {
671        let now = at(2026, 7, 2, 12, 0);
672        // 8 entries × 3 rows; a short terminal fits only ~2, so moving the
673        // selection to the bottom must scroll the window to keep it visible.
674        let mut state = SelectorState::new(
675            (0..8)
676                .map(|i| entry(&format!("Session {i}"), None, now, 100))
677                .collect(),
678        );
679        let backend = TestBackend::new(80, 14);
680        let mut terminal = Terminal::new(backend).unwrap();
681        // First frame records the viewport height for the follow clamp.
682        terminal.draw(|f| render(f, &mut state, now)).unwrap();
683        for _ in 0..7 {
684            state.move_down();
685        }
686        terminal.draw(|f| render(f, &mut state, now)).unwrap();
687        let text = dump(&terminal);
688        assert!(
689            text.contains("> Session 7"),
690            "selection followed into view:\n{text}"
691        );
692        assert!(
693            !text.contains("Session 0"),
694            "top entries scrolled away:\n{text}"
695        );
696    }
697
698    #[test]
699    fn wheel_scrolls_viewport_without_moving_selection() {
700        let now = at(2026, 7, 2, 12, 0);
701        let mut state = SelectorState::new(
702            (0..8)
703                .map(|i| entry(&format!("Session {i}"), None, now, 100))
704                .collect(),
705        );
706        let backend = TestBackend::new(80, 14);
707        let mut terminal = Terminal::new(backend).unwrap();
708        terminal.draw(|f| render(f, &mut state, now)).unwrap();
709
710        state.scroll_viewport_down(); // wheel down: viewport moves, selection doesn't
711        terminal.draw(|f| render(f, &mut state, now)).unwrap();
712        let text = dump(&terminal);
713        assert_eq!(state.selected, 0, "the wheel must not move the selection");
714        assert!(
715            !text.contains("Session 0"),
716            "viewport scrolled past the top:\n{text}"
717        );
718    }
719
720    #[test]
721    fn delete_flow_arms_confirm_then_drops_the_entry() {
722        let now = at(2026, 7, 2, 12, 0);
723        let mut state = SelectorState::new(vec![
724            entry("keep", None, now, 1),
725            entry("gone", None, now, 1),
726        ]);
727        state.move_down(); // highlight "gone" (entries index 1)
728        state.request_delete();
729        assert_eq!(
730            state.pending_delete,
731            Some(1),
732            "armed on the highlighted entry"
733        );
734        // The manager's on-disk delete lives in run_selector; here we drive the
735        // state mutation that follows a confirmed delete.
736        let idx = state.take_pending_delete().expect("pending");
737        state.remove_entry(idx);
738        assert_eq!(state.entries.len(), 1);
739        assert_eq!(
740            state.current().map(|e| e.history.title.as_str()),
741            Some("keep"),
742            "selection re-clamped onto the surviving entry"
743        );
744    }
745}