Skip to main content

recall_cli/
ui.rs

1use ratatui::Frame;
2use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
3use ratatui::style::{Color, Modifier, Style};
4use ratatui::text::{Line, Span};
5use ratatui::widgets::{
6    Block, Borders, Clear, List, ListItem, ListState, Padding, Paragraph, Wrap,
7};
8
9use crate::app::{App, LineStyle, Mode};
10use crate::session::human_time_ago;
11
12const OLIVE: Color = Color::Rgb(107, 142, 35);
13const BURNT: Color = Color::Rgb(204, 85, 0);
14const GOLD: Color = Color::Rgb(218, 165, 32);
15const CLAUDE: Color = Color::Rgb(204, 120, 50);
16const DIM: Color = Color::Rgb(120, 120, 120);
17const SURFACE: Color = Color::Rgb(30, 30, 30);
18
19pub fn draw(f: &mut Frame, app: &App) {
20    let chunks = Layout::default()
21        .direction(Direction::Vertical)
22        .constraints([Constraint::Min(1), Constraint::Length(1)])
23        .split(f.area());
24
25    let main_area = chunks[0];
26    let status_bar = chunks[1];
27
28    let panels = Layout::default()
29        .direction(Direction::Horizontal)
30        .constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
31        .split(main_area);
32
33    draw_session_list(f, app, panels[0]);
34    draw_preview(f, app, panels[1]);
35    draw_status_bar(f, app, status_bar);
36
37    if app.confirm_delete {
38        draw_delete_confirm(f, app);
39    }
40}
41
42fn draw_session_list(f: &mut Frame, app: &App, area: Rect) {
43    let title = if app.search_active || !app.search_query.is_empty() {
44        format!(
45            " Sessions ({}/{}) ▸ {} ",
46            app.filtered.len(),
47            app.sessions.len(),
48            app.search_query
49        )
50    } else {
51        format!(" Sessions ({}) ", app.filtered.len())
52    };
53
54    let block = Block::default()
55        .borders(Borders::ALL)
56        .border_style(Style::default().fg(if app.mode == Mode::Search {
57            GOLD
58        } else {
59            OLIVE
60        }))
61        .title(title)
62        .title_style(Style::default().fg(OLIVE).add_modifier(Modifier::BOLD))
63        .padding(Padding::horizontal(1));
64
65    let items: Vec<ListItem> = app
66        .filtered
67        .iter()
68        .enumerate()
69        .map(|(display_idx, &session_idx)| {
70            let session = &app.sessions[session_idx];
71            let age = human_time_ago(&session.updated_at);
72            let cwd = shorten_path(&session.cwd, 30);
73
74            let summary = if session.summary.is_empty() {
75                session
76                    .user_messages
77                    .first()
78                    .map(|m| truncate_str(m, 40))
79                    .unwrap_or_else(|| "(empty)".to_string())
80            } else {
81                truncate_str(&session.summary, 40)
82            };
83
84            let is_selected = display_idx == app.selected;
85
86            let provider_color = if session.provider == "Claude Code" {
87                CLAUDE
88            } else {
89                OLIVE
90            };
91            let provider_badge = if session.provider == "Claude Code" {
92                "◆ "
93            } else {
94                "● "
95            };
96
97            let title_style = if is_selected {
98                Style::default().fg(GOLD).add_modifier(Modifier::BOLD)
99            } else {
100                Style::default().fg(Color::White)
101            };
102
103            let meta_style = Style::default().fg(DIM);
104
105            let checkpoints = if session.checkpoints.is_empty() {
106                String::new()
107            } else {
108                format!(" · {}cp", session.checkpoints.len())
109            };
110
111            let messages = if session.user_messages.is_empty() {
112                String::new()
113            } else {
114                format!(" · {}msg", session.user_messages.len())
115            };
116
117            ListItem::new(vec![
118                Line::from(vec![
119                    Span::styled(provider_badge, Style::default().fg(provider_color)),
120                    Span::styled(summary, title_style),
121                ]),
122                Line::from(Span::styled(
123                    format!("{age} · {cwd}{checkpoints}{messages}"),
124                    meta_style,
125                )),
126                Line::from(""),
127            ])
128        })
129        .collect();
130
131    let mut state = ListState::default();
132    state.select(Some(app.selected));
133
134    let list = List::new(items)
135        .block(block)
136        .highlight_style(Style::default().bg(SURFACE));
137
138    f.render_stateful_widget(list, area, &mut state);
139}
140
141fn draw_preview(f: &mut Frame, app: &App, area: Rect) {
142    let block = Block::default()
143        .borders(Borders::ALL)
144        .border_style(Style::default().fg(OLIVE))
145        .title(" Preview ")
146        .title_style(Style::default().fg(OLIVE).add_modifier(Modifier::BOLD))
147        .padding(Padding::new(2, 2, 1, 1));
148
149    let preview_lines = app.build_preview_lines();
150    let inner = block.inner(area);
151
152    let lines: Vec<Line> = preview_lines
153        .iter()
154        .skip(app.preview_scroll)
155        .map(|pl| {
156            let style = match pl.style {
157                LineStyle::Header => Style::default().fg(GOLD).add_modifier(Modifier::BOLD),
158                LineStyle::Section => Style::default().fg(BURNT).add_modifier(Modifier::BOLD),
159                LineStyle::Label => Style::default().fg(OLIVE),
160                LineStyle::Normal => Style::default().fg(Color::White),
161                LineStyle::Dim => Style::default().fg(DIM),
162                LineStyle::Empty => Style::default(),
163            };
164            Line::from(Span::styled(&pl.text, style))
165        })
166        .collect();
167
168    let paragraph = Paragraph::new(lines)
169        .block(block)
170        .wrap(Wrap { trim: false });
171
172    f.render_widget(paragraph, area);
173
174    let total = preview_lines.len();
175    if total > inner.height as usize {
176        let scrollbar_text = format!(
177            " {}/{} ",
178            app.preview_scroll + 1,
179            total.saturating_sub(inner.height as usize) + 1
180        );
181        let scroll_indicator = Paragraph::new(scrollbar_text)
182            .style(Style::default().fg(DIM))
183            .alignment(Alignment::Right);
184        let indicator_area = Rect::new(area.x + area.width - 12, area.y, 12, 1);
185        f.render_widget(scroll_indicator, indicator_area);
186    }
187}
188
189fn draw_status_bar(f: &mut Frame, app: &App, area: Rect) {
190    let keys = if app.mode == Mode::Search {
191        vec![("Esc", "cancel"), ("Enter", "confirm"), ("Ctrl+U", "clear")]
192    } else {
193        vec![
194            ("↑↓", "navigate"),
195            ("Enter", "resume"),
196            ("d", "delete"),
197            ("/", "search"),
198            ("Tab", "provider"),
199            ("Shift+↑↓", "scroll preview"),
200            ("q", "quit"),
201        ]
202    };
203
204    let filter_label = app.provider_filter.label();
205
206    let mut spans: Vec<Span> = keys
207        .iter()
208        .enumerate()
209        .flat_map(|(i, (key, action))| {
210            let mut s = vec![
211                Span::styled(
212                    format!(" {key} "),
213                    Style::default().fg(Color::Black).bg(OLIVE),
214                ),
215                Span::styled(format!(" {action} "), Style::default().fg(DIM)),
216            ];
217            if i < keys.len() - 1 {
218                s.push(Span::styled(" ", Style::default()));
219            }
220            s
221        })
222        .collect();
223
224    spans.push(Span::styled("  ", Style::default()));
225    spans.push(Span::styled(
226        format!(" {filter_label} "),
227        Style::default().fg(Color::Black).bg(DIM),
228    ));
229
230    let bar = Paragraph::new(Line::from(spans));
231    f.render_widget(bar, area);
232}
233
234fn draw_delete_confirm(f: &mut Frame, app: &App) {
235    let Some(session) = app.selected_session() else {
236        return;
237    };
238
239    let area = centered_rect(50, 7, f.area());
240    f.render_widget(Clear, area);
241
242    let summary = truncate_str(&session.summary, 40);
243    let text = vec![
244        Line::from(""),
245        Line::from(Span::styled(
246            format!("Delete \"{summary}\"?"),
247            Style::default().fg(BURNT).add_modifier(Modifier::BOLD),
248        )),
249        Line::from(""),
250        Line::from(vec![
251            Span::styled(" d ", Style::default().fg(Color::Black).bg(BURNT)),
252            Span::styled(" confirm  ", Style::default().fg(DIM)),
253            Span::styled(" Esc ", Style::default().fg(Color::Black).bg(OLIVE)),
254            Span::styled(" cancel", Style::default().fg(DIM)),
255        ]),
256    ];
257
258    let block = Block::default()
259        .borders(Borders::ALL)
260        .border_style(Style::default().fg(BURNT))
261        .title(" Confirm ")
262        .title_style(Style::default().fg(BURNT).add_modifier(Modifier::BOLD));
263
264    let paragraph = Paragraph::new(text)
265        .block(block)
266        .alignment(Alignment::Center);
267
268    f.render_widget(paragraph, area);
269}
270
271fn centered_rect(percent_x: u16, height: u16, area: Rect) -> Rect {
272    let popup_layout = Layout::default()
273        .direction(Direction::Vertical)
274        .constraints([
275            Constraint::Fill(1),
276            Constraint::Length(height),
277            Constraint::Fill(1),
278        ])
279        .split(area);
280
281    Layout::default()
282        .direction(Direction::Horizontal)
283        .constraints([
284            Constraint::Percentage((100 - percent_x) / 2),
285            Constraint::Percentage(percent_x),
286            Constraint::Percentage((100 - percent_x) / 2),
287        ])
288        .split(popup_layout[1])[1]
289}
290
291fn shorten_path(path: &str, max: usize) -> String {
292    if path.len() <= max {
293        return path.to_string();
294    }
295    let home = dirs::home_dir()
296        .map(|h| h.to_string_lossy().to_string())
297        .unwrap_or_default();
298    let shortened = path.replace(&home, "~");
299    if shortened.len() <= max {
300        return shortened;
301    }
302    format!("…{}", &shortened[shortened.len() - max + 1..])
303}
304
305fn truncate_str(s: &str, max: usize) -> String {
306    if s.chars().count() <= max {
307        s.to_string()
308    } else {
309        let truncated: String = s.chars().take(max).collect();
310        format!("{truncated}…")
311    }
312}