Skip to main content

rpi_cli/
resume_picker.rs

1//! Startup session picker used by `-r` / `--resume`.
2
3use std::path::Path;
4use std::sync::Arc;
5use std::time::{Duration, SystemTime, UNIX_EPOCH};
6
7use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEventKind};
8use rpi_tui::{
9    Container, ProcessTerminal, SelectItem, SelectList, SelectListLayoutOptions, Spacer, Text,
10    TuiAltScreen, TUI,
11};
12
13/// Select a project session before the main harness is built.
14///
15/// Returns `Ok(None)` when the user cancels with Esc or Ctrl+C.
16pub async fn select(cwd: &Path) -> Result<Option<String>, String> {
17    let cwd_text = cwd.to_string_lossy().into_owned();
18    let metadata = crate::session::list_session_metadata(&cwd_text)
19        .await
20        .map_err(|e| e.to_string())?;
21    if metadata.is_empty() {
22        return Err(format!(
23            "no sessions found in {}",
24            crate::session::default_session_dir(cwd).display()
25        ));
26    }
27
28    // Each summary opens + reads a (bounded) slice of the session file. That is
29    // blocking file I/O over every saved session, so keep it off the async
30    // runtime the TUI will later run on.
31    let metas_for_summary = metadata.clone();
32    let summaries = tokio::task::spawn_blocking(move || {
33        metas_for_summary
34            .iter()
35            .map(|meta| crate::session::summarize_session_file(Path::new(&meta.path)))
36            .collect::<Vec<_>>()
37    })
38    .await
39    .map_err(|e| format!("session summary task failed: {e}"))?;
40
41    let now_ms = SystemTime::now()
42        .duration_since(UNIX_EPOCH)
43        .unwrap_or_default()
44        .as_millis()
45        .min(i64::MAX as u128) as i64;
46    let mut items: Vec<SelectItem> = Vec::new();
47    for (meta, summary) in metadata.iter().zip(summaries.iter()) {
48        // A header-only session has nothing to restore; offering it is how a
49        // resume lands on an empty transcript. Skip them so every listed row is
50        // resumable.
51        if summary.empty {
52            continue;
53        }
54        let file_name = Path::new(&meta.path)
55            .file_name()
56            .and_then(|name| name.to_str())
57            .unwrap_or(&meta.id);
58        let label = crate::session::session_display_label(summary);
59        let short_id = crate::session::short_session_id(&meta.id);
60        let description = format!(
61            "{} · {} · {}",
62            format_modified_age(now_ms, meta.modified_at),
63            crate::session::format_session_bytes(summary.bytes),
64            short_id,
65        );
66        items.push(
67            SelectItem::new(&meta.id, &label)
68                .with_description(&description)
69                .with_search_text(&format!("{label} {short_id} {file_name}")),
70        );
71    }
72    if items.is_empty() {
73        return Err(format!(
74            "no resumable sessions with messages in {}",
75            crate::session::default_session_dir(cwd).display()
76        ));
77    }
78
79    // A wide primary column so the name/preview label is not clipped to the
80    // 32-column default (which showed only the timestamp prefix of the file
81    // name and made sessions indistinguishable).
82    let mut list = SelectList::new(items, 12);
83    list.set_layout(SelectListLayoutOptions {
84        min_primary_column_width: Some(56),
85        max_primary_column_width: Some(72),
86        truncate_primary: None,
87    });
88    let list = Arc::new(list);
89    let search = Arc::new(Text::new("  Filter: ", 0, 0));
90    let root = Arc::new(Container::new());
91    root.add_child(Arc::new(Text::new("Resume session", 1, 1)));
92    root.add_child(Arc::new(Text::new(
93        "  Type to filter, Enter to resume, Esc to cancel",
94        0,
95        0,
96    )));
97    root.add_child(Arc::new(Spacer::new(1)));
98    root.add_child(search.clone());
99    root.add_child(Arc::new(Spacer::new(1)));
100    root.add_child(list.clone());
101
102    let tui = Arc::new(TuiAltScreen::new(
103        Box::new(ProcessTerminal::new()),
104        false,
105        None,
106    ));
107    tui.set_layout_root(Some(root));
108    // Short-lived selector, but the same native-pi frame throttle applies: its
109    // keystroke bursts coalesce instead of repainting per keypress.
110    tui.start_render_scheduler();
111    tui.start_readerless();
112
113    let input_tui = tui.clone();
114    let input_list = list.clone();
115    let input_search = search.clone();
116    let task = tokio::task::spawn_blocking(move || -> Result<Option<String>, String> {
117        let mut query = String::new();
118        loop {
119            match crossterm::event::poll(Duration::from_millis(100)) {
120                Ok(true) => {}
121                Ok(false) => continue,
122                Err(e) => return Err(format!("could not read terminal input: {e}")),
123            }
124            let event = crossterm::event::read()
125                .map_err(|e| format!("could not read terminal input: {e}"))?;
126            match event {
127                Event::Resize(_, _) => {
128                    input_tui.refresh_size();
129                }
130                Event::Mouse(mouse) => {
131                    let code = match mouse.kind {
132                        MouseEventKind::ScrollUp => Some(KeyCode::Up),
133                        MouseEventKind::ScrollDown => Some(KeyCode::Down),
134                        _ => None,
135                    };
136                    if let Some(code) = code {
137                        input_list.handle_key(KeyEvent::new(code, KeyModifiers::NONE));
138                        input_tui.request_render(false);
139                    }
140                }
141                Event::Key(key) if key.kind != KeyEventKind::Release => {
142                    if key.modifiers.contains(KeyModifiers::CONTROL)
143                        && key.code == KeyCode::Char('c')
144                    {
145                        return Ok(None);
146                    }
147                    match key.code {
148                        KeyCode::Enter => {
149                            return Ok(input_list.get_selected_item().map(|item| item.value));
150                        }
151                        KeyCode::Esc if query.is_empty() => return Ok(None),
152                        KeyCode::Esc => {
153                            query.clear();
154                            input_list.set_filter("");
155                        }
156                        KeyCode::Backspace => {
157                            query.pop();
158                            input_list.set_filter(&query);
159                        }
160                        KeyCode::Char(ch)
161                            if !key
162                                .modifiers
163                                .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
164                        {
165                            query.push(ch);
166                            input_list.set_filter(&query);
167                        }
168                        _ => input_list.handle_key(key),
169                    }
170                    input_search.set_text(format!("  Filter: {query}"));
171                    input_tui.request_render(false);
172                }
173                _ => {}
174            }
175        }
176    });
177
178    let result = task
179        .await
180        .map_err(|e| format!("session picker task failed: {e}"));
181    tui.stop(Default::default());
182    result?
183}
184
185fn format_modified_age(now_ms: i64, modified_ms: i64) -> String {
186    let seconds = now_ms.saturating_sub(modified_ms).max(0) / 1000;
187    match seconds {
188        0..=59 => "just now".to_string(),
189        60..=3_599 => format!("{}m ago", seconds / 60),
190        3_600..=86_399 => format!("{}h ago", seconds / 3_600),
191        _ => format!("{}d ago", seconds / 86_400),
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn modified_age_is_compact_and_stable() {
201        let now = 10 * 86_400_000;
202        assert_eq!(format_modified_age(now, now - 20_000), "just now");
203        assert_eq!(format_modified_age(now, now - 5 * 60_000), "5m ago");
204        assert_eq!(format_modified_age(now, now - 3 * 3_600_000), "3h ago");
205        assert_eq!(format_modified_age(now, now - 2 * 86_400_000), "2d ago");
206    }
207
208    #[test]
209    fn label_falls_back_from_name_to_preview_to_marker() {
210        use crate::session::{session_display_label, SessionSummary};
211        let named = SessionSummary {
212            name: Some("fix resume picker".into()),
213            preview: Some("a different prompt".into()),
214            ..Default::default()
215        };
216        assert_eq!(session_display_label(&named), "fix resume picker");
217
218        let preview = SessionSummary {
219            name: Some("   ".into()),
220            preview: Some("读取下当前 rpi -r 命令".into()),
221            ..Default::default()
222        };
223        assert_eq!(session_display_label(&preview), "读取下当前 rpi -r 命令");
224
225        let bare = SessionSummary::default();
226        assert_eq!(session_display_label(&bare), "(no messages)");
227    }
228
229    #[test]
230    fn byte_and_id_formatting_is_compact() {
231        use crate::session::{format_session_bytes, short_session_id};
232        assert_eq!(format_session_bytes(512), "512B");
233        assert_eq!(format_session_bytes(34 * 1024), "34KB");
234        assert_eq!(format_session_bytes(1_258_291), "1.2MB");
235        assert_eq!(
236            short_session_id("01a0d919-6742-70db-a206-9f5ed4cf665a"),
237            "01a0d919"
238        );
239        assert_eq!(short_session_id("abc"), "abc");
240    }
241}