From 9e4ae214a8c5bbac92111a9e4beb1c716b65789b Mon Sep 17 00:00:00 2001 From: Tommaso Sciortino Date: Sun, 9 Nov 2025 08:45:04 -0800 Subject: [PATCH] Revamp KeypressContext (#12746) --- packages/cli/src/gemini.tsx | 3 +- packages/cli/src/test-utils/render.tsx | 4 +- .../src/ui/components/InputPrompt.test.tsx | 68 +- .../src/ui/components/SettingsDialog.test.tsx | 8 +- .../src/ui/components/ThemeDialog.test.tsx | 6 +- .../components/shared/ScrollableList.test.tsx | 2 +- .../src/ui/contexts/KeypressContext.test.tsx | 715 ++------ .../cli/src/ui/contexts/KeypressContext.tsx | 1542 +++++++---------- packages/cli/src/ui/hooks/useFocus.test.tsx | 20 +- .../cli/src/ui/hooks/useKeypress.test.tsx | 31 +- .../cli/src/ui/utils/platformConstants.ts | 87 - packages/cli/src/ui/utils/terminalSetup.ts | 4 +- .../clearcut-logger/clearcut-logger.ts | 15 - packages/core/src/telemetry/index.ts | 2 - packages/core/src/telemetry/loggers.ts | 15 - packages/core/src/telemetry/types.ts | 29 - 16 files changed, 891 insertions(+), 1660 deletions(-) delete mode 100644 packages/cli/src/ui/utils/platformConstants.ts diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 8baee92063..915d07161a 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -184,11 +184,10 @@ export async function startInteractiveUI( // Create wrapper component to use hooks inside render const AppWrapper = () => { - const kittyProtocolStatus = useKittyKeyboardProtocol(); + useKittyKeyboardProtocol(); return ( diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 82abb7f4f3..4efd94c9f4 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -120,7 +120,6 @@ export const renderWithProviders = ( settings = mockSettings, uiState: providedUiState, width, - kittyProtocolEnabled = true, mouseEventsEnabled = false, config = configProxy as unknown as Config, }: { @@ -128,7 +127,6 @@ export const renderWithProviders = ( settings?: LoadedSettings; uiState?: Partial; width?: number; - kittyProtocolEnabled?: boolean; mouseEventsEnabled?: boolean; config?: Config; } = {}, @@ -166,7 +164,7 @@ export const renderWithProviders = ( - + { const { stdin, unmount } = renderWithProviders( , - { kittyProtocolEnabled: true }, ); await act(async () => { await vi.runAllTimersAsync(); @@ -1352,6 +1351,9 @@ describe('InputPrompt', () => { }); describe('enhanced input UX - double ESC clear functionality', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + it('should clear buffer on second ESC press', async () => { const onEscapePromptChange = vi.fn(); props.onEscapePromptChange = onEscapePromptChange; @@ -1359,22 +1361,40 @@ describe('InputPrompt', () => { const { stdin, unmount } = renderWithProviders( , - { kittyProtocolEnabled: false }, ); await act(async () => { stdin.write('\x1B'); - await waitFor(() => { - expect(onEscapePromptChange).toHaveBeenCalledWith(false); - }); + vi.advanceTimersByTime(100); + + expect(onEscapePromptChange).toHaveBeenCalledWith(false); }); await act(async () => { stdin.write('\x1B'); - await waitFor(() => { - expect(props.buffer.setText).toHaveBeenCalledWith(''); - expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled(); - }); + vi.advanceTimersByTime(100); + + expect(props.buffer.setText).toHaveBeenCalledWith(''); + expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled(); + }); + unmount(); + }); + + it('should clear buffer on double ESC', async () => { + const onEscapePromptChange = vi.fn(); + props.onEscapePromptChange = onEscapePromptChange; + props.buffer.setText('text to clear'); + + const { stdin, unmount } = renderWithProviders( + , + ); + + await act(async () => { + stdin.write('\x1B\x1B'); + vi.advanceTimersByTime(100); + + expect(props.buffer.setText).toHaveBeenCalledWith(''); + expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled(); }); unmount(); }); @@ -1386,7 +1406,6 @@ describe('InputPrompt', () => { const { stdin, unmount } = renderWithProviders( , - { kittyProtocolEnabled: false }, ); await act(async () => { @@ -1410,14 +1429,13 @@ describe('InputPrompt', () => { const { stdin, unmount } = renderWithProviders( , - { kittyProtocolEnabled: false }, ); await act(async () => { stdin.write('\x1B'); - await waitFor(() => - expect(props.setShellModeActive).toHaveBeenCalledWith(false), - ); + vi.advanceTimersByTime(100); + + expect(props.setShellModeActive).toHaveBeenCalledWith(false); }); unmount(); }); @@ -1431,26 +1449,23 @@ describe('InputPrompt', () => { const { stdin, unmount } = renderWithProviders( , - { kittyProtocolEnabled: false }, ); await act(async () => { stdin.write('\x1B'); + + vi.advanceTimersByTime(100); + expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled(); }); - await waitFor(() => - expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled(), - ); unmount(); }); it('should not call onEscapePromptChange when not provided', async () => { - vi.useFakeTimers(); props.onEscapePromptChange = undefined; props.buffer.setText('some text'); const { stdin, unmount } = renderWithProviders( , - { kittyProtocolEnabled: false }, ); await act(async () => { await vi.runAllTimersAsync(); @@ -1463,14 +1478,12 @@ describe('InputPrompt', () => { await vi.runAllTimersAsync(); }); - vi.useRealTimers(); unmount(); }); it('should not interfere with existing keyboard shortcuts', async () => { const { stdin, unmount } = renderWithProviders( , - { kittyProtocolEnabled: false }, ); await act(async () => { @@ -1535,18 +1548,13 @@ describe('InputPrompt', () => { }); it.each([ - { name: 'standard', kittyProtocolEnabled: false, escapeSequence: '\x1B' }, - { - name: 'kitty', - kittyProtocolEnabled: true, - escapeSequence: '\u001b[27u', - }, + { name: 'standard', escapeSequence: '\x1B' }, + { name: 'kitty', escapeSequence: '\u001b[27u' }, ])( 'resets reverse search state on Escape ($name)', - async ({ kittyProtocolEnabled, escapeSequence }) => { + async ({ escapeSequence }) => { const { stdin, stdout, unmount } = renderWithProviders( , - { kittyProtocolEnabled }, ); await act(async () => { diff --git a/packages/cli/src/ui/components/SettingsDialog.test.tsx b/packages/cli/src/ui/components/SettingsDialog.test.tsx index 501e14e85c..884bc218ea 100644 --- a/packages/cli/src/ui/components/SettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.test.tsx @@ -234,7 +234,7 @@ const renderDialog = ( }, ) => render( - + { const { stdin, unmount } = render( - + , @@ -1062,7 +1062,7 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { stdin, unmount, rerender } = render( - + , ); @@ -1087,7 +1087,7 @@ describe('SettingsDialog', () => { {}, ); rerender( - + , ); diff --git a/packages/cli/src/ui/components/ThemeDialog.test.tsx b/packages/cli/src/ui/components/ThemeDialog.test.tsx index 7fd4b27ec1..aadf8d27d0 100644 --- a/packages/cli/src/ui/components/ThemeDialog.test.tsx +++ b/packages/cli/src/ui/components/ThemeDialog.test.tsx @@ -78,7 +78,7 @@ describe('ThemeDialog Snapshots', () => { const settings = createMockSettings(); const { lastFrame } = render( - + , @@ -91,7 +91,7 @@ describe('ThemeDialog Snapshots', () => { const settings = createMockSettings(); const { lastFrame, stdin } = render( - + , @@ -113,7 +113,7 @@ describe('ThemeDialog Snapshots', () => { const settings = createMockSettings(); const { stdin } = render( - + - + diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index 63b265ca26..42d60628f6 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -9,17 +9,12 @@ import { act } from 'react'; import { renderHook } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; import type { Mock } from 'vitest'; -import { vi } from 'vitest'; +import { vi, afterAll, beforeAll } from 'vitest'; import type { Key } from './KeypressContext.js'; import { KeypressProvider, useKeypressContext, - DRAG_COMPLETION_TIMEOUT_MS, - KITTY_SEQUENCE_TIMEOUT_MS, - // CSI_END_O, - // SS3_END, - SINGLE_QUOTE, - DOUBLE_QUOTE, + ESC_TIMEOUT, } from './KeypressContext.js'; import { useStdin } from 'ink'; import { EventEmitter } from 'node:events'; @@ -53,12 +48,10 @@ class MockStdin extends EventEmitter { } // Helper function to setup keypress test with standard configuration -const setupKeypressTest = (kittyProtocolEnabled = true) => { +const setupKeypressTest = () => { const keyHandler = vi.fn(); const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - + {children} ); const { result } = renderHook(() => useKeypressContext(), { wrapper }); @@ -67,22 +60,17 @@ const setupKeypressTest = (kittyProtocolEnabled = true) => { return { result, keyHandler }; }; -describe('KeypressContext - Kitty Protocol', () => { +describe('KeypressContext', () => { let stdin: MockStdin; const mockSetRawMode = vi.fn(); - const wrapper = ({ - children, - kittyProtocolEnabled = true, - }: { - children: React.ReactNode; - kittyProtocolEnabled?: boolean; - }) => ( - - {children} - + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} ); + beforeAll(() => vi.useFakeTimers()); + afterAll(() => vi.useRealTimers()); + beforeEach(() => { vi.clearAllMocks(); stdin = new MockStdin(); @@ -103,16 +91,13 @@ describe('KeypressContext - Kitty Protocol', () => { sequence: '\x1b[57414u', }, ])('should recognize $name in kitty protocol', async ({ sequence }) => { - const { keyHandler } = setupKeypressTest(true); + const { keyHandler } = setupKeypressTest(); - act(() => { - stdin.write(sequence); - }); + act(() => stdin.write(sequence)); expect(keyHandler).toHaveBeenCalledWith( expect.objectContaining({ name: 'return', - kittyProtocol: true, ctrl: false, meta: false, shift: false, @@ -139,42 +124,23 @@ describe('KeypressContext - Kitty Protocol', () => { ])( 'should handle numpad enter with $modifier modifier', async ({ sequence, expected }) => { - const { keyHandler } = setupKeypressTest(true); + const { keyHandler } = setupKeypressTest(); act(() => stdin.write(sequence)); expect(keyHandler).toHaveBeenCalledWith( expect.objectContaining({ name: 'return', - kittyProtocol: true, ...expected, }), ); }, ); - - it('should not process kitty sequences when kitty protocol is disabled', async () => { - const { keyHandler } = setupKeypressTest(false); - - // Send kitty protocol sequence for numpad enter - act(() => { - stdin.write(`\x1b[57414u`); - }); - - // When kitty protocol is disabled, the sequence should be passed through - // as individual keypresses, not recognized as a single enter key - expect(keyHandler).not.toHaveBeenCalledWith( - expect.objectContaining({ - name: 'return', - kittyProtocol: true, - }), - ); - }); }); describe('Escape key handling', () => { it('should recognize escape key (keycode 27) in kitty protocol', async () => { - const { keyHandler } = setupKeypressTest(true); + const { keyHandler } = setupKeypressTest(); // Send kitty protocol sequence for escape: ESC[27u act(() => { @@ -184,19 +150,41 @@ describe('KeypressContext - Kitty Protocol', () => { expect(keyHandler).toHaveBeenCalledWith( expect.objectContaining({ name: 'escape', - kittyProtocol: true, }), ); }); - it('should handle lone Escape key (keycode 27) with timeout when kitty protocol is enabled', async () => { - // Use real timers for this test to avoid issues with stream/buffer timing - vi.useRealTimers(); + it('should handle double Escape', async () => { const keyHandler = vi.fn(); const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - + {children} + ); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + act(() => result.current.subscribe(keyHandler)); + + act(() => { + stdin.write('\x1b'); + vi.advanceTimersByTime(10); + stdin.write('\x1b'); + expect(keyHandler).not.toHaveBeenCalled(); + vi.advanceTimersByTime(ESC_TIMEOUT); + + expect(keyHandler).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ name: 'escape', meta: true }), + ); + expect(keyHandler).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ name: 'escape', meta: true }), + ); + }); + }); + + it('should handle lone Escape key (keycode 27) with timeout when kitty protocol is enabled', async () => { + // Use real timers for this test to avoid issues with stream/buffer timing + const keyHandler = vi.fn(); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} ); const { result } = renderHook(() => useKeypressContext(), { wrapper }); act(() => result.current.subscribe(keyHandler)); @@ -204,23 +192,19 @@ describe('KeypressContext - Kitty Protocol', () => { // Send just ESC act(() => { stdin.write('\x1b'); + + // Should be buffered initially + expect(keyHandler).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(ESC_TIMEOUT + 10); + + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'escape', + meta: true, + }), + ); }); - - // Should be buffered initially - expect(keyHandler).not.toHaveBeenCalled(); - - // Wait for timeout - await waitFor( - () => { - expect(keyHandler).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'escape', - meta: true, - }), - ); - }, - { timeout: 500 }, - ); }); }); @@ -254,7 +238,7 @@ describe('KeypressContext - Kitty Protocol', () => { ])( 'should recognize $name in kitty protocol', async ({ sequence, expected }) => { - const { keyHandler } = setupKeypressTest(true); + const { keyHandler } = setupKeypressTest(); act(() => { stdin.write(sequence); @@ -263,7 +247,6 @@ describe('KeypressContext - Kitty Protocol', () => { expect(keyHandler).toHaveBeenCalledWith( expect.objectContaining({ ...expected, - kittyProtocol: true, }), ); }, @@ -341,10 +324,7 @@ describe('KeypressContext - Kitty Protocol', () => { const keyHandler = vi.fn(); const wrapper = ({ children }: { children: React.ReactNode }) => ( - + {children} ); @@ -368,10 +348,7 @@ describe('KeypressContext - Kitty Protocol', () => { const keyHandler = vi.fn(); const wrapper = ({ children }: { children: React.ReactNode }) => ( - + {children} ); @@ -384,76 +361,7 @@ describe('KeypressContext - Kitty Protocol', () => { act(() => stdin.write('\x1b[27u')); expect(consoleLogSpy).toHaveBeenCalledWith( - '[DEBUG] Input buffer accumulating:', - expect.stringContaining('"\\u001b[27u"'), - ); - const parsedCall = consoleLogSpy.mock.calls.find( - (args) => - typeof args[0] === 'string' && - args[0].includes('[DEBUG] Sequence parsed successfully'), - ); - expect(parsedCall).toBeTruthy(); - expect(parsedCall?.[1]).toEqual(expect.stringContaining('\\u001b[27u')); - }); - - it('should log kitty buffer overflow when debugKeystrokeLogging is true', async () => { - const keyHandler = vi.fn(); - - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - - ); - - const { result } = renderHook(() => useKeypressContext(), { wrapper }); - - act(() => result.current.subscribe(keyHandler)); - - // Send a long sequence starting with a valid kitty prefix to trigger overflow - const longSequence = '\x1b[1;' + '1'.repeat(100); - act(() => stdin.write(longSequence)); - - expect(consoleLogSpy).toHaveBeenCalledWith( - '[DEBUG] Input buffer overflow, clearing:', - expect.any(String), - ); - }); - - it('should log kitty buffer clear on Ctrl+C when debugKeystrokeLogging is true', async () => { - const keyHandler = vi.fn(); - - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - - ); - - const { result } = renderHook(() => useKeypressContext(), { wrapper }); - - act(() => result.current.subscribe(keyHandler)); - - act(() => stdin.write(INCOMPLETE_KITTY_SEQUENCE)); - - // Send Ctrl+C - act(() => stdin.write('\x03')); - - expect(consoleLogSpy).toHaveBeenCalledWith( - '[DEBUG] Input buffer cleared on Ctrl+C:', - INCOMPLETE_KITTY_SEQUENCE, - ); - - // Verify Ctrl+C was handled - expect(keyHandler).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'c', - ctrl: true, - }), + `[DEBUG] Raw StdIn: ${JSON.stringify('\x1b[27u')}`, ); }); @@ -461,10 +369,7 @@ describe('KeypressContext - Kitty Protocol', () => { const keyHandler = vi.fn(); const wrapper = ({ children }: { children: React.ReactNode }) => ( - + {children} ); @@ -478,14 +383,7 @@ describe('KeypressContext - Kitty Protocol', () => { // Verify debug logging for accumulation expect(consoleLogSpy).toHaveBeenCalledWith( - '[DEBUG] Input buffer accumulating:', - JSON.stringify(INCOMPLETE_KITTY_SEQUENCE), - ); - - // Verify warning for char codes - expect(consoleWarnSpy).toHaveBeenCalledWith( - 'Input sequence buffer has content:', - JSON.stringify(INCOMPLETE_KITTY_SEQUENCE), + `[DEBUG] Raw StdIn: ${JSON.stringify(INCOMPLETE_KITTY_SEQUENCE)}`, ); }); }); @@ -554,7 +452,7 @@ describe('KeypressContext - Kitty Protocol', () => { describe('Double-tap and batching', () => { it('should emit two delete events for double-tap CSI[3~', async () => { - const { keyHandler } = setupKeypressTest(true); + const { keyHandler } = setupKeypressTest(); act(() => stdin.write(`\x1b[3~`)); act(() => stdin.write(`\x1b[3~`)); @@ -570,7 +468,7 @@ describe('KeypressContext - Kitty Protocol', () => { }); it('should parse two concatenated tilde-coded sequences in one chunk', async () => { - const { keyHandler } = setupKeypressTest(true); + const { keyHandler } = setupKeypressTest(); act(() => stdin.write(`\x1b[3~\x1b[5~`)); @@ -581,145 +479,6 @@ describe('KeypressContext - Kitty Protocol', () => { expect.objectContaining({ name: 'pageup' }), ); }); - - it('should ignore incomplete CSI then parse the next complete sequence', async () => { - const { keyHandler } = setupKeypressTest(true); - - // Incomplete ESC sequence then a complete Delete - act(() => { - // Provide an incomplete ESC sequence chunk with a real ESC character - stdin.write('\x1b[1;'); - }); - act(() => stdin.write(`\x1b[3~`)); - - expect(keyHandler).toHaveBeenCalledTimes(1); - expect(keyHandler).toHaveBeenCalledWith( - expect.objectContaining({ name: 'delete' }), - ); - }); - }); -}); - -describe('Drag and Drop Handling', () => { - let stdin: MockStdin; - const mockSetRawMode = vi.fn(); - - const wrapper = ({ - children, - kittyProtocolEnabled = true, - }: { - children: React.ReactNode; - kittyProtocolEnabled?: boolean; - }) => ( - - {children} - - ); - - beforeEach(() => { - vi.clearAllMocks(); - vi.useFakeTimers(); - stdin = new MockStdin(); - (useStdin as Mock).mockReturnValue({ - stdin, - setRawMode: mockSetRawMode, - }); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - describe('drag start by quotes', () => { - it.each([ - { name: 'single quote', quote: SINGLE_QUOTE }, - { name: 'double quote', quote: DOUBLE_QUOTE }, - ])( - 'should start collecting when $name arrives and not broadcast immediately', - async ({ quote }) => { - const keyHandler = vi.fn(); - - const { result } = renderHook(() => useKeypressContext(), { wrapper }); - - act(() => result.current.subscribe(keyHandler)); - - act(() => stdin.write(quote)); - - expect(keyHandler).not.toHaveBeenCalled(); - }, - ); - }); - - describe('drag collection and completion', () => { - it.each([ - { - name: 'collect single character inputs during drag mode', - characters: ['a'], - expectedText: 'a', - }, - { - name: 'collect multiple characters and complete on timeout', - characters: ['p', 'a', 't', 'h'], - expectedText: 'path', - }, - ])('should $name', async ({ characters, expectedText }) => { - const keyHandler = vi.fn(); - - const { result } = renderHook(() => useKeypressContext(), { wrapper }); - - act(() => result.current.subscribe(keyHandler)); - - act(() => stdin.write(SINGLE_QUOTE)); - - characters.forEach((char) => { - act(() => stdin.write(char)); - }); - - expect(keyHandler).not.toHaveBeenCalled(); - - act(() => { - vi.advanceTimersByTime(DRAG_COMPLETION_TIMEOUT_MS + 10); - }); - - expect(keyHandler).toHaveBeenCalledWith( - expect.objectContaining({ - name: '', - paste: true, - sequence: `${SINGLE_QUOTE}${expectedText}`, - }), - ); - }); - }); -}); - -describe('Kitty Sequence Parsing', () => { - let stdin: MockStdin; - const mockSetRawMode = vi.fn(); - - const wrapper = ({ - children, - kittyProtocolEnabled = true, - }: { - children: React.ReactNode; - kittyProtocolEnabled?: boolean; - }) => ( - - {children} - - ); - - beforeEach(() => { - vi.clearAllMocks(); - vi.useFakeTimers(); - stdin = new MockStdin(); - (useStdin as Mock).mockReturnValue({ - stdin, - setRawMode: mockSetRawMode, - }); - }); - - afterEach(() => { - vi.useRealTimers(); }); describe('Cross-terminal Alt key handling (simulating macOS)', () => { @@ -765,7 +524,6 @@ describe('Kitty Sequence Parsing', () => { meta: true, shift: false, paste: false, - kittyProtocol: true, }, }; } else if (terminal === 'MacTerminal') { @@ -806,20 +564,10 @@ describe('Kitty Sequence Parsing', () => { ), )( 'should handle Alt+$key in $terminal', - ({ - chunk, - expected, - kitty = true, - }: { - chunk: string; - expected: Partial; - kitty?: boolean; - }) => { + ({ chunk, expected }: { chunk: string; expected: Partial }) => { const keyHandler = vi.fn(); const testWrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - + {children} ); const { result } = renderHook(() => useKeypressContext(), { wrapper: testWrapper, @@ -836,16 +584,8 @@ describe('Kitty Sequence Parsing', () => { }); describe('Backslash key handling', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - it('should treat backslash as a regular keystroke', () => { - const { keyHandler } = setupKeypressTest(true); + const { keyHandler } = setupKeypressTest(); act(() => stdin.write('\\')); @@ -875,7 +615,7 @@ describe('Kitty Sequence Parsing', () => { expect(keyHandler).not.toHaveBeenCalled(); // Advance time just before timeout - act(() => vi.advanceTimersByTime(KITTY_SEQUENCE_TIMEOUT_MS - 5)); + act(() => vi.advanceTimersByTime(ESC_TIMEOUT - 5)); // Still shouldn't broadcast expect(keyHandler).not.toHaveBeenCalled(); @@ -886,7 +626,7 @@ describe('Kitty Sequence Parsing', () => { // Should now broadcast the incomplete sequence as regular input expect(keyHandler).toHaveBeenCalledWith( expect.objectContaining({ - name: '', + name: 'undefined', sequence: INCOMPLETE_KITTY_SEQUENCE, paste: false, }), @@ -926,7 +666,6 @@ describe('Kitty Sequence Parsing', () => { expect.objectContaining({ name: 'a', ctrl: true, - kittyProtocol: true, }), ); }); @@ -947,7 +686,6 @@ describe('Kitty Sequence Parsing', () => { expect.objectContaining({ name: 'a', ctrl: true, - kittyProtocol: true, }), ); expect(keyHandler).toHaveBeenNthCalledWith( @@ -955,31 +693,6 @@ describe('Kitty Sequence Parsing', () => { expect.objectContaining({ name: 'b', ctrl: true, - kittyProtocol: true, - }), - ); - }); - - it('should clear kitty buffer and timeout on Ctrl+C', async () => { - const keyHandler = vi.fn(); - const { result } = renderHook(() => useKeypressContext(), { wrapper }); - - act(() => result.current.subscribe(keyHandler)); - - act(() => stdin.write(INCOMPLETE_KITTY_SEQUENCE)); - - // Press Ctrl+C - act(() => stdin.write('\x03')); - - // Advance past timeout - act(() => vi.advanceTimersByTime(KITTY_SEQUENCE_TIMEOUT_MS + 10)); - - // Should only have received Ctrl+C, not the incomplete sequence - expect(keyHandler).toHaveBeenCalledTimes(1); - expect(keyHandler).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'c', - ctrl: true, }), ); }); @@ -1000,7 +713,6 @@ describe('Kitty Sequence Parsing', () => { 1, expect.objectContaining({ name: 'return', - kittyProtocol: true, }), ); expect(keyHandler).toHaveBeenNthCalledWith( @@ -1011,61 +723,31 @@ describe('Kitty Sequence Parsing', () => { ); }); - it('should not buffer sequences when kitty protocol is disabled', async () => { - const keyHandler = vi.fn(); - const { result } = renderHook(() => useKeypressContext(), { - wrapper: ({ children }) => - wrapper({ children, kittyProtocolEnabled: false }), - }); + it.each([1, ESC_TIMEOUT - 1])( + 'should handle sequences arriving character by character with %s ms delay', + async (delay) => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); - act(() => result.current.subscribe(keyHandler)); + act(() => result.current.subscribe(keyHandler)); - // Send what would be a kitty sequence - act(() => stdin.write('\x1b[13u')); + // Send kitty sequence character by character + for (const char of '\x1b[27u') { + act(() => stdin.write(char)); + // Advance time but not enough to timeout + vi.advanceTimersByTime(delay); + } - // Should pass through without parsing - expect(keyHandler).toHaveBeenCalledWith( - expect.objectContaining({ - sequence: '\x1b[13u', - }), - ); - expect(keyHandler).not.toHaveBeenCalledWith( - expect.objectContaining({ - name: 'return', - kittyProtocol: true, - }), - ); - }); - - it('should handle sequences arriving character by character', async () => { - vi.useRealTimers(); // Required for correct buffering timing. - - const keyHandler = vi.fn(); - const { result } = renderHook(() => useKeypressContext(), { wrapper }); - - act(() => { - result.current.subscribe(keyHandler); - }); - - // Send kitty sequence character by character - const sequence = '\x1b[27u'; // Escape key - for (const char of sequence) { - act(() => { - stdin.emit('data', Buffer.from(char)); + // Should parse once complete + await waitFor(() => { + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'escape', + }), + ); }); - await new Promise((resolve) => setImmediate(resolve)); - } - - // Should parse once complete - await waitFor(() => { - expect(keyHandler).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'escape', - kittyProtocol: true, - }), - ); - }); - }); + }, + ); it('should reset timeout when new input arrives', async () => { const keyHandler = vi.fn(); @@ -1095,108 +777,10 @@ describe('Kitty Sequence Parsing', () => { expect(keyHandler).toHaveBeenCalledWith( expect.objectContaining({ name: 'a', - kittyProtocol: true, }), ); }); - it('should flush incomplete kitty sequence on FOCUS_IN event', async () => { - const keyHandler = vi.fn(); - const { result } = renderHook(() => useKeypressContext(), { wrapper }); - - act(() => result.current.subscribe(keyHandler)); - - act(() => stdin.write(INCOMPLETE_KITTY_SEQUENCE)); - - // Incomplete sequence should be buffered, not broadcast - expect(keyHandler).not.toHaveBeenCalled(); - - // Send FOCUS_IN event - act(() => stdin.write('\x1b[I')); - - // The buffered sequence should be flushed - expect(keyHandler).toHaveBeenCalledTimes(1); - expect(keyHandler).toHaveBeenCalledWith( - expect.objectContaining({ - name: '', - sequence: INCOMPLETE_KITTY_SEQUENCE, - paste: false, - }), - ); - }); - - it('should flush incomplete kitty sequence on FOCUS_OUT event', async () => { - const keyHandler = vi.fn(); - const { result } = renderHook(() => useKeypressContext(), { wrapper }); - - act(() => result.current.subscribe(keyHandler)); - - act(() => stdin.write(INCOMPLETE_KITTY_SEQUENCE)); - - // Incomplete sequence should be buffered, not broadcast - expect(keyHandler).not.toHaveBeenCalled(); - - // Send FOCUS_OUT event - act(() => stdin.write('\x1b[O')); - - // The buffered sequence should be flushed - expect(keyHandler).toHaveBeenCalledTimes(1); - expect(keyHandler).toHaveBeenCalledWith( - expect.objectContaining({ - name: '', - sequence: INCOMPLETE_KITTY_SEQUENCE, - paste: false, - }), - ); - }); - - it('should flush incomplete kitty sequence on paste event', async () => { - vi.useFakeTimers(); - const keyHandler = vi.fn(); - const { result } = renderHook(() => useKeypressContext(), { wrapper }); - - act(() => result.current.subscribe(keyHandler)); - - act(() => stdin.write(INCOMPLETE_KITTY_SEQUENCE)); - - // Incomplete sequence should be buffered, not broadcast - expect(keyHandler).not.toHaveBeenCalled(); - - // Send paste start sequence - act(() => stdin.write(`\x1b[200~`)); - - // The buffered sequence should be flushed - expect(keyHandler).toHaveBeenCalledTimes(1); - expect(keyHandler).toHaveBeenCalledWith( - expect.objectContaining({ - name: '', - sequence: INCOMPLETE_KITTY_SEQUENCE, - paste: false, - }), - ); - - // Now send some paste content and end paste to make sure paste still works - const pastedText = 'hello'; - const PASTE_MODE_SUFFIX = `\x1b[201~`; - act(() => { - stdin.write(pastedText); - stdin.write(PASTE_MODE_SUFFIX); - }); - - act(() => vi.runAllTimers()); - - // The paste event should be broadcast - expect(keyHandler).toHaveBeenCalledTimes(2); - expect(keyHandler).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - paste: true, - sequence: pastedText, - }), - ); - vi.useRealTimers(); - }); - describe('SGR Mouse Handling', () => { it('should ignore SGR mouse sequences', async () => { const keyHandler = vi.fn(); @@ -1248,16 +832,13 @@ describe('Kitty Sequence Parsing', () => { // Space is 32. 32+0=32 (button 0), 32+33=65 ('A', col 33), 32+34=66 ('B', row 34) const x11Seq = '\x1b[M AB'; - act(() => { - stdin.write(x11Seq); - }); + act(() => stdin.write(x11Seq)); // Should not broadcast as keystrokes expect(keyHandler).not.toHaveBeenCalled(); }); it('should not flush slow SGR mouse sequences as garbage', async () => { - vi.useFakeTimers(); const keyHandler = vi.fn(); const { result } = renderHook(() => useKeypressContext(), { wrapper }); @@ -1267,15 +848,13 @@ describe('Kitty Sequence Parsing', () => { act(() => stdin.write('\x1b[<')); // Advance time past the normal kitty timeout (50ms) - act(() => vi.advanceTimersByTime(KITTY_SEQUENCE_TIMEOUT_MS + 10)); + act(() => vi.advanceTimersByTime(ESC_TIMEOUT + 10)); // Send the rest act(() => stdin.write('0;37;25M')); // Should NOT have flushed the prefix as garbage, and should have consumed the whole thing expect(keyHandler).not.toHaveBeenCalled(); - - vi.useRealTimers(); }); it('should ignore specific SGR mouse sequence sandwiched between keystrokes', async () => { @@ -1303,61 +882,44 @@ describe('Kitty Sequence Parsing', () => { }); describe('Ignored Sequences', () => { - describe.each([true, false])( - 'with kittyProtocolEnabled = %s', - (kittyEnabled) => { - it.each([ - { name: 'Focus In', sequence: '\x1b[I' }, - { name: 'Focus Out', sequence: '\x1b[O' }, - { name: 'SGR Mouse Release', sequence: '\u001b[<0;44;18m' }, - { name: 'something mouse', sequence: '\u001b[<0;53;19M' }, - { name: 'another mouse', sequence: '\u001b[<0;29;19m' }, - ])('should ignore $name sequence', async ({ sequence }) => { - vi.useFakeTimers(); - const keyHandler = vi.fn(); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - - ); - const { result } = renderHook(() => useKeypressContext(), { - wrapper, - }); - act(() => result.current.subscribe(keyHandler)); - - for (const char of sequence) { - act(() => { - stdin.write(char); - }); - await act(async () => { - vi.advanceTimersByTime(0); - }); - } - - act(() => { - stdin.write('HI'); - }); - - expect(keyHandler).toHaveBeenCalledTimes(2); - expect(keyHandler).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ name: 'h', sequence: 'H', shift: true }), - ); - expect(keyHandler).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ name: 'i', sequence: 'I', shift: true }), - ); - vi.useRealTimers(); - }); - }, - ); - - it('should handle F12 when kittyProtocolEnabled is false', async () => { + it.each([ + { name: 'Focus In', sequence: '\x1b[I' }, + { name: 'Focus Out', sequence: '\x1b[O' }, + { name: 'SGR Mouse Release', sequence: '\u001b[<0;44;18m' }, + { name: 'something mouse', sequence: '\u001b[<0;53;19M' }, + { name: 'another mouse', sequence: '\u001b[<0;29;19m' }, + ])('should ignore $name sequence', async ({ sequence }) => { const keyHandler = vi.fn(); const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - + {children} + ); + const { result } = renderHook(() => useKeypressContext(), { + wrapper, + }); + act(() => result.current.subscribe(keyHandler)); + + for (const char of sequence) { + act(() => stdin.write(char)); + act(() => vi.advanceTimersByTime(0)); + } + + act(() => stdin.write('HI')); + + expect(keyHandler).toHaveBeenCalledTimes(2); + expect(keyHandler).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ name: 'h', sequence: 'H', shift: true }), + ); + expect(keyHandler).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ name: 'i', sequence: 'I', shift: true }), + ); + }); + + it('should handle F12', async () => { + const keyHandler = vi.fn(); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} ); const { result } = renderHook(() => useKeypressContext(), { wrapper }); act(() => result.current.subscribe(keyHandler)); @@ -1371,4 +933,27 @@ describe('Kitty Sequence Parsing', () => { ); }); }); + + describe('Individual Character Input', () => { + it.each([ + 'abc', // ASCII character + '你好', // Chinese characters + 'こんにちは', // Japanese characters + '안녕하세요', // Korean characters + 'A你B好C', // Mixed characters + ])('should correctly handle string "%s"', async (inputString) => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + act(() => result.current.subscribe(keyHandler)); + + act(() => stdin.write(inputString)); + + expect(keyHandler).toHaveBeenCalledTimes(inputString.length); + for (const char of inputString) { + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ sequence: char }), + ); + } + }); + }); }); diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 172b767789..1f89b1d89c 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -4,12 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Config } from '@google/gemini-cli-core'; -import { - debugLogger, - KittySequenceOverflowEvent, - logKittySequenceOverflow, -} from '@google/gemini-cli-core'; +import { debugLogger, type Config } from '@google/gemini-cli-core'; import { useStdin } from 'ink'; import type React from 'react'; import { @@ -19,416 +14,681 @@ import { useEffect, useRef, } from 'react'; -import readline from 'node:readline'; -import { PassThrough } from 'node:stream'; -import { - BACKSLASH_ENTER_DETECTION_WINDOW_MS, - CHAR_CODE_ESC, - KITTY_CTRL_C, - KITTY_KEYCODE_BACKSPACE, - KITTY_KEYCODE_ENTER, - KITTY_KEYCODE_NUMPAD_ENTER, - KITTY_KEYCODE_TAB, - MAX_KITTY_SEQUENCE_LENGTH, - KITTY_MODIFIER_BASE, - KITTY_MODIFIER_EVENT_TYPES_OFFSET, - MODIFIER_SHIFT_BIT, - MODIFIER_ALT_BIT, - MODIFIER_CTRL_BIT, -} from '../utils/platformConstants.js'; -import { ESC, couldBeMouseSequence } from '../utils/input.js'; +import { ESC } from '../utils/input.js'; +import { parseMouseEvent } from '../utils/mouse.js'; import { FOCUS_IN, FOCUS_OUT } from '../hooks/useFocus.js'; -import { isIncompleteMouseSequence, parseMouseEvent } from '../utils/mouse.js'; -export const PASTE_MODE_START = `${ESC}[200~`; -export const PASTE_MODE_END = `${ESC}[201~`; -export const DRAG_COMPLETION_TIMEOUT_MS = 100; // Broadcast full path after 100ms if no more input -export const KITTY_SEQUENCE_TIMEOUT_MS = 50; // Flush incomplete kitty sequences after 50ms -export const PASTE_CODE_TIMEOUT_MS = 50; // Flush incomplete paste code after 50ms -export const SINGLE_QUOTE = "'"; -export const DOUBLE_QUOTE = '"'; +export const BACKSLASH_ENTER_TIMEOUT = 5; +export const ESC_TIMEOUT = 50; +export const PASTE_TIMEOUT = 50; + +const kUTF16SurrogateThreshold = 0x10000; // 2 ** 16 +function charLengthAt(str: string, i: number): number { + if (str.length <= i) { + // Pretend to move to the right. This is necessary to autocomplete while + // moving to the right. + return 1; + } + const code = str.codePointAt(i); + return code !== undefined && code >= kUTF16SurrogateThreshold ? 2 : 1; +} -// On Mac, hitting alt+char will yield funny characters. -// Remap these three since we listen for them. const MAC_ALT_KEY_CHARACTER_MAP: Record = { '\u222B': 'b', // "∫" back one word '\u0192': 'f', // "ƒ" forward one word '\u00B5': 'm', // "µ" toggle markup view }; -/** - * Maps symbols from parameterized functional keys `\x1b[1;1` - * to their corresponding key names (e.g., 'up', 'f1'). - */ -const LEGACY_FUNC_TO_NAME: { [k: string]: string } = { - A: 'up', - B: 'down', - C: 'right', - D: 'left', - H: 'home', - F: 'end', - P: 'f1', - Q: 'f2', - R: 'f3', - S: 'f4', -}; - -/** - * Maps key codes from tilde-coded functional keys `\x1b[~` - * to their corresponding key names. - */ -const TILDE_KEYCODE_TO_NAME: Record = { - 1: 'home', - 2: 'insert', - 3: 'delete', - 4: 'end', - 5: 'pageup', - 6: 'pagedown', - 11: 'f1', - 12: 'f2', - 13: 'f3', - 14: 'f4', - 15: 'f5', - 17: 'f6', // skipping 16 is intentional - 18: 'f7', - 19: 'f8', - 20: 'f9', - 21: 'f10', - 23: 'f11', // skipping 22 is intentional - 24: 'f12', -}; - -/** - * Check if a buffer could potentially be a valid kitty sequence or its prefix. - */ -function couldBeKittySequence(buffer: string): boolean { - // Kitty sequences always start with ESC[. - if (buffer.length === 0) return true; - if (buffer === ESC || buffer === `${ESC}[`) return true; - - if (!buffer.startsWith(`${ESC}[`)) return false; - - if (couldBeMouseSequence(buffer)) return true; - - // Check for known kitty sequence patterns: - // 1. ESC[ - could be CSI-u or tilde-coded - // 2. ESC[1; - parameterized functional - // 3. ESC[ - legacy functional keys - // 4. ESC[Z - reverse tab - const afterCSI = buffer.slice(2); - - // Check if it starts with a digit (could be CSI-u or parameterized) - if (/^\d/.test(afterCSI)) return true; - - // Check for known single-letter sequences - if (/^[ABCDHFPQRSZ]/.test(afterCSI)) return true; - - // Check for 1; pattern (parameterized sequences) - if (/^1;\d/.test(afterCSI)) return true; - - // Anything else starting with ESC[ that doesn't match our patterns - // is likely not a kitty sequence we handle - return false; -} - -/** - * Parses a single complete kitty/parameterized/legacy sequence from the start - * of the buffer. - * - * This enables peel-and-continue parsing for batched input, allowing us to - * "peel off" one complete event when multiple sequences arrive in a single - * chunk, preventing buffer overflow and fragmentation. - * - * @param buffer - The input buffer string to parse. - * @returns The parsed Key and the number of characters consumed, or null if - * no complete sequence is found at the start of the buffer. - */ -function parseKittyPrefix(buffer: string): { key: Key; length: number } | null { - // In older terminals ESC [ Z was used as Cursor Backward Tabulation (CBT) - // In newer terminals the same functionality of key combination for moving - // backward through focusable elements is Shift+Tab, hence we will - // map ESC [ Z to Shift+Tab - // 0) Reverse Tab (legacy): ESC [ Z - // Treat as Shift+Tab for UI purposes. - // Regex parts: - // ^ - start of buffer - // ESC [ - CSI introducer - // Z - legacy reverse tab - const revTabLegacy = new RegExp(`^${ESC}\\[Z`); - let m = buffer.match(revTabLegacy); - if (m) { - return { - key: { - name: 'tab', - ctrl: false, - meta: false, - shift: true, - paste: false, - sequence: buffer.slice(0, m[0].length), - kittyProtocol: false, - }, - length: m[0].length, - }; - } - - // 1) Reverse Tab (parameterized): ESC [ 1 ; Z - // Parameterized reverse Tab: ESC [ 1 ; Z - const revTabParam = new RegExp(`^${ESC}\\[1;(\\d+)Z`); - m = buffer.match(revTabParam); - if (m) { - let mods = parseInt(m[1], 10); - if (mods >= KITTY_MODIFIER_EVENT_TYPES_OFFSET) { - mods -= KITTY_MODIFIER_EVENT_TYPES_OFFSET; - } - const bits = mods - KITTY_MODIFIER_BASE; - const alt = (bits & MODIFIER_ALT_BIT) === MODIFIER_ALT_BIT; - const ctrl = (bits & MODIFIER_CTRL_BIT) === MODIFIER_CTRL_BIT; - return { - key: { - name: 'tab', - ctrl, - meta: alt, - // Reverse tab implies Shift behavior; force shift regardless of mods - shift: true, - paste: false, - sequence: buffer.slice(0, m[0].length), - kittyProtocol: true, - }, - length: m[0].length, - }; - } - - // 2) Parameterized functional: ESC [ 1 ; (A|B|C|D|H|F|P|Q|R|S) - // 2) Parameterized functional: ESC [ 1 ; (A|B|C|D|H|F|P|Q|R|S) - // Arrows, Home/End, F1–F4 with modifiers encoded in . - const arrowPrefix = new RegExp(`^${ESC}\\[1;(\\d+)([ABCDHFPQSR])`); - m = buffer.match(arrowPrefix); - if (m) { - let mods = parseInt(m[1], 10); - if (mods >= KITTY_MODIFIER_EVENT_TYPES_OFFSET) { - mods -= KITTY_MODIFIER_EVENT_TYPES_OFFSET; - } - const bits = mods - KITTY_MODIFIER_BASE; - const shift = (bits & MODIFIER_SHIFT_BIT) === MODIFIER_SHIFT_BIT; - const alt = (bits & MODIFIER_ALT_BIT) === MODIFIER_ALT_BIT; - const ctrl = (bits & MODIFIER_CTRL_BIT) === MODIFIER_CTRL_BIT; - const sym = m[2]; - const name = LEGACY_FUNC_TO_NAME[sym] || ''; - if (!name) return null; - return { - key: { - name, - ctrl, - meta: alt, - shift, - paste: false, - sequence: buffer.slice(0, m[0].length), - kittyProtocol: true, - }, - length: m[0].length, - }; - } - - // 3) CSI-u form: ESC [ ; (u|~) - // 3) CSI-u and tilde-coded functional keys: ESC [ ; (u|~) - // 'u' terminator: Kitty CSI-u; '~' terminator: tilde-coded function keys. - const csiUPrefix = new RegExp(`^${ESC}\\[(\\d+)(;(\\d+))?([u~])`); - m = buffer.match(csiUPrefix); - if (m) { - const keyCode = parseInt(m[1], 10); - let modifiers = m[3] ? parseInt(m[3], 10) : KITTY_MODIFIER_BASE; - if (modifiers >= KITTY_MODIFIER_EVENT_TYPES_OFFSET) { - modifiers -= KITTY_MODIFIER_EVENT_TYPES_OFFSET; - } - const modifierBits = modifiers - KITTY_MODIFIER_BASE; - const shift = (modifierBits & MODIFIER_SHIFT_BIT) === MODIFIER_SHIFT_BIT; - const alt = (modifierBits & MODIFIER_ALT_BIT) === MODIFIER_ALT_BIT; - const ctrl = (modifierBits & MODIFIER_CTRL_BIT) === MODIFIER_CTRL_BIT; - const terminator = m[4]; - - // Tilde-coded functional keys (Delete, Insert, PageUp/Down, Home/End) - if (terminator === '~') { - const name = TILDE_KEYCODE_TO_NAME[keyCode]; - if (name) { - return { - key: { - name, - ctrl, - meta: alt, - shift, - paste: false, - sequence: buffer.slice(0, m[0].length), - kittyProtocol: false, - }, - length: m[0].length, - }; - } - } - - const kittyKeyCodeToName: { [key: number]: string } = { - [CHAR_CODE_ESC]: 'escape', - [KITTY_KEYCODE_TAB]: 'tab', - [KITTY_KEYCODE_BACKSPACE]: 'backspace', - [KITTY_KEYCODE_ENTER]: 'return', - [KITTY_KEYCODE_NUMPAD_ENTER]: 'return', - }; - - const name = kittyKeyCodeToName[keyCode]; - if (name) { - return { - key: { - name, - ctrl, - meta: alt, - shift, - paste: false, - sequence: buffer.slice(0, m[0].length), - kittyProtocol: true, - }, - length: m[0].length, - }; - } - - // Ctrl+letters and Alt+letters +function nonKeyboardEventFilter( + keypressHandler: KeypressHandler, +): KeypressHandler { + return (key: Key) => { if ( - (ctrl || alt) && - keyCode >= 'a'.charCodeAt(0) && - keyCode <= 'z'.charCodeAt(0) + !parseMouseEvent(key.sequence) && + key.sequence !== FOCUS_IN && + key.sequence !== FOCUS_OUT ) { - const letter = String.fromCharCode(keyCode); - return { - key: { - name: letter, - ctrl, - meta: alt, - shift, - paste: false, - sequence: buffer.slice(0, m[0].length), - kittyProtocol: true, - }, - length: m[0].length, - }; + keypressHandler(key); } - } - - // 4) Legacy function keys (no parameters): ESC [ (A|B|C|D|H|F) - // Arrows + Home/End without modifiers. - const legacyFuncKey = new RegExp(`^${ESC}\\[([ABCDHF])`); - m = buffer.match(legacyFuncKey); - if (m) { - const sym = m[1]; - const name = LEGACY_FUNC_TO_NAME[sym]!; - return { - key: { - name, - ctrl: false, - meta: false, - shift: false, - paste: false, - sequence: buffer.slice(0, m[0].length), - kittyProtocol: false, - }, - length: m[0].length, - }; - } - - return null; + }; } /** - * Returns the first index before which we are certain there is no paste marker. + * Buffers "/" keys to see if they are followed return. + * Will flush the buffer if no data is received for DRAG_COMPLETION_TIMEOUT_MS + * or when a null key is received. */ -function earliestPossiblePasteMarker(data: string): number { - // Check data for full start-paste or end-paste markers. - const startIndex = data.indexOf(PASTE_MODE_START); - const endIndex = data.indexOf(PASTE_MODE_END); - if (startIndex !== -1 && endIndex !== -1) { - return Math.min(startIndex, endIndex); - } else if (startIndex !== -1) { - return startIndex; - } else if (endIndex !== -1) { - return endIndex; - } - - // data contains no full start-paste or end-paste. - // Check if data ends with a prefix of start-paste or end-paste. - const codeLength = PASTE_MODE_START.length; - for (let i = Math.min(data.length, codeLength - 1); i > 0; i--) { - const candidate = data.slice(data.length - i); - if ( - PASTE_MODE_START.indexOf(candidate) === 0 || - PASTE_MODE_END.indexOf(candidate) === 0 - ) { - return data.length - i; - } - } - return data.length; -} - -/** - * A generator that takes in data chunks and spits out paste-start and - * paste-end keypresses. All non-paste marker data is passed to passthrough. - */ -function* pasteMarkerParser( - passthrough: PassThrough, - keypressHandler: (_: unknown, key: Key) => void, -): Generator { - while (true) { - let data = yield; - if (data.length === 0) { - continue; // we timed out - } - +function bufferBackslashEnter( + keypressHandler: KeypressHandler, +): (key: Key | null) => void { + const bufferer = (function* (): Generator { while (true) { - const index = earliestPossiblePasteMarker(data); - if (index === data.length) { - // no possible paste markers were found - passthrough.write(data); - break; + const key = yield; + + if (key == null) { + continue; + } else if (key.name !== '\\') { + keypressHandler(key); + continue; } - if (index > 0) { - // snip off and send the part that doesn't have a paste marker - passthrough.write(data.slice(0, index)); - data = data.slice(index); + + const timeoutId = setTimeout( + () => bufferer.next(null), + BACKSLASH_ENTER_TIMEOUT, + ); + const nextKey = yield; + clearTimeout(timeoutId); + + if (nextKey === null) { + keypressHandler(key); + } else if (nextKey.name === 'return') { + keypressHandler({ + ...key, + shift: true, + sequence: '\r', // Corrected escaping for newline + }); + } else { + keypressHandler(key); + keypressHandler(nextKey); } - // data starts with a possible paste marker - const codeLength = PASTE_MODE_START.length; - if (data.length < codeLength) { - // we have a prefix. Concat the next data and try again. - const newData = yield; - if (newData.length === 0) { - // we timed out. Just dump what we have and start over. - passthrough.write(data); + } + })(); + + bufferer.next(); // prime the generator so it starts listening. + + return (key: Key | null) => bufferer.next(key); +} + +/** + * Buffers paste events between paste-start and paste-end sequences. + * Will flush the buffer if no data is received for PASTE_TIMEOUT ms or + * when a null key is received. + */ +function bufferPaste( + keypressHandler: KeypressHandler, +): (key: Key | null) => void { + const bufferer = (function* (): Generator { + while (true) { + let key = yield; + + if (key === null) { + continue; + } else if (key.name !== 'paste-start') { + keypressHandler(key); + continue; + } + + let buffer = ''; + while (true) { + const timeoutId = setTimeout(() => bufferer.next(null), PASTE_TIMEOUT); + key = yield; + clearTimeout(timeoutId); + + if (key === null || key.name === 'paste-end') { break; } - data += newData; - } else if (data.startsWith(PASTE_MODE_START)) { - keypressHandler(undefined, { - name: 'paste-start', + buffer += key.sequence; + } + + if (buffer.length > 0) { + keypressHandler({ + name: '', ctrl: false, meta: false, shift: false, - paste: false, - sequence: '', + paste: true, + sequence: buffer, }); - data = data.slice(PASTE_MODE_START.length); - } else if (data.startsWith(PASTE_MODE_END)) { - keypressHandler(undefined, { - name: 'paste-end', - ctrl: false, - meta: false, - shift: false, - paste: false, - sequence: '', - }); - data = data.slice(PASTE_MODE_END.length); - } else { - // This should never happen. - passthrough.write(data); - break; } } + })(); + bufferer.next(); // prime the generator so it starts listening. + + return (key: Key | null) => bufferer.next(key); +} + +/** + * Turns raw data strings into keypress events sent to the provided handler. + * Buffers escape sequences until a full sequence is received or + * until a timeout occurs. + */ +function createDataListener(keypressHandler: KeypressHandler) { + const parser = emitKeys(keypressHandler); + parser.next(); // prime the generator so it starts listening. + + let timeoutId: NodeJS.Timeout; + return (data: string) => { + clearTimeout(timeoutId); + for (const char of data) { + parser.next(char); + } + if (data.length !== 0) { + timeoutId = setTimeout(() => parser.next(''), ESC_TIMEOUT); + } + }; +} + +/** + * Translates raw keypress characters into key events. + * Buffers escape sequences until a full sequence is received or + * until an empty string is sent to indicate a timeout. + */ +function* emitKeys( + keypressHandler: KeypressHandler, +): Generator { + while (true) { + let ch = yield; + let sequence = ch; + let escaped = false; + + let name = undefined; + let ctrl = false; + let meta = false; + let shift = false; + let code = undefined; + + if (ch === ESC) { + escaped = true; + ch = yield; + sequence += ch; + + if (ch === ESC) { + ch = yield; + sequence += ch; + } + } + + if (escaped && (ch === 'O' || ch === '[')) { + // ANSI escape sequence + code = ch; + let modifier = 0; + + if (ch === 'O') { + // ESC O letter + // ESC O modifier letter + ch = yield; + sequence += ch; + + if (ch >= '0' && ch <= '9') { + modifier = parseInt(ch, 10) - 1; + ch = yield; + sequence += ch; + } + + code += ch; + } else if (ch === '[') { + // ESC [ letter + // ESC [ modifier letter + // ESC [ [ modifier letter + // ESC [ [ num char + ch = yield; + sequence += ch; + + if (ch === '[') { + // \x1b[[A + // ^--- escape codes might have a second bracket + code += ch; + ch = yield; + sequence += ch; + } + + /* + * Here and later we try to buffer just enough data to get + * a complete ascii sequence. + * + * We have basically two classes of ascii characters to process: + * + * + * 1. `\x1b[24;5~` should be parsed as { code: '[24~', modifier: 5 } + * + * This particular example is featuring Ctrl+F12 in xterm. + * + * - `;5` part is optional, e.g. it could be `\x1b[24~` + * - first part can contain one or two digits + * - there is also special case when there can be 3 digits + * but without modifier. They are the case of paste bracket mode + * + * So the generic regexp is like /^(?:\d\d?(;\d)?[~^$]|\d{3}~)$/ + * + * + * 2. `\x1b[1;5H` should be parsed as { code: '[H', modifier: 5 } + * + * This particular example is featuring Ctrl+Home in xterm. + * + * - `1;5` part is optional, e.g. it could be `\x1b[H` + * - `1;` part is optional, e.g. it could be `\x1b[5H` + * + * So the generic regexp is like /^((\d;)?\d)?[A-Za-z]$/ + * + */ + const cmdStart = sequence.length - 1; + + // collect as many digits as possible + while (ch >= '0' && ch <= '9') { + ch = yield; + sequence += ch; + } + + // skip modifier + if (ch === ';') { + ch = yield; + sequence += ch; + + // collect as many digits as possible + while (ch >= '0' && ch <= '9') { + ch = yield; + sequence += ch; + } + } else if (ch === '<') { + // SGR mouse mode + ch = yield; + sequence += ch; + // Don't skip on empty string here to avoid timeouts on slow events. + while (ch === '' || ch === ';' || (ch >= '0' && ch <= '9')) { + ch = yield; + sequence += ch; + } + } else if (ch === 'M') { + // X11 mouse mode + // three characters after 'M' + ch = yield; + sequence += ch; + ch = yield; + sequence += ch; + ch = yield; + sequence += ch; + } + + /* + * We buffered enough data, now trying to extract code + * and modifier from it + */ + const cmd = sequence.slice(cmdStart); + let match; + + if ((match = /^(\d+)(?:;(\d+))?([~^$u])$/.exec(cmd))) { + code += match[1] + match[3]; + // Defaults to '1' if no modifier exists, resulting in a 0 modifier value + modifier = parseInt(match[2] ?? '1', 10) - 1; + } else if ((match = /^((\d;)?(\d))?([A-Za-z])$/.exec(cmd))) { + code += match[4]; + modifier = parseInt(match[3] ?? '1', 10) - 1; + } else { + code += cmd; + } + } + + // Parse the key modifier + ctrl = !!(modifier & 4); + meta = !!(modifier & 10); // use 10 to catch both alt (2) and meta (8). + shift = !!(modifier & 1); + + // Parse the key itself + switch (code) { + /* paste bracket mode */ + case '[200~': + name = 'paste-start'; + break; + case '[201~': + name = 'paste-end'; + break; + + // from Cygwin and used in libuv + case '[[A': + name = 'f1'; + break; + case '[[B': + name = 'f2'; + break; + case '[[C': + name = 'f3'; + break; + case '[[D': + name = 'f4'; + break; + case '[[E': + name = 'f5'; + break; + + case '[1~': + name = 'home'; + break; + case '[2~': + name = 'insert'; + break; + case '[3~': + name = 'delete'; + break; + case '[4~': + name = 'end'; + break; + case '[5~': + name = 'pageup'; + break; + case '[6~': + name = 'pagedown'; + break; + case '[7~': + name = 'home'; + break; + case '[8~': + name = 'end'; + break; + + case '[11~': + name = 'f1'; + break; + case '[12~': + name = 'f2'; + break; + case '[13~': + name = 'f3'; + break; + case '[14~': + name = 'f4'; + break; + case '[15~': + name = 'f5'; + break; + case '[17~': + name = 'f6'; + break; + case '[18~': + name = 'f7'; + break; + case '[19~': + name = 'f8'; + break; + case '[20~': + name = 'f9'; + break; + case '[21~': + name = 'f10'; + break; + case '[23~': + name = 'f11'; + break; + case '[24~': + name = 'f12'; + break; + + // xterm ESC [ letter + case '[A': + name = 'up'; + break; + case '[B': + name = 'down'; + break; + case '[C': + name = 'right'; + break; + case '[D': + name = 'left'; + break; + case '[E': + name = 'clear'; + break; + case '[F': + name = 'end'; + break; + case '[H': + name = 'home'; + break; + case '[P': + name = 'f1'; + break; + case '[Q': + name = 'f2'; + break; + case '[R': + name = 'f3'; + break; + case '[S': + name = 'f4'; + break; + + // xterm/gnome ESC O letter + case 'OA': + name = 'up'; + break; + case 'OB': + name = 'down'; + break; + case 'OC': + name = 'right'; + break; + case 'OD': + name = 'left'; + break; + case 'OE': + name = 'clear'; + break; + case 'OF': + name = 'end'; + break; + case 'OH': + name = 'home'; + break; + case 'OP': + name = 'f1'; + break; + case 'OQ': + name = 'f2'; + break; + case 'OR': + name = 'f3'; + break; + case 'OS': + name = 'f4'; + break; + + // putty + case '[[5~': + name = 'pageup'; + break; + case '[[6~': + name = 'pagedown'; + break; + + // rxvt keys with modifiers + case '[a': + name = 'up'; + shift = true; + break; + case '[b': + name = 'down'; + shift = true; + break; + case '[c': + name = 'right'; + shift = true; + break; + case '[d': + name = 'left'; + shift = true; + break; + case '[e': + name = 'clear'; + shift = true; + break; + + case '[2$': + name = 'insert'; + shift = true; + break; + case '[3$': + name = 'delete'; + shift = true; + break; + case '[5$': + name = 'pageup'; + shift = true; + break; + case '[6$': + name = 'pagedown'; + shift = true; + break; + case '[7$': + name = 'home'; + shift = true; + break; + case '[8$': + name = 'end'; + shift = true; + break; + + case 'Oa': + name = 'up'; + ctrl = true; + break; + case 'Ob': + name = 'down'; + ctrl = true; + break; + case 'Oc': + name = 'right'; + ctrl = true; + break; + case 'Od': + name = 'left'; + ctrl = true; + break; + case 'Oe': + name = 'clear'; + ctrl = true; + break; + + case '[2^': + name = 'insert'; + ctrl = true; + break; + case '[3^': + name = 'delete'; + ctrl = true; + break; + case '[5^': + name = 'pageup'; + ctrl = true; + break; + case '[6^': + name = 'pagedown'; + ctrl = true; + break; + case '[7^': + name = 'home'; + ctrl = true; + break; + case '[8^': + name = 'end'; + ctrl = true; + break; + + // kitty protocol sequences + case '[9u': + name = 'tab'; + break; + case '[13u': + name = 'return'; + break; + case '[27u': + name = 'escape'; + break; + case '[127u': + name = 'backspace'; + break; + case '[57414u': // Numpad Enter + name = 'return'; + break; + + // misc. + case '[Z': + name = 'tab'; + shift = true; + break; + default: + name = 'undefined'; + if ((ctrl || meta) && (code.endsWith('u') || code.endsWith('~'))) { + // CSI-u or tilde-coded functional keys: ESC [ ; (u|~) + const codeNumber = parseInt(code.slice(1, -1), 10); + if ( + codeNumber >= 'a'.charCodeAt(0) && + codeNumber <= 'z'.charCodeAt(0) + ) { + name = String.fromCharCode(codeNumber); + } + } + break; + } + } else if (ch === '\r') { + // carriage return + name = 'return'; + meta = escaped; + } else if (ch === '\n') { + // Enter, should have been called linefeed + name = 'enter'; + meta = escaped; + } else if (ch === '\t') { + // tab + name = 'tab'; + meta = escaped; + } else if (ch === '\b' || ch === '\x7f') { + // backspace or ctrl+h + name = 'backspace'; + meta = escaped; + } else if (ch === ESC) { + // escape key + name = 'escape'; + meta = escaped; + } else if (ch === ' ') { + name = 'space'; + meta = escaped; + } else if (!escaped && ch <= '\x1a') { + // ctrl+letter + name = String.fromCharCode(ch.charCodeAt(0) + 'a'.charCodeAt(0) - 1); + ctrl = true; + } else if (/^[0-9A-Za-z]$/.exec(ch) !== null) { + // Letter, number, shift+letter + name = ch.toLowerCase(); + shift = /^[A-Z]$/.exec(ch) !== null; + meta = escaped; + } else if (MAC_ALT_KEY_CHARACTER_MAP[ch] && process.platform === 'darwin') { + name = MAC_ALT_KEY_CHARACTER_MAP[ch]; + meta = true; + } else if (sequence === `${ESC}${ESC}`) { + // Double escape + name = 'escape'; + meta = true; + + // Emit first escape key here, then continue processing + keypressHandler({ + name: 'escape', + ctrl, + meta, + shift, + paste: false, + sequence: ESC, + }); + } else if (escaped) { + // Escape sequence timeout + name = ch.length ? undefined : 'escape'; + meta = true; + } + + if ( + (sequence.length !== 0 && (name !== undefined || escaped)) || + charLengthAt(sequence, 0) === sequence.length + ) { + keypressHandler({ + name: name || '', + ctrl, + meta, + shift, + paste: false, + sequence, + }); + } + // Unrecognized or broken escape sequence, don't emit anything } } @@ -439,7 +699,6 @@ export interface Key { shift: boolean; paste: boolean; sequence: string; - kittyProtocol?: boolean; } export type KeypressHandler = (key: Key) => void; @@ -463,18 +722,12 @@ export function useKeypressContext() { return context; } -function shouldUsePassthrough(): boolean { - return process.env['PASTE_WORKAROUND'] !== 'false'; -} - export function KeypressProvider({ children, - kittyProtocolEnabled, config, debugKeystrokeLogging, }: { children: React.ReactNode; - kittyProtocolEnabled: boolean; config?: Config; debugKeystrokeLogging?: boolean; }) { @@ -500,498 +753,39 @@ export function KeypressProvider({ setRawMode(true); } - const keypressStream = shouldUsePassthrough() ? new PassThrough() : null; + process.stdin.setEncoding('utf8'); // Make data events emit strings - // If non-null that means we are in paste mode - let pasteBuffer: Buffer | null = null; + const mouseFilterer = nonKeyboardEventFilter(broadcast); + const backslashBufferer = bufferBackslashEnter(mouseFilterer); + const pasteBufferer = bufferPaste(backslashBufferer); + let dataListener = createDataListener(pasteBufferer); - // Used to turn "\" quickly followed by a "enter" into a shift enter - let backslashTimeout: NodeJS.Timeout | null = null; - - // Buffers incomplete sequences (Kitty or Mouse) and timer to flush it - let inputBuffer = ''; - let inputTimeout: NodeJS.Timeout | null = null; - - // Used to detect filename drag-and-drops. - let dragBuffer = ''; - let draggingTimer: NodeJS.Timeout | null = null; - - const clearDraggingTimer = () => { - if (draggingTimer) { - clearTimeout(draggingTimer); - draggingTimer = null; - } - }; - - const flushInputBufferOnInterrupt = (reason: string) => { - if (inputBuffer) { - if (debugKeystrokeLogging) { - debugLogger.log( - `[DEBUG] Input sequence flushed due to ${reason}:`, - JSON.stringify(inputBuffer), - ); + if (debugKeystrokeLogging) { + const old = dataListener; + dataListener = (data: string) => { + if (data.length > 0) { + debugLogger.log(`[DEBUG] Raw StdIn: ${JSON.stringify(data)}`); } - broadcast({ - name: '', - ctrl: false, - meta: false, - shift: false, - paste: false, - sequence: inputBuffer, - }); - inputBuffer = ''; - } - if (inputTimeout) { - clearTimeout(inputTimeout); - inputTimeout = null; - } - }; - - const handleKeypress = (_: unknown, key: Key) => { - if (key.sequence === FOCUS_IN || key.sequence === FOCUS_OUT) { - flushInputBufferOnInterrupt('focus event'); - return; - } - if (key.name === 'paste-start') { - flushInputBufferOnInterrupt('paste start'); - pasteBuffer = Buffer.alloc(0); - return; - } - if (key.name === 'paste-end') { - if (pasteBuffer !== null) { - broadcast({ - name: '', - ctrl: false, - meta: false, - shift: false, - paste: true, - sequence: pasteBuffer.toString(), - }); - } - pasteBuffer = null; - return; - } - - if (pasteBuffer !== null) { - pasteBuffer = Buffer.concat([pasteBuffer, Buffer.from(key.sequence)]); - return; - } - - if ( - key.sequence === SINGLE_QUOTE || - key.sequence === DOUBLE_QUOTE || - draggingTimer !== null - ) { - dragBuffer += key.sequence; - - clearDraggingTimer(); - draggingTimer = setTimeout(() => { - draggingTimer = null; - const seq = dragBuffer; - dragBuffer = ''; - if (seq) { - broadcast({ ...key, name: '', paste: true, sequence: seq }); - } - }, DRAG_COMPLETION_TIMEOUT_MS); - - return; - } - - const mappedLetter = MAC_ALT_KEY_CHARACTER_MAP[key.sequence]; - if (process.platform === 'darwin' && mappedLetter && !key.meta) { - broadcast({ - name: mappedLetter, - ctrl: false, - meta: true, - shift: false, - paste: pasteBuffer !== null, - sequence: key.sequence, - }); - return; - } - - if (key.name === 'return' && backslashTimeout !== null) { - clearTimeout(backslashTimeout); - backslashTimeout = null; - broadcast({ - ...key, - shift: true, - sequence: '\r', // Corrected escaping for newline - }); - return; - } - - if (key.sequence === '\\' && !key.name) { - // Corrected escaping for backslash - backslashTimeout = setTimeout(() => { - backslashTimeout = null; - broadcast(key); - }, BACKSLASH_ENTER_DETECTION_WINDOW_MS); - return; - } - - if (backslashTimeout !== null && key.name !== 'return') { - clearTimeout(backslashTimeout); - backslashTimeout = null; - 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}` - ) { - if (inputBuffer && debugKeystrokeLogging) { - debugLogger.log( - '[DEBUG] Input buffer cleared on Ctrl+C:', - inputBuffer, - ); - } - inputBuffer = ''; - if (inputTimeout) { - clearTimeout(inputTimeout); - inputTimeout = null; - } - 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; - } - - // Clear any pending timeout when new input arrives - if (inputTimeout) { - clearTimeout(inputTimeout); - inputTimeout = null; - } - - // Always check if this could start a sequence we need to buffer (Kitty or Mouse) - // Other ESC sequences (like Alt+Key which is ESC+Key) should be let through if readline parsed them. - const shouldBuffer = couldBeKittySequence(key.sequence); - const isExcluded = [ - PASTE_MODE_START, - PASTE_MODE_END, - FOCUS_IN, - FOCUS_OUT, - ].some((prefix) => key.sequence.startsWith(prefix)); - - if (inputBuffer || (shouldBuffer && !isExcluded)) { - inputBuffer += key.sequence; - - if (debugKeystrokeLogging && !couldBeMouseSequence(inputBuffer)) { - debugLogger.log( - '[DEBUG] Input buffer accumulating:', - JSON.stringify(inputBuffer), - ); - } - - // Try immediate parsing - let remainingBuffer = inputBuffer; - let parsedAny = false; - - while (remainingBuffer) { - const parsed = parseKittyPrefix(remainingBuffer); - - if (parsed) { - // If kitty protocol is disabled, only allow legacy/standard sequences. - // parseKittyPrefix returns true for kittyProtocol if it's a modern kitty sequence. - if (kittyProtocolEnabled || !parsed.key.kittyProtocol) { - if (debugKeystrokeLogging) { - const parsedSequence = remainingBuffer.slice(0, parsed.length); - debugLogger.log( - '[DEBUG] Sequence parsed successfully:', - JSON.stringify(parsedSequence), - ); - } - broadcast(parsed.key); - remainingBuffer = remainingBuffer.slice(parsed.length); - parsedAny = true; - continue; - } - } - - const mouseParsed = parseMouseEvent(remainingBuffer); - if (mouseParsed) { - // These are handled by the separate mouse sequence parser. - // All we need to do is make sure we don't get confused by these - // sequences. - remainingBuffer = remainingBuffer.slice(mouseParsed.length); - parsedAny = true; - continue; - } - // If we can't parse a sequence at the start, check if there's - // another ESC later in the buffer. If so, the data before it - // is garbage/incomplete and should be dropped so we can - // process the next sequence. - const nextEscIndex = remainingBuffer.indexOf(ESC, 1); - if (nextEscIndex !== -1) { - const garbage = remainingBuffer.slice(0, nextEscIndex); - - // Special case: if garbage is exactly ESC, it's likely a rapid ESC press. - if (garbage === ESC) { - if (debugKeystrokeLogging) { - debugLogger.log( - '[DEBUG] Flushing rapid ESC before next ESC:', - JSON.stringify(garbage), - ); - } - broadcast({ - name: 'escape', - ctrl: false, - meta: true, - shift: false, - paste: false, - sequence: garbage, - }); - } else { - if (debugKeystrokeLogging) { - debugLogger.log( - '[DEBUG] Dropping incomplete sequence before next ESC:', - JSON.stringify(garbage), - ); - } - } - - // Continue parsing from next ESC - remainingBuffer = remainingBuffer.slice(nextEscIndex); - // We made progress, so we can continue the loop to parse the next sequence - continue; - } - - // Check if buffer could become a valid sequence - const couldBeValidKitty = - kittyProtocolEnabled && couldBeKittySequence(remainingBuffer); - const isMouse = isIncompleteMouseSequence(remainingBuffer); - const couldBeValid = couldBeValidKitty || isMouse; - - if (!couldBeValid) { - // Not a valid sequence - flush as regular input immediately - if (debugKeystrokeLogging) { - debugLogger.log( - '[DEBUG] Not a valid sequence, flushing:', - JSON.stringify(remainingBuffer), - ); - } - broadcast({ - name: '', - ctrl: false, - meta: false, - shift: false, - paste: false, - sequence: remainingBuffer, - }); - remainingBuffer = ''; - parsedAny = true; - } else if (remainingBuffer.length > MAX_KITTY_SEQUENCE_LENGTH) { - // Buffer overflow - log and clear - if (debugKeystrokeLogging) { - debugLogger.log( - '[DEBUG] Input buffer overflow, clearing:', - JSON.stringify(remainingBuffer), - ); - } - if (config && kittyProtocolEnabled) { - const event = new KittySequenceOverflowEvent( - remainingBuffer.length, - remainingBuffer, - ); - logKittySequenceOverflow(config, event); - } - // Flush as regular input - broadcast({ - name: '', - ctrl: false, - meta: false, - shift: false, - paste: false, - sequence: remainingBuffer, - }); - remainingBuffer = ''; - parsedAny = true; - } else { - if ( - (config?.getDebugMode() || debugKeystrokeLogging) && - !couldBeMouseSequence(inputBuffer) - ) { - debugLogger.warn( - 'Input sequence buffer has content:', - JSON.stringify(inputBuffer), - ); - } - // Could be valid but incomplete - set timeout - // Only set timeout if it's NOT a mouse sequence. - // Mouse sequences might be slow (e.g. over network) and we don't want to - // flush them as garbage keypresses. - // However, if it's just ESC or ESC[, it might be a user typing slowly, - // so we should still timeout in that case. - const isAmbiguousPrefix = - remainingBuffer === ESC || remainingBuffer === `${ESC}[`; - - if (!isMouse || isAmbiguousPrefix) { - inputTimeout = setTimeout(() => { - if (inputBuffer) { - if (debugKeystrokeLogging) { - debugLogger.log( - '[DEBUG] Input sequence timeout, flushing:', - JSON.stringify(inputBuffer), - ); - } - const isEscape = inputBuffer === ESC; - broadcast({ - name: isEscape ? 'escape' : '', - ctrl: false, - meta: isEscape, - shift: false, - paste: false, - sequence: inputBuffer, - }); - inputBuffer = ''; - } - inputTimeout = null; - }, KITTY_SEQUENCE_TIMEOUT_MS); - } else { - // It IS a mouse sequence and it's long enough to be unambiguously NOT just a user hitting ESC slowly. - // We just wait for more data. - if (inputTimeout) { - clearTimeout(inputTimeout); - inputTimeout = null; - } - } - break; - } - } - - inputBuffer = remainingBuffer; - if (parsedAny || inputBuffer) return; - } - - if (key.name === 'return' && key.sequence === `${ESC}\r`) { - key.meta = true; - } - broadcast({ ...key, paste: pasteBuffer !== null }); - }; - - let cleanup = () => {}; - let rl: readline.Interface; - if (keypressStream !== null) { - rl = readline.createInterface({ - input: keypressStream, - escapeCodeTimeout: 0, - }); - readline.emitKeypressEvents(keypressStream, rl); - - const parser = pasteMarkerParser(keypressStream, handleKeypress); - parser.next(); // prime the generator so it starts listening. - let timeoutId: NodeJS.Timeout; - const handleRawKeypress = (data: string) => { - clearTimeout(timeoutId); - parser.next(data); - timeoutId = setTimeout(() => parser.next(''), PASTE_CODE_TIMEOUT_MS); + old(data); }; - - keypressStream.on('keypress', handleKeypress); - process.stdin.setEncoding('utf8'); // so handleRawKeypress gets strings - stdin.on('data', handleRawKeypress); - - cleanup = () => { - keypressStream.removeListener('keypress', handleKeypress); - stdin.removeListener('data', handleRawKeypress); - }; - } else { - rl = readline.createInterface({ input: stdin, escapeCodeTimeout: 0 }); - readline.emitKeypressEvents(stdin, rl); - - stdin.on('keypress', handleKeypress); - - cleanup = () => stdin.removeListener('keypress', handleKeypress); } + stdin.on('data', dataListener); + return () => { - cleanup(); - rl.close(); + // flush buffers by sending null key + backslashBufferer(null); + pasteBufferer(null); + // flush by sending empty string to the data listener + dataListener(''); + stdin.removeListener('data', dataListener); // Restore the terminal to its original state. if (wasRaw === false) { setRawMode(false); } - - if (backslashTimeout) { - clearTimeout(backslashTimeout); - backslashTimeout = null; - } - - if (inputTimeout) { - clearTimeout(inputTimeout); - inputTimeout = null; - } - - // Flush any pending kitty sequence data to avoid data loss on exit. - if (inputBuffer) { - broadcast({ - name: '', - ctrl: false, - meta: false, - shift: false, - paste: false, - sequence: inputBuffer, - }); - inputBuffer = ''; - } - - // Flush any pending paste data to avoid data loss on exit. - if (pasteBuffer !== null) { - broadcast({ - name: '', - ctrl: false, - meta: false, - shift: false, - paste: true, - sequence: pasteBuffer.toString(), - }); - pasteBuffer = null; - } - - clearDraggingTimer(); - if (dragBuffer) { - broadcast({ - name: '', - ctrl: false, - meta: false, - shift: false, - paste: true, - sequence: dragBuffer, - }); - dragBuffer = ''; - } }; - }, [ - stdin, - setRawMode, - kittyProtocolEnabled, - config, - debugKeystrokeLogging, - broadcast, - ]); + }, [stdin, setRawMode, config, debugKeystrokeLogging, broadcast]); return ( diff --git a/packages/cli/src/ui/hooks/useFocus.test.tsx b/packages/cli/src/ui/hooks/useFocus.test.tsx index 83c2405f0f..070156b184 100644 --- a/packages/cli/src/ui/hooks/useFocus.test.tsx +++ b/packages/cli/src/ui/hooks/useFocus.test.tsx @@ -55,7 +55,7 @@ describe('useFocus', () => { return null; } const { unmount } = render( - + , ); @@ -84,7 +84,7 @@ describe('useFocus', () => { // Simulate focus-out event act(() => { - stdin.emit('data', Buffer.from('\x1b[O')); + stdin.emit('data', '\x1b[O'); }); // State should now be unfocused @@ -96,13 +96,13 @@ describe('useFocus', () => { // Simulate focus-out to set initial state to false act(() => { - stdin.emit('data', Buffer.from('\x1b[O')); + stdin.emit('data', '\x1b[O'); }); expect(result.current).toBe(false); // Simulate focus-in event act(() => { - stdin.emit('data', Buffer.from('\x1b[I')); + stdin.emit('data', '\x1b[I'); }); // State should now be focused @@ -128,22 +128,22 @@ describe('useFocus', () => { const { result } = renderFocusHook(); act(() => { - stdin.emit('data', Buffer.from('\x1b[O')); + stdin.emit('data', '\x1b[O'); }); expect(result.current).toBe(false); act(() => { - stdin.emit('data', Buffer.from('\x1b[O')); + stdin.emit('data', '\x1b[O'); }); expect(result.current).toBe(false); act(() => { - stdin.emit('data', Buffer.from('\x1b[I')); + stdin.emit('data', '\x1b[I'); }); expect(result.current).toBe(true); act(() => { - stdin.emit('data', Buffer.from('\x1b[I')); + stdin.emit('data', '\x1b[I'); }); expect(result.current).toBe(true); }); @@ -153,13 +153,13 @@ describe('useFocus', () => { // Simulate focus-out event act(() => { - stdin.emit('data', Buffer.from('\x1b[O')); + stdin.emit('data', '\x1b[O'); }); expect(result.current).toBe(false); // Simulate a keypress act(() => { - stdin.emit('data', Buffer.from('a')); + stdin.emit('data', 'a'); }); expect(result.current).toBe(true); }); diff --git a/packages/cli/src/ui/hooks/useKeypress.test.tsx b/packages/cli/src/ui/hooks/useKeypress.test.tsx index 5a8240d300..f849a86a25 100644 --- a/packages/cli/src/ui/hooks/useKeypress.test.tsx +++ b/packages/cli/src/ui/hooks/useKeypress.test.tsx @@ -38,7 +38,7 @@ class MockStdin extends EventEmitter { } } -describe.each([true, false])(`useKeypress with useKitty=%s`, (useKitty) => { +describe(`useKeypress with useKitty=%s`, () => { let stdin: MockStdin; const mockSetRawMode = vi.fn(); const onKeypress = vi.fn(); @@ -50,7 +50,7 @@ describe.each([true, false])(`useKeypress with useKitty=%s`, (useKitty) => { return null; } return render( - + , ); @@ -196,20 +196,13 @@ describe.each([true, false])(`useKeypress with useKitty=%s`, (useKitty) => { stdin.write('do'); }); - if (useKitty) { - vi.advanceTimersByTime(60); // wait for kitty timeout - expect(onKeypress).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ sequence: '\x1B[200do' }), - ); - } else { - expect(onKeypress).toHaveBeenCalledWith( - expect.objectContaining({ sequence: '\x1B[200d' }), - ); - expect(onKeypress).toHaveBeenCalledWith( - expect.objectContaining({ sequence: 'o' }), - ); - expect(onKeypress).toHaveBeenCalledTimes(2); - } + expect(onKeypress).toHaveBeenCalledWith( + expect.objectContaining({ sequence: '\x1B[200d' }), + ); + expect(onKeypress).toHaveBeenCalledWith( + expect.objectContaining({ sequence: 'o' }), + ); + expect(onKeypress).toHaveBeenCalledTimes(2); }); it('should handle back to back pastes', () => { @@ -249,11 +242,11 @@ describe.each([true, false])(`useKeypress with useKitty=%s`, (useKitty) => { const pasteText = 'pasted'; await act(async () => { stdin.write(PASTE_START.slice(0, 3)); - vi.advanceTimersByTime(50); + vi.advanceTimersByTime(40); stdin.write(PASTE_START.slice(3) + pasteText.slice(0, 3)); - vi.advanceTimersByTime(50); + vi.advanceTimersByTime(40); stdin.write(pasteText.slice(3) + PASTE_END.slice(0, 3)); - vi.advanceTimersByTime(50); + vi.advanceTimersByTime(40); stdin.write(PASTE_END.slice(3)); }); expect(onKeypress).toHaveBeenCalledWith( diff --git a/packages/cli/src/ui/utils/platformConstants.ts b/packages/cli/src/ui/utils/platformConstants.ts deleted file mode 100644 index 7993883d6c..0000000000 --- a/packages/cli/src/ui/utils/platformConstants.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * Terminal Platform Constants - * - * This file contains terminal-related constants used throughout the application, - * specifically for handling keyboard inputs and terminal protocols. - */ - -/** - * Kitty keyboard protocol sequences for enhanced keyboard input. - * @see https://sw.kovidgoyal.net/kitty/keyboard-protocol/ - */ -export const KITTY_CTRL_C = '[99;5u'; - -/** - * Kitty keyboard protocol keycodes - */ -export const KITTY_KEYCODE_ENTER = 13; -export const KITTY_KEYCODE_NUMPAD_ENTER = 57414; -export const KITTY_KEYCODE_TAB = 9; -export const KITTY_KEYCODE_BACKSPACE = 127; - -/** - * Kitty modifier decoding constants - * - * In Kitty/Ghostty, the modifier parameter is encoded as (1 + bitmask). - * Some terminals also set bit 7 (i.e., add 128) when reporting event types. - */ -export const KITTY_MODIFIER_BASE = 1; // Base value per spec before bitmask decode -export const KITTY_MODIFIER_EVENT_TYPES_OFFSET = 128; // Added when event types are included - -/** - * Modifier bit flags for Kitty/Xterm-style parameters. - * - * Per spec, the modifiers parameter encodes (1 + bitmask) where: - * - 1: no modifiers - * - bit 0 (1): Shift - * - bit 1 (2): Alt/Option (reported as "alt" in spec; we map to meta) - * - bit 2 (4): Ctrl - * - * Some terminals add 128 to the entire modifiers field when reporting event types. - * See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/#modifiers - */ -export const MODIFIER_SHIFT_BIT = 1; -export const MODIFIER_ALT_BIT = 2; -export const MODIFIER_CTRL_BIT = 4; - -/** - * Timing constants for terminal interactions - */ -export const CTRL_EXIT_PROMPT_DURATION_MS = 1000; - -/** - * VS Code terminal integration constants - */ -export const VSCODE_SHIFT_ENTER_SEQUENCE = '\\\r\n'; - -/** - * Backslash + Enter detection window in milliseconds. - * Used to detect Shift+Enter pattern where backslash - * is followed by Enter within this timeframe. - */ -export const BACKSLASH_ENTER_DETECTION_WINDOW_MS = 5; - -/** - * Maximum expected length of a Kitty keyboard protocol sequence. - * Format: ESC [ ; u/~ - * Example: \x1b[13;2u (Shift+Enter) = 8 chars - * Longest reasonable: \x1b[127;15~ = 11 chars (Del with all modifiers) - * We use 12 to provide a small buffer. - */ -// Increased to accommodate parameterized forms and occasional colon subfields -// while still being small enough to avoid pathological buffering. -export const MAX_KITTY_SEQUENCE_LENGTH = 32; - -/** - * Character codes for common escape sequences - */ -export const CHAR_CODE_ESC = 27; -export const CHAR_CODE_LEFT_BRACKET = 91; -export const CHAR_CODE_1 = 49; -export const CHAR_CODE_2 = 50; diff --git a/packages/cli/src/ui/utils/terminalSetup.ts b/packages/cli/src/ui/utils/terminalSetup.ts index 864750c9fd..87e4ae1501 100644 --- a/packages/cli/src/ui/utils/terminalSetup.ts +++ b/packages/cli/src/ui/utils/terminalSetup.ts @@ -29,9 +29,11 @@ import * as path from 'node:path'; import { exec } from 'node:child_process'; import { promisify } from 'node:util'; import { isKittyProtocolEnabled } from './kittyProtocolDetector.js'; -import { VSCODE_SHIFT_ENTER_SEQUENCE } from './platformConstants.js'; + import { debugLogger } from '@google/gemini-cli-core'; +export const VSCODE_SHIFT_ENTER_SEQUENCE = '\\\r\n'; + const execAsync = promisify(exec); /** diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts index 6050314313..bb9e35f1fc 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -18,7 +18,6 @@ import type { MalformedJsonResponseEvent, IdeConnectionEvent, ConversationFinishedEvent, - KittySequenceOverflowEvent, ChatCompressionEvent, FileOperationEvent, InvalidChunkEvent, @@ -847,20 +846,6 @@ export class ClearcutLogger { this.flushIfNeeded(); } - logKittySequenceOverflowEvent(event: KittySequenceOverflowEvent): void { - const data: EventValue[] = [ - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_KITTY_SEQUENCE_LENGTH, - value: event.sequence_length.toString(), - }, - ]; - - this.enqueueLogEvent( - this.createLogEvent(EventNames.KITTY_SEQUENCE_OVERFLOW, data), - ); - this.flushIfNeeded(); - } - logEndSessionEvent(): void { // Flush immediately on session end. this.enqueueLogEvent(this.createLogEvent(EventNames.END_SESSION, [])); diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts index c86aaf6b46..f9f520ae78 100644 --- a/packages/core/src/telemetry/index.ts +++ b/packages/core/src/telemetry/index.ts @@ -38,7 +38,6 @@ export { logFlashFallback, logSlashCommand, logConversationFinishedEvent, - logKittySequenceOverflow, logChatCompression, logToolOutputTruncated, logExtensionEnable, @@ -59,7 +58,6 @@ export { StartSessionEvent, ToolCallEvent, ConversationFinishedEvent, - KittySequenceOverflowEvent, ToolOutputTruncatedEvent, WebFetchFallbackAttemptEvent, ToolCallDecision, diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index c2d6180c67..f5e43b9b00 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -28,7 +28,6 @@ import type { LoopDetectionDisabledEvent, SlashCommandEvent, ConversationFinishedEvent, - KittySequenceOverflowEvent, ChatCompressionEvent, MalformedJsonResponseEvent, InvalidChunkEvent, @@ -398,20 +397,6 @@ export function logChatCompression( }); } -export function logKittySequenceOverflow( - config: Config, - event: KittySequenceOverflowEvent, -): void { - ClearcutLogger.getInstance(config)?.logKittySequenceOverflowEvent(event); - if (!isTelemetrySdkInitialized()) return; - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: event.toLogBody(), - attributes: event.toOpenTelemetryAttributes(config), - }; - logger.emit(logRecord); -} - export function logMalformedJsonResponse( config: Config, event: MalformedJsonResponseEvent, diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 7461689e2f..e5b309fea8 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -949,34 +949,6 @@ export class ConversationFinishedEvent { } } -export class KittySequenceOverflowEvent { - 'event.name': 'kitty_sequence_overflow'; - 'event.timestamp': string; // ISO 8601 - sequence_length: number; - truncated_sequence: string; - constructor(sequence_length: number, truncated_sequence: string) { - this['event.name'] = 'kitty_sequence_overflow'; - this['event.timestamp'] = new Date().toISOString(); - this.sequence_length = sequence_length; - // Truncate to first 20 chars for logging (avoid logging sensitive data) - this.truncated_sequence = truncated_sequence.substring(0, 20); - } - - toOpenTelemetryAttributes(config: Config): LogAttributes { - return { - ...getCommonAttributes(config), - 'event.name': this['event.name'], - 'event.timestamp': this['event.timestamp'], - sequence_length: this.sequence_length, - truncated_sequence: this.truncated_sequence, - }; - } - - toLogBody(): string { - return `Kitty sequence buffer overflow: ${this.sequence_length} bytes`; - } -} - export const EVENT_FILE_OPERATION = 'gemini_cli.file_operation'; export class FileOperationEvent implements BaseTelemetryEvent { 'event.name': 'file_operation'; @@ -1444,7 +1416,6 @@ export type TelemetryEvent = | LoopDetectedEvent | LoopDetectionDisabledEvent | NextSpeakerCheckEvent - | KittySequenceOverflowEvent | MalformedJsonResponseEvent | IdeConnectionEvent | ConversationFinishedEvent