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