Files
qwen-code/packages/cli/src/ui/contexts/KeypressContext.tsx
2025-09-15 16:03:51 +08:00

447 lines
12 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
Config,
KittySequenceOverflowEvent,
logKittySequenceOverflow,
} from '@qwen-code/qwen-code-core';
import { useStdin } from 'ink';
import React, {
createContext,
useCallback,
useContext,
useEffect,
useRef,
} from 'react';
import readline from 'readline';
import { PassThrough } from 'stream';
import {
BACKSLASH_ENTER_DETECTION_WINDOW_MS,
KITTY_CTRL_C,
KITTY_KEYCODE_BACKSPACE,
KITTY_KEYCODE_ENTER,
KITTY_KEYCODE_NUMPAD_ENTER,
KITTY_KEYCODE_TAB,
MAX_KITTY_SEQUENCE_LENGTH,
} from '../utils/platformConstants.js';
import { FOCUS_IN, FOCUS_OUT } from '../hooks/useFocus.js';
const ESC = '\u001B';
export const PASTE_MODE_PREFIX = `${ESC}[200~`;
export const PASTE_MODE_SUFFIX = `${ESC}[201~`;
export interface Key {
name: string;
ctrl: boolean;
meta: boolean;
shift: boolean;
paste: boolean;
sequence: string;
kittyProtocol?: boolean;
}
export type KeypressHandler = (key: Key) => void;
interface KeypressContextValue {
subscribe: (handler: KeypressHandler) => void;
unsubscribe: (handler: KeypressHandler) => void;
}
const KeypressContext = createContext<KeypressContextValue | undefined>(
undefined,
);
export function useKeypressContext() {
const context = useContext(KeypressContext);
if (!context) {
throw new Error(
'useKeypressContext must be used within a KeypressProvider',
);
}
return context;
}
export function KeypressProvider({
children,
kittyProtocolEnabled,
config,
}: {
children: React.ReactNode;
kittyProtocolEnabled: boolean;
config?: Config;
}) {
const { stdin, setRawMode } = useStdin();
const subscribers = useRef<Set<KeypressHandler>>(new Set()).current;
const subscribe = useCallback(
(handler: KeypressHandler) => {
subscribers.add(handler);
},
[subscribers],
);
const unsubscribe = useCallback(
(handler: KeypressHandler) => {
subscribers.delete(handler);
},
[subscribers],
);
useEffect(() => {
setRawMode(true);
const keypressStream = new PassThrough();
let usePassthrough = false;
const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10);
const isWindows = process.platform === 'win32';
// On Windows, Node's readline keypress stream often loses bracketed paste
// boundaries, causing multi-line pastes to be delivered as plain Return
// key events. This leads to accidental submits on Enter within pasted text.
// Force passthrough on Windows to parse raw bytes and detect ESC[200~...201~.
if (
nodeMajorVersion < 20 ||
isWindows ||
process.env['PASTE_WORKAROUND'] === '1' ||
process.env['PASTE_WORKAROUND'] === 'true'
) {
usePassthrough = true;
}
let isPaste = false;
let pasteBuffer = Buffer.alloc(0);
let kittySequenceBuffer = '';
let backslashTimeout: NodeJS.Timeout | null = null;
let waitingForEnterAfterBackslash = false;
const parseKittySequence = (sequence: string): Key | null => {
const kittyPattern = new RegExp(`^${ESC}\\[(\\d+)(;(\\d+))?([u~])$`);
const match = sequence.match(kittyPattern);
if (!match) return null;
const keyCode = parseInt(match[1], 10);
const modifiers = match[3] ? parseInt(match[3], 10) : 1;
const modifierBits = modifiers - 1;
const shift = (modifierBits & 1) === 1;
const alt = (modifierBits & 2) === 2;
const ctrl = (modifierBits & 4) === 4;
if (keyCode === 27) {
return {
name: 'escape',
ctrl,
meta: alt,
shift,
paste: false,
sequence,
kittyProtocol: true,
};
}
if (keyCode === KITTY_KEYCODE_TAB) {
return {
name: 'tab',
ctrl,
meta: alt,
shift,
paste: false,
sequence,
kittyProtocol: true,
};
}
if (keyCode === KITTY_KEYCODE_BACKSPACE) {
return {
name: 'backspace',
ctrl,
meta: alt,
shift,
paste: false,
sequence,
kittyProtocol: true,
};
}
if (
keyCode === KITTY_KEYCODE_ENTER ||
keyCode === KITTY_KEYCODE_NUMPAD_ENTER
) {
return {
name: 'return',
ctrl,
meta: alt,
shift,
paste: false,
sequence,
kittyProtocol: true,
};
}
if (keyCode >= 97 && keyCode <= 122 && ctrl) {
const letter = String.fromCharCode(keyCode);
return {
name: letter,
ctrl: true,
meta: alt,
shift,
paste: false,
sequence,
kittyProtocol: true,
};
}
return null;
};
const broadcast = (key: Key) => {
for (const handler of subscribers) {
handler(key);
}
};
const handleKeypress = (_: unknown, key: Key) => {
if (key.name === 'paste-start') {
isPaste = true;
return;
}
if (key.name === 'paste-end') {
isPaste = false;
broadcast({
name: '',
ctrl: false,
meta: false,
shift: false,
paste: true,
sequence: pasteBuffer.toString(),
});
pasteBuffer = Buffer.alloc(0);
return;
}
if (isPaste) {
pasteBuffer = Buffer.concat([pasteBuffer, Buffer.from(key.sequence)]);
return;
}
if (key.name === 'return' && waitingForEnterAfterBackslash) {
if (backslashTimeout) {
clearTimeout(backslashTimeout);
backslashTimeout = null;
}
waitingForEnterAfterBackslash = false;
broadcast({
...key,
shift: true,
sequence: '\r', // Corrected escaping for newline
});
return;
}
if (key.sequence === '\\' && !key.name) {
// Corrected escaping for backslash
waitingForEnterAfterBackslash = true;
backslashTimeout = setTimeout(() => {
waitingForEnterAfterBackslash = false;
backslashTimeout = null;
broadcast(key);
}, BACKSLASH_ENTER_DETECTION_WINDOW_MS);
return;
}
if (waitingForEnterAfterBackslash && key.name !== 'return') {
if (backslashTimeout) {
clearTimeout(backslashTimeout);
backslashTimeout = null;
}
waitingForEnterAfterBackslash = false;
broadcast({
name: '',
sequence: '\\',
ctrl: false,
meta: false,
shift: false,
paste: false,
});
}
if (['up', 'down', 'left', 'right'].includes(key.name)) {
broadcast(key);
return;
}
if (
(key.ctrl && key.name === 'c') ||
key.sequence === `${ESC}${KITTY_CTRL_C}`
) {
kittySequenceBuffer = '';
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 (kittyProtocolEnabled) {
if (
kittySequenceBuffer ||
(key.sequence.startsWith(`${ESC}[`) &&
!key.sequence.startsWith(PASTE_MODE_PREFIX) &&
!key.sequence.startsWith(PASTE_MODE_SUFFIX) &&
!key.sequence.startsWith(FOCUS_IN) &&
!key.sequence.startsWith(FOCUS_OUT))
) {
kittySequenceBuffer += key.sequence;
const kittyKey = parseKittySequence(kittySequenceBuffer);
if (kittyKey) {
kittySequenceBuffer = '';
broadcast(kittyKey);
return;
}
if (config?.getDebugMode()) {
const codes = Array.from(kittySequenceBuffer).map((ch) =>
ch.charCodeAt(0),
);
console.warn('Kitty sequence buffer has char codes:', codes);
}
if (kittySequenceBuffer.length > MAX_KITTY_SEQUENCE_LENGTH) {
if (config) {
const event = new KittySequenceOverflowEvent(
kittySequenceBuffer.length,
kittySequenceBuffer,
);
logKittySequenceOverflow(config, event);
}
kittySequenceBuffer = '';
} else {
return;
}
}
}
if (key.name === 'return' && key.sequence === `${ESC}\r`) {
key.meta = true;
}
broadcast({ ...key, paste: isPaste });
};
const handleRawKeypress = (data: Buffer) => {
const pasteModePrefixBuffer = Buffer.from(PASTE_MODE_PREFIX);
const pasteModeSuffixBuffer = Buffer.from(PASTE_MODE_SUFFIX);
let pos = 0;
while (pos < data.length) {
const prefixPos = data.indexOf(pasteModePrefixBuffer, pos);
const suffixPos = data.indexOf(pasteModeSuffixBuffer, pos);
const isPrefixNext =
prefixPos !== -1 && (suffixPos === -1 || prefixPos < suffixPos);
const isSuffixNext =
suffixPos !== -1 && (prefixPos === -1 || suffixPos < prefixPos);
let nextMarkerPos = -1;
let markerLength = 0;
if (isPrefixNext) {
nextMarkerPos = prefixPos;
} else if (isSuffixNext) {
nextMarkerPos = suffixPos;
}
markerLength = pasteModeSuffixBuffer.length;
if (nextMarkerPos === -1) {
keypressStream.write(data.slice(pos));
return;
}
const nextData = data.slice(pos, nextMarkerPos);
if (nextData.length > 0) {
keypressStream.write(nextData);
}
const createPasteKeyEvent = (
name: 'paste-start' | 'paste-end',
): Key => ({
name,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: '',
});
if (isPrefixNext) {
handleKeypress(undefined, createPasteKeyEvent('paste-start'));
} else if (isSuffixNext) {
handleKeypress(undefined, createPasteKeyEvent('paste-end'));
}
pos = nextMarkerPos + markerLength;
}
};
let rl: readline.Interface;
if (usePassthrough) {
rl = readline.createInterface({
input: keypressStream,
escapeCodeTimeout: 0,
});
readline.emitKeypressEvents(keypressStream, rl);
keypressStream.on('keypress', handleKeypress);
stdin.on('data', handleRawKeypress);
} else {
rl = readline.createInterface({ input: stdin, escapeCodeTimeout: 0 });
readline.emitKeypressEvents(stdin, rl);
stdin.on('keypress', handleKeypress);
}
return () => {
if (usePassthrough) {
keypressStream.removeListener('keypress', handleKeypress);
stdin.removeListener('data', handleRawKeypress);
} else {
stdin.removeListener('keypress', handleKeypress);
}
rl.close();
// Restore the terminal to its original state.
setRawMode(false);
if (backslashTimeout) {
clearTimeout(backslashTimeout);
backslashTimeout = null;
}
// Flush any pending paste data to avoid data loss on exit.
if (isPaste) {
broadcast({
name: '',
ctrl: false,
meta: false,
shift: false,
paste: true,
sequence: pasteBuffer.toString(),
});
pasteBuffer = Buffer.alloc(0);
}
};
}, [stdin, setRawMode, kittyProtocolEnabled, config, subscribers]);
return (
<KeypressContext.Provider value={{ subscribe, unsubscribe }}>
{children}
</KeypressContext.Provider>
);
}