tui: add branch to 'codex resume', filter by cwd (#6232)

By default, show only sessions that shared a cwd with the current cwd.
`--all` shows all sessions in all cwds. Also, show the branch name from
the rollout metadata.

<img width="1091" height="638" alt="Screenshot 2025-11-04 at 3 30 47 PM"
src="https://github.com/user-attachments/assets/aae90308-6115-455f-aff7-22da5f1d9681"
/>
This commit is contained in:
Jeremy Rose
2025-11-18 16:47:37 -08:00
committed by GitHub
parent b952bd2649
commit 526eb3ff82
6 changed files with 227 additions and 50 deletions

View File

@@ -134,6 +134,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,
}
@@ -448,6 +452,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
Some(Subcommand::Resume(ResumeCommand {
session_id,
last,
all,
config_overrides,
})) => {
interactive = finalize_resume_interactive(
@@ -455,6 +460,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> 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?;
@@ -611,6 +617,7 @@ fn finalize_resume_interactive(
root_config_overrides: CliConfigOverrides,
session_id: Option<String>,
last: bool,
show_all: bool,
resume_cli: TuiCli,
) -> TuiCli {
// Start with the parsed interactive CLI so resume shares the same
@@ -619,6 +626,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);
@@ -702,13 +710,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 {
@@ -775,6 +791,7 @@ mod tests {
assert!(interactive.resume_picker);
assert!(!interactive.resume_last);
assert_eq!(interactive.resume_session_id, None);
assert!(!interactive.resume_show_all);
}
#[test]
@@ -783,6 +800,7 @@ mod tests {
assert!(!interactive.resume_picker);
assert!(interactive.resume_last);
assert_eq!(interactive.resume_session_id, None);
assert!(!interactive.resume_show_all);
}
#[test]
@@ -791,6 +809,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]

View File

@@ -28,6 +28,10 @@ pub struct Cli {
#[clap(skip)]
pub resume_session_id: Option<String>,
/// 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<String>,

View File

@@ -474,6 +474,7 @@ async fn run_ratatui_app(
&mut tui,
&config.codex_home,
&config.model_provider_id,
cli.resume_show_all,
)
.await?
{

View File

@@ -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;
@@ -69,11 +71,17 @@ pub async fn run_resume_picker(
tui: &mut Tui,
codex_home: &Path,
default_provider: &str,
show_all: bool,
) -> Result<ResumeSelection> {
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| {
@@ -102,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();
@@ -177,6 +187,8 @@ struct PickerState {
page_loader: PageLoader,
view_rows: Option<usize>,
default_provider: String,
show_all: bool,
filter_cwd: Option<PathBuf>,
}
struct PaginationState {
@@ -234,6 +246,8 @@ struct Row {
preview: String,
created_at: Option<DateTime<Utc>>,
updated_at: Option<DateTime<Utc>>,
cwd: Option<PathBuf>,
git_branch: Option<String>,
}
impl PickerState {
@@ -242,6 +256,8 @@ impl PickerState {
requester: FrameRequester,
page_loader: PageLoader,
default_provider: String,
show_all: bool,
filter_cwd: Option<PathBuf>,
) -> Self {
Self {
codex_home,
@@ -264,6 +280,8 @@ impl PickerState {
page_loader,
view_rows: None,
default_provider,
show_all,
filter_cwd,
}
}
@@ -418,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();
@@ -439,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;
@@ -606,6 +639,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 +650,29 @@ 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<PathBuf>, Option<String>) {
for value in head {
if let Ok(meta_line) = serde_json::from_value::<SessionMetaLine>(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 paths_match(a: &Path, b: &Path) -> bool {
if let (Ok(ca), Ok(cb)) = (a.canonicalize(), b.canonicalize()) {
return ca == cb;
}
a == b
}
fn parse_timestamp_str(ts: &str) -> Option<DateTime<Utc>> {
chrono::DateTime::parse_from_rfc3339(ts)
.map(|dt| dt.with_timezone(&Utc))
@@ -670,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);
@@ -720,10 +774,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 +786,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 {
None
} else {
Some(Span::from(format!("{created_label:<max_created_width$}")).dim())
};
let updated_span = if max_updated_width == 0 {
None
} else {
Some(Span::from(format!("{updated_label:<max_updated_width$}")).dim())
};
let branch_span = if max_branch_width == 0 {
None
} else if branch_label.is_empty() {
Some(
Span::from(format!(
"{empty:<width$}",
empty = "-",
width = max_branch_width
))
.dim(),
)
} else {
Some(Span::from(format!("{branch_label:<max_branch_width$}")).cyan())
};
let cwd_span = if max_cwd_width == 0 {
None
} else if cwd_label.is_empty() {
Some(
Span::from(format!(
"{empty:<width$}",
empty = "-",
width = max_cwd_width
))
.dim(),
)
} else {
Some(Span::from(format!("{cwd_label:<max_cwd_width$}")).dim())
};
let mut preview_width = area.width as usize;
preview_width = preview_width.saturating_sub(marker_width);
if max_created_width > 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<Span> = 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());
}
@@ -844,12 +930,6 @@ fn human_time_ago(ts: DateTime<Utc>) -> 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),
@@ -868,15 +948,6 @@ fn render_column_headers(
}
let mut spans: Vec<Span> = vec![" ".into()];
if metrics.max_created_width > 0 {
let label = format!(
"{text:<width$}",
text = "Created",
width = metrics.max_created_width
);
spans.push(Span::from(label).bold());
spans.push(" ".into());
}
if metrics.max_updated_width > 0 {
let label = format!(
"{text:<width$}",
@@ -886,32 +957,88 @@ fn render_column_headers(
spans.push(Span::from(label).bold());
spans.push(" ".into());
}
if metrics.max_branch_width > 0 {
let label = format!(
"{text:<width$}",
text = "Branch",
width = metrics.max_branch_width
);
spans.push(Span::from(label).bold());
spans.push(" ".into());
}
if metrics.max_cwd_width > 0 {
let label = format!(
"{text:<width$}",
text = "CWD",
width = metrics.max_cwd_width
);
spans.push(Span::from(label).bold());
spans.push(" ".into());
}
spans.push("Conversation".bold());
frame.render_widget_ref(Line::from(spans), area);
}
struct ColumnMetrics {
max_created_width: usize,
max_updated_width: usize,
labels: Vec<(String, String)>,
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 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();
}
if max <= 1 {
return "".to_string();
}
let tail_len = max - 1;
let tail: String = s
.chars()
.rev()
.take(tail_len)
.collect::<String>()
.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 = if include_cwd {
UnicodeWidthStr::width("CWD")
} else {
0
};
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 = 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()));
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,
}
}
@@ -1088,6 +1215,8 @@ mod tests {
FrameRequester::test_dummy(),
loader,
String::from("openai"),
true,
None,
);
let now = Utc::now();
@@ -1097,18 +1226,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();
@@ -1118,7 +1253,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;
@@ -1148,6 +1283,8 @@ mod tests {
FrameRequester::test_dummy(),
loader,
String::from("openai"),
true,
None,
);
state.reset_pagination();
@@ -1214,6 +1351,8 @@ mod tests {
FrameRequester::test_dummy(),
loader,
String::from("openai"),
true,
None,
);
state.reset_pagination();
state.ingest_page(page(
@@ -1243,6 +1382,8 @@ mod tests {
FrameRequester::test_dummy(),
loader,
String::from("openai"),
true,
None,
);
let mut items = Vec::new();
@@ -1291,6 +1432,8 @@ mod tests {
FrameRequester::test_dummy(),
loader,
String::from("openai"),
true,
None,
);
let mut items = Vec::new();
@@ -1335,6 +1478,8 @@ mod tests {
FrameRequester::test_dummy(),
loader,
String::from("openai"),
true,
None,
);
state.reset_pagination();
state.ingest_page(page(

View File

@@ -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

View File

@@ -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 <SESSION_ID>` (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: