diff --git a/packages/cli/src/ui/hooks/useKeypress.test.ts b/packages/cli/src/ui/hooks/useKeypress.test.ts new file mode 100644 index 00000000..a30eabf2 --- /dev/null +++ b/packages/cli/src/ui/hooks/useKeypress.test.ts @@ -0,0 +1,261 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderHook, act } from '@testing-library/react'; +import { useKeypress, Key } from './useKeypress.js'; +import { useStdin } from 'ink'; +import { EventEmitter } from 'events'; +import { PassThrough } from 'stream'; + +// Mock the 'ink' module to control stdin +vi.mock('ink', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + useStdin: vi.fn(), + }; +}); + +// Mock the 'readline' module +vi.mock('readline', () => { + const mockedReadline = { + createInterface: vi.fn().mockReturnValue({ close: vi.fn() }), + // The paste workaround involves replacing stdin with a PassThrough stream. + // This mock ensures that when emitKeypressEvents is called on that + // stream, we simulate the 'keypress' events that the hook expects. + emitKeypressEvents: vi.fn((stream: EventEmitter) => { + if (stream instanceof PassThrough) { + stream.on('data', (data) => { + const str = data.toString(); + for (const char of str) { + stream.emit('keypress', null, { + name: char, + sequence: char, + ctrl: false, + meta: false, + shift: false, + }); + } + }); + } + }), + }; + return { + ...mockedReadline, + default: mockedReadline, + }; +}); + +class MockStdin extends EventEmitter { + isTTY = true; + setRawMode = vi.fn(); + on = this.addListener; + removeListener = this.removeListener; + write = vi.fn(); + resume = vi.fn(); + + private isLegacy = false; + + setLegacy(isLegacy: boolean) { + this.isLegacy = isLegacy; + } + + // Helper to simulate a full paste event. + paste(text: string) { + if (this.isLegacy) { + const PASTE_START = '\x1B[200~'; + const PASTE_END = '\x1B[201~'; + this.emit('data', Buffer.from(`${PASTE_START}${text}${PASTE_END}`)); + } else { + this.emit('keypress', null, { name: 'paste-start' }); + this.emit('keypress', null, { sequence: text }); + this.emit('keypress', null, { name: 'paste-end' }); + } + } + + // Helper to simulate the start of a paste, without the end. + startPaste(text: string) { + if (this.isLegacy) { + this.emit('data', Buffer.from('\x1B[200~' + text)); + } else { + this.emit('keypress', null, { name: 'paste-start' }); + this.emit('keypress', null, { sequence: text }); + } + } + + // Helper to simulate a single keypress event. + pressKey(key: Partial) { + if (this.isLegacy) { + this.emit('data', Buffer.from(key.sequence ?? '')); + } else { + this.emit('keypress', null, key); + } + } +} + +describe('useKeypress', () => { + let stdin: MockStdin; + const mockSetRawMode = vi.fn(); + const onKeypress = vi.fn(); + let originalNodeVersion: string; + + beforeEach(() => { + vi.clearAllMocks(); + stdin = new MockStdin(); + (useStdin as vi.Mock).mockReturnValue({ + stdin, + setRawMode: mockSetRawMode, + }); + + originalNodeVersion = process.versions.node; + delete process.env['PASTE_WORKAROUND']; + }); + + afterEach(() => { + Object.defineProperty(process.versions, 'node', { + value: originalNodeVersion, + configurable: true, + }); + }); + + const setNodeVersion = (version: string) => { + Object.defineProperty(process.versions, 'node', { + value: version, + configurable: true, + }); + }; + + it('should not listen if isActive is false', () => { + renderHook(() => useKeypress(onKeypress, { isActive: false })); + act(() => stdin.pressKey({ name: 'a' })); + expect(onKeypress).not.toHaveBeenCalled(); + }); + + it('should listen for keypress when active', () => { + renderHook(() => useKeypress(onKeypress, { isActive: true })); + const key = { name: 'a', sequence: 'a' }; + act(() => stdin.pressKey(key)); + expect(onKeypress).toHaveBeenCalledWith(expect.objectContaining(key)); + }); + + it('should set and release raw mode', () => { + const { unmount } = renderHook(() => + useKeypress(onKeypress, { isActive: true }), + ); + expect(mockSetRawMode).toHaveBeenCalledWith(true); + unmount(); + expect(mockSetRawMode).toHaveBeenCalledWith(false); + }); + + it('should stop listening after being unmounted', () => { + const { unmount } = renderHook(() => + useKeypress(onKeypress, { isActive: true }), + ); + unmount(); + act(() => stdin.pressKey({ name: 'a' })); + expect(onKeypress).not.toHaveBeenCalled(); + }); + + it('should correctly identify alt+enter (meta key)', () => { + renderHook(() => useKeypress(onKeypress, { isActive: true })); + const key = { name: 'return', sequence: '\x1B\r' }; + act(() => stdin.pressKey(key)); + expect(onKeypress).toHaveBeenCalledWith( + expect.objectContaining({ ...key, meta: true, paste: false }), + ); + }); + + describe.each([ + { + description: 'Modern Node (>= v20)', + setup: () => setNodeVersion('20.0.0'), + isLegacy: false, + }, + { + description: 'Legacy Node (< v20)', + setup: () => setNodeVersion('18.0.0'), + isLegacy: true, + }, + { + description: 'Workaround Env Var', + setup: () => { + setNodeVersion('20.0.0'); + process.env['PASTE_WORKAROUND'] = 'true'; + }, + isLegacy: true, + }, + ])('Paste Handling in $description', ({ setup, isLegacy }) => { + beforeEach(() => { + setup(); + stdin.setLegacy(isLegacy); + }); + + it('should process a paste as a single event', () => { + renderHook(() => useKeypress(onKeypress, { isActive: true })); + const pasteText = 'hello world'; + act(() => stdin.paste(pasteText)); + + expect(onKeypress).toHaveBeenCalledTimes(1); + expect(onKeypress).toHaveBeenCalledWith({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: true, + sequence: pasteText, + }); + }); + + it('should handle keypress interspersed with pastes', () => { + renderHook(() => useKeypress(onKeypress, { isActive: true })); + + const keyA = { name: 'a', sequence: 'a' }; + act(() => stdin.pressKey(keyA)); + expect(onKeypress).toHaveBeenCalledWith( + expect.objectContaining({ ...keyA, paste: false }), + ); + + const pasteText = 'pasted'; + act(() => stdin.paste(pasteText)); + expect(onKeypress).toHaveBeenCalledWith( + expect.objectContaining({ paste: true, sequence: pasteText }), + ); + + const keyB = { name: 'b', sequence: 'b' }; + act(() => stdin.pressKey(keyB)); + expect(onKeypress).toHaveBeenCalledWith( + expect.objectContaining({ ...keyB, paste: false }), + ); + + expect(onKeypress).toHaveBeenCalledTimes(3); + }); + + it('should emit partial paste content if unmounted mid-paste', () => { + const { unmount } = renderHook(() => + useKeypress(onKeypress, { isActive: true }), + ); + const pasteText = 'incomplete paste'; + + act(() => stdin.startPaste(pasteText)); + + // No event should be fired yet. + expect(onKeypress).not.toHaveBeenCalled(); + + // Unmounting should trigger the flush. + unmount(); + + expect(onKeypress).toHaveBeenCalledTimes(1); + expect(onKeypress).toHaveBeenCalledWith({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: true, + sequence: pasteText, + }); + }); + }); +}); diff --git a/packages/cli/src/ui/hooks/useKeypress.ts b/packages/cli/src/ui/hooks/useKeypress.ts index a8adba8d..d3e3df5c 100644 --- a/packages/cli/src/ui/hooks/useKeypress.ts +++ b/packages/cli/src/ui/hooks/useKeypress.ts @@ -7,6 +7,7 @@ import { useEffect, useRef } from 'react'; import { useStdin } from 'ink'; import readline from 'readline'; +import { PassThrough } from 'stream'; export interface Key { name: string; @@ -48,7 +49,19 @@ export function useKeypress( setRawMode(true); - const rl = readline.createInterface({ input: stdin }); + 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); @@ -79,11 +92,78 @@ export function useKeypress( } }; - readline.emitKeypressEvents(stdin, rl); - stdin.on('keypress', handleKeypress); + const handleRawKeypress = (data: Buffer) => { + const PASTE_MODE_PREFIX = Buffer.from('\x1B[200~'); + const PASTE_MODE_SUFFIX = Buffer.from('\x1B[201~'); + + let pos = 0; + while (pos < data.length) { + const prefixPos = data.indexOf(PASTE_MODE_PREFIX, pos); + const suffixPos = data.indexOf(PASTE_MODE_SUFFIX, 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 = PASTE_MODE_SUFFIX.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 }); + readline.emitKeypressEvents(keypressStream, rl); + keypressStream.on('keypress', handleKeypress); + stdin.on('data', handleRawKeypress); + } else { + rl = readline.createInterface({ input: stdin }); + readline.emitKeypressEvents(stdin, rl); + stdin.on('keypress', handleKeypress); + } return () => { - stdin.removeListener('keypress', handleKeypress); + if (usePassthrough) { + keypressStream.removeListener('keypress', handleKeypress); + stdin.removeListener('data', handleRawKeypress); + } else { + stdin.removeListener('keypress', handleKeypress); + } rl.close(); setRawMode(false);