Compare commits

...
1 Commits
Author SHA1 Message Date
Hunter Bown 6c9fc19be5 fix: feat(tui): consider Ctrl+C clearing a non-empty composer before it arms exit (Esc/Ctrl+C split revisited) (#6237)
Produced by CodeWhale running in a Boat VM. Untested.
2026-10-24 22:29:46 -07:00
4 changed files with 65 additions and 12 deletions

No files matched your search

+8 -3
View File
@@ -2038,13 +2038,16 @@ fn clear_active_selection(app: &mut App) -> bool {
}
/// 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.
/// the unit-tested contract for #1337 / #1367 / #6237: 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 a non-empty
/// composer is cleared recoverably (the sibling-tool muscle-memory convention);
/// otherwise the quit-arm flow runs.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum CtrlCDisposition {
CopySelection,
CancelTurn,
ClearInput,
ConfirmExit,
ArmExit,
}
@@ -2058,6 +2061,8 @@ pub(crate) fn ctrl_c_disposition(app: &App) -> CtrlCDisposition {
|| app.goal_continuation_waiting
{
CtrlCDisposition::CancelTurn
} else if !app.input.is_empty() {
CtrlCDisposition::ClearInput
} else if app.quit_is_armed() {
CtrlCDisposition::ConfirmExit
} else {
+23 -8
View File
@@ -6059,12 +6059,12 @@ pub(crate) async fn run_event_loop(
// the conversation composer match below handle this key
// exactly as they would in a live session.
} else {
// Ctrl+C on the launch screen follows the same two-tap
// Ctrl+C on the launch screen follows the same
// contract as the session shell (`CtrlCDisposition`):
// first press arms the visible exit prompt, the second
// inside QUIT_CONFIRMATION_WINDOW exits. Selection
// copy and turn cancel cannot apply before a session
// exists, so every other disposition arms.
// a non-empty composer clears recoverably, then the first
// press arms the visible exit prompt, the second inside
// QUIT_CONFIRMATION_WINDOW exits. Selection copy and turn
// cancel cannot apply before a session exists.
if key.code == KeyCode::Char('c')
&& key.modifiers.contains(KeyModifiers::CONTROL)
{
@@ -6073,6 +6073,13 @@ pub(crate) async fn run_event_loop(
let _ = engine_handle.send(Op::Shutdown).await;
return Ok(());
}
CtrlCDisposition::ClearInput => {
app.edit_in_progress = false;
app.clear_input_recoverable();
let _ = app.maybe_show_behavioral_tip(
crate::tui::behavioral_tips::BehavioralTip::ClearedInputRestore,
);
}
_ => app.arm_quit(),
}
app.needs_redraw = true;
@@ -6612,11 +6619,12 @@ pub(crate) async fn run_event_loop(
copy_active_selection(app);
}
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
// Four behaviors layered on Ctrl+C in priority order — see
// Five 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.
// #1337); 2. turn in flight → cancel; 3. non-empty
// composer → recoverable clear (#6237); 4. quit-armed →
// exit; 5. otherwise → arm the 2-second exit prompt.
match ctrl_c_disposition(app) {
CtrlCDisposition::CopySelection => {
copy_active_selection(app);
@@ -6652,6 +6660,13 @@ pub(crate) async fn run_event_loop(
app.status_message = Some(parent_stop_status(app, base));
app.disarm_quit();
}
CtrlCDisposition::ClearInput => {
app.edit_in_progress = false;
app.clear_input_recoverable();
let _ = app.maybe_show_behavioral_tip(
crate::tui::behavioral_tips::BehavioralTip::ClearedInputRestore,
);
}
CtrlCDisposition::ConfirmExit => {
let _ = engine_handle.send(Op::Shutdown).await;
return Ok(());
+32
View File
@@ -19281,6 +19281,38 @@ fn ctrl_c_disposition_goal_delay_cancels_continuation_instead_of_arming_exit() {
assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);
}
#[test]
fn ctrl_c_disposition_non_empty_composer_clears_input() {
// #6237: with a draft present and nothing in flight, Ctrl+C clears the
// composer recoverably instead of arming the exit prompt, matching the
// sibling-tool muscle-memory convention.
let mut app = create_test_app();
app.input = "draft".to_string();
assert!(!app.is_loading);
assert!(!app.quit_is_armed());
assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ClearInput);
}
#[test]
fn ctrl_c_disposition_loading_beats_non_empty_composer() {
// In flight, Ctrl+C must stay "cancel"; a draft must not swallow the
// only way out of a running turn.
let mut app = create_test_app();
app.input = "draft".to_string();
app.is_loading = true;
assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);
}
#[test]
fn ctrl_c_disposition_non_empty_composer_beats_armed_quit() {
// Once a draft is present, Ctrl+C returns to "clear", so the two-tap exit
// path only runs on an empty composer.
let mut app = create_test_app();
app.arm_quit();
app.input = "draft".to_string();
assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ClearInput);
}
#[test]
fn escape_goal_delay_cancels_continuation_with_empty_composer() {
let mut app = create_test_app();
+2 -1
View File
@@ -14,7 +14,7 @@ Global key chords are not yet user-configurable — tracked for a future release
| `F2` | Toggle the typed Settings editor |
| `F3` | Open the provider/model picker (same as `/provider`) |
| `Ctrl-K` | Open the command palette (slash-command finder) |
| `Ctrl-C` | Cancel current turn / dismiss modal / arm-then-confirm quit |
| `Ctrl-C` | Clear a non-empty draft (recoverable — see `Ctrl-Z`); otherwise cancel current turn / dismiss modal / arm-then-confirm quit |
| `Ctrl-B` | Release the current shell wait so the turn can continue; foreground commands move into `/jobs`, and background tasks keep running. Use `/jobs` or `Bash` with `action: "wait"` to inspect output |
| `Ctrl-D` | Quit (only when the composer is empty) |
| `Tab` | When the composer is empty, cycle TUI mode: Plan → Work → Operate → Plan |
@@ -49,6 +49,7 @@ Editing the message you're about to send.
| `Ctrl-Enter` / `Cmd-Enter` | Send into the current turn; send normally when idle (when the terminal reports it distinctly) |
| `Ctrl-U` | Clear the whole draft (recoverable — see `Ctrl-Z`) |
| `Ctrl-Z` | Restore the cleared draft (only while the composer is empty) |
| `Ctrl-C` | Clear the whole draft (recoverable — see `Ctrl-Z`) when the composer is non-empty and nothing is in flight |
| `Ctrl-W` / `Ctrl-Backspace` / `Alt-Backspace` | Delete previous word |
| `Ctrl-A` / `Home` | Move to start of input / start of line (readline convention) |
| `Ctrl-E` / `End` | Move to end of input / end of line |