diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index 9228e6213..7a5a1c23d 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -13,6 +13,7 @@ import { KeypressProvider, useKeypressContext, DRAG_COMPLETION_TIMEOUT_MS, + PASTE_IDLE_TIMEOUT_MS, // CSI_END_O, // SS3_END, SINGLE_QUOTE, @@ -230,6 +231,79 @@ describe('KeypressContext - Kitty Protocol', () => { ); }); + it('Ctrl+C escapes a paste mode that never received its paste-end marker', async () => { + // Regression test for the "must restart terminal" lockup reported by + // a user on Ghostty + Sogou pinyin: bracketed-paste-start arrived, + // isPaste was set true, and paste-end never followed. Every + // subsequent keystroke — including Ctrl+C — was silently buffered. + // This test checks that Ctrl+C is always dispatched regardless of + // paste mode state. + const keyHandler = vi.fn(); + + const { result } = renderHook(() => useKeypressContext(), { + wrapper: ({ children }) => + wrapper({ children, kittyProtocolEnabled: true }), + }); + act(() => { + result.current.subscribe(keyHandler); + }); + + // Send ONLY the paste-start marker (no paste-end) — this puts the + // dispatcher into the broken state. + act(() => { + stdin.emit('data', Buffer.from('\x1b[200~')); + }); + await new Promise((r) => setTimeout(r, 50)); + + // Ctrl+C should fire now, not get buffered into the stuck paste. + act(() => { + stdin.emit('data', Buffer.from('\x03')); + }); + await new Promise((r) => setTimeout(r, 50)); + + const ctrlCSeen = keyHandler.mock.calls.some( + (c) => c[0]?.ctrl === true && c[0]?.name === 'c', + ); + expect(ctrlCSeen).toBe(true); + }); + + it('auto-recovers from a stuck paste mode via idle timeout', async () => { + // Automatic recovery safety net for the same "must restart terminal" + // lockup the Ctrl+C test above covers manually: if paste-end never + // arrives, an idle timeout should flush whatever is in the paste + // buffer and reset paste state so normal typing resumes automatically + // (without requiring the user to hit Ctrl+C or restart the terminal). + const keyHandler = vi.fn(); + + const { result } = renderHook(() => useKeypressContext(), { + wrapper: ({ children }) => + wrapper({ children, kittyProtocolEnabled: true }), + }); + act(() => { + result.current.subscribe(keyHandler); + }); + + act(() => { + stdin.emit('data', Buffer.from('\x1b[200~hello')); + }); + + // Wait long enough for the paste idle timeout to trigger recovery. + // Derived from the production constant so the test stays in sync + // if the timeout is ever tuned. + await new Promise((r) => setTimeout(r, PASTE_IDLE_TIMEOUT_MS + 200)); + + // A plain ASCII key after recovery must reach the handler. + act(() => { + stdin.emit('data', Buffer.from('z')); + }); + await new Promise((r) => setTimeout(r, 50)); + + const zSeen = keyHandler.mock.calls.some( + (c) => c[0]?.sequence === 'z' && c[0]?.paste !== true, + ); + expect(zSeen).toBe(true); + }); + it('should not process kitty sequences when kitty protocol is disabled', async () => { const keyHandler = vi.fn(); diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index d94d32fd8..e4ff4db0e 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -49,6 +49,15 @@ export const DRAG_COMPLETION_TIMEOUT_MS = 100; // Broadcast full path after 100m // - Too long: delayed recovery from interrupted sequences (e.g., IME interruptions) // Based on empirical testing with IME input patterns in VS Code integrated terminal. export const KITTY_SEQUENCE_TIMEOUT_MS = 200; + +// Paste idle timeout: auto-recovers from a stuck bracketed-paste mode +// when `paste-end` (`ESC[201~`) never arrives. Without this safety net, a +// lost paste-end marker leaves `isPaste = true` forever, every subsequent +// keystroke (including Ctrl+C) is silently buffered, and the only way to +// recover is to kill the terminal. 1000ms is long enough to cover slow +// chunked pastes on cold terminals yet short enough that users don't +// perceive the recovery as a hang. +export const PASTE_IDLE_TIMEOUT_MS = 1000; export const SINGLE_QUOTE = "'"; export const DOUBLE_QUOTE = '"'; @@ -167,6 +176,12 @@ export function KeypressProvider({ let isPaste = false; let pasteBuffer = Buffer.alloc(0); + // Set to true when paste mode is ended by something other than a + // received paste-end event (idle timeout or Ctrl+C escape). The next + // real paste-end event that arrives — if any — is then a stale echo + // and must be swallowed instead of producing a spurious empty paste. + let pasteAlreadyFlushed = false; + let pasteIdleTimeout: NodeJS.Timeout | null = null; const kittySequenceBufferRef = { current: '' }; let kittySequenceTimeout: NodeJS.Timeout | null = null; let backslashTimeout: NodeJS.Timeout | null = null; @@ -227,6 +242,48 @@ export function KeypressProvider({ kittySequenceBufferRef.current = ''; }; + const clearPasteIdleTimeout = () => { + if (pasteIdleTimeout) { + clearTimeout(pasteIdleTimeout); + pasteIdleTimeout = null; + } + }; + + // Force-flush a paste that has gone too long without its paste-end + // marker. Rather than dropping whatever the user typed, broadcast the + // buffered content as a regular paste event and reset state so the + // next keystroke is handled normally. + const forceFlushStuckPaste = () => { + clearPasteIdleTimeout(); + // Nothing to recover from: not in paste mode AND no buffered content. + // We still run when either condition is true — e.g. isPaste=true with + // an empty buffer (need to clear the flag) or isPaste=false with stale + // buffered content (e.g. after a race between Ctrl+C and the timer). + if (!isPaste && pasteBuffer.length === 0) return; + const buffered = pasteBuffer.toString(); + isPaste = false; + pasteBuffer = Buffer.alloc(0); + pasteAlreadyFlushed = true; + if (buffered.length > 0) { + broadcast({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: true, + sequence: buffered, + }); + } + }; + + const startPasteIdleTimeout = () => { + clearPasteIdleTimeout(); + pasteIdleTimeout = setTimeout( + forceFlushStuckPaste, + PASTE_IDLE_TIMEOUT_MS, + ); + }; + const createPrintableKey = (char: string): Key => { const printableName = char === ' ' @@ -607,11 +664,63 @@ export function KeypressProvider({ if (key.sequence === FOCUS_IN || key.sequence === FOCUS_OUT) { return; } + + // Ctrl+C is an always-available escape hatch. It MUST be processed + // before the `isPaste` branch below, otherwise a stuck paste mode + // (paste-start without paste-end) silently buffers every key — + // including Ctrl+C itself — and the user has no way to recover + // without killing the terminal. + const isCtrlCKey = + (key.ctrl && key.name === 'c') || + key.sequence === `${ESC}${KITTY_CTRL_C}`; + if (isCtrlCKey) { + if (isPaste || pasteBuffer.length > 0) { + isPaste = false; + pasteBuffer = Buffer.alloc(0); + pasteAlreadyFlushed = true; + clearPasteIdleTimeout(); + } + if (kittySequenceBufferRef.current && debugKeystrokeLogging) { + debugLogger.debug( + '[DEBUG] Kitty buffer cleared on Ctrl+C:', + kittySequenceBufferRef.current, + ); + } + clearKittyBufferAndTimeout(); + if (key.sequence === `${ESC}${KITTY_CTRL_C}`) { + broadcast({ + name: 'c', + ctrl: true, + meta: false, + shift: false, + paste: false, + sequence: key.sequence, + kittyProtocol: true, + }); + } else { + broadcast(key); + } + return; + } + if (key.name === 'paste-start') { isPaste = true; + pasteAlreadyFlushed = false; + startPasteIdleTimeout(); return; } if (key.name === 'paste-end') { + clearPasteIdleTimeout(); + // A stale paste-end may arrive after we force-flushed the paste + // via the idle timeout or Ctrl+C escape — swallow it so we don't + // broadcast a spurious empty/image paste event. + if (pasteAlreadyFlushed) { + // Reset for the next paste cycle. + pasteAlreadyFlushed = false; + isPaste = false; + pasteBuffer = Buffer.alloc(0); + return; + } isPaste = false; if (pasteBuffer.toString().length > 0) { broadcast({ @@ -641,6 +750,7 @@ export function KeypressProvider({ if (isPaste) { pasteBuffer = Buffer.concat([pasteBuffer, Buffer.from(key.sequence)]); + startPasteIdleTimeout(); return; } @@ -694,32 +804,8 @@ export function KeypressProvider({ return; } - if ( - (key.ctrl && key.name === 'c') || - key.sequence === `${ESC}${KITTY_CTRL_C}` - ) { - if (kittySequenceBufferRef.current && debugKeystrokeLogging) { - debugLogger.debug( - '[DEBUG] Kitty buffer cleared on Ctrl+C:', - kittySequenceBufferRef.current, - ); - } - clearKittyBufferAndTimeout(); - if (key.sequence === `${ESC}${KITTY_CTRL_C}`) { - broadcast({ - name: 'c', - ctrl: true, - meta: false, - shift: false, - paste: false, - sequence: key.sequence, - kittyProtocol: true, - }); - } else { - broadcast(key); - } - return; - } + // Ctrl+C is handled earlier, above the paste-state branches, so + // that it remains an escape hatch even when paste mode is stuck. if (kittyProtocolEnabled) { if ( @@ -1033,6 +1119,7 @@ export function KeypressProvider({ } clearKittyBufferAndTimeout(); + clearPasteIdleTimeout(); if (rawFlushTimeout) { clearTimeout(rawFlushTimeout);