diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index b5626f6d19..6c99b452b4 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -1,4 +1,4 @@ -//! Daemon-wide overview of recent and locally retained sessions and their subagents. +//! Overview of recent and locally retained sessions and their subagents. //! Tasks owned by another app server open as frozen, read-only history snapshots. //! Only the immediate attachment of a dashboard-created task is treated as fresh. @@ -89,7 +89,7 @@ impl Drop for AgentsOverviewState { } impl App { - pub(super) fn open_agents_overview(&mut self, app_server: &AppServerSession) { + pub(super) fn open_initial_agents_overview(&mut self, app_server: &AppServerSession) { if !self.app_server_target.supports_command_center() { let workload_identity_selected = codex_login::is_workload_identity_selected(); self.chat_widget.show_selection_view(SelectionViewParams { @@ -138,6 +138,10 @@ impl App { return; } + self.open_agents_overview(app_server); + } + + pub(super) fn open_agents_overview(&mut self, app_server: &AppServerSession) { let threads = self .agents_overview .threads @@ -245,7 +249,7 @@ impl App { } } Err(error) => { - tracing::warn!(%error, "failed to refresh shared agents"); + tracing::warn!(%error, "failed to refresh agents overview"); } } for notifications in diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs index 9ad879d931..a1632f2716 100644 --- a/codex-rs/tui/src/app/agents_overview_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -1519,7 +1519,7 @@ async fn embedded_sessions_offer_to_start_a_background_server_without_migrating( .await .expect("embedded app server"); - app.open_agents_overview(&app_server); + app.open_initial_agents_overview(&app_server); insta::with_settings!({snapshot_path => "../snapshots"}, { insta::assert_snapshot!( diff --git a/codex-rs/tui/src/app/agents_overview_threads.rs b/codex-rs/tui/src/app/agents_overview_threads.rs index 8c70faf277..084f86ed97 100644 --- a/codex-rs/tui/src/app/agents_overview_threads.rs +++ b/codex-rs/tui/src/app/agents_overview_threads.rs @@ -253,10 +253,7 @@ impl App { .chat_widget .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) .is_some(); - if !visible - && (self.agents_overview.initialized - || !self.app_server_target.supports_command_center()) - { + if !visible && self.agents_overview.initialized { return; } if self.agents_overview.request_id.is_some() { diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 3fa49e6ff2..32ab400b6b 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -21,12 +21,15 @@ use crate::session_resume::cwds_differ; use crate::status::StatusAccountDisplay; use codex_app_server_client::AppServerEvent; use codex_app_server_protocol::ThreadGoalStatus; +use codex_app_server_protocol::ThreadStatus; #[cfg(target_os = "windows")] use codex_app_server_protocol::WindowsSandboxSetupMode; pub(super) const SHUTDOWN_FIRST_EXIT_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 2); impl App { + // Keep the existing dispatcher layout until it can be formatted separately. + #[rustfmt::skip] pub(crate) async fn handle_event( &mut self, tui: &mut tui::Tui, @@ -672,7 +675,7 @@ impl App { } else { None }; - self.detach_current_thread_for_fork(app_server, forked.session.thread_id).await; + self.retain_subscriptions_for_fork(); match self .replace_chat_widget_with_app_server_thread( tui, @@ -1029,7 +1032,37 @@ impl App { AppEvent::StopCommitAnimation => { self.commit_animation = None; } - AppEvent::Exit(mode) => { + AppEvent::Exit(mode) + if matches!(self.app_server_target, AppServerTarget::Embedded) + && (self.agents_overview.threads.iter().any(|(id, thread)| { + (mode == ExitMode::Immediate + || Some(*id) != self.current_displayed_thread_id() + || self.chat_widget.active_view_is(AGENTS_OVERVIEW_VIEW_ID)) + && matches!(thread.as_ref().map(|t| &t.status), Some(ThreadStatus::Active { .. })) + }) || self.running_side_thread_id().await.is_some()) => + { + self.chat_widget.show_selection_view(SelectionViewParams { + title: Some("Quit and stop running sessions?".into()), + subtitle: Some("Quitting this TUI will stop its running sessions.".into()), + items: vec![ + SelectionItem { + name: "Stay in Codex".into(), + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Stop sessions and quit".into(), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::ConfirmedEmbeddedExit(mode)) + })], + dismiss_on_select: true, + ..Default::default() + }, + ], + ..SelectionViewParams::confirmation() + }); + } + AppEvent::Exit(mode) | AppEvent::ConfirmedEmbeddedExit(mode) => { if matches!(mode, ExitMode::ShutdownFirst | ExitMode::ShutdownAfterInterrupt) { self.show_shutdown_feedback(tui)?; } @@ -3899,6 +3932,32 @@ impl App { } } + async fn return_to_agents_after_removal( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + thread_id: ThreadId, + ) -> Result<()> { + self.discard_thread_local_state(thread_id).await; + self.agents_overview.input_states.remove(&thread_id); + self.agents_overview.dispatched_requests.remove(&thread_id); + self.reset_for_thread_switch(tui)?; + self.pending_thread_switch_resets += 1; + self.app_event_tx + .send(AppEvent::ResetTranscriptForThreadSwitch); + self.detach_current_thread_for_navigation(app_server, /*destination*/ None) + .await; + self.reset_thread_event_state().await; + let init = self.chatwidget_init_for_forked_or_resumed_thread( + tui, + self.config.clone(), + /*initial_user_message*/ None, + ); + self.replace_chat_widget(ChatWidget::new_with_app_event(init)); + self.open_agents_overview(app_server); + Ok(()) + } + pub(super) async fn archive_current_thread( &mut self, tui: &mut tui::Tui, @@ -3917,10 +3976,9 @@ impl App { return Ok(AppRunControl::Continue); } - if self.app_server_target.supports_command_center() - && !self - .shutdown_side_threads(app_server, Some(thread_id)) - .await + if !self + .shutdown_side_threads(app_server, Some(thread_id)) + .await { return Ok(AppRunControl::Continue); } @@ -3932,32 +3990,14 @@ impl App { } .await; Ok(match result { - Ok(()) if !self.app_server_target.supports_command_center() => { - AppRunControl::Exit(ExitReason::Archived(thread_id)) - } Ok(()) => { self.track_agents_overview_notification(&ServerNotification::ThreadArchived( codex_app_server_protocol::ThreadArchivedNotification { thread_id: thread_id.to_string(), }, )); - self.discard_thread_local_state(thread_id).await; - self.agents_overview.input_states.remove(&thread_id); - self.agents_overview.dispatched_requests.remove(&thread_id); - self.reset_for_thread_switch(tui)?; - self.pending_thread_switch_resets += 1; - self.app_event_tx - .send(AppEvent::ResetTranscriptForThreadSwitch); - self.detach_current_thread_for_navigation(app_server, /*destination*/ None) - .await; - self.reset_thread_event_state().await; - let init = self.chatwidget_init_for_forked_or_resumed_thread( - tui, - self.config.clone(), - /*initial_user_message*/ None, - ); - self.replace_chat_widget(ChatWidget::new_with_app_event(init)); - self.open_agents_overview(app_server); + self.return_to_agents_after_removal(tui, app_server, thread_id) + .await?; AppRunControl::Continue } Err(err) => { @@ -3986,10 +4026,9 @@ impl App { return Ok(AppRunControl::Continue); } - if self.app_server_target.supports_command_center() - && !self - .shutdown_side_threads(app_server, Some(thread_id)) - .await + if !self + .shutdown_side_threads(app_server, Some(thread_id)) + .await { return Ok(AppRunControl::Continue); } @@ -4001,32 +4040,14 @@ impl App { } .await; Ok(match result { - Ok(()) if !self.app_server_target.supports_command_center() => { - AppRunControl::Exit(ExitReason::ThreadRemoved) - } Ok(()) => { self.track_agents_overview_notification(&ServerNotification::ThreadDeleted( codex_app_server_protocol::ThreadDeletedNotification { thread_id: thread_id.to_string(), }, )); - self.discard_thread_local_state(thread_id).await; - self.agents_overview.input_states.remove(&thread_id); - self.agents_overview.dispatched_requests.remove(&thread_id); - self.reset_for_thread_switch(tui)?; - self.pending_thread_switch_resets += 1; - self.app_event_tx - .send(AppEvent::ResetTranscriptForThreadSwitch); - self.detach_current_thread_for_navigation(app_server, /*destination*/ None) - .await; - self.reset_thread_event_state().await; - let init = self.chatwidget_init_for_forked_or_resumed_thread( - tui, - self.config.clone(), - /*initial_user_message*/ None, - ); - self.replace_chat_widget(ChatWidget::new_with_app_event(init)); - self.open_agents_overview(app_server); + self.return_to_agents_after_removal(tui, app_server, thread_id) + .await?; AppRunControl::Continue } Err(err) => { diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index 60fb6b1e4c..e824cf9943 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -438,15 +438,11 @@ impl App { && key_event.kind == KeyEventKind::Press { let modifiers = key_event.modifiers; - if key_event.code == KeyCode::Esc - && modifiers == KeyModifiers::NONE - && self.app_server_target.supports_command_center() - { + if key_event.code == KeyCode::Esc && modifiers == KeyModifiers::NONE { self.open_agents_overview(app_server); return; } let quit = match key_event.code { - KeyCode::Esc => modifiers == KeyModifiers::NONE, KeyCode::Char('q' | 'Q') => { modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT } @@ -550,12 +546,7 @@ impl App { let mut running_side_thread_id = running_thread_id.filter(|thread_id| self.side_threads.contains_key(thread_id)); if running_side_thread_id.is_none() { - for thread_id in self.side_threads.keys().copied() { - if self.active_turn_id_for_thread(thread_id).await.is_some() { - running_side_thread_id = Some(thread_id); - break; - } - } + running_side_thread_id = self.running_side_thread_id().await; } if running_thread_id.is_none() { running_thread_id = running_side_thread_id; @@ -651,7 +642,6 @@ impl App { && key_event.code == KeyCode::Left && key_event.modifiers == KeyModifiers::NONE && self.chat_widget.agents_navigation_key_available() - && self.app_server_target.supports_command_center() { self.open_agents_overview(app_server); } diff --git a/codex-rs/tui/src/app/resume_config.rs b/codex-rs/tui/src/app/resume_config.rs index b28eb82f91..225b8f25b7 100644 --- a/codex-rs/tui/src/app/resume_config.rs +++ b/codex-rs/tui/src/app/resume_config.rs @@ -143,7 +143,8 @@ impl App { | Ok(crate::session_resume::ResolveCwdOutcome::ContinueAfterPrompt(cwd)) => cwd, Ok(crate::session_resume::ResolveCwdOutcome::Continue(None)) => current_cwd.clone(), Ok(crate::session_resume::ResolveCwdOutcome::Exit) => { - return Err(AppRunControl::Exit(ExitReason::UserRequested)); + self.app_event_tx.send(AppEvent::Exit(ExitMode::Immediate)); + return Err(AppRunControl::Continue); } } }; @@ -277,9 +278,6 @@ impl App { if options.cancel == Some(crate::onboarding::TrustCancelAction::CurrentTask) { return Err(AppRunControl::Continue); } - if !target.supports_command_center() { - return Err(AppRunControl::Exit(ExitReason::UserRequested)); - } if self .chat_widget .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) diff --git a/codex-rs/tui/src/app/safety_recourse.rs b/codex-rs/tui/src/app/safety_recourse.rs index 121fd47524..796c089ebe 100644 --- a/codex-rs/tui/src/app/safety_recourse.rs +++ b/codex-rs/tui/src/app/safety_recourse.rs @@ -88,8 +88,7 @@ pub(super) async fn continue_with_rewrite( if let Some(input) = app.chat_widget.capture_thread_input_state() { app.agents_overview.input_states.insert(thread_id, input); } - app.detach_current_thread_for_fork(app_server, fork_id) - .await; + app.retain_subscriptions_for_fork(); attached_fork = Some(fork_id); app.replace_chat_widget_with_app_server_thread( tui, diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 3f1b8064b9..724ae6a5ff 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -513,7 +513,6 @@ impl App { chat_widget.windows_sandbox_elevated_setup_complete = self.chat_widget.windows_sandbox_elevated_setup_complete; } - chat_widget.set_command_center_enabled(self.app_server_target.supports_command_center()); chat_widget.inherit_backend_banner_state(&mut self.chat_widget); chat_widget.inherit_security_setup(&mut self.chat_widget); chat_widget @@ -938,7 +937,6 @@ impl App { } // Lifecycle notifications may arrive before the thread/start response. if !self.config.ephemeral - && self.app_server_target.supports_command_center() && !self.pending_primary_events.iter().any(|event| { matches!(event, ThreadBufferedEvent::Notification(notification) if matches!(notification.as_ref(), @@ -1097,7 +1095,7 @@ impl App { None }; let thread_id = started.session.thread_id; - if !self.config.ephemeral && self.app_server_target.supports_command_center() { + if !self.config.ephemeral { self.agents_overview .blank_sessions .insert(thread_id, started.clone()); diff --git a/codex-rs/tui/src/app/side.rs b/codex-rs/tui/src/app/side.rs index 8ac0adfeab..34e0d63a00 100644 --- a/codex-rs/tui/src/app/side.rs +++ b/codex-rs/tui/src/app/side.rs @@ -227,6 +227,15 @@ impl SideThreadState { } impl App { + pub(super) async fn running_side_thread_id(&self) -> Option { + for thread_id in self.side_threads.keys().copied() { + if self.active_turn_id_for_thread(thread_id).await.is_some() { + return Some(thread_id); + } + } + None + } + pub(super) fn sync_side_thread_ui(&mut self) { let clear_side_ui = |chat_widget: &mut crate::chatwidget::ChatWidget| { chat_widget.set_side_conversation_context_label(/*label*/ None); diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__thread_title__tests__automatic_thread_title_pending.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__thread_title__tests__automatic_thread_title_pending.snap index be3bc665c5..79485985f2 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__thread_title__tests__automatic_thread_title_pending.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__thread_title__tests__automatic_thread_title_pending.snap @@ -7,4 +7,4 @@ terminal title: project footer: › Ask Codex to do anything - gpt-5.2 default · /tmp/project + gpt-5.2 default · /tmp/project · ← for agents diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__thread_title__tests__manual_rename_cancels_thread_title.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__thread_title__tests__manual_rename_cancels_thread_title.snap index a37e03f7fd..5e686a7669 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__thread_title__tests__manual_rename_cancels_thread_title.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__thread_title__tests__manual_rename_cancels_thread_title.snap @@ -5,4 +5,4 @@ expression: normalize_snapshot_paths(popup) --- › Ask Codex to do anything - gpt-5.2 default · /tmp/project · Keep this title + gpt-5.2 default · /tmp/project · Keep this title · ← for agents diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index 8d5f31cbae..5e688ca5a1 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -927,7 +927,6 @@ impl App { &app_server_target, environment_manager.as_ref(), )); - chat_widget.set_command_center_enabled(app_server_target.supports_command_center()); let thread_and_widget_ms = thread_and_widget_started_at.elapsed().as_millis(); chat_widget.windows_sandbox_local_server = !app_server_target.uses_remote_workspace() && app_server.app_server_platform_os() == Some("windows"); @@ -1112,8 +1111,8 @@ See the Codex keymap documentation for supported actions and examples." ); } if start_in_agents_overview { - app.open_agents_overview(&app_server); - } else if app.app_server_target.supports_command_center() { + app.open_initial_agents_overview(&app_server); + } else { app.refresh_agents_overview_threads(&app_server); } if let Some(entry) = startup_hooks_browser { diff --git a/codex-rs/tui/src/app/tests/advanced_reasoning_tests.rs b/codex-rs/tui/src/app/tests/advanced_reasoning_tests.rs index 6b1551c61a..6029fd59f8 100644 --- a/codex-rs/tui/src/app/tests/advanced_reasoning_tests.rs +++ b/codex-rs/tui/src/app/tests/advanced_reasoning_tests.rs @@ -43,7 +43,7 @@ async fn fork_current_session_preserves_conversation_ultra() -> Result<()> { assert!(matches!(control, AppRunControl::Continue)); assert_ne!(app.chat_widget.thread_id(), Some(source_thread_id)); assert!( - !app.agents_overview + app.agents_overview .dispatched_requests .contains_key(&source_thread_id) ); diff --git a/codex-rs/tui/src/app/tests/agents_navigation_tests.rs b/codex-rs/tui/src/app/tests/agents_navigation_tests.rs index 820eae3d4b..5337998121 100644 --- a/codex-rs/tui/src/app/tests/agents_navigation_tests.rs +++ b/codex-rs/tui/src/app/tests/agents_navigation_tests.rs @@ -2,25 +2,19 @@ use super::*; use pretty_assertions::assert_eq; #[tokio::test] -async fn agents_navigation_requires_shared_app_server() -> Result<()> { +async fn agents_navigation_opens_the_command_center_on_every_target() -> Result<()> { let endpoint = crate::RemoteAppServerEndpoint::UnixSocket { socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, }; - for (target, enabled) in [ - (AppServerTarget::Embedded, false), - ( - AppServerTarget::Remote { - endpoint: endpoint.clone(), - }, - true, - ), - ( - AppServerTarget::LocalDaemon { - endpoint, - allow_embedded_fallback: true, - }, - true, - ), + for target in [ + AppServerTarget::Embedded, + AppServerTarget::Remote { + endpoint: endpoint.clone(), + }, + AppServerTarget::LocalDaemon { + endpoint, + allow_embedded_fallback: true, + }, ] { let (mut app, mut events, _op_rx) = make_test_app_with_channels().await; let mut tui = crate::tui::test_support::make_test_tui()?; @@ -34,9 +28,8 @@ async fn agents_navigation_requires_shared_app_server() -> Result<()> { app.replace_chat_widget(ChatWidget::new_with_app_event(init)); while events.try_recv().is_ok() {} let composer = render_bottom_popup(&app.chat_widget, /*width*/ 96); - assert_eq!(composer.contains("← for agents"), enabled); - if matches!(app.app_server_target, AppServerTarget::Remote { .. }) { - let footer = composer.lines().last().expect("composer footer").trim(); + let footer = composer.lines().last().expect("composer footer").trim(); + insta::allow_duplicates! { insta::assert_snapshot!(normalize_snapshot_paths(footer), @"GPT-5.6-Sol default · /tmp/project · ← for agents"); } app.handle_tui_event( @@ -45,15 +38,10 @@ async fn agents_navigation_requires_shared_app_server() -> Result<()> { TuiEvent::Key(KeyCode::Left.into()), ) .await?; - if enabled { - let event = events.try_recv()?; - assert_matches!(event, AppEvent::OpenAgentsOverview); - app.handle_event(&mut tui, &mut app_server, event).await?; - assert!(app.chat_widget.active_view_is(AGENTS_OVERVIEW_VIEW_ID)); - } else { - assert!(events.try_recv().is_err()); - assert!(app.chat_widget.no_modal_or_popup_active()); - } + let event = events.try_recv()?; + assert_matches!(event, AppEvent::OpenAgentsOverview); + app.handle_event(&mut tui, &mut app_server, event).await?; + assert!(app.chat_widget.active_view_is(AGENTS_OVERVIEW_VIEW_ID)); app_server.shutdown().await?; } Ok(()) diff --git a/codex-rs/tui/src/app/tests/background_exit_tests.rs b/codex-rs/tui/src/app/tests/background_exit_tests.rs index 8acf351f4f..298781bd20 100644 --- a/codex-rs/tui/src/app/tests/background_exit_tests.rs +++ b/codex-rs/tui/src/app/tests/background_exit_tests.rs @@ -2,7 +2,7 @@ use super::*; use pretty_assertions::assert_eq; #[tokio::test] -async fn external_writer_view_quits_with_escape_ctrl_c_or_q() -> Result<()> { +async fn external_writer_view_quits_with_ctrl_c_or_q() -> Result<()> { let (mut app, mut events, _operations) = make_test_app_with_channels().await; app.chat_widget.show_external_writer_thread(); let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?; @@ -27,7 +27,6 @@ async fn external_writer_view_quits_with_escape_ctrl_c_or_q() -> Result<()> { ); for key in [ - KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), ] { @@ -40,6 +39,41 @@ async fn external_writer_view_quits_with_escape_ctrl_c_or_q() -> Result<()> { Ok(()) } +#[tokio::test] +async fn embedded_quit_confirms_stopping_a_running_side_conversation() -> Result<()> { + let (mut app, mut events, mut ops) = make_test_app_with_channels().await; + let (mut server, mut tui) = prepare_background_exit_test(&app, &mut events, &mut ops).await?; + let id = ThreadId::new(); + app.side_threads + .insert(id, SideThreadState::new(ThreadId::new())); + app.thread_event_channels.insert( + id, + ThreadEventChannel::new_with_session( + THREAD_EVENT_CHANNEL_CAPACITY, + test_thread_session(id, test_path_buf("/tmp/project")), + vec![test_turn("side-turn", TurnStatus::InProgress, Vec::new())], + ), + ); + assert_matches!( + app.handle_event(&mut tui, &mut server, AppEvent::Exit(ExitMode::Immediate)) + .await?, + AppRunControl::Continue + ); + insta::assert_snapshot!( + "embedded_exit_running_sessions", + render_bottom_popup(&app.chat_widget, /*width*/ 90) + ); + app.chat_widget.handle_key_event(KeyCode::Down.into()); + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + assert_matches!( + app.handle_event(&mut tui, &mut server, events.try_recv()?) + .await?, + AppRunControl::Exit(ExitReason::UserRequested) + ); + server.shutdown().await?; + Ok(()) +} + #[tokio::test] async fn external_writer_view_preserves_draft_from_keys_and_paste() -> Result<()> { let (mut app, _events, _operations) = make_test_app_with_channels().await; @@ -157,8 +191,6 @@ async fn external_writer_view_respects_remapped_left() -> Result<()> { app.app_server_target = AppServerTarget::Remote { endpoint: crate::resolve_remote_addr("ws://127.0.0.1:4500")?, }; - app.chat_widget - .set_command_center_enabled(app.app_server_target.supports_command_center()); app.chat_widget.apply_keymap_update(config.clone(), &keymap); app.keymap = keymap.clone(); if vim { diff --git a/codex-rs/tui/src/app/tests/new_session_tests.rs b/codex-rs/tui/src/app/tests/new_session_tests.rs index 495b362c2b..4c6c7ef2c5 100644 --- a/codex-rs/tui/src/app/tests/new_session_tests.rs +++ b/codex-rs/tui/src/app/tests/new_session_tests.rs @@ -367,12 +367,12 @@ async fn replacement_uses_server_defaults_and_preserves_explicit_launch_settings ], ); if explicit == "saved" { - let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 80) + let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 140) .replace(&server_config.cwd.display().to_string(), ""); insta::assert_snapshot!(rendered, @r" › Ask Codex to do anything - server-model high · + server-model high · · ← for agents "); } if explicit.starts_with("profile_summary") { diff --git a/codex-rs/tui/src/app/tests/rate_limits.rs b/codex-rs/tui/src/app/tests/rate_limits.rs index 460b977b3c..6b0cffaa73 100644 --- a/codex-rs/tui/src/app/tests/rate_limits.rs +++ b/codex-rs/tui/src/app/tests/rate_limits.rs @@ -126,7 +126,6 @@ async fn backend_banner_state_survives_widget_replacement() -> Result<()> { app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); } - let before = render_bottom_popup(&app.chat_widget, /*width*/ 90); let mut tui = crate::tui::test_support::make_test_tui()?; let init = app.chatwidget_init_for_forked_or_resumed_thread( &mut tui, @@ -139,9 +138,15 @@ async fn backend_banner_state_survives_widget_replacement() -> Result<()> { Box::new(PlainHistoryCell::new(Vec::new())), ); app.chat_widget.pre_draw_tick(); - assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 90), before); + assert_eq!( + render_bottom_popup(&app.chat_widget, /*width*/ 90).contains("View usage"), + !dismiss + ); app.chat_widget.update_backend_banner(&response); - assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 90), before); + assert_eq!( + render_bottom_popup(&app.chat_widget, /*width*/ 90).contains("View usage"), + !dismiss + ); response.rate_limit_upsell = None; app.chat_widget.update_backend_banner(&response); assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 90).contains("View usage")); diff --git a/codex-rs/tui/src/app/tests/safety_buffering.rs b/codex-rs/tui/src/app/tests/safety_buffering.rs index 8fe1861c74..66e137d673 100644 --- a/codex-rs/tui/src/app/tests/safety_buffering.rs +++ b/codex-rs/tui/src/app/tests/safety_buffering.rs @@ -324,6 +324,109 @@ async fn agents_overview_acknowledges_inactive_steer_before_interrupt() -> Resul Box::pin(interrupt_after_inactive_steer(SteerSwitch::BetweenTasks)).await } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn embedded_session_switch_routes_background_approval_and_restores_draft() -> Result<()> { + use app_test_support::MockResponsesConfig; + + core_test_support::skip_if_no_network!(Ok(())); + let (release_command, gate) = oneshot::channel(); + let (model, _) = start_streaming_sse_server(vec![ + vec![StreamingSseChunk { + gate: Some(gate), + body: responses::sse(vec![ + responses::ev_response_created("first"), + responses::ev_function_call("command", "exec_command", + r#"{"cmd":"echo embedded","sandbox_permissions":"require_escalated","justification":"Test background approval"}"#), + responses::ev_completed("first"), + ]), + }], + response_chunks("finished"), + ]).await; + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let folder = tempdir()?; + let cwd = folder.path().canonicalize()?; + MockResponsesConfig::new(model.uri()) + .with_approval_policy("on-request") + .with_extra_config(&format!( + "[projects.{}]\ntrust_level = 'trusted'", + serde_json::to_string(&cwd)? + )) + .write(&app.config.codex_home)?; + app.config = app.rebuild_config_for_cwd(cwd.clone()).await?; + let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.pause_events(); + app.start_agents_overview_session( + &mut tui, + &mut server, + Some(cwd.clone().abs()), + /*managed_worktree*/ None, + /*startup_draft*/ None, + ) + .await?; + let first = app.chat_widget.thread_id().expect("first session"); + submit_prompt(&mut app, "Run the command"); + app.submit_thread_op(&mut server, first, next_user_turn_event(&mut events)) + .await?; + next_turn_started(&mut app, &mut server, first).await; + + app.open_agents_overview(&server); + app.start_agents_overview_session( + &mut tui, + &mut server, + Some(cwd.abs()), + /*managed_worktree*/ None, + /*startup_draft*/ None, + ) + .await?; + let second = app.chat_widget.thread_id().expect("second session"); + assert_ne!(first, second); + app.chat_widget.insert_str("Draft for the second session"); + release_command.send(()).unwrap(); + loop { + let event = server.next_event().await.expect("connected"); + let approval = match &event.event { + AppServerEvent::ServerRequest(request) => matches!( + request.as_ref(), + ServerRequest::CommandExecutionRequestApproval { .. } + ), + AppServerEvent::ServerNotification(n) if matches!(n.as_ref(), ServerNotification::TurnCompleted(done) if done.thread_id == first.to_string()) => + { + panic!("turn ended before approval") + } + _ => false, + }; + app.handle_backend_event(&server, event).await; + if approval { + break; + } + } + let quit = AppEvent::Exit(ExitMode::ShutdownFirst); + assert!(matches!( + boxed_future!(app.handle_event(&mut tui, &mut server, quit)).await?, + AppRunControl::Continue + )); + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 90).contains("Stop sessions and quit")); + app.chat_widget.handle_key_event(KeyCode::Esc.into()); + app.select_agents_overview_thread(&mut tui, &mut server, first) + .await?; + drain_active_thread_events(&mut app); + app.chat_widget.handle_key_event(KeyCode::Esc.into()); + while let Ok(event) = events.try_recv() { + boxed_future!(app.handle_event(&mut tui, &mut server, event)).await?; + } + wait_for_turn_completed(&mut app, &mut server, first).await; + app.select_agents_overview_thread(&mut tui, &mut server, second) + .await?; + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "Draft for the second session" + ); + server.shutdown().await?; + model.shutdown().await; + Ok(()) +} + enum SteerSwitch { WithinTask, BetweenTasks, diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index efe15c722f..ef641dd038 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -1092,29 +1092,27 @@ async fn delete_current_thread_navigates_only_after_success() -> Result<()> { let unrelated_side = ThreadId::new(); app.side_threads .insert(unrelated_side, SideThreadState::new(ThreadId::new())); - if !matches!(app.app_server_target, AppServerTarget::Embedded) { - // The recording proxy rejects the next unsubscribe after a failed fork. - side_config.cwd = side_config.cwd.join("failure"); - assert!( - server - .fork_side_thread( - &app.local_settings, - side_config, - thread_id, - /*selected_profile*/ None - ) - .await - .is_err() - ); - assert_matches!( - app.delete_current_thread(&mut tui, &mut server).await?, - AppRunControl::Continue - ); - assert_eq!(app.active_thread_id, Some(thread_id)); - assert!(app.side_threads.contains_key(&side_id)); - assert!(!app.chat_widget.has_active_view()); - assert_eq!(recorded_params(&requests, "thread/delete").len(), 1); - } + // The recording proxy rejects the next unsubscribe after a failed fork. + side_config.cwd = side_config.cwd.join("failure"); + assert!( + server + .fork_side_thread( + &app.local_settings, + side_config, + thread_id, + /*selected_profile*/ None + ) + .await + .is_err() + ); + assert_matches!( + app.delete_current_thread(&mut tui, &mut server).await?, + AppRunControl::Continue + ); + assert_eq!(app.active_thread_id, Some(thread_id)); + assert!(app.side_threads.contains_key(&side_id)); + assert!(!app.chat_widget.has_active_view()); + assert_eq!(recorded_params(&requests, "thread/delete").len(), 1); let (tx, mut events) = tokio::sync::mpsc::unbounded_channel(); app.app_event_tx = AppEventSender::new(tx); let control = @@ -1126,40 +1124,35 @@ async fn delete_current_thread_navigates_only_after_success() -> Result<()> { .await .is_err() ); - if matches!(app.app_server_target, AppServerTarget::Embedded) { - assert_matches!(control, AppRunControl::Exit(ExitReason::ThreadRemoved)); - assert!(recorded_params(&requests, "thread/unsubscribe").is_empty()); - } else { - assert_matches!(control, AppRunControl::Continue); - assert_eq!( - app.side_threads.keys().copied().collect::>(), - vec![unrelated_side] - ); - assert_eq!( - recorded_params(&requests, "thread/unsubscribe"), - vec![serde_json::json!({"threadId": side_id.to_string()}); 2] - ); - loop { - let event = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), events.recv()) - .await? - .expect("command center refresh"); - let refreshed = matches!(&event, AppEvent::AgentsOverviewThreadsLoaded { .. }); - Box::pin(app.handle_event(&mut tui, &mut server, event)).await?; - if refreshed { - break; - } + assert_matches!(control, AppRunControl::Continue); + assert_eq!( + app.side_threads.keys().copied().collect::>(), + vec![unrelated_side] + ); + assert_eq!( + recorded_params(&requests, "thread/unsubscribe"), + vec![serde_json::json!({"threadId": side_id.to_string()}); 2] + ); + loop { + let event = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), events.recv()) + .await? + .expect("command center refresh"); + let refreshed = matches!(&event, AppEvent::AgentsOverviewThreadsLoaded { .. }); + Box::pin(app.handle_event(&mut tui, &mut server, event)).await?; + if refreshed { + break; } - assert_eq!( - ( - app.active_thread_id, - app.primary_thread_id, - app.chat_widget.thread_id() - ), - (None, None, None) - ); - assert!(app.chat_widget.composer_is_empty()); - assert!(app.chat_widget.has_active_view()); } + assert_eq!( + ( + app.active_thread_id, + app.primary_thread_id, + app.chat_widget.thread_id() + ), + (None, None, None) + ); + assert!(app.chat_widget.composer_is_empty()); + assert!(app.chat_widget.has_active_view()); assert_eq!( recorded_params(&requests, "thread/delete"), vec![ @@ -1347,16 +1340,18 @@ async fn archive_current_thread_reports_success_only_after_archiving() -> Result app.active_thread_id = Some(thread_id); assert_matches!( - app.archive_current_thread(&mut tui, &mut app_server).await?, - AppRunControl::Exit(ExitReason::Archived(archived_id)) if archived_id == thread_id + app.archive_current_thread(&mut tui, &mut app_server) + .await?, + AppRunControl::Continue ); + assert!(app.chat_widget.active_view_is(AGENTS_OVERVIEW_VIEW_ID)); app_server.shutdown().await?; Ok(()) } #[tokio::test] -async fn archive_current_thread_returns_shared_servers_to_agents() -> Result<()> { +async fn archive_current_thread_returns_to_agents() -> Result<()> { let endpoint = crate::resolve_remote_addr("ws://127.0.0.1:4500")?; for (target, attachment, side_exists) in [ AppServerTarget::LocalDaemon { diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__embedded_exit_running_sessions.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__embedded_exit_running_sessions.snap new file mode 100644 index 0000000000..6428314af1 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__embedded_exit_running_sessions.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/app/tests/background_exit_tests.rs +assertion_line: 57 +expression: "render_bottom_popup(&app.chat_widget, 90)" +--- + Quit and stop running sessions? +› Ask Cod Quitting this TUI will stop its running sessions. + + GPT-5.6 + › 1. Stay in Codex + 2. Stop sessions and quit + + enter select · esc back diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__misalignment_policy__misalignment_continue_failed.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__misalignment_policy__misalignment_continue_failed.snap index 04fc89a935..19da4bc8c5 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__misalignment_policy__misalignment_continue_failed.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__misalignment_policy__misalignment_continue_failed.snap @@ -9,5 +9,6 @@ expression: "format!(\"{errors}\\n{}\", render_bottom_popup(&app.chat_widget, 80 › 1. Review findings - 2. New chat - 3. Resume another chat + 2. Agent command center + 3. New chat + 4. Resume another chat diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index d488964e61..dd3823939c 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -137,28 +137,20 @@ impl App { } } - pub(super) async fn detach_current_thread_for_fork( - &mut self, - app_server: &mut AppServerSession, - fork_id: ThreadId, - ) { - if !app_server.uses_embedded_app_server() { - // Daemon tasks can start while the fork RPC is pending. - for id in self - .thread_event_channels - .keys() - .copied() - .chain(self.agent_navigation.tracked_thread_ids()) - .filter(|id| !self.side_threads.contains_key(id)) - { - self.agents_overview - .dispatched_requests - .entry(id) - .or_default(); - } + pub(super) fn retain_subscriptions_for_fork(&mut self) { + // Tasks can start while the fork RPC is pending. + for id in self + .thread_event_channels + .keys() + .copied() + .chain(self.agent_navigation.tracked_thread_ids()) + .filter(|id| !self.side_threads.contains_key(id)) + { + self.agents_overview + .dispatched_requests + .entry(id) + .or_default(); } - self.detach_current_thread_for_navigation(app_server, Some(fork_id)) - .await; } pub(super) async fn activate_thread_for_replay( diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 4ca6142c03..298f59f816 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -320,7 +320,7 @@ pub(crate) enum AppEvent { NewAgentsOverviewSession { cwd: Option, }, - /// Update the daemon-wide overview after a background thread listing finishes. + /// Update the agents overview after a background thread listing finishes. AgentsOverviewThreadsLoaded { request_id: Uuid, result: Result, @@ -657,10 +657,10 @@ pub(crate) enum AppEvent { /// Resume a thread by UUID or thread name inside the running TUI session. ResumeSessionByIdOrName(String), - /// Archive the current active main thread and exit after it succeeds. + /// Archive the current active main thread and return to the command center. ArchiveCurrentThread, - /// Permanently delete the current active main thread and exit after it succeeds. + /// Permanently delete the current active main thread and return to the command center. DeleteCurrentThread, /// Fork the current session into a new thread, optionally assigning it a name. @@ -686,6 +686,8 @@ pub(crate) enum AppEvent { /// escape hatch that skips shutdown and may drop in-flight work (e.g., /// background tasks, rollout flush, or child process cleanup). Exit(ExitMode), + /// The user accepted stopping other sessions owned by this embedded server. + ConfirmedEmbeddedExit(ExitMode), /// Apply a choice from the running-task exit menu to its originating thread. RunningTaskExit { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/agents_navigation.rs b/codex-rs/tui/src/bottom_pane/chat_composer/agents_navigation.rs index 0a95a9a2f0..41d048b2cc 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/agents_navigation.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/agents_navigation.rs @@ -1,6 +1,5 @@ //! Availability of the empty-prompt agents shortcut and its footer hint. //! -//! Sessions connected to a local daemon or remote App Server enable this shortcut. //! Draft contents, transient input surfaces, and explicit editor remaps take precedence. use super::*; diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 26d42ab5d7..5993f75295 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -489,13 +489,8 @@ impl BottomPane { self.request_redraw(); } - pub(crate) fn set_command_center_enabled(&mut self, enabled: bool) { - self.composer.command_center_enabled = enabled; - self.request_redraw(); - } - - pub(crate) fn command_center_enabled(&self) -> bool { - self.composer.command_center_enabled + pub(crate) fn enable_agents_navigation(&mut self) { + self.composer.command_center_enabled = true; } pub(crate) fn agents_navigation_key_available(&self) -> bool { diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index e628af6b47..71c4f23aba 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -4,7 +4,9 @@ use super::*; impl ChatWidget { pub(crate) fn new_with_app_event(common: ChatWidgetInit) -> Self { - Self::new_with_op_target(common, CodexOpTarget::AppEvent) + let mut widget = Self::new_with_op_target(common, CodexOpTarget::AppEvent); + widget.bottom_pane.enable_agents_navigation(); + widget } pub(super) fn new_with_op_target( diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index cf9505a885..94f4fe228d 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -65,10 +65,6 @@ impl ChatWidget { self.bottom_pane.prepare_composer_mouse(event) } - pub(crate) fn set_command_center_enabled(&mut self, enabled: bool) { - self.bottom_pane.set_command_center_enabled(enabled); - } - pub(crate) fn agents_navigation_key_available(&self) -> bool { self.bottom_pane.agents_navigation_key_available() } diff --git a/codex-rs/tui/src/chatwidget/misalignment_policy.rs b/codex-rs/tui/src/chatwidget/misalignment_policy.rs index d42f37c82d..55bb275e03 100644 --- a/codex-rs/tui/src/chatwidget/misalignment_policy.rs +++ b/codex-rs/tui/src/chatwidget/misalignment_policy.rs @@ -155,16 +155,14 @@ impl ChatWidget { }, ); } - if self.bottom_pane.command_center_enabled() { - items.insert( - 1, - SelectionItem { - name: "Agent command center".to_string(), - actions: vec![Box::new(|tx| tx.send(AppEvent::OpenAgentsOverview))], - ..Default::default() - }, - ); - } + items.insert( + 1, + SelectionItem { + name: "Agent command center".to_string(), + actions: vec![Box::new(|tx| tx.send(AppEvent::OpenAgentsOverview))], + ..Default::default() + }, + ); self.bottom_pane.show_selection_view(SelectionViewParams { view_id: Some(PRECAUTION_VIEW), header: Box::new( diff --git a/codex-rs/tui/src/chatwidget/rendering.rs b/codex-rs/tui/src/chatwidget/rendering.rs index 76642531de..97a518cd8a 100644 --- a/codex-rs/tui/src/chatwidget/rendering.rs +++ b/codex-rs/tui/src/chatwidget/rendering.rs @@ -14,7 +14,6 @@ use ratatui::widgets::Block; use std::cell::Cell; struct ExternalWriterNotice { - command_center_available: bool, agents_navigation_key_available: bool, transcript_hint: Option, } @@ -95,21 +94,17 @@ impl ExternalWriterNotice { ("f".to_string(), "fork".to_string()), ]; let escape = crate::key_hint::plain(KeyCode::Esc).display_label(); - let mut quit_keys = vec![ + let quit_keys = [ crate::key_hint::ctrl(KeyCode::Char('c')).display_label(), crate::key_hint::plain(KeyCode::Char('q')).display_label(), ]; - if self.command_center_available { - let key = if self.agents_navigation_key_available { - let left = crate::key_hint::plain(KeyCode::Left).display_label(); - format!("{left}/{escape}") - } else { - escape - }; - items.push((key, "command center".to_string())); + let key = if self.agents_navigation_key_available { + let left = crate::key_hint::plain(KeyCode::Left).display_label(); + format!("{left}/{escape}") } else { - quit_keys.insert(/*index*/ 0, escape); - } + escape + }; + items.push((key, "command center".to_string())); items.push((quit_keys.join("/"), "exit".to_string())); if let Some(hint) = self.transcript_hint { items.push((hint.display_label(), "transcript".to_string())); @@ -261,7 +256,6 @@ impl ChatWidget { })) } else if self.external_writer_view && !self.bottom_pane.has_active_view() { RenderableItem::Owned(Box::new(ExternalWriterNotice { - command_center_available: self.bottom_pane.command_center_enabled(), agents_navigation_key_available: self.agents_navigation_key_available(), transcript_hint: self.bottom_pane.transcript_shortcut_hint(), })) diff --git a/codex-rs/tui/src/chatwidget/rendering_tests.rs b/codex-rs/tui/src/chatwidget/rendering_tests.rs index 1f8655617d..28108e8952 100644 --- a/codex-rs/tui/src/chatwidget/rendering_tests.rs +++ b/codex-rs/tui/src/chatwidget/rendering_tests.rs @@ -544,23 +544,12 @@ async fn removing_active_cell_invalidates_layout_before_reusing_its_identity() { } #[tokio::test] -async fn external_writer_notice_offers_command_center_on_shared_servers() { - let endpoint = crate::resolve_remote_addr("ws://127.0.0.1:4500").unwrap(); - for target in [ - crate::AppServerTarget::LocalDaemon { - allow_embedded_fallback: true, - endpoint: endpoint.clone(), - }, - crate::AppServerTarget::Remote { endpoint }, - ] { - let (mut widget, _sender, _events, _operations) = - make_chatwidget_manual_with_sender().await; - widget.set_command_center_enabled(target.supports_command_center()); - widget.show_external_writer_thread(); - for width in [60, 100] { - let rendered = crate::chatwidget::tests::render_bottom_popup(&widget, width); - insta::assert_snapshot!(format!("external_writer_command_center_{width}"), rendered); - } +async fn external_writer_notice_offers_command_center() { + let (mut widget, _sender, _events, _operations) = make_chatwidget_manual_with_sender().await; + widget.show_external_writer_thread(); + for width in [60, 100] { + let rendered = crate::chatwidget::tests::render_bottom_popup(&widget, width); + insta::assert_snapshot!(format!("external_writer_command_center_{width}"), rendered); } } diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 79a9018537..d4aa484a1d 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -285,12 +285,7 @@ impl ChatWidget { ..Default::default() }, SelectionItem { - name: if self.bottom_pane.command_center_enabled() { - "Yes, delete and return to command center" - } else { - "Yes, delete and exit" - } - .to_string(), + name: "Yes, delete and return to command center".to_string(), description: Some("Permanently delete this session now".to_string()), actions: vec![Box::new(|tx| { tx.send(AppEvent::DeleteCurrentThread); diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__rendering__tests__external_writer_view_shows_notice_instead_of_composer.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__rendering__tests__external_writer_view_shows_notice_instead_of_composer.snap index 207bccc731..809fc93590 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__rendering__tests__external_writer_view_shows_notice_instead_of_composer.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__rendering__tests__external_writer_view_shows_notice_instead_of_composer.snap @@ -7,4 +7,5 @@ expression: "rows.iter().map(|row| row.trim_end()).collect::>().join(\"\\ 🔒 This conversation is open in another app r to retry Close it there and press R to continue here. - r retry f fork esc/⌃c/q exit ⌃t transcript + r retry f fork ←/esc command center ⌃c/q exit ⌃t + transcript diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__misalignment_policy_violation.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__misalignment_policy_violation.snap index 5de2938c6e..9161427ee7 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__misalignment_policy_violation.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__misalignment_policy_violation.snap @@ -8,4 +8,5 @@ expression: popup › 1. New chat - 2. Resume another chat + 2. Agent command center + 3. Resume another chat diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__misalignment_policy_violation_narrow.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__misalignment_policy_violation_narrow.snap index 6cff02102a..29545b2000 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__misalignment_policy_violation_narrow.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__misalignment_policy_violation_narrow.snap @@ -10,4 +10,5 @@ expression: "render_bottom_popup(&chat, 38)" › 1. New chat - 2. Resume another chat + 2. Agent command center + 3. Resume another chat diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__misalignment_policy_violation_remote.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__misalignment_policy_violation_remote.snap deleted file mode 100644 index cf646d12e9..0000000000 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__misalignment_policy_violation_remote.snap +++ /dev/null @@ -1,12 +0,0 @@ ---- -source: tui/src/chatwidget/tests/misalignment_policy_tests.rs -expression: "render_bottom_popup(&chat, 80)" ---- - Chat stopped as a precaution - We couldn’t confirm the agent was acting safely and following your - instructions. To continue working, start or resume another chat. - - -› 1. New chat - 2. Agent command center - 3. Resume another chat diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_delete_confirmation_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_delete_confirmation_popup.snap deleted file mode 100644 index 85506a0ab4..0000000000 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_delete_confirmation_popup.snap +++ /dev/null @@ -1,12 +0,0 @@ ---- -source: tui/src/chatwidget/tests/slash_commands.rs -expression: popup ---- - Delete this session? -› As Cannot be undone. Subagent threads will also be deleted. - - GP - › 1. No, keep this session Return to the current session - 2. Yes, delete and exit Permanently delete this session now - - enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/tests/misalignment_policy_tests.rs b/codex-rs/tui/src/chatwidget/tests/misalignment_policy_tests.rs index 23444b918d..d9e11729a5 100644 --- a/codex-rs/tui/src/chatwidget/tests/misalignment_policy_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/misalignment_policy_tests.rs @@ -158,16 +158,12 @@ async fn misalignment_policy_failure_stops_the_thread_and_renders_once() { chat.show_misalignment_policy_precaution(); chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); assert_matches!(rx.try_recv(), Ok(AppEvent::OpenResumePicker)); assert!(chat.bottom_pane.has_active_view()); - chat.set_command_center_enabled(/*enabled*/ true); chat.show_misalignment_policy_precaution(); - assert_chatwidget_snapshot!( - "misalignment_policy_violation_remote", - render_bottom_popup(&chat, /*width*/ 80) - ); chat.handle_key_event(KeyEvent::from(KeyCode::Down)); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); assert_matches!(rx.try_recv(), Ok(AppEvent::OpenAgentsOverview)); diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index a8ce5ef1a1..dc5d14e86f 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -3115,30 +3115,15 @@ async fn slash_archive_confirmation_requests_current_thread_archive() { #[tokio::test] async fn slash_delete_confirmation_requests_current_thread_delete() { - let endpoint = crate::resolve_remote_addr("ws://127.0.0.1:4500").unwrap(); - for target in [ - crate::AppServerTarget::Embedded, - crate::AppServerTarget::LocalDaemon { - allow_embedded_fallback: true, - endpoint: endpoint.clone(), - }, - crate::AppServerTarget::Remote { endpoint }, - ] { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.set_command_center_enabled(target.supports_command_center()); - chat.dispatch_command(SlashCommand::Delete); - assert!(chat.bottom_pane.has_active_view()); - assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); - let popup = render_bottom_popup(&chat, /*width*/ 80); - if matches!(target, crate::AppServerTarget::Embedded) { - assert_chatwidget_snapshot!("slash_delete_confirmation_popup", popup); - } else { - assert_chatwidget_snapshot!("slash_delete_confirmation_command_center", popup); - } - chat.handle_key_event(KeyEvent::from(KeyCode::Down)); - chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); - assert_matches!(rx.try_recv(), Ok(AppEvent::DeleteCurrentThread)); - } + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.dispatch_command(SlashCommand::Delete); + assert!(chat.bottom_pane.has_active_view()); + assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); + let popup = render_bottom_popup(&chat, /*width*/ 80); + assert_chatwidget_snapshot!("slash_delete_confirmation_command_center", popup); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_matches!(rx.try_recv(), Ok(AppEvent::DeleteCurrentThread)); } #[tokio::test] diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 5c6c295e8a..0123b5ccef 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1159,6 +1159,21 @@ fn restore_terminal_before_fatal_exit() { startup_recovery::print_unsent_draft(); } +/// Construct a large future outside its caller's poll frame before boxing it. +/// `Box::pin(large_future())` can still reserve the future's full size on that stack. +#[inline(never)] +fn box_future(create: impl FnOnce() -> F) -> std::pin::Pin> { + Box::pin(create()) +} + +// Keep boxed call sites shaped like ordinary calls, without closure indentation. +macro_rules! boxed_future { + ($future:expr) => { + $crate::box_future(|| $future) + }; +} +pub(crate) use boxed_future; + pub async fn run_main( cli: Cli, arg0_paths: Arg0DispatchPaths, @@ -1169,7 +1184,7 @@ pub async fn run_main( startup_recovery::scope(async move { // Startup retains a large future for the whole session. Keep it off callers' stacks, // which also need room to construct a replacement chat widget on `/new`. - match Box::pin(startup_orchestration::run_main_inner( + match boxed_future!(startup_orchestration::run_main_inner( cli, arg0_paths, loader_overrides, @@ -1972,7 +1987,8 @@ async fn run_ratatui_app( trust_cwd, onboarding::DirectoryTrustOptions { resumed_thread: resumed_thread.as_ref(), - ..Default::default() + cancel: (!app_server_target.supports_command_center()) + .then_some(onboarding::TrustCancelAction::Quit), }, Some(&mut startup_draft), ) @@ -2213,7 +2229,7 @@ async fn run_ratatui_app( // Keep the large event-loop future out of the enclosing startup futures so session // transitions have enough stack headroom to rebuild configuration and the chat widget. - let app_result = Box::pin(App::run( + let app_result = boxed_future!(App::run( &mut tui, crate::app::backend_sessions::InitialBackendSessions::singleton(app_server), config, diff --git a/codex-rs/tui/src/onboarding/directory_trust.rs b/codex-rs/tui/src/onboarding/directory_trust.rs index bd58c59f30..990d6d1f44 100644 --- a/codex-rs/tui/src/onboarding/directory_trust.rs +++ b/codex-rs/tui/src/onboarding/directory_trust.rs @@ -102,11 +102,7 @@ pub(crate) async fn check_directory_trust( steps: vec![Step::TrustDirectory(TrustDirectoryWidget { restricted: project.trust_level == Some(TrustLevel::Untrusted), existing_task: connected && resumed_thread.is_some(), - cancel: cancel.unwrap_or(if target.supports_command_center() { - TrustCancelAction::AgentsOverview - } else { - TrustCancelAction::Quit - }), + cancel: cancel.unwrap_or(TrustCancelAction::AgentsOverview), cwd: project.cwd, trust_target: project.trust_target, show_windows_create_sandbox_hint: false, diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__selected_unloaded_subagent_V1.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__selected_unloaded_subagent_V1.snap index 1252049489..9e40dcd3a5 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__selected_unloaded_subagent_V1.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__selected_unloaded_subagent_V1.snap @@ -5,4 +5,4 @@ expression: "render_bottom_popup(&app.chat_widget, 80)" --- › Ask Codex to do anything - GPT-6.1-Sol default · /tmp/project · child-0 [worker] + GPT-6.1-Sol default · /tmp/project · child-0 [worker] · ← for agents diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__selected_unloaded_subagent_V2.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__selected_unloaded_subagent_V2.snap index b36b6636f4..f99b38fb4e 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__selected_unloaded_subagent_V2.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__selected_unloaded_subagent_V2.snap @@ -5,4 +5,4 @@ expression: "render_bottom_popup(&app.chat_widget, 80)" --- › Viewing sub-agent — direct input is disabled - GPT-6.1-Sol default · /tmp/project · child-1 [worker] + GPT-6.1-Sol default · /tmp/project · child-1 [worker] · ← for agents diff --git a/codex-rs/tui/src/startup_orchestration.rs b/codex-rs/tui/src/startup_orchestration.rs index 7854472f74..08c9b64bae 100644 --- a/codex-rs/tui/src/startup_orchestration.rs +++ b/codex-rs/tui/src/startup_orchestration.rs @@ -997,7 +997,7 @@ pub(super) async fn run_main_inner( } // Keep the large app future off the enclosing CLI startup stack during transitions. - let app_result = Box::pin(run_ratatui_app( + let app_result = crate::boxed_future!(run_ratatui_app( cli, arg0_paths, loader_overrides, diff --git a/codex-rs/tui/src/test_support.rs b/codex-rs/tui/src/test_support.rs index a83764e1bb..00bd43be9c 100644 --- a/codex-rs/tui/src/test_support.rs +++ b/codex-rs/tui/src/test_support.rs @@ -1,7 +1,5 @@ //! Test-only helpers shared across the TUI crate. -use std::future::Future; -use std::pin::Pin; use std::sync::LazyLock; use codex_models_manager::bundled_models_response; @@ -56,17 +54,4 @@ where }) } -/// Construct a large test future outside its caller's poll frame before boxing it. -/// `Box::pin(large_future())` can still reserve the future's full size on that stack. -#[inline(never)] -pub(crate) fn box_future(create: impl FnOnce() -> F) -> Pin> { - Box::pin(create()) -} - -// Keep boxed call sites shaped like ordinary calls, without closure indentation. -macro_rules! boxed_future { - ($future:expr) => { - $crate::test_support::box_future(|| $future) - }; -} -pub(crate) use boxed_future; +pub(crate) use crate::boxed_future;