Skip to content

require double Ctrl+C to exit TUI and move agent-cancel from Esc to Ctrl+C #6646

Description

@bug-ops

Description

The TUI overloads two keys in ways that make accidental, irreversible actions too easy:

  1. Single Ctrl+C quits the whole TUI immediately. Ctrl+C is a reflexive "interrupt" keystroke; a single accidental press tears down the session with no confirmation.
  2. Esc cancels the in-flight agent turn (only when the agent is busy). Esc is extremely easy to hit by reflex (it is also the Insert→Normal toggle), so a stray Esc silently aborts a running turn.

This issue unifies Ctrl+C semantics in the TUI around the well-known REPL pattern (Python/IPython: Ctrl+C interrupts the current operation immediately; a second Ctrl+C at an idle prompt exits). It is a single coherent change to Ctrl+C handling, so both parts are tracked here rather than as two independent (potentially conflicting) issues.

Current Behavior

  • crates/zeph-tui/src/app/keys.rs:34-37 — global first check in decode_key: any Ctrl+C returns Action::Quit (overrides every modal), which reduces to Effect::Quitapp.should_quit = true (crates/zeph-tui/src/app/reducer.rs:168-169,1049-1051).
  • crates/zeph-tui/src/app/keys.rs:787 — in Normal mode, Esc if self.is_agent_busy() returns Action::CancelAgent (notify_waiters() on app.cancel_signal, reducer.rs:171-176). When the agent is idle the same Esc falls through to _ => None (keys.rs:838-840).
  • Esc in Insert mode → Action::EnterNormal (decode_insert_text_key, keys.rs:963) — unrelated, must stay.

Expected Behavior (unified Ctrl+C model)

Context Key New behavior
Agent busy Ctrl+C Immediately cancel the current turn (Action::CancelAgent) — no double-press, no timer. Same effect as today, only the trigger moves from Esc to Ctrl+C.
Agent idle, first press Ctrl+C Do NOT quit. Arm a quit window and show Press Ctrl+C again to exit in the status bar.
Agent idle, second press ≤500 ms after the first Ctrl+C Quit (Effect::Quit).
Agent idle, next press >500 ms after the first Ctrl+C Treated as a fresh first press (re-arms window + hint; does not quit).
Normal mode Esc No longer cancels the agent — falls through to _ => None (no-op), matching the existing idle-agent behavior.
Insert mode Esc Unchanged — Insert→Normal toggle.
Normal mode q, /quit, TuiCommand::Quit Unchanged — immediate Effect::Quit (explicit, deliberate; no double-press). Only Ctrl+C gets the double-press treatment.

The status-bar hint auto-expires when the quit window lapses (no manual dismiss needed) and never appears while the agent is busy.

Design (single coherent state machine)

Time source — reuse the existing tick clock, do NOT use Instant::now(). The project already exposes a testable, monotonic animation clock App::anim_tick() (alias of wave_tick, advanced once per 100 ms in the tui_loop heartbeat, lib.rs:205-209). Toasts (delights.rs ToastQueue, born_tick + TOAST_TTL_TICKS) and the splash shimmer already build TTL logic on it. The double-press window follows the same pattern: 500 ms ≈ 5 ticks at 100 ms/tick. This keeps unit tests deterministic (advance the clock via advance_wave_tick() instead of sleeping) and adds no Instant/SystemTime on the key path. Accepted tradeoff: the threshold is quantized to ~100 ms — documented in the spec.

New state (one field on App, top-level — Ctrl+C is global, not per-session):

// crates/zeph-tui/src/app/state.rs — App struct + App::new init to None
pending_quit_tick: Option<u64>,   // anim_tick captured on the first idle Ctrl+C
// shared const (state.rs), referenced by reducer + widget
pub(crate) const CTRL_C_DOUBLE_PRESS_TICKS: u64 = 5; // ~500 ms at 100 ms/tick

Decode (keys.rs, keep the global Ctrl+C check FIRST — before all modals, preserving the existing global-override property):

if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
    return Some(if self.is_agent_busy() {
        Action::CancelAgent          // busy → immediate cancel
    } else {
        Action::RequestQuit          // idle → double-press logic in reduce
    });
}

is_agent_busy() is a pure &self read, so the busy/idle branch stays inside the &self decode_key; all mutation (arming the window) remains in reduce per INV-R1 (tui-reducer/spec.md). No change to decode_key's signature.

New action + reducer arm (the only mutation site):

// action.rs
/// 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,
// reducer.rs
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![]                    // no effect, no I/O — INV-R2 preserved
        }
    }
}

No new Effect variant: arming is a pure field write; quitting reuses Effect::Quit. Action::CancelAgent and its effect are unchanged.

Remove the Esc→cancel trigger:

// keys.rs:787 — delete this arm; Esc in Normal now always falls to `_ => None`
KeyCode::Esc if self.is_agent_busy() => Some(Action::CancelAgent),

Hint rendering + auto-expiry (pure function of state — no new tick/prune plumbing):

// state.rs — used by the status widget
#[must_use]
pub fn quit_hint_active(&self) -> bool {
    if self.is_agent_busy() { return false; } // vanish immediately if a turn starts
    self.pending_quit_tick
        .is_some_and(|t0| self.anim_tick().saturating_sub(t0) <= CTRL_C_DOUBLE_PRESS_TICKS)
}

crates/zeph-tui/src/widgets/status.rs pushes a Priority::Critical segment Press Ctrl+C again to exit when app.quit_hint_active(). Expiry needs no new machinery: the EventReader emits AppEvent::Tick at its cadence (event.rs:88-91) → event_rx arm sets dirty = Full → the status bar repaints every ~100–250 ms even while idle, so quit_hint_active() flips to false and the hint disappears on the next frame. pending_quit_tick is never reset on expiry — the delta check is self-correcting (a later press re-arms), exactly like ToastQueue never resets born_tick. The DirtyState/should_draw gate (lib.rs:243-250) needs no change.

Implementation Plan (file-by-file)

  1. crates/zeph-tui/src/app/state.rs — add pending_quit_tick: Option<u64> (init None in App::new), pub(crate) const CTRL_C_DOUBLE_PRESS_TICKS, and App::quit_hint_active() (with /// docs + doctest). Optionally expose App::quit_hint()->Option<&'static str> for the widget.
  2. crates/zeph-tui/src/app/action.rs — add Action::RequestQuit with a doc comment.
  3. crates/zeph-tui/src/app/keys.rs — (a) branch the global Ctrl+C check on is_agent_busy()CancelAgent / RequestQuit; (b) delete the Esc if self.is_agent_busy() arm at line 787.
  4. crates/zeph-tui/src/app/reducer.rs — add the Action::RequestQuit arm (above).
  5. crates/zeph-tui/src/widgets/status.rs — render the Critical hint segment when quit_hint_active().
  6. crates/zeph-tui/src/widgets/help.rs — update keybind rows: Ctrl+C → "interrupt agent / press twice to exit"; remove/adjust any Esc→cancel mention; the Normal-mode q row stays.
  7. crates/zeph-tui/src/app/reducer.rs:883 — update the /quit-related help text ("quit the TUI (q / Ctrl+C)") to reflect the double-press for Ctrl+C.
  8. Tests (crates/zeph-tui/src/app/tests.rs):
    • Rework ctrl_c_quits (line 29): a single idle Ctrl+C must NOT set should_quit; it must arm the hint (quit_hint_active() true).
    • Add: two Ctrl+C within the window (no advance_wave_tick or ≤5 ticks) → should_quit.
    • Add: second Ctrl+C after >5 advance_wave_tick() calls → NOT quit, re-armed.
    • Add: Ctrl+C while is_agent_busy()CancelAgent path fires (cancel signal notified), does NOT arm quit, no hint.
    • Add: Esc in Normal mode with a busy agent → no-op (agent NOT cancelled).
    • Update help_popup_does_not_block_ctrl_c (line 757) and palette_intercepts_all_keys_except_ctrl_c (line 1379): Ctrl+C must still be handled above the modal (global override preserved), but now arms the hint on first idle press instead of quitting immediately.
    • Hint auto-expiry: arm, advance the clock past the window, assert quit_hint_active() is false.

Integration Points (per project Development Rules)

  • Status-bar hint (TUI system indicator) — covered above.
  • Help overlay (?) keybind rows updated (point 6).
  • No new config/CLI/wizard/migration surface: this is a fixed keybinding-semantics change (window/const are compile-time, not user-configurable in this iteration — a future [tui] confirm_quit/quit_window_ms option can be a follow-up if requested).
  • Live-testing playbook + coverage-status rows: add to .local/testing/playbooks/tui.md and .local/testing/coverage-status.md before the PR (scenarios: single vs double Ctrl+C idle, Ctrl+C-cancels-busy, Esc-no-longer-cancels, hint appears/expires; verify across CLI-TUI at minimum).

Invariants (must hold)

  • The global Ctrl+C check stays the FIRST branch in decode_key, above every modal — modals never swallow Ctrl+C (preserves the existing override contract; only single-vs-double semantics change).
  • All timing uses anim_tick() — NEVER Instant::now()/SystemTime::now() on the key/decode path (testability + non-blocking hot-path contract, CLAUDE.md Async & Background Tasks).
  • Window arming mutates state ONLY in reduce (INV-R1); reduce performs no I/O for RequestQuit (INV-R2).
  • The hint is a transient status-bar segment derived purely from (pending_quit_tick, anim_tick, is_agent_busy) — no separate timer task, no lock held across .await.
  • Agent cancellation stays immediate (single Ctrl+C when busy) — the double-press delay applies to quit only, never to cancel.
  • Esc retains ONLY its Insert→Normal role; it must never cancel a turn.

Spec

Full semantics documented in specs/011-tui/spec.md → new section "Ctrl+C Interrupt & Double-Press Quit Semantics" (with Key Invariants (Ctrl+C)), cross-referencing tui-reducer/spec.md INV-R1/R2 and specs/001-system-invariants/spec.md.

Environment

  • Version: main @ current HEAD (v0.22.3)
  • Crate: zeph-tui (feature: tui); features desktop in the CI set
  • Out of scope: making the window/hint text configurable; changing q//quit; any non-TUI channel.

Metadata

Metadata

Assignees

Labels

P2High value, medium complexityenhancementNew feature or request

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions