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
This commit is contained in:
Eric Traut
2026-08-25 15:55:35 +00:00
committed by copyberry
parent 0cdb1f1c83
commit a63cb33e8d
16 changed files with 408 additions and 96 deletions
+68 -39
View File
@@ -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<AbsolutePathBuf, String> {
.map_err(|err| format!("failed to resolve socket path `{raw}`: {err}"))
}
fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<String> {
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<F, Fut>(
mut start_tui: F,
remote_auth_token_env: Option<&str>,
) -> std::io::Result<AppExitInfo>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = std::io::Result<AppExitInfo>>,
{
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![
+8
View File
@@ -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<ThreadId>,
pub resume_hint: Option<String>,
pub disconnect_info: Option<DisconnectInfo>,
pub update_action: Option<UpdateAction>,
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),
}
+10 -6
View File
@@ -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}"));
+138
View File
@@ -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<String>,
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(|| "<server-address>".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<String> {
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
}
}
+1 -13
View File
@@ -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))
}
}
+1
View File
@@ -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,
}));
@@ -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<ThreadId> {
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(())
}
@@ -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,
@@ -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
@@ -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
@@ -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.
+2
View File
@@ -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
+8
View File
@@ -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,
});
+4 -31
View File
@@ -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<String> {
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 {
+1
View File
@@ -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,
}
+4 -3
View File
@@ -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(|| "<invalid websocket URL>".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<String> {
pub(crate) fn sanitized_websocket_url(raw: &str) -> Option<Url> {
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)]