mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
511 lines
14 KiB
TypeScript
511 lines
14 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~`;
|
|
const RAW_PASTE_DEBOUNCE_MS = 8; // Debounce window to coalesce fragmented paste chunks
|
|
const RAW_PASTE_BUFFER_LIMIT = 32;
|
|
|
|
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;
|
|
let rawDataBuffer = Buffer.alloc(0);
|
|
let rawFlushTimeout: NodeJS.Timeout | null = null;
|
|
|
|
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 clearRawFlushTimeout = () => {
|
|
if (rawFlushTimeout) {
|
|
clearTimeout(rawFlushTimeout);
|
|
rawFlushTimeout = null;
|
|
}
|
|
};
|
|
|
|
const createPasteKeyEvent = (
|
|
name: 'paste-start' | 'paste-end' | '' = '',
|
|
sequence: string = '',
|
|
): Key => ({
|
|
name,
|
|
ctrl: false,
|
|
meta: false,
|
|
shift: false,
|
|
paste: false,
|
|
sequence,
|
|
});
|
|
|
|
const flushRawBuffer = () => {
|
|
if (!rawDataBuffer.length) {
|
|
return;
|
|
}
|
|
|
|
const pasteModePrefixBuffer = Buffer.from(PASTE_MODE_PREFIX);
|
|
const pasteModeSuffixBuffer = Buffer.from(PASTE_MODE_SUFFIX);
|
|
const data = rawDataBuffer;
|
|
let cursor = 0;
|
|
|
|
while (cursor < data.length) {
|
|
const prefixPos = data.indexOf(pasteModePrefixBuffer, cursor);
|
|
const suffixPos = data.indexOf(pasteModeSuffixBuffer, cursor);
|
|
const hasPrefix =
|
|
prefixPos !== -1 &&
|
|
prefixPos + pasteModePrefixBuffer.length <= data.length;
|
|
const hasSuffix =
|
|
suffixPos !== -1 &&
|
|
suffixPos + pasteModeSuffixBuffer.length <= data.length;
|
|
|
|
let markerPos = -1;
|
|
let markerLength = 0;
|
|
let markerType: 'prefix' | 'suffix' | null = null;
|
|
|
|
if (hasPrefix && (!hasSuffix || prefixPos < suffixPos)) {
|
|
markerPos = prefixPos;
|
|
markerLength = pasteModePrefixBuffer.length;
|
|
markerType = 'prefix';
|
|
} else if (hasSuffix) {
|
|
markerPos = suffixPos;
|
|
markerLength = pasteModeSuffixBuffer.length;
|
|
markerType = 'suffix';
|
|
}
|
|
|
|
if (markerPos === -1) {
|
|
break;
|
|
}
|
|
|
|
const nextData = data.slice(cursor, markerPos);
|
|
if (nextData.length > 0) {
|
|
keypressStream.write(nextData);
|
|
}
|
|
if (markerType === 'prefix') {
|
|
handleKeypress(undefined, createPasteKeyEvent('paste-start'));
|
|
} else if (markerType === 'suffix') {
|
|
handleKeypress(undefined, createPasteKeyEvent('paste-end'));
|
|
}
|
|
cursor = markerPos + markerLength;
|
|
}
|
|
|
|
rawDataBuffer = data.slice(cursor);
|
|
|
|
if (rawDataBuffer.length === 0) {
|
|
return;
|
|
}
|
|
|
|
if (rawDataBuffer.length <= 2 || isPaste) {
|
|
keypressStream.write(rawDataBuffer);
|
|
} else {
|
|
// Flush raw data buffer as a paste event
|
|
handleKeypress(undefined, createPasteKeyEvent('paste-start'));
|
|
keypressStream.write(rawDataBuffer);
|
|
handleKeypress(undefined, createPasteKeyEvent('paste-end'));
|
|
}
|
|
|
|
rawDataBuffer = Buffer.alloc(0);
|
|
clearRawFlushTimeout();
|
|
};
|
|
|
|
const handleRawKeypress = (_data: Buffer) => {
|
|
const data = Buffer.isBuffer(_data) ? _data : Buffer.from(_data, 'utf8');
|
|
|
|
// Buffer the incoming data
|
|
rawDataBuffer = Buffer.concat([rawDataBuffer, data]);
|
|
|
|
// If buffered data exceeds limit, flush immediately
|
|
if (rawDataBuffer.length > RAW_PASTE_BUFFER_LIMIT) {
|
|
clearRawFlushTimeout();
|
|
flushRawBuffer();
|
|
return;
|
|
}
|
|
|
|
clearRawFlushTimeout();
|
|
|
|
rawFlushTimeout = setTimeout(flushRawBuffer, RAW_PASTE_DEBOUNCE_MS);
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
if (rawFlushTimeout) {
|
|
clearTimeout(rawFlushTimeout);
|
|
rawFlushTimeout = 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>
|
|
);
|
|
}
|