diff --git a/CHANGELOG.md b/CHANGELOG.md index bfa8627b..55a500f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 12ef103b..80ae590d 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -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; diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index cdfc2ff5..bb4c628a 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -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();