diff options
Diffstat (limited to 'packages/cli/src/ui/contexts/KeypressContext.tsx')
| -rw-r--r-- | packages/cli/src/ui/contexts/KeypressContext.tsx | 405 |
1 files changed, 405 insertions, 0 deletions
diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx new file mode 100644 index 00000000..f0c000a0 --- /dev/null +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -0,0 +1,405 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + Config, + KittySequenceOverflowEvent, + logKittySequenceOverflow, +} from '@google/gemini-cli-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, + 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); + if ( + nodeMajorVersion < 20 || + 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 === 13) { + 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 === '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 === 'paste-start') { + isPaste = true; + } else if (key.name === 'paste-end') { + isPaste = false; + broadcast({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: true, + sequence: pasteBuffer.toString(), + }); + pasteBuffer = Buffer.alloc(0); + } else { + if (isPaste) { + pasteBuffer = Buffer.concat([pasteBuffer, Buffer.from(key.sequence)]); + } else { + 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> + ); +} |
