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:
+137
-1
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user