Skip to main content

recall_cli/
app.rs

1use crate::session::{Session, delete_session, human_time_ago};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum ProviderFilter {
5    All,
6    Copilot,
7    Claude,
8}
9
10impl ProviderFilter {
11    pub fn label(self) -> &'static str {
12        match self {
13            Self::All => "All",
14            Self::Copilot => "Copilot",
15            Self::Claude => "Claude Code",
16        }
17    }
18
19    fn matches(self, provider: &str) -> bool {
20        match self {
21            Self::All => true,
22            Self::Copilot => provider == "Copilot",
23            Self::Claude => provider == "Claude Code",
24        }
25    }
26
27    fn next(self) -> Self {
28        match self {
29            Self::All => Self::Copilot,
30            Self::Copilot => Self::Claude,
31            Self::Claude => Self::All,
32        }
33    }
34}
35
36pub struct App {
37    pub sessions: Vec<Session>,
38    pub filtered: Vec<usize>,
39    pub selected: usize,
40    pub preview_scroll: usize,
41    pub search_query: String,
42    pub search_active: bool,
43    pub mode: Mode,
44    pub provider_filter: ProviderFilter,
45    pub confirm_delete: bool,
46    pub should_quit: bool,
47    pub resume_session: Option<String>,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum Mode {
52    Browse,
53    Search,
54}
55
56impl App {
57    pub fn new(sessions: Vec<Session>) -> Self {
58        let filtered: Vec<usize> = (0..sessions.len()).collect();
59        Self {
60            sessions,
61            filtered,
62            selected: 0,
63            preview_scroll: 0,
64            search_query: String::new(),
65            search_active: false,
66            mode: Mode::Browse,
67            provider_filter: ProviderFilter::All,
68            confirm_delete: false,
69            should_quit: false,
70            resume_session: None,
71        }
72    }
73
74    pub fn selected_session(&self) -> Option<&Session> {
75        self.filtered
76            .get(self.selected)
77            .and_then(|&idx| self.sessions.get(idx))
78    }
79
80    pub fn move_up(&mut self) {
81        if self.selected > 0 {
82            self.selected -= 1;
83            self.preview_scroll = 0;
84            self.confirm_delete = false;
85        }
86    }
87
88    pub fn move_down(&mut self) {
89        if self.selected + 1 < self.filtered.len() {
90            self.selected += 1;
91            self.preview_scroll = 0;
92            self.confirm_delete = false;
93        }
94    }
95
96    pub fn scroll_preview_up(&mut self) {
97        self.preview_scroll = self.preview_scroll.saturating_sub(3);
98    }
99
100    pub fn scroll_preview_down(&mut self) {
101        self.preview_scroll += 3;
102    }
103
104    pub fn resume_selected(&mut self) {
105        if let Some(session) = self.selected_session() {
106            self.resume_session = Some(session.id.clone());
107            self.should_quit = true;
108        }
109    }
110
111    pub fn delete_selected(&mut self) {
112        if !self.confirm_delete {
113            self.confirm_delete = true;
114            return;
115        }
116
117        let Some(&idx) = self.filtered.get(self.selected) else {
118            return;
119        };
120
121        if delete_session(&self.sessions[idx]).is_ok() {
122            self.sessions.remove(idx);
123            self.apply_filter();
124            if self.selected >= self.filtered.len() && self.selected > 0 {
125                self.selected -= 1;
126            }
127        }
128        self.confirm_delete = false;
129    }
130
131    pub fn enter_search(&mut self) {
132        self.mode = Mode::Search;
133        self.search_active = true;
134    }
135
136    pub fn exit_search(&mut self) {
137        self.mode = Mode::Browse;
138        self.search_active = false;
139    }
140
141    pub fn clear_search(&mut self) {
142        self.search_query.clear();
143        self.apply_filter();
144        self.exit_search();
145    }
146
147    pub fn search_input(&mut self, c: char) {
148        self.search_query.push(c);
149        self.apply_filter();
150    }
151
152    pub fn search_backspace(&mut self) {
153        self.search_query.pop();
154        self.apply_filter();
155    }
156
157    pub fn cycle_provider_filter(&mut self) {
158        self.provider_filter = self.provider_filter.next();
159        self.apply_filter();
160    }
161
162    pub fn apply_filter(&mut self) {
163        let query = self.search_query.to_lowercase();
164        self.filtered = self
165            .sessions
166            .iter()
167            .enumerate()
168            .filter(|(_, s)| self.provider_filter.matches(&s.provider))
169            .filter(|(_, s)| {
170                query.is_empty()
171                    || s.summary.to_lowercase().contains(&query)
172                    || s.cwd.to_lowercase().contains(&query)
173                    || s.user_messages
174                        .iter()
175                        .any(|m| m.to_lowercase().contains(&query))
176            })
177            .map(|(i, _)| i)
178            .collect();
179
180        if self.selected >= self.filtered.len() {
181            self.selected = self.filtered.len().saturating_sub(1);
182        }
183        self.preview_scroll = 0;
184    }
185
186    pub fn build_preview_lines(&self) -> Vec<PreviewLine> {
187        let Some(session) = self.selected_session() else {
188            return vec![PreviewLine::dim("No session selected".into())];
189        };
190
191        let mut lines = Vec::new();
192
193        lines.push(PreviewLine::header(session.summary.clone()));
194        lines.push(PreviewLine::empty());
195
196        let created = session.created_at.format("%b %d, %Y %H:%M").to_string();
197        let updated = human_time_ago(&session.updated_at);
198        lines.push(PreviewLine::label_value("Provider", &session.provider));
199        lines.push(PreviewLine::label_value("Created", &created));
200        lines.push(PreviewLine::label_value("Updated", &updated));
201        lines.push(PreviewLine::label_value("Directory", &session.cwd));
202        lines.push(PreviewLine::label_value("Session ID", &session.id));
203
204        if !session.checkpoints.is_empty() {
205            lines.push(PreviewLine::empty());
206            lines.push(PreviewLine::section(format!(
207                "── Checkpoints ({}) ──",
208                session.checkpoints.len()
209            )));
210            for (i, cp) in session.checkpoints.iter().enumerate() {
211                lines.push(PreviewLine::normal(format!("  {}. {}", i + 1, cp.title)));
212            }
213        }
214
215        if !session.user_messages.is_empty() {
216            lines.push(PreviewLine::empty());
217            lines.push(PreviewLine::section(format!(
218                "── Messages ({}) ──",
219                session.user_messages.len()
220            )));
221            for msg in session.user_messages.iter().take(10) {
222                lines.push(PreviewLine::dim(format!("  > {msg}")));
223            }
224            if session.user_messages.len() > 10 {
225                lines.push(PreviewLine::dim(format!(
226                    "  ... and {} more",
227                    session.user_messages.len() - 10
228                )));
229            }
230        }
231
232        if !session.task_summaries.is_empty() {
233            lines.push(PreviewLine::empty());
234            lines.push(PreviewLine::section("── Completed Tasks ──".into()));
235            for summary in &session.task_summaries {
236                lines.push(PreviewLine::normal(format!("  ✓ {summary}")));
237            }
238        }
239
240        lines
241    }
242}
243
244#[derive(Debug, Clone)]
245pub struct PreviewLine {
246    pub text: String,
247    pub style: LineStyle,
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251pub enum LineStyle {
252    Header,
253    Section,
254    Label,
255    Normal,
256    Dim,
257    Empty,
258}
259
260impl PreviewLine {
261    pub fn header(text: String) -> Self {
262        Self {
263            text,
264            style: LineStyle::Header,
265        }
266    }
267
268    pub fn section(text: String) -> Self {
269        Self {
270            text,
271            style: LineStyle::Section,
272        }
273    }
274
275    pub fn label_value(label: &str, value: &str) -> Self {
276        Self {
277            text: format!("{label}: {value}"),
278            style: LineStyle::Label,
279        }
280    }
281
282    pub fn normal(text: String) -> Self {
283        Self {
284            text,
285            style: LineStyle::Normal,
286        }
287    }
288
289    pub fn dim(text: String) -> Self {
290        Self {
291            text,
292            style: LineStyle::Dim,
293        }
294    }
295
296    pub fn empty() -> Self {
297        Self {
298            text: String::new(),
299            style: LineStyle::Empty,
300        }
301    }
302}