mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
## Why The app directory is a discovery catalog and can include apps that are not callable in the active thread. Using it for `$` suggestions can therefore offer mentions that cannot be used. ## What changed - Load mention candidates from `app/installed`, enrich callable apps through bounded `app/read` batches, and retain compatibility with app servers that do not support `app/installed`. - Keep the mention catalog separate from the `/apps` directory cache and scope results to the current thread, workspace, and account. - Refresh mentions after app notifications, enablement changes, configuration updates, and apps MCP readiness, while immediately removing disabled apps. ## Testing Add coverage for batching and fallback behavior, stale scoped results, notification refreshes, enablement changes, MCP startup races, and filtering non-callable apps from mentions. GitOrigin-RevId: 4d9e20794aed30cc97120170792fb555b86fe1e7
2889 lines
129 KiB
Rust
2889 lines
129 KiB
Rust
//! AppEvent dispatch for the TUI app.
|
|
//!
|
|
//! This module contains the exhaustive `AppEvent` dispatcher and exit-mode handling. Large domain
|
|
//! actions are delegated to focused app submodules so the central match remains the routing layer.
|
|
|
|
use super::resize_reflow::trailing_run_start;
|
|
use super::session_lifecycle::ThreadAttachPresentation;
|
|
use super::*;
|
|
use crate::app_server_session::ForkGoalContinuation;
|
|
use crate::config_update::format_config_error;
|
|
use crate::external_agent_config_migration::flow::ExternalAgentConfigMigrationFlowOutcome;
|
|
use crate::pager_overlay::TranscriptHistoryState;
|
|
use crate::session_resume::cwds_differ;
|
|
#[cfg(target_os = "windows")]
|
|
use codex_config::types::WindowsSandboxModeToml;
|
|
|
|
const SHUTDOWN_FIRST_EXIT_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 2);
|
|
|
|
impl App {
|
|
pub(super) async fn handle_event(
|
|
&mut self,
|
|
tui: &mut tui::Tui,
|
|
app_server: &mut AppServerSession,
|
|
event: AppEvent,
|
|
) -> Result<AppRunControl> {
|
|
match event {
|
|
AppEvent::SkillsListLoaded { ref cwd, .. }
|
|
| AppEvent::PluginMentionsLoaded { ref cwd, .. }
|
|
if cwds_differ(cwd, self.config.cwd.as_path()) => {}
|
|
AppEvent::NewSession { name } => {
|
|
self.start_fresh_session_with_summary_hint(
|
|
tui, app_server, /*session_start_source*/ None,
|
|
/*initial_user_message*/ None, name,
|
|
)
|
|
.await;
|
|
}
|
|
AppEvent::ChangeWorkingDirectory {
|
|
thread_id,
|
|
requested_cwd,
|
|
} => {
|
|
if self.primary_thread_id != Some(thread_id)
|
|
|| !self.chat_widget.can_change_working_directory(thread_id)
|
|
{
|
|
self.chat_widget.add_error_message(
|
|
"Changing directories requires an idle primary session without queued input."
|
|
.to_string(),
|
|
);
|
|
} else if crate::uses_remote_workspace_or_environment(
|
|
&self.app_server_target,
|
|
self.environment_manager.as_ref(),
|
|
) {
|
|
self.chat_widget.add_error_message(
|
|
"Changing directories is not supported for remote workspaces or remote execution environments."
|
|
.to_string(),
|
|
);
|
|
} else {
|
|
let cwd = AbsolutePathBuf::resolve_path_against_base(
|
|
requested_cwd.as_path(),
|
|
self.chat_widget.config_ref().cwd.as_path(),
|
|
);
|
|
match std::fs::metadata(cwd.as_path()) {
|
|
Ok(metadata) if metadata.is_dir() => {
|
|
self.change_working_directory(tui, app_server, cwd).await;
|
|
}
|
|
Ok(_) => self
|
|
.chat_widget
|
|
.add_error_message(format!("Not a directory: {}", cwd.display())),
|
|
Err(error) => self.chat_widget.add_error_message(format!(
|
|
"Cannot access directory {}: {error}",
|
|
cwd.display()
|
|
)),
|
|
}
|
|
}
|
|
}
|
|
AppEvent::StartupThreadStarted { result } => {
|
|
self.handle_startup_thread_started(app_server, result)
|
|
.await?;
|
|
}
|
|
AppEvent::RequestOlderScrollbackHistory { thread_id } => {
|
|
if self.chat_widget.thread_id() == Some(thread_id)
|
|
&& self.overlay.is_none()
|
|
&& self.scrollback_has_older_history
|
|
{
|
|
self.request_older_history_page(app_server, thread_id);
|
|
}
|
|
}
|
|
AppEvent::OlderThreadHistoryLoaded {
|
|
thread_id,
|
|
cursor,
|
|
result,
|
|
} => {
|
|
if let Err(err) = self
|
|
.handle_older_history_page(tui, app_server, thread_id, &cursor, result)
|
|
.await
|
|
{
|
|
app_server.cancel_older_history_page(thread_id);
|
|
if self.chat_widget.thread_id() == Some(thread_id)
|
|
&& let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut()
|
|
{
|
|
overlay.set_history_state(TranscriptHistoryState::Failed);
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
tracing::warn!(%thread_id, error = %err, "failed to load older transcript history");
|
|
}
|
|
}
|
|
AppEvent::OpenTranscriptExportFilePrompt => {
|
|
self.chat_widget.show_transcript_export_file_prompt();
|
|
}
|
|
AppEvent::ExportTranscript { destination } => {
|
|
if let Err(error) = self.export_transcript(app_server, destination).await {
|
|
self.chat_widget
|
|
.add_error_message(format!("Export failed: {error}"));
|
|
}
|
|
if self.chat_widget.no_modal_or_popup_active() {
|
|
self.chat_widget
|
|
.set_queue_autosend_suppressed(/*suppressed*/ false);
|
|
self.chat_widget.maybe_send_next_queued_input();
|
|
}
|
|
}
|
|
AppEvent::ClearUi { name } => {
|
|
self.clear_terminal_ui(tui, /*redraw_header*/ false)?;
|
|
self.reset_app_ui_state_after_clear();
|
|
|
|
self.start_fresh_session_with_summary_hint(
|
|
tui,
|
|
app_server,
|
|
Some(ThreadStartSource::Clear),
|
|
/*initial_user_message*/ None,
|
|
name,
|
|
)
|
|
.await;
|
|
}
|
|
AppEvent::RawOutputModeChanged { enabled } => {
|
|
self.apply_raw_output_mode(tui, enabled, /*notify*/ false);
|
|
}
|
|
AppEvent::ClearUiAndSubmitUserMessage { text } => {
|
|
self.clear_terminal_ui(tui, /*redraw_header*/ false)?;
|
|
self.reset_app_ui_state_after_clear();
|
|
|
|
self.start_fresh_session_with_summary_hint(
|
|
tui,
|
|
app_server,
|
|
Some(ThreadStartSource::Clear),
|
|
crate::chatwidget::create_initial_user_message(
|
|
Some(text),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
),
|
|
/*new_thread_name*/ None,
|
|
)
|
|
.await;
|
|
}
|
|
AppEvent::OpenResumePicker => {
|
|
let picker_app_server = match crate::start_app_server_for_picker(
|
|
&self.config,
|
|
&self.app_server_target,
|
|
self.state_db.clone(),
|
|
self.environment_manager.clone(),
|
|
)
|
|
.await
|
|
{
|
|
Ok(app_server) => app_server,
|
|
Err(err) => {
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to start TUI session picker: {err}"
|
|
));
|
|
self.chat_widget.maybe_send_next_queued_input();
|
|
return Ok(AppRunControl::Continue);
|
|
}
|
|
};
|
|
match crate::resume_picker::run_resume_picker_from_existing_session_with_app_server(
|
|
tui,
|
|
&self.config,
|
|
/*show_all*/ false,
|
|
/*include_non_interactive*/ false,
|
|
picker_app_server,
|
|
app_server.request_handle(),
|
|
self.primary_thread_id
|
|
.or(self.current_displayed_thread_id()),
|
|
)
|
|
.await?
|
|
{
|
|
SessionSelection::Resume(target_session) => {
|
|
match self
|
|
.resume_target_session(tui, app_server, target_session)
|
|
.await?
|
|
{
|
|
AppRunControl::Continue => {}
|
|
AppRunControl::Exit(reason) => {
|
|
return Ok(AppRunControl::Exit(reason));
|
|
}
|
|
}
|
|
}
|
|
SessionSelection::Exit | SessionSelection::StartFresh => {
|
|
self.refresh_in_memory_config_from_disk_best_effort(
|
|
"closing the session picker",
|
|
)
|
|
.await;
|
|
}
|
|
SessionSelection::Fork(_) => {}
|
|
}
|
|
|
|
self.chat_widget.maybe_send_next_queued_input();
|
|
// Leaving alt-screen may blank the inline viewport; force a redraw either way.
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
AppEvent::OpenExternalAgentConfigMigration => {
|
|
match crate::external_agent_config_migration::flow::handle_external_agent_config_migration_prompt(
|
|
tui,
|
|
app_server,
|
|
&self.config,
|
|
)
|
|
.await
|
|
{
|
|
Ok(ExternalAgentConfigMigrationFlowOutcome::Started(lines)) => {
|
|
self.chat_widget.add_plain_history_lines(lines);
|
|
}
|
|
Ok(ExternalAgentConfigMigrationFlowOutcome::NoItems) => {
|
|
self.chat_widget.add_info_message(
|
|
crate::external_agent_config_migration::flow::EXTERNAL_AGENT_CONFIG_MIGRATION_NO_ITEMS_MESSAGE
|
|
.to_string(),
|
|
/*hint*/ None,
|
|
);
|
|
}
|
|
Ok(ExternalAgentConfigMigrationFlowOutcome::Cancelled) => {}
|
|
Ok(ExternalAgentConfigMigrationFlowOutcome::TerminalError(err)) => {
|
|
return Err(err.into());
|
|
}
|
|
Err(error_message) => {
|
|
self.chat_widget.add_error_message(error_message);
|
|
}
|
|
}
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
AppEvent::ResumeSessionByIdOrName(id_or_name) => {
|
|
match crate::lookup_session_target_with_app_server(
|
|
app_server,
|
|
&self.config,
|
|
&id_or_name,
|
|
)
|
|
.await?
|
|
{
|
|
Some(target_session) => {
|
|
return self
|
|
.resume_target_session(tui, app_server, target_session)
|
|
.await;
|
|
}
|
|
None => {
|
|
self.chat_widget.add_error_message(format!(
|
|
"No saved chat found matching '{id_or_name}'."
|
|
));
|
|
}
|
|
}
|
|
}
|
|
AppEvent::ArchiveCurrentThread => {
|
|
return Ok(self.archive_current_thread(app_server).await);
|
|
}
|
|
AppEvent::DeleteCurrentThread => {
|
|
return Ok(self.delete_current_thread(app_server).await);
|
|
}
|
|
AppEvent::ForkCurrentSession { name } => {
|
|
self.session_telemetry.counter(
|
|
"codex.thread.fork",
|
|
/*inc*/ 1,
|
|
&[("source", "slash_command")],
|
|
);
|
|
let summary = session_summary(
|
|
self.chat_widget.token_usage(),
|
|
self.chat_widget.thread_id(),
|
|
self.chat_widget.thread_name(),
|
|
self.chat_widget.rollout_path().as_deref(),
|
|
);
|
|
self.chat_widget
|
|
.add_plain_history_lines(vec!["/fork".magenta().into()]);
|
|
if let Some(thread_id) = self.chat_widget.thread_id() {
|
|
self.refresh_in_memory_config_from_disk_best_effort("forking the thread")
|
|
.await;
|
|
let mut fork_config = self.config.clone();
|
|
fork_config.model = Some(self.chat_widget.current_model().to_string());
|
|
fork_config.model_reasoning_effort =
|
|
self.chat_widget.current_reasoning_effort();
|
|
match app_server.fork_thread(fork_config, thread_id).await {
|
|
Ok(mut forked) => {
|
|
let name_error = if let Some(name) = name {
|
|
match app_server
|
|
.thread_set_name(forked.session.thread_id, name.clone())
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
forked.session.thread_name = Some(name);
|
|
None
|
|
}
|
|
Err(err) => {
|
|
Some(format!("Failed to name the forked session: {err}"))
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
self.shutdown_current_thread(app_server).await;
|
|
match self
|
|
.replace_chat_widget_with_app_server_thread(
|
|
tui,
|
|
forked,
|
|
ThreadAttachPresentation::SessionLineage,
|
|
/*initial_user_message*/ None,
|
|
)
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
if let Some(err) = name_error {
|
|
self.chat_widget.add_error_message(err);
|
|
}
|
|
if let Some(summary) = summary {
|
|
let mut lines: Vec<Line<'static>> = Vec::new();
|
|
if let Some(usage_line) = summary.usage_line {
|
|
lines.push(usage_line.into());
|
|
}
|
|
if let Some(command) = summary.resume_hint {
|
|
let spans = vec![
|
|
"To continue this session, run ".into(),
|
|
command.cyan(),
|
|
];
|
|
lines.push(spans.into());
|
|
}
|
|
self.chat_widget.add_plain_history_lines(lines);
|
|
}
|
|
}
|
|
Err(err) => {
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to attach to forked app-server thread: {err}"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
Err(err) => {
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to fork current session through the app server: {err}"
|
|
));
|
|
}
|
|
}
|
|
} else {
|
|
self.chat_widget.add_error_message(
|
|
"A thread must contain at least one turn before it can be forked."
|
|
.to_string(),
|
|
);
|
|
}
|
|
|
|
self.chat_widget.maybe_send_next_queued_input();
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
AppEvent::ForkSessionForPromptEdit {
|
|
thread_id,
|
|
nth_user_message,
|
|
mut prompt,
|
|
} => {
|
|
if self.chat_widget.thread_id() != Some(thread_id) {
|
|
return Ok(AppRunControl::Continue);
|
|
}
|
|
self.session_telemetry.counter(
|
|
"codex.thread.fork",
|
|
/*inc*/ 1,
|
|
&[("source", "transcript")],
|
|
);
|
|
self.refresh_in_memory_config_from_disk_best_effort("forking the thread")
|
|
.await;
|
|
let config = self.fresh_session_config();
|
|
let turns = match self.thread_event_channels.get(&thread_id) {
|
|
Some(channel) => {
|
|
let store = channel.store.lock().await;
|
|
let mut turns = store.turns.clone();
|
|
// Snapshot turns contain loaded history; newer live turns remain in
|
|
// the replay buffer and must also be visible to prompt-edit lookups.
|
|
for event in &store.buffer {
|
|
let ThreadBufferedEvent::Notification(notification) = event else {
|
|
continue;
|
|
};
|
|
match notification.as_ref() {
|
|
ServerNotification::TurnStarted(notification)
|
|
if !turns
|
|
.iter()
|
|
.any(|turn| turn.id == notification.turn.id) =>
|
|
{
|
|
turns.push(notification.turn.clone());
|
|
}
|
|
ServerNotification::ItemCompleted(notification) => {
|
|
if matches!(
|
|
notification.item,
|
|
ThreadItem::UserMessage { .. }
|
|
| ThreadItem::EnteredReviewMode { .. }
|
|
| ThreadItem::ExitedReviewMode { .. }
|
|
) && let Some(turn) = turns
|
|
.iter_mut()
|
|
.find(|turn| turn.id == notification.turn_id)
|
|
&& !turn
|
|
.items
|
|
.iter()
|
|
.any(|item| item.id() == notification.item.id())
|
|
{
|
|
turn.items.push(notification.item.clone());
|
|
}
|
|
}
|
|
ServerNotification::TurnCompleted(notification) => {
|
|
if let Some(turn) = turns
|
|
.iter_mut()
|
|
.find(|turn| turn.id == notification.turn.id)
|
|
{
|
|
turn.status = notification.turn.status.clone();
|
|
turn.error = notification.turn.error.clone();
|
|
turn.started_at = notification.turn.started_at;
|
|
turn.completed_at = notification.turn.completed_at;
|
|
turn.duration_ms = notification.turn.duration_ms;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
Some(turns)
|
|
}
|
|
None => None,
|
|
};
|
|
let started = match turns {
|
|
Some(turns) => match crate::app_backtrack::backtrack_fork_before_turn_id(
|
|
&turns,
|
|
nth_user_message,
|
|
&mut prompt,
|
|
) {
|
|
Ok(before_turn_id)
|
|
if before_turn_id.is_some()
|
|
|| app_server.has_older_history(thread_id) =>
|
|
{
|
|
let before_turn_id = before_turn_id
|
|
.or_else(|| turns.first().map(|turn| turn.id.clone()));
|
|
app_server
|
|
.fork_thread_at(
|
|
config.clone(),
|
|
thread_id,
|
|
/*last_turn_id*/ None,
|
|
before_turn_id,
|
|
ForkGoalContinuation::StartIfIdle,
|
|
)
|
|
.await
|
|
}
|
|
Ok(_) => {
|
|
app_server
|
|
.start_thread_with_session_start_source(
|
|
&config, /*session_start_source*/ None,
|
|
)
|
|
.await
|
|
}
|
|
Err(err) => Err(err),
|
|
},
|
|
None => Err(color_eyre::eyre::eyre!(
|
|
"the selected thread is no longer available for prompt editing"
|
|
)),
|
|
};
|
|
match started {
|
|
Ok(forked) => {
|
|
self.shutdown_current_thread(app_server).await;
|
|
match self
|
|
.replace_chat_widget_with_app_server_thread(
|
|
tui,
|
|
forked,
|
|
ThreadAttachPresentation::PromptEdit,
|
|
/*initial_user_message*/ None,
|
|
)
|
|
.await
|
|
{
|
|
Ok(()) => self.chat_widget.restore_user_message_to_composer(prompt),
|
|
Err(err) => {
|
|
self.restore_backtrack_prompt_after_branch_error(prompt, err);
|
|
}
|
|
}
|
|
}
|
|
Err(err) => {
|
|
self.restore_backtrack_prompt_after_branch_error(prompt, err);
|
|
}
|
|
}
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
AppEvent::BeginInitialHistoryReplayBuffer => {
|
|
self.begin_initial_history_replay_buffer();
|
|
}
|
|
AppEvent::BeginThreadSwitchHistoryReplayBuffer => {
|
|
self.begin_thread_switch_history_replay_buffer();
|
|
}
|
|
AppEvent::InsertHistoryCell(cell) => {
|
|
self.insert_history_cell(tui, cell);
|
|
}
|
|
AppEvent::EndInitialHistoryReplayBuffer => {
|
|
self.scrollback_has_older_history = self
|
|
.chat_widget
|
|
.thread_id()
|
|
.is_some_and(|thread_id| app_server.has_older_history(thread_id));
|
|
self.finish_initial_history_replay_buffer(tui);
|
|
}
|
|
AppEvent::ConsolidateAgentMessage {
|
|
source,
|
|
cwd,
|
|
inline_visualization_context,
|
|
scrollback_reflow,
|
|
deferred_history_cell,
|
|
} => {
|
|
self.handle_consolidate_agent_message(
|
|
tui,
|
|
source,
|
|
cwd,
|
|
inline_visualization_context,
|
|
scrollback_reflow,
|
|
deferred_history_cell,
|
|
)?;
|
|
self.chat_widget.note_stream_consolidation_completed();
|
|
self.insert_pending_usage_output_after_stream_shutdown(tui);
|
|
}
|
|
AppEvent::ConsolidateProposedPlan(source) => {
|
|
let end = self.transcript_cells.len();
|
|
let start = trailing_run_start::<history_cell::ProposedPlanStreamCell>(
|
|
&self.transcript_cells,
|
|
);
|
|
let consolidated: Arc<dyn HistoryCell> =
|
|
Arc::new(history_cell::new_proposed_plan(source, &self.config.cwd));
|
|
|
|
if start < end {
|
|
self.transcript_cells
|
|
.splice(start..end, std::iter::once(consolidated.clone()));
|
|
|
|
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
|
|
t.consolidate_cells(start..end, consolidated.clone());
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
|
|
self.finish_required_stream_reflow(tui)?;
|
|
} else {
|
|
self.transcript_cells.push(consolidated.clone());
|
|
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
|
|
t.insert_cell(consolidated.clone());
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
self.insert_history_cell_lines(
|
|
tui,
|
|
consolidated.as_ref(),
|
|
self.chat_widget
|
|
.history_wrap_width(tui.terminal.last_known_screen_size.width),
|
|
);
|
|
|
|
self.maybe_finish_stream_reflow(tui)?;
|
|
}
|
|
self.chat_widget.note_stream_consolidation_completed();
|
|
self.insert_pending_usage_output_after_stream_shutdown(tui);
|
|
}
|
|
AppEvent::StartCommitAnimation => {
|
|
if self
|
|
.commit_anim_running
|
|
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
|
|
.is_ok()
|
|
{
|
|
let tx = self.app_event_tx.clone();
|
|
let running = self.commit_anim_running.clone();
|
|
thread::spawn(move || {
|
|
while running.load(Ordering::Relaxed) {
|
|
thread::sleep(COMMIT_ANIMATION_TICK);
|
|
tx.send(AppEvent::CommitTick);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
AppEvent::StopCommitAnimation => {
|
|
self.commit_anim_running.store(false, Ordering::Release);
|
|
}
|
|
AppEvent::CommitTick => {
|
|
self.chat_widget.on_commit_tick();
|
|
}
|
|
AppEvent::Exit(mode) => {
|
|
if mode == ExitMode::ShutdownFirst {
|
|
self.show_shutdown_feedback(tui)?;
|
|
}
|
|
return Ok(self.handle_exit_mode(app_server, mode).await);
|
|
}
|
|
AppEvent::RunningTaskExit { action, thread_id } => match action {
|
|
RunningTaskExitAction::RunInBackground => {
|
|
return Ok(self.handle_exit_mode(app_server, ExitMode::Immediate).await);
|
|
}
|
|
RunningTaskExitAction::CancelTask => {
|
|
if self.chat_widget.thread_id() == Some(thread_id)
|
|
&& self.chat_widget.is_agent_turn_running()
|
|
&& self.chat_widget.submit_op(AppCommand::interrupt())
|
|
{
|
|
self.chat_widget.pause_active_goal_for_interrupt();
|
|
} else if self.side_threads.contains_key(&thread_id)
|
|
&& let Err(error) = self
|
|
.try_submit_active_thread_op_via_app_server(
|
|
app_server,
|
|
thread_id,
|
|
&AppCommand::interrupt(),
|
|
)
|
|
.await
|
|
{
|
|
self.chat_widget
|
|
.add_error_message(format!("Failed to interrupt task: {error}"));
|
|
}
|
|
}
|
|
RunningTaskExitAction::Exit => {
|
|
if self.chat_widget.thread_id() == Some(thread_id)
|
|
&& self.chat_widget.is_active_goal_turn_running()
|
|
&& let Err(error) = app_server
|
|
.thread_goal_set(
|
|
thread_id,
|
|
/*objective*/ None,
|
|
Some(codex_app_server_protocol::ThreadGoalStatus::Paused),
|
|
/*token_budget*/ None,
|
|
)
|
|
.await
|
|
{
|
|
self.chat_widget
|
|
.add_error_message(format!("Failed to pause task goal: {error}"));
|
|
return Ok(AppRunControl::Continue);
|
|
}
|
|
let turn_id = self
|
|
.active_turn_id_for_thread(thread_id)
|
|
.await
|
|
.unwrap_or_default();
|
|
match app_server.turn_interrupt(thread_id, turn_id).await {
|
|
Ok(()) => {
|
|
self.app_event_tx
|
|
.send(AppEvent::Exit(ExitMode::ShutdownFirst));
|
|
}
|
|
Err(error) => {
|
|
self.chat_widget
|
|
.add_error_message(format!("Failed to interrupt task: {error}"));
|
|
}
|
|
}
|
|
}
|
|
},
|
|
AppEvent::Logout => match app_server.logout_account().await {
|
|
Ok(()) => {
|
|
self.show_shutdown_feedback(tui)?;
|
|
return Ok(self
|
|
.handle_exit_mode(app_server, ExitMode::ShutdownFirst)
|
|
.await);
|
|
}
|
|
Err(err) => {
|
|
tracing::error!("failed to logout: {err}");
|
|
self.chat_widget
|
|
.add_error_message(format!("Logout failed: {err}"));
|
|
}
|
|
},
|
|
AppEvent::FatalExitRequest(message) => {
|
|
return Ok(AppRunControl::Exit(ExitReason::Fatal(message)));
|
|
}
|
|
AppEvent::CodexOp(op) => {
|
|
let is_user_turn = matches!(&op, AppCommand::UserTurn { .. });
|
|
if is_user_turn {
|
|
let screen_size = tui.terminal.last_known_screen_size;
|
|
self.handle_draw_pre_render(tui, screen_size)?;
|
|
if self.transcript_reflow.has_pending_reflow() {
|
|
self.transcript_reflow.schedule_immediate();
|
|
self.maybe_run_resize_reflow(tui, screen_size)?;
|
|
}
|
|
self.chat_widget.pre_draw_tick();
|
|
self.render_chat_widget_frame(tui, screen_size)?;
|
|
}
|
|
self.chat_widget.prepare_local_op_submission(&op);
|
|
if let Err(err) = self.submit_active_thread_op(app_server, op).await {
|
|
let handled = is_user_turn
|
|
&& matches!(
|
|
err.downcast_ref::<TypedRequestError>(),
|
|
Some(TypedRequestError::Server { method, .. })
|
|
if method == "turn/start"
|
|
)
|
|
&& self
|
|
.chat_widget
|
|
.handle_turn_start_rejection(format!("Failed to start turn: {err:#}"));
|
|
if !handled {
|
|
return Err(err);
|
|
}
|
|
tracing::error!(error = ?err, "failed to start turn through app server");
|
|
}
|
|
}
|
|
AppEvent::RetrySafetyBufferedTurn {
|
|
thread_id,
|
|
turn_id,
|
|
model,
|
|
turn,
|
|
prompt,
|
|
} => {
|
|
self.retry_safety_buffered_turn(
|
|
tui,
|
|
app_server,
|
|
super::safety_buffering::SafetyBufferedRetry {
|
|
thread_id,
|
|
turn_id,
|
|
model,
|
|
turn,
|
|
prompt,
|
|
},
|
|
)
|
|
.await;
|
|
}
|
|
AppEvent::AppendMessageHistoryEntry { thread_id, text } => {
|
|
self.append_message_history_entry(thread_id, text);
|
|
}
|
|
AppEvent::SyncThreadGitBranch {
|
|
thread_id,
|
|
branch,
|
|
cwd: _cwd,
|
|
} => {
|
|
if let Err(err) = app_server
|
|
.thread_metadata_update_branch(thread_id, branch)
|
|
.await
|
|
{
|
|
tracing::warn!("failed to sync thread git branch from directive: {err}");
|
|
}
|
|
}
|
|
AppEvent::LookupMessageHistoryEntry {
|
|
thread_id,
|
|
offset,
|
|
log_id,
|
|
} => {
|
|
self.lookup_message_history_entry(thread_id, offset, log_id)
|
|
.await?;
|
|
}
|
|
AppEvent::LookupMessageHistoryBatch {
|
|
thread_id,
|
|
cursor,
|
|
log_id,
|
|
} => {
|
|
self.lookup_message_history_batch(thread_id, cursor, log_id)
|
|
.await?;
|
|
}
|
|
AppEvent::ApproveRecentAutoReviewDenial { thread_id, id } => {
|
|
self.chat_widget
|
|
.approve_recent_auto_review_denial(thread_id, id);
|
|
}
|
|
AppEvent::SubmitThreadOp { thread_id, op } => {
|
|
self.submit_thread_op(app_server, thread_id, op).await?;
|
|
}
|
|
AppEvent::ThreadHistoryEntryResponse { thread_id, event } => {
|
|
self.enqueue_thread_history_entry_response(thread_id, event)
|
|
.await?;
|
|
}
|
|
AppEvent::DiffResult(cwd, text) => {
|
|
if cwds_differ(&cwd, self.chat_widget.config_ref().cwd.as_path()) {
|
|
return Ok(AppRunControl::Continue);
|
|
}
|
|
// Clear the in-progress state in the bottom pane
|
|
self.chat_widget.on_diff_complete();
|
|
// Enter alternate screen using TUI helper and build pager lines
|
|
let _ = tui.enter_alt_screen();
|
|
let pager_lines: Vec<ratatui::text::Line<'static>> = if text.trim().is_empty() {
|
|
vec!["No changes detected.".italic().into()]
|
|
} else {
|
|
text.lines().map(ansi_escape_line).collect()
|
|
};
|
|
self.overlay = Some(Overlay::new_static_with_lines(
|
|
pager_lines,
|
|
"D I F F".to_string(),
|
|
self.keymap.pager.clone(),
|
|
));
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
AppEvent::OpenAppLink {
|
|
app_id,
|
|
title,
|
|
description,
|
|
instructions,
|
|
url,
|
|
is_installed,
|
|
is_enabled,
|
|
} => {
|
|
self.chat_widget
|
|
.open_app_link_view(crate::bottom_pane::AppLinkViewParams {
|
|
app_id,
|
|
title,
|
|
description,
|
|
instructions,
|
|
url,
|
|
is_installed,
|
|
is_enabled,
|
|
suggest_reason: None,
|
|
suggestion_type: None,
|
|
elicitation_target: None,
|
|
});
|
|
}
|
|
AppEvent::OpenUrlInBrowser { url } => {
|
|
self.open_url_in_browser(url);
|
|
}
|
|
AppEvent::OpenDesktopThread { thread_id } => {
|
|
self.open_desktop_thread(thread_id);
|
|
}
|
|
AppEvent::PetSelected { pet_id } => {
|
|
self.handle_pet_selected(tui, pet_id);
|
|
}
|
|
AppEvent::PetDisabled => {
|
|
self.handle_pet_disabled(tui).await;
|
|
}
|
|
AppEvent::PetPreviewRequested { pet_id } => {
|
|
self.chat_widget.start_pet_picker_preview(pet_id);
|
|
}
|
|
AppEvent::PetPreviewLoaded { request_id, result } => {
|
|
self.handle_pet_preview_loaded(tui, request_id, result);
|
|
}
|
|
AppEvent::PetSelectionLoaded {
|
|
request_id,
|
|
pet_id,
|
|
result,
|
|
} => {
|
|
return self
|
|
.handle_pet_selection_loaded(tui, request_id, pet_id, result)
|
|
.await;
|
|
}
|
|
AppEvent::ConfiguredPetLoaded { pet_id, result } => {
|
|
self.handle_configured_pet_loaded(tui, pet_id, result);
|
|
}
|
|
AppEvent::RefreshConnectors { force_refetch } => {
|
|
self.chat_widget.refresh_connectors(force_refetch);
|
|
}
|
|
AppEvent::FetchConnectorsList {
|
|
force_refetch,
|
|
generation,
|
|
} => {
|
|
if generation == self.chat_widget.connector_scope_generation() {
|
|
self.fetch_connectors_list(app_server, force_refetch);
|
|
}
|
|
}
|
|
AppEvent::FetchInstalledConnectorMentions {
|
|
force_refresh,
|
|
generation,
|
|
} => {
|
|
if generation == self.chat_widget.connector_scope_generation() {
|
|
self.fetch_installed_connector_mentions(app_server, force_refresh, generation);
|
|
}
|
|
}
|
|
AppEvent::PluginInstallAuthAdvance { refresh_connectors } => {
|
|
if refresh_connectors {
|
|
self.chat_widget.refresh_connectors(/*force_refetch*/ true);
|
|
}
|
|
self.chat_widget.advance_plugin_install_auth_flow();
|
|
}
|
|
AppEvent::PluginInstallAuthAbandon => {
|
|
self.chat_widget.abandon_plugin_install_auth_flow();
|
|
}
|
|
AppEvent::FetchPluginsList { cwd } => {
|
|
self.fetch_plugins_list(app_server, cwd);
|
|
}
|
|
AppEvent::FetchHooksList { cwd } => {
|
|
self.fetch_hooks_list(app_server, cwd);
|
|
}
|
|
AppEvent::OpenMarketplaceAddPrompt => {
|
|
self.chat_widget.open_marketplace_add_prompt();
|
|
}
|
|
AppEvent::OpenMarketplaceAddLoading { source } => {
|
|
self.chat_widget.open_marketplace_add_loading_popup(&source);
|
|
}
|
|
AppEvent::OpenMarketplaceRemoveConfirm {
|
|
marketplace_name,
|
|
marketplace_display_name,
|
|
} => {
|
|
self.chat_widget.open_marketplace_remove_confirmation(
|
|
marketplace_name,
|
|
marketplace_display_name,
|
|
);
|
|
}
|
|
AppEvent::OpenMarketplaceRemoveLoading {
|
|
marketplace_display_name,
|
|
} => {
|
|
self.chat_widget
|
|
.open_marketplace_remove_loading_popup(&marketplace_display_name);
|
|
}
|
|
AppEvent::OpenMarketplaceUpgradeLoading { marketplace_name } => {
|
|
self.chat_widget
|
|
.open_marketplace_upgrade_loading_popup(marketplace_name.as_deref());
|
|
}
|
|
AppEvent::OpenPluginDetailLoading {
|
|
plugin_display_name,
|
|
} => {
|
|
self.chat_widget
|
|
.open_plugin_detail_loading_popup(&plugin_display_name);
|
|
}
|
|
AppEvent::OpenPluginInstallLoading {
|
|
plugin_display_name,
|
|
} => {
|
|
self.chat_widget
|
|
.open_plugin_install_loading_popup(&plugin_display_name);
|
|
}
|
|
AppEvent::OpenPluginUninstallLoading {
|
|
plugin_display_name,
|
|
} => {
|
|
self.chat_widget
|
|
.open_plugin_uninstall_loading_popup(&plugin_display_name);
|
|
}
|
|
AppEvent::PluginsLoaded { cwd, result } => {
|
|
self.chat_widget.on_plugins_loaded(cwd, result);
|
|
}
|
|
AppEvent::OpenPluginsList { cwd, response } => {
|
|
self.chat_widget.open_plugins_list(cwd, response);
|
|
}
|
|
AppEvent::PluginRemoteSectionsLoaded {
|
|
cwd,
|
|
marketplaces,
|
|
section_errors,
|
|
} => {
|
|
self.chat_widget.on_plugin_remote_sections_loaded(
|
|
cwd,
|
|
marketplaces,
|
|
section_errors,
|
|
);
|
|
}
|
|
AppEvent::HooksLoaded { cwd, result } => {
|
|
self.chat_widget.on_hooks_loaded(cwd, result);
|
|
}
|
|
AppEvent::FetchMarketplaceAdd { cwd, source } => {
|
|
self.fetch_marketplace_add(app_server, cwd, source);
|
|
}
|
|
AppEvent::FetchMarketplaceUpgrade {
|
|
cwd,
|
|
marketplace_name,
|
|
} => {
|
|
self.fetch_marketplace_upgrade(app_server, cwd, marketplace_name);
|
|
}
|
|
AppEvent::MarketplaceAddLoaded {
|
|
cwd,
|
|
source,
|
|
result,
|
|
} => {
|
|
let add_succeeded = result.is_ok();
|
|
self.chat_widget
|
|
.on_marketplace_add_loaded(cwd.clone(), source, result);
|
|
if add_succeeded && self.chat_widget.config_ref().cwd.as_path() == cwd.as_path() {
|
|
self.fetch_plugins_list(app_server, cwd);
|
|
}
|
|
}
|
|
AppEvent::MarketplaceUpgradeLoaded { cwd, result } => {
|
|
let marketplace_contents_changed =
|
|
matches!(&result, Ok(response) if !response.upgraded_roots.is_empty());
|
|
if marketplace_contents_changed {
|
|
self.refresh_plugin_mentions_after_config_write();
|
|
}
|
|
self.chat_widget
|
|
.on_marketplace_upgrade_loaded(cwd.clone(), result);
|
|
if self.chat_widget.config_ref().cwd.as_path() == cwd.as_path() {
|
|
self.fetch_plugins_list(app_server, cwd);
|
|
}
|
|
}
|
|
AppEvent::FetchMarketplaceRemove {
|
|
cwd,
|
|
marketplace_name,
|
|
marketplace_display_name,
|
|
} => {
|
|
self.fetch_marketplace_remove(
|
|
app_server,
|
|
cwd,
|
|
marketplace_name,
|
|
marketplace_display_name,
|
|
);
|
|
}
|
|
AppEvent::MarketplaceRemoveLoaded {
|
|
cwd,
|
|
marketplace_name,
|
|
marketplace_display_name,
|
|
result,
|
|
} => {
|
|
let remove_succeeded = result.is_ok();
|
|
self.chat_widget.on_marketplace_remove_loaded(
|
|
cwd.clone(),
|
|
marketplace_name,
|
|
marketplace_display_name,
|
|
result,
|
|
);
|
|
if remove_succeeded && self.chat_widget.config_ref().cwd.as_path() == cwd.as_path()
|
|
{
|
|
self.refresh_plugin_mentions_after_config_write();
|
|
self.fetch_plugins_list(app_server, cwd);
|
|
}
|
|
}
|
|
AppEvent::FetchPluginDetail { cwd, params } => {
|
|
self.fetch_plugin_detail(app_server, cwd, params);
|
|
}
|
|
AppEvent::PluginDetailLoaded { cwd, result } => {
|
|
self.chat_widget.on_plugin_detail_loaded(cwd, result);
|
|
}
|
|
AppEvent::FetchPluginInstall {
|
|
cwd,
|
|
location,
|
|
plugin_name,
|
|
plugin_display_name,
|
|
} => {
|
|
self.fetch_plugin_install(
|
|
app_server,
|
|
cwd,
|
|
location,
|
|
plugin_name,
|
|
plugin_display_name,
|
|
);
|
|
}
|
|
AppEvent::FetchPluginUninstall {
|
|
cwd,
|
|
plugin_id,
|
|
plugin_display_name,
|
|
} => {
|
|
self.fetch_plugin_uninstall(app_server, cwd, plugin_id, plugin_display_name);
|
|
}
|
|
AppEvent::SetPluginEnabled {
|
|
cwd,
|
|
plugin_id,
|
|
enabled,
|
|
} => {
|
|
self.set_plugin_enabled(app_server, cwd, plugin_id, enabled);
|
|
}
|
|
AppEvent::PluginInstallLoaded {
|
|
cwd,
|
|
location,
|
|
plugin_name,
|
|
plugin_display_name,
|
|
result,
|
|
} => {
|
|
let install_succeeded = result.is_ok();
|
|
if install_succeeded {
|
|
self.refresh_plugin_mentions_after_config_write();
|
|
}
|
|
let should_refresh_plugin_detail = self.chat_widget.on_plugin_install_loaded(
|
|
cwd.clone(),
|
|
location.clone(),
|
|
plugin_name.clone(),
|
|
plugin_display_name,
|
|
result,
|
|
);
|
|
if install_succeeded && self.chat_widget.config_ref().cwd.as_path() == cwd.as_path()
|
|
{
|
|
self.fetch_plugins_list(app_server, cwd.clone());
|
|
if should_refresh_plugin_detail {
|
|
let (marketplace_path, remote_marketplace_name) =
|
|
location.into_request_params();
|
|
self.fetch_plugin_detail(
|
|
app_server,
|
|
cwd,
|
|
PluginReadParams {
|
|
marketplace_path,
|
|
remote_marketplace_name,
|
|
plugin_name,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
AppEvent::PluginEnabledSet {
|
|
cwd,
|
|
plugin_id,
|
|
enabled,
|
|
result,
|
|
} => {
|
|
let queued_enabled = self
|
|
.pending_plugin_enabled_writes
|
|
.get_mut(&plugin_id)
|
|
.and_then(Option::take);
|
|
let should_apply_result = if let Some(queued_enabled) = queued_enabled
|
|
&& (result.is_err() || queued_enabled != enabled)
|
|
{
|
|
self.spawn_plugin_enabled_write(
|
|
app_server,
|
|
cwd.clone(),
|
|
plugin_id.clone(),
|
|
queued_enabled,
|
|
);
|
|
false
|
|
} else {
|
|
true
|
|
};
|
|
if should_apply_result {
|
|
self.pending_plugin_enabled_writes.remove(&plugin_id);
|
|
let update_succeeded = result.is_ok();
|
|
if update_succeeded {
|
|
self.refresh_plugin_mentions_after_config_write();
|
|
}
|
|
self.chat_widget
|
|
.on_plugin_enabled_set(cwd, plugin_id, enabled, result);
|
|
}
|
|
}
|
|
AppEvent::FetchMcpInventory { detail, thread_id } => {
|
|
self.fetch_mcp_inventory(app_server, detail, thread_id);
|
|
}
|
|
AppEvent::McpInventoryLoaded {
|
|
result,
|
|
detail,
|
|
thread_id,
|
|
} => {
|
|
self.handle_mcp_inventory_result(result, detail, thread_id);
|
|
}
|
|
AppEvent::SkillsListLoaded { result, .. } => {
|
|
self.handle_skills_list_result(
|
|
result.map_err(|err| color_eyre::eyre::eyre!(err)),
|
|
"failed to load skills on startup",
|
|
);
|
|
}
|
|
AppEvent::StartFileSearch(query) => {
|
|
self.file_search.on_user_query(query);
|
|
}
|
|
AppEvent::FileSearchResult { query, matches } => {
|
|
self.chat_widget.apply_file_search_result(query, matches);
|
|
}
|
|
AppEvent::RefreshRateLimits { origin } => {
|
|
self.refresh_rate_limits(app_server, origin);
|
|
}
|
|
AppEvent::RefreshTokenActivity { request_id } => {
|
|
self.refresh_token_activity(app_server, request_id);
|
|
}
|
|
AppEvent::RefreshThreadUsage {
|
|
thread_id,
|
|
request_id,
|
|
} => {
|
|
self.refresh_thread_usage(app_server, thread_id, request_id);
|
|
}
|
|
AppEvent::RefreshStatusLineWorkspaceHeadline { request_id } => {
|
|
self.refresh_status_line_workspace_headline(app_server, request_id);
|
|
}
|
|
AppEvent::OpenThreadGoalMenu { thread_id } => {
|
|
self.open_thread_goal_menu(app_server, thread_id).await;
|
|
}
|
|
AppEvent::OpenThreadGoalEditor { thread_id } => {
|
|
self.open_thread_goal_editor(app_server, thread_id).await;
|
|
}
|
|
AppEvent::SetThreadGoalDraft {
|
|
thread_id,
|
|
draft,
|
|
mode,
|
|
} => {
|
|
self.set_thread_goal_draft(app_server, thread_id, draft, mode)
|
|
.await;
|
|
}
|
|
AppEvent::SetThreadGoalStatus { thread_id, status } => {
|
|
self.set_thread_goal_status(app_server, thread_id, status)
|
|
.await;
|
|
}
|
|
AppEvent::ClearThreadGoal { thread_id } => {
|
|
self.clear_thread_goal(app_server, thread_id).await;
|
|
}
|
|
AppEvent::SendAddCreditsNudgeEmail { credit_type } => {
|
|
if self
|
|
.chat_widget
|
|
.start_add_credits_nudge_email_request(credit_type)
|
|
{
|
|
self.send_add_credits_nudge_email(app_server, credit_type);
|
|
}
|
|
}
|
|
AppEvent::AddCreditsNudgeEmailFinished { result } => {
|
|
self.chat_widget
|
|
.finish_add_credits_nudge_email_request(result);
|
|
}
|
|
AppEvent::RateLimitsLoaded {
|
|
origin,
|
|
hard_stop_generation,
|
|
result,
|
|
} => match result {
|
|
Ok(response) => {
|
|
let rate_limit_reset_credits = response.rate_limit_reset_credits.clone();
|
|
let snapshots = if hard_stop_generation == self.rate_limit_hard_stop_generation
|
|
{
|
|
app_server_rate_limit_snapshots(response)
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
match origin {
|
|
RateLimitRefreshOrigin::StartupPrefetch {
|
|
reset_hint_request_id,
|
|
} => {
|
|
if self.chat_widget.finish_rate_limit_reset_hint_refresh(
|
|
reset_hint_request_id,
|
|
snapshots,
|
|
rate_limit_reset_credits.ok_or_else(|| {
|
|
"account/rateLimits/read response did not include rateLimitResetCredits"
|
|
.to_string()
|
|
}),
|
|
) {
|
|
self.insert_pending_usage_output_if_ready(tui);
|
|
}
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
RateLimitRefreshOrigin::ResetConsume { request_id } => {
|
|
self.chat_widget.finish_post_consume_reset_credits_refresh(
|
|
request_id,
|
|
snapshots,
|
|
rate_limit_reset_credits.ok_or_else(|| {
|
|
"account/rateLimits/read response did not include rateLimitResetCredits"
|
|
.to_string()
|
|
}),
|
|
);
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
RateLimitRefreshOrigin::StatusCommand { request_id } => {
|
|
self.chat_widget
|
|
.finish_status_rate_limit_refresh(request_id, snapshots);
|
|
}
|
|
RateLimitRefreshOrigin::UsageMenu { request_id } => {
|
|
self.chat_widget.finish_usage_menu_rate_limit_refresh(
|
|
request_id,
|
|
snapshots,
|
|
rate_limit_reset_credits.ok_or_else(|| {
|
|
"account/rateLimits/read response did not include rateLimitResetCredits"
|
|
.to_string()
|
|
}),
|
|
);
|
|
}
|
|
RateLimitRefreshOrigin::ResetPicker { request_id } => {
|
|
self.chat_widget.finish_rate_limit_reset_credits_refresh(
|
|
request_id,
|
|
snapshots,
|
|
rate_limit_reset_credits.ok_or_else(|| {
|
|
"account/rateLimits/read response did not include rateLimitResetCredits"
|
|
.to_string()
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
Err(err) => {
|
|
tracing::warn!("account/rateLimits/read failed during TUI refresh: {err}");
|
|
match origin {
|
|
RateLimitRefreshOrigin::StartupPrefetch {
|
|
reset_hint_request_id,
|
|
} => {
|
|
self.chat_widget.finish_rate_limit_reset_hint_refresh(
|
|
reset_hint_request_id,
|
|
Vec::new(),
|
|
Err(err),
|
|
);
|
|
}
|
|
RateLimitRefreshOrigin::ResetConsume { request_id } => {
|
|
self.chat_widget.finish_post_consume_reset_credits_refresh(
|
|
request_id,
|
|
Vec::new(),
|
|
Err(err),
|
|
);
|
|
}
|
|
RateLimitRefreshOrigin::StatusCommand { request_id } => {
|
|
self.chat_widget
|
|
.finish_status_rate_limit_refresh(request_id, Vec::new());
|
|
}
|
|
RateLimitRefreshOrigin::UsageMenu { request_id } => {
|
|
self.chat_widget.finish_usage_menu_rate_limit_refresh(
|
|
request_id,
|
|
Vec::new(),
|
|
Err(err),
|
|
);
|
|
}
|
|
RateLimitRefreshOrigin::ResetPicker { request_id } => {
|
|
self.chat_widget.finish_rate_limit_reset_credits_refresh(
|
|
request_id,
|
|
Vec::new(),
|
|
Err(err),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
AppEvent::OpenTokenActivity => {
|
|
self.chat_widget
|
|
.add_token_activity_output(crate::chatwidget::TokenActivityView::Daily);
|
|
}
|
|
AppEvent::OpenRateLimitResetCredits => {
|
|
let request_id = self.chat_widget.show_rate_limit_reset_loading_popup();
|
|
self.refresh_rate_limits(
|
|
app_server,
|
|
RateLimitRefreshOrigin::ResetPicker { request_id },
|
|
);
|
|
}
|
|
AppEvent::OpenRateLimitResetConfirmation {
|
|
picker_request_id,
|
|
confirmation_gate,
|
|
credit_id,
|
|
reset_title,
|
|
reset_detail,
|
|
reset_description,
|
|
} => {
|
|
self.chat_widget.show_rate_limit_reset_confirmation(
|
|
picker_request_id,
|
|
confirmation_gate,
|
|
credit_id,
|
|
reset_title,
|
|
reset_detail,
|
|
reset_description,
|
|
);
|
|
}
|
|
AppEvent::ConsumeRateLimitResetCredit {
|
|
idempotency_key,
|
|
credit_id,
|
|
} => {
|
|
if let Some(request_id) = self
|
|
.chat_widget
|
|
.start_rate_limit_reset_consumption(&idempotency_key)
|
|
{
|
|
self.consume_rate_limit_reset_credit(
|
|
app_server,
|
|
request_id,
|
|
idempotency_key,
|
|
credit_id,
|
|
);
|
|
}
|
|
}
|
|
AppEvent::RateLimitResetCreditConsumed {
|
|
request_id,
|
|
idempotency_key,
|
|
credit_id,
|
|
result,
|
|
} => {
|
|
if let Err(err) = &result {
|
|
tracing::warn!(
|
|
"account/rateLimitResetCredit/consume failed during TUI request: {err}"
|
|
);
|
|
}
|
|
if self.chat_widget.finish_rate_limit_reset_consume(
|
|
request_id,
|
|
idempotency_key,
|
|
credit_id,
|
|
result,
|
|
) {
|
|
self.refresh_rate_limits(
|
|
app_server,
|
|
RateLimitRefreshOrigin::ResetConsume { request_id },
|
|
);
|
|
}
|
|
}
|
|
AppEvent::TokenActivityLoaded { request_id, result } => {
|
|
if let Err(err) = &result {
|
|
tracing::warn!("account/usage/read failed during TUI refresh: {err}");
|
|
}
|
|
if self
|
|
.chat_widget
|
|
.finish_token_activity_refresh(request_id, result)
|
|
{
|
|
// Commit synchronously so an already queued /clear cannot overtake this card.
|
|
// Do not route through ChatWidget::add_to_history: /usage may complete during
|
|
// active work, and flushing an in-progress tool cell would corrupt its lifecycle.
|
|
// If an answer stream is active, keep the settled card transient until its
|
|
// provisional transcript cells have been consolidated.
|
|
self.insert_pending_usage_output_if_ready(tui);
|
|
}
|
|
}
|
|
AppEvent::ThreadUsageLoaded {
|
|
thread_id,
|
|
request_id,
|
|
result,
|
|
} => {
|
|
self.finish_thread_usage_refresh(tui, thread_id, request_id, result)?;
|
|
}
|
|
AppEvent::CommitPendingUsageOutput => {
|
|
self.insert_pending_usage_output_if_ready(tui);
|
|
}
|
|
AppEvent::CommitPendingUsageOutputAfterStreamShutdown => {
|
|
self.insert_pending_usage_output_after_stream_shutdown(tui);
|
|
}
|
|
AppEvent::ConnectorsLoaded {
|
|
thread_id,
|
|
cwd,
|
|
generation,
|
|
result,
|
|
is_final,
|
|
} => {
|
|
if thread_id == self.current_displayed_thread_id()
|
|
&& cwd.as_path() == self.chat_widget.config_ref().cwd.as_path()
|
|
&& generation == self.chat_widget.connector_scope_generation()
|
|
{
|
|
self.chat_widget.on_connectors_loaded(result, is_final);
|
|
}
|
|
}
|
|
AppEvent::InstalledConnectorMentionsLoaded {
|
|
thread_id,
|
|
cwd,
|
|
generation,
|
|
result,
|
|
} => {
|
|
if thread_id == self.current_displayed_thread_id()
|
|
&& cwd.as_path() == self.chat_widget.config_ref().cwd.as_path()
|
|
&& generation == self.chat_widget.connector_scope_generation()
|
|
{
|
|
self.chat_widget
|
|
.on_connector_mentions_loaded(generation, result);
|
|
}
|
|
}
|
|
AppEvent::UpdateReasoningEffort(effort) => {
|
|
self.on_update_reasoning_effort(effort.clone());
|
|
self.sync_active_thread_reasoning_setting(app_server, effort)
|
|
.await;
|
|
}
|
|
AppEvent::UpdateModel(model) => {
|
|
let model_changed = self.chat_widget.current_model() != model
|
|
|| self.chat_widget.current_collaboration_mode().model() != model;
|
|
if model_changed {
|
|
self.chat_widget.set_model(&model);
|
|
self.sync_active_thread_model_setting(app_server, model, /*effort*/ None)
|
|
.await;
|
|
self.sync_active_thread_service_tier_to_cached_session()
|
|
.await;
|
|
}
|
|
}
|
|
AppEvent::UpdatePersonality(personality) => {
|
|
self.on_update_personality(personality);
|
|
self.sync_active_thread_personality_setting(app_server, personality)
|
|
.await;
|
|
}
|
|
AppEvent::SettingsSelectionClosed => {
|
|
self.app_event_tx.send(AppEvent::SettingsSelectionSettled);
|
|
}
|
|
AppEvent::SettingsSelectionSettled => {
|
|
if self.chat_widget.no_modal_or_popup_active() {
|
|
self.chat_widget
|
|
.set_queue_autosend_suppressed(/*suppressed*/ false);
|
|
self.chat_widget.maybe_send_next_queued_input();
|
|
}
|
|
}
|
|
AppEvent::OpenReasoningPopup { model } => {
|
|
self.chat_widget.open_reasoning_popup(model);
|
|
}
|
|
AppEvent::OpenAdvancedReasoningPopup { model } => {
|
|
self.chat_widget.open_advanced_reasoning_popup(model);
|
|
}
|
|
AppEvent::ApplyAdvancedReasoning { model, effort } => {
|
|
let model_changed = self.chat_widget.current_model() != model
|
|
|| self.chat_widget.current_collaboration_mode().model() != model;
|
|
let default_effort =
|
|
self.on_apply_advanced_reasoning(model.as_str(), effort.clone());
|
|
if model_changed {
|
|
self.sync_active_thread_model_setting(
|
|
app_server,
|
|
model.clone(),
|
|
Some(effort.clone()),
|
|
)
|
|
.await;
|
|
} else if let Some(mut params) =
|
|
self.active_thread_reasoning_setting_update_params(Some(effort.clone()))
|
|
{
|
|
params.collaboration_mode =
|
|
Some(self.chat_widget.effective_collaboration_mode());
|
|
self.send_thread_settings_update(app_server, params).await;
|
|
}
|
|
self.sync_active_thread_service_tier_to_cached_session()
|
|
.await;
|
|
|
|
if let Some(default_effort) = default_effort.as_ref()
|
|
&& let Err(err) = crate::config_update::write_config_batch(
|
|
app_server.request_handle(),
|
|
crate::config_update::build_model_selection_edits(
|
|
model.as_str(),
|
|
Some(default_effort),
|
|
),
|
|
)
|
|
.await
|
|
{
|
|
let error = format_config_error(&err);
|
|
tracing::error!(error = %error, "failed to persist conversation model");
|
|
self.chat_widget
|
|
.add_error_message(format!("Failed to save default model: {error}"));
|
|
} else {
|
|
self.chat_widget.add_info_message(
|
|
format!("Model changed to {model} {effort} for this conversation"),
|
|
/*hint*/ None,
|
|
);
|
|
}
|
|
}
|
|
AppEvent::OpenPlanReasoningScopePrompt { model, effort } => {
|
|
self.chat_widget
|
|
.open_plan_reasoning_scope_prompt(model, effort);
|
|
}
|
|
AppEvent::OpenAllModelsPopup { models } => {
|
|
self.chat_widget.open_all_models_popup(models);
|
|
}
|
|
AppEvent::OpenFullAccessConfirmation {
|
|
preset,
|
|
return_to_permissions,
|
|
profile_selection,
|
|
} => {
|
|
self.chat_widget.open_full_access_confirmation(
|
|
preset,
|
|
return_to_permissions,
|
|
profile_selection,
|
|
);
|
|
}
|
|
AppEvent::OpenWorldWritableWarningConfirmation {
|
|
preset,
|
|
profile_selection,
|
|
sample_paths,
|
|
extra_count,
|
|
failed_scan,
|
|
} => {
|
|
self.chat_widget.open_world_writable_warning_confirmation(
|
|
preset,
|
|
profile_selection,
|
|
sample_paths,
|
|
extra_count,
|
|
failed_scan,
|
|
);
|
|
}
|
|
AppEvent::OpenFeedbackNote {
|
|
category,
|
|
include_logs,
|
|
} => {
|
|
self.chat_widget.open_feedback_note(category, include_logs);
|
|
}
|
|
AppEvent::OpenFeedbackConsent { category } => {
|
|
self.chat_widget.open_feedback_consent(category);
|
|
}
|
|
AppEvent::SubmitFeedback {
|
|
category,
|
|
reason,
|
|
turn_id,
|
|
include_logs,
|
|
} => {
|
|
self.submit_feedback(app_server, category, reason, turn_id, include_logs);
|
|
}
|
|
AppEvent::FeedbackSubmitted {
|
|
origin_thread_id,
|
|
category,
|
|
include_logs,
|
|
result,
|
|
} => {
|
|
self.handle_feedback_submitted(origin_thread_id, category, include_logs, result)
|
|
.await;
|
|
}
|
|
AppEvent::LaunchExternalEditor => {
|
|
if self.chat_widget.external_editor_state() == ExternalEditorState::Active {
|
|
self.launch_external_editor(tui).await;
|
|
}
|
|
}
|
|
AppEvent::OpenWindowsSandboxEnablePrompt {
|
|
preset,
|
|
profile_selection,
|
|
} => {
|
|
self.chat_widget
|
|
.open_windows_sandbox_enable_prompt(preset, profile_selection);
|
|
}
|
|
AppEvent::OpenWindowsSandboxFallbackPrompt {
|
|
preset,
|
|
profile_selection,
|
|
} => {
|
|
self.session_telemetry.counter(
|
|
"codex.windows_sandbox.fallback_prompt_shown",
|
|
/*inc*/ 1,
|
|
&[],
|
|
);
|
|
self.chat_widget.clear_windows_sandbox_setup_status();
|
|
if let Some(started_at) = self.windows_sandbox.setup_started_at.take() {
|
|
self.session_telemetry.record_duration(
|
|
"codex.windows_sandbox.elevated_setup_duration_ms",
|
|
started_at.elapsed(),
|
|
&[("result", "failure")],
|
|
);
|
|
}
|
|
self.chat_widget
|
|
.open_windows_sandbox_fallback_prompt(preset, profile_selection);
|
|
}
|
|
AppEvent::BeginWindowsSandboxElevatedSetup {
|
|
preset,
|
|
profile_selection,
|
|
} => {
|
|
#[cfg(any(target_os = "windows", test))]
|
|
if !self.chat_widget.windows_sandbox_mode_allowed(
|
|
codex_config::types::WindowsSandboxModeToml::Elevated,
|
|
) {
|
|
tracing::warn!(
|
|
"refusing to set up elevated Windows sandbox mode disallowed by requirements"
|
|
);
|
|
self.chat_widget.add_info_message(
|
|
"That Windows sandbox option is disallowed by requirements.".to_string(),
|
|
/*hint*/ None,
|
|
);
|
|
return Ok(AppRunControl::Continue);
|
|
}
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
let setup_permissions = match self
|
|
.windows_setup_permissions(&preset, profile_selection.as_ref())
|
|
.await
|
|
{
|
|
Ok(setup_permissions) => setup_permissions,
|
|
Err(err) => {
|
|
tracing::warn!(
|
|
error = %err,
|
|
"failed to resolve permission profile for elevated Windows sandbox setup"
|
|
);
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to prepare Windows sandbox for the selected permission profile: {err}"
|
|
));
|
|
return Ok(AppRunControl::Continue);
|
|
}
|
|
};
|
|
let permission_profile = setup_permissions.permission_profile;
|
|
let workspace_roots = setup_permissions.workspace_roots;
|
|
let command_cwd = self.config.cwd.clone();
|
|
let env_map: std::collections::HashMap<String, String> =
|
|
std::env::vars().collect();
|
|
let codex_home = self.config.codex_home.clone();
|
|
let tx = self.app_event_tx.clone();
|
|
|
|
// If the elevated setup already ran on this machine, don't prompt for
|
|
// elevation again - just flip the config to use the elevated path.
|
|
if crate::windows_sandbox::sandbox_setup_is_complete(codex_home.as_path()) {
|
|
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
|
|
preset,
|
|
mode: WindowsSandboxEnableMode::Elevated,
|
|
profile_selection,
|
|
});
|
|
return Ok(AppRunControl::Continue);
|
|
}
|
|
|
|
self.chat_widget.show_windows_sandbox_setup_status();
|
|
self.windows_sandbox.setup_started_at = Some(Instant::now());
|
|
let session_telemetry = self.session_telemetry.clone();
|
|
tokio::task::spawn_blocking(move || {
|
|
let result = crate::windows_sandbox::run_elevated_setup(
|
|
&permission_profile,
|
|
workspace_roots.as_slice(),
|
|
command_cwd.as_path(),
|
|
&env_map,
|
|
codex_home.as_path(),
|
|
);
|
|
let event = match result {
|
|
Ok(()) => {
|
|
session_telemetry.counter(
|
|
"codex.windows_sandbox.elevated_setup_success",
|
|
/*inc*/ 1,
|
|
&[],
|
|
);
|
|
AppEvent::EnableWindowsSandboxForAgentMode {
|
|
preset: preset.clone(),
|
|
mode: WindowsSandboxEnableMode::Elevated,
|
|
profile_selection: profile_selection.clone(),
|
|
}
|
|
}
|
|
Err(err) => {
|
|
let mut code_tag: Option<String> = None;
|
|
let mut message_tag: Option<String> = None;
|
|
if let Some((code, message)) =
|
|
crate::windows_sandbox::elevated_setup_failure_details(&err)
|
|
{
|
|
code_tag = Some(code);
|
|
message_tag = Some(message);
|
|
}
|
|
let mut tags: Vec<(&str, &str)> = Vec::new();
|
|
if let Some(code) = code_tag.as_deref() {
|
|
tags.push(("code", code));
|
|
}
|
|
if let Some(message) = message_tag.as_deref() {
|
|
tags.push(("message", message));
|
|
}
|
|
session_telemetry.counter(
|
|
crate::windows_sandbox::elevated_setup_failure_metric_name(
|
|
&err,
|
|
),
|
|
/*inc*/ 1,
|
|
&tags,
|
|
);
|
|
tracing::error!(
|
|
error = %err,
|
|
"failed to run elevated Windows sandbox setup"
|
|
);
|
|
AppEvent::OpenWindowsSandboxFallbackPrompt {
|
|
preset,
|
|
profile_selection,
|
|
}
|
|
}
|
|
};
|
|
tx.send(event);
|
|
});
|
|
}
|
|
#[cfg(not(target_os = "windows"))]
|
|
{
|
|
let _ = (preset, profile_selection);
|
|
}
|
|
}
|
|
AppEvent::BeginWindowsSandboxLegacySetup {
|
|
preset,
|
|
profile_selection,
|
|
} => {
|
|
#[cfg(any(target_os = "windows", test))]
|
|
if !self.chat_widget.windows_sandbox_mode_allowed(
|
|
codex_config::types::WindowsSandboxModeToml::Unelevated,
|
|
) {
|
|
tracing::warn!(
|
|
"refusing to set up unelevated Windows sandbox mode disallowed by requirements"
|
|
);
|
|
self.chat_widget.add_info_message(
|
|
"That Windows sandbox option is disallowed by requirements.".to_string(),
|
|
/*hint*/ None,
|
|
);
|
|
return Ok(AppRunControl::Continue);
|
|
}
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
let setup_permissions = match self
|
|
.windows_setup_permissions(&preset, profile_selection.as_ref())
|
|
.await
|
|
{
|
|
Ok(setup_permissions) => setup_permissions,
|
|
Err(err) => {
|
|
tracing::warn!(
|
|
error = %err,
|
|
"failed to resolve permission profile for legacy Windows sandbox setup"
|
|
);
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to prepare Windows sandbox for the selected permission profile: {err}"
|
|
));
|
|
return Ok(AppRunControl::Continue);
|
|
}
|
|
};
|
|
let permission_profile = setup_permissions.permission_profile;
|
|
let workspace_roots = setup_permissions.workspace_roots;
|
|
let command_cwd = self.config.cwd.clone();
|
|
let env_map: std::collections::HashMap<String, String> =
|
|
std::env::vars().collect();
|
|
let codex_home = self.config.codex_home.clone();
|
|
let tx = self.app_event_tx.clone();
|
|
let session_telemetry = self.session_telemetry.clone();
|
|
|
|
self.chat_widget.show_windows_sandbox_setup_status();
|
|
tokio::task::spawn_blocking(move || {
|
|
if let Err(err) =
|
|
codex_windows_sandbox::run_windows_sandbox_legacy_preflight(
|
|
&permission_profile,
|
|
workspace_roots.as_slice(),
|
|
codex_home.as_path(),
|
|
command_cwd.as_path(),
|
|
&env_map,
|
|
)
|
|
{
|
|
session_telemetry.counter(
|
|
"codex.windows_sandbox.legacy_setup_preflight_failed",
|
|
/*inc*/ 1,
|
|
&[],
|
|
);
|
|
tracing::warn!(
|
|
error = %err,
|
|
"failed to preflight non-admin Windows sandbox setup"
|
|
);
|
|
}
|
|
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
|
|
preset,
|
|
mode: WindowsSandboxEnableMode::Legacy,
|
|
profile_selection,
|
|
});
|
|
});
|
|
}
|
|
#[cfg(not(target_os = "windows"))]
|
|
{
|
|
let _ = (preset, profile_selection);
|
|
}
|
|
}
|
|
AppEvent::BeginWindowsSandboxGrantReadRoot { path } => {
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
self.chat_widget
|
|
.add_to_history(history_cell::new_info_event(
|
|
format!("Granting sandbox read access to {path} ..."),
|
|
/*hint*/ None,
|
|
));
|
|
|
|
let permission_profile = self.config.permissions.effective_permission_profile();
|
|
let workspace_roots = self.config.effective_workspace_roots();
|
|
let command_cwd = self.config.cwd.clone();
|
|
let env_map: std::collections::HashMap<String, String> =
|
|
std::env::vars().collect();
|
|
let codex_home = self.config.codex_home.clone();
|
|
let tx = self.app_event_tx.clone();
|
|
|
|
tokio::task::spawn_blocking(move || {
|
|
let requested_path = PathBuf::from(path);
|
|
let event = match crate::windows_sandbox::grant_read_root_non_elevated(
|
|
&permission_profile,
|
|
workspace_roots.as_slice(),
|
|
command_cwd.as_path(),
|
|
&env_map,
|
|
codex_home.as_path(),
|
|
requested_path.as_path(),
|
|
) {
|
|
Ok(canonical_path) => AppEvent::WindowsSandboxGrantReadRootCompleted {
|
|
path: canonical_path,
|
|
error: None,
|
|
},
|
|
Err(err) => AppEvent::WindowsSandboxGrantReadRootCompleted {
|
|
path: requested_path,
|
|
error: Some(err.to_string()),
|
|
},
|
|
};
|
|
tx.send(event);
|
|
});
|
|
}
|
|
#[cfg(not(target_os = "windows"))]
|
|
{
|
|
let _ = path;
|
|
}
|
|
}
|
|
AppEvent::WindowsSandboxGrantReadRootCompleted { path, error } => match error {
|
|
Some(err) => {
|
|
self.chat_widget
|
|
.add_to_history(history_cell::new_error_event(format!("Error: {err}")));
|
|
}
|
|
None => {
|
|
self.chat_widget
|
|
.add_to_history(history_cell::new_info_event(
|
|
format!("Sandbox read access granted for {}", path.display()),
|
|
/*hint*/ None,
|
|
));
|
|
}
|
|
},
|
|
AppEvent::EnableWindowsSandboxForAgentMode {
|
|
preset,
|
|
mode,
|
|
profile_selection,
|
|
} => {
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
self.chat_widget.clear_windows_sandbox_setup_status();
|
|
if let Some(started_at) = self.windows_sandbox.setup_started_at.take() {
|
|
self.session_telemetry.record_duration(
|
|
"codex.windows_sandbox.elevated_setup_duration_ms",
|
|
started_at.elapsed(),
|
|
&[("result", "success")],
|
|
);
|
|
}
|
|
let selected_mode = match mode {
|
|
WindowsSandboxEnableMode::Elevated => WindowsSandboxModeToml::Elevated,
|
|
WindowsSandboxEnableMode::Legacy => WindowsSandboxModeToml::Unelevated,
|
|
};
|
|
let elevated_enabled = selected_mode == WindowsSandboxModeToml::Elevated;
|
|
if !self.chat_widget.windows_sandbox_mode_allowed(selected_mode) {
|
|
tracing::warn!(
|
|
?selected_mode,
|
|
"refusing to persist Windows sandbox mode disallowed by requirements"
|
|
);
|
|
self.chat_widget.add_info_message(
|
|
"That Windows sandbox option is disallowed by requirements."
|
|
.to_string(),
|
|
/*hint*/ None,
|
|
);
|
|
return Ok(AppRunControl::Continue);
|
|
}
|
|
let edits =
|
|
crate::config_update::build_windows_sandbox_mode_edits(elevated_enabled);
|
|
match crate::config_update::write_config_batch(
|
|
app_server.request_handle(),
|
|
edits,
|
|
)
|
|
.await
|
|
{
|
|
Ok(response) if response.status == WriteStatus::OkOverridden => {
|
|
self.sync_windows_sandbox_after_overridden_write(app_server, &response)
|
|
.await;
|
|
}
|
|
Ok(_) => {
|
|
if elevated_enabled {
|
|
self.config.set_windows_sandbox_enabled(/*value*/ false);
|
|
self.config
|
|
.set_windows_elevated_sandbox_enabled(/*value*/ true);
|
|
} else {
|
|
self.config.set_windows_sandbox_enabled(/*value*/ true);
|
|
self.config
|
|
.set_windows_elevated_sandbox_enabled(/*value*/ false);
|
|
}
|
|
self.chat_widget.set_windows_sandbox_mode(
|
|
self.config.permissions.windows_sandbox_mode,
|
|
);
|
|
let windows_sandbox_level =
|
|
crate::windows_sandbox::level_from_config(&self.config);
|
|
if let Some((sample_paths, extra_count, failed_scan)) =
|
|
self.chat_widget.world_writable_warning_details()
|
|
{
|
|
self.app_event_tx.send(AppEvent::CodexOp(
|
|
AppCommand::override_turn_context(
|
|
/*cwd*/ None,
|
|
/*approval_policy*/ None,
|
|
/*approvals_reviewer*/ None,
|
|
/*permission_profile*/ None,
|
|
/*active_permission_profile*/ None,
|
|
#[cfg(target_os = "windows")]
|
|
Some(windows_sandbox_level),
|
|
/*model*/ None,
|
|
/*effort*/ None,
|
|
/*summary*/ None,
|
|
/*service_tier*/ None,
|
|
/*collaboration_mode*/ None,
|
|
/*personality*/ None,
|
|
),
|
|
));
|
|
self.app_event_tx.send(
|
|
AppEvent::OpenWorldWritableWarningConfirmation {
|
|
preset: Some(preset.clone()),
|
|
profile_selection: profile_selection.clone(),
|
|
sample_paths,
|
|
extra_count,
|
|
failed_scan,
|
|
},
|
|
);
|
|
} else if let Some(selection) = profile_selection {
|
|
self.app_event_tx.send(AppEvent::CodexOp(
|
|
AppCommand::override_turn_context(
|
|
/*cwd*/ None,
|
|
/*approval_policy*/ None,
|
|
/*approvals_reviewer*/ None,
|
|
/*permission_profile*/ None,
|
|
/*active_permission_profile*/ None,
|
|
#[cfg(target_os = "windows")]
|
|
Some(windows_sandbox_level),
|
|
/*model*/ None,
|
|
/*effort*/ None,
|
|
/*summary*/ None,
|
|
/*service_tier*/ None,
|
|
/*collaboration_mode*/ None,
|
|
/*personality*/ None,
|
|
),
|
|
));
|
|
if self.apply_permission_profile_selection(selection).await {
|
|
self.chat_widget.submit_initial_user_message_if_pending();
|
|
}
|
|
self.chat_widget.add_plain_history_lines(vec![
|
|
Line::from(vec!["• ".dim(), "Sandbox ready".into()]),
|
|
Line::from(vec![
|
|
" ".into(),
|
|
"Codex can now safely edit files and execute commands in your computer"
|
|
.dark_gray(),
|
|
]),
|
|
]);
|
|
} else {
|
|
self.app_event_tx.send(AppEvent::CodexOp(
|
|
AppCommand::override_turn_context(
|
|
/*cwd*/ None,
|
|
Some(AskForApproval::from(preset.approval)),
|
|
Some(self.config.approvals_reviewer),
|
|
Some(preset.permission_profile.clone()),
|
|
Some(preset.active_permission_profile.clone()),
|
|
#[cfg(target_os = "windows")]
|
|
Some(windows_sandbox_level),
|
|
/*model*/ None,
|
|
/*effort*/ None,
|
|
/*summary*/ None,
|
|
/*service_tier*/ None,
|
|
/*collaboration_mode*/ None,
|
|
/*personality*/ None,
|
|
),
|
|
));
|
|
self.app_event_tx.send(AppEvent::UpdateAskForApprovalPolicy(
|
|
AskForApproval::from(preset.approval),
|
|
));
|
|
self.app_event_tx
|
|
.send(AppEvent::UpdateActivePermissionProfile(
|
|
preset.active_permission_profile.clone(),
|
|
));
|
|
self.chat_widget.add_plain_history_lines(vec![
|
|
Line::from(vec!["• ".dim(), "Sandbox ready".into()]),
|
|
Line::from(vec![
|
|
" ".into(),
|
|
"Codex can now safely edit files and execute commands in your computer"
|
|
.dark_gray(),
|
|
]),
|
|
]);
|
|
}
|
|
}
|
|
Err(err) => {
|
|
tracing::error!(
|
|
error = %err,
|
|
"failed to enable Windows sandbox feature"
|
|
);
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to enable the Windows sandbox feature: {err}"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
#[cfg(not(target_os = "windows"))]
|
|
{
|
|
let _ = (preset, mode, profile_selection);
|
|
}
|
|
}
|
|
AppEvent::PersistModelSelection { model, effort } => {
|
|
match crate::config_update::write_config_batch(
|
|
app_server.request_handle(),
|
|
crate::config_update::build_model_selection_edits(
|
|
model.as_str(),
|
|
effort.as_ref(),
|
|
),
|
|
)
|
|
.await
|
|
{
|
|
Ok(_) => {
|
|
let effort_label = effort
|
|
.as_ref()
|
|
.map(std::string::ToString::to_string)
|
|
.unwrap_or_else(|| "default".to_string());
|
|
tracing::info!("Selected model: {model}, Selected effort: {effort_label}");
|
|
let mut message = format!("Model changed to {model}");
|
|
if let Some(label) = Self::reasoning_label_for(&model, effort.as_ref()) {
|
|
message.push(' ');
|
|
message.push_str(&label);
|
|
}
|
|
self.chat_widget.add_info_message(message, /*hint*/ None);
|
|
}
|
|
Err(err) => {
|
|
let error = format_config_error(&err);
|
|
tracing::error!(
|
|
error = %error,
|
|
"failed to persist model selection"
|
|
);
|
|
self.chat_widget
|
|
.add_error_message(format!("Failed to save default model: {error}"));
|
|
}
|
|
}
|
|
}
|
|
AppEvent::CyberModelAutoReviewNotice => {
|
|
self.chat_widget.add_warning_message(
|
|
"Cyber models default to \"Approve for me\" for safety reasons.".to_string(),
|
|
);
|
|
}
|
|
AppEvent::PluginUninstallLoaded {
|
|
cwd,
|
|
plugin_id: _plugin_id,
|
|
plugin_display_name,
|
|
result,
|
|
} => {
|
|
let uninstall_succeeded = result.is_ok();
|
|
if uninstall_succeeded {
|
|
self.refresh_plugin_mentions_after_config_write();
|
|
}
|
|
self.chat_widget.on_plugin_uninstall_loaded(
|
|
cwd.clone(),
|
|
plugin_display_name,
|
|
result,
|
|
);
|
|
if uninstall_succeeded
|
|
&& self.chat_widget.config_ref().cwd.as_path() == cwd.as_path()
|
|
{
|
|
self.fetch_plugins_list(app_server, cwd);
|
|
}
|
|
}
|
|
AppEvent::RefreshPluginMentions => {
|
|
self.refresh_plugin_mentions(app_server);
|
|
}
|
|
AppEvent::PluginMentionsLoaded { mut plugins, .. } => {
|
|
if !self.config.features.enabled(Feature::Plugins) {
|
|
plugins = None;
|
|
}
|
|
self.chat_widget.on_plugin_mentions_loaded(plugins);
|
|
}
|
|
AppEvent::PersistPersonalitySelection { personality } => {
|
|
match crate::config_update::write_config_batch(
|
|
app_server.request_handle(),
|
|
vec![crate::config_update::replace_config_value(
|
|
"personality",
|
|
serde_json::json!(personality.to_string()),
|
|
)],
|
|
)
|
|
.await
|
|
{
|
|
Ok(_) => {
|
|
let label = Self::personality_label(personality);
|
|
let message = format!("Personality set to {label}");
|
|
self.chat_widget.add_info_message(message, /*hint*/ None);
|
|
}
|
|
Err(err) => {
|
|
tracing::error!(
|
|
error = %err,
|
|
"failed to persist personality selection"
|
|
);
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to save default personality: {err}"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
AppEvent::PersistServiceTierSelection { service_tier } => {
|
|
self.refresh_status_line();
|
|
self.config.service_tier = service_tier.clone();
|
|
self.sync_active_thread_service_tier_to_cached_session()
|
|
.await;
|
|
let edits = crate::config_update::build_service_tier_selection_edits(
|
|
service_tier.as_deref(),
|
|
);
|
|
match crate::config_update::write_config_batch(app_server.request_handle(), edits)
|
|
.await
|
|
{
|
|
Ok(_) => {
|
|
let message = if let Some(service_tier) = service_tier {
|
|
format!("Service tier set to {service_tier}")
|
|
} else {
|
|
"Service tier cleared".to_string()
|
|
};
|
|
self.chat_widget.add_info_message(message, /*hint*/ None);
|
|
}
|
|
Err(err) => {
|
|
tracing::error!(error = %err, "failed to persist service tier selection");
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to save default service tier: {err}"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
AppEvent::UpdateAskForApprovalPolicy(policy) => {
|
|
let mut config = self.config.clone();
|
|
if !self.try_set_approval_policy_on_config(
|
|
&mut config,
|
|
policy,
|
|
"Failed to set approval policy",
|
|
"failed to set approval policy on app config",
|
|
) {
|
|
return Ok(AppRunControl::Continue);
|
|
}
|
|
self.config = config;
|
|
let approval_policy =
|
|
AskForApproval::from(self.config.permissions.approval_policy.value());
|
|
self.runtime_approval_policy_override = Some(approval_policy);
|
|
self.chat_widget.set_approval_policy(approval_policy);
|
|
self.sync_active_thread_permission_settings_to_cached_session()
|
|
.await;
|
|
}
|
|
AppEvent::UpdateActivePermissionProfile(active_permission_profile) => {
|
|
let mut config = self.config.clone();
|
|
let Some(permission_profile) = self
|
|
.try_set_builtin_active_permission_profile_on_config(
|
|
&mut config,
|
|
active_permission_profile.clone(),
|
|
"Failed to set permission profile",
|
|
"failed to set active permission profile on app config",
|
|
)
|
|
else {
|
|
return Ok(AppRunControl::Continue);
|
|
};
|
|
#[cfg(target_os = "windows")]
|
|
let permission_profile_is_managed_restricted =
|
|
managed_filesystem_sandbox_is_restricted(&permission_profile);
|
|
let permission_profile_for_chat = permission_profile.clone();
|
|
|
|
self.config = config;
|
|
if let Err(err) = self
|
|
.chat_widget
|
|
.set_permission_profile_from_session_snapshot(
|
|
PermissionProfileSnapshot::active(
|
|
permission_profile_for_chat,
|
|
active_permission_profile,
|
|
),
|
|
)
|
|
{
|
|
tracing::warn!(%err, "failed to set permission profile on chat config");
|
|
self.chat_widget
|
|
.add_error_message(format!("Failed to set permission profile: {err}"));
|
|
return Ok(AppRunControl::Continue);
|
|
}
|
|
self.runtime_permission_profile_override =
|
|
Some(RuntimePermissionProfileOverride::from_config(&self.config));
|
|
self.sync_active_thread_permission_settings_to_cached_session()
|
|
.await;
|
|
self.chat_widget.submit_initial_user_message_if_pending();
|
|
|
|
// If a managed filesystem sandbox is active, run the Windows
|
|
// world-writable scan.
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
// One-shot suppression if the user just confirmed continue.
|
|
if self.windows_sandbox.skip_world_writable_scan_once {
|
|
self.windows_sandbox.skip_world_writable_scan_once = false;
|
|
return Ok(AppRunControl::Continue);
|
|
}
|
|
|
|
let should_check = crate::windows_sandbox::level_from_config(&self.config)
|
|
!= WindowsSandboxLevel::Disabled
|
|
&& permission_profile_is_managed_restricted
|
|
&& !self.chat_widget.world_writable_warning_hidden();
|
|
if should_check {
|
|
let cwd = self.config.cwd.clone();
|
|
let workspace_roots = self.config.effective_workspace_roots();
|
|
let env_map: std::collections::HashMap<String, String> =
|
|
std::env::vars().collect();
|
|
let tx = self.app_event_tx.clone();
|
|
let logs_base_dir = self.config.codex_home.clone();
|
|
let permission_profile =
|
|
self.config.permissions.effective_permission_profile();
|
|
Self::spawn_world_writable_scan(
|
|
cwd,
|
|
workspace_roots,
|
|
env_map,
|
|
logs_base_dir,
|
|
permission_profile,
|
|
tx,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
AppEvent::SelectPermissionProfile(selection) => {
|
|
if self.apply_permission_profile_selection(selection).await {
|
|
self.chat_widget.submit_initial_user_message_if_pending();
|
|
}
|
|
}
|
|
AppEvent::UpdateApprovalsReviewer(policy) => {
|
|
self.config.approvals_reviewer = policy;
|
|
self.chat_widget.set_approvals_reviewer(policy);
|
|
self.sync_active_thread_permission_settings_to_cached_session()
|
|
.await;
|
|
if let Err(err) = crate::config_update::write_config_batch(
|
|
app_server.request_handle(),
|
|
vec![crate::config_update::replace_config_value(
|
|
"approvals_reviewer",
|
|
serde_json::json!(policy.to_string()),
|
|
)],
|
|
)
|
|
.await
|
|
{
|
|
tracing::error!(
|
|
error = %err,
|
|
"failed to persist approvals reviewer update"
|
|
);
|
|
self.chat_widget
|
|
.add_error_message(format!("Failed to save approvals reviewer: {err}"));
|
|
}
|
|
}
|
|
AppEvent::UpdateFeatureFlags { updates } => {
|
|
self.update_feature_flags(app_server, updates).await;
|
|
}
|
|
AppEvent::UpdateMemorySettings {
|
|
use_memories,
|
|
generate_memories,
|
|
} => {
|
|
self.update_memory_settings_with_app_server(
|
|
app_server,
|
|
use_memories,
|
|
generate_memories,
|
|
)
|
|
.await;
|
|
}
|
|
AppEvent::ResetMemories => {
|
|
self.reset_memories_with_app_server(app_server).await;
|
|
}
|
|
AppEvent::SkipNextWorldWritableScan => {
|
|
self.windows_sandbox.skip_world_writable_scan_once = true;
|
|
}
|
|
AppEvent::UpdateWorldWritableWarningAcknowledged(ack) => {
|
|
self.chat_widget
|
|
.set_world_writable_warning_acknowledged(ack);
|
|
}
|
|
AppEvent::UpdateRateLimitSwitchPromptHidden(hidden) => {
|
|
self.chat_widget.set_rate_limit_switch_prompt_hidden(hidden);
|
|
}
|
|
AppEvent::UpdatePlanModeReasoningEffort(effort) => {
|
|
self.on_update_plan_mode_reasoning_effort(effort);
|
|
self.sync_active_thread_plan_mode_reasoning_setting(app_server)
|
|
.await;
|
|
}
|
|
AppEvent::PersistWorldWritableWarningAcknowledged => {
|
|
if let Err(err) = ConfigEditsBuilder::for_config(&self.config)
|
|
.set_hide_world_writable_warning(/*acknowledged*/ true)
|
|
.apply()
|
|
.await
|
|
{
|
|
tracing::error!(
|
|
error = %err,
|
|
"failed to persist world-writable warning acknowledgement"
|
|
);
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to save Agent mode warning preference: {err}"
|
|
));
|
|
}
|
|
}
|
|
AppEvent::PersistRateLimitSwitchPromptHidden => {
|
|
if let Err(err) = ConfigEditsBuilder::for_config(&self.config)
|
|
.set_hide_rate_limit_model_nudge(/*acknowledged*/ true)
|
|
.apply()
|
|
.await
|
|
{
|
|
tracing::error!(
|
|
error = %err,
|
|
"failed to persist rate limit switch prompt preference"
|
|
);
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to save rate limit reminder preference: {err}"
|
|
));
|
|
}
|
|
}
|
|
AppEvent::PersistPlanModeReasoningEffort(effort) => {
|
|
let key_path = "plan_mode_reasoning_effort";
|
|
let edit = if let Some(effort) = effort {
|
|
crate::config_update::replace_config_value(
|
|
key_path,
|
|
serde_json::json!(effort.to_string()),
|
|
)
|
|
} else {
|
|
crate::config_update::clear_config_value(key_path)
|
|
};
|
|
if let Err(err) = crate::config_update::write_config_batch(
|
|
app_server.request_handle(),
|
|
vec![edit],
|
|
)
|
|
.await
|
|
{
|
|
tracing::error!(
|
|
error = %err,
|
|
"failed to persist plan mode reasoning effort"
|
|
);
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to save Plan mode reasoning effort: {err}"
|
|
));
|
|
}
|
|
}
|
|
AppEvent::PersistModelMigrationPromptAcknowledged {
|
|
from_model,
|
|
to_model,
|
|
} => {
|
|
if let Err(err) = ConfigEditsBuilder::for_config(&self.config)
|
|
.record_model_migration_seen(from_model.as_str(), to_model.as_str())
|
|
.apply()
|
|
.await
|
|
{
|
|
tracing::error!(
|
|
error = %err,
|
|
"failed to persist model migration prompt acknowledgement"
|
|
);
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to save model migration prompt preference: {err}"
|
|
));
|
|
}
|
|
}
|
|
AppEvent::OpenApprovalsPopup => {
|
|
self.chat_widget.open_approvals_popup();
|
|
}
|
|
AppEvent::OpenAgentPicker => {
|
|
self.open_agent_picker(app_server).await;
|
|
}
|
|
AppEvent::AgentPickerThreadsLoaded {
|
|
primary_thread_id,
|
|
request_id,
|
|
result,
|
|
} => {
|
|
self.apply_agent_picker_thread_refresh(primary_thread_id, request_id, result);
|
|
}
|
|
AppEvent::SelectAgentThread(thread_id) => {
|
|
self.select_agent_thread_and_discard_side(tui, app_server, thread_id)
|
|
.await?;
|
|
}
|
|
AppEvent::StartSide {
|
|
parent_thread_id,
|
|
user_message,
|
|
} => {
|
|
return self
|
|
.handle_start_side(tui, app_server, parent_thread_id, user_message)
|
|
.await;
|
|
}
|
|
AppEvent::OpenSkillsList => {
|
|
self.chat_widget.open_skills_list();
|
|
}
|
|
AppEvent::OpenManageSkillsPopup => {
|
|
self.chat_widget.open_manage_skills_popup();
|
|
}
|
|
AppEvent::SetSkillEnabled { path, enabled } => {
|
|
match crate::config_update::write_skill_enabled(
|
|
app_server.request_handle(),
|
|
path.clone(),
|
|
enabled,
|
|
)
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
self.chat_widget.update_skill_enabled(path, enabled);
|
|
}
|
|
Err(err) => {
|
|
let path_display = path.display();
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to update skill config for {path_display}: {err}"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
AppEvent::SetAppEnabled { id, enabled } => {
|
|
let edits = if enabled {
|
|
vec![
|
|
crate::config_update::clear_config_value(
|
|
crate::config_update::app_scoped_key_path(&id, "enabled"),
|
|
),
|
|
crate::config_update::clear_config_value(
|
|
crate::config_update::app_scoped_key_path(&id, "disabled_reason"),
|
|
),
|
|
]
|
|
} else {
|
|
vec![
|
|
crate::config_update::replace_config_value(
|
|
crate::config_update::app_scoped_key_path(&id, "enabled"),
|
|
serde_json::json!(false),
|
|
),
|
|
crate::config_update::replace_config_value(
|
|
crate::config_update::app_scoped_key_path(&id, "disabled_reason"),
|
|
serde_json::json!("user"),
|
|
),
|
|
]
|
|
};
|
|
match crate::config_update::write_config_batch(app_server.request_handle(), edits)
|
|
.await
|
|
{
|
|
Ok(_) => {
|
|
self.chat_widget.update_connector_enabled(&id, enabled);
|
|
}
|
|
Err(err) => {
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to update app config for {id}: {err}"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
AppEvent::SetHookEnabled { key, enabled } => {
|
|
self.set_hook_enabled(app_server, key, enabled);
|
|
}
|
|
AppEvent::TrustHook { key, current_hash } => {
|
|
self.trust_hook(app_server, key, current_hash);
|
|
}
|
|
AppEvent::TrustHooks { updates } => {
|
|
self.trust_hooks(app_server, updates);
|
|
}
|
|
AppEvent::HookEnabledSet {
|
|
key,
|
|
enabled,
|
|
result,
|
|
} => {
|
|
let queued_enabled = self
|
|
.pending_hook_enabled_writes
|
|
.get_mut(&key)
|
|
.and_then(Option::take);
|
|
let should_apply_result = if let Some(queued_enabled) = queued_enabled
|
|
&& (result.is_err() || queued_enabled != enabled)
|
|
{
|
|
self.spawn_hook_enabled_write(app_server, key.clone(), queued_enabled);
|
|
false
|
|
} else {
|
|
true
|
|
};
|
|
if should_apply_result {
|
|
self.pending_hook_enabled_writes.remove(&key);
|
|
if let Err(err) = result {
|
|
self.chat_widget.add_error_message(err);
|
|
}
|
|
}
|
|
}
|
|
AppEvent::HookTrusted { result } => {
|
|
if let Err(err) = result {
|
|
self.chat_widget.add_error_message(err);
|
|
}
|
|
}
|
|
AppEvent::OpenPermissionsPopup => {
|
|
self.chat_widget.open_permissions_popup();
|
|
}
|
|
AppEvent::OpenReviewBranchPicker(cwd) => {
|
|
self.chat_widget.show_review_branch_picker(&cwd).await;
|
|
}
|
|
AppEvent::OpenReviewCommitPicker(cwd) => {
|
|
self.chat_widget.show_review_commit_picker(&cwd).await;
|
|
}
|
|
AppEvent::OpenReviewCustomPrompt => {
|
|
self.chat_widget.show_review_custom_prompt();
|
|
}
|
|
AppEvent::SubmitUserMessageWithMode {
|
|
text,
|
|
collaboration_mode,
|
|
} => {
|
|
self.chat_widget
|
|
.submit_user_message_with_mode(text, collaboration_mode);
|
|
}
|
|
AppEvent::ManageSkillsClosed => {
|
|
self.chat_widget.handle_manage_skills_closed();
|
|
}
|
|
AppEvent::FullScreenApprovalRequest(request) => match request {
|
|
ApprovalRequest::ApplyPatch(request) => {
|
|
let _ = tui.enter_alt_screen();
|
|
let diff_summary = DiffSummary::new(request.changes, request.cwd);
|
|
self.overlay = Some(Overlay::new_static_with_renderables(
|
|
vec![diff_summary.into()],
|
|
"P A T C H".to_string(),
|
|
self.keymap.pager.clone(),
|
|
));
|
|
}
|
|
ApprovalRequest::Exec(request) => {
|
|
let _ = tui.enter_alt_screen();
|
|
let full_cmd = strip_bash_lc_and_escape(&request.command);
|
|
let full_cmd_lines = highlight_bash_to_lines(&full_cmd);
|
|
self.overlay = Some(Overlay::new_static_with_lines(
|
|
full_cmd_lines,
|
|
"E X E C".to_string(),
|
|
self.keymap.pager.clone(),
|
|
));
|
|
}
|
|
ApprovalRequest::Permissions(request) => {
|
|
let _ = tui.enter_alt_screen();
|
|
let mut lines = Vec::new();
|
|
if let Some(environment_id) = request.environment_id {
|
|
lines.push(Line::from(vec![
|
|
"Environment: ".into(),
|
|
environment_id.bold(),
|
|
]));
|
|
lines.push(Line::from(""));
|
|
}
|
|
if let Some(reason) = request.reason {
|
|
lines.push(Line::from(vec!["Reason: ".into(), reason.italic()]));
|
|
lines.push(Line::from(""));
|
|
}
|
|
if let Some(rule_line) =
|
|
crate::bottom_pane::format_requested_permissions_rule(&request.permissions)
|
|
{
|
|
lines.push(Line::from(vec![
|
|
"Permission rule: ".into(),
|
|
rule_line.cyan(),
|
|
]));
|
|
}
|
|
self.overlay = Some(Overlay::new_static_with_renderables(
|
|
vec![Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))],
|
|
"P E R M I S S I O N S".to_string(),
|
|
self.keymap.pager.clone(),
|
|
));
|
|
}
|
|
ApprovalRequest::McpElicitation(request) => {
|
|
let _ = tui.enter_alt_screen();
|
|
let paragraph = Paragraph::new(vec![
|
|
Line::from(vec!["Server: ".into(), request.server_name.bold()]),
|
|
Line::from(""),
|
|
Line::from(request.message),
|
|
])
|
|
.wrap(Wrap { trim: false });
|
|
self.overlay = Some(Overlay::new_static_with_renderables(
|
|
vec![Box::new(paragraph)],
|
|
"E L I C I T A T I O N".to_string(),
|
|
self.keymap.pager.clone(),
|
|
));
|
|
}
|
|
},
|
|
AppEvent::StatusLineSetup {
|
|
items,
|
|
use_theme_colors,
|
|
} => {
|
|
let ids = items.iter().map(ToString::to_string).collect::<Vec<_>>();
|
|
let items_edit = crate::legacy_core::config::edit::status_line_items_edit(&ids);
|
|
let colors_edit =
|
|
crate::legacy_core::config::edit::status_line_use_colors_edit(use_theme_colors);
|
|
let apply_result = ConfigEditsBuilder::for_config(&self.config)
|
|
.with_edits([items_edit, colors_edit])
|
|
.apply()
|
|
.await;
|
|
match apply_result {
|
|
Ok(()) => {
|
|
self.config.tui_status_line = Some(ids.clone());
|
|
self.config.tui_status_line_use_colors = use_theme_colors;
|
|
self.chat_widget.setup_status_line(items, use_theme_colors);
|
|
}
|
|
Err(err) => {
|
|
let error = format_config_error(&err);
|
|
tracing::error!(error = %error, "failed to persist status line settings; keeping previous selection");
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to save status line settings: {error}"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
AppEvent::StatusLineBranchUpdated { cwd, branch } => {
|
|
self.chat_widget.set_status_line_branch(cwd, branch);
|
|
self.refresh_status_line();
|
|
}
|
|
AppEvent::StatusLineGitSummaryUpdated { cwd, summary } => {
|
|
self.chat_widget.set_status_line_git_summary(cwd, summary);
|
|
self.refresh_status_line();
|
|
}
|
|
AppEvent::StatusLineWorkspaceHeadlineUpdated { request_id, result } => {
|
|
if self
|
|
.chat_widget
|
|
.set_status_line_workspace_headline(request_id, result)
|
|
{
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
}
|
|
AppEvent::StatusLineSetupCancelled => {
|
|
self.chat_widget.cancel_status_line_setup();
|
|
}
|
|
AppEvent::TerminalTitleSetup { items } => {
|
|
let ids = items.iter().map(ToString::to_string).collect::<Vec<_>>();
|
|
let edit = crate::legacy_core::config::edit::terminal_title_items_edit(&ids);
|
|
let apply_result = ConfigEditsBuilder::for_config(&self.config)
|
|
.with_edits([edit])
|
|
.apply()
|
|
.await;
|
|
match apply_result {
|
|
Ok(()) => {
|
|
self.config.tui_terminal_title = Some(ids.clone());
|
|
self.chat_widget.setup_terminal_title(items);
|
|
}
|
|
Err(err) => {
|
|
tracing::error!(error = %err, "failed to persist terminal title items; keeping previous selection");
|
|
self.chat_widget.revert_terminal_title_setup_preview();
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to save terminal title items: {err}"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
AppEvent::TerminalTitleSetupPreview { items } => {
|
|
self.chat_widget.preview_terminal_title(items);
|
|
}
|
|
AppEvent::TerminalTitleSetupCancelled => {
|
|
self.chat_widget.cancel_terminal_title_setup();
|
|
}
|
|
AppEvent::SyntaxThemeSelected { name } => {
|
|
let edit = crate::legacy_core::config::edit::syntax_theme_edit(&name);
|
|
let apply_result = ConfigEditsBuilder::for_config(&self.config)
|
|
.with_edits([edit])
|
|
.apply()
|
|
.await;
|
|
match apply_result {
|
|
Ok(()) => {
|
|
// Ensure the selected theme is active in the current
|
|
// session. The preview callback covers arrow-key
|
|
// navigation, but if the user presses Enter without
|
|
// navigating, the runtime theme must still be applied.
|
|
if let Some(theme) = crate::render::highlight::resolve_theme_by_name(
|
|
&name,
|
|
Some(&self.config.codex_home),
|
|
) {
|
|
crate::render::highlight::set_syntax_theme(theme);
|
|
}
|
|
self.sync_tui_theme_selection(name);
|
|
self.refresh_status_line();
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
Err(err) => {
|
|
self.restore_runtime_theme_from_config();
|
|
self.refresh_status_line();
|
|
tracing::error!(error = %err, "failed to persist theme selection");
|
|
self.chat_widget
|
|
.add_error_message(format!("Failed to save theme: {err}"));
|
|
}
|
|
}
|
|
}
|
|
AppEvent::SyntaxThemePreviewed => {
|
|
self.refresh_status_line();
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
AppEvent::OpenKeymapActionMenu { context, action } => {
|
|
self.chat_widget
|
|
.open_keymap_action_menu(context, action, &self.keymap);
|
|
}
|
|
AppEvent::OpenKeymapReplaceBindingMenu { context, action } => {
|
|
self.chat_widget
|
|
.open_keymap_replace_binding_menu(context, action, &self.keymap);
|
|
}
|
|
AppEvent::OpenKeymapCapture {
|
|
context,
|
|
action,
|
|
intent,
|
|
capture_mode,
|
|
} => {
|
|
self.chat_widget.open_keymap_capture(
|
|
context,
|
|
action,
|
|
intent,
|
|
capture_mode,
|
|
&self.keymap,
|
|
);
|
|
}
|
|
AppEvent::OpenKeymapDebug => {
|
|
self.chat_widget.open_keymap_debug(&self.keymap);
|
|
}
|
|
AppEvent::KeymapCaptured {
|
|
context,
|
|
action,
|
|
key,
|
|
intent,
|
|
} => {
|
|
self.apply_keymap_capture(context, action, key, intent)
|
|
.await;
|
|
}
|
|
AppEvent::KeymapCleared { context, action } => {
|
|
self.apply_keymap_clear(context, action).await;
|
|
}
|
|
}
|
|
Ok(AppRunControl::Continue)
|
|
}
|
|
|
|
async fn apply_keymap_capture(
|
|
&mut self,
|
|
context: String,
|
|
action: String,
|
|
key: String,
|
|
intent: crate::app_event::KeymapEditIntent,
|
|
) {
|
|
let outcome = match crate::keymap_setup::keymap_with_edit(
|
|
&self.config.tui_keymap,
|
|
&self.keymap,
|
|
&context,
|
|
&action,
|
|
&key,
|
|
&intent,
|
|
) {
|
|
Ok(outcome) => outcome,
|
|
Err(err) => {
|
|
self.chat_widget.add_error_message(err);
|
|
return;
|
|
}
|
|
};
|
|
let (keymap_config, bindings, message) = match outcome {
|
|
crate::keymap_setup::KeymapEditOutcome::Updated {
|
|
keymap_config,
|
|
bindings,
|
|
message,
|
|
} => (*keymap_config, bindings, message),
|
|
crate::keymap_setup::KeymapEditOutcome::Unchanged { message } => {
|
|
self.chat_widget.add_info_message(message, /*hint*/ None);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let runtime_keymap = match RuntimeKeymap::from_config(&keymap_config) {
|
|
Ok(runtime_keymap) => runtime_keymap,
|
|
Err(err) => {
|
|
let params = crate::keymap_setup::build_keymap_conflict_params(
|
|
context, action, key, intent, err,
|
|
);
|
|
self.chat_widget.show_selection_view(params);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let edit =
|
|
crate::legacy_core::config::edit::keymap_bindings_edit(&context, &action, &bindings);
|
|
match ConfigEditsBuilder::for_config(&self.config)
|
|
.with_edits([edit])
|
|
.apply()
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
self.cancel_pending_key_chord();
|
|
self.config.tui_keymap = keymap_config.clone();
|
|
self.keymap = runtime_keymap.clone();
|
|
self.chat_widget
|
|
.apply_keymap_update(keymap_config, &runtime_keymap);
|
|
self.sync_side_thread_ui();
|
|
self.chat_widget
|
|
.return_to_keymap_picker(&context, &action, &runtime_keymap);
|
|
self.chat_widget.add_info_message(message, /*hint*/ None);
|
|
}
|
|
Err(err) => {
|
|
tracing::error!(error = %err, "failed to persist keymap binding");
|
|
self.chat_widget
|
|
.add_error_message(format!("Failed to save shortcut: {err}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn refresh_plugin_mentions_after_config_write(&mut self) {
|
|
self.chat_widget.refresh_plugin_mentions();
|
|
self.chat_widget.submit_op(AppCommand::reload_user_config());
|
|
self.chat_widget
|
|
.refresh_connector_mentions(/*force_refresh*/ true);
|
|
}
|
|
|
|
async fn apply_keymap_clear(&mut self, context: String, action: String) {
|
|
let keymap_config = match crate::keymap_setup::keymap_without_custom_binding(
|
|
&self.config.tui_keymap,
|
|
&context,
|
|
&action,
|
|
) {
|
|
Ok(keymap_config) => keymap_config,
|
|
Err(err) => {
|
|
self.chat_widget.add_error_message(err);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let runtime_keymap = match RuntimeKeymap::from_config(&keymap_config) {
|
|
Ok(runtime_keymap) => runtime_keymap,
|
|
Err(err) => {
|
|
self.chat_widget
|
|
.add_error_message(format!("Failed to refresh shortcuts: {err}"));
|
|
return;
|
|
}
|
|
};
|
|
|
|
let edit = crate::legacy_core::config::edit::keymap_binding_clear_edit(&context, &action);
|
|
match ConfigEditsBuilder::for_config(&self.config)
|
|
.with_edits([edit])
|
|
.apply()
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
self.cancel_pending_key_chord();
|
|
self.config.tui_keymap = keymap_config.clone();
|
|
self.keymap = runtime_keymap.clone();
|
|
self.chat_widget
|
|
.apply_keymap_update(keymap_config, &runtime_keymap);
|
|
self.sync_side_thread_ui();
|
|
self.chat_widget
|
|
.return_to_keymap_picker(&context, &action, &runtime_keymap);
|
|
self.chat_widget.add_info_message(
|
|
format!("Removed custom shortcut for `{context}.{action}`."),
|
|
/*hint*/ None,
|
|
);
|
|
}
|
|
Err(err) => {
|
|
tracing::error!(error = %err, "failed to clear keymap binding");
|
|
self.chat_widget
|
|
.add_error_message(format!("Failed to remove shortcut: {err}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) async fn handle_exit_mode(
|
|
&mut self,
|
|
app_server: &mut AppServerSession,
|
|
mode: ExitMode,
|
|
) -> AppRunControl {
|
|
match mode {
|
|
ExitMode::ShutdownFirst => {
|
|
// Mark the thread we are explicitly shutting down for exit so
|
|
// its shutdown completion does not trigger agent failover.
|
|
self.pending_shutdown_exit_thread_id =
|
|
self.active_thread_id.or(self.chat_widget.thread_id());
|
|
if self.pending_shutdown_exit_thread_id.is_some() {
|
|
// This is a UI escape-hatch budget, not a protocol
|
|
// deadline. A healthy local thread/unsubscribe round trip
|
|
// should finish comfortably inside two seconds, while a
|
|
// longer wait makes Ctrl+C feel broken when the app-server
|
|
// is already wedged.
|
|
if tokio::time::timeout(
|
|
SHUTDOWN_FIRST_EXIT_TIMEOUT,
|
|
self.shutdown_current_thread(app_server),
|
|
)
|
|
.await
|
|
.is_err()
|
|
{
|
|
tracing::warn!("timed out waiting for app-server thread shutdown");
|
|
}
|
|
}
|
|
self.pending_shutdown_exit_thread_id = None;
|
|
AppRunControl::Exit(ExitReason::UserRequested)
|
|
}
|
|
ExitMode::Immediate => {
|
|
self.pending_shutdown_exit_thread_id = None;
|
|
AppRunControl::Exit(ExitReason::UserRequested)
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) async fn archive_current_thread(
|
|
&mut self,
|
|
app_server: &mut AppServerSession,
|
|
) -> AppRunControl {
|
|
let Some(thread_id) = self.active_thread_id.or(self.chat_widget.thread_id()) else {
|
|
self.chat_widget
|
|
.add_error_message("A thread must start before it can be archived.".to_string());
|
|
return AppRunControl::Continue;
|
|
};
|
|
if self.side_threads.contains_key(&thread_id) {
|
|
self.chat_widget.add_error_message(
|
|
"'/archive' is unavailable in side conversations. Press Ctrl+C to return to the main thread first."
|
|
.to_string(),
|
|
);
|
|
return AppRunControl::Continue;
|
|
}
|
|
|
|
match app_server.thread_archive(thread_id).await {
|
|
Ok(()) => AppRunControl::Exit(ExitReason::UserRequested),
|
|
Err(err) => {
|
|
self.chat_widget
|
|
.add_error_message(format!("Failed to archive current thread: {err}"));
|
|
AppRunControl::Continue
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) async fn delete_current_thread(
|
|
&mut self,
|
|
app_server: &mut AppServerSession,
|
|
) -> AppRunControl {
|
|
let Some(thread_id) = self.active_thread_id.or(self.chat_widget.thread_id()) else {
|
|
self.chat_widget
|
|
.add_error_message("A thread must start before it can be deleted.".to_string());
|
|
return AppRunControl::Continue;
|
|
};
|
|
if self.side_threads.contains_key(&thread_id) {
|
|
self.chat_widget.add_error_message(
|
|
"'/delete' is unavailable in side conversations. Press Ctrl+C to return to the main thread first."
|
|
.to_string(),
|
|
);
|
|
return AppRunControl::Continue;
|
|
}
|
|
|
|
match app_server.thread_delete(thread_id).await {
|
|
Ok(()) => AppRunControl::Exit(ExitReason::UserRequested),
|
|
Err(err) => {
|
|
self.chat_widget
|
|
.add_error_message(format!("Failed to delete current thread: {err}"));
|
|
AppRunControl::Continue
|
|
}
|
|
}
|
|
}
|
|
}
|