core-agent-ide/docs/exit-confirmation-prompt-design.md

97 lines
3.9 KiB
Markdown
Raw Normal View History

2026-01-21 17:02:29 -08:00
# Exit and shutdown flow (tui)
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 09:42:52 -08:00
2026-01-21 17:02:29 -08:00
This document describes how exit, shutdown, and interruption work in the Rust TUI (`codex-rs/tui`).
It is intended for Codex developers and Codex itself when reasoning about future exit/shutdown
changes.
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 09:42:52 -08:00
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.