1use std::cmp::Ordering;
2use std::path::Path;
3
4use imp_core::session::SessionInfo;
5use ratatui::buffer::Buffer;
6use ratatui::layout::{Constraint, Direction, Layout, Rect};
7use ratatui::style::Style;
8use ratatui::text::{Line, Span};
9use ratatui::widgets::{Block, Borders, Clear, Widget};
10
11use crate::theme::Theme;
12
13const ROW_HEIGHT: usize = 3;
14
15#[derive(Debug, Clone)]
16pub struct SessionPickerState {
17 pub sessions: Vec<SessionInfo>,
18 pub filtered_indices: Vec<usize>,
19 pub filter: String,
20 pub selected: usize,
21 pub scroll_offset: usize,
22 preferred_cwd: Option<String>,
23}
24
25impl SessionPickerState {
26 pub fn new(sessions: Vec<SessionInfo>, preferred_cwd: Option<&Path>) -> Self {
27 let mut state = Self {
28 sessions,
29 filtered_indices: Vec::new(),
30 filter: String::new(),
31 selected: 0,
32 scroll_offset: 0,
33 preferred_cwd: preferred_cwd.map(|path| path.to_string_lossy().to_string()),
34 };
35 state.refresh_filter();
36 state
37 }
38
39 pub fn move_up(&mut self) {
40 if self.selected > 0 {
41 self.selected -= 1;
42 if self.selected < self.scroll_offset {
43 self.scroll_offset = self.selected;
44 }
45 }
46 }
47
48 pub fn move_down(&mut self) {
49 if self.selected + 1 < self.filtered_indices.len() {
50 self.selected += 1;
51 }
52 }
53
54 pub fn push_filter(&mut self, c: char) {
55 self.filter.push(c);
56 self.refresh_filter();
57 }
58
59 pub fn pop_filter(&mut self) {
60 self.filter.pop();
61 self.refresh_filter();
62 }
63
64 pub fn clamp_scroll(&mut self, visible_rows: usize) {
66 if visible_rows == 0 {
67 return;
68 }
69 if self.selected < self.scroll_offset {
70 self.scroll_offset = self.selected;
71 } else if self.selected >= self.scroll_offset + visible_rows {
72 self.scroll_offset = self.selected + 1 - visible_rows;
73 }
74 }
75
76 pub fn selected_session(&self) -> Option<&SessionInfo> {
77 let idx = *self.filtered_indices.get(self.selected)?;
78 self.sessions.get(idx)
79 }
80
81 pub fn visible_sessions(&self) -> impl Iterator<Item = (usize, &SessionInfo)> {
82 self.filtered_indices
83 .iter()
84 .copied()
85 .enumerate()
86 .map(|(visible_idx, session_idx)| (visible_idx, &self.sessions[session_idx]))
87 }
88
89 fn refresh_filter(&mut self) {
90 let needle = self.filter.trim().to_lowercase();
91
92 if needle.is_empty() {
93 self.filtered_indices = (0..self.sessions.len()).collect();
94 self.filtered_indices.sort_by(|idx_a, idx_b| {
95 compare_session_recency(&self.sessions[*idx_a], &self.sessions[*idx_b])
96 });
97 } else {
98 let mut ranked: Vec<(i64, usize)> = self
99 .sessions
100 .iter()
101 .enumerate()
102 .filter_map(|(idx, session)| {
103 session_score(session, &needle, self.preferred_cwd.as_deref())
104 .map(|score| (score, idx))
105 })
106 .collect();
107
108 ranked.sort_by(|(score_a, idx_a), (score_b, idx_b)| {
109 score_b.cmp(score_a).then_with(|| {
110 compare_session_recency(&self.sessions[*idx_a], &self.sessions[*idx_b])
111 })
112 });
113
114 self.filtered_indices = ranked.into_iter().map(|(_, idx)| idx).collect();
115 }
116
117 if self.selected >= self.filtered_indices.len() {
118 self.selected = self.filtered_indices.len().saturating_sub(1);
119 }
120 self.scroll_offset = self.scroll_offset.min(self.selected);
121 }
122}
123
124pub struct SessionPickerView<'a> {
125 state: &'a SessionPickerState,
126 theme: &'a Theme,
127}
128
129impl<'a> SessionPickerView<'a> {
130 pub fn new(state: &'a SessionPickerState, theme: &'a Theme) -> Self {
131 Self { state, theme }
132 }
133}
134
135impl Widget for SessionPickerView<'_> {
136 fn render(self, area: Rect, buf: &mut Buffer) {
137 if area.height < 8 || area.width < 24 {
138 return;
139 }
140
141 Clear.render(area, buf);
142 let title = if self.state.filter.is_empty() {
143 " Resume Session ".to_string()
144 } else {
145 format!(" Resume Session / {} ", self.state.filter)
146 };
147 let block = Block::default()
148 .title(title)
149 .borders(Borders::ALL)
150 .border_style(self.theme.accent_style());
151 let inner = block.inner(area);
152 block.render(area, buf);
153
154 let has_preview = inner.width >= 88;
155 let columns = if has_preview {
156 Layout::default()
157 .direction(Direction::Horizontal)
158 .constraints([Constraint::Percentage(46), Constraint::Percentage(54)])
159 .split(inner)
160 } else {
161 Layout::default()
162 .direction(Direction::Horizontal)
163 .constraints([Constraint::Percentage(100), Constraint::Percentage(0)])
164 .split(inner)
165 };
166 let list_area = columns[0];
167 let preview_area = columns[1];
168
169 if self.state.filtered_indices.is_empty() {
170 let msg = if self.state.filter.is_empty() {
171 " No sessions found"
172 } else {
173 " No matching sessions"
174 };
175 let line = Line::from(Span::styled(msg, self.theme.muted_style()));
176 buf.set_line(list_area.x, list_area.y, &line, list_area.width);
177 if has_preview {
178 render_preview_empty(preview_area, buf, self.theme);
179 }
180 return;
181 }
182
183 render_session_list(list_area, self.state, buf, self.theme);
184 if has_preview {
185 render_session_preview(preview_area, self.state.selected_session(), buf, self.theme);
186 }
187 }
188}
189
190fn render_session_list(area: Rect, state: &SessionPickerState, buf: &mut Buffer, theme: &Theme) {
191 if area.height == 0 || area.width == 0 {
192 return;
193 }
194
195 let visible_rows = (area.height as usize / ROW_HEIGHT).max(1);
196 let scroll_offset = state.scroll_offset;
197 let total = state.filtered_indices.len();
198
199 let visible_sessions = state
200 .visible_sessions()
201 .skip(scroll_offset)
202 .take(visible_rows);
203
204 for (row, (visible_idx, session)) in visible_sessions.enumerate() {
205 let is_selected = visible_idx == state.selected;
206 let style = if is_selected {
207 theme.selected_style()
208 } else {
209 Style::default()
210 };
211
212 let preview = session
213 .summary
214 .as_deref()
215 .filter(|summary| !summary.trim().is_empty())
216 .map(|summary| summary.trim().to_string())
217 .or_else(|| {
218 session
219 .first_message
220 .as_deref()
221 .map(|text| text.split_whitespace().collect::<Vec<_>>().join(" "))
222 })
223 .unwrap_or_else(|| "(empty)".to_string());
224
225 let project = project_name(&session.cwd);
226 let title = session
227 .title(48)
228 .unwrap_or_else(|| "(unnamed session)".to_string());
229 let age = format_age(session.updated_at);
230 let msgs = format!("{} msg", session.message_count);
231
232 let title_width = area.width.saturating_sub(4) as usize;
233 let meta_width = area.width.saturating_sub(4) as usize;
234 let preview_width = area.width.saturating_sub(6) as usize;
235
236 let title = truncate(&title, title_width);
237 let meta = truncate(&format!("{project} • {msgs} • {age}"), meta_width);
238 let preview = truncate(&preview, preview_width);
239
240 let base_y = area.y + (row as u16 * ROW_HEIGHT as u16);
241
242 let title_line = Line::from(vec![
243 Span::styled(
244 if is_selected { " ▸ " } else { " " },
245 theme.accent_style(),
246 ),
247 Span::styled(title, style),
248 ]);
249 buf.set_line(area.x, base_y, &title_line, area.width);
250
251 if base_y + 1 < area.y + area.height {
252 let meta_line = Line::from(vec![
253 Span::raw(" "),
254 Span::styled(meta, theme.muted_style()),
255 ]);
256 buf.set_line(area.x, base_y + 1, &meta_line, area.width);
257 }
258
259 if base_y + 2 < area.y + area.height {
260 let preview_line = Line::from(vec![
261 Span::raw(" "),
262 Span::styled(preview, theme.muted_style()),
263 ]);
264 buf.set_line(area.x, base_y + 2, &preview_line, area.width);
265 }
266 }
267
268 if scroll_offset > 0 {
269 let indicator = Line::from(Span::styled("▲", theme.muted_style()));
270 buf.set_line(area.x + area.width.saturating_sub(1), area.y, &indicator, 1);
271 }
272 if scroll_offset + visible_rows < total {
273 let indicator = Line::from(Span::styled("▼", theme.muted_style()));
274 buf.set_line(
275 area.x + area.width.saturating_sub(1),
276 area.y + area.height.saturating_sub(1),
277 &indicator,
278 1,
279 );
280 }
281}
282
283fn render_preview_empty(area: Rect, buf: &mut Buffer, theme: &Theme) {
284 let block = Block::default()
285 .title(" Preview ")
286 .borders(Borders::LEFT)
287 .border_style(theme.muted_style());
288 let inner = block.inner(area);
289 block.render(area, buf);
290 let line = Line::from(Span::styled(
291 "Type to fuzzy-search sessions.",
292 theme.muted_style(),
293 ));
294 if inner.height > 0 {
295 buf.set_line(inner.x, inner.y, &line, inner.width);
296 }
297}
298
299fn render_session_preview(
300 area: Rect,
301 session: Option<&SessionInfo>,
302 buf: &mut Buffer,
303 theme: &Theme,
304) {
305 let block = Block::default()
306 .title(" Preview ")
307 .borders(Borders::LEFT)
308 .border_style(theme.muted_style());
309 let inner = block.inner(area);
310 block.render(area, buf);
311
312 let Some(session) = session else {
313 return;
314 };
315
316 let title = session
317 .title(80)
318 .unwrap_or_else(|| "(unnamed session)".to_string());
319 let summary = session
320 .summary
321 .as_deref()
322 .filter(|summary| !summary.trim().is_empty())
323 .unwrap_or("(no summary yet)");
324 let prompt = session
325 .first_message
326 .as_deref()
327 .filter(|text| !text.trim().is_empty())
328 .unwrap_or("(no prompt captured)");
329
330 let lines = [
331 format!("Title: {title}"),
332 format!("Project: {}", project_name(&session.cwd)),
333 format!("Updated: {}", format_age(session.updated_at)),
334 format!("Messages: {}", session.message_count),
335 format!("ID: {}", session.id),
336 String::new(),
337 "Summary:".to_string(),
338 summary.to_string(),
339 String::new(),
340 "First prompt:".to_string(),
341 prompt.to_string(),
342 String::new(),
343 "Enter opens • type filters • Esc cancels".to_string(),
344 ];
345
346 let wrapped = wrap_lines(&lines, inner.width as usize, inner.height as usize);
347 for (i, line) in wrapped.iter().enumerate() {
348 if i >= inner.height as usize {
349 break;
350 }
351 let style = if line.is_empty() {
352 theme.muted_style()
353 } else if matches!(line.as_str(), "Summary:" | "First prompt:") {
354 theme.accent_style()
355 } else {
356 theme.muted_style()
357 };
358 let rendered = Line::from(Span::styled(line.clone(), style));
359 buf.set_line(inner.x, inner.y + i as u16, &rendered, inner.width);
360 }
361}
362
363fn compare_session_recency(a: &SessionInfo, b: &SessionInfo) -> Ordering {
364 b.updated_at
365 .cmp(&a.updated_at)
366 .then_with(|| b.created_at.cmp(&a.created_at))
367}
368
369fn session_score(session: &SessionInfo, needle: &str, preferred_cwd: Option<&str>) -> Option<i64> {
370 let mut score = 0i64;
371
372 if let Some(cwd) = preferred_cwd {
373 if session.cwd == cwd {
374 score += 20_000;
375 } else if path_related(&session.cwd, cwd) {
376 score += 5_000;
377 } else if project_name(&session.cwd) == project_name(cwd) {
378 score += 1_500;
379 }
380 }
381
382 if needle.is_empty() {
383 return Some(score);
384 }
385
386 let mut best_match = 0i64;
387 best_match = best_match.max(text_match_score(session.name.as_deref(), needle, 1_200));
388 best_match = best_match.max(text_match_score(session.summary.as_deref(), needle, 1_000));
389 best_match = best_match.max(text_match_score(
390 session.first_message.as_deref(),
391 needle,
392 700,
393 ));
394 best_match = best_match.max(text_match_score(Some(&session.cwd), needle, 500));
395 best_match = best_match.max(text_match_score(Some(&session.id), needle, 300));
396
397 if best_match == 0 {
398 None
399 } else {
400 Some(score + best_match)
401 }
402}
403
404fn text_match_score(value: Option<&str>, needle: &str, weight: i64) -> i64 {
405 let Some(value) = value else { return 0 };
406 let haystack = value.to_lowercase();
407
408 if haystack == needle {
409 return weight + 900;
410 }
411 if haystack.starts_with(needle) {
412 return weight + 600;
413 }
414 if let Some(pos) = haystack.find(needle) {
415 return weight + 400 - pos as i64;
416 }
417 if fuzzy_match(&haystack, needle) {
418 return weight + 150 + needle.len() as i64;
419 }
420 0
421}
422
423fn path_related(a: &str, b: &str) -> bool {
424 let a = Path::new(a);
425 let b = Path::new(b);
426 a.starts_with(b) || b.starts_with(a)
427}
428
429fn project_name(path: &str) -> String {
430 Path::new(path)
431 .file_name()
432 .map(|name| name.to_string_lossy().to_string())
433 .filter(|name| !name.is_empty())
434 .unwrap_or_else(|| ".".to_string())
435}
436
437fn fuzzy_match(haystack: &str, needle: &str) -> bool {
438 if needle.is_empty() {
439 return true;
440 }
441 if haystack.contains(needle) {
442 return true;
443 }
444
445 let mut chars = needle.chars();
446 let Some(mut current) = chars.next() else {
447 return true;
448 };
449
450 for ch in haystack.chars() {
451 if ch == current {
452 if let Some(next) = chars.next() {
453 current = next;
454 } else {
455 return true;
456 }
457 }
458 }
459
460 false
461}
462
463fn truncate(text: &str, max_chars: usize) -> String {
464 if max_chars == 0 {
465 return String::new();
466 }
467
468 let count = text.chars().count();
469 if count <= max_chars {
470 return text.to_string();
471 }
472
473 if max_chars == 1 {
474 return "…".to_string();
475 }
476
477 let take = max_chars.saturating_sub(1);
478 let mut out = text.chars().take(take).collect::<String>();
479 out.push('…');
480 out
481}
482
483fn wrap_lines(lines: &[String], width: usize, max_lines: usize) -> Vec<String> {
484 if width == 0 || max_lines == 0 {
485 return Vec::new();
486 }
487
488 let mut out = Vec::new();
489 for line in lines {
490 if out.len() >= max_lines {
491 break;
492 }
493 if line.is_empty() {
494 out.push(String::new());
495 continue;
496 }
497
498 let words: Vec<&str> = line.split_whitespace().collect();
499 if words.is_empty() {
500 out.push(String::new());
501 continue;
502 }
503
504 let mut current = String::new();
505 for word in words {
506 let candidate = if current.is_empty() {
507 word.to_string()
508 } else {
509 format!("{current} {word}")
510 };
511
512 if candidate.chars().count() <= width {
513 current = candidate;
514 } else {
515 if !current.is_empty() {
516 out.push(current);
517 if out.len() >= max_lines {
518 return out;
519 }
520 }
521 current = truncate(word, width);
522 }
523 }
524
525 if !current.is_empty() {
526 out.push(current);
527 }
528 }
529
530 out.truncate(max_lines);
531 out
532}
533
534fn format_age(updated_at: u64) -> String {
535 let now = std::time::SystemTime::now()
536 .duration_since(std::time::UNIX_EPOCH)
537 .unwrap_or_default()
538 .as_secs();
539 let delta = now.saturating_sub(updated_at);
540 if delta < 60 {
541 "just now".into()
542 } else if delta < 3600 {
543 format!("{}m ago", delta / 60)
544 } else if delta < 86400 {
545 format!("{}h ago", delta / 3600)
546 } else {
547 format!("{}d ago", delta / 86400)
548 }
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554 use std::path::PathBuf;
555
556 fn make_session(
557 id: &str,
558 title: Option<&str>,
559 summary: Option<&str>,
560 cwd: &str,
561 first_message: &str,
562 updated_at: u64,
563 ) -> SessionInfo {
564 SessionInfo {
565 id: id.to_string(),
566 path: PathBuf::from(format!("/tmp/{id}.jsonl")),
567 cwd: cwd.to_string(),
568 created_at: 0,
569 updated_at,
570 message_count: 3,
571 first_message: Some(first_message.to_string()),
572 name: title.map(str::to_string),
573 summary: summary.map(str::to_string),
574 }
575 }
576
577 #[test]
578 fn picker_filter_matches_name_summary_and_path() {
579 let sessions = vec![
580 make_session(
581 "one",
582 Some("oauth debugging"),
583 Some("Investigated OAuth refresh failures"),
584 "/tmp/tower/imp",
585 "first prompt about oauth login",
586 10,
587 ),
588 make_session(
589 "two",
590 Some("render tweaks"),
591 Some("Adjusted top bar display"),
592 "/tmp/tower/wizard",
593 "first prompt about top bar tweaks",
594 20,
595 ),
596 ];
597
598 let mut state = SessionPickerState::new(sessions, Some(Path::new("/tmp/tower/imp")));
599 state.push_filter('o');
600 state.push_filter('a');
601 state.push_filter('u');
602 state.push_filter('t');
603 state.push_filter('h');
604
605 assert_eq!(state.filtered_indices.len(), 1);
606 assert_eq!(state.selected_session().unwrap().id, "one");
607
608 state.pop_filter();
609 assert_eq!(state.filter, "oaut");
610 }
611
612 #[test]
613 fn fuzzy_match_supports_subsequence() {
614 assert!(fuzzy_match("oauth debugging", "oad"));
615 assert!(!fuzzy_match("render tweaks", "oz"));
616 }
617
618 #[test]
619 fn default_order_is_most_recent_first() {
620 let sessions = vec![
621 make_session(
622 "old-local",
623 Some("local"),
624 Some("older local session"),
625 "/tmp/tower/imp",
626 "prompt",
627 10,
628 ),
629 make_session(
630 "new-remote",
631 Some("remote"),
632 Some("newer remote session"),
633 "/tmp/tower/wizard",
634 "prompt",
635 99,
636 ),
637 ];
638
639 let state = SessionPickerState::new(sessions, Some(Path::new("/tmp/tower/imp")));
640 assert_eq!(state.selected_session().unwrap().id, "new-remote");
641 }
642
643 #[test]
644 fn preferred_cwd_ranks_filtered_matches_first() {
645 let sessions = vec![
646 make_session(
647 "old-local",
648 Some("local"),
649 Some("older local session"),
650 "/tmp/tower/imp",
651 "prompt",
652 10,
653 ),
654 make_session(
655 "new-remote",
656 Some("remote"),
657 Some("newer remote session"),
658 "/tmp/tower/wizard",
659 "prompt",
660 99,
661 ),
662 ];
663
664 let mut state = SessionPickerState::new(sessions, Some(Path::new("/tmp/tower/imp")));
665 for c in "prompt".chars() {
666 state.push_filter(c);
667 }
668 assert_eq!(state.selected_session().unwrap().id, "old-local");
669 }
670}