//! Top-level TUI application state and runtime wiring.//!//! This module owns the `App` struct, shared imports, and the high-level run loop that coordinates//! the focused app submodules.use crate::AppServerTarget;use crate::app_backtrack::BacktrackState;use crate::app_command::AppCommand;use crate::app_event::AppEvent;use crate::app_event::ExitMode;use crate::app_event::FeedbackCategory;use crate::app_event::HistoryLookupResponse;use crate::app_event::PermissionProfileSelection;use crate::app_event::PluginLocation;use crate::app_event::PluginRemoteSectionError;use crate::app_event::RateLimitRefreshOrigin;#[cfg(target_os = "windows")]use crate::app_event::WindowsSandboxEnableMode;use crate::app_event_sender::AppEventSender;use crate::app_server_session::AppServerBootstrap;use crate::app_server_session::AppServerSession;use crate::app_server_session::AppServerStartedThread;use crate::app_server_session::TurnPermissionsOverride;use crate::app_server_session::app_server_rate_limit_snapshots;use crate::bottom_pane::AppLinkViewParams;use crate::bottom_pane::ApprovalRequest;use crate::bottom_pane::FeedbackAudience;use crate::bottom_pane::McpServerElicitationFormRequest;use crate::bottom_pane::SelectionItem;use crate::bottom_pane::SelectionViewParams;use crate::bottom_pane::popup_consts::standard_popup_hint_line;use crate::chatwidget::ChatWidget;use crate::chatwidget::ExternalEditorState;use crate::chatwidget::ReplayKind;use crate::chatwidget::ThreadInputState;use crate::cwd_prompt::CwdPromptAction;use crate::diff_render::DiffSummary;use crate::exec_command::split_command_string;use crate::exec_command::strip_bash_lc_and_escape;use crate::external_editor;use crate::file_search::FileSearchManager;use crate::history_cell;use crate::history_cell::HistoryCell;#[cfg(not(debug_assertions))]use crate::history_cell::UpdateAvailableHistoryCell;use crate::hooks_rpc::HookTrustUpdate;use crate::key_hint::KeyBindingListExt;use crate::keymap::RuntimeKeymap;use crate::legacy_core::config::Config;use crate::legacy_core::config::ConfigBuilder;use crate::legacy_core::config::ConfigOverrides;use crate::legacy_core::config::PermissionProfileSnapshot;use crate::legacy_core::config::edit::ConfigEditsBuilder;use crate::model_catalog::ModelCatalog;use crate::model_migration::ModelMigrationOutcome;use crate::model_migration::migration_copy_for_models;use crate::model_migration::run_model_migration_prompt;use crate::multi_agents::agent_picker_status_dot_spans;use crate::multi_agents::format_agent_picker_item_name;use crate::multi_agents::next_agent_shortcut_matches;use crate::multi_agents::previous_agent_shortcut_matches;use crate::multi_agents::sub_agent_activity_display;use crate::pager_overlay::Overlay;use crate::render::highlight::highlight_bash_to_lines;use crate::render::renderable::Renderable;use crate::resume_picker::SessionSelection;use crate::resume_picker::SessionTarget;use crate::session_state::ThreadSessionState;#[cfg(test)]use crate::test_support::PathBufExt;#[cfg(test)]use crate::test_support::test_path_buf;#[cfg(test)]use crate::test_support::test_path_display;use crate::token_usage::TokenUsage;use crate::transcript_reflow::TranscriptReflowState;use crate::tui;use crate::tui::TuiEvent;use crate::update_action::UpdateAction;use crate::version::CODEX_CLI_VERSION;use crate::workspace_command::AppServerWorkspaceCommandRunner;use crate::workspace_command::WorkspaceCommandRunner;use codex_ansi_escape::ansi_escape_line;use codex_app_server_client::AppServerRequestHandle;use codex_app_server_client::TypedRequestError;use codex_app_server_protocol::AddCreditsNudgeCreditType;use codex_app_server_protocol::AskForApproval;use codex_app_server_protocol::ClientRequest;use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo;use codex_app_server_protocol::ConfigBatchWriteParams;use codex_app_server_protocol::ConfigLayerSource;use codex_app_server_protocol::ConfigReadResponse;use codex_app_server_protocol::ConfigValueWriteParams;use codex_app_server_protocol::ConfigWriteResponse;use codex_app_server_protocol::FeedbackUploadParams;use codex_app_server_protocol::FeedbackUploadResponse;use codex_app_server_protocol::GetAccountRateLimitsResponse;use codex_app_server_protocol::HooksListEntry;use codex_app_server_protocol::ListMcpServerStatusParams;use codex_app_server_protocol::ListMcpServerStatusResponse;#[cfg(test)]use codex_app_server_protocol::McpAuthStatus;use codex_app_server_protocol::McpServerStatus;use codex_app_server_protocol::McpServerStatusDetail;use codex_app_server_protocol::MergeStrategy;use codex_app_server_protocol::PluginInstallParams;use codex_app_server_protocol::PluginInstallResponse;use codex_app_server_protocol::PluginListMarketplaceKind;use codex_app_server_protocol::PluginListParams;use codex_app_server_protocol::PluginListResponse;use codex_app_server_protocol::PluginMarketplaceEntry;use codex_app_server_protocol::PluginReadParams;use codex_app_server_protocol::PluginReadResponse;use codex_app_server_protocol::PluginUninstallParams;use codex_app_server_protocol::PluginUninstallResponse;use codex_app_server_protocol::SandboxMode as AppServerSandboxMode;use codex_app_server_protocol::SendAddCreditsNudgeEmailParams;use codex_app_server_protocol::ServerNotification;use codex_app_server_protocol::ServerRequest;use codex_app_server_protocol::SkillErrorInfo;use codex_app_server_protocol::SkillsListParams;use codex_app_server_protocol::SkillsListResponse;use codex_app_server_protocol::ThreadItem;use codex_app_server_protocol::ThreadLoadedListParams;use codex_app_server_protocol::ThreadMemoryMode;use codex_app_server_protocol::ThreadRollbackResponse;use codex_app_server_protocol::ThreadStartSource;use codex_app_server_protocol::Turn;use codex_app_server_protocol::TurnError as AppServerTurnError;use codex_app_server_protocol::TurnStatus;use codex_app_server_protocol::WriteStatus;use codex_config::CloudConfigBundleLoader;use codex_config::ConfigLayerStackOrdering;use codex_config::LoaderOverrides;use codex_config::types::ApprovalsReviewer;use codex_config::types::MemoriesToml;use codex_config::types::ModelAvailabilityNuxConfig;#[cfg(target_os = "windows")]use codex_config::types::WindowsToml;use codex_exec_server::EnvironmentManager;use codex_features::Feature;use codex_features::FeaturesToml;use codex_model_provider::create_model_provider;use codex_model_provider_info::ModelProviderInfo;use codex_models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG;use codex_models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG;use codex_otel::SessionTelemetry;use codex_otel::TelemetryAuthMode;use codex_protocol::ThreadId;use codex_protocol::config_types::Personality;#[cfg(target_os = "windows")]use codex_protocol::config_types::WindowsSandboxLevel;use codex_protocol::models::ActivePermissionProfile;use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;use codex_protocol::models::PermissionProfile;use codex_protocol::openai_models::ModelAvailabilityNux;use codex_protocol::openai_models::ModelPreset;use codex_protocol::openai_models::ModelUpgrade;use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;#[cfg(target_os = "windows")]use codex_protocol::permissions::FileSystemSandboxKind;use codex_rollout::StateDbHandle;use codex_terminal_detection::user_agent;use codex_utils_absolute_path::AbsolutePathBuf;use codex_utils_approval_presets::builtin_permission_profile_for_active_permission_profile;use color_eyre::eyre::Result;use color_eyre::eyre::WrapErr;use crossterm::event::KeyCode;use crossterm::event::KeyEvent;use crossterm::event::KeyEventKind;use crossterm::event::KeyModifiers;use ratatui::backend::Backend;use ratatui::layout::Rect;use ratatui::style::Stylize;use ratatui::text::Line;use ratatui::widgets::Paragraph;use ratatui::widgets::Wrap;use std::collections::BTreeMap;use std::collections::HashMap;use std::collections::VecDeque;use std::io::Write;use std::path::Path;use std::path::PathBuf;use std::sync::Arc;use std::sync::atomic::AtomicBool;use std::sync::atomic::Ordering;use std::thread;use std::time::Duration;use std::time::Instant;use tokio::select;use tokio::sync::Mutex;use tokio::sync::mpsc;use tokio::sync::mpsc::error::TryRecvError;use tokio::sync::mpsc::error::TrySendError;use tokio::sync::mpsc::unbounded_channel;use tokio::task::JoinHandle;use toml::Value as TomlValue;use uuid::Uuid;mod agent_message_consolidation;mod agent_navigation;mod agent_status_feed;mod app_server_event_targets;mod app_server_events;pub(crate) mod app_server_requests;mod background_requests;mod config_persistence;mod event_dispatch;mod history_ui;mod input;mod loaded_threads;mod pending_interactive_replay;mod pets;mod platform_actions;mod plugin_mentions;mod replay_filter;mod resize_reflow;mod session_lifecycle;mod side;mod startup_prompts;mod thread_events;mod thread_goal_actions;mod thread_routing;mod thread_session_state;mod thread_settings;use self::agent_navigation::AgentNavigationDirection;use self::agent_navigation::AgentNavigationState;use self::app_server_requests::PendingAppServerRequests;use self::loaded_threads::find_loaded_subagent_threads_for_primary;use self::pending_interactive_replay::PendingInteractiveReplayState;use self::platform_actions::*;use self::side::SideParentStatus;use self::side::SideParentStatusChange;use self::side::SideThreadState;use self::startup_prompts::*;use self::thread_events::*;const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue.";const THREAD_EVENT_CHANNEL_CAPACITY: usize = 32768;enum ThreadInteractiveRequest { AppLink(AppLinkViewParams), Approval(ApprovalRequest), McpServerElicitation(McpServerElicitationFormRequest),}/// Extracts `receiver_thread_ids` from collab agent tool-call notifications.////// Only `ItemStarted` and `ItemCompleted` notifications with a `CollabAgentToolCall` item carry/// receiver thread ids. All other notification variants return `None`.fn collab_receiver_thread_ids(notification: &ServerNotification) -> Option<&[String]> { match notification { ServerNotification::ItemStarted(notification) => match ¬ification.item { ThreadItem::CollabAgentToolCall { receiver_thread_ids, .. } => Some(receiver_thread_ids), _ => None, }, ServerNotification::ItemCompleted(notification) => match ¬ification.item { ThreadItem::CollabAgentToolCall { receiver_thread_ids, .. } => Some(receiver_thread_ids), _ => None, }, _ => None, }}fn sub_agent_activity_item(notification: &ServerNotification) -> Option<&ThreadItem> { match notification { ServerNotification::ItemStarted(notification) => match ¬ification.item { ThreadItem::SubAgentActivity { .. } => Some(¬ification.item), _ => None, }, ServerNotification::ItemCompleted(notification) => match ¬ification.item { ThreadItem::SubAgentActivity { .. } => Some(¬ification.item), _ => None, }, _ => None, }}fn collab_receiver_is_not_found( notification: &ServerNotification, receiver_thread_id: &str,) -> bool { match notification { ServerNotification::ItemCompleted(notification) => match ¬ification.item { ThreadItem::CollabAgentToolCall { agents_states, .. } => { agents_states.get(receiver_thread_id).is_some_and(|state| { matches!( &state.status, codex_app_server_protocol::CollabAgentStatus::NotFound ) }) } _ => false, }, _ => false, }}fn default_exec_approval_decisions( network_approval_context: Option<&codex_app_server_protocol::NetworkApprovalContext>, proposed_execpolicy_amendment: Option<&codex_app_server_protocol::ExecPolicyAmendment>, proposed_network_policy_amendments: Option< &[codex_app_server_protocol::NetworkPolicyAmendment], >, additional_permissions: Option<&codex_app_server_protocol::AdditionalPermissionProfile>,) -> Vec<codex_app_server_protocol::CommandExecutionApprovalDecision> { use codex_app_server_protocol::CommandExecutionApprovalDecision; use codex_app_server_protocol::NetworkPolicyRuleAction; if network_approval_context.is_some() { let mut decisions = vec![ CommandExecutionApprovalDecision::Accept, CommandExecutionApprovalDecision::AcceptForSession, ]; if let Some(amendment) = proposed_network_policy_amendments.and_then(|amendments| { amendments .iter() .find(|amendment| amendment.action == NetworkPolicyRuleAction::Allow) }) { decisions.push( CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment { network_policy_amendment: amendment.clone(), }, ); } decisions.push(CommandExecutionApprovalDecision::Cancel); return decisions; } if additional_permissions.is_some() { return vec![ CommandExecutionApprovalDecision::Accept, CommandExecutionApprovalDecision::Cancel, ]; } let mut decisions = vec![CommandExecutionApprovalDecision::Accept]; if let Some(execpolicy_amendment) = proposed_execpolicy_amendment { decisions.push( CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment { execpolicy_amendment: execpolicy_amendment.clone(), }, ); } decisions.push(CommandExecutionApprovalDecision::Cancel); decisions}#[derive(Clone, Debug, PartialEq, Eq)]struct AutoReviewMode { approval_policy: AskForApproval, approvals_reviewer: ApprovalsReviewer, active_permission_profile: ActivePermissionProfile,}/// Enabling the Auto-review experiment in the TUI should also switch the/// current `/permissions` settings to the matching Auto-review mode. Users/// can still change `/permissions` afterward; this just assumes that opting into/// the experiment means they want Auto-review enabled immediately.fn auto_review_mode() -> AutoReviewMode { AutoReviewMode { approval_policy: AskForApproval::OnRequest, approvals_reviewer: ApprovalsReviewer::AutoReview, active_permission_profile: ActivePermissionProfile::new( BUILT_IN_PERMISSION_PROFILE_WORKSPACE, ), }}#[cfg(test)]impl AutoReviewMode { fn permission_profile(&self) -> PermissionProfile { builtin_permission_profile_for_active_permission_profile(&self.active_permission_profile) .expect("auto-review mode should use a built-in permission profile") }}#[cfg(target_os = "windows")]fn managed_filesystem_sandbox_is_restricted(permission_profile: &PermissionProfile) -> bool { matches!( permission_profile.file_system_sandbox_policy().kind, FileSystemSandboxKind::Restricted )}/// Baseline cadence for periodic stream commit animation ticks.////// Smooth-mode streaming drains one line per tick, so this interval controls/// perceived typing speed for non-backlogged output.const COMMIT_ANIMATION_TICK: Duration = tui::TARGET_FRAME_INTERVAL;#[derive(Debug, Clone)]pub struct AppExitInfo { pub token_usage: TokenUsage, pub thread_id: Option<ThreadId>, pub resume_hint: Option<String>, pub update_action: Option<UpdateAction>, pub exit_reason: ExitReason,}impl AppExitInfo { pub fn fatal(message: impl Into<String>) -> Self { Self { token_usage: TokenUsage::default(), thread_id: None, resume_hint: None, update_action: None, exit_reason: ExitReason::Fatal(message.into()), } }}#[derive(Debug)]pub(crate) enum AppRunControl { Continue, Exit(ExitReason),}#[derive(Debug, Clone)]pub enum ExitReason { UserRequested, Fatal(String),}fn session_summary( token_usage: TokenUsage, thread_id: Option<ThreadId>, thread_name: Option<String>, rollout_path: Option<&Path>,) -> Option<SessionSummary> { let usage_line = (!token_usage.is_zero()).then(|| token_usage.to_string()); let resume_hint = resume_hint_for_resumable_thread(thread_id, thread_name, rollout_path); if usage_line.is_none() && resume_hint.is_none() { return None; } Some(SessionSummary { usage_line, resume_hint, })}#[derive(Debug, Clone, PartialEq, Eq)]struct ResumableThread { thread_id: ThreadId, thread_name: Option<String>,}fn resumable_thread( thread_id: Option<ThreadId>, thread_name: Option<String>, rollout_path: Option<&Path>,) -> Option<ResumableThread> { let thread_id = thread_id?; let rollout_path = rollout_path?; rollout_path_is_resumable(rollout_path).then_some(ResumableThread { thread_id, thread_name, })}fn resume_hint_for_resumable_thread( thread_id: Option<ThreadId>, thread_name: Option<String>, rollout_path: Option<&Path>,) -> Option<String> { let thread = resumable_thread(thread_id, thread_name, rollout_path)?; codex_utils_cli::resume_hint(thread.thread_name.as_deref(), Some(thread.thread_id))}fn rollout_path_is_resumable(rollout_path: &Path) -> bool { std::fs::metadata(rollout_path).is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0)}fn errors_for_cwd(cwd: &Path, response: &SkillsListResponse) -> Vec<SkillErrorInfo> { response .data .iter() .find(|entry| entry.cwd.as_path() == cwd) .map(|entry| entry.errors.clone()) .unwrap_or_default()}#[derive(Debug, Clone, PartialEq, Eq)]struct SessionSummary { usage_line: Option<String>, resume_hint: Option<String>,}#[derive(Debug, Default)]struct InitialHistoryReplayBuffer { retained_lines: VecDeque<crate::terminal_hyperlinks::HyperlinkLine>, render_from_transcript_tail: bool,}pub(crate) struct App { model_catalog: Arc<ModelCatalog>, pub(crate) session_telemetry: SessionTelemetry, pub(crate) app_event_tx: AppEventSender, pub(crate) chat_widget: ChatWidget, workspace_command_runner: Option<WorkspaceCommandRunner>, /// Config is stored here so we can recreate ChatWidgets as needed. pub(crate) config: Config, pub(crate) state_db: Option<StateDbHandle>, cli_kv_overrides: Vec<(String, TomlValue)>, harness_overrides: ConfigOverrides, loader_overrides: LoaderOverrides, cloud_config_bundle: CloudConfigBundleLoader, runtime_approval_policy_override: Option<AskForApproval>, runtime_permission_profile_override: Option<RuntimePermissionProfileOverride>, pub(crate) file_search: FileSearchManager, pub(crate) transcript_cells: Vec<Arc<dyn HistoryCell>>, // Pager overlay state (Transcript or Static like Diff) pub(crate) overlay: Option<Overlay>, pub(crate) deferred_history_lines: Vec<crate::terminal_hyperlinks::HyperlinkLine>, has_emitted_history_lines: bool, transcript_reflow: TranscriptReflowState, initial_history_replay_buffer: Option<InitialHistoryReplayBuffer>, pub(crate) enhanced_keys_supported: bool, pub(crate) keymap: RuntimeKeymap, /// Controls the animation thread that sends CommitTick events. pub(crate) commit_anim_running: Arc<AtomicBool>, // Shared across ChatWidget instances so invalid status-line config warnings only emit once. status_line_invalid_items_warned: Arc<AtomicBool>, // Shared across ChatWidget instances so invalid terminal-title config warnings only emit once. terminal_title_invalid_items_warned: Arc<AtomicBool>, // Tracks active skill-load warnings so refreshes do not duplicate history cells. skill_load_warnings: SkillLoadWarningState, // Esc-backtracking state grouped pub(crate) backtrack: crate::app_backtrack::BacktrackState, /// When set, the next draw rebuilds terminal scrollback from the retained transcript cells. /// /// This is used after a confirmed thread rollback to ensure scrollback reflects the trimmed /// transcript cells. pub(crate) backtrack_render_pending: bool, pub(crate) feedback: codex_feedback::CodexFeedback, feedback_audience: FeedbackAudience, environment_manager: Arc<EnvironmentManager>, app_server_target: AppServerTarget, /// Set when the user confirms an update; propagated on exit. pub(crate) pending_update_action: Option<UpdateAction>, /// Tracks the thread we intentionally shut down while exiting the app. /// /// When this matches the active thread, its `ShutdownComplete` should lead to /// process exit instead of being treated as an unexpected sub-agent death that /// triggers failover to the primary thread. /// /// This is thread-scoped state (`Option<ThreadId>`) instead of a global bool /// so shutdown events from other threads still take the normal failover path. pending_shutdown_exit_thread_id: Option<ThreadId>, windows_sandbox: WindowsSandboxState, thread_event_channels: HashMap<ThreadId, ThreadEventChannel>, thread_event_listener_tasks: HashMap<ThreadId, JoinHandle<()>>, agent_navigation: AgentNavigationState, side_threads: HashMap<ThreadId, SideThreadState>, active_thread_id: Option<ThreadId>, active_thread_rx: Option<mpsc::Receiver<ThreadBufferedEvent>>, primary_thread_id: Option<ThreadId>, last_subagent_backfill_attempt: Option<ThreadId>, primary_session_configured: Option<ThreadSessionState>, pending_primary_events: VecDeque<ThreadBufferedEvent>, pending_app_server_requests: PendingAppServerRequests, pending_startup_thread_start: bool, // Serialize plugin enablement writes per plugin so stale completions cannot // overwrite a newer toggle, even if the plugin is toggled from different // cwd contexts. pending_plugin_enabled_writes: HashMap<String, Option<bool>>, // Serialize hook enablement writes per hook so stale completions cannot // persist an older toggle after a newer one. pending_hook_enabled_writes: HashMap<String, Option<bool>>,}#[derive(Debug, Clone, PartialEq)]struct RuntimePermissionProfileOverride { permission_profile: PermissionProfile, active_permission_profile: Option<ActivePermissionProfile>, network: Option<crate::legacy_core::config::NetworkProxySpec>,}impl RuntimePermissionProfileOverride { fn from_config(config: &Config) -> Self { Self { permission_profile: config.permissions.permission_profile().clone(), active_permission_profile: config.permissions.active_permission_profile(), network: config.permissions.network.clone(), } }}fn active_turn_not_steerable_turn_error(error: &TypedRequestError) -> Option<AppServerTurnError> { let TypedRequestError::Server { source, .. } = error else { return None; }; let turn_error: AppServerTurnError = serde_json::from_value(source.data.clone()?).ok()?; matches!( turn_error.codex_error_info, Some(AppServerCodexErrorInfo::ActiveTurnNotSteerable { .. }) ) .then_some(turn_error)}async fn resolve_runtime_model_provider_base_url(provider: &ModelProviderInfo) -> Option<String> { let provider = create_model_provider(provider.clone(), /*auth_manager*/ None); match provider.runtime_base_url().await { Ok(base_url) => base_url, Err(err) => { tracing::warn!(%err, "failed to resolve runtime model provider base URL for status"); None } }}fn spawn_startup_thread_start( app_server: &AppServerSession, config: Config, app_event_tx: AppEventSender,) { let request_handle = app_server.request_handle(); let thread_params_mode = app_server.thread_params_mode(); let remote_cwd_override = app_server.remote_cwd_override().map(Path::to_path_buf); tokio::spawn(async move { let result = crate::app_server_session::start_thread_with_request_handle( request_handle, config, thread_params_mode, remote_cwd_override, ) .await .map_err(|err| format!("{err:#}")); app_event_tx.send(AppEvent::StartupThreadStarted { result }); });}#[derive(Debug, Clone, PartialEq, Eq)]enum ActiveTurnSteerRace { Missing, ExpectedTurnMismatch { actual_turn_id: String },}fn active_turn_steer_race(error: &TypedRequestError) -> Option<ActiveTurnSteerRace> { let TypedRequestError::Server { method, source } = error else { return None; }; if method != "turn/steer" { return None; } if source.message == "no active turn to steer" { return Some(ActiveTurnSteerRace::Missing); } // App-server steer mismatches mean our cached active turn id is stale, but the response // includes the server's current active turn so we can resynchronize and retry once. let mismatch_prefix = "expected active turn id `"; let mismatch_separator = "` but found `"; let actual_turn_id = source .message .strip_prefix(mismatch_prefix)? .split_once(mismatch_separator)? .1 .strip_suffix('`')? .to_string(); Some(ActiveTurnSteerRace::ExpectedTurnMismatch { actual_turn_id })}fn session_start_error( action: &str, target_session: &SessionTarget, err: color_eyre::eyre::Report,) -> color_eyre::eyre::Report { if let Some(message) = archived_session_guidance(&err) { return color_eyre::eyre::eyre!("{message}"); } let target_label = target_session.display_label(); color_eyre::eyre::eyre!("Failed to {action} session from {target_label}: {err}")}fn archived_session_guidance(err: &color_eyre::eyre::Report) -> Option<String> { let err = err.to_string(); let message = &err[err.find("session ")?..]; if !message.contains(" is archived. Run `codex unarchive ") { return None; } let message = message .split_once(" (code ") .map_or(message, |(message, _)| message); Some(message.to_string())}fn active_turn_interrupt_race(error: &TypedRequestError) -> Option<String> { let TypedRequestError::Server { method, source } = error else { return None; }; if method != "turn/interrupt" { return None; } let mismatch_prefix = "expected active turn id "; let mismatch_separator = " but found "; Some( source .message .strip_prefix(mismatch_prefix)? .split_once(mismatch_separator)? .1 .to_string(), )}impl App { pub fn chatwidget_init_for_forked_or_resumed_thread( &self, tui: &mut tui::Tui, cfg: crate::legacy_core::config::Config, initial_user_message: Option<crate::chatwidget::UserMessage>, ) -> crate::chatwidget::ChatWidgetInit { crate::chatwidget::ChatWidgetInit { config: cfg, frame_requester: tui.frame_requester(), app_event_tx: self.app_event_tx.clone(), workspace_command_runner: self.workspace_command_runner.clone(), initial_user_message, enhanced_keys_supported: self.enhanced_keys_supported, has_chatgpt_account: self.chat_widget.has_chatgpt_account(), has_codex_backend_auth: self.chat_widget.has_codex_backend_auth(), model_catalog: self.model_catalog.clone(), feedback: self.feedback.clone(), is_first_run: false, status_account_display: self.chat_widget.status_account_display().cloned(), runtime_model_provider_base_url: self .chat_widget .runtime_model_provider_base_url() .map(str::to_string), initial_plan_type: self.chat_widget.current_plan_type(), model: Some(self.chat_widget.current_model().to_string()), startup_tooltip_override: None, status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(), terminal_title_invalid_items_warned: self.terminal_title_invalid_items_warned.clone(), session_telemetry: self.session_telemetry.clone(), } } #[allow(clippy::too_many_arguments)] pub async fn run( tui: &mut tui::Tui, mut app_server: AppServerSession, mut config: Config, 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>, ) -> Result<AppExitInfo> { use tokio_stream::StreamExt; let startup_started_at = Instant::now(); let (app_event_tx, mut app_event_rx) = unbounded_channel(); let app_event_tx = AppEventSender::new(app_event_tx); emit_project_config_warnings(&app_event_tx, &config); emit_system_bwrap_warning(&app_event_tx, &config); tui.set_notification_settings( config.tui_notifications.method, config.tui_notifications.condition, ); let harness_overrides = normalize_harness_overrides_for_cwd(harness_overrides, &config.cwd)?; let bootstrap = match startup_bootstrap { Some(bootstrap) => bootstrap, None => app_server.bootstrap(&config).await?, }; let bootstrap_ms = bootstrap.duration.as_millis(); let mut model = bootstrap.default_model; let available_models = bootstrap.available_models; let remote_connection = crate::status::remote_connection::remote_connection_status_value( &app_server_target, app_server.server_version(), ); let exit_info = handle_model_migration_prompt_if_needed( tui, &mut config, model.as_str(), &app_event_tx, &available_models, ) .await; if let Some(exit_info) = exit_info { app_server .shutdown() .await .inspect_err(|err| { tracing::warn!("app-server shutdown failed: {err}"); }) .ok(); return Ok(exit_info); } if let Some(updated_model) = config.model.clone() { model = updated_model; } let model_catalog = Arc::new(ModelCatalog::new(available_models.clone())); 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 config .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 runtime_model_provider_started_at = Instant::now(); let runtime_model_provider_base_url = resolve_runtime_model_provider_base_url(&config.model_provider).await; let runtime_model_provider_ms = runtime_model_provider_started_at.elapsed().as_millis(); 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 chat_widget, initial_started_thread) = match session_selection { SessionSelection::StartFresh | SessionSelection::Exit => { spawn_startup_thread_start(&app_server, config.clone(), app_event_tx.clone()); // Count a startup tooltip once the initial chat widget can render it. let startup_tooltip_override = prepare_startup_tooltip_override(&mut config, &available_models, is_first_run) .await; let init = crate::chatwidget::ChatWidgetInit { 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, has_codex_backend_auth, model_catalog: model_catalog.clone(), feedback: feedback.clone(), is_first_run, status_account_display: status_account_display.clone(), runtime_model_provider_base_url: runtime_model_provider_base_url.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*/ true); (chat_widget, None) } SessionSelection::Resume(target_session) => { let resumed = app_server .resume_thread(config.clone(), target_session.thread_id) .await .map_err(|err| session_start_error("resume", &target_session, err))?; let init = crate::chatwidget::ChatWidgetInit { 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, has_codex_backend_auth, model_catalog: model_catalog.clone(), feedback: feedback.clone(), is_first_run, status_account_display: status_account_display.clone(), runtime_model_provider_base_url: runtime_model_provider_base_url.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), Some(resumed)) } SessionSelection::Fork(target_session) => { session_telemetry.counter( "codex.thread.fork", /*inc*/ 1, &[("source", "cli_subcommand")], ); let forked = app_server .fork_thread(config.clone(), target_session.thread_id) .await .map_err(|err| session_start_error("fork", &target_session, err))?; let init = crate::chatwidget::ChatWidgetInit { 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, has_codex_backend_auth, model_catalog: model_catalog.clone(), feedback: feedback.clone(), is_first_run, status_account_display: status_account_display.clone(), runtime_model_provider_base_url: runtime_model_provider_base_url.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), Some(forked)) } }; chat_widget.remote_connection = remote_connection; let thread_and_widget_ms = thread_and_widget_started_at.elapsed().as_millis(); chat_widget .maybe_prompt_windows_sandbox_enable(should_prompt_windows_sandbox_nux_at_startup); let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone()); let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).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." ) })?; #[cfg(not(debug_assertions))] let upgrade_version = crate::updates::get_upgrade_version(&config); let mut app = Self { model_catalog, session_telemetry: session_telemetry.clone(), app_event_tx, chat_widget, workspace_command_runner: Some(workspace_command_runner), config, state_db, cli_kv_overrides, harness_overrides, loader_overrides, cloud_config_bundle, runtime_approval_policy_override: None, runtime_permission_profile_override: None, file_search, enhanced_keys_supported, keymap: runtime_keymap, transcript_cells: Vec::new(), overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, transcript_reflow: TranscriptReflowState::default(), initial_history_replay_buffer: None, commit_anim_running: Arc::new(AtomicBool::new(false)), 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, pending_update_action: None, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), thread_event_channels: HashMap::new(), thread_event_listener_tasks: HashMap::new(), agent_navigation: AgentNavigationState::default(), side_threads: HashMap::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(), pending_startup_thread_start, pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), }; if let Some(entry) = startup_hooks_browser { app.chat_widget.open_hooks_browser(entry); } let initial_session_started_at = Instant::now(); if let Some(started) = initial_started_thread { let thread_id = started.session.thread_id; app.enqueue_primary_thread_session(started.session, started.turns) .await?; if should_prompt_for_paused_goal_after_startup_resume { app.maybe_prompt_resume_paused_goal_after_resume(&mut app_server, thread_id) .await; } } let initial_session_ms = initial_session_started_at.elapsed().as_millis(); // On startup, if a managed filesystem sandbox is active, warn about // world-writable dirs on Windows. #[cfg(target_os = "windows")] { let startup_permission_profile = app.config.permissions.effective_permission_profile(); let should_check = crate::windows_sandbox::level_from_config(&app.config) != WindowsSandboxLevel::Disabled && managed_filesystem_sandbox_is_restricted(&startup_permission_profile) && !app .config .notices .hide_world_writable_warning .unwrap_or(false); if should_check { let cwd = app.config.cwd.clone(); let workspace_roots = app.config.effective_workspace_roots(); let env_map: std::collections::HashMap<String, String> = std::env::vars().collect(); let tx = app.app_event_tx.clone(); let logs_base_dir = app.config.codex_home.clone(); Self::spawn_world_writable_scan( cwd, workspace_roots, env_map, logs_base_dir, startup_permission_profile, tx, ); } } let event_stream_started_at = Instant::now(); let tui_events = tui.event_stream(); tokio::pin!(tui_events); tui.frame_requester().schedule_frame(); tracing::info!( duration_ms = %(startup_elapsed_before_app + startup_started_at.elapsed()).as_millis(), bootstrap_ms = %bootstrap_ms, runtime_model_provider_ms = %runtime_model_provider_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 { 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 listen_for_app_server_events = true; let mut waiting_for_initial_session_configured = wait_for_initial_session_configured; #[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 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 exit_reason_result = if let Some(exit_reason) = pre_loop_exit_reason { Ok(exit_reason) } else { loop { let control = select! { Some(event) = app_event_rx.recv() => { match Box::pin(app.handle_event(tui, &mut app_server, event)).await { Ok(control) => control, 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() ) => { if let Some(event) = active { if let Err(err) = app.handle_active_thread_event(tui, &mut app_server, event).await { break Err(err); } } else { app.clear_active_thread().await; } AppRunControl::Continue } event = tui_events.next() => { if let Some(event) = event { match app.handle_tui_event(tui, &mut app_server, event).await { Ok(control) => control, Err(err) => break Err(err), } } else { tracing::warn!("terminal input stream closed; shutting down active thread"); app.handle_exit_mode(&mut app_server, ExitMode::ShutdownFirst).await } } app_server_event = app_server.next_event(), if listen_for_app_server_events => { match app_server_event { Some(event) => app.handle_app_server_event(&app_server, event).await, None => { listen_for_app_server_events = false; tracing::warn!("app-server event stream closed"); } } 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; } match control { AppRunControl::Continue => {} AppRunControl::Exit(reason) => break Ok(reason), } } }; if let Err(err) = app_server.shutdown().await { tracing::warn!(error = %err, "failed to shut down embedded app server"); } let clear_pet_result = tui.clear_ambient_pet_image(); let clear_result = tui.terminal.clear(); 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); } }; let thread_id = app.chat_widget.thread_id().or(app.primary_thread_id); let resume_hint = resume_hint_for_resumable_thread( thread_id, app.chat_widget.thread_name(), app.chat_widget.rollout_path().as_deref(), ); Ok(AppExitInfo { token_usage: app.token_usage(), thread_id, resume_hint, update_action: app.pending_update_action, exit_reason, }) } pub(crate) async fn handle_tui_event( &mut self, tui: &mut tui::Tui, app_server: &mut AppServerSession, event: TuiEvent, ) -> Result<AppRunControl> { if matches!(event, TuiEvent::Draw | TuiEvent::Resize) { self.handle_draw_pre_render(tui)?; } if self.overlay.is_some() { let _ = self.handle_backtrack_overlay_event(tui, event).await?; } else { match event { TuiEvent::Key(key_event) => { self.handle_key_event(tui, app_server, key_event).await; } TuiEvent::Paste(pasted) => { // Many terminals convert newlines to \r when pasting (e.g., iTerm2), // but tui-textarea expects \n. Normalize CR to LF. // [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783 // [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216 let pasted = pasted.replace("\r", "\n"); self.chat_widget.handle_paste(pasted); } TuiEvent::Draw | TuiEvent::Resize => { if self.backtrack_render_pending { self.rebuild_transcript_after_backtrack(tui)?; self.backtrack_render_pending = false; } self.chat_widget.maybe_post_pending_notification(tui); if self .chat_widget .handle_paste_burst_tick(tui.frame_requester()) { return Ok(AppRunControl::Continue); } // Allow widgets to process any pending timers before rendering. self.chat_widget.pre_draw_tick(); let rendered_area = self.render_chat_widget_frame(tui)?; if self.chat_widget.ambient_pet_image_enabled() { let terminal_size = tui.terminal.size()?; let ambient_pet_area = Rect::new( /*x*/ 0, /*y*/ 0, terminal_size.width, terminal_size.height, ); if let Err(err) = tui.draw_ambient_pet_image( self.chat_widget .ambient_pet_draw(ambient_pet_area, rendered_area.bottom()), ) { self.handle_ambient_pet_image_render_error(tui, err)?; } } if let Some(request) = self.chat_widget.pet_picker_preview_draw() { if let Err(err) = tui.draw_pet_picker_preview_image(Some(request)) { self.handle_pet_picker_preview_image_render_error(tui, err)?; } } else if self.chat_widget.should_clear_pet_picker_preview_image() && let Err(err) = tui.draw_pet_picker_preview_image(/*request*/ None) { self.handle_pet_picker_preview_image_render_error(tui, err)?; } if self.chat_widget.external_editor_state() == ExternalEditorState::Requested { self.chat_widget .set_external_editor_state(ExternalEditorState::Active); self.app_event_tx.send(AppEvent::LaunchExternalEditor); } } } } Ok(AppRunControl::Continue) } pub(super) fn show_shutdown_feedback(&mut self, tui: &mut tui::Tui) -> Result<()> { self.disable_ambient_pet_before_shutdown(tui)?; self.chat_widget.show_shutdown_in_progress(); self.handle_draw_pre_render(tui)?; self.chat_widget.pre_draw_tick(); self.render_chat_widget_frame(tui)?; Ok(()) } fn render_chat_widget_frame(&mut self, tui: &mut tui::Tui) -> Result<Rect> { let desired_height = self.chat_widget.desired_height(tui.terminal.size()?.width); let mut rendered_area = Rect::default(); tui.draw_with_resize_reflow(desired_height, |frame| { let area = frame.area(); rendered_area = area; self.chat_widget.render(area, frame.buffer); if let Some((x, y)) = self.chat_widget.cursor_pos(area) { frame.set_cursor_style(self.chat_widget.cursor_style(area)); frame.set_cursor_position((x, y)); } })?; Ok(rendered_area) }}impl Drop for App { fn drop(&mut self) { if let Err(err) = self.chat_widget.clear_managed_terminal_title() { tracing::debug!(error = %err, "failed to clear terminal title on app drop"); } }}#[cfg(test)]pub(super) mod test_support;#[cfg(test)]mod tests;