feat(tui): two-tap Ctrl+C quit confirmation with 2s countdown

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<Instant>` 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
This commit is contained in:
Hunter Bown
2026-04-26 17:32:24 -05:00
parent ec98a64711
commit ec3470fd07
2 changed files with 176 additions and 6 deletions
+137 -1
View File
@@ -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<Instant>,
/// 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<Instant>,
}
/// 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<String>,
@@ -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());
+39 -5
View File
@@ -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);