use std::collections::HashSet; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use chrono::DateTime; use chrono::Utc; use codex_core::ConversationItem; use codex_core::ConversationsPage; use codex_core::Cursor; use codex_core::INTERACTIVE_SESSION_SOURCES; use codex_core::RolloutRecorder; use codex_protocol::items::TurnItem; use color_eyre::eyre::Result; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use ratatui::layout::Constraint; use ratatui::layout::Layout; use ratatui::layout::Rect; use ratatui::style::Stylize as _; use ratatui::text::Line; use ratatui::text::Span; use tokio::sync::mpsc; use tokio_stream::StreamExt; use tokio_stream::wrappers::UnboundedReceiverStream; use unicode_width::UnicodeWidthStr; use crate::key_hint; use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; use crate::tui::Tui; use crate::tui::TuiEvent; use codex_protocol::models::ResponseItem; const PAGE_SIZE: usize = 25; const LOAD_NEAR_THRESHOLD: usize = 5; #[derive(Debug, Clone)] pub enum ResumeSelection { StartFresh, Resume(PathBuf), Exit, } #[derive(Clone)] struct PageLoadRequest { codex_home: PathBuf, cursor: Option, request_token: usize, search_token: Option, default_provider: String, } type PageLoader = Arc; enum BackgroundEvent { PageLoaded { request_token: usize, search_token: Option, page: std::io::Result, }, } /// Interactive session picker that lists recorded rollout files with simple /// search and pagination. Shows the first user input as the preview, relative /// time (e.g., "5 seconds ago"), and the absolute path. pub async fn run_resume_picker( tui: &mut Tui, codex_home: &Path, default_provider: &str, ) -> Result { let alt = AltScreenGuard::enter(tui); let (bg_tx, bg_rx) = mpsc::unbounded_channel(); let default_provider = default_provider.to_string(); let loader_tx = bg_tx.clone(); let page_loader: PageLoader = Arc::new(move |request: PageLoadRequest| { let tx = loader_tx.clone(); tokio::spawn(async move { let provider_filter = vec![request.default_provider.clone()]; let page = RolloutRecorder::list_conversations( &request.codex_home, PAGE_SIZE, request.cursor.as_ref(), INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), request.default_provider.as_str(), ) .await; let _ = tx.send(BackgroundEvent::PageLoaded { request_token: request.request_token, search_token: request.search_token, page, }); }); }); let mut state = PickerState::new( codex_home.to_path_buf(), alt.tui.frame_requester(), page_loader, default_provider.clone(), ); state.load_initial_page().await?; state.request_frame(); let mut tui_events = alt.tui.event_stream().fuse(); let mut background_events = UnboundedReceiverStream::new(bg_rx).fuse(); loop { tokio::select! { Some(ev) = tui_events.next() => { match ev { TuiEvent::Key(key) => { if matches!(key.kind, KeyEventKind::Release) { continue; } if let Some(sel) = state.handle_key(key).await? { return Ok(sel); } } TuiEvent::Draw => { if let Ok(size) = alt.tui.terminal.size() { let list_height = size.height.saturating_sub(4) as usize; state.update_view_rows(list_height); state.ensure_minimum_rows_for_view(list_height); } draw_picker(alt.tui, &state)?; } _ => {} } } Some(event) = background_events.next() => { state.handle_background_event(event)?; } else => break, } } // Fallback – treat as cancel/new Ok(ResumeSelection::StartFresh) } /// RAII guard that ensures we leave the alt-screen on scope exit. struct AltScreenGuard<'a> { tui: &'a mut Tui, } impl<'a> AltScreenGuard<'a> { fn enter(tui: &'a mut Tui) -> Self { let _ = tui.enter_alt_screen(); Self { tui } } } impl Drop for AltScreenGuard<'_> { fn drop(&mut self) { let _ = self.tui.leave_alt_screen(); } } struct PickerState { codex_home: PathBuf, requester: FrameRequester, pagination: PaginationState, all_rows: Vec, filtered_rows: Vec, seen_paths: HashSet, selected: usize, scroll_top: usize, query: String, search_state: SearchState, next_request_token: usize, next_search_token: usize, page_loader: PageLoader, view_rows: Option, default_provider: String, } struct PaginationState { next_cursor: Option, num_scanned_files: usize, reached_scan_cap: bool, loading: LoadingState, } #[derive(Clone, Copy, Debug)] enum LoadingState { Idle, Pending(PendingLoad), } #[derive(Clone, Copy, Debug)] struct PendingLoad { request_token: usize, search_token: Option, } #[derive(Clone, Copy, Debug)] enum SearchState { Idle, Active { token: usize }, } enum LoadTrigger { Scroll, Search { token: usize }, } impl LoadingState { fn is_pending(&self) -> bool { matches!(self, LoadingState::Pending(_)) } } impl SearchState { fn active_token(&self) -> Option { match self { SearchState::Idle => None, SearchState::Active { token } => Some(*token), } } fn is_active(&self) -> bool { self.active_token().is_some() } } #[derive(Clone)] struct Row { path: PathBuf, preview: String, created_at: Option>, updated_at: Option>, } impl PickerState { fn new( codex_home: PathBuf, requester: FrameRequester, page_loader: PageLoader, default_provider: String, ) -> Self { Self { codex_home, requester, pagination: PaginationState { next_cursor: None, num_scanned_files: 0, reached_scan_cap: false, loading: LoadingState::Idle, }, all_rows: Vec::new(), filtered_rows: Vec::new(), seen_paths: HashSet::new(), selected: 0, scroll_top: 0, query: String::new(), search_state: SearchState::Idle, next_request_token: 0, next_search_token: 0, page_loader, view_rows: None, default_provider, } } fn request_frame(&self) { self.requester.schedule_frame(); } async fn handle_key(&mut self, key: KeyEvent) -> Result> { match key.code { KeyCode::Esc => return Ok(Some(ResumeSelection::StartFresh)), KeyCode::Char('c') if key .modifiers .contains(crossterm::event::KeyModifiers::CONTROL) => { return Ok(Some(ResumeSelection::Exit)); } KeyCode::Enter => { if let Some(row) = self.filtered_rows.get(self.selected) { return Ok(Some(ResumeSelection::Resume(row.path.clone()))); } } KeyCode::Up => { if self.selected > 0 { self.selected -= 1; self.ensure_selected_visible(); } self.request_frame(); } KeyCode::Down => { if self.selected + 1 < self.filtered_rows.len() { self.selected += 1; self.ensure_selected_visible(); } self.maybe_load_more_for_scroll(); self.request_frame(); } KeyCode::PageUp => { let step = self.view_rows.unwrap_or(10).max(1); if self.selected > 0 { self.selected = self.selected.saturating_sub(step); self.ensure_selected_visible(); self.request_frame(); } } KeyCode::PageDown => { if !self.filtered_rows.is_empty() { let step = self.view_rows.unwrap_or(10).max(1); let max_index = self.filtered_rows.len().saturating_sub(1); self.selected = (self.selected + step).min(max_index); self.ensure_selected_visible(); self.maybe_load_more_for_scroll(); self.request_frame(); } } KeyCode::Backspace => { let mut new_query = self.query.clone(); new_query.pop(); self.set_query(new_query); } KeyCode::Char(c) => { // basic text input for search if !key .modifiers .contains(crossterm::event::KeyModifiers::CONTROL) && !key.modifiers.contains(crossterm::event::KeyModifiers::ALT) { let mut new_query = self.query.clone(); new_query.push(c); self.set_query(new_query); } } _ => {} } Ok(None) } async fn load_initial_page(&mut self) -> Result<()> { let provider_filter = vec![self.default_provider.clone()]; let page = RolloutRecorder::list_conversations( &self.codex_home, PAGE_SIZE, None, INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), self.default_provider.as_str(), ) .await?; self.reset_pagination(); self.all_rows.clear(); self.filtered_rows.clear(); self.seen_paths.clear(); self.search_state = SearchState::Idle; self.selected = 0; self.ingest_page(page); Ok(()) } fn handle_background_event(&mut self, event: BackgroundEvent) -> Result<()> { match event { BackgroundEvent::PageLoaded { request_token, search_token, page, } => { let pending = match self.pagination.loading { LoadingState::Pending(pending) => pending, LoadingState::Idle => return Ok(()), }; if pending.request_token != request_token { return Ok(()); } self.pagination.loading = LoadingState::Idle; let page = page.map_err(color_eyre::Report::from)?; self.ingest_page(page); let completed_token = pending.search_token.or(search_token); self.continue_search_if_token_matches(completed_token); } } Ok(()) } fn reset_pagination(&mut self) { self.pagination.next_cursor = None; self.pagination.num_scanned_files = 0; self.pagination.reached_scan_cap = false; self.pagination.loading = LoadingState::Idle; } fn ingest_page(&mut self, page: ConversationsPage) { if let Some(cursor) = page.next_cursor.clone() { self.pagination.next_cursor = Some(cursor); } else { self.pagination.next_cursor = None; } self.pagination.num_scanned_files = self .pagination .num_scanned_files .saturating_add(page.num_scanned_files); if page.reached_scan_cap { self.pagination.reached_scan_cap = true; } let rows = rows_from_items(page.items); for row in rows { if self.seen_paths.insert(row.path.clone()) { self.all_rows.push(row); } } self.apply_filter(); } fn apply_filter(&mut self) { if self.query.is_empty() { self.filtered_rows = self.all_rows.clone(); } else { let q = self.query.to_lowercase(); self.filtered_rows = self .all_rows .iter() .filter(|r| r.preview.to_lowercase().contains(&q)) .cloned() .collect(); } if self.selected >= self.filtered_rows.len() { self.selected = self.filtered_rows.len().saturating_sub(1); } if self.filtered_rows.is_empty() { self.scroll_top = 0; } self.ensure_selected_visible(); self.request_frame(); } fn set_query(&mut self, new_query: String) { if self.query == new_query { return; } self.query = new_query; self.selected = 0; self.apply_filter(); if self.query.is_empty() { self.search_state = SearchState::Idle; return; } if !self.filtered_rows.is_empty() { self.search_state = SearchState::Idle; return; } if self.pagination.reached_scan_cap || self.pagination.next_cursor.is_none() { self.search_state = SearchState::Idle; return; } let token = self.allocate_search_token(); self.search_state = SearchState::Active { token }; self.load_more_if_needed(LoadTrigger::Search { token }); } fn continue_search_if_needed(&mut self) { let Some(token) = self.search_state.active_token() else { return; }; if !self.filtered_rows.is_empty() { self.search_state = SearchState::Idle; return; } if self.pagination.reached_scan_cap || self.pagination.next_cursor.is_none() { self.search_state = SearchState::Idle; return; } self.load_more_if_needed(LoadTrigger::Search { token }); } fn continue_search_if_token_matches(&mut self, completed_token: Option) { let Some(active) = self.search_state.active_token() else { return; }; if let Some(token) = completed_token && token != active { return; } self.continue_search_if_needed(); } fn ensure_selected_visible(&mut self) { if self.filtered_rows.is_empty() { self.scroll_top = 0; return; } let capacity = self.view_rows.unwrap_or(self.filtered_rows.len()).max(1); if self.selected < self.scroll_top { self.scroll_top = self.selected; } else { let last_visible = self.scroll_top.saturating_add(capacity - 1); if self.selected > last_visible { self.scroll_top = self.selected.saturating_sub(capacity - 1); } } let max_start = self.filtered_rows.len().saturating_sub(capacity); if self.scroll_top > max_start { self.scroll_top = max_start; } } fn ensure_minimum_rows_for_view(&mut self, minimum_rows: usize) { if minimum_rows == 0 { return; } if self.filtered_rows.len() >= minimum_rows { return; } if self.pagination.loading.is_pending() || self.pagination.next_cursor.is_none() { return; } if let Some(token) = self.search_state.active_token() { self.load_more_if_needed(LoadTrigger::Search { token }); } else { self.load_more_if_needed(LoadTrigger::Scroll); } } fn update_view_rows(&mut self, rows: usize) { self.view_rows = if rows == 0 { None } else { Some(rows) }; self.ensure_selected_visible(); } fn maybe_load_more_for_scroll(&mut self) { if self.pagination.loading.is_pending() { return; } if self.pagination.next_cursor.is_none() { return; } if self.filtered_rows.is_empty() { return; } let remaining = self.filtered_rows.len().saturating_sub(self.selected + 1); if remaining <= LOAD_NEAR_THRESHOLD { self.load_more_if_needed(LoadTrigger::Scroll); } } fn load_more_if_needed(&mut self, trigger: LoadTrigger) { if self.pagination.loading.is_pending() { return; } let Some(cursor) = self.pagination.next_cursor.clone() else { return; }; let request_token = self.allocate_request_token(); let search_token = match trigger { LoadTrigger::Scroll => None, LoadTrigger::Search { token } => Some(token), }; self.pagination.loading = LoadingState::Pending(PendingLoad { request_token, search_token, }); self.request_frame(); (self.page_loader)(PageLoadRequest { codex_home: self.codex_home.clone(), cursor: Some(cursor), request_token, search_token, default_provider: self.default_provider.clone(), }); } fn allocate_request_token(&mut self) -> usize { let token = self.next_request_token; self.next_request_token = self.next_request_token.wrapping_add(1); token } fn allocate_search_token(&mut self) -> usize { let token = self.next_search_token; self.next_search_token = self.next_search_token.wrapping_add(1); token } } fn rows_from_items(items: Vec) -> Vec { items.into_iter().map(|item| head_to_row(&item)).collect() } fn head_to_row(item: &ConversationItem) -> Row { let created_at = item .created_at .as_deref() .and_then(parse_timestamp_str) .or_else(|| item.head.first().and_then(extract_timestamp)); let updated_at = item .updated_at .as_deref() .and_then(parse_timestamp_str) .or(created_at); let preview = preview_from_head(&item.head) .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .unwrap_or_else(|| String::from("(no message yet)")); Row { path: item.path.clone(), preview, created_at, updated_at, } } fn parse_timestamp_str(ts: &str) -> Option> { chrono::DateTime::parse_from_rfc3339(ts) .map(|dt| dt.with_timezone(&Utc)) .ok() } fn extract_timestamp(value: &serde_json::Value) -> Option> { value .get("timestamp") .and_then(|v| v.as_str()) .and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok()) .map(|dt| dt.with_timezone(&Utc)) } fn preview_from_head(head: &[serde_json::Value]) -> Option { head.iter() .filter_map(|value| serde_json::from_value::(value.clone()).ok()) .find_map(|item| match codex_core::parse_turn_item(&item) { Some(TurnItem::UserMessage(user)) => Some(user.message()), _ => None, }) } fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> { // Render full-screen overlay let height = tui.terminal.size()?.height; tui.draw(height, |frame| { let area = frame.area(); let [header, search, columns, list, hint] = Layout::vertical([ Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(area.height.saturating_sub(4)), Constraint::Length(1), ]) .areas(area); // Header frame.render_widget_ref( Line::from(vec!["Resume a previous session".bold().cyan()]), header, ); // Search line let q = if state.query.is_empty() { "Type to search".dim().to_string() } else { format!("Search: {}", state.query) }; frame.render_widget_ref(Line::from(q), search); let metrics = calculate_column_metrics(&state.filtered_rows); // Column headers and list render_column_headers(frame, columns, &metrics); render_list(frame, list, state, &metrics); // Hint line let hint_line: Line = vec![ key_hint::plain(KeyCode::Enter).into(), " to resume ".dim(), " ".dim(), key_hint::plain(KeyCode::Esc).into(), " to start new ".dim(), " ".dim(), key_hint::ctrl(KeyCode::Char('c')).into(), " to quit ".dim(), " ".dim(), key_hint::plain(KeyCode::Up).into(), "/".dim(), key_hint::plain(KeyCode::Down).into(), " to browse".dim(), ] .into(); frame.render_widget_ref(hint_line, hint); }) } fn render_list( frame: &mut crate::custom_terminal::Frame, area: Rect, state: &PickerState, metrics: &ColumnMetrics, ) { if area.height == 0 { return; } let rows = &state.filtered_rows; if rows.is_empty() { let message = render_empty_state_line(state); frame.render_widget_ref(message, area); return; } let capacity = area.height as usize; let start = state.scroll_top.min(rows.len().saturating_sub(1)); let end = rows.len().min(start + capacity); let labels = &metrics.labels; let mut y = area.y; let max_created_width = metrics.max_created_width; let max_updated_width = metrics.max_updated_width; for (idx, (row, (created_label, updated_label))) in rows[start..end] .iter() .zip(labels[start..end].iter()) .enumerate() { let is_sel = start + idx == state.selected; let marker = if is_sel { "> ".bold() } else { " ".into() }; let marker_width = 2usize; let created_span = if max_created_width == 0 { None } else { Some(Span::from(format!("{created_label: 0 { preview_width = preview_width.saturating_sub(max_created_width + 2); } if max_updated_width > 0 { preview_width = preview_width.saturating_sub(max_updated_width + 2); } let add_leading_gap = max_created_width == 0 && max_updated_width == 0; if add_leading_gap { preview_width = preview_width.saturating_sub(2); } let preview = truncate_text(&row.preview, preview_width); let mut spans: Vec = vec![marker]; if let Some(created) = created_span { spans.push(created); spans.push(" ".into()); } if let Some(updated) = updated_span { spans.push(updated); spans.push(" ".into()); } if add_leading_gap { spans.push(" ".into()); } spans.push(preview.into()); let line: Line = spans.into(); let rect = Rect::new(area.x, y, area.width, 1); frame.render_widget_ref(line, rect); y = y.saturating_add(1); } if state.pagination.loading.is_pending() && y < area.y.saturating_add(area.height) { let loading_line: Line = vec![" ".into(), "Loading older sessions…".italic().dim()].into(); let rect = Rect::new(area.x, y, area.width, 1); frame.render_widget_ref(loading_line, rect); } } fn render_empty_state_line(state: &PickerState) -> Line<'static> { if !state.query.is_empty() { if state.search_state.is_active() || (state.pagination.loading.is_pending() && state.pagination.next_cursor.is_some()) { return vec!["Searching…".italic().dim()].into(); } if state.pagination.reached_scan_cap { let msg = format!( "Search scanned first {} sessions; more may exist", state.pagination.num_scanned_files ); return vec![Span::from(msg).italic().dim()].into(); } return vec!["No results for your search".italic().dim()].into(); } if state.all_rows.is_empty() && state.pagination.num_scanned_files == 0 { return vec!["No sessions yet".italic().dim()].into(); } if state.pagination.loading.is_pending() { return vec!["Loading older sessions…".italic().dim()].into(); } vec!["No sessions yet".italic().dim()].into() } fn human_time_ago(ts: DateTime) -> String { let now = Utc::now(); let delta = now - ts; let secs = delta.num_seconds(); if secs < 60 { let n = secs.max(0); if n == 1 { format!("{n} second ago") } else { format!("{n} seconds ago") } } else if secs < 60 * 60 { let m = secs / 60; if m == 1 { format!("{m} minute ago") } else { format!("{m} minutes ago") } } else if secs < 60 * 60 * 24 { let h = secs / 3600; if h == 1 { format!("{h} hour ago") } else { format!("{h} hours ago") } } else { let d = secs / (60 * 60 * 24); if d == 1 { format!("{d} day ago") } else { format!("{d} days ago") } } } fn format_created_label(row: &Row) -> String { row.created_at .map(human_time_ago) .unwrap_or_else(|| "-".to_string()) } fn format_updated_label(row: &Row) -> String { match (row.updated_at, row.created_at) { (Some(updated), _) => human_time_ago(updated), (None, Some(created)) => human_time_ago(created), (None, None) => "-".to_string(), } } fn render_column_headers( frame: &mut crate::custom_terminal::Frame, area: Rect, metrics: &ColumnMetrics, ) { if area.height == 0 { return; } let mut spans: Vec = vec![" ".into()]; if metrics.max_created_width > 0 { let label = format!( "{text: 0 { let label = format!( "{text:, } fn calculate_column_metrics(rows: &[Row]) -> ColumnMetrics { let mut labels: Vec<(String, String)> = Vec::with_capacity(rows.len()); let mut max_created_width = UnicodeWidthStr::width("Created"); let mut max_updated_width = UnicodeWidthStr::width("Updated"); for row in rows { let created = format_created_label(row); let updated = format_updated_label(row); max_created_width = max_created_width.max(UnicodeWidthStr::width(created.as_str())); max_updated_width = max_updated_width.max(UnicodeWidthStr::width(updated.as_str())); labels.push((created, updated)); } ColumnMetrics { max_created_width, max_updated_width, labels, } } #[cfg(test)] mod tests { use super::*; use chrono::Duration; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; use insta::assert_snapshot; use serde_json::json; use std::future::Future; use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; fn head_with_ts_and_user_text(ts: &str, texts: &[&str]) -> Vec { vec![ json!({ "timestamp": ts }), json!({ "type": "message", "role": "user", "content": texts .iter() .map(|t| json!({ "type": "input_text", "text": *t })) .collect::>() }), ] } fn make_item(path: &str, ts: &str, preview: &str) -> ConversationItem { ConversationItem { path: PathBuf::from(path), head: head_with_ts_and_user_text(ts, &[preview]), tail: Vec::new(), created_at: Some(ts.to_string()), updated_at: Some(ts.to_string()), } } fn cursor_from_str(repr: &str) -> Cursor { serde_json::from_str::(&format!("\"{repr}\"")) .expect("cursor format should deserialize") } fn page( items: Vec, next_cursor: Option, num_scanned_files: usize, reached_scan_cap: bool, ) -> ConversationsPage { ConversationsPage { items, next_cursor, num_scanned_files, reached_scan_cap, } } fn block_on_future, T>(future: F) -> T { tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap() .block_on(future) } #[test] fn preview_uses_first_message_input_text() { let head = vec![ json!({ "timestamp": "2025-01-01T00:00:00Z" }), json!({ "type": "message", "role": "user", "content": [ { "type": "input_text", "text": "# AGENTS.md instructions for project\n\n\nhi\n" }, ] }), json!({ "type": "message", "role": "user", "content": [ { "type": "input_text", "text": "..." }, ] }), json!({ "type": "message", "role": "user", "content": [ { "type": "input_text", "text": "real question" }, { "type": "input_image", "image_url": "ignored" } ] }), json!({ "type": "message", "role": "user", "content": [ { "type": "input_text", "text": "later text" } ] }), ]; let preview = preview_from_head(&head); assert_eq!(preview.as_deref(), Some("real question")); } #[test] fn rows_from_items_preserves_backend_order() { // Construct two items with different timestamps and real user text. let a = ConversationItem { path: PathBuf::from("/tmp/a.jsonl"), head: head_with_ts_and_user_text("2025-01-01T00:00:00Z", &["A"]), tail: Vec::new(), created_at: Some("2025-01-01T00:00:00Z".into()), updated_at: Some("2025-01-01T00:00:00Z".into()), }; let b = ConversationItem { path: PathBuf::from("/tmp/b.jsonl"), head: head_with_ts_and_user_text("2025-01-02T00:00:00Z", &["B"]), tail: Vec::new(), created_at: Some("2025-01-02T00:00:00Z".into()), updated_at: Some("2025-01-02T00:00:00Z".into()), }; let rows = rows_from_items(vec![a, b]); assert_eq!(rows.len(), 2); // Preserve the given order even if timestamps differ; backend already provides newest-first. assert!(rows[0].preview.contains('A')); assert!(rows[1].preview.contains('B')); } #[test] fn row_uses_tail_timestamp_for_updated_at() { let head = head_with_ts_and_user_text("2025-01-01T00:00:00Z", &["Hello"]); let tail = vec![json!({ "timestamp": "2025-01-01T01:00:00Z", "type": "message", "role": "assistant", "content": [ { "type": "output_text", "text": "hi", } ], })]; let item = ConversationItem { path: PathBuf::from("/tmp/a.jsonl"), head, tail, created_at: Some("2025-01-01T00:00:00Z".into()), updated_at: Some("2025-01-01T01:00:00Z".into()), }; let row = head_to_row(&item); let expected_created = chrono::DateTime::parse_from_rfc3339("2025-01-01T00:00:00Z") .unwrap() .with_timezone(&Utc); let expected_updated = chrono::DateTime::parse_from_rfc3339("2025-01-01T01:00:00Z") .unwrap() .with_timezone(&Utc); assert_eq!(row.created_at, Some(expected_created)); assert_eq!(row.updated_at, Some(expected_updated)); } #[test] fn resume_table_snapshot() { use crate::custom_terminal::Terminal; use crate::test_backend::VT100Backend; use ratatui::layout::Constraint; use ratatui::layout::Layout; let loader: PageLoader = Arc::new(|_| {}); let mut state = PickerState::new( PathBuf::from("/tmp"), FrameRequester::test_dummy(), loader, String::from("openai"), ); let now = Utc::now(); let rows = vec![ Row { path: PathBuf::from("/tmp/a.jsonl"), preview: String::from("Fix resume picker timestamps"), created_at: Some(now - Duration::minutes(16)), updated_at: Some(now - Duration::seconds(42)), }, Row { path: PathBuf::from("/tmp/b.jsonl"), preview: String::from("Investigate lazy pagination cap"), created_at: Some(now - Duration::hours(1)), updated_at: Some(now - Duration::minutes(35)), }, Row { path: PathBuf::from("/tmp/c.jsonl"), preview: String::from("Explain the codebase"), created_at: Some(now - Duration::hours(2)), updated_at: Some(now - Duration::hours(2)), }, ]; state.all_rows = rows.clone(); state.filtered_rows = rows; state.view_rows = Some(3); state.selected = 1; state.scroll_top = 0; state.update_view_rows(3); let metrics = calculate_column_metrics(&state.filtered_rows); let width: u16 = 80; let height: u16 = 6; let backend = VT100Backend::new(width, height); let mut terminal = Terminal::with_options(backend).expect("terminal"); terminal.set_viewport_area(Rect::new(0, 0, width, height)); { let mut frame = terminal.get_frame(); let area = frame.area(); let segments = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(area); render_column_headers(&mut frame, segments[0], &metrics); render_list(&mut frame, segments[1], &state, &metrics); } terminal.flush().expect("flush"); let snapshot = terminal.backend().to_string(); assert_snapshot!("resume_picker_table", snapshot); } #[test] fn pageless_scrolling_deduplicates_and_keeps_order() { let loader: PageLoader = Arc::new(|_| {}); let mut state = PickerState::new( PathBuf::from("/tmp"), FrameRequester::test_dummy(), loader, String::from("openai"), ); state.reset_pagination(); state.ingest_page(page( vec![ make_item("/tmp/a.jsonl", "2025-01-03T00:00:00Z", "third"), make_item("/tmp/b.jsonl", "2025-01-02T00:00:00Z", "second"), ], Some(cursor_from_str( "2025-01-02T00-00-00|00000000-0000-0000-0000-000000000000", )), 2, false, )); state.ingest_page(page( vec![ make_item("/tmp/a.jsonl", "2025-01-03T00:00:00Z", "duplicate"), make_item("/tmp/c.jsonl", "2025-01-01T00:00:00Z", "first"), ], Some(cursor_from_str( "2025-01-01T00-00-00|00000000-0000-0000-0000-000000000001", )), 2, false, )); state.ingest_page(page( vec![make_item( "/tmp/d.jsonl", "2024-12-31T23:00:00Z", "very old", )], None, 1, false, )); let previews: Vec<_> = state .filtered_rows .iter() .map(|row| row.preview.as_str()) .collect(); assert_eq!(previews, vec!["third", "second", "first", "very old"]); let unique_paths = state .filtered_rows .iter() .map(|row| row.path.clone()) .collect::>(); assert_eq!(unique_paths.len(), 4); } #[test] fn ensure_minimum_rows_prefetches_when_underfilled() { let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); let request_sink = recorded_requests.clone(); let loader: PageLoader = Arc::new(move |req: PageLoadRequest| { request_sink.lock().unwrap().push(req); }); let mut state = PickerState::new( PathBuf::from("/tmp"), FrameRequester::test_dummy(), loader, String::from("openai"), ); state.reset_pagination(); state.ingest_page(page( vec![ make_item("/tmp/a.jsonl", "2025-01-01T00:00:00Z", "one"), make_item("/tmp/b.jsonl", "2025-01-02T00:00:00Z", "two"), ], Some(cursor_from_str( "2025-01-03T00-00-00|00000000-0000-0000-0000-000000000000", )), 2, false, )); assert!(recorded_requests.lock().unwrap().is_empty()); state.ensure_minimum_rows_for_view(10); let guard = recorded_requests.lock().unwrap(); assert_eq!(guard.len(), 1); assert!(guard[0].search_token.is_none()); } #[test] fn page_navigation_uses_view_rows() { let loader: PageLoader = Arc::new(|_| {}); let mut state = PickerState::new( PathBuf::from("/tmp"), FrameRequester::test_dummy(), loader, String::from("openai"), ); let mut items = Vec::new(); for idx in 0..20 { let ts = format!("2025-01-{:02}T00:00:00Z", idx + 1); let preview = format!("item-{idx}"); let path = format!("/tmp/item-{idx}.jsonl"); items.push(make_item(&path, &ts, &preview)); } state.reset_pagination(); state.ingest_page(page(items, None, 20, false)); state.update_view_rows(5); assert_eq!(state.selected, 0); block_on_future(async { state .handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE)) .await .unwrap(); }); assert_eq!(state.selected, 5); block_on_future(async { state .handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE)) .await .unwrap(); }); assert_eq!(state.selected, 10); block_on_future(async { state .handle_key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE)) .await .unwrap(); }); assert_eq!(state.selected, 5); } #[test] fn up_at_bottom_does_not_scroll_when_visible() { let loader: PageLoader = Arc::new(|_| {}); let mut state = PickerState::new( PathBuf::from("/tmp"), FrameRequester::test_dummy(), loader, String::from("openai"), ); let mut items = Vec::new(); for idx in 0..10 { let ts = format!("2025-02-{:02}T00:00:00Z", idx + 1); let preview = format!("item-{idx}"); let path = format!("/tmp/item-{idx}.jsonl"); items.push(make_item(&path, &ts, &preview)); } state.reset_pagination(); state.ingest_page(page(items, None, 10, false)); state.update_view_rows(5); state.selected = state.filtered_rows.len().saturating_sub(1); state.ensure_selected_visible(); let initial_top = state.scroll_top; assert_eq!(initial_top, state.filtered_rows.len().saturating_sub(5)); block_on_future(async { state .handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)) .await .unwrap(); }); assert_eq!(state.scroll_top, initial_top); assert_eq!(state.selected, state.filtered_rows.len().saturating_sub(2)); } #[test] fn set_query_loads_until_match_and_respects_scan_cap() { let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); let request_sink = recorded_requests.clone(); let loader: PageLoader = Arc::new(move |req: PageLoadRequest| { request_sink.lock().unwrap().push(req); }); let mut state = PickerState::new( PathBuf::from("/tmp"), FrameRequester::test_dummy(), loader, String::from("openai"), ); state.reset_pagination(); state.ingest_page(page( vec![make_item( "/tmp/start.jsonl", "2025-01-01T00:00:00Z", "alpha", )], Some(cursor_from_str( "2025-01-02T00-00-00|00000000-0000-0000-0000-000000000000", )), 1, false, )); recorded_requests.lock().unwrap().clear(); state.set_query("target".to_string()); let first_request = { let guard = recorded_requests.lock().unwrap(); assert_eq!(guard.len(), 1); guard[0].clone() }; state .handle_background_event(BackgroundEvent::PageLoaded { request_token: first_request.request_token, search_token: first_request.search_token, page: Ok(page( vec![make_item("/tmp/beta.jsonl", "2025-01-02T00:00:00Z", "beta")], Some(cursor_from_str( "2025-01-03T00-00-00|00000000-0000-0000-0000-000000000001", )), 5, false, )), }) .unwrap(); let second_request = { let guard = recorded_requests.lock().unwrap(); assert_eq!(guard.len(), 2); guard[1].clone() }; assert!(state.search_state.is_active()); assert!(state.filtered_rows.is_empty()); state .handle_background_event(BackgroundEvent::PageLoaded { request_token: second_request.request_token, search_token: second_request.search_token, page: Ok(page( vec![make_item( "/tmp/match.jsonl", "2025-01-03T00:00:00Z", "target log", )], Some(cursor_from_str( "2025-01-04T00-00-00|00000000-0000-0000-0000-000000000002", )), 7, false, )), }) .unwrap(); assert!(!state.filtered_rows.is_empty()); assert!(!state.search_state.is_active()); recorded_requests.lock().unwrap().clear(); state.set_query("missing".to_string()); let active_request = { let guard = recorded_requests.lock().unwrap(); assert_eq!(guard.len(), 1); guard[0].clone() }; state .handle_background_event(BackgroundEvent::PageLoaded { request_token: second_request.request_token, search_token: second_request.search_token, page: Ok(page(Vec::new(), None, 0, false)), }) .unwrap(); assert_eq!(recorded_requests.lock().unwrap().len(), 1); state .handle_background_event(BackgroundEvent::PageLoaded { request_token: active_request.request_token, search_token: active_request.search_token, page: Ok(page(Vec::new(), None, 3, true)), }) .unwrap(); assert!(state.filtered_rows.is_empty()); assert!(!state.search_state.is_active()); assert!(state.pagination.reached_scan_cap); } }