diff --git a/CHANGELOG.md b/CHANGELOG.md index 388206ffb..7a5316f92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Changed + +- `zeph-tui`: unified `Ctrl+C` semantics in the TUI (issue #6646). `Ctrl+C` now cancels the + current agent turn immediately when the agent is busy (moved from `Esc`, which no longer + cancels anything in Normal mode). When idle, a single `Ctrl+C` no longer quits outright — + it arms a ~500ms double-press window and shows `Press Ctrl+C again to exit` in the status + bar; a second `Ctrl+C` within that window quits, a later press re-arms instead. `q` and + `/quit` are unaffected. + ## [0.22.3] - 2026-07-22 ### Fixed diff --git a/crates/zeph-tui/src/app/action.rs b/crates/zeph-tui/src/app/action.rs index 22bd9ef73..00f2d22e9 100644 --- a/crates/zeph-tui/src/app/action.rs +++ b/crates/zeph-tui/src/app/action.rs @@ -100,6 +100,10 @@ pub(crate) enum Action { Quit, /// Cancel the current agent turn (Ctrl-C equivalent). CancelAgent, + /// First-stage quit request from `Ctrl+C` when the agent is idle. + /// + /// Arms a double-press window; a confirming press within it produces `Effect::Quit`. + RequestQuit, // ── Input mode ───────────────────────────────────────────────────────────── /// Transition the input box to Insert mode. diff --git a/crates/zeph-tui/src/app/keys.rs b/crates/zeph-tui/src/app/keys.rs index 27a2a650a..28d51fcaf 100644 --- a/crates/zeph-tui/src/app/keys.rs +++ b/crates/zeph-tui/src/app/keys.rs @@ -31,9 +31,14 @@ impl App { /// has no effect (e.g. an unrecognised key in a modal that ignores it). #[allow(clippy::too_many_lines)] fn decode_key(&self, key: KeyEvent) -> Option { - // Global: Ctrl-C always quits. + // Global: Ctrl-C cancels a busy agent turn immediately; when idle it arms a + // double-press quit window (see `Action::RequestQuit`, reducer.rs). if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { - return Some(Action::Quit); + return Some(if self.is_agent_busy() { + Action::CancelAgent + } else { + Action::RequestQuit + }); } // Help overlay: only '?' and Esc close it. @@ -784,7 +789,6 @@ impl App { return Some(a); } match key.code { - KeyCode::Esc if self.is_agent_busy() => Some(Action::CancelAgent), KeyCode::Char('q') => Some(Action::Quit), KeyCode::Char('H') => Some(Action::Dispatch(TuiCommand::SessionBrowser)), KeyCode::Char('i') => Some(Action::EnterInsert), diff --git a/crates/zeph-tui/src/app/mod.rs b/crates/zeph-tui/src/app/mod.rs index 750ed2f3f..558f149fe 100644 --- a/crates/zeph-tui/src/app/mod.rs +++ b/crates/zeph-tui/src/app/mod.rs @@ -488,6 +488,10 @@ pub struct App { /// so that the wave renderer stays purely deterministic. pub(crate) wave_tick: u64, + /// `anim_tick` captured on the first idle `Ctrl+C` press, arming the double-press + /// quit window (see [`crate::App::quit_hint_active`]). `None` when no window is armed. + pub(crate) pending_quit_tick: Option, + /// Timestamp of the last observed progress event (token chunk or status change). /// /// Initialized at the moment the agent transitions to busy, NOT at `App` construction diff --git a/crates/zeph-tui/src/app/reducer.rs b/crates/zeph-tui/src/app/reducer.rs index b67ceff91..2762294e4 100644 --- a/crates/zeph-tui/src/app/reducer.rs +++ b/crates/zeph-tui/src/app/reducer.rs @@ -11,6 +11,7 @@ use zeph_core::channel::ElicitationResponse; use super::action::{Action, CursorMove, ElicitationEdit, PaletteEdit, ScrollDir, VertDir}; +use super::state::CTRL_C_DOUBLE_PRESS_TICKS; use super::{App, ChatMessage, InputMode, MessageRole, Panel, format_security_report}; use crate::command::TuiCommand; use crate::file_picker::FilePickerState; @@ -172,8 +173,25 @@ pub(crate) fn reduce(app: &mut App, action: Action) -> Vec { if let Some(ref signal) = app.cancel_signal { signal.notify_waiters(); } + // A busy-turn cancel is not a quit-window press — clear any stale window + // armed before the turn started so a later idle Ctrl+C reads as a fresh + // first press instead of an accidental second one (#6646 M1). + app.pending_quit_tick = None; vec![] } + Action::RequestQuit => { + let now = app.anim_tick(); + match app.pending_quit_tick { + Some(t0) if now.saturating_sub(t0) <= CTRL_C_DOUBLE_PRESS_TICKS => { + app.pending_quit_tick = None; + vec![Effect::Quit] + } + _ => { + app.pending_quit_tick = Some(now); + vec![] + } + } + } // ── Input mode ────────────────────────────────────────────────────────── Action::EnterInsert => { @@ -880,7 +898,7 @@ pub(crate) fn reduce(app: &mut App, action: Action) -> Vec { TuiCommand::DaemonDisconnect => { app.push_system_message_pub( "There is no live daemon connection to tear down in this mode.\n\ - If you started with `--connect `, quit the TUI (q / Ctrl+C) to disconnect." + If you started with `--connect `, quit the TUI (q / press Ctrl+C twice) to disconnect." .to_owned(), ); return vec![]; diff --git a/crates/zeph-tui/src/app/state.rs b/crates/zeph-tui/src/app/state.rs index ecf323b73..b645af283 100644 --- a/crates/zeph-tui/src/app/state.rs +++ b/crates/zeph-tui/src/app/state.rs @@ -27,6 +27,12 @@ use super::{ /// TODO: wire to `config.tui.stall_threshold_secs` (deferred per #5096 v1 scope) const STALL_THRESHOLD: std::time::Duration = std::time::Duration::from_secs(10); +/// Ticks within which a second idle `Ctrl+C` press confirms quit. +/// +/// `~500ms` at the 100ms/tick `wave_tick` cadence. Shared between the reducer +/// (arms/consumes the window) and the status widget (renders the hint). +pub(crate) const CTRL_C_DOUBLE_PRESS_TICKS: u64 = 5; + impl App { /// Create a new `App` with the given I/O channels. /// @@ -107,6 +113,7 @@ impl App { collapsed_panels: [false; 4], motion: zeph_config::Motion::Full, wave_tick: 0, + pending_quit_tick: None, last_progress_at: Instant::now(), show_equalizer: true, delights: zeph_config::DelightsConfig::default(), @@ -1228,6 +1235,19 @@ impl App { self.wave_tick } + /// True while a `Ctrl+C` quit window is armed and not yet expired. + /// + /// Always `false` while the agent is busy — the hint must vanish immediately + /// if a turn starts after the window was armed. + #[must_use] + pub(crate) fn quit_hint_active(&self) -> bool { + if self.is_agent_busy() { + return false; + } + self.pending_quit_tick + .is_some_and(|t0| self.anim_tick().saturating_sub(t0) <= CTRL_C_DOUBLE_PRESS_TICKS) + } + /// Begin an animated scroll to `target_offset` for the current session. /// /// When smooth-scroll is disabled (`motion = Off` or `delights.smooth_scroll = false`), diff --git a/crates/zeph-tui/src/app/tests.rs b/crates/zeph-tui/src/app/tests.rs index 40349af27..1437a26ba 100644 --- a/crates/zeph-tui/src/app/tests.rs +++ b/crates/zeph-tui/src/app/tests.rs @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2026 Andrei G // SPDX-License-Identifier: MIT OR Apache-2.0 +use super::state::CTRL_C_DOUBLE_PRESS_TICKS; use super::*; use crate::event::{AgentEvent, AppEvent}; use crate::session::MAX_TUI_MESSAGES; @@ -26,13 +27,152 @@ fn initial_state() { } #[test] -fn ctrl_c_quits() { +fn ctrl_c_arms_quit_hint_but_does_not_quit() { let (mut app, _rx, _tx) = make_app(); let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); app.handle_event(AppEvent::Key(key)); + assert!(!app.should_quit); + assert!(app.quit_hint_active()); +} + +#[test] +fn ctrl_c_twice_within_window_quits() { + let (mut app, _rx, _tx) = make_app(); + let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + app.handle_event(AppEvent::Key(key)); + assert!(!app.should_quit); + app.advance_wave_tick(); + app.handle_event(AppEvent::Key(key)); assert!(app.should_quit); } +#[test] +fn ctrl_c_twice_after_window_expires_rearms_instead_of_quitting() { + let (mut app, _rx, _tx) = make_app(); + let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + app.handle_event(AppEvent::Key(key)); + for _ in 0..=CTRL_C_DOUBLE_PRESS_TICKS { + app.advance_wave_tick(); + } + app.handle_event(AppEvent::Key(key)); + assert!(!app.should_quit); + assert!(app.quit_hint_active()); +} + +#[test] +fn quit_hint_auto_expires_after_window() { + let (mut app, _rx, _tx) = make_app(); + let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + app.handle_event(AppEvent::Key(key)); + assert!(app.quit_hint_active()); + for _ in 0..=CTRL_C_DOUBLE_PRESS_TICKS { + app.advance_wave_tick(); + } + assert!(!app.quit_hint_active()); +} + +#[tokio::test] +async fn ctrl_c_cancels_immediately_when_agent_busy() { + let (mut app, _rx, _tx) = make_app(); + let notify = Arc::new(Notify::new()); + let notify_waiter = Arc::clone(¬ify); + let handle = tokio::spawn(async move { + notify_waiter.notified().await; + true + }); + tokio::task::yield_now().await; + + app = app.with_cancel_signal(Arc::clone(¬ify)); + app.sessions.current_mut().status_label = Some("Thinking...".into()); + assert!(app.is_agent_busy()); + + let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + app.handle_event(AppEvent::Key(key)); + let result = tokio::time::timeout(std::time::Duration::from_millis(100), handle).await; + assert!(result.is_ok(), "notify should have been triggered"); + assert!(!app.should_quit); + assert!(!app.quit_hint_active()); +} + +#[test] +fn ctrl_c_second_press_exactly_at_window_boundary_quits() { + // now - t0 == CTRL_C_DOUBLE_PRESS_TICKS must still count as "within window" (`<=`). + let (mut app, _rx, _tx) = make_app(); + let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + app.handle_event(AppEvent::Key(key)); + for _ in 0..CTRL_C_DOUBLE_PRESS_TICKS { + app.advance_wave_tick(); + } + app.handle_event(AppEvent::Key(key)); + assert!(app.should_quit, "boundary tick delta must still quit"); +} + +#[test] +fn quit_hint_active_at_exact_boundary_tick() { + let (mut app, _rx, _tx) = make_app(); + let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + app.handle_event(AppEvent::Key(key)); + for _ in 0..CTRL_C_DOUBLE_PRESS_TICKS { + app.advance_wave_tick(); + } + assert!( + app.quit_hint_active(), + "hint must still be active exactly at the boundary tick" + ); + app.advance_wave_tick(); + assert!( + !app.quit_hint_active(), + "hint must expire one tick past the boundary" + ); +} + +#[tokio::test] +async fn cancel_agent_clears_pending_quit_tick_preventing_accidental_quit_on_next_idle_press() { + // Regression for #6646 M1: arm the quit window, let the agent become busy and get + // cancelled via Ctrl+C, then a later idle Ctrl+C (still within the OLD window) must + // read as a fresh first press, not a stale second one. + let (mut app, _rx, _tx) = make_app(); + let notify = Arc::new(Notify::new()); + let notify_waiter = Arc::clone(¬ify); + let handle = tokio::spawn(async move { + notify_waiter.notified().await; + true + }); + tokio::task::yield_now().await; + app = app.with_cancel_signal(Arc::clone(¬ify)); + + let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + + // 1. Idle Ctrl+C arms the window. + app.handle_event(AppEvent::Key(key)); + assert!(app.quit_hint_active()); + + // 2. Agent becomes busy before a second press. + app.advance_wave_tick(); + app.sessions.current_mut().status_label = Some("Thinking...".into()); + assert!(app.is_agent_busy()); + + // 3. Ctrl+C while busy cancels the turn (not a quit-window press). + app.handle_event(AppEvent::Key(key)); + let result = tokio::time::timeout(std::time::Duration::from_millis(100), handle).await; + assert!(result.is_ok(), "cancel signal should have fired"); + + // 4. Turn ends, agent idle again — still within the original window's tick range. + app.sessions.current_mut().status_label = None; + assert!(!app.is_agent_busy()); + + // 5. User's next Ctrl+C is intended as a FIRST press — must not quit. + app.handle_event(AppEvent::Key(key)); + assert!( + !app.should_quit, + "stale pre-cancel window must not cause an unintended quit" + ); + assert!( + app.quit_hint_active(), + "this press must be treated as a fresh arm, not a confirming second press" + ); +} + #[test] fn insert_mode_typing() { let (mut app, _rx, _tx) = make_app(); @@ -759,7 +899,10 @@ fn help_popup_does_not_block_ctrl_c() { app.show_help = true; let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); app.handle_event(AppEvent::Key(key)); - assert!(app.should_quit); + // Ctrl+C is still handled above the help modal (global override preserved) — + // it now arms the quit hint on first idle press instead of quitting immediately. + assert!(!app.should_quit); + assert!(app.quit_hint_active()); } #[test] @@ -773,7 +916,7 @@ fn question_mark_in_insert_mode_does_not_open_help() { } #[tokio::test] -async fn esc_in_normal_mode_cancels_when_busy() { +async fn esc_in_normal_mode_does_not_cancel_when_busy() { let (mut app, _rx, _tx) = make_app(); let notify = Arc::new(Notify::new()); let notify_waiter = Arc::clone(¬ify); @@ -781,7 +924,6 @@ async fn esc_in_normal_mode_cancels_when_busy() { notify_waiter.notified().await; true }); - tokio::task::yield_now().await; app = app.with_cancel_signal(Arc::clone(¬ify)); app.sessions.current_mut().input_mode = InputMode::Normal; @@ -790,8 +932,12 @@ async fn esc_in_normal_mode_cancels_when_busy() { let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); app.handle_event(AppEvent::Key(key)); - let result = tokio::time::timeout(std::time::Duration::from_millis(100), handle).await; - assert!(result.is_ok(), "notify should have been triggered"); + // Esc must never cancel a turn anymore — Ctrl+C owns that role. + let result = tokio::time::timeout(std::time::Duration::from_millis(50), handle).await; + assert!( + result.is_err(), + "notify must NOT have been triggered by Esc" + ); } #[test] diff --git a/crates/zeph-tui/src/widgets/help.rs b/crates/zeph-tui/src/widgets/help.rs index c89a6c137..664d64203 100644 --- a/crates/zeph-tui/src/widgets/help.rs +++ b/crates/zeph-tui/src/widgets/help.rs @@ -9,9 +9,10 @@ use ratatui::widgets::{Block, BorderType, Borders, Cell, Clear, Row, Table}; use crate::layout::centered_rect; use crate::theme::Theme; -// 47 data rows + 1 header row + 2 border lines -const POPUP_HEIGHT: u16 = 50; +// 48 data rows + 1 header row + 2 border lines +const POPUP_HEIGHT: u16 = 51; +#[allow(clippy::too_many_lines)] pub fn render(frame: &mut Frame, area: Rect, theme: &Theme) { let popup = centered_rect(70, POPUP_HEIGHT, area); frame.render_widget(Clear, popup); @@ -22,6 +23,10 @@ pub fn render(frame: &mut Frame, area: Rect, theme: &Theme) { Cell::from(""), ]), keybind_row("q", "quit"), + keybind_row( + "Ctrl+C", + "interrupt agent turn / press twice to exit when idle", + ), keybind_row("i", "enter insert mode"), keybind_row("j / k", "scroll down / up"), keybind_row("PgDn / PgUp", "page scroll down / up"), diff --git a/crates/zeph-tui/src/widgets/input.rs b/crates/zeph-tui/src/widgets/input.rs index e0404bc75..b498b5404 100644 --- a/crates/zeph-tui/src/widgets/input.rs +++ b/crates/zeph-tui/src/widgets/input.rs @@ -36,7 +36,7 @@ fn push_busy_tail<'a>( "·" }; spans.push(Span::styled(format!(" {symbol}"), theme.highlight)); - spans.push(Span::styled(" esc to interrupt", theme.system_message)); + spans.push(Span::styled(" ctrl+c to interrupt", theme.system_message)); } /// Mode hint shown on the separator row, accurate to what the keys actually do. @@ -270,7 +270,7 @@ mod tests { render_input(&app, frame, area, true, 0, zeph_config::Motion::Minimal); }); assert!( - output.contains("esc to interrupt"), + output.contains("ctrl+c to interrupt"), "spinner hint must appear when busy" ); } @@ -309,7 +309,7 @@ mod tests { render_input(&app, frame, area, true, 0, zeph_config::Motion::Minimal); }); assert!( - output.contains("esc to interrupt"), + output.contains("ctrl+c to interrupt"), "spinner hint must appear on wide terminal" ); } @@ -387,7 +387,7 @@ mod tests { "prompt glyph must appear in Full+busy; got: {output:?}" ); assert!( - output.contains("esc to interrupt"), + output.contains("ctrl+c to interrupt"), "interrupt hint must appear in Full+busy mode; got: {output:?}" ); } @@ -415,7 +415,7 @@ mod tests { render_input(&app, frame, area, true, 0, zeph_config::Motion::Off); }); assert!( - output.contains("esc to interrupt"), + output.contains("ctrl+c to interrupt"), "interrupt hint must appear in Off mode; got: {output:?}" ); assert!( diff --git a/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__help__tests__help_default.snap b/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__help__tests__help_default.snap index 6102e68ad..2616964bd 100644 --- a/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__help__tests__help_default.snap +++ b/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__help__tests__help_default.snap @@ -6,6 +6,7 @@ expression: output │Key Action │ │Normal mode │ │q quit │ + │Ctrl+C interrupt agent turn / press twice│ │i enter insert mode │ │j / k scroll down / up │ │PgDn / PgUp page scroll down / up │ @@ -30,5 +31,4 @@ expression: output │h / l switch tab (Providers/MCP/Agents) │ │j / k move selection │ │Esc close panel focus │ - │Insert mode │ ╰──────────────────────────────────────────────────────╯ diff --git a/crates/zeph-tui/src/widgets/status.rs b/crates/zeph-tui/src/widgets/status.rs index 9773f7d53..f8942c69e 100644 --- a/crates/zeph-tui/src/widgets/status.rs +++ b/crates/zeph-tui/src/widgets/status.rs @@ -281,6 +281,8 @@ fn build_segment_lists( if app.is_agent_busy() { push_busy_segment(&mut list, app, theme); + } else if app.quit_hint_active() { + push_quit_hint_segment(&mut list, theme); } push_plan_subagent_segments(&mut list, app, metrics, theme); @@ -349,6 +351,16 @@ fn push_busy_segment(list: &mut SegmentList, app: &App, theme: &Theme) { } } +fn push_quit_hint_segment(list: &mut SegmentList, theme: &Theme) { + list.push( + Priority::Critical, + vec![ + Span::styled(" · ", theme.system_message), + Span::styled("Press Ctrl+C again to exit", theme.error), + ], + ); +} + fn push_plan_subagent_segments( list: &mut SegmentList, app: &App, @@ -1287,6 +1299,61 @@ mod tests { ); } + #[test] + fn quit_hint_segment_appears_when_window_armed_and_idle() { + use tokio::sync::mpsc; + + use crate::app::App; + use crate::metrics::MetricsSnapshot; + use crate::test_utils::render_to_string; + + let (user_tx, _) = mpsc::channel(1); + let (_, agent_rx) = mpsc::channel(1); + let mut app = App::new(user_tx, agent_rx); + app.pending_quit_tick = Some(app.anim_tick()); + assert!(app.quit_hint_active()); + + let metrics = MetricsSnapshot::default(); + let output = render_to_string(200, 1, |frame, area| { + super::render(&app, &metrics, frame, area); + }); + + assert!( + output.contains("Press Ctrl+C again to exit"), + "quit hint must appear while the window is armed and the agent is idle; got: {output:?}" + ); + } + + #[test] + fn quit_hint_segment_absent_when_agent_busy() { + use tokio::sync::mpsc; + + use crate::app::App; + use crate::metrics::MetricsSnapshot; + use crate::test_utils::render_to_string; + + let (user_tx, _) = mpsc::channel(1); + let (_, agent_rx) = mpsc::channel(1); + let mut app = App::new(user_tx, agent_rx); + app.pending_quit_tick = Some(app.anim_tick()); + app.sessions.current_mut().status_label = Some("Thinking...".to_owned()); + assert!(app.is_agent_busy()); + assert!( + !app.quit_hint_active(), + "quit_hint_active must be false while busy even with a window armed" + ); + + let metrics = MetricsSnapshot::default(); + let output = render_to_string(200, 1, |frame, area| { + super::render(&app, &metrics, frame, area); + }); + + assert!( + !output.contains("Press Ctrl+C again to exit"), + "quit hint must never appear while the agent is busy; got: {output:?}" + ); + } + #[test] fn busy_segment_ascii_fallback_uses_ascii_breeze_frames() { use tokio::sync::mpsc; diff --git a/specs/011-tui/spec.md b/specs/011-tui/spec.md index 4764726b6..ff9fb0283 100644 --- a/specs/011-tui/spec.md +++ b/specs/011-tui/spec.md @@ -318,6 +318,60 @@ multibyte UTF-8 input (Cyrillic, CJK characters). NEVER truncate at a byte bound --- +## Ctrl+C Interrupt & Double-Press Quit Semantics (#6646) + +Unifies `Ctrl+C` in the TUI around the REPL pattern (Python/IPython): `Ctrl+C` +interrupts the running operation immediately; a second `Ctrl+C` at an idle prompt +exits. This replaces two accident-prone bindings — single-press `Ctrl+C` quitting +outright, and `Esc` cancelling the in-flight agent turn. + +### Behavior + +| Context | Key | Behavior | +|---|---|---| +| Agent **busy** | `Ctrl+C` | Cancel the current turn immediately (`Action::CancelAgent`) — no double-press, no window | +| Agent **idle**, 1st press | `Ctrl+C` | Do NOT quit; arm a quit window and show `Press Ctrl+C again to exit` in the status bar | +| Agent **idle**, 2nd press ≤ window | `Ctrl+C` | Quit (`Effect::Quit`) | +| Agent **idle**, press > window later | `Ctrl+C` | Fresh first press — re-arms window + hint, does not quit | +| Normal mode | `Esc` | No-op (falls to `_ => None`) — no longer cancels the agent | +| Insert mode | `Esc` | Unchanged — Insert→Normal toggle | +| Normal mode | `q` / `/quit` / `TuiCommand::Quit` | Unchanged — immediate `Effect::Quit`; double-press applies to `Ctrl+C` only | + +### Time source (tick-based, not wall-clock) + +The double-press window is measured on the existing animation clock `App::anim_tick()` +(alias of `wave_tick`, advanced once per 100 ms by the `tui_loop` heartbeat), the same +monotonic tick that `ToastQueue::born_tick` and the splash shimmer already use for TTL. +`CTRL_C_DOUBLE_PRESS_TICKS = 5` ≈ 500 ms at 100 ms/tick. This keeps unit tests +deterministic (advance via `advance_wave_tick()` rather than sleeping) and puts no +`Instant`/`SystemTime` on the key path. The threshold is quantized to ~100 ms — an +accepted tradeoff, consistent with all other TUI animation timing. + +### State and dispatch + +- One top-level `App` field `pending_quit_tick: Option` (Ctrl+C is global, not + per-session); captured on the first idle press. +- The global `Ctrl+C` check stays the FIRST branch of `decode_key` (above every modal), + branching on the pure `&self` read `is_agent_busy()`: busy → `Action::CancelAgent`, + idle → the new `Action::RequestQuit`. `decode_key` stays `&self`; window arming happens + only in `reduce` (INV-R1) and emits no effect for the first press (INV-R2). +- Hint visibility is a pure function `quit_hint_active() = !is_agent_busy() && + pending_quit_tick within window`. It auto-expires with no extra plumbing: the + `EventReader` `AppEvent::Tick` cadence marks the frame dirty (`event.rs`), so the status + bar repaints while idle and drops the hint once the window lapses. `pending_quit_tick` is + not reset on expiry — the delta check is self-correcting (like `ToastQueue`). + +### Key Invariants (Ctrl+C) + +- The global `Ctrl+C` branch MUST remain the first check in `decode_key` — modals NEVER swallow `Ctrl+C`; only single-vs-double semantics changed +- Double-press timing MUST use `anim_tick()` — NEVER `Instant::now()`/`SystemTime::now()` on the key/decode path +- Window arming MUST mutate state only in `reduce` (INV-R1); `RequestQuit` MUST perform no I/O (INV-R2, `tui-reducer/spec.md`) +- Agent cancellation MUST stay immediate on a single `Ctrl+C` when busy — the double-press delay applies to quit ONLY, NEVER to cancel +- The quit hint MUST be a transient status-bar segment derived purely from `(pending_quit_tick, anim_tick, is_agent_busy)` — NEVER a separate timer task +- `Esc` retains ONLY its Insert→Normal role — it MUST NEVER cancel an agent turn + +--- + ## Key Invariants - Metrics updated every turn — not only when a specific event fires diff --git a/specs/README.md b/specs/README.md index fcee3d134..f9f7691f3 100644 --- a/specs/README.md +++ b/specs/README.md @@ -106,7 +106,7 @@ Spec IDs (001–069) follow a logical grouping: | `010-security/010-5-egress-logging.md` | Egress logging sub-spec: `EgressEvent` per outbound HTTP call, `AuditEntry.correlation_id`, bounded mpsc telemetry (256 + drop counter), TUI Security panel surface | `zeph-tools`, `zeph-core`, `zeph-tui` | | `010-security/010-6-vigil-intent-anchoring.md` | VIGIL verify-before-commit sub-spec: pre-sanitizer regex tripwire with Block/Sanitize action, per-turn `current_turn_intent`, subagent exemption, non-retryable blocks via `error_category="vigil_blocked"` | `zeph-core`, `zeph-tools`, `zeph-config` | | `010-security/010-7-shadow-memory-guardrail.md` | Shadow Memory Guardrail sub-spec: MAGE multi-turn threat detection (goal hijacking via accumulating risk scores), SafeHarbor hierarchical guardrail tree (entropy-based evolution, adaptive rule injection) | `zeph-sanitizer`, `zeph-memory`, `zeph-agent-tools`, `zeph-core` | -| `011-tui/spec.md` | ratatui dashboard, spinner rule for background operations, TuiChannel, RenderCache, embed backfill progress, multi-session `SessionRegistry`, `/session` commands, compact paste indicator; Fleet panel (`f` key, #3884); reasoning token tracking; terminal title (#4354); fleet session lifecycle wiring (#4363) | `zeph-tui` | +| `011-tui/spec.md` | ratatui dashboard, spinner rule for background operations, TuiChannel, RenderCache, embed backfill progress, multi-session `SessionRegistry`, `/session` commands, compact paste indicator; Fleet panel (`f` key, #3884); reasoning token tracking; terminal title (#4354); fleet session lifecycle wiring (#4363); Ctrl+C interrupt & double-press-to-quit semantics + agent-cancel moved from Esc to Ctrl+C (#6646) | `zeph-tui` | | `012-graph-memory/spec.md` | Entity graph, BFS recall, community detection, MAGMA typed edges, SYNAPSE spreading activation | `zeph-memory` | | `004-memory/004-6-graph-memory.md` | Graph memory sub-spec (concise reference within 004-memory): MAGMA typed edges, SYNAPSE config, A-MEM link weights, key invariants | `zeph-memory` | | `013-acp/spec.md` | ACP transports, session management, permissions, fork/resume, session/close handlers, capability advertisement, /agent.json endpoint; 0.14.0 bump: session/set_model removed, message-id echo removed, provider renames, feature flag stabilizations | `zeph-acp` |