mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 11:41:04 +00:00
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:
parent
53a9c0a28a
commit
fd6c846979
2 changed files with 187 additions and 26 deletions
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue