Files
codex/codex-rs/tui/src/app/event_dispatch.rs
T
Charlie Marsh fe5889928c Use installed callable apps for TUI mentions (#39051)
## Why

The app directory is a discovery catalog and can include apps that are not
callable in the active thread. Using it for `$` suggestions can therefore
offer mentions that cannot be used.

## What changed

- Load mention candidates from `app/installed`, enrich callable apps through
  bounded `app/read` batches, and retain compatibility with app servers that
  do not support `app/installed`.
- Keep the mention catalog separate from the `/apps` directory cache and scope
  results to the current thread, workspace, and account.
- Refresh mentions after app notifications, enablement changes, configuration
  updates, and apps MCP readiness, while immediately removing disabled apps.

## Testing

Add coverage for batching and fallback behavior, stale scoped results,
notification refreshes, enablement changes, MCP startup races, and filtering
non-callable apps from mentions.

GitOrigin-RevId: 4d9e20794aed30cc97120170792fb555b86fe1e7
2026-08-17 19:11:34 +00:00

2889 lines
129 KiB
Rust

//! 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::resize_reflow::trailing_run_start;
use super::session_lifecycle::ThreadAttachPresentation;
use super::*;
use crate::app_server_session::ForkGoalContinuation;
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;
#[cfg(target_os = "windows")]
use codex_config::types::WindowsSandboxModeToml;
const SHUTDOWN_FIRST_EXIT_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 2);
impl App {
pub(super) async fn handle_event(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
event: AppEvent,
) -> Result<AppRunControl> {
match event {
AppEvent::SkillsListLoaded { ref cwd, .. }
| AppEvent::PluginMentionsLoaded { ref cwd, .. }
if cwds_differ(cwd, self.config.cwd.as_path()) => {}
AppEvent::NewSession { name } => {
self.start_fresh_session_with_summary_hint(
tui, app_server, /*session_start_source*/ None,
/*initial_user_message*/ None, name,
)
.await;
}
AppEvent::ChangeWorkingDirectory {
thread_id,
requested_cwd,
} => {
if 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.change_working_directory(tui, app_server, cwd).await;
}
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::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);
if self.chat_widget.thread_id() == Some(thread_id)
&& 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::OpenTranscriptExportFilePrompt => {
self.chat_widget.show_transcript_export_file_prompt();
}
AppEvent::ExportTranscript { destination } => {
if let Err(error) = self.export_transcript(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::ClearUi { name } => {
self.clear_terminal_ui(tui, /*redraw_header*/ false)?;
self.reset_app_ui_state_after_clear();
self.start_fresh_session_with_summary_hint(
tui,
app_server,
Some(ThreadStartSource::Clear),
/*initial_user_message*/ None,
name,
)
.await;
}
AppEvent::RawOutputModeChanged { enabled } => {
self.apply_raw_output_mode(tui, enabled, /*notify*/ false);
}
AppEvent::ClearUiAndSubmitUserMessage { text } => {
self.clear_terminal_ui(tui, /*redraw_header*/ false)?;
self.reset_app_ui_state_after_clear();
self.start_fresh_session_with_summary_hint(
tui,
app_server,
Some(ThreadStartSource::Clear),
crate::chatwidget::create_initial_user_message(
Some(text),
Vec::new(),
Vec::new(),
),
/*new_thread_name*/ None,
)
.await;
}
AppEvent::OpenResumePicker => {
let picker_app_server = match crate::start_app_server_for_picker(
&self.config,
&self.app_server_target,
self.state_db.clone(),
self.environment_manager.clone(),
)
.await
{
Ok(app_server) => app_server,
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to start TUI session picker: {err}"
));
self.chat_widget.maybe_send_next_queued_input();
return Ok(AppRunControl::Continue);
}
};
match crate::resume_picker::run_resume_picker_from_existing_session_with_app_server(
tui,
&self.config,
/*show_all*/ false,
/*include_non_interactive*/ false,
picker_app_server,
app_server.request_handle(),
self.primary_thread_id
.or(self.current_displayed_thread_id()),
)
.await?
{
SessionSelection::Resume(target_session) => {
match self
.resume_target_session(tui, app_server, target_session)
.await?
{
AppRunControl::Continue => {}
AppRunControl::Exit(reason) => {
return Ok(AppRunControl::Exit(reason));
}
}
}
SessionSelection::Exit | SessionSelection::StartFresh => {
self.refresh_in_memory_config_from_disk_best_effort(
"closing the session picker",
)
.await;
}
SessionSelection::Fork(_) => {}
}
self.chat_widget.maybe_send_next_queued_input();
// Leaving alt-screen may blank the inline viewport; force a redraw either way.
tui.frame_requester().schedule_frame();
}
AppEvent::OpenExternalAgentConfigMigration => {
match crate::external_agent_config_migration::flow::handle_external_agent_config_migration_prompt(
tui,
app_server,
&self.config,
)
.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(
app_server,
&self.config,
&id_or_name,
)
.await?
{
Some(target_session) => {
return self
.resume_target_session(tui, app_server, target_session)
.await;
}
None => {
self.chat_widget.add_error_message(format!(
"No saved chat found matching '{id_or_name}'."
));
}
}
}
AppEvent::ArchiveCurrentThread => {
return Ok(self.archive_current_thread(app_server).await);
}
AppEvent::DeleteCurrentThread => {
return Ok(self.delete_current_thread(app_server).await);
}
AppEvent::ForkCurrentSession { name } => {
self.session_telemetry.counter(
"codex.thread.fork",
/*inc*/ 1,
&[("source", "slash_command")],
);
let summary = session_summary(
self.chat_widget.token_usage(),
self.chat_widget.thread_id(),
self.chat_widget.thread_name(),
self.chat_widget.rollout_path().as_deref(),
);
self.chat_widget
.add_plain_history_lines(vec!["/fork".magenta().into()]);
if let Some(thread_id) = self.chat_widget.thread_id() {
self.refresh_in_memory_config_from_disk_best_effort("forking the thread")
.await;
let mut fork_config = self.config.clone();
fork_config.model = Some(self.chat_widget.current_model().to_string());
fork_config.model_reasoning_effort =
self.chat_widget.current_reasoning_effort();
match app_server.fork_thread(fork_config, thread_id).await {
Ok(mut forked) => {
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.shutdown_current_thread(app_server).await;
match self
.replace_chat_widget_with_app_server_thread(
tui,
forked,
ThreadAttachPresentation::SessionLineage,
/*initial_user_message*/ None,
)
.await
{
Ok(()) => {
if let Some(err) = name_error {
self.chat_widget.add_error_message(err);
}
if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> = Vec::new();
if let Some(usage_line) = summary.usage_line {
lines.push(usage_line.into());
}
if let Some(command) = summary.resume_hint {
let spans = vec![
"To continue this session, run ".into(),
command.cyan(),
];
lines.push(spans.into());
}
self.chat_widget.add_plain_history_lines(lines);
}
}
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to attach to forked app-server thread: {err}"
));
}
}
}
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to fork current session through the app server: {err}"
));
}
}
} else {
self.chat_widget.add_error_message(
"A thread must contain at least one turn before it can be forked."
.to_string(),
);
}
self.chat_widget.maybe_send_next_queued_input();
tui.frame_requester().schedule_frame();
}
AppEvent::ForkSessionForPromptEdit {
thread_id,
nth_user_message,
mut prompt,
} => {
if self.chat_widget.thread_id() != Some(thread_id) {
return Ok(AppRunControl::Continue);
}
self.session_telemetry.counter(
"codex.thread.fork",
/*inc*/ 1,
&[("source", "transcript")],
);
self.refresh_in_memory_config_from_disk_best_effort("forking the thread")
.await;
let config = self.fresh_session_config();
let turns = match self.thread_event_channels.get(&thread_id) {
Some(channel) => {
let store = channel.store.lock().await;
let mut turns = store.turns.clone();
// Snapshot turns contain loaded history; newer live turns remain in
// the replay buffer and must also be visible to prompt-edit lookups.
for event in &store.buffer {
let ThreadBufferedEvent::Notification(notification) = event else {
continue;
};
match notification.as_ref() {
ServerNotification::TurnStarted(notification)
if !turns
.iter()
.any(|turn| turn.id == notification.turn.id) =>
{
turns.push(notification.turn.clone());
}
ServerNotification::ItemCompleted(notification) => {
if matches!(
notification.item,
ThreadItem::UserMessage { .. }
| ThreadItem::EnteredReviewMode { .. }
| ThreadItem::ExitedReviewMode { .. }
) && let Some(turn) = turns
.iter_mut()
.find(|turn| turn.id == notification.turn_id)
&& !turn
.items
.iter()
.any(|item| item.id() == notification.item.id())
{
turn.items.push(notification.item.clone());
}
}
ServerNotification::TurnCompleted(notification) => {
if let Some(turn) = turns
.iter_mut()
.find(|turn| turn.id == notification.turn.id)
{
turn.status = notification.turn.status.clone();
turn.error = notification.turn.error.clone();
turn.started_at = notification.turn.started_at;
turn.completed_at = notification.turn.completed_at;
turn.duration_ms = notification.turn.duration_ms;
}
}
_ => {}
}
}
Some(turns)
}
None => None,
};
let started = match turns {
Some(turns) => match crate::app_backtrack::backtrack_fork_before_turn_id(
&turns,
nth_user_message,
&mut prompt,
) {
Ok(before_turn_id)
if before_turn_id.is_some()
|| app_server.has_older_history(thread_id) =>
{
let before_turn_id = before_turn_id
.or_else(|| turns.first().map(|turn| turn.id.clone()));
app_server
.fork_thread_at(
config.clone(),
thread_id,
/*last_turn_id*/ None,
before_turn_id,
ForkGoalContinuation::StartIfIdle,
)
.await
}
Ok(_) => {
app_server
.start_thread_with_session_start_source(
&config, /*session_start_source*/ None,
)
.await
}
Err(err) => Err(err),
},
None => Err(color_eyre::eyre::eyre!(
"the selected thread is no longer available for prompt editing"
)),
};
match started {
Ok(forked) => {
self.shutdown_current_thread(app_server).await;
match self
.replace_chat_widget_with_app_server_thread(
tui,
forked,
ThreadAttachPresentation::PromptEdit,
/*initial_user_message*/ None,
)
.await
{
Ok(()) => self.chat_widget.restore_user_message_to_composer(prompt),
Err(err) => {
self.restore_backtrack_prompt_after_branch_error(prompt, err);
}
}
}
Err(err) => {
self.restore_backtrack_prompt_after_branch_error(prompt, err);
}
}
tui.frame_requester().schedule_frame();
}
AppEvent::BeginInitialHistoryReplayBuffer => {
self.begin_initial_history_replay_buffer();
}
AppEvent::BeginThreadSwitchHistoryReplayBuffer => {
self.begin_thread_switch_history_replay_buffer();
}
AppEvent::InsertHistoryCell(cell) => {
self.insert_history_cell(tui, cell);
}
AppEvent::EndInitialHistoryReplayBuffer => {
self.scrollback_has_older_history = self
.chat_widget
.thread_id()
.is_some_and(|thread_id| app_server.has_older_history(thread_id));
self.finish_initial_history_replay_buffer(tui);
}
AppEvent::ConsolidateAgentMessage {
source,
cwd,
inline_visualization_context,
scrollback_reflow,
deferred_history_cell,
} => {
self.handle_consolidate_agent_message(
tui,
source,
cwd,
inline_visualization_context,
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.transcript_cells
.splice(start..end, std::iter::once(consolidated.clone()));
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
t.consolidate_cells(start..end, consolidated.clone());
tui.frame_requester().schedule_frame();
}
self.finish_required_stream_reflow(tui)?;
} else {
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.insert_history_cell_lines(
tui,
consolidated.as_ref(),
self.chat_widget
.history_wrap_width(tui.terminal.last_known_screen_size.width),
);
self.maybe_finish_stream_reflow(tui)?;
}
self.chat_widget.note_stream_consolidation_completed();
self.insert_pending_usage_output_after_stream_shutdown(tui);
}
AppEvent::StartCommitAnimation => {
if self
.commit_anim_running
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_ok()
{
let tx = self.app_event_tx.clone();
let running = self.commit_anim_running.clone();
thread::spawn(move || {
while running.load(Ordering::Relaxed) {
thread::sleep(COMMIT_ANIMATION_TICK);
tx.send(AppEvent::CommitTick);
}
});
}
}
AppEvent::StopCommitAnimation => {
self.commit_anim_running.store(false, Ordering::Release);
}
AppEvent::CommitTick => {
self.chat_widget.on_commit_tick();
}
AppEvent::Exit(mode) => {
if mode == ExitMode::ShutdownFirst {
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::ShutdownFirst));
}
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::CodexOp(op) => {
let is_user_turn = matches!(&op, AppCommand::UserTurn { .. });
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)?;
}
self.chat_widget.prepare_local_op_submission(&op);
if let Err(err) = self.submit_active_thread_op(app_server, op).await {
let handled = is_user_turn
&& matches!(
err.downcast_ref::<TypedRequestError>(),
Some(TypedRequestError::Server { method, .. })
if method == "turn/start"
)
&& self
.chat_widget
.handle_turn_start_rejection(format!("Failed to start turn: {err:#}"));
if !handled {
return Err(err);
}
tracing::error!(error = ?err, "failed to start turn through app server");
}
}
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::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::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 { pet_id, result } => {
self.handle_configured_pet_loaded(tui, 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 { cwd } => {
self.fetch_hooks_list(app_server, cwd);
}
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 { cwd, result } => {
self.chat_widget.on_hooks_loaded(cwd, 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::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",
);
}
AppEvent::StartFileSearch(query) => {
self.file_search.on_user_query(query);
}
AppEvent::FileSearchResult { query, matches } => {
self.chat_widget.apply_file_search_result(query, matches);
}
AppEvent::RefreshRateLimits { origin } => {
self.refresh_rate_limits(app_server, origin);
}
AppEvent::RefreshTokenActivity { request_id } => {
self.refresh_token_activity(app_server, request_id);
}
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 self
.chat_widget
.start_add_credits_nudge_email_request(credit_type)
{
self.send_add_credits_nudge_email(app_server, credit_type);
}
}
AppEvent::AddCreditsNudgeEmailFinished { result } => {
self.chat_widget
.finish_add_credits_nudge_email_request(result);
}
AppEvent::RateLimitsLoaded {
origin,
hard_stop_generation,
result,
} => match result {
Ok(response) => {
let rate_limit_reset_credits = response.rate_limit_reset_credits.clone();
let snapshots = if hard_stop_generation == self.rate_limit_hard_stop_generation
{
app_server_rate_limit_snapshots(response)
} else {
Vec::new()
};
match origin {
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) => {
tracing::warn!("account/rateLimits/read failed during TUI refresh: {err}");
match origin {
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),
);
}
}
}
},
AppEvent::OpenTokenActivity => {
self.chat_widget
.add_token_activity_output(crate::chatwidget::TokenActivityView::Daily);
}
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,
) {
self.refresh_rate_limits(
app_server,
RateLimitRefreshOrigin::ResetConsume { request_id },
);
}
}
AppEvent::TokenActivityLoaded { request_id, result } => {
if let Err(err) = &result {
tracing::warn!("account/usage/read failed during TUI refresh: {err}");
}
if self
.chat_widget
.finish_token_activity_refresh(request_id, result)
{
// Commit synchronously so an already queued /clear cannot overtake this card.
// Do not route through ChatWidget::add_to_history: /usage may complete during
// active work, and flushing an in-progress tool cell would corrupt its lifecycle.
// If an answer stream is active, keep the settled card transient until its
// provisional transcript cells have been consolidated.
self.insert_pending_usage_output_if_ready(tui);
}
}
AppEvent::ThreadUsageLoaded {
thread_id,
request_id,
result,
} => {
self.finish_thread_usage_refresh(tui, 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::UpdateModel(model) => {
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::UpdatePersonality(personality) => {
self.on_update_personality(personality);
self.sync_active_thread_personality_setting(app_server, personality)
.await;
}
AppEvent::SettingsSelectionClosed => {
self.app_event_tx.send(AppEvent::SettingsSelectionSettled);
}
AppEvent::SettingsSelectionSettled => {
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::OpenReasoningPopup { model } => {
self.chat_widget.open_reasoning_popup(model);
}
AppEvent::OpenAdvancedReasoningPopup { model } => {
self.chat_widget.open_advanced_reasoning_popup(model);
}
AppEvent::ApplyAdvancedReasoning { model, effort } => {
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) = crate::config_update::write_config_batch(
app_server.request_handle(),
crate::config_update::build_model_selection_edits(
model.as_str(),
Some(default_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 { models } => {
self.chat_widget.open_all_models_popup(models);
}
AppEvent::OpenFullAccessConfirmation {
preset,
return_to_permissions,
profile_selection,
} => {
self.chat_widget.open_full_access_confirmation(
preset,
return_to_permissions,
profile_selection,
);
}
AppEvent::OpenWorldWritableWarningConfirmation {
preset,
profile_selection,
sample_paths,
extra_count,
failed_scan,
} => {
self.chat_widget.open_world_writable_warning_confirmation(
preset,
profile_selection,
sample_paths,
extra_count,
failed_scan,
);
}
AppEvent::OpenFeedbackNote {
category,
include_logs,
} => {
self.chat_widget.open_feedback_note(category, include_logs);
}
AppEvent::OpenFeedbackConsent { category } => {
self.chat_widget.open_feedback_consent(category);
}
AppEvent::SubmitFeedback {
category,
reason,
turn_id,
include_logs,
} => {
self.submit_feedback(app_server, category, reason, turn_id, include_logs);
}
AppEvent::FeedbackSubmitted {
origin_thread_id,
category,
include_logs,
result,
} => {
self.handle_feedback_submitted(origin_thread_id, category, include_logs, result)
.await;
}
AppEvent::LaunchExternalEditor => {
if self.chat_widget.external_editor_state() == ExternalEditorState::Active {
self.launch_external_editor(tui).await;
}
}
AppEvent::OpenWindowsSandboxEnablePrompt {
preset,
profile_selection,
} => {
self.chat_widget
.open_windows_sandbox_enable_prompt(preset, profile_selection);
}
AppEvent::OpenWindowsSandboxFallbackPrompt {
preset,
profile_selection,
} => {
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(any(target_os = "windows", test))]
if !self.chat_widget.windows_sandbox_mode_allowed(
codex_config::types::WindowsSandboxModeToml::Elevated,
) {
tracing::warn!(
"refusing to set up elevated Windows sandbox mode disallowed by requirements"
);
self.chat_widget.add_info_message(
"That Windows sandbox option is disallowed by requirements.".to_string(),
/*hint*/ None,
);
return Ok(AppRunControl::Continue);
}
#[cfg(target_os = "windows")]
{
let setup_permissions = match self
.windows_setup_permissions(&preset, profile_selection.as_ref())
.await
{
Ok(setup_permissions) => setup_permissions,
Err(err) => {
tracing::warn!(
error = %err,
"failed to resolve permission profile for elevated Windows sandbox setup"
);
self.chat_widget.add_error_message(format!(
"Failed to prepare Windows sandbox for the selected permission profile: {err}"
));
return Ok(AppRunControl::Continue);
}
};
let permission_profile = setup_permissions.permission_profile;
let workspace_roots = setup_permissions.workspace_roots;
let command_cwd = self.config.cwd.clone();
let env_map: std::collections::HashMap<String, String> =
std::env::vars().collect();
let codex_home = self.config.codex_home.clone();
let tx = self.app_event_tx.clone();
// If the elevated setup already ran on this machine, don't prompt for
// elevation again - just flip the config to use the elevated path.
if crate::windows_sandbox::sandbox_setup_is_complete(codex_home.as_path()) {
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
preset,
mode: WindowsSandboxEnableMode::Elevated,
profile_selection,
});
return Ok(AppRunControl::Continue);
}
self.chat_widget.show_windows_sandbox_setup_status();
self.windows_sandbox.setup_started_at = Some(Instant::now());
let session_telemetry = self.session_telemetry.clone();
tokio::task::spawn_blocking(move || {
let result = crate::windows_sandbox::run_elevated_setup(
&permission_profile,
workspace_roots.as_slice(),
command_cwd.as_path(),
&env_map,
codex_home.as_path(),
);
let event = match result {
Ok(()) => {
session_telemetry.counter(
"codex.windows_sandbox.elevated_setup_success",
/*inc*/ 1,
&[],
);
AppEvent::EnableWindowsSandboxForAgentMode {
preset: preset.clone(),
mode: WindowsSandboxEnableMode::Elevated,
profile_selection: profile_selection.clone(),
}
}
Err(err) => {
let mut code_tag: Option<String> = None;
let mut message_tag: Option<String> = None;
if let Some((code, message)) =
crate::windows_sandbox::elevated_setup_failure_details(&err)
{
code_tag = Some(code);
message_tag = Some(message);
}
let mut tags: Vec<(&str, &str)> = Vec::new();
if let Some(code) = code_tag.as_deref() {
tags.push(("code", code));
}
if let Some(message) = message_tag.as_deref() {
tags.push(("message", message));
}
session_telemetry.counter(
crate::windows_sandbox::elevated_setup_failure_metric_name(
&err,
),
/*inc*/ 1,
&tags,
);
tracing::error!(
error = %err,
"failed to run elevated Windows sandbox setup"
);
AppEvent::OpenWindowsSandboxFallbackPrompt {
preset,
profile_selection,
}
}
};
tx.send(event);
});
}
#[cfg(not(target_os = "windows"))]
{
let _ = (preset, profile_selection);
}
}
AppEvent::BeginWindowsSandboxLegacySetup {
preset,
profile_selection,
} => {
#[cfg(any(target_os = "windows", test))]
if !self.chat_widget.windows_sandbox_mode_allowed(
codex_config::types::WindowsSandboxModeToml::Unelevated,
) {
tracing::warn!(
"refusing to set up unelevated Windows sandbox mode disallowed by requirements"
);
self.chat_widget.add_info_message(
"That Windows sandbox option is disallowed by requirements.".to_string(),
/*hint*/ None,
);
return Ok(AppRunControl::Continue);
}
#[cfg(target_os = "windows")]
{
let setup_permissions = match self
.windows_setup_permissions(&preset, profile_selection.as_ref())
.await
{
Ok(setup_permissions) => setup_permissions,
Err(err) => {
tracing::warn!(
error = %err,
"failed to resolve permission profile for legacy Windows sandbox setup"
);
self.chat_widget.add_error_message(format!(
"Failed to prepare Windows sandbox for the selected permission profile: {err}"
));
return Ok(AppRunControl::Continue);
}
};
let permission_profile = setup_permissions.permission_profile;
let workspace_roots = setup_permissions.workspace_roots;
let command_cwd = self.config.cwd.clone();
let env_map: std::collections::HashMap<String, String> =
std::env::vars().collect();
let codex_home = self.config.codex_home.clone();
let tx = self.app_event_tx.clone();
let session_telemetry = self.session_telemetry.clone();
self.chat_widget.show_windows_sandbox_setup_status();
tokio::task::spawn_blocking(move || {
if let Err(err) =
codex_windows_sandbox::run_windows_sandbox_legacy_preflight(
&permission_profile,
workspace_roots.as_slice(),
codex_home.as_path(),
command_cwd.as_path(),
&env_map,
)
{
session_telemetry.counter(
"codex.windows_sandbox.legacy_setup_preflight_failed",
/*inc*/ 1,
&[],
);
tracing::warn!(
error = %err,
"failed to preflight non-admin Windows sandbox setup"
);
}
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
preset,
mode: WindowsSandboxEnableMode::Legacy,
profile_selection,
});
});
}
#[cfg(not(target_os = "windows"))]
{
let _ = (preset, profile_selection);
}
}
AppEvent::BeginWindowsSandboxGrantReadRoot { path } => {
#[cfg(target_os = "windows")]
{
self.chat_widget
.add_to_history(history_cell::new_info_event(
format!("Granting sandbox read access to {path} ..."),
/*hint*/ None,
));
let permission_profile = self.config.permissions.effective_permission_profile();
let workspace_roots = self.config.effective_workspace_roots();
let command_cwd = self.config.cwd.clone();
let env_map: std::collections::HashMap<String, String> =
std::env::vars().collect();
let codex_home = self.config.codex_home.clone();
let tx = self.app_event_tx.clone();
tokio::task::spawn_blocking(move || {
let requested_path = PathBuf::from(path);
let event = match crate::windows_sandbox::grant_read_root_non_elevated(
&permission_profile,
workspace_roots.as_slice(),
command_cwd.as_path(),
&env_map,
codex_home.as_path(),
requested_path.as_path(),
) {
Ok(canonical_path) => AppEvent::WindowsSandboxGrantReadRootCompleted {
path: canonical_path,
error: None,
},
Err(err) => AppEvent::WindowsSandboxGrantReadRootCompleted {
path: requested_path,
error: Some(err.to_string()),
},
};
tx.send(event);
});
}
#[cfg(not(target_os = "windows"))]
{
let _ = path;
}
}
AppEvent::WindowsSandboxGrantReadRootCompleted { path, error } => match error {
Some(err) => {
self.chat_widget
.add_to_history(history_cell::new_error_event(format!("Error: {err}")));
}
None => {
self.chat_widget
.add_to_history(history_cell::new_info_event(
format!("Sandbox read access granted for {}", path.display()),
/*hint*/ None,
));
}
},
AppEvent::EnableWindowsSandboxForAgentMode {
preset,
mode,
profile_selection,
} => {
#[cfg(target_os = "windows")]
{
self.chat_widget.clear_windows_sandbox_setup_status();
if let Some(started_at) = self.windows_sandbox.setup_started_at.take() {
self.session_telemetry.record_duration(
"codex.windows_sandbox.elevated_setup_duration_ms",
started_at.elapsed(),
&[("result", "success")],
);
}
let selected_mode = match mode {
WindowsSandboxEnableMode::Elevated => WindowsSandboxModeToml::Elevated,
WindowsSandboxEnableMode::Legacy => WindowsSandboxModeToml::Unelevated,
};
let elevated_enabled = selected_mode == WindowsSandboxModeToml::Elevated;
if !self.chat_widget.windows_sandbox_mode_allowed(selected_mode) {
tracing::warn!(
?selected_mode,
"refusing to persist Windows sandbox mode disallowed by requirements"
);
self.chat_widget.add_info_message(
"That Windows sandbox option is disallowed by requirements."
.to_string(),
/*hint*/ None,
);
return Ok(AppRunControl::Continue);
}
let edits =
crate::config_update::build_windows_sandbox_mode_edits(elevated_enabled);
match crate::config_update::write_config_batch(
app_server.request_handle(),
edits,
)
.await
{
Ok(response) if response.status == WriteStatus::OkOverridden => {
self.sync_windows_sandbox_after_overridden_write(app_server, &response)
.await;
}
Ok(_) => {
if elevated_enabled {
self.config.set_windows_sandbox_enabled(/*value*/ false);
self.config
.set_windows_elevated_sandbox_enabled(/*value*/ true);
} else {
self.config.set_windows_sandbox_enabled(/*value*/ true);
self.config
.set_windows_elevated_sandbox_enabled(/*value*/ false);
}
self.chat_widget.set_windows_sandbox_mode(
self.config.permissions.windows_sandbox_mode,
);
let windows_sandbox_level =
crate::windows_sandbox::level_from_config(&self.config);
if let Some((sample_paths, extra_count, failed_scan)) =
self.chat_widget.world_writable_warning_details()
{
self.app_event_tx.send(AppEvent::CodexOp(
AppCommand::override_turn_context(
/*cwd*/ None,
/*approval_policy*/ None,
/*approvals_reviewer*/ None,
/*permission_profile*/ None,
/*active_permission_profile*/ None,
#[cfg(target_os = "windows")]
Some(windows_sandbox_level),
/*model*/ None,
/*effort*/ None,
/*summary*/ None,
/*service_tier*/ None,
/*collaboration_mode*/ None,
/*personality*/ None,
),
));
self.app_event_tx.send(
AppEvent::OpenWorldWritableWarningConfirmation {
preset: Some(preset.clone()),
profile_selection: profile_selection.clone(),
sample_paths,
extra_count,
failed_scan,
},
);
} else if let Some(selection) = profile_selection {
self.app_event_tx.send(AppEvent::CodexOp(
AppCommand::override_turn_context(
/*cwd*/ None,
/*approval_policy*/ None,
/*approvals_reviewer*/ None,
/*permission_profile*/ None,
/*active_permission_profile*/ None,
#[cfg(target_os = "windows")]
Some(windows_sandbox_level),
/*model*/ None,
/*effort*/ None,
/*summary*/ None,
/*service_tier*/ None,
/*collaboration_mode*/ None,
/*personality*/ None,
),
));
if self.apply_permission_profile_selection(selection).await {
self.chat_widget.submit_initial_user_message_if_pending();
}
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 {
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()),
#[cfg(target_os = "windows")]
Some(windows_sandbox_level),
/*model*/ None,
/*effort*/ None,
/*summary*/ None,
/*service_tier*/ None,
/*collaboration_mode*/ None,
/*personality*/ 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(),
]),
]);
}
}
Err(err) => {
tracing::error!(
error = %err,
"failed to enable Windows sandbox feature"
);
self.chat_widget.add_error_message(format!(
"Failed to enable the Windows sandbox feature: {err}"
));
}
}
}
#[cfg(not(target_os = "windows"))]
{
let _ = (preset, mode, profile_selection);
}
}
AppEvent::PersistModelSelection { model, effort } => {
match crate::config_update::write_config_batch(
app_server.request_handle(),
crate::config_update::build_model_selection_edits(
model.as_str(),
effort.as_ref(),
),
)
.await
{
Ok(_) => {
let effort_label = effort
.as_ref()
.map(std::string::ToString::to_string)
.unwrap_or_else(|| "default".to_string());
tracing::info!("Selected model: {model}, Selected effort: {effort_label}");
let mut message = format!("Model changed to {model}");
if let Some(label) = Self::reasoning_label_for(&model, effort.as_ref()) {
message.push(' ');
message.push_str(&label);
}
self.chat_widget.add_info_message(message, /*hint*/ None);
}
Err(err) => {
let error = format_config_error(&err);
tracing::error!(
error = %error,
"failed to persist model selection"
);
self.chat_widget
.add_error_message(format!("Failed to save default model: {error}"));
}
}
}
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::PersistPersonalitySelection { personality } => {
match crate::config_update::write_config_batch(
app_server.request_handle(),
vec![crate::config_update::replace_config_value(
"personality",
serde_json::json!(personality.to_string()),
)],
)
.await
{
Ok(_) => {
let label = Self::personality_label(personality);
let message = format!("Personality set to {label}");
self.chat_widget.add_info_message(message, /*hint*/ None);
}
Err(err) => {
tracing::error!(
error = %err,
"failed to persist personality selection"
);
self.chat_widget.add_error_message(format!(
"Failed to save default personality: {err}"
));
}
}
}
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 crate::config_update::write_config_batch(app_server.request_handle(), edits)
.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");
self.chat_widget.add_error_message(format!(
"Failed to save default service tier: {err}"
));
}
}
}
AppEvent::UpdateAskForApprovalPolicy(policy) => {
let mut config = self.config.clone();
if !self.try_set_approval_policy_on_config(
&mut config,
policy,
"Failed to set approval policy",
"failed to set approval policy on app config",
) {
return Ok(AppRunControl::Continue);
}
self.config = config;
let approval_policy =
AskForApproval::from(self.config.permissions.approval_policy.value());
self.runtime_approval_policy_override = Some(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) => {
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);
};
#[cfg(target_os = "windows")]
let permission_profile_is_managed_restricted =
managed_filesystem_sandbox_is_restricted(&permission_profile);
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_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();
// If a managed filesystem sandbox is active, run the Windows
// world-writable scan.
#[cfg(target_os = "windows")]
{
// One-shot suppression if the user just confirmed continue.
if self.windows_sandbox.skip_world_writable_scan_once {
self.windows_sandbox.skip_world_writable_scan_once = false;
return Ok(AppRunControl::Continue);
}
let should_check = crate::windows_sandbox::level_from_config(&self.config)
!= WindowsSandboxLevel::Disabled
&& permission_profile_is_managed_restricted
&& !self.chat_widget.world_writable_warning_hidden();
if should_check {
let cwd = self.config.cwd.clone();
let workspace_roots = self.config.effective_workspace_roots();
let env_map: std::collections::HashMap<String, String> =
std::env::vars().collect();
let tx = self.app_event_tx.clone();
let logs_base_dir = self.config.codex_home.clone();
let permission_profile =
self.config.permissions.effective_permission_profile();
Self::spawn_world_writable_scan(
cwd,
workspace_roots,
env_map,
logs_base_dir,
permission_profile,
tx,
);
}
}
}
AppEvent::SelectPermissionProfile(selection) => {
if self.apply_permission_profile_selection(selection).await {
self.chat_widget.submit_initial_user_message_if_pending();
}
}
AppEvent::UpdateApprovalsReviewer(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: {err}"));
}
}
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::SkipNextWorldWritableScan => {
self.windows_sandbox.skip_world_writable_scan_once = true;
}
AppEvent::UpdateWorldWritableWarningAcknowledged(ack) => {
self.chat_widget
.set_world_writable_warning_acknowledged(ack);
}
AppEvent::UpdateRateLimitSwitchPromptHidden(hidden) => {
self.chat_widget.set_rate_limit_switch_prompt_hidden(hidden);
}
AppEvent::UpdatePlanModeReasoningEffort(effort) => {
self.on_update_plan_mode_reasoning_effort(effort);
self.sync_active_thread_plan_mode_reasoning_setting(app_server)
.await;
}
AppEvent::PersistWorldWritableWarningAcknowledged => {
if let Err(err) = ConfigEditsBuilder::for_config(&self.config)
.set_hide_world_writable_warning(/*acknowledged*/ true)
.apply()
.await
{
tracing::error!(
error = %err,
"failed to persist world-writable warning acknowledgement"
);
self.chat_widget.add_error_message(format!(
"Failed to save Agent mode warning preference: {err}"
));
}
}
AppEvent::PersistRateLimitSwitchPromptHidden => {
if let Err(err) = ConfigEditsBuilder::for_config(&self.config)
.set_hide_rate_limit_model_nudge(/*acknowledged*/ true)
.apply()
.await
{
tracing::error!(
error = %err,
"failed to persist rate limit switch prompt preference"
);
self.chat_widget.add_error_message(format!(
"Failed to save rate limit reminder preference: {err}"
));
}
}
AppEvent::PersistPlanModeReasoningEffort(effort) => {
let 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) = crate::config_update::write_config_batch(
app_server.request_handle(),
vec![edit],
)
.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(&self.config)
.record_model_migration_seen(from_model.as_str(), to_model.as_str())
.apply()
.await
{
tracing::error!(
error = %err,
"failed to persist model migration prompt acknowledgement"
);
self.chat_widget.add_error_message(format!(
"Failed to save model migration prompt preference: {err}"
));
}
}
AppEvent::OpenApprovalsPopup => {
self.chat_widget.open_approvals_popup();
}
AppEvent::OpenAgentPicker => {
self.open_agent_picker(app_server).await;
}
AppEvent::AgentPickerThreadsLoaded {
primary_thread_id,
request_id,
result,
} => {
self.apply_agent_picker_thread_refresh(primary_thread_id, request_id, result);
}
AppEvent::SelectAgentThread(thread_id) => {
self.select_agent_thread_and_discard_side(tui, app_server, thread_id)
.await?;
}
AppEvent::StartSide {
parent_thread_id,
user_message,
} => {
return self
.handle_start_side(tui, app_server, parent_thread_id, user_message)
.await;
}
AppEvent::OpenSkillsList => {
self.chat_widget.open_skills_list();
}
AppEvent::OpenManageSkillsPopup => {
self.chat_widget.open_manage_skills_popup();
}
AppEvent::SetSkillEnabled { path, enabled } => {
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.display();
self.chat_widget.add_error_message(format!(
"Failed to update skill config for {path_display}: {err}"
));
}
}
}
AppEvent::SetAppEnabled { id, enabled } => {
let edits = if enabled {
vec![
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::OpenPermissionsPopup => {
self.chat_widget.open_permissions_popup();
}
AppEvent::OpenReviewBranchPicker(cwd) => {
self.chat_widget.show_review_branch_picker(&cwd).await;
}
AppEvent::OpenReviewCommitPicker(cwd) => {
self.chat_widget.show_review_commit_picker(&cwd).await;
}
AppEvent::OpenReviewCustomPrompt => {
self.chat_widget.show_review_custom_prompt();
}
AppEvent::SubmitUserMessageWithMode {
text,
collaboration_mode,
} => {
self.chat_widget
.submit_user_message_with_mode(text, collaboration_mode);
}
AppEvent::ManageSkillsClosed => {
self.chat_widget.handle_manage_skills_closed();
}
AppEvent::FullScreenApprovalRequest(request) => match request {
ApprovalRequest::ApplyPatch(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::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(&self.config)
.with_edits([items_edit, colors_edit])
.apply()
.await;
match apply_result {
Ok(()) => {
self.config.tui_status_line = Some(ids.clone());
self.config.tui_status_line_use_colors = use_theme_colors;
self.chat_widget.setup_status_line(items, use_theme_colors);
}
Err(err) => {
let error = format_config_error(&err);
tracing::error!(error = %error, "failed to persist status line settings; keeping previous selection");
self.chat_widget.add_error_message(format!(
"Failed to save status line settings: {error}"
));
}
}
}
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();
}
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(&self.config)
.with_edits([edit])
.apply()
.await;
match apply_result {
Ok(()) => {
self.config.tui_terminal_title = Some(ids.clone());
self.chat_widget.setup_terminal_title(items);
}
Err(err) => {
tracing::error!(error = %err, "failed to persist terminal title items; keeping previous selection");
self.chat_widget.revert_terminal_title_setup_preview();
self.chat_widget.add_error_message(format!(
"Failed to save terminal title items: {err}"
));
}
}
}
AppEvent::TerminalTitleSetupPreview { items } => {
self.chat_widget.preview_terminal_title(items);
}
AppEvent::TerminalTitleSetupCancelled => {
self.chat_widget.cancel_terminal_title_setup();
}
AppEvent::SyntaxThemeSelected { name } => {
let edit = crate::legacy_core::config::edit::syntax_theme_edit(&name);
let apply_result = ConfigEditsBuilder::for_config(&self.config)
.with_edits([edit])
.apply()
.await;
match apply_result {
Ok(()) => {
// Ensure the selected theme is active in the current
// session. The preview callback covers arrow-key
// navigation, but if the user presses Enter without
// navigating, the runtime theme must still be applied.
if let Some(theme) = crate::render::highlight::resolve_theme_by_name(
&name,
Some(&self.config.codex_home),
) {
crate::render::highlight::set_syntax_theme(theme);
}
self.sync_tui_theme_selection(name);
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.chat_widget
.add_error_message(format!("Failed to save theme: {err}"));
}
}
}
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(context, action, key, intent)
.await;
}
AppEvent::KeymapCleared { context, action } => {
self.apply_keymap_clear(context, action).await;
}
}
Ok(AppRunControl::Continue)
}
async fn apply_keymap_capture(
&mut self,
context: String,
action: String,
key: String,
intent: crate::app_event::KeymapEditIntent,
) {
let outcome = match crate::keymap_setup::keymap_with_edit(
&self.config.tui_keymap,
&self.keymap,
&context,
&action,
&key,
&intent,
) {
Ok(outcome) => outcome,
Err(err) => {
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.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.chat_widget.show_selection_view(params);
return;
}
};
let edit =
crate::legacy_core::config::edit::keymap_bindings_edit(&context, &action, &bindings);
match ConfigEditsBuilder::for_config(&self.config)
.with_edits([edit])
.apply()
.await
{
Ok(()) => {
self.cancel_pending_key_chord();
self.config.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.chat_widget.add_info_message(message, /*hint*/ None);
}
Err(err) => {
tracing::error!(error = %err, "failed to persist keymap binding");
self.chat_widget
.add_error_message(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.config.tui_keymap,
&context,
&action,
) {
Ok(keymap_config) => keymap_config,
Err(err) => {
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.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(&self.config)
.with_edits([edit])
.apply()
.await
{
Ok(()) => {
self.cancel_pending_key_chord();
self.config.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.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.chat_widget
.add_error_message(format!("Failed to remove shortcut: {err}"));
}
}
}
pub(super) async fn handle_exit_mode(
&mut self,
app_server: &mut AppServerSession,
mode: ExitMode,
) -> AppRunControl {
match mode {
ExitMode::ShutdownFirst => {
// Mark the thread we are explicitly shutting down for exit so
// its shutdown completion does not trigger agent failover.
self.pending_shutdown_exit_thread_id =
self.active_thread_id.or(self.chat_widget.thread_id());
if self.pending_shutdown_exit_thread_id.is_some() {
// 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(ExitReason::UserRequested)
}
ExitMode::Immediate => {
self.pending_shutdown_exit_thread_id = None;
AppRunControl::Exit(ExitReason::UserRequested)
}
}
}
pub(super) async fn archive_current_thread(
&mut self,
app_server: &mut AppServerSession,
) -> 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 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 AppRunControl::Continue;
}
match app_server.thread_archive(thread_id).await {
Ok(()) => AppRunControl::Exit(ExitReason::UserRequested),
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,
app_server: &mut AppServerSession,
) -> 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 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 AppRunControl::Continue;
}
match app_server.thread_delete(thread_id).await {
Ok(()) => AppRunControl::Exit(ExitReason::UserRequested),
Err(err) => {
self.chat_widget
.add_error_message(format!("Failed to delete current thread: {err}"));
AppRunControl::Continue
}
}
}
}