From ec3470fd0744a1393e900d67bf07584b86697b34 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sun, 26 Apr 2026 17:32:24 -0500 Subject: [PATCH] feat(tui): two-tap Ctrl+C quit confirmation with 2s countdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a codex-rs-style "Press Ctrl+C again to quit" prompt so a single stray Ctrl+C in idle state no longer kills the session. State is held on `App::quit_armed_until: Option` and threaded through three small helpers (`arm_quit`, `quit_is_armed`, `disarm_quit`, `tick_quit_armed`). The redraw loop calls `tick_quit_armed` each pass and caps `event::poll` at the deadline so the prompt expires on time even when no input arrives. Behavior: - First Ctrl+C in idle state: arm a 2s window; footer shows "Press Ctrl+C again to quit" (warning color) overriding any active status toast. - Second Ctrl+C inside the window: clean shutdown via `Op::Shutdown`. - Ctrl+C 3 seconds later: re-arms instead of quitting. - Ctrl+C while a turn is in flight (`is_loading`): unchanged — still cancels the turn, and explicitly disarms the quit prompt. Tests cover the timer lifecycle: default-disarmed, arm-sets-2s-window, disarm-clears-and-redraws, tick-no-op-within-window, tick-clears-after- expiry, and re-arm-after-expiry-starts-fresh-window. Fixes #90 --- crates/tui/src/tui/app.rs | 138 +++++++++++++++++++++++++++++++++++++- crates/tui/src/tui/ui.rs | 44 ++++++++++-- 2 files changed, 176 insertions(+), 6 deletions(-) diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 2661c3d6..d82da2db 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -2,7 +2,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::path::{Path, PathBuf}; -use std::time::Instant; +use std::time::{Duration, Instant}; use ratatui::layout::Rect; use serde_json::Value; @@ -586,6 +586,13 @@ pub struct App { pub coherence_state: CoherenceState, /// Timestamp of the last user message send (for brief visual feedback). pub last_send_at: Option, + /// Two-tap quit confirmation. When set, a prior Ctrl+C in idle state has + /// armed the quit shortcut; a second Ctrl+C before this `Instant` exits + /// the app, while expiry silently re-arms the prompt for next time. + /// Stays `None` while a turn is in flight or a modal/picker is open so + /// Ctrl+C keeps its current "interrupt this turn" semantics in those + /// states. See [`App::arm_quit`] / [`App::quit_is_armed`]. + pub quit_armed_until: Option, } /// Message queued while the engine is busy. @@ -865,6 +872,7 @@ impl App { user_scrolled_during_stream: false, coherence_state: CoherenceState::default(), last_send_at: None, + quit_armed_until: None, } } @@ -1287,6 +1295,48 @@ impl App { self.needs_redraw = true; } + /// How long the "press Ctrl+C again to quit" prompt stays armed before it + /// silently expires. + pub const QUIT_CONFIRMATION_WINDOW: Duration = Duration::from_secs(2); + + /// Arm the quit confirmation timer. The next Ctrl+C within + /// [`QUIT_CONFIRMATION_WINDOW`] should exit the app cleanly. Call this only + /// from idle state — while a turn is in flight or a modal is open Ctrl+C + /// retains its existing "interrupt this turn" / "close modal" semantics. + pub fn arm_quit(&mut self) { + self.quit_armed_until = Some(Instant::now() + Self::QUIT_CONFIRMATION_WINDOW); + self.needs_redraw = true; + } + + /// Whether the quit timer is currently armed (i.e. a prior Ctrl+C set it + /// and it hasn't expired yet). + pub fn quit_is_armed(&self) -> bool { + self.quit_armed_until + .map(|deadline| Instant::now() < deadline) + .unwrap_or(false) + } + + /// Clear the quit-armed timer. Call when expiry is detected on a tick or + /// when the user takes any other action that should disarm the prompt + /// (typing, sending a message, etc.). + pub fn disarm_quit(&mut self) { + if self.quit_armed_until.is_some() { + self.quit_armed_until = None; + self.needs_redraw = true; + } + } + + /// Tick called from the redraw loop. Lets time-based UI state (the + /// quit-armed prompt) expire even when no input event is delivered. + pub fn tick_quit_armed(&mut self) { + if let Some(deadline) = self.quit_armed_until + && Instant::now() >= deadline + { + self.quit_armed_until = None; + self.needs_redraw = true; + } + } + pub fn set_sticky_status( &mut self, text: impl Into, @@ -2215,6 +2265,92 @@ mod tests { assert!(empty.input.is_empty()); } + // ---- Issue #90: quit confirmation timeout ---- + + #[test] + fn quit_is_not_armed_by_default() { + let app = App::new(test_options(false), &Config::default()); + assert!(!app.quit_is_armed()); + assert!(app.quit_armed_until.is_none()); + } + + #[test] + fn arm_quit_sets_two_second_window() { + let mut app = App::new(test_options(false), &Config::default()); + app.arm_quit(); + assert!(app.quit_is_armed()); + let deadline = app.quit_armed_until.expect("deadline set"); + let remaining = deadline.saturating_duration_since(Instant::now()); + // Allow a generous margin for slow CI machines: 1.5s..=2.0s. + assert!( + remaining >= Duration::from_millis(1500) && remaining <= Duration::from_secs(2), + "expected ~2s window, got {remaining:?}", + ); + assert!(app.needs_redraw, "armed prompt should request a redraw"); + } + + #[test] + fn disarm_quit_clears_the_timer() { + let mut app = App::new(test_options(false), &Config::default()); + app.arm_quit(); + app.needs_redraw = false; + app.disarm_quit(); + assert!(!app.quit_is_armed()); + assert!(app.quit_armed_until.is_none()); + assert!(app.needs_redraw, "disarming should request a redraw"); + } + + #[test] + fn disarm_quit_when_not_armed_is_a_noop() { + let mut app = App::new(test_options(false), &Config::default()); + app.needs_redraw = false; + app.disarm_quit(); + assert!(!app.needs_redraw, "no redraw when nothing changed"); + } + + #[test] + fn quit_armed_expires_after_window() { + let mut app = App::new(test_options(false), &Config::default()); + // Pin the deadline in the past to simulate a stale timer. + app.quit_armed_until = Some(Instant::now() - Duration::from_millis(10)); + assert!( + !app.quit_is_armed(), + "expired timer must not count as armed" + ); + + app.needs_redraw = false; + app.tick_quit_armed(); + assert!(app.quit_armed_until.is_none(), "tick clears expired timer"); + assert!( + app.needs_redraw, + "expiry triggers a redraw to repaint footer" + ); + } + + #[test] + fn quit_armed_tick_is_noop_within_window() { + let mut app = App::new(test_options(false), &Config::default()); + app.arm_quit(); + app.needs_redraw = false; + app.tick_quit_armed(); + assert!( + app.quit_is_armed(), + "tick within window keeps the timer armed" + ); + assert!(!app.needs_redraw, "no redraw when nothing changed"); + } + + #[test] + fn re_arming_after_expiry_starts_a_fresh_window() { + let mut app = App::new(test_options(false), &Config::default()); + app.quit_armed_until = Some(Instant::now() - Duration::from_secs(5)); + app.tick_quit_armed(); + assert!(app.quit_armed_until.is_none()); + app.arm_quit(); + let deadline = app.quit_armed_until.expect("re-armed"); + assert!(deadline > Instant::now(), "fresh deadline in the future"); + } + #[test] fn kill_and_yank_handle_multibyte_utf8() { let mut app = App::new(test_options(false), &Config::default()); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index bd8a79af..e54d761a 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -936,6 +936,9 @@ async fn run_event_loop( let now = Instant::now(); app.flush_paste_burst_if_due(now); app.sync_status_message_to_toasts(); + // Expire the "Press Ctrl+C again to quit" prompt silently after its + // window. Triggers a redraw if the prompt was visible. + app.tick_quit_armed(); let allow_workspace_context_refresh = !app.is_loading && !has_running_agents && !app.is_compacting; refresh_workspace_context_if_needed(app, now, allow_workspace_context_refresh); @@ -953,6 +956,12 @@ async fn run_event_loop( if let Some(until_flush) = app.paste_burst.next_flush_delay(now) { poll_timeout = poll_timeout.min(until_flush); } + // While the quit-confirmation prompt is armed, ensure we wake up to + // expire it on time even if no input event arrives. + if let Some(deadline) = app.quit_armed_until { + let remaining = deadline.saturating_duration_since(now); + poll_timeout = poll_timeout.min(remaining.max(Duration::from_millis(50))); + } if event::poll(poll_timeout)? { let evt = event::read()?; app.needs_redraw = true; @@ -1327,15 +1336,26 @@ async fn run_event_loop( copy_active_selection(app); } KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - // Cancel current request or exit + // Three behaviors layered on Ctrl+C, in priority order: + // 1. While a turn is in flight, cancel it (unchanged). + // 2. Otherwise, on the first press, arm a 2-second + // "press Ctrl+C again to quit" prompt and stay + // running. + // 3. On the second press while still armed, exit cleanly. + // The prompt expires silently after the window so a + // stray Ctrl+C three seconds later re-arms instead of + // accidentally exiting. if app.is_loading { engine_handle.cancel(); app.is_loading = false; app.streaming_state.reset(); app.status_message = Some("Request cancelled".to_string()); - } else { + app.disarm_quit(); + } else if app.quit_is_armed() { let _ = engine_handle.send(Op::Shutdown).await; return Ok(()); + } else { + app.arm_quit(); } } KeyCode::Char('d') @@ -3178,9 +3198,23 @@ fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { // Pull in the toast first so we don't re-borrow `app` mutably mid-build, // then build the FooterProps once. The widget itself is a pure render — // it owns no `App` knowledge; all width-aware layout lives in the widget. - let toast = app.active_status_toast().map(|toast| FooterToast { - text: toast.text, - color: status_color(toast.level), + // + // The quit-confirmation prompt takes precedence over normal status toasts + // because it represents a transient instruction the user must respond to + // within ~2s. Mirrors codex-rs's `FooterMode::QuitShortcutReminder`. + let quit_prompt = if app.quit_is_armed() { + Some(FooterToast { + text: "Press Ctrl+C again to quit".to_string(), + color: palette::STATUS_WARNING, + }) + } else { + None + }; + let toast = quit_prompt.or_else(|| { + app.active_status_toast().map(|toast| FooterToast { + text: toast.text, + color: status_color(toast.level), + }) }); let (state_label, state_color) = footer_state_label(app);