diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index a24c09e36b7..0def0440a80 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 d8df24e6556..97b0176c5ea 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")] @@ -855,9 +856,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 42f96d9098b..6ce5d2cae54 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 27e191d8ef6..f600263acbb 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -56,7 +56,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; @@ -167,7 +168,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, @@ -221,7 +223,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, @@ -566,16 +569,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); @@ -1475,10 +1499,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 @@ -1767,7 +1788,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 @@ -1779,6 +1800,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, @@ -1789,8 +1811,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, } @@ -2331,16 +2358,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 3fb04c39351..42c0392a611 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 f505b0271ee..34aa2f9587b 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 49ffb0d4c8f..7ecc5bba719 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 9979372a1b9..31a1b743b8e 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 97bab98756c..f90a4a3d642 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; @@ -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; @@ -118,6 +120,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; @@ -135,6 +138,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; @@ -335,12 +340,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, @@ -407,6 +418,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. @@ -643,7 +662,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); @@ -1142,7 +1163,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) { @@ -1569,6 +1590,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, @@ -1660,6 +1683,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, @@ -1688,6 +1713,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, @@ -1700,7 +1738,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; } _ => {} } @@ -1911,7 +1951,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( @@ -1920,7 +1960,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)); @@ -2397,8 +2437,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) { @@ -3785,19 +3838,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(); - self.submit_op(Op::Interrupt); + 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.submit_op(Op::Shutdown); + self.arm_quit_shortcut(key); + + if self.is_cancellable_work_active() { + self.submit_op(Op::Interrupt); + } + } + + /// 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; + } + + 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 21ed92d0ee6..b24233c2af8 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 0c5f6b852d4..a44a97a0fa1 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; @@ -429,6 +430,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, @@ -1103,19 +1106,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] @@ -1134,7 +1176,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(); @@ -1143,7 +1185,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!( @@ -1525,7 +1567,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] @@ -1534,7 +1576,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 4db9e572fd2..72d0fc40f49 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 afedcded242..5c9d6e355c3 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")] @@ -1642,9 +1643,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 59cc18047bd..f24ca1e07cd 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 df8016a168c..553b8986f14 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -169,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, @@ -228,7 +229,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, @@ -499,16 +501,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); @@ -1408,10 +1431,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 @@ -1705,7 +1725,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 @@ -1717,6 +1737,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, @@ -1732,8 +1753,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, } @@ -2305,16 +2331,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 c543ab6ee35..bdfb5c8dfc4 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 bbf5e8849d9..c67129c5b59 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 d323fda148b..d9395f2b055 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 98bc87b38ee..157853e73d5 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 02855551a02..f7d0e5e2fbd 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; @@ -106,6 +107,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; @@ -115,6 +117,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; @@ -131,6 +134,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; @@ -303,12 +308,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, @@ -374,6 +385,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. @@ -609,7 +628,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); @@ -997,7 +1018,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) { @@ -1428,6 +1449,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, @@ -1517,6 +1540,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, @@ -1544,6 +1569,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, @@ -1572,7 +1610,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; } _ => {} } @@ -1729,7 +1769,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( @@ -1738,7 +1778,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)); @@ -2175,8 +2215,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) { @@ -3488,19 +3541,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(); - self.submit_op(Op::Interrupt); + 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.submit_op(Op::Shutdown); + self.arm_quit_shortcut(key); + + if self.is_cancellable_work_active() { + self.submit_op(Op::Interrupt); + } + } + + /// 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; + } + + 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 24c40365302..0cfda7f3429 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 41b27600091..f586c4786ab 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 4db9e572fd2..72d0fc40f49 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 0b5fecf6e7a..87945b25a1a 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 00000000000..1163e2f0ea9 --- /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.