From ac0cdf81a284f042d441bcf08e9cedadfdeefab1 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sun, 17 May 2026 10:48:13 -0700 Subject: [PATCH 1/5] Start fresh TUI thread in background --- codex-rs/tui/src/app.rs | 45 +++++++++++++++++++++-- codex-rs/tui/src/app/event_dispatch.rs | 4 ++ codex-rs/tui/src/app/session_lifecycle.rs | 36 ++++++++++++++++++ codex-rs/tui/src/app/test_support.rs | 1 + codex-rs/tui/src/app/tests.rs | 2 + codex-rs/tui/src/app_event.rs | 7 ++++ codex-rs/tui/src/app_server_session.rs | 26 ++++++++++++- 7 files changed, 117 insertions(+), 4 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 3164f16acb85..0daa243dd619 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -543,6 +543,7 @@ pub(crate) struct App { primary_session_configured: Option, pending_primary_events: VecDeque, pending_app_server_requests: PendingAppServerRequests, + pending_startup_thread_start_request_id: Option, // Serialize plugin enablement writes per plugin so stale completions cannot // overwrite a newer toggle, even if the plugin is toggled from different // cwd contexts. @@ -575,6 +576,33 @@ async fn resolve_runtime_model_provider_base_url(provider: &ModelProviderInfo) - } } +fn spawn_startup_thread_start( + app_server: &AppServerSession, + config: Config, + request_id: String, + app_event_tx: AppEventSender, +) { + let request_handle = app_server.request_handle(); + let thread_params_mode = app_server.thread_params_mode(); + let remote_cwd_override = app_server.remote_cwd_override().map(Path::to_path_buf); + let event_request_id = request_id.clone(); + tokio::spawn(async move { + let result = crate::app_server_session::start_thread_with_request_handle( + request_handle, + request_id, + config, + thread_params_mode, + remote_cwd_override, + ) + .await + .map_err(|err| format!("{err:#}")); + app_event_tx.send(AppEvent::StartupThreadStarted { + request_id: event_request_id, + result, + }); + }); +} + #[derive(Debug, Clone, PartialEq, Eq)] enum ActiveTurnSteerRace { Missing, @@ -778,10 +806,20 @@ impl App { &initial_images, ); let thread_and_widget_started_at = Instant::now(); + let mut pending_startup_thread_start_request_id = None; let (mut chat_widget, initial_started_thread) = match session_selection { SessionSelection::StartFresh | SessionSelection::Exit => { - let started = app_server.start_thread(&config).await?; - // Only count a startup tooltip once the fresh thread can actually render it. + let startup_thread_start_request_id = + format!("startup-thread-start-{}", Uuid::new_v4()); + pending_startup_thread_start_request_id = + Some(startup_thread_start_request_id.clone()); + spawn_startup_thread_start( + &app_server, + config.clone(), + startup_thread_start_request_id, + app_event_tx.clone(), + ); + // Count a startup tooltip once the initial chat widget can render it. let startup_tooltip_override = prepare_startup_tooltip_override(&mut config, &available_models, is_first_run) .await; @@ -811,7 +849,7 @@ impl App { .clone(), session_telemetry: session_telemetry.clone(), }; - (ChatWidget::new_with_app_event(init), Some(started)) + (ChatWidget::new_with_app_event(init), None) } SessionSelection::Resume(target_session) => { let resumed = app_server @@ -956,6 +994,7 @@ See the Codex keymap documentation for supported actions and examples." primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), + pending_startup_thread_start_request_id, pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), }; diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 68bd759e2f63..145f68509642 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -23,6 +23,10 @@ impl App { ) .await; } + AppEvent::StartupThreadStarted { request_id, result } => { + self.handle_startup_thread_started(app_server, request_id, result) + .await?; + } AppEvent::ClearUi => { self.clear_terminal_ui(tui, /*redraw_header*/ false)?; self.reset_app_ui_state_after_clear(); diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 6ea53d8b3cb0..65959a86cea7 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -418,10 +418,46 @@ impl App { self.primary_session_configured = None; self.pending_primary_events.clear(); self.pending_app_server_requests.clear(); + self.pending_startup_thread_start_request_id = None; self.chat_widget.set_pending_thread_approvals(Vec::new()); self.sync_active_agent_label(); } + pub(super) async fn handle_startup_thread_started( + &mut self, + app_server: &mut AppServerSession, + request_id: String, + result: Result, + ) -> Result<()> { + if self.pending_startup_thread_start_request_id.as_deref() != Some(request_id.as_str()) { + if let Ok(started) = result + && let Err(err) = app_server + .thread_unsubscribe(started.session.thread_id) + .await + { + tracing::warn!( + thread_id = %started.session.thread_id, + "failed to unsubscribe stale startup thread: {err}" + ); + } + return Ok(()); + } + + self.pending_startup_thread_start_request_id = None; + match result { + Ok(started) => { + self.enqueue_primary_thread_session(started.session, started.turns) + .await?; + } + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to start a fresh session through the app server: {err}" + )); + } + } + Ok(()) + } + pub(super) async fn start_fresh_session_with_summary_hint( &mut self, tui: &mut tui::Tui, diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 6b6c4242102d..4c236c167a34 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -60,6 +60,7 @@ pub(super) async fn make_test_app() -> App { primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), + pending_startup_thread_start_request_id: None, pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index bddf5e544c3d..0bf6e188fdb1 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3882,6 +3882,7 @@ async fn make_test_app() -> App { primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), + pending_startup_thread_start_request_id: None, pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), } @@ -3945,6 +3946,7 @@ async fn make_test_app_with_channels() -> ( primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), + pending_startup_thread_start_request_id: None, pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), }, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 07a5b5117536..cd3a510eb4b9 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -33,6 +33,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_approval_presets::ApprovalPreset; use crate::app_command::AppCommand; +use crate::app_server_session::AppServerStartedThread; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::StatusLineItem; use crate::bottom_pane::TerminalTitleItem; @@ -180,6 +181,12 @@ pub(crate) enum AppEvent { /// Start a new session. NewSession, + /// Result of the fresh startup thread that is attached after the input UI is live. + StartupThreadStarted { + request_id: String, + result: Result, + }, + /// Clear the terminal UI (screen + scrollback), start a fresh session, and keep the /// previous chat resumable. ClearUi, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 1bcb4a559736..f29633308738 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -167,6 +167,7 @@ impl ThreadParamsMode { } } +#[derive(Debug)] pub(crate) struct AppServerStartedThread { pub(crate) session: ThreadSessionState, pub(crate) turns: Vec, @@ -337,6 +338,7 @@ impl AppServerSession { self.client.next_event().await } + #[cfg(test)] pub(crate) async fn start_thread(&mut self, config: &Config) -> Result { self.start_thread_with_session_start_source(config, /*session_start_source*/ None) .await @@ -427,7 +429,7 @@ impl AppServerSession { Ok(started) } - fn thread_params_mode(&self) -> ThreadParamsMode { + pub(crate) fn thread_params_mode(&self) -> ThreadParamsMode { self.thread_params_mode } @@ -1002,6 +1004,28 @@ impl AppServerSession { } } +pub(crate) async fn start_thread_with_request_handle( + request_handle: AppServerRequestHandle, + request_id: String, + config: Config, + thread_params_mode: ThreadParamsMode, + remote_cwd_override: Option, +) -> Result { + let response: ThreadStartResponse = request_handle + .request_typed(ClientRequest::ThreadStart { + request_id: RequestId::String(request_id), + params: thread_start_params_from_config( + &config, + thread_params_mode, + remote_cwd_override.as_deref(), + /*session_start_source*/ None, + ), + }) + .await + .map_err(|err| bootstrap_request_error("thread/start failed during TUI bootstrap", err))?; + started_thread_from_start_response(response, &config, thread_params_mode).await +} + fn thread_realtime_start_params( thread_id: ThreadId, transport: Option, From 62c88b87d7702a81449506781f62d2b73e6f9963 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 19 May 2026 21:28:36 -0700 Subject: [PATCH 2/5] Queue startup input until thread attaches --- codex-rs/tui/src/app.rs | 4 +- codex-rs/tui/src/app/session_lifecycle.rs | 3 + codex-rs/tui/src/app/tests/startup.rs | 49 ++++++++++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 92 ++++++++++++++++++- codex-rs/tui/src/bottom_pane/mod.rs | 4 + codex-rs/tui/src/chatwidget/input_flow.rs | 5 + codex-rs/tui/src/chatwidget/session_flow.rs | 2 + 7 files changed, 155 insertions(+), 4 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 0daa243dd619..b57a28ab9134 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -849,7 +849,9 @@ impl App { .clone(), session_telemetry: session_telemetry.clone(), }; - (ChatWidget::new_with_app_event(init), None) + let mut chat_widget = ChatWidget::new_with_app_event(init); + chat_widget.set_queue_submissions_until_session_configured(/*queue*/ true); + (chat_widget, None) } SessionSelection::Resume(target_session) => { let resumed = app_server diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 65959a86cea7..d1a8679f4528 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -444,10 +444,13 @@ impl App { } self.pending_startup_thread_start_request_id = None; + self.chat_widget + .set_queue_submissions_until_session_configured(/*queue*/ false); match result { Ok(started) => { self.enqueue_primary_thread_session(started.session, started.turns) .await?; + self.chat_widget.maybe_send_next_queued_input(); } Err(err) => { self.chat_widget.add_error_message(format!( diff --git a/codex-rs/tui/src/app/tests/startup.rs b/codex-rs/tui/src/app/tests/startup.rs index c0aa1b61b7b9..6760594e63d5 100644 --- a/codex-rs/tui/src/app/tests/startup.rs +++ b/codex-rs/tui/src/app/tests/startup.rs @@ -1,4 +1,7 @@ use super::*; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; use pretty_assertions::assert_eq; #[test] @@ -132,6 +135,52 @@ fn startup_waiting_gate_not_applied_for_resume_or_fork_session_selection() { ); } +#[tokio::test] +async fn startup_thread_started_submits_queued_startup_input() { + let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; + let request_id = "startup-thread-start-test".to_string(); + app.pending_startup_thread_start_request_id = Some(request_id.clone()); + app.chat_widget + .set_queue_submissions_until_session_configured(/*queue*/ true); + app.chat_widget + .apply_external_edit("queued before startup completes".to_string()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!( + app.chat_widget.queued_user_message_texts(), + vec!["queued before startup completes".to_string()] + ); + + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( + app.chat_widget.config_ref(), + )) + .await + .expect("embedded app server"); + let thread_id = ThreadId::new(); + app.handle_startup_thread_started( + &mut app_server, + request_id, + Ok(AppServerStartedThread { + session: test_thread_session(thread_id, test_path_buf("/tmp/project")), + turns: Vec::new(), + }), + ) + .await + .expect("startup thread should attach"); + + match next_user_turn_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "queued before startup completes".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected queued startup input submission, got {other:?}"), + } +} + #[tokio::test] async fn ignore_same_thread_resume_reports_noop_for_current_thread() { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 2176e7762ec6..212af83d74b4 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -355,6 +355,7 @@ pub(crate) struct ChatComposer { attachments: AttachmentState, placeholder_text: String, is_task_running: bool, + queue_submissions: bool, /// Slash-command draft staged for local recall after application-level dispatch. /// /// This slot is intentionally separate from `ChatComposerHistory` so inline slash commands can @@ -524,6 +525,7 @@ impl ChatComposer { attachments: AttachmentState::default(), placeholder_text, is_task_running: false, + queue_submissions: false, pending_slash_command_history: None, #[cfg(not(target_os = "linux"))] next_element_id: 0, @@ -2817,6 +2819,9 @@ impl ChatComposer { now: Instant, ) -> (InputResult, bool) { if should_queue { + if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { + self.handle_paste(pasted); + } let raw_text = self.draft.textarea.text(); let defer_slash_validation = self.should_parse_as_slash_on_dequeue_from_raw_text(raw_text); @@ -3227,13 +3232,13 @@ impl ChatComposer { self.footer.mode = reset_mode_after_activity(self.footer.mode); } if self.queue_keys.is_pressed(key_event) - && (self.is_task_running || !self.is_bang_shell_command()) + && (self.is_task_running || self.queue_submissions || !self.is_bang_shell_command()) { - return self.handle_submission(self.is_task_running); + return self.handle_submission(self.is_task_running || self.queue_submissions); } if self.submit_keys.is_pressed(key_event) { - return self.handle_submission(/*should_queue*/ false); + return self.handle_submission(self.queue_submissions); } if let KeyEvent { @@ -4143,6 +4148,10 @@ impl ChatComposer { self.is_task_running = running; } + pub(crate) fn set_queue_submissions(&mut self, queue_submissions: bool) { + self.queue_submissions = queue_submissions; + } + pub(crate) fn set_context_window(&mut self, percent: Option, used_tokens: Option) { if self.footer.context_window_percent == percent && self.footer.context_window_used_tokens == used_tokens @@ -7064,6 +7073,49 @@ mod tests { assert_eq!(composer.draft.textarea.text(), "hi\nthere"); } + /// Behavior: startup-pending submissions are queued immediately, so Enter should flush any + /// buffered burst text into that queued message instead of turning into a draft newline. + #[test] + fn queued_submission_flushes_ascii_burst_instead_of_inserting_newline() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let mut now = Instant::now(); + let step = Duration::from_millis(1); + for ch in ['h', 'i'] { + let _ = composer.handle_input_basic_with_time( + KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), + now, + ); + now += step; + } + assert!(composer.is_in_paste_burst()); + + let (result, _) = composer.handle_submission_with_time(/*should_queue*/ true, now); + + assert_eq!( + result, + InputResult::Queued { + text: "hi".to_string(), + text_elements: Vec::new(), + action: QueuedInputAction::Plain, + } + ); + assert!(composer.draft.textarea.text().is_empty()); + assert!(!composer.is_in_paste_burst()); + } + /// Behavior: even if Enter suppression would normally be active for a burst, Enter should /// still dispatch a built-in slash command when the first line begins with `/`. #[test] @@ -7921,6 +7973,40 @@ mod tests { assert!(found_error, "expected error history cell to be sent"); } + #[test] + fn enter_queues_when_queue_submissions_is_enabled() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_queue_submissions(/*queue_submissions*/ true); + composer + .draft + .textarea + .set_text_clearing_elements("queued before session"); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!( + result, + InputResult::Queued { + text: "queued before session".to_string(), + text_elements: Vec::new(), + action: QueuedInputAction::Plain, + } + ); + } + #[test] fn tab_queues_slash_led_prompts_while_task_running_without_validation() { use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 5aeb2a4cfddb..65770b61ae83 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -999,6 +999,10 @@ impl BottomPane { } } + pub(crate) fn set_queue_submissions(&mut self, queue_submissions: bool) { + self.composer.set_queue_submissions(queue_submissions); + } + /// Hide the status indicator while leaving task-running state untouched. pub(crate) fn hide_status_indicator(&mut self) { if self.status.take().is_some() { diff --git a/codex-rs/tui/src/chatwidget/input_flow.rs b/codex-rs/tui/src/chatwidget/input_flow.rs index 704d5dcac6d3..dbc52269a3e2 100644 --- a/codex-rs/tui/src/chatwidget/input_flow.rs +++ b/codex-rs/tui/src/chatwidget/input_flow.rs @@ -72,6 +72,11 @@ impl ChatWidget { self.queue_user_message_with_options(user_message, QueuedInputAction::Plain); } + pub(crate) fn set_queue_submissions_until_session_configured(&mut self, queue: bool) { + self.bottom_pane + .set_queue_submissions(queue && !self.is_session_configured()); + } + pub(super) fn queue_user_message_with_options( &mut self, user_message: UserMessage, diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index 2b8beefdf85a..9837eaa6ea5c 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -20,6 +20,8 @@ impl ChatWidget { self.session_network_proxy = session.network_proxy.clone(); let previous_thread_id = self.thread_id; self.thread_id = Some(session.thread_id); + self.bottom_pane + .set_queue_submissions(/*queue_submissions*/ false); if previous_thread_id != self.thread_id { self.review.recent_auto_review_denials = RecentAutoReviewDenials::default(); } From 0db29bec437c69879cea43c690e3a7590df04cd6 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 19 May 2026 21:38:24 -0700 Subject: [PATCH 3/5] Handle startup thread edge cases --- codex-rs/tui/src/app/session_lifecycle.rs | 29 ++++---- codex-rs/tui/src/app/side.rs | 6 +- codex-rs/tui/src/app/tests/startup.rs | 80 +++++++++++++++++++++++ 3 files changed, 100 insertions(+), 15 deletions(-) diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index d1a8679f4528..fcb21c3ed87a 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -430,15 +430,15 @@ impl App { result: Result, ) -> Result<()> { if self.pending_startup_thread_start_request_id.as_deref() != Some(request_id.as_str()) { - if let Ok(started) = result - && let Err(err) = app_server - .thread_unsubscribe(started.session.thread_id) - .await - { - tracing::warn!( - thread_id = %started.session.thread_id, - "failed to unsubscribe stale startup thread: {err}" - ); + if let Ok(started) = result { + let thread_id = started.session.thread_id; + if let Err(err) = app_server.thread_unsubscribe(thread_id).await { + tracing::warn!( + thread_id = %thread_id, + "failed to unsubscribe stale startup thread: {err}" + ); + } + self.discard_thread_local_state(thread_id).await; } return Ok(()); } @@ -453,9 +453,14 @@ impl App { self.chat_widget.maybe_send_next_queued_input(); } Err(err) => { - self.chat_widget.add_error_message(format!( - "Failed to start a fresh session through the app server: {err}" - )); + let message = + format!("Failed to start a fresh session through the app server: {err}"); + tracing::warn!( + error = %err, + "startup thread/start failed" + ); + self.chat_widget.add_error_message(message.clone()); + return Err(color_eyre::eyre::eyre!(message)); } } Ok(()) diff --git a/codex-rs/tui/src/app/side.rs b/codex-rs/tui/src/app/side.rs index 044cb9b3910d..492639f1397f 100644 --- a/codex-rs/tui/src/app/side.rs +++ b/codex-rs/tui/src/app/side.rs @@ -369,15 +369,15 @@ impl App { self.chat_widget.add_error_message(message); return false; } - self.discard_side_thread_local(thread_id).await; + self.discard_thread_local_state(thread_id).await; true } pub(super) async fn discard_closed_side_thread(&mut self, thread_id: ThreadId) { - self.discard_side_thread_local(thread_id).await; + self.discard_thread_local_state(thread_id).await; } - async fn discard_side_thread_local(&mut self, thread_id: ThreadId) { + pub(super) async fn discard_thread_local_state(&mut self, thread_id: ThreadId) { self.abort_thread_event_listener(thread_id); self.thread_event_channels.remove(&thread_id); self.side_threads.remove(&thread_id); diff --git a/codex-rs/tui/src/app/tests/startup.rs b/codex-rs/tui/src/app/tests/startup.rs index 6760594e63d5..305bb8caa43a 100644 --- a/codex-rs/tui/src/app/tests/startup.rs +++ b/codex-rs/tui/src/app/tests/startup.rs @@ -181,6 +181,86 @@ async fn startup_thread_started_submits_queued_startup_input() { } } +#[tokio::test] +async fn startup_thread_start_failure_returns_error() { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + let request_id = "startup-thread-start-failed".to_string(); + app.pending_startup_thread_start_request_id = Some(request_id.clone()); + app.chat_widget + .set_queue_submissions_until_session_configured(/*queue*/ true); + + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( + app.chat_widget.config_ref(), + )) + .await + .expect("embedded app server"); + let err = app + .handle_startup_thread_started(&mut app_server, request_id, Err("boom".to_string())) + .await + .expect_err("startup thread failure should exit instead of leaving chat unconfigured"); + + assert!( + err.to_string() + .contains("Failed to start a fresh session through the app server: boom") + ); + assert_eq!(app.pending_startup_thread_start_request_id, None); + assert_eq!(app.primary_thread_id, None); +} + +#[test] +fn stale_startup_thread_started_removes_local_routing_state() -> Result<()> { + const WORKER_THREADS: usize = 1; + const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024; + + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(WORKER_THREADS) + .thread_stack_size(TEST_STACK_SIZE_BYTES) + .enable_all() + .build()?; + + runtime.block_on(async { + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + let stale_started = app_server + .start_thread(app.chat_widget.config_ref()) + .await?; + let primary_thread_id = ThreadId::new(); + let stale_thread_id = stale_started.session.thread_id; + app.primary_thread_id = Some(primary_thread_id); + app.thread_event_channels.insert( + primary_thread_id, + ThreadEventChannel::new(THREAD_EVENT_CHANNEL_CAPACITY), + ); + app.activate_thread_channel(primary_thread_id).await; + app.thread_event_channels.insert( + stale_thread_id, + ThreadEventChannel::new(THREAD_EVENT_CHANNEL_CAPACITY), + ); + app.agent_navigation.upsert( + stale_thread_id, + Some("Stale".to_string()), + /*agent_role*/ None, + /*is_closed*/ false, + ); + app.pending_startup_thread_start_request_id = Some("newer-startup-request".to_string()); + assert!(app.thread_event_channels.contains_key(&stale_thread_id)); + assert!(app.agent_navigation.get(&stale_thread_id).is_some()); + + app.handle_startup_thread_started( + &mut app_server, + "old-startup-request".to_string(), + Ok(stale_started), + ) + .await?; + + assert!(!app.thread_event_channels.contains_key(&stale_thread_id)); + assert_eq!(app.agent_navigation.get(&stale_thread_id), None); + assert_eq!(app.active_thread_id, Some(primary_thread_id)); + Ok(()) + }) +} + #[tokio::test] async fn ignore_same_thread_resume_reports_noop_for_current_thread() { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; From b80dd4c5364252b5fb33be30822819f86c178cc8 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 19 May 2026 21:43:27 -0700 Subject: [PATCH 4/5] Simplify startup thread handling --- codex-rs/tui/src/app.rs | 8 ++------ codex-rs/tui/src/app/session_lifecycle.rs | 11 +++-------- codex-rs/tui/src/app/tests/startup.rs | 4 +--- 3 files changed, 6 insertions(+), 17 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index b57a28ab9134..6c70091f31f6 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -585,21 +585,17 @@ fn spawn_startup_thread_start( let request_handle = app_server.request_handle(); let thread_params_mode = app_server.thread_params_mode(); let remote_cwd_override = app_server.remote_cwd_override().map(Path::to_path_buf); - let event_request_id = request_id.clone(); tokio::spawn(async move { let result = crate::app_server_session::start_thread_with_request_handle( request_handle, - request_id, + request_id.clone(), config, thread_params_mode, remote_cwd_override, ) .await .map_err(|err| format!("{err:#}")); - app_event_tx.send(AppEvent::StartupThreadStarted { - request_id: event_request_id, - result, - }); + app_event_tx.send(AppEvent::StartupThreadStarted { request_id, result }); }); } diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index fcb21c3ed87a..a6eceba48ba4 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -453,14 +453,9 @@ impl App { self.chat_widget.maybe_send_next_queued_input(); } Err(err) => { - let message = - format!("Failed to start a fresh session through the app server: {err}"); - tracing::warn!( - error = %err, - "startup thread/start failed" - ); - self.chat_widget.add_error_message(message.clone()); - return Err(color_eyre::eyre::eyre!(message)); + return Err(color_eyre::eyre::eyre!( + "Failed to start a fresh session through the app server: {err}" + )); } } Ok(()) diff --git a/codex-rs/tui/src/app/tests/startup.rs b/codex-rs/tui/src/app/tests/startup.rs index 305bb8caa43a..58a3b3bf01b7 100644 --- a/codex-rs/tui/src/app/tests/startup.rs +++ b/codex-rs/tui/src/app/tests/startup.rs @@ -186,8 +186,6 @@ async fn startup_thread_start_failure_returns_error() { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; let request_id = "startup-thread-start-failed".to_string(); app.pending_startup_thread_start_request_id = Some(request_id.clone()); - app.chat_widget - .set_queue_submissions_until_session_configured(/*queue*/ true); let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( app.chat_widget.config_ref(), @@ -239,7 +237,7 @@ fn stale_startup_thread_started_removes_local_routing_state() -> Result<()> { ); app.agent_navigation.upsert( stale_thread_id, - Some("Stale".to_string()), + /*agent_nickname*/ None, /*agent_role*/ None, /*is_closed*/ false, ); From 39aaf0971f09313af3e388b11fbe4ecac8e36844 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 19 May 2026 21:57:39 -0700 Subject: [PATCH 5/5] Simplify startup thread state tracking --- codex-rs/tui/src/app.rs | 24 ++---- codex-rs/tui/src/app/event_dispatch.rs | 4 +- codex-rs/tui/src/app/session_lifecycle.rs | 7 +- codex-rs/tui/src/app/test_support.rs | 2 +- codex-rs/tui/src/app/tests.rs | 4 +- codex-rs/tui/src/app/tests/startup.rs | 97 ++++++++++------------- codex-rs/tui/src/app_event.rs | 1 - codex-rs/tui/src/app_server_session.rs | 4 +- 8 files changed, 62 insertions(+), 81 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 6c70091f31f6..0288c9c593bc 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -543,7 +543,7 @@ pub(crate) struct App { primary_session_configured: Option, pending_primary_events: VecDeque, pending_app_server_requests: PendingAppServerRequests, - pending_startup_thread_start_request_id: Option, + pending_startup_thread_start: bool, // Serialize plugin enablement writes per plugin so stale completions cannot // overwrite a newer toggle, even if the plugin is toggled from different // cwd contexts. @@ -579,7 +579,6 @@ async fn resolve_runtime_model_provider_base_url(provider: &ModelProviderInfo) - fn spawn_startup_thread_start( app_server: &AppServerSession, config: Config, - request_id: String, app_event_tx: AppEventSender, ) { let request_handle = app_server.request_handle(); @@ -588,14 +587,13 @@ fn spawn_startup_thread_start( tokio::spawn(async move { let result = crate::app_server_session::start_thread_with_request_handle( request_handle, - request_id.clone(), config, thread_params_mode, remote_cwd_override, ) .await .map_err(|err| format!("{err:#}")); - app_event_tx.send(AppEvent::StartupThreadStarted { request_id, result }); + app_event_tx.send(AppEvent::StartupThreadStarted { result }); }); } @@ -802,19 +800,13 @@ impl App { &initial_images, ); let thread_and_widget_started_at = Instant::now(); - let mut pending_startup_thread_start_request_id = None; + let pending_startup_thread_start = matches!( + &session_selection, + SessionSelection::StartFresh | SessionSelection::Exit + ); let (mut chat_widget, initial_started_thread) = match session_selection { SessionSelection::StartFresh | SessionSelection::Exit => { - let startup_thread_start_request_id = - format!("startup-thread-start-{}", Uuid::new_v4()); - pending_startup_thread_start_request_id = - Some(startup_thread_start_request_id.clone()); - spawn_startup_thread_start( - &app_server, - config.clone(), - startup_thread_start_request_id, - app_event_tx.clone(), - ); + spawn_startup_thread_start(&app_server, config.clone(), app_event_tx.clone()); // Count a startup tooltip once the initial chat widget can render it. let startup_tooltip_override = prepare_startup_tooltip_override(&mut config, &available_models, is_first_run) @@ -992,7 +984,7 @@ See the Codex keymap documentation for supported actions and examples." primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), - pending_startup_thread_start_request_id, + pending_startup_thread_start, pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), }; diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 145f68509642..c1cd816b0d10 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -23,8 +23,8 @@ impl App { ) .await; } - AppEvent::StartupThreadStarted { request_id, result } => { - self.handle_startup_thread_started(app_server, request_id, result) + AppEvent::StartupThreadStarted { result } => { + self.handle_startup_thread_started(app_server, result) .await?; } AppEvent::ClearUi => { diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index a6eceba48ba4..6c8789443402 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -418,7 +418,7 @@ impl App { self.primary_session_configured = None; self.pending_primary_events.clear(); self.pending_app_server_requests.clear(); - self.pending_startup_thread_start_request_id = None; + self.pending_startup_thread_start = false; self.chat_widget.set_pending_thread_approvals(Vec::new()); self.sync_active_agent_label(); } @@ -426,10 +426,9 @@ impl App { pub(super) async fn handle_startup_thread_started( &mut self, app_server: &mut AppServerSession, - request_id: String, result: Result, ) -> Result<()> { - if self.pending_startup_thread_start_request_id.as_deref() != Some(request_id.as_str()) { + if !self.pending_startup_thread_start { if let Ok(started) = result { let thread_id = started.session.thread_id; if let Err(err) = app_server.thread_unsubscribe(thread_id).await { @@ -443,7 +442,7 @@ impl App { return Ok(()); } - self.pending_startup_thread_start_request_id = None; + self.pending_startup_thread_start = false; self.chat_widget .set_queue_submissions_until_session_configured(/*queue*/ false); match result { diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 4c236c167a34..10c234a903a9 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -60,7 +60,7 @@ pub(super) async fn make_test_app() -> App { primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), - pending_startup_thread_start_request_id: None, + pending_startup_thread_start: false, pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 0bf6e188fdb1..8d52500af6de 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3882,7 +3882,7 @@ async fn make_test_app() -> App { primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), - pending_startup_thread_start_request_id: None, + pending_startup_thread_start: false, pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), } @@ -3946,7 +3946,7 @@ async fn make_test_app_with_channels() -> ( primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), - pending_startup_thread_start_request_id: None, + pending_startup_thread_start: false, pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), }, diff --git a/codex-rs/tui/src/app/tests/startup.rs b/codex-rs/tui/src/app/tests/startup.rs index 58a3b3bf01b7..51d6622e1613 100644 --- a/codex-rs/tui/src/app/tests/startup.rs +++ b/codex-rs/tui/src/app/tests/startup.rs @@ -138,8 +138,7 @@ fn startup_waiting_gate_not_applied_for_resume_or_fork_session_selection() { #[tokio::test] async fn startup_thread_started_submits_queued_startup_input() { let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; - let request_id = "startup-thread-start-test".to_string(); - app.pending_startup_thread_start_request_id = Some(request_id.clone()); + app.pending_startup_thread_start = true; app.chat_widget .set_queue_submissions_until_session_configured(/*queue*/ true); app.chat_widget @@ -160,7 +159,6 @@ async fn startup_thread_started_submits_queued_startup_input() { let thread_id = ThreadId::new(); app.handle_startup_thread_started( &mut app_server, - request_id, Ok(AppServerStartedThread { session: test_thread_session(thread_id, test_path_buf("/tmp/project")), turns: Vec::new(), @@ -184,8 +182,7 @@ async fn startup_thread_started_submits_queued_startup_input() { #[tokio::test] async fn startup_thread_start_failure_returns_error() { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; - let request_id = "startup-thread-start-failed".to_string(); - app.pending_startup_thread_start_request_id = Some(request_id.clone()); + app.pending_startup_thread_start = true; let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( app.chat_widget.config_ref(), @@ -193,7 +190,7 @@ async fn startup_thread_start_failure_returns_error() { .await .expect("embedded app server"); let err = app - .handle_startup_thread_started(&mut app_server, request_id, Err("boom".to_string())) + .handle_startup_thread_started(&mut app_server, Err("boom".to_string())) .await .expect_err("startup thread failure should exit instead of leaving chat unconfigured"); @@ -201,62 +198,56 @@ async fn startup_thread_start_failure_returns_error() { err.to_string() .contains("Failed to start a fresh session through the app server: boom") ); - assert_eq!(app.pending_startup_thread_start_request_id, None); + assert!(!app.pending_startup_thread_start); assert_eq!(app.primary_thread_id, None); } #[test] fn stale_startup_thread_started_removes_local_routing_state() -> Result<()> { - const WORKER_THREADS: usize = 1; - const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024; - - let runtime = tokio::runtime::Builder::new_multi_thread() - .worker_threads(WORKER_THREADS) - .thread_stack_size(TEST_STACK_SIZE_BYTES) + tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .thread_stack_size(8 * 1024 * 1024) .enable_all() - .build()?; + .build()? + .block_on(async { + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + let primary_thread_id = ThreadId::new(); + let stale_thread_id = ThreadId::new(); + app.primary_thread_id = Some(primary_thread_id); + app.thread_event_channels.insert( + primary_thread_id, + ThreadEventChannel::new(THREAD_EVENT_CHANNEL_CAPACITY), + ); + app.activate_thread_channel(primary_thread_id).await; + app.thread_event_channels.insert( + stale_thread_id, + ThreadEventChannel::new(THREAD_EVENT_CHANNEL_CAPACITY), + ); + app.agent_navigation.upsert( + stale_thread_id, + /*agent_nickname*/ None, + /*agent_role*/ None, + /*is_closed*/ false, + ); + assert!(app.thread_event_channels.contains_key(&stale_thread_id)); + assert!(app.agent_navigation.get(&stale_thread_id).is_some()); - runtime.block_on(async { - let mut app = make_test_app().await; - let mut app_server = - crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; - let stale_started = app_server - .start_thread(app.chat_widget.config_ref()) + app.handle_startup_thread_started( + &mut app_server, + Ok(AppServerStartedThread { + session: test_thread_session(stale_thread_id, test_path_buf("/tmp/project")), + turns: Vec::new(), + }), + ) .await?; - let primary_thread_id = ThreadId::new(); - let stale_thread_id = stale_started.session.thread_id; - app.primary_thread_id = Some(primary_thread_id); - app.thread_event_channels.insert( - primary_thread_id, - ThreadEventChannel::new(THREAD_EVENT_CHANNEL_CAPACITY), - ); - app.activate_thread_channel(primary_thread_id).await; - app.thread_event_channels.insert( - stale_thread_id, - ThreadEventChannel::new(THREAD_EVENT_CHANNEL_CAPACITY), - ); - app.agent_navigation.upsert( - stale_thread_id, - /*agent_nickname*/ None, - /*agent_role*/ None, - /*is_closed*/ false, - ); - app.pending_startup_thread_start_request_id = Some("newer-startup-request".to_string()); - assert!(app.thread_event_channels.contains_key(&stale_thread_id)); - assert!(app.agent_navigation.get(&stale_thread_id).is_some()); - - app.handle_startup_thread_started( - &mut app_server, - "old-startup-request".to_string(), - Ok(stale_started), - ) - .await?; - assert!(!app.thread_event_channels.contains_key(&stale_thread_id)); - assert_eq!(app.agent_navigation.get(&stale_thread_id), None); - assert_eq!(app.active_thread_id, Some(primary_thread_id)); - Ok(()) - }) + assert!(!app.thread_event_channels.contains_key(&stale_thread_id)); + assert_eq!(app.agent_navigation.get(&stale_thread_id), None); + assert_eq!(app.active_thread_id, Some(primary_thread_id)); + Ok(()) + }) } #[tokio::test] diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index cd3a510eb4b9..a85ee5a70c9e 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -183,7 +183,6 @@ pub(crate) enum AppEvent { /// Result of the fresh startup thread that is attached after the input UI is live. StartupThreadStarted { - request_id: String, result: Result, }, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index f29633308738..51029a6deee7 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -119,6 +119,7 @@ use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; use std::collections::HashMap; use std::path::PathBuf; +use uuid::Uuid; fn bootstrap_request_error(context: &'static str, err: TypedRequestError) -> color_eyre::Report { color_eyre::eyre::eyre!("{context}: {err}") @@ -1006,14 +1007,13 @@ impl AppServerSession { pub(crate) async fn start_thread_with_request_handle( request_handle: AppServerRequestHandle, - request_id: String, config: Config, thread_params_mode: ThreadParamsMode, remote_cwd_override: Option, ) -> Result { let response: ThreadStartResponse = request_handle .request_typed(ClientRequest::ThreadStart { - request_id: RequestId::String(request_id), + request_id: RequestId::String(format!("startup-thread-start-{}", Uuid::new_v4())), params: thread_start_params_from_config( &config, thread_params_mode,