From 4283a7432bbdc124c4e9851733bdc7c9205a2a41 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Wed, 14 Jan 2026 09:42:52 -0800 Subject: [PATCH] tui: double-press Ctrl+C/Ctrl+D to quit (#8936) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 + 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: Screenshot 2026-01-13 at 1 05 28 PM --- codex-rs/core/src/config/edit.rs | 1 + codex-rs/tui/src/app.rs | 10 +- codex-rs/tui/src/app_event.rs | 35 +++- codex-rs/tui/src/bottom_pane/chat_composer.rs | 67 +++++--- codex-rs/tui/src/bottom_pane/footer.rs | 73 ++++++--- codex-rs/tui/src/bottom_pane/mod.rs | 103 +++++++++--- ...__tests__footer_mode_ctrl_c_interrupt.snap | 2 +- ...er__tests__footer_ctrl_c_quit_running.snap | 2 +- codex-rs/tui/src/chatwidget.rs | 155 ++++++++++++++++-- codex-rs/tui/src/chatwidget/agent.rs | 1 + codex-rs/tui/src/chatwidget/tests.rs | 62 +++++-- codex-rs/tui/tests/fixtures/oss-story.jsonl | 2 +- codex-rs/tui2/src/app.rs | 10 +- codex-rs/tui2/src/app_event.rs | 35 +++- .../tui2/src/bottom_pane/chat_composer.rs | 64 +++++--- codex-rs/tui2/src/bottom_pane/footer.rs | 75 ++++++--- codex-rs/tui2/src/bottom_pane/mod.rs | 108 +++++++++--- ...__tests__footer_mode_ctrl_c_interrupt.snap | 2 +- ...er__tests__footer_ctrl_c_quit_running.snap | 2 +- codex-rs/tui2/src/chatwidget.rs | 155 ++++++++++++++++-- codex-rs/tui2/src/chatwidget/agent.rs | 1 + codex-rs/tui2/src/chatwidget/tests.rs | 62 +++++-- codex-rs/tui2/tests/fixtures/oss-story.jsonl | 2 +- docs/config.md | 6 + docs/exit-confirmation-prompt-design.md | 96 +++++++++++ 25 files changed, 932 insertions(+), 199 deletions(-) create mode 100644 docs/exit-confirmation-prompt-design.md diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index a24c09e36..0def0440a 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -1007,6 +1007,7 @@ hide_rate_limit_model_nudge = true "#; assert_eq!(contents, expected); } + #[test] fn blocking_set_hide_gpt5_1_migration_prompt_preserves_table() { let tmp = tempdir().expect("tmpdir"); diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 560e6adc4..a1927d817 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -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))); } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 42f96d909..6ce5d2cae 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -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, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index f5c2bd269..41d8217dc 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -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, + quit_shortcut_key: KeyBinding, esc_backtrack_hint: bool, use_shift_enter_hint: bool, dismissed_file_popup_token: Option, @@ -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)); }); diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 3fb04c393..42c0392a6 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -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, pub(crate) context_window_used_tokens: Option, } +/// 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> { // 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> { } } -#[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, }, diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index f505b0271..34aa2f958 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -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) { 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()); } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap index 49ffb0d4c..7ecc5bba7 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap @@ -10,4 +10,4 @@ expression: terminal.backend() " " " " " " -" ctrl + c again to interrupt " +" ctrl + c again to quit " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap index 9979372a1..31a1b743b 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap @@ -2,4 +2,4 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -" ctrl + c again to interrupt " +" ctrl + c again to quit " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 984a31969..f1f532a62 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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, @@ -431,6 +442,14 @@ pub(crate) struct ChatWidget { queued_user_messages: VecDeque, // Pending notification to show when unfocused on next Draw pending_notification: Option, + /// 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, + /// 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, // 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 { diff --git a/codex-rs/tui/src/chatwidget/agent.rs b/codex-rs/tui/src/chatwidget/agent.rs index 21ed92d0e..b24233c2a 100644 --- a/codex-rs/tui/src/chatwidget/agent.rs +++ b/codex-rs/tui/src/chatwidget/agent.rs @@ -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; } }; diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 96ec39f50..3a0b119ae 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -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] diff --git a/codex-rs/tui/tests/fixtures/oss-story.jsonl b/codex-rs/tui/tests/fixtures/oss-story.jsonl index 4db9e572f..72d0fc40f 100644 --- a/codex-rs/tui/tests/fixtures/oss-story.jsonl +++ b/codex-rs/tui/tests/fixtures/oss-story.jsonl @@ -8037,5 +8037,5 @@ {"ts":"2025-08-10T03:48:49.926Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] Shutting down Codex instance"} {"ts":"2025-08-10T03:48:49.927Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] Aborting existing session"} {"ts":"2025-08-10T03:48:49.927Z","dir":"to_tui","kind":"codex_event","payload":{"id":"7","msg":{"type":"shutdown_complete"}}} -{"ts":"2025-08-10T03:48:49.927Z","dir":"to_tui","kind":"app_event","variant":"ExitRequest"} +{"ts":"2025-08-10T03:48:49.927Z","dir":"to_tui","kind":"app_event","variant":"Exit"} {"ts":"2025-08-10T03:48:49.927Z","dir":"meta","kind":"session_end"} diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index 4009db530..f33406b25 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -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")] @@ -1645,9 +1646,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))); } diff --git a/codex-rs/tui2/src/app_event.rs b/codex-rs/tui2/src/app_event.rs index 59cc18047..f24ca1e07 100644 --- a/codex-rs/tui2/src/app_event.rs +++ b/codex-rs/tui2/src/app_event.rs @@ -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; @@ -40,8 +50,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), @@ -206,6 +221,22 @@ pub(crate) enum AppEvent { }, } +/// 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, diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index b8648b1eb..728f3c1bf 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -170,7 +170,8 @@ pub(crate) struct ChatComposer { active_popup: ActivePopup, app_event_tx: AppEventSender, history: ChatComposerHistory, - ctrl_c_quit_hint: bool, + quit_shortcut_expires_at: Option, + quit_shortcut_key: KeyBinding, esc_backtrack_hint: bool, use_shift_enter_hint: bool, dismissed_file_popup_token: Option, @@ -229,7 +230,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, @@ -511,16 +513,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); @@ -1431,10 +1454,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 @@ -1741,7 +1761,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 @@ -1753,6 +1773,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, @@ -1768,8 +1789,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, } @@ -2341,16 +2367,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)); }); diff --git a/codex-rs/tui2/src/bottom_pane/footer.rs b/codex-rs/tui2/src/bottom_pane/footer.rs index c543ab6ee..bdfb5c8df 100644 --- a/codex-rs/tui2/src/bottom_pane/footer.rs +++ b/codex-rs/tui2/src/bottom_pane/footer.rs @@ -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; @@ -15,6 +25,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, @@ -22,6 +38,10 @@ 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, pub(crate) context_window_used_tokens: Option, pub(crate) transcript_scrolled: bool, @@ -31,9 +51,14 @@ pub(crate) struct FooterProps { pub(crate) transcript_copy_feedback: Option, } +/// 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, @@ -41,12 +66,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, } } @@ -63,7 +90,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, } @@ -103,9 +130,9 @@ fn footer_lines(props: FooterProps) -> Vec> { // the shortcut hint is hidden). Hide it only for the multi-line // ShortcutOverlay. let mut lines = 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, @@ -170,11 +197,6 @@ fn footer_lines(props: FooterProps) -> Vec> { lines } -#[derive(Clone, Copy, Debug)] -struct CtrlCReminderState { - is_task_running: bool, -} - #[derive(Clone, Copy, Debug)] struct ShortcutsState { use_shift_enter_hint: bool, @@ -182,17 +204,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> { @@ -518,6 +531,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, transcript_scrolled: false, @@ -536,6 +550,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, transcript_scrolled: true, @@ -554,6 +569,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, transcript_scrolled: false, @@ -567,11 +583,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, transcript_scrolled: false, @@ -585,11 +602,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, transcript_scrolled: false, @@ -608,6 +626,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, transcript_scrolled: false, @@ -626,6 +645,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, transcript_scrolled: false, @@ -644,6 +664,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, transcript_scrolled: false, @@ -662,6 +683,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), transcript_scrolled: false, @@ -680,6 +702,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, transcript_scrolled: false, @@ -698,6 +721,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, transcript_scrolled: false, @@ -716,6 +740,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, transcript_scrolled: false, diff --git a/codex-rs/tui2/src/bottom_pane/mod.rs b/codex-rs/tui2/src/bottom_pane/mod.rs index bbf5e8849..c67129c5b 100644 --- a/codex-rs/tui2/src/bottom_pane/mod.rs +++ b/codex-rs/tui2/src/bottom_pane/mod.rs @@ -1,8 +1,24 @@ -//! 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::key_hint; +use crate::key_hint::KeyBinding; use crate::render::renderable::FlexRenderable; use crate::render::renderable::Renderable; use crate::render::renderable::RenderableItem; @@ -43,6 +59,20 @@ mod selection_popup_common; mod textarea; 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, @@ -58,6 +88,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. @@ -71,7 +105,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, @@ -122,7 +155,6 @@ impl BottomPane { frame_requester, has_input_focus, is_task_running: false, - ctrl_c_quit_hint: false, status: None, queued_user_messages: QueuedUserMessages::new(), esc_backtrack_hint: false, @@ -210,8 +242,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(); @@ -220,7 +258,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() { @@ -228,7 +266,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 } } @@ -292,25 +330,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)] @@ -450,6 +504,20 @@ impl BottomPane { !self.is_task_running && self.view_stack.is_empty() && !self.composer.popup_active() } + /// Return true when no popups or modal views are active, regardless of task state. + pub(crate) fn can_launch_external_editor(&self) -> bool { + 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) { self.push_view(view); } @@ -629,7 +697,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()); } diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap index d323fda14..d9395f2b0 100644 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap @@ -10,4 +10,4 @@ expression: terminal.backend() " " " " " " -" ctrl + c again to interrupt " +" ctrl + c again to quit " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap index 98bc87b38..157853e73 100644 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap @@ -2,4 +2,4 @@ source: tui2/src/bottom_pane/footer.rs expression: terminal.backend() --- -" ctrl + c again to interrupt " +" ctrl + c again to quit " diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index f5f7eef2a..9ebf17882 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -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; @@ -107,6 +108,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; @@ -116,6 +118,7 @@ use crate::bottom_pane::BottomPane; use crate::bottom_pane::BottomPaneParams; use crate::bottom_pane::CancellationEvent; 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; @@ -132,6 +135,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; @@ -304,12 +309,18 @@ enum RateLimitSwitchPromptState { Shown, } -/// 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, @@ -375,6 +386,14 @@ pub(crate) struct ChatWidget { queued_user_messages: VecDeque, // Pending notification to show when unfocused on next Draw pending_notification: Option, + /// 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, + /// 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, // Simple review mode flag; used to adjust layout and banners. is_review_mode: bool, // Snapshot of token usage to restore after review mode exits. @@ -610,7 +629,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); @@ -1006,7 +1027,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) { @@ -1437,6 +1458,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, @@ -1526,6 +1549,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, @@ -1553,6 +1578,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, @@ -1581,7 +1619,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; } _ => {} } @@ -1738,7 +1778,7 @@ impl ChatWidget { }; } SlashCommand::Quit | SlashCommand::Exit => { - self.request_exit(); + self.request_quit_without_confirmation(); } SlashCommand::Logout => { if let Err(e) = codex_core::auth::logout( @@ -1747,7 +1787,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)); @@ -2184,8 +2224,21 @@ impl ChatWidget { self.needs_final_message_separator = false; } - 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) { @@ -3497,19 +3550,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 { diff --git a/codex-rs/tui2/src/chatwidget/agent.rs b/codex-rs/tui2/src/chatwidget/agent.rs index 24c403653..0cfda7f34 100644 --- a/codex-rs/tui2/src/chatwidget/agent.rs +++ b/codex-rs/tui2/src/chatwidget/agent.rs @@ -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; } }; diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index 41b276000..f586c4786 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -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; @@ -417,6 +418,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, @@ -1054,19 +1057,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] @@ -1085,7 +1127,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(); @@ -1094,7 +1136,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!( @@ -1291,7 +1333,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] @@ -1300,7 +1342,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] diff --git a/codex-rs/tui2/tests/fixtures/oss-story.jsonl b/codex-rs/tui2/tests/fixtures/oss-story.jsonl index 4db9e572f..72d0fc40f 100644 --- a/codex-rs/tui2/tests/fixtures/oss-story.jsonl +++ b/codex-rs/tui2/tests/fixtures/oss-story.jsonl @@ -8037,5 +8037,5 @@ {"ts":"2025-08-10T03:48:49.926Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] Shutting down Codex instance"} {"ts":"2025-08-10T03:48:49.927Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] Aborting existing session"} {"ts":"2025-08-10T03:48:49.927Z","dir":"to_tui","kind":"codex_event","payload":{"id":"7","msg":{"type":"shutdown_complete"}}} -{"ts":"2025-08-10T03:48:49.927Z","dir":"to_tui","kind":"app_event","variant":"ExitRequest"} +{"ts":"2025-08-10T03:48:49.927Z","dir":"to_tui","kind":"app_event","variant":"Exit"} {"ts":"2025-08-10T03:48:49.927Z","dir":"meta","kind":"session_end"} diff --git a/docs/config.md b/docs/config.md index 0b5fecf6e..87945b25a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -21,3 +21,9 @@ Codex can run a notification hook when the agent finishes a turn. See the config ## JSON Schema The generated JSON Schema for `config.toml` lives at `codex-rs/core/config.schema.json`. + +## Notices + +Codex stores "do not show again" flags for some UI prompts under the `[notice]` table. + +Ctrl+C/Ctrl+D quitting uses a ~1 second double-press hint (`ctrl + c again to quit`). diff --git a/docs/exit-confirmation-prompt-design.md b/docs/exit-confirmation-prompt-design.md new file mode 100644 index 000000000..1163e2f0e --- /dev/null +++ b/docs/exit-confirmation-prompt-design.md @@ -0,0 +1,96 @@ +# 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.