diff --git a/ui-tui/src/__tests__/useInputHandlers.test.ts b/ui-tui/src/__tests__/useInputHandlers.test.ts index ef9e676f73..f77a3b69ce 100644 --- a/ui-tui/src/__tests__/useInputHandlers.test.ts +++ b/ui-tui/src/__tests__/useInputHandlers.test.ts @@ -5,6 +5,7 @@ import { applyVoiceRecordResponse, dismissSensitivePrompt, handleIdleHotkeyExit, + resolveCtrlCComposerAction, shouldAllowIdleHotkeyExit, shouldFallThroughForScroll } from '../app/useInputHandlers.js' @@ -59,6 +60,28 @@ describe('shouldAllowIdleHotkeyExit', () => { }) }) +describe('resolveCtrlCComposerAction — draft wins over interrupt', () => { + it('clears a non-empty composer even while the agent is streaming', () => { + expect(resolveCtrlCComposerAction({ busy: true, hasDraft: true, hasSession: true })).toBe('clear') + }) + + it('interrupts a running turn when the composer is empty', () => { + expect(resolveCtrlCComposerAction({ busy: true, hasDraft: false, hasSession: true })).toBe('interrupt') + }) + + it('clears an idle composer instead of exiting', () => { + expect(resolveCtrlCComposerAction({ busy: false, hasDraft: true, hasSession: true })).toBe('clear') + }) + + it('exits when idle with an empty composer', () => { + expect(resolveCtrlCComposerAction({ busy: false, hasDraft: false, hasSession: true })).toBe('exit') + }) + + it('does not interrupt a busy session that has no sid yet', () => { + expect(resolveCtrlCComposerAction({ busy: true, hasDraft: false, hasSession: false })).toBe('exit') + }) +}) + describe('handleIdleHotkeyExit', () => { it('exits in normal terminals', () => { const actions = { die: vi.fn(), sys: vi.fn() } diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 67ad4c17bc..f549bc8668 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -62,6 +62,29 @@ export function handleIdleHotkeyExit( return actions.die() } +export type CtrlCComposerAction = 'clear' | 'interrupt' | 'exit' + +/** + * Ctrl+C (and terminals that rewrite Cmd+C to it) is clear / interrupt / exit + * in that order. A non-empty composer always wins — mid-stream, the chord + * used to interrupt the turn even when the user was trying to dump a draft. + */ +export function resolveCtrlCComposerAction(opts: { + busy: boolean + hasDraft: boolean + hasSession: boolean +}): CtrlCComposerAction { + if (opts.hasDraft) { + return 'clear' + } + + if (opts.busy && opts.hasSession) { + return 'interrupt' + } + + return 'exit' +} + /** * Approval / clarify / confirm overlays mount their own `useInput` handlers * for the in-prompt keys (arrows, numbers, Enter, sometimes Esc). The global @@ -334,10 +357,9 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { } // Double-Esc discards the draft, matching Claude Code / Gemini CLI. It - // sits above the isBlocked early-return on purpose: Ctrl+C interrupts a - // running turn rather than clearing, so while the agent streams there is - // otherwise no way to throw away a half-typed prompt. The draft is pushed - // to history first so Up recalls it. + // sits above the isBlocked early-return so a prompt overlay cannot swallow + // it. Ctrl+C now clears a non-empty composer even mid-stream; Esc Esc is + // still the dedicated discard (pushes the draft to history so Up recalls it). const lastEscRef = useRef(0) useInput((ch, key) => { @@ -610,7 +632,17 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { } if (key.ctrl && ch.toLowerCase() === 'c') { - if (live.busy && live.sid) { + const ctrlC = resolveCtrlCComposerAction({ + busy: live.busy, + hasDraft: Boolean(cState.input || cState.inputBuf.length), + hasSession: Boolean(live.sid) + }) + + if (ctrlC === 'clear') { + return cActions.clearIn() + } + + if (ctrlC === 'interrupt' && live.sid) { return turnController.interruptTurn({ appendMessage: actions.appendMessage, gw: gateway.gw, @@ -619,10 +651,6 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { }) } - if (cState.input || cState.inputBuf.length) { - return cActions.clearIn() - } - return handleIdleHotkeyExit(actions, DASHBOARD_TUI_MODE, () => { gateway.gw.publishLocalEvent({ payload: { reason: 'idle_exit_hotkey' }, diff --git a/ui-tui/src/content/hotkeys.ts b/ui-tui/src/content/hotkeys.ts index d243b84d85..37f21f6c13 100644 --- a/ui-tui/src/content/hotkeys.ts +++ b/ui-tui/src/content/hotkeys.ts @@ -6,14 +6,14 @@ const paste = isMac ? 'Cmd' : 'Alt' const copyHotkeys: [string, string][] = isMac ? [ ['Cmd+C', 'copy selection'], - ['Ctrl+C', 'interrupt / clear draft / exit'] + ['Ctrl+C', 'clear draft / interrupt / exit'] ] : isRemoteShell() ? [ ['Cmd+C', 'copy selection when forwarded by the terminal'], - ['Ctrl+C', 'copy selection / interrupt / clear draft / exit'] + ['Ctrl+C', 'copy selection / clear draft / interrupt / exit'] ] - : [['Ctrl+C', 'copy selection / interrupt / clear draft / exit']] + : [['Ctrl+C', 'copy selection / clear draft / interrupt / exit']] export const HOTKEYS: [string, string][] = [ ...copyHotkeys,