From e775b31d37587944451bfe2387552c7dc79371f7 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Sun, 4 Jan 2026 13:01:23 -0800 Subject: [PATCH] Add filtered resume picker snapshots --- codex-rs/tui/src/resume_picker.rs | 229 ++++++++++++++++- ...e_picker__tests__resume_picker_screen.snap | 4 +- ..._tests__resume_picker_screen_filtered.snap | 14 ++ codex-rs/tui2/src/resume_picker.rs | 235 +++++++++++++++++- ...e_picker__tests__resume_picker_screen.snap | 3 +- ..._tests__resume_picker_screen_filtered.snap | 14 ++ 6 files changed, 476 insertions(+), 23 deletions(-) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_screen_filtered.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__resume_picker__tests__resume_picker_screen_filtered.snap diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 0f55bb5e0d..21283f666f 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -78,11 +78,8 @@ pub async fn run_resume_picker( let (bg_tx, bg_rx) = mpsc::unbounded_channel(); let default_provider = default_provider.to_string(); - let filter_cwd = if show_all { - None - } else { - std::env::current_dir().ok() - }; + let current_dir = std::env::current_dir().ok(); + let filter_cwd = if show_all { None } else { current_dir.clone() }; let loader_tx = bg_tx.clone(); let page_loader: PageLoader = Arc::new(move |request: PageLoadRequest| { @@ -113,6 +110,7 @@ pub async fn run_resume_picker( default_provider.clone(), show_all, filter_cwd, + current_dir, ); state.start_initial_load(); state.request_frame(); @@ -134,7 +132,7 @@ pub async fn run_resume_picker( } TuiEvent::Draw => { if let Ok(size) = alt.tui.terminal.size() { - let list_height = size.height.saturating_sub(4) as usize; + let list_height = size.height.saturating_sub(5) as usize; state.update_view_rows(list_height); state.ensure_minimum_rows_for_view(list_height); } @@ -190,6 +188,7 @@ struct PickerState { default_provider: String, show_all: bool, filter_cwd: Option, + display_cwd: Option, } struct PaginationState { @@ -259,6 +258,7 @@ impl PickerState { default_provider: String, show_all: bool, filter_cwd: Option, + display_cwd: Option, ) -> Self { Self { codex_home, @@ -283,6 +283,7 @@ impl PickerState { default_provider, show_all, filter_cwd, + display_cwd, } } @@ -703,16 +704,44 @@ fn preview_from_head(head: &[serde_json::Value]) -> Option { }) } +fn render_filter_hint_line(state: &PickerState) -> Line<'static> { + let cwd = state + .display_cwd + .as_ref() + .map(|path| display_path_for(path, Path::new("/"))) + .unwrap_or_else(|| String::from("unknown")); + + if state.show_all { + vec![ + "Showing sessions from all directories ".dim(), + "(--all)".cyan(), + " · Current directory: ".dim(), + Span::from(cwd).cyan(), + ] + .into() + } else { + vec![ + "Filtering to current directory: ".dim(), + Span::from(cwd).cyan(), + " · Use ".dim(), + "--all".cyan(), + " to show sessions from all directories".dim(), + ] + .into() + } +} + 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([ + let [header, search, filter_hint, columns, list, hint] = Layout::vertical([ Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), - Constraint::Min(area.height.saturating_sub(4)), + Constraint::Length(1), + Constraint::Min(area.height.saturating_sub(5)), Constraint::Length(1), ]) .areas(area); @@ -731,6 +760,9 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> { }; frame.render_widget_ref(Line::from(q), search); + let filter_hint_line = render_filter_hint_line(state); + frame.render_widget_ref(filter_hint_line, filter_hint); + let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all); // Column headers and list @@ -1200,6 +1232,7 @@ mod tests { String::from("openai"), true, None, + None, ); let now = Utc::now(); @@ -1349,6 +1382,7 @@ mod tests { String::from("openai"), true, None, + Some(PathBuf::from("/tmp/project")), ); let page = RolloutRecorder::list_conversations( @@ -1381,11 +1415,12 @@ mod tests { { let mut frame = terminal.get_frame(); let area = frame.area(); - let [header, search, columns, list, hint] = Layout::vertical([ + let [header, search, filter_hint, columns, list, hint] = Layout::vertical([ Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), - Constraint::Min(area.height.saturating_sub(4)), + Constraint::Length(1), + Constraint::Min(area.height.saturating_sub(5)), Constraint::Length(1), ]) .areas(area); @@ -1397,6 +1432,9 @@ mod tests { frame.render_widget_ref(Line::from("Type to search".dim()), search); + let filter_hint_line = render_filter_hint_line(&state); + frame.render_widget_ref(filter_hint_line, filter_hint); + render_column_headers(&mut frame, columns, &metrics); render_list(&mut frame, list, &state, &metrics); @@ -1419,6 +1457,172 @@ mod tests { assert_snapshot!("resume_picker_screen", snapshot); } + #[tokio::test] + async fn resume_picker_screen_filtered_snapshot() { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + use uuid::Uuid; + + // Create real rollout files so the snapshot uses the actual listing pipeline. + let tempdir = tempfile::tempdir().expect("tempdir"); + let sessions_root = tempdir.path().join("sessions"); + std::fs::create_dir_all(&sessions_root).expect("mkdir sessions root"); + + let now = Utc::now(); + + // Helper to write a rollout file with minimal meta + one user message. + let write_rollout = |ts: DateTime, cwd: &str, branch: &str, preview: &str| { + let dir = sessions_root + .join(ts.format("%Y").to_string()) + .join(ts.format("%m").to_string()) + .join(ts.format("%d").to_string()); + std::fs::create_dir_all(&dir).expect("mkdir date dirs"); + let filename = format!( + "rollout-{}-{}.jsonl", + ts.format("%Y-%m-%dT%H-%M-%S"), + Uuid::new_v4() + ); + let path = dir.join(filename); + let meta = serde_json::json!({ + "timestamp": ts.to_rfc3339(), + "item": { + "SessionMeta": { + "meta": { + "id": Uuid::new_v4(), + "timestamp": ts.to_rfc3339(), + "cwd": cwd, + "originator": "user", + "cli_version": "0.0.0", + "instructions": null, + "source": "Cli", + "model_provider": "openai", + } + } + } + }); + let user = serde_json::json!({ + "timestamp": ts.to_rfc3339(), + "item": { + "EventMsg": { + "UserMessage": { + "message": preview, + "images": null + } + } + } + }); + let branch_meta = serde_json::json!({ + "timestamp": ts.to_rfc3339(), + "item": { + "EventMsg": { + "SessionMeta": { + "meta": { + "git_branch": branch + } + } + } + } + }); + std::fs::write(&path, format!("{meta}\n{user}\n{branch_meta}\n")) + .expect("write rollout"); + }; + + write_rollout( + now - Duration::seconds(42), + "/tmp/project", + "feature/resume", + "Fix resume picker timestamps", + ); + write_rollout( + now - Duration::minutes(35), + "/tmp/other", + "main", + "Investigate lazy pagination cap", + ); + + let loader: PageLoader = Arc::new(|_| {}); + let mut state = PickerState::new( + PathBuf::from("/tmp"), + FrameRequester::test_dummy(), + loader, + String::from("openai"), + false, + Some(PathBuf::from("/tmp/project")), + Some(PathBuf::from("/tmp/project")), + ); + + let page = RolloutRecorder::list_conversations( + &state.codex_home, + PAGE_SIZE, + None, + INTERACTIVE_SESSION_SOURCES, + Some(&[String::from("openai")]), + "openai", + ) + .await + .expect("list conversations"); + + let rows = rows_from_items(page.items); + state.all_rows = rows.clone(); + state.filtered_rows = rows; + state.view_rows = Some(4); + state.selected = 0; + state.scroll_top = 0; + state.update_view_rows(4); + + let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all); + + let width: u16 = 80; + let height: u16 = 9; + 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 [header, search, filter_hint, columns, list, hint] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(area.height.saturating_sub(5)), + Constraint::Length(1), + ]) + .areas(area); + + frame.render_widget_ref( + Line::from(vec!["Resume a previous session".bold().cyan()]), + header, + ); + + frame.render_widget_ref(Line::from("Type to search".dim()), search); + + let filter_hint_line = render_filter_hint_line(&state); + frame.render_widget_ref(filter_hint_line, filter_hint); + + render_column_headers(&mut frame, columns, &metrics); + render_list(&mut frame, list, &state, &metrics); + + 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(), + ] + .into(); + frame.render_widget_ref(hint_line, hint); + } + terminal.flush().expect("flush"); + + let snapshot = terminal.backend().to_string(); + assert_snapshot!("resume_picker_screen_filtered", snapshot); + } + #[test] fn pageless_scrolling_deduplicates_and_keeps_order() { let loader: PageLoader = Arc::new(|_| {}); @@ -1429,6 +1633,7 @@ mod tests { String::from("openai"), true, None, + None, ); state.reset_pagination(); @@ -1497,6 +1702,7 @@ mod tests { String::from("openai"), true, None, + None, ); state.reset_pagination(); state.ingest_page(page( @@ -1528,6 +1734,7 @@ mod tests { String::from("openai"), true, None, + None, ); let mut items = Vec::new(); @@ -1572,6 +1779,7 @@ mod tests { String::from("openai"), true, None, + None, ); let mut items = Vec::new(); @@ -1616,6 +1824,7 @@ mod tests { String::from("openai"), true, None, + None, ); state.reset_pagination(); state.ingest_page(page( diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_screen.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_screen.snap index 79a169a06d..9599df9dc7 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_screen.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_screen.snap @@ -1,14 +1,14 @@ --- source: tui/src/resume_picker.rs -assertion_line: 1438 +assertion_line: 1457 expression: snapshot --- Resume a previous session Type to search +Showing sessions from all directories (--all) · Current directory: /tmp/project Updated Branch CWD Conversation No sessions yet - enter to resume esc to start new ctrl + c to quit diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_screen_filtered.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_screen_filtered.snap new file mode 100644 index 0000000000..ce6c360e49 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_screen_filtered.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/resume_picker.rs +assertion_line: 1623 +expression: snapshot +--- +Resume a previous session +Type to search +Filtering to current directory: /tmp/project · Use --all to show sessions from a + Updated Branch Conversation +No sessions yet + + + +enter to resume esc to start new ctrl + c to quit diff --git a/codex-rs/tui2/src/resume_picker.rs b/codex-rs/tui2/src/resume_picker.rs index 0f55bb5e0d..073f5b82bf 100644 --- a/codex-rs/tui2/src/resume_picker.rs +++ b/codex-rs/tui2/src/resume_picker.rs @@ -78,11 +78,8 @@ pub async fn run_resume_picker( let (bg_tx, bg_rx) = mpsc::unbounded_channel(); let default_provider = default_provider.to_string(); - let filter_cwd = if show_all { - None - } else { - std::env::current_dir().ok() - }; + let current_dir = std::env::current_dir().ok(); + let filter_cwd = if show_all { None } else { current_dir.clone() }; let loader_tx = bg_tx.clone(); let page_loader: PageLoader = Arc::new(move |request: PageLoadRequest| { @@ -113,6 +110,7 @@ pub async fn run_resume_picker( default_provider.clone(), show_all, filter_cwd, + current_dir, ); state.start_initial_load(); state.request_frame(); @@ -134,7 +132,7 @@ pub async fn run_resume_picker( } TuiEvent::Draw => { if let Ok(size) = alt.tui.terminal.size() { - let list_height = size.height.saturating_sub(4) as usize; + let list_height = size.height.saturating_sub(5) as usize; state.update_view_rows(list_height); state.ensure_minimum_rows_for_view(list_height); } @@ -190,6 +188,7 @@ struct PickerState { default_provider: String, show_all: bool, filter_cwd: Option, + display_cwd: Option, } struct PaginationState { @@ -259,6 +258,7 @@ impl PickerState { default_provider: String, show_all: bool, filter_cwd: Option, + display_cwd: Option, ) -> Self { Self { codex_home, @@ -283,6 +283,7 @@ impl PickerState { default_provider, show_all, filter_cwd, + display_cwd, } } @@ -703,16 +704,50 @@ fn preview_from_head(head: &[serde_json::Value]) -> Option { }) } +fn render_filter_hint_line(state: &PickerState) -> Line<'static> { + let cwd = state + .display_cwd + .as_ref() + .map(|path| { + if path.is_absolute() { + path.display().to_string() + } else { + display_path_for(path, Path::new("/")) + } + }) + .unwrap_or_else(|| String::from("unknown")); + + if state.show_all { + vec![ + "Showing sessions from all directories ".dim(), + "(--all)".cyan(), + " · Current directory: ".dim(), + Span::from(cwd).cyan(), + ] + .into() + } else { + vec![ + "Filtering to current directory: ".dim(), + Span::from(cwd).cyan(), + " · Use ".dim(), + "--all".cyan(), + " to show sessions from all directories".dim(), + ] + .into() + } +} + 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([ + let [header, search, filter_hint, columns, list, hint] = Layout::vertical([ Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), - Constraint::Min(area.height.saturating_sub(4)), + Constraint::Length(1), + Constraint::Min(area.height.saturating_sub(5)), Constraint::Length(1), ]) .areas(area); @@ -731,6 +766,9 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> { }; frame.render_widget_ref(Line::from(q), search); + let filter_hint_line = render_filter_hint_line(state); + frame.render_widget_ref(filter_hint_line, filter_hint); + let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all); // Column headers and list @@ -1200,6 +1238,7 @@ mod tests { String::from("openai"), true, None, + None, ); let now = Utc::now(); @@ -1349,6 +1388,7 @@ mod tests { String::from("openai"), true, None, + Some(PathBuf::from("/tmp/project")), ); let page = RolloutRecorder::list_conversations( @@ -1381,11 +1421,12 @@ mod tests { { let mut frame = terminal.get_frame(); let area = frame.area(); - let [header, search, columns, list, hint] = Layout::vertical([ + let [header, search, filter_hint, columns, list, hint] = Layout::vertical([ Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), - Constraint::Min(area.height.saturating_sub(4)), + Constraint::Length(1), + Constraint::Min(area.height.saturating_sub(5)), Constraint::Length(1), ]) .areas(area); @@ -1397,6 +1438,9 @@ mod tests { frame.render_widget_ref(Line::from("Type to search".dim()), search); + let filter_hint_line = render_filter_hint_line(&state); + frame.render_widget_ref(filter_hint_line, filter_hint); + render_column_headers(&mut frame, columns, &metrics); render_list(&mut frame, list, &state, &metrics); @@ -1419,6 +1463,172 @@ mod tests { assert_snapshot!("resume_picker_screen", snapshot); } + #[tokio::test] + async fn resume_picker_screen_filtered_snapshot() { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + use uuid::Uuid; + + // Create real rollout files so the snapshot uses the actual listing pipeline. + let tempdir = tempfile::tempdir().expect("tempdir"); + let sessions_root = tempdir.path().join("sessions"); + std::fs::create_dir_all(&sessions_root).expect("mkdir sessions root"); + + let now = Utc::now(); + + // Helper to write a rollout file with minimal meta + one user message. + let write_rollout = |ts: DateTime, cwd: &str, branch: &str, preview: &str| { + let dir = sessions_root + .join(ts.format("%Y").to_string()) + .join(ts.format("%m").to_string()) + .join(ts.format("%d").to_string()); + std::fs::create_dir_all(&dir).expect("mkdir date dirs"); + let filename = format!( + "rollout-{}-{}.jsonl", + ts.format("%Y-%m-%dT%H-%M-%S"), + Uuid::new_v4() + ); + let path = dir.join(filename); + let meta = serde_json::json!({ + "timestamp": ts.to_rfc3339(), + "item": { + "SessionMeta": { + "meta": { + "id": Uuid::new_v4(), + "timestamp": ts.to_rfc3339(), + "cwd": cwd, + "originator": "user", + "cli_version": "0.0.0", + "instructions": null, + "source": "Cli", + "model_provider": "openai", + } + } + } + }); + let user = serde_json::json!({ + "timestamp": ts.to_rfc3339(), + "item": { + "EventMsg": { + "UserMessage": { + "message": preview, + "images": null + } + } + } + }); + let branch_meta = serde_json::json!({ + "timestamp": ts.to_rfc3339(), + "item": { + "EventMsg": { + "SessionMeta": { + "meta": { + "git_branch": branch + } + } + } + } + }); + std::fs::write(&path, format!("{meta}\n{user}\n{branch_meta}\n")) + .expect("write rollout"); + }; + + write_rollout( + now - Duration::seconds(42), + "/tmp/project", + "feature/resume", + "Fix resume picker timestamps", + ); + write_rollout( + now - Duration::minutes(35), + "/tmp/other", + "main", + "Investigate lazy pagination cap", + ); + + let loader: PageLoader = Arc::new(|_| {}); + let mut state = PickerState::new( + PathBuf::from("/tmp"), + FrameRequester::test_dummy(), + loader, + String::from("openai"), + false, + Some(PathBuf::from("/tmp/project")), + Some(PathBuf::from("/tmp/project")), + ); + + let page = RolloutRecorder::list_conversations( + &state.codex_home, + PAGE_SIZE, + None, + INTERACTIVE_SESSION_SOURCES, + Some(&[String::from("openai")]), + "openai", + ) + .await + .expect("list conversations"); + + let rows = rows_from_items(page.items); + state.all_rows = rows.clone(); + state.filtered_rows = rows; + state.view_rows = Some(4); + state.selected = 0; + state.scroll_top = 0; + state.update_view_rows(4); + + let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all); + + let width: u16 = 80; + let height: u16 = 9; + 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 [header, search, filter_hint, columns, list, hint] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(area.height.saturating_sub(5)), + Constraint::Length(1), + ]) + .areas(area); + + frame.render_widget_ref( + Line::from(vec!["Resume a previous session".bold().cyan()]), + header, + ); + + frame.render_widget_ref(Line::from("Type to search".dim()), search); + + let filter_hint_line = render_filter_hint_line(&state); + frame.render_widget_ref(filter_hint_line, filter_hint); + + render_column_headers(&mut frame, columns, &metrics); + render_list(&mut frame, list, &state, &metrics); + + 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(), + ] + .into(); + frame.render_widget_ref(hint_line, hint); + } + terminal.flush().expect("flush"); + + let snapshot = terminal.backend().to_string(); + assert_snapshot!("resume_picker_screen_filtered", snapshot); + } + #[test] fn pageless_scrolling_deduplicates_and_keeps_order() { let loader: PageLoader = Arc::new(|_| {}); @@ -1429,6 +1639,7 @@ mod tests { String::from("openai"), true, None, + None, ); state.reset_pagination(); @@ -1497,6 +1708,7 @@ mod tests { String::from("openai"), true, None, + None, ); state.reset_pagination(); state.ingest_page(page( @@ -1528,6 +1740,7 @@ mod tests { String::from("openai"), true, None, + None, ); let mut items = Vec::new(); @@ -1572,6 +1785,7 @@ mod tests { String::from("openai"), true, None, + None, ); let mut items = Vec::new(); @@ -1616,6 +1830,7 @@ mod tests { String::from("openai"), true, None, + None, ); state.reset_pagination(); state.ingest_page(page( diff --git a/codex-rs/tui2/src/snapshots/codex_tui2__resume_picker__tests__resume_picker_screen.snap b/codex-rs/tui2/src/snapshots/codex_tui2__resume_picker__tests__resume_picker_screen.snap index 25f8d5afaa..483722c0c9 100644 --- a/codex-rs/tui2/src/snapshots/codex_tui2__resume_picker__tests__resume_picker_screen.snap +++ b/codex-rs/tui2/src/snapshots/codex_tui2__resume_picker__tests__resume_picker_screen.snap @@ -1,13 +1,14 @@ --- source: tui2/src/resume_picker.rs +assertion_line: 1463 expression: snapshot --- Resume a previous session Type to search +Showing sessions from all directories (--all) · Current directory: /tmp/project Updated Branch CWD Conversation No sessions yet - enter to resume esc to start new ctrl + c to quit diff --git a/codex-rs/tui2/src/snapshots/codex_tui2__resume_picker__tests__resume_picker_screen_filtered.snap b/codex-rs/tui2/src/snapshots/codex_tui2__resume_picker__tests__resume_picker_screen_filtered.snap new file mode 100644 index 0000000000..48b0d73e95 --- /dev/null +++ b/codex-rs/tui2/src/snapshots/codex_tui2__resume_picker__tests__resume_picker_screen_filtered.snap @@ -0,0 +1,14 @@ +--- +source: tui2/src/resume_picker.rs +assertion_line: 1629 +expression: snapshot +--- +Resume a previous session +Type to search +Filtering to current directory: /tmp/project · Use --all to show sessions from a + Updated Branch Conversation +No sessions yet + + + +enter to resume esc to start new ctrl + c to quit