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