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:
Josh McKinney
2026-01-14 09:42:52 -08:00
committed by GitHub
parent 92472e7baa
commit 4283a7432b
25 changed files with 932 additions and 199 deletions

View File

@@ -1,5 +1,6 @@
use crate::app_backtrack::BacktrackState;
use crate::app_event::AppEvent;
use crate::app_event::ExitMode;
#[cfg(target_os = "windows")]
use crate::app_event::WindowsSandboxEnableMode;
#[cfg(target_os = "windows")]
@@ -858,9 +859,12 @@ impl App {
}
self.chat_widget.handle_codex_event(event);
}
AppEvent::ExitRequest => {
return Ok(AppRunControl::Exit(ExitReason::UserRequested));
}
AppEvent::Exit(mode) => match mode {
ExitMode::ShutdownFirst => self.chat_widget.submit_op(Op::Shutdown),
ExitMode::Immediate => {
return Ok(AppRunControl::Exit(ExitReason::UserRequested));
}
},
AppEvent::FatalExitRequest(message) => {
return Ok(AppRunControl::Exit(ExitReason::Fatal(message)));
}

View File

@@ -1,3 +1,13 @@
//! 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;
@@ -41,8 +51,13 @@ pub(crate) enum AppEvent {
/// Open the fork picker inside the running TUI session.
OpenForkPicker,
/// Request to exit the application gracefully.
ExitRequest,
/// 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),
@@ -215,6 +230,22 @@ pub(crate) enum AppEvent {
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,

View File

@@ -57,7 +57,8 @@
//! edits and renders a placeholder prompt instead of the editable textarea. This is part of the
//! overall state machine, since it affects which transitions are even possible from a given UI
//! state.
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::has_ctrl_or_alt;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -168,7 +169,8 @@ pub(crate) struct ChatComposer {
active_popup: ActivePopup,
app_event_tx: AppEventSender,
history: ChatComposerHistory,
ctrl_c_quit_hint: bool,
quit_shortcut_expires_at: Option<Instant>,
quit_shortcut_key: KeyBinding,
esc_backtrack_hint: bool,
use_shift_enter_hint: bool,
dismissed_file_popup_token: Option<String>,
@@ -222,7 +224,8 @@ impl ChatComposer {
active_popup: ActivePopup::None,
app_event_tx,
history: ChatComposerHistory::new(),
ctrl_c_quit_hint: false,
quit_shortcut_expires_at: None,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
esc_backtrack_hint: false,
use_shift_enter_hint,
dismissed_file_popup_token: None,
@@ -578,16 +581,37 @@ impl ChatComposer {
}
}
pub fn set_ctrl_c_quit_hint(&mut self, show: bool, has_focus: bool) {
self.ctrl_c_quit_hint = show;
if show {
self.footer_mode = FooterMode::CtrlCReminder;
} else {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}
/// Show the transient "press again to quit" hint for `key`.
///
/// The owner (`BottomPane`/`ChatWidget`) is responsible for scheduling a
/// redraw after [`super::QUIT_SHORTCUT_TIMEOUT`] so the hint can disappear
/// even when the UI is otherwise idle.
pub fn show_quit_shortcut_hint(&mut self, key: KeyBinding, has_focus: bool) {
self.quit_shortcut_expires_at = Instant::now()
.checked_add(super::QUIT_SHORTCUT_TIMEOUT)
.or_else(|| Some(Instant::now()));
self.quit_shortcut_key = key;
self.footer_mode = FooterMode::QuitShortcutReminder;
self.set_has_focus(has_focus);
}
/// Clear the "press again to quit" hint immediately.
pub fn clear_quit_shortcut_hint(&mut self, has_focus: bool) {
self.quit_shortcut_expires_at = None;
self.footer_mode = reset_mode_after_activity(self.footer_mode);
self.set_has_focus(has_focus);
}
/// Whether the quit shortcut hint should currently be shown.
///
/// This is time-based rather than event-based: it may become false without
/// any additional user input, so the UI schedules a redraw when the hint
/// expires.
pub(crate) fn quit_shortcut_hint_visible(&self) -> bool {
self.quit_shortcut_expires_at
.is_some_and(|expires_at| Instant::now() < expires_at)
}
fn next_large_paste_placeholder(&mut self, char_count: usize) -> String {
let base = format!("[Pasted Content {char_count} chars]");
let next_suffix = self.large_paste_counters.entry(char_count).or_insert(0);
@@ -1497,10 +1521,7 @@ impl ChatComposer {
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} if self.is_empty() => {
self.app_event_tx.send(AppEvent::ExitRequest);
(InputResult::None, true)
}
} if self.is_empty() => (InputResult::None, false),
// -------------------------------------------------------------
// History navigation (Up / Down) only when the composer is not
// empty or when the cursor is at the correct position, to avoid
@@ -1801,7 +1822,7 @@ impl ChatComposer {
return false;
}
let next = toggle_shortcut_mode(self.footer_mode, self.ctrl_c_quit_hint);
let next = toggle_shortcut_mode(self.footer_mode, self.quit_shortcut_hint_visible());
let changed = next != self.footer_mode;
self.footer_mode = next;
changed
@@ -1813,6 +1834,7 @@ impl ChatComposer {
esc_backtrack_hint: self.esc_backtrack_hint,
use_shift_enter_hint: self.use_shift_enter_hint,
is_task_running: self.is_task_running,
quit_shortcut_key: self.quit_shortcut_key,
steer_enabled: self.steer_enabled,
context_window_percent: self.context_window_percent,
context_window_used_tokens: self.context_window_used_tokens,
@@ -1823,8 +1845,13 @@ impl ChatComposer {
match self.footer_mode {
FooterMode::EscHint => FooterMode::EscHint,
FooterMode::ShortcutOverlay => FooterMode::ShortcutOverlay,
FooterMode::CtrlCReminder => FooterMode::CtrlCReminder,
FooterMode::ShortcutSummary if self.ctrl_c_quit_hint => FooterMode::CtrlCReminder,
FooterMode::QuitShortcutReminder if self.quit_shortcut_hint_visible() => {
FooterMode::QuitShortcutReminder
}
FooterMode::QuitShortcutReminder => FooterMode::ShortcutSummary,
FooterMode::ShortcutSummary if self.quit_shortcut_hint_visible() => {
FooterMode::QuitShortcutReminder
}
FooterMode::ShortcutSummary if !self.is_empty() => FooterMode::ContextOnly,
other => other,
}
@@ -2365,16 +2392,16 @@ mod tests {
});
snapshot_composer_state("footer_mode_ctrl_c_quit", true, |composer| {
composer.set_ctrl_c_quit_hint(true, true);
composer.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), true);
});
snapshot_composer_state("footer_mode_ctrl_c_interrupt", true, |composer| {
composer.set_task_running(true);
composer.set_ctrl_c_quit_hint(true, true);
composer.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), true);
});
snapshot_composer_state("footer_mode_ctrl_c_then_esc_hint", true, |composer| {
composer.set_ctrl_c_quit_hint(true, true);
composer.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), true);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
});

View File

@@ -1,3 +1,13 @@
//! The bottom-pane footer renders transient hints and context indicators.
//!
//! The footer is pure rendering: it formats `FooterProps` into `Line`s without mutating any state.
//! It intentionally does not decide *which* footer content should be shown; that is owned by the
//! `ChatComposer` (which selects a `FooterMode`) and by higher-level state machines like
//! `ChatWidget` (which decides when quit/interrupt is allowed).
//!
//! Some footer content is time-based rather than event-based, such as the "press again to quit"
//! hint. The owning widgets schedule redraws so time-based hints can expire even if the UI is
//! otherwise idle.
#[cfg(target_os = "linux")]
use crate::clipboard_paste::is_probably_wsl;
use crate::key_hint;
@@ -14,6 +24,12 @@ use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
/// The rendering inputs for the footer area under the composer.
///
/// Callers are expected to construct `FooterProps` from higher-level state (`ChatComposer`,
/// `BottomPane`, and `ChatWidget`) and pass it to `render_footer`. The footer treats these values as
/// authoritative and does not attempt to infer missing state (for example, it does not query
/// whether a task is running).
#[derive(Clone, Copy, Debug)]
pub(crate) struct FooterProps {
pub(crate) mode: FooterMode,
@@ -21,13 +37,22 @@ pub(crate) struct FooterProps {
pub(crate) use_shift_enter_hint: bool,
pub(crate) is_task_running: bool,
pub(crate) steer_enabled: bool,
/// Which key the user must press again to quit.
///
/// This is rendered when `mode` is `FooterMode::QuitShortcutReminder`.
pub(crate) quit_shortcut_key: KeyBinding,
pub(crate) context_window_percent: Option<i64>,
pub(crate) context_window_used_tokens: Option<i64>,
}
/// Selects which footer content is rendered.
///
/// The current mode is owned by `ChatComposer`, which may override it based on transient state
/// (for example, showing `QuitShortcutReminder` only while its timer is active).
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum FooterMode {
CtrlCReminder,
/// Transient "press again to quit" reminder (Ctrl+C/Ctrl+D).
QuitShortcutReminder,
ShortcutSummary,
ShortcutOverlay,
EscHint,
@@ -35,12 +60,14 @@ pub(crate) enum FooterMode {
}
pub(crate) fn toggle_shortcut_mode(current: FooterMode, ctrl_c_hint: bool) -> FooterMode {
if ctrl_c_hint && matches!(current, FooterMode::CtrlCReminder) {
if ctrl_c_hint && matches!(current, FooterMode::QuitShortcutReminder) {
return current;
}
match current {
FooterMode::ShortcutOverlay | FooterMode::CtrlCReminder => FooterMode::ShortcutSummary,
FooterMode::ShortcutOverlay | FooterMode::QuitShortcutReminder => {
FooterMode::ShortcutSummary
}
_ => FooterMode::ShortcutOverlay,
}
}
@@ -57,7 +84,7 @@ pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode {
match current {
FooterMode::EscHint
| FooterMode::ShortcutOverlay
| FooterMode::CtrlCReminder
| FooterMode::QuitShortcutReminder
| FooterMode::ContextOnly => FooterMode::ShortcutSummary,
other => other,
}
@@ -82,9 +109,9 @@ fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
// the shortcut hint is hidden). Hide it only for the multi-line
// ShortcutOverlay.
match props.mode {
FooterMode::CtrlCReminder => vec![ctrl_c_reminder_line(CtrlCReminderState {
is_task_running: props.is_task_running,
})],
FooterMode::QuitShortcutReminder => {
vec![quit_shortcut_reminder_line(props.quit_shortcut_key)]
}
FooterMode::ShortcutSummary => {
let mut line = context_window_line(
props.context_window_percent,
@@ -126,11 +153,6 @@ fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
}
}
#[derive(Clone, Copy, Debug)]
struct CtrlCReminderState {
is_task_running: bool,
}
#[derive(Clone, Copy, Debug)]
struct ShortcutsState {
use_shift_enter_hint: bool,
@@ -138,17 +160,8 @@ struct ShortcutsState {
is_wsl: bool,
}
fn ctrl_c_reminder_line(state: CtrlCReminderState) -> Line<'static> {
let action = if state.is_task_running {
"interrupt"
} else {
"quit"
};
Line::from(vec![
key_hint::ctrl(KeyCode::Char('c')).into(),
format!(" again to {action}").into(),
])
.dim()
fn quit_shortcut_reminder_line(key: KeyBinding) -> Line<'static> {
Line::from(vec![key.into(), " again to quit".into()]).dim()
}
fn esc_hint_line(esc_backtrack_hint: bool) -> Line<'static> {
@@ -487,6 +500,7 @@ mod tests {
use_shift_enter_hint: false,
is_task_running: false,
steer_enabled: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
},
@@ -500,6 +514,7 @@ mod tests {
use_shift_enter_hint: true,
is_task_running: false,
steer_enabled: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
},
@@ -508,11 +523,12 @@ mod tests {
snapshot_footer(
"footer_ctrl_c_quit_idle",
FooterProps {
mode: FooterMode::CtrlCReminder,
mode: FooterMode::QuitShortcutReminder,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: false,
steer_enabled: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
},
@@ -521,11 +537,12 @@ mod tests {
snapshot_footer(
"footer_ctrl_c_quit_running",
FooterProps {
mode: FooterMode::CtrlCReminder,
mode: FooterMode::QuitShortcutReminder,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: true,
steer_enabled: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
},
@@ -539,6 +556,7 @@ mod tests {
use_shift_enter_hint: false,
is_task_running: false,
steer_enabled: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
},
@@ -552,6 +570,7 @@ mod tests {
use_shift_enter_hint: false,
is_task_running: false,
steer_enabled: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
},
@@ -565,6 +584,7 @@ mod tests {
use_shift_enter_hint: false,
is_task_running: true,
steer_enabled: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: Some(72),
context_window_used_tokens: None,
},
@@ -578,6 +598,7 @@ mod tests {
use_shift_enter_hint: false,
is_task_running: false,
steer_enabled: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: Some(123_456),
},
@@ -591,6 +612,7 @@ mod tests {
use_shift_enter_hint: false,
is_task_running: true,
steer_enabled: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
},
@@ -604,6 +626,7 @@ mod tests {
use_shift_enter_hint: false,
is_task_running: true,
steer_enabled: true,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
},

View File

@@ -1,9 +1,25 @@
//! Bottom pane: shows the ChatComposer or a BottomPaneView, if one is active.
//! The bottom pane is the interactive footer of the chat UI.
//!
//! The pane owns the [`ChatComposer`] (editable prompt input) and a stack of transient
//! [`BottomPaneView`]s (popups/modals) that temporarily replace the composer for focused
//! interactions like selection lists.
//!
//! Input routing is layered: `BottomPane` decides which local surface receives a key (view vs
//! composer), while higher-level intent such as "interrupt" or "quit" is decided by the parent
//! widget (`ChatWidget`). This split matters for Ctrl+C/Ctrl+D: the bottom pane gives the active
//! view the first chance to consume Ctrl+C (typically to dismiss itself), and `ChatWidget` may
//! treat an unhandled Ctrl+C as an interrupt or as the first press of a double-press quit
//! shortcut.
//!
//! Some UI is time-based rather than input-based, such as the transient "press again to quit"
//! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle.
use std::path::PathBuf;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::queued_user_messages::QueuedUserMessages;
use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::render::renderable::FlexRenderable;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableItem;
@@ -46,6 +62,20 @@ mod textarea;
mod unified_exec_footer;
pub(crate) use feedback_view::FeedbackNoteView;
/// How long the "press again to quit" hint stays visible.
///
/// This is shared between:
/// - `ChatWidget`: arming the double-press quit shortcut.
/// - `BottomPane`/`ChatComposer`: rendering and expiring the footer hint.
///
/// Keeping a single value ensures Ctrl+C and Ctrl+D behave identically.
pub(crate) const QUIT_SHORTCUT_TIMEOUT: Duration = Duration::from_secs(1);
/// The result of offering a cancellation key to a bottom-pane surface.
///
/// This is primarily used for Ctrl+C routing: active views can consume the key to dismiss
/// themselves, and the caller can decide what higher-level action (if any) to take when the key is
/// not handled locally.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CancellationEvent {
Handled,
@@ -63,6 +93,10 @@ pub(crate) use list_selection_view::SelectionAction;
pub(crate) use list_selection_view::SelectionItem;
/// Pane displayed in the lower half of the chat UI.
///
/// This is the owning container for the prompt input (`ChatComposer`) and the view stack
/// (`BottomPaneView`). It performs local input routing and renders time-based hints, while leaving
/// process-level decisions (quit, interrupt, shutdown) to `ChatWidget`.
pub(crate) struct BottomPane {
/// Composer is retained even when a BottomPaneView is displayed so the
/// input state is retained when the view is closed.
@@ -76,7 +110,6 @@ pub(crate) struct BottomPane {
has_input_focus: bool,
is_task_running: bool,
ctrl_c_quit_hint: bool,
esc_backtrack_hint: bool,
animations_enabled: bool,
@@ -129,7 +162,6 @@ impl BottomPane {
frame_requester,
has_input_focus,
is_task_running: false,
ctrl_c_quit_hint: false,
status: None,
unified_exec_footer: UnifiedExecFooter::new(),
queued_user_messages: QueuedUserMessages::new(),
@@ -218,8 +250,14 @@ impl BottomPane {
}
}
/// Handle Ctrl-C in the bottom pane. If a modal view is active it gets a
/// chance to consume the event (e.g. to dismiss itself).
/// Handles a Ctrl+C press within the bottom pane.
///
/// An active modal view is given the first chance to consume the key (typically to dismiss
/// itself). If no view is active, Ctrl+C clears draft composer input.
///
/// This method may show the quit shortcut hint as a user-visible acknowledgement that Ctrl+C
/// was received, but it does not decide whether the process should exit; `ChatWidget` owns the
/// quit/interrupt state machine and uses the result to decide what happens next.
pub(crate) fn on_ctrl_c(&mut self) -> CancellationEvent {
if let Some(view) = self.view_stack.last_mut() {
let event = view.on_ctrl_c();
@@ -228,7 +266,7 @@ impl BottomPane {
self.view_stack.pop();
self.on_active_view_complete();
}
self.show_ctrl_c_quit_hint();
self.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')));
}
event
} else if self.composer_is_empty() {
@@ -236,7 +274,7 @@ impl BottomPane {
} else {
self.view_stack.pop();
self.clear_composer_for_ctrl_c();
self.show_ctrl_c_quit_hint();
self.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')));
CancellationEvent::Handled
}
}
@@ -314,25 +352,41 @@ impl BottomPane {
}
}
pub(crate) fn show_ctrl_c_quit_hint(&mut self) {
self.ctrl_c_quit_hint = true;
/// Show the transient "press again to quit" hint for `key`.
///
/// `ChatWidget` owns the quit shortcut state machine (it decides when quit is
/// allowed), while the bottom pane owns rendering. We also schedule a redraw
/// after [`QUIT_SHORTCUT_TIMEOUT`] so the hint disappears even if the user
/// stops typing and no other events trigger a draw.
pub(crate) fn show_quit_shortcut_hint(&mut self, key: KeyBinding) {
self.composer
.set_ctrl_c_quit_hint(true, self.has_input_focus);
.show_quit_shortcut_hint(key, self.has_input_focus);
let frame_requester = self.frame_requester.clone();
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
tokio::time::sleep(QUIT_SHORTCUT_TIMEOUT).await;
frame_requester.schedule_frame();
});
} else {
// In tests (and other non-Tokio contexts), fall back to a thread so
// the hint can still expire without requiring an explicit draw.
std::thread::spawn(move || {
std::thread::sleep(QUIT_SHORTCUT_TIMEOUT);
frame_requester.schedule_frame();
});
}
self.request_redraw();
}
pub(crate) fn clear_ctrl_c_quit_hint(&mut self) {
if self.ctrl_c_quit_hint {
self.ctrl_c_quit_hint = false;
self.composer
.set_ctrl_c_quit_hint(false, self.has_input_focus);
self.request_redraw();
}
/// Clear the "press again to quit" hint immediately.
pub(crate) fn clear_quit_shortcut_hint(&mut self) {
self.composer.clear_quit_shortcut_hint(self.has_input_focus);
self.request_redraw();
}
#[cfg(test)]
pub(crate) fn ctrl_c_quit_hint_visible(&self) -> bool {
self.ctrl_c_quit_hint
pub(crate) fn quit_shortcut_hint_visible(&self) -> bool {
self.composer.quit_shortcut_hint_visible()
}
#[cfg(test)]
@@ -463,6 +517,15 @@ impl BottomPane {
self.view_stack.is_empty() && !self.composer.popup_active()
}
/// Returns true when the bottom pane has no active modal view and no active composer popup.
///
/// This is the UI-level definition of "no modal/popup is active" for key routing decisions.
/// It intentionally does not include task state, since some actions are safe while a task is
/// running and some are not.
pub(crate) fn no_modal_or_popup_active(&self) -> bool {
self.can_launch_external_editor()
}
pub(crate) fn show_view(&mut self, view: Box<dyn BottomPaneView>) {
self.push_view(view);
}
@@ -648,7 +711,7 @@ mod tests {
});
pane.push_approval_request(exec_request(), &features);
assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c());
assert!(pane.ctrl_c_quit_hint_visible());
assert!(pane.quit_shortcut_hint_visible());
assert_eq!(CancellationEvent::NotHandled, pane.on_ctrl_c());
}

View File

@@ -10,4 +10,4 @@ expression: terminal.backend()
" "
" "
" "
" ctrl + c again to interrupt "
" ctrl + c again to quit "

View File

@@ -2,4 +2,4 @@
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
" ctrl + c again to interrupt "
" ctrl + c again to quit "

View File

@@ -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 {

View File

@@ -38,6 +38,7 @@ pub(crate) fn spawn_agent(
msg: EventMsg::Error(err.to_error_event(None)),
}));
app_event_tx_clone.send(AppEvent::FatalExitRequest(message));
tracing::error!("failed to initialize codex: {err}");
return;
}
};

View File

@@ -6,6 +6,7 @@
use super::*;
use crate::app_event::AppEvent;
use crate::app_event::ExitMode;
use crate::app_event_sender::AppEventSender;
use crate::test_backend::VT100Backend;
use crate::tui::FrameRequester;
@@ -430,6 +431,8 @@ async fn make_chatwidget_manual(
queued_user_messages: VecDeque::new(),
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,
@@ -1104,19 +1107,58 @@ async fn streaming_final_answer_keeps_task_running_state() {
Ok(Op::Interrupt) => {}
other => panic!("expected Op::Interrupt, got {other:?}"),
}
assert!(chat.bottom_pane.ctrl_c_quit_hint_visible());
assert!(chat.bottom_pane.quit_shortcut_hint_visible());
}
#[tokio::test]
async fn ctrl_c_shutdown_ignores_caps_lock() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.handle_key_event(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::CONTROL));
match op_rx.try_recv() {
Ok(Op::Shutdown) => {}
other => panic!("expected Op::Shutdown, got {other:?}"),
}
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));
}
#[tokio::test]
async fn ctrl_d_double_press_quits_without_prompt() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL));
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
let width = 100;
let height = chat.desired_height(width);
let mut terminal =
ratatui::Terminal::new(VT100Backend::new(width, height)).expect("create terminal");
terminal.set_viewport_area(Rect::new(0, 0, width, height));
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw after ctrl+d");
assert!(
terminal
.backend()
.vt100()
.screen()
.contents()
.contains("ctrl + d again to quit")
);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL));
assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));
}
#[tokio::test]
async fn ctrl_d_with_modal_open_does_not_quit() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.open_approvals_popup();
chat.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL));
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
}
#[tokio::test]
@@ -1135,7 +1177,7 @@ async fn ctrl_c_cleared_prompt_is_recoverable_via_history() {
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
assert!(chat.bottom_pane.composer_text().is_empty());
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
assert!(chat.bottom_pane.ctrl_c_quit_hint_visible());
assert!(chat.bottom_pane.quit_shortcut_hint_visible());
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
let restored_text = chat.bottom_pane.composer_text();
@@ -1144,7 +1186,7 @@ async fn ctrl_c_cleared_prompt_is_recoverable_via_history() {
"expected placeholder {placeholder:?} after history recall"
);
assert!(restored_text.starts_with("draft message "));
assert!(!chat.bottom_pane.ctrl_c_quit_hint_visible());
assert!(!chat.bottom_pane.quit_shortcut_hint_visible());
let images = chat.bottom_pane.take_recent_submission_images();
assert!(
@@ -1487,7 +1529,7 @@ async fn slash_quit_requests_exit() {
chat.dispatch_command(SlashCommand::Quit);
assert_matches!(rx.try_recv(), Ok(AppEvent::ExitRequest));
assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));
}
#[tokio::test]
@@ -1496,7 +1538,7 @@ async fn slash_exit_requests_exit() {
chat.dispatch_command(SlashCommand::Exit);
assert_matches!(rx.try_recv(), Ok(AppEvent::ExitRequest));
assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));
}
#[tokio::test]