mirror of
https://github.com/openai/codex.git
synced 2026-04-30 17:36:40 +00:00
tui: double-press Ctrl+C/Ctrl+D to quit (#8936)
## 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"
/>
This commit is contained in:
@@ -26,6 +26,7 @@ use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_backend_client::Client as BackendClient;
|
||||
@@ -108,6 +109,7 @@ use tokio::task::JoinHandle;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event::ExitMode;
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::app_event::WindowsSandboxEnableMode;
|
||||
use crate::app_event::WindowsSandboxFallbackReason;
|
||||
@@ -119,6 +121,7 @@ use crate::bottom_pane::BottomPaneParams;
|
||||
use crate::bottom_pane::CancellationEvent;
|
||||
use crate::bottom_pane::ExperimentalFeaturesView;
|
||||
use crate::bottom_pane::InputResult;
|
||||
use crate::bottom_pane::QUIT_SHORTCUT_TIMEOUT;
|
||||
use crate::bottom_pane::SelectionAction;
|
||||
use crate::bottom_pane::SelectionItem;
|
||||
use crate::bottom_pane::SelectionViewParams;
|
||||
@@ -136,6 +139,8 @@ use crate::history_cell::AgentMessageCell;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::McpToolCallCell;
|
||||
use crate::history_cell::PlainHistoryCell;
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::markdown::append_markdown;
|
||||
use crate::render::Insets;
|
||||
use crate::render::renderable::ColumnRenderable;
|
||||
@@ -358,12 +363,18 @@ pub(crate) enum ExternalEditorState {
|
||||
Active,
|
||||
}
|
||||
|
||||
/// Maintains the per-session UI state for the chat screen.
|
||||
/// Maintains the per-session UI state and interaction state machines for the chat screen.
|
||||
///
|
||||
/// This type owns the state derived from a `codex_core::protocol` event stream (history cells,
|
||||
/// active streaming buffers, bottom-pane overlays, and transient status text). It is not
|
||||
/// responsible for running the agent itself; it only reflects progress by updating UI state and by
|
||||
/// sending `Op` requests back to codex-core.
|
||||
/// `ChatWidget` owns the state derived from the protocol event stream (history cells, streaming
|
||||
/// buffers, bottom-pane overlays, and transient status text) and turns key presses into user
|
||||
/// intent (`Op` submissions and `AppEvent` requests).
|
||||
///
|
||||
/// It is not responsible for running the agent itself; it reflects progress by updating UI state
|
||||
/// and by sending requests back to codex-core.
|
||||
///
|
||||
/// Quit/interrupt behavior intentionally spans layers: the bottom pane owns local input routing
|
||||
/// (which view gets Ctrl+C), while `ChatWidget` owns process-level decisions such as interrupting
|
||||
/// active work, arming the double-press quit shortcut, and requesting shutdown-first exit.
|
||||
pub(crate) struct ChatWidget {
|
||||
app_event_tx: AppEventSender,
|
||||
codex_op_tx: UnboundedSender<Op>,
|
||||
@@ -431,6 +442,14 @@ pub(crate) struct ChatWidget {
|
||||
queued_user_messages: VecDeque<UserMessage>,
|
||||
// Pending notification to show when unfocused on next Draw
|
||||
pending_notification: Option<Notification>,
|
||||
/// When `Some`, the user has pressed a quit shortcut and the second press
|
||||
/// must occur before `quit_shortcut_expires_at`.
|
||||
quit_shortcut_expires_at: Option<Instant>,
|
||||
/// Tracks which quit shortcut key was pressed first.
|
||||
///
|
||||
/// We require the second press to match this key so `Ctrl+C` followed by
|
||||
/// `Ctrl+D` (or vice versa) doesn't quit accidentally.
|
||||
quit_shortcut_key: Option<KeyBinding>,
|
||||
// Simple review mode flag; used to adjust layout and banners.
|
||||
is_review_mode: bool,
|
||||
// Snapshot of token usage to restore after review mode exits.
|
||||
@@ -693,7 +712,9 @@ impl ChatWidget {
|
||||
|
||||
fn on_task_started(&mut self) {
|
||||
self.agent_turn_running = true;
|
||||
self.bottom_pane.clear_ctrl_c_quit_hint();
|
||||
self.bottom_pane.clear_quit_shortcut_hint();
|
||||
self.quit_shortcut_expires_at = None;
|
||||
self.quit_shortcut_key = None;
|
||||
self.update_task_running_state();
|
||||
self.retry_status_header = None;
|
||||
self.bottom_pane.set_interrupt_hint_visible(true);
|
||||
@@ -1197,7 +1218,7 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn on_shutdown_complete(&mut self) {
|
||||
self.request_exit();
|
||||
self.request_immediate_exit();
|
||||
}
|
||||
|
||||
fn on_turn_diff(&mut self, unified_diff: String) {
|
||||
@@ -1625,6 +1646,8 @@ impl ChatWidget {
|
||||
show_welcome_banner: is_first_run,
|
||||
suppress_session_configured_redraw: false,
|
||||
pending_notification: None,
|
||||
quit_shortcut_expires_at: None,
|
||||
quit_shortcut_key: None,
|
||||
is_review_mode: false,
|
||||
pre_review_token_info: None,
|
||||
needs_final_message_separator: false,
|
||||
@@ -1717,6 +1740,8 @@ impl ChatWidget {
|
||||
show_welcome_banner: false,
|
||||
suppress_session_configured_redraw: true,
|
||||
pending_notification: None,
|
||||
quit_shortcut_expires_at: None,
|
||||
quit_shortcut_key: None,
|
||||
is_review_mode: false,
|
||||
pre_review_token_info: None,
|
||||
needs_final_message_separator: false,
|
||||
@@ -1745,6 +1770,19 @@ impl ChatWidget {
|
||||
self.on_ctrl_c();
|
||||
return;
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'d') => {
|
||||
if self.on_ctrl_d() {
|
||||
return;
|
||||
}
|
||||
self.bottom_pane.clear_quit_shortcut_hint();
|
||||
self.quit_shortcut_expires_at = None;
|
||||
self.quit_shortcut_key = None;
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers,
|
||||
@@ -1757,7 +1795,9 @@ impl ChatWidget {
|
||||
return;
|
||||
}
|
||||
other if other.kind == KeyEventKind::Press => {
|
||||
self.bottom_pane.clear_ctrl_c_quit_hint();
|
||||
self.bottom_pane.clear_quit_shortcut_hint();
|
||||
self.quit_shortcut_expires_at = None;
|
||||
self.quit_shortcut_key = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -1968,7 +2008,7 @@ impl ChatWidget {
|
||||
self.open_experimental_popup();
|
||||
}
|
||||
SlashCommand::Quit | SlashCommand::Exit => {
|
||||
self.request_exit();
|
||||
self.request_quit_without_confirmation();
|
||||
}
|
||||
SlashCommand::Logout => {
|
||||
if let Err(e) = codex_core::auth::logout(
|
||||
@@ -1977,7 +2017,7 @@ impl ChatWidget {
|
||||
) {
|
||||
tracing::error!("failed to logout: {e}");
|
||||
}
|
||||
self.request_exit();
|
||||
self.request_quit_without_confirmation();
|
||||
}
|
||||
// SlashCommand::Undo => {
|
||||
// self.app_event_tx.send(AppEvent::CodexOp(Op::Undo));
|
||||
@@ -2432,8 +2472,21 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
fn request_exit(&self) {
|
||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
/// Exit the UI immediately without waiting for shutdown.
|
||||
///
|
||||
/// Prefer [`Self::request_quit_without_confirmation`] for user-initiated exits;
|
||||
/// this is mainly a fallback for shutdown completion or emergency exits.
|
||||
fn request_immediate_exit(&self) {
|
||||
self.app_event_tx.send(AppEvent::Exit(ExitMode::Immediate));
|
||||
}
|
||||
|
||||
/// Request a shutdown-first quit.
|
||||
///
|
||||
/// This is used for explicit quit commands (`/quit`, `/exit`, `/logout`) and for
|
||||
/// the double-press Ctrl+C/Ctrl+D quit shortcut.
|
||||
fn request_quit_without_confirmation(&self) {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::Exit(ExitMode::ShutdownFirst));
|
||||
}
|
||||
|
||||
fn request_redraw(&mut self) {
|
||||
@@ -3820,19 +3873,87 @@ impl ChatWidget {
|
||||
self.bottom_pane.on_file_search_result(query, matches);
|
||||
}
|
||||
|
||||
/// Handle Ctrl-C key press.
|
||||
/// Handles a Ctrl+C press at the chat-widget layer.
|
||||
///
|
||||
/// The first press arms a time-bounded quit shortcut and shows a footer hint via the bottom
|
||||
/// pane. If cancellable work is active, Ctrl+C also submits `Op::Interrupt` after the shortcut
|
||||
/// is armed.
|
||||
///
|
||||
/// If the same quit shortcut is pressed again before expiry, this requests a shutdown-first
|
||||
/// quit.
|
||||
fn on_ctrl_c(&mut self) {
|
||||
let key = key_hint::ctrl(KeyCode::Char('c'));
|
||||
let modal_or_popup_active = !self.bottom_pane.no_modal_or_popup_active();
|
||||
if self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled {
|
||||
if modal_or_popup_active {
|
||||
self.quit_shortcut_expires_at = None;
|
||||
self.quit_shortcut_key = None;
|
||||
self.bottom_pane.clear_quit_shortcut_hint();
|
||||
} else {
|
||||
self.arm_quit_shortcut(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if self.bottom_pane.is_task_running() {
|
||||
self.bottom_pane.show_ctrl_c_quit_hint();
|
||||
if self.quit_shortcut_active_for(key) {
|
||||
self.quit_shortcut_expires_at = None;
|
||||
self.quit_shortcut_key = None;
|
||||
self.request_quit_without_confirmation();
|
||||
return;
|
||||
}
|
||||
|
||||
self.arm_quit_shortcut(key);
|
||||
|
||||
if self.is_cancellable_work_active() {
|
||||
self.submit_op(Op::Interrupt);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles a Ctrl+D press at the chat-widget layer.
|
||||
///
|
||||
/// Ctrl-D only participates in quit when the composer is empty and no modal/popup is active.
|
||||
/// Otherwise it should be routed to the active view and not attempt to quit.
|
||||
fn on_ctrl_d(&mut self) -> bool {
|
||||
let key = key_hint::ctrl(KeyCode::Char('d'));
|
||||
if self.quit_shortcut_active_for(key) {
|
||||
self.quit_shortcut_expires_at = None;
|
||||
self.quit_shortcut_key = None;
|
||||
self.request_quit_without_confirmation();
|
||||
return true;
|
||||
}
|
||||
|
||||
self.submit_op(Op::Shutdown);
|
||||
if !self.bottom_pane.composer_is_empty() || !self.bottom_pane.no_modal_or_popup_active() {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.arm_quit_shortcut(key);
|
||||
true
|
||||
}
|
||||
|
||||
/// True if `key` matches the armed quit shortcut and the window has not expired.
|
||||
fn quit_shortcut_active_for(&self, key: KeyBinding) -> bool {
|
||||
self.quit_shortcut_key == Some(key)
|
||||
&& self
|
||||
.quit_shortcut_expires_at
|
||||
.is_some_and(|expires_at| Instant::now() < expires_at)
|
||||
}
|
||||
|
||||
/// Arm the double-press quit shortcut and show the footer hint.
|
||||
///
|
||||
/// This keeps the state machine (`quit_shortcut_*`) in `ChatWidget`, since
|
||||
/// it is the component that interprets Ctrl+C vs Ctrl+D and decides whether
|
||||
/// quitting is currently allowed, while delegating rendering to `BottomPane`.
|
||||
fn arm_quit_shortcut(&mut self, key: KeyBinding) {
|
||||
self.quit_shortcut_expires_at = Instant::now()
|
||||
.checked_add(QUIT_SHORTCUT_TIMEOUT)
|
||||
.or_else(|| Some(Instant::now()));
|
||||
self.quit_shortcut_key = Some(key);
|
||||
self.bottom_pane.show_quit_shortcut_hint(key);
|
||||
}
|
||||
|
||||
// Review mode counts as cancellable work so Ctrl+C interrupts instead of quitting.
|
||||
fn is_cancellable_work_active(&self) -> bool {
|
||||
self.bottom_pane.is_task_running() || self.is_review_mode
|
||||
}
|
||||
|
||||
pub(crate) fn composer_is_empty(&self) -> bool {
|
||||
|
||||
Reference in New Issue
Block a user