mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
## Why SQLite state was still being opened from consumer paths, including lazy `OnceCell`-backed thread-store call sites. That let one process construct multiple state DB connections for the same Codex home, which makes SQLite lock contention and `database is locked` failures much easier to hit. State DB lifetime should be chosen by main-like entrypoints and tests, then passed through explicitly. Consumers should use the supplied `Option<StateDbHandle>` or `StateDbHandle` and keep their existing filesystem fallback or error behavior when no handle is available. The startup path also needs to keep the rollout crate in charge of SQLite state initialization. Opening `codex_state::StateRuntime` directly bypasses rollout metadata backfill, so entrypoints should initialize through `codex_rollout::state_db` and receive a handle only after required rollout backfills have completed. ## What Changed - Initialize the state DB in main-like entrypoints for CLI, TUI, app-server, exec, MCP server, and the thread-manager sample. - Pass `Option<StateDbHandle>` through `ThreadManager`, `LocalThreadStore`, app-server processors, TUI app wiring, rollout listing/recording, personality migration, shell snapshot cleanup, session-name lookup, and memory/device-key consumers. - Remove the lazy local state DB wrapper from the thread store so non-test consumers use only the supplied handle or their existing fallback path. - Make `codex_rollout::state_db::init` the local state startup path: it opens/migrates SQLite, runs rollout metadata backfill when needed, waits for concurrent backfill workers up to a bounded timeout, verifies completion, and then returns the initialized handle. - Keep optional/non-owning SQLite helpers, such as remote TUI local reads, as open-only paths that do not run startup backfill. - Switch app-server startup from direct `codex_state::StateRuntime::init` to the rollout state initializer so app-server cannot skip rollout backfill. - Collapse split rollout lookup/list APIs so callers use the normal methods with an optional state handle instead of `_with_state_db` variants. - Restore `getConversationSummary(ThreadId)` to delegate through `ThreadStore::read_thread` instead of a LocalThreadStore-specific rollout path special case. - Keep DB-backed rollout path lookup keyed on the DB row and file existence, without imposing the filesystem filename convention on existing DB rows. - Verify readable DB-backed rollout paths against `session_meta.id` before returning them, so a stale SQLite row that points at another thread's JSONL falls back to filesystem search and read-repairs the DB row. - Keep `debug prompt-input` filesystem-only so a one-off debug command does not initialize or backfill SQLite state just to print prompt input. - Keep goal-session test Codex homes alive only in the goal-specific helper, rather than leaking tempdirs from the shared session test helper. - Update tests and call sites to pass explicit state handles where DB behavior is expected and explicit `None` where filesystem-only behavior is intended. ## Validation - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo check -p codex-rollout -p codex-thread-store -p codex-app-server -p codex-core -p codex-tui -p codex-exec -p codex-cli --tests` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-rollout state_db_` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-rollout find_thread_path` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-rollout find_thread_path -- --nocapture` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-rollout try_init_ -- --nocapture` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-rollout` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo clippy -p codex-rollout --lib -- -D warnings` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-thread-store read_thread_falls_back_when_sqlite_path_points_to_another_thread -- --nocapture` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-thread-store` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-core shell_snapshot` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-core --test all personality_migration` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-core --test all rollout_list_find` - `RUST_MIN_STACK=8388608 CODEX_SKIP_VENDORED_BWRAP=1 CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-core --test all rollout_list_find::find_prefers_sqlite_path_by_id -- --nocapture` - `RUST_MIN_STACK=8388608 CODEX_SKIP_VENDORED_BWRAP=1 CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-core --test all rollout_list_find -- --nocapture` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-core interrupt_accounts_active_goal_before_pausing` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-app-server get_auth_status -- --test-threads=1` - `CODEX_SKIP_VENDORED_BWRAP=1 CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-app-server --lib` - `CODEX_SKIP_VENDORED_BWRAP=1 CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo check -p codex-rollout -p codex-app-server --tests` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db just fix -p codex-rollout -p codex-thread-store -p codex-core -p codex-app-server -p codex-tui -p codex-exec -p codex-cli` - `CODEX_SKIP_VENDORED_BWRAP=1 CARGO_TARGET_DIR=/tmp/codex-target-state-db just fix -p codex-rollout -p codex-app-server` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db just fix -p codex-rollout` - `CODEX_SKIP_VENDORED_BWRAP=1 CARGO_TARGET_DIR=/tmp/codex-target-state-db just fix -p codex-core` - `just argument-comment-lint -p codex-core` - `just argument-comment-lint -p codex-rollout` Focused coverage added in `codex-rollout`: - `recorder::tests::state_db_init_backfills_before_returning` verifies the rollout metadata row exists before startup init returns. - `state_db::tests::try_init_waits_for_concurrent_startup_backfill` verifies startup waits for another worker to finish backfill instead of disabling the handle for the process. - `state_db::tests::try_init_times_out_waiting_for_stuck_startup_backfill` verifies startup does not hang indefinitely on a stuck backfill lease. - `tests::find_thread_path_accepts_existing_state_db_path_without_canonical_filename` verifies DB-backed lookup accepts valid existing rollout paths even when the filename does not include the thread UUID. - `tests::find_thread_path_falls_back_when_db_path_points_to_another_thread` verifies DB-backed lookup ignores a stale row whose existing path belongs to another thread and read-repairs the row after filesystem fallback. Focused coverage updated in `codex-core`: - `rollout_list_find::find_prefers_sqlite_path_by_id` now uses a DB-preferred rollout file with matching `session_meta.id`, so it still verifies that valid SQLite paths win without depending on stale/empty rollout contents. `cargo test -p codex-app-server thread_list_respects_search_term_filter -- --test-threads=1 --nocapture` was attempted locally but timed out waiting for the app-server test harness `initialize` response before reaching the changed thread-list code path. `bazel test //codex-rs/thread-store:thread-store-unit-tests --test_output=errors` was attempted locally after the thread-store fix, but this container failed before target analysis while fetching `v8+` through BuildBuddy/direct GitHub. The equivalent local crate coverage, including `cargo test -p codex-thread-store`, passes. A plain local `cargo check -p codex-rollout -p codex-app-server --tests` also requires system `libcap.pc` for `codex-linux-sandbox`; the follow-up app-server check above used `CODEX_SKIP_VENDORED_BWRAP=1` in this container.
2102 lines
93 KiB
Rust
2102 lines
93 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::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_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
|
|
| 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 } => {
|
|
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::AgentMessageCell>(&self.transcript_cells);
|
|
if start < end {
|
|
let consolidated: Arc<dyn HistoryCell> =
|
|
Arc::new(history_cell::AgentMarkdownCell::new(source, &cwd));
|
|
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.maybe_finish_stream_reflow(tui)?;
|
|
} else {
|
|
self.maybe_finish_stream_reflow(tui)?;
|
|
}
|
|
}
|
|
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::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;
|
|
let mut edits = ConfigEditsBuilder::new(&self.config.codex_home)
|
|
.with_profile(profile)
|
|
.set_service_tier(service_tier);
|
|
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 status = if matches!(
|
|
service_tier,
|
|
Some(codex_protocol::config_types::ServiceTier::Fast)
|
|
) {
|
|
"on"
|
|
} else {
|
|
"off"
|
|
};
|
|
let mut message = format!("Fast mode set to {status}");
|
|
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 fast mode selection");
|
|
if let Some(profile) = profile {
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to save Fast mode for profile `{profile}`: {err}"
|
|
));
|
|
} else {
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to save default Fast mode: {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::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::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::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)
|
|
}
|
|
}
|
|
}
|
|
}
|