Files
codex/codex-rs/tui/src/app/startup.rs
T
Benjamin Carlsson b279cfb170 Enable conversation-scoped speed selection for Cloud turns (#61178)
## What changed

- Stage Cloud speed choices for the next new turn in the conversation, alongside pending model and effort selections. Update the displayed conversation speed after admission without changing the local default.
- Offer speed commands supported by the pending model. Clear an incompatible pending speed change with a warning when switching models.
- Carry `service_tier` through Cloud message requests, preserving omission to inherit the thread tier and explicit null to clear it. Translate `default` to null and reject tier overrides on queued requests.

## Testing

Add tests for Cloud speed toggling, model-dependent speed choices, admission-time updates, and preserving the conversation tier when selecting a model. Extend request and wire tests to cover service-tier values, explicit clearing, and queue rejection.

GitOrigin-RevId: b4daa701f1fe29593d90a938e60cc20cb980298a
2026-11-12 20:56:33 +00:00

1831 lines
86 KiB
Rust

//! Startup bootstrap, draft handoff, and protected-input orchestration for the TUI app.
//!
//! Owns the main app run loop from app-server bootstrap through terminal shutdown. Startup input
//! remains isolated from protected interactive requests until the initialized composer owns it.
//! Queued resume history replaces the provisional loading frame only when it is ready to render.
//! Explicit local launch permissions remain runtime overrides across new sessions and reconnects.
use super::backend_selection::NewBackendSession;
use super::reconnect::ReconnectState;
use super::startup_bootstrap::bootstrap_server_owned_start;
use super::startup_bootstrap::uses_server_owned_fresh_bootstrap;
use super::*;
use crate::session_start::SessionStartAction;
use crate::session_start::SessionStartConfig;
use crate::session_start::SessionStartOutcome;
use crate::session_start::cancel_session_start;
use crate::session_start::complete_session_start;
use crate::unarchive_prompt::run_unarchive_prompt;
use color_eyre::eyre::ContextCompat;
fn spawn_startup_thread_start(
app_server: &AppServerSession,
local_settings: crate::local_settings::LocalSettings,
config: Config,
launch_choices: crate::app_server_session::StartupLaunchChoices,
app_event_tx: AppEventSender,
worktree: Option<crate::ManagedTuiWorktree>,
) {
let backend_id = app_server.backend_id();
let request_handle = app_server.request_handle();
let thread_params_mode = app_server.thread_params_mode();
let remote_cwd_override = app_server.remote_cwd_override().map(Path::to_path_buf);
let thread_tool_transport = app_server.thread_tool_transport();
tokio::spawn(async move {
let result = crate::app_server_session::start_thread_with_request_handle(
request_handle,
&local_settings,
config,
thread_params_mode,
remote_cwd_override,
thread_tool_transport,
launch_choices,
)
.await
.and_then(|started| {
if let Some(worktree) = worktree.as_ref() {
worktree.bind(started.session.thread_id)?;
}
Ok(started)
});
app_event_tx.send(AppEvent::StartupThreadStarted { backend_id, result });
});
}
pub(super) async fn prepare_fresh_startup_config(
config: &mut Config,
app_server: &AppServerSession,
app_server_target: &AppServerTarget,
cli_kv_overrides: &[(String, TomlValue)],
harness_overrides: &ConfigOverrides,
environments: &EnvironmentManager,
) -> Result<crate::projectless::AppliedDefaults> {
let defaults_cwd = match app_server_target {
AppServerTarget::Embedded | AppServerTarget::LocalDaemon { .. } => config.cwd.as_path(),
AppServerTarget::Remote { .. } | AppServerTarget::Cloud => {
app_server.remote_cwd_override().unwrap_or(Path::new("."))
}
};
let defaults = crate::config_update::read_effective_config_with_layers(
app_server.request_handle(),
defaults_cwd,
)
.await?;
super::new_session::overlay_new_session_defaults(
config,
&defaults.config,
cli_kv_overrides,
harness_overrides,
);
let projectless = crate::projectless::apply_defaults(
config,
harness_overrides,
app_server,
environments,
&defaults,
);
apply_managed_new_thread_defaults(
config,
app_server.managed_new_thread_defaults(),
cli_kv_overrides,
harness_overrides,
);
Ok(projectless)
}
pub(super) fn startup_model(
config: &Config,
bootstrap: &AppServerBootstrap,
server_defaults_read: bool,
) -> Result<String> {
config
.model
.clone()
.or_else(|| {
if server_defaults_read {
// Legacy bootstrap paths may be seeded with local config, which can differ from a
// cleared server model. Use the server catalog when config/read returned model: null.
bootstrap
.available_models
.iter()
.find(|model| model.is_default)
.or_else(|| bootstrap.available_models.first())
.map(|model| model.model.clone())
.or_else(|| bootstrap.default_model.clone())
} else {
bootstrap.default_model.clone()
}
})
.wrap_err("The initial app-server connection did not provide a startup model.")
}
impl App {
/// Keep explicit local launch choices available for new sessions and reconnect recovery.
pub(super) fn remember_launch_permissions(&mut self) {
if self.app_server_target.thread_params_mode()
== crate::app_server_session::ThreadParamsMode::Remote
{
return;
}
let selected = crate::resume_permissions::ResumePermissions::from_overrides(
&self.config,
&self.harness_overrides,
);
if selected.approval_policy {
self.runtime_approval_policy_override = Some(RuntimeApprovalPolicyOverride::Explicit(
self.config.permissions.approval_policy.value().into(),
));
}
if selected.approvals_reviewer {
self.runtime_approvals_reviewer_override = Some(self.config.approvals_reviewer);
}
if selected.profile {
let profile = RuntimePermissionProfileOverride::from_config(&self.config);
// The server owns constrained profiles that legacy turn parameters cannot express.
self.runtime_permission_profile_override =
(profile.active_permission_profile.is_some()
|| crate::app_server_session::turn_permissions_overrides(
TurnPermissionsOverride::LegacySandbox(
self.config.permissions.effective_permission_profile(),
),
self.config.cwd.as_path(),
)
.is_ok())
.then_some(profile);
}
}
pub(super) fn awaiting_side_replay(&self, tui: &tui::Tui) -> bool {
self.pending_thread_switch_resets != 0 && self.side_retention_enabled(tui)
}
/// Apply queued app events before input can cross a backend or side-chat switch.
pub(super) fn can_read_terminal_after_thread_switch(
&self,
tui: &tui::Tui,
app_event_rx: &mpsc::UnboundedReceiver<AppEvent>,
) -> bool {
self.pending_thread_switch_resets == 0
&& (app_event_rx.is_empty()
|| (!self.config.features.enabled(Feature::TuiMultiBackend)
&& !self.side_retention_enabled(tui)
&& self
.current_displayed_thread_id()
.and_then(|id| self.side_pair_key(id))
.is_none()))
}
/// Keep the provisional loading frame until queued history reaches the owned transcript.
/// Visible startup decisions and the agent overview must still render immediately.
pub(super) fn render_startup_frame(
&mut self,
tui: &mut tui::Tui,
app_event_rx: &mpsc::UnboundedReceiver<AppEvent>,
) -> Result<()> {
if self.awaiting_side_replay(tui)
|| (tui.is_owned_screen()
&& !app_event_rx.is_empty()
&& !self.chat_widget.has_active_view())
{
return Ok(());
}
self.chat_widget.pre_draw_tick();
self.render_chat_widget_frame(tui, tui.terminal.last_known_screen_size)?;
if self.chat_widget.has_active_modal() && self.startup_protected_input_boundary {
self.discard_startup_modal_input(tui)?;
}
Ok(())
}
/// Keep a visible modal closed to input if the terminal has not settled yet. The next input
/// event retries the drain instead of acting on the modal; server events can continue meanwhile.
pub(super) fn discard_startup_modal_input(&mut self, tui: &mut tui::Tui) -> Result<()> {
match tui.discard_pending_input_before_interactive_screen() {
Ok(()) => self.startup_pending_protected_request = false,
Err(err) if err.kind() == std::io::ErrorKind::TimedOut => {
self.startup_pending_protected_request = true;
tracing::warn!(%err, "keeping startup modal input quarantined");
}
Err(err) => return Err(err.into()),
}
Ok(())
}
/// Recognizes queued requests before they become visible protected screens.
pub(super) fn has_queued_startup_protected_request(&self) -> bool {
self.startup_protected_input_boundary
&& (self
.active_thread_rx
.as_ref()
.is_some_and(|receiver| !receiver.is_empty())
&& self
.active_thread_id
.and_then(|thread_id| self.thread_event_channels.get(&thread_id))
.is_none_or(|channel| {
// A bounded drain can leave ordinary notifications queued. Only protect
// input for pending requests, or when their state cannot be inspected.
channel.store.try_lock().map_or(/*default*/ true, |store| {
store.side_parent_pending_status().is_some()
})
})
|| self
.pending_primary_events
.iter()
.any(|event| matches!(event, ThreadBufferedEvent::Request(_))))
}
/// Wait until visible and queued startup decisions cannot consume a delayed OSC response.
#[cfg(any(windows, test))]
pub(super) fn ready_for_terminal_color_probe(&self, has_pending_app_events: bool) -> bool {
!has_pending_app_events
&& !self.chat_widget.has_active_view()
&& !self.startup_pending_protected_request
&& !self.has_queued_startup_protected_request()
&& !self.chat_widget.has_pending_protected_request()
}
#[allow(clippy::too_many_arguments)]
pub async fn run(
tui: &mut tui::Tui,
initial_backends: super::backend_sessions::InitialBackendSessions,
mut config: Config,
launch_cwd: PathBuf,
backend_preference_context: backend_selection::BackendPreferenceContext,
cli_kv_overrides: Vec<(String, TomlValue)>,
harness_overrides: ConfigOverrides,
loader_overrides: LoaderOverrides,
cloud_config_bundle: CloudConfigBundleLoader,
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
session_selection: SessionSelection,
feedback: codex_feedback::CodexFeedback,
is_first_run: bool,
should_prompt_windows_sandbox_nux_at_startup: bool,
app_server_target: AppServerTarget,
state_db: Option<StateDbHandle>,
environment_manager: Arc<EnvironmentManager>,
startup_elapsed_before_app: Duration,
startup_bootstrap: Option<AppServerBootstrap>,
startup_hooks_browser: Option<HooksListEntry>,
daemon_startup_warning: Option<String>,
managed_daemon: bool,
mut startup_draft: StartupDraftPump,
managed_worktree: Option<crate::ManagedTuiWorktree>,
daemon_cli_executable: Option<AbsolutePathBuf>,
) -> Result<AppExitInfo> {
use tokio_stream::StreamExt;
async fn shutdown_startup_sessions(sessions: impl IntoIterator<Item = AppServerSession>) {
let shutdowns = sessions.into_iter().map(AppServerSession::shutdown);
let shutdown_result: std::io::Result<Vec<()>> = futures::future::join_all(shutdowns)
.await
.into_iter()
.collect();
if let Err(shutdown_error) = shutdown_result {
tracing::warn!("app-server shutdown failed: {shutdown_error}");
}
}
async fn shutdown_on_startup_error(
app_server: AppServerSession,
other_backends: &mut Vec<AppServerSession>,
error: impl Into<color_eyre::eyre::Report>,
) -> Result<AppExitInfo> {
shutdown_startup_sessions(std::iter::once(app_server).chain(other_backends.drain(..)))
.await;
Err(error.into())
}
async fn cancel_startup_sessions(
app_server: AppServerSession,
other_backends: &mut Vec<AppServerSession>,
) -> AppExitInfo {
let exit_info = cancel_session_start(app_server).await;
shutdown_startup_sessions(other_backends.drain(..)).await;
exit_info
}
let (mut app_server, mut other_backends, backend_settings) = initial_backends.into_parts();
// Preserve singleton error propagation while releasing all sessions on multi-backend exits.
macro_rules! startup_try {
($result:expr) => {
match $result {
Ok(value) => value,
Err(error) if other_backends.is_empty() => return Err(error.into()),
Err(error) => {
return shutdown_on_startup_error(app_server, &mut other_backends, error)
.await;
}
}
};
}
// Adopt actual launch ownership before constructing session-local preferences.
startup_try!(tui.prepare_owned_screen(config.tui_fullscreen_transcript));
let mut local_settings = crate::local_settings::LocalSettings::for_tui(&config, tui);
let startup_started_at = Instant::now();
if let SessionSelection::Resume(target) | SessionSelection::Fork(target) =
&session_selection
{
startup_try!(target.ensure_backend(app_server.backend_id()));
}
let (app_event_tx, mut app_event_rx) = unbounded_channel();
let app_event_tx = AppEventSender::new(app_event_tx);
if let Some(message) = project_config_warning(&config) {
app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::StartupWarningsCell::new(vec![message]),
)));
}
emit_system_bwrap_warning(&app_event_tx, &config);
tui.set_notification_settings(
local_settings.tui.notification_settings.method,
local_settings.tui.notification_settings.condition,
);
let harness_overrides = startup_try!(normalize_harness_overrides_for_cwd(
harness_overrides,
&config.cwd
));
app_server.model_provider_override = harness_overrides.model_provider.clone();
let fresh_start = matches!(
&session_selection,
SessionSelection::StartFresh | SessionSelection::Exit
);
let server_owned_fresh_bootstrap =
uses_server_owned_fresh_bootstrap(&session_selection, &loader_overrides);
let mut bootstrap = startup_bootstrap;
if bootstrap.is_none() && !server_owned_fresh_bootstrap {
bootstrap = Some(
match startup_draft
.run_until(tui, app_server.bootstrap(&config))
.await
{
Ok(bootstrap) => startup_try!(bootstrap),
Err(err) => {
return shutdown_on_startup_error(app_server, &mut other_backends, err)
.await;
}
},
);
}
if matches!(&session_selection, SessionSelection::Fork(_)) {
// The app server resolves omitted overrides from the fork destination's config.
if harness_overrides.model.is_none()
&& !super::new_session::has_launch_setting(&config, &cli_kv_overrides, "model")
&& !super::new_session::has_launch_setting(
&config,
&cli_kv_overrides,
"model_reasoning_effort",
)
{
config.model = None;
config.model_reasoning_effort = None;
}
}
let startup_defaults = if fresh_start {
match startup_draft
.run_until(
tui,
prepare_fresh_startup_config(
&mut config,
&app_server,
&app_server_target,
&cli_kv_overrides,
&harness_overrides,
&environment_manager,
),
)
.await
{
Ok(Ok(defaults)) => defaults,
Ok(Err(err)) => {
return shutdown_on_startup_error(app_server, &mut other_backends, err).await;
}
Err(err) => {
return shutdown_on_startup_error(app_server, &mut other_backends, err).await;
}
}
} else {
crate::projectless::AppliedDefaults::default()
};
let mut launch_choices = crate::app_server_session::StartupLaunchChoices::from_launch(
&cli_kv_overrides,
&harness_overrides,
&loader_overrides,
);
let bootstrap = match bootstrap {
Some(bootstrap) => bootstrap,
None => match startup_draft
.run_until(
tui,
bootstrap_server_owned_start(
&mut app_server,
&mut config,
&mut launch_choices,
&cli_kv_overrides,
&harness_overrides,
),
)
.await
{
Ok(bootstrap) => startup_try!(bootstrap),
Err(err) => {
return shutdown_on_startup_error(app_server, &mut other_backends, err).await;
}
},
};
launch_choices.apply_projectless_approval_policy(
startup_defaults.approval_policy,
bootstrap.allowed_approval_policies.as_deref(),
);
tracing::debug!(
has_platform_family = app_server.app_server_platform_family().is_some(),
has_platform_os = app_server.app_server_platform_os().is_some(),
"connected app-server platform"
);
let bootstrap_ms = bootstrap.duration.as_millis();
if !server_owned_fresh_bootstrap
&& matches!(&session_selection, SessionSelection::AgentsOverview)
{
apply_managed_new_thread_defaults(
&mut config,
app_server.managed_new_thread_defaults(),
&cli_kv_overrides,
&harness_overrides,
);
}
let mut model = startup_try!(startup_model(&config, &bootstrap, fresh_start));
let available_models = bootstrap.available_models;
let mut remote_connection =
crate::status::remote_connection::remote_connection_status_value(
&app_server_target,
app_server.server_version(),
);
if let Some(connection) = &mut remote_connection {
connection.is_managed_daemon = connection.is_local_daemon && managed_daemon;
}
let initial_server_version_notice =
if !matches!(app_server_target, AppServerTarget::Embedded) {
crate::status::remote_connection::pending_server_version_notice(
&local_settings.tui,
&app_server_target,
app_server.server_codex_home(),
CODEX_CLI_VERSION,
app_server.server_version(),
/*last_shown*/ None,
)
} else {
None
};
if let Err(err) = startup_draft.flush_pending_events(tui).await {
return shutdown_on_startup_error(app_server, &mut other_backends, err).await;
}
let exit_info =
if matches!(&session_selection, SessionSelection::Fork(_)) && config.model.is_none() {
None
} else {
let result = handle_model_migration_prompt_if_needed(
tui,
&mut config,
&local_settings,
&mut launch_choices,
model.as_str(),
&app_event_tx,
&available_models,
)
.await;
startup_try!(result)
};
if let Some(exit_info) = exit_info {
shutdown_startup_sessions(std::iter::once(app_server).chain(other_backends)).await;
return Ok(exit_info);
}
if let Some(updated_model) = config.model.clone() {
model = updated_model;
}
let mut source_overrides = harness_overrides.clone();
source_overrides.cwd = None;
app_server.worktree_source_config_builder = Some(Box::new(
crate::legacy_core::config::ConfigBuilder::default()
.codex_home(config.codex_home.to_path_buf())
.cli_overrides(cli_kv_overrides.clone())
.harness_overrides(source_overrides)
.loader_overrides(loader_overrides.clone())
.cloud_config_bundle(cloud_config_bundle.clone()),
));
let dynamic_tool_status_updates = tokio::sync::broadcast::channel(/*capacity*/ 64).0;
if matches!(
&app_server_target,
AppServerTarget::LocalDaemon { .. } | AppServerTarget::Embedded
) && !crate::uses_remote_workspace_or_environment(
&app_server_target,
environment_manager.as_ref(),
) && let Err(error) = app_server
.start_dynamic_tool_mcp(
config.clone(),
app_event_tx.clone(),
dynamic_tool_status_updates.clone(),
)
.await
{
tracing::warn!(%error, "TUI task delegation is unavailable without its MCP server");
}
let mut provider_writes = HashMap::new();
let model_catalog = Arc::new(
ModelCatalog::new(available_models.clone())
.for_backend(app_server.backend_id(), &mut provider_writes)
.with_collaboration_modes(bootstrap.collaboration_modes),
);
let feedback_audience = bootstrap.feedback_audience;
let auth_mode = bootstrap.auth_mode;
let has_chatgpt_account = bootstrap.has_chatgpt_account;
let has_codex_backend_auth = matches!(auth_mode, Some(TelemetryAuthMode::Chatgpt));
let requires_openai_auth = bootstrap.requires_openai_auth;
let status_account_display = bootstrap.status_account_display.clone();
let initial_plan_type = bootstrap.plan_type;
let session_telemetry = SessionTelemetry::new(
ThreadId::new(),
model.as_str(),
model.as_str(),
/*account_id*/ None,
bootstrap.account_email.clone(),
auth_mode,
codex_login::default_client::originator().value,
config.otel.log_user_prompt,
user_agent(),
serde_json::from_value(serde_json::json!("cli"))
.unwrap_or_else(|err| panic!("cli session source should deserialize: {err}")),
);
if local_settings
.tui
.status_line
.as_ref()
.is_some_and(|cmd| !cmd.is_empty())
{
session_telemetry.counter("codex.status_line", /*inc*/ 1, &[]);
}
let status_line_invalid_items_warned = Arc::new(AtomicBool::new(false));
let terminal_title_invalid_items_warned = Arc::new(AtomicBool::new(false));
let workspace_command_runner: WorkspaceCommandRunner = Arc::new(
AppServerWorkspaceCommandRunner::new(app_server.request_handle()),
);
let enhanced_keys_supported = tui.enhanced_keys_supported();
let wait_for_initial_session_configured =
Self::should_wait_for_initial_session(&session_selection);
let should_prompt_for_paused_goal_after_startup_resume =
Self::should_prompt_for_paused_goal_after_startup_resume(
&session_selection,
&initial_prompt,
&initial_images,
);
let thread_and_widget_started_at = Instant::now();
let pending_startup_thread_start = matches!(
&session_selection,
SessionSelection::StartFresh | SessionSelection::Exit
);
let mut start_in_agents_overview =
matches!(&session_selection, SessionSelection::AgentsOverview);
let mut read_only_thread = false;
let mut provider_error = None;
let mut history_notice = None;
let (mut chat_widget, initial_started_thread) = match session_selection {
SessionSelection::StartFresh
| SessionSelection::Exit
| SessionSelection::AgentsOverview => {
if !start_in_agents_overview {
spawn_startup_thread_start(
&app_server,
local_settings.clone(),
config.clone(),
launch_choices.clone(),
app_event_tx.clone(),
managed_worktree.clone(),
);
}
// Count a startup tooltip once the initial chat widget can render it.
let startup_tooltip_override = if start_in_agents_overview {
None
} else {
match startup_draft
.run_until(
tui,
prepare_startup_tooltip_override(
&mut local_settings,
&available_models,
is_first_run,
),
)
.await
{
Ok(tooltip_override) => tooltip_override,
Err(err) => {
return shutdown_on_startup_error(app_server, &mut other_backends, err)
.await;
}
}
};
let init = crate::chatwidget::ChatWidgetInit {
local_settings: local_settings.clone(),
config: config.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: app_event_tx.clone(),
workspace_command_runner: Some(workspace_command_runner.clone()),
initial_user_message: crate::chatwidget::create_initial_user_message(
initial_prompt.clone(),
initial_images.clone(),
// CLI prompt args are plain strings, so they don't provide element ranges.
Vec::new(),
),
enhanced_keys_supported,
has_chatgpt_account,
requires_openai_auth,
has_codex_backend_auth,
model_catalog: model_catalog.clone(),
feedback: feedback.clone(),
is_first_run,
status_account_display: status_account_display.clone(),
initial_plan_type,
model: Some(model.clone()),
startup_tooltip_override,
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned
.clone(),
session_telemetry: session_telemetry.clone(),
};
let mut chat_widget = ChatWidget::new_with_app_event(init);
chat_widget.set_queue_submissions_until_session_configured(
/*queue*/ !start_in_agents_overview,
);
(chat_widget, None)
}
SessionSelection::Resume(target_session) => {
if app_server_target.thread_params_mode()
== crate::app_server_session::ThreadParamsMode::Remote
&& config_persistence::has_explicit_resume_permission_override(
&config,
&harness_overrides,
)
{
return shutdown_on_startup_error(
app_server,
&mut other_backends,
color_eyre::eyre::eyre!(
"Permission overrides are not supported when resuming a remote task."
),
)
.await;
}
if let Some(history_mode) = target_session.history_mode {
app_server.remember_thread_history_mode(target_session.thread_id, history_mode);
}
let model_settings = config_persistence::resume_model_settings_for_overrides(
&config,
&harness_overrides,
);
let resumed = match startup_draft
.run_until(
tui,
app_server.resume_thread_with_permission_overrides(
&local_settings,
config.clone(),
target_session.thread_id,
model_settings,
crate::resume_permissions::ResumePermissions::from_overrides(
&config,
&harness_overrides,
),
),
)
.await
{
Ok(Ok(resumed)) => Ok(resumed),
Ok(Err(err))
if crate::app_server_session::is_active_writer_error(&err)
|| crate::app_server_session::model_provider_error(&err).is_some() =>
{
provider_error = crate::app_server_session::model_provider_error(&err);
read_only_thread = true;
match startup_draft
.run_until(
tui,
app_server.read_thread_for_viewing(
&config,
&local_settings,
target_session.thread_id,
),
)
.await
{
Ok(result) => result.map(|(thread, notice)| {
history_notice = notice;
thread
}),
Err(err) => {
return shutdown_on_startup_error(
app_server,
&mut other_backends,
err,
)
.await;
}
}
}
Ok(resumed) => resumed,
Err(err) => {
return shutdown_on_startup_error(app_server, &mut other_backends, err)
.await;
}
};
let resumed = if read_only_thread {
match resumed {
Ok(resumed) => Some(resumed),
Err(err) => {
return shutdown_on_startup_error(app_server, &mut other_backends, err)
.await;
}
}
} else {
let action = SessionStartAction::Resume(
model_settings,
crate::resume_permissions::ResumePermissions::from_overrides(
&config,
&harness_overrides,
),
);
let resumed = complete_session_start(
&mut app_server,
SessionStartConfig {
config: &config,
local_settings: &local_settings,
},
&target_session,
action,
resumed,
async || {
startup_draft.flush_pending_events(tui).await?;
run_unarchive_prompt(tui, target_session.thread_id, action).await
},
)
.await;
match startup_try!(resumed) {
SessionStartOutcome::Started(started) => Some(*started),
SessionStartOutcome::CommandCenter => None,
SessionStartOutcome::Exit => {
return Ok(
cancel_startup_sessions(app_server, &mut other_backends).await
);
}
}
};
let init = crate::chatwidget::ChatWidgetInit {
local_settings: local_settings.clone(),
config: config.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: app_event_tx.clone(),
workspace_command_runner: Some(workspace_command_runner.clone()),
initial_user_message: crate::chatwidget::create_initial_user_message(
initial_prompt.clone(),
initial_images.clone(),
// CLI prompt args are plain strings, so they don't provide element ranges.
Vec::new(),
)
.filter(|_| resumed.is_some()),
enhanced_keys_supported,
has_chatgpt_account,
requires_openai_auth,
has_codex_backend_auth,
model_catalog: model_catalog.clone(),
feedback: feedback.clone(),
is_first_run,
status_account_display: status_account_display.clone(),
initial_plan_type,
model: config.model.clone(),
startup_tooltip_override: None,
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned
.clone(),
session_telemetry: session_telemetry.clone(),
};
(ChatWidget::new_with_app_event(init), resumed)
}
SessionSelection::Fork(target_session) => {
let explicit_permission_override =
config_persistence::has_explicit_resume_permission_override(
&config,
&harness_overrides,
);
if explicit_permission_override
&& app_server_target.thread_params_mode()
== crate::app_server_session::ThreadParamsMode::Remote
{
return shutdown_on_startup_error(
app_server,
&mut other_backends,
color_eyre::eyre::eyre!(
"Permission overrides are not supported when forking a remote task."
),
)
.await;
}
let permission_mode = if explicit_permission_override {
crate::app_server_session::ForkPermissionMode::OverrideFromCurrentConfig
} else {
crate::app_server_session::ForkPermissionMode::InheritSaved
};
session_telemetry.counter(
"codex.thread.fork",
/*inc*/ 1,
&[("source", "cli_subcommand")],
);
let forked = match startup_draft
.run_until(
tui,
app_server.fork_thread_with_permission_mode(
&local_settings,
config.clone(),
target_session.thread_id,
permission_mode,
),
)
.await
{
Ok(forked) => forked,
Err(err) => {
return shutdown_on_startup_error(app_server, &mut other_backends, err)
.await;
}
};
let action = SessionStartAction::Fork(permission_mode);
let forked = complete_session_start(
&mut app_server,
SessionStartConfig {
config: &config,
local_settings: &local_settings,
},
&target_session,
action,
forked,
async || {
startup_draft.flush_pending_events(tui).await?;
run_unarchive_prompt(tui, target_session.thread_id, action).await
},
)
.await;
let forked = match startup_try!(forked) {
SessionStartOutcome::Started(started) => Some(*started),
SessionStartOutcome::CommandCenter => None,
SessionStartOutcome::Exit => {
return Ok(cancel_startup_sessions(app_server, &mut other_backends).await);
}
};
if let Some(forked) = &forked {
if let Some(worktree) = managed_worktree.as_ref()
&& let Err(err) = worktree.bind(forked.session.thread_id)
{
return shutdown_on_startup_error(app_server, &mut other_backends, err)
.await;
}
if config.model_reasoning_effort.is_none() {
config.model_reasoning_effort = forked.session.reasoning_effort.clone();
}
}
let init = crate::chatwidget::ChatWidgetInit {
local_settings: local_settings.clone(),
config: config.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: app_event_tx.clone(),
workspace_command_runner: Some(workspace_command_runner.clone()),
initial_user_message: crate::chatwidget::create_initial_user_message(
initial_prompt.clone(),
initial_images.clone(),
// CLI prompt args are plain strings, so they don't provide element ranges.
Vec::new(),
)
.filter(|_| forked.is_some()),
enhanced_keys_supported,
has_chatgpt_account,
requires_openai_auth,
has_codex_backend_auth,
model_catalog: model_catalog.clone(),
feedback: feedback.clone(),
is_first_run,
status_account_display: status_account_display.clone(),
initial_plan_type,
model: config.model.clone(),
startup_tooltip_override: None,
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned
.clone(),
session_telemetry: session_telemetry.clone(),
};
(ChatWidget::new_with_app_event(init), forked)
}
};
let startup_session_cancelled = initial_started_thread.is_none()
&& !pending_startup_thread_start
&& !start_in_agents_overview;
if startup_session_cancelled {
start_in_agents_overview = true;
}
chat_widget.note_rendered_width(tui.terminal.last_known_screen_size.width);
if pending_startup_thread_start && !start_in_agents_overview {
chat_widget
.empty_state_animation
.borrow_mut()
.continue_from(&mut startup_draft.blossom.borrow_mut());
}
chat_widget.remote_connection = remote_connection;
chat_widget.snapshot_local_images = app_server_target.uses_remote_workspace();
chat_widget.thread_settings_owner = app_server.thread_params_mode().settings_owner();
chat_widget.pending_cloud_turn_settings = app_server.pending_cloud_turn_settings();
chat_widget.set_local_worktree_operations(!crate::uses_remote_workspace_or_environment(
&app_server_target,
environment_manager.as_ref(),
));
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");
chat_widget.windows_sandbox_host = if app_server_target.uses_remote_workspace() {
WindowsSandboxHost::Remote
} else {
initial_started_thread
.as_ref()
.map_or(WindowsSandboxHost::Unknown, |started| {
started.session.windows_sandbox_host
})
};
// This launch warning belongs to the TUI, independent of picker/trust client replacement.
if let Some(warning) = daemon_startup_warning {
app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::StartupWarningsCell::new(vec![warning]),
)));
}
// Automatic NFS detection is Linux-only; manual migration supports Unix.
#[cfg(target_os = "linux")]
if !app_server_target.uses_remote_workspace() {
tokio::spawn(startup_warnings::warn_about_nfs_migration(
config.sqlite_config().clone(),
app_event_tx.clone(),
));
}
let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone());
let runtime_keymap = RuntimeKeymap::for_session(
&local_settings.tui.keymap,
local_settings.workspace_enabled(&config),
)
.map_err(|err| {
color_eyre::eyre::eyre!(
"Invalid `tui.keymap` configuration: {err}\n\
Fix the config and retry.\n\
See the Codex keymap documentation for supported actions and examples."
)
});
let runtime_keymap = startup_try!(runtime_keymap);
#[cfg(not(debug_assertions))]
let upgrade_version = crate::updates::get_upgrade_version(&config);
let agents_overview =
agents_overview::AgentsOverviewState::new(local_settings.tui.agents_overview_grouping);
let mut app = Self {
provider_writes,
feature_write_lock: Arc::default(),
config_notification_test_generation: 0,
config_notification_test_pending: None,
model_catalog,
session_telemetry: session_telemetry.clone(),
terminal_size_tags: tui.terminal_size_tags.clone(),
app_event_tx,
chat_widget,
workspace_command_runner: Some(workspace_command_runner),
backend_preference_context,
config,
local_settings,
launch_cwd,
runtime_working_directory_override: None,
state_db,
cli_kv_overrides,
harness_overrides,
loader_overrides,
cloud_config_bundle,
runtime_approval_policy_override: None,
runtime_approvals_reviewer_override: None,
runtime_permission_profile_override: None,
pending_server_profiles: HashMap::new(),
file_search,
enhanced_keys_supported,
keymap: runtime_keymap,
key_chord_matcher: KeyChordMatcher::default(),
routed_key_activation: None,
transcript_cells: Vec::new(),
native_history: Default::default(),
turn_tips: Default::default(),
transcript_view: Default::default(),
conversation_frame: Default::default(),
last_rendered_history_tail: None,
last_thread_usage_status_cell: None,
pending_thread_usage_history_refresh: false,
overlay: None,
retained_analytics: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
transcript_reflow: TranscriptReflowState::default(),
initial_history_replay_buffer: None,
pending_thread_switch_resets: 0,
scrollback_has_older_history: false,
commit_animation: None,
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned.clone(),
skill_load_warnings: SkillLoadWarningState::default(),
backtrack: BacktrackState::default(),
backtrack_render_pending: false,
feedback: feedback.clone(),
feedback_audience,
environment_manager,
app_server_target,
app_server_backend_id: Some(app_server.backend_id()),
pending_right_click_paste: None,
right_click_paste_environment: super::right_click_paste::PasteEnvironment::detect(),
reconnect: ReconnectState {
seen_version_notice: initial_server_version_notice
.as_ref()
.map(|(_, key)| key.clone()),
..Default::default()
},
daemon_cli_executable,
pending_update_action: None,
daemon_restart: None,
pending_shutdown_exit_thread_id: None,
windows_sandbox: WindowsSandboxState {
prompt_after_trust: should_prompt_windows_sandbox_nux_at_startup
|| startup_defaults.prompt_windows_sandbox,
..Default::default()
},
thread_event_channels: HashMap::new(),
pending_realtime_speech_replay: HashMap::new(),
pending_realtime_transcript_replay: HashMap::new(),
realtime_replay_order: VecDeque::new(),
background_voice: None,
background_voice_error: None,
temporary_structured_requests: HashMap::new(),
pending_thread_titles: HashMap::new(),
thread_event_listener_tasks: HashMap::new(),
agent_navigation: AgentNavigationState::default(),
backend_registry: Default::default(),
agents_overview,
side_threads: HashMap::new(),
saved_openings: Default::default(),
sidebars: HashMap::new(),
side_companions: HashMap::new(),
zoomed_thread: None,
exact_sidebar_width: None,
divider_drag: None,
pane_resize: None,
passive_link_drag: None,
abandoned_side_threads: HashSet::new(),
active_thread_id: None,
active_thread_rx: None,
primary_thread_id: None,
last_subagent_backfill_attempt: None,
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
pending_app_server_requests: PendingAppServerRequests::default(),
dynamic_tool_status_updates,
dynamic_tool_tasks: HashMap::new(),
pending_startup_thread_start,
pending_server_version_notice: if pending_startup_thread_start {
initial_server_version_notice
.as_ref()
.map(|(notice, _)| notice.clone())
} else {
None
},
pending_open_resume_picker: false,
pending_working_directory_change: None,
pending_start_managed_worktree: None,
pending_managed_worktree_creation: false,
pending_managed_worktree_created: None,
pending_managed_worktree_transition: None,
pending_managed_worktree_attach: None,
startup_protected_input_boundary: true,
startup_pending_protected_request: false,
account_email_request_id: None,
rate_limit_hard_stop_generation: 0,
rate_limit_refresh_state: Default::default(),
pending_mcp_login_start: None,
active_mcp_login_ids: HashMap::new(),
pending_plugin_enabled_writes: HashMap::new(),
pending_hook_enabled_writes: HashMap::new(),
recap: recap::RecapState::default(),
#[cfg(test)]
_test_codex_home: None,
};
if tui.is_owned_screen()
&& app.right_click_paste_environment.wsl
&& app.right_click_paste_environment.allows(
app.local_settings.tui.right_click_paste,
app.local_settings.transcript_mode,
)
{
// Prewarm WSL interop off the UI thread, without accessing clipboard contents.
let _ = tui.clipboard.warm_text_reader(tui.frame_requester());
}
app.remember_launch_permissions();
if !tui.is_terminal_focused() {
app.recap.note_focus_lost(Instant::now());
}
let _ =
app.initialize_server_version_notice(CODEX_CLI_VERSION, app_server.server_version());
if initial_server_version_notice.is_none() {
app.update_server_version_overview_notice(
CODEX_CLI_VERSION,
/*server_version*/ None,
);
}
if let Some(thread_id) = app.current_displayed_thread_id() {
app.backend_registry
.observe(app_server.backend_id(), thread_id);
}
if start_in_agents_overview {
app.open_agents_overview(&app_server);
} else {
app.refresh_agents_overview_threads(&app_server);
}
if let Some(entry) = startup_hooks_browser {
app.chat_widget.open_hooks_browser(entry);
}
app.update_visible_history_rows(tui.terminal.last_known_screen_size);
let initial_session_started_at = Instant::now();
if let Some(started) = initial_started_thread {
let thread_id = started.session.thread_id;
app.chat_widget
.set_task_mentions_enabled(started.task_tools_available);
if started.blocks_direct_input {
app.mark_primary_thread_parent_owned(thread_id);
}
if let Some(error) = provider_error.clone() {
app.chat_widget
.show_provider_error(error, /*rejected_message_id*/ None);
} else if read_only_thread {
app.chat_widget.show_external_writer_thread();
}
match startup_draft
.run_until(
tui,
app.enqueue_primary_thread_session(started.session, started.turns),
)
.await
{
Ok(result) => startup_try!(result),
Err(err) => {
return shutdown_on_startup_error(app_server, &mut other_backends, err).await;
}
}
app.remember_attached_host_project(&app_server);
if let Some(error) = provider_error {
app.chat_widget
.show_provider_error(error, /*rejected_message_id*/ None);
} else if read_only_thread {
app.ensure_thread_channel(thread_id).mark_external_writer();
app.chat_widget.show_external_writer_thread();
}
if let Some(notice) = history_notice {
app.chat_widget
.add_info_message(notice.to_string(), /*hint*/ None);
}
if !read_only_thread
&& should_prompt_for_paused_goal_after_startup_resume
&& let Err(err) = startup_draft
.run_until(
tui,
app.maybe_prompt_resume_paused_goal_after_resume(
&mut app_server,
thread_id,
),
)
.await
{
return shutdown_on_startup_error(app_server, &mut other_backends, err).await;
}
}
if !start_in_agents_overview
&& !pending_startup_thread_start
&& let Some((notice, _)) = &initial_server_version_notice
{
app.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_server_version_warning(notice.clone()),
)));
}
let initial_session_ms = initial_session_started_at.elapsed().as_millis();
if let Err(err) = startup_draft.flush_pending_events(tui).await {
return shutdown_on_startup_error(app_server, &mut other_backends, err).await;
}
if Self::should_handle_active_thread_events(
wait_for_initial_session_configured,
app.active_thread_rx.is_some(),
) && let Err(err) = app.drain_active_thread_events(tui).await
{
return shutdown_on_startup_error(app_server, &mut other_backends, err).await;
}
if app_event_rx.is_empty()
&& !app.has_queued_startup_protected_request()
&& !app.startup_pending_protected_request
&& !app.chat_widget.has_active_view()
&& !app.chat_widget.has_pending_protected_request()
&& let Err(err) = startup_draft.flush_pending_paste_newline(tui).await
{
return shutdown_on_startup_error(app_server, &mut other_backends, err).await;
}
// Keep cancelled resume/fork text editable, but never carry confirmation to another session.
let mut pending_startup_submission =
startup_draft.take_submission_intent() && !startup_session_cancelled;
let mut pending_startup_draft = Some(startup_draft.into_draft());
if app_event_rx.is_empty() && !app.has_queued_startup_protected_request() {
app.chat_widget.restore_startup_input_when_ready(
&mut pending_startup_draft,
&mut pending_startup_submission,
);
}
#[cfg(windows)]
let mut terminal_color_probe_pending = true;
#[cfg(windows)]
if app.ready_for_terminal_color_probe(!app_event_rx.is_empty()) {
tui.probe_default_colors_after_protected_startup();
terminal_color_probe_pending = false;
}
let event_stream_started_at = Instant::now();
tui.schedule_screen_size_recheck(Duration::ZERO);
if let Err(err) = app.render_startup_frame(tui, &app_event_rx) {
return shutdown_on_startup_error(app_server, &mut other_backends, err).await;
}
let tui_events = tui.event_stream();
tokio::pin!(tui_events);
tracing::info!(
duration_ms = %(startup_elapsed_before_app + startup_started_at.elapsed()).as_millis(),
bootstrap_ms = %bootstrap_ms,
thread_and_widget_ms = %thread_and_widget_ms,
initial_session_ms = %initial_session_ms,
event_stream_ms = %event_stream_started_at.elapsed().as_millis(),
"tui startup initial frame scheduled"
);
app.refresh_startup_skills(&app_server);
// Kick off a non-blocking rate-limit prefetch so the first `/status`
// already has data and available reset credits can be surfaced, without
// delaying the initial frame render.
if requires_openai_auth && has_chatgpt_account {
crate::security_setup::prefetch(
&app.config,
&app_server,
app.app_event_tx.clone(),
app.chat_widget.security_setup_request_id,
);
let reset_hint_request_id = app.chat_widget.start_rate_limit_reset_startup_check();
app.refresh_rate_limits(
&app_server,
RateLimitRefreshOrigin::StartupPrefetch {
reset_hint_request_id,
},
);
}
let mut last_screen_size = tui.terminal.last_known_screen_size;
let mut settled_size_tags = crate::terminal_size_telemetry::tags(last_screen_size);
let mut pending_resize: Option<(ratatui::layout::Size, Instant)> = None;
let mut reconnect = None;
let mut waiting_for_initial_session_configured = wait_for_initial_session_configured;
let mut waiting_for_initial_session_header = true;
#[cfg(not(debug_assertions))]
let pre_loop_exit_reason = if let Some(latest_version) = upgrade_version {
let control = Box::pin(app.handle_event(
tui,
&mut app_server,
AppEvent::InsertHistoryCell(Box::new(UpdateAvailableHistoryCell::new(
latest_version,
crate::update_action::get_update_action(),
))),
))
.await;
match startup_try!(control) {
AppRunControl::Continue => None,
AppRunControl::Exit(exit_reason) => Some(exit_reason),
}
} else {
None
};
#[cfg(debug_assertions)]
let pre_loop_exit_reason: Option<ExitReason> = None;
let mut backends =
super::backend_sessions::BackendSessions::from_initial(app_server, other_backends);
if matches!(
app.app_server_target,
AppServerTarget::Embedded | AppServerTarget::LocalDaemon { .. }
) {
backends.set_preference_key(backends.displayed_owner(), "local");
}
for (&owner, settings) in &backend_settings {
if settings.target == AppServerTarget::Cloud {
backends.set_preference_key(owner, "cloud");
}
}
backends.settings = backend_settings;
let mut parked_side_completions = HashMap::<_, Vec<AppEvent>>::new();
let exit_reason_result = if let Some(exit_reason) = pre_loop_exit_reason {
Ok(exit_reason)
} else {
loop {
let displayed_session = backends.displayed;
let displayed_owner = backends.displayed_owner();
if let Some(events) = parked_side_completions.remove(&displayed_owner) {
for event in events {
app.app_event_tx.send(event);
}
}
backends.replay_ready_side_startup(&app);
let multi_backend = app.config.features.enabled(Feature::TuiMultiBackend);
let next_backend = if multi_backend && app.can_cycle_conversation_backend() {
backends.next_listening_backend(displayed_owner)
} else {
None
};
let current_label =
next_backend.and_then(|_| backends.displayed_name(&app.app_server_target));
app.chat_widget.set_new_backend_control(current_label);
if !backends.is_listening(displayed_owner)
&& !app.pending_startup_thread_start
&& !app.reconnect.offline
{
app.begin_reconnect();
}
// Reconnect can dismiss an overlay from the server-event path.
if app.overlay.is_none() {
if !tui.is_owned_screen() && tui.is_alt_screen_active() {
app.close_transcript_overlay(tui);
} else if let Err(err) =
tui.set_mouse_capture(app.chat_widget.mouse_capture(tui.is_owned_screen()))
{
break Err(err.into());
}
}
if app.pending_open_resume_picker {
app.pending_open_resume_picker = false;
match Box::pin(app.open_resume_picker_for_backends(tui, &mut backends)).await {
Ok(AppRunControl::Continue) => {}
Ok(AppRunControl::Exit(reason)) => break Ok(reason),
Err(err) if app.recover_transport_error(&err) => {}
Err(err) => break Err(err),
}
continue;
}
if let Some(pending) = app.pending_working_directory_change.take() {
Box::pin(app.finish_working_directory_change(
tui,
backends.session_at_mut(displayed_session),
pending,
))
.await;
continue;
}
if let Some((mode, name)) = app.pending_start_managed_worktree.take() {
Box::pin(app.start_managed_worktree(
backends.session_at_mut(displayed_session),
mode,
name,
))
.await;
continue;
}
// Complete the fork and widget attachment on separate fresh loop iterations.
if let Some(attach) = app.pending_managed_worktree_attach.take() {
Box::pin(app.attach_working_directory(
tui,
backends.session_at_mut(displayed_session),
*attach,
))
.await;
continue;
}
if let Some(transition) = app.pending_managed_worktree_transition.take() {
if let Err(error) = Box::pin(app.switch_to_managed_worktree(
tui,
backends.session_at_mut(displayed_session),
*transition,
))
.await
{
app.chat_widget.add_error_message(error.to_string());
}
continue;
}
if let Some(created) = app.pending_managed_worktree_created.take() {
Box::pin(app.finish_managed_worktree(*created)).await;
continue;
}
if app.reconnect.offline && !app.reconnect.failed && reconnect.is_none() {
let presentation = app.reconnect.presentation;
let wait_for_drain =
app.pending_startup_thread_start && backends.is_listening(displayed_owner);
let attempt = reconnect::reconnect(
displayed_owner,
app.app_server_target.clone(),
app.config.clone(),
app.local_settings.clone(),
app.current_displayed_thread_id(),
backends
.session_at(displayed_session)
.remote_cwd_override()
.map(Path::to_path_buf),
backends
.session_at(displayed_session)
.thread_tool_transport(),
presentation,
);
let future = Box::pin(async move {
if let reconnect::ReconnectPresentation::Startup(deadline) = presentation
&& wait_for_drain
{
tokio::time::sleep_until(deadline).await;
color_eyre::eyre::bail!(
"Automatic reconnect timed out waiting for the server to finish shutting down."
);
}
attempt.await
});
reconnect = Some((displayed_owner, future));
}
// Replay queues history and operations. A buffered closure must not switch
// widgets before those app events have been applied.
let has_pending_app_events = !app_event_rx.is_empty();
let paused_backend = ((app.reconnect.offline && !app.pending_startup_thread_start)
|| (app.awaiting_side_replay(tui) && has_pending_app_events)
|| (!matches!(app.app_server_target, AppServerTarget::Embedded)
&& has_pending_app_events))
.then_some(displayed_owner);
let initial_session_header_pending = waiting_for_initial_session_header
&& app.primary_session_configured.is_some()
&& has_pending_app_events;
let block_terminal_input_for_pending_startup_events =
(!matches!(app.app_server_target, AppServerTarget::Embedded)
&& has_pending_app_events)
|| initial_session_header_pending
|| (pending_startup_draft.is_some()
|| app.startup_protected_input_boundary)
&& has_pending_app_events
|| (!waiting_for_initial_session_configured
&& app.has_queued_startup_protected_request());
// The screen cache is physical geometry, not the inline viewport. This also
// notices the final size after returning from a standalone nested screen.
let size = tui.terminal.last_known_screen_size;
if !crate::terminal_size_telemetry::enabled(&app.config)
|| app.terminal_size_tags.is_empty()
{
pending_resize = None;
settled_size_tags = crate::terminal_size_telemetry::tags(size);
} else if size != last_screen_size {
pending_resize =
Some((size, Instant::now() + Duration::from_secs(/*secs*/ 15)));
}
last_screen_size = size;
let rate_limit_poll_deadline = app
.chat_widget
.rate_limit_refresh_interval()
.and_then(|interval| app.rate_limit_refresh_state.poll_deadline(interval));
let control = select! {
result = async {
match app.daemon_restart.as_mut() {
Some(task) => task.await,
None => std::future::pending().await,
}
}, if app.daemon_restart.is_some() => {
app.daemon_restart = None;
if let Err(err) = result.unwrap_or_else(|err| Err(err.into())) {
app.chat_widget.add_error_message(format!("Could not restart the background server: {err:#}"));
}
AppRunControl::Continue
}
Some(event) = app.saved_openings.next_event(&mut app_event_rx) => {
let switch_backend = match &event {
AppEvent::ChooseNewSessionCheckout { name } | AppEvent::NewSession { name }
if multi_backend => app.other_new_session_backend(&backends).await
.map(|owner| (owner, NewBackendSession::Fresh { name: name.clone() })),
AppEvent::CycleNewConversationBackend => {
let Some(owner) = next_backend else { continue };
Some((owner, NewBackendSession::CarryDraft))
}
_ => None,
};
if let Some((owner, session)) = switch_backend {
if let Err(message) = Box::pin(app.start_new_session_on_other_backend(tui, &mut backends, owner, session)).await {
app.chat_widget.add_error_message(message);
}
continue;
}
if let AppEvent::ResumeBackendSession(target) = event {
match app.resume_backend_session(tui, &mut backends, target).await {
Ok(AppRunControl::Continue) => continue,
Ok(AppRunControl::Exit(reason)) => break Ok(reason),
Err(error) => break Err(error),
}
}
let is_initial_session_header = matches!(
&event,
AppEvent::InsertHistoryCell(cell)
if cell.as_any().is::<history_cell::SessionInfoCell>()
);
let event_session = match &event {
AppEvent::SideCreated { backend_id, .. } if *backend_id != displayed_owner => {
parked_side_completions.entry(*backend_id).or_default().push(event);
continue;
}
AppEvent::SavedSideChatsLoaded { backend_id, .. }
| AppEvent::StartupThreadStarted { backend_id, .. } => {
backends.slot(*backend_id).unwrap_or(displayed_session)
}
_ => displayed_session,
};
// A modal deferred behind a snapshot reset still needs its first frame
// and protected-input drain when replay completes.
let had_visible_modal = app.chat_widget.has_active_modal()
&& !app.awaiting_side_replay(tui);
match Box::pin(app.handle_event(tui, backends.session_at_mut(event_session), event)).await {
Ok(AppRunControl::Continue) => {
if is_initial_session_header {
waiting_for_initial_session_header = false;
}
if !had_visible_modal
&& app.chat_widget.has_active_modal()
&& let Err(err) = app.render_startup_frame(tui, &app_event_rx)
{
break Err(err);
}
AppRunControl::Continue
}
Ok(AppRunControl::Exit(reason)) => AppRunControl::Exit(reason),
Err(err) if app.recover_transport_error(&err) => AppRunControl::Continue,
Err(err) => break Err(err),
}
}
active = async {
if let Some(rx) = app.active_thread_rx.as_mut() {
rx.recv().await
} else {
None
}
}, if App::should_handle_active_thread_events(
waiting_for_initial_session_configured,
app.active_thread_rx.is_some()
) && !has_pending_app_events && !app.reconnect.offline => {
if let Some(event) = active {
if let Err(err) = app.handle_active_thread_event(tui, backends.session_at_mut(displayed_session), event).await {
break Err(err);
}
} else {
app.clear_active_thread().await;
}
AppRunControl::Continue
}
event = tui_events.next(), if app.can_read_terminal_after_thread_switch(tui, &app_event_rx)
&& (app.reconnect.offline || !block_terminal_input_for_pending_startup_events) => {
if let Some(event) = event {
if (matches!(
&event,
TuiEvent::Key(key)
if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat)
) || matches!(&event, TuiEvent::Paste(_)))
&& !app.reconnect.offline
&& pending_startup_draft.is_none()
&& !waiting_for_initial_session_configured
&& app_event_rx.is_empty()
&& !app.startup_pending_protected_request
&& app
.active_thread_rx
.as_ref()
.is_none_or(tokio::sync::mpsc::Receiver::is_empty)
&& !app.pending_primary_events.iter().any(|event| {
matches!(event, ThreadBufferedEvent::Request(_))
})
{
app.startup_protected_input_boundary = false;
}
match app.handle_tui_event(tui, backends.session_at_mut(displayed_session), event).await {
Ok(control) => control,
Err(err) if app.recover_transport_error(&err) => AppRunControl::Continue,
Err(err) => break Err(err),
}
} else {
tracing::warn!("terminal input stream closed; shutting down active thread");
app.handle_exit_mode(backends.session_at_mut(displayed_session), ExitMode::ShutdownFirst).await
}
}
(owner, app_server_event) = backends.next_event(paused_backend) => {
match app_server_event {
Some(event) if app.defer_side_startup_notification(&event, displayed_owner) => {
backends.defer_side_startup(event);
}
Some(event) if owner != displayed_owner || !app.reconnect.offline => {
if let Some(slot) = backends.slot(owner) {
let (session, parked_inputs) = backends.session_and_parked_inputs(slot);
app.handle_polled_backend_event(session, displayed_owner, parked_inputs, event).await;
}
}
Some(_) => {}
None => {
app.agents_overview
.invalidate_discovery(owner, owner == displayed_owner);
if owner == displayed_owner {
if app.reconnect.offline && app.pending_startup_thread_start {
reconnect = None;
} else if !app.pending_startup_thread_start {
app.begin_reconnect();
}
}
tracing::warn!("app-server event stream closed");
}
}
AppRunControl::Continue
}
(reconnect_owner, result) = async { match reconnect.as_mut() { Some((owner, future)) => (*owner, future.await), None => std::future::pending().await } }, if reconnect.is_some() && !has_pending_app_events => {
reconnect = None;
match result {
Ok(connected) if reconnect_owner != displayed_owner => {
let _ = connected.session.shutdown().await;
}
Err(_) if reconnect_owner != displayed_owner => {}
Ok(connected) => {
if let Err(err) = app.finish_reconnect(tui, backends.session_at_mut(displayed_session), &mut app_event_rx, connected, CODEX_CLI_VERSION).await {
break Err(err);
}
backends.resume_events(displayed_owner);
waiting_for_initial_session_configured = app.pending_startup_thread_start;
if app.pending_startup_thread_start {
spawn_startup_thread_start(
backends.session_at(displayed_session),
app.local_settings.clone(),
app.config.clone(),
launch_choices.clone(),
app.app_event_tx.clone(),
managed_worktree.clone(),
);
}
}
Err(error) => {
if app.pending_startup_thread_start {
app.pending_startup_thread_start = false;
app.chat_widget.cancel_startup_submission();
app.chat_widget
.set_initial_user_message_submit_suppressed(
/*suppressed*/ true,
);
if let Some(message) =
app.chat_widget.initial_user_message.take()
{
app.chat_widget.restore_user_message_to_composer(message);
}
}
app.reconnect.failed = true;
app.chat_widget.reconnect_failed();
app.chat_widget.add_error_message(error.to_string());
if let Ok(mut state) = app.agents_overview.view_state.lock() {
state.connection_notice = Some("Reconnect failed — agent list is stale; relaunch to retry");
}
}
}
AppRunControl::Continue
}
() = async {
match pending_resize {
Some((_, deadline)) => tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await,
None => std::future::pending().await,
}
} => {
if let Some((expected, _)) = pending_resize.take()
&& crate::terminal_size_telemetry::enabled(&app.config)
{
let actual = crate::terminal_size_telemetry::read_size();
if actual != expected {
pending_resize = Some((actual, Instant::now() + Duration::from_secs(/*secs*/ 15)));
} else {
let tags = crate::terminal_size_telemetry::tags(actual);
if tags != settled_size_tags {
crate::terminal_size_telemetry::record(&app.terminal_size_tags, &app.config, "resize", actual);
}
settled_size_tags = tags;
}
}
AppRunControl::Continue
}
() = async {
match rate_limit_poll_deadline {
Some(deadline) => {
tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await;
}
None => std::future::pending().await,
}
}, if backends.is_listening(displayed_owner) => {
app.refresh_rate_limits(backends.session_at(displayed_session), RateLimitRefreshOrigin::Periodic);
AppRunControl::Continue
}
() = async {
match app.chat_widget.terminal_title_next_refresh {
Some(deadline) => {
tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await;
}
None => std::future::pending().await,
}
} => {
app.chat_widget.refresh_goal_status_indicator_for_time_tick();
app.chat_widget.refresh_terminal_title();
AppRunControl::Continue
}
() = async {
match app.commit_animation.as_mut() {
Some(interval) => {
interval.tick().await;
}
None => std::future::pending().await,
}
}, if !has_pending_app_events => {
crate::session_log::log_commit_tick();
app.chat_widget.on_commit_tick();
AppRunControl::Continue
}
};
if App::should_stop_waiting_for_initial_session(
waiting_for_initial_session_configured,
app.primary_thread_id,
) {
waiting_for_initial_session_configured = false;
let had_active_modal = app.chat_widget.has_active_modal();
if let Err(err) = app.drain_active_thread_events(tui).await {
break Err(err);
}
if !had_active_modal
&& app.chat_widget.has_active_modal()
&& let Err(err) = app.render_startup_frame(tui, &app_event_rx)
{
break Err(err);
}
}
match control {
AppRunControl::Continue => {
if app.reconnect.offline && !app.pending_startup_thread_start {
pending_startup_submission = false;
app.chat_widget.cancel_startup_submission();
}
if app_event_rx.is_empty() && !app.has_queued_startup_protected_request() {
app.chat_widget.restore_startup_input_when_ready(
&mut pending_startup_draft,
&mut pending_startup_submission,
);
}
#[cfg(windows)]
if terminal_color_probe_pending
&& app.ready_for_terminal_color_probe(!app_event_rx.is_empty())
{
tui.probe_default_colors_after_protected_startup();
terminal_color_probe_pending = false;
}
}
AppRunControl::Exit(reason) => break Ok(reason),
}
}
};
// Let the pending terminal poll unwind while the app server shuts down.
tui.pause_events();
app.chat_widget.mention_usage_mut().flush().await;
if let Err(err) = backends.shutdown().await {
tracing::warn!(error = %err, "failed to shut down embedded app server");
}
let clear_pet_result = tui.clear_ambient_pet_image();
let clear_images = tui.terminal.clear_frame_images(&mut tui.retained_images);
let clear_result = clear_images.and(tui.terminal.clear());
if let Some(task) = app.daemon_restart.take()
&& let Err(err) = task.await.unwrap_or_else(|err| Err(err.into()))
{
tracing::warn!(error = %err, "failed to finish background server restart during exit");
}
// Keep the alternate screen active until the outer guard restores both keyboard stacks.
let exit_reason = match exit_reason_result {
Ok(exit_reason) => {
clear_pet_result?;
clear_result?;
exit_reason
}
Err(err) => {
if let Err(clear_pet_err) = clear_pet_result {
tracing::warn!(error = %clear_pet_err, "failed to clear ambient pet image");
}
if let Err(clear_err) = clear_result {
tracing::warn!(error = %clear_err, "failed to clear terminal UI");
}
return Err(err);
}
};
Ok(app.exit_info(exit_reason))
}
}