Clarify resume guidance in exit summaries (#41938)

## What changed

- Show the exact `codex resume <thread-id>` command on its own indented line.
- When a thread has a name, also explain that users can run `codex resume` and select the named thread from the picker.
- Apply command highlighting to both resume options when color is enabled.

## Testing

- Cover unnamed, empty-name, named, colored, and embedded-session exit summaries.

GitOrigin-RevId: d97c04db8a34376c6d9ea839b515b04a384b85d1
This commit is contained in:
Benjamin Carlsson authored and copyberry committed 2026-09-01 00:02:38 +00:00
1 parent 115ffaf8bf
commit 0e03f88a30
5 files changed
+81 -39

No files matched your search

+44 -19
View File
@@ -3735,7 +3735,10 @@ mod tests {
AppExitInfo { AppExitInfo {
token_usage, token_usage,
thread_id, thread_id,
resume_hint: codex_utils_cli::resume_hint(thread_name, thread_id), resume_hint: thread_id.map(|thread_id| codex_tui::ResumableThread {
thread_id,
thread_name: thread_name.map(str::to_string),
}),
disconnect_info: None, disconnect_info: None,
update_action: None, update_action: None,
exit_reason: ExitReason::UserRequested, exit_reason: ExitReason::UserRequested,
@@ -3844,27 +3847,26 @@ mod tests {
lines, lines,
vec![ vec![
"Token usage: total=2 input=0 output=2".to_string(), "Token usage: total=2 input=0 output=2".to_string(),
"To continue this session, run codex resume 123e4567-e89b-12d3-a456-426614174000" "To continue this session, run:".to_string(),
.to_string(), " codex resume 123e4567-e89b-12d3-a456-426614174000".to_string(),
] ]
); );
} }
#[test] #[test]
fn format_exit_messages_includes_resume_hint_without_color() { fn format_exit_messages_includes_resume_hint_without_color() {
let exit_info = sample_exit_info( insta::allow_duplicates! {
Some("123e4567-e89b-12d3-a456-426614174000"), for thread_name in [None, Some("")] {
/*thread_name*/ None, let exit_info =
); sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000"), thread_name);
let lines = exit_info.format_exit_messages(/*color_enabled*/ false); let lines = exit_info.format_exit_messages(/*color_enabled*/ false);
assert_eq!( insta::assert_snapshot!(lines.join("\n"), @"
lines, Token usage: total=2 input=0 output=2
vec![ To continue this session, run:
"Token usage: total=2 input=0 output=2".to_string(), codex resume 123e4567-e89b-12d3-a456-426614174000
"To continue this session, run codex resume 123e4567-e89b-12d3-a456-426614174000" ");
.to_string(), }
] }
);
} }
#[test] #[test]
@@ -3874,8 +3876,14 @@ mod tests {
/*thread_name*/ None, /*thread_name*/ None,
); );
let lines = exit_info.format_exit_messages(/*color_enabled*/ true); let lines = exit_info.format_exit_messages(/*color_enabled*/ true);
assert_eq!(lines.len(), 2); assert_eq!(
assert!(lines[1].contains("\u{1b}[36m")); lines,
vec![
"Token usage: total=2 input=0 output=2",
"To continue this session, run:",
" \u{1b}[36mcodex resume 123e4567-e89b-12d3-a456-426614174000\u{1b}[39m",
]
);
} }
#[test] #[test]
@@ -3885,11 +3893,28 @@ mod tests {
Some("my-thread"), Some("my-thread"),
); );
let lines = exit_info.format_exit_messages(/*color_enabled*/ false); let lines = exit_info.format_exit_messages(/*color_enabled*/ false);
insta::assert_snapshot!(lines.join("\n"), @"
Token usage: total=2 input=0 output=2
To continue this session, run:
codex resume 123e4567-e89b-12d3-a456-426614174000
Or run codex resume and select my-thread.
");
}
#[test]
fn format_exit_messages_colors_commands_and_thread_name() {
let exit_info = sample_exit_info(
Some("123e4567-e89b-12d3-a456-426614174000"),
Some("my-thread"),
);
let lines = exit_info.format_exit_messages(/*color_enabled*/ true);
assert_eq!( assert_eq!(
lines, lines,
vec![ vec![
"Token usage: total=2 input=0 output=2".to_string(), "Token usage: total=2 input=0 output=2",
"To continue this session, run codex resume, then select my-thread (123e4567-e89b-12d3-a456-426614174000)".to_string(), "To continue this session, run:",
" \u{1b}[36mcodex resume 123e4567-e89b-12d3-a456-426614174000\u{1b}[39m",
"Or run \u{1b}[36mcodex resume\u{1b}[39m and select \u{1b}[36mmy-thread\u{1b}[39m.",
] ]
); );
} }
+2 -7
View File
@@ -423,7 +423,7 @@ const COMMIT_ANIMATION_TICK: Duration = tui::TARGET_FRAME_INTERVAL;
pub struct AppExitInfo { pub struct AppExitInfo {
pub token_usage: TokenUsage, pub token_usage: TokenUsage,
pub thread_id: Option<ThreadId>, pub thread_id: Option<ThreadId>,
pub resume_hint: Option<String>, pub resume_hint: Option<ResumableThread>,
pub disconnect_info: Option<DisconnectInfo>, pub disconnect_info: Option<DisconnectInfo>,
pub update_action: Option<UpdateAction>, pub update_action: Option<UpdateAction>,
pub exit_reason: ExitReason, pub exit_reason: ExitReason,
@@ -443,6 +443,7 @@ impl AppExitInfo {
} }
pub use exit_summary::DisconnectInfo; pub use exit_summary::DisconnectInfo;
pub use exit_summary::ResumableThread;
#[derive(Debug)] #[derive(Debug)]
pub(crate) enum AppRunControl { pub(crate) enum AppRunControl {
@@ -479,12 +480,6 @@ fn session_summary(
}) })
} }
#[derive(Debug, Clone, PartialEq, Eq)]
struct ResumableThread {
thread_id: ThreadId,
thread_name: Option<String>,
}
fn resumable_thread( fn resumable_thread(
thread_id: Option<ThreadId>, thread_id: Option<ThreadId>,
thread_name: Option<String>, thread_name: Option<String>,
+20 -4
View File
@@ -1,10 +1,18 @@
//! Exit output distinguishes a disconnected client from a stopped embedded server. //! Exit output distinguishes a disconnected client from a stopped embedded server.
//! Persisted sessions offer a direct UUID command and an optional named picker hint.
use super::*; use super::*;
use crate::RemoteAppServerEndpoint; use crate::RemoteAppServerEndpoint;
use crate::exec_command::escape_command; use crate::exec_command::escape_command;
use crate::status::remote_connection::sanitized_websocket_url; use crate::status::remote_connection::sanitized_websocket_url;
/// A persisted thread that can be resumed after the TUI exits.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResumableThread {
pub thread_id: ThreadId,
pub thread_name: Option<String>,
}
/// Reconnection and stop guidance for a task owned by a persistent app server. /// Reconnection and stop guidance for a task owned by a persistent app server.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct DisconnectInfo { pub struct DisconnectInfo {
@@ -57,7 +65,7 @@ impl App {
AppExitInfo { AppExitInfo {
token_usage: self.token_usage(), token_usage: self.token_usage(),
thread_id, thread_id,
resume_hint: resume_hint_for_resumable_thread( resume_hint: resumable_thread(
thread_id, thread_id,
self.chat_widget.thread_name(), self.chat_widget.thread_name(),
self.chat_widget.rollout_path().as_deref(), self.chat_widget.rollout_path().as_deref(),
@@ -125,11 +133,19 @@ impl AppExitInfo {
} }
if let ExitReason::Archived(thread_id) = self.exit_reason { if let ExitReason::Archived(thread_id) = self.exit_reason {
lines.push(format!("Session archived: {thread_id}")); lines.push(format!("Session archived: {thread_id}"));
} else if let Some(resume_command) = self.resume_hint { } else if let Some(thread) = self.resume_hint {
lines.push("To continue this session, run:".to_string());
lines.push(format!( lines.push(format!(
"To continue this session, run {}", " {}",
color_command(resume_command), color_command(format!("codex resume {}", thread.thread_id)),
)); ));
if let Some(thread_name) = thread.thread_name.filter(|name| !name.is_empty()) {
lines.push(format!(
"Or run {} and select {}.",
color_command("codex resume".to_string()),
color_command(thread_name),
));
}
} else if let Some(thread_id) = self.thread_id { } else if let Some(thread_id) = self.thread_id {
lines.push(format!("Session ID: {thread_id}")); lines.push(format!("Session ID: {thread_id}"));
} }
@@ -52,7 +52,7 @@ async fn remote_disconnect_exit_summary_does_not_require_a_local_rollout_or_prin
#[tokio::test] #[tokio::test]
async fn embedded_exit_keeps_the_session_summary() { async fn embedded_exit_keeps_the_session_summary() {
let (mut app, _, _) = make_test_app_with_channels().await; let (mut app, _, _) = make_test_app_with_channels().await;
prepare_local_daemon_thread(&mut app).unwrap(); let thread_id = prepare_local_daemon_thread(&mut app).unwrap();
app.app_server_target = AppServerTarget::Embedded; app.app_server_target = AppServerTarget::Embedded;
let mut exit_info = app.exit_info(ExitReason::UserRequested); let mut exit_info = app.exit_info(ExitReason::UserRequested);
exit_info.token_usage = TokenUsage { exit_info.token_usage = TokenUsage {
@@ -60,14 +60,19 @@ async fn embedded_exit_keeps_the_session_summary() {
total_tokens: 2, total_tokens: 2,
..Default::default() ..Default::default()
}; };
exit_info.resume_hint = Some("codex resume THREAD_ID".to_string()); exit_info.resume_hint = Some(ResumableThread {
assert_eq!( thread_id,
exit_info.format_exit_messages(/*color_enabled*/ false), thread_name: None,
vec![ });
"Token usage: total=2 input=0 output=2", let output = exit_info
"To continue this session, run codex resume THREAD_ID", .format_exit_messages(/*color_enabled*/ false)
] .join("\n")
); .replace(&thread_id.to_string(), "THREAD_ID");
assert_snapshot!(output, @"
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> { fn prepare_local_daemon_thread(app: &mut App) -> Result<ThreadId> {
+1
View File
@@ -23,6 +23,7 @@ use app::App;
pub use app::AppExitInfo; pub use app::AppExitInfo;
pub use app::DisconnectInfo; pub use app::DisconnectInfo;
pub use app::ExitReason; pub use app::ExitReason;
pub use app::ResumableThread;
use app_server_session::AppServerSession; use app_server_session::AppServerSession;
use app_server_session::ThreadParamsMode; use app_server_session::ThreadParamsMode;
use codex_app_server_client::AppServerClient; use codex_app_server_client::AppServerClient;