diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index e80c4ad7932..e1bb35d712d 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -86,6 +86,7 @@ struct PageLoadRequest { request_token: usize, search_token: Option, default_provider: String, + sort_key: ThreadSortKey, } type PageLoader = Arc; @@ -99,9 +100,21 @@ enum BackgroundEvent { } /// Interactive session picker that lists recorded rollout files with simple -/// search and pagination. Shows the session name when available, otherwise the -/// first user input as the preview, relative time (e.g., "5 seconds ago"), and -/// the absolute path. +/// search and pagination. +/// +/// The picker displays sessions in a table with timestamp columns (created/updated), +/// git branch, working directory, and conversation preview. Users can toggle +/// between sorting by creation time and last-updated time using the Tab key. +/// +/// Sessions are loaded on-demand via cursor-based pagination. The backend +/// `RolloutRecorder::list_threads` returns pages ordered by the selected sort key, +/// and the picker deduplicates across pages to handle overlapping windows when +/// new sessions appear during pagination. +/// +/// Filtering happens in two layers: +/// 1. Provider and source filtering at the backend (only interactive CLI sessions +/// for the current model provider). +/// 2. Working-directory filtering at the picker (unless `--all` is passed). pub async fn run_resume_picker( tui: &mut Tui, codex_home: &Path, @@ -160,7 +173,7 @@ async fn run_session_picker( &request.codex_home, PAGE_SIZE, request.cursor.as_ref(), - ThreadSortKey::CreatedAt, + request.sort_key, INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), request.default_provider.as_str(), @@ -223,6 +236,14 @@ async fn run_session_picker( Ok(SessionSelection::StartFresh) } +/// Returns the human-readable column header for the given sort key. +fn sort_key_label(sort_key: ThreadSortKey) -> &'static str { + match sort_key { + ThreadSortKey::CreatedAt => "Created at", + ThreadSortKey::UpdatedAt => "Updated at", + } +} + /// RAII guard that ensures we leave the alt-screen on scope exit. struct AltScreenGuard<'a> { tui: &'a mut Tui, @@ -260,6 +281,7 @@ struct PickerState { show_all: bool, filter_cwd: Option, action: SessionPickerAction, + sort_key: ThreadSortKey, thread_name_cache: HashMap>, } @@ -376,6 +398,7 @@ impl PickerState { show_all, filter_cwd, action, + sort_key: ThreadSortKey::CreatedAt, thread_name_cache: HashMap::new(), } } @@ -432,6 +455,10 @@ impl PickerState { self.request_frame(); } } + KeyCode::Tab => { + self.toggle_sort_key(); + self.request_frame(); + } KeyCode::Backspace => { let mut new_query = self.query.clone(); new_query.pop(); @@ -459,13 +486,21 @@ impl PickerState { self.all_rows.clear(); self.filtered_rows.clear(); self.seen_paths.clear(); - self.search_state = SearchState::Idle; self.selected = 0; + let search_token = if self.query.is_empty() { + self.search_state = SearchState::Idle; + None + } else { + let token = self.allocate_search_token(); + self.search_state = SearchState::Active { token }; + Some(token) + }; + let request_token = self.allocate_request_token(); self.pagination.loading = LoadingState::Pending(PendingLoad { request_token, - search_token: None, + search_token, }); self.request_frame(); @@ -473,8 +508,9 @@ impl PickerState { codex_home: self.codex_home.clone(), cursor: None, request_token, - search_token: None, + search_token, default_provider: self.default_provider.clone(), + sort_key: self.sort_key, }); } @@ -745,6 +781,7 @@ impl PickerState { request_token, search_token, default_provider: self.default_provider.clone(), + sort_key: self.sort_key, }); } @@ -759,6 +796,19 @@ impl PickerState { self.next_search_token = self.next_search_token.wrapping_add(1); token } + + /// Cycles the sort order between creation time and last-updated time. + /// + /// Triggers a full reload because the backend must re-sort all sessions. + /// The existing `all_rows` are cleared and pagination restarts from the + /// beginning with the new sort key. + fn toggle_sort_key(&mut self) { + self.sort_key = match self.sort_key { + ThreadSortKey::CreatedAt => ThreadSortKey::UpdatedAt, + ThreadSortKey::UpdatedAt => ThreadSortKey::CreatedAt, + }; + self.start_initial_load(); + } } fn rows_from_items(items: Vec) -> Vec { @@ -857,7 +907,15 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> { .areas(area); // Header - frame.render_widget_ref(Line::from(vec![state.action.title().bold().cyan()]), header); + let header_line: Line = vec![ + state.action.title().bold().cyan(), + " ".into(), + "Sort:".dim(), + " ".into(), + sort_key_label(state.sort_key).magenta(), + ] + .into(); + frame.render_widget_ref(header_line, header); // Search line let q = if state.query.is_empty() { @@ -870,7 +928,7 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> { let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all); // Column headers and list - render_column_headers(frame, columns, &metrics); + render_column_headers(frame, columns, &metrics, state.sort_key); render_list(frame, list, state, &metrics); // Hint line @@ -885,6 +943,9 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> { key_hint::ctrl(KeyCode::Char('c')).into(), " to quit ".dim(), " ".dim(), + key_hint::plain(KeyCode::Tab).into(), + " to toggle sort ".dim(), + " ".dim(), key_hint::plain(KeyCode::Up).into(), "/".dim(), key_hint::plain(KeyCode::Down).into(), @@ -918,11 +979,13 @@ fn render_list( let labels = &metrics.labels; let mut y = area.y; + let visibility = column_visibility(area.width, metrics, state.sort_key); + let max_created_width = metrics.max_created_width; let max_updated_width = metrics.max_updated_width; let max_branch_width = metrics.max_branch_width; let max_cwd_width = metrics.max_cwd_width; - for (idx, (row, (updated_label, branch_label, cwd_label))) in rows[start..end] + for (idx, (row, (created_label, updated_label, branch_label, cwd_label))) in rows[start..end] .iter() .zip(labels[start..end].iter()) .enumerate() @@ -930,12 +993,17 @@ fn render_list( let is_sel = start + idx == state.selected; let marker = if is_sel { "> ".bold() } else { " ".into() }; let marker_width = 2usize; - let updated_span = if max_updated_width == 0 { - None + let created_span = if visibility.show_created { + Some(Span::from(format!("{created_label: 0 { + if visibility.show_created { + preview_width = preview_width.saturating_sub(max_created_width + 2); + } + if visibility.show_updated { preview_width = preview_width.saturating_sub(max_updated_width + 2); } - if max_branch_width > 0 { + if visibility.show_branch { preview_width = preview_width.saturating_sub(max_branch_width + 2); } - if max_cwd_width > 0 { + if visibility.show_cwd { preview_width = preview_width.saturating_sub(max_cwd_width + 2); } - let add_leading_gap = max_updated_width == 0 && max_branch_width == 0 && max_cwd_width == 0; + let add_leading_gap = !visibility.show_created + && !visibility.show_updated + && !visibility.show_branch + && !visibility.show_cwd; if add_leading_gap { preview_width = preview_width.saturating_sub(2); } let preview = truncate_text(row.display_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()); @@ -1082,26 +1160,44 @@ fn format_updated_label(row: &Row) -> String { } } +fn format_created_label(row: &Row) -> String { + match row.created_at { + Some(created) => human_time_ago(created), + None => "-".to_string(), + } +} + fn render_column_headers( frame: &mut crate::custom_terminal::Frame, area: Rect, metrics: &ColumnMetrics, + sort_key: ThreadSortKey, ) { if area.height == 0 { return; } let mut spans: Vec = vec![" ".into()]; - if metrics.max_updated_width > 0 { + let visibility = column_visibility(area.width, metrics, sort_key); + if visibility.show_created { + let label = format!( + "{text: 0 { + if visibility.show_branch { let label = format!( "{text: 0 { + if visibility.show_cwd { let label = format!( "{text:, + /// (created_label, updated_label, branch_label, cwd_label) per row. + labels: Vec<(String, String, String, String)>, +} + +/// Determines which columns to render given available terminal width. +/// +/// When the terminal is narrow, only one timestamp column is shown (whichever +/// matches the current sort key). Branch and CWD are hidden if their max +/// widths are zero (no data to show). +#[derive(Debug, PartialEq, Eq)] +struct ColumnVisibility { + show_created: bool, + show_updated: bool, + show_branch: bool, + show_cwd: bool, } fn calculate_column_metrics(rows: &[Row], include_cwd: bool) -> ColumnMetrics { @@ -1150,8 +1265,9 @@ fn calculate_column_metrics(rows: &[Row], include_cwd: bool) -> ColumnMetrics { format!("…{tail}") } - let mut labels: Vec<(String, String, String)> = Vec::with_capacity(rows.len()); - let mut max_updated_width = UnicodeWidthStr::width("Updated"); + let mut labels: Vec<(String, String, String, String)> = Vec::with_capacity(rows.len()); + let mut max_created_width = UnicodeWidthStr::width("Created at"); + let mut max_updated_width = UnicodeWidthStr::width("Updated at"); let mut max_branch_width = UnicodeWidthStr::width("Branch"); let mut max_cwd_width = if include_cwd { UnicodeWidthStr::width("CWD") @@ -1160,6 +1276,7 @@ fn calculate_column_metrics(rows: &[Row], include_cwd: bool) -> ColumnMetrics { }; for row in rows { + let created = format_created_label(row); let updated = format_updated_label(row); let branch_raw = row.git_branch.clone().unwrap_or_default(); let branch = right_elide(&branch_raw, 24); @@ -1173,13 +1290,15 @@ fn calculate_column_metrics(rows: &[Row], include_cwd: bool) -> ColumnMetrics { } else { String::new() }; + max_created_width = max_created_width.max(UnicodeWidthStr::width(created.as_str())); max_updated_width = max_updated_width.max(UnicodeWidthStr::width(updated.as_str())); max_branch_width = max_branch_width.max(UnicodeWidthStr::width(branch.as_str())); max_cwd_width = max_cwd_width.max(UnicodeWidthStr::width(cwd.as_str())); - labels.push((updated, branch, cwd)); + labels.push((created, updated, branch, cwd)); } ColumnMetrics { + max_created_width, max_updated_width, max_branch_width, max_cwd_width, @@ -1187,15 +1306,79 @@ fn calculate_column_metrics(rows: &[Row], include_cwd: bool) -> ColumnMetrics { } } +/// Computes which columns fit in the available width. +/// +/// The algorithm reserves at least `MIN_PREVIEW_WIDTH` characters for the +/// conversation preview. If both timestamp columns don't fit, only the one +/// matching the current sort key is shown. +fn column_visibility( + area_width: u16, + metrics: &ColumnMetrics, + sort_key: ThreadSortKey, +) -> ColumnVisibility { + const MIN_PREVIEW_WIDTH: usize = 10; + + let show_branch = metrics.max_branch_width > 0; + let show_cwd = metrics.max_cwd_width > 0; + + // Calculate remaining width after all optional columns. + let mut preview_width = area_width as usize; + preview_width = preview_width.saturating_sub(2); // marker + if metrics.max_created_width > 0 { + preview_width = preview_width.saturating_sub(metrics.max_created_width + 2); + } + if metrics.max_updated_width > 0 { + preview_width = preview_width.saturating_sub(metrics.max_updated_width + 2); + } + if show_branch { + preview_width = preview_width.saturating_sub(metrics.max_branch_width + 2); + } + if show_cwd { + preview_width = preview_width.saturating_sub(metrics.max_cwd_width + 2); + } + + // If preview would be too narrow, hide the non-active timestamp column. + let show_both = preview_width >= MIN_PREVIEW_WIDTH; + let show_created = if show_both { + metrics.max_created_width > 0 + } else { + sort_key == ThreadSortKey::CreatedAt + }; + let show_updated = if show_both { + metrics.max_updated_width > 0 + } else { + sort_key == ThreadSortKey::UpdatedAt + }; + + ColumnVisibility { + show_created, + show_updated, + show_branch, + show_cwd, + } +} + #[cfg(test)] mod tests { use super::*; use chrono::Duration; + use codex_protocol::ThreadId; + use codex_protocol::models::ContentItem; + use codex_protocol::models::ResponseItem; + use codex_protocol::protocol::RolloutItem; + use codex_protocol::protocol::RolloutLine; + use codex_protocol::protocol::SessionMeta; + use codex_protocol::protocol::SessionMetaLine; + use codex_protocol::protocol::SessionSource; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; use insta::assert_snapshot; + use pretty_assertions::assert_eq; use serde_json::json; + use std::fs::FileTimes; + use std::fs::OpenOptions; + use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; @@ -1242,6 +1425,105 @@ mod tests { } } + fn set_rollout_mtime(path: &Path, updated_at: DateTime) { + let times = FileTimes::new().set_modified(updated_at.into()); + OpenOptions::new() + .append(true) + .open(path) + .expect("open rollout") + .set_times(times) + .expect("set times"); + } + + #[tokio::test] + async fn resume_picker_orders_by_updated_at() { + use uuid::Uuid; + + 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(); + + let write_rollout = |ts: DateTime, preview: &str| -> PathBuf { + 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 = SessionMeta { + id: ThreadId::new(), + forked_from_id: None, + timestamp: ts.to_rfc3339(), + cwd: PathBuf::from("/tmp"), + originator: String::from("user"), + cli_version: String::from("0.0.0"), + source: SessionSource::Cli, + model_provider: Some(String::from("openai")), + base_instructions: None, + dynamic_tools: None, + }; + let meta_line = RolloutLine { + timestamp: ts.to_rfc3339(), + item: RolloutItem::SessionMeta(SessionMetaLine { meta, git: None }), + }; + let user_line = RolloutLine { + timestamp: ts.to_rfc3339(), + item: RolloutItem::ResponseItem(ResponseItem::Message { + id: None, + role: String::from("user"), + content: vec![ContentItem::InputText { + text: preview.to_string(), + }], + end_turn: None, + phase: None, + }), + }; + let meta_json = serde_json::to_string(&meta_line).expect("serialize meta"); + let user_json = serde_json::to_string(&user_line).expect("serialize user"); + std::fs::write(&path, format!("{meta_json}\n{user_json}\n")).expect("write rollout"); + path + }; + + let created_a = now - Duration::minutes(1); + let created_b = now - Duration::minutes(2); + + let path_a = write_rollout(created_a, "A (created newer)"); + let path_b = write_rollout(created_b, "B (created older)"); + + set_rollout_mtime(&path_a, now - Duration::minutes(10)); + set_rollout_mtime(&path_b, now - Duration::seconds(10)); + + let page = RolloutRecorder::list_threads( + tempdir.path(), + PAGE_SIZE, + None, + ThreadSortKey::UpdatedAt, + INTERACTIVE_SESSION_SOURCES, + Some(&[String::from("openai")]), + "openai", + ) + .await + .expect("list threads"); + + let rows = rows_from_items(page.items); + let previews: Vec = rows.iter().map(|row| row.preview.clone()).collect(); + + assert_eq!( + previews, + vec![ + "B (created older)".to_string(), + "A (created newer)".to_string() + ] + ); + } + #[test] fn preview_uses_first_message_input_text() { let head = vec![ @@ -1409,7 +1691,7 @@ mod tests { 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_column_headers(&mut frame, segments[0], &metrics, state.sort_key); render_list(&mut frame, segments[1], &state, &metrics); } terminal.flush().expect("flush"); @@ -1552,13 +1834,19 @@ mod tests { .areas(area); frame.render_widget_ref( - Line::from(vec!["Resume a previous session".bold().cyan()]), + Line::from(vec![ + "Resume a previous session".bold().cyan(), + " ".into(), + "Sort:".dim(), + " ".into(), + "Created at".magenta(), + ]), header, ); frame.render_widget_ref(Line::from("Type to search".dim()), search); - render_column_headers(&mut frame, columns, &metrics); + render_column_headers(&mut frame, columns, &metrics, state.sort_key); render_list(&mut frame, list, &state, &metrics); let hint_line: Line = vec![ @@ -1570,6 +1858,9 @@ mod tests { " ".dim(), key_hint::ctrl(KeyCode::Char('c')).into(), " to quit ".dim(), + " ".dim(), + key_hint::plain(KeyCode::Tab).into(), + " to toggle sort ".dim(), ] .into(); frame.render_widget_ref(hint_line, hint); @@ -1669,7 +1960,7 @@ mod tests { 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_column_headers(&mut frame, segments[0], &metrics, state.sort_key); render_list(&mut frame, segments[1], &state, &metrics); } terminal.flush().expect("flush"); @@ -1779,6 +2070,85 @@ mod tests { assert!(guard[0].search_token.is_none()); } + #[test] + fn column_visibility_hides_extra_date_column_when_narrow() { + let metrics = ColumnMetrics { + max_created_width: 8, + max_updated_width: 12, + max_branch_width: 0, + max_cwd_width: 0, + labels: Vec::new(), + }; + + let created = column_visibility(30, &metrics, ThreadSortKey::CreatedAt); + assert_eq!( + created, + ColumnVisibility { + show_created: true, + show_updated: false, + show_branch: false, + show_cwd: false, + } + ); + + let updated = column_visibility(30, &metrics, ThreadSortKey::UpdatedAt); + assert_eq!( + updated, + ColumnVisibility { + show_created: false, + show_updated: true, + show_branch: false, + show_cwd: false, + } + ); + + let wide = column_visibility(40, &metrics, ThreadSortKey::CreatedAt); + assert_eq!( + wide, + ColumnVisibility { + show_created: true, + show_updated: true, + show_branch: false, + show_cwd: false, + } + ); + } + + #[tokio::test] + async fn toggle_sort_key_reloads_with_new_sort() { + 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"), + true, + None, + SessionPickerAction::Resume, + ); + + state.start_initial_load(); + { + let guard = recorded_requests.lock().unwrap(); + assert_eq!(guard.len(), 1); + assert_eq!(guard[0].sort_key, ThreadSortKey::CreatedAt); + } + + state + .handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)) + .await + .unwrap(); + + let guard = recorded_requests.lock().unwrap(); + assert_eq!(guard.len(), 2); + assert_eq!(guard[1].sort_key, ThreadSortKey::UpdatedAt); + } + #[tokio::test] async fn page_navigation_uses_view_rows() { let loader: PageLoader = Arc::new(|_| {}); 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 79a169a06d3..885ba888423 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,13 @@ --- source: tui/src/resume_picker.rs -assertion_line: 1438 expression: snapshot --- -Resume a previous session +Resume a previous session Sort: Created at Type to search - Updated Branch CWD Conversation + Created at Updated at Branch CWD Conversation No sessions yet -enter to resume esc to start new ctrl + c to quit +enter to resume esc to start new ctrl + c to quit tab to toggle sort diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_table.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_table.snap index c0290432191..1505d6e7e39 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_table.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_table.snap @@ -2,7 +2,7 @@ source: tui/src/resume_picker.rs expression: snapshot --- - Updated Branch CWD Conversation - 42 seconds ago - - Fix resume picker timestamps -> 35 minutes ago - - Investigate lazy pagination cap - 2 hours ago - - Explain the codebase + Created at Updated at Branch CWD Conversation + 16 minutes ago 42 seconds ago - - Fix resume picker timestamps +> 1 hour ago 35 minutes ago - - Investigate lazy pagination cap + 2 hours ago 2 hours ago - - Explain the codebase diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_thread_names.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_thread_names.snap index e9bca47d402..d001fff0b9f 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_thread_names.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_thread_names.snap @@ -1,8 +1,7 @@ --- source: tui/src/resume_picker.rs -assertion_line: 1683 expression: snapshot --- - Updated Branch CWD Conversation -> 2 days ago - - Keep this for now - 3 days ago - - Named thread + Created at Updated at Branch CWD Conversation +> - 2 days ago - - Keep this for now + - 3 days ago - - Named thread