mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
## Why This PR is the invariant-cleanup layer that follows the workspace-roots base merged in [#22610](https://github.com/openai/codex/pull/22610). #22610 adds `[permissions.<id>.workspace_roots]` and keeps runtime workspace roots separate from the raw permission profile, but its in-memory representation is intentionally transitional: `Permissions` still carries the selected profile identity next to a constrained `PermissionProfile`. That makes APIs such as `set_constrained_permission_profile_with_active_profile()` fragile because the id and value only mean the right thing when every caller keeps them in sync. This PR introduces a single resolved profile state so profile identity, `extends`, the profile value, and profile-declared workspace roots travel together. The next PR, [#22611](https://github.com/openai/codex/pull/22611), builds on this by changing the app-server turn API to select permission profiles by id plus runtime workspace roots. ## Stack Context - #22610, now merged: adds profile-declared `workspace_roots`, runtime workspace roots, and `:workspace_roots` materialization. - This PR: replaces the parallel active-profile/profile-value fields with `PermissionProfileState`. - #22611: switches app-server turn updates toward profile ids plus runtime workspace roots. - #22612: updates TUI/exec summaries to show the effective workspace roots. Keeping this separate from #22611 is deliberate: reviewers can validate the internal state invariant before reviewing the app-server protocol migration. ## What Changed - Added `ResolvedPermissionProfile::{Legacy, BuiltIn, Named}` and `PermissionProfileState`. - Typed built-in profile ids with `BuiltInPermissionProfileId`. - Moved selected profile identity and profile-declared workspace roots into the resolved state. - Replaced `Permissions` parallel profile fields with one `permission_profile_state`. - Removed `set_constrained_permission_profile_with_active_profile()` from session sync paths. - Kept trusted session replay/`SessionConfigured` compatibility through explicit session snapshot helpers. - Updated session configuration, MCP initialization, app-server, exec, TUI, and guardian call sites to consume `&PermissionProfile` directly. ## Review Guide Start with `codex-rs/core/src/config/resolved_permission_profile.rs`; it is the new invariant boundary. Then review `codex-rs/core/src/config/mod.rs` to see how config loading records active profile identity and profile workspace roots. The remaining call-site changes are mostly mechanical fallout from `Permissions::permission_profile()` returning `&PermissionProfile` instead of `&Constrained<PermissionProfile>`. ## Verification The existing config/session coverage now constructs and asserts through `PermissionProfileState`. The workspace-root config test also asserts that profile-declared roots are preserved in the resolved state, which is the behavior #22611 relies on when runtime roots become mutable through the app-server API. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/22683). * #22612 * #22611 * __->__ #22683
2154 lines
95 KiB
Rust
2154 lines
95 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_endpoint.clone() {
|
|
Some(endpoint) => crate::AppServerTarget::Remote { endpoint },
|
|
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(),
|
|
self.chat_widget
|
|
.history_wrap_width(tui.terminal.last_known_screen_size.width),
|
|
);
|
|
} else {
|
|
self.insert_history_cell_lines(
|
|
tui,
|
|
cell.as_ref(),
|
|
self.chat_widget
|
|
.history_wrap_width(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(),
|
|
self.chat_widget
|
|
.history_wrap_width(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::SyncThreadGitBranch { thread_id, branch } => {
|
|
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::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::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::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::OpenThreadGoalEditor { thread_id } => {
|
|
self.open_thread_goal_editor(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::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::for_config(&self.config)
|
|
.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::for_config(&self.config)
|
|
.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(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 } => {
|
|
let profile = self.active_profile.as_deref();
|
|
match ConfigEditsBuilder::for_config(&self.config)
|
|
.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::for_config(&self.config)
|
|
.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::for_config(&self.config)
|
|
.set_realtime_microphone(name.as_deref())
|
|
}
|
|
RealtimeAudioDeviceKind::Speaker => {
|
|
ConfigEditsBuilder::for_config(&self.config)
|
|
.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().clone());
|
|
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.effective_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::for_config(&self.config)
|
|
.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::for_config(&self.config)
|
|
.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::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 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::for_config(&self.config)
|
|
.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::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::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::for_config(&self.config)
|
|
.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::for_config(&self.config)
|
|
.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::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) => {
|
|
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::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();
|
|
}
|
|
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::for_config(&self.config)
|
|
.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::for_config(&self.config)
|
|
.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)
|
|
}
|
|
}
|
|
}
|
|
}
|