diff options
| author | Deepankar Sharma <[email protected]> | 2025-08-13 13:32:54 -0400 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-08-13 17:32:54 +0000 |
| commit | 9c7fb870c1a7c80741fafdfc6837d4b92e373b2d (patch) | |
| tree | 1e31f201520c9724b4659ee7a36c21215e6280b9 /packages/cli/src/ui/hooks/useKeypress.ts | |
| parent | 74a13fb535b255797d6c9aa3499acfea6aadc58d (diff) | |
Add terminal setup command for Shift+Enter and Ctrl+Enter support (#3289)
Co-authored-by: jacob314 <[email protected]>
Diffstat (limited to 'packages/cli/src/ui/hooks/useKeypress.ts')
| -rw-r--r-- | packages/cli/src/ui/hooks/useKeypress.ts | 246 |
1 files changed, 238 insertions, 8 deletions
diff --git a/packages/cli/src/ui/hooks/useKeypress.ts b/packages/cli/src/ui/hooks/useKeypress.ts index 6c2b7e8f..920270ee 100644 --- a/packages/cli/src/ui/hooks/useKeypress.ts +++ b/packages/cli/src/ui/hooks/useKeypress.ts @@ -8,6 +8,21 @@ import { useEffect, useRef } from 'react'; import { useStdin } from 'ink'; import readline from 'readline'; import { PassThrough } from 'stream'; +import { + KITTY_CTRL_C, + BACKSLASH_ENTER_DETECTION_WINDOW_MS, + MAX_KITTY_SEQUENCE_LENGTH, +} from '../utils/platformConstants.js'; +import { + KittySequenceOverflowEvent, + logKittySequenceOverflow, + Config, +} from '@google/gemini-cli-core'; +import { FOCUS_IN, FOCUS_OUT } from './useFocus.js'; + +const ESC = '\u001B'; +export const PASTE_MODE_PREFIX = `${ESC}[200~`; +export const PASTE_MODE_SUFFIX = `${ESC}[201~`; export interface Key { name: string; @@ -16,6 +31,7 @@ export interface Key { shift: boolean; paste: boolean; sequence: string; + kittyProtocol?: boolean; } /** @@ -30,10 +46,16 @@ export interface Key { * @param onKeypress - The callback function to execute on each keypress. * @param options - Options to control the hook's behavior. * @param options.isActive - Whether the hook should be actively listening for input. + * @param options.kittyProtocolEnabled - Whether Kitty keyboard protocol is enabled. + * @param options.config - Optional config for telemetry logging. */ export function useKeypress( onKeypress: (key: Key) => void, - { isActive }: { isActive: boolean }, + { + isActive, + kittyProtocolEnabled = false, + config, + }: { isActive: boolean; kittyProtocolEnabled?: boolean; config?: Config }, ) { const { stdin, setRawMode } = useStdin(); const onKeypressRef = useRef(onKeypress); @@ -64,8 +86,210 @@ export function useKeypress( let isPaste = false; let pasteBuffer = Buffer.alloc(0); + let kittySequenceBuffer = ''; + let backslashTimeout: NodeJS.Timeout | null = null; + let waitingForEnterAfterBackslash = false; + + // Parse Kitty protocol sequences + const parseKittySequence = (sequence: string): Key | null => { + // Match CSI <number> ; <modifiers> u or ~ + // Format: ESC [ <keycode> ; <modifiers> u/~ + 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; + + // Decode modifiers (subtract 1 as per Kitty protocol spec) + const modifierBits = modifiers - 1; + const shift = (modifierBits & 1) === 1; + const alt = (modifierBits & 2) === 2; + const ctrl = (modifierBits & 4) === 4; + + // Handle Escape key (code 27) + if (keyCode === 27) { + return { + name: 'escape', + ctrl, + meta: alt, + shift, + paste: false, + sequence, + kittyProtocol: true, + }; + } + + // Handle Enter key (code 13) + if (keyCode === 13) { + return { + name: 'return', + ctrl, + meta: alt, + shift, + paste: false, + sequence, + kittyProtocol: true, + }; + } + + // Handle Ctrl+letter combinations (a-z) + // ASCII codes: a=97, b=98, c=99, ..., z=122 + if (keyCode >= 97 && keyCode <= 122 && ctrl) { + const letter = String.fromCharCode(keyCode); + return { + name: letter, + ctrl: true, + meta: alt, + shift, + paste: false, + sequence, + kittyProtocol: true, + }; + } + + // Handle other keys as needed + return null; + }; const handleKeypress = (_: unknown, key: Key) => { + // Handle VS Code's backslash+return pattern (Shift+Enter) + if (key.name === 'return' && waitingForEnterAfterBackslash) { + // Cancel the timeout since we got the Enter + if (backslashTimeout) { + clearTimeout(backslashTimeout); + backslashTimeout = null; + } + waitingForEnterAfterBackslash = false; + + // Convert to Shift+Enter + onKeypressRef.current({ + ...key, + shift: true, + sequence: '\\\r', // VS Code's Shift+Enter representation + }); + return; + } + + // Handle backslash - hold it to see if Enter follows + if (key.sequence === '\\' && !key.name) { + // Don't pass through the backslash yet - wait to see if Enter follows + waitingForEnterAfterBackslash = true; + + // Set up a timeout to pass through the backslash if no Enter follows + backslashTimeout = setTimeout(() => { + waitingForEnterAfterBackslash = false; + backslashTimeout = null; + // Pass through the backslash since no Enter followed + onKeypressRef.current(key); + }, BACKSLASH_ENTER_DETECTION_WINDOW_MS); + + return; + } + + // If we're waiting for Enter after backslash but got something else, + // pass through the backslash first, then the new key + if (waitingForEnterAfterBackslash && key.name !== 'return') { + if (backslashTimeout) { + clearTimeout(backslashTimeout); + backslashTimeout = null; + } + waitingForEnterAfterBackslash = false; + + // Pass through the backslash that was held + onKeypressRef.current({ + name: '', + sequence: '\\', + ctrl: false, + meta: false, + shift: false, + paste: false, + }); + + // Then continue processing the current key normally + } + + // If readline has already identified an arrow key, pass it through + // immediately, bypassing the Kitty protocol sequence buffering. + if (['up', 'down', 'left', 'right'].includes(key.name)) { + onKeypressRef.current(key); + return; + } + + // Always pass through Ctrl+C immediately, regardless of protocol state + // Check both standard format and Kitty protocol sequence + if ( + (key.ctrl && key.name === 'c') || + key.sequence === `${ESC}${KITTY_CTRL_C}` + ) { + kittySequenceBuffer = ''; + // If it's the Kitty sequence, create a proper key object + if (key.sequence === `${ESC}${KITTY_CTRL_C}`) { + onKeypressRef.current({ + name: 'c', + ctrl: true, + meta: false, + shift: false, + paste: false, + sequence: key.sequence, + kittyProtocol: true, + }); + } else { + onKeypressRef.current(key); + } + return; + } + + // If Kitty protocol is enabled, handle CSI sequences + if (kittyProtocolEnabled) { + // If we have a buffer or this starts a CSI sequence + 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; + + // Try to parse the buffer as a Kitty sequence + const kittyKey = parseKittySequence(kittySequenceBuffer); + if (kittyKey) { + kittySequenceBuffer = ''; + onKeypressRef.current(kittyKey); + return; + } + + if (config?.getDebugMode()) { + const codes = Array.from(kittySequenceBuffer).map((ch) => + ch.charCodeAt(0), + ); + // Unless the user is sshing over a slow connection, this likely + // indicates this is not a kitty sequence but we have incorrectly + // interpreted it as such. See the examples above for sequences + // such as FOCUS_IN that are not Kitty sequences. + console.warn('Kitty sequence buffer has char codes:', codes); + } + + // If buffer doesn't match expected pattern and is getting long, flush it + if (kittySequenceBuffer.length > MAX_KITTY_SEQUENCE_LENGTH) { + // Log telemetry for buffer overflow + if (config) { + const event = new KittySequenceOverflowEvent( + kittySequenceBuffer.length, + kittySequenceBuffer, + ); + logKittySequenceOverflow(config, event); + } + // Not a Kitty sequence, treat as regular key + kittySequenceBuffer = ''; + } else { + // Wait for more characters + return; + } + } + } if (key.name === 'paste-start') { isPaste = true; } else if (key.name === 'paste-end') { @@ -84,7 +308,7 @@ export function useKeypress( pasteBuffer = Buffer.concat([pasteBuffer, Buffer.from(key.sequence)]); } else { // Handle special keys - if (key.name === 'return' && key.sequence === '\x1B\r') { + if (key.name === 'return' && key.sequence === `${ESC}\r`) { key.meta = true; } onKeypressRef.current({ ...key, paste: isPaste }); @@ -93,13 +317,13 @@ export function useKeypress( }; const handleRawKeypress = (data: Buffer) => { - const PASTE_MODE_PREFIX = Buffer.from('\x1B[200~'); - const PASTE_MODE_SUFFIX = Buffer.from('\x1B[201~'); + 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(PASTE_MODE_PREFIX, pos); - const suffixPos = data.indexOf(PASTE_MODE_SUFFIX, pos); + const prefixPos = data.indexOf(pasteModePrefixBuffer, pos); + const suffixPos = data.indexOf(pasteModeSuffixBuffer, pos); // Determine which marker comes first, if any. const isPrefixNext = @@ -115,7 +339,7 @@ export function useKeypress( } else if (isSuffixNext) { nextMarkerPos = suffixPos; } - markerLength = PASTE_MODE_SUFFIX.length; + markerLength = pasteModeSuffixBuffer.length; if (nextMarkerPos === -1) { keypressStream.write(data.slice(pos)); @@ -170,6 +394,12 @@ export function useKeypress( rl.close(); setRawMode(false); + // Clean up any pending backslash timeout + if (backslashTimeout) { + clearTimeout(backslashTimeout); + backslashTimeout = null; + } + // If we are in the middle of a paste, send what we have. if (isPaste) { onKeypressRef.current({ @@ -183,5 +413,5 @@ export function useKeypress( pasteBuffer = Buffer.alloc(0); } }; - }, [isActive, stdin, setRawMode]); + }, [isActive, stdin, setRawMode, kittyProtocolEnabled, config]); } |
