1use anyhow::Result;
11use chrono::{DateTime, Local};
12use crossterm::{
13 event::{self, Event, KeyCode},
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;
29
30pub struct SessionEntry {
33 pub history: ConversationHistory,
34 pub size_bytes: u64,
35}
36
37pub fn select_conversation(
41 entries: Vec<SessionEntry>,
42 now: DateTime<Local>,
43) -> Result<Option<ConversationHistory>> {
44 if entries.is_empty() {
45 println!("No previous conversations found in this directory.");
46 return Ok(None);
47 }
48
49 enable_raw_mode()?;
50 let mut stdout = io::stdout();
51 execute!(stdout, EnterAlternateScreen)?;
52 let backend = CrosstermBackend::new(stdout);
53 let mut terminal = Terminal::new(backend)?;
54
55 let mut state = SelectorState::new(entries);
56 let result = run_selector(&mut terminal, &mut state, now);
57
58 disable_raw_mode()?;
59 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
60 terminal.show_cursor()?;
61
62 result
63}
64
65pub struct SelectorState {
68 entries: Vec<SessionEntry>,
69 query: String,
70 selected: usize,
72}
73
74impl SelectorState {
75 pub fn new(entries: Vec<SessionEntry>) -> Self {
76 Self {
77 entries,
78 query: String::new(),
79 selected: 0,
80 }
81 }
82
83 fn filtered(&self) -> Vec<usize> {
88 if self.query.is_empty() {
89 return (0..self.entries.len()).collect();
90 }
91 let needle = self.query.to_lowercase();
92 self.entries
93 .iter()
94 .enumerate()
95 .filter(|(_, e)| entry_matches(&e.history, &needle))
96 .map(|(i, _)| i)
97 .collect()
98 }
99
100 fn current(&self) -> Option<&SessionEntry> {
102 self.filtered()
103 .get(self.selected)
104 .map(|&i| &self.entries[i])
105 }
106
107 fn move_down(&mut self) {
108 let n = self.filtered().len();
109 if n > 0 && self.selected + 1 < n {
110 self.selected += 1;
111 }
112 }
113
114 fn move_up(&mut self) {
115 if self.selected > 0 {
116 self.selected -= 1;
117 }
118 }
119
120 fn push_query(&mut self, c: char) {
123 self.query.push(c);
124 self.selected = 0;
125 }
126
127 fn pop_query(&mut self) {
128 self.query.pop();
129 self.selected = 0;
130 }
131}
132
133fn entry_matches(history: &ConversationHistory, needle: &str) -> bool {
136 history.title.to_lowercase().contains(needle)
137 || history
138 .git_branch
139 .as_deref()
140 .is_some_and(|b| b.to_lowercase().contains(needle))
141}
142
143fn run_selector(
144 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
145 state: &mut SelectorState,
146 now: DateTime<Local>,
147) -> Result<Option<ConversationHistory>> {
148 loop {
149 terminal.draw(|f| render(f, state, now))?;
150
151 if let Event::Key(key) = event::read()? {
152 match key.code {
153 KeyCode::Esc => return Ok(None),
154 KeyCode::Enter => return Ok(state.current().map(|e| e.history.clone())),
155 KeyCode::Down => state.move_down(),
156 KeyCode::Up => state.move_up(),
157 KeyCode::Backspace => state.pop_query(),
158 KeyCode::Char(c) => state.push_query(c),
161 _ => {},
162 }
163 }
164 }
165}
166
167const ACCENT: Color = Color::Cyan;
170const META: Color = Color::Gray;
171const DIM: Color = Color::DarkGray;
172
173pub fn render(f: &mut Frame, state: &SelectorState, now: DateTime<Local>) {
176 let layout = Layout::default()
177 .direction(Direction::Vertical)
178 .constraints([
179 Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(3), Constraint::Length(1), ]);
188 let [title, _s1, search, _s2, project, _s3, list, hints] = f.area().layout(&layout);
189
190 f.render_widget(
191 Paragraph::new(Line::from(Span::styled(
192 "Resume session",
193 Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
194 ))),
195 title,
196 );
197
198 let search_line = if state.query.is_empty() {
200 Line::from(Span::styled(" Search…", Style::default().fg(DIM)))
201 } else {
202 Line::from(vec![
203 Span::styled(" ", Style::default()),
204 Span::styled(state.query.clone(), Style::default().fg(Color::White)),
205 Span::styled("▏", Style::default().fg(ACCENT)),
206 ])
207 };
208 f.render_widget(Paragraph::new(search_line), search);
209
210 if let Some(name) = state
212 .entries
213 .first()
214 .map(|e| project_name(&e.history.project_path))
215 {
216 f.render_widget(
217 Paragraph::new(Line::from(Span::styled(
218 name,
219 Style::default().fg(META).add_modifier(Modifier::BOLD),
220 ))),
221 project,
222 );
223 }
224
225 let filtered = state.filtered();
226 let lines: Vec<Line> = if filtered.is_empty() {
227 vec![Line::from(Span::styled(
228 " No sessions match your search.",
229 Style::default().fg(DIM),
230 ))]
231 } else {
232 filtered
233 .iter()
234 .enumerate()
235 .flat_map(|(row, &idx)| {
236 let entry = &state.entries[idx];
237 let is_selected = row == state.selected;
238 let (marker, title_style) = if is_selected {
239 (
240 "> ",
241 Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
242 )
243 } else {
244 (" ", Style::default().fg(Color::White))
245 };
246 let title_line = Line::from(vec![
247 Span::styled(marker, Style::default().fg(ACCENT)),
248 Span::styled(entry.history.title.clone(), title_style),
249 ]);
250 let meta_line = Line::from(vec![Span::styled(
251 format!(" {}", meta_label(entry, now)),
252 Style::default().fg(META),
253 )]);
254 [title_line, meta_line, Line::from("")]
255 })
256 .collect()
257 };
258 f.render_widget(Paragraph::new(lines), list);
259
260 f.render_widget(
261 Paragraph::new(Line::from(Span::styled(
262 "↑↓ select · type to search · enter resume · esc cancel",
263 Style::default().fg(DIM),
264 ))),
265 hints,
266 );
267}
268
269fn meta_label(entry: &SessionEntry, now: DateTime<Local>) -> String {
272 let mut bits = vec![humanize_relative(now, entry.history.updated_at)];
273 if let Some(branch) = &entry.history.git_branch
274 && !branch.is_empty()
275 {
276 bits.push(branch.clone());
277 }
278 bits.push(humanize_size(entry.size_bytes));
279 bits.join(" · ")
280}
281
282fn project_name(project_path: &str) -> String {
284 Path::new(project_path)
285 .file_name()
286 .map(|n| n.to_string_lossy().into_owned())
287 .filter(|s| !s.is_empty())
288 .unwrap_or_else(|| project_path.to_string())
289}
290
291fn humanize_relative(now: DateTime<Local>, then: DateTime<Local>) -> String {
294 let secs = (now - then).num_seconds().max(0);
295 let (n, unit) = if secs < 45 {
296 return "just now".to_string();
297 } else if secs < 3600 {
298 (secs / 60, "minute")
299 } else if secs < 86_400 {
300 (secs / 3600, "hour")
301 } else if secs < 7 * 86_400 {
302 (secs / 86_400, "day")
303 } else if secs < 30 * 86_400 {
304 (secs / (7 * 86_400), "week")
305 } else {
306 (secs / (30 * 86_400), "month")
307 };
308 let n = n.max(1);
309 if n == 1 {
310 format!("1 {unit} ago")
311 } else {
312 format!("{n} {unit}s ago")
313 }
314}
315
316fn humanize_size(bytes: u64) -> String {
318 const KB: f64 = 1024.0;
319 const MB: f64 = 1024.0 * 1024.0;
320 let b = bytes as f64;
321 if b < KB {
322 format!("{bytes}B")
323 } else if b < MB {
324 format!("{:.1}KB", b / KB)
325 } else {
326 format!("{:.1}MB", b / MB)
327 }
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use chrono::TimeZone;
334 use ratatui::Terminal;
335 use ratatui::backend::TestBackend;
336 use std::collections::VecDeque;
337
338 fn at(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> DateTime<Local> {
339 Local.with_ymd_and_hms(y, mo, d, h, mi, 0).unwrap()
340 }
341
342 fn history(title: &str, branch: Option<&str>, updated: DateTime<Local>) -> ConversationHistory {
343 ConversationHistory {
344 id: "20260101_000000_000".to_string(),
345 title: title.to_string(),
346 messages: Vec::new(),
347 model_name: "ollama/test".to_string(),
348 project_path: "/home/nsabaj/Development/source-clone".to_string(),
349 created_at: updated,
350 updated_at: updated,
351 total_tokens: None,
352 compactions: Vec::new(),
353 input_history: VecDeque::new(),
354 git_branch: branch.map(str::to_string),
355 }
356 }
357
358 fn entry(
359 title: &str,
360 branch: Option<&str>,
361 updated: DateTime<Local>,
362 size: u64,
363 ) -> SessionEntry {
364 SessionEntry {
365 history: history(title, branch, updated),
366 size_bytes: size,
367 }
368 }
369
370 #[test]
371 fn humanize_relative_scales_and_pluralizes() {
372 let now = at(2026, 7, 2, 12, 0);
373 assert_eq!(humanize_relative(now, now), "just now");
374 assert_eq!(
375 humanize_relative(now, at(2026, 7, 2, 11, 58)),
376 "2 minutes ago"
377 );
378 assert_eq!(humanize_relative(now, at(2026, 7, 2, 11, 0)), "1 hour ago");
379 assert_eq!(humanize_relative(now, at(2026, 7, 1, 12, 0)), "1 day ago");
380 assert_eq!(humanize_relative(now, at(2026, 6, 29, 12, 0)), "3 days ago");
381 assert_eq!(humanize_relative(now, at(2026, 6, 20, 12, 0)), "1 week ago");
382 assert_eq!(
383 humanize_relative(now, at(2026, 5, 1, 12, 0)),
384 "2 months ago"
385 );
386 assert_eq!(humanize_relative(now, at(2026, 7, 2, 12, 30)), "just now");
388 }
389
390 #[test]
391 fn humanize_size_picks_unit() {
392 assert_eq!(humanize_size(512), "512B");
393 assert_eq!(humanize_size(23_100), "22.6KB");
394 assert_eq!(humanize_size(1_367_426), "1.3MB");
395 }
396
397 #[test]
398 fn filtering_matches_title_and_branch_case_insensitively() {
399 let now = at(2026, 7, 2, 12, 0);
400 let mut state = SelectorState::new(vec![
401 entry("Examine the workspace", Some("main"), now, 100),
402 entry("Fix the parser", Some("feature/parser"), now, 200),
403 entry("Unrelated", Some("main"), now, 300),
404 ]);
405 assert_eq!(state.filtered().len(), 3);
407 state.push_query('E');
409 state.push_query('x');
410 assert_eq!(state.filtered(), vec![0]);
411 state.query.clear();
413 for c in "parser".chars() {
414 state.push_query(c);
415 }
416 assert_eq!(state.filtered(), vec![1]);
417 state.query.clear();
419 for c in "zzz".chars() {
420 state.push_query(c);
421 }
422 assert!(state.filtered().is_empty());
423 assert!(state.current().is_none());
424 }
425
426 #[test]
427 fn navigation_is_clamped_to_filtered_set() {
428 let now = at(2026, 7, 2, 12, 0);
429 let mut state =
430 SelectorState::new(vec![entry("A", None, now, 1), entry("B", None, now, 2)]);
431 state.move_up(); assert_eq!(state.selected, 0);
433 state.move_down();
434 assert_eq!(state.selected, 1);
435 state.move_down(); assert_eq!(state.selected, 1);
437 assert_eq!(state.current().unwrap().history.title, "B");
438 }
439
440 #[test]
441 fn render_shows_claude_code_style_rows() {
442 let now = at(2026, 7, 2, 12, 0);
443 let state = SelectorState::new(vec![
444 entry(
445 "Examine the workspace",
446 Some("main"),
447 at(2026, 7, 2, 10, 0),
448 1_367_426,
449 ),
450 entry(
451 "Older session",
452 Some("master"),
453 at(2026, 6, 29, 12, 0),
454 18_400_000,
455 ),
456 ]);
457 let backend = TestBackend::new(80, 24);
458 let mut terminal = Terminal::new(backend).unwrap();
459 terminal.draw(|f| render(f, &state, now)).unwrap();
460 let buf = terminal.backend().buffer();
461 let mut text = String::new();
462 for y in 0..buf.area.height {
463 for x in 0..buf.area.width {
464 text.push_str(buf[(x, y)].symbol());
465 }
466 text.push('\n');
467 }
468 assert!(text.contains("Resume session"), "header:\n{text}");
469 assert!(text.contains("Search"), "search placeholder:\n{text}");
470 assert!(text.contains("source-clone"), "project name:\n{text}");
471 assert!(text.contains("Examine the workspace"), "title:\n{text}");
472 assert!(text.contains("2 hours ago · main · 1.3MB"), "meta:\n{text}");
474 assert!(
475 text.contains("3 days ago · master · 17.5MB"),
476 "meta2:\n{text}"
477 );
478 assert!(text.contains("> Examine the workspace"), "marker:\n{text}");
480 assert!(text.contains("esc cancel"), "hints:\n{text}");
481 }
482
483 #[test]
484 fn render_empty_filter_shows_no_match_message() {
485 let now = at(2026, 7, 2, 12, 0);
486 let mut state = SelectorState::new(vec![entry("A", None, now, 1)]);
487 for c in "zzz".chars() {
488 state.push_query(c);
489 }
490 let backend = TestBackend::new(80, 24);
491 let mut terminal = Terminal::new(backend).unwrap();
492 terminal.draw(|f| render(f, &state, now)).unwrap();
493 let buf = terminal.backend().buffer();
494 let mut text = String::new();
495 for y in 0..buf.area.height {
496 for x in 0..buf.area.width {
497 text.push_str(buf[(x, y)].symbol());
498 }
499 }
500 assert!(text.contains("No sessions match"), "{text}");
501 }
502}