Files
codex/docs/exit-confirmation-prompt-design.md
Josh McKinney 4283a7432b 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"
/>
2026-01-14 17:42:52 +00:00

3.9 KiB

Exit and shutdown flow (tui + tui2)

This document describes how exit, shutdown, and interruption work in the Rust TUIs (codex-rs/tui and codex-rs/tui2). It is intended for Codex developers and Codex itself when reasoning about future exit/shutdown changes.

This doc replaces earlier separate history and design notes. High-level history is summarized below; full details are captured in PR #8936.

Terms

  • Exit: end the UI event loop and terminate the process.
  • Shutdown: request a graceful agent/core shutdown (Op::Shutdown) and wait for ShutdownComplete so cleanup can run.
  • Interrupt: cancel a running operation (Op::Interrupt).

Event model (AppEvent)

Exit is coordinated via a single event with explicit modes:

  • AppEvent::Exit(ExitMode::ShutdownFirst)
    • Prefer this for user-initiated quits so cleanup runs.
  • AppEvent::Exit(ExitMode::Immediate)
    • Escape hatch for immediate exit. This bypasses shutdown and can drop in-flight work (e.g., tasks, rollout flush, child process cleanup).

App is the coordinator: it submits Op::Shutdown and it exits the UI loop only when ExitMode::Immediate arrives (typically after ShutdownComplete).

User-triggered quit flows

Ctrl+C

Priority order in the UI layer:

  1. Active modal/view gets the first chance to consume (BottomPane::on_ctrl_c).
    • If the modal handles it, the quit flow stops.
    • When a modal/popup handles Ctrl+C, the quit shortcut is cleared so dismissing a modal cannot accidentally prime a subsequent Ctrl+C to quit.
  2. If the user has already armed Ctrl+C and the 1 second window has not expired, the second Ctrl+C triggers shutdown-first quit immediately.
  3. Otherwise, ChatWidget arms Ctrl+C and shows the quit hint (ctrl + c again to quit) for 1 second.
  4. If cancellable work is active (streaming/tools/review), ChatWidget submits Op::Interrupt.

Ctrl+D

  • Only participates in quit when the composer is empty and no modal is active.
    • On first press, show the quit hint (same as Ctrl+C) and start the 1 second timer.
    • If pressed again while the hint is visible, request shutdown-first quit.
  • With any modal/popup open, key events are routed to the view and Ctrl+D does not attempt to quit.

Slash commands

  • /quit, /exit, /logout request shutdown-first quit without a prompt, because slash commands are harder to trigger accidentally and imply clear intent to quit.

/new

  • Uses shutdown without exit (suppresses ShutdownComplete) so the app can start a fresh session without terminating.

Shutdown completion and suppression

ShutdownComplete is the signal that core cleanup has finished. The UI treats it as the boundary for exit:

  • ChatWidget requests Exit(Immediate) on ShutdownComplete.
  • App can suppress a single ShutdownComplete when shutdown is used as a cleanup step (e.g., /new).

Edge cases and invariants

  • Review mode counts as cancellable work. Ctrl+C should interrupt review, not quit.
  • Modal open means Ctrl+C/Ctrl+D should not quit unless the modal explicitly declines to handle Ctrl+C.
  • Immediate exit is not a normal user path; it is a fallback for shutdown completion or an emergency exit. Use it sparingly because it skips cleanup.

Testing expectations

At a minimum, we want coverage for:

  • Ctrl+C while working interrupts, does not quit.
  • Ctrl+C while idle and empty shows quit hint, then shutdown-first quit on second press.
  • Ctrl+D with modal open does not quit.
  • /quit / /exit / /logout quit without prompt, but still shutdown-first.
    • Ctrl+D while idle and empty shows quit hint, then shutdown-first quit on second press.

History (high level)

Codex has historically mixed "exit immediately" and "shutdown-first" across quit gestures, largely due to incremental changes and regressions in state tracking. This doc reflects the current unified, shutdown-first approach. See PR #8936 for the detailed history and rationale.