From a76b546afce381eeeb6bd069417978c4a3685b33 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sun, 17 May 2026 20:18:10 -0700 Subject: [PATCH 1/3] Clarify resume hints for renamed threads Refs #23181 --- codex-rs/cli/src/main.rs | 10 ++--- codex-rs/tui/src/app.rs | 13 ++++--- codex-rs/tui/src/app/event_dispatch.rs | 2 +- codex-rs/tui/src/app/session_lifecycle.rs | 4 +- codex-rs/tui/src/app/tests/session_summary.rs | 11 ++++-- codex-rs/tui/src/chatwidget.rs | 14 +++---- ...tests__thread_name_update_resume_hint.snap | 5 +++ .../tui/src/chatwidget/tests/app_server.rs | 6 +-- codex-rs/tui/src/main.rs | 5 ++- codex-rs/utils/cli/src/lib.rs | 1 + codex-rs/utils/cli/src/resume_command.rs | 39 +++++++++++++++++++ 11 files changed, 79 insertions(+), 31 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__thread_name_update_resume_hint.snap diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 6c79c34c9906..8ac11ac218ee 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -38,7 +38,7 @@ use codex_tui::UpdateAction; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::CliConfigOverrides; use codex_utils_cli::ProfileV2Name; -use codex_utils_cli::resume_command; +use codex_utils_cli::resume_hint; use owo_colors::OwoColorize; use std::io::IsTerminal; use std::path::PathBuf; @@ -629,6 +629,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec Vec, ) -> Option { let usage_line = (!token_usage.is_zero()).then(|| token_usage.to_string()); - let thread_id = - resumable_thread(thread_id, thread_name, rollout_path).map(|thread| thread.thread_id); - let resume_command = codex_utils_cli::resume_command(/*thread_name*/ None, thread_id); + let resumable_thread = resumable_thread(thread_id, thread_name, rollout_path); + let resume_hint = resumable_thread.as_ref().and_then(|thread| { + codex_utils_cli::resume_hint(thread.thread_name.as_deref(), Some(thread.thread_id)) + }); - if usage_line.is_none() && resume_command.is_none() { + if usage_line.is_none() && resume_hint.is_none() { return None; } Some(SessionSummary { usage_line, - resume_command, + resume_hint, }) } @@ -459,7 +460,7 @@ fn errors_for_cwd(cwd: &Path, response: &SkillsListResponse) -> Vec, - resume_command: Option, + resume_hint: Option, } #[derive(Debug, Default)] diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 9271089c4358..9866f97ac555 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -152,7 +152,7 @@ impl App { if let Some(usage_line) = summary.usage_line { lines.push(usage_line.into()); } - if let Some(command) = summary.resume_command { + if let Some(command) = summary.resume_hint { let spans = vec![ "To continue this session, run ".into(), command.cyan(), diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 75af2f1da972..1a57b596b0ff 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -473,7 +473,7 @@ impl App { if let Some(usage_line) = summary.usage_line { lines.push(usage_line.into()); } - if let Some(command) = summary.resume_command { + if let Some(command) = summary.resume_hint { let spans = vec!["To continue this session, run ".into(), command.cyan()]; lines.push(spans.into()); } @@ -701,7 +701,7 @@ impl App { if let Some(usage_line) = summary.usage_line { lines.push(usage_line.into()); } - if let Some(command) = summary.resume_command { + if let Some(command) = summary.resume_hint { let spans = vec!["To continue this session, run ".into(), command.cyan()]; lines.push(spans.into()); diff --git a/codex-rs/tui/src/app/tests/session_summary.rs b/codex-rs/tui/src/app/tests/session_summary.rs index 2a96f9ad3deb..63ff92a097e0 100644 --- a/codex-rs/tui/src/app/tests/session_summary.rs +++ b/codex-rs/tui/src/app/tests/session_summary.rs @@ -57,13 +57,13 @@ async fn session_summary_includes_resume_hint_for_persisted_rollout() { Some("Token usage: total=12 input=10 output=2".to_string()) ); assert_eq!( - summary.resume_command, + summary.resume_hint, Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string()) ); } #[tokio::test] -async fn session_summary_uses_id_even_when_thread_has_name() { +async fn session_summary_names_picker_item_when_thread_has_name() { let usage = TokenUsage { input_tokens: 10, output_tokens: 2, @@ -83,7 +83,10 @@ async fn session_summary_uses_id_even_when_thread_has_name() { ) .expect("summary"); assert_eq!( - summary.resume_command, - Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string()) + summary.resume_hint, + Some( + "codex resume, then select my-session (123e4567-e89b-12d3-a456-426614174000)" + .to_string() + ) ); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index a37371bf73ef..1307c4467f69 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -166,7 +166,7 @@ use codex_terminal_detection::TerminalInfo; use codex_terminal_detection::TerminalName; use codex_terminal_detection::terminal_info; use codex_utils_absolute_path::AbsolutePathBuf; -use codex_utils_cli::resume_command; +use codex_utils_cli::resume_hint; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -1436,16 +1436,14 @@ impl ChatWidget { } fn rename_confirmation_cell(name: &str, thread_id: Option) -> PlainHistoryCell { - let resume_cmd = - resume_command(Some(name), thread_id).unwrap_or_else(|| format!("codex resume {name}")); - let name = name.to_string(); - let line = vec![ + let mut line = vec![ "• ".into(), "Thread renamed to ".into(), - name.cyan(), - ", to resume this thread run ".into(), - resume_cmd.cyan(), + name.to_string().cyan(), ]; + if let Some(hint) = resume_hint(Some(name), thread_id) { + line.extend([". To resume this thread run ".into(), hint.cyan()]); + } PlainHistoryCell::new(vec![line.into()]) } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__thread_name_update_resume_hint.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__thread_name_update_resume_hint.snap new file mode 100644 index 000000000000..b3cdf89da3a4 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__thread_name_update_resume_hint.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests/app_server.rs +expression: rendered +--- +• Thread renamed to review-fix. To resume this thread run codex resume, then select review-fix (123e4567-e89b-12d3-a456-426614174000) diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 26e38900ce53..e6fad4555fe9 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -827,7 +827,8 @@ async fn live_app_server_invalid_thread_name_update_is_ignored() { #[tokio::test] async fn live_app_server_thread_name_update_shows_resume_hint() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let thread_id = ThreadId::new(); + let thread_id = + ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").expect("thread id"); chat.thread_id = Some(thread_id); chat.handle_server_notification( @@ -844,8 +845,7 @@ async fn live_app_server_thread_name_update_shows_resume_hint() { let cells = drain_insert_history(&mut rx); assert_eq!(cells.len(), 1); let rendered = lines_to_single_string(&cells[0]); - assert!(rendered.contains("Thread renamed to review-fix")); - assert!(rendered.contains("codex resume review-fix")); + assert_chatwidget_snapshot!("thread_name_update_resume_hint", rendered); } #[tokio::test] diff --git a/codex-rs/tui/src/main.rs b/codex-rs/tui/src/main.rs index e41d5f5a7df9..9c69c7c81af6 100644 --- a/codex-rs/tui/src/main.rs +++ b/codex-rs/tui/src/main.rs @@ -7,13 +7,14 @@ use codex_tui::Cli; use codex_tui::ExitReason; use codex_tui::run_main; use codex_utils_cli::CliConfigOverrides; -use codex_utils_cli::resume_command; +use codex_utils_cli::resume_hint; use supports_color::Stream; fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec { let AppExitInfo { token_usage, thread_id, + thread_name, .. } = exit_info; @@ -22,7 +23,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec, thread_id: Option) -> }) } +pub fn resume_hint(thread_name: Option<&str>, thread_id: Option) -> Option { + let thread_id = thread_id?; + match thread_name.filter(|name| !name.is_empty()) { + Some(thread_name) => Some(format!( + "codex resume, then select {thread_name} ({thread_id})" + )), + None => resume_command(/*thread_name*/ None, Some(thread_id)), + } +} + #[cfg(test)] mod tests { use super::*; @@ -61,4 +71,33 @@ mod tests { let command = resume_command(Some("quote'case"), /*thread_id*/ None); assert_eq!(command, Some("codex resume \"quote'case\"".to_string())); } + + #[test] + fn resume_hint_names_picker_item_with_id() { + let thread_id = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); + let hint = resume_hint(Some("my-thread"), Some(thread_id)); + assert_eq!( + hint, + Some( + "codex resume, then select my-thread (123e4567-e89b-12d3-a456-426614174000)" + .to_string() + ) + ); + } + + #[test] + fn resume_hint_uses_direct_id_command_without_name() { + let thread_id = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); + let hint = resume_hint(/*thread_name*/ None, Some(thread_id)); + assert_eq!( + hint, + Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string()) + ); + } + + #[test] + fn resume_hint_requires_thread_id() { + let hint = resume_hint(Some("my-thread"), /*thread_id*/ None); + assert_eq!(hint, None); + } } From 781e587753532e13578ec9b97c1a55b7c4a1b1b6 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 18 May 2026 14:48:32 -0300 Subject: [PATCH 2/3] fix(tui): handle paste in session picker --- codex-rs/tui/src/resume_picker.rs | 104 ++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 527c84a8e222..b6712cfa97d4 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -477,6 +477,9 @@ async fn run_session_picker_with_loader( return Ok(sel); } } + TuiEvent::Paste(pasted) => { + state.handle_paste(pasted); + } TuiEvent::Draw | TuiEvent::Resize => { if let Ok(size) = alt.tui.terminal.size() { let list_height = @@ -543,6 +546,11 @@ fn picker_cwd_filter( } } +fn normalize_pasted_query(pasted: &str) -> Option { + let normalized = pasted.split_whitespace().collect::>().join(" "); + (!normalized.is_empty()).then_some(normalized) +} + fn spawn_app_server_page_loader( app_server: AppServerSession, include_non_interactive: bool, @@ -1227,6 +1235,21 @@ impl PickerState { Ok(None) } + fn handle_paste(&mut self, pasted: String) { + if self.is_transcript_loading() { + return; + } + let Some(pasted) = normalize_pasted_query(&pasted) else { + return; + }; + let mut new_query = self.query.clone(); + if !new_query.is_empty() && !new_query.ends_with(char::is_whitespace) { + new_query.push(' '); + } + new_query.push_str(&pasted); + self.set_query(new_query); + } + fn start_initial_load(&mut self) { self.relative_time_reference = Some(Utc::now()); self.reset_pagination(); @@ -6216,6 +6239,87 @@ session_picker_view = "dense" assert!(state.pagination.reached_scan_cap); } + #[tokio::test] + async fn paste_appends_to_existing_query() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.query = String::from("resize"); + + state.handle_paste(String::from("results")); + + assert_eq!(state.query, "resize results"); + } + + #[test] + fn normalize_pasted_query_collapses_whitespace() { + assert_eq!( + normalize_pasted_query(" alpha\n\tbeta\r\n gamma "), + Some(String::from("alpha beta gamma")) + ); + } + + #[tokio::test] + async fn whitespace_only_paste_is_ignored() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.query = String::from("resize"); + + state.handle_paste(String::from(" \n\t ")); + + assert_eq!(state.query, "resize"); + } + + #[tokio::test] + async fn paste_uses_existing_search_loading_path() { + let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let request_sink = recorded_requests.clone(); + let loader = page_only_loader(move |req: PageLoadRequest| { + request_sink.lock().unwrap().push(req); + }); + + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.reset_pagination(); + state.ingest_page(page( + vec![make_row( + "/tmp/start.jsonl", + "2025-01-01T00:00:00Z", + "alpha", + )], + Some("2025-01-02T00:00:00Z"), + /*num_scanned_files*/ 1, + /*reached_scan_cap*/ false, + )); + recorded_requests.lock().unwrap().clear(); + + state.handle_paste(String::from("target")); + + let guard = recorded_requests.lock().unwrap(); + assert_eq!(state.query, "target"); + assert_eq!(guard.len(), 1); + assert!(guard[0].search_token.is_some()); + } + #[tokio::test] async fn esc_with_empty_query_starts_fresh() { let loader = page_only_loader(|_| {}); From 3e069288ed179a578a52e5ebdd6f2663e658df90 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 18 May 2026 14:54:33 -0300 Subject: [PATCH 3/3] fix(tui): remove dead resume picker match arm --- codex-rs/tui/src/resume_picker.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index b6712cfa97d4..cfc66d927d66 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -492,7 +492,6 @@ async fn run_session_picker_with_loader( state.open_pending_transcript_if_ready(); } } - _ => {} } } Some(event) = background_events.next() => {