From 6c5471feb20f8a4b34f2efb9239e4e641149e77a Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 9 Apr 2026 14:21:27 +0100 Subject: [PATCH] feat: /resume per ID/name (#17222) Support `/resume 00000-0000-0000-00000000` from the TUI (equivalent for the name) --- codex-rs/tui/src/app.rs | 211 ++++++++++-------- codex-rs/tui/src/app_event.rs | 3 + codex-rs/tui/src/chatwidget.rs | 11 + .../src/chatwidget/tests/slash_commands.rs | 18 ++ codex-rs/tui/src/slash_command.rs | 1 + 5 files changed, 155 insertions(+), 89 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 3ca5edb00d..6986b9e595 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -47,6 +47,7 @@ use crate::read_session_model; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::Renderable; use crate::resume_picker::SessionSelection; +use crate::resume_picker::SessionTarget; #[cfg(test)] use crate::test_support::PathBufExt; use crate::tui; @@ -4047,6 +4048,108 @@ impl App { Ok(AppRunControl::Continue) } + async fn resume_target_session( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + target_session: SessionTarget, + ) -> Result { + if self.ignore_same_thread_resume(&target_session) { + tui.frame_requester().schedule_frame(); + return Ok(AppRunControl::Continue); + } + + let current_cwd = self.config.cwd.to_path_buf(); + let resume_cwd = if self.remote_app_server_url.is_some() { + current_cwd.clone() + } else { + match crate::resolve_cwd_for_resume_or_fork( + tui, + &self.config, + ¤t_cwd, + target_session.thread_id, + target_session.path.as_deref(), + CwdPromptAction::Resume, + /*allow_prompt*/ true, + ) + .await? + { + crate::ResolveCwdOutcome::Continue(Some(cwd)) => cwd, + crate::ResolveCwdOutcome::Continue(None) => current_cwd.clone(), + crate::ResolveCwdOutcome::Exit => { + return Ok(AppRunControl::Exit(ExitReason::UserRequested)); + } + } + }; + + let mut resume_config = match self + .rebuild_config_for_resume_or_fallback(¤t_cwd, resume_cwd) + .await + { + Ok(cfg) => cfg, + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to rebuild configuration for resume: {err}" + )); + return Ok(AppRunControl::Continue); + } + }; + self.apply_runtime_policy_overrides(&mut resume_config); + + let summary = session_summary( + self.chat_widget.token_usage(), + self.chat_widget.thread_id(), + self.chat_widget.thread_name(), + ); + match app_server + .resume_thread(resume_config.clone(), target_session.thread_id) + .await + { + Ok(resumed) => { + self.shutdown_current_thread(app_server).await; + self.config = resume_config; + tui.set_notification_settings( + self.config.tui_notifications.method, + self.config.tui_notifications.condition, + ); + self.file_search + .update_search_dir(self.config.cwd.to_path_buf()); + match self + .replace_chat_widget_with_app_server_thread(tui, app_server, resumed) + .await + { + Ok(()) => { + if let Some(summary) = summary { + let mut lines: Vec> = Vec::new(); + if let Some(usage_line) = summary.usage_line { + lines.push(usage_line.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) => { + self.chat_widget.add_error_message(format!( + "Failed to attach to resumed app-server thread: {err}" + )); + } + } + } + Err(err) => { + let path_display = target_session.display_label(); + self.chat_widget.add_error_message(format!( + "Failed to resume session from {path_display}: {err}" + )); + } + } + + Ok(AppRunControl::Continue) + } + async fn handle_event( &mut self, tui: &mut tui::Tui, @@ -4097,97 +4200,13 @@ impl App { .await? { SessionSelection::Resume(target_session) => { - if self.ignore_same_thread_resume(&target_session) { - tui.frame_requester().schedule_frame(); - return Ok(AppRunControl::Continue); - } - let current_cwd = self.config.cwd.to_path_buf(); - let resume_cwd = if self.remote_app_server_url.is_some() { - current_cwd.clone() - } else { - match crate::resolve_cwd_for_resume_or_fork( - tui, - &self.config, - ¤t_cwd, - target_session.thread_id, - target_session.path.as_deref(), - CwdPromptAction::Resume, - /*allow_prompt*/ true, - ) + match self + .resume_target_session(tui, app_server, target_session) .await? - { - crate::ResolveCwdOutcome::Continue(Some(cwd)) => cwd, - crate::ResolveCwdOutcome::Continue(None) => current_cwd.clone(), - crate::ResolveCwdOutcome::Exit => { - return Ok(AppRunControl::Exit(ExitReason::UserRequested)); - } - } - }; - let mut resume_config = match self - .rebuild_config_for_resume_or_fallback(¤t_cwd, resume_cwd) - .await { - Ok(cfg) => cfg, - Err(err) => { - self.chat_widget.add_error_message(format!( - "Failed to rebuild configuration for resume: {err}" - )); - return Ok(AppRunControl::Continue); - } - }; - self.apply_runtime_policy_overrides(&mut resume_config); - let summary = session_summary( - self.chat_widget.token_usage(), - self.chat_widget.thread_id(), - self.chat_widget.thread_name(), - ); - match app_server - .resume_thread(resume_config.clone(), target_session.thread_id) - .await - { - Ok(resumed) => { - self.shutdown_current_thread(app_server).await; - self.config = resume_config; - tui.set_notification_settings( - self.config.tui_notifications.method, - self.config.tui_notifications.condition, - ); - self.file_search - .update_search_dir(self.config.cwd.to_path_buf()); - match self - .replace_chat_widget_with_app_server_thread( - tui, app_server, resumed, - ) - .await - { - Ok(()) => { - if let Some(summary) = summary { - let mut lines: Vec> = Vec::new(); - if let Some(usage_line) = summary.usage_line { - lines.push(usage_line.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) => { - self.chat_widget.add_error_message(format!( - "Failed to attach to resumed app-server thread: {err}" - )); - } - } - } - Err(err) => { - let path_display = target_session.display_label(); - self.chat_widget.add_error_message(format!( - "Failed to resume session from {path_display}: {err}" - )); + AppRunControl::Continue => {} + AppRunControl::Exit(reason) => { + return Ok(AppRunControl::Exit(reason)); } } } @@ -4199,6 +4218,20 @@ impl App { // Leaving alt-screen may blank the inline viewport; force a redraw either way. tui.frame_requester().schedule_frame(); } + AppEvent::ResumeSessionByIdOrName(id_or_name) => { + match crate::lookup_session_target_with_app_server(app_server, &id_or_name).await? { + Some(target_session) => { + return self + .resume_target_session(tui, app_server, target_session) + .await; + } + None => { + self.chat_widget.add_error_message(format!( + "No saved chat found matching '{id_or_name}'." + )); + } + } + } AppEvent::ForkCurrentSession => { self.session_telemetry.counter( "codex.thread.fork", diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 6bc873b27e..78a2525350 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -124,6 +124,9 @@ pub(crate) enum AppEvent { /// Open the resume picker inside the running TUI session. OpenResumePicker, + /// Resume a thread by UUID or thread name inside the running TUI session. + ResumeSessionByIdOrName(String), + /// Fork the current session into a new thread. ForkCurrentSession, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index fd87c58f69..27450ed5f9 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5452,6 +5452,17 @@ impl ChatWidget { })); self.bottom_pane.drain_pending_submission_state(); } + SlashCommand::Resume if !trimmed.is_empty() => { + let Some((prepared_args, _prepared_elements)) = self + .bottom_pane + .prepare_inline_args_submission(/*record_history*/ false) + else { + return; + }; + self.app_event_tx + .send(AppEvent::ResumeSessionByIdOrName(prepared_args)); + self.bottom_pane.drain_pending_submission_state(); + } SlashCommand::SandboxReadRoot if !trimmed.is_empty() => { let Some((prepared_args, _prepared_elements)) = self .bottom_pane diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index fdf7c5008b..e2fda41685 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -447,6 +447,24 @@ async fn slash_resume_opens_picker() { assert_matches!(rx.try_recv(), Ok(AppEvent::OpenResumePicker)); } +#[tokio::test] +async fn slash_resume_with_arg_requests_named_session() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.bottom_pane.set_composer_text( + "/resume my-saved-thread".to_string(), + Vec::new(), + Vec::new(), + ); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::ResumeSessionByIdOrName(id_or_name)) if id_or_name == "my-saved-thread" + ); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); +} + #[tokio::test] async fn slash_fork_requests_current_fork() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index ec624d3fb9..b1a6e97ad1 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -132,6 +132,7 @@ impl SlashCommand { | SlashCommand::Rename | SlashCommand::Plan | SlashCommand::Fast + | SlashCommand::Resume | SlashCommand::SandboxReadRoot ) }