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:
committed by
brooklyn!
parent
a9a4a04070
commit
0593293962
@@ -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() }
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user