diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index 3b239b77f8..65d8442cd9 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -21,6 +21,7 @@ use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::popup_consts::standard_popup_hint_line_for_keymap; use crate::chatwidget::ThreadInputStateRestoreMode; +use crate::startup_draft::StartupDraftPump; use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadHistoryMode; @@ -54,6 +55,7 @@ pub(super) struct AgentsOverviewState { /// Keep new tasks subscribed and reusable until a first turn makes them resumable. pub(super) blank_sessions: HashMap, pub(super) input_states: HashMap, + pub(super) new_session_draft: Option>, pub(super) dispatched_requests: HashMap>, } @@ -310,8 +312,10 @@ impl App { app_server: &mut AppServerSession, thread_id: ThreadId, ) -> Result { - Box::pin(self.attach_agents_overview_thread(tui, app_server, thread_id, /*started*/ None)) - .await + Box::pin(self.attach_agents_overview_thread( + tui, app_server, thread_id, /*started*/ None, /*startup_draft*/ None, + )) + .await } async fn attach_agents_overview_thread( @@ -320,10 +324,12 @@ impl App { app_server: &mut AppServerSession, root_thread_id: ThreadId, started: Option<(Config, crate::app_server_session::AppServerStartedThread)>, + mut startup_draft: Option<&mut StartupDraftPump>, ) -> color_eyre::Result { if self.windows_sandbox_blocks_thread_switch() { return Ok(AppRunControl::Continue); } + let is_new_session = started.is_some(); if self.current_displayed_thread_id() == Some(root_thread_id) && (!self.thread_unavailable(root_thread_id) || self.chat_widget.is_external_writer_view()) @@ -392,9 +398,12 @@ impl App { .insert(active_thread_id, input_state); } - let target_thread = match app_server - .thread_read(root_thread_id, /*include_turns*/ false) - .await + let target_thread = match StartupDraftPump::run_with_optional_draft( + startup_draft.as_deref_mut(), + tui, + app_server.thread_read(root_thread_id, /*include_turns*/ false), + ) + .await { Ok(thread) => thread, Err(error) => { @@ -456,6 +465,7 @@ impl App { &mut resume_config, target_thread.cwd.as_path(), Some(&target_thread), + /*startup_draft*/ None, ) .await { @@ -553,7 +563,18 @@ impl App { }; if !previous_running_thread_ids.is_empty() { for side_thread_id in Vec::from_iter(self.side_threads.keys().copied()) { - if !self.discard_side_thread(app_server, side_thread_id).await { + let discarded = match startup_draft.as_deref_mut() { + Some(draft) => { + draft + .run_until( + tui, + self.discard_side_thread(app_server, side_thread_id), + ) + .await? + } + None => self.discard_side_thread(app_server, side_thread_id).await, + }; + if !discarded { let _ = app_server.thread_unsubscribe(root_thread_id).await; return Ok(AppRunControl::Continue); } @@ -580,7 +601,14 @@ impl App { && !previous_displayed_thread_id .is_some_and(|id| self.agents_overview.blank_sessions.contains_key(&id)) { - self.shutdown_current_thread(app_server).await; + match startup_draft.as_deref_mut() { + Some(draft) => { + draft + .run_until(tui, self.shutdown_current_thread(app_server)) + .await? + } + None => self.shutdown_current_thread(app_server).await, + } } // Explicit choices carry across cold resumes and new sessions. self.runtime_approval_policy_override = @@ -656,10 +684,13 @@ impl App { .matches_config(&self.config)) .then(|| RuntimePermissionProfileOverride::from_restored_config(&self.config)); } - if !self - .backfill_loaded_subagent_threads(app_server) - .await - .completed + // A new session has no descendants. Scanning every loaded thread here + // adds a serial round trip per agent before the composer can render. + if !is_new_session + && !self + .backfill_loaded_subagent_threads(app_server) + .await + .completed { self.backfill_loaded_subagent_threads(app_server).await; } @@ -668,7 +699,12 @@ impl App { && thread_id != root_thread_id && Some(thread_id) != previous_displayed_thread_id && !self.agents_overview.blank_sessions.contains_key(&thread_id) - && let Err(error) = app_server.thread_unsubscribe(thread_id).await + && let Err(error) = StartupDraftPump::run_with_optional_draft( + startup_draft.as_deref_mut(), + tui, + app_server.thread_unsubscribe(thread_id), + ) + .await { tracing::warn!(%thread_id, %error, "failed to unsubscribe previous agent thread"); } @@ -705,7 +741,7 @@ impl App { self.chat_widget.maybe_send_next_queued_input(); } } - if !read_only { + if !read_only && !is_new_session { self.maybe_prompt_resume_paused_goal_after_resume(app_server, root_thread_id) .await; } @@ -738,6 +774,7 @@ impl App { tui: &mut tui::Tui, app_server: &mut AppServerSession, cwd: Option, + mut startup_draft: Option<&mut StartupDraftPump>, ) -> Option<(Config, Option)> { if self .chat_widget @@ -763,7 +800,13 @@ impl App { |cwd| cwd.to_path_buf(), ) }; - let mut config = match self.rebuild_config_for_cwd(local_cwd).await { + let mut config = match StartupDraftPump::run_with_optional_draft( + startup_draft.as_deref_mut(), + tui, + self.rebuild_config_for_cwd(local_cwd), + ) + .await + { Ok(config) => config, Err(error) => { self.add_agents_overview_error(format!("Failed to load project settings: {error}")); @@ -781,6 +824,7 @@ impl App { &mut config, &trust_cwd, /*resumed_thread*/ None, + startup_draft.as_deref_mut(), ) .await .is_err() @@ -812,10 +856,17 @@ impl App { .or_else(|| app_server.remote_cwd_override()) .unwrap_or(Path::new(".")), }; + if let Some(draft) = startup_draft.as_deref_mut() { + draft.apply_config(&config); + } let mut server_model_cleared = false; - match crate::config_update::read_effective_config_if_supported( - app_server.request_handle(), - defaults_cwd, + match StartupDraftPump::run_with_optional_draft( + startup_draft, + tui, + crate::config_update::read_effective_config_if_supported( + app_server.request_handle(), + defaults_cwd, + ), ) .await { diff --git a/codex-rs/tui/src/app/agents_overview_new.rs b/codex-rs/tui/src/app/agents_overview_new.rs index 1d151f232b..09039f120c 100644 --- a/codex-rs/tui/src/app/agents_overview_new.rs +++ b/codex-rs/tui/src/app/agents_overview_new.rs @@ -2,6 +2,9 @@ //! Worktrees start at the repository default branch and bind only the new session. use super::*; +use crate::startup_draft::StartupDraftInitialScreen; +use crate::startup_draft::StartupDraftPump; +use crate::startup_draft::StartupDraftSessionAction; /// Owns a freshly created checkout until the UI accepts its completion event. /// Dropping a cancelled worker or undelivered event removes only a clean checkout. @@ -39,13 +42,46 @@ impl App { app_server: &mut AppServerSession, cwd: Option, ) -> Result { + if self.reconnect.offline || self.windows_sandbox_blocks_thread_switch() { + return Ok(AppRunControl::Continue); + } + let previous_thread = self.current_displayed_thread_id(); + let mut draft = self + .agents_overview + .new_session_draft + .take() + .unwrap_or_else(|| { + Box::new(StartupDraftPump::new( + tui, + StartupDraftInitialScreen::Composer, + StartupDraftSessionAction::NewFromCommandCenter, + )) + }); + let mut display_config = self.chat_widget.config_ref().clone(); + if let Some(cwd) = cwd.as_ref() { + display_config.cwd = cwd.clone(); + } + draft.apply_config(&display_config); + tui.terminal.clear()?; // Keep the large session-start future off the TUI's stack in dev builds. - Box::pin( - self.start_agents_overview_session( - tui, app_server, cwd, /*managed_worktree*/ None, - ), - ) - .await + let result = Box::pin(self.start_agents_overview_session( + tui, + app_server, + cwd, + /*managed_worktree*/ None, + Some(&mut draft), + )) + .await; + if self.current_displayed_thread_id() != previous_thread { + draft.flush_pending_paste_newline(tui).await?; + self.chat_widget.restore_startup_draft(draft.take_draft()); + } else { + // Retain edits if setup fails so retrying `n` does not lose the draft. + draft.flush_pending_events(tui).await?; + self.agents_overview.new_session_draft = Some(draft); + } + tui.frame_requester().schedule_frame(); + result } pub(in crate::app) async fn start_agents_overview_session( @@ -57,6 +93,7 @@ impl App { codex_worktree::WorktreeManager, codex_worktree::ManagedWorktree, )>, + mut startup_draft: Option<&mut StartupDraftPump>, ) -> Result { if self.reconnect.offline || self.windows_sandbox_blocks_thread_switch() { if let Some((_, checkout)) = &managed_worktree { @@ -68,7 +105,7 @@ impl App { return Ok(AppRunControl::Continue); } let Some((config, remote_cwd)) = self - .agents_overview_session_config(tui, app_server, cwd) + .agents_overview_session_config(tui, app_server, cwd, startup_draft.as_deref_mut()) .await else { if let Some((_, checkout)) = &managed_worktree { @@ -94,15 +131,22 @@ impl App { display_label: active.id, }) }); - let result = app_server - .start_thread_with_session_start_source( - &crate::local_settings::LocalSettings::from(&config), + let local_settings = crate::local_settings::LocalSettings::from(&config); + if let Some(draft) = startup_draft.as_deref_mut() { + draft.apply_config(&config); + } + let result = StartupDraftPump::run_with_optional_draft( + startup_draft.as_deref_mut(), + tui, + Box::pin(app_server.start_thread_with_session_start_source( + &local_settings, &config, /*session_start_source*/ None, remote_cwd.as_deref(), selected_profile.as_ref(), - ) - .await; + )), + ) + .await; let started = match result { Ok(started) => started, Err(error) => { @@ -144,9 +188,14 @@ impl App { .insert(thread_id, started.clone()); // Use the dashboard's existing attachment path, which preserves running agents // and unsent input in the previous session. Do not send an initial turn. - let control = self - .attach_agents_overview_thread(tui, app_server, thread_id, Some((config, started))) - .await?; + let control = Box::pin(self.attach_agents_overview_thread( + tui, + app_server, + thread_id, + Some((config, started)), + startup_draft, + )) + .await?; if self.current_displayed_thread_id() != Some(thread_id) { self.agents_overview.blank_sessions.remove(&thread_id); let _ = app_server.thread_unsubscribe(thread_id).await; @@ -184,7 +233,7 @@ impl App { return; } let Some((config, _)) = self - .agents_overview_session_config(tui, app_server, cwd) + .agents_overview_session_config(tui, app_server, cwd, /*startup_draft*/ None) .await else { return; diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 595b996be9..4421e5a1de 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -2472,7 +2472,7 @@ impl App { }; let manager = pending.manager.clone(); let cwd = AbsolutePathBuf::try_from(checkout.cwd.clone())?; - return Box::pin(self.start_agents_overview_session(tui, app_server, Some(cwd), Some((manager, checkout)))).await; + return Box::pin(self.start_agents_overview_session(tui, app_server, Some(cwd), Some((manager, checkout)), /*startup_draft*/ None)).await; } Err(error) => self.add_agents_overview_error(error), } diff --git a/codex-rs/tui/src/app/resume_config.rs b/codex-rs/tui/src/app/resume_config.rs index 0c1352069f..1c32645b61 100644 --- a/codex-rs/tui/src/app/resume_config.rs +++ b/codex-rs/tui/src/app/resume_config.rs @@ -4,6 +4,7 @@ use super::*; use crate::onboarding::onboarding_screen::check_directory_trust; +use crate::startup_draft::StartupDraftPump; use crate::startup_hooks_review::StartupHooksReviewOutcome; use crate::startup_hooks_review::load_startup_hooks_review_entry; use crate::startup_hooks_review::maybe_run_startup_hooks_review; @@ -135,6 +136,7 @@ impl App { &mut resume_config.0, &trust_cwd, resumed_thread.as_ref(), + /*startup_draft*/ None, ) .await?; resume_config.1 = crate::local_settings::LocalSettings::from(&resume_config.0); @@ -148,6 +150,7 @@ impl App { config: &mut Config, cwd: &Path, resumed_thread: Option<&codex_app_server_protocol::Thread>, + mut startup_draft: Option<&mut StartupDraftPump>, ) -> std::result::Result<(), AppRunControl> { // Keep the existing explicit remote --cd gate, including retries after cancellation. // Other remote destinations await authoritative trust-root metadata. @@ -166,7 +169,7 @@ impl App { &self.app_server_target, cwd, resumed_thread, - /*startup_draft*/ None, + startup_draft.as_deref_mut(), ) .await .map_err(|error| { @@ -187,21 +190,40 @@ impl App { return Err(AppRunControl::Continue); } if result.directory_trust_persisted && !app_server.uses_remote_workspace() { - *config = self - .rebuild_config_for_cwd(config.cwd.to_path_buf()) - .await - .map_err(|error| { - self.add_session_picker_error(format!( - "Failed to reload trusted folder settings: {error}" - )); - AppRunControl::Continue - })?; + *config = StartupDraftPump::run_with_optional_draft( + startup_draft.as_deref_mut(), + tui, + self.rebuild_config_for_cwd(config.cwd.to_path_buf()), + ) + .await + .map_err(|error| { + self.add_session_picker_error(format!( + "Failed to reload trusted folder settings: {error}" + )); + AppRunControl::Continue + })?; if resumed_thread.is_none() { - let hooks = load_startup_hooks_review_entry( + let load_hooks = load_startup_hooks_review_entry( app_server.request_handle(), config.cwd.to_path_buf(), - ) - .await; + ); + let hooks = if let Some(draft) = startup_draft { + draft.apply_config(config); + async { + let hooks = draft.run_until(tui, load_hooks).await?; + draft.flush_pending_events(tui).await?; + Ok::<_, std::io::Error>(hooks) + } + .await + .map_err(|error| { + self.add_session_picker_error(format!( + "Unable to load folder hooks: {error}" + )); + AppRunControl::Continue + })? + } else { + load_hooks.await + }; match maybe_run_startup_hooks_review( app_server, tui, diff --git a/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs b/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs index ed16de3a9f..e2ed7a59ef 100644 --- a/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs +++ b/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs @@ -45,6 +45,7 @@ fn trust_launch_folder(app: &mut App) { #[tokio::test] async fn command_center_new_reads_server_defaults_for_actual_destination() -> Result<()> { let mut tui = make_test_tui()?; + tui.pause_events(); for (mode, explicit_cwd, launch_override, expected_cwd, expected_model) in [ ("local", false, false, "launch", "server-model"), ("local", true, false, "destination", "destination-model"), @@ -263,6 +264,7 @@ async fn command_center_new_reads_server_defaults_for_actual_destination() -> Re #[tokio::test] async fn command_center_new_preserves_explicit_choices_and_managed_defaults() -> Result<()> { let mut tui = make_test_tui()?; + tui.pause_events(); for (choice, expected_model, expected_effort) in [ ("saved", "server-model", "high"), ("cli_effort", "server-model", "low"), @@ -362,6 +364,7 @@ async fn command_center_new_preserves_explicit_choices_and_managed_defaults() -> #[tokio::test] async fn command_center_new_read_failure_keeps_overview_and_does_not_start() -> Result<()> { let mut tui = make_test_tui()?; + tui.pause_events(); for capability in [ HistoryCapabilities::ConfigReadFails, HistoryCapabilities::ThreadStartFails, @@ -459,6 +462,7 @@ async fn command_center_new_preserves_permissions_across_sessions() -> Result<() .await ); let mut tui = make_test_tui()?; + tui.pause_events(); for _ in 0..2 { app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None) .await?; @@ -545,6 +549,7 @@ async fn command_center_new_preserves_only_selected_server_profiles() -> Result< RuntimePermissionProfileOverride::from_restored_config(app.chat_widget.config_ref()), ); let mut tui = make_test_tui()?; + tui.pause_events(); app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None) .await?; assert_eq!( @@ -647,6 +652,7 @@ async fn command_center_new_restores_blank_drafts_and_builtin_permissions() -> R ) .await?; let mut tui = make_test_tui()?; + tui.pause_events(); app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None) .await?; let first = app.chat_widget.thread_id().unwrap(); @@ -855,6 +861,7 @@ async fn command_center_new_checkout_and_worktree_preserve_source_and_default_br ) .await?; let mut tui = make_test_tui()?; + tui.pause_events(); let new_session = app.new_agents_overview_session( &mut tui, &mut server, @@ -958,6 +965,7 @@ async fn command_center_new_checkout_and_worktree_preserve_source_and_default_br &mut failed_server, Some(unused.cwd.clone().abs()), Some((manager.clone(), unused.clone())), + /*startup_draft*/ None, ) .await?; assert_eq!(app.chat_widget.thread_id(), Some(second)); diff --git a/codex-rs/tui/src/startup_draft.rs b/codex-rs/tui/src/startup_draft.rs index ee3cc2bc6d..816b3e6003 100644 --- a/codex-rs/tui/src/startup_draft.rs +++ b/codex-rs/tui/src/startup_draft.rs @@ -61,6 +61,8 @@ pub(crate) enum StartupDraftInitialScreen { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum StartupDraftSessionAction { New, + /// Keep in-app creation alive while allowing draft edits until setup finishes. + NewFromCommandCenter, Resume, Fork, } @@ -111,25 +113,11 @@ impl StartupDraft { initialized_terminal.enhanced_keys_supported, initialized_terminal.stderr_guard, ); - let (app_event_tx, app_event_rx) = unbounded_channel(); - let bottom_pane = startup_draft_bottom_pane( - AppEventSender::new(app_event_tx), - tui.frame_requester(), - tui.enhanced_keys_supported(), - ); - let events = tui.event_stream(); + let pump = StartupDraftPump::new(&tui, initial_screen, session_action); let mut draft = Self { tui, terminal_restore_guard, - pump: StartupDraftPump { - header: startup_session_header(/*config*/ None), - bottom_pane, - events, - app_event_rx, - initial_screen, - session_action, - pending_paste_newline: None, - }, + pump, }; draft.pump.show_initial_screen(&mut draft.tui)?; Ok(draft) @@ -165,6 +153,43 @@ impl StartupDraft { } impl StartupDraftPump { + pub(crate) fn new( + tui: &Tui, + initial_screen: StartupDraftInitialScreen, + session_action: StartupDraftSessionAction, + ) -> Self { + let (app_event_tx, app_event_rx) = unbounded_channel(); + Self { + header: startup_session_header(/*config*/ None), + bottom_pane: startup_draft_bottom_pane( + AppEventSender::new(app_event_tx), + tui.frame_requester(), + tui.enhanced_keys_supported(), + ), + events: tui.event_stream(), + app_event_rx, + initial_screen, + session_action, + pending_paste_newline: None, + } + } + + /// Keep a provisional composer responsive when the caller has one to display. + pub(crate) async fn run_with_optional_draft( + draft: Option<&mut Self>, + tui: &mut Tui, + future: F, + ) -> Result + where + F: Future>, + E: From, + { + match draft { + Some(draft) => draft.run_until(tui, future).await?, + None => future.await, + } + } + /// Refresh the session header and safe editor shortcuts without enabling modal editing. pub(crate) fn apply_config(&mut self, config: &Config) { let local_settings = crate::local_settings::LocalSettings::from(config); @@ -264,6 +289,10 @@ impl StartupDraftPump { /// Preserve the editable draft, its cursor, and any pending large-paste placeholders. pub(crate) fn into_draft(mut self) -> ComposerDraftSnapshot { + self.take_draft() + } + + pub(crate) fn take_draft(&mut self) -> ComposerDraftSnapshot { self.bottom_pane.flush_composer_paste_burst(); self.bottom_pane.composer_draft_snapshot() } @@ -346,16 +375,22 @@ impl StartupDraftPump { self.pending_paste_newline = Some((Instant::now(), "\n".to_string())); } } - handle_startup_draft_key(&mut self.bottom_pane, key).inspect_err(|error| { - if StartupCancelled::matches(error) - && let Err(clear_error) = tui.terminal.clear() - { - tracing::warn!( - error = %clear_error, - "failed to clear the cancelled startup composer" - ); + match handle_startup_draft_key(&mut self.bottom_pane, key) { + Err(error) if StartupCancelled::matches(&error) => { + // In-app setup owns a live creation request. Keep pumping input + // instead of abandoning it or blocking on cancellation cleanup. + if self.session_action != StartupDraftSessionAction::NewFromCommandCenter { + if let Err(clear_error) = tui.terminal.clear() { + tracing::warn!( + error = %clear_error, + "failed to clear the cancelled startup composer" + ); + } + return Err(error); + } } - })?; + result => result?, + } } TuiEvent::Paste(text) => { if self.initial_screen == StartupDraftInitialScreen::Composer { @@ -477,7 +512,7 @@ fn startup_draft_renderable<'a>( let mut renderable = FlexRenderable::new(); renderable.push(/*flex*/ 1, RenderableItem::Borrowed(header)); let loading_message = match session_action { - StartupDraftSessionAction::New => None, + StartupDraftSessionAction::New | StartupDraftSessionAction::NewFromCommandCenter => None, StartupDraftSessionAction::Resume => Some(" Resuming session…"), StartupDraftSessionAction::Fork => Some(" Forking session…"), };