fix(tui): Ctrl+C clears a typed draft instead of interrupting mid-stream

A non-empty composer used to lose to the busy-turn interrupt branch, so
Cmd/Ctrl+C while typing during a stream killed the agent. Clear first;
interrupt only when the input is already empty.
This commit is contained in:
Brooklyn Nicholson
2026-08-18 08:03:35 -05:00
committed by brooklyn!
parent a9a4a04070
commit 0593293962
3 changed files with 63 additions and 12 deletions

View File

@@ -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() }

View File

@@ -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' },

View File

@@ -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,