Skip to main content

rab/agent/ui/components/
session_picker.rs

1use crate::agent::SessionRepo;
2use crate::agent::session::SessionInfo;
3use crate::tui::Theme;
4use crate::tui::theme::ThemeKey;
5use std::path::PathBuf;
6
7/// Interactive session picker state.
8/// Not a full TUI Component — emits a result for the app to act on.
9pub struct SessionPicker {
10    /// All loaded sessions.
11    sessions: Vec<SessionInfo>,
12    /// Current filter text (matched against name, id, cwd).
13    filter: String,
14    /// Selected index (in the filtered list).
15    selected: usize,
16    /// Filtered session indices (into self.sessions).
17    filtered: Vec<usize>,
18    /// Whether we're still loading.
19    loading: bool,
20    /// Loading progress.
21    loaded_count: usize,
22    total_count: usize,
23}
24
25#[derive(Debug, Clone)]
26pub enum SessionPickerResult {
27    /// Switch to the session at the given path.
28    Select(PathBuf),
29    /// Dismiss without selecting.
30    Cancel,
31    /// Show session info for the selected session.
32    Info(PathBuf),
33    /// Delete the selected session.
34    Delete(PathBuf),
35}
36
37impl SessionPicker {
38    pub fn new() -> Self {
39        Self {
40            sessions: Vec::new(),
41            filter: String::new(),
42            selected: 0,
43            filtered: Vec::new(),
44            loading: true,
45            loaded_count: 0,
46            total_count: 0,
47        }
48    }
49
50    /// Load sessions from disk (call from async context).
51    pub fn load_sessions(&mut self, repo: &dyn SessionRepo) {
52        self.loading = true;
53        self.loaded_count = 0;
54        self.total_count = 0;
55
56        // Track progress via interior counters
57        let loaded = std::cell::Cell::new(0usize);
58        let total = std::cell::Cell::new(0usize);
59
60        let sessions = repo.list_all(Some(&|l, t| {
61            loaded.set(l);
62            total.set(t);
63        }));
64
65        self.loaded_count = loaded.get();
66        self.total_count = total.get();
67        self.sessions = sessions;
68        self.loading = false;
69        self.selected = 0;
70        self.rebuild_filter();
71    }
72
73    /// Set the filter string and rebuild the filtered list.
74    pub fn set_filter(&mut self, filter: &str) {
75        self.filter = filter.to_lowercase();
76        self.rebuild_filter();
77    }
78
79    /// Get the current filter string.
80    pub fn filter(&self) -> &str {
81        &self.filter
82    }
83
84    /// Move selection up.
85    pub fn select_prev(&mut self) {
86        if !self.filtered.is_empty() {
87            self.selected = self.selected.saturating_sub(1);
88        }
89    }
90
91    /// Move selection down.
92    pub fn select_next(&mut self) {
93        if !self.filtered.is_empty() {
94            self.selected = std::cmp::min(self.selected + 1, self.filtered.len() - 1);
95        }
96    }
97
98    /// Get the currently selected session info, if any.
99    pub fn selected_info(&self) -> Option<&SessionInfo> {
100        self.filtered.get(self.selected).map(|&i| &self.sessions[i])
101    }
102
103    /// Get the path of the selected session.
104    pub fn selected_path(&self) -> Option<PathBuf> {
105        self.selected_info().map(|s| s.path.clone())
106    }
107
108    /// Whether the picker is still loading.
109    pub fn is_loading(&self) -> bool {
110        self.loading
111    }
112
113    /// Loading progress.
114    pub fn progress(&self) -> (usize, usize) {
115        (self.loaded_count, self.total_count)
116    }
117
118    /// Whether there are any sessions matching the filter.
119    pub fn is_empty(&self) -> bool {
120        self.filtered.is_empty()
121    }
122
123    /// Number of sessions matching the filter.
124    pub fn len(&self) -> usize {
125        self.filtered.len()
126    }
127
128    fn rebuild_filter(&mut self) {
129        if self.filter.is_empty() {
130            self.filtered = (0..self.sessions.len()).collect();
131        } else {
132            self.filtered = self
133                .sessions
134                .iter()
135                .enumerate()
136                .filter(|(_, s)| {
137                    let name = s.name.as_deref().unwrap_or("").to_lowercase();
138                    let cwd = s.cwd.to_lowercase();
139                    let id = s.id.to_lowercase();
140                    name.contains(&self.filter)
141                        || cwd.contains(&self.filter)
142                        || id.contains(&self.filter)
143                })
144                .map(|(i, _)| i)
145                .collect();
146        }
147        self.selected = 0;
148    }
149
150    /// Render the session list into lines for display.
151    /// Returns (lines, cursor_y) where cursor_y is the selected row.
152    pub fn render(&self, _width: usize, theme: &dyn Theme) -> (Vec<String>, usize) {
153        let mut lines = Vec::new();
154
155        if self.loading {
156            lines.push(theme.fg_key(
157                ThemeKey::Dim,
158                &format!(
159                    "Loading sessions... ({}/{})",
160                    self.loaded_count, self.total_count
161                ),
162            ));
163            return (lines, 0);
164        }
165
166        if self.sessions.is_empty() {
167            lines.push(theme.fg_key(ThemeKey::Dim, "No sessions found."));
168            return (lines, 0);
169        }
170
171        // Header
172        lines.push(theme.bold("Sessions"));
173        lines.push(theme.fg_key(
174            ThemeKey::Dim,
175            &format!(
176                "{} total, {} shown",
177                self.sessions.len(),
178                self.filtered.len()
179            ),
180        ));
181        lines.push(String::new());
182
183        let mut cursor_y = 0;
184
185        for (display_idx, &session_idx) in self.filtered.iter().enumerate() {
186            let session = &self.sessions[session_idx];
187            let is_selected = display_idx == self.selected;
188
189            let name = session.name.as_deref().unwrap_or("unnamed").to_string();
190            let cwd_short = shorten_cwd(&session.cwd);
191
192            let marker = if is_selected { "▸ " } else { "  " };
193            let line = format!(
194                "{}{}  {}  {}  ({} msgs)",
195                marker,
196                name,
197                cwd_short,
198                fmt_time(&session.created),
199                session.message_count,
200            );
201
202            if is_selected {
203                lines.push(theme.fg("accent", &line));
204                cursor_y = lines.len() - 1;
205            } else {
206                lines.push(line);
207            }
208        }
209
210        // Footer hint
211        lines.push(String::new());
212        lines.push(theme.fg_key(
213            ThemeKey::Dim,
214            "↑↓ navigate · Enter select · / filter · Esc cancel",
215        ));
216
217        (lines, cursor_y)
218    }
219}
220
221impl Default for SessionPicker {
222    fn default() -> Self {
223        Self::new()
224    }
225}
226
227fn shorten_cwd(cwd: &str) -> String {
228    // Replace home dir with ~/
229    let home = directories::BaseDirs::new()
230        .map(|d| d.home_dir().to_string_lossy().to_string())
231        .unwrap_or_default();
232    if let Some(rest) = cwd.strip_prefix(&home) {
233        format!("~{}", rest)
234    } else {
235        cwd.to_string()
236    }
237}
238
239fn fmt_time(dt: &chrono::DateTime<chrono::Utc>) -> String {
240    dt.format("%Y-%m-%d %H:%M").to_string()
241}