Skip to main content

mermaid_cli/render/widgets/
rewind_picker.rs

1//! Double-Esc rewind picker — renders the bottom zone when
2//! `UiMode::RewindPicker` is active.
3//!
4//! Same visual shape as the conversation-list picker: a bordered pane with
5//! an arrow-selectable list, one row per earlier USER message (newest
6//! first). Selecting a row forks the session at that message.
7
8use ratatui::buffer::Buffer;
9use ratatui::layout::Rect;
10use ratatui::style::{Modifier, Style};
11use ratatui::text::{Line, Span};
12use ratatui::widgets::{Block, Borders, Paragraph, Widget};
13
14use super::truncate_to_cells;
15use crate::domain::RewindCandidate;
16use crate::render::theme::Theme;
17
18pub struct RewindPickerWidget<'a> {
19    pub theme: &'a Theme,
20    pub candidates: &'a [RewindCandidate],
21    pub cursor: usize,
22}
23
24impl<'a> Widget for RewindPickerWidget<'a> {
25    fn render(self, area: Rect, buf: &mut Buffer) {
26        let title = "Rewind — fork at an earlier message · ↑↓ navigate · Enter fork · Esc cancel";
27        let block = Block::default()
28            .borders(Borders::ALL)
29            .title(title)
30            .border_style(Style::default().fg(self.theme.colors.border.to_color()));
31
32        // Reserve room for borders; show up to `visible` rows, keeping the
33        // cursor in view (same window math as the conversation list).
34        let inner_height = area.height.saturating_sub(2) as usize;
35        let visible = inner_height.min(10);
36        let start = if self.cursor >= visible {
37            self.cursor + 1 - visible
38        } else {
39            0
40        };
41
42        let rows: Vec<Line<'_>> = self
43            .candidates
44            .iter()
45            .enumerate()
46            .skip(start)
47            .take(visible)
48            .map(|(i, candidate)| {
49                let highlighted = i == self.cursor;
50                let prefix = if highlighted { " > " } else { "   " };
51                let row_style = if highlighted {
52                    Style::default()
53                        .bg(self.theme.colors.text_disabled.to_color())
54                        .add_modifier(Modifier::BOLD)
55                } else {
56                    Style::default()
57                };
58                let excerpt = truncate_to_cells(&candidate.excerpt, 64);
59                // 1-based recency label: #1 = the newest user message.
60                let meta = format!("  (#{} back)", i + 1);
61                Line::from(vec![
62                    Span::raw(prefix),
63                    Span::styled(
64                        excerpt,
65                        row_style.fg(self.theme.colors.text_primary.to_color()),
66                    ),
67                    Span::styled(
68                        meta,
69                        row_style.fg(self.theme.colors.text_disabled.to_color()),
70                    ),
71                ])
72            })
73            .collect();
74
75        Paragraph::new(rows).block(block).render(area, buf);
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    fn candidates(excerpts: &[&str]) -> Vec<RewindCandidate> {
84        excerpts
85            .iter()
86            .enumerate()
87            .map(|(i, e)| RewindCandidate {
88                message_index: i,
89                excerpt: e.to_string(),
90            })
91            .collect()
92    }
93
94    fn render_text(widget: RewindPickerWidget<'_>, width: u16, height: u16) -> String {
95        let area = Rect::new(0, 0, width, height);
96        let mut buf = Buffer::empty(area);
97        widget.render(area, &mut buf);
98        (0..height)
99            .map(|y| {
100                (0..width)
101                    .map(|x| buf[(x, y)].symbol().to_string())
102                    .collect::<String>()
103            })
104            .collect::<Vec<_>>()
105            .join("\n")
106    }
107
108    #[test]
109    fn renders_excerpts_with_recency_labels() {
110        let theme = Theme::dark();
111        let list = candidates(&["fix the resolver", "add tests"]);
112        let text = render_text(
113            RewindPickerWidget {
114                theme: &theme,
115                candidates: &list,
116                cursor: 0,
117            },
118            90,
119            6,
120        );
121        assert!(text.contains("Rewind"), "{text}");
122        assert!(text.contains("fix the resolver"), "{text}");
123        assert!(text.contains("(#2 back)"), "{text}");
124    }
125
126    #[test]
127    fn out_of_range_cursor_does_not_panic() {
128        let theme = Theme::dark();
129        let list = candidates(&["only one"]);
130        let text = render_text(
131            RewindPickerWidget {
132                theme: &theme,
133                candidates: &list,
134                cursor: 42,
135            },
136            60,
137            5,
138        );
139        // The cursor row scrolled past the end: the list may render empty,
140        // but the widget must not panic and the frame stays intact.
141        assert!(text.contains("Rewind"), "{text}");
142    }
143}