From 677b96f901baa2345343ebaf3cc51256f2cbbd57 Mon Sep 17 00:00:00 2001 From: Jeremy Rose Date: Mon, 3 Nov 2025 15:27:28 -0800 Subject: [PATCH 1/3] wip --- codex-rs/tui/src/resume_picker.rs | 151 ++++++++++++++++++++++++------ docs/getting-started.md | 1 + 2 files changed, 124 insertions(+), 28 deletions(-) diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 83e3a1aef4..6167be1e5b 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -26,12 +26,14 @@ use tokio_stream::StreamExt; use tokio_stream::wrappers::UnboundedReceiverStream; use unicode_width::UnicodeWidthStr; +use crate::diff_render::display_path_for; 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; +use codex_protocol::protocol::SessionMetaLine; const PAGE_SIZE: usize = 25; const LOAD_NEAR_THRESHOLD: usize = 5; @@ -234,6 +236,8 @@ struct Row { preview: String, created_at: Option>, updated_at: Option>, + cwd: Option, + git_branch: Option, } impl PickerState { @@ -606,6 +610,7 @@ fn head_to_row(item: &ConversationItem) -> Row { .and_then(parse_timestamp_str) .or(created_at); + let (cwd, git_branch) = extract_session_meta_from_head(&item.head); let preview = preview_from_head(&item.head) .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) @@ -616,9 +621,22 @@ fn head_to_row(item: &ConversationItem) -> Row { preview, created_at, updated_at, + cwd, + git_branch, } } +fn extract_session_meta_from_head(head: &[serde_json::Value]) -> (Option, Option) { + for value in head { + if let Ok(meta_line) = serde_json::from_value::(value.clone()) { + let cwd = Some(meta_line.meta.cwd); + let git_branch = meta_line.git.and_then(|git| git.branch); + return (cwd, git_branch); + } + } + (None, None) +} + fn parse_timestamp_str(ts: &str) -> Option> { chrono::DateTime::parse_from_rfc3339(ts) .map(|dt| dt.with_timezone(&Utc)) @@ -720,10 +738,11 @@ fn render_list( 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; + let max_branch_width = metrics.max_branch_width; + let max_cwd_width = metrics.max_cwd_width; - for (idx, (row, (created_label, updated_label))) in rows[start..end] + for (idx, (row, (updated_label, branch_label, cwd_label))) in rows[start..end] .iter() .zip(labels[start..end].iter()) .enumerate() @@ -731,38 +750,69 @@ fn render_list( 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 { + let updated_span = if max_updated_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 max_branch_width > 0 { + preview_width = preview_width.saturating_sub(max_branch_width + 2); + } + if max_cwd_width > 0 { + 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; 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 let Some(branch) = branch_span { + spans.push(branch); + spans.push(" ".into()); + } + if let Some(cwd) = cwd_span { + spans.push(cwd); + spans.push(" ".into()); + } if add_leading_gap { spans.push(" ".into()); } @@ -868,20 +918,29 @@ fn render_column_headers( } let mut spans: Vec = vec![" ".into()]; - if metrics.max_created_width > 0 { + if metrics.max_updated_width > 0 { let label = format!( "{text: 0 { + if metrics.max_branch_width > 0 { let label = format!( "{text: 0 { + let label = format!( + "{text:, + max_branch_width: usize, + max_cwd_width: usize, + labels: Vec<(String, String, String)>, } 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"); + fn right_elide(s: &str, max: usize) -> String { + if s.chars().count() <= max { + return s.to_string(); + } + if max <= 1 { + return "…".to_string(); + } + let tail_len = max - 1; + let tail: String = s + .chars() + .rev() + .take(tail_len) + .collect::() + .chars() + .rev() + .collect(); + format!("…{tail}") + } + + let mut labels: Vec<(String, String, String)> = Vec::with_capacity(rows.len()); let mut max_updated_width = UnicodeWidthStr::width("Updated"); + let mut max_branch_width = UnicodeWidthStr::width("Branch"); + let mut max_cwd_width = UnicodeWidthStr::width("CWD"); 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())); + let branch_raw = row.git_branch.clone().unwrap_or_default(); + let branch = right_elide(&branch_raw, 24); + let cwd_raw = row + .cwd + .as_ref() + .map(|p| display_path_for(p, std::path::Path::new("/"))) + .unwrap_or_default(); + let cwd = right_elide(&cwd_raw, 24); max_updated_width = max_updated_width.max(UnicodeWidthStr::width(updated.as_str())); - labels.push((created, updated)); + 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)); } ColumnMetrics { - max_created_width, max_updated_width, + max_branch_width, + max_cwd_width, labels, } } @@ -1097,18 +1186,24 @@ mod tests { preview: String::from("Fix resume picker timestamps"), created_at: Some(now - Duration::minutes(16)), updated_at: Some(now - Duration::seconds(42)), + cwd: None, + git_branch: None, }, 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)), + cwd: None, + git_branch: None, }, 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)), + cwd: None, + git_branch: None, }, ]; state.all_rows = rows.clone(); diff --git a/docs/getting-started.md b/docs/getting-started.md index 4930061c27..83c66c5724 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -21,6 +21,7 @@ Key flags: `--model/-m`, `--ask-for-approval/-a`. - Run `codex resume` to display the session picker UI - Resume most recent: `codex resume --last` - Resume by id: `codex resume ` (You can get session ids from /status or `~/.codex/sessions/`) +- The picker shows the session's original working directory and, when available, the Git branch it was recorded on Examples: From add27565b252ef97a5150d68ae9280115a052d3d Mon Sep 17 00:00:00 2001 From: Jeremy Rose Date: Tue, 4 Nov 2025 15:24:55 -0800 Subject: [PATCH 2/3] filter --- codex-rs/cli/src/main.rs | 28 ++++++- codex-rs/tui/src/cli.rs | 4 + codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/resume_picker.rs | 84 +++++++++++++++---- ...me_picker__tests__resume_picker_table.snap | 8 +- 5 files changed, 106 insertions(+), 19 deletions(-) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 929fd9e785..20a3909400 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -133,6 +133,10 @@ struct ResumeCommand { #[arg(long = "last", default_value_t = false, conflicts_with = "session_id")] last: bool, + /// Show all sessions (disables cwd filtering and shows CWD column). + #[arg(long = "all", default_value_t = false)] + all: bool, + #[clap(flatten)] config_overrides: TuiCli, } @@ -393,6 +397,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() Some(Subcommand::Resume(ResumeCommand { session_id, last, + all, config_overrides, })) => { interactive = finalize_resume_interactive( @@ -400,6 +405,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() root_config_overrides.clone(), session_id, last, + all, config_overrides, ); let exit_info = codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?; @@ -559,6 +565,7 @@ fn finalize_resume_interactive( root_config_overrides: CliConfigOverrides, session_id: Option, last: bool, + show_all: bool, resume_cli: TuiCli, ) -> TuiCli { // Start with the parsed interactive CLI so resume shares the same @@ -567,6 +574,7 @@ fn finalize_resume_interactive( interactive.resume_picker = resume_session_id.is_none() && !last; interactive.resume_last = last; interactive.resume_session_id = resume_session_id; + interactive.resume_show_all = show_all; // Merge resume-scoped flags and overrides with highest precedence. merge_resume_cli_flags(&mut interactive, resume_cli); @@ -650,13 +658,21 @@ mod tests { let Subcommand::Resume(ResumeCommand { session_id, last, + all, config_overrides: resume_cli, }) = subcommand.expect("resume present") else { unreachable!() }; - finalize_resume_interactive(interactive, root_overrides, session_id, last, resume_cli) + finalize_resume_interactive( + interactive, + root_overrides, + session_id, + last, + all, + resume_cli, + ) } fn sample_exit_info(conversation: Option<&str>) -> AppExitInfo { @@ -723,6 +739,7 @@ mod tests { assert!(interactive.resume_picker); assert!(!interactive.resume_last); assert_eq!(interactive.resume_session_id, None); + assert!(!interactive.resume_show_all); } #[test] @@ -731,6 +748,7 @@ mod tests { assert!(!interactive.resume_picker); assert!(interactive.resume_last); assert_eq!(interactive.resume_session_id, None); + assert!(!interactive.resume_show_all); } #[test] @@ -739,6 +757,14 @@ mod tests { assert!(!interactive.resume_picker); assert!(!interactive.resume_last); assert_eq!(interactive.resume_session_id.as_deref(), Some("1234")); + assert!(!interactive.resume_show_all); + } + + #[test] + fn resume_all_flag_sets_show_all() { + let interactive = finalize_from_args(["codex", "resume", "--all"].as_ref()); + assert!(interactive.resume_picker); + assert!(interactive.resume_show_all); } #[test] diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index d86040b5c9..c1c3f74b07 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -28,6 +28,10 @@ pub struct Cli { #[clap(skip)] pub resume_session_id: Option, + /// Internal: show all sessions (disables cwd filtering and shows CWD column). + #[clap(skip)] + pub resume_show_all: bool, + /// Model the agent should use. #[arg(long, short = 'm')] pub model: Option, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 99312ec198..1a509fde3b 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -427,6 +427,7 @@ async fn run_ratatui_app( &mut tui, &config.codex_home, &config.model_provider_id, + cli.resume_show_all, ) .await? { diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 6167be1e5b..7fe55948f8 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -71,11 +71,17 @@ pub async fn run_resume_picker( tui: &mut Tui, codex_home: &Path, default_provider: &str, + show_all: bool, ) -> Result { let alt = AltScreenGuard::enter(tui); 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 loader_tx = bg_tx.clone(); let page_loader: PageLoader = Arc::new(move |request: PageLoadRequest| { @@ -104,6 +110,8 @@ pub async fn run_resume_picker( alt.tui.frame_requester(), page_loader, default_provider.clone(), + show_all, + filter_cwd, ); state.load_initial_page().await?; state.request_frame(); @@ -179,6 +187,8 @@ struct PickerState { page_loader: PageLoader, view_rows: Option, default_provider: String, + show_all: bool, + filter_cwd: Option, } struct PaginationState { @@ -246,6 +256,8 @@ impl PickerState { requester: FrameRequester, page_loader: PageLoader, default_provider: String, + show_all: bool, + filter_cwd: Option, ) -> Self { Self { codex_home, @@ -268,6 +280,8 @@ impl PickerState { page_loader, view_rows: None, default_provider, + show_all, + filter_cwd, } } @@ -422,13 +436,15 @@ impl PickerState { } fn apply_filter(&mut self) { + let base_iter = self + .all_rows + .iter() + .filter(|row| self.row_matches_filter(row)); if self.query.is_empty() { - self.filtered_rows = self.all_rows.clone(); + self.filtered_rows = base_iter.cloned().collect(); } else { let q = self.query.to_lowercase(); - self.filtered_rows = self - .all_rows - .iter() + self.filtered_rows = base_iter .filter(|r| r.preview.to_lowercase().contains(&q)) .cloned() .collect(); @@ -443,6 +459,19 @@ impl PickerState { self.request_frame(); } + fn row_matches_filter(&self, row: &Row) -> bool { + if self.show_all { + return true; + } + let Some(filter_cwd) = self.filter_cwd.as_ref() else { + return true; + }; + let Some(row_cwd) = row.cwd.as_ref() else { + return false; + }; + paths_match(row_cwd, filter_cwd) + } + fn set_query(&mut self, new_query: String) { if self.query == new_query { return; @@ -637,6 +666,13 @@ fn extract_session_meta_from_head(head: &[serde_json::Value]) -> (Option bool { + if let (Ok(ca), Ok(cb)) = (a.canonicalize(), b.canonicalize()) { + return ca == cb; + } + a == b +} + fn parse_timestamp_str(ts: &str) -> Option> { chrono::DateTime::parse_from_rfc3339(ts) .map(|dt| dt.with_timezone(&Utc)) @@ -688,7 +724,7 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> { }; frame.render_widget_ref(Line::from(q), search); - let metrics = calculate_column_metrics(&state.filtered_rows); + let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all); // Column headers and list render_column_headers(frame, columns, &metrics); @@ -956,7 +992,7 @@ struct ColumnMetrics { labels: Vec<(String, String, String)>, } -fn calculate_column_metrics(rows: &[Row]) -> ColumnMetrics { +fn calculate_column_metrics(rows: &[Row], include_cwd: bool) -> ColumnMetrics { fn right_elide(s: &str, max: usize) -> String { if s.chars().count() <= max { return s.to_string(); @@ -979,18 +1015,26 @@ fn calculate_column_metrics(rows: &[Row]) -> ColumnMetrics { let mut labels: Vec<(String, String, String)> = Vec::with_capacity(rows.len()); let mut max_updated_width = UnicodeWidthStr::width("Updated"); let mut max_branch_width = UnicodeWidthStr::width("Branch"); - let mut max_cwd_width = UnicodeWidthStr::width("CWD"); + let mut max_cwd_width = if include_cwd { + UnicodeWidthStr::width("CWD") + } else { + 0 + }; for row in rows { let updated = format_updated_label(row); let branch_raw = row.git_branch.clone().unwrap_or_default(); let branch = right_elide(&branch_raw, 24); - let cwd_raw = row - .cwd - .as_ref() - .map(|p| display_path_for(p, std::path::Path::new("/"))) - .unwrap_or_default(); - let cwd = right_elide(&cwd_raw, 24); + let cwd = if include_cwd { + let cwd_raw = row + .cwd + .as_ref() + .map(|p| display_path_for(p, std::path::Path::new("/"))) + .unwrap_or_default(); + right_elide(&cwd_raw, 24) + } else { + String::new() + }; 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())); @@ -1177,6 +1221,8 @@ mod tests { FrameRequester::test_dummy(), loader, String::from("openai"), + true, + None, ); let now = Utc::now(); @@ -1213,7 +1259,7 @@ mod tests { state.scroll_top = 0; state.update_view_rows(3); - let metrics = calculate_column_metrics(&state.filtered_rows); + let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all); let width: u16 = 80; let height: u16 = 6; @@ -1243,6 +1289,8 @@ mod tests { FrameRequester::test_dummy(), loader, String::from("openai"), + true, + None, ); state.reset_pagination(); @@ -1309,6 +1357,8 @@ mod tests { FrameRequester::test_dummy(), loader, String::from("openai"), + true, + None, ); state.reset_pagination(); state.ingest_page(page( @@ -1338,6 +1388,8 @@ mod tests { FrameRequester::test_dummy(), loader, String::from("openai"), + true, + None, ); let mut items = Vec::new(); @@ -1386,6 +1438,8 @@ mod tests { FrameRequester::test_dummy(), loader, String::from("openai"), + true, + None, ); let mut items = Vec::new(); @@ -1430,6 +1484,8 @@ mod tests { FrameRequester::test_dummy(), loader, String::from("openai"), + true, + None, ); state.reset_pagination(); state.ingest_page(page( 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 0883651432..c029043219 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 --- - Created Updated 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 + Updated Branch CWD Conversation + 42 seconds ago - - Fix resume picker timestamps +> 35 minutes ago - - Investigate lazy pagination cap + 2 hours ago - - Explain the codebase From 78b0a0ebcc3b03278113731a5457476ffa1ddc41 Mon Sep 17 00:00:00 2001 From: Jeremy Rose Date: Tue, 4 Nov 2025 21:15:45 -0800 Subject: [PATCH 3/3] lint --- codex-rs/tui/src/resume_picker.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 7fe55948f8..6a21496d34 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -930,12 +930,6 @@ fn human_time_ago(ts: DateTime) -> String { } } -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),