Description
The TUI overloads two keys in ways that make accidental, irreversible actions too easy:
- 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.
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::Quit → app.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)
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.
crates/zeph-tui/src/app/action.rs — add Action::RequestQuit with a doc comment.
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.
crates/zeph-tui/src/app/reducer.rs — add the Action::RequestQuit arm (above).
crates/zeph-tui/src/widgets/status.rs — render the Critical hint segment when quit_hint_active().
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.
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.
- 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.
Description
The TUI overloads two keys in ways that make accidental, irreversible actions too easy:
Ctrl+Cquits the whole TUI immediately.Ctrl+Cis a reflexive "interrupt" keystroke; a single accidental press tears down the session with no confirmation.Esccancels the in-flight agent turn (only when the agent is busy).Escis extremely easy to hit by reflex (it is also the Insert→Normal toggle), so a strayEscsilently aborts a running turn.This issue unifies
Ctrl+Csemantics in the TUI around the well-known REPL pattern (Python/IPython:Ctrl+Cinterrupts the current operation immediately; a secondCtrl+Cat an idle prompt exits). It is a single coherent change toCtrl+Chandling, 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 indecode_key: anyCtrl+CreturnsAction::Quit(overrides every modal), which reduces toEffect::Quit→app.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()returnsAction::CancelAgent(notify_waiters()onapp.cancel_signal,reducer.rs:171-176). When the agent is idle the sameEscfalls through to_ => None(keys.rs:838-840).Escin Insert mode →Action::EnterNormal(decode_insert_text_key,keys.rs:963) — unrelated, must stay.Expected Behavior (unified Ctrl+C model)
Ctrl+CAction::CancelAgent) — no double-press, no timer. Same effect as today, only the trigger moves fromEsctoCtrl+C.Ctrl+CPress Ctrl+C again to exitin the status bar.Ctrl+CEffect::Quit).Ctrl+CEsc_ => None(no-op), matching the existing idle-agent behavior.Escq,/quit,TuiCommand::QuitEffect::Quit(explicit, deliberate; no double-press). OnlyCtrl+Cgets 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 clockApp::anim_tick()(alias ofwave_tick, advanced once per 100 ms in thetui_loopheartbeat,lib.rs:205-209). Toasts (delights.rsToastQueue,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 ticksat 100 ms/tick. This keeps unit tests deterministic (advance the clock viaadvance_wave_tick()instead of sleeping) and adds noInstant/SystemTimeon 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+Cis global, not per-session):Decode (
keys.rs, keep the globalCtrl+Ccheck FIRST — before all modals, preserving the existing global-override property):is_agent_busy()is a pure&selfread, so the busy/idle branch stays inside the&selfdecode_key; all mutation (arming the window) remains inreduceper INV-R1 (tui-reducer/spec.md). No change todecode_key's signature.New action + reducer arm (the only mutation site):
No new
Effectvariant: arming is a pure field write; quitting reusesEffect::Quit.Action::CancelAgentand its effect are unchanged.Remove the
Esc→cancel trigger:Hint rendering + auto-expiry (pure function of state — no new tick/prune plumbing):
crates/zeph-tui/src/widgets/status.rspushes aPriority::CriticalsegmentPress Ctrl+C again to exitwhenapp.quit_hint_active(). Expiry needs no new machinery: theEventReaderemitsAppEvent::Tickat its cadence (event.rs:88-91) →event_rxarm setsdirty = Full→ the status bar repaints every ~100–250 ms even while idle, soquit_hint_active()flips tofalseand the hint disappears on the next frame.pending_quit_tickis never reset on expiry — the delta check is self-correcting (a later press re-arms), exactly likeToastQueuenever resetsborn_tick. TheDirtyState/should_drawgate (lib.rs:243-250) needs no change.Implementation Plan (file-by-file)
crates/zeph-tui/src/app/state.rs— addpending_quit_tick: Option<u64>(initNoneinApp::new),pub(crate) const CTRL_C_DOUBLE_PRESS_TICKS, andApp::quit_hint_active()(with///docs + doctest). Optionally exposeApp::quit_hint()->Option<&'static str>for the widget.crates/zeph-tui/src/app/action.rs— addAction::RequestQuitwith a doc comment.crates/zeph-tui/src/app/keys.rs— (a) branch the globalCtrl+Ccheck onis_agent_busy()→CancelAgent/RequestQuit; (b) delete theEsc if self.is_agent_busy()arm at line 787.crates/zeph-tui/src/app/reducer.rs— add theAction::RequestQuitarm (above).crates/zeph-tui/src/widgets/status.rs— render the Critical hint segment whenquit_hint_active().crates/zeph-tui/src/widgets/help.rs— update keybind rows:Ctrl+C→ "interrupt agent / press twice to exit"; remove/adjust anyEsc→cancel mention; the Normal-modeqrow stays.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.crates/zeph-tui/src/app/tests.rs):ctrl_c_quits(line 29): a single idleCtrl+Cmust NOT setshould_quit; it must arm the hint (quit_hint_active()true).Ctrl+Cwithin the window (noadvance_wave_tickor ≤5 ticks) →should_quit.Ctrl+Cafter >5advance_wave_tick()calls → NOT quit, re-armed.Ctrl+Cwhileis_agent_busy()→CancelAgentpath fires (cancel signal notified), does NOT arm quit, no hint.Escin Normal mode with a busy agent → no-op (agent NOT cancelled).help_popup_does_not_block_ctrl_c(line 757) andpalette_intercepts_all_keys_except_ctrl_c(line 1379):Ctrl+Cmust still be handled above the modal (global override preserved), but now arms the hint on first idle press instead of quitting immediately.quit_hint_active()isfalse.Integration Points (per project Development Rules)
?) keybind rows updated (point 6).[tui] confirm_quit/quit_window_msoption can be a follow-up if requested)..local/testing/playbooks/tui.mdand.local/testing/coverage-status.mdbefore 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)
Ctrl+Ccheck stays the FIRST branch indecode_key, above every modal — modals never swallowCtrl+C(preserves the existing override contract; only single-vs-double semantics change).anim_tick()— NEVERInstant::now()/SystemTime::now()on the key/decode path (testability + non-blocking hot-path contract,CLAUDE.mdAsync & Background Tasks).reduce(INV-R1);reduceperforms no I/O forRequestQuit(INV-R2).(pending_quit_tick, anim_tick, is_agent_busy)— no separate timer task, no lock held across.await.Ctrl+Cwhen busy) — the double-press delay applies to quit only, never to cancel.Escretains 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" (withKey Invariants (Ctrl+C)), cross-referencingtui-reducer/spec.mdINV-R1/R2 andspecs/001-system-invariants/spec.md.Environment
zeph-tui(feature:tui); featuresdesktopin the CI setq//quit; any non-TUI channel.