Enable the agent command center for embedded sessions (#58238)

## Why

Command-center navigation required a shared app server, leaving embedded sessions without the same way to switch between chats. Archiving or deleting an embedded session also exited the TUI.

## What changed

- Enable command-center navigation and session listing for embedded sessions, including navigation from read-only conversations and the safety precaution menu.
- Return to the command center after archiving or deleting a session.
- Retain thread subscriptions when forking, including on embedded servers.
- Confirm before quitting an embedded server would stop running sessions or side conversations.
- Construct large startup and event-loop futures outside their callers' stack frames before boxing them.

## Testing

Add regression coverage for embedded session switching with background approvals and draft restoration, plus confirmation before quitting with a running side conversation. Update navigation, archive/delete, fork, and UI snapshot tests for the new behavior.

GitOrigin-RevId: 96bac48cccf85b685b58934d23dd1db93b663751
This commit is contained in:
Eric Traut
2026-10-30 04:00:47 +00:00
committed by copyberry
parent c2e4631a34
commit 7689f21b79
44 changed files with 419 additions and 348 deletions
+7 -3
View File
@@ -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
@@ -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!(
@@ -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() {
+71 -50
View File
@@ -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) => {
+2 -12
View File
@@ -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);
}
+2 -4
View File
@@ -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)
+1 -2
View File
@@ -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,
+1 -3
View File
@@ -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());
+9
View File
@@ -227,6 +227,15 @@ impl SideThreadState {
}
impl App {
pub(super) async fn running_side_thread_id(&self) -> Option<ThreadId> {
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);
@@ -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
@@ -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
+2 -3
View File
@@ -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 {
@@ -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)
);
@@ -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(())
@@ -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 {
@@ -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(), "<PROJECT>");
insta::assert_snapshot!(rendered, @r"
› Ask Codex to do anything
server-model high · <PROJECT>
server-model high · <PROJECT> · ← for agents
");
}
if explicit.starts_with("profile_summary") {
+8 -3
View File
@@ -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"));
@@ -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,
@@ -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<_>>(),
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<_>>(),
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 {
@@ -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
@@ -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
+13 -21
View File
@@ -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(
+5 -3
View File
@@ -320,7 +320,7 @@ pub(crate) enum AppEvent {
NewAgentsOverviewSession {
cwd: Option<AbsolutePathBuf>,
},
/// 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<AgentsOverviewThreadRefresh, String>,
@@ -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 {
@@ -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::*;
+2 -7
View File
@@ -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 {
+3 -1
View File
@@ -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(
@@ -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()
}
@@ -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(
+7 -13
View File
@@ -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<crate::key_hint::ShortcutHint>,
}
@@ -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(),
}))
+6 -17
View File
@@ -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);
}
}
@@ -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);
@@ -7,4 +7,5 @@ expression: "rows.iter().map(|row| row.trim_end()).collect::<Vec<_>>().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
@@ -8,4 +8,5 @@ expression: popup
› 1. New chat
2. Resume another chat
2. Agent command center
3. Resume another chat
@@ -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
@@ -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
@@ -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
@@ -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));
@@ -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]
+19 -3
View File
@@ -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<F: std::future::Future>(create: impl FnOnce() -> F) -> std::pin::Pin<Box<F>> {
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,
@@ -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,
@@ -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
@@ -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
+1 -1
View File
@@ -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,
+1 -16
View File
@@ -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<F: Future>(create: impl FnOnce() -> F) -> Pin<Box<F>> {
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;