feat(tui): context-sensitive Ctrl+C — copy / cancel / arm-exit (#1337, #1367)

Plain Ctrl+C used to mean "cancel turn or arm exit" unconditionally,
which fought the OS-wide copy convention on Windows: every time a
user pressed Ctrl+C to copy a model response, they instead armed the
exit prompt and lost their place. Ctrl+Shift+C and Cmd+C copied
correctly but weren't discoverable.

The handler is now a four-stage decision, factored into a
`CtrlCDisposition` helper with a unit-tested priority table:

  1. CopySelection — transcript selection active → copy + clear it
     (matches Windows / cross-platform Ctrl+C convention; #1337).
  2. CancelTurn — turn in flight → cancel (unchanged).
  3. ConfirmExit — quit-armed within the 2s window → exit.
  4. ArmExit — idle, no selection → arm the "press Ctrl+C again"
     prompt for 2s (unchanged).

A turn-in-flight beats a quit-arm even when both are true, so a
Ctrl+C that lands while the user is mid-turn but had recently
half-armed the exit prompt always cancels the turn rather than
exiting. Pinned by `ctrl_c_disposition_loading_beats_armed_quit`.

Cmd+C (macOS) and Ctrl+Shift+C continue to copy via `is_copy_shortcut`
unchanged; only plain Ctrl+C now branches on selection state.

For #1367, on TurnStarted the status-message slot now surfaces
"Press Esc or Ctrl+C to cancel" if it's empty. Real transient
messages still take precedence; the hint clears on the next status
update. Closes the discoverability gap for users who didn't know
how to interrupt a long-running task.

Closes #1337, #1367.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-05-10 09:57:57 -05:00
parent be54a046d0
commit 21f9e9d38d
3 changed files with 129 additions and 28 deletions
+20
View File
@@ -108,6 +108,26 @@ internal fix. Big thanks to every contributor below.
rendered width is capped at the budget for every line; full content
is preserved across wrapped segments. Snapshot-style tests pin the
invariant at widths 40, 60, 80, and 120.
### Changed
- **`Ctrl+C` now copies an active transcript selection** (#1337) — on
Windows, plain `Ctrl+C` is the OS-wide copy chord, and treating it
as "exit" stole work whenever a user copy-pasted from the
transcript. `Ctrl+C` is now a four-stage decision: 1) selection
active → copy + clear (matches the OS convention); 2) turn in
flight → cancel (unchanged); 3) quit-armed within 2s → exit cleanly
(unchanged); 4) idle, no selection → arm the 2-second
"press Ctrl+C again to quit" prompt (unchanged). The decision is
factored into a `CtrlCDisposition` helper with a unit-tested
priority table. `Cmd+C` (macOS) and `Ctrl+Shift+C` continue to copy
unchanged.
- **Cancel-key discoverability hint on turn start** (#1367) — when a
turn begins, the status-message slot now surfaces "Press Esc or
Ctrl+C to cancel" if the slot is otherwise empty. Real transient
status messages still take precedence; the hint clears as soon as
any other update fires. Closes the loop on users who didn't know
how to interrupt a long-running turn.
- **HTTP 400 quota errors retried** (#1203) — some OpenAI-compatible
gateways return quota/rate-limit errors as HTTP 400 instead of 429.
These are now classified as retryable `RateLimited` errors.
+66 -28
View File
@@ -860,6 +860,16 @@ async fn run_event_loop(
app.streaming_message_index = None;
app.streaming_thinking_active_entry = None;
app.turn_started_at = Some(Instant::now());
// Discoverability hint for users who don't know how
// to interrupt a long-running turn (#1367). Only
// surface when the status_message slot is empty so
// we don't trample over a real transient message
// (e.g. "/queue saved", "Selection copied"); the
// hint then auto-clears as soon as anything else
// updates the slot.
if app.status_message.is_none() {
app.status_message = Some("Press Esc or Ctrl+C to cancel".to_string());
}
app.runtime_turn_id = Some(turn_id);
app.runtime_turn_status = Some("in_progress".to_string());
app.reasoning_buffer.clear();
@@ -2257,34 +2267,38 @@ async fn run_event_loop(
copy_active_selection(app);
}
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
// 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.dispatch_started_at = None;
app.streaming_state.reset();
// Optimistically clear the turn-in-progress flag so
// the footer wave animation halts immediately —
// without this, the strip keeps animating until the
// engine eventually emits TurnComplete (#5a). The
// engine's eventual TurnComplete event will overwrite
// with the real outcome ("interrupted").
app.runtime_turn_status = None;
app.status_message = Some("Request cancelled".to_string());
app.disarm_quit();
} else if app.quit_is_armed() {
let _ = engine_handle.send(Op::Shutdown).await;
return Ok(());
} else {
app.arm_quit();
// Four behaviors layered on Ctrl+C in priority order — see
// `CtrlCDisposition` for the unit-tested decision table.
// 1. selection active → copy + clear (Windows convention,
// #1337); 2. turn in flight → cancel; 3. quit-armed →
// exit; 4. otherwise → arm the 2-second exit prompt.
match ctrl_c_disposition(app) {
CtrlCDisposition::CopySelection => {
copy_active_selection(app);
app.viewport.transcript_selection.clear();
}
CtrlCDisposition::CancelTurn => {
engine_handle.cancel();
app.is_loading = false;
app.dispatch_started_at = None;
app.streaming_state.reset();
// Optimistically clear the turn-in-progress flag
// so the footer wave animation halts immediately —
// without this, the strip keeps animating until
// the engine eventually emits TurnComplete (#5a).
// The engine's eventual TurnComplete event will
// overwrite with the real outcome ("interrupted").
app.runtime_turn_status = None;
app.status_message = Some("Request cancelled".to_string());
app.disarm_quit();
}
CtrlCDisposition::ConfirmExit => {
let _ = engine_handle.send(Op::Shutdown).await;
return Ok(());
}
CtrlCDisposition::ArmExit => {
app.arm_quit();
}
}
}
KeyCode::Char('d')
@@ -8237,6 +8251,30 @@ fn selection_has_content(app: &App) -> bool {
selection_to_text(app).is_some_and(|text| !text.is_empty())
}
/// Branches taken by the Ctrl+C key handler. The order encodes priority and is
/// the unit-tested contract for #1337 / #1367: a transcript selection always
/// wins (so users learn that Ctrl+C copies when there's something to copy);
/// otherwise an active turn is interrupted; otherwise the quit-arm flow runs.
#[derive(Debug, PartialEq, Eq)]
enum CtrlCDisposition {
CopySelection,
CancelTurn,
ConfirmExit,
ArmExit,
}
fn ctrl_c_disposition(app: &App) -> CtrlCDisposition {
if selection_has_content(app) {
CtrlCDisposition::CopySelection
} else if app.is_loading {
CtrlCDisposition::CancelTurn
} else if app.quit_is_armed() {
CtrlCDisposition::ConfirmExit
} else {
CtrlCDisposition::ArmExit
}
}
fn copy_active_selection(app: &mut App) {
if !app.viewport.transcript_selection.is_active() {
return;
+43
View File
@@ -2169,6 +2169,49 @@ fn test_ctrl_c_exits_when_not_loading() {
assert!(!app.is_loading);
}
#[test]
fn ctrl_c_disposition_idle_arms_exit_prompt() {
let app = create_test_app();
assert!(!app.is_loading);
assert!(!app.quit_is_armed());
assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ArmExit);
}
#[test]
fn ctrl_c_disposition_loading_cancels_turn() {
let mut app = create_test_app();
app.is_loading = true;
assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);
}
#[test]
fn ctrl_c_disposition_armed_idle_confirms_exit() {
let mut app = create_test_app();
app.arm_quit();
assert!(app.quit_is_armed());
assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ConfirmExit);
}
#[test]
fn ctrl_c_disposition_loading_beats_armed_quit() {
// If a turn started while quit is armed, the user almost certainly meant
// "cancel the turn", not "exit". Pin that priority order.
let mut app = create_test_app();
app.arm_quit();
app.is_loading = true;
assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);
}
#[test]
fn ctrl_c_disposition_no_selection_means_no_copy() {
// Regression guard for #1337: with no transcript selection, Ctrl+C must
// NOT route to copy. (When selection is active, the copy branch wins;
// exercised by the integration-level mouse-drag tests in this file.)
let app = create_test_app();
assert!(!selection_has_content(&app));
assert_ne!(ctrl_c_disposition(&app), CtrlCDisposition::CopySelection);
}
#[test]
fn test_ctrl_d_exits_when_input_empty() {
let mut app = create_test_app();