Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions crates/zeph-tui/src/app/action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 7 additions & 3 deletions crates/zeph-tui/src/app/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Action> {
// 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.
Expand Down Expand Up @@ -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),
Expand Down
4 changes: 4 additions & 0 deletions crates/zeph-tui/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64>,

/// 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
Expand Down
20 changes: 19 additions & 1 deletion crates/zeph-tui/src/app/reducer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -172,8 +173,25 @@ pub(crate) fn reduce(app: &mut App, action: Action) -> Vec<Effect> {
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 => {
Expand Down Expand Up @@ -880,7 +898,7 @@ pub(crate) fn reduce(app: &mut App, action: Action) -> Vec<Effect> {
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 <URL>`, quit the TUI (q / Ctrl+C) to disconnect."
If you started with `--connect <URL>`, quit the TUI (q / press Ctrl+C twice) to disconnect."
.to_owned(),
);
return vec![];
Expand Down
20 changes: 20 additions & 0 deletions crates/zeph-tui/src/app/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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`),
Expand Down
158 changes: 152 additions & 6 deletions crates/zeph-tui/src/app/tests.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
// 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;
Expand All @@ -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(&notify);
let handle = tokio::spawn(async move {
notify_waiter.notified().await;
true
});
tokio::task::yield_now().await;

app = app.with_cancel_signal(Arc::clone(&notify));
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(&notify);
let handle = tokio::spawn(async move {
notify_waiter.notified().await;
true
});
tokio::task::yield_now().await;
app = app.with_cancel_signal(Arc::clone(&notify));

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();
Expand Down Expand Up @@ -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]
Expand All @@ -773,15 +916,14 @@ 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(&notify);
let handle = tokio::spawn(async move {
notify_waiter.notified().await;
true
});
tokio::task::yield_now().await;

app = app.with_cancel_signal(Arc::clone(&notify));
app.sessions.current_mut().input_mode = InputMode::Normal;
Expand All @@ -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]
Expand Down
9 changes: 7 additions & 2 deletions crates/zeph-tui/src/widgets/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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"),
Expand Down
Loading
Loading