From 862dd9f6c2ae69aa2f73477463c1d328821409ec Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 4 Feb 2026 13:05:54 -0300 Subject: [PATCH 1/5] fix(tui): resume session picker order items by updated at --- codex-rs/tui/src/resume_picker.rs | 117 +++++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index e80c4ad7932..e8be5ef54ac 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -160,7 +160,7 @@ async fn run_session_picker( &request.codex_home, PAGE_SIZE, request.cursor.as_ref(), - ThreadSortKey::CreatedAt, + resume_picker_sort_key(), INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), request.default_provider.as_str(), @@ -223,6 +223,10 @@ async fn run_session_picker( Ok(SessionSelection::StartFresh) } +fn resume_picker_sort_key() -> ThreadSortKey { + ThreadSortKey::UpdatedAt +} + /// RAII guard that ensures we leave the alt-screen on scope exit. struct AltScreenGuard<'a> { tui: &'a mut Tui, @@ -1191,11 +1195,23 @@ fn calculate_column_metrics(rows: &[Row], include_cwd: bool) -> ColumnMetrics { 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 +1258,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, + resume_picker_sort_key(), + 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![ From 5d2ae1bd25c71fe058b4467fbcb930f7f2af46cb Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 4 Feb 2026 15:07:05 -0300 Subject: [PATCH 2/5] feat(tui): add sort order toggle to resume picker - Add Tab key to toggle between creation time and last updated sorting - Default to creation time (CreatedAt) instead of last updated (UpdatedAt) - Display current sort key in picker header - Add keyboard hint for toggle functionality --- codex-rs/tui/src/resume_picker.rs | 101 ++++++++++++++++-- ...e_picker__tests__resume_picker_screen.snap | 5 +- 2 files changed, 94 insertions(+), 12 deletions(-) diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index e8be5ef54ac..e4ce7efeddc 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; @@ -160,7 +161,7 @@ async fn run_session_picker( &request.codex_home, PAGE_SIZE, request.cursor.as_ref(), - resume_picker_sort_key(), + request.sort_key, INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), request.default_provider.as_str(), @@ -223,8 +224,11 @@ async fn run_session_picker( Ok(SessionSelection::StartFresh) } -fn resume_picker_sort_key() -> ThreadSortKey { - ThreadSortKey::UpdatedAt +fn sort_key_label(sort_key: ThreadSortKey) -> &'static str { + match sort_key { + ThreadSortKey::CreatedAt => "Creation", + ThreadSortKey::UpdatedAt => "Last updated", + } } /// RAII guard that ensures we leave the alt-screen on scope exit. @@ -264,6 +268,7 @@ struct PickerState { show_all: bool, filter_cwd: Option, action: SessionPickerAction, + sort_key: ThreadSortKey, thread_name_cache: HashMap>, } @@ -380,6 +385,7 @@ impl PickerState { show_all, filter_cwd, action, + sort_key: ThreadSortKey::CreatedAt, thread_name_cache: HashMap::new(), } } @@ -436,6 +442,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(); @@ -463,13 +473,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(); @@ -477,8 +495,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, }); } @@ -749,6 +768,7 @@ impl PickerState { request_token, search_token, default_provider: self.default_provider.clone(), + sort_key: self.sort_key, }); } @@ -763,6 +783,14 @@ impl PickerState { self.next_search_token = self.next_search_token.wrapping_add(1); token } + + 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 { @@ -861,7 +889,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() { @@ -889,6 +925,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(), @@ -1337,7 +1376,7 @@ mod tests { tempdir.path(), PAGE_SIZE, None, - resume_picker_sort_key(), + ThreadSortKey::UpdatedAt, INTERACTIVE_SESSION_SOURCES, Some(&[String::from("openai")]), "openai", @@ -1667,7 +1706,13 @@ 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(), + "Creation".magenta(), + ]), header, ); @@ -1685,6 +1730,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); @@ -1894,6 +1942,41 @@ mod tests { assert!(guard[0].search_token.is_none()); } + #[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..d0dbf136429 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,9 +1,8 @@ --- source: tui/src/resume_picker.rs -assertion_line: 1438 expression: snapshot --- -Resume a previous session +Resume a previous session Sort: Creation Type to search Updated Branch CWD Conversation No sessions yet @@ -11,4 +10,4 @@ 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 From f6f4342c226d28128a862af8f29a928662b12293 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Thu, 5 Feb 2026 11:01:41 -0300 Subject: [PATCH 3/5] docs(tui): document resume picker sorting and pagination logic Add comprehensive documentation for the resume picker's core mechanisms: - Module-level overview explaining table display, sort toggling, and pagination - Document the two-layer filtering approach (backend + picker) - Explain toggle_sort_key's full reload behavior - Add helper function and struct documentation for column metrics This is a documentation-only change with no functional modifications. --- codex-rs/tui/src/resume_picker.rs | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index e4ce7efeddc..4c41058fb27 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -100,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, @@ -224,6 +236,7 @@ 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 => "Creation", @@ -784,6 +797,11 @@ impl PickerState { 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, @@ -1166,10 +1184,15 @@ fn render_column_headers( frame.render_widget_ref(Line::from(spans), area); } +/// Pre-computed column widths and formatted labels for all visible rows. +/// +/// Widths are measured in Unicode display width (not byte length) so columns +/// align correctly when labels contain non-ASCII characters. struct ColumnMetrics { max_updated_width: usize, max_branch_width: usize, max_cwd_width: usize, + /// (updated_label, branch_label, cwd_label) per row. labels: Vec<(String, String, String)>, } From 25b77c6087d68dc85799d04e0a06369c4dca16a3 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Thu, 5 Feb 2026 11:05:15 -0300 Subject: [PATCH 4/5] feat(tui): show both timestamp columns with responsive hiding Display both "Created at" and "Updated at" columns in the resume picker, improving visibility of session history. When terminal width is limited, intelligently hide the inactive timestamp column (whichever doesn't match the current sort key) to preserve space for the conversation preview. Changes: - Add Created at column alongside Updated at - Implement ColumnVisibility logic to show/hide columns based on width - Update column labels from "Creation"/"Last updated" to "Created at"/"Updated at" - Add format_created_label() helper function - Add column_visibility_hides_extra_date_column_when_narrow() test - Update snapshots to reflect new column layout The picker now shows maximum information when space allows, while gracefully degrading to a single timestamp column on narrow terminals. --- codex-rs/tui/src/resume_picker.rs | 200 +++++++++++++++--- ...e_picker__tests__resume_picker_screen.snap | 4 +- ...me_picker__tests__resume_picker_table.snap | 8 +- ...er__tests__resume_picker_thread_names.snap | 7 +- 4 files changed, 184 insertions(+), 35 deletions(-) diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 4c41058fb27..d6ccfde031b 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -239,8 +239,8 @@ async fn run_session_picker( /// 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 => "Creation", - ThreadSortKey::UpdatedAt => "Last updated", + ThreadSortKey::CreatedAt => "Created at", + ThreadSortKey::UpdatedAt => "Updated at", } } @@ -928,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 @@ -979,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() @@ -991,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()); @@ -1143,26 +1160,45 @@ fn format_updated_label(row: &Row) -> String { } } +fn format_created_label(row: &Row) -> String { + match (row.created_at, row.updated_at) { + (Some(created), _) => human_time_ago(created), + (None, Some(updated)) => human_time_ago(updated), + (None, 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 { @@ -1216,8 +1266,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") @@ -1226,6 +1277,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); @@ -1239,13 +1291,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, @@ -1253,6 +1307,58 @@ 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::*; @@ -1586,7 +1692,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"); @@ -1734,14 +1840,14 @@ mod tests { " ".into(), "Sort:".dim(), " ".into(), - "Creation".magenta(), + "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![ @@ -1855,7 +1961,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"); @@ -1965,6 +2071,50 @@ 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())); 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 d0dbf136429..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 @@ -2,9 +2,9 @@ source: tui/src/resume_picker.rs expression: snapshot --- -Resume a previous session Sort: Creation +Resume a previous session Sort: Created at Type to search - Updated Branch CWD Conversation + Created at Updated at Branch CWD Conversation No sessions yet 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..72a4c8b88bb 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 2 days ago - - Keep this for now + 3 days ago 3 days ago - - Named thread From a1be3903a2fdbd968a5ea1efd2efbe43fde7938e Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Thu, 5 Feb 2026 13:24:28 -0300 Subject: [PATCH 5/5] fix(tui): show '-' when created_at is missing --- codex-rs/tui/src/resume_picker.rs | 7 +++---- ...__resume_picker__tests__resume_picker_thread_names.snap | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index d6ccfde031b..e1bb35d712d 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -1161,10 +1161,9 @@ fn format_updated_label(row: &Row) -> String { } fn format_created_label(row: &Row) -> String { - match (row.created_at, row.updated_at) { - (Some(created), _) => human_time_ago(created), - (None, Some(updated)) => human_time_ago(updated), - (None, None) => "-".to_string(), + match row.created_at { + Some(created) => human_time_ago(created), + None => "-".to_string(), } } 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 72a4c8b88bb..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 @@ -3,5 +3,5 @@ source: tui/src/resume_picker.rs expression: snapshot --- Created at Updated at Branch CWD Conversation -> 2 days ago 2 days ago - - Keep this for now - 3 days ago 3 days ago - - Named thread +> - 2 days ago - - Keep this for now + - 3 days ago - - Named thread