//! AppEvent dispatch for the TUI app. //! //! This module contains the exhaustive `AppEvent` dispatcher and exit-mode handling. Large domain //! actions are delegated to focused app submodules so the central match remains the routing layer. use super::*; impl App { pub(super) async fn handle_event( &mut self, tui: &mut tui::Tui, app_server: &mut AppServerSession, event: AppEvent, ) -> Result { match event { AppEvent::NewSession => { self.start_fresh_session_with_summary_hint( tui, app_server, /*session_start_source*/ None, /*initial_user_message*/ None, ) .await; } AppEvent::ClearUi => { self.clear_terminal_ui(tui, /*redraw_header*/ false)?; self.reset_app_ui_state_after_clear(); self.start_fresh_session_with_summary_hint( tui, app_server, Some(ThreadStartSource::Clear), /*initial_user_message*/ None, ) .await; } AppEvent::ClearUiAndSubmitUserMessage { text } => { self.clear_terminal_ui(tui, /*redraw_header*/ false)?; self.reset_app_ui_state_after_clear(); self.start_fresh_session_with_summary_hint( tui, app_server, Some(ThreadStartSource::Clear), crate::chatwidget::create_initial_user_message( Some(text), Vec::new(), Vec::new(), ), ) .await; } AppEvent::OpenResumePicker => { let picker_app_server = match crate::start_app_server_for_picker( &self.config, &match self.remote_app_server_url.clone() { Some(websocket_url) => crate::AppServerTarget::Remote { websocket_url, auth_token: self.remote_app_server_auth_token.clone(), }, None => crate::AppServerTarget::Embedded, }, self.environment_manager.clone(), ) .await { Ok(app_server) => app_server, Err(err) => { self.chat_widget.add_error_message(format!( "Failed to start TUI session picker: {err}" )); return Ok(AppRunControl::Continue); } }; match crate::resume_picker::run_resume_picker_with_app_server( tui, &self.config, /*show_all*/ false, /*include_non_interactive*/ false, picker_app_server, ) .await? { SessionSelection::Resume(target_session) => { match self .resume_target_session(tui, app_server, target_session) .await? { AppRunControl::Continue => {} AppRunControl::Exit(reason) => { return Ok(AppRunControl::Exit(reason)); } } } SessionSelection::Exit | SessionSelection::StartFresh | SessionSelection::Fork(_) => {} } // Leaving alt-screen may blank the inline viewport; force a redraw either way. tui.frame_requester().schedule_frame(); } AppEvent::ResumeSessionByIdOrName(id_or_name) => { match crate::lookup_session_target_with_app_server(app_server, &id_or_name).await? { Some(target_session) => { return self .resume_target_session(tui, app_server, target_session) .await; } None => { self.chat_widget.add_error_message(format!( "No saved chat found matching '{id_or_name}'." )); } } } AppEvent::ForkCurrentSession => { self.session_telemetry.counter( "codex.thread.fork", /*inc*/ 1, &[("source", "slash_command")], ); let summary = session_summary( self.chat_widget.token_usage(), self.chat_widget.thread_id(), self.chat_widget.thread_name(), self.chat_widget.rollout_path().as_deref(), ); self.chat_widget .add_plain_history_lines(vec!["/fork".magenta().into()]); if let Some(thread_id) = self.chat_widget.thread_id() { self.refresh_in_memory_config_from_disk_best_effort("forking the thread") .await; match app_server.fork_thread(self.config.clone(), thread_id).await { Ok(forked) => { self.shutdown_current_thread(app_server).await; match self .replace_chat_widget_with_app_server_thread( tui, app_server, forked, /*initial_user_message*/ None, ) .await { Ok(()) => { if let Some(summary) = summary { let mut lines: Vec> = Vec::new(); if let Some(usage_line) = summary.usage_line { lines.push(usage_line.into()); } if let Some(command) = summary.resume_command { let spans = vec![ "To continue this session, run ".into(), command.cyan(), ]; lines.push(spans.into()); } self.chat_widget.add_plain_history_lines(lines); } } Err(err) => { self.chat_widget.add_error_message(format!( "Failed to attach to forked app-server thread: {err}" )); } } } Err(err) => { self.chat_widget.add_error_message(format!( "Failed to fork current session through the app server: {err}" )); } } } else { self.chat_widget.add_error_message( "A thread must contain at least one turn before it can be forked." .to_string(), ); } tui.frame_requester().schedule_frame(); } AppEvent::InsertHistoryCell(cell) => { let cell: Arc = cell.into(); if let Some(Overlay::Transcript(t)) = &mut self.overlay { t.insert_cell(cell.clone()); tui.frame_requester().schedule_frame(); } self.transcript_cells.push(cell.clone()); let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width); if !display.is_empty() { // Only insert a separating blank line for new cells that are not // part of an ongoing stream. Streaming continuations should not // accrue extra blank lines between chunks. if !cell.is_stream_continuation() { if self.has_emitted_history_lines { display.insert(0, Line::from("")); } else { self.has_emitted_history_lines = true; } } if self.overlay.is_some() { self.deferred_history_lines.extend(display); } else { tui.insert_history_lines(display); } } } AppEvent::ApplyThreadRollback { num_turns } => { if self.apply_non_pending_thread_rollback(num_turns) { tui.frame_requester().schedule_frame(); } } AppEvent::StartCommitAnimation => { if self .commit_anim_running .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) .is_ok() { let tx = self.app_event_tx.clone(); let running = self.commit_anim_running.clone(); thread::spawn(move || { while running.load(Ordering::Relaxed) { thread::sleep(COMMIT_ANIMATION_TICK); tx.send(AppEvent::CommitTick); } }); } } AppEvent::StopCommitAnimation => { self.commit_anim_running.store(false, Ordering::Release); } AppEvent::CommitTick => { self.chat_widget.on_commit_tick(); } AppEvent::Exit(mode) => { return Ok(self.handle_exit_mode(app_server, mode).await); } AppEvent::Logout => match app_server.logout_account().await { Ok(()) => { return Ok(self .handle_exit_mode(app_server, ExitMode::ShutdownFirst) .await); } Err(err) => { tracing::error!("failed to logout: {err}"); self.chat_widget .add_error_message(format!("Logout failed: {err}")); } }, AppEvent::FatalExitRequest(message) => { return Ok(AppRunControl::Exit(ExitReason::Fatal(message))); } AppEvent::CodexOp(op) => { self.submit_active_thread_op(app_server, op.into()).await?; } AppEvent::SubmitThreadOp { thread_id, op } => { self.submit_thread_op(app_server, thread_id, op.into()) .await?; } AppEvent::ThreadHistoryEntryResponse { thread_id, event } => { self.enqueue_thread_history_entry_response(thread_id, event) .await?; } AppEvent::DiffResult(text) => { // Clear the in-progress state in the bottom pane self.chat_widget.on_diff_complete(); // Enter alternate screen using TUI helper and build pager lines let _ = tui.enter_alt_screen(); let pager_lines: Vec> = if text.trim().is_empty() { vec!["No changes detected.".italic().into()] } else { text.lines().map(ansi_escape_line).collect() }; self.overlay = Some(Overlay::new_static_with_lines( pager_lines, "D I F F".to_string(), )); tui.frame_requester().schedule_frame(); } AppEvent::OpenAppLink { app_id, title, description, instructions, url, is_installed, is_enabled, } => { self.chat_widget .open_app_link_view(crate::bottom_pane::AppLinkViewParams { app_id, title, description, instructions, url, is_installed, is_enabled, suggest_reason: None, suggestion_type: None, elicitation_target: None, }); } AppEvent::OpenUrlInBrowser { url } => { self.open_url_in_browser(url); } AppEvent::RefreshConnectors { force_refetch } => { self.chat_widget.refresh_connectors(force_refetch); } AppEvent::PluginInstallAuthAdvance { refresh_connectors } => { if refresh_connectors { self.chat_widget.refresh_connectors(/*force_refetch*/ true); } self.chat_widget.advance_plugin_install_auth_flow(); } AppEvent::PluginInstallAuthAbandon => { self.chat_widget.abandon_plugin_install_auth_flow(); } AppEvent::FetchPluginsList { cwd } => { self.fetch_plugins_list(app_server, cwd); } AppEvent::OpenPluginDetailLoading { plugin_display_name, } => { self.chat_widget .open_plugin_detail_loading_popup(&plugin_display_name); } AppEvent::OpenPluginInstallLoading { plugin_display_name, } => { self.chat_widget .open_plugin_install_loading_popup(&plugin_display_name); } AppEvent::OpenPluginUninstallLoading { plugin_display_name, } => { self.chat_widget .open_plugin_uninstall_loading_popup(&plugin_display_name); } AppEvent::PluginsLoaded { cwd, result } => { self.chat_widget.on_plugins_loaded(cwd, result); } AppEvent::FetchPluginDetail { cwd, params } => { self.fetch_plugin_detail(app_server, cwd, params); } AppEvent::PluginDetailLoaded { cwd, result } => { self.chat_widget.on_plugin_detail_loaded(cwd, result); } AppEvent::FetchPluginInstall { cwd, marketplace_path, plugin_name, plugin_display_name, } => { self.fetch_plugin_install( app_server, cwd, marketplace_path, plugin_name, plugin_display_name, ); } AppEvent::FetchPluginUninstall { cwd, plugin_id, plugin_display_name, } => { self.fetch_plugin_uninstall(app_server, cwd, plugin_id, plugin_display_name); } AppEvent::SetPluginEnabled { cwd, plugin_id, enabled, } => { self.set_plugin_enabled(app_server, cwd, plugin_id, enabled); } AppEvent::PluginInstallLoaded { cwd, marketplace_path, plugin_name, plugin_display_name, result, } => { let install_succeeded = result.is_ok(); if install_succeeded { if let Err(err) = self.refresh_in_memory_config_from_disk().await { tracing::warn!(error = %err, "failed to refresh config after plugin install"); } self.chat_widget.refresh_plugin_mentions(); self.chat_widget.submit_op(AppCommand::reload_user_config()); } let should_refresh_plugin_detail = self.chat_widget.on_plugin_install_loaded( cwd.clone(), marketplace_path.clone(), plugin_name.clone(), plugin_display_name, result, ); if install_succeeded && self.chat_widget.config_ref().cwd.as_path() == cwd.as_path() { self.fetch_plugins_list(app_server, cwd.clone()); if should_refresh_plugin_detail { self.fetch_plugin_detail( app_server, cwd, PluginReadParams { marketplace_path: Some(marketplace_path), remote_marketplace_name: None, plugin_name, }, ); } } } AppEvent::PluginEnabledSet { cwd, plugin_id, enabled, result, } => { let queued_enabled = self .pending_plugin_enabled_writes .get_mut(&plugin_id) .and_then(Option::take); let should_apply_result = if let Some(queued_enabled) = queued_enabled && (result.is_err() || queued_enabled != enabled) { self.spawn_plugin_enabled_write( app_server, cwd.clone(), plugin_id.clone(), queued_enabled, ); false } else { true }; if should_apply_result { self.pending_plugin_enabled_writes.remove(&plugin_id); let update_succeeded = result.is_ok(); if update_succeeded { if let Err(err) = self.refresh_in_memory_config_from_disk().await { tracing::warn!( error = %err, "failed to refresh config after plugin toggle" ); } self.chat_widget.refresh_plugin_mentions(); self.chat_widget.submit_op(AppCommand::reload_user_config()); } self.chat_widget .on_plugin_enabled_set(cwd, plugin_id, enabled, result); } } AppEvent::FetchMcpInventory { detail } => { self.fetch_mcp_inventory(app_server, detail); } AppEvent::McpInventoryLoaded { result, detail } => { self.handle_mcp_inventory_result(result, detail); } AppEvent::SkillsListLoaded { result } => { self.handle_skills_list_result( result.map_err(|err| color_eyre::eyre::eyre!(err)), "failed to load skills on startup", ); } AppEvent::StartFileSearch(query) => { self.file_search.on_user_query(query); } AppEvent::FileSearchResult { query, matches } => { self.chat_widget.apply_file_search_result(query, matches); } AppEvent::RefreshRateLimits { origin } => { self.refresh_rate_limits(app_server, origin); } AppEvent::SendAddCreditsNudgeEmail { credit_type } => { if self .chat_widget .start_add_credits_nudge_email_request(credit_type) { self.send_add_credits_nudge_email(app_server, credit_type); } } AppEvent::AddCreditsNudgeEmailFinished { result } => { self.chat_widget .finish_add_credits_nudge_email_request(result); } AppEvent::RateLimitsLoaded { origin, result } => match result { Ok(snapshots) => { for snapshot in snapshots { self.chat_widget.on_rate_limit_snapshot(Some(snapshot)); } match origin { RateLimitRefreshOrigin::StartupPrefetch => { tui.frame_requester().schedule_frame(); } RateLimitRefreshOrigin::StatusCommand { request_id } => { self.chat_widget .finish_status_rate_limit_refresh(request_id); } } } Err(err) => { tracing::warn!("account/rateLimits/read failed during TUI refresh: {err}"); if let RateLimitRefreshOrigin::StatusCommand { request_id } = origin { self.chat_widget .finish_status_rate_limit_refresh(request_id); } } }, AppEvent::ConnectorsLoaded { result, is_final } => { self.chat_widget.on_connectors_loaded(result, is_final); } AppEvent::UpdateReasoningEffort(effort) => { self.on_update_reasoning_effort(effort); } AppEvent::UpdateModel(model) => { self.chat_widget.set_model(&model); } AppEvent::UpdateCollaborationMode(mask) => { self.chat_widget.set_collaboration_mask(mask); } AppEvent::UpdatePersonality(personality) => { self.on_update_personality(personality); } AppEvent::OpenRealtimeAudioDeviceSelection { kind } => { self.chat_widget.open_realtime_audio_device_selection(kind); } AppEvent::RealtimeWebrtcOfferCreated { result } => { self.chat_widget.on_realtime_webrtc_offer_created(result); } AppEvent::RealtimeWebrtcEvent(event) => { self.chat_widget.on_realtime_webrtc_event(event); } AppEvent::RealtimeWebrtcLocalAudioLevel(peak) => { self.chat_widget.on_realtime_webrtc_local_audio_level(peak); } AppEvent::OpenReasoningPopup { model } => { self.chat_widget.open_reasoning_popup(model); } AppEvent::OpenPlanReasoningScopePrompt { model, effort } => { self.chat_widget .open_plan_reasoning_scope_prompt(model, effort); } AppEvent::OpenAllModelsPopup { models } => { self.chat_widget.open_all_models_popup(models); } AppEvent::OpenFullAccessConfirmation { preset, return_to_permissions, } => { self.chat_widget .open_full_access_confirmation(preset, return_to_permissions); } AppEvent::OpenWorldWritableWarningConfirmation { preset, sample_paths, extra_count, failed_scan, } => { self.chat_widget.open_world_writable_warning_confirmation( preset, sample_paths, extra_count, failed_scan, ); } AppEvent::OpenFeedbackNote { category, include_logs, } => { self.chat_widget.open_feedback_note(category, include_logs); } AppEvent::OpenFeedbackConsent { category } => { self.chat_widget.open_feedback_consent(category); } AppEvent::SubmitFeedback { category, reason, turn_id, include_logs, } => { self.submit_feedback(app_server, category, reason, turn_id, include_logs); } AppEvent::FeedbackSubmitted { origin_thread_id, category, include_logs, result, } => { self.handle_feedback_submitted(origin_thread_id, category, include_logs, result) .await; } AppEvent::LaunchExternalEditor => { if self.chat_widget.external_editor_state() == ExternalEditorState::Active { self.launch_external_editor(tui).await; } } AppEvent::OpenWindowsSandboxEnablePrompt { preset } => { self.chat_widget.open_windows_sandbox_enable_prompt(preset); } AppEvent::OpenWindowsSandboxFallbackPrompt { preset } => { self.session_telemetry.counter( "codex.windows_sandbox.fallback_prompt_shown", /*inc*/ 1, &[], ); self.chat_widget.clear_windows_sandbox_setup_status(); if let Some(started_at) = self.windows_sandbox.setup_started_at.take() { self.session_telemetry.record_duration( "codex.windows_sandbox.elevated_setup_duration_ms", started_at.elapsed(), &[("result", "failure")], ); } self.chat_widget .open_windows_sandbox_fallback_prompt(preset); } AppEvent::BeginWindowsSandboxElevatedSetup { preset } => { #[cfg(target_os = "windows")] { let policy = preset.sandbox.clone(); let policy_cwd = self.config.cwd.clone(); let command_cwd = policy_cwd.clone(); let env_map: std::collections::HashMap = std::env::vars().collect(); let codex_home = self.config.codex_home.clone(); let tx = self.app_event_tx.clone(); // If the elevated setup already ran on this machine, don't prompt for // elevation again - just flip the config to use the elevated path. if crate::legacy_core::windows_sandbox::sandbox_setup_is_complete( codex_home.as_path(), ) { tx.send(AppEvent::EnableWindowsSandboxForAgentMode { preset, mode: WindowsSandboxEnableMode::Elevated, }); return Ok(AppRunControl::Continue); } self.chat_widget.show_windows_sandbox_setup_status(); self.windows_sandbox.setup_started_at = Some(Instant::now()); let session_telemetry = self.session_telemetry.clone(); tokio::task::spawn_blocking(move || { let result = crate::legacy_core::windows_sandbox::run_elevated_setup( &policy, policy_cwd.as_path(), command_cwd.as_path(), &env_map, codex_home.as_path(), ); let event = match result { Ok(()) => { session_telemetry.counter( "codex.windows_sandbox.elevated_setup_success", /*inc*/ 1, &[], ); AppEvent::EnableWindowsSandboxForAgentMode { preset: preset.clone(), mode: WindowsSandboxEnableMode::Elevated, } } Err(err) => { let mut code_tag: Option = None; let mut message_tag: Option = None; if let Some((code, message)) = crate::legacy_core::windows_sandbox::elevated_setup_failure_details( &err, ) { code_tag = Some(code); message_tag = Some(message); } let mut tags: Vec<(&str, &str)> = Vec::new(); if let Some(code) = code_tag.as_deref() { tags.push(("code", code)); } if let Some(message) = message_tag.as_deref() { tags.push(("message", message)); } session_telemetry.counter( crate::legacy_core::windows_sandbox::elevated_setup_failure_metric_name( &err, ), /*inc*/ 1, &tags, ); tracing::error!( error = %err, "failed to run elevated Windows sandbox setup" ); AppEvent::OpenWindowsSandboxFallbackPrompt { preset } } }; tx.send(event); }); } #[cfg(not(target_os = "windows"))] { let _ = preset; } } AppEvent::BeginWindowsSandboxLegacySetup { preset } => { #[cfg(target_os = "windows")] { let policy = preset.sandbox.clone(); let policy_cwd = self.config.cwd.clone(); let command_cwd = policy_cwd.clone(); let env_map: std::collections::HashMap = std::env::vars().collect(); let codex_home = self.config.codex_home.clone(); let tx = self.app_event_tx.clone(); let session_telemetry = self.session_telemetry.clone(); self.chat_widget.show_windows_sandbox_setup_status(); tokio::task::spawn_blocking(move || { if let Err(err) = crate::legacy_core::windows_sandbox::run_legacy_setup_preflight( &policy, policy_cwd.as_path(), command_cwd.as_path(), &env_map, codex_home.as_path(), ) { session_telemetry.counter( "codex.windows_sandbox.legacy_setup_preflight_failed", /*inc*/ 1, &[], ); tracing::warn!( error = %err, "failed to preflight non-admin Windows sandbox setup" ); } tx.send(AppEvent::EnableWindowsSandboxForAgentMode { preset, mode: WindowsSandboxEnableMode::Legacy, }); }); } #[cfg(not(target_os = "windows"))] { let _ = preset; } } AppEvent::BeginWindowsSandboxGrantReadRoot { path } => { #[cfg(target_os = "windows")] { self.chat_widget .add_to_history(history_cell::new_info_event( format!("Granting sandbox read access to {path} ..."), /*hint*/ None, )); let policy = self.config.permissions.sandbox_policy.get().clone(); let policy_cwd = self.config.cwd.clone(); let command_cwd = self.config.cwd.clone(); let env_map: std::collections::HashMap = std::env::vars().collect(); let codex_home = self.config.codex_home.clone(); let tx = self.app_event_tx.clone(); tokio::task::spawn_blocking(move || { let requested_path = PathBuf::from(path); let event = match crate::legacy_core::grant_read_root_non_elevated( &policy, policy_cwd.as_path(), command_cwd.as_path(), &env_map, codex_home.as_path(), requested_path.as_path(), ) { Ok(canonical_path) => AppEvent::WindowsSandboxGrantReadRootCompleted { path: canonical_path, error: None, }, Err(err) => AppEvent::WindowsSandboxGrantReadRootCompleted { path: requested_path, error: Some(err.to_string()), }, }; tx.send(event); }); } #[cfg(not(target_os = "windows"))] { let _ = path; } } AppEvent::WindowsSandboxGrantReadRootCompleted { path, error } => match error { Some(err) => { self.chat_widget .add_to_history(history_cell::new_error_event(format!("Error: {err}"))); } None => { self.chat_widget .add_to_history(history_cell::new_info_event( format!("Sandbox read access granted for {}", path.display()), /*hint*/ None, )); } }, AppEvent::EnableWindowsSandboxForAgentMode { preset, mode } => { #[cfg(target_os = "windows")] { self.chat_widget.clear_windows_sandbox_setup_status(); if let Some(started_at) = self.windows_sandbox.setup_started_at.take() { self.session_telemetry.record_duration( "codex.windows_sandbox.elevated_setup_duration_ms", started_at.elapsed(), &[("result", "success")], ); } let profile = self.active_profile.as_deref(); let elevated_enabled = matches!(mode, WindowsSandboxEnableMode::Elevated); let builder = ConfigEditsBuilder::new(&self.config.codex_home) .with_profile(profile) .set_windows_sandbox_mode(if elevated_enabled { "elevated" } else { "unelevated" }) .clear_legacy_windows_sandbox_keys(); match builder.apply().await { Ok(()) => { if elevated_enabled { self.config.set_windows_sandbox_enabled(/*value*/ false); self.config .set_windows_elevated_sandbox_enabled(/*value*/ true); } else { self.config.set_windows_sandbox_enabled(/*value*/ true); self.config .set_windows_elevated_sandbox_enabled(/*value*/ false); } self.chat_widget.set_windows_sandbox_mode( self.config.permissions.windows_sandbox_mode, ); let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config); if let Some((sample_paths, extra_count, failed_scan)) = self.chat_widget.world_writable_warning_details() { self.app_event_tx.send(AppEvent::CodexOp( AppCommand::override_turn_context( /*cwd*/ None, /*approval_policy*/ None, /*approvals_reviewer*/ None, /*sandbox_policy*/ None, #[cfg(target_os = "windows")] Some(windows_sandbox_level), /*model*/ None, /*effort*/ None, /*summary*/ None, /*service_tier*/ None, /*collaboration_mode*/ None, /*personality*/ None, ) .into(), )); self.app_event_tx.send( AppEvent::OpenWorldWritableWarningConfirmation { preset: Some(preset.clone()), sample_paths, extra_count, failed_scan, }, ); } else { self.app_event_tx.send(AppEvent::CodexOp( AppCommand::override_turn_context( /*cwd*/ None, Some(preset.approval), Some(self.config.approvals_reviewer), Some(preset.sandbox.clone()), #[cfg(target_os = "windows")] Some(windows_sandbox_level), /*model*/ None, /*effort*/ None, /*summary*/ None, /*service_tier*/ None, /*collaboration_mode*/ None, /*personality*/ None, ) .into(), )); self.app_event_tx .send(AppEvent::UpdateAskForApprovalPolicy(preset.approval)); self.app_event_tx .send(AppEvent::UpdateSandboxPolicy(preset.sandbox.clone())); let _ = mode; self.chat_widget.add_plain_history_lines(vec![ Line::from(vec!["• ".dim(), "Sandbox ready".into()]), Line::from(vec![ " ".into(), "Codex can now safely edit files and execute commands in your computer" .dark_gray(), ]), ]); } } Err(err) => { tracing::error!( error = %err, "failed to enable Windows sandbox feature" ); self.chat_widget.add_error_message(format!( "Failed to enable the Windows sandbox feature: {err}" )); } } } #[cfg(not(target_os = "windows"))] { let _ = (preset, mode); } } AppEvent::PersistModelSelection { model, effort } => { let profile = self.active_profile.as_deref(); match ConfigEditsBuilder::new(&self.config.codex_home) .with_profile(profile) .set_model(Some(model.as_str()), effort) .apply() .await { Ok(()) => { let effort_label = effort .map(|selected_effort| selected_effort.to_string()) .unwrap_or_else(|| "default".to_string()); tracing::info!("Selected model: {model}, Selected effort: {effort_label}"); let mut message = format!("Model changed to {model}"); if let Some(label) = Self::reasoning_label_for(&model, effort) { message.push(' '); message.push_str(label); } if let Some(profile) = profile { message.push_str(" for "); message.push_str(profile); message.push_str(" profile"); } self.chat_widget.add_info_message(message, /*hint*/ None); } Err(err) => { tracing::error!( error = %err, "failed to persist model selection" ); if let Some(profile) = profile { self.chat_widget.add_error_message(format!( "Failed to save model for profile `{profile}`: {err}" )); } else { self.chat_widget .add_error_message(format!("Failed to save default model: {err}")); } } } } AppEvent::PluginUninstallLoaded { cwd, plugin_id: _plugin_id, plugin_display_name, result, } => { let uninstall_succeeded = result.is_ok(); if uninstall_succeeded { if let Err(err) = self.refresh_in_memory_config_from_disk().await { tracing::warn!( error = %err, "failed to refresh config after plugin uninstall" ); } self.chat_widget.refresh_plugin_mentions(); self.chat_widget.submit_op(AppCommand::reload_user_config()); } self.chat_widget.on_plugin_uninstall_loaded( cwd.clone(), plugin_display_name, result, ); if uninstall_succeeded && self.chat_widget.config_ref().cwd.as_path() == cwd.as_path() { self.fetch_plugins_list(app_server, cwd); } } AppEvent::RefreshPluginMentions => { self.refresh_plugin_mentions(); } AppEvent::PluginMentionsLoaded { mut plugins } => { if !self.config.features.enabled(Feature::Plugins) { plugins = None; } self.chat_widget.on_plugin_mentions_loaded(plugins); } AppEvent::PersistPersonalitySelection { personality } => { let profile = self.active_profile.as_deref(); match ConfigEditsBuilder::new(&self.config.codex_home) .with_profile(profile) .set_personality(Some(personality)) .apply() .await { Ok(()) => { let label = Self::personality_label(personality); let mut message = format!("Personality set to {label}"); if let Some(profile) = profile { message.push_str(" for "); message.push_str(profile); message.push_str(" profile"); } self.chat_widget.add_info_message(message, /*hint*/ None); } Err(err) => { tracing::error!( error = %err, "failed to persist personality selection" ); if let Some(profile) = profile { self.chat_widget.add_error_message(format!( "Failed to save personality for profile `{profile}`: {err}" )); } else { self.chat_widget.add_error_message(format!( "Failed to save default personality: {err}" )); } } } } AppEvent::PersistServiceTierSelection { service_tier } => { self.refresh_status_line(); let profile = self.active_profile.as_deref(); match ConfigEditsBuilder::new(&self.config.codex_home) .with_profile(profile) .set_service_tier(service_tier) .apply() .await { Ok(()) => { let status = if service_tier.is_some() { "on" } else { "off" }; let mut message = format!("Fast mode set to {status}"); if let Some(profile) = profile { message.push_str(" for "); message.push_str(profile); message.push_str(" profile"); } self.chat_widget.add_info_message(message, /*hint*/ None); } Err(err) => { tracing::error!(error = %err, "failed to persist fast mode selection"); if let Some(profile) = profile { self.chat_widget.add_error_message(format!( "Failed to save Fast mode for profile `{profile}`: {err}" )); } else { self.chat_widget.add_error_message(format!( "Failed to save default Fast mode: {err}" )); } } } } AppEvent::PersistRealtimeAudioDeviceSelection { kind, name } => { let builder = match kind { RealtimeAudioDeviceKind::Microphone => { ConfigEditsBuilder::new(&self.config.codex_home) .set_realtime_microphone(name.as_deref()) } RealtimeAudioDeviceKind::Speaker => { ConfigEditsBuilder::new(&self.config.codex_home) .set_realtime_speaker(name.as_deref()) } }; match builder.apply().await { Ok(()) => { match kind { RealtimeAudioDeviceKind::Microphone => { self.config.realtime_audio.microphone = name.clone(); } RealtimeAudioDeviceKind::Speaker => { self.config.realtime_audio.speaker = name.clone(); } } self.chat_widget .set_realtime_audio_device(kind, name.clone()); if self.chat_widget.realtime_conversation_is_live() { self.chat_widget.open_realtime_audio_restart_prompt(kind); } else { let selection = name.unwrap_or_else(|| "System default".to_string()); self.chat_widget.add_info_message( format!("Realtime {} set to {selection}", kind.noun()), /*hint*/ None, ); } } Err(err) => { tracing::error!( error = %err, "failed to persist realtime audio selection" ); self.chat_widget.add_error_message(format!( "Failed to save realtime {}: {err}", kind.noun() )); } } } AppEvent::RestartRealtimeAudioDevice { kind } => { self.chat_widget.restart_realtime_audio_device(kind); } AppEvent::UpdateAskForApprovalPolicy(policy) => { let mut config = self.config.clone(); if !self.try_set_approval_policy_on_config( &mut config, policy, "Failed to set approval policy", "failed to set approval policy on app config", ) { return Ok(AppRunControl::Continue); } self.config = config; self.runtime_approval_policy_override = Some(self.config.permissions.approval_policy.value()); self.chat_widget .set_approval_policy(self.config.permissions.approval_policy.value()); } AppEvent::UpdateSandboxPolicy(policy) => { #[cfg(target_os = "windows")] let policy_is_workspace_write_or_ro = matches!( &policy, codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. } | codex_protocol::protocol::SandboxPolicy::ReadOnly { .. } ); let policy_for_chat = policy.clone(); let mut config = self.config.clone(); if !self.try_set_sandbox_policy_on_config( &mut config, policy, "Failed to set sandbox policy", "failed to set sandbox policy on app config", ) { return Ok(AppRunControl::Continue); } self.config = config; if let Err(err) = self.chat_widget.set_sandbox_policy(policy_for_chat) { tracing::warn!(%err, "failed to set sandbox policy on chat config"); self.chat_widget .add_error_message(format!("Failed to set sandbox policy: {err}")); return Ok(AppRunControl::Continue); } self.runtime_sandbox_policy_override = Some(self.config.permissions.sandbox_policy.get().clone()); // If sandbox policy becomes workspace-write or read-only, run the Windows world-writable scan. #[cfg(target_os = "windows")] { // One-shot suppression if the user just confirmed continue. if self.windows_sandbox.skip_world_writable_scan_once { self.windows_sandbox.skip_world_writable_scan_once = false; return Ok(AppRunControl::Continue); } let should_check = WindowsSandboxLevel::from_config(&self.config) != WindowsSandboxLevel::Disabled && policy_is_workspace_write_or_ro && !self.chat_widget.world_writable_warning_hidden(); if should_check { let cwd = self.config.cwd.clone(); let env_map: std::collections::HashMap = std::env::vars().collect(); let tx = self.app_event_tx.clone(); let logs_base_dir = self.config.codex_home.clone(); let sandbox_policy = self.config.permissions.sandbox_policy.get().clone(); Self::spawn_world_writable_scan( cwd, env_map, logs_base_dir, sandbox_policy, tx, ); } } } AppEvent::UpdateApprovalsReviewer(policy) => { self.config.approvals_reviewer = policy; self.chat_widget.set_approvals_reviewer(policy); let profile = self.active_profile.as_deref(); let segments = if let Some(profile) = profile { vec![ "profiles".to_string(), profile.to_string(), "approvals_reviewer".to_string(), ] } else { vec!["approvals_reviewer".to_string()] }; if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) .with_profile(profile) .with_edits([ConfigEdit::SetPath { segments, value: policy.to_string().into(), }]) .apply() .await { tracing::error!( error = %err, "failed to persist approvals reviewer update" ); self.chat_widget .add_error_message(format!("Failed to save approvals reviewer: {err}")); } } AppEvent::UpdateFeatureFlags { updates } => { self.update_feature_flags(updates).await; } AppEvent::UpdateMemorySettings { use_memories, generate_memories, } => { self.update_memory_settings_with_app_server( app_server, use_memories, generate_memories, ) .await; } AppEvent::ResetMemories => { self.reset_memories_with_app_server(app_server).await; } AppEvent::SkipNextWorldWritableScan => { self.windows_sandbox.skip_world_writable_scan_once = true; } AppEvent::UpdateFullAccessWarningAcknowledged(ack) => { self.chat_widget.set_full_access_warning_acknowledged(ack); } AppEvent::UpdateWorldWritableWarningAcknowledged(ack) => { self.chat_widget .set_world_writable_warning_acknowledged(ack); } AppEvent::UpdateRateLimitSwitchPromptHidden(hidden) => { self.chat_widget.set_rate_limit_switch_prompt_hidden(hidden); } AppEvent::UpdatePlanModeReasoningEffort(effort) => { self.config.plan_mode_reasoning_effort = effort; self.chat_widget.set_plan_mode_reasoning_effort(effort); } AppEvent::PersistFullAccessWarningAcknowledged => { if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) .set_hide_full_access_warning(/*acknowledged*/ true) .apply() .await { tracing::error!( error = %err, "failed to persist full access warning acknowledgement" ); self.chat_widget.add_error_message(format!( "Failed to save full access confirmation preference: {err}" )); } } AppEvent::PersistWorldWritableWarningAcknowledged => { if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) .set_hide_world_writable_warning(/*acknowledged*/ true) .apply() .await { tracing::error!( error = %err, "failed to persist world-writable warning acknowledgement" ); self.chat_widget.add_error_message(format!( "Failed to save Agent mode warning preference: {err}" )); } } AppEvent::PersistRateLimitSwitchPromptHidden => { if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) .set_hide_rate_limit_model_nudge(/*acknowledged*/ true) .apply() .await { tracing::error!( error = %err, "failed to persist rate limit switch prompt preference" ); self.chat_widget.add_error_message(format!( "Failed to save rate limit reminder preference: {err}" )); } } AppEvent::PersistPlanModeReasoningEffort(effort) => { let profile = self.active_profile.as_deref(); let segments = if let Some(profile) = profile { vec![ "profiles".to_string(), profile.to_string(), "plan_mode_reasoning_effort".to_string(), ] } else { vec!["plan_mode_reasoning_effort".to_string()] }; let edit = if let Some(effort) = effort { ConfigEdit::SetPath { segments, value: effort.to_string().into(), } } else { ConfigEdit::ClearPath { segments } }; if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) .with_edits([edit]) .apply() .await { tracing::error!( error = %err, "failed to persist plan mode reasoning effort" ); if let Some(profile) = profile { self.chat_widget.add_error_message(format!( "Failed to save Plan mode reasoning effort for profile `{profile}`: {err}" )); } else { self.chat_widget.add_error_message(format!( "Failed to save Plan mode reasoning effort: {err}" )); } } } AppEvent::PersistModelMigrationPromptAcknowledged { from_model, to_model, } => { if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) .record_model_migration_seen(from_model.as_str(), to_model.as_str()) .apply() .await { tracing::error!( error = %err, "failed to persist model migration prompt acknowledgement" ); self.chat_widget.add_error_message(format!( "Failed to save model migration prompt preference: {err}" )); } } AppEvent::OpenApprovalsPopup => { self.chat_widget.open_approvals_popup(); } AppEvent::OpenAgentPicker => { self.open_agent_picker(app_server).await; } AppEvent::SelectAgentThread(thread_id) => { self.select_agent_thread_and_discard_side(tui, app_server, thread_id) .await?; } AppEvent::StartSide { parent_thread_id, user_message, } => { return self .handle_start_side(tui, app_server, parent_thread_id, user_message) .await; } AppEvent::OpenSkillsList => { self.chat_widget.open_skills_list(); } AppEvent::OpenManageSkillsPopup => { self.chat_widget.open_manage_skills_popup(); } AppEvent::SetSkillEnabled { path, enabled } => { let edits = [ConfigEdit::SetSkillConfig { path: path.to_path_buf(), enabled, }]; match ConfigEditsBuilder::new(&self.config.codex_home) .with_edits(edits) .apply() .await { Ok(()) => { self.chat_widget.update_skill_enabled(path, enabled); if let Err(err) = self.refresh_in_memory_config_from_disk().await { tracing::warn!( error = %err, "failed to refresh config after skill toggle" ); } } Err(err) => { let path_display = path.display(); self.chat_widget.add_error_message(format!( "Failed to update skill config for {path_display}: {err}" )); } } } AppEvent::SetAppEnabled { id, enabled } => { let edits = if enabled { vec![ ConfigEdit::ClearPath { segments: vec!["apps".to_string(), id.clone(), "enabled".to_string()], }, ConfigEdit::ClearPath { segments: vec![ "apps".to_string(), id.clone(), "disabled_reason".to_string(), ], }, ] } else { vec![ ConfigEdit::SetPath { segments: vec!["apps".to_string(), id.clone(), "enabled".to_string()], value: false.into(), }, ConfigEdit::SetPath { segments: vec![ "apps".to_string(), id.clone(), "disabled_reason".to_string(), ], value: "user".into(), }, ] }; match ConfigEditsBuilder::new(&self.config.codex_home) .with_edits(edits) .apply() .await { Ok(()) => { self.chat_widget.update_connector_enabled(&id, enabled); if let Err(err) = self.refresh_in_memory_config_from_disk().await { tracing::warn!(error = %err, "failed to refresh config after app toggle"); } self.chat_widget.submit_op(AppCommand::reload_user_config()); } Err(err) => { self.chat_widget.add_error_message(format!( "Failed to update app config for {id}: {err}" )); } } } AppEvent::OpenPermissionsPopup => { self.chat_widget.open_permissions_popup(); } AppEvent::OpenReviewBranchPicker(cwd) => { self.chat_widget.show_review_branch_picker(&cwd).await; } AppEvent::OpenReviewCommitPicker(cwd) => { self.chat_widget.show_review_commit_picker(&cwd).await; } AppEvent::OpenReviewCustomPrompt => { self.chat_widget.show_review_custom_prompt(); } AppEvent::SubmitUserMessageWithMode { text, collaboration_mode, } => { self.chat_widget .submit_user_message_with_mode(text, collaboration_mode); } AppEvent::ManageSkillsClosed => { self.chat_widget.handle_manage_skills_closed(); } AppEvent::FullScreenApprovalRequest(request) => match request { ApprovalRequest::ApplyPatch { cwd, changes, .. } => { let _ = tui.enter_alt_screen(); let diff_summary = DiffSummary::new(changes, cwd); self.overlay = Some(Overlay::new_static_with_renderables( vec![diff_summary.into()], "P A T C H".to_string(), )); } ApprovalRequest::Exec { command, .. } => { let _ = tui.enter_alt_screen(); let full_cmd = strip_bash_lc_and_escape(&command); let full_cmd_lines = highlight_bash_to_lines(&full_cmd); self.overlay = Some(Overlay::new_static_with_lines( full_cmd_lines, "E X E C".to_string(), )); } ApprovalRequest::Permissions { permissions, reason, .. } => { let _ = tui.enter_alt_screen(); let mut lines = Vec::new(); if let Some(reason) = reason { lines.push(Line::from(vec!["Reason: ".into(), reason.italic()])); lines.push(Line::from("")); } if let Some(rule_line) = crate::bottom_pane::format_requested_permissions_rule(&permissions) { lines.push(Line::from(vec![ "Permission rule: ".into(), rule_line.cyan(), ])); } self.overlay = Some(Overlay::new_static_with_renderables( vec![Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))], "P E R M I S S I O N S".to_string(), )); } ApprovalRequest::McpElicitation { server_name, message, .. } => { let _ = tui.enter_alt_screen(); let paragraph = Paragraph::new(vec![ Line::from(vec!["Server: ".into(), server_name.bold()]), Line::from(""), Line::from(message), ]) .wrap(Wrap { trim: false }); self.overlay = Some(Overlay::new_static_with_renderables( vec![Box::new(paragraph)], "E L I C I T A T I O N".to_string(), )); } }, #[cfg(not(target_os = "linux"))] AppEvent::UpdateRecordingMeter { id, text } => { // Update in place to preserve the element id for subsequent frames. let updated = self.chat_widget.update_recording_meter_in_place(&id, &text); if updated || self .chat_widget .stop_realtime_conversation_for_deleted_meter(&id) { tui.frame_requester().schedule_frame(); } } AppEvent::StatusLineSetup { items } => { let ids = items.iter().map(ToString::to_string).collect::>(); let edit = crate::legacy_core::config::edit::status_line_items_edit(&ids); let apply_result = ConfigEditsBuilder::new(&self.config.codex_home) .with_edits([edit]) .apply() .await; match apply_result { Ok(()) => { self.config.tui_status_line = Some(ids.clone()); self.chat_widget.setup_status_line(items); } Err(err) => { tracing::error!(error = %err, "failed to persist status line items; keeping previous selection"); self.chat_widget .add_error_message(format!("Failed to save status line items: {err}")); } } } AppEvent::StatusLineBranchUpdated { cwd, branch } => { self.chat_widget.set_status_line_branch(cwd, branch); self.refresh_status_line(); } AppEvent::StatusLineSetupCancelled => { self.chat_widget.cancel_status_line_setup(); } AppEvent::TerminalTitleSetup { items } => { let ids = items.iter().map(ToString::to_string).collect::>(); let edit = crate::legacy_core::config::edit::terminal_title_items_edit(&ids); let apply_result = ConfigEditsBuilder::new(&self.config.codex_home) .with_edits([edit]) .apply() .await; match apply_result { Ok(()) => { self.config.tui_terminal_title = Some(ids.clone()); self.chat_widget.setup_terminal_title(items); } Err(err) => { tracing::error!(error = %err, "failed to persist terminal title items; keeping previous selection"); self.chat_widget.revert_terminal_title_setup_preview(); self.chat_widget.add_error_message(format!( "Failed to save terminal title items: {err}" )); } } } AppEvent::TerminalTitleSetupPreview { items } => { self.chat_widget.preview_terminal_title(items); } AppEvent::TerminalTitleSetupCancelled => { self.chat_widget.cancel_terminal_title_setup(); } AppEvent::SyntaxThemeSelected { name } => { let edit = crate::legacy_core::config::edit::syntax_theme_edit(&name); let apply_result = ConfigEditsBuilder::new(&self.config.codex_home) .with_edits([edit]) .apply() .await; match apply_result { Ok(()) => { // Ensure the selected theme is active in the current // session. The preview callback covers arrow-key // navigation, but if the user presses Enter without // navigating, the runtime theme must still be applied. if let Some(theme) = crate::render::highlight::resolve_theme_by_name( &name, Some(&self.config.codex_home), ) { crate::render::highlight::set_syntax_theme(theme); } self.sync_tui_theme_selection(name); } Err(err) => { self.restore_runtime_theme_from_config(); tracing::error!(error = %err, "failed to persist theme selection"); self.chat_widget .add_error_message(format!("Failed to save theme: {err}")); } } } } Ok(AppRunControl::Continue) } pub(super) async fn handle_exit_mode( &mut self, app_server: &mut AppServerSession, mode: ExitMode, ) -> AppRunControl { match mode { ExitMode::ShutdownFirst => { // Mark the thread we are explicitly shutting down for exit so // its shutdown completion does not trigger agent failover. self.pending_shutdown_exit_thread_id = self.active_thread_id.or(self.chat_widget.thread_id()); if self.pending_shutdown_exit_thread_id.is_some() { self.shutdown_current_thread(app_server).await; } self.pending_shutdown_exit_thread_id = None; AppRunControl::Exit(ExitReason::UserRequested) } ExitMode::Immediate => { self.pending_shutdown_exit_thread_id = None; AppRunControl::Exit(ExitReason::UserRequested) } } } }