diff options
Diffstat (limited to 'packages/cli/src/ui/hooks')
| -rw-r--r-- | packages/cli/src/ui/hooks/useKeypress.test.ts | 36 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useKeypress.ts | 406 |
2 files changed, 39 insertions, 403 deletions
diff --git a/packages/cli/src/ui/hooks/useKeypress.test.ts b/packages/cli/src/ui/hooks/useKeypress.test.ts index 946ee054..b804eb90 100644 --- a/packages/cli/src/ui/hooks/useKeypress.test.ts +++ b/packages/cli/src/ui/hooks/useKeypress.test.ts @@ -4,8 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ +import React from 'react'; import { renderHook, act } from '@testing-library/react'; import { useKeypress, Key } from './useKeypress.js'; +import { KeypressProvider } from '../contexts/KeypressContext.js'; import { useStdin } from 'ink'; import { EventEmitter } from 'events'; import { PassThrough } from 'stream'; @@ -102,6 +104,9 @@ describe('useKeypress', () => { const onKeypress = vi.fn(); let originalNodeVersion: string; + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(KeypressProvider, null, children); + beforeEach(() => { vi.clearAllMocks(); stdin = new MockStdin(); @@ -129,7 +134,9 @@ describe('useKeypress', () => { }; it('should not listen if isActive is false', () => { - renderHook(() => useKeypress(onKeypress, { isActive: false })); + renderHook(() => useKeypress(onKeypress, { isActive: false }), { + wrapper, + }); act(() => stdin.pressKey({ name: 'a' })); expect(onKeypress).not.toHaveBeenCalled(); }); @@ -141,14 +148,15 @@ describe('useKeypress', () => { { key: { name: 'up', sequence: '\x1b[A' } }, { key: { name: 'down', sequence: '\x1b[B' } }, ])('should listen for keypress when active for key $key.name', ({ key }) => { - renderHook(() => useKeypress(onKeypress, { isActive: true })); + renderHook(() => useKeypress(onKeypress, { isActive: true }), { wrapper }); act(() => stdin.pressKey(key)); expect(onKeypress).toHaveBeenCalledWith(expect.objectContaining(key)); }); it('should set and release raw mode', () => { - const { unmount } = renderHook(() => - useKeypress(onKeypress, { isActive: true }), + const { unmount } = renderHook( + () => useKeypress(onKeypress, { isActive: true }), + { wrapper }, ); expect(mockSetRawMode).toHaveBeenCalledWith(true); unmount(); @@ -156,8 +164,9 @@ describe('useKeypress', () => { }); it('should stop listening after being unmounted', () => { - const { unmount } = renderHook(() => - useKeypress(onKeypress, { isActive: true }), + const { unmount } = renderHook( + () => useKeypress(onKeypress, { isActive: true }), + { wrapper }, ); unmount(); act(() => stdin.pressKey({ name: 'a' })); @@ -165,7 +174,7 @@ describe('useKeypress', () => { }); it('should correctly identify alt+enter (meta key)', () => { - renderHook(() => useKeypress(onKeypress, { isActive: true })); + renderHook(() => useKeypress(onKeypress, { isActive: true }), { wrapper }); const key = { name: 'return', sequence: '\x1B\r' }; act(() => stdin.pressKey(key)); expect(onKeypress).toHaveBeenCalledWith( @@ -199,7 +208,9 @@ describe('useKeypress', () => { }); it('should process a paste as a single event', () => { - renderHook(() => useKeypress(onKeypress, { isActive: true })); + renderHook(() => useKeypress(onKeypress, { isActive: true }), { + wrapper, + }); const pasteText = 'hello world'; act(() => stdin.paste(pasteText)); @@ -215,7 +226,9 @@ describe('useKeypress', () => { }); it('should handle keypress interspersed with pastes', () => { - renderHook(() => useKeypress(onKeypress, { isActive: true })); + renderHook(() => useKeypress(onKeypress, { isActive: true }), { + wrapper, + }); const keyA = { name: 'a', sequence: 'a' }; act(() => stdin.pressKey(keyA)); @@ -239,8 +252,9 @@ describe('useKeypress', () => { }); it('should emit partial paste content if unmounted mid-paste', () => { - const { unmount } = renderHook(() => - useKeypress(onKeypress, { isActive: true }), + const { unmount } = renderHook( + () => useKeypress(onKeypress, { isActive: true }), + { wrapper }, ); const pasteText = 'incomplete paste'; diff --git a/packages/cli/src/ui/hooks/useKeypress.ts b/packages/cli/src/ui/hooks/useKeypress.ts index 920270ee..bead50e6 100644 --- a/packages/cli/src/ui/hooks/useKeypress.ts +++ b/packages/cli/src/ui/hooks/useKeypress.ts @@ -4,414 +4,36 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useEffect, useRef } from 'react'; -import { useStdin } from 'ink'; -import readline from 'readline'; -import { PassThrough } from 'stream'; +import { useEffect } from 'react'; 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~`; + useKeypressContext, + KeypressHandler, + Key, +} from '../contexts/KeypressContext.js'; -export interface Key { - name: string; - ctrl: boolean; - meta: boolean; - shift: boolean; - paste: boolean; - sequence: string; - kittyProtocol?: boolean; -} +export { Key }; /** - * A hook that listens for keypress events from stdin, providing a - * key object that mirrors the one from Node's `readline` module, - * adding a 'paste' flag for characters input as part of a bracketed - * paste (when enabled). - * - * Pastes are currently sent as a single key event where the full paste - * is in the sequence field. + * A hook that listens for keypress events from stdin. * * @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, - kittyProtocolEnabled = false, - config, - }: { isActive: boolean; kittyProtocolEnabled?: boolean; config?: Config }, + onKeypress: KeypressHandler, + { isActive }: { isActive: boolean }, ) { - const { stdin, setRawMode } = useStdin(); - const onKeypressRef = useRef(onKeypress); - - useEffect(() => { - onKeypressRef.current = onKeypress; - }, [onKeypress]); + const { subscribe, unsubscribe } = useKeypressContext(); useEffect(() => { - if (!isActive || !stdin.isTTY) { + if (!isActive) { return; } - 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' - ) { - // Prior to node 20, node's built-in readline does not support bracketed - // paste mode. We hack by detecting it with our own handler. - usePassthrough = true; - } - - 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') { - isPaste = false; - onKeypressRef.current({ - 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 { - // Handle special keys - if (key.name === 'return' && key.sequence === `${ESC}\r`) { - key.meta = true; - } - onKeypressRef.current({ ...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); - - // Determine which marker comes first, if any. - 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); - } - + subscribe(onKeypress); return () => { - if (usePassthrough) { - keypressStream.removeListener('keypress', handleKeypress); - stdin.removeListener('data', handleRawKeypress); - } else { - stdin.removeListener('keypress', handleKeypress); - } - 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({ - name: '', - ctrl: false, - meta: false, - shift: false, - paste: true, - sequence: pasteBuffer.toString(), - }); - pasteBuffer = Buffer.alloc(0); - } + unsubscribe(onKeypress); }; - }, [isActive, stdin, setRawMode, kittyProtocolEnabled, config]); + }, [isActive, onKeypress, subscribe, unsubscribe]); } |
