From a63cb33e8deab97002c4ab6bb872183fbde99aa6 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 25 Aug 2026 15:41:32 +0000 Subject: [PATCH] Show reconnect guidance when disconnecting from tasks (#40629) ## Why Exiting a TUI connected to a persistent app server can leave work running, so the existing session-exit summary does not explain how to reconnect or stop the active turn. ## What changed - Distinguish disconnects, interrupted turns, and removed threads when building exit summaries. - For daemon and remote sessions, report whether work may still be running and print commands to reconnect or stop the current turn. - Sanitize remote WebSocket addresses in displayed commands and preserve the configured remote authentication environment-variable option. - Keep the existing token-usage and resume summary for embedded sessions. ## Testing Add coverage for daemon, remote, interrupted, embedded, archived, and deleted session exits, including credential sanitization. GitOrigin-RevId: 9cbbf9c7dd36423a5ed99909a2b4cb32e3e87712 --- codex-rs/cli/src/main.rs | 107 +++++++++----- codex-rs/tui/src/app.rs | 8 + codex-rs/tui/src/app/event_dispatch.rs | 16 +- codex-rs/tui/src/app/exit_summary.rs | 138 ++++++++++++++++++ codex-rs/tui/src/app/startup.rs | 14 +- codex-rs/tui/src/app/startup_prompts.rs | 1 + .../src/app/tests/background_exit_tests.rs | 88 ++++++++++- .../app/tests/session_lifecycle_requests.rs | 58 ++++++++ ...nd_exit_tests__daemon_disconnect_exit.snap | 8 + ...it_tests__interrupted_disconnect_exit.snap | 6 + ...nd_exit_tests__remote_disconnect_exit.snap | 7 + codex-rs/tui/src/app_event.rs | 2 + codex-rs/tui/src/lib.rs | 8 + codex-rs/tui/src/main.rs | 35 +---- codex-rs/tui/src/session_start.rs | 1 + codex-rs/tui/src/status/remote_connection.rs | 7 +- 16 files changed, 408 insertions(+), 96 deletions(-) create mode 100644 codex-rs/tui/src/app/exit_summary.rs create mode 100644 codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__daemon_disconnect_exit.snap create mode 100644 codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__interrupted_disconnect_exit.snap create mode 100644 codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__remote_disconnect_exit.snap diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 455682e142..7266e9c339 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -36,7 +36,6 @@ use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::CliConfigOverrides; use codex_utils_cli::ProfileV2Name; use codex_utils_cli::SharedCliOptions; -use owo_colors::OwoColorize; use std::collections::HashSet; use std::io::IsTerminal; use std::io::Write; @@ -784,34 +783,6 @@ fn parse_socket_path(raw: &str) -> Result { .map_err(|err| format!("failed to resolve socket path `{raw}`: {err}")) } -fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec { - let is_fatal = matches!(&exit_info.exit_reason, ExitReason::Fatal(_)); - let AppExitInfo { - token_usage, - thread_id: conversation_id, - resume_hint, - .. - } = exit_info; - - let mut lines = Vec::new(); - if !token_usage.is_zero() { - lines.push(token_usage.to_string()); - } - - if let Some(resume_cmd) = resume_hint { - let command = if color_enabled { - resume_cmd.cyan().to_string() - } else { - resume_cmd - }; - lines.push(format!("To continue this session, run {command}")); - } else if is_fatal && let Some(conversation_id) = conversation_id { - lines.push(format!("Session ID: {conversation_id}")); - } - - lines -} - /// Handle the app exit and print the results. Optionally run the update action. fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> { let is_fatal = match &exit_info.exit_reason { @@ -819,12 +790,14 @@ fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> { eprintln!("ERROR: {message}"); true } - ExitReason::UserRequested => false, + ExitReason::UserRequested | ExitReason::TurnInterrupted | ExitReason::ThreadRemoved => { + false + } }; let update_action = exit_info.update_action; let color_enabled = supports_color::on(Stream::Stdout).is_some(); - for line in format_exit_messages(exit_info, color_enabled) { + for line in exit_info.format_exit_messages(color_enabled) { println!("{line}"); } if is_fatal { @@ -2625,7 +2598,7 @@ async fn run_interactive_tui( .map_err(std::io::Error::other)?; } - let remote_endpoint = match resolve_remote_endpoint(remote, remote_auth_token_env) { + let remote_endpoint = match resolve_remote_endpoint(remote, remote_auth_token_env.clone()) { Ok(remote_endpoint) => remote_endpoint, Err(err) if is_remote_auth_usage_error(&err) => { return Ok(AppExitInfo::fatal(err.to_string())); @@ -2640,11 +2613,31 @@ async fn run_interactive_tui( remote_endpoint.clone(), ) }; + run_tui_with_recovery(start_tui, remote_auth_token_env.as_deref()).await +} + +async fn run_tui_with_recovery( + mut start_tui: F, + remote_auth_token_env: Option<&str>, +) -> std::io::Result +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ let mut attempted_backups = HashSet::new(); loop { // Keep the large TUI future out of the CLI dispatcher's stack frame. let err = match Box::pin(start_tui()).await { - Ok(exit_info) => return Ok(exit_info), + Ok(mut exit_info) => { + if let Some(disconnect) = &mut exit_info.disconnect_info + && let Some(env_var) = remote_auth_token_env + { + disconnect + .command + .extend(["--remote-auth-token-env".to_string(), env_var.to_string()]); + } + return Ok(exit_info); + } Err(err) => err, }; let Some(startup_error) = local_state_db::startup_error(&err) else { @@ -3742,6 +3735,7 @@ mod tests { token_usage, thread_id, resume_hint: codex_utils_cli::resume_hint(thread_name, thread_id), + disconnect_info: None, update_action: None, exit_reason: ExitReason::UserRequested, } @@ -3753,23 +3747,58 @@ mod tests { token_usage: TokenUsage::default(), thread_id: None, resume_hint: None, + disconnect_info: None, update_action: None, exit_reason: ExitReason::UserRequested, }; - let lines = format_exit_messages(exit_info, /*color_enabled*/ false); + let lines = exit_info.format_exit_messages(/*color_enabled*/ false); assert!(lines.is_empty()); } + #[tokio::test] + async fn format_exit_messages_preserves_auth_env_through_tui_runner() { + let exit_info = run_tui_with_recovery( + || async { + let mut exit_info = sample_exit_info( + Some("123e4567-e89b-12d3-a456-426614174000"), + /*thread_name*/ None, + ); + exit_info.disconnect_info = Some(codex_tui::DisconnectInfo { + command: vec![ + "codex".to_string(), + "--remote".to_string(), + "wss://example.com:443/".to_string(), + ], + stop_hint: "press ctrl + x".to_string(), + }); + Ok(exit_info) + }, + Some("CODEX_REMOTE_TOKEN"), + ) + .await + .unwrap(); + assert_eq!( + exit_info.format_exit_messages(/*color_enabled*/ false), + vec![ + "Disconnected from this task. Any running work continues.", + "Reconnect: codex --remote wss://example.com:443/ --remote-auth-token-env CODEX_REMOTE_TOKEN resume 123e4567-e89b-12d3-a456-426614174000", + "Stop the current turn: run codex --remote wss://example.com:443/ --remote-auth-token-env CODEX_REMOTE_TOKEN agents, select this task, and press ctrl + x.", + "Token usage so far: total=2 input=0 output=2", + ] + ); + } + #[test] fn format_exit_messages_includes_session_id_for_fatal_exit_without_resume_hint() { let exit_info = AppExitInfo { token_usage: TokenUsage::default(), thread_id: Some(ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap()), resume_hint: None, + disconnect_info: None, update_action: None, exit_reason: ExitReason::Fatal("boom".to_string()), }; - let lines = format_exit_messages(exit_info, /*color_enabled*/ false); + let lines = exit_info.format_exit_messages(/*color_enabled*/ false); assert_eq!( lines, vec!["Session ID: 123e4567-e89b-12d3-a456-426614174000".to_string()] @@ -3783,7 +3812,7 @@ mod tests { /*thread_name*/ None, ); exit_info.exit_reason = ExitReason::Fatal("boom".to_string()); - let lines = format_exit_messages(exit_info, /*color_enabled*/ false); + let lines = exit_info.format_exit_messages(/*color_enabled*/ false); assert_eq!( lines, vec![ @@ -3800,7 +3829,7 @@ mod tests { Some("123e4567-e89b-12d3-a456-426614174000"), /*thread_name*/ None, ); - let lines = format_exit_messages(exit_info, /*color_enabled*/ false); + let lines = exit_info.format_exit_messages(/*color_enabled*/ false); assert_eq!( lines, vec![ @@ -3817,7 +3846,7 @@ mod tests { Some("123e4567-e89b-12d3-a456-426614174000"), /*thread_name*/ None, ); - let lines = format_exit_messages(exit_info, /*color_enabled*/ true); + let lines = exit_info.format_exit_messages(/*color_enabled*/ true); assert_eq!(lines.len(), 2); assert!(lines[1].contains("\u{1b}[36m")); } @@ -3828,7 +3857,7 @@ mod tests { Some("123e4567-e89b-12d3-a456-426614174000"), Some("my-thread"), ); - let lines = format_exit_messages(exit_info, /*color_enabled*/ false); + let lines = exit_info.format_exit_messages(/*color_enabled*/ false); assert_eq!( lines, vec![ diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index b6457a8d6e..44754f0f27 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -217,6 +217,7 @@ mod background_requests; mod config_persistence; mod connector_mentions; mod event_dispatch; +mod exit_summary; mod file_change_approvals; mod history_pagination; mod history_ui; @@ -421,6 +422,7 @@ pub struct AppExitInfo { pub token_usage: TokenUsage, pub thread_id: Option, pub resume_hint: Option, + pub disconnect_info: Option, pub update_action: Option, pub exit_reason: ExitReason, } @@ -431,12 +433,15 @@ impl AppExitInfo { token_usage: TokenUsage::default(), thread_id: None, resume_hint: None, + disconnect_info: None, update_action: None, exit_reason: ExitReason::Fatal(message.into()), } } } +pub use exit_summary::DisconnectInfo; + #[derive(Debug)] pub(crate) enum AppRunControl { Continue, @@ -446,6 +451,9 @@ pub(crate) enum AppRunControl { #[derive(Debug, Clone)] pub enum ExitReason { UserRequested, + TurnInterrupted, + /// The current thread was archived or deleted, rather than disconnected. + ThreadRemoved, Fatal(String), } diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index cbee2c1fc6..e7e854d13d 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -638,7 +638,7 @@ impl App { self.chat_widget.on_commit_tick(); } AppEvent::Exit(mode) => { - if mode == ExitMode::ShutdownFirst { + if matches!(mode, ExitMode::ShutdownFirst | ExitMode::ShutdownAfterInterrupt) { self.show_shutdown_feedback(tui)?; } return Ok(self.handle_exit_mode(app_server, mode).await); @@ -689,7 +689,7 @@ impl App { match app_server.turn_interrupt(thread_id, turn_id).await { Ok(()) => { self.app_event_tx - .send(AppEvent::Exit(ExitMode::ShutdownFirst)); + .send(AppEvent::Exit(ExitMode::ShutdownAfterInterrupt)); } Err(error) => { self.chat_widget @@ -3062,7 +3062,7 @@ impl App { } } match mode { - ExitMode::ShutdownFirst => { + ExitMode::ShutdownFirst | ExitMode::ShutdownAfterInterrupt => { // Mark the thread we are explicitly shutting down for exit so // its shutdown completion does not trigger agent failover. self.pending_shutdown_exit_thread_id = @@ -3084,7 +3084,11 @@ impl App { } } self.pending_shutdown_exit_thread_id = None; - AppRunControl::Exit(ExitReason::UserRequested) + AppRunControl::Exit(if mode == ExitMode::ShutdownAfterInterrupt { + ExitReason::TurnInterrupted + } else { + ExitReason::UserRequested + }) } ExitMode::Immediate => { self.pending_shutdown_exit_thread_id = None; @@ -3111,7 +3115,7 @@ impl App { } match app_server.thread_archive(thread_id).await { - Ok(()) => AppRunControl::Exit(ExitReason::UserRequested), + Ok(()) => AppRunControl::Exit(ExitReason::ThreadRemoved), Err(err) => { self.chat_widget .add_error_message(format!("Failed to archive current thread: {err}")); @@ -3138,7 +3142,7 @@ impl App { } match app_server.thread_delete(thread_id).await { - Ok(()) => AppRunControl::Exit(ExitReason::UserRequested), + Ok(()) => AppRunControl::Exit(ExitReason::ThreadRemoved), Err(err) => { self.chat_widget .add_error_message(format!("Failed to delete current thread: {err}")); diff --git a/codex-rs/tui/src/app/exit_summary.rs b/codex-rs/tui/src/app/exit_summary.rs new file mode 100644 index 0000000000..32b55004e4 --- /dev/null +++ b/codex-rs/tui/src/app/exit_summary.rs @@ -0,0 +1,138 @@ +//! Exit output distinguishes a disconnected client from a stopped embedded server. + +use super::*; +use crate::RemoteAppServerEndpoint; +use crate::exec_command::escape_command; +use crate::status::remote_connection::sanitized_websocket_url; + +/// Reconnection and stop guidance for a task owned by a persistent app server. +#[derive(Debug, Clone)] +pub struct DisconnectInfo { + /// CLI arguments identifying the server, without credential values. + pub command: Vec, + pub stop_hint: String, +} + +impl App { + pub(super) fn exit_info(&self, exit_reason: ExitReason) -> AppExitInfo { + let thread_id = match exit_reason { + ExitReason::ThreadRemoved => None, + ExitReason::UserRequested | ExitReason::TurnInterrupted | ExitReason::Fatal(_) => { + self.chat_widget.thread_id().or(self.primary_thread_id) + } + }; + let disconnect_info = thread_id.and_then(|_| { + let command = match &self.app_server_target { + AppServerTarget::Embedded => return None, + AppServerTarget::LocalDaemon { .. } => vec!["codex".to_string()], + AppServerTarget::Remote { endpoint } => { + let address = match endpoint { + RemoteAppServerEndpoint::WebSocket { websocket_url, .. } => { + sanitized_websocket_url(websocket_url) + .and_then(|url| { + let host = url.host()?; + let port = url.port_or_known_default()?; + // The CLI requires an explicit port, including 80/443. + Some(format!("{}://{host}:{port}{}", url.scheme(), url.path())) + }) + .unwrap_or_else(|| "".to_string()) + } + RemoteAppServerEndpoint::UnixSocket { socket_path } => { + format!("unix://{}", socket_path.display()) + } + }; + vec!["codex".to_string(), "--remote".to_string(), address] + } + }; + let stop_hint = self + .keymap + .agents + .primary_hint("stop", &self.keymap.agents.stop) + .map_or_else( + || "use the configured stop shortcut".to_string(), + |key| format!("press {}", key.display_label()), + ); + Some(DisconnectInfo { command, stop_hint }) + }); + AppExitInfo { + token_usage: self.token_usage(), + thread_id, + resume_hint: resume_hint_for_resumable_thread( + thread_id, + self.chat_widget.thread_name(), + self.chat_widget.rollout_path().as_deref(), + ), + disconnect_info, + update_action: self.pending_update_action, + exit_reason, + } + } +} + +impl AppExitInfo { + /// Format the terminal summary for both the CLI and standalone TUI binaries. + pub fn format_exit_messages(self, color_enabled: bool) -> Vec { + let color_command = |command: String| { + if color_enabled { + format!("\u{1b}[36m{command}\u{1b}[39m") + } else { + command + } + }; + let mut lines = Vec::new(); + if let Some(disconnect) = self.disconnect_info + && let Some(thread_id) = self.thread_id + { + let turn_interrupted = matches!(self.exit_reason, ExitReason::TurnInterrupted); + let message = match self.exit_reason { + ExitReason::UserRequested | ExitReason::ThreadRemoved => { + "Disconnected from this task. Any running work continues." + } + ExitReason::Fatal(_) => "Disconnected from this task. Work may still be running.", + ExitReason::TurnInterrupted => { + "Disconnected from this task. The current turn was stopped." + } + }; + lines.push(message.to_string()); + let mut resume_command = disconnect.command.clone(); + resume_command.extend(["resume".to_string(), thread_id.to_string()]); + lines.push(format!( + "Reconnect: {}", + color_command(escape_command(&resume_command)), + )); + if !turn_interrupted { + let mut agents_command = disconnect.command; + agents_command.push("agents".to_string()); + lines.push(format!( + "Stop the current turn: run {}, select this task, and {}.", + color_command(escape_command(&agents_command)), + disconnect.stop_hint, + )); + } + if !self.token_usage.is_zero() { + let usage = self.token_usage.to_string(); + lines.push(if turn_interrupted { + usage + } else { + usage.replacen("Token usage:", "Token usage so far:", /*count*/ 1) + }); + } + return lines; + } + + if !self.token_usage.is_zero() { + lines.push(self.token_usage.to_string()); + } + if let Some(resume_command) = self.resume_hint { + lines.push(format!( + "To continue this session, run {}", + color_command(resume_command), + )); + } else if matches!(self.exit_reason, ExitReason::Fatal(_)) + && let Some(thread_id) = self.thread_id + { + lines.push(format!("Session ID: {thread_id}")); + } + lines + } +} diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index ece191b3c6..e5acfa6bfc 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -868,18 +868,6 @@ See the Codex keymap documentation for supported actions and examples." return Err(err); } }; - let thread_id = app.chat_widget.thread_id().or(app.primary_thread_id); - let resume_hint = resume_hint_for_resumable_thread( - thread_id, - app.chat_widget.thread_name(), - app.chat_widget.rollout_path().as_deref(), - ); - Ok(AppExitInfo { - token_usage: app.token_usage(), - thread_id, - resume_hint, - update_action: app.pending_update_action, - exit_reason, - }) + Ok(app.exit_info(exit_reason)) } } diff --git a/codex-rs/tui/src/app/startup_prompts.rs b/codex-rs/tui/src/app/startup_prompts.rs index 88f118bf9f..5ecc7a9dfc 100644 --- a/codex-rs/tui/src/app/startup_prompts.rs +++ b/codex-rs/tui/src/app/startup_prompts.rs @@ -345,6 +345,7 @@ pub(super) async fn handle_model_migration_prompt_if_needed( token_usage: TokenUsage::default(), thread_id: None, resume_hint: None, + disconnect_info: None, update_action: None, exit_reason: ExitReason::UserRequested, })); diff --git a/codex-rs/tui/src/app/tests/background_exit_tests.rs b/codex-rs/tui/src/app/tests/background_exit_tests.rs index 2c6a5fbdde..d693d27f90 100644 --- a/codex-rs/tui/src/app/tests/background_exit_tests.rs +++ b/codex-rs/tui/src/app/tests/background_exit_tests.rs @@ -1,6 +1,75 @@ use super::*; use pretty_assertions::assert_eq; +#[tokio::test] +async fn daemon_disconnect_exit_summary_includes_reconnect_and_stop_instructions() -> Result<()> { + let (mut app, _, _) = make_test_app_with_channels().await; + let thread_id = prepare_running_local_daemon(&mut app)?; + app.keymap.agents.stop = vec![crate::key_hint::plain(KeyCode::F(10))]; + let mut exit_info = app.exit_info(ExitReason::UserRequested); + exit_info.token_usage = TokenUsage { + input_tokens: 10, + output_tokens: 2, + total_tokens: 12, + ..Default::default() + }; + let output = exit_info + .format_exit_messages(/*color_enabled*/ false) + .join("\n") + .replace(&thread_id.to_string(), "THREAD_ID"); + assert_snapshot!("daemon_disconnect_exit", output); + Ok(()) +} + +#[tokio::test] +async fn remote_disconnect_exit_summary_does_not_require_a_local_rollout_or_print_credentials() { + let (mut app, _, _) = make_test_app_with_channels().await; + app.app_server_target = AppServerTarget::Remote { + endpoint: crate::RemoteAppServerEndpoint::WebSocket { + websocket_url: "wss://user:secret@example.com:443/?token=private#secret".to_string(), + auth_token: Some("secret-token".to_string()), + }, + }; + let thread_id = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); + app.active_thread_id = Some(thread_id); + app.chat_widget.handle_thread_session(test_thread_session( + thread_id, + test_path_buf("/tmp/project"), + )); + let exit_info = app.exit_info(ExitReason::Fatal("connection lost".to_string())); + let lines = exit_info.format_exit_messages(/*color_enabled*/ false); + let command = shlex::split(lines[1].strip_prefix("Reconnect: ").unwrap()).unwrap(); + assert_eq!( + crate::resolve_remote_addr(&command[2]).unwrap(), + crate::RemoteAppServerEndpoint::WebSocket { + websocket_url: "wss://example.com/".to_string(), + auth_token: None, + } + ); + assert_snapshot!("remote_disconnect_exit", lines.join("\n")); +} + +#[tokio::test] +async fn embedded_exit_keeps_the_session_summary() { + let (mut app, _, _) = make_test_app_with_channels().await; + prepare_local_daemon_thread(&mut app).unwrap(); + app.app_server_target = AppServerTarget::Embedded; + let mut exit_info = app.exit_info(ExitReason::UserRequested); + exit_info.token_usage = TokenUsage { + output_tokens: 2, + total_tokens: 2, + ..Default::default() + }; + exit_info.resume_hint = Some("codex resume THREAD_ID".to_string()); + assert_eq!( + exit_info.format_exit_messages(/*color_enabled*/ false), + vec![ + "Token usage: total=2 input=0 output=2", + "To continue this session, run codex resume THREAD_ID", + ] + ); +} + fn prepare_local_daemon_thread(app: &mut App) -> Result { app.app_server_target = AppServerTarget::LocalDaemon { endpoint: crate::RemoteAppServerEndpoint::UnixSocket { @@ -226,10 +295,21 @@ async fn exit_interrupts_before_requesting_shutdown() -> Result<()> { assert_matches!(control, AppRunControl::Continue); assert!(op_rx.try_recv().is_err()); - assert_matches!( - app_event_rx.try_recv(), - Ok(AppEvent::Exit(ExitMode::ShutdownFirst)) - ); + let exit_event = app_event_rx.try_recv()?; + assert_matches!(exit_event, AppEvent::Exit(ExitMode::ShutdownAfterInterrupt)); + let AppRunControl::Exit(reason) = app + .handle_event(&mut tui, &mut app_server, exit_event) + .await? + else { + panic!("stop-and-exit must exit"); + }; + assert_matches!(reason, ExitReason::TurnInterrupted); + let output = app + .exit_info(reason) + .format_exit_messages(/*color_enabled*/ false) + .join("\n") + .replace(&thread_id.to_string(), "THREAD_ID"); + assert_snapshot!("interrupted_disconnect_exit", output); Ok(()) } diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index 8baba2bcf3..5f803be251 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -348,6 +348,64 @@ async fn make_history_test_app() -> Result<(App, tempfile::TempDir)> { Ok((app, codex_home)) } +#[tokio::test] +async fn removing_remote_thread_omits_disconnect_guidance() -> Result<()> { + for event in [ + AppEvent::ArchiveCurrentThread, + AppEvent::DeleteCurrentThread, + ] { + let (mut app, codex_home) = make_history_test_app().await?; + let thread_id = ThreadId::from_string( + &create_fake_rollout( + codex_home.path(), + "2026-01-01T00-00-00", + "2026-01-01T00:00:00Z", + "Saved user message", + Some(app.config.model_provider_id.as_str()), + /*git_info*/ None, + ) + .expect("create rollout"), + )?; + let (mut server, _, proxy) = start_recording_app_server( + &app.config, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + ) + .await?; + let resumed = server + .resume_thread( + app.config.clone(), + thread_id, + crate::app_server_session::ResumeModelSettings::RestoreFromThread, + ) + .await?; + app.app_server_target = AppServerTarget::Remote { + endpoint: crate::resolve_remote_addr("ws://127.0.0.1:4500")?, + }; + app.active_thread_id = Some(thread_id); + app.chat_widget.handle_thread_session(resumed.session); + let mut tui = crate::tui::test_support::make_test_tui()?; + let AppRunControl::Exit(reason) = app.handle_event(&mut tui, &mut server, event).await? + else { + panic!("removing the current thread must exit"); + }; + assert_matches!(reason, ExitReason::ThreadRemoved); + let mut exit_info = app.exit_info(reason); + exit_info.token_usage = TokenUsage { + output_tokens: 2, + total_tokens: 2, + ..Default::default() + }; + assert_eq!( + exit_info.format_exit_messages(/*color_enabled*/ false), + vec!["Token usage: total=2 input=0 output=2",] + ); + server.shutdown().await?; + proxy.await??; + } + Ok(()) +} + fn spawn_approved_task_tool_call( app: &App, app_server: &AppServerSession, diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__daemon_disconnect_exit.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__daemon_disconnect_exit.snap new file mode 100644 index 0000000000..720042aed7 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__daemon_disconnect_exit.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/app/tests/background_exit_tests.rs +expression: output +--- +Disconnected from this task. Any running work continues. +Reconnect: codex resume THREAD_ID +Stop the current turn: run codex agents, select this task, and press f10. +Token usage so far: total=12 input=10 output=2 diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__interrupted_disconnect_exit.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__interrupted_disconnect_exit.snap new file mode 100644 index 0000000000..7ee7009841 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__interrupted_disconnect_exit.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/app/tests/background_exit_tests.rs +expression: output +--- +Disconnected from this task. The current turn was stopped. +Reconnect: codex resume THREAD_ID diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__remote_disconnect_exit.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__remote_disconnect_exit.snap new file mode 100644 index 0000000000..8a0a99e23a --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__remote_disconnect_exit.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/app/tests/background_exit_tests.rs +expression: "lines.join(\"\\n\")" +--- +Disconnected from this task. Work may still be running. +Reconnect: codex --remote wss://example.com:443/ resume 123e4567-e89b-12d3-a456-426614174000 +Stop the current turn: run codex --remote wss://example.com:443/ agents, select this task, and press ctrl + x. diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index b6b82ef7d8..56d8f141a7 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1346,6 +1346,8 @@ pub(crate) struct PermissionProfileSelection { pub(crate) enum ExitMode { /// Shutdown core and exit after completion. ShutdownFirst, + /// Unsubscribe and exit after the current turn was successfully interrupted. + ShutdownAfterInterrupt, /// Exit the UI loop immediately without waiting for shutdown. /// /// This skips `Op::Shutdown`, so any in-flight work may be dropped and diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 2c0a96bf02..878978a1d2 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -21,6 +21,7 @@ pub use crate::startup_error::LocalStateDbStartupError; use additional_dirs::add_dir_warning_message; use app::App; pub use app::AppExitInfo; +pub use app::DisconnectInfo; pub use app::ExitReason; use app_server_session::AppServerSession; use app_server_session::ThreadParamsMode; @@ -948,6 +949,7 @@ pub async fn run_main( token_usage: TokenUsage::default(), thread_id: None, resume_hint: None, + disconnect_info: None, update_action: None, exit_reason: ExitReason::UserRequested, }), @@ -1007,6 +1009,7 @@ async fn run_ratatui_app( token_usage: crate::token_usage::TokenUsage::default(), thread_id: None, resume_hint: None, + disconnect_info: None, update_action: Some(action), exit_reason: ExitReason::UserRequested, }); @@ -1179,6 +1182,7 @@ async fn run_ratatui_app( token_usage: crate::token_usage::TokenUsage::default(), thread_id: None, resume_hint: None, + disconnect_info: None, update_action: None, exit_reason: ExitReason::UserRequested, }); @@ -1251,6 +1255,7 @@ async fn run_ratatui_app( token_usage: crate::token_usage::TokenUsage::default(), thread_id: None, resume_hint: None, + disconnect_info: None, update_action: None, exit_reason: ExitReason::Fatal(format!( "No saved session found with ID {id_str}. Run `codex {action}` without an ID to choose from existing sessions." @@ -1346,6 +1351,7 @@ async fn run_ratatui_app( token_usage: crate::token_usage::TokenUsage::default(), thread_id: None, resume_hint: None, + disconnect_info: None, update_action: None, exit_reason: ExitReason::UserRequested, }); @@ -1440,6 +1446,7 @@ async fn run_ratatui_app( token_usage: crate::token_usage::TokenUsage::default(), thread_id: None, resume_hint: None, + disconnect_info: None, update_action: None, exit_reason: ExitReason::UserRequested, }); @@ -1489,6 +1496,7 @@ async fn run_ratatui_app( token_usage: crate::token_usage::TokenUsage::default(), thread_id: None, resume_hint: None, + disconnect_info: None, update_action: None, exit_reason: ExitReason::UserRequested, }); diff --git a/codex-rs/tui/src/main.rs b/codex-rs/tui/src/main.rs index 486dbe6499..bab3c66912 100644 --- a/codex-rs/tui/src/main.rs +++ b/codex-rs/tui/src/main.rs @@ -2,7 +2,6 @@ use clap::Parser; use codex_arg0::Arg0DispatchPaths; use codex_arg0::arg0_dispatch_or_else; use codex_config::LoaderOverrides; -use codex_tui::AppExitInfo; use codex_tui::Cli; use codex_tui::ExitReason; use codex_tui::run_main; @@ -10,34 +9,6 @@ use codex_utils_cli::CliConfigOverrides; use std::io::Write; use supports_color::Stream; -fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec { - let is_fatal = matches!(&exit_info.exit_reason, ExitReason::Fatal(_)); - let AppExitInfo { - token_usage, - thread_id, - resume_hint, - .. - } = exit_info; - - let mut lines = Vec::new(); - if !token_usage.is_zero() { - lines.push(token_usage.to_string()); - } - - if let Some(resume_cmd) = resume_hint { - let command = if color_enabled { - format!("\u{1b}[36m{resume_cmd}\u{1b}[39m") - } else { - resume_cmd - }; - lines.push(format!("To continue this session, run {command}")); - } else if is_fatal && let Some(thread_id) = thread_id { - lines.push(format!("Session ID: {thread_id}")); - } - - lines -} - #[derive(Parser, Debug)] struct TopCli { #[clap(flatten)] @@ -67,11 +38,13 @@ fn main() -> anyhow::Result<()> { eprintln!("ERROR: {message}"); true } - ExitReason::UserRequested => false, + ExitReason::UserRequested | ExitReason::TurnInterrupted | ExitReason::ThreadRemoved => { + false + } }; let color_enabled = supports_color::on(Stream::Stdout).is_some(); - for line in format_exit_messages(exit_info, color_enabled) { + for line in exit_info.format_exit_messages(color_enabled) { println!("{line}"); } if is_fatal { diff --git a/codex-rs/tui/src/session_start.rs b/codex-rs/tui/src/session_start.rs index 5bae25a7c9..b5e6f15aba 100644 --- a/codex-rs/tui/src/session_start.rs +++ b/codex-rs/tui/src/session_start.rs @@ -92,6 +92,7 @@ pub(crate) async fn cancel_session_start(app_server: AppServerSession) -> AppExi token_usage: Default::default(), thread_id: None, resume_hint: None, + disconnect_info: None, update_action: None, exit_reason: ExitReason::UserRequested, } diff --git a/codex-rs/tui/src/status/remote_connection.rs b/codex-rs/tui/src/status/remote_connection.rs index 610af5815b..5b38f148b4 100644 --- a/codex-rs/tui/src/status/remote_connection.rs +++ b/codex-rs/tui/src/status/remote_connection.rs @@ -20,7 +20,8 @@ pub(crate) fn remote_connection_status_value( }; let address = match endpoint { RemoteAppServerEndpoint::WebSocket { websocket_url, .. } => { - sanitized_websocket_display_address(websocket_url) + sanitized_websocket_url(websocket_url) + .map(|url| url.to_string()) .unwrap_or_else(|| "".to_string()) } RemoteAppServerEndpoint::UnixSocket { socket_path } => { @@ -33,13 +34,13 @@ pub(crate) fn remote_connection_status_value( Some(RemoteConnectionStatus { address, version }) } -fn sanitized_websocket_display_address(raw: &str) -> Option { +pub(crate) fn sanitized_websocket_url(raw: &str) -> Option { let mut url = Url::parse(raw).ok()?; let _ = url.set_username(""); let _ = url.set_password(None); url.set_query(None); url.set_fragment(None); - Some(url.to_string()) + Some(url) } #[cfg(test)]