From 4223948cf5b1e2b301da737dd9a0a614354e5bab Mon Sep 17 00:00:00 2001 From: Anton Panasenko Date: Mon, 12 Jan 2026 10:09:11 -0800 Subject: [PATCH] feat: wire fork to codex cli (#8994) ## Summary - add `codex fork` subcommand and `/fork` slash command mirroring resume - extend session picker to support fork/resume actions with dynamic labels in tui/tui2 - wire fork selection flow through tui bootstraps and add fork-related tests --- codex-rs/cli/src/main.rs | 176 ++++++++++++++++++++++---- codex-rs/tui/src/app.rs | 115 +++++++++++++++-- codex-rs/tui/src/app_event.rs | 3 + codex-rs/tui/src/chatwidget.rs | 3 + codex-rs/tui/src/chatwidget/tests.rs | 9 ++ codex-rs/tui/src/cli.rs | 17 +++ codex-rs/tui/src/lib.rs | 102 +++++++++++---- codex-rs/tui/src/resume_picker.rs | 96 ++++++++++++-- codex-rs/tui/src/slash_command.rs | 3 + codex-rs/tui2/src/app.rs | 114 +++++++++++++++-- codex-rs/tui2/src/app_event.rs | 3 + codex-rs/tui2/src/chatwidget.rs | 3 + codex-rs/tui2/src/chatwidget/tests.rs | 9 ++ codex-rs/tui2/src/cli.rs | 21 +++ codex-rs/tui2/src/lib.rs | 105 +++++++++++---- codex-rs/tui2/src/resume_picker.rs | 96 ++++++++++++-- codex-rs/tui2/src/slash_command.rs | 3 + 17 files changed, 754 insertions(+), 124 deletions(-) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 11ba7cfa2..918626c71 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -119,6 +119,9 @@ enum Subcommand { /// Resume a previous interactive session (picker by default; use --last to continue the most recent). Resume(ResumeCommand), + /// Fork a previous interactive session (picker by default; use --last to fork the most recent). + Fork(ForkCommand), + /// [EXPERIMENTAL] Browse tasks from Codex Cloud and apply changes locally. #[clap(name = "cloud", alias = "cloud-tasks")] Cloud(CloudTasksCli), @@ -161,6 +164,25 @@ struct ResumeCommand { config_overrides: TuiCli, } +#[derive(Debug, Parser)] +struct ForkCommand { + /// Conversation/session id (UUID). When provided, forks this session. + /// If omitted, use --last to pick the most recent recorded session. + #[arg(value_name = "SESSION_ID")] + session_id: Option, + + /// Fork the most recent session without showing the picker. + #[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, +} + #[derive(Debug, Parser)] struct SandboxArgs { #[command(subcommand)] @@ -508,6 +530,23 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() let exit_info = run_interactive_tui(interactive, codex_linux_sandbox_exe).await?; handle_app_exit(exit_info)?; } + Some(Subcommand::Fork(ForkCommand { + session_id, + last, + all, + config_overrides, + })) => { + interactive = finalize_fork_interactive( + interactive, + root_config_overrides.clone(), + session_id, + last, + all, + config_overrides, + ); + let exit_info = run_interactive_tui(interactive, codex_linux_sandbox_exe).await?; + handle_app_exit(exit_info)?; + } Some(Subcommand::Login(mut login_cli)) => { prepend_config_flags( &mut login_cli.config_overrides, @@ -725,7 +764,7 @@ fn finalize_resume_interactive( interactive.resume_show_all = show_all; // Merge resume-scoped flags and overrides with highest precedence. - merge_resume_cli_flags(&mut interactive, resume_cli); + merge_interactive_cli_flags(&mut interactive, resume_cli); // Propagate any root-level config overrides (e.g. `-c key=value`). prepend_config_flags(&mut interactive.config_overrides, root_config_overrides); @@ -733,51 +772,77 @@ fn finalize_resume_interactive( interactive } -/// Merge flags provided to `codex resume` so they take precedence over any -/// root-level flags. Only overrides fields explicitly set on the resume-scoped +/// Build the final `TuiCli` for a `codex fork` invocation. +fn finalize_fork_interactive( + mut interactive: TuiCli, + root_config_overrides: CliConfigOverrides, + session_id: Option, + last: bool, + show_all: bool, + fork_cli: TuiCli, +) -> TuiCli { + // Start with the parsed interactive CLI so fork shares the same + // configuration surface area as `codex` without additional flags. + let fork_session_id = session_id; + interactive.fork_picker = fork_session_id.is_none() && !last; + interactive.fork_last = last; + interactive.fork_session_id = fork_session_id; + interactive.fork_show_all = show_all; + + // Merge fork-scoped flags and overrides with highest precedence. + merge_interactive_cli_flags(&mut interactive, fork_cli); + + // Propagate any root-level config overrides (e.g. `-c key=value`). + prepend_config_flags(&mut interactive.config_overrides, root_config_overrides); + + interactive +} + +/// Merge flags provided to `codex resume`/`codex fork` so they take precedence over any +/// root-level flags. Only overrides fields explicitly set on the subcommand-scoped /// CLI. Also appends `-c key=value` overrides with highest precedence. -fn merge_resume_cli_flags(interactive: &mut TuiCli, resume_cli: TuiCli) { - if let Some(model) = resume_cli.model { +fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli) { + if let Some(model) = subcommand_cli.model { interactive.model = Some(model); } - if resume_cli.oss { + if subcommand_cli.oss { interactive.oss = true; } - if let Some(profile) = resume_cli.config_profile { + if let Some(profile) = subcommand_cli.config_profile { interactive.config_profile = Some(profile); } - if let Some(sandbox) = resume_cli.sandbox_mode { + if let Some(sandbox) = subcommand_cli.sandbox_mode { interactive.sandbox_mode = Some(sandbox); } - if let Some(approval) = resume_cli.approval_policy { + if let Some(approval) = subcommand_cli.approval_policy { interactive.approval_policy = Some(approval); } - if resume_cli.full_auto { + if subcommand_cli.full_auto { interactive.full_auto = true; } - if resume_cli.dangerously_bypass_approvals_and_sandbox { + if subcommand_cli.dangerously_bypass_approvals_and_sandbox { interactive.dangerously_bypass_approvals_and_sandbox = true; } - if let Some(cwd) = resume_cli.cwd { + if let Some(cwd) = subcommand_cli.cwd { interactive.cwd = Some(cwd); } - if resume_cli.web_search { + if subcommand_cli.web_search { interactive.web_search = true; } - if !resume_cli.images.is_empty() { - interactive.images = resume_cli.images; + if !subcommand_cli.images.is_empty() { + interactive.images = subcommand_cli.images; } - if !resume_cli.add_dir.is_empty() { - interactive.add_dir.extend(resume_cli.add_dir); + if !subcommand_cli.add_dir.is_empty() { + interactive.add_dir.extend(subcommand_cli.add_dir); } - if let Some(prompt) = resume_cli.prompt { + if let Some(prompt) = subcommand_cli.prompt { interactive.prompt = Some(prompt); } interactive .config_overrides .raw_overrides - .extend(resume_cli.config_overrides.raw_overrides); + .extend(subcommand_cli.config_overrides.raw_overrides); } fn print_completion(cmd: CompletionCommand) { @@ -794,7 +859,7 @@ mod tests { use codex_protocol::ThreadId; use pretty_assertions::assert_eq; - fn finalize_from_args(args: &[&str]) -> TuiCli { + fn finalize_resume_from_args(args: &[&str]) -> TuiCli { let cli = MultitoolCli::try_parse_from(args).expect("parse"); let MultitoolCli { interactive, @@ -823,6 +888,28 @@ mod tests { ) } + fn finalize_fork_from_args(args: &[&str]) -> TuiCli { + let cli = MultitoolCli::try_parse_from(args).expect("parse"); + let MultitoolCli { + interactive, + config_overrides: root_overrides, + subcommand, + feature_toggles: _, + } = cli; + + let Subcommand::Fork(ForkCommand { + session_id, + last, + all, + config_overrides: fork_cli, + }) = subcommand.expect("fork present") + else { + unreachable!() + }; + + finalize_fork_interactive(interactive, root_overrides, session_id, last, all, fork_cli) + } + fn sample_exit_info(conversation: Option<&str>) -> AppExitInfo { let token_usage = TokenUsage { output_tokens: 2, @@ -871,7 +958,8 @@ mod tests { #[test] fn resume_model_flag_applies_when_no_root_flags() { - let interactive = finalize_from_args(["codex", "resume", "-m", "gpt-5.1-test"].as_ref()); + let interactive = + finalize_resume_from_args(["codex", "resume", "-m", "gpt-5.1-test"].as_ref()); assert_eq!(interactive.model.as_deref(), Some("gpt-5.1-test")); assert!(interactive.resume_picker); @@ -881,7 +969,7 @@ mod tests { #[test] fn resume_picker_logic_none_and_not_last() { - let interactive = finalize_from_args(["codex", "resume"].as_ref()); + let interactive = finalize_resume_from_args(["codex", "resume"].as_ref()); assert!(interactive.resume_picker); assert!(!interactive.resume_last); assert_eq!(interactive.resume_session_id, None); @@ -890,7 +978,7 @@ mod tests { #[test] fn resume_picker_logic_last() { - let interactive = finalize_from_args(["codex", "resume", "--last"].as_ref()); + let interactive = finalize_resume_from_args(["codex", "resume", "--last"].as_ref()); assert!(!interactive.resume_picker); assert!(interactive.resume_last); assert_eq!(interactive.resume_session_id, None); @@ -899,7 +987,7 @@ mod tests { #[test] fn resume_picker_logic_with_session_id() { - let interactive = finalize_from_args(["codex", "resume", "1234"].as_ref()); + let interactive = finalize_resume_from_args(["codex", "resume", "1234"].as_ref()); assert!(!interactive.resume_picker); assert!(!interactive.resume_last); assert_eq!(interactive.resume_session_id.as_deref(), Some("1234")); @@ -908,14 +996,14 @@ mod tests { #[test] fn resume_all_flag_sets_show_all() { - let interactive = finalize_from_args(["codex", "resume", "--all"].as_ref()); + let interactive = finalize_resume_from_args(["codex", "resume", "--all"].as_ref()); assert!(interactive.resume_picker); assert!(interactive.resume_show_all); } #[test] fn resume_merges_option_flags_and_full_auto() { - let interactive = finalize_from_args( + let interactive = finalize_resume_from_args( [ "codex", "resume", @@ -972,7 +1060,7 @@ mod tests { #[test] fn resume_merges_dangerously_bypass_flag() { - let interactive = finalize_from_args( + let interactive = finalize_resume_from_args( [ "codex", "resume", @@ -986,6 +1074,40 @@ mod tests { assert_eq!(interactive.resume_session_id, None); } + #[test] + fn fork_picker_logic_none_and_not_last() { + let interactive = finalize_fork_from_args(["codex", "fork"].as_ref()); + assert!(interactive.fork_picker); + assert!(!interactive.fork_last); + assert_eq!(interactive.fork_session_id, None); + assert!(!interactive.fork_show_all); + } + + #[test] + fn fork_picker_logic_last() { + let interactive = finalize_fork_from_args(["codex", "fork", "--last"].as_ref()); + assert!(!interactive.fork_picker); + assert!(interactive.fork_last); + assert_eq!(interactive.fork_session_id, None); + assert!(!interactive.fork_show_all); + } + + #[test] + fn fork_picker_logic_with_session_id() { + let interactive = finalize_fork_from_args(["codex", "fork", "1234"].as_ref()); + assert!(!interactive.fork_picker); + assert!(!interactive.fork_last); + assert_eq!(interactive.fork_session_id.as_deref(), Some("1234")); + assert!(!interactive.fork_show_all); + } + + #[test] + fn fork_all_flag_sets_show_all() { + let interactive = finalize_fork_from_args(["codex", "fork", "--all"].as_ref()); + assert!(interactive.fork_picker); + assert!(interactive.fork_show_all); + } + #[test] fn feature_toggles_known_features_generate_overrides() { let toggles = FeatureToggles { diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 9e5ac2d95..ad5b32c82 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -20,7 +20,7 @@ use crate::model_migration::run_model_migration_prompt; use crate::pager_overlay::Overlay; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::Renderable; -use crate::resume_picker::ResumeSelection; +use crate::resume_picker::SessionSelection; use crate::tui; use crate::tui::TuiEvent; use crate::update_action::UpdateAction; @@ -340,7 +340,7 @@ impl App { active_profile: Option, initial_prompt: Option, initial_images: Vec, - resume_selection: ResumeSelection, + session_selection: SessionSelection, feedback: codex_feedback::CodexFeedback, is_first_run: bool, ) -> Result { @@ -373,8 +373,8 @@ impl App { } let enhanced_keys_supported = tui.enhanced_keys_supported(); - let mut chat_widget = match resume_selection { - ResumeSelection::StartFresh | ResumeSelection::Exit => { + let mut chat_widget = match session_selection { + SessionSelection::StartFresh | SessionSelection::Exit => { let init = crate::chatwidget::ChatWidgetInit { config: config.clone(), frame_requester: tui.frame_requester(), @@ -390,12 +390,13 @@ impl App { }; ChatWidget::new(init, thread_manager.clone()) } - ResumeSelection::Resume(path) => { + SessionSelection::Resume(path) => { let resumed = thread_manager .resume_thread_from_rollout(config.clone(), path.clone(), auth_manager.clone()) .await .wrap_err_with(|| { - format!("Failed to resume session from {}", path.display()) + let path_display = path.display(); + format!("Failed to resume session from {path_display}") })?; let init = crate::chatwidget::ChatWidgetInit { config: config.clone(), @@ -412,6 +413,29 @@ impl App { }; ChatWidget::new_from_existing(init, resumed.thread, resumed.session_configured) } + SessionSelection::Fork(path) => { + let forked = thread_manager + .fork_thread(usize::MAX, config.clone(), path.clone()) + .await + .wrap_err_with(|| { + let path_display = path.display(); + format!("Failed to fork session from {path_display}") + })?; + let init = crate::chatwidget::ChatWidgetInit { + config: config.clone(), + frame_requester: tui.frame_requester(), + app_event_tx: app_event_tx.clone(), + initial_prompt: initial_prompt.clone(), + initial_images: initial_images.clone(), + enhanced_keys_supported, + auth_manager: auth_manager.clone(), + models_manager: thread_manager.get_models_manager(), + feedback: feedback.clone(), + is_first_run, + model: model.clone(), + }; + ChatWidget::new_from_existing(init, forked.thread, forked.session_configured) + } }; chat_widget.maybe_prompt_windows_sandbox_enable(); @@ -592,7 +616,7 @@ impl App { ) .await? { - ResumeSelection::Resume(path) => { + SessionSelection::Resume(path) => { let summary = session_summary( self.chat_widget.token_usage(), self.chat_widget.thread_id(), @@ -641,14 +665,85 @@ impl App { } } Err(err) => { + let path_display = path.display(); self.chat_widget.add_error_message(format!( - "Failed to resume session from {}: {err}", - path.display() + "Failed to resume session from {path_display}: {err}" )); } } } - ResumeSelection::Exit | ResumeSelection::StartFresh => {} + SessionSelection::Exit + | SessionSelection::StartFresh + | SessionSelection::Fork(_) => {} + } + + // Leaving alt-screen may blank the inline viewport; force a redraw either way. + tui.frame_requester().schedule_frame(); + } + AppEvent::OpenForkPicker => { + match crate::resume_picker::run_fork_picker( + tui, + &self.config.codex_home, + &self.config.model_provider_id, + false, + ) + .await? + { + SessionSelection::Fork(path) => { + let summary = session_summary( + self.chat_widget.token_usage(), + self.chat_widget.thread_id(), + ); + match self + .server + .fork_thread(usize::MAX, self.config.clone(), path.clone()) + .await + { + Ok(forked) => { + self.shutdown_current_thread().await; + let init = crate::chatwidget::ChatWidgetInit { + config: self.config.clone(), + frame_requester: tui.frame_requester(), + app_event_tx: self.app_event_tx.clone(), + initial_prompt: None, + initial_images: Vec::new(), + enhanced_keys_supported: self.enhanced_keys_supported, + auth_manager: self.auth_manager.clone(), + models_manager: self.server.get_models_manager(), + feedback: self.feedback.clone(), + is_first_run: false, + model: self.current_model.clone(), + }; + self.chat_widget = ChatWidget::new_from_existing( + init, + forked.thread, + forked.session_configured, + ); + self.current_model = model_info.slug.clone(); + if let Some(summary) = summary { + let mut lines: Vec> = + vec![summary.usage_line.clone().into()]; + if let Some(command) = summary.resume_command { + let spans = vec![ + "To continue this session, run ".into(), + command.cyan(), + ]; + lines.push(spans.into()); + } + self.chat_widget.add_plain_history_lines(lines); + } + } + Err(err) => { + let path_display = path.display(); + self.chat_widget.add_error_message(format!( + "Failed to fork session from {path_display}: {err}" + )); + } + } + } + SessionSelection::Exit + | SessionSelection::StartFresh + | SessionSelection::Resume(_) => {} } // Leaving alt-screen may blank the inline viewport; force a redraw either way. diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 861ba2a54..9301be827 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -39,6 +39,9 @@ pub(crate) enum AppEvent { /// Open the resume picker inside the running TUI session. OpenResumePicker, + /// Open the fork picker inside the running TUI session. + OpenForkPicker, + /// Request to exit the application gracefully. ExitRequest, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e3fd7a891..08b6f3a8b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1713,6 +1713,9 @@ impl ChatWidget { SlashCommand::Resume => { self.app_event_tx.send(AppEvent::OpenResumePicker); } + SlashCommand::Fork => { + self.app_event_tx.send(AppEvent::OpenForkPicker); + } SlashCommand::Init => { let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME); if init_target.exists() { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 5d1ac3663..94aff345a 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1476,6 +1476,15 @@ async fn slash_resume_opens_picker() { assert_matches!(rx.try_recv(), Ok(AppEvent::OpenResumePicker)); } +#[tokio::test] +async fn slash_fork_opens_picker() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.dispatch_command(SlashCommand::Fork); + + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenForkPicker)); +} + #[tokio::test] async fn slash_rollout_displays_current_path() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index 8f011ff59..43f2ae588 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -32,6 +32,23 @@ pub struct Cli { #[clap(skip)] pub resume_show_all: 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)] + pub fork_picker: bool, + + #[clap(skip)] + pub fork_last: bool, + + /// Internal: fork a specific recorded session by id (UUID). Set by the + /// top-level `codex fork ` wrapper; not exposed as a public flag. + #[clap(skip)] + pub fork_session_id: Option, + + /// Internal: show all sessions (disables cwd filtering and shows CWD column). + #[clap(skip)] + pub fork_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 0f38c5ee7..bf572bf3d 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -431,27 +431,77 @@ async fn run_ratatui_app( initial_config }; - // Determine resume behavior: explicit id, then resume last, then picker. - let resume_selection = if let Some(id_str) = cli.resume_session_id.as_deref() { - match find_thread_path_by_id_str(&config.codex_home, id_str).await? { - Some(path) => resume_picker::ResumeSelection::Resume(path), - None => { - error!("Error finding conversation path: {id_str}"); - restore(); - session_log::log_session_end(); - let _ = tui.terminal.clear(); - if let Err(err) = writeln!( - std::io::stdout(), - "No saved session found with ID {id_str}. Run `codex resume` without an ID to choose from existing sessions." - ) { - error!("Failed to write resume error message: {err}"); - } - return Ok(AppExitInfo { - token_usage: codex_core::protocol::TokenUsage::default(), - thread_id: None, - update_action: None, - }); + let mut missing_session_exit = |id_str: &str, action: &str| { + error!("Error finding conversation path: {id_str}"); + restore(); + session_log::log_session_end(); + let _ = tui.terminal.clear(); + if let Err(err) = writeln!( + std::io::stdout(), + "No saved session found with ID {id_str}. Run `codex {action}` without an ID to choose from existing sessions." + ) { + error!("Failed to write session error message: {err}"); + } + Ok(AppExitInfo { + token_usage: codex_core::protocol::TokenUsage::default(), + thread_id: None, + update_action: None, + }) + }; + + let use_fork = cli.fork_picker || cli.fork_last || cli.fork_session_id.is_some(); + let session_selection = if use_fork { + if let Some(id_str) = cli.fork_session_id.as_deref() { + match find_thread_path_by_id_str(&config.codex_home, id_str).await? { + Some(path) => resume_picker::SessionSelection::Fork(path), + None => return missing_session_exit(id_str, "fork"), } + } else if cli.fork_last { + let provider_filter = vec![config.model_provider_id.clone()]; + match RolloutRecorder::list_threads( + &config.codex_home, + 1, + None, + INTERACTIVE_SESSION_SOURCES, + Some(provider_filter.as_slice()), + &config.model_provider_id, + ) + .await + { + Ok(page) => page + .items + .first() + .map(|it| resume_picker::SessionSelection::Fork(it.path.clone())) + .unwrap_or(resume_picker::SessionSelection::StartFresh), + Err(_) => resume_picker::SessionSelection::StartFresh, + } + } else if cli.fork_picker { + match resume_picker::run_fork_picker( + &mut tui, + &config.codex_home, + &config.model_provider_id, + cli.fork_show_all, + ) + .await? + { + resume_picker::SessionSelection::Exit => { + restore(); + session_log::log_session_end(); + return Ok(AppExitInfo { + token_usage: codex_core::protocol::TokenUsage::default(), + thread_id: None, + update_action: None, + }); + } + other => other, + } + } else { + resume_picker::SessionSelection::StartFresh + } + } else if let Some(id_str) = cli.resume_session_id.as_deref() { + match find_thread_path_by_id_str(&config.codex_home, id_str).await? { + Some(path) => resume_picker::SessionSelection::Resume(path), + None => return missing_session_exit(id_str, "resume"), } } else if cli.resume_last { let provider_filter = vec![config.model_provider_id.clone()]; @@ -468,9 +518,9 @@ async fn run_ratatui_app( Ok(page) => page .items .first() - .map(|it| resume_picker::ResumeSelection::Resume(it.path.clone())) - .unwrap_or(resume_picker::ResumeSelection::StartFresh), - Err(_) => resume_picker::ResumeSelection::StartFresh, + .map(|it| resume_picker::SessionSelection::Resume(it.path.clone())) + .unwrap_or(resume_picker::SessionSelection::StartFresh), + Err(_) => resume_picker::SessionSelection::StartFresh, } } else if cli.resume_picker { match resume_picker::run_resume_picker( @@ -481,7 +531,7 @@ async fn run_ratatui_app( ) .await? { - resume_picker::ResumeSelection::Exit => { + resume_picker::SessionSelection::Exit => { restore(); session_log::log_session_end(); return Ok(AppExitInfo { @@ -493,7 +543,7 @@ async fn run_ratatui_app( other => other, } } else { - resume_picker::ResumeSelection::StartFresh + resume_picker::SessionSelection::StartFresh }; let Cli { @@ -513,7 +563,7 @@ async fn run_ratatui_app( active_profile, prompt, images, - resume_selection, + session_selection, feedback, should_show_trust_screen, // Proxy to: is it a first run in this directory? ) diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 13f7b9db7..5e96c8f2b 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -40,12 +40,42 @@ const PAGE_SIZE: usize = 25; const LOAD_NEAR_THRESHOLD: usize = 5; #[derive(Debug, Clone)] -pub enum ResumeSelection { +pub enum SessionSelection { StartFresh, Resume(PathBuf), + Fork(PathBuf), Exit, } +#[derive(Clone, Copy, Debug)] +pub enum SessionPickerAction { + Resume, + Fork, +} + +impl SessionPickerAction { + fn title(self) -> &'static str { + match self { + SessionPickerAction::Resume => "Resume a previous session", + SessionPickerAction::Fork => "Fork a previous session", + } + } + + fn action_label(self) -> &'static str { + match self { + SessionPickerAction::Resume => "resume", + SessionPickerAction::Fork => "fork", + } + } + + fn selection(self, path: PathBuf) -> SessionSelection { + match self { + SessionPickerAction::Resume => SessionSelection::Resume(path), + SessionPickerAction::Fork => SessionSelection::Fork(path), + } + } +} + #[derive(Clone)] struct PageLoadRequest { codex_home: PathBuf, @@ -73,7 +103,40 @@ pub async fn run_resume_picker( codex_home: &Path, default_provider: &str, show_all: bool, -) -> Result { +) -> Result { + run_session_picker( + tui, + codex_home, + default_provider, + show_all, + SessionPickerAction::Resume, + ) + .await +} + +pub async fn run_fork_picker( + tui: &mut Tui, + codex_home: &Path, + default_provider: &str, + show_all: bool, +) -> Result { + run_session_picker( + tui, + codex_home, + default_provider, + show_all, + SessionPickerAction::Fork, + ) + .await +} + +async fn run_session_picker( + tui: &mut Tui, + codex_home: &Path, + default_provider: &str, + show_all: bool, + action: SessionPickerAction, +) -> Result { let alt = AltScreenGuard::enter(tui); let (bg_tx, bg_rx) = mpsc::unbounded_channel(); @@ -113,6 +176,7 @@ pub async fn run_resume_picker( default_provider.clone(), show_all, filter_cwd, + action, ); state.start_initial_load(); state.request_frame(); @@ -151,7 +215,7 @@ pub async fn run_resume_picker( } // Fallback – treat as cancel/new - Ok(ResumeSelection::StartFresh) + Ok(SessionSelection::StartFresh) } /// RAII guard that ensures we leave the alt-screen on scope exit. @@ -190,6 +254,7 @@ struct PickerState { default_provider: String, show_all: bool, filter_cwd: Option, + action: SessionPickerAction, } struct PaginationState { @@ -259,6 +324,7 @@ impl PickerState { default_provider: String, show_all: bool, filter_cwd: Option, + action: SessionPickerAction, ) -> Self { Self { codex_home, @@ -283,6 +349,7 @@ impl PickerState { default_provider, show_all, filter_cwd, + action, } } @@ -290,19 +357,19 @@ impl PickerState { self.requester.schedule_frame(); } - async fn handle_key(&mut self, key: KeyEvent) -> Result> { + async fn handle_key(&mut self, key: KeyEvent) -> Result> { match key.code { - KeyCode::Esc => return Ok(Some(ResumeSelection::StartFresh)), + KeyCode::Esc => return Ok(Some(SessionSelection::StartFresh)), KeyCode::Char('c') if key .modifiers .contains(crossterm::event::KeyModifiers::CONTROL) => { - return Ok(Some(ResumeSelection::Exit)); + return Ok(Some(SessionSelection::Exit)); } KeyCode::Enter => { if let Some(row) = self.filtered_rows.get(self.selected) { - return Ok(Some(ResumeSelection::Resume(row.path.clone()))); + return Ok(Some(self.action.selection(row.path.clone()))); } } KeyCode::Up => { @@ -718,10 +785,7 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> { .areas(area); // Header - frame.render_widget_ref( - Line::from(vec!["Resume a previous session".bold().cyan()]), - header, - ); + frame.render_widget_ref(Line::from(vec![state.action.title().bold().cyan()]), header); // Search line let q = if state.query.is_empty() { @@ -738,9 +802,10 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> { render_list(frame, list, state, &metrics); // Hint line + let action_label = state.action.action_label(); let hint_line: Line = vec![ key_hint::plain(KeyCode::Enter).into(), - " to resume ".dim(), + format!(" to {action_label} ").dim(), " ".dim(), key_hint::plain(KeyCode::Esc).into(), " to start new ".dim(), @@ -1200,6 +1265,7 @@ mod tests { String::from("openai"), true, None, + SessionPickerAction::Resume, ); let now = Utc::now(); @@ -1349,6 +1415,7 @@ mod tests { String::from("openai"), true, None, + SessionPickerAction::Resume, ); let page = RolloutRecorder::list_threads( @@ -1429,6 +1496,7 @@ mod tests { String::from("openai"), true, None, + SessionPickerAction::Resume, ); state.reset_pagination(); @@ -1497,6 +1565,7 @@ mod tests { String::from("openai"), true, None, + SessionPickerAction::Resume, ); state.reset_pagination(); state.ingest_page(page( @@ -1528,6 +1597,7 @@ mod tests { String::from("openai"), true, None, + SessionPickerAction::Resume, ); let mut items = Vec::new(); @@ -1572,6 +1642,7 @@ mod tests { String::from("openai"), true, None, + SessionPickerAction::Resume, ); let mut items = Vec::new(); @@ -1616,6 +1687,7 @@ mod tests { String::from("openai"), true, None, + SessionPickerAction::Resume, ); state.reset_pagination(); state.ingest_page(page( diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index a5bab57d9..0d274f8eb 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -21,6 +21,7 @@ pub enum SlashCommand { Review, New, Resume, + Fork, Init, Compact, // Undo, @@ -47,6 +48,7 @@ impl SlashCommand { SlashCommand::Compact => "summarize conversation to prevent hitting the context limit", SlashCommand::Review => "review my current changes and find issues", SlashCommand::Resume => "resume a saved chat", + SlashCommand::Fork => "fork a saved chat", // SlashCommand::Undo => "ask Codex to undo a turn", SlashCommand::Quit | SlashCommand::Exit => "exit Codex", SlashCommand::Diff => "show git diff (including untracked files)", @@ -76,6 +78,7 @@ impl SlashCommand { match self { SlashCommand::New | SlashCommand::Resume + | SlashCommand::Fork | SlashCommand::Init | SlashCommand::Compact // | SlashCommand::Undo diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index 292ccb5ac..305ae114d 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -19,7 +19,7 @@ use crate::model_migration::run_model_migration_prompt; use crate::pager_overlay::Overlay; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::Renderable; -use crate::resume_picker::ResumeSelection; +use crate::resume_picker::SessionSelection; use crate::transcript_copy_action::TranscriptCopyAction; use crate::transcript_copy_action::TranscriptCopyFeedback; use crate::transcript_copy_ui::TranscriptCopyUi; @@ -403,7 +403,7 @@ impl App { active_profile: Option, initial_prompt: Option, initial_images: Vec, - resume_selection: ResumeSelection, + session_selection: SessionSelection, feedback: codex_feedback::CodexFeedback, is_first_run: bool, ) -> Result { @@ -436,8 +436,8 @@ impl App { } let enhanced_keys_supported = tui.enhanced_keys_supported(); - let mut chat_widget = match resume_selection { - ResumeSelection::StartFresh | ResumeSelection::Exit => { + let mut chat_widget = match session_selection { + SessionSelection::StartFresh | SessionSelection::Exit => { let init = crate::chatwidget::ChatWidgetInit { config: config.clone(), frame_requester: tui.frame_requester(), @@ -453,12 +453,13 @@ impl App { }; ChatWidget::new(init, thread_manager.clone()) } - ResumeSelection::Resume(path) => { + SessionSelection::Resume(path) => { let resumed = thread_manager .resume_thread_from_rollout(config.clone(), path.clone(), auth_manager.clone()) .await .wrap_err_with(|| { - format!("Failed to resume session from {}", path.display()) + let path_display = path.display(); + format!("Failed to resume session from {path_display}") })?; let init = crate::chatwidget::ChatWidgetInit { config: config.clone(), @@ -475,6 +476,29 @@ impl App { }; ChatWidget::new_from_existing(init, resumed.thread, resumed.session_configured) } + SessionSelection::Fork(path) => { + let forked = thread_manager + .fork_thread(usize::MAX, config.clone(), path.clone()) + .await + .wrap_err_with(|| { + let path_display = path.display(); + format!("Failed to fork session from {path_display}") + })?; + let init = crate::chatwidget::ChatWidgetInit { + config: config.clone(), + frame_requester: tui.frame_requester(), + app_event_tx: app_event_tx.clone(), + initial_prompt: initial_prompt.clone(), + initial_images: initial_images.clone(), + enhanced_keys_supported, + auth_manager: auth_manager.clone(), + models_manager: thread_manager.get_models_manager(), + feedback: feedback.clone(), + is_first_run, + model: model.clone(), + }; + ChatWidget::new_from_existing(init, forked.thread, forked.session_configured) + } }; chat_widget.maybe_prompt_windows_sandbox_enable(); @@ -1390,7 +1414,7 @@ impl App { ) .await? { - ResumeSelection::Resume(path) => { + SessionSelection::Resume(path) => { let summary = session_summary( self.chat_widget.token_usage(), self.chat_widget.conversation_id(), @@ -1438,14 +1462,84 @@ impl App { } } Err(err) => { + let path_display = path.display(); self.chat_widget.add_error_message(format!( - "Failed to resume session from {}: {err}", - path.display() + "Failed to resume session from {path_display}: {err}" )); } } } - ResumeSelection::Exit | ResumeSelection::StartFresh => {} + SessionSelection::Exit + | SessionSelection::StartFresh + | SessionSelection::Fork(_) => {} + } + + // Leaving alt-screen may blank the inline viewport; force a redraw either way. + tui.frame_requester().schedule_frame(); + } + AppEvent::OpenForkPicker => { + match crate::resume_picker::run_fork_picker( + tui, + &self.config.codex_home, + &self.config.model_provider_id, + false, + ) + .await? + { + SessionSelection::Fork(path) => { + let summary = session_summary( + self.chat_widget.token_usage(), + self.chat_widget.conversation_id(), + ); + match self + .server + .fork_thread(usize::MAX, self.config.clone(), path.clone()) + .await + { + Ok(forked) => { + self.shutdown_current_conversation().await; + let init = crate::chatwidget::ChatWidgetInit { + config: self.config.clone(), + frame_requester: tui.frame_requester(), + app_event_tx: self.app_event_tx.clone(), + initial_prompt: None, + initial_images: Vec::new(), + enhanced_keys_supported: self.enhanced_keys_supported, + auth_manager: self.auth_manager.clone(), + models_manager: self.server.get_models_manager(), + feedback: self.feedback.clone(), + is_first_run: false, + model: self.current_model.clone(), + }; + self.chat_widget = ChatWidget::new_from_existing( + init, + forked.thread, + forked.session_configured, + ); + if let Some(summary) = summary { + let mut lines: Vec> = + vec![summary.usage_line.clone().into()]; + if let Some(command) = summary.resume_command { + let spans = vec![ + "To continue this session, run ".into(), + command.cyan(), + ]; + lines.push(spans.into()); + } + self.chat_widget.add_plain_history_lines(lines); + } + } + Err(err) => { + let path_display = path.display(); + self.chat_widget.add_error_message(format!( + "Failed to fork session from {path_display}: {err}" + )); + } + } + } + SessionSelection::Exit + | SessionSelection::StartFresh + | SessionSelection::Resume(_) => {} } // Leaving alt-screen may blank the inline viewport; force a redraw either way. diff --git a/codex-rs/tui2/src/app_event.rs b/codex-rs/tui2/src/app_event.rs index d72eef2b9..a7e566edf 100644 --- a/codex-rs/tui2/src/app_event.rs +++ b/codex-rs/tui2/src/app_event.rs @@ -38,6 +38,9 @@ pub(crate) enum AppEvent { /// Open the resume picker inside the running TUI session. OpenResumePicker, + /// Open the fork picker inside the running TUI session. + OpenForkPicker, + /// Request to exit the application gracefully. ExitRequest, diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index f5af1bfe6..c0ea54217 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -1547,6 +1547,9 @@ impl ChatWidget { SlashCommand::Resume => { self.app_event_tx.send(AppEvent::OpenResumePicker); } + SlashCommand::Fork => { + self.app_event_tx.send(AppEvent::OpenForkPicker); + } SlashCommand::Init => { let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME); if init_target.exists() { diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index 9b7a825e0..2d847c84a 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -1302,6 +1302,15 @@ async fn slash_resume_opens_picker() { assert_matches!(rx.try_recv(), Ok(AppEvent::OpenResumePicker)); } +#[tokio::test] +async fn slash_fork_opens_picker() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.dispatch_command(SlashCommand::Fork); + + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenForkPicker)); +} + #[tokio::test] async fn slash_rollout_displays_current_path() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/tui2/src/cli.rs b/codex-rs/tui2/src/cli.rs index 8c057dac7..7faeac4e1 100644 --- a/codex-rs/tui2/src/cli.rs +++ b/codex-rs/tui2/src/cli.rs @@ -32,6 +32,23 @@ pub struct Cli { #[clap(skip)] pub resume_show_all: 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)] + pub fork_picker: bool, + + #[clap(skip)] + pub fork_last: bool, + + /// Internal: fork a specific recorded session by id (UUID). Set by the + /// top-level `codex fork ` wrapper; not exposed as a public flag. + #[clap(skip)] + pub fork_session_id: Option, + + /// Internal: show all sessions (disables cwd filtering and shows CWD column). + #[clap(skip)] + pub fork_show_all: bool, + /// Model the agent should use. #[arg(long, short = 'm')] pub model: Option, @@ -103,6 +120,10 @@ impl From for Cli { resume_last: cli.resume_last, resume_session_id: cli.resume_session_id, resume_show_all: cli.resume_show_all, + fork_picker: cli.fork_picker, + fork_last: cli.fork_last, + fork_session_id: cli.fork_session_id, + fork_show_all: cli.fork_show_all, model: cli.model, oss: cli.oss, oss_provider: cli.oss_provider, diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs index e111af5b1..aad676fd4 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -451,28 +451,79 @@ async fn run_ratatui_app( initial_config }; - // Determine resume behavior: explicit id, then resume last, then picker. - let resume_selection = if let Some(id_str) = cli.resume_session_id.as_deref() { - match find_thread_path_by_id_str(&config.codex_home, id_str).await? { - Some(path) => resume_picker::ResumeSelection::Resume(path), - None => { - error!("Error finding conversation path: {id_str}"); - restore(); - session_log::log_session_end(); - let _ = tui.terminal.clear(); - if let Err(err) = writeln!( - std::io::stdout(), - "No saved session found with ID {id_str}. Run `codex resume` without an ID to choose from existing sessions." - ) { - error!("Failed to write resume error message: {err}"); - } - return Ok(AppExitInfo { - token_usage: codex_core::protocol::TokenUsage::default(), - conversation_id: None, - update_action: None, - session_lines: Vec::new(), - }); + let mut missing_session_exit = |id_str: &str, action: &str| { + error!("Error finding conversation path: {id_str}"); + restore(); + session_log::log_session_end(); + let _ = tui.terminal.clear(); + if let Err(err) = writeln!( + std::io::stdout(), + "No saved session found with ID {id_str}. Run `codex {action}` without an ID to choose from existing sessions." + ) { + error!("Failed to write session error message: {err}"); + } + Ok(AppExitInfo { + token_usage: codex_core::protocol::TokenUsage::default(), + conversation_id: None, + update_action: None, + session_lines: Vec::new(), + }) + }; + + let use_fork = cli.fork_picker || cli.fork_last || cli.fork_session_id.is_some(); + let session_selection = if use_fork { + if let Some(id_str) = cli.fork_session_id.as_deref() { + match find_thread_path_by_id_str(&config.codex_home, id_str).await? { + Some(path) => resume_picker::SessionSelection::Fork(path), + None => return missing_session_exit(id_str, "fork"), } + } else if cli.fork_last { + let provider_filter = vec![config.model_provider_id.clone()]; + match RolloutRecorder::list_threads( + &config.codex_home, + 1, + None, + INTERACTIVE_SESSION_SOURCES, + Some(provider_filter.as_slice()), + &config.model_provider_id, + ) + .await + { + Ok(page) => page + .items + .first() + .map(|it| resume_picker::SessionSelection::Fork(it.path.clone())) + .unwrap_or(resume_picker::SessionSelection::StartFresh), + Err(_) => resume_picker::SessionSelection::StartFresh, + } + } else if cli.fork_picker { + match resume_picker::run_fork_picker( + &mut tui, + &config.codex_home, + &config.model_provider_id, + cli.fork_show_all, + ) + .await? + { + resume_picker::SessionSelection::Exit => { + restore(); + session_log::log_session_end(); + return Ok(AppExitInfo { + token_usage: codex_core::protocol::TokenUsage::default(), + conversation_id: None, + update_action: None, + session_lines: Vec::new(), + }); + } + other => other, + } + } else { + resume_picker::SessionSelection::StartFresh + } + } else if let Some(id_str) = cli.resume_session_id.as_deref() { + match find_thread_path_by_id_str(&config.codex_home, id_str).await? { + Some(path) => resume_picker::SessionSelection::Resume(path), + None => return missing_session_exit(id_str, "resume"), } } else if cli.resume_last { let provider_filter = vec![config.model_provider_id.clone()]; @@ -489,9 +540,9 @@ async fn run_ratatui_app( Ok(page) => page .items .first() - .map(|it| resume_picker::ResumeSelection::Resume(it.path.clone())) - .unwrap_or(resume_picker::ResumeSelection::StartFresh), - Err(_) => resume_picker::ResumeSelection::StartFresh, + .map(|it| resume_picker::SessionSelection::Resume(it.path.clone())) + .unwrap_or(resume_picker::SessionSelection::StartFresh), + Err(_) => resume_picker::SessionSelection::StartFresh, } } else if cli.resume_picker { match resume_picker::run_resume_picker( @@ -502,7 +553,7 @@ async fn run_ratatui_app( ) .await? { - resume_picker::ResumeSelection::Exit => { + resume_picker::SessionSelection::Exit => { restore(); session_log::log_session_end(); return Ok(AppExitInfo { @@ -515,7 +566,7 @@ async fn run_ratatui_app( other => other, } } else { - resume_picker::ResumeSelection::StartFresh + resume_picker::SessionSelection::StartFresh }; let Cli { @@ -560,7 +611,7 @@ async fn run_ratatui_app( active_profile, prompt, images, - resume_selection, + session_selection, feedback, should_show_trust_screen, // Proxy to: is it a first run in this directory? ) diff --git a/codex-rs/tui2/src/resume_picker.rs b/codex-rs/tui2/src/resume_picker.rs index 13f7b9db7..5e96c8f2b 100644 --- a/codex-rs/tui2/src/resume_picker.rs +++ b/codex-rs/tui2/src/resume_picker.rs @@ -40,12 +40,42 @@ const PAGE_SIZE: usize = 25; const LOAD_NEAR_THRESHOLD: usize = 5; #[derive(Debug, Clone)] -pub enum ResumeSelection { +pub enum SessionSelection { StartFresh, Resume(PathBuf), + Fork(PathBuf), Exit, } +#[derive(Clone, Copy, Debug)] +pub enum SessionPickerAction { + Resume, + Fork, +} + +impl SessionPickerAction { + fn title(self) -> &'static str { + match self { + SessionPickerAction::Resume => "Resume a previous session", + SessionPickerAction::Fork => "Fork a previous session", + } + } + + fn action_label(self) -> &'static str { + match self { + SessionPickerAction::Resume => "resume", + SessionPickerAction::Fork => "fork", + } + } + + fn selection(self, path: PathBuf) -> SessionSelection { + match self { + SessionPickerAction::Resume => SessionSelection::Resume(path), + SessionPickerAction::Fork => SessionSelection::Fork(path), + } + } +} + #[derive(Clone)] struct PageLoadRequest { codex_home: PathBuf, @@ -73,7 +103,40 @@ pub async fn run_resume_picker( codex_home: &Path, default_provider: &str, show_all: bool, -) -> Result { +) -> Result { + run_session_picker( + tui, + codex_home, + default_provider, + show_all, + SessionPickerAction::Resume, + ) + .await +} + +pub async fn run_fork_picker( + tui: &mut Tui, + codex_home: &Path, + default_provider: &str, + show_all: bool, +) -> Result { + run_session_picker( + tui, + codex_home, + default_provider, + show_all, + SessionPickerAction::Fork, + ) + .await +} + +async fn run_session_picker( + tui: &mut Tui, + codex_home: &Path, + default_provider: &str, + show_all: bool, + action: SessionPickerAction, +) -> Result { let alt = AltScreenGuard::enter(tui); let (bg_tx, bg_rx) = mpsc::unbounded_channel(); @@ -113,6 +176,7 @@ pub async fn run_resume_picker( default_provider.clone(), show_all, filter_cwd, + action, ); state.start_initial_load(); state.request_frame(); @@ -151,7 +215,7 @@ pub async fn run_resume_picker( } // Fallback – treat as cancel/new - Ok(ResumeSelection::StartFresh) + Ok(SessionSelection::StartFresh) } /// RAII guard that ensures we leave the alt-screen on scope exit. @@ -190,6 +254,7 @@ struct PickerState { default_provider: String, show_all: bool, filter_cwd: Option, + action: SessionPickerAction, } struct PaginationState { @@ -259,6 +324,7 @@ impl PickerState { default_provider: String, show_all: bool, filter_cwd: Option, + action: SessionPickerAction, ) -> Self { Self { codex_home, @@ -283,6 +349,7 @@ impl PickerState { default_provider, show_all, filter_cwd, + action, } } @@ -290,19 +357,19 @@ impl PickerState { self.requester.schedule_frame(); } - async fn handle_key(&mut self, key: KeyEvent) -> Result> { + async fn handle_key(&mut self, key: KeyEvent) -> Result> { match key.code { - KeyCode::Esc => return Ok(Some(ResumeSelection::StartFresh)), + KeyCode::Esc => return Ok(Some(SessionSelection::StartFresh)), KeyCode::Char('c') if key .modifiers .contains(crossterm::event::KeyModifiers::CONTROL) => { - return Ok(Some(ResumeSelection::Exit)); + return Ok(Some(SessionSelection::Exit)); } KeyCode::Enter => { if let Some(row) = self.filtered_rows.get(self.selected) { - return Ok(Some(ResumeSelection::Resume(row.path.clone()))); + return Ok(Some(self.action.selection(row.path.clone()))); } } KeyCode::Up => { @@ -718,10 +785,7 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> { .areas(area); // Header - frame.render_widget_ref( - Line::from(vec!["Resume a previous session".bold().cyan()]), - header, - ); + frame.render_widget_ref(Line::from(vec![state.action.title().bold().cyan()]), header); // Search line let q = if state.query.is_empty() { @@ -738,9 +802,10 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> { render_list(frame, list, state, &metrics); // Hint line + let action_label = state.action.action_label(); let hint_line: Line = vec![ key_hint::plain(KeyCode::Enter).into(), - " to resume ".dim(), + format!(" to {action_label} ").dim(), " ".dim(), key_hint::plain(KeyCode::Esc).into(), " to start new ".dim(), @@ -1200,6 +1265,7 @@ mod tests { String::from("openai"), true, None, + SessionPickerAction::Resume, ); let now = Utc::now(); @@ -1349,6 +1415,7 @@ mod tests { String::from("openai"), true, None, + SessionPickerAction::Resume, ); let page = RolloutRecorder::list_threads( @@ -1429,6 +1496,7 @@ mod tests { String::from("openai"), true, None, + SessionPickerAction::Resume, ); state.reset_pagination(); @@ -1497,6 +1565,7 @@ mod tests { String::from("openai"), true, None, + SessionPickerAction::Resume, ); state.reset_pagination(); state.ingest_page(page( @@ -1528,6 +1597,7 @@ mod tests { String::from("openai"), true, None, + SessionPickerAction::Resume, ); let mut items = Vec::new(); @@ -1572,6 +1642,7 @@ mod tests { String::from("openai"), true, None, + SessionPickerAction::Resume, ); let mut items = Vec::new(); @@ -1616,6 +1687,7 @@ mod tests { String::from("openai"), true, None, + SessionPickerAction::Resume, ); state.reset_pagination(); state.ingest_page(page( diff --git a/codex-rs/tui2/src/slash_command.rs b/codex-rs/tui2/src/slash_command.rs index bbebcd409..e2d776122 100644 --- a/codex-rs/tui2/src/slash_command.rs +++ b/codex-rs/tui2/src/slash_command.rs @@ -20,6 +20,7 @@ pub enum SlashCommand { Review, New, Resume, + Fork, Init, Compact, // Undo, @@ -45,6 +46,7 @@ impl SlashCommand { SlashCommand::Compact => "summarize conversation to prevent hitting the context limit", SlashCommand::Review => "review my current changes and find issues", SlashCommand::Resume => "resume a saved chat", + SlashCommand::Fork => "fork a saved chat", // SlashCommand::Undo => "ask Codex to undo a turn", SlashCommand::Quit | SlashCommand::Exit => "exit Codex", SlashCommand::Diff => "show git diff (including untracked files)", @@ -72,6 +74,7 @@ impl SlashCommand { match self { SlashCommand::New | SlashCommand::Resume + | SlashCommand::Fork | SlashCommand::Init | SlashCommand::Compact // | SlashCommand::Undo