Skip to main content

atomcode_tuix/modals/
session_picker.rs

1// crates/atomcode-tuix/src/modals/session_picker.rs
2//
3// `/resume` modal — prior-sessions picker.
4//
5// Lists all sessions for the current project (pre-filtered to >0 msgs)
6// with type-to-filter search. Up/Down navigates, Enter loads + replays
7// into scrollback + syncs the agent via `AgentCommand::SetMessages`,
8// Esc cancels, printable chars + Backspace edit the filter query.
9// F2 renames the selected session.
10
11use anyhow::Result;
12use atomcode_core::agent::AgentCommand;
13use atomcode_core::session::{Session, SessionMeta};
14use crossterm::event::{KeyCode, KeyModifiers};
15
16use super::{Modal, ModalAction};
17use crate::event_loop::{
18    build_status, format_tool_detail, perform_session_rename, summarise, Buffer, LoopCtx,
19};
20use crate::render::{MenuPayload, Renderer, UiLine};
21use crate::state::UiState;
22
23pub struct SessionPicker {
24    /// All sessions for the project, pre-filtered to message_count > 0.
25    pub sessions: Vec<SessionMeta>,
26    /// User-typed filter text. Empty string = show all.
27    pub query: String,
28    /// Indices into `sessions` that match `query` (case-insensitive substring).
29    pub filtered: Vec<usize>,
30    /// Index into `filtered`.
31    pub selected: usize,
32    /// Whether we are in rename editing mode.
33    pub rename_editing: bool,
34    /// The new name being edited for rename.
35    pub rename_buffer: String,
36}
37
38impl SessionPicker {
39    pub fn open(sessions: Vec<SessionMeta>) -> Self {
40        let filtered: Vec<usize> = (0..sessions.len()).collect();
41        Self {
42            sessions,
43            query: String::new(),
44            filtered,
45            selected: 0,
46            rename_editing: false,
47            rename_buffer: String::new(),
48        }
49    }
50
51    pub fn update_filter(&mut self) {
52        let q = self.query.to_lowercase();
53        self.filtered = self
54            .sessions
55            .iter()
56            .enumerate()
57            .filter(|(_, s)| q.is_empty() || s.name.to_lowercase().contains(&q))
58            .map(|(i, _)| i)
59            .collect();
60        self.selected = 0;
61    }
62
63    pub fn up(&mut self) {
64        if self.filtered.is_empty() {
65            self.selected = 0;
66            return;
67        }
68        self.selected = self.selected.saturating_sub(1);
69    }
70
71    pub fn down(&mut self) {
72        if self.filtered.is_empty() {
73            self.selected = 0;
74            return;
75        }
76        let max = self.filtered.len().saturating_sub(1);
77        if self.selected < max {
78            self.selected += 1;
79        }
80    }
81
82    pub fn chosen_id(&self) -> Option<atomcode_core::session::SessionId> {
83        let i = *self.filtered.get(self.selected)?;
84        self.sessions.get(i).map(|s| s.id.clone())
85    }
86}
87
88impl Modal for SessionPicker {
89    fn handle_key(
90        &mut self,
91        code: KeyCode,
92        mods: KeyModifiers,
93        buf: &mut Buffer,
94        state: &mut UiState,
95        ctx: &mut LoopCtx,
96        renderer: &mut dyn Renderer,
97    ) -> Result<ModalAction> {
98        // Handle rename editing mode
99        if self.rename_editing {
100            match code {
101                KeyCode::Esc => {
102                    // Cancel rename editing
103                    self.rename_editing = false;
104                    self.rename_buffer.clear();
105                    self.draw(buf, state, ctx, renderer);
106                    return Ok(ModalAction::Continue);
107                }
108                KeyCode::Enter => {
109                    if let Some(idx) = self.filtered.get(self.selected).copied() {
110                        if let Some(session_meta) = self.sessions.get(idx) {
111                            let id = session_meta.id.clone();
112                            match perform_session_rename(
113                                &ctx.session_manager,
114                                &id,
115                                &self.rename_buffer,
116                            ) {
117                                Ok((old_name, new_name)) => {
118                                    // Update the session name in our local list
119                                    if let Some(s) = self.sessions.get_mut(idx) {
120                                        s.name = new_name.clone();
121                                    }
122                                    // Recompute filtered list since new name may no longer match query
123                                    let prev_id = id.clone();
124                                    self.update_filter();
125                                    // Try to keep the same session selected
126                                    self.selected = self
127                                        .filtered
128                                        .iter()
129                                        .position(|&fi| self.sessions[fi].id == prev_id)
130                                        .unwrap_or(0);
131                                    // Show success feedback
132                                    renderer.render(UiLine::CommandOutput(
133                                        crate::i18n::t(crate::i18n::Msg::SessionRenamed {
134                                            old: &old_name,
135                                            new: &new_name,
136                                        }).into_owned(),
137                                    ));
138                                    renderer.flush();
139                                }
140                                Err(err) => {
141                                    renderer.render(UiLine::Error(err));
142                                    renderer.flush();
143                                }
144                            }
145                        }
146                    }
147                    self.rename_editing = false;
148                    self.rename_buffer.clear();
149                    self.draw(buf, state, ctx, renderer);
150                    return Ok(ModalAction::Continue);
151                }
152                KeyCode::Backspace => {
153                    self.rename_buffer.pop();
154                    self.draw(buf, state, ctx, renderer);
155                    return Ok(ModalAction::Continue);
156                }
157                KeyCode::Char(c) if !mods.contains(KeyModifiers::CONTROL) => {
158                    self.rename_buffer.push(c);
159                    self.draw(buf, state, ctx, renderer);
160                    return Ok(ModalAction::Continue);
161                }
162                _ => {
163                    self.draw(buf, state, ctx, renderer);
164                    return Ok(ModalAction::Continue);
165                }
166            }
167        }
168
169        // Normal mode handling
170        match code {
171            KeyCode::Up => {
172                self.up();
173                self.draw(buf, state, ctx, renderer);
174                Ok(ModalAction::Continue)
175            }
176            KeyCode::Down => {
177                self.down();
178                self.draw(buf, state, ctx, renderer);
179                Ok(ModalAction::Continue)
180            }
181            KeyCode::Backspace => {
182                self.query.pop();
183                self.update_filter();
184                self.draw(buf, state, ctx, renderer);
185                Ok(ModalAction::Continue)
186            }
187            KeyCode::Char(c) if !mods.contains(KeyModifiers::CONTROL) => {
188                self.query.push(c);
189                self.update_filter();
190                self.draw(buf, state, ctx, renderer);
191                Ok(ModalAction::Continue)
192            }
193            KeyCode::F(2) => {
194                // F2 to start rename editing for selected session
195                if let Some(idx) = self.filtered.get(self.selected).copied() {
196                    if let Some(session) = self.sessions.get(idx) {
197                        self.rename_buffer = session.name.clone();
198                        self.rename_editing = true;
199                        self.draw(buf, state, ctx, renderer);
200                    }
201                } else {
202                    renderer.render(UiLine::Error(
203                        crate::i18n::t(crate::i18n::Msg::SessionNoneSelected).into_owned(),
204                    ));
205                    renderer.flush();
206                }
207                Ok(ModalAction::Continue)
208            }
209            KeyCode::Enter => {
210                let Some(id) = self.chosen_id() else {
211                    // Filter matched nothing — ignore Enter, stay open.
212                    return Ok(ModalAction::Continue);
213                };
214                match ctx.session_manager.load(&id) {
215                    Ok(session) => {
216                        ctx.current_session_id = Some(id);
217                        replay_session(renderer, &session, true);
218                        ctx.agent
219                            .cmd_tx
220                            .send(AgentCommand::SetMessages(session.messages.clone()))
221                            .ok();
222                        // Continue accumulating into the same session
223                        // file — future TurnComplete saves overwrite it
224                        // instead of leaving the old snapshot + creating
225                        // a new one beside it.
226                        // Bind telemetry session_id to the resumed session's UUID.
227                        if let Ok(uuid) = uuid::Uuid::parse_str(session.id.as_str()) {
228                            ctx.telemetry.set_session_id(uuid);
229                        }
230                        ctx.current_session = session;
231                        ctx.bg_manager
232                            .set_foreground_session(ctx.current_session.clone());
233                        state.on_turn_complete();
234                        Ok(ModalAction::Close)
235                    }
236                    Err(e) => {
237                        ctx.current_session_id = None;
238                        state.total_tokens = 0;
239                        state.thinking_idx = 0;
240                        state.on_turn_complete();
241                        let msg = format!("{}", e);
242                        renderer.render(UiLine::Error(
243                            crate::i18n::t(crate::i18n::Msg::SessionLoadFailed { error: &msg }).into_owned(),
244                        ));
245                        renderer.flush();
246                        Ok(ModalAction::Close)
247                    }
248                }
249            }
250            KeyCode::Esc => Ok(ModalAction::Close),
251            _ => Ok(ModalAction::Continue),
252        }
253    }
254
255    fn draw(&self, buf: &Buffer, state: &UiState, ctx: &LoopCtx, renderer: &mut dyn Renderer) {
256        let payload = build_menu_payload(self);
257        renderer.render(UiLine::InputPrompt {
258            buf: buf.text.clone(),
259            cursor_byte: buf.cursor,
260            menu: Some(payload),
261            status: build_status(state, ctx),
262            attachments: Vec::new(),
263        });
264        renderer.flush();
265    }
266}
267
268fn build_menu_payload(p: &SessionPicker) -> MenuPayload {
269    // Empty state: surface a hint row so the user can tell the filter is
270    // active and which query is excluding everything (otherwise the menu
271    // renders as blank space and looks like the modal hung).
272    if p.filtered.is_empty() {
273        let label = if p.sessions.is_empty() {
274            "(no sessions in this project yet)".to_string()
275        } else if p.query.is_empty() {
276            "(no sessions match)".to_string()
277        } else {
278            format!("(no sessions match \"{}\" — Backspace to clear)", p.query)
279        };
280        return MenuPayload {
281            items: vec![(label, String::new())],
282            selected: 0,
283            kind: crate::render::MenuKind::SlashCommand,
284        };
285    }
286    let items: Vec<(String, String)> = p
287        .filtered
288        .iter()
289        .enumerate()
290        .map(|(filter_idx, &session_idx)| {
291            let s = &p.sessions[session_idx];
292            let msgs = crate::i18n::t(crate::i18n::Msg::SessionMsgCount { count: s.message_count });
293            let desc = format!("{} · {}", msgs, humanize_age(s.updated_at));
294            // If in rename editing mode and this is the selected item, show the editing buffer
295            if p.rename_editing && filter_idx == p.selected {
296                (
297                    crate::i18n::t(crate::i18n::Msg::SessionRenameEditing {
298                        buffer: &p.rename_buffer,
299                    }).into_owned(),
300                    desc,
301                )
302            } else {
303                (s.name.clone(), desc)
304            }
305        })
306        .collect();
307    MenuPayload {
308        items,
309        selected: p.selected,
310        kind: crate::render::MenuKind::SlashCommand,
311    }
312}
313
314fn humanize_age(ts: u64) -> String {
315    use crate::i18n::{t, Msg};
316    use std::time::{SystemTime, UNIX_EPOCH};
317    let now = SystemTime::now()
318        .duration_since(UNIX_EPOCH)
319        .map(|d| d.as_secs())
320        .unwrap_or(ts);
321    let d = now.saturating_sub(ts);
322    if d < 60 {
323        t(Msg::SessionTimeJustNow).into_owned()
324    } else if d < 3600 {
325        t(Msg::SessionTimeMinAgo { n: d / 60 }).into_owned()
326    } else if d < 86400 {
327        t(Msg::SessionTimeHourAgo { n: d / 3600 }).into_owned()
328    } else {
329        t(Msg::SessionTimeDayAgo { n: d / 86400 }).into_owned()
330    }
331}
332
333/// Emit historical session messages into scrollback as semantic UiLines,
334/// so the user sees the prior conversation before continuing.
335///
336/// `reset = true` clears the screen first (used by `/resume` mid-session
337/// — without this, repeated switches stack body_lines and the worker's
338/// render-cmd backlog, manifesting as dropped keystrokes "吞字" + 50-150ms
339/// per-keystroke latency). `reset = false` appends to existing scrollback
340/// — used by the CLI auto-continue path at startup, which has the welcome
341/// banner above the replay and shouldn't wipe it.
342pub(crate) fn replay_session(renderer: &mut dyn Renderer, session: &Session, reset: bool) {
343    use atomcode_core::conversation::message::{MessageContent, Role};
344    if reset {
345        renderer.reset();
346    }
347    let resumed = crate::i18n::t(crate::i18n::Msg::SessionResumedLabel { name: &session.name }).into_owned();
348    renderer.render(UiLine::TurnSeparator {
349        label: resumed.clone(),
350    });
351    for m in &session.messages {
352        match (&m.role, &m.content) {
353            (Role::User, MessageContent::Text(s)) => {
354                renderer.render(UiLine::User(s.clone()));
355            }
356            (Role::Assistant, MessageContent::Text(s)) => {
357                if !s.is_empty() {
358                    renderer.render(UiLine::AssistantText(s.clone()));
359                    renderer.render(UiLine::AssistantLineBreak);
360                }
361            }
362            (
363                Role::Assistant,
364                MessageContent::AssistantWithToolCalls {
365                    text, tool_calls, ..
366                },
367            ) => {
368                if let Some(t) = text {
369                    if !t.is_empty() {
370                        renderer.render(UiLine::AssistantText(t.clone()));
371                        renderer.render(UiLine::AssistantLineBreak);
372                    }
373                }
374                for tc in tool_calls {
375                    renderer.render(UiLine::ToolCall {
376                        name: tc.name.clone(),
377                        detail: format_tool_detail(&tc.name, &tc.arguments),
378                    });
379                }
380            }
381            (Role::Tool, MessageContent::ToolResult(r)) => {
382                renderer.render(UiLine::ToolResult {
383                    success: r.success,
384                    summary: summarise(&r.output, r.success),
385                });
386            }
387            (Role::Tool, MessageContent::ToolResultRef(r)) => {
388                renderer.render(UiLine::ToolResult {
389                    success: true,
390                    summary: summarise(&r.summary, true),
391                });
392            }
393            _ => {}
394        }
395    }
396    renderer.render(UiLine::TurnComplete);
397    renderer.render(UiLine::TurnSeparator {
398        label: resumed,
399    });
400    renderer.flush();
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use atomcode_core::session::{SessionId, SessionMeta};
407    use std::path::PathBuf;
408
409    fn meta(name: &str, msgs: usize) -> SessionMeta {
410        SessionMeta {
411            id: SessionId::from_string(format!("id-{name}")),
412            name: name.to_string(),
413            working_dir: PathBuf::from("/tmp/x"),
414            created_at: 0,
415            updated_at: 0,
416            message_count: msgs,
417            file_size: 0,
418        }
419    }
420
421    #[test]
422    fn open_shows_all_sessions_initially() {
423        let p = SessionPicker::open(vec![meta("alpha", 3), meta("beta", 5)]);
424        assert_eq!(p.filtered.len(), 2);
425        assert_eq!(p.selected, 0);
426        assert!(p.query.is_empty());
427    }
428
429    #[test]
430    fn update_filter_matches_by_substring_case_insensitive() {
431        let mut p = SessionPicker::open(vec![
432            meta("Fix auth bug", 4),
433            meta("Refactor renderer", 7),
434            meta("authentication flow", 2),
435        ]);
436        p.query = "auth".to_string();
437        p.update_filter();
438        assert_eq!(p.filtered.len(), 2);
439        let names: Vec<&str> = p
440            .filtered
441            .iter()
442            .map(|i| p.sessions[*i].name.as_str())
443            .collect();
444        assert!(names.contains(&"Fix auth bug"));
445        assert!(names.contains(&"authentication flow"));
446    }
447
448    #[test]
449    fn update_filter_empty_query_shows_all() {
450        let mut p = SessionPicker::open(vec![meta("x", 1), meta("y", 1)]);
451        p.query = "zz".to_string();
452        p.update_filter();
453        assert_eq!(p.filtered.len(), 0);
454        p.query.clear();
455        p.update_filter();
456        assert_eq!(p.filtered.len(), 2);
457    }
458
459    #[test]
460    fn update_filter_resets_selection_to_zero() {
461        let mut p = SessionPicker::open(vec![meta("one", 1), meta("two", 1), meta("three", 1)]);
462        p.selected = 2;
463        p.query = "on".to_string();
464        p.update_filter();
465        assert_eq!(p.selected, 0, "selection must reset when filter changes");
466    }
467
468    #[test]
469    fn down_and_up_stay_within_filtered_bounds() {
470        let mut p = SessionPicker::open(vec![meta("a", 1), meta("b", 1)]);
471        p.down();
472        assert_eq!(p.selected, 1);
473        p.down();
474        assert_eq!(p.selected, 1, "down at end stays put");
475        p.up();
476        assert_eq!(p.selected, 0);
477        p.up();
478        assert_eq!(p.selected, 0, "up at top stays put");
479    }
480
481    #[test]
482    fn chosen_returns_session_at_selected() {
483        let sessions = vec![meta("first", 1), meta("second", 1)];
484        let mut p = SessionPicker::open(sessions);
485        p.down();
486        let id = p.chosen_id().expect("selection should exist");
487        assert_eq!(id.as_str(), "id-second");
488    }
489
490    #[test]
491    fn chosen_returns_none_when_filter_empty() {
492        let mut p = SessionPicker::open(vec![meta("alpha", 1)]);
493        p.query = "xyz".to_string();
494        p.update_filter();
495        assert!(p.chosen_id().is_none());
496    }
497
498    #[test]
499    fn build_menu_payload_shows_hint_when_filter_matches_nothing() {
500        // Regression: typing a query that excludes every session used to
501        // render a blank menu (items.len() == 0), so the user couldn't
502        // tell whether /resume hung, the filter was active, or what.
503        // Now we surface a single non-interactive hint row so the empty
504        // state is visible.
505        let mut p = SessionPicker::open(vec![meta("alpha", 1), meta("beta", 1)]);
506        p.query = "zz".to_string();
507        p.update_filter();
508        assert_eq!(p.filtered.len(), 0);
509        let payload = build_menu_payload(&p);
510        assert_eq!(
511            payload.items.len(),
512            1,
513            "empty filter should produce a single hint row, got: {:?}",
514            payload.items
515        );
516        let (label, _) = &payload.items[0];
517        assert!(
518            label.contains("zz"),
519            "hint should echo the user's query so they know which filter is active: {}",
520            label
521        );
522    }
523
524    #[test]
525    fn build_menu_payload_shows_hint_when_no_sessions_at_all() {
526        let p = SessionPicker::open(vec![]);
527        let payload = build_menu_payload(&p);
528        assert_eq!(payload.items.len(), 1, "must show some empty-state hint");
529    }
530}