## Problem
Codex’s TUI quit behavior has historically been easy to trigger
accidentally and hard to reason
about.
- `Ctrl+C`/`Ctrl+D` could terminate the UI immediately, which is a
common key to press while trying
to dismiss a modal, cancel a command, or recover from a stuck state.
- “Quit” and “shutdown” were not consistently separated, so some exit
paths could bypass the
shutdown/cleanup work that should run before the process terminates.
This PR makes quitting both safer (harder to do by accident) and more
uniform across quit
gestures, while keeping the shutdown-first semantics explicit.
## Mental model
After this change, the system treats quitting as a UI request that is
coordinated by the app
layer.
- The UI requests exit via `AppEvent::Exit(ExitMode)`.
- `ExitMode::ShutdownFirst` is the normal user path: the app triggers
`Op::Shutdown`, continues
rendering while shutdown runs, and only ends the UI loop once shutdown
has completed.
- `ExitMode::Immediate` exists as an escape hatch (and as the
post-shutdown “now actually exit”
signal); it bypasses cleanup and should not be the default for
user-triggered quits.
User-facing quit gestures are intentionally “two-step” for safety:
- `Ctrl+C` and `Ctrl+D` no longer exit immediately.
- The first press arms a 1-second window and shows a footer hint (“ctrl
+ <key> again to quit”).
- Pressing the same key again within the window requests a
shutdown-first quit; otherwise the
hint expires and the next press starts a fresh window.
Key routing remains modal-first:
- A modal/popup gets first chance to consume `Ctrl+C`.
- If a modal handles `Ctrl+C`, any armed quit shortcut is cleared so
dismissing a modal cannot
prime a subsequent `Ctrl+C` to quit.
- `Ctrl+D` only participates in quitting when the composer is empty and
no modal/popup is active.
The design doc `docs/exit-confirmation-prompt-design.md` captures the
intended routing and the
invariants the UI should maintain.
## Non-goals
- This does not attempt to redesign modal UX or make modals uniformly
dismissible via `Ctrl+C`.
It only ensures modals get priority and that quit arming does not leak
across modal handling.
- This does not introduce a persistent confirmation prompt/menu for
quitting; the goal is to keep
the exit gesture lightweight and consistent.
- This does not change the semantics of core shutdown itself; it changes
how the UI requests and
sequences it.
## Tradeoffs
- Quitting via `Ctrl+C`/`Ctrl+D` now requires a deliberate second
keypress, which adds friction for
users who relied on the old “instant quit” behavior.
- The UI now maintains a small time-bounded state machine for the armed
shortcut, which increases
complexity and introduces timing-dependent behavior.
This design was chosen over alternatives (a modal confirmation prompt or
a long-lived “are you
sure” state) because it provides an explicit safety barrier while
keeping the flow fast and
keyboard-native.
## Architecture
- `ChatWidget` owns the quit-shortcut state machine and decides when a
quit gesture is allowed
(idle vs cancellable work, composer state, etc.).
- `BottomPane` owns rendering and local input routing for modals/popups.
It is responsible for
consuming cancellation keys when a view is active and for
showing/expiring the footer hint.
- `App` owns shutdown sequencing: translating
`AppEvent::Exit(ShutdownFirst)` into `Op::Shutdown`
and only terminating the UI loop when exit is safe.
This keeps “what should happen” decisions (quit vs interrupt vs ignore)
in the chat/widget layer,
while keeping “how it looks and which view gets the key” in the
bottom-pane layer.
## Observability
You can tell this is working by running the TUIs and exercising the quit
gestures:
- While idle: pressing `Ctrl+C` (or `Ctrl+D` with an empty composer and
no modal) shows a footer
hint for ~1 second; pressing again within that window exits via
shutdown-first.
- While streaming/tools/review are active: `Ctrl+C` interrupts work
rather than quitting.
- With a modal/popup open: `Ctrl+C` dismisses/handles the modal (if it
chooses to) and does not
arm a quit shortcut; a subsequent quick `Ctrl+C` should not quit unless
the user re-arms it.
Failure modes are visible as:
- Quits that happen immediately (no hint window) from `Ctrl+C`/`Ctrl+D`.
- Quits that occur while a modal is open and consuming `Ctrl+C`.
- UI termination before shutdown completes (cleanup skipped).
## Tests
- Updated/added unit and snapshot coverage in `codex-tui` and
`codex-tui2` to validate:
- The quit hint appears and expires on the expected key.
- Double-press within the window triggers a shutdown-first quit request.
- Modal-first routing prevents quit bypass and clears any armed shortcut
when a modal consumes
`Ctrl+C`.
These tests focus on the UI-level invariants and rendered output; they
do not attempt to validate
real terminal key-repeat timing or end-to-end process shutdown behavior.
---
Screenshot:
<img width="912" height="740" alt="Screenshot 2026-01-13 at 1 05 28 PM"
src="https://github.com/user-attachments/assets/18f3d22e-2557-47f2-a369-ae7a9531f29f"
/>
255 lines
8.8 KiB
Rust
255 lines
8.8 KiB
Rust
//! Application-level events used to coordinate UI actions.
|
|
//!
|
|
//! `AppEvent` is the internal message bus between UI components and the top-level `App` loop.
|
|
//! Widgets emit events to request actions that must be handled at the app layer (like opening
|
|
//! pickers, persisting configuration, or shutting down the agent), without needing direct access to
|
|
//! `App` internals.
|
|
//!
|
|
//! Exit is modelled explicitly via `AppEvent::Exit(ExitMode)` so callers can request shutdown-first
|
|
//! quits without reaching into the app loop or coupling to shutdown/exit sequencing.
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use codex_common::approval_presets::ApprovalPreset;
|
|
use codex_core::protocol::Event;
|
|
use codex_core::protocol::RateLimitSnapshot;
|
|
use codex_file_search::FileMatch;
|
|
use codex_protocol::openai_models::ModelPreset;
|
|
|
|
use crate::bottom_pane::ApprovalRequest;
|
|
use crate::history_cell::HistoryCell;
|
|
|
|
use codex_core::features::Feature;
|
|
use codex_core::protocol::AskForApproval;
|
|
use codex_core::protocol::SandboxPolicy;
|
|
use codex_protocol::openai_models::ReasoningEffort;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
pub(crate) enum WindowsSandboxEnableMode {
|
|
Elevated,
|
|
Legacy,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
pub(crate) enum WindowsSandboxFallbackReason {
|
|
ElevationFailed,
|
|
}
|
|
|
|
#[allow(clippy::large_enum_variant)]
|
|
#[derive(Debug)]
|
|
pub(crate) enum AppEvent {
|
|
CodexEvent(Event),
|
|
|
|
/// Start a new session.
|
|
NewSession,
|
|
|
|
/// Open the resume picker inside the running TUI session.
|
|
OpenResumePicker,
|
|
|
|
/// Open the fork picker inside the running TUI session.
|
|
OpenForkPicker,
|
|
|
|
/// Request to exit the application.
|
|
///
|
|
/// Use `ShutdownFirst` for user-initiated quits so core cleanup runs and the
|
|
/// UI exits only after `ShutdownComplete`. `Immediate` is a last-resort
|
|
/// escape hatch that skips shutdown and may drop in-flight work (e.g.,
|
|
/// background tasks, rollout flush, or child process cleanup).
|
|
Exit(ExitMode),
|
|
|
|
/// Request to exit the application due to a fatal error.
|
|
FatalExitRequest(String),
|
|
|
|
/// Forward an `Op` to the Agent. Using an `AppEvent` for this avoids
|
|
/// bubbling channels through layers of widgets.
|
|
CodexOp(codex_core::protocol::Op),
|
|
|
|
/// Kick off an asynchronous file search for the given query (text after
|
|
/// the `@`). Previous searches may be cancelled by the app layer so there
|
|
/// is at most one in-flight search.
|
|
StartFileSearch(String),
|
|
|
|
/// Result of a completed asynchronous file search. The `query` echoes the
|
|
/// original search term so the UI can decide whether the results are
|
|
/// still relevant.
|
|
FileSearchResult {
|
|
query: String,
|
|
matches: Vec<FileMatch>,
|
|
},
|
|
|
|
/// Result of refreshing rate limits
|
|
RateLimitSnapshotFetched(RateLimitSnapshot),
|
|
|
|
/// Result of computing a `/diff` command.
|
|
DiffResult(String),
|
|
|
|
InsertHistoryCell(Box<dyn HistoryCell>),
|
|
|
|
StartCommitAnimation,
|
|
StopCommitAnimation,
|
|
CommitTick,
|
|
|
|
/// Update the current reasoning effort in the running app and widget.
|
|
UpdateReasoningEffort(Option<ReasoningEffort>),
|
|
|
|
/// Update the current model slug in the running app and widget.
|
|
UpdateModel(String),
|
|
|
|
/// Persist the selected model and reasoning effort to the appropriate config.
|
|
PersistModelSelection {
|
|
model: String,
|
|
effort: Option<ReasoningEffort>,
|
|
},
|
|
|
|
/// Open the reasoning selection popup after picking a model.
|
|
OpenReasoningPopup {
|
|
model: ModelPreset,
|
|
},
|
|
|
|
/// Open the full model picker (non-auto models).
|
|
OpenAllModelsPopup {
|
|
models: Vec<ModelPreset>,
|
|
},
|
|
|
|
/// Open the confirmation prompt before enabling full access mode.
|
|
OpenFullAccessConfirmation {
|
|
preset: ApprovalPreset,
|
|
},
|
|
|
|
/// Open the Windows world-writable directories warning.
|
|
/// If `preset` is `Some`, the confirmation will apply the provided
|
|
/// approval/sandbox configuration on Continue; if `None`, it performs no
|
|
/// policy change and only acknowledges/dismisses the warning.
|
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
OpenWorldWritableWarningConfirmation {
|
|
preset: Option<ApprovalPreset>,
|
|
/// Up to 3 sample world-writable directories to display in the warning.
|
|
sample_paths: Vec<String>,
|
|
/// If there are more than `sample_paths`, this carries the remaining count.
|
|
extra_count: usize,
|
|
/// True when the scan failed (e.g. ACL query error) and protections could not be verified.
|
|
failed_scan: bool,
|
|
},
|
|
|
|
/// Prompt to enable the Windows sandbox feature before using Agent mode.
|
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
OpenWindowsSandboxEnablePrompt {
|
|
preset: ApprovalPreset,
|
|
},
|
|
|
|
/// Open the Windows sandbox fallback prompt after declining or failing elevation.
|
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
OpenWindowsSandboxFallbackPrompt {
|
|
preset: ApprovalPreset,
|
|
reason: WindowsSandboxFallbackReason,
|
|
},
|
|
|
|
/// Begin the elevated Windows sandbox setup flow.
|
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
BeginWindowsSandboxElevatedSetup {
|
|
preset: ApprovalPreset,
|
|
},
|
|
|
|
/// Enable the Windows sandbox feature and switch to Agent mode.
|
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
EnableWindowsSandboxForAgentMode {
|
|
preset: ApprovalPreset,
|
|
mode: WindowsSandboxEnableMode,
|
|
},
|
|
|
|
/// Update the current approval policy in the running app and widget.
|
|
UpdateAskForApprovalPolicy(AskForApproval),
|
|
|
|
/// Update the current sandbox policy in the running app and widget.
|
|
UpdateSandboxPolicy(SandboxPolicy),
|
|
|
|
/// Update feature flags and persist them to the top-level config.
|
|
UpdateFeatureFlags {
|
|
updates: Vec<(Feature, bool)>,
|
|
},
|
|
|
|
/// Update whether the full access warning prompt has been acknowledged.
|
|
UpdateFullAccessWarningAcknowledged(bool),
|
|
|
|
/// Update whether the world-writable directories warning has been acknowledged.
|
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
UpdateWorldWritableWarningAcknowledged(bool),
|
|
|
|
/// Update whether the rate limit switch prompt has been acknowledged for the session.
|
|
UpdateRateLimitSwitchPromptHidden(bool),
|
|
|
|
/// Persist the acknowledgement flag for the full access warning prompt.
|
|
PersistFullAccessWarningAcknowledged,
|
|
|
|
/// Persist the acknowledgement flag for the world-writable directories warning.
|
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
PersistWorldWritableWarningAcknowledged,
|
|
|
|
/// Persist the acknowledgement flag for the rate limit switch prompt.
|
|
PersistRateLimitSwitchPromptHidden,
|
|
|
|
/// Persist the acknowledgement flag for the model migration prompt.
|
|
PersistModelMigrationPromptAcknowledged {
|
|
from_model: String,
|
|
to_model: String,
|
|
},
|
|
|
|
/// Skip the next world-writable scan (one-shot) after a user-confirmed continue.
|
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
SkipNextWorldWritableScan,
|
|
|
|
/// Re-open the approval presets popup.
|
|
OpenApprovalsPopup,
|
|
|
|
/// Open the branch picker option from the review popup.
|
|
OpenReviewBranchPicker(PathBuf),
|
|
|
|
/// Open the commit picker option from the review popup.
|
|
OpenReviewCommitPicker(PathBuf),
|
|
|
|
/// Open the custom prompt option from the review popup.
|
|
OpenReviewCustomPrompt,
|
|
|
|
/// Open the approval popup.
|
|
FullScreenApprovalRequest(ApprovalRequest),
|
|
|
|
/// Open the feedback note entry overlay after the user selects a category.
|
|
OpenFeedbackNote {
|
|
category: FeedbackCategory,
|
|
include_logs: bool,
|
|
},
|
|
|
|
/// Open the upload consent popup for feedback after selecting a category.
|
|
OpenFeedbackConsent {
|
|
category: FeedbackCategory,
|
|
},
|
|
|
|
/// Launch the external editor after a normal draw has completed.
|
|
LaunchExternalEditor,
|
|
}
|
|
|
|
/// The exit strategy requested by the UI layer.
|
|
///
|
|
/// Most user-initiated exits should use `ShutdownFirst` so core cleanup runs and the UI exits only
|
|
/// after core acknowledges completion. `Immediate` is an escape hatch for cases where shutdown has
|
|
/// already completed (or is being bypassed) and the UI loop should terminate right away.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) enum ExitMode {
|
|
/// Shutdown core and exit after completion.
|
|
ShutdownFirst,
|
|
/// Exit the UI loop immediately without waiting for shutdown.
|
|
///
|
|
/// This skips `Op::Shutdown`, so any in-flight work may be dropped and
|
|
/// cleanup that normally runs before `ShutdownComplete` can be missed.
|
|
Immediate,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) enum FeedbackCategory {
|
|
BadResult,
|
|
GoodResult,
|
|
Bug,
|
|
Other,
|
|
}
|