//! 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::rate_limit_refresh::RateLimitReadStatus; use super::rate_limit_refresh::RateLimitRefreshOutcome; use super::resize_reflow::trailing_run_start; use super::session_lifecycle::ThreadAttachPresentation; use super::*; use crate::app_event::CapturedSideStream; use crate::app_event::RecapTrigger; use crate::app_event::ThreadTitleDestination; use crate::app_server_session::ForkGoalContinuation; use crate::app_server_session::ThreadSettingsOwner; use crate::app_server_session::UnsupportedLegacyPermissionProfile; use crate::app_server_session::turn_permissions_overrides; use crate::config_update::format_config_error; use crate::external_agent_config_migration::flow::ExternalAgentConfigMigrationFlowOutcome; use crate::pager_overlay::TranscriptHistoryState; use crate::session_resume::cwds_differ; use crate::status::StatusAccountDisplay; use codex_app_server_client::AppServerEvent; use codex_app_server_protocol::ThreadGoalStatus; use codex_app_server_protocol::ThreadStatus; #[cfg(target_os = "windows")] use codex_app_server_protocol::WindowsSandboxSetupMode; pub(super) const SHUTDOWN_FIRST_EXIT_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 2); impl App { // Keep the existing dispatcher layout until it can be formatted separately. #[rustfmt::skip] pub(crate) async fn handle_event( &mut self, tui: &mut tui::Tui, app_server: &mut AppServerSession, event: AppEvent, ) -> Result { let from_agents_overview = matches!(event, AppEvent::ForkAgentsOverviewThreadReady { .. }); let event = if let AppEvent::ForkAgentsOverviewThreadReady { thread_id } = event { if self.current_displayed_thread_id() != Some(thread_id) || (self.thread_unavailable(thread_id) && !self.chat_widget.is_external_writer_view()) { self.chat_widget.fork_in_progress = false; return Ok(AppRunControl::Continue); } AppEvent::ForkCurrentSession { name: None } } else { event }; // Release the shortcut's input guard even when a fork is rejected below. if matches!(event, AppEvent::ForkCurrentSession { .. }) { self.chat_widget.fork_in_progress = false; } if self.reconnect.offline && matches!(event, AppEvent::Exit(ExitMode::ShutdownFirst)) { return Ok(AppRunControl::Exit(ExitReason::UserRequested)); } if self.reconnect.offline && !matches!( &event, AppEvent::OpenDaemonMenu | AppEvent::OpenWarnings | AppEvent::StatusLineSetup { .. } | AppEvent::StatusLineSetupCancelled | AppEvent::TerminalTitleSetup { .. } | AppEvent::TerminalTitleSetupPreview { .. } | AppEvent::TerminalTitleSetupCancelled | AppEvent::OpenConfigEditor(_) | AppEvent::Provider(_) | AppEvent::SyntaxThemeSelected { .. } | AppEvent::SyntaxThemePreviewed | AppEvent::OpenKeymapActionMenu { .. } | AppEvent::OpenKeymapReplaceBindingMenu { .. } | AppEvent::OpenKeymapCapture { .. } | AppEvent::OpenKeymapDebug | AppEvent::KeymapCaptured { .. } | AppEvent::KeymapCleared { .. } | AppEvent::TestConfigNotification | AppEvent::SendConfigTestNotification { .. } | AppEvent::SaveConfigPreference(_) | AppEvent::CopyWarning(_) | AppEvent::UpdateWarnings { .. } | AppEvent::CopySelection { .. } | AppEvent::SelectTranscriptCopy { .. } | AppEvent::TranscriptCopyClosed | AppEvent::ConfirmDaemonUpdate(_) | AppEvent::RunDaemonUpdate(_) | AppEvent::InsertHistoryCell(_) | AppEvent::CommitRealtimeTranscriptHistory | AppEvent::ParkSideCompanion { .. } | AppEvent::SideCreated { .. } | AppEvent::SideReplayFinished(_) | AppEvent::ResetTranscriptForThreadSwitch | AppEvent::ResetTranscriptForThreadSwitchPreservingScreen | AppEvent::FinishPromptRevert { .. } | AppEvent::ManagedWorktreeCreated(_) | AppEvent::AgentsOverviewWorktreeCreated(_) | AppEvent::AgentsOverviewPinToggled { .. } | AppEvent::AppendMessageHistoryEntry { .. } | AppEvent::BeginInitialHistoryReplayBuffer | AppEvent::BeginThreadSwitchHistoryReplayBuffer | AppEvent::EndInitialHistoryReplayBuffer | AppEvent::FatalExitRequest(_) ) { return Ok(AppRunControl::Continue); } if matches!( &event, AppEvent::OpenWindowsSandboxEnablePrompt { .. } | AppEvent::OpenWindowsSandboxFallbackPrompt { .. } | AppEvent::BeginWindowsSandboxElevatedSetup { .. } | AppEvent::BeginWindowsSandboxLegacySetup { .. } | AppEvent::EnableWindowsSandboxForAgentMode { .. } ) && !self.windows_sandbox_setup_is_local() { if matches!( &event, AppEvent::OpenWindowsSandboxFallbackPrompt { .. } | AppEvent::EnableWindowsSandboxForAgentMode { .. } ) { #[cfg(any(target_os = "windows", test))] { self.windows_sandbox.pending_setup = None; } self.chat_widget.clear_windows_sandbox_setup_status(); } self.chat_widget.add_info_message( "Windows sandbox setup requires local connections and executors.".to_string(), /*hint*/ None, ); return Ok(AppRunControl::Continue); } if self.chat_widget.has_misalignment_policy_violation() && matches!( event, AppEvent::OpenAgentPicker | AppEvent::SelectAgentThread(_) | AppEvent::StartSide { .. } | AppEvent::ForkCurrentSession { .. } | AppEvent::StartManagedWorktree { mode: crate::app_event::ManagedWorktreeMode::Fork, .. } | AppEvent::RevertSessionForPromptEdit { .. } | AppEvent::SetThreadGoalDraft { .. } | AppEvent::SetThreadGoalStatus { status: ThreadGoalStatus::Active, .. } ) { return Ok(AppRunControl::Continue); } if matches!(event, AppEvent::OpenDaemonMenu | AppEvent::OpenWarnings) { self.verify_managed_daemon().await; } // Keep the picker check and model update in one event without recursively polling this // large dispatcher on the TUI thread's stack. let event = match event { AppEvent::ScopedModelPicker { backend, thread_id, event } => { if backend != app_server.backend_id() || thread_id != self.chat_widget.thread_id() || self.chat_widget.model_catalog().provider_may_have_changed() { return Ok(AppRunControl::Continue); } *event } event => event, }; let (event, sparkle_model) = match event { AppEvent::AstraSelectedFromModelPicker { thread_id, model, action, } => { let should_offer = self.chat_widget.current_model() != model && self.chat_widget.sparkle_thread_for_picker_action(&model) == Some(thread_id); let next_event = action.into_app_event(model.clone()); (next_event, should_offer.then_some(model)) } event => (event, None), }; match event { AppEvent::OpenDaemonMenu => self.open_daemon_menu(), AppEvent::ConfirmDaemonRestart => self.confirm_daemon_restart(), AppEvent::RunDaemonRestart => { if self.chat_widget.remote_connection.as_ref().is_some_and(|connection| connection.is_managed_daemon) && self.daemon_restart.is_none() { self.daemon_restart = Some(tokio::spawn(codex_app_server_daemon::run( codex_app_server_daemon::LifecycleCommand::Restart { feature_overrides: Default::default(), }, ))); } } AppEvent::ConfirmDaemonUpdate(source) => self.confirm_daemon_update(source), AppEvent::RunDaemonUpdate(source) => { self.pending_update_action = Some(UpdateAction::Daemon(source)); return Ok(self.handle_exit_mode(app_server, ExitMode::Immediate).await); } AppEvent::UserVerificationApproved { thread_id, server_name, request_id } => { Box::pin(self.start_user_verification(app_server, thread_id, server_name, request_id)).await?; } AppEvent::UserVerificationFinished { thread_id, server_name, request_id, attempt_id, result } => { // Keep this RPC future out of the event loop's stack frame. Box::pin(self.finish_user_verification(app_server, thread_id, server_name, request_id, attempt_id, result)).await?; } AppEvent::ReviewMisalignment(review) => { self.open_misalignment_review(tui, review); } AppEvent::ContinueMisalignment(review) => { self.continue_misalignment(app_server, review).await; } AppEvent::CloseMisalignmentReview => self.chat_widget.show_misalignment_policy_precaution(), AppEvent::SkillsListLoaded { ref cwd, .. } if cwds_differ(cwd, self.config.cwd.as_path()) => { self.skill_load_warnings.startup_complete = true; } AppEvent::PluginMentionsLoaded { ref cwd, .. } if cwds_differ(cwd, self.config.cwd.as_path()) => {} AppEvent::ChooseNewSessionCheckout { name } if self.chat_widget.managed_worktree_available() => { self.chat_widget .open_session_checkout_picker(crate::app_event::ManagedWorktreeMode::New, name); } AppEvent::ChooseNewSessionCheckout { name } | AppEvent::NewSession { name } | AppEvent::NewSessionInCurrentWorktree { name } => { self.start_fresh_session( tui, app_server, /*session_start_source*/ None, /*initial_user_message*/ None, name, ) .await; if self.chat_widget.has_misalignment_policy_violation() { self.chat_widget.show_misalignment_policy_precaution(); } } AppEvent::ContinueProviderChat { thread_id } => { // A queued shortcut must still refer to the locked chat on screen. if self.chat_widget.thread_id() == Some(thread_id) && self.chat_widget.has_provider_error() && let Some(path) = self.chat_widget.rollout_path() { let name = self .chat_widget .thread_name() .filter(|name| !name.trim().is_empty()) .map_or_else( || "our previous conversation".to_string(), |name| format!("“{name}”"), ); let message = format!( "Read {name} and let’s continue where we left off.\n\nSaved conversation: {}", path.display(), ); self.start_fresh_session( tui, app_server, /*session_start_source*/ None, /*initial_user_message*/ None, /*new_thread_name*/ None, ) .await; if self .chat_widget .thread_id() .is_some_and(|new_id| new_id != thread_id) { self.chat_widget .restore_user_message_to_composer(message.into()); } } } AppEvent::StartManagedWorktree { mode, name } => { if self.pending_start_managed_worktree.is_some() { self.chat_widget .add_error_message("A worktree is already being created.".to_string()); } else { self.pending_start_managed_worktree = Some((mode, name)); } } AppEvent::ManagedWorktreeCreated(created) => { self.pending_managed_worktree_created = Some(created); } AppEvent::BrowseManagedWorktrees => { if let Some(request) = self.chat_widget.request_managed_worktrees() { crate::worktree_browser::fetch( request, self.config.codex_home.to_path_buf(), app_server.request_handle(), self.app_event_tx.clone(), ); } } AppEvent::ManagedWorktreesLoaded { request, result } => { self.chat_widget.on_managed_worktrees_loaded(request, result); } AppEvent::ManagedWorktreeAction { request, action } => { if let Some(event) = self.chat_widget.managed_worktree_action(&request, action) { self.app_event_tx.send(event); } } AppEvent::ShowManagedWorktreeActions { request, entry } => { self.chat_widget.show_managed_worktree_actions(request, entry); } AppEvent::ConfirmManagedWorktreeRemoval { request, root } => { self.chat_widget.confirm_managed_worktree_removal(request, root); } AppEvent::RemoveManagedWorktree { request, root } => { if self.chat_widget.worktree_request_is_current(&request) && !request.cwd.starts_with(&root) { let codex_home = self.config.codex_home.to_path_buf(); let tx = self.app_event_tx.clone(); tokio::spawn(async move { let result = crate::worktree_browser::remove( codex_home, request.cwd, root.clone(), ) .await .map_err(|error| error.to_string()); tx.send(AppEvent::ManagedWorktreeRemoved { root, result }); }); } } AppEvent::ManagedWorktreeRemoved { root, result } => match result { Ok(()) => self.chat_widget.add_info_message( format!("Removed worktree at {}. Thread history was kept.", root.display()), /*hint*/ None, ), Err(error) => self.chat_widget.add_error_message(format!( "Could not remove worktree at {}: {error}", root.display() )), }, AppEvent::ChangeWorkingDirectory { thread_id, requested_cwd, } => { if self.pending_working_directory_change.is_some() || self.primary_thread_id != Some(thread_id) || !self.chat_widget.can_change_working_directory(thread_id) { self.chat_widget.add_error_message( "Changing directories requires an idle primary session without queued input." .to_string(), ); } else if crate::uses_remote_workspace_or_environment( &self.app_server_target, self.environment_manager.as_ref(), ) { self.chat_widget.add_error_message( "Changing directories is not supported for remote workspaces or remote execution environments." .to_string(), ); } else { let cwd = AbsolutePathBuf::resolve_path_against_base( requested_cwd.as_path(), self.chat_widget.config_ref().cwd.as_path(), ); match std::fs::metadata(cwd.as_path()) { Ok(metadata) if metadata.is_dir() => { self.pending_working_directory_change = Some(working_directory::PendingWorkingDirectoryChange { source_thread_id: thread_id, source_cwd: self.config.cwd.clone(), destination: cwd, }); } Ok(_) => self .chat_widget .add_error_message(format!("Not a directory: {}", cwd.display())), Err(error) => self.chat_widget.add_error_message(format!( "Cannot access directory {}: {error}", cwd.display() )), } } } AppEvent::StartupThreadStarted { result, .. } => { self.handle_startup_thread_started(app_server, result) .await?; } AppEvent::DynamicToolThreadStarted { thread, task_tools_available, registered, } => { let Ok(thread_id) = ThreadId::from_string(&thread.id) else { return Ok(AppRunControl::Continue); }; self.agents_overview .dispatched_requests .entry(thread_id) .or_default(); if task_tools_available { app_server.remember_task_tool_thread(thread_id); } // Fallback metadata must not replay after fresh reads or newer notifications. if !thread.ephemeral && !self.agents_overview.removed_threads.contains(&thread_id) && !self.agents_overview.threads.get(&thread_id).is_some_and(Option::is_some) { self.agents_overview.threads.insert(thread_id, Some(thread)); self.agents_overview.refresh_thread_ids.insert(thread_id); } self.refresh_changed_agents_overview_threads(app_server); self.repaint_agents_overview(); let _ = registered.send(()); } AppEvent::DynamicToolCallCompleted { request_id, response, } => { self.dynamic_tool_tasks.remove(&request_id); match serde_json::to_value(response) { Ok(result) => { if let Err(error) = app_server .resolve_server_request(request_id.clone(), result) .await { tracing::warn!(?request_id, %error, "failed to resolve dynamic tool call"); } } Err(error) => { tracing::warn!(?request_id, %error, "failed to serialize dynamic tool response"); } } } AppEvent::TaskToolsAvailable { thread_id } => { app_server.remember_task_tool_thread(thread_id); } AppEvent::RequestOlderScrollbackHistory { thread_id } => { if self.chat_widget.thread_id() == Some(thread_id) && self.overlay.is_none() && self.scrollback_has_older_history { self.request_older_history_page(app_server, thread_id); } } AppEvent::OlderThreadHistoryLoaded { thread_id, cursor, result, } => { if let Err(err) = self .handle_older_history_page(tui, app_server, thread_id, &cursor, result) .await { app_server.cancel_older_history_page(thread_id, &cursor); if self.chat_widget.thread_id() == Some(thread_id) { self.transcript_view.history = TranscriptHistoryState::Failed; if let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() { overlay.set_history_state(TranscriptHistoryState::Failed); } tui.frame_requester().schedule_frame(); } if let Some(pane) = self.side_companions.get_mut(&thread_id) { pane.view.history = TranscriptHistoryState::Failed; tui.frame_requester().schedule_frame(); } tracing::warn!(%thread_id, error = %err, "failed to load older transcript history"); } } AppEvent::OpenWarnings => self.chat_widget.open_warnings(&self.transcript_cells), AppEvent::UpdateWarnings { transcript, dismissed, kept } => { if !Arc::ptr_eq(&transcript, &self.chat_widget.warning_display_state.transcript) { return Ok(AppRunControl::Continue); } let state = &mut self.chat_widget.warning_display_state.dismissed; state.extend(dismissed.into_iter().map(|entry| (entry.id, entry.details))); for entry in kept { if state.get(&entry.id) == Some(&entry.details) { state.remove(&entry.id); } } self.chat_widget.warning_display_state.synced_cells = None; tui.frame_requester().schedule_frame(); } AppEvent::CopyWarning(text) => { let result = tui.copy_transcript_selection(&text, crate::clipboard_copy::CopyFormat::PlainText); self.chat_widget.show_selection_copy_result(result); } AppEvent::OpenTranscriptExportFilePrompt => { self.chat_widget.show_transcript_export_file_prompt(); } AppEvent::ExportTranscript { destination } => { if let Err(error) = self.export_transcript(tui, app_server, destination).await { self.chat_widget .add_error_message(format!("Export failed: {error}")); } if self.chat_widget.no_modal_or_popup_active() { self.chat_widget .set_queue_autosend_suppressed(/*suppressed*/ false); self.chat_widget.maybe_send_next_queued_input(); } } AppEvent::TranscriptCopyClosed => { self.chat_widget.maybe_send_next_queued_input(); } AppEvent::SelectTranscriptCopy { guard } => { self.refresh_owned_transcript(tui)?; if !self.transcript_view.begin_copy_mode(&self.transcript_cells, Some(guard)) { self.chat_widget.add_info_message("Nothing to copy".into(), /*hint*/ None); } tui.frame_requester().schedule_frame(); } AppEvent::CopySelection { text, label, format } => { let result = tui.clipboard.copy(text, format, tui.frame_requester()); self.chat_widget.show_copy_result(&label, result); } AppEvent::ClearUi { name } => { if self.reject_pending_permission_root_switch() { return Ok(AppRunControl::Continue); } self.clear_terminal_ui(tui, /*redraw_header*/ false)?; self.reset_app_ui_state_after_clear(); self.start_fresh_session( tui, app_server, Some(ThreadStartSource::Clear), /*initial_user_message*/ None, name, ) .await; } AppEvent::RawOutputModeChanged { enabled } => { self.apply_raw_output_mode(tui, enabled, /*notify*/ false); } AppEvent::ClearUiAndSubmitUserMessage { text } => { if self.reject_pending_permission_root_switch() { self.chat_widget.restore_user_message_to_composer(text.into()); return Ok(AppRunControl::Continue); } self.clear_terminal_ui(tui, /*redraw_header*/ false)?; self.reset_app_ui_state_after_clear(); self.start_fresh_session( tui, app_server, Some(ThreadStartSource::Clear), crate::chatwidget::create_initial_user_message( Some(text), Vec::new(), Vec::new(), ), /*new_thread_name*/ None, ) .await; } AppEvent::OpenResumePicker => { self.pending_open_resume_picker = true; } AppEvent::OpenExternalAgentConfigMigration => { let cwd = if self.chat_widget.thread_id().is_some() || !app_server.uses_remote_workspace() { Some(self.chat_widget.config_ref().cwd.to_path_buf()) } else { app_server.remote_cwd_override().map(Path::to_path_buf) }; match crate::external_agent_config_migration::flow::handle_external_agent_config_migration_prompt( tui, app_server, cwd.as_deref(), ) .await { Ok(ExternalAgentConfigMigrationFlowOutcome::Started(lines)) => { self.chat_widget.add_plain_history_lines(lines); } Ok(ExternalAgentConfigMigrationFlowOutcome::NoItems) => { self.chat_widget.add_info_message( crate::external_agent_config_migration::flow::EXTERNAL_AGENT_CONFIG_MIGRATION_NO_ITEMS_MESSAGE .to_string(), /*hint*/ None, ); } Ok(ExternalAgentConfigMigrationFlowOutcome::Cancelled) => {} Ok(ExternalAgentConfigMigrationFlowOutcome::TerminalError(err)) => { return Err(err.into()); } Err(error_message) => { self.chat_widget.add_error_message(error_message); } } tui.frame_requester().schedule_frame(); } AppEvent::ResumeSessionByIdOrName(id_or_name) => { match crate::lookup_session_target_with_app_server( crate::resume_picker::SessionPickerAction::Resume, app_server, &self.config, &id_or_name, ) .await { Ok(Some(target_session)) => { return self .resume_target_session(tui, app_server, target_session) .await; } Ok(None) => { self.chat_widget.add_error_message(format!( "No saved chat found matching '{id_or_name}'." )); } Err(err) if err .downcast_ref::() .is_some() => { self.chat_widget.add_error_message(err.to_string()); } Err(err) => return Err(err), } } AppEvent::ResumeBackendSession(_) | AppEvent::CycleNewConversationBackend => { unreachable!("backend selection is handled by the session manager") } AppEvent::ArchiveCurrentThread => { return self.archive_current_thread(tui, app_server).await; } AppEvent::DeleteCurrentThread => { return self.delete_current_thread(tui, app_server).await; } AppEvent::ForkCurrentSession { name } => { let from_locked_thread = self.chat_widget.is_external_writer_view(); let source = if from_agents_overview { "agents_overview_shortcut" } else if from_locked_thread { "locked_thread_shortcut" } else { "slash_command" }; self.session_telemetry.counter( "codex.thread.fork", /*inc*/ 1, &[("source", source)], ); self.chat_widget .add_plain_history_lines(vec!["/fork".magenta().into()]); if let Some(thread_id) = self.chat_widget.thread_id() { if self.pending_server_profiles.contains_key(&thread_id) { self.chat_widget.add_error_message( "Wait for permissions to update before forking.".into(), ); return Ok(AppRunControl::Continue); } self.chat_widget.fork_in_progress = true; // This handler awaits the fork outside the draw loop. Paint before waiting. let screen_size = tui.terminal.last_known_screen_size; self.handle_draw_pre_render(tui, screen_size)?; self.chat_widget.pre_draw_tick(); self.render_chat_widget_frame(tui, screen_size)?; self.refresh_in_memory_config_from_disk_best_effort("forking the thread") .await; let mut fork_config = self.config.clone(); fork_config.daybreak_enabled = self.chat_widget.daybreak_enabled; if app_server.uses_remote_workspace() { fork_config.workspace_roots.clone_from( &self.chat_widget.config_ref().workspace_roots, ); } fork_config.model_provider_id.clone_from(&self.chat_widget.config_ref().model_provider_id); fork_config.model = Some(self.chat_widget.current_model().to_string()); fork_config.model_reasoning_effort = self.chat_widget.current_reasoning_effort(); let selected_profile = self.selected_server_profile(thread_id); match app_server.fork_thread_at( &self.local_settings, fork_config, thread_id, /*last_turn_id*/ None, /*before_turn_id*/ None, ForkGoalContinuation::StartIfIdle, selected_profile.as_ref(), ).await { Ok(mut forked) => { let retained_input = from_locked_thread .then(|| self.chat_widget.capture_thread_input_state()) .flatten(); let name_error = if let Some(name) = name { match app_server .thread_set_name(forked.session.thread_id, name.clone()) .await { Ok(()) => { forked.session.thread_name = Some(name); None } Err(err) => { Some(format!("Failed to name the forked session: {err}")) } } } else { None }; self.retain_subscriptions_for_fork(); match self .replace_chat_widget_with_app_server_thread( tui, forked, ThreadAttachPresentation::SessionLineage, /*initial_user_message*/ None, ) .await { Ok(()) => { if selected_profile.is_some() { self.adopt_inherited_server_selection(); } // Keep local input without replacing the fork's running state. self.chat_widget.restore_reconnected_input(retained_input, &[]); if let Some(err) = name_error { self.chat_widget.add_error_message(err); } self.chat_widget.add_info_message( "Fork created. You can continue here.".to_string(), /*hint*/ None, ); } 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}" )); } } if from_locked_thread || from_agents_overview { // Repeated fork shortcuts must not act on the resulting view. if let Err(err) = tui.discard_pending_input_before_interactive_screen() { tracing::warn!(%err, "failed to discard input after forking"); } tui.schedule_screen_size_recheck(Duration::ZERO); } } else { self.chat_widget.add_error_message( "A thread must contain at least one turn before it can be forked." .to_string(), ); } self.chat_widget.fork_in_progress = false; if !from_agents_overview { self.chat_widget.maybe_send_next_queued_input(); } tui.frame_requester().schedule_frame(); } AppEvent::RevertSessionForPromptEdit { thread_id, selected_cell, mut prompt, } => { if self.chat_widget.thread_id() != Some(thread_id) { return Ok(AppRunControl::Continue); } if self.app_server_target.uses_remote_workspace() && (!prompt.local_images.is_empty() || prompt.text.trim_start().starts_with(['/', '!'])) { self.chat_widget.add_error_message( "This remote prompt contains local image paths or command syntax that cannot be restored safely. Write a new message and reattach any images.".into(), ); tui.frame_requester().schedule_frame(); return Ok(AppRunControl::Continue); } if self.pending_server_profiles.contains_key(&thread_id) { self.chat_widget.restore_user_message_to_composer(prompt); self.chat_widget.add_error_message( "Wait for permissions to update before editing this prompt.".into(), ); tui.frame_requester().schedule_frame(); return Ok(AppRunControl::Continue); } let Some(index) = self.transcript_cells.iter().position(|cell| Arc::ptr_eq(cell, &selected_cell)) else { self.restore_backtrack_prompt_after_revert_error(prompt, "the selected prompt is no longer visible"); tui.frame_requester().schedule_frame(); return Ok(AppRunControl::Continue); }; let nth_user_message = crate::app_backtrack::user_count(&self.transcript_cells[..index]); let selection: Result<(String, Vec)> = async { let channel = self.thread_event_channels.get(&thread_id) .ok_or_else(|| color_eyre::eyre::eyre!("the selected thread is no longer available"))?; let (start_item, loaded_tail, latest_turn_id) = { let store = channel.store.lock().await; ( store.turns.iter().find_map(|turn| turn.items.first() .map(|item| (turn.id.clone(), item.id().to_string()))), store.turns.last().map(|turn| turn.id.clone()), store.latest_turn_id.clone(), ) }; let mut thread = app_server.thread_read(thread_id, /*include_turns*/ false).await?; app_server.hydrate_initial_thread_history( &mut thread, /*turn_cursor*/ None, /*item_cursor*/ None, /*config*/ None, /*local_settings*/ None, start_item.as_ref().map(|(turn_id, _)| turn_id).or(loaded_tail.as_ref()).map_or( crate::app_server_session::HistoryHydrationScope::Complete, |turn_id| crate::app_server_session::HistoryHydrationScope::ThroughTurn(turn_id), ), ).await?; if thread.turns.last().map(|turn| &turn.id) != latest_turn_id.as_ref() { color_eyre::eyre::bail!("thread history changed; reload the session before editing this prompt"); } // With no retained visible items, the next prompt follows the metadata-only tail. let start_item = start_item.or_else(|| loaded_tail.as_ref().and_then(|tail| { thread.turns.iter().position(|turn| &turn.id == tail).and_then(|index| { thread.turns[index + 1..].iter().find_map(|turn| turn.items.first() .map(|item| (turn.id.clone(), item.id().to_string()))) }) })); let before_turn_id = crate::app_backtrack::backtrack_revert_before_turn_id( &thread.turns, start_item.as_ref(), nth_user_message, &mut prompt, )?; // Keep the store aligned with the displayed prefix, including retained live turns. let cut = thread.turns.iter().position(|turn| turn.id == before_turn_id) .ok_or_else(|| color_eyre::eyre::eyre!("selected turn disappeared"))?; thread.turns.truncate(cut); if let Some((turn_id, item_id)) = &start_item { for turn in &mut thread.turns { if &turn.id == turn_id { if let Some(index) = turn.items.iter().position(|item| item.id() == item_id) { turn.items.drain(..index); } break; } turn.items.clear(); } } Ok((before_turn_id, thread.turns)) }.await; let (before_turn_id, retained_turns) = match selection { Ok(selection) => selection, Err(err) => { self.restore_backtrack_prompt_after_revert_error(prompt, err); tui.frame_requester().schedule_frame(); return Ok(AppRunControl::Continue); } }; let reverted = match app_server.revert_thread(thread_id, before_turn_id, &retained_turns).await { Ok(reverted) => reverted, Err(err) => { // Validation and unsupported-method errors leave history unchanged. Other // failures can arrive after the server has already committed the revert. if matches!(&err, codex_app_server_client::TypedRequestError::Server { source, .. } if matches!(source.code, -32602..=-32600)) { self.restore_backtrack_prompt_after_revert_error(prompt, err); tui.frame_requester().schedule_frame(); return Ok(AppRunControl::Continue); } self.chat_widget.restore_user_message_to_composer(prompt); return Err(color_eyre::Report::new(err).wrap_err("prompt edit could not be confirmed; resume this session to reload its history")); } }; self.chat_widget.restore_user_message_to_composer(prompt.clone()); // Stop on any post-mutation failure: accepting input against the old displayed // transcript would hide the fact that server history has already changed. tokio::time::timeout(Duration::from_secs(/*secs*/ 10), async { while let Some(event) = app_server.next_event().await { let is_reverted = event.backend_id == app_server.backend_id() && matches!( &event.event, AppServerEvent::ServerNotification(notification) if matches!(notification.as_ref(), ServerNotification::ThreadReverted(notification) if notification.thread_id == thread_id.to_string()) ); self.handle_backend_event(app_server, event).await; if is_reverted { return Ok(()); } } Err(color_eyre::eyre::eyre!("app-server disconnected")) }).await .wrap_err("history was reverted, but refreshing the session timed out; resume this session to reload it")??; // Preserve the widget and unrelated threads; only replace this replay store. self.chat_widget.restore_thread_input_state( /*input_state*/ None, crate::chatwidget::ThreadInputStateRestoreMode { preserve_in_flight_turn: false }, ); self.chat_widget.restore_user_message_to_composer(prompt); self.chat_widget.set_queue_autosend_suppressed(/*suppressed*/ true); self.abort_thread_event_listener(thread_id); if let Some(mut rx) = self.active_thread_rx.take() { while let Ok(event) = rx.try_recv() { if let ThreadBufferedEvent::Notification(notification) = event { self.chat_widget.handle_server_notification(*notification, Some(ReplayKind::ThreadSnapshot)); } } } let mut session = self.thread_event_channels.get(&thread_id) .ok_or_else(|| color_eyre::eyre::eyre!("reverted thread is no longer available"))? .store.lock().await.session.clone() .ok_or_else(|| color_eyre::eyre::eyre!("reverted thread has no session"))?; session.rollout_path = reverted.thread.path.clone(); if self.primary_thread_id == Some(thread_id) { self.primary_session_configured = Some(session.clone()); } self.thread_event_channels.remove(&thread_id); self.active_thread_id = None; self.recap.reset_for_new_thread(Instant::now()); self.recap.seed_from_turns(&retained_turns, Instant::now()); self.retain_realtime_replay_state_before_replace(); self.forget_realtime_replay_thread(thread_id); self.chat_widget.reset_after_prompt_revert(reverted.thread.path, &retained_turns); self.ensure_thread_channel(thread_id).store.lock().await .set_session(session, retained_turns); self.activate_thread_channel(thread_id).await; // Apply the trim after any transcript inserts produced by shutdown notifications. // The existing reset barrier keeps terminal input blocked until then. self.pending_thread_switch_resets += 1; self.app_event_tx.send(AppEvent::FinishPromptRevert { thread_id, nth_user_message, }); } AppEvent::FinishPromptRevert { thread_id, nth_user_message } => { self.pending_thread_switch_resets -= 1; if self.chat_widget.thread_id() == Some(thread_id) { if let Some(index) = crate::app_backtrack::nth_user_position(&self.transcript_cells, nth_user_message) { self.transcript_cells.truncate(index); self.native_history.retain(&self.transcript_cells); } self.transcript_view = Default::default(); self.scrollback_has_older_history = app_server.has_older_history(thread_id); self.deferred_history_lines.clear(); self.last_rendered_history_tail = None; self.last_thread_usage_status_cell = None; self.pending_thread_usage_history_refresh = false; self.backtrack_render_pending = !tui.is_owned_screen(); self.chat_widget.set_queue_autosend_suppressed(/*suppressed*/ false); self.chat_widget.emit_prompt_edit_thread_event(); tui.frame_requester().schedule_frame(); } } AppEvent::BeginInitialHistoryReplayBuffer => { self.begin_initial_history_replay_buffer(); } AppEvent::BeginThreadSwitchHistoryReplayBuffer => { self.begin_thread_switch_history_replay_buffer(); } AppEvent::ParkSideCompanion { pair, thread_id, stream, display } => { if self.side_threads.contains_key(&pair) { self.transcript_view.sync_history_tail(&self.transcript_cells); let mut answer = None; if let Some(stream) = stream { let cells = &mut self.transcript_cells; let (start, replacement): (_, Arc) = match stream { CapturedSideStream::Answer { cell, item } => { let start = trailing_run_start::(cells); answer = item.map(|item| super::side_companion::SideAnswer::new(item, start)); (start, cell) } CapturedSideStream::Plan(cell) => { (trailing_run_start::(cells), cell) } }; let range = start..cells.len(); self.transcript_view.replace_group(cells, range.clone(), &replacement); cells.splice(range, [replacement]); } self.side_companions.insert(thread_id, SideCompanion { display, parked_tail: self.transcript_cells.len(), cells: std::mem::take(&mut self.transcript_cells), view: std::mem::take(&mut self.transcript_view), answer, }); } } AppEvent::ResetTranscriptForThreadSwitch => { self.reset_for_thread_switch(tui)?; self.pending_thread_switch_resets -= 1; } AppEvent::SideReplayFinished(id) => { self.pending_thread_switch_resets -= 1; let finished_current_replay = self.pending_thread_switch_resets == 0 && self.current_displayed_thread_id() == Some(id); // Keys typed against the old frame must not answer the newly revealed modal. self.startup_protected_input_boundary |= finished_current_replay && self.chat_widget.has_active_modal(); if finished_current_replay && let Some(companion) = self.side_companions.remove(&id) { let replay_may_have_output = match self.thread_event_channels.get(&id) { Some(channel) => channel.store.lock().await.replay_may_have_output, None => true, }; self.transcript_view = companion.view; self.transcript_view.restore_after_replay( &companion.cells, &self.transcript_cells, replay_may_have_output, self.chat_widget.active_cell_transcript_key(), ); self.scrollback_has_older_history = app_server.has_older_history(id); if self.transcript_view.history == TranscriptHistoryState::LoadingOlder && app_server.pending_older_history_cursor(id).is_none() { self.transcript_view.history = if self.scrollback_has_older_history { TranscriptHistoryState::Partial } else { TranscriptHistoryState::Complete }; } tui.frame_requester().schedule_frame(); } } AppEvent::SidePanePressAfterReplay { peer, thread_id, area, mut press, draft_cursor } => { if self.pending_thread_switch_resets == 0 && self.current_displayed_thread_id() == Some(thread_id) && self.conversation_focus_available(tui) { let size = tui.prepare_draw_size()?; if side_pane::layout(self, tui, size.into()).is_some_and(|panes| panes.passive_thread == peer) { if let Some(cursor) = draft_cursor { // The editor can reflow during the slide. Keep the original text position. self.chat_widget.set_composer_cursor(cursor); } self.refresh_owned_transcript(tui)?; if draft_cursor.is_some() { let position = tui.terminal.last_known_cursor_pos; press.column = position.x; press.row = position.y; } else if !self.conversation_frame.side_pane().is_some_and(|panes| panes.focused == area) { return Ok(AppRunControl::Continue); } self.handle_owned_transcript_event(tui, app_server, &TuiEvent::Mouse(press))?; } } } AppEvent::ResetTranscriptForThreadSwitchPreservingScreen => { self.reset_transcript_state_after_clear(); tui.clear_pending_history_lines(); if self.conversation_frame.chooser_area().is_none() && self.chooser_focus().is_none() { tui.defer_thread_switch_clear(); } self.pending_thread_switch_resets -= 1; } AppEvent::CommitRealtimeTranscriptHistory => { for cell in self.chat_widget.take_realtime_transcript_history() { self.insert_history_cell(tui, cell); } } AppEvent::FollowTranscript => { self.transcript_view.jump_to_latest(); tui.frame_requester().schedule_frame(); } AppEvent::InsertHistoryCell(cell) => { self.insert_history_cell(tui, cell); } AppEvent::TurnTipReady { thread_id, turn_id } => { self.turn_tips.ready(thread_id, &turn_id, self.transcript_cells.last()); tui.frame_requester().schedule_frame(); } AppEvent::EndInitialHistoryReplayBuffer => { self.scrollback_has_older_history = self .chat_widget .thread_id() .is_some_and(|thread_id| app_server.has_older_history(thread_id)); if tui.is_owned_screen() { self.transcript_view.history = if self.scrollback_has_older_history { TranscriptHistoryState::Partial } else { TranscriptHistoryState::Complete }; } self.finish_initial_history_replay_buffer(tui); } AppEvent::ConsolidateAgentMessage { interrupted_item, source, copy_source, cwd, inline_visualization_context, scrollback_reflow, deferred_history_cell, } => { let mut cell = history_cell::AgentMarkdownCell::new_with_inline_visualizations(source, &cwd, inline_visualization_context).with_copy_source(copy_source); cell.interrupted_item = interrupted_item; self.handle_consolidate_agent_message( tui, cell, scrollback_reflow, deferred_history_cell, )?; self.chat_widget.note_stream_consolidation_completed(); self.insert_pending_usage_output_after_stream_shutdown(tui); } AppEvent::CorrectFlushedAgentMessage { item, cell } => { let belongs_to_item = |cell: &Arc| { cell.as_any().downcast_ref::().is_some_and(|cell| cell.interrupted_item.as_ref() == Some(&item)) }; if let Some(index) = self.transcript_cells.iter().position(belongs_to_item) { // Keep the reply where it first appeared, with intervening tools in order. if self.transcript_cells.extract_if(index + 1.., |cell| belongs_to_item(cell)).count() > 0 { self.native_history.retain(&self.transcript_cells); self.transcript_view.restart_search(); self.transcript_view.history_loaded(&self.transcript_cells, 0..0); if let Some(Overlay::Transcript(overlay)) = &mut self.overlay { overlay.replace_cells(self.transcript_cells.clone()); } } self.replace_agent_message(tui, index..index + 1, cell, crate::app_event::ConsolidationScrollbackReflow::Required)?; } else { self.insert_history_cell(tui, Box::new(cell)); } } AppEvent::ConsolidateProposedPlan(source) => { let end = self.transcript_cells.len(); let start = trailing_run_start::( &self.transcript_cells, ); let consolidated: Arc = Arc::new(history_cell::new_proposed_plan(source, &self.config.cwd)); if start < end { self.native_history.consolidate(&self.transcript_cells[start..end], &consolidated); if tui.is_owned_screen() { self.transcript_view.replace_group(&self.transcript_cells, start..end, &consolidated); } else { self.transcript_view.replace_range(&self.transcript_cells, start..end, &consolidated); } self.transcript_cells .splice(start..end, std::iter::once(consolidated.clone())); if let Some(Overlay::Transcript(t)) = &mut self.overlay { t.regroup_cells(start..end, consolidated.clone()); tui.frame_requester().schedule_frame(); } self.finish_required_stream_reflow(tui)?; } else { let deferred = tui.is_owned_screen() || self.native_history.insert(&consolidated); self.transcript_cells.push(consolidated.clone()); if let Some(Overlay::Transcript(t)) = &mut self.overlay { t.insert_cell(consolidated.clone()); tui.frame_requester().schedule_frame(); } self.render_inserted_history_cell(tui, &consolidated, deferred); self.maybe_finish_stream_reflow(tui)?; } self.chat_widget.note_stream_consolidation_completed(); self.insert_pending_usage_output_after_stream_shutdown(tui); } AppEvent::StartCommitAnimation => { self.commit_animation.get_or_insert_with(|| { let mut interval = tokio::time::interval_at( tokio::time::Instant::now() + COMMIT_ANIMATION_TICK, COMMIT_ANIMATION_TICK, ); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); interval }); } AppEvent::StopCommitAnimation => { self.commit_animation = None; } AppEvent::Exit(mode) if matches!(self.app_server_target, AppServerTarget::Embedded) && (self.agents_overview.threads.iter().any(|(id, thread)| { (mode == ExitMode::Immediate || Some(*id) != self.current_displayed_thread_id() || self.chat_widget.active_view_is(AGENTS_OVERVIEW_VIEW_ID)) && matches!(thread.as_ref().map(|t| &t.status), Some(ThreadStatus::Active { .. })) }) || self.running_side_thread_id().await.is_some()) => { self.chat_widget.show_selection_view(SelectionViewParams { title: Some("Quit and stop running sessions?".into()), subtitle: Some("Quitting this TUI will stop its running sessions.".into()), items: vec![ SelectionItem { name: "Stay in Codex".into(), dismiss_on_select: true, ..Default::default() }, SelectionItem { name: "Stop sessions and quit".into(), actions: vec![Box::new(move |tx| { tx.send(AppEvent::ConfirmedEmbeddedExit(mode)) })], dismiss_on_select: true, ..Default::default() }, ], ..SelectionViewParams::confirmation() }); } AppEvent::Exit(mode) | AppEvent::ConfirmedEmbeddedExit(mode) => { if matches!(mode, ExitMode::ShutdownFirst | ExitMode::ShutdownAfterInterrupt) { self.show_shutdown_feedback(tui)?; } return Ok(self.handle_exit_mode(app_server, mode).await); } AppEvent::RunningTaskExit { action, thread_id } => match action { RunningTaskExitAction::RunInBackground => { return Ok(self.handle_exit_mode(app_server, ExitMode::Immediate).await); } RunningTaskExitAction::CancelTask => { if self.chat_widget.thread_id() == Some(thread_id) && self.chat_widget.is_agent_turn_running() && self.chat_widget.submit_op(AppCommand::interrupt()) { self.chat_widget.pause_active_goal_for_interrupt(); } else if self.side_threads.contains_key(&thread_id) && let Err(error) = self .try_submit_active_thread_op_via_app_server( app_server, thread_id, &AppCommand::interrupt(), ) .await { self.chat_widget .add_error_message(format!("Failed to interrupt task: {error}")); } } RunningTaskExitAction::Exit => { if self.chat_widget.thread_id() == Some(thread_id) && self.chat_widget.is_active_goal_turn_running() && let Err(error) = app_server .thread_goal_set( thread_id, /*objective*/ None, Some(codex_app_server_protocol::ThreadGoalStatus::Paused), /*token_budget*/ None, ) .await { self.chat_widget .add_error_message(format!("Failed to pause task goal: {error}")); return Ok(AppRunControl::Continue); } let turn_id = self .active_turn_id_for_thread(thread_id) .await .unwrap_or_default(); match app_server.turn_interrupt(thread_id, turn_id).await { Ok(()) => { self.app_event_tx .send(AppEvent::Exit(ExitMode::ShutdownAfterInterrupt)); } Err(error) => { self.chat_widget .add_error_message(format!("Failed to interrupt task: {error}")); } } } }, AppEvent::Logout => match app_server.logout_account().await { Ok(()) => { self.show_shutdown_feedback(tui)?; 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::ImagesPrepared(id) => { self.chat_widget.on_images_prepared(id); } AppEvent::CodexOp(AppCommand::ListSkills { cwds, force_reload: true }) if self.awaiting_side_replay(tui) && let Some(thread_id) = self.active_thread_id && self.side_pair_key(thread_id).is_some() && !self.thread_unavailable(thread_id) => { // Session replay refreshes skill metadata. Let focus paint while that RPC is pending. let cwd = self.chat_widget.config_ref().cwd.to_path_buf(); if cwds.contains(&cwd) { let request_id = Uuid::new_v4(); app_server.pending_side_skills_refresh = Some(request_id); let rpc_id = app_server.next_request_id(); let request_handle = app_server.request_handle(); let app_event_tx = self.app_event_tx.clone(); tokio::spawn(async move { let result = background_requests::fetch_skills_list( request_handle, rpc_id, SkillsListParams { cwds, force_reload: true }, ) .await .map_err(|err| format!("{err:#}")); app_event_tx.send(AppEvent::SideReplaySkillsLoaded { request_id, thread_id, cwd, result, }); }); } } AppEvent::CodexOp(mut op) => { if let AppCommand::OverrideTurnContext { cwd, approval_policy, approvals_reviewer, permission_profile, active_permission_profile, .. } = &op && (cwd.is_some() || approval_policy.is_some() || approvals_reviewer.is_some() || permission_profile.is_some() || active_permission_profile.is_some()) && self.reject_pending_permission_change() { return Ok(AppRunControl::Continue); } if self.active_thread_id == self.chat_widget.thread_id() { if matches!(&op, AppCommand::UserTurn { .. }) && self.chat_widget.defer_pending_turn_for_luna_reserve() { return Ok(AppRunControl::Continue); } self.chat_widget .apply_reserve_fallback_to_pending_turn(&mut op); } let is_user_turn = matches!(&op, AppCommand::UserTurn { .. }); let is_realtime_stop = matches!(&op, AppCommand::RealtimeConversationStop { .. }); let realtime_stop_thread_id = match &op { AppCommand::RealtimeConversationStop { thread_id } => Some(*thread_id), _ => None, }; let realtime_speech_delivery_id = match &op { AppCommand::RealtimeConversationSpeech { delivery_id, .. } => { Some(*delivery_id) } _ => None, }; let is_realtime_conversation = matches!( &op, AppCommand::RealtimeConversationStart { .. } | AppCommand::RealtimeConversationStop { .. } | AppCommand::RealtimeConversationSpeech { .. } ); if is_user_turn { let screen_size = tui.terminal.last_known_screen_size; self.handle_draw_pre_render(tui, screen_size)?; if self.transcript_reflow.has_pending_reflow() { self.transcript_reflow.schedule_immediate(); self.maybe_run_resize_reflow(tui, screen_size)?; } self.chat_widget.pre_draw_tick(); self.render_chat_widget_frame(tui, screen_size)?; } let parked_voice = match &op { AppCommand::RealtimeConversationStart { thread_id, .. } | AppCommand::RealtimeConversationStop { thread_id } | AppCommand::RealtimeConversationSpeech { thread_id, .. } => self .background_voice .as_ref() .is_some_and(|owner| owner.thread_id() == Some(*thread_id)), _ => false, }; let visible_thread = self.active_thread_id; if parked_voice && let Some(owner) = self.background_voice.as_mut() { std::mem::swap(&mut self.chat_widget, owner); self.active_thread_id = self.chat_widget.thread_id(); } self.chat_widget.prepare_local_op_submission(&op); let result = self.submit_active_thread_op(app_server, op).await; if result.is_err() && let Some(delivery_id) = realtime_speech_delivery_id { self.chat_widget.restore_undelivered_realtime_speech(delivery_id); } if parked_voice && let Some(owner) = self.background_voice.as_mut() { std::mem::swap(&mut self.chat_widget, owner); self.active_thread_id = visible_thread; } if let Err(err) = result { if is_realtime_stop { let chat_widget = match self.background_voice.as_deref_mut() { Some(owner) if parked_voice => owner, _ => &mut self.chat_widget, }; if chat_widget.thread_id() == realtime_stop_thread_id { chat_widget.record_realtime_failure( crate::chatwidget::RealtimeFailureCause::AppServerRequest, ); } } if self.recover_transport_error(&err) { return Ok(AppRunControl::Continue); } let chat_widget = match self.background_voice.as_deref_mut() { Some(owner) if parked_voice => owner, _ => &mut self.chat_widget, }; let unsupported_permissions = err .downcast_ref::() .is_some(); if unsupported_permissions { chat_widget .set_queue_autosend_suppressed(/*suppressed*/ true); } let handled = is_user_turn && (matches!( err.downcast_ref::(), Some(TypedRequestError::Server { method, .. }) if method == "turn/start" ) || unsupported_permissions) && chat_widget .handle_turn_start_rejection(format!("Failed to start turn: {err:#}")); if is_realtime_conversation { let message = format!("Voice conversation failed: {err:#}"); if is_realtime_stop { if chat_widget.thread_id() == realtime_stop_thread_id { chat_widget.reset_realtime_conversation(); chat_widget.add_realtime_error(message); } } else { chat_widget.on_realtime_error( message, crate::chatwidget::RealtimeFailureCause::AppServerRequest, ); } tracing::error!(error = ?err, "realtime conversation request failed"); } else if handled { tracing::error!(error = ?err, "failed to start turn through app server"); } else { return Err(err); } } } AppEvent::ConfirmSafetyBufferedRetry { thread_id, turn_id, model, turn, prompt, } => { self.chat_widget .confirm_safety_buffered_retry(thread_id, turn_id, model, turn, prompt); } AppEvent::RetrySafetyBufferedTurn { thread_id, turn_id, model, turn, prompt, } => { self.retry_safety_buffered_turn( tui, app_server, super::safety_buffering::SafetyBufferedRetry { thread_id, turn_id, model, turn, prompt, }, ) .await; } AppEvent::ContinueSafetyRecourse { thread_id, turn_id, prompt, } => { super::safety_recourse::continue_with_rewrite( self, tui, app_server, thread_id, turn_id, prompt, ) .await; } AppEvent::AppendMessageHistoryEntry { thread_id, text } => { self.append_message_history_entry(thread_id, text); } AppEvent::SyncThreadGitBranch { thread_id, branch, cwd: _cwd, } => { if let Err(err) = app_server .thread_metadata_update_branch(thread_id, branch) .await { tracing::warn!("failed to sync thread git branch from directive: {err}"); } } AppEvent::LookupMessageHistoryEntry { thread_id, offset, log_id, } => { self.lookup_message_history_entry(thread_id, offset, log_id) .await?; } AppEvent::LookupMessageHistoryBatch { thread_id, cursor, log_id, } => { self.lookup_message_history_batch(thread_id, cursor, log_id) .await?; } AppEvent::ApproveRecentAutoReviewDenial { thread_id, id } => { self.chat_widget .approve_recent_auto_review_denial(thread_id, id); } AppEvent::SubmitThreadOp { thread_id, op } => { self.submit_thread_op(app_server, thread_id, op).await?; } AppEvent::ThreadHistoryEntryResponse { thread_id, event } => { self.enqueue_thread_history_entry_response(thread_id, event) .await?; } AppEvent::DiffResult(cwd, text) => { if cwds_differ(&cwd, self.chat_widget.config_ref().cwd.as_path()) { return Ok(AppRunControl::Continue); } // 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(), self.keymap.pager.clone(), )); 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::AccountEmailLoaded { request_id, email, workspace } => { if self.account_email_request_id == Some(request_id) { self.account_email_request_id = None; self.chat_widget.on_account_email_loaded(email, workspace); } } AppEvent::SecuritySetupLoaded { request_id, identity, notice } => { tracing::debug!(current = request_id == self.chat_widget.security_setup_request_id, "handling security setup notice"); if request_id == self.chat_widget.security_setup_request_id { self.chat_widget.show_security_setup(identity, notice); } } AppEvent::OpenUrlInBrowser { url } => { self.open_url_in_browser(url); } AppEvent::OpenDesktopThread { thread_id } => { self.open_desktop_thread(thread_id); } AppEvent::PetSelected { pet_id } => { self.handle_pet_selected(tui, pet_id); } AppEvent::PetDisabled => { self.handle_pet_disabled(tui).await; } AppEvent::PetPreviewRequested { pet_id } => { self.chat_widget.start_pet_picker_preview(pet_id); } AppEvent::PetPreviewLoaded { request_id, result } => { self.handle_pet_preview_loaded(tui, request_id, result); } AppEvent::PetSelectionLoaded { request_id, pet_id, result, } => { return self .handle_pet_selection_loaded(tui, request_id, pet_id, result) .await; } AppEvent::ConfiguredPetLoaded { generation, pet_id, result, } => { self.handle_configured_pet_loaded(tui, generation, pet_id, result); } AppEvent::RefreshConnectors { force_refetch } => { self.chat_widget.refresh_connectors(force_refetch); } AppEvent::FetchConnectorsList { force_refetch, generation, } => { if generation == self.chat_widget.connector_scope_generation() { self.fetch_connectors_list(app_server, force_refetch); } } AppEvent::FetchInstalledConnectorMentions { force_refresh, generation, } => { if generation == self.chat_widget.connector_scope_generation() { self.fetch_installed_connector_mentions(app_server, force_refresh, generation); } } 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::FetchHooksList { target } => { self.fetch_hooks_list(app_server, target); } AppEvent::OpenMarketplaceAddPrompt => { self.chat_widget.open_marketplace_add_prompt(); } AppEvent::OpenMarketplaceAddLoading { source } => { self.chat_widget.open_marketplace_add_loading_popup(&source); } AppEvent::OpenMarketplaceRemoveConfirm { marketplace_name, marketplace_display_name, } => { self.chat_widget.open_marketplace_remove_confirmation( marketplace_name, marketplace_display_name, ); } AppEvent::OpenMarketplaceRemoveLoading { marketplace_display_name, } => { self.chat_widget .open_marketplace_remove_loading_popup(&marketplace_display_name); } AppEvent::OpenMarketplaceUpgradeLoading { marketplace_name } => { self.chat_widget .open_marketplace_upgrade_loading_popup(marketplace_name.as_deref()); } 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::OpenPluginsList { cwd, response } => { self.chat_widget.open_plugins_list(cwd, response); } AppEvent::PluginRemoteSectionsLoaded { cwd, marketplaces, section_errors, } => { self.chat_widget.on_plugin_remote_sections_loaded( cwd, marketplaces, section_errors, ); } AppEvent::HooksLoaded { target, result } => { self.chat_widget.on_hooks_loaded(target, result); } AppEvent::FetchMarketplaceAdd { cwd, source } => { self.fetch_marketplace_add(app_server, cwd, source); } AppEvent::FetchMarketplaceUpgrade { cwd, marketplace_name, } => { self.fetch_marketplace_upgrade(app_server, cwd, marketplace_name); } AppEvent::MarketplaceAddLoaded { cwd, source, result, } => { let add_succeeded = result.is_ok(); self.chat_widget .on_marketplace_add_loaded(cwd.clone(), source, result); if add_succeeded && self.chat_widget.config_ref().cwd.as_path() == cwd.as_path() { self.fetch_plugins_list(app_server, cwd); } } AppEvent::MarketplaceUpgradeLoaded { cwd, result } => { let marketplace_contents_changed = matches!(&result, Ok(response) if !response.upgraded_roots.is_empty()); if marketplace_contents_changed { self.refresh_plugin_mentions_after_config_write(); } self.chat_widget .on_marketplace_upgrade_loaded(cwd.clone(), result); if self.chat_widget.config_ref().cwd.as_path() == cwd.as_path() { self.fetch_plugins_list(app_server, cwd); } } AppEvent::FetchMarketplaceRemove { cwd, marketplace_name, marketplace_display_name, } => { self.fetch_marketplace_remove( app_server, cwd, marketplace_name, marketplace_display_name, ); } AppEvent::MarketplaceRemoveLoaded { cwd, marketplace_name, marketplace_display_name, result, } => { let remove_succeeded = result.is_ok(); self.chat_widget.on_marketplace_remove_loaded( cwd.clone(), marketplace_name, marketplace_display_name, result, ); if remove_succeeded && self.chat_widget.config_ref().cwd.as_path() == cwd.as_path() { self.refresh_plugin_mentions_after_config_write(); self.fetch_plugins_list(app_server, cwd); } } 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, location, plugin_name, plugin_display_name, } => { self.fetch_plugin_install( app_server, cwd, location, 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, location, plugin_name, plugin_display_name, result, } => { let install_succeeded = result.is_ok(); if install_succeeded { self.refresh_plugin_mentions_after_config_write(); } let should_refresh_plugin_detail = self.chat_widget.on_plugin_install_loaded( cwd.clone(), location.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 { let (marketplace_path, remote_marketplace_name) = location.into_request_params(); self.fetch_plugin_detail( app_server, cwd, PluginReadParams { marketplace_path, remote_marketplace_name, 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 { self.refresh_plugin_mentions_after_config_write(); } self.chat_widget .on_plugin_enabled_set(cwd, plugin_id, enabled, result); } } AppEvent::FetchMcpInventory { detail, thread_id } => { self.fetch_mcp_inventory(app_server, detail, thread_id); } AppEvent::StartMcpLogin { name, thread_id } => { if self.pending_mcp_login_start.is_some() { self.chat_widget.add_info_message( "MCP sign-in is starting. Wait for it to finish before trying again.".to_string(), /*hint*/ None, ); return Ok(AppRunControl::Continue); } let request_id = format!("mcp-login-{}", uuid::Uuid::new_v4()); self.pending_mcp_login_start = Some(PendingMcpLoginStart { request_id: request_id.clone(), name: name.clone(), thread_id, completions: Vec::new(), }); self.start_mcp_login(app_server, request_id, name, thread_id); } AppEvent::McpLoginStarted { request_id, result } => { if let Some(pending) = self .pending_mcp_login_start .take_if(|pending| pending.request_id == request_id) { match result { Ok(response) => { // Track the latest attempt per server: unrelated OAuth logins can // remain open while a user retries this server. if let Some(login_id) = response.login_id.as_ref() { self.active_mcp_login_ids .insert(pending.name.clone(), login_id.clone()); } let completed = pending.completions.iter().any(|completion| { completion.login_id == response.login_id && completion.name == pending.name }); if !completed { self.open_url_in_browser(response.authorization_url); } } Err(error) => { self.enqueue_thread_notification( pending.thread_id, ServerNotification::McpServerOauthLoginCompleted( codex_app_server_protocol::McpServerOauthLoginCompletedNotification { name: pending.name, thread_id: Some(pending.thread_id.to_string()), login_id: None, success: false, error: Some(error), }, ), ).await?; } } // A rejected start leaves the old attempt current. A successful replacement // has changed its ID, so the old cancellation is discarded here. for completion in pending.completions { if completion.login_id.is_some() { if completion.login_id.as_ref() != self.active_mcp_login_ids.get(&completion.name) { continue; } self.active_mcp_login_ids.remove(&completion.name); } if let Some(thread_id) = completion.thread_id.as_deref() .and_then(|id| ThreadId::from_string(id).ok()) { self.enqueue_thread_notification( thread_id, ServerNotification::McpServerOauthLoginCompleted(completion), ).await?; } } } } AppEvent::McpInventoryLoaded { result, detail, thread_id, } => { self.handle_mcp_inventory_result(result, detail, thread_id); } AppEvent::SideReplaySkillsLoaded { request_id, thread_id, cwd, result } => { if app_server.pending_side_skills_refresh == Some(request_id) { app_server.pending_side_skills_refresh = None; if !self.awaiting_side_replay(tui) && self.current_displayed_thread_id() == Some(thread_id) && !cwds_differ(&cwd, self.chat_widget.config_ref().cwd.as_path()) { self.handle_skills_list_result( result.map_err(|err| color_eyre::eyre::eyre!(err)), "failed to refresh skills", ); } } } AppEvent::SkillsListLoaded { result, .. } => { self.handle_skills_list_result( result.map_err(|err| color_eyre::eyre::eyre!(err)), "failed to load skills on startup", ); self.skill_load_warnings.startup_complete = true; } AppEvent::StartFileSearch(query) => { self.file_search.on_user_query(query.clone()); if let Some(thread_id) = self.active_thread_id && app_server.task_tools_available(thread_id) && self.config.features.enabled(Feature::MentionsV2) { let cwd = self .thread_cwd(thread_id) .await .map(|cwd| cwd.to_path_buf()) .or_else(|| { app_server .remote_cwd_override() .map(std::path::Path::to_path_buf) }) .unwrap_or_else(|| self.config.cwd.to_path_buf()); crate::task_mentions::spawn_search( app_server.request_handle(), query, thread_id, cwd, app_server.task_search_generation(), self.app_event_tx.clone(), ); } } AppEvent::FileSearchResult { query, matches } => { self.chat_widget.apply_file_search_result(query, matches); } AppEvent::TaskSearchResult { thread_id, query, matches, } => { if self.active_thread_id == Some(thread_id) && app_server.task_tools_available(thread_id) { self.chat_widget.on_task_search_result(&query, matches); } } AppEvent::RefreshRateLimits { origin } => { self.refresh_rate_limits(app_server, origin); } AppEvent::ApplyBackendBannerFallback { thread_id } => { if self.active_thread_id == Some(thread_id) && self.chat_widget.thread_id() == Some(thread_id) { self.apply_backend_banner_fallback(app_server).await; if !self.rate_limit_refresh_state.has_pending_recovery() { self.chat_widget.finish_rate_limit_recovery(); } self.refresh_rate_limits(app_server, RateLimitRefreshOrigin::Periodic); } } AppEvent::RestoreBackgroundTerminals(thread_id) => { if self.chat_widget.thread_id() == Some(thread_id) { let request_id = self.chat_widget.begin_background_terminal_restore(); let mut server = app_server.request_view(); let tx = self.app_event_tx.clone(); tokio::spawn(async move { let terminals = server.list_background_terminals(thread_id).await.unwrap_or_default(); tx.send(AppEvent::BackgroundTerminalsLoaded { request_id, terminals }); }); } } AppEvent::BackgroundTerminalsLoaded { request_id, terminals } => { self.chat_widget.restore_background_terminals(request_id, terminals); } AppEvent::RefreshThreadUsage { thread_id, request_id, } => { self.refresh_thread_usage(app_server, thread_id, request_id); } AppEvent::RefreshStatusLineWorkspaceHeadline { request_id } => { self.refresh_status_line_workspace_headline(app_server, request_id); } AppEvent::OpenThreadGoalMenu { thread_id } => { self.open_thread_goal_menu(app_server, thread_id).await; } AppEvent::OpenThreadGoalEditor { thread_id } => { self.open_thread_goal_editor(app_server, thread_id).await; } AppEvent::SetThreadGoalDraft { thread_id, draft, mode, } => { self.set_thread_goal_draft(app_server, thread_id, draft, mode) .await; } AppEvent::SetThreadGoalStatus { thread_id, status } => { self.set_thread_goal_status(app_server, thread_id, status) .await; } AppEvent::ClearThreadGoal { thread_id } => { self.clear_thread_goal(app_server, thread_id).await; } AppEvent::SendAddCreditsNudgeEmail { credit_type } => { if let Some(request_id) = self .chat_widget .start_add_credits_nudge_email_request(credit_type) { self.send_add_credits_nudge_email(app_server, request_id, credit_type); } } AppEvent::AddCreditsNudgeEmailFinished { request_id, result } => { self.chat_widget .finish_add_credits_nudge_email_request(request_id, result); } AppEvent::RateLimitsLoaded { request_id, origin, hard_stop_generation, result, } => { let accepted = match self.rate_limit_refresh_state.finish( request_id, hard_stop_generation, self.rate_limit_hard_stop_generation, if result.is_ok() { RateLimitReadStatus::Succeeded } else { RateLimitReadStatus::Failed }, ) { RateLimitRefreshOutcome::Apply => true, RateLimitRefreshOutcome::Ignore => false, RateLimitRefreshOutcome::RefreshRecovery => { // Start in this account's event turn; a queued refresh could cross an account change. self.refresh_rate_limits(app_server, RateLimitRefreshOrigin::Recovery); false } }; match result { Ok(response) => { let rate_limit_reset_credits = response.rate_limit_reset_credits.clone(); let snapshots = if accepted { self.chat_widget.apply_usage_notice_read(request_id); self.chat_widget.update_backend_banner(&response); self.apply_backend_banner_fallback(app_server).await; app_server_rate_limit_snapshots(response) } else { Vec::new() }; match origin { RateLimitRefreshOrigin::Recovery | RateLimitRefreshOrigin::Periodic => { for snapshot in snapshots { self.chat_widget.on_rate_limit_snapshot(Some(snapshot)); } } RateLimitRefreshOrigin::StartupPrefetch { reset_hint_request_id, } => { if self.chat_widget.finish_rate_limit_reset_hint_refresh( reset_hint_request_id, snapshots, rate_limit_reset_credits.ok_or_else(|| { "account/rateLimits/read response did not include rateLimitResetCredits" .to_string() }), ) { self.insert_pending_usage_output_if_ready(tui); } tui.frame_requester().schedule_frame(); } RateLimitRefreshOrigin::ResetConsume { request_id } => { self.chat_widget.finish_post_consume_reset_credits_refresh( request_id, snapshots, rate_limit_reset_credits.ok_or_else(|| { "account/rateLimits/read response did not include rateLimitResetCredits" .to_string() }), ); tui.frame_requester().schedule_frame(); } RateLimitRefreshOrigin::StatusCommand { request_id } => { self.chat_widget .finish_status_rate_limit_refresh(request_id, snapshots); } RateLimitRefreshOrigin::UsageMenu { request_id } => { self.chat_widget.finish_usage_menu_rate_limit_refresh( request_id, snapshots, rate_limit_reset_credits.ok_or_else(|| { "account/rateLimits/read response did not include rateLimitResetCredits" .to_string() }), ); } RateLimitRefreshOrigin::ResetPicker { request_id } => { self.chat_widget.finish_rate_limit_reset_credits_refresh( request_id, snapshots, rate_limit_reset_credits.ok_or_else(|| { "account/rateLimits/read response did not include rateLimitResetCredits" .to_string() }), ); } } } Err(err) => { // A failed read is not authoritative recovery. Keep the last valid banner. tracing::warn!("account/rateLimits/read failed during TUI refresh: {err}"); match origin { RateLimitRefreshOrigin::Recovery | RateLimitRefreshOrigin::Periodic => { // Re-evaluate snapshot age even when the backend cannot refresh it. // This updates display freshness without authorizing model recovery. self.chat_widget.refresh_status_surfaces(); }, RateLimitRefreshOrigin::StartupPrefetch { reset_hint_request_id, } => { self.chat_widget.finish_rate_limit_reset_hint_refresh( reset_hint_request_id, Vec::new(), Err(err), ); } RateLimitRefreshOrigin::ResetConsume { request_id } => { self.chat_widget.finish_post_consume_reset_credits_refresh( request_id, Vec::new(), Err(err), ); } RateLimitRefreshOrigin::StatusCommand { request_id } => { self.chat_widget .finish_status_rate_limit_refresh(request_id, Vec::new()); } RateLimitRefreshOrigin::UsageMenu { request_id } => { self.chat_widget.finish_usage_menu_rate_limit_refresh( request_id, Vec::new(), Err(err), ); } RateLimitRefreshOrigin::ResetPicker { request_id } => { self.chat_widget.finish_rate_limit_reset_credits_refresh( request_id, Vec::new(), Err(err), ); } } } } if (accepted || matches!( origin, RateLimitRefreshOrigin::Recovery | RateLimitRefreshOrigin::ResetConsume { .. } )) && !self.rate_limit_refresh_state.has_pending_recovery() { self.chat_widget.finish_rate_limit_recovery(); } }, AppEvent::OpenAnalytics { view: summary_view } => { tui.enter_alt_screen()?; let mut view = self.retained_analytics.take().unwrap_or_else(|| { Box::new(crate::analytics::AnalyticsView::new(self.keymap.list.clone())) }); view.keymap = self.keymap.list.clone(); if summary_view.is_some() { view.select_summary(summary_view); } view.open( app_server.request_handle(), tui.frame_requester(), self.model_catalog.try_list_models()?, std::sync::Arc::new(self.config.clone()), ); self.overlay = Some(Overlay::Analytics(view)); tui.frame_requester().schedule_frame(); } AppEvent::OpenRateLimitResetCredits => { let request_id = self.chat_widget.show_rate_limit_reset_loading_popup(); self.refresh_rate_limits( app_server, RateLimitRefreshOrigin::ResetPicker { request_id }, ); } AppEvent::OpenRateLimitResetConfirmation { picker_request_id, confirmation_gate, credit_id, reset_title, reset_detail, reset_description, } => { self.chat_widget.show_rate_limit_reset_confirmation( picker_request_id, confirmation_gate, credit_id, reset_title, reset_detail, reset_description, ); } AppEvent::ConsumeRateLimitResetCredit { idempotency_key, credit_id, } => { if let Some(request_id) = self .chat_widget .start_rate_limit_reset_consumption(&idempotency_key) { self.consume_rate_limit_reset_credit( app_server, request_id, idempotency_key, credit_id, ); } } AppEvent::RateLimitResetCreditConsumed { request_id, idempotency_key, credit_id, result, } => { if let Err(err) = &result { tracing::warn!( "account/rateLimitResetCredit/consume failed during TUI request: {err}" ); } if self.chat_widget.finish_rate_limit_reset_consume( request_id, idempotency_key, credit_id, result, ) { // Reads started before redemption must not restore the pre-reset banner. self.rate_limit_hard_stop_generation = self.rate_limit_hard_stop_generation.wrapping_add(1); self.rate_limit_refresh_state.invalidate_recovery(); self.chat_widget.clear_backend_banner(); self.refresh_rate_limits( app_server, RateLimitRefreshOrigin::ResetConsume { request_id }, ); } } AppEvent::ThreadUsageLoaded { thread_id, request_id, result, } => { self.finish_thread_usage_refresh(tui, thread_id, request_id, result)?; } AppEvent::AgentsOverviewUsageLoaded { thread_id, request_id, result } => { self.finish_agents_overview_usage(thread_id, request_id, result); } AppEvent::CommitPendingUsageOutput => { self.insert_pending_usage_output_if_ready(tui); } AppEvent::CommitPendingUsageOutputAfterStreamShutdown => { self.insert_pending_usage_output_after_stream_shutdown(tui); } AppEvent::ConnectorsLoaded { thread_id, cwd, generation, result, is_final, } => { if thread_id == self.current_displayed_thread_id() && cwd.as_path() == self.chat_widget.config_ref().cwd.as_path() && generation == self.chat_widget.connector_scope_generation() { self.chat_widget.on_connectors_loaded(result, is_final); } } AppEvent::InstalledConnectorMentionsLoaded { thread_id, cwd, generation, result, } => { if thread_id == self.current_displayed_thread_id() && cwd.as_path() == self.chat_widget.config_ref().cwd.as_path() && generation == self.chat_widget.connector_scope_generation() { self.chat_widget .on_connector_mentions_loaded(generation, result); } } AppEvent::UpdateReasoningEffort(effort) => { self.on_update_reasoning_effort(effort.clone()); self.sync_active_thread_reasoning_setting(app_server, effort) .await; } AppEvent::UpdateLunaReserveReasoning { thread_id, effort } => { self.update_luna_reserve_reasoning(app_server, thread_id, effort) .await; } AppEvent::UpdateModel(model) => { if self .active_thread_model_setting_update_params(model.clone()) .is_some_and(|params| params.permissions.is_some()) && self.reject_pending_permission_change() { return Ok(AppRunControl::Continue); } let model_changed = self.chat_widget.current_model() != model || self.chat_widget.current_collaboration_mode().model() != model; if model_changed { self.chat_widget.set_model(&model); self.sync_active_thread_model_setting(app_server, model, /*effort*/ None) .await; self.sync_active_thread_service_tier_to_cached_session() .await; } } AppEvent::ApplyModelSelection { model, effort } => { if app_server.thread_params_mode().settings_owner() == ThreadSettingsOwner::Server { self.select_session_model( app_server, model, effort, /*service_tier*/ None, ) .await; } else { self.apply_default_model_selection(app_server, model, effort) .await; } } AppEvent::ScopedModelPicker { .. } | AppEvent::AstraSelectedFromModelPicker { .. } => unreachable!("picker event unwrapped"), AppEvent::BackgroundVoiceError { thread_id, message } => { if self.chat_widget.thread_id() == Some(thread_id) { self.chat_widget.add_error_message(message); } else { self.background_voice_error = Some((thread_id, message)); } } AppEvent::RealtimeConversationStateChanged => { self.repaint_agents_overview(); } AppEvent::VoiceControl { thread_id, control } => { if thread_id == self.chat_widget.thread_id() || self.voice_owner_thread_id().is_some() { self.control_voice(control); } } AppEvent::RealtimeSessionLockStopped { thread_id } => { if let Some(owner) = self.voice_widget_for_thread(thread_id) { owner.stop_if_session_locked(); } } AppEvent::RealtimeWebrtcOfferCreated { thread_id, attempt_id, result, } => { if let Some(owner) = self.voice_widget_for_thread(thread_id) { owner.on_realtime_webrtc_offer_created(thread_id, attempt_id, result); } else if let Ok(offer) = result { offer.handle.close(); } } AppEvent::RealtimeWebrtcConnected { thread_id, attempt_id, result, } => { if let Some(owner) = self.voice_widget_for_thread(thread_id) { owner.on_realtime_webrtc_connected(attempt_id, result); } } AppEvent::StopRealtimeConversation { thread_id } => { match tokio::time::timeout( SHUTDOWN_FIRST_EXIT_TIMEOUT, app_server.thread_realtime_stop(thread_id), ) .await { Ok(Ok(())) => {} Ok(Err(error)) => tracing::warn!( %thread_id, %error, "failed to stop voice conversation after switching threads" ), Err(_) => tracing::warn!( %thread_id, "timed out stopping voice conversation after switching threads" ), } } AppEvent::SettingsSelectionClosed => { #[cfg(any(target_os = "windows", test))] let windows_sandbox_setup_pending = self.windows_sandbox.pending_setup.is_some(); #[cfg(not(any(target_os = "windows", test)))] let windows_sandbox_setup_pending = false; if self.chat_widget.no_modal_or_popup_active() && !windows_sandbox_setup_pending { self.chat_widget .recover_inline_permission_profile_selection(); } self.app_event_tx.send(AppEvent::SettingsSelectionSettled); } AppEvent::SettingsSelectionSettled => { if !self.chat_widget.permission_popup_pending && self.chat_widget.no_modal_or_popup_active() && !self .chat_widget .inline_permission_profile_selection_pending() && !self .chat_widget .thread_id() .is_some_and(|thread_id| { self.pending_server_profiles.contains_key(&thread_id) || self .agents_overview .requested_permission_profiles .get(&thread_id) .is_some_and(|selection| { let config = self.chat_widget.config_ref(); config .permissions .active_permission_profile() .is_none_or(|active| { active.id != selection.profile_id }) || selection.approval_policy.is_some_and(|policy| { config.permissions.approval_policy.value() != policy.to_core() }) || selection .approvals_reviewer .is_some_and(|reviewer| { config.approvals_reviewer != reviewer }) }) }) { let config = self.chat_widget.config_ref(); let permissions_override = Self::turn_permissions_override_from_config( config, config.permissions.active_permission_profile().as_ref(), self.runtime_permission_profile_override .as_ref() .and_then(RuntimePermissionProfileOverride::turn_permission_profile), ); if turn_permissions_overrides(permissions_override, config.cwd.as_path()) .is_ok() { self.chat_widget .set_queue_autosend_suppressed(/*suppressed*/ false); self.chat_widget.maybe_send_next_queued_input(); } } } AppEvent::FetchPermissionProfiles { request_id, thread_cwd } => { if self.chat_widget.permission_popup_request_is_current(request_id) { crate::permission_discovery::fetch( app_server, request_id, self.chat_widget.config_ref(), thread_cwd.as_deref(), self.app_event_tx.clone(), ); } } AppEvent::PermissionProfilesLoaded { request_id, result } => { self.chat_widget.on_permission_profiles_loaded(request_id, result); } AppEvent::FetchModels { request_id } => { if self.chat_widget.model_popup_request_is_current(request_id) { app_server.fetch_models(request_id, self.app_event_tx.clone()); } } AppEvent::ModelsLoaded { request_id, result: Ok(models) } if self.chat_widget.model_popup_request_is_current(request_id) && self.chat_widget.status_account_display() == Some(&StatusAccountDisplay::ApiKey) => { let checked = app_server.constrain_api_key_models( self.chat_widget.config_ref(), self.chat_widget.thread_id(), /*requirements*/ None, models, ); let tx = self.app_event_tx.clone(); tokio::spawn(async move { tx.send(AppEvent::ApiKeyCompatModelsLoaded { request_id, result: Ok(checked.await) }); }); } AppEvent::ModelsLoaded { request_id, result } | AppEvent::ApiKeyCompatModelsLoaded { request_id, result } => { self.chat_widget.on_models_loaded(request_id, result); let catalog = self.chat_widget.model_catalog(); if !Arc::ptr_eq(&self.model_catalog, &catalog) { self.model_catalog = catalog; app_server.set_available_models(self.model_catalog.try_list_models()?); self.sync_active_thread_service_tier_to_cached_session().await; } } AppEvent::OpenReasoningPopup { model } => { self.chat_widget.open_reasoning_popup(model); } AppEvent::OpenAdvancedReasoningPopup { model } => { self.chat_widget.open_advanced_reasoning_popup(model); } AppEvent::ApplyAdvancedReasoning { model, effort } => { self.app_event_tx.send(AppEvent::FollowTranscript); if app_server.thread_params_mode().settings_owner() == ThreadSettingsOwner::Server { self.select_session_model( app_server, model, Some(effort), /*service_tier*/ None, ) .await; if let Some(model) = sparkle_model.as_ref() { self.chat_widget .on_sparkle_model_selected_from_picker(model); } return Ok(AppRunControl::Continue); } if self .active_thread_model_setting_update_params(model.clone()) .is_some_and(|params| params.permissions.is_some()) && self.reject_pending_permission_change() { return Ok(AppRunControl::Continue); } let model_changed = self.chat_widget.current_model() != model || self.chat_widget.current_collaboration_mode().model() != model; let default_effort = self.on_apply_advanced_reasoning(model.as_str(), effort.clone()); if model_changed { self.sync_active_thread_model_setting( app_server, model.clone(), Some(effort.clone()), ) .await; } else if let Some(mut params) = self.active_thread_reasoning_setting_update_params(Some(effort.clone())) { params.collaboration_mode = Some(self.chat_widget.effective_collaboration_mode()); self.send_thread_settings_update(app_server, params).await; } self.sync_active_thread_service_tier_to_cached_session() .await; if let Some(default_effort) = default_effort.as_ref() && let Err(err) = self.persist_model_defaults( app_server.request_handle(), crate::config_update::build_model_selection_edits( model.as_str(), Some(default_effort), ), "default model and reasoning effort", ) .await { let error = format_config_error(&err); tracing::error!(error = %error, "failed to persist conversation model"); self.chat_widget .add_error_message(format!("Failed to save default model: {error}")); } else { self.chat_widget.add_info_message( format!("Model changed to {model} {effort} for this conversation"), /*hint*/ None, ); } } AppEvent::OpenPlanReasoningScopePrompt { model, effort } => { self.chat_widget .open_plan_reasoning_scope_prompt(model, effort); } AppEvent::OpenAllModelsPopup => { self.chat_widget.open_all_models_popup(); } AppEvent::OpenFullAccessConfirmation { preset, return_to_permissions, profile_selection, } => { self.chat_widget.open_full_access_confirmation( preset, return_to_permissions, profile_selection, ); } AppEvent::ApplyPermissionShortcut { thread_id, selection } => { self.apply_permission_shortcut(app_server, thread_id, selection).await; } AppEvent::OpenFeedbackNote { category, include_logs, } => { self.chat_widget.open_feedback_note(category, include_logs, self.feedback_audience); } 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::RefreshWindowsSandbox { thread_id } => { #[cfg(any(target_os = "windows", test))] self.refresh_windows_sandbox_for_thread(app_server, thread_id).await; #[cfg(not(any(target_os = "windows", test)))] let _ = thread_id; } AppEvent::OpenWindowsSandboxEnablePrompt { preset, profile_selection, } => { self.chat_widget .open_windows_sandbox_enable_prompt(preset, profile_selection); } AppEvent::OpenWindowsSandboxFallbackPrompt { preset, profile_selection, } => { #[cfg(any(target_os = "windows", test))] { self.windows_sandbox.pending_setup = None; } 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, profile_selection); } AppEvent::BeginWindowsSandboxElevatedSetup { preset, profile_selection, } => { #[cfg(target_os = "windows")] self.begin_windows_sandbox_setup( app_server, preset, profile_selection, WindowsSandboxEnableMode::Elevated, ) .await; #[cfg(not(target_os = "windows"))] let _ = (preset, profile_selection); } AppEvent::BeginWindowsSandboxLegacySetup { preset, profile_selection, } => { #[cfg(target_os = "windows")] self.begin_windows_sandbox_setup( app_server, preset, profile_selection, WindowsSandboxEnableMode::Legacy, ) .await; #[cfg(not(target_os = "windows"))] let _ = (preset, profile_selection); } AppEvent::EnableWindowsSandboxForAgentMode { preset, mode, profile_selection, } => { #[cfg(any(target_os = "windows", test))] { self.windows_sandbox.pending_setup = None; } #[cfg(target_os = "windows")] { self.chat_widget.clear_windows_sandbox_setup_status(); if let Some(started_at) = self.windows_sandbox.setup_started_at.take() && mode == WindowsSandboxEnableMode::Elevated { self.session_telemetry.record_duration( "codex.windows_sandbox.elevated_setup_duration_ms", started_at.elapsed(), &[("result", "success")], ); } let selected_mode = match mode { WindowsSandboxEnableMode::Elevated => WindowsSandboxSetupMode::Elevated, WindowsSandboxEnableMode::Legacy => WindowsSandboxSetupMode::Unelevated, }; let elevated_enabled = selected_mode == WindowsSandboxSetupMode::Elevated; if self .verify_windows_sandbox_mode_after_setup(app_server, selected_mode) .await { self.chat_widget.windows_sandbox_elevated_setup_complete = elevated_enabled; if let Some(selection) = profile_selection { let inline = self.chat_widget.complete_inline_permission_profile_selection(); self.select_permission_profile(app_server, selection).await; if inline { self.app_event_tx.send(AppEvent::SettingsSelectionClosed); } } else { self.app_event_tx.send(AppEvent::CodexOp( AppCommand::override_turn_context( /*cwd*/ None, Some(AskForApproval::from(preset.approval)), Some(self.config.approvals_reviewer), Some(preset.permission_profile.clone()), Some(preset.active_permission_profile.clone()), /*model*/ None, /*effort*/ None, /*summary*/ None, /*service_tier*/ None, /*collaboration_mode*/ None, ), )); self.app_event_tx.send(AppEvent::UpdateAskForApprovalPolicy( AskForApproval::from(preset.approval), )); self.app_event_tx .send(AppEvent::UpdateActivePermissionProfile( preset.active_permission_profile.clone(), )); } 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(), ]), ]); } else if self .chat_widget .recover_inline_permission_profile_selection() { self.app_event_tx.send(AppEvent::SettingsSelectionClosed); } else { self.chat_widget .retain_input_after_failed_permission_selection(); } } #[cfg(not(target_os = "windows"))] { let _ = (preset, mode, profile_selection); } } AppEvent::PersistModelSelection { model, effort } => { self.persist_default_model_selection(app_server.request_handle(), model, effort) .await; } AppEvent::PersistDaybreakSelection { thread_id, enabled } => { self.persist_daybreak_selection(app_server, thread_id, enabled).await; } AppEvent::SelectSessionModel { model, effort, service_tier } => { self.app_event_tx.send(AppEvent::FollowTranscript); self.select_session_model(app_server, model, effort, service_tier).await; } AppEvent::OpenQuickDialEditor { target, dial } => { self.chat_widget.open_quick_dial_editor(target, dial); } AppEvent::OpenQuickDialSavePicker(dial) => { self.chat_widget.open_quick_dial_save_picker(dial); } AppEvent::ConfirmQuickDial { slot, dial } => self.chat_widget.confirm_quick_dial(slot, dial), AppEvent::SaveQuickDial { slot, dial } => { self.app_event_tx.send(AppEvent::FollowTranscript); self.save_quick_dial(slot, dial).await; } AppEvent::RecallQuickDial { slot, thread_id } => { if self.chat_widget.thread_id() == thread_id { self.chat_widget.recall_quick_dial(slot); } } AppEvent::CyberModelAutoReviewNotice => { self.chat_widget.add_warning_message( "Cyber models default to \"Approve for me\" for safety reasons.".to_string(), ); } AppEvent::PluginUninstallLoaded { cwd, plugin_id: _plugin_id, plugin_display_name, result, } => { let uninstall_succeeded = result.is_ok(); if uninstall_succeeded { self.refresh_plugin_mentions_after_config_write(); } 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(app_server); } AppEvent::PluginMentionsLoaded { mut plugins, .. } => { if !self.config.features.enabled(Feature::Plugins) { plugins = None; } self.chat_widget.on_plugin_mentions_loaded(plugins); } AppEvent::OpenRealtimeSettings => self.chat_widget.open_realtime_settings(), AppEvent::OpenRealtimeSoundDevices => self.chat_widget.open_realtime_sound_devices(), AppEvent::OpenRealtimeVoices => self.open_realtime_voices(app_server).await, AppEvent::OpenRealtimeDevicePicker { kind } => self.list_realtime_devices(kind), AppEvent::OpenRealtimeInputChannels { device } => self.chat_widget.open_realtime_input_channels(device), AppEvent::RealtimeDevicesListed { origin, kind, result } => { if origin == self.active_thread_id { match result { Ok(devices) => self.chat_widget.open_realtime_device_picker(kind, devices), Err(error) => self.chat_widget.add_error_message(error), } } } AppEvent::PersistRealtimeDevice { kind, name } => { self.persist_realtime_device(kind, name).await; } AppEvent::PersistRealtimeInputChannel { channel } => { self.persist_realtime_input_channel(channel).await; } AppEvent::PersistRealtimeVoiceSelection { voice } => { self.persist_realtime_voice(app_server, voice).await; } AppEvent::PersistServiceTierSelection { service_tier } => { self.refresh_status_line(); self.config.service_tier = service_tier.clone(); self.sync_active_thread_service_tier_to_cached_session() .await; let edits = crate::config_update::build_service_tier_selection_edits( service_tier.as_deref(), ); match self.persist_model_defaults(app_server.request_handle(), edits, "default service tier") .await { Ok(()) => { let message = if let Some(service_tier) = service_tier { format!("Service tier set to {service_tier}") } else { "Service tier cleared".to_string() }; self.chat_widget.add_info_message(message, /*hint*/ None); } Err(err) => { tracing::error!(error = %err, "failed to persist service tier selection"); let error = format_config_error(&err); self.chat_widget.add_error_message(format!( "Failed to save default service tier: {error}\n\ You can continue this task. To save the default, resolve the error above, then switch to a different tier and back to the desired tier." )); } } } AppEvent::UpdateAskForApprovalPolicy(policy) => { if self.reject_pending_permission_change() { return Ok(AppRunControl::Continue); } 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; let approval_policy = AskForApproval::from(self.config.permissions.approval_policy.value()); self.runtime_approval_policy_override = Some(RuntimeApprovalPolicyOverride::Explicit(approval_policy)); self.chat_widget.set_approval_policy(approval_policy); self.sync_active_thread_permission_settings_to_cached_session() .await; } AppEvent::UpdateActivePermissionProfile(active_permission_profile) => { if self.reject_pending_permission_change() { return Ok(AppRunControl::Continue); } let mut config = self.config.clone(); let Some(permission_profile) = self .try_set_builtin_active_permission_profile_on_config( &mut config, active_permission_profile.clone(), "Failed to set permission profile", "failed to set active permission profile on app config", ) else { return Ok(AppRunControl::Continue); }; let permission_profile_for_chat = permission_profile.clone(); self.config = config; if let Err(err) = self .chat_widget .set_permission_profile_from_session_snapshot( PermissionProfileSnapshot::active( permission_profile_for_chat, active_permission_profile, ), ) { tracing::warn!(%err, "failed to set permission profile on chat config"); self.chat_widget .add_error_message(format!("Failed to set permission profile: {err}")); return Ok(AppRunControl::Continue); } self.runtime_approvals_reviewer_override = Some(self.config.approvals_reviewer); self.runtime_permission_profile_override = Some(RuntimePermissionProfileOverride::from_config(&self.config)); self.sync_active_thread_permission_settings_to_cached_session() .await; self.chat_widget.submit_initial_user_message_if_pending(); } AppEvent::SelectPermissionProfile(selection) => { let inline_selection = self .chat_widget .complete_inline_permission_profile_selection(); self.select_permission_profile(app_server, selection).await; if inline_selection { self.app_event_tx.send(AppEvent::SettingsSelectionClosed); } } AppEvent::UpdateApprovalsReviewer(policy) => { if self.reject_pending_permission_change() { return Ok(AppRunControl::Continue); } self.runtime_approvals_reviewer_override = Some(policy); self.config.approvals_reviewer = policy; self.chat_widget.set_approvals_reviewer(policy); self.sync_active_thread_permission_settings_to_cached_session() .await; if let Err(err) = crate::config_update::write_config_batch( app_server.request_handle(), vec![crate::config_update::replace_config_value( "approvals_reviewer", serde_json::json!(policy.to_string()), )], ) .await { tracing::error!( error = %err, "failed to persist approvals reviewer update" ); self.chat_widget.add_error_message(format!( "Failed to save approvals reviewer: {}", format_config_error(&err) )); } } AppEvent::FetchExperimentalFeatures { thread_id, response_tx } => { self.fetch_experimental_features(app_server, thread_id, response_tx); } AppEvent::SaveExperimentalFeatures { thread_id, updates, response_tx } => { self.save_experimental_features(app_server, thread_id, updates, response_tx); } AppEvent::EnableFeatureForNewThreads(feature) => { self.enable_feature_for_new_threads(tui, app_server, feature) .await; } AppEvent::UpdateFeatureFlags { updates } => { self.update_feature_flags(app_server, 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::UpdateRateLimitSwitchPromptHidden(hidden) => { self.chat_widget.set_rate_limit_switch_prompt_hidden(hidden); } AppEvent::UpdatePlanModeReasoningEffort(effort) => { self.on_update_plan_mode_reasoning_effort(effort); self.sync_active_thread_plan_mode_reasoning_setting(app_server) .await; } AppEvent::PersistRateLimitSwitchPromptHidden => { self.local_settings.notices.hide_rate_limit_model_nudge = Some(true); if let Err(err) = ConfigEditsBuilder::for_config_path(self.local_settings.user_config_path.as_path()) .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) => { self.app_event_tx.send(AppEvent::FollowTranscript); let key_path = "plan_mode_reasoning_effort"; let edit = if let Some(effort) = effort { crate::config_update::replace_config_value( key_path, serde_json::json!(effort.to_string()), ) } else { crate::config_update::clear_config_value(key_path) }; if let Err(err) = self.persist_model_defaults( app_server.request_handle(), vec![edit], "Plan mode reasoning effort", ) .await { tracing::error!( error = %err, "failed to persist plan mode reasoning effort" ); 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::for_config_path(self.local_settings.user_config_path.as_path()) .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::OpenAgentsOverview => self.open_agents_overview(app_server), AppEvent::ShowMoreAgentsOverview => self.show_more_agents_overview(app_server), AppEvent::RefreshViewedThread { backend_id, thread_id, revision } => { if app_server.backend_id() == backend_id { app_server.frozen_receipt.take_if(|(id, rev)| *id == thread_id && *rev == revision); self.agents_overview.refresh_thread_ids.insert(thread_id); self.refresh_changed_agents_overview_threads(app_server); } } AppEvent::NewAgentsOverviewSession { cwd } => { return Box::pin(self.new_agents_overview_session(tui, app_server, cwd)).await; } AppEvent::AgentsOverviewThreadsLoaded { request_id, result } => { self.apply_agents_overview_thread_refresh(app_server, request_id, result); } AppEvent::SelectAgentsOverviewThread { thread_id } => { match self .select_agents_overview_thread(tui, app_server, thread_id) .await? { AppRunControl::Continue if self.primary_thread_id.is_none() && self.chat_widget.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID).is_none() => { self.open_agents_overview(app_server); } AppRunControl::Continue => {} AppRunControl::Exit(reason) => return Ok(AppRunControl::Exit(reason)), } } AppEvent::ForkAgentsOverviewThread { thread_id } => { if self.reconnect.offline { return Ok(AppRunControl::Continue); } if !self.side_threads.is_empty() && self.current_displayed_thread_id() != Some(thread_id) { self.add_agents_overview_error( "Close the side conversation before forking another task.".into(), ); return Ok(AppRunControl::Continue); } // Attachment replay must not submit queued input on the source task. self.chat_widget.fork_in_progress = true; let control = self.select_agents_overview_thread(tui, app_server, thread_id).await; if matches!(&control, Ok(AppRunControl::Continue)) && self.current_displayed_thread_id() == Some(thread_id) { if let Some(input) = self.chat_widget.capture_thread_input_state() { self.agents_overview.input_states.insert(thread_id, input); } self.app_event_tx.send(AppEvent::ForkAgentsOverviewThreadReady { thread_id }); } else { self.chat_widget.fork_in_progress = false; } return control; } AppEvent::ForkAgentsOverviewThreadReady { .. } => unreachable!("normalized above"), AppEvent::NewAgentsOverviewWorktree { cwd } => { Box::pin(self.new_agents_overview_worktree(tui, app_server, cwd)).await; } AppEvent::AgentsOverviewWorktreeCreated(result) => { self.pending_managed_worktree_creation = false; self.agents_overview.view_state.lock().unwrap_or_else(std::sync::PoisonError::into_inner).creating_worktree = false; match result { Ok(mut pending) => { let Some(checkout) = pending.checkout.take() else { return Ok(AppRunControl::Continue); }; let manager = pending.manager.clone(); let cwd = AbsolutePathBuf::try_from(checkout.cwd.clone())?; return Box::pin(self.start_agents_overview_session(tui, app_server, Some(cwd), Some((manager, checkout)), /*startup_draft*/ None)).await; } Err(error) => self.add_agents_overview_error(error), } } AppEvent::RenameAgentsOverviewThread { thread_id, name } => { match app_server.thread_set_name(thread_id, name.clone()).await { Ok(()) => { self.chat_widget.expect_manual_thread_name(thread_id, name); self.cancel_thread_title_generation(thread_id); } Err(error) => { if let Ok(mut state) = self.agents_overview.view_state.lock() { state.set_rename_input(&name, &self.keymap); state.rename_target = Some(thread_id); } self.repaint_agents_overview(); self.add_agents_overview_error(format!("Failed to rename task: {error}")); } } } AppEvent::ToggleAgentsOverviewPin { thread_id, pinned } => { self.toggle_agents_overview_pin(app_server, thread_id, pinned); } AppEvent::AgentsOverviewPinToggled { request_id, thread_id, pinned, result, } => { self.complete_agents_overview_pin( app_server, request_id, thread_id, pinned, result, ); } AppEvent::SuggestThreadName { thread_id, request_id, } => { self.suggest_thread_name(app_server, thread_id, request_id) .await; } AppEvent::ThreadTitleStarted { cancellation, thread_id, destination, prompt, effort, result, } => { self.on_thread_title_started( app_server, thread_id, destination, prompt, effort, result, cancellation, ); } AppEvent::GeneratedThreadTitle { cancellation, thread_id, temporary_thread_id, destination, result, } => { self.temporary_structured_requests .remove(&temporary_thread_id); if cancellation.is_cancelled() { return Ok(AppRunControl::Continue); } self.finish_thread_title_generation(thread_id, destination); if let ThreadTitleDestination::RenameSuggestion { request_id } = destination { let suggestion = result.ok() .and_then(|response| super::thread_title::parse_thread_title(&response)); self.chat_widget.apply_thread_name_suggestion( thread_id, request_id, suggestion.as_deref(), ); } } AppEvent::HideAgentsOverviewThread { thread_id } => { self.prepare_agents_overview_removal(&HashSet::from([thread_id])); self.agents_overview.hidden_threads.insert(thread_id); self.repaint_agents_overview(); } AppEvent::ConfirmAgentsOverviewAction { thread_id, action } => { self.confirm_agents_overview_action(thread_id, action); } AppEvent::RunAgentsOverviewAction { thread_id, action } => { self.run_agents_overview_action(tui, app_server, thread_id, action).await?; } AppEvent::StopAgentsOverviewThread { thread_id } => { self.stop_agents_overview_thread(app_server, thread_id) .await; } AppEvent::OpenAgentPicker => { self.open_agent_picker(app_server).await; } AppEvent::AgentPickerThreadsLoaded { primary_thread_id, request_id, result, } => { self.apply_agent_picker_thread_refresh( app_server, primary_thread_id, request_id, result, ); } AppEvent::SelectAgentThread(thread_id) => { self.select_agent_thread(tui, app_server, thread_id) .await?; } AppEvent::StartSide { parent_thread_id, user_message, } => { return Box::pin(self.handle_start_side(tui, app_server, parent_thread_id, user_message)).await; } AppEvent::SideCreated { backend_id: _, parent, request, result, } => { Box::pin(self.finish_side_creation(tui, app_server, parent, request, result)).await; } AppEvent::OpenSkillsList => { self.chat_widget.open_skills_list(); } AppEvent::OpenManageSkillsPopup => { self.chat_widget.open_manage_skills_popup(); } AppEvent::SetSkillEnabled { path, enabled } => { match crate::config_update::write_skill_enabled( app_server.request_handle(), path.clone(), enabled, ) .await { Ok(()) => { self.chat_widget.update_skill_enabled(path, enabled); } Err(err) => { let path_display = path.inferred_native_path_string(); 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![ crate::config_update::clear_config_value( crate::config_update::app_scoped_key_path(&id, "enabled"), ), crate::config_update::clear_config_value( crate::config_update::app_scoped_key_path(&id, "disabled_reason"), ), ] } else { vec![ crate::config_update::replace_config_value( crate::config_update::app_scoped_key_path(&id, "enabled"), serde_json::json!(false), ), crate::config_update::replace_config_value( crate::config_update::app_scoped_key_path(&id, "disabled_reason"), serde_json::json!("user"), ), ] }; match crate::config_update::write_config_batch(app_server.request_handle(), edits) .await { Ok(_) => { self.chat_widget.update_connector_enabled(&id, enabled); } Err(err) => { self.chat_widget.add_error_message(format!( "Failed to update app config for {id}: {err}" )); } } } AppEvent::SetHookEnabled { key, enabled } => { self.set_hook_enabled(app_server, key, enabled); } AppEvent::TrustHook { key, current_hash } => { self.trust_hook(app_server, key, current_hash); } AppEvent::TrustHooks { updates } => { self.trust_hooks(app_server, updates); } AppEvent::HookEnabledSet { key, enabled, result, } => { let queued_enabled = self .pending_hook_enabled_writes .get_mut(&key) .and_then(Option::take); let should_apply_result = if let Some(queued_enabled) = queued_enabled && (result.is_err() || queued_enabled != enabled) { self.spawn_hook_enabled_write(app_server, key.clone(), queued_enabled); false } else { true }; if should_apply_result { self.pending_hook_enabled_writes.remove(&key); if let Err(err) = result { self.chat_widget.add_error_message(err); } } } AppEvent::HookTrusted { result } => { if let Err(err) = result { self.chat_widget.add_error_message(err); } } AppEvent::ResolvePermissionProfile { profile_id } => { if self.reject_pending_permission_change() { self.chat_widget .retain_input_after_failed_permission_selection(); self.app_event_tx.send(AppEvent::SettingsSelectionClosed); return Ok(AppRunControl::Continue); } #[cfg(any(target_os = "windows", test))] if self.chat_widget.windows_sandbox_local_server && self.windows_sandbox_host() != WindowsSandboxHost::Remote && self.chat_widget.windows_sandbox_config.requirements.is_none() && !self.refresh_windows_sandbox_config(app_server).await { self.app_event_tx.send(AppEvent::SettingsSelectionClosed); return Ok(AppRunControl::Continue); } self.chat_widget .select_permission_profile(&profile_id); } AppEvent::OpenPermissionsPopup | AppEvent::OpenApprovalsPopup => { self.chat_widget.permission_popup_pending = false; if self.reject_pending_permission_change() { self.app_event_tx.send(AppEvent::SettingsSelectionClosed); return Ok(AppRunControl::Continue); } #[cfg(any(target_os = "windows", test))] if self.chat_widget.windows_sandbox_local_server && self.windows_sandbox_host() != WindowsSandboxHost::Remote && self.chat_widget.windows_sandbox_config.requirements.is_none() && !self.refresh_windows_sandbox_config(app_server).await { self.app_event_tx.send(AppEvent::SettingsSelectionClosed); return Ok(AppRunControl::Continue); } if app_server.uses_remote_workspace() { self.chat_widget.request_permission_profiles(); } else { self.chat_widget.open_approvals_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::FullScreenUserVerificationRequest(request) => { let _ = tui.enter_alt_screen(); self.overlay = Some(Overlay::new_static_with_renderables( vec![crate::bottom_pane::user_verification::prompt_header(&request)], "U S E R V E R I F I C A T I O N".to_string(), self.keymap.pager.clone(), )); } AppEvent::FullScreenApprovalRequest(request) => match request { ApprovalRequest::ApplyPatch(request) => { let _ = tui.enter_alt_screen(); let diff_summary = DiffSummary::new(request.changes, request.cwd); self.overlay = Some(Overlay::new_static_with_renderables( vec![diff_summary.into()], "P A T C H".to_string(), self.keymap.pager.clone(), )); } ApprovalRequest::Exec(request) => { let _ = tui.enter_alt_screen(); let full_cmd = strip_bash_lc_and_escape(&request.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(), self.keymap.pager.clone(), )); } ApprovalRequest::Permissions(request) => { let _ = tui.enter_alt_screen(); let mut lines = Vec::new(); if let Some(environment_id) = request.environment_id { lines.push(Line::from(vec![ "Environment: ".into(), environment_id.bold(), ])); lines.push(Line::from("")); } if let Some(reason) = request.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(&request.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(), self.keymap.pager.clone(), )); } ApprovalRequest::McpElicitation(request) => { let _ = tui.enter_alt_screen(); let paragraph = Paragraph::new(vec![ Line::from(vec!["Server: ".into(), request.server_name.bold()]), Line::from(""), Line::from(request.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(), self.keymap.pager.clone(), )); } }, AppEvent::FullscreenTranscriptSelected { enabled } => { self.save_fullscreen_transcript(enabled).await; } AppEvent::StatusLineSetup { items, use_theme_colors, } => { let ids = items.iter().map(ToString::to_string).collect::>(); let items_edit = crate::legacy_core::config::edit::status_line_items_edit(&ids); let colors_edit = crate::legacy_core::config::edit::status_line_use_colors_edit(use_theme_colors); let apply_result = ConfigEditsBuilder::for_config_path(self.local_settings.user_config_path.as_path()) .with_edits([items_edit, colors_edit]) .apply() .await; match apply_result { Ok(()) => { self.local_settings.tui.status_line = Some(ids.clone()); self.local_settings.tui.status_line_use_colors = use_theme_colors; self.chat_widget.setup_status_line(items, use_theme_colors); self.chat_widget.refresh_config_panel(/*notice*/ None); } Err(err) => { let error = format_config_error(&err); tracing::error!(error = %error, "failed to persist status line settings; keeping previous selection"); self.app_event_tx.send(AppEvent::FollowTranscript); let message = format!("Failed to save status line settings: {error}"); self.chat_widget.report_config_save_error(message); } } } AppEvent::StatusLineBranchUpdated { cwd, branch } => { self.chat_widget.set_status_line_branch(cwd, branch); self.refresh_status_line(); } AppEvent::StatusLineGitSummaryUpdated { cwd, summary } => { self.chat_widget.set_status_line_git_summary(cwd, summary); self.refresh_status_line(); } AppEvent::StatusLineWorkspaceHeadlineUpdated { request_id, result } => { if self .chat_widget .set_status_line_workspace_headline(request_id, result) { tui.frame_requester().schedule_frame(); } } AppEvent::StatusLineSetupCancelled => { self.chat_widget.cancel_status_line_setup(); self.chat_widget.refresh_config_panel(/*notice*/ None); } 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::for_config_path(self.local_settings.user_config_path.as_path()) .with_edits([edit]) .apply() .await; match apply_result { Ok(()) => { self.local_settings.tui.terminal_title = Some(ids.clone()); self.chat_widget.setup_terminal_title(items); self.chat_widget.refresh_config_panel(/*notice*/ None); } Err(err) => { tracing::error!(error = %err, "failed to persist terminal title items; keeping previous selection"); self.app_event_tx.send(AppEvent::FollowTranscript); self.chat_widget.revert_terminal_title_setup_preview(); let message = format!("Failed to save terminal title items: {err}"); self.chat_widget.report_config_save_error(message); } } } AppEvent::TerminalTitleSetupPreview { items } => { self.chat_widget.preview_terminal_title(items); } AppEvent::TerminalTitleSetupCancelled => { self.chat_widget.cancel_terminal_title_setup(); self.chat_widget.refresh_config_panel(/*notice*/ None); } AppEvent::Provider(event) => super::provider::handle(self, tui, app_server, event), AppEvent::OpenConfigEditor(handoff) => { self.cancel_pending_key_chord(); if let Err(err) = tui.discard_pending_input_before_interactive_screen() { tracing::warn!(%err, "failed to discard input before opening config editor"); self.chat_widget.config_editor_open_failed(format!( "Failed to open setting because terminal input could not be cleared: {err}" )); } else { let resume_current_availability = super::resume_config::resume_current_directory_availability( &self.app_server_target, self.environment_manager.as_ref(), self.runtime_working_directory_override .as_deref() .or(self.harness_overrides.cwd.as_deref()) .or_else(|| app_server.remote_cwd_override()), ); self.chat_widget .open_config_editor(handoff, resume_current_availability); self.chat_widget.refresh_config_panel(/*notice*/ None); } } AppEvent::TestConfigNotification => self.schedule_config_notification_test(), AppEvent::SendConfigTestNotification { generation } => { self.send_config_notification_test(tui, generation) } AppEvent::SaveConfigPreference(preference) => { self.save_config_preference(tui, preference).await; } AppEvent::PersistAgentsOverviewGrouping(grouping) => { self.persist_agents_overview_grouping(grouping).await; } AppEvent::SyntaxThemeSelected { name } => { let edit = crate::legacy_core::config::edit::syntax_theme_edit(&name); let apply_result = ConfigEditsBuilder::for_config_path(self.local_settings.user_config_path.as_path()) .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.local_settings.codex_home), ) { crate::render::highlight::set_syntax_theme(theme); } self.sync_tui_theme_selection(name); self.chat_widget.refresh_config_panel(/*notice*/ None); self.refresh_status_line(); tui.frame_requester().schedule_frame(); } Err(err) => { self.restore_runtime_theme_from_config(); self.refresh_status_line(); tracing::error!(error = %err, "failed to persist theme selection"); self.app_event_tx.send(AppEvent::FollowTranscript); let message = format!("Failed to save theme: {err}"); self.chat_widget.report_config_save_error(message); } } } AppEvent::SyntaxThemePreviewed => { self.refresh_status_line(); tui.frame_requester().schedule_frame(); } AppEvent::OpenKeymapActionMenu { context, action } => { self.chat_widget .open_keymap_action_menu(context, action, &self.keymap); } AppEvent::OpenKeymapReplaceBindingMenu { context, action } => { self.chat_widget .open_keymap_replace_binding_menu(context, action, &self.keymap); } AppEvent::OpenKeymapCapture { context, action, intent, capture_mode, } => { self.chat_widget.open_keymap_capture( context, action, intent, capture_mode, &self.keymap, ); } AppEvent::OpenKeymapDebug => { self.chat_widget.open_keymap_debug(&self.keymap); } AppEvent::KeymapCaptured { context, action, key, intent, } => { self.apply_keymap_capture(app_server, context, action, key, intent) .await; self.merge_startup_warnings(tui, &history_cell::StartupWarningsCell::default()); } AppEvent::KeymapCleared { context, action } => { self.apply_keymap_clear(context, action).await; self.merge_startup_warnings(tui, &history_cell::StartupWarningsCell::default()); } AppEvent::GenerateRecap { thread_id } => { if self.current_displayed_thread_id() == Some(thread_id) { if self.chat_widget.is_user_turn_pending_or_running() { self.chat_widget.add_error_message( "Wait for the current task to finish before running /recap.".to_string(), ); } else { self.request_recap(app_server, thread_id, RecapTrigger::Manual); } } } AppEvent::CheckRecap { thread_id } => { if self.current_displayed_thread_id() == Some(thread_id) && !self.chat_widget.is_user_turn_pending_or_running() && self .recap .should_generate(tokio::time::Instant::now().into_std()) { self.request_recap(app_server, thread_id, RecapTrigger::Automatic); } } AppEvent::RecapStarted { thread_id, request_id, trigger, completed_turn_count, turn_revision, history, result, } => { self.handle_recap_started( app_server, recap::RecapRequest { thread_id, request_id, trigger, completed_turn_count, turn_revision, }, history, result, ); } AppEvent::RecapGenerated { thread_id, request_id, trigger, temporary_thread_id, completed_turn_count, turn_revision, result, } => { self.temporary_structured_requests.remove(&temporary_thread_id); if let Some(cell) = self.handle_generated_recap( recap::RecapRequest { thread_id, request_id, trigger, completed_turn_count, turn_revision, }, temporary_thread_id, result, ) { self.insert_history_cell(tui, Box::new(cell)); } } } if let Some(model) = sparkle_model { self.chat_widget .on_sparkle_model_selected_from_picker(&model); } Ok(AppRunControl::Continue) } async fn apply_keymap_capture( &mut self, app_server: &mut AppServerSession, context: String, action: String, key: String, intent: crate::app_event::KeymapEditIntent, ) { let outcome = match crate::keymap_setup::keymap_with_edit( &self.local_settings.tui.keymap, &self.keymap, &context, &action, &key, &intent, ) { Ok(outcome) => outcome, Err(err) => { self.app_event_tx.send(AppEvent::FollowTranscript); self.chat_widget.add_error_message(err); return; } }; let (keymap_config, bindings, message) = match outcome { crate::keymap_setup::KeymapEditOutcome::Updated { keymap_config, bindings, message, } => (*keymap_config, bindings, message), crate::keymap_setup::KeymapEditOutcome::Unchanged { message } => { self.app_event_tx.send(AppEvent::FollowTranscript); self.chat_widget.add_info_message(message, /*hint*/ None); return; } }; let runtime_keymap = match RuntimeKeymap::for_session( &keymap_config, self.local_settings.workspace_enabled(&self.config), ) { Ok(runtime_keymap) => runtime_keymap, Err(err) => { let params = crate::keymap_setup::build_keymap_conflict_params( context, action, key, intent, err, &self.keymap, ); self.chat_widget.show_selection_view(params); return; } }; let save = if matches!(self.app_server_target, AppServerTarget::LocalDaemon { .. }) { // Let the daemon validate shared config before writing: older daemons may // not understand bindings introduced by this TUI, including new defaults. app_server .request_handle() .request_typed::( codex_app_server_protocol::ClientRequest::ConfigValueWrite { request_id: app_server.next_request_id(), params: codex_app_server_protocol::ConfigValueWriteParams { key_path: format!("tui.keymap.{context}.{action}"), value: match bindings.as_slice() { [binding] => serde_json::json!(binding), bindings => serde_json::json!(bindings), }, merge_strategy: codex_app_server_protocol::MergeStrategy::Replace, file_path: Some( self.local_settings.user_config_path.display().to_string(), ), expected_version: None, }, }, ) .await .map(|_| ()) .map_err(anyhow::Error::new) } else { let edit = crate::legacy_core::config::edit::keymap_bindings_edit( &context, &action, &bindings, ); ConfigEditsBuilder::for_config_path(self.local_settings.user_config_path.as_path()) .with_edits([edit]) .apply() .await }; match save { Ok(()) => { self.cancel_pending_key_chord(); self.local_settings.tui.keymap = keymap_config.clone(); self.keymap = runtime_keymap.clone(); self.chat_widget .apply_keymap_update(keymap_config, &runtime_keymap); self.sync_side_thread_ui(); self.chat_widget .return_to_keymap_picker(&context, &action, &runtime_keymap); self.app_event_tx.send(AppEvent::FollowTranscript); self.chat_widget.add_info_message(message, /*hint*/ None); } Err(err) => { tracing::error!(error = %err, "failed to persist keymap binding"); self.app_event_tx.send(AppEvent::FollowTranscript); self.chat_widget .report_config_save_error(format!("Failed to save shortcut: {err}")); } } } fn refresh_plugin_mentions_after_config_write(&mut self) { self.chat_widget.refresh_plugin_mentions(); self.chat_widget.submit_op(AppCommand::reload_user_config()); self.chat_widget .refresh_connector_mentions(/*force_refresh*/ true); } async fn apply_keymap_clear(&mut self, context: String, action: String) { let keymap_config = match crate::keymap_setup::keymap_without_custom_binding( &self.local_settings.tui.keymap, &context, &action, ) { Ok(keymap_config) => keymap_config, Err(err) => { self.app_event_tx.send(AppEvent::FollowTranscript); self.chat_widget.add_error_message(err); return; } }; let runtime_keymap = match RuntimeKeymap::for_session( &keymap_config, self.local_settings.workspace_enabled(&self.config), ) { Ok(runtime_keymap) => runtime_keymap, Err(err) => { self.app_event_tx.send(AppEvent::FollowTranscript); self.chat_widget .add_error_message(format!("Failed to refresh shortcuts: {err}")); return; } }; let edit = crate::legacy_core::config::edit::keymap_binding_clear_edit(&context, &action); match ConfigEditsBuilder::for_config_path(self.local_settings.user_config_path.as_path()) .with_edits([edit]) .apply() .await { Ok(()) => { self.cancel_pending_key_chord(); self.local_settings.tui.keymap = keymap_config.clone(); self.keymap = runtime_keymap.clone(); self.chat_widget .apply_keymap_update(keymap_config, &runtime_keymap); self.sync_side_thread_ui(); self.chat_widget .return_to_keymap_picker(&context, &action, &runtime_keymap); self.app_event_tx.send(AppEvent::FollowTranscript); self.chat_widget.add_info_message( format!("Removed custom shortcut for `{context}.{action}`."), /*hint*/ None, ); } Err(err) => { tracing::error!(error = %err, "failed to clear keymap binding"); self.app_event_tx.send(AppEvent::FollowTranscript); self.chat_widget .report_config_save_error(format!("Failed to remove shortcut: {err}")); } } } pub(super) async fn handle_exit_mode( &mut self, app_server: &mut AppServerSession, mode: ExitMode, ) -> AppRunControl { for (request_id, (_, task)) in self.dynamic_tool_tasks.drain() { task.abort(); let response = crate::dynamic_tools::failure_response( "TUI disconnected while handling a dynamic tool call", ); match serde_json::to_value(response) { Ok(result) => { if let Err(error) = app_server .resolve_server_request(request_id.clone(), result) .await { tracing::warn!(?request_id, %error, "failed to cancel dynamic tool call"); } } Err(error) => { tracing::warn!(?request_id, %error, "failed to serialize dynamic tool response") } } } match mode { ExitMode::ShutdownFirst | ExitMode::ShutdownAfterInterrupt => { // 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.side_threads.is_empty() && tokio::time::timeout( SHUTDOWN_FIRST_EXIT_TIMEOUT, self.shutdown_side_threads(app_server, /*root*/ None), ) .await .is_err() { tracing::warn!("timed out waiting for side-conversation shutdown"); } if self.pending_shutdown_exit_thread_id.is_some() || self.voice_owner_thread_id().is_some() { // This is a UI escape-hatch budget, not a protocol // deadline. A healthy local thread/unsubscribe round trip // should finish comfortably inside two seconds, while a // longer wait makes Ctrl+C feel broken when the app-server // is already wedged. if tokio::time::timeout( SHUTDOWN_FIRST_EXIT_TIMEOUT, self.shutdown_current_thread(app_server), ) .await .is_err() { tracing::warn!("timed out waiting for app-server thread shutdown"); } } self.pending_shutdown_exit_thread_id = None; AppRunControl::Exit(if mode == ExitMode::ShutdownAfterInterrupt { ExitReason::TurnInterrupted } else { ExitReason::UserRequested }) } ExitMode::Immediate => { self.stop_realtime_conversation(app_server).await; self.pending_shutdown_exit_thread_id = None; AppRunControl::Exit(ExitReason::UserRequested) } } } async fn return_to_agents_after_removal( &mut self, tui: &mut tui::Tui, app_server: &mut AppServerSession, thread_id: ThreadId, ) -> Result<()> { self.discard_thread_local_state(thread_id).await; self.agents_overview.input_states.remove(&thread_id); self.agents_overview.dispatched_requests.remove(&thread_id); self.reset_for_thread_switch(tui)?; self.pending_thread_switch_resets += 1; self.app_event_tx .send(AppEvent::ResetTranscriptForThreadSwitch); self.detach_current_thread_for_navigation(app_server, /*destination*/ None) .await; self.reset_thread_event_state().await; let init = self.chatwidget_init_for_forked_or_resumed_thread( tui, self.config.clone(), /*initial_user_message*/ None, ); self.replace_chat_widget(ChatWidget::new_with_app_event(init)); self.open_agents_overview(app_server); Ok(()) } pub(super) async fn archive_current_thread( &mut self, tui: &mut tui::Tui, app_server: &mut AppServerSession, ) -> Result { let Some(thread_id) = self.active_thread_id.or(self.chat_widget.thread_id()) else { self.chat_widget .add_error_message("A thread must start before it can be archived.".to_string()); return Ok(AppRunControl::Continue); }; if self.side_threads.contains_key(&thread_id) { self.chat_widget.add_error_message( "'/archive' is unavailable in side conversations. Press Ctrl+C to return to the main thread first." .to_string(), ); return Ok(AppRunControl::Continue); } if !self .shutdown_side_threads(app_server, Some(thread_id)) .await { return Ok(AppRunControl::Continue); } let result = async { self.stop_voice_for_removed_thread(app_server, thread_id) .await?; app_server.thread_archive(thread_id).await } .await; Ok(match result { Ok(()) => { self.track_agents_overview_notification(&ServerNotification::ThreadArchived( codex_app_server_protocol::ThreadArchivedNotification { thread_id: thread_id.to_string(), }, )); self.return_to_agents_after_removal(tui, app_server, thread_id) .await?; AppRunControl::Continue } Err(err) => { self.chat_widget .add_error_message(format!("Failed to archive current thread: {err}")); AppRunControl::Continue } }) } pub(super) async fn delete_current_thread( &mut self, tui: &mut tui::Tui, app_server: &mut AppServerSession, ) -> Result { let Some(thread_id) = self.active_thread_id.or(self.chat_widget.thread_id()) else { self.chat_widget .add_error_message("A thread must start before it can be deleted.".to_string()); return Ok(AppRunControl::Continue); }; if self.side_threads.contains_key(&thread_id) { self.chat_widget.add_error_message( "'/delete' is unavailable in side conversations. Press Ctrl+C to return to the main thread first." .to_string(), ); return Ok(AppRunControl::Continue); } if !self .shutdown_side_threads(app_server, Some(thread_id)) .await { return Ok(AppRunControl::Continue); } let result = async { self.stop_voice_for_removed_thread(app_server, thread_id) .await?; app_server.thread_delete(thread_id).await } .await; Ok(match result { Ok(()) => { self.track_agents_overview_notification(&ServerNotification::ThreadDeleted( codex_app_server_protocol::ThreadDeletedNotification { thread_id: thread_id.to_string(), }, )); self.return_to_agents_after_removal(tui, app_server, thread_id) .await?; AppRunControl::Continue } Err(err) => { self.chat_widget .add_error_message(format!("Failed to delete current thread: {err}")); AppRunControl::Continue } }) } }