Files
codex/codex-rs/tui/src/app/event_dispatch.rs
T
Eric Traut 025f6d715d Show unread results in the TUI command center (#58263)
## What changed

- Display blue filled dots for unread results and gray hollow dots for read idle tasks, preserving status text on narrow rows.
- Load unread state with thread listings and update markers from read-state notifications and refreshes.
- Acknowledge the observed receipt revision when a conversation is rendered in a focused, unobstructed view, including read-only snapshots, without clearing newer results.

## Testing

Extend rendering snapshots and tests for stale unread markers and read-only snapshot receipts.

GitOrigin-RevId: 2dc676c3ffe1e887a5bbf131c88891057386ffc8
2026-10-30 06:43:51 +00:00

4053 lines
188 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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::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<AppRunControl> {
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::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::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, 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::NewSession { 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();
}
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 } => {
let size = tui.prepare_draw_size()?;
self.render_owned_transcript(tui, size)?;
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::<crate::named_session_lookup::AmbiguousSessionName>()
.is_some() =>
{
self.chat_widget.add_error_message(err.to_string());
}
Err(err) => return Err(err),
}
}
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<Turn>)> = 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::ResetTranscriptForThreadSwitch => {
self.reset_for_thread_switch(tui)?;
self.pending_thread_switch_resets -= 1;
}
AppEvent::ResetTranscriptForThreadSwitchPreservingScreen => {
self.reset_transcript_state_after_clear();
tui.clear_pending_history_lines();
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 {
source,
copy_source,
cwd,
inline_visualization_context,
scrollback_reflow,
deferred_history_cell,
} => {
self.handle_consolidate_agent_message(
tui,
history_cell::AgentMarkdownCell::new_with_inline_visualizations(source, &cwd, inline_visualization_context).with_copy_source(copy_source),
scrollback_reflow,
deferred_history_cell,
)?;
self.chat_widget.note_stream_consolidation_completed();
self.insert_pending_usage_output_after_stream_shutdown(tui);
}
AppEvent::ConsolidateProposedPlan(source) => {
let end = self.transcript_cells.len();
let start = trailing_run_start::<history_cell::ProposedPlanStreamCell>(
&self.transcript_cells,
);
let consolidated: Arc<dyn HistoryCell> =
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(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::<UnsupportedLegacyPermissionProfile>()
.is_some();
if unsupported_permissions {
chat_widget
.set_queue_autosend_suppressed(/*suppressed*/ true);
}
let handled = is_user_turn
&& (matches!(
err.downcast_ref::<TypedRequestError>(),
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<ratatui::text::Line<'static>> = 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::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::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::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::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);
match destination {
ThreadTitleDestination::Automatic => {
if let Ok(response) = result
&& let Some(title) = super::thread_title::parse_thread_title(&response)
&& let Ok(thread) = app_server
.thread_read(thread_id, /*include_turns*/ false)
.await
&& thread.name.is_none()
{
match app_server.thread_set_name(thread_id, title.clone()).await {
Ok(()) => self
.chat_widget
.on_thread_name_updated(thread_id, Some(title)),
Err(error) => {
tracing::debug!(%error, "failed to apply generated thread title");
}
}
}
}
ThreadTitleDestination::RenameSuggestion { request_id } => {
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 self
.handle_start_side(tui, app_server, parent_thread_id, user_message)
.await;
}
AppEvent::OpenSkillsList => {
self.chat_widget.open_skills_list();
}
AppEvent::OpenManageSkillsPopup => {
self.chat_widget.open_manage_skills_popup();
}
AppEvent::SetSkillEnabled { path, enabled } => {
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::<Vec<_>>();
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::<Vec<_>>();
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::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::from_config(&keymap_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::ConfigWriteResponse>(
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::from_config(&keymap_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<AppRunControl> {
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<AppRunControl> {
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
}
})
}
}