qwen-code/packages/cli/src/ui/contexts/KeypressContext.tsx
DragonnZhang 7886ec6c8d fix(keypress): handle unsupported Kitty CSI-u keys and recover plain text
- Add helper functions for better code organization (createPrintableKey,
  getCompleteCsiSequenceLength, parsePlainTextPrefix)
- Drop unsupported Kitty CSI-u keys without blocking subsequent input
- Recover plain text that arrives in same chunk after unsupported CSI-u keys
- Add comprehensive tests for edge cases (CAPS_LOCK, event metadata variants)

Improves robustness of Kitty keyboard protocol parsing by gracefully
handling unsupported key codes and ensuring plain text input is not lost.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-17 14:02:41 +08:00

1011 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '@qwen-code/qwen-code-core';
import {
KittySequenceOverflowEvent,
logKittySequenceOverflow,
createDebugLogger,
} from '@qwen-code/qwen-code-core';
import { useStdin } from 'ink';
import type React from 'react';
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
} from 'react';
import readline from 'node:readline';
import { PassThrough } from 'node:stream';
import {
BACKSLASH_ENTER_DETECTION_WINDOW_MS,
CHAR_CODE_ESC,
KITTY_CTRL_C,
KITTY_KEYCODE_BACKSPACE,
KITTY_KEYCODE_ENTER,
KITTY_KEYCODE_NUMPAD_ENTER,
KITTY_KEYCODE_TAB,
MAX_KITTY_SEQUENCE_LENGTH,
KITTY_MODIFIER_BASE,
KITTY_MODIFIER_EVENT_TYPES_OFFSET,
MODIFIER_SHIFT_BIT,
MODIFIER_ALT_BIT,
MODIFIER_CTRL_BIT,
} from '../utils/platformConstants.js';
import { clipboardHasImage } from '../utils/clipboardUtils.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 const DRAG_COMPLETION_TIMEOUT_MS = 100; // Broadcast full path after 100ms if no more input
export const SINGLE_QUOTE = "'";
export const DOUBLE_QUOTE = '"';
// Kitty keypad private-use keycodes (0xE000-0xE026)
// Reference: https://sw.kovidgoyal.net/kitty/keyboard-protocol/#functional-key-definitions
const KITTY_KEYPAD_PRINTABLE_KEYCODE_TO_CHAR: Record<number, string> = {
57399: '0',
57400: '1',
57401: '2',
57402: '3',
57403: '4',
57404: '5',
57405: '6',
57406: '7',
57407: '8',
57408: '9',
57409: '.',
57410: '/',
57411: '*',
57412: '-',
57413: '+',
// 57414 is keypad Enter - handled separately via CSI~ sequence
57415: '=',
57416: ',',
};
const KITTY_KEYPAD_FUNCTIONAL_KEYCODE_TO_NAME: Record<number, string> = {
57417: 'left',
57418: 'right',
57419: 'up',
57420: 'down',
57421: 'pageup',
57422: 'pagedown',
57423: 'home',
57424: 'end',
57425: 'insert',
57426: 'delete',
};
export interface Key {
name: string;
ctrl: boolean;
meta: boolean;
shift: boolean;
paste: boolean;
sequence: string;
kittyProtocol?: boolean;
pasteImage?: boolean;
}
export type KeypressHandler = (key: Key) => void;
interface KeypressContextValue {
subscribe: (handler: KeypressHandler) => void;
unsubscribe: (handler: KeypressHandler) => void;
pasteWorkaround: boolean;
}
const KeypressContext = createContext<KeypressContextValue | undefined>(
undefined,
);
const debugLogger = createDebugLogger('KEYPRESS');
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,
pasteWorkaround = false,
config,
debugKeystrokeLogging,
}: {
children?: React.ReactNode;
kittyProtocolEnabled: boolean;
pasteWorkaround?: boolean;
config?: Config;
debugKeystrokeLogging?: boolean;
}) {
const { stdin, setRawMode } = useStdin();
const subscribers = useRef<Set<KeypressHandler>>(new Set()).current;
const isDraggingRef = useRef(false);
const dragBufferRef = useRef('');
const draggingTimerRef = useRef<NodeJS.Timeout | null>(null);
const subscribe = useCallback(
(handler: KeypressHandler) => {
subscribers.add(handler);
},
[subscribers],
);
const unsubscribe = useCallback(
(handler: KeypressHandler) => {
subscribers.delete(handler);
},
[subscribers],
);
useEffect(() => {
const clearDraggingTimer = () => {
if (draggingTimerRef.current) {
clearTimeout(draggingTimerRef.current);
draggingTimerRef.current = null;
}
};
const wasRaw = stdin.isRaw;
if (wasRaw === false) {
setRawMode(true);
}
const keypressStream = new PassThrough();
let usePassthrough = false;
// Use passthrough mode when pasteWorkaround is enabled,
if (pasteWorkaround) {
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 createPrintableKey = (char: string): Key => {
const printableName =
char === ' '
? 'space'
: /^[A-Za-z]$/.test(char)
? char.toLowerCase()
: char;
return {
name: printableName,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: char,
kittyProtocol: true,
};
};
// Parse a single complete kitty sequence from the start (prefix) of the
// buffer and return both the Key and the number of characters consumed.
// This lets us "peel off" one complete event when multiple sequences arrive
// in a single chunk, preventing buffer overflow and fragmentation.
// Parse a single complete kitty/parameterized/legacy sequence from the start
// of the buffer and return both the parsed Key and the number of characters
// consumed. This enables peel-and-continue parsing for batched input.
const parseKittyPrefix = (
buffer: string,
): { key: Key; length: number } | null => {
// In older terminals ESC [ Z was used as Cursor Backward Tabulation (CBT)
// In newer terminals the same functionality of key combination for moving
// backward through focusable elements is Shift+Tab, hence we will
// map ESC [ Z to Shift+Tab
// 0) Reverse Tab (legacy): ESC [ Z
// Treat as Shift+Tab for UI purposes.
// Regex parts:
// ^ - start of buffer
// ESC [ - CSI introducer
// Z - legacy reverse tab
const revTabLegacy = new RegExp(`^${ESC}\\[Z`);
let m = buffer.match(revTabLegacy);
if (m) {
return {
key: {
name: 'tab',
ctrl: false,
meta: false,
shift: true,
paste: false,
sequence: buffer.slice(0, m[0].length),
kittyProtocol: true,
},
length: m[0].length,
};
}
// 1) Reverse Tab (parameterized): ESC [ 1 ; <mods> Z
// Parameterized reverse Tab: ESC [ 1 ; <mods> Z
const revTabParam = new RegExp(`^${ESC}\\[1;(\\d+)Z`);
m = buffer.match(revTabParam);
if (m) {
let mods = parseInt(m[1], 10);
if (mods >= KITTY_MODIFIER_EVENT_TYPES_OFFSET) {
mods -= KITTY_MODIFIER_EVENT_TYPES_OFFSET;
}
const bits = mods - KITTY_MODIFIER_BASE;
const alt = (bits & MODIFIER_ALT_BIT) === MODIFIER_ALT_BIT;
const ctrl = (bits & MODIFIER_CTRL_BIT) === MODIFIER_CTRL_BIT;
return {
key: {
name: 'tab',
ctrl,
meta: alt,
// Reverse tab implies Shift behavior; force shift regardless of mods
shift: true,
paste: false,
sequence: buffer.slice(0, m[0].length),
kittyProtocol: true,
},
length: m[0].length,
};
}
// 2) Parameterized functional: ESC [ 1 ; <mods> (A|B|C|D|H|F|P|Q|R|S)
// 2) Parameterized functional: ESC [ 1 ; <mods> (A|B|C|D|H|F|P|Q|R|S)
// Arrows, Home/End, F1F4 with modifiers encoded in <mods>.
const arrowPrefix = new RegExp(`^${ESC}\\[1;(\\d+)([ABCDHFPQSR])`);
m = buffer.match(arrowPrefix);
if (m) {
let mods = parseInt(m[1], 10);
if (mods >= KITTY_MODIFIER_EVENT_TYPES_OFFSET) {
mods -= KITTY_MODIFIER_EVENT_TYPES_OFFSET;
}
const bits = mods - KITTY_MODIFIER_BASE;
const shift = (bits & MODIFIER_SHIFT_BIT) === MODIFIER_SHIFT_BIT;
const alt = (bits & MODIFIER_ALT_BIT) === MODIFIER_ALT_BIT;
const ctrl = (bits & MODIFIER_CTRL_BIT) === MODIFIER_CTRL_BIT;
const sym = m[2];
const symbolToName: { [k: string]: string } = {
A: 'up',
B: 'down',
C: 'right',
D: 'left',
H: 'home',
F: 'end',
P: 'f1',
Q: 'f2',
R: 'f3',
S: 'f4',
};
const name = symbolToName[sym] || '';
if (!name) return null;
return {
key: {
name,
ctrl,
meta: alt,
shift,
paste: false,
sequence: buffer.slice(0, m[0].length),
kittyProtocol: true,
},
length: m[0].length,
};
}
// 3) CSI-u form: ESC [ <code> ; <mods> (u|~)
// 3) CSI-u and tilde-coded functional keys: ESC [ <code> ; <mods> (u|~)
// 'u' terminator: Kitty CSI-u; '~' terminator: tilde-coded function keys.
const csiUPrefix = new RegExp(`^${ESC}\\[(\\d+)(;(\\d+))?([u~])`);
m = buffer.match(csiUPrefix);
if (m) {
const keyCode = parseInt(m[1], 10);
let modifiers = m[3] ? parseInt(m[3], 10) : KITTY_MODIFIER_BASE;
if (modifiers >= KITTY_MODIFIER_EVENT_TYPES_OFFSET) {
modifiers -= KITTY_MODIFIER_EVENT_TYPES_OFFSET;
}
const modifierBits = modifiers - KITTY_MODIFIER_BASE;
const shift =
(modifierBits & MODIFIER_SHIFT_BIT) === MODIFIER_SHIFT_BIT;
const alt = (modifierBits & MODIFIER_ALT_BIT) === MODIFIER_ALT_BIT;
const ctrl = (modifierBits & MODIFIER_CTRL_BIT) === MODIFIER_CTRL_BIT;
const terminator = m[4];
// Tilde-coded functional keys (Delete, Insert, PageUp/Down, Home/End)
if (terminator === '~') {
let name: string | null = null;
switch (keyCode) {
case 1:
name = 'home';
break;
case 2:
name = 'insert';
break;
case 3:
name = 'delete';
break;
case 4:
name = 'end';
break;
case 5:
name = 'pageup';
break;
case 6:
name = 'pagedown';
break;
default:
break;
}
if (name) {
return {
key: {
name,
ctrl,
meta: alt,
shift,
paste: false,
sequence: buffer.slice(0, m[0].length),
kittyProtocol: true,
},
length: m[0].length,
};
}
}
const kittyKeyCodeToName: { [key: number]: string } = {
[CHAR_CODE_ESC]: 'escape',
[KITTY_KEYCODE_TAB]: 'tab',
[KITTY_KEYCODE_BACKSPACE]: 'backspace',
[KITTY_KEYCODE_ENTER]: 'return',
[KITTY_KEYCODE_NUMPAD_ENTER]: 'return',
};
const name = kittyKeyCodeToName[keyCode];
if (name) {
return {
key: {
name,
ctrl,
meta: alt,
shift,
paste: false,
sequence: buffer.slice(0, m[0].length),
kittyProtocol: true,
},
length: m[0].length,
};
}
if (!ctrl) {
const keypadChar = KITTY_KEYPAD_PRINTABLE_KEYCODE_TO_CHAR[keyCode];
if (keypadChar) {
return {
key: {
name: keypadChar,
ctrl: false,
meta: alt,
shift,
paste: false,
sequence: keypadChar,
kittyProtocol: true,
},
length: m[0].length,
};
}
}
const keypadName = KITTY_KEYPAD_FUNCTIONAL_KEYCODE_TO_NAME[keyCode];
if (keypadName) {
return {
key: {
name: keypadName,
ctrl,
meta: alt,
shift,
paste: false,
sequence: buffer.slice(0, m[0].length),
kittyProtocol: true,
},
length: m[0].length,
};
}
// Printable CSI-u keys (including space) should behave like regular
// character input so downstream text inputs receive the literal char.
// Kitty uses the Unicode private use area for some functional keys
// such as keypad events, so exclude that range from generic printable
// conversion and handle mapped keys explicitly above.
if (
terminator === 'u' &&
!ctrl &&
keyCode >= 32 &&
keyCode !== 127 &&
keyCode <= 0x10ffff &&
!(keyCode >= 0xe000 && keyCode <= 0xf8ff)
) {
return {
key: {
...createPrintableKey(String.fromCodePoint(keyCode)),
meta: alt,
shift,
},
length: m[0].length,
};
}
// Ctrl+letters
if (
ctrl &&
keyCode >= 'a'.charCodeAt(0) &&
keyCode <= 'z'.charCodeAt(0)
) {
const letter = String.fromCharCode(keyCode);
return {
key: {
name: letter,
ctrl: true,
meta: alt,
shift,
paste: false,
sequence: buffer.slice(0, m[0].length),
kittyProtocol: true,
},
length: m[0].length,
};
}
}
// 4) Legacy function keys (no parameters): ESC [ (A|B|C|D|H|F)
// Arrows + Home/End without modifiers.
const legacyFuncKey = new RegExp(`^${ESC}\\[([ABCDHF])`);
m = buffer.match(legacyFuncKey);
if (m) {
const sym = m[1];
const nameMap: { [key: string]: string } = {
A: 'up',
B: 'down',
C: 'right',
D: 'left',
H: 'home',
F: 'end',
};
const name = nameMap[sym]!;
return {
key: {
name,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: buffer.slice(0, m[0].length),
kittyProtocol: true,
},
length: m[0].length,
};
}
return null;
};
const getCompleteCsiSequenceLength = (buffer: string): number | null => {
if (!buffer.startsWith(`${ESC}[`)) {
return null;
}
for (let i = 2; i < buffer.length; i++) {
const code = buffer.charCodeAt(i);
if (code >= 0x40 && code <= 0x7e) {
return i + 1;
}
if (code < 0x20 || code > 0x3f) {
return 0;
}
}
return null;
};
const parsePlainTextPrefix = (
buffer: string,
): { key: Key; length: number } | null => {
if (!buffer || buffer.startsWith(ESC)) {
return null;
}
const [char] = Array.from(buffer);
if (!char) {
return null;
}
return {
key: createPrintableKey(char),
length: char.length,
};
};
const broadcast = (key: Key) => {
for (const handler of subscribers) {
handler(key);
}
};
const handleKeypress = async (_: unknown, key: Key) => {
if (key.sequence === FOCUS_IN || key.sequence === FOCUS_OUT) {
return;
}
if (key.name === 'paste-start') {
isPaste = true;
return;
}
if (key.name === 'paste-end') {
isPaste = false;
if (pasteBuffer.toString().length > 0) {
broadcast({
name: '',
ctrl: false,
meta: false,
shift: false,
paste: true,
sequence: pasteBuffer.toString(),
});
} else {
const hasImage = await clipboardHasImage();
broadcast({
name: '',
ctrl: false,
meta: false,
shift: false,
paste: true,
pasteImage: hasImage,
sequence: pasteBuffer.toString(),
});
}
pasteBuffer = Buffer.alloc(0);
return;
}
if (isPaste) {
pasteBuffer = Buffer.concat([pasteBuffer, Buffer.from(key.sequence)]);
return;
}
if (
key.sequence === SINGLE_QUOTE ||
key.sequence === DOUBLE_QUOTE ||
isDraggingRef.current
) {
isDraggingRef.current = true;
dragBufferRef.current += key.sequence;
clearDraggingTimer();
draggingTimerRef.current = setTimeout(() => {
isDraggingRef.current = false;
const seq = dragBufferRef.current;
dragBufferRef.current = '';
if (seq) {
broadcast({ ...key, name: '', paste: true, sequence: seq });
}
}, DRAG_COMPLETION_TIMEOUT_MS);
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}`
) {
if (kittySequenceBuffer && debugKeystrokeLogging) {
debugLogger.debug(
'[DEBUG] Kitty buffer cleared on Ctrl+C:',
kittySequenceBuffer,
);
}
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;
if (debugKeystrokeLogging) {
debugLogger.debug(
'[DEBUG] Kitty buffer accumulating:',
kittySequenceBuffer,
);
}
// Try to peel off as many complete sequences as are available at the
// start of the buffer. This handles batched inputs cleanly. If the
// prefix is incomplete or invalid, skip to the next CSI introducer
// (ESC[) so that a following valid sequence can still be parsed.
let bufferedInputHandled = false;
while (kittySequenceBuffer) {
const parsed = parseKittyPrefix(kittySequenceBuffer);
if (parsed) {
if (debugKeystrokeLogging) {
const parsedSequence = kittySequenceBuffer.slice(
0,
parsed.length,
);
if (kittySequenceBuffer.length > parsed.length) {
debugLogger.debug(
'[DEBUG] Kitty sequence parsed successfully (prefix):',
parsedSequence,
);
} else {
debugLogger.debug(
'[DEBUG] Kitty sequence parsed successfully:',
parsedSequence,
);
}
}
// Consume the parsed prefix and broadcast it.
kittySequenceBuffer = kittySequenceBuffer.slice(parsed.length);
broadcast(parsed.key);
bufferedInputHandled = true;
continue;
}
const completeUnsupportedCsiLength =
getCompleteCsiSequenceLength(kittySequenceBuffer);
if (completeUnsupportedCsiLength) {
if (debugKeystrokeLogging) {
debugLogger.debug(
'[DEBUG] Dropping unsupported complete CSI sequence:',
kittySequenceBuffer.slice(0, completeUnsupportedCsiLength),
);
}
kittySequenceBuffer = kittySequenceBuffer.slice(
completeUnsupportedCsiLength,
);
bufferedInputHandled = true;
continue;
}
const plainTextPrefix = parsePlainTextPrefix(kittySequenceBuffer);
if (plainTextPrefix) {
if (debugKeystrokeLogging) {
debugLogger.debug(
'[DEBUG] Recovered plain text after kitty sequence:',
plainTextPrefix.key.sequence,
);
}
kittySequenceBuffer = kittySequenceBuffer.slice(
plainTextPrefix.length,
);
broadcast(plainTextPrefix.key);
bufferedInputHandled = true;
continue;
}
// Look for the next potential CSI start beyond index 0
const nextStart = kittySequenceBuffer.indexOf(`${ESC}[`, 1);
if (nextStart > 0) {
if (debugKeystrokeLogging) {
debugLogger.debug(
'[DEBUG] Skipping incomplete/invalid CSI prefix:',
kittySequenceBuffer.slice(0, nextStart),
);
}
kittySequenceBuffer = kittySequenceBuffer.slice(nextStart);
bufferedInputHandled = true;
continue;
}
break;
}
if (bufferedInputHandled) return;
if (config?.getDebugMode() || debugKeystrokeLogging) {
const codes = Array.from(kittySequenceBuffer).map((ch) =>
ch.charCodeAt(0),
);
debugLogger.warn('Kitty sequence buffer has char codes:', codes);
}
if (kittySequenceBuffer.length > MAX_KITTY_SEQUENCE_LENGTH) {
if (debugKeystrokeLogging) {
debugLogger.debug(
'[DEBUG] Kitty buffer overflow, clearing:',
kittySequenceBuffer,
);
}
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 && rawDataBuffer.includes(0x0d)) ||
!rawDataBuffer.includes(0x0d) ||
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]);
clearRawFlushTimeout();
// On some Windows terminals, during a paste, the terminal might send a
// single return character chunk. In this case, we need to wait a time period
// to know if it is part of a paste or just a return character.
const isReturnChar =
rawDataBuffer.length <= 2 && rawDataBuffer.includes(0x0d);
if (isReturnChar) {
rawFlushTimeout = setTimeout(flushRawBuffer, 100);
} else {
flushRawBuffer();
}
};
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.
if (wasRaw === false) {
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);
}
if (draggingTimerRef.current) {
clearTimeout(draggingTimerRef.current);
draggingTimerRef.current = null;
}
if (isDraggingRef.current && dragBufferRef.current) {
broadcast({
name: '',
ctrl: false,
meta: false,
shift: false,
paste: true,
sequence: dragBufferRef.current,
});
isDraggingRef.current = false;
dragBufferRef.current = '';
}
};
}, [
stdin,
setRawMode,
kittyProtocolEnabled,
debugKeystrokeLogging,
pasteWorkaround,
config,
subscribers,
]);
return (
<KeypressContext.Provider
value={{ subscribe, unsubscribe, pasteWorkaround }}
>
{children}
</KeypressContext.Provider>
);
}