From 6b10e186c4d1b544745d241416c9d94bb83a9fef Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:05:07 -0700 Subject: [PATCH] Add non-interactive resume filter option (#15339) ## Summary - add `codex resume --include-non-interactive` to include non-interactive sessions in the picker and `--last` - keep current-provider and cwd filtering behavior unchanged - replace the picker API boolean with a `SessionSourceFilter` enum to avoid a boolean trap ## Tests - `cargo test -p codex-cli` - `cargo test -p codex-tui` - `just fmt` - `just fix -p codex-cli` - `just fix -p codex-tui` --- codex-rs/cli/src/main.rs | 19 ++++++++++++++ codex-rs/tui/src/app.rs | 1 + codex-rs/tui/src/cli.rs | 4 +++ codex-rs/tui/src/lib.rs | 21 +++++++++++++-- codex-rs/tui/src/resume_picker.rs | 43 +++++++++++++++++++++++++++---- 5 files changed, 81 insertions(+), 7 deletions(-) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 8446e457b..82ede6932 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -208,6 +208,10 @@ struct ResumeCommand { #[arg(long = "all", default_value_t = false)] all: bool, + /// Include non-interactive sessions in the resume picker and --last selection. + #[arg(long = "include-non-interactive", default_value_t = false)] + include_non_interactive: bool, + #[clap(flatten)] remote: InteractiveRemoteOptions, @@ -691,6 +695,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { session_id, last, all, + include_non_interactive, remote, config_overrides, })) => { @@ -700,6 +705,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { session_id, last, all, + include_non_interactive, config_overrides, ); let exit_info = run_interactive_tui( @@ -1169,6 +1175,7 @@ fn finalize_resume_interactive( session_id: Option, last: bool, show_all: bool, + include_non_interactive: bool, resume_cli: TuiCli, ) -> TuiCli { // Start with the parsed interactive CLI so resume shares the same @@ -1178,6 +1185,7 @@ fn finalize_resume_interactive( interactive.resume_last = last; interactive.resume_session_id = resume_session_id; interactive.resume_show_all = show_all; + interactive.resume_include_non_interactive = include_non_interactive; // Merge resume-scoped flags and overrides with highest precedence. merge_interactive_cli_flags(&mut interactive, resume_cli); @@ -1290,6 +1298,7 @@ mod tests { session_id, last, all, + include_non_interactive, remote: _, config_overrides: resume_cli, }) = subcommand.expect("resume present") @@ -1303,6 +1312,7 @@ mod tests { session_id, last, all, + include_non_interactive, resume_cli, ) } @@ -1498,6 +1508,15 @@ mod tests { assert!(interactive.resume_show_all); } + #[test] + fn resume_include_non_interactive_flag_sets_source_filter_override() { + let interactive = + finalize_resume_from_args(["codex", "resume", "--include-non-interactive"].as_ref()); + + assert!(interactive.resume_picker); + assert!(interactive.resume_include_non_interactive); + } + #[test] fn resume_merges_option_flags_and_full_auto() { let interactive = finalize_resume_from_args( diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 43ccc7865..a60652027 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2819,6 +2819,7 @@ impl App { tui, &self.config, /*show_all*/ false, + crate::resume_picker::SessionSourceFilter::InteractiveOnly, ) .await? { diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index 86bea97ab..e3087af2a 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -32,6 +32,10 @@ pub struct Cli { #[clap(skip)] pub resume_show_all: bool, + /// Internal: include non-interactive sessions in resume listings. + #[clap(skip)] + pub resume_include_non_interactive: bool, + // Internal controls set by the top-level `codex fork` subcommand. // These are not exposed as user flags on the base `codex` command. #[clap(skip)] diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 2fcf2e3dc..43182b524 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -845,12 +845,17 @@ async fn run_ratatui_app( } else { Some(config.cwd.as_path()) }; + let allowed_sources = if cli.resume_include_non_interactive { + &[][..] + } else { + INTERACTIVE_SESSION_SOURCES.as_slice() + }; match RolloutRecorder::find_latest_thread_path( &config, /*page_size*/ 1, /*cursor*/ None, ThreadSortKey::UpdatedAt, - INTERACTIVE_SESSION_SOURCES.as_slice(), + allowed_sources, Some(provider_filter.as_slice()), &config.model_provider_id, filter_cwd, @@ -888,7 +893,19 @@ async fn run_ratatui_app( _ => resume_picker::SessionSelection::StartFresh, } } else if cli.resume_picker { - match resume_picker::run_resume_picker(&mut tui, &config, cli.resume_show_all).await? { + let source_filter = if cli.resume_include_non_interactive { + resume_picker::SessionSourceFilter::IncludeNonInteractive + } else { + resume_picker::SessionSourceFilter::InteractiveOnly + }; + match resume_picker::run_resume_picker( + &mut tui, + &config, + cli.resume_show_all, + source_filter, + ) + .await? + { resume_picker::SessionSelection::Exit => { terminal_restore_guard.restore_silently(); session_log::log_session_end(); diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 1a74fcd83..1ac3492eb 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -60,6 +60,21 @@ pub enum SessionPickerAction { Fork, } +#[derive(Clone, Copy, Debug)] +pub enum SessionSourceFilter { + InteractiveOnly, + IncludeNonInteractive, +} + +impl SessionSourceFilter { + fn allowed_sources(self) -> &'static [codex_protocol::protocol::SessionSource] { + match self { + SessionSourceFilter::InteractiveOnly => INTERACTIVE_SESSION_SOURCES.as_slice(), + SessionSourceFilter::IncludeNonInteractive => &[], + } + } +} + impl SessionPickerAction { fn title(self) -> &'static str { match self { @@ -116,15 +131,24 @@ enum BackgroundEvent { /// 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). +/// 1. Provider and source filtering at the backend (interactive sessions for the +/// current model provider by default, optionally including non-interactive +/// sessions). /// 2. Working-directory filtering at the picker (unless `--all` is passed). pub async fn run_resume_picker( tui: &mut Tui, config: &Config, show_all: bool, + source_filter: SessionSourceFilter, ) -> Result { - run_session_picker(tui, config, show_all, SessionPickerAction::Resume).await + run_session_picker( + tui, + config, + show_all, + source_filter, + SessionPickerAction::Resume, + ) + .await } pub async fn run_fork_picker( @@ -132,13 +156,21 @@ pub async fn run_fork_picker( config: &Config, show_all: bool, ) -> Result { - run_session_picker(tui, config, show_all, SessionPickerAction::Fork).await + run_session_picker( + tui, + config, + show_all, + SessionSourceFilter::InteractiveOnly, + SessionPickerAction::Fork, + ) + .await } async fn run_session_picker( tui: &mut Tui, config: &Config, show_all: bool, + source_filter: SessionSourceFilter, action: SessionPickerAction, ) -> Result { let alt = AltScreenGuard::enter(tui); @@ -154,6 +186,7 @@ async fn run_session_picker( let config = config.clone(); let loader_tx = bg_tx.clone(); + let allowed_sources = source_filter.allowed_sources(); let page_loader: PageLoader = Arc::new(move |request: PageLoadRequest| { let tx = loader_tx.clone(); let config = config.clone(); @@ -164,7 +197,7 @@ async fn run_session_picker( PAGE_SIZE, request.cursor.as_ref(), request.sort_key, - INTERACTIVE_SESSION_SOURCES.as_slice(), + allowed_sources, Some(provider_filter.as_slice()), request.default_provider.as_str(), /*search_term*/ None,