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:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user