Files
codex/codex-rs/tui/src/app/event_dispatch.rs
T
Felipe Coury 5248e3da2b feat(tui): render responsive Markdown tables in TUI (#22052)
## Why

The TUI currently treats Markdown tables as ordinary wrapped text, which
makes table-heavy responses hard to read and brittle across narrow panes
and terminal resizes.

This change teaches the TUI to render Markdown tables responsively while
preserving the raw Markdown source needed to re-render streamed and
finalized transcript content after width changes. The goal is to keep
tables legible during streaming, after resize, and once a turn has
finished, without corrupting scrollback ordering.

## What Changed

- add table detection and responsive table rendering in the Markdown
renderer
- render standard tables with Unicode box-drawing borders when the pane
is wide enough
- add a vertical readability fallback for constrained or dense tables so
narrow panes still show each row clearly
- keep links and `<br>` content inside table cells instead of leaking
text outside the table
- avoid table normalization inside fenced or indented code blocks
- preserve raw streamed Markdown source and keep the active table as a
mutable tail until finalization
- consolidate finalized streamed content into source-backed transcript
cells so post-resize re-rendering stays correct
- add snapshot and targeted streaming/resize regression coverage for the
new table behavior

## How to Test

1. Start Codex TUI from this branch.
2. Paste this exact prompt:
`This is a session to test codex, no need to do any thinking, just end
different markdown tables, with columns exploring different markdown
contents, like links, bold italic, code, etc. Make them different sizes,
some 30+ rows, some not and intertwine them with some paragraphs with
complex formatting as well.`
3. Confirm the response includes several Markdown tables mixed with
richly formatted paragraphs.
4. Confirm wide-enough tables render with box-drawing borders instead of
plain wrapped pipe text.
5. Resize the terminal narrower while the answer is still streaming and
confirm the in-progress table stays coherent instead of duplicating
headers or leaving broken scrollback behind.
6. Resize again after the turn finishes and confirm the finalized
transcript re-renders cleanly at the new width.
7. In a narrow pane, verify dense tables fall back to the vertical
per-row layout instead of producing unreadable wrapped columns.
8. Also verify pipe-heavy fenced code blocks still render as code, not
as tables.

Targeted tests:
- `cargo test -p codex-tui table_readability_fallback --no-fail-fast`
- `cargo test -p codex-tui markdown_render --no-fail-fast`
- `cargo test -p codex-tui streaming::controller --no-fail-fast`
- `cargo test -p codex-tui table_resize_lifecycle --no-fail-fast`

## Docs

No developer docs update appears necessary.
2026-05-10 20:42:11 +00:00

2121 lines
94 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::*;
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::NewSession => {
self.start_fresh_session_with_summary_hint(
tui, app_server, /*session_start_source*/ None,
/*initial_user_message*/ None,
)
.await;
}
AppEvent::ClearUi => {
self.clear_terminal_ui(tui, /*redraw_header*/ false)?;
self.reset_app_ui_state_after_clear();
self.start_fresh_session_with_summary_hint(
tui,
app_server,
Some(ThreadStartSource::Clear),
/*initial_user_message*/ None,
)
.await;
}
AppEvent::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(),
),
)
.await;
}
AppEvent::OpenResumePicker => {
let picker_app_server = match crate::start_app_server_for_picker(
&self.config,
&match self.remote_app_server_url.clone() {
Some(websocket_url) => crate::AppServerTarget::Remote {
websocket_url,
auth_token: self.remote_app_server_auth_token.clone(),
},
None => crate::AppServerTarget::Embedded,
},
self.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}"
));
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,
)
.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(_) => {}
}
// Leaving alt-screen may blank the inline viewport; force a redraw either way.
tui.frame_requester().schedule_frame();
}
AppEvent::ResumeSessionByIdOrName(id_or_name) => {
match crate::lookup_session_target_with_app_server(app_server, &id_or_name).await? {
Some(target_session) => {
return self
.resume_target_session(tui, app_server, target_session)
.await;
}
None => {
self.chat_widget.add_error_message(format!(
"No saved chat found matching '{id_or_name}'."
));
}
}
}
AppEvent::ForkCurrentSession => {
self.session_telemetry.counter(
"codex.thread.fork",
/*inc*/ 1,
&[("source", "slash_command")],
);
let summary = session_summary(
self.chat_widget.token_usage(),
self.chat_widget.thread_id(),
self.chat_widget.thread_name(),
self.chat_widget.rollout_path().as_deref(),
);
self.chat_widget
.add_plain_history_lines(vec!["/fork".magenta().into()]);
if let Some(thread_id) = self.chat_widget.thread_id() {
self.refresh_in_memory_config_from_disk_best_effort("forking the thread")
.await;
match app_server.fork_thread(self.config.clone(), thread_id).await {
Ok(forked) => {
self.shutdown_current_thread(app_server).await;
match self
.replace_chat_widget_with_app_server_thread(
tui, app_server, forked, /*initial_user_message*/ None,
)
.await
{
Ok(()) => {
if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> = Vec::new();
if let Some(usage_line) = summary.usage_line {
lines.push(usage_line.into());
}
if let Some(command) = summary.resume_command {
let spans = vec![
"To continue this session, run ".into(),
command.cyan(),
];
lines.push(spans.into());
}
self.chat_widget.add_plain_history_lines(lines);
}
}
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to attach to forked app-server thread: {err}"
));
}
}
}
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to fork current session through the app server: {err}"
));
}
}
} else {
self.chat_widget.add_error_message(
"A thread must contain at least one turn before it can be forked."
.to_string(),
);
}
tui.frame_requester().schedule_frame();
}
AppEvent::BeginInitialHistoryReplayBuffer => {
self.begin_initial_history_replay_buffer();
}
AppEvent::BeginThreadSwitchHistoryReplayBuffer => {
self.begin_thread_switch_history_replay_buffer();
}
AppEvent::InsertHistoryCell(cell) => {
let cell: Arc<dyn HistoryCell> = cell.into();
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
t.insert_cell(cell.clone());
tui.frame_requester().schedule_frame();
}
self.transcript_cells.push(cell.clone());
if self.initial_history_replay_buffer.as_ref().is_some() {
self.insert_history_cell_lines_with_initial_replay_buffer(
tui,
cell.as_ref(),
tui.terminal.last_known_screen_size.width,
);
} else {
self.insert_history_cell_lines(
tui,
cell.as_ref(),
tui.terminal.last_known_screen_size.width,
);
}
}
AppEvent::EndInitialHistoryReplayBuffer => {
self.finish_initial_history_replay_buffer(tui);
}
AppEvent::ConsolidateAgentMessage {
source,
cwd,
scrollback_reflow,
deferred_history_cell,
} => {
self.handle_consolidate_agent_message(
tui,
source,
cwd,
scrollback_reflow,
deferred_history_cell,
)?;
}
AppEvent::ConsolidateProposedPlan(source) => {
if !self.terminal_resize_reflow_enabled() {
self.transcript_reflow.clear();
return Ok(AppRunControl::Continue);
}
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(),
tui.terminal.last_known_screen_size.width,
);
self.maybe_finish_stream_reflow(tui)?;
}
}
AppEvent::ApplyThreadRollback { num_turns } => {
if self.apply_non_pending_thread_rollback(num_turns) {
tui.frame_requester().schedule_frame();
}
}
AppEvent::StartCommitAnimation => {
if self
.commit_anim_running
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_ok()
{
let tx = self.app_event_tx.clone();
let running = self.commit_anim_running.clone();
thread::spawn(move || {
while running.load(Ordering::Relaxed) {
thread::sleep(COMMIT_ANIMATION_TICK);
tx.send(AppEvent::CommitTick);
}
});
}
}
AppEvent::StopCommitAnimation => {
self.commit_anim_running.store(false, Ordering::Release);
}
AppEvent::CommitTick => {
self.chat_widget.on_commit_tick();
}
AppEvent::Exit(mode) => {
return Ok(self.handle_exit_mode(app_server, mode).await);
}
AppEvent::Logout => match app_server.logout_account().await {
Ok(()) => {
return Ok(self
.handle_exit_mode(app_server, ExitMode::ShutdownFirst)
.await);
}
Err(err) => {
tracing::error!("failed to logout: {err}");
self.chat_widget
.add_error_message(format!("Logout failed: {err}"));
}
},
AppEvent::FatalExitRequest(message) => {
return Ok(AppRunControl::Exit(ExitReason::Fatal(message)));
}
AppEvent::CodexOp(op) => {
self.submit_active_thread_op(app_server, op).await?;
}
AppEvent::AppendMessageHistoryEntry { thread_id, text } => {
self.append_message_history_entry(thread_id, text);
}
AppEvent::LookupMessageHistoryEntry {
thread_id,
offset,
log_id,
} => {
self.lookup_message_history_entry(thread_id, offset, 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(text) => {
// Clear the in-progress state in the bottom pane
self.chat_widget.on_diff_complete();
// Enter alternate screen using TUI helper and build pager lines
let _ = tui.enter_alt_screen();
let pager_lines: Vec<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::RefreshConnectors { force_refetch } => {
self.chat_widget.refresh_connectors(force_refetch);
}
AppEvent::PluginInstallAuthAdvance { refresh_connectors } => {
if refresh_connectors {
self.chat_widget.refresh_connectors(/*force_refetch*/ true);
}
self.chat_widget.advance_plugin_install_auth_flow();
}
AppEvent::PluginInstallAuthAbandon => {
self.chat_widget.abandon_plugin_install_auth_flow();
}
AppEvent::FetchPluginsList { cwd } => {
self.fetch_plugins_list(app_server, cwd);
}
AppEvent::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::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() {
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
tracing::warn!(error = %err, "failed to refresh config after marketplace add");
}
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 {
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
tracing::warn!(
error = %err,
"failed to refresh config after marketplace upgrade"
);
}
self.chat_widget.refresh_plugin_mentions();
self.chat_widget.submit_op(AppCommand::reload_user_config());
}
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()
{
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
tracing::warn!(error = %err, "failed to refresh config after marketplace remove");
}
self.chat_widget.refresh_plugin_mentions();
self.chat_widget.submit_op(AppCommand::reload_user_config());
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,
marketplace_path,
plugin_name,
plugin_display_name,
} => {
self.fetch_plugin_install(
app_server,
cwd,
marketplace_path,
plugin_name,
plugin_display_name,
);
}
AppEvent::FetchPluginUninstall {
cwd,
plugin_id,
plugin_display_name,
} => {
self.fetch_plugin_uninstall(app_server, cwd, plugin_id, plugin_display_name);
}
AppEvent::SetPluginEnabled {
cwd,
plugin_id,
enabled,
} => {
self.set_plugin_enabled(app_server, cwd, plugin_id, enabled);
}
AppEvent::PluginInstallLoaded {
cwd,
marketplace_path,
plugin_name,
plugin_display_name,
result,
} => {
let install_succeeded = result.is_ok();
if install_succeeded {
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
tracing::warn!(error = %err, "failed to refresh config after plugin install");
}
self.chat_widget.refresh_plugin_mentions();
self.chat_widget.submit_op(AppCommand::reload_user_config());
}
let should_refresh_plugin_detail = self.chat_widget.on_plugin_install_loaded(
cwd.clone(),
marketplace_path.clone(),
plugin_name.clone(),
plugin_display_name,
result,
);
if install_succeeded && self.chat_widget.config_ref().cwd.as_path() == cwd.as_path()
{
self.fetch_plugins_list(app_server, cwd.clone());
if should_refresh_plugin_detail {
self.fetch_plugin_detail(
app_server,
cwd,
PluginReadParams {
marketplace_path: Some(marketplace_path),
remote_marketplace_name: None,
plugin_name,
},
);
}
}
}
AppEvent::PluginEnabledSet {
cwd,
plugin_id,
enabled,
result,
} => {
let queued_enabled = self
.pending_plugin_enabled_writes
.get_mut(&plugin_id)
.and_then(Option::take);
let should_apply_result = if let Some(queued_enabled) = queued_enabled
&& (result.is_err() || queued_enabled != enabled)
{
self.spawn_plugin_enabled_write(
app_server,
cwd.clone(),
plugin_id.clone(),
queued_enabled,
);
false
} else {
true
};
if should_apply_result {
self.pending_plugin_enabled_writes.remove(&plugin_id);
let update_succeeded = result.is_ok();
if update_succeeded {
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
tracing::warn!(
error = %err,
"failed to refresh config after plugin toggle"
);
}
self.chat_widget.refresh_plugin_mentions();
self.chat_widget.submit_op(AppCommand::reload_user_config());
}
self.chat_widget
.on_plugin_enabled_set(cwd, plugin_id, enabled, result);
}
}
AppEvent::FetchMcpInventory { detail } => {
self.fetch_mcp_inventory(app_server, detail);
}
AppEvent::McpInventoryLoaded { result, detail } => {
self.handle_mcp_inventory_result(result, detail);
}
AppEvent::SkillsListLoaded { result } => {
self.handle_skills_list_result(
result.map_err(|err| color_eyre::eyre::eyre!(err)),
"failed to load skills on startup",
);
}
AppEvent::StartFileSearch(query) => {
self.file_search.on_user_query(query);
}
AppEvent::FileSearchResult { query, matches } => {
self.chat_widget.apply_file_search_result(query, matches);
}
AppEvent::RefreshRateLimits { origin } => {
self.refresh_rate_limits(app_server, origin);
}
AppEvent::OpenThreadGoalMenu { thread_id } => {
self.open_thread_goal_menu(app_server, thread_id).await;
}
AppEvent::SetThreadGoalObjective {
thread_id,
objective,
mode,
} => {
self.set_thread_goal_objective(app_server, thread_id, objective, 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, result } => match result {
Ok(snapshots) => {
for snapshot in snapshots {
self.chat_widget.on_rate_limit_snapshot(Some(snapshot));
}
match origin {
RateLimitRefreshOrigin::StartupPrefetch => {
tui.frame_requester().schedule_frame();
}
RateLimitRefreshOrigin::StatusCommand { request_id } => {
self.chat_widget
.finish_status_rate_limit_refresh(request_id);
}
}
}
Err(err) => {
tracing::warn!("account/rateLimits/read failed during TUI refresh: {err}");
if let RateLimitRefreshOrigin::StatusCommand { request_id } = origin {
self.chat_widget
.finish_status_rate_limit_refresh(request_id);
}
}
},
AppEvent::ConnectorsLoaded { result, is_final } => {
self.chat_widget.on_connectors_loaded(result, is_final);
}
AppEvent::UpdateReasoningEffort(effort) => {
self.on_update_reasoning_effort(effort);
}
AppEvent::UpdateModel(model) => {
self.chat_widget.set_model(&model);
}
AppEvent::UpdateCollaborationMode(mask) => {
self.chat_widget.set_collaboration_mask(mask);
}
AppEvent::UpdatePersonality(personality) => {
self.on_update_personality(personality);
}
AppEvent::OpenRealtimeAudioDeviceSelection { kind } => {
self.chat_widget.open_realtime_audio_device_selection(kind);
}
AppEvent::RealtimeWebrtcOfferCreated { result } => {
self.chat_widget.on_realtime_webrtc_offer_created(result);
}
AppEvent::RealtimeWebrtcEvent(event) => {
self.chat_widget.on_realtime_webrtc_event(event);
}
AppEvent::RealtimeWebrtcLocalAudioLevel(peak) => {
self.chat_widget.on_realtime_webrtc_local_audio_level(peak);
}
AppEvent::OpenReasoningPopup { model } => {
self.chat_widget.open_reasoning_popup(model);
}
AppEvent::OpenPlanReasoningScopePrompt { model, effort } => {
self.chat_widget
.open_plan_reasoning_scope_prompt(model, effort);
}
AppEvent::OpenAllModelsPopup { models } => {
self.chat_widget.open_all_models_popup(models);
}
AppEvent::OpenFullAccessConfirmation {
preset,
return_to_permissions,
} => {
self.chat_widget
.open_full_access_confirmation(preset, return_to_permissions);
}
AppEvent::OpenWorldWritableWarningConfirmation {
preset,
sample_paths,
extra_count,
failed_scan,
} => {
self.chat_widget.open_world_writable_warning_confirmation(
preset,
sample_paths,
extra_count,
failed_scan,
);
}
AppEvent::OpenFeedbackNote {
category,
include_logs,
} => {
self.chat_widget.open_feedback_note(category, include_logs);
}
AppEvent::OpenFeedbackConsent { category } => {
self.chat_widget.open_feedback_consent(category);
}
AppEvent::SubmitFeedback {
category,
reason,
turn_id,
include_logs,
} => {
self.submit_feedback(app_server, category, reason, turn_id, include_logs);
}
AppEvent::FeedbackSubmitted {
origin_thread_id,
category,
include_logs,
result,
} => {
self.handle_feedback_submitted(origin_thread_id, category, include_logs, result)
.await;
}
AppEvent::LaunchExternalEditor => {
if self.chat_widget.external_editor_state() == ExternalEditorState::Active {
self.launch_external_editor(tui).await;
}
}
AppEvent::OpenWindowsSandboxEnablePrompt { preset } => {
self.chat_widget.open_windows_sandbox_enable_prompt(preset);
}
AppEvent::OpenWindowsSandboxFallbackPrompt { preset } => {
self.session_telemetry.counter(
"codex.windows_sandbox.fallback_prompt_shown",
/*inc*/ 1,
&[],
);
self.chat_widget.clear_windows_sandbox_setup_status();
if let Some(started_at) = self.windows_sandbox.setup_started_at.take() {
self.session_telemetry.record_duration(
"codex.windows_sandbox.elevated_setup_duration_ms",
started_at.elapsed(),
&[("result", "failure")],
);
}
self.chat_widget
.open_windows_sandbox_fallback_prompt(preset);
}
AppEvent::BeginWindowsSandboxElevatedSetup { preset } => {
#[cfg(target_os = "windows")]
{
let permission_profile = preset.permission_profile.clone();
let policy_cwd = self.config.cwd.clone();
let command_cwd = policy_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::legacy_core::windows_sandbox::sandbox_setup_is_complete(
codex_home.as_path(),
) {
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
preset,
mode: WindowsSandboxEnableMode::Elevated,
});
return Ok(AppRunControl::Continue);
}
self.chat_widget.show_windows_sandbox_setup_status();
self.windows_sandbox.setup_started_at = Some(Instant::now());
let session_telemetry = self.session_telemetry.clone();
let Ok(policy) = permission_profile
.to_legacy_sandbox_policy(policy_cwd.as_path())
.inspect_err(|err| {
tracing::error!(
%err,
"approval preset permissions cannot be projected for elevated Windows sandbox setup"
);
})
else {
tx.send(AppEvent::OpenWindowsSandboxFallbackPrompt { preset });
return Ok(AppRunControl::Continue);
};
tokio::task::spawn_blocking(move || {
let result = crate::legacy_core::windows_sandbox::run_elevated_setup(
&policy,
policy_cwd.as_path(),
command_cwd.as_path(),
&env_map,
codex_home.as_path(),
);
let event = match result {
Ok(()) => {
session_telemetry.counter(
"codex.windows_sandbox.elevated_setup_success",
/*inc*/ 1,
&[],
);
AppEvent::EnableWindowsSandboxForAgentMode {
preset: preset.clone(),
mode: WindowsSandboxEnableMode::Elevated,
}
}
Err(err) => {
let mut code_tag: Option<String> = None;
let mut message_tag: Option<String> = None;
if let Some((code, message)) =
crate::legacy_core::windows_sandbox::elevated_setup_failure_details(
&err,
)
{
code_tag = Some(code);
message_tag = Some(message);
}
let mut tags: Vec<(&str, &str)> = Vec::new();
if let Some(code) = code_tag.as_deref() {
tags.push(("code", code));
}
if let Some(message) = message_tag.as_deref() {
tags.push(("message", message));
}
session_telemetry.counter(
crate::legacy_core::windows_sandbox::elevated_setup_failure_metric_name(
&err,
),
/*inc*/ 1,
&tags,
);
tracing::error!(
error = %err,
"failed to run elevated Windows sandbox setup"
);
AppEvent::OpenWindowsSandboxFallbackPrompt { preset }
}
};
tx.send(event);
});
}
#[cfg(not(target_os = "windows"))]
{
let _ = preset;
}
}
AppEvent::BeginWindowsSandboxLegacySetup { preset } => {
#[cfg(target_os = "windows")]
{
let permission_profile = preset.permission_profile.clone();
let policy_cwd = self.config.cwd.clone();
let command_cwd = policy_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();
let Ok(policy) = permission_profile
.to_legacy_sandbox_policy(policy_cwd.as_path())
.inspect_err(|err| {
tracing::error!(
%err,
"approval preset permissions cannot be projected for legacy Windows sandbox setup"
);
})
else {
tx.send(AppEvent::OpenWindowsSandboxFallbackPrompt { preset });
return Ok(AppRunControl::Continue);
};
tokio::task::spawn_blocking(move || {
if let Err(err) =
crate::legacy_core::windows_sandbox::run_legacy_setup_preflight(
&policy,
policy_cwd.as_path(),
command_cwd.as_path(),
&env_map,
codex_home.as_path(),
)
{
session_telemetry.counter(
"codex.windows_sandbox.legacy_setup_preflight_failed",
/*inc*/ 1,
&[],
);
tracing::warn!(
error = %err,
"failed to preflight non-admin Windows sandbox setup"
);
}
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
preset,
mode: WindowsSandboxEnableMode::Legacy,
});
});
}
#[cfg(not(target_os = "windows"))]
{
let _ = preset;
}
}
AppEvent::BeginWindowsSandboxGrantReadRoot { path } => {
#[cfg(target_os = "windows")]
{
self.chat_widget
.add_to_history(history_cell::new_info_event(
format!("Granting sandbox read access to {path} ..."),
/*hint*/ None,
));
let policy = self
.config
.permissions
.legacy_sandbox_policy(self.config.cwd.as_path());
let policy_cwd = self.config.cwd.clone();
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::legacy_core::grant_read_root_non_elevated(
&policy,
policy_cwd.as_path(),
command_cwd.as_path(),
&env_map,
codex_home.as_path(),
requested_path.as_path(),
) {
Ok(canonical_path) => AppEvent::WindowsSandboxGrantReadRootCompleted {
path: canonical_path,
error: None,
},
Err(err) => AppEvent::WindowsSandboxGrantReadRootCompleted {
path: requested_path,
error: Some(err.to_string()),
},
};
tx.send(event);
});
}
#[cfg(not(target_os = "windows"))]
{
let _ = path;
}
}
AppEvent::WindowsSandboxGrantReadRootCompleted { path, error } => match error {
Some(err) => {
self.chat_widget
.add_to_history(history_cell::new_error_event(format!("Error: {err}")));
}
None => {
self.chat_widget
.add_to_history(history_cell::new_info_event(
format!("Sandbox read access granted for {}", path.display()),
/*hint*/ None,
));
}
},
AppEvent::EnableWindowsSandboxForAgentMode { preset, mode } => {
#[cfg(target_os = "windows")]
{
self.chat_widget.clear_windows_sandbox_setup_status();
if let Some(started_at) = self.windows_sandbox.setup_started_at.take() {
self.session_telemetry.record_duration(
"codex.windows_sandbox.elevated_setup_duration_ms",
started_at.elapsed(),
&[("result", "success")],
);
}
let profile = self.active_profile.as_deref();
let elevated_enabled = matches!(mode, WindowsSandboxEnableMode::Elevated);
let builder = ConfigEditsBuilder::new(&self.config.codex_home)
.with_profile(profile)
.set_windows_sandbox_mode(if elevated_enabled {
"elevated"
} else {
"unelevated"
})
.clear_legacy_windows_sandbox_keys();
match builder.apply().await {
Ok(()) => {
if elevated_enabled {
self.config.set_windows_sandbox_enabled(/*value*/ false);
self.config
.set_windows_elevated_sandbox_enabled(/*value*/ true);
} else {
self.config.set_windows_sandbox_enabled(/*value*/ true);
self.config
.set_windows_elevated_sandbox_enabled(/*value*/ false);
}
self.chat_widget.set_windows_sandbox_mode(
self.config.permissions.windows_sandbox_mode,
);
let windows_sandbox_level =
WindowsSandboxLevel::from_config(&self.config);
if let Some((sample_paths, extra_count, failed_scan)) =
self.chat_widget.world_writable_warning_details()
{
self.app_event_tx.send(AppEvent::CodexOp(
AppCommand::override_turn_context(
/*cwd*/ None,
/*approval_policy*/ None,
/*approvals_reviewer*/ None,
/*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()),
sample_paths,
extra_count,
failed_scan,
},
);
} 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()),
#[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::UpdatePermissionProfile(
preset.permission_profile.clone(),
));
let _ = mode;
self.chat_widget.add_plain_history_lines(vec![
Line::from(vec!["• ".dim(), "Sandbox ready".into()]),
Line::from(vec![
" ".into(),
"Codex can now safely edit files and execute commands in your computer"
.dark_gray(),
]),
]);
}
}
Err(err) => {
tracing::error!(
error = %err,
"failed to enable Windows sandbox feature"
);
self.chat_widget.add_error_message(format!(
"Failed to enable the Windows sandbox feature: {err}"
));
}
}
}
#[cfg(not(target_os = "windows"))]
{
let _ = (preset, mode);
}
}
AppEvent::PersistModelSelection { model, effort } => {
let profile = self.active_profile.as_deref();
match ConfigEditsBuilder::new(&self.config.codex_home)
.with_profile(profile)
.set_model(Some(model.as_str()), effort)
.apply()
.await
{
Ok(()) => {
let effort_label = effort
.map(|selected_effort| selected_effort.to_string())
.unwrap_or_else(|| "default".to_string());
tracing::info!("Selected model: {model}, Selected effort: {effort_label}");
let mut message = format!("Model changed to {model}");
if let Some(label) = Self::reasoning_label_for(&model, effort) {
message.push(' ');
message.push_str(label);
}
if let Some(profile) = profile {
message.push_str(" for ");
message.push_str(profile);
message.push_str(" profile");
}
self.chat_widget.add_info_message(message, /*hint*/ None);
}
Err(err) => {
tracing::error!(
error = %err,
"failed to persist model selection"
);
if let Some(profile) = profile {
self.chat_widget.add_error_message(format!(
"Failed to save model for profile `{profile}`: {err}"
));
} else {
self.chat_widget
.add_error_message(format!("Failed to save default model: {err}"));
}
}
}
}
AppEvent::PluginUninstallLoaded {
cwd,
plugin_id: _plugin_id,
plugin_display_name,
result,
} => {
let uninstall_succeeded = result.is_ok();
if uninstall_succeeded {
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
tracing::warn!(
error = %err,
"failed to refresh config after plugin uninstall"
);
}
self.chat_widget.refresh_plugin_mentions();
self.chat_widget.submit_op(AppCommand::reload_user_config());
}
self.chat_widget.on_plugin_uninstall_loaded(
cwd.clone(),
plugin_display_name,
result,
);
if uninstall_succeeded
&& self.chat_widget.config_ref().cwd.as_path() == cwd.as_path()
{
self.fetch_plugins_list(app_server, cwd);
}
}
AppEvent::RefreshPluginMentions => {
self.refresh_plugin_mentions();
}
AppEvent::PluginMentionsLoaded { mut plugins } => {
if !self.config.features.enabled(Feature::Plugins) {
plugins = None;
}
self.chat_widget.on_plugin_mentions_loaded(plugins);
}
AppEvent::PersistPersonalitySelection { personality } => {
let profile = self.active_profile.as_deref();
match ConfigEditsBuilder::new(&self.config.codex_home)
.with_profile(profile)
.set_personality(Some(personality))
.apply()
.await
{
Ok(()) => {
let label = Self::personality_label(personality);
let mut message = format!("Personality set to {label}");
if let Some(profile) = profile {
message.push_str(" for ");
message.push_str(profile);
message.push_str(" profile");
}
self.chat_widget.add_info_message(message, /*hint*/ None);
}
Err(err) => {
tracing::error!(
error = %err,
"failed to persist personality selection"
);
if let Some(profile) = profile {
self.chat_widget.add_error_message(format!(
"Failed to save personality for profile `{profile}`: {err}"
));
} else {
self.chat_widget.add_error_message(format!(
"Failed to save default personality: {err}"
));
}
}
}
}
AppEvent::PersistServiceTierSelection { service_tier } => {
self.refresh_status_line();
let profile = self.active_profile.as_deref();
self.config.service_tier = service_tier.clone();
let mut edits = ConfigEditsBuilder::new(&self.config.codex_home)
.with_profile(profile)
.set_service_tier(service_tier.clone());
if service_tier.is_none() {
self.config.notices.fast_default_opt_out = Some(true);
edits = edits.set_fast_default_opt_out(/*opted_out*/ true);
}
match edits.apply().await {
Ok(()) => {
let mut message = if let Some(service_tier) = service_tier {
format!("Service tier set to {service_tier}")
} else {
"Service tier cleared".to_string()
};
if let Some(profile) = profile {
message.push_str(" for ");
message.push_str(profile);
message.push_str(" profile");
}
self.chat_widget.add_info_message(message, /*hint*/ None);
}
Err(err) => {
tracing::error!(error = %err, "failed to persist service tier selection");
if let Some(profile) = profile {
self.chat_widget.add_error_message(format!(
"Failed to save service tier for profile `{profile}`: {err}"
));
} else {
self.chat_widget.add_error_message(format!(
"Failed to save default service tier: {err}"
));
}
}
}
}
AppEvent::PersistRealtimeAudioDeviceSelection { kind, name } => {
let builder = match kind {
RealtimeAudioDeviceKind::Microphone => {
ConfigEditsBuilder::new(&self.config.codex_home)
.set_realtime_microphone(name.as_deref())
}
RealtimeAudioDeviceKind::Speaker => {
ConfigEditsBuilder::new(&self.config.codex_home)
.set_realtime_speaker(name.as_deref())
}
};
match builder.apply().await {
Ok(()) => {
match kind {
RealtimeAudioDeviceKind::Microphone => {
self.config.realtime_audio.microphone = name.clone();
}
RealtimeAudioDeviceKind::Speaker => {
self.config.realtime_audio.speaker = name.clone();
}
}
self.chat_widget
.set_realtime_audio_device(kind, name.clone());
if self.chat_widget.realtime_conversation_is_live() {
self.chat_widget.open_realtime_audio_restart_prompt(kind);
} else {
let selection = name.unwrap_or_else(|| "System default".to_string());
self.chat_widget.add_info_message(
format!("Realtime {} set to {selection}", kind.noun()),
/*hint*/ None,
);
}
}
Err(err) => {
tracing::error!(
error = %err,
"failed to persist realtime audio selection"
);
self.chat_widget.add_error_message(format!(
"Failed to save realtime {}: {err}",
kind.noun()
));
}
}
}
AppEvent::RestartRealtimeAudioDevice { kind } => {
self.chat_widget.restart_realtime_audio_device(kind);
}
AppEvent::UpdateAskForApprovalPolicy(policy) => {
let mut config = self.config.clone();
if !self.try_set_approval_policy_on_config(
&mut config,
policy,
"Failed to set approval policy",
"failed to set approval policy on app config",
) {
return Ok(AppRunControl::Continue);
}
self.config = config;
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::UpdatePermissionProfile(permission_profile) => {
#[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();
let mut config = self.config.clone();
if !self.try_set_permission_profile_on_config(
&mut config,
permission_profile,
"Failed to set permission profile",
"failed to set permission profile on app config",
) {
return Ok(AppRunControl::Continue);
}
self.config = config;
if let Err(err) = self
.chat_widget
.set_permission_profile(permission_profile_for_chat)
{
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(self.config.permissions.permission_profile());
self.sync_active_thread_permission_settings_to_cached_session()
.await;
// 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 = WindowsSandboxLevel::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 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.permission_profile();
Self::spawn_world_writable_scan(
cwd,
env_map,
logs_base_dir,
permission_profile,
tx,
);
}
}
}
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;
let profile = self.active_profile.as_deref();
let segments = if let Some(profile) = profile {
vec![
"profiles".to_string(),
profile.to_string(),
"approvals_reviewer".to_string(),
]
} else {
vec!["approvals_reviewer".to_string()]
};
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
.with_profile(profile)
.with_edits([ConfigEdit::SetPath {
segments,
value: policy.to_string().into(),
}])
.apply()
.await
{
tracing::error!(
error = %err,
"failed to persist approvals reviewer update"
);
self.chat_widget
.add_error_message(format!("Failed to save approvals reviewer: {err}"));
}
}
AppEvent::UpdateFeatureFlags { updates } => {
self.update_feature_flags(updates).await;
}
AppEvent::UpdateMemorySettings {
use_memories,
generate_memories,
} => {
self.update_memory_settings_with_app_server(
app_server,
use_memories,
generate_memories,
)
.await;
}
AppEvent::ResetMemories => {
self.reset_memories_with_app_server(app_server).await;
}
AppEvent::SkipNextWorldWritableScan => {
self.windows_sandbox.skip_world_writable_scan_once = true;
}
AppEvent::UpdateFullAccessWarningAcknowledged(ack) => {
self.chat_widget.set_full_access_warning_acknowledged(ack);
}
AppEvent::UpdateWorldWritableWarningAcknowledged(ack) => {
self.chat_widget
.set_world_writable_warning_acknowledged(ack);
}
AppEvent::UpdateRateLimitSwitchPromptHidden(hidden) => {
self.chat_widget.set_rate_limit_switch_prompt_hidden(hidden);
}
AppEvent::UpdatePlanModeReasoningEffort(effort) => {
self.config.plan_mode_reasoning_effort = effort;
self.chat_widget.set_plan_mode_reasoning_effort(effort);
}
AppEvent::PersistFullAccessWarningAcknowledged => {
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
.set_hide_full_access_warning(/*acknowledged*/ true)
.apply()
.await
{
tracing::error!(
error = %err,
"failed to persist full access warning acknowledgement"
);
self.chat_widget.add_error_message(format!(
"Failed to save full access confirmation preference: {err}"
));
}
}
AppEvent::PersistWorldWritableWarningAcknowledged => {
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
.set_hide_world_writable_warning(/*acknowledged*/ true)
.apply()
.await
{
tracing::error!(
error = %err,
"failed to persist world-writable warning acknowledgement"
);
self.chat_widget.add_error_message(format!(
"Failed to save Agent mode warning preference: {err}"
));
}
}
AppEvent::PersistRateLimitSwitchPromptHidden => {
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
.set_hide_rate_limit_model_nudge(/*acknowledged*/ true)
.apply()
.await
{
tracing::error!(
error = %err,
"failed to persist rate limit switch prompt preference"
);
self.chat_widget.add_error_message(format!(
"Failed to save rate limit reminder preference: {err}"
));
}
}
AppEvent::PersistPlanModeReasoningEffort(effort) => {
let profile = self.active_profile.as_deref();
let segments = if let Some(profile) = profile {
vec![
"profiles".to_string(),
profile.to_string(),
"plan_mode_reasoning_effort".to_string(),
]
} else {
vec!["plan_mode_reasoning_effort".to_string()]
};
let edit = if let Some(effort) = effort {
ConfigEdit::SetPath {
segments,
value: effort.to_string().into(),
}
} else {
ConfigEdit::ClearPath { segments }
};
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
.with_edits([edit])
.apply()
.await
{
tracing::error!(
error = %err,
"failed to persist plan mode reasoning effort"
);
if let Some(profile) = profile {
self.chat_widget.add_error_message(format!(
"Failed to save Plan mode reasoning effort for profile `{profile}`: {err}"
));
} else {
self.chat_widget.add_error_message(format!(
"Failed to save Plan mode reasoning effort: {err}"
));
}
}
}
AppEvent::PersistModelMigrationPromptAcknowledged {
from_model,
to_model,
} => {
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
.record_model_migration_seen(from_model.as_str(), to_model.as_str())
.apply()
.await
{
tracing::error!(
error = %err,
"failed to persist model migration prompt acknowledgement"
);
self.chat_widget.add_error_message(format!(
"Failed to save model migration prompt preference: {err}"
));
}
}
AppEvent::OpenApprovalsPopup => {
self.chat_widget.open_approvals_popup();
}
AppEvent::OpenAgentPicker => {
self.open_agent_picker(app_server).await;
}
AppEvent::SelectAgentThread(thread_id) => {
self.select_agent_thread_and_discard_side(tui, app_server, thread_id)
.await?;
}
AppEvent::StartSide {
parent_thread_id,
user_message,
} => {
return self
.handle_start_side(tui, app_server, parent_thread_id, user_message)
.await;
}
AppEvent::OpenSkillsList => {
self.chat_widget.open_skills_list();
}
AppEvent::OpenManageSkillsPopup => {
self.chat_widget.open_manage_skills_popup();
}
AppEvent::SetSkillEnabled { path, enabled } => {
let edits = [ConfigEdit::SetSkillConfig {
path: path.to_path_buf(),
enabled,
}];
match ConfigEditsBuilder::new(&self.config.codex_home)
.with_edits(edits)
.apply()
.await
{
Ok(()) => {
self.chat_widget.update_skill_enabled(path, enabled);
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
tracing::warn!(
error = %err,
"failed to refresh config after skill toggle"
);
}
}
Err(err) => {
let path_display = path.display();
self.chat_widget.add_error_message(format!(
"Failed to update skill config for {path_display}: {err}"
));
}
}
}
AppEvent::SetAppEnabled { id, enabled } => {
let edits = if enabled {
vec![
ConfigEdit::ClearPath {
segments: vec!["apps".to_string(), id.clone(), "enabled".to_string()],
},
ConfigEdit::ClearPath {
segments: vec![
"apps".to_string(),
id.clone(),
"disabled_reason".to_string(),
],
},
]
} else {
vec![
ConfigEdit::SetPath {
segments: vec!["apps".to_string(), id.clone(), "enabled".to_string()],
value: false.into(),
},
ConfigEdit::SetPath {
segments: vec![
"apps".to_string(),
id.clone(),
"disabled_reason".to_string(),
],
value: "user".into(),
},
]
};
match ConfigEditsBuilder::new(&self.config.codex_home)
.with_edits(edits)
.apply()
.await
{
Ok(()) => {
self.chat_widget.update_connector_enabled(&id, enabled);
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
tracing::warn!(error = %err, "failed to refresh config after app toggle");
}
self.chat_widget.submit_op(AppCommand::reload_user_config());
}
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to update app config for {id}: {err}"
));
}
}
}
AppEvent::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 { cwd, changes, .. } => {
let _ = tui.enter_alt_screen();
let diff_summary = DiffSummary::new(changes, cwd);
self.overlay = Some(Overlay::new_static_with_renderables(
vec![diff_summary.into()],
"P A T C H".to_string(),
self.keymap.pager.clone(),
));
}
ApprovalRequest::Exec { command, .. } => {
let _ = tui.enter_alt_screen();
let full_cmd = strip_bash_lc_and_escape(&command);
let full_cmd_lines = highlight_bash_to_lines(&full_cmd);
self.overlay = Some(Overlay::new_static_with_lines(
full_cmd_lines,
"E X E C".to_string(),
self.keymap.pager.clone(),
));
}
ApprovalRequest::Permissions {
permissions,
reason,
..
} => {
let _ = tui.enter_alt_screen();
let mut lines = Vec::new();
if let Some(reason) = reason {
lines.push(Line::from(vec!["Reason: ".into(), reason.italic()]));
lines.push(Line::from(""));
}
if let Some(rule_line) =
crate::bottom_pane::format_requested_permissions_rule(&permissions)
{
lines.push(Line::from(vec![
"Permission rule: ".into(),
rule_line.cyan(),
]));
}
self.overlay = Some(Overlay::new_static_with_renderables(
vec![Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))],
"P E R M I S S I O N S".to_string(),
self.keymap.pager.clone(),
));
}
ApprovalRequest::McpElicitation {
server_name,
message,
..
} => {
let _ = tui.enter_alt_screen();
let paragraph = Paragraph::new(vec![
Line::from(vec!["Server: ".into(), server_name.bold()]),
Line::from(""),
Line::from(message),
])
.wrap(Wrap { trim: false });
self.overlay = Some(Overlay::new_static_with_renderables(
vec![Box::new(paragraph)],
"E L I C I T A T I O N".to_string(),
self.keymap.pager.clone(),
));
}
},
#[cfg(not(target_os = "linux"))]
AppEvent::UpdateRecordingMeter { id, text } => {
// Update in place to preserve the element id for subsequent frames.
let updated = self.chat_widget.update_recording_meter_in_place(&id, &text);
if updated
|| self
.chat_widget
.stop_realtime_conversation_for_deleted_meter(&id)
{
tui.frame_requester().schedule_frame();
}
}
AppEvent::StatusLineSetup {
items,
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::new(&self.config.codex_home)
.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) => {
tracing::error!(error = %err, "failed to persist status line settings; keeping previous selection");
self.chat_widget.add_error_message(format!(
"Failed to save status line settings: {err}"
));
}
}
}
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::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::new(&self.config.codex_home)
.with_edits([edit])
.apply()
.await;
match apply_result {
Ok(()) => {
self.config.tui_terminal_title = Some(ids.clone());
self.chat_widget.setup_terminal_title(items);
}
Err(err) => {
tracing::error!(error = %err, "failed to persist terminal title items; keeping previous selection");
self.chat_widget.revert_terminal_title_setup_preview();
self.chat_widget.add_error_message(format!(
"Failed to save terminal title items: {err}"
));
}
}
}
AppEvent::TerminalTitleSetupPreview { items } => {
self.chat_widget.preview_terminal_title(items);
}
AppEvent::TerminalTitleSetupCancelled => {
self.chat_widget.cancel_terminal_title_setup();
}
AppEvent::SyntaxThemeSelected { name } => {
let edit = crate::legacy_core::config::edit::syntax_theme_edit(&name);
let apply_result = ConfigEditsBuilder::new(&self.config.codex_home)
.with_edits([edit])
.apply()
.await;
match apply_result {
Ok(()) => {
// Ensure the selected theme is active in the current
// session. The preview callback covers arrow-key
// navigation, but if the user presses Enter without
// navigating, the runtime theme must still be applied.
if let Some(theme) = crate::render::highlight::resolve_theme_by_name(
&name,
Some(&self.config.codex_home),
) {
crate::render::highlight::set_syntax_theme(theme);
}
self.sync_tui_theme_selection(name);
self.refresh_status_line();
}
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();
}
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,
} => {
self.chat_widget
.open_keymap_capture(context, action, intent, &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::new(&self.config.codex_home)
.with_edits([edit])
.apply()
.await
{
Ok(()) => {
self.config.tui_keymap = keymap_config.clone();
self.keymap = runtime_keymap.clone();
self.chat_widget
.apply_keymap_update(keymap_config, &runtime_keymap);
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}"));
}
}
}
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::new(&self.config.codex_home)
.with_edits([edit])
.apply()
.await
{
Ok(()) => {
self.config.tui_keymap = keymap_config.clone();
self.keymap = runtime_keymap.clone();
self.chat_widget
.apply_keymap_update(keymap_config, &runtime_keymap);
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)
}
}
}
}