/** * @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( 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>(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 ( {children} ); }