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}