diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 3164f16acb..0daa243dd6 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 68bd759e2f..145f685096 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 6ea53d8b3c..65959a86ce 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 6b6c424210..4c236c167a 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 bddf5e544c..0bf6e188fd 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 07a5b51175..cd3a510eb4 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 1bcb4a5597..f296333087 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,