rab/agent/ui/components/
session_picker.rs1use crate::agent::SessionRepo;
2use crate::agent::session::SessionInfo;
3use crate::tui::Theme;
4use crate::tui::theme::ThemeKey;
5use std::path::PathBuf;
6
7pub struct SessionPicker {
10 sessions: Vec<SessionInfo>,
12 filter: String,
14 selected: usize,
16 filtered: Vec<usize>,
18 loading: bool,
20 loaded_count: usize,
22 total_count: usize,
23}
24
25#[derive(Debug, Clone)]
26pub enum SessionPickerResult {
27 Select(PathBuf),
29 Cancel,
31 Info(PathBuf),
33 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 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 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 pub fn set_filter(&mut self, filter: &str) {
75 self.filter = filter.to_lowercase();
76 self.rebuild_filter();
77 }
78
79 pub fn filter(&self) -> &str {
81 &self.filter
82 }
83
84 pub fn select_prev(&mut self) {
86 if !self.filtered.is_empty() {
87 self.selected = self.selected.saturating_sub(1);
88 }
89 }
90
91 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 pub fn selected_info(&self) -> Option<&SessionInfo> {
100 self.filtered.get(self.selected).map(|&i| &self.sessions[i])
101 }
102
103 pub fn selected_path(&self) -> Option<PathBuf> {
105 self.selected_info().map(|s| s.path.clone())
106 }
107
108 pub fn is_loading(&self) -> bool {
110 self.loading
111 }
112
113 pub fn progress(&self) -> (usize, usize) {
115 (self.loaded_count, self.total_count)
116 }
117
118 pub fn is_empty(&self) -> bool {
120 self.filtered.is_empty()
121 }
122
123 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 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 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 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 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}