fix(cli): recover from stuck bracketed-paste mode and keep Ctrl+C reachable (#3181)

* fix(cli): recover from stuck bracketed-paste mode and keep Ctrl+C reachable

If bracketed-paste-start (`ESC[200~`) arrives but paste-end (`ESC[201~`)
is lost — for example on Ghostty + Sogou pinyin on macOS, which a user
reported in a working directory with only three files — `isPaste` stays
`true` forever. The order of checks inside `handleKeypress`:

  1. `key.name === 'paste-start'`  → set isPaste = true, return
  2. `key.name === 'paste-end'`    → reset + flush + return
  3. `if (isPaste) { pasteBuffer.append; return }`
  4. backslash / return handling
  5. arrow keys
  6. Ctrl+C

means that every subsequent key — **including Ctrl+C** — is appended to
the paste buffer and silently returned. The user has no keyboard escape
hatch; they must kill the process / restart the terminal.

Two layered fixes:

1. **Ctrl+C escape hatch.** Move the Ctrl+C check above the `isPaste`
   branch and clear paste state when it fires. Ctrl+C now always reaches
   the broadcast regardless of any stuck paste state.

2. **Idle timeout auto-recovery.** Add `PASTE_IDLE_TIMEOUT_MS = 1000`.
   Start the timer on paste-start, reset it on each paste content byte,
   clear it on paste-end. If the timer fires, force-flush the paste
   buffer as a regular paste event and reset `isPaste`. A
   `pasteAlreadyFlushed` flag guards against a stale paste-end event
   arriving later and broadcasting a spurious empty/image paste.

Two regression tests cover both paths:
- `Ctrl+C escapes a paste mode that never received its paste-end marker`
- `auto-recovers from a stuck paste mode via idle timeout`

Both fail on main and pass with this change. Full cli test suite:
3968 pass / 7 skipped, no regressions.

* test(cli): derive paste idle-timeout wait from PASTE_IDLE_TIMEOUT_MS

Address review feedback: the auto-recovery regression test hard-coded a
1500ms sleep instead of referencing the production constant. Import
PASTE_IDLE_TIMEOUT_MS and derive the wait as `constant + 200ms buffer`
so the test stays in sync if the production timeout is ever tuned.

* docs(cli): clarify paste state-machine comments from review feedback

Address review bot nits:
- forceFlushStuckPaste: explain why the empty-guard is asymmetric
  (isPaste/buffer can be out of sync after a Ctrl+C vs idle-timeout race)
- paste-end handler: note that pasteAlreadyFlushed=false is the reset
  for the next paste cycle
- auto-recovers test: frame it as the "automatic recovery safety net"
  counterpart to the manual Ctrl+C escape test above

Comments only, no behavioural change.
This commit is contained in:
Shaojin Wen 2026-04-13 17:02:50 +08:00 committed by GitHub
parent 53a9c0a28a
commit fd6c846979
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 187 additions and 26 deletions

View file

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

View file

@ -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);