Skip to main content

imp_tui/views/
session_picker.rs

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    /// Adjust scroll_offset so the selected item is visible within `visible_rows` entries.
95    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}