diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx new file mode 100644 index 00000000..05b92532 --- /dev/null +++ b/packages/cli/src/test-utils/render.tsx @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from 'ink-testing-library'; +import React from 'react'; +import { KeypressProvider } from '../ui/contexts/KeypressContext.js'; + +export const renderWithProviders = ( + component: React.ReactElement, +): ReturnType => + render( + + {component} + , + ); diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index 64cd5842..d9d368f0 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -5,7 +5,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest'; -import { render } from 'ink-testing-library'; +import { renderWithProviders } from '../test-utils/render.js'; import { AppWrapper as App } from './App.js'; import { Config as ServerConfig, @@ -371,7 +371,7 @@ describe('App UI', () => { mockedCheckForUpdates.mockResolvedValue(info); const { spawn } = await import('node:child_process'); - const { unmount } = render( + const { unmount } = renderWithProviders( { }; mockedCheckForUpdates.mockResolvedValue(info); - const { lastFrame, unmount } = render( + const { lastFrame, unmount } = renderWithProviders( { }; mockedCheckForUpdates.mockResolvedValue(info); - const { lastFrame, unmount } = render( + const { lastFrame, unmount } = renderWithProviders( { }; mockedCheckForUpdates.mockResolvedValue(info); - const { lastFrame, unmount } = render( + const { lastFrame, unmount } = renderWithProviders( { mockedCheckForUpdates.mockResolvedValue(info); const { spawn } = await import('node:child_process'); - const { unmount } = render( + const { unmount } = renderWithProviders( { }, }); - const { lastFrame, unmount } = render( + const { lastFrame, unmount } = renderWithProviders( { }, }); - const { lastFrame, unmount } = render( + const { lastFrame, unmount } = renderWithProviders( { }, }); - const { lastFrame, unmount } = render( + const { lastFrame, unmount } = renderWithProviders( { mockConfig.getGeminiMdFileCount.mockReturnValue(1); mockConfig.getAllGeminiMdFilenames.mockReturnValue(['GEMINI.md']); - const { lastFrame, unmount } = render( + const { lastFrame, unmount } = renderWithProviders( { mockConfig.getDebugMode.mockReturnValue(false); mockConfig.getShowMemoryUsage.mockReturnValue(false); - const { lastFrame, unmount } = render( + const { lastFrame, unmount } = renderWithProviders( { mockConfig.getDebugMode.mockReturnValue(false); mockConfig.getShowMemoryUsage.mockReturnValue(false); - const { lastFrame, unmount } = render( + const { lastFrame, unmount } = renderWithProviders( { mockConfig.getDebugMode.mockReturnValue(false); mockConfig.getShowMemoryUsage.mockReturnValue(false); - const { lastFrame, unmount } = render( + const { lastFrame, unmount } = renderWithProviders( { mockConfig.getDebugMode.mockReturnValue(false); mockConfig.getShowMemoryUsage.mockReturnValue(false); - const { lastFrame, unmount } = render( + const { lastFrame, unmount } = renderWithProviders( { mockConfig.getDebugMode.mockReturnValue(false); mockConfig.getShowMemoryUsage.mockReturnValue(false); - const { lastFrame, unmount } = render( + const { lastFrame, unmount } = renderWithProviders( { mockConfig.getDebugMode.mockReturnValue(false); mockConfig.getShowMemoryUsage.mockReturnValue(false); - const { lastFrame, unmount } = render( + const { lastFrame, unmount } = renderWithProviders( { mockConfig.getDebugMode.mockReturnValue(false); mockConfig.getShowMemoryUsage.mockReturnValue(false); - const { lastFrame, unmount } = render( + const { lastFrame, unmount } = renderWithProviders( { mockConfig.getDebugMode.mockReturnValue(false); mockConfig.getShowMemoryUsage.mockReturnValue(false); - const { lastFrame, unmount } = render( + const { lastFrame, unmount } = renderWithProviders( { }); it('should display Tips component by default', async () => { - const { unmount } = render( + const { unmount } = renderWithProviders( { }, }); - const { unmount } = render( + const { unmount } = renderWithProviders( { it('should display Header component by default', async () => { const { Header } = await import('./components/Header.js'); - const { unmount } = render( + const { unmount } = renderWithProviders( { user: { hideBanner: true }, }); - const { unmount } = render( + const { unmount } = renderWithProviders( { workspace: { hideTips: true }, }); - const { unmount } = render( + const { unmount } = renderWithProviders( { it('should display theme dialog if NO_COLOR is not set', async () => { delete process.env.NO_COLOR; - const { lastFrame, unmount } = render( + const { lastFrame, unmount } = renderWithProviders( { it('should display a message if NO_COLOR is set', async () => { process.env.NO_COLOR = 'true'; - const { lastFrame, unmount } = render( + const { lastFrame, unmount } = renderWithProviders( { }); it('should render the initial UI correctly', () => { - const { lastFrame, unmount } = render( + const { lastFrame, unmount } = renderWithProviders( { thought: null, }); - const { lastFrame, unmount } = render( + const { lastFrame, unmount } = renderWithProviders( { getUserTier: vi.fn(), } as unknown as GeminiClient); - const { unmount, rerender } = render( + const { unmount, rerender } = renderWithProviders( { clearConsoleMessages: vi.fn(), }); - const { lastFrame, unmount } = render( + const { lastFrame, unmount } = renderWithProviders( { }, }); - const { unmount } = render( + const { unmount } = renderWithProviders( { }, }); - const { unmount } = render( + const { unmount } = renderWithProviders( { rows: 24, }); - const { lastFrame, unmount } = render( + const { lastFrame, unmount } = renderWithProviders( { handleFolderTrustSelect: vi.fn(), }); - const { lastFrame, unmount } = render( + const { lastFrame, unmount } = renderWithProviders( { }); mockConfig.isTrustedFolder.mockReturnValue(false); - const { lastFrame, unmount } = render( + const { lastFrame, unmount } = renderWithProviders( { }); mockConfig.isTrustedFolder.mockReturnValue(false); - const { lastFrame, unmount } = render( + const { lastFrame, unmount } = renderWithProviders( ( - - - - - -); +export const AppWrapper = (props: AppProps) => { + const kittyProtocolStatus = useKittyKeyboardProtocol(); + return ( + + + + + + + + ); +}; const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { const isFocused = useFocus(); @@ -611,7 +620,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator(streamingState); const showAutoAcceptIndicator = useAutoAcceptIndicator({ config }); - const kittyProtocolStatus = useKittyKeyboardProtocol(); const handleExit = useCallback( ( @@ -706,8 +714,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { useKeypress(handleGlobalKeypress, { isActive: true, - kittyProtocolEnabled: kittyProtocolStatus.enabled, - config, }); useEffect(() => { diff --git a/packages/cli/src/ui/components/AuthDialog.test.tsx b/packages/cli/src/ui/components/AuthDialog.test.tsx index a8893215..3efc3c01 100644 --- a/packages/cli/src/ui/components/AuthDialog.test.tsx +++ b/packages/cli/src/ui/components/AuthDialog.test.tsx @@ -4,11 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from 'ink-testing-library'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { AuthDialog } from './AuthDialog.js'; import { LoadedSettings, SettingScope } from '../../config/settings.js'; import { AuthType } from '@google/gemini-cli-core'; +import { renderWithProviders } from '../../test-utils/render.js'; describe('AuthDialog', () => { const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -47,7 +47,7 @@ describe('AuthDialog', () => { [], ); - const { lastFrame } = render( + const { lastFrame } = renderWithProviders( {}} settings={settings} @@ -84,7 +84,7 @@ describe('AuthDialog', () => { [], ); - const { lastFrame } = render( + const { lastFrame } = renderWithProviders( {}} settings={settings} />, ); @@ -117,7 +117,7 @@ describe('AuthDialog', () => { [], ); - const { lastFrame } = render( + const { lastFrame } = renderWithProviders( {}} settings={settings} />, ); @@ -150,7 +150,7 @@ describe('AuthDialog', () => { [], ); - const { lastFrame } = render( + const { lastFrame } = renderWithProviders( {}} settings={settings} />, ); @@ -184,7 +184,7 @@ describe('AuthDialog', () => { [], ); - const { lastFrame } = render( + const { lastFrame } = renderWithProviders( {}} settings={settings} />, ); @@ -213,7 +213,7 @@ describe('AuthDialog', () => { [], ); - const { lastFrame } = render( + const { lastFrame } = renderWithProviders( {}} settings={settings} />, ); @@ -244,7 +244,7 @@ describe('AuthDialog', () => { [], ); - const { lastFrame } = render( + const { lastFrame } = renderWithProviders( {}} settings={settings} />, ); @@ -279,7 +279,7 @@ describe('AuthDialog', () => { [], ); - const { lastFrame, stdin, unmount } = render( + const { lastFrame, stdin, unmount } = renderWithProviders( , ); await wait(); @@ -318,7 +318,7 @@ describe('AuthDialog', () => { [], ); - const { lastFrame, stdin, unmount } = render( + const { lastFrame, stdin, unmount } = renderWithProviders( { [], ); - const { stdin, unmount } = render( + const { stdin, unmount } = renderWithProviders( , ); await wait(); diff --git a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx index d1be0b61..e2b695e2 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx @@ -4,14 +4,16 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from 'ink-testing-library'; +import { renderWithProviders } from '../../test-utils/render.js'; import { waitFor } from '@testing-library/react'; import { vi } from 'vitest'; import { FolderTrustDialog, FolderTrustChoice } from './FolderTrustDialog.js'; describe('FolderTrustDialog', () => { it('should render the dialog with title and description', () => { - const { lastFrame } = render(); + const { lastFrame } = renderWithProviders( + , + ); expect(lastFrame()).toContain('Do you trust this folder?'); expect(lastFrame()).toContain( @@ -21,7 +23,9 @@ describe('FolderTrustDialog', () => { it('should call onSelect with DO_NOT_TRUST when escape is pressed', async () => { const onSelect = vi.fn(); - const { stdin } = render(); + const { stdin } = renderWithProviders( + , + ); stdin.write('\x1b'); diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index ec2d7441..a4aaf6e9 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from 'ink-testing-library'; +import { renderWithProviders } from '../../test-utils/render.js'; import { waitFor } from '@testing-library/react'; import { InputPrompt, InputPromptProps } from './InputPrompt.js'; import type { TextBuffer } from './shared/text-buffer.js'; @@ -197,7 +197,7 @@ describe('InputPrompt', () => { it('should call shellHistory.getPreviousCommand on up arrow in shell mode', async () => { props.shellModeActive = true; - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders(); await wait(); stdin.write('\u001B[A'); @@ -209,7 +209,7 @@ describe('InputPrompt', () => { it('should call shellHistory.getNextCommand on down arrow in shell mode', async () => { props.shellModeActive = true; - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders(); await wait(); stdin.write('\u001B[B'); @@ -224,7 +224,7 @@ describe('InputPrompt', () => { vi.mocked(mockShellHistory.getPreviousCommand).mockReturnValue( 'previous command', ); - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders(); await wait(); stdin.write('\u001B[A'); @@ -238,7 +238,7 @@ describe('InputPrompt', () => { it('should call shellHistory.addCommandToHistory on submit in shell mode', async () => { props.shellModeActive = true; props.buffer.setText('ls -l'); - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders(); await wait(); stdin.write('\r'); @@ -251,7 +251,7 @@ describe('InputPrompt', () => { it('should NOT call shell history methods when not in shell mode', async () => { props.buffer.setText('some text'); - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders(); await wait(); stdin.write('\u001B[A'); // Up arrow @@ -283,7 +283,7 @@ describe('InputPrompt', () => { props.buffer.setText('/mem'); - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders(); await wait(); // Test up arrow @@ -309,7 +309,7 @@ describe('InputPrompt', () => { }); props.buffer.setText('/mem'); - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders(); await wait(); // Test down arrow @@ -331,7 +331,7 @@ describe('InputPrompt', () => { }); props.buffer.setText('some text'); - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders(); await wait(); stdin.write('\u001B[A'); // Up arrow @@ -363,7 +363,9 @@ describe('InputPrompt', () => { '/test/.gemini-clipboard/clipboard-123.png', ); - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders( + , + ); await wait(); // Send Ctrl+V @@ -384,7 +386,9 @@ describe('InputPrompt', () => { it('should not insert anything when clipboard has no image', async () => { vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false); - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders( + , + ); await wait(); stdin.write('\x16'); // Ctrl+V @@ -400,7 +404,9 @@ describe('InputPrompt', () => { vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true); vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(null); - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders( + , + ); await wait(); stdin.write('\x16'); // Ctrl+V @@ -426,7 +432,9 @@ describe('InputPrompt', () => { mockBuffer.lines = ['Hello world']; mockBuffer.replaceRangeByOffset = vi.fn(); - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders( + , + ); await wait(); stdin.write('\x16'); // Ctrl+V @@ -454,7 +462,9 @@ describe('InputPrompt', () => { new Error('Clipboard error'), ); - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders( + , + ); await wait(); stdin.write('\x16'); // Ctrl+V @@ -481,7 +491,7 @@ describe('InputPrompt', () => { }); props.buffer.setText('/mem'); - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders(); await wait(); stdin.write('\t'); // Press Tab @@ -504,7 +514,7 @@ describe('InputPrompt', () => { }); props.buffer.setText('/memory '); - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders(); await wait(); stdin.write('\t'); // Press Tab @@ -528,7 +538,7 @@ describe('InputPrompt', () => { // The user has backspaced, so the query is now just '/memory' props.buffer.setText('/memory'); - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders(); await wait(); stdin.write('\t'); // Press Tab @@ -549,7 +559,7 @@ describe('InputPrompt', () => { }); props.buffer.setText('/chat resume fi-'); - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders(); await wait(); stdin.write('\t'); // Press Tab @@ -568,7 +578,7 @@ describe('InputPrompt', () => { }); props.buffer.setText('/mem'); - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders(); await wait(); stdin.write('\r'); @@ -599,7 +609,7 @@ describe('InputPrompt', () => { }); props.buffer.setText('/?'); - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders(); await wait(); stdin.write('\t'); // Press Tab for autocomplete @@ -612,7 +622,7 @@ describe('InputPrompt', () => { it('should not submit on Enter when the buffer is empty or only contains whitespace', async () => { props.buffer.setText(' '); // Set buffer to whitespace - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders(); await wait(); stdin.write('\r'); // Press Enter @@ -630,7 +640,7 @@ describe('InputPrompt', () => { }); props.buffer.setText('/clear'); - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders(); await wait(); stdin.write('\r'); @@ -648,7 +658,7 @@ describe('InputPrompt', () => { }); props.buffer.setText('/clear'); - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders(); await wait(); stdin.write('\r'); @@ -667,7 +677,7 @@ describe('InputPrompt', () => { }); props.buffer.setText('@src/components/'); - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders(); await wait(); stdin.write('\r'); @@ -684,7 +694,7 @@ describe('InputPrompt', () => { mockBuffer.cursor = [0, 11]; mockBuffer.lines = ['first line\\']; - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders(); await wait(); stdin.write('\r'); @@ -698,7 +708,7 @@ describe('InputPrompt', () => { it('should clear the buffer on Ctrl+C if it has text', async () => { props.buffer.setText('some text to clear'); - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders(); await wait(); stdin.write('\x03'); // Ctrl+C character @@ -712,7 +722,7 @@ describe('InputPrompt', () => { it('should NOT clear the buffer on Ctrl+C if it is empty', async () => { props.buffer.text = ''; - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders(); await wait(); stdin.write('\x03'); // Ctrl+C character @@ -735,7 +745,7 @@ describe('InputPrompt', () => { suggestions: [{ label: 'Button.tsx', value: 'Button.tsx' }], }); - const { unmount } = render(); + const { unmount } = renderWithProviders(); await wait(); // Verify useCompletion was called with correct signature @@ -763,7 +773,7 @@ describe('InputPrompt', () => { suggestions: [{ label: 'show', value: 'show' }], }); - const { unmount } = render(); + const { unmount } = renderWithProviders(); await wait(); expect(mockedUseCommandCompletion).toHaveBeenCalledWith( @@ -790,7 +800,7 @@ describe('InputPrompt', () => { suggestions: [], }); - const { unmount } = render(); + const { unmount } = renderWithProviders(); await wait(); expect(mockedUseCommandCompletion).toHaveBeenCalledWith( @@ -817,7 +827,7 @@ describe('InputPrompt', () => { suggestions: [], }); - const { unmount } = render(); + const { unmount } = renderWithProviders(); await wait(); expect(mockedUseCommandCompletion).toHaveBeenCalledWith( @@ -844,7 +854,7 @@ describe('InputPrompt', () => { suggestions: [], }); - const { unmount } = render(); + const { unmount } = renderWithProviders(); await wait(); expect(mockedUseCommandCompletion).toHaveBeenCalledWith( @@ -871,7 +881,7 @@ describe('InputPrompt', () => { suggestions: [], }); - const { unmount } = render(); + const { unmount } = renderWithProviders(); await wait(); // Verify useCompletion was called with the buffer @@ -899,7 +909,7 @@ describe('InputPrompt', () => { suggestions: [{ label: 'show', value: 'show' }], }); - const { unmount } = render(); + const { unmount } = renderWithProviders(); await wait(); expect(mockedUseCommandCompletion).toHaveBeenCalledWith( @@ -927,7 +937,7 @@ describe('InputPrompt', () => { suggestions: [{ label: 'file👍.txt', value: 'file👍.txt' }], }); - const { unmount } = render(); + const { unmount } = renderWithProviders(); await wait(); expect(mockedUseCommandCompletion).toHaveBeenCalledWith( @@ -955,7 +965,7 @@ describe('InputPrompt', () => { suggestions: [], }); - const { unmount } = render(); + const { unmount } = renderWithProviders(); await wait(); expect(mockedUseCommandCompletion).toHaveBeenCalledWith( @@ -983,7 +993,7 @@ describe('InputPrompt', () => { suggestions: [{ label: 'my file.txt', value: 'my file.txt' }], }); - const { unmount } = render(); + const { unmount } = renderWithProviders(); await wait(); expect(mockedUseCommandCompletion).toHaveBeenCalledWith( @@ -1011,7 +1021,7 @@ describe('InputPrompt', () => { suggestions: [], }); - const { unmount } = render(); + const { unmount } = renderWithProviders(); await wait(); expect(mockedUseCommandCompletion).toHaveBeenCalledWith( @@ -1041,7 +1051,7 @@ describe('InputPrompt', () => { ], }); - const { unmount } = render(); + const { unmount } = renderWithProviders(); await wait(); expect(mockedUseCommandCompletion).toHaveBeenCalledWith( @@ -1069,7 +1079,7 @@ describe('InputPrompt', () => { suggestions: [{ label: 'test-command', value: 'test-command' }], }); - const { unmount } = render(); + const { unmount } = renderWithProviders(); await wait(); expect(mockedUseCommandCompletion).toHaveBeenCalledWith( @@ -1099,7 +1109,7 @@ describe('InputPrompt', () => { ], }); - const { unmount } = render(); + const { unmount } = renderWithProviders(); await wait(); expect(mockedUseCommandCompletion).toHaveBeenCalledWith( @@ -1120,7 +1130,9 @@ describe('InputPrompt', () => { it('should not call buffer.handleInput when vim mode is enabled and vim handles the input', async () => { props.vimModeEnabled = true; props.vimHandleInput = vi.fn().mockReturnValue(true); // Mock that vim handled it. - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders( + , + ); await wait(); stdin.write('i'); @@ -1134,7 +1146,9 @@ describe('InputPrompt', () => { it('should call buffer.handleInput when vim mode is enabled but vim does not handle the input', async () => { props.vimModeEnabled = true; props.vimHandleInput = vi.fn().mockReturnValue(false); // Mock that vim did NOT handle it. - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders( + , + ); await wait(); stdin.write('i'); @@ -1148,7 +1162,9 @@ describe('InputPrompt', () => { it('should call handleInput when vim mode is disabled', async () => { // Mock vimHandleInput to return false (vim didn't handle the input) props.vimHandleInput = vi.fn().mockReturnValue(false); - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders( + , + ); await wait(); stdin.write('i'); @@ -1163,7 +1179,9 @@ describe('InputPrompt', () => { describe('unfocused paste', () => { it('should handle bracketed paste when not focused', async () => { props.focus = false; - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders( + , + ); await wait(); stdin.write('\x1B[200~pasted text\x1B[201~'); @@ -1180,7 +1198,9 @@ describe('InputPrompt', () => { it('should ignore regular keypresses when not focused', async () => { props.focus = false; - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders( + , + ); await wait(); stdin.write('a'); @@ -1197,7 +1217,9 @@ describe('InputPrompt', () => { props.onEscapePromptChange = onEscapePromptChange; props.buffer.setText('text to clear'); - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders( + , + ); await wait(); stdin.write('\x1B'); @@ -1216,7 +1238,9 @@ describe('InputPrompt', () => { props.onEscapePromptChange = onEscapePromptChange; props.buffer.setText('some text'); - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders( + , + ); stdin.write('\x1B'); @@ -1235,7 +1259,9 @@ describe('InputPrompt', () => { it('should handle ESC in shell mode by disabling shell mode', async () => { props.shellModeActive = true; - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders( + , + ); await wait(); stdin.write('\x1B'); @@ -1252,7 +1278,9 @@ describe('InputPrompt', () => { suggestions: [{ label: 'suggestion', value: 'suggestion' }], }); - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders( + , + ); await wait(); stdin.write('\x1B'); @@ -1266,7 +1294,9 @@ describe('InputPrompt', () => { props.onEscapePromptChange = undefined; props.buffer.setText('some text'); - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders( + , + ); await wait(); stdin.write('\x1B'); @@ -1276,7 +1306,9 @@ describe('InputPrompt', () => { }); it('should not interfere with existing keyboard shortcuts', async () => { - const { stdin, unmount } = render(); + const { stdin, unmount } = renderWithProviders( + , + ); await wait(); stdin.write('\x0C'); @@ -1306,7 +1338,9 @@ describe('InputPrompt', () => { }); it('invokes reverse search on Ctrl+R', async () => { - const { stdin, stdout, unmount } = render(); + const { stdin, stdout, unmount } = renderWithProviders( + , + ); await wait(); stdin.write('\x12'); @@ -1322,7 +1356,9 @@ describe('InputPrompt', () => { }); it('resets reverse search state on Escape', async () => { - const { stdin, stdout, unmount } = render(); + const { stdin, stdout, unmount } = renderWithProviders( + , + ); await wait(); stdin.write('\x12'); @@ -1339,7 +1375,9 @@ describe('InputPrompt', () => { }); it('completes the highlighted entry on Tab and exits reverse-search', async () => { - const { stdin, stdout, unmount } = render(); + const { stdin, stdout, unmount } = renderWithProviders( + , + ); stdin.write('\x12'); await wait(); stdin.write('\t'); @@ -1353,7 +1391,9 @@ describe('InputPrompt', () => { }); it('submits the highlighted entry on Enter and exits reverse-search', async () => { - const { stdin, stdout, unmount } = render(); + const { stdin, stdout, unmount } = renderWithProviders( + , + ); stdin.write('\x12'); await wait(); expect(stdout.lastFrame()).toContain('(r:)'); @@ -1370,7 +1410,9 @@ describe('InputPrompt', () => { it('text and cursor position should be restored after reverse search', async () => { props.buffer.setText('initial text'); props.buffer.cursor = [0, 3]; - const { stdin, stdout, unmount } = render(); + const { stdin, stdout, unmount } = renderWithProviders( + , + ); stdin.write('\x12'); await wait(); expect(stdout.lastFrame()).toContain('(r:)'); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 94cbcf1b..dcfdace3 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -17,7 +17,6 @@ import { useShellHistory } from '../hooks/useShellHistory.js'; import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js'; import { useCommandCompletion } from '../hooks/useCommandCompletion.js'; import { useKeypress, Key } from '../hooks/useKeypress.js'; -import { useKittyKeyboardProtocol } from '../hooks/useKittyKeyboardProtocol.js'; import { keyMatchers, Command } from '../keyMatchers.js'; import { CommandContext, SlashCommand } from '../commands/types.js'; import { Config } from '@google/gemini-cli-core'; @@ -67,7 +66,6 @@ export const InputPrompt: React.FC = ({ const [escPressCount, setEscPressCount] = useState(0); const [showEscapePrompt, setShowEscapePrompt] = useState(false); const escapeTimerRef = useRef(null); - const kittyProtocolStatus = useKittyKeyboardProtocol(); const [dirs, setDirs] = useState( config.getWorkspaceContext().getDirectories(), @@ -529,8 +527,6 @@ export const InputPrompt: React.FC = ({ useKeypress(handleInput, { isActive: true, - kittyProtocolEnabled: kittyProtocolStatus.enabled, - config, }); const linesToRender = buffer.viewportVisualLines; diff --git a/packages/cli/src/ui/components/SettingsDialog.test.tsx b/packages/cli/src/ui/components/SettingsDialog.test.tsx index 94f33561..e636bad5 100644 --- a/packages/cli/src/ui/components/SettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.test.tsx @@ -21,8 +21,8 @@ * */ -import { render } from 'ink-testing-library'; import { waitFor } from '@testing-library/react'; +import { renderWithProviders } from '../../test-utils/render.js'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { SettingsDialog } from './SettingsDialog.js'; import { LoadedSettings, SettingScope } from '../../config/settings.js'; @@ -102,7 +102,7 @@ describe('SettingsDialog', () => { const settings = createMockSettings(); const onSelect = vi.fn(); - const { lastFrame } = render( + const { lastFrame } = renderWithProviders( , ); @@ -116,7 +116,7 @@ describe('SettingsDialog', () => { const settings = createMockSettings(); const onSelect = vi.fn(); - const { lastFrame } = render( + const { lastFrame } = renderWithProviders( , ); @@ -129,7 +129,7 @@ describe('SettingsDialog', () => { const settings = createMockSettings(); const onSelect = vi.fn(); - const { lastFrame } = render( + const { lastFrame } = renderWithProviders( , ); @@ -144,7 +144,7 @@ describe('SettingsDialog', () => { const settings = createMockSettings(); const onSelect = vi.fn(); - const { stdin, unmount } = render( + const { stdin, unmount } = renderWithProviders( , ); @@ -160,7 +160,7 @@ describe('SettingsDialog', () => { const settings = createMockSettings(); const onSelect = vi.fn(); - const { stdin, unmount } = render( + const { stdin, unmount } = renderWithProviders( , ); @@ -177,7 +177,7 @@ describe('SettingsDialog', () => { const settings = createMockSettings(); const onSelect = vi.fn(); - const { stdin, unmount } = render( + const { stdin, unmount } = renderWithProviders( , ); @@ -194,7 +194,7 @@ describe('SettingsDialog', () => { const settings = createMockSettings(); const onSelect = vi.fn(); - const { stdin, unmount } = render( + const { stdin, unmount } = renderWithProviders( , ); @@ -212,7 +212,7 @@ describe('SettingsDialog', () => { const settings = createMockSettings(); const onSelect = vi.fn(); - const { stdin, unmount } = render( + const { stdin, unmount } = renderWithProviders( , ); @@ -227,7 +227,7 @@ describe('SettingsDialog', () => { const settings = createMockSettings(); const onSelect = vi.fn(); - const { stdin, unmount } = render( + const { stdin, unmount } = renderWithProviders( , ); @@ -242,7 +242,7 @@ describe('SettingsDialog', () => { const settings = createMockSettings(); const onSelect = vi.fn(); - const { stdin, unmount } = render( + const { stdin, unmount } = renderWithProviders( , ); @@ -261,7 +261,7 @@ describe('SettingsDialog', () => { const settings = createMockSettings(); const onSelect = vi.fn(); - const { stdin, unmount } = render( + const { stdin, unmount } = renderWithProviders( , ); @@ -280,7 +280,7 @@ describe('SettingsDialog', () => { const settings = createMockSettings(); const onSelect = vi.fn(); - const { lastFrame, stdin, unmount } = render( + const { lastFrame, stdin, unmount } = renderWithProviders( , ); @@ -308,7 +308,7 @@ describe('SettingsDialog', () => { const settings = createMockSettings(); const onRestartRequest = vi.fn(); - const { unmount } = render( + const { unmount } = renderWithProviders( {}} @@ -327,7 +327,7 @@ describe('SettingsDialog', () => { const settings = createMockSettings(); const onRestartRequest = vi.fn(); - const { stdin, unmount } = render( + const { stdin, unmount } = renderWithProviders( {}} @@ -349,7 +349,7 @@ describe('SettingsDialog', () => { const settings = createMockSettings(); const onSelect = vi.fn(); - const { stdin, unmount } = render( + const { stdin, unmount } = renderWithProviders( , ); @@ -368,7 +368,7 @@ describe('SettingsDialog', () => { const settings = createMockSettings({ vimMode: true }); const onSelect = vi.fn(); - const { stdin, unmount } = render( + const { stdin, unmount } = renderWithProviders( , ); @@ -392,7 +392,7 @@ describe('SettingsDialog', () => { ); const onSelect = vi.fn(); - const { lastFrame } = render( + const { lastFrame } = renderWithProviders( , ); @@ -409,7 +409,7 @@ describe('SettingsDialog', () => { const settings = createMockSettings(); const onSelect = vi.fn(); - const { stdin, unmount } = render( + const { stdin, unmount } = renderWithProviders( , ); @@ -427,7 +427,7 @@ describe('SettingsDialog', () => { const settings = createMockSettings(); const onSelect = vi.fn(); - const { stdin, unmount } = render( + const { stdin, unmount } = renderWithProviders( , ); @@ -449,7 +449,7 @@ describe('SettingsDialog', () => { const settings = createMockSettings(); const onSelect = vi.fn(); - const { stdin, unmount } = render( + const { stdin, unmount } = renderWithProviders( , ); @@ -468,7 +468,7 @@ describe('SettingsDialog', () => { const settings = createMockSettings(); const onSelect = vi.fn(); - const { stdin, unmount } = render( + const { stdin, unmount } = renderWithProviders( , @@ -492,7 +492,7 @@ describe('SettingsDialog', () => { ); const onSelect = vi.fn(); - const { lastFrame } = render( + const { lastFrame } = renderWithProviders( , ); @@ -505,7 +505,7 @@ describe('SettingsDialog', () => { const settings = createMockSettings(); const onSelect = vi.fn(); - const { stdin, unmount } = render( + const { stdin, unmount } = renderWithProviders( , ); @@ -521,7 +521,7 @@ describe('SettingsDialog', () => { const settings = createMockSettings(); const onSelect = vi.fn(); - const { lastFrame, unmount } = render( + const { lastFrame, unmount } = renderWithProviders( , ); @@ -541,7 +541,7 @@ describe('SettingsDialog', () => { const settings = createMockSettings(); const onSelect = vi.fn(); - const { unmount } = render( + const { unmount } = renderWithProviders( , ); @@ -559,7 +559,7 @@ describe('SettingsDialog', () => { ); const onSelect = vi.fn(); - const { lastFrame } = render( + const { lastFrame } = renderWithProviders( , ); @@ -576,7 +576,7 @@ describe('SettingsDialog', () => { ); const onSelect = vi.fn(); - const { lastFrame } = render( + const { lastFrame } = renderWithProviders( , ); @@ -591,7 +591,7 @@ describe('SettingsDialog', () => { const settings = createMockSettings(); const onSelect = vi.fn(); - const { stdin, unmount } = render( + const { stdin, unmount } = renderWithProviders( , ); @@ -610,7 +610,7 @@ describe('SettingsDialog', () => { const settings = createMockSettings({ vimMode: true }); // Start with vimMode enabled const onSelect = vi.fn(); - const { stdin, unmount } = render( + const { stdin, unmount } = renderWithProviders( , ); @@ -626,7 +626,7 @@ describe('SettingsDialog', () => { const settings = createMockSettings({ vimMode: true }); // Start with vimMode enabled const onSelect = vi.fn(); - const { stdin, unmount } = render( + const { stdin, unmount } = renderWithProviders( , ); @@ -642,7 +642,7 @@ describe('SettingsDialog', () => { const settings = createMockSettings(); const onSelect = vi.fn(); - const { stdin, unmount } = render( + const { stdin, unmount } = renderWithProviders( , ); @@ -659,7 +659,7 @@ describe('SettingsDialog', () => { const settings = createMockSettings(); const onSelect = vi.fn(); - const { lastFrame, stdin, unmount } = render( + const { lastFrame, stdin, unmount } = renderWithProviders( , ); @@ -692,7 +692,7 @@ describe('SettingsDialog', () => { ); const onSelect = vi.fn(); - const { lastFrame } = render( + const { lastFrame } = renderWithProviders( , ); @@ -705,7 +705,7 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); // Should not crash even if some settings are missing definitions - const { lastFrame } = render( + const { lastFrame } = renderWithProviders( , ); @@ -718,7 +718,7 @@ describe('SettingsDialog', () => { const settings = createMockSettings(); const onSelect = vi.fn(); - const { stdin, unmount } = render( + const { stdin, unmount } = renderWithProviders( , ); @@ -763,7 +763,7 @@ describe('SettingsDialog', () => { const settings = createMockSettings(); const onSelect = vi.fn(); - const { stdin, unmount } = render( + const { stdin, unmount } = renderWithProviders( , ); @@ -792,7 +792,7 @@ describe('SettingsDialog', () => { const settings = createMockSettings({ vimMode: true }); const onSelect = vi.fn(); - const { stdin, unmount } = render( + const { stdin, unmount } = renderWithProviders( , ); @@ -816,7 +816,7 @@ describe('SettingsDialog', () => { const settings = createMockSettings(); const onRestartRequest = vi.fn(); - const { stdin, unmount } = render( + const { stdin, unmount } = renderWithProviders( {}} diff --git a/packages/cli/src/ui/components/ShellConfirmationDialog.test.tsx b/packages/cli/src/ui/components/ShellConfirmationDialog.test.tsx index 35783d44..bacf055f 100644 --- a/packages/cli/src/ui/components/ShellConfirmationDialog.test.tsx +++ b/packages/cli/src/ui/components/ShellConfirmationDialog.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from 'ink-testing-library'; +import { renderWithProviders } from '../../test-utils/render.js'; import { describe, it, expect, vi } from 'vitest'; import { ShellConfirmationDialog } from './ShellConfirmationDialog.js'; @@ -17,12 +17,16 @@ describe('ShellConfirmationDialog', () => { }; it('renders correctly', () => { - const { lastFrame } = render(); + const { lastFrame } = renderWithProviders( + , + ); expect(lastFrame()).toMatchSnapshot(); }); it('calls onConfirm with ProceedOnce when "Yes, allow once" is selected', () => { - const { lastFrame } = render(); + const { lastFrame } = renderWithProviders( + , + ); const select = lastFrame()!.toString(); // Simulate selecting the first option // This is a simplified way to test the selection @@ -30,14 +34,18 @@ describe('ShellConfirmationDialog', () => { }); it('calls onConfirm with ProceedAlways when "Yes, allow always for this session" is selected', () => { - const { lastFrame } = render(); + const { lastFrame } = renderWithProviders( + , + ); const select = lastFrame()!.toString(); // Simulate selecting the second option expect(select).toContain('Yes, allow always for this session'); }); it('calls onConfirm with Cancel when "No (esc)" is selected', () => { - const { lastFrame } = render(); + const { lastFrame } = renderWithProviders( + , + ); const select = lastFrame()!.toString(); // Simulate selecting the third option expect(select).toContain('No (esc)'); diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx index 968c1d6f..6950b02e 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx @@ -4,10 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from 'ink-testing-library'; import { describe, it, expect, vi } from 'vitest'; import { ToolConfirmationMessage } from './ToolConfirmationMessage.js'; import { ToolCallConfirmationDetails } from '@google/gemini-cli-core'; +import { renderWithProviders } from '../../../test-utils/render.js'; describe('ToolConfirmationMessage', () => { it('should not display urls if prompt and url are the same', () => { @@ -19,7 +19,7 @@ describe('ToolConfirmationMessage', () => { onConfirm: vi.fn(), }; - const { lastFrame } = render( + const { lastFrame } = renderWithProviders( { onConfirm: vi.fn(), }; - const { lastFrame } = render( + const { lastFrame } = renderWithProviders( > = [ describe('', () => { it('renders a list of items and matches snapshot', () => { - const { lastFrame } = render( + const { lastFrame } = renderWithProviders( {}} isFocused={true} />, ); expect(lastFrame()).toMatchSnapshot(); }); it('renders with the second item selected and matches snapshot', () => { - const { lastFrame } = render( + const { lastFrame } = renderWithProviders( {}} />, ); expect(lastFrame()).toMatchSnapshot(); }); it('renders with numbers hidden and matches snapshot', () => { - const { lastFrame } = render( + const { lastFrame } = renderWithProviders( {}} @@ -49,7 +49,7 @@ describe('', () => { label: `Item ${i + 1}`, value: `item-${i + 1}`, })); - const { lastFrame } = render( + const { lastFrame } = renderWithProviders( {}} @@ -75,7 +75,7 @@ describe('', () => { themeTypeDisplay: '(Dark)', }, ]; - const { lastFrame } = render( + const { lastFrame } = renderWithProviders( {}} />, ); expect(lastFrame()).toMatchSnapshot(); @@ -86,14 +86,14 @@ describe('', () => { label: `Item ${i + 1}`, value: `item-${i + 1}`, })); - const { lastFrame } = render( + const { lastFrame } = renderWithProviders( {}} />, ); expect(lastFrame()).toMatchSnapshot(); }); it('renders nothing when no items are provided', () => { - const { lastFrame } = render( + const { lastFrame } = renderWithProviders( {}} isFocused={true} />, ); expect(lastFrame()).toBe(''); @@ -103,7 +103,7 @@ describe('', () => { describe('keyboard navigation', () => { it('should call onSelect when "enter" is pressed', () => { const onSelect = vi.fn(); - const { stdin } = render( + const { stdin } = renderWithProviders( , ); @@ -115,7 +115,7 @@ describe('keyboard navigation', () => { describe('when isFocused is false', () => { it('should not handle any keyboard input', () => { const onSelect = vi.fn(); - const { stdin } = render( + const { stdin } = renderWithProviders( { ])('$description', ({ isFocused }) => { it('should navigate down with arrow key and select with enter', async () => { const onSelect = vi.fn(); - const { stdin, lastFrame } = render( + const { stdin, lastFrame } = renderWithProviders( { it('should navigate up with arrow key and select with enter', async () => { const onSelect = vi.fn(); - const { stdin, lastFrame } = render( + const { stdin, lastFrame } = renderWithProviders( void; + +interface KeypressContextValue { + subscribe: (handler: KeypressHandler) => void; + unsubscribe: (handler: KeypressHandler) => void; +} + +const KeypressContext = createContext( + undefined, +); + +export function useKeypressContext() { + const context = useContext(KeypressContext); + if (!context) { + throw new Error( + 'useKeypressContext must be used within a KeypressProvider', + ); + } + return context; +} + +export function KeypressProvider({ + children, + kittyProtocolEnabled, + config, +}: { + children: React.ReactNode; + kittyProtocolEnabled: boolean; + config?: Config; +}) { + const { stdin, setRawMode } = useStdin(); + const subscribers = useRef>(new Set()).current; + + const subscribe = useCallback( + (handler: KeypressHandler) => { + subscribers.add(handler); + }, + [subscribers], + ); + + const unsubscribe = useCallback( + (handler: KeypressHandler) => { + subscribers.delete(handler); + }, + [subscribers], + ); + + useEffect(() => { + setRawMode(true); + + const keypressStream = new PassThrough(); + let usePassthrough = false; + const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10); + if ( + nodeMajorVersion < 20 || + process.env['PASTE_WORKAROUND'] === '1' || + process.env['PASTE_WORKAROUND'] === 'true' + ) { + usePassthrough = true; + } + + let isPaste = false; + let pasteBuffer = Buffer.alloc(0); + let kittySequenceBuffer = ''; + let backslashTimeout: NodeJS.Timeout | null = null; + let waitingForEnterAfterBackslash = false; + + const parseKittySequence = (sequence: string): Key | null => { + const kittyPattern = new RegExp(`^${ESC}\\[(\\d+)(;(\\d+))?([u~])$`); + const match = sequence.match(kittyPattern); + if (!match) return null; + + const keyCode = parseInt(match[1], 10); + const modifiers = match[3] ? parseInt(match[3], 10) : 1; + const modifierBits = modifiers - 1; + const shift = (modifierBits & 1) === 1; + const alt = (modifierBits & 2) === 2; + const ctrl = (modifierBits & 4) === 4; + + if (keyCode === 27) { + return { + name: 'escape', + ctrl, + meta: alt, + shift, + paste: false, + sequence, + kittyProtocol: true, + }; + } + + if (keyCode === 13) { + return { + name: 'return', + ctrl, + meta: alt, + shift, + paste: false, + sequence, + kittyProtocol: true, + }; + } + + if (keyCode >= 97 && keyCode <= 122 && ctrl) { + const letter = String.fromCharCode(keyCode); + return { + name: letter, + ctrl: true, + meta: alt, + shift, + paste: false, + sequence, + kittyProtocol: true, + }; + } + + return null; + }; + + const broadcast = (key: Key) => { + for (const handler of subscribers) { + handler(key); + } + }; + + const handleKeypress = (_: unknown, key: Key) => { + if (key.name === 'return' && waitingForEnterAfterBackslash) { + if (backslashTimeout) { + clearTimeout(backslashTimeout); + backslashTimeout = null; + } + waitingForEnterAfterBackslash = false; + broadcast({ + ...key, + shift: true, + sequence: '\r', // Corrected escaping for newline + }); + return; + } + + if (key.sequence === '\\' && !key.name) { + // Corrected escaping for backslash + waitingForEnterAfterBackslash = true; + backslashTimeout = setTimeout(() => { + waitingForEnterAfterBackslash = false; + backslashTimeout = null; + broadcast(key); + }, BACKSLASH_ENTER_DETECTION_WINDOW_MS); + return; + } + + if (waitingForEnterAfterBackslash && key.name !== 'return') { + if (backslashTimeout) { + clearTimeout(backslashTimeout); + backslashTimeout = null; + } + waitingForEnterAfterBackslash = false; + broadcast({ + name: '', + sequence: '\\', + ctrl: false, + meta: false, + shift: false, + paste: false, + }); + } + + if (['up', 'down', 'left', 'right'].includes(key.name)) { + broadcast(key); + return; + } + + if ( + (key.ctrl && key.name === 'c') || + key.sequence === `${ESC}${KITTY_CTRL_C}` + ) { + kittySequenceBuffer = ''; + if (key.sequence === `${ESC}${KITTY_CTRL_C}`) { + broadcast({ + name: 'c', + ctrl: true, + meta: false, + shift: false, + paste: false, + sequence: key.sequence, + kittyProtocol: true, + }); + } else { + broadcast(key); + } + return; + } + + if (kittyProtocolEnabled) { + if ( + kittySequenceBuffer || + (key.sequence.startsWith(`${ESC}[`) && + !key.sequence.startsWith(PASTE_MODE_PREFIX) && + !key.sequence.startsWith(PASTE_MODE_SUFFIX) && + !key.sequence.startsWith(FOCUS_IN) && + !key.sequence.startsWith(FOCUS_OUT)) + ) { + kittySequenceBuffer += key.sequence; + const kittyKey = parseKittySequence(kittySequenceBuffer); + if (kittyKey) { + kittySequenceBuffer = ''; + broadcast(kittyKey); + return; + } + + if (config?.getDebugMode()) { + const codes = Array.from(kittySequenceBuffer).map((ch) => + ch.charCodeAt(0), + ); + console.warn('Kitty sequence buffer has char codes:', codes); + } + + if (kittySequenceBuffer.length > MAX_KITTY_SEQUENCE_LENGTH) { + if (config) { + const event = new KittySequenceOverflowEvent( + kittySequenceBuffer.length, + kittySequenceBuffer, + ); + logKittySequenceOverflow(config, event); + } + kittySequenceBuffer = ''; + } else { + return; + } + } + } + + if (key.name === 'paste-start') { + isPaste = true; + } else if (key.name === 'paste-end') { + isPaste = false; + broadcast({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: true, + sequence: pasteBuffer.toString(), + }); + pasteBuffer = Buffer.alloc(0); + } else { + if (isPaste) { + pasteBuffer = Buffer.concat([pasteBuffer, Buffer.from(key.sequence)]); + } else { + if (key.name === 'return' && key.sequence === `${ESC}\r`) { + key.meta = true; + } + broadcast({ ...key, paste: isPaste }); + } + } + }; + + const handleRawKeypress = (data: Buffer) => { + const pasteModePrefixBuffer = Buffer.from(PASTE_MODE_PREFIX); + const pasteModeSuffixBuffer = Buffer.from(PASTE_MODE_SUFFIX); + + let pos = 0; + while (pos < data.length) { + const prefixPos = data.indexOf(pasteModePrefixBuffer, pos); + const suffixPos = data.indexOf(pasteModeSuffixBuffer, pos); + const isPrefixNext = + prefixPos !== -1 && (suffixPos === -1 || prefixPos < suffixPos); + const isSuffixNext = + suffixPos !== -1 && (prefixPos === -1 || suffixPos < prefixPos); + + let nextMarkerPos = -1; + let markerLength = 0; + + if (isPrefixNext) { + nextMarkerPos = prefixPos; + } else if (isSuffixNext) { + nextMarkerPos = suffixPos; + } + markerLength = pasteModeSuffixBuffer.length; + + if (nextMarkerPos === -1) { + keypressStream.write(data.slice(pos)); + return; + } + + const nextData = data.slice(pos, nextMarkerPos); + if (nextData.length > 0) { + keypressStream.write(nextData); + } + const createPasteKeyEvent = ( + name: 'paste-start' | 'paste-end', + ): Key => ({ + name, + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '', + }); + if (isPrefixNext) { + handleKeypress(undefined, createPasteKeyEvent('paste-start')); + } else if (isSuffixNext) { + handleKeypress(undefined, createPasteKeyEvent('paste-end')); + } + pos = nextMarkerPos + markerLength; + } + }; + + let rl: readline.Interface; + if (usePassthrough) { + rl = readline.createInterface({ + input: keypressStream, + escapeCodeTimeout: 0, + }); + readline.emitKeypressEvents(keypressStream, rl); + keypressStream.on('keypress', handleKeypress); + stdin.on('data', handleRawKeypress); + } else { + rl = readline.createInterface({ input: stdin, escapeCodeTimeout: 0 }); + readline.emitKeypressEvents(stdin, rl); + stdin.on('keypress', handleKeypress); + } + + return () => { + if (usePassthrough) { + keypressStream.removeListener('keypress', handleKeypress); + stdin.removeListener('data', handleRawKeypress); + } else { + stdin.removeListener('keypress', handleKeypress); + } + + rl.close(); + + // Restore the terminal to its original state. + setRawMode(false); + + if (backslashTimeout) { + clearTimeout(backslashTimeout); + backslashTimeout = null; + } + + // Flush any pending paste data to avoid data loss on exit. + if (isPaste) { + broadcast({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: true, + sequence: pasteBuffer.toString(), + }); + pasteBuffer = Buffer.alloc(0); + } + }; + }, [stdin, setRawMode, kittyProtocolEnabled, config, subscribers]); + + return ( + + {children} + + ); +} diff --git a/packages/cli/src/ui/hooks/useKeypress.test.ts b/packages/cli/src/ui/hooks/useKeypress.test.ts index 946ee054..b804eb90 100644 --- a/packages/cli/src/ui/hooks/useKeypress.test.ts +++ b/packages/cli/src/ui/hooks/useKeypress.test.ts @@ -4,8 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ +import React from 'react'; import { renderHook, act } from '@testing-library/react'; import { useKeypress, Key } from './useKeypress.js'; +import { KeypressProvider } from '../contexts/KeypressContext.js'; import { useStdin } from 'ink'; import { EventEmitter } from 'events'; import { PassThrough } from 'stream'; @@ -102,6 +104,9 @@ describe('useKeypress', () => { const onKeypress = vi.fn(); let originalNodeVersion: string; + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(KeypressProvider, null, children); + beforeEach(() => { vi.clearAllMocks(); stdin = new MockStdin(); @@ -129,7 +134,9 @@ describe('useKeypress', () => { }; it('should not listen if isActive is false', () => { - renderHook(() => useKeypress(onKeypress, { isActive: false })); + renderHook(() => useKeypress(onKeypress, { isActive: false }), { + wrapper, + }); act(() => stdin.pressKey({ name: 'a' })); expect(onKeypress).not.toHaveBeenCalled(); }); @@ -141,14 +148,15 @@ describe('useKeypress', () => { { key: { name: 'up', sequence: '\x1b[A' } }, { key: { name: 'down', sequence: '\x1b[B' } }, ])('should listen for keypress when active for key $key.name', ({ key }) => { - renderHook(() => useKeypress(onKeypress, { isActive: true })); + renderHook(() => useKeypress(onKeypress, { isActive: true }), { wrapper }); act(() => stdin.pressKey(key)); expect(onKeypress).toHaveBeenCalledWith(expect.objectContaining(key)); }); it('should set and release raw mode', () => { - const { unmount } = renderHook(() => - useKeypress(onKeypress, { isActive: true }), + const { unmount } = renderHook( + () => useKeypress(onKeypress, { isActive: true }), + { wrapper }, ); expect(mockSetRawMode).toHaveBeenCalledWith(true); unmount(); @@ -156,8 +164,9 @@ describe('useKeypress', () => { }); it('should stop listening after being unmounted', () => { - const { unmount } = renderHook(() => - useKeypress(onKeypress, { isActive: true }), + const { unmount } = renderHook( + () => useKeypress(onKeypress, { isActive: true }), + { wrapper }, ); unmount(); act(() => stdin.pressKey({ name: 'a' })); @@ -165,7 +174,7 @@ describe('useKeypress', () => { }); it('should correctly identify alt+enter (meta key)', () => { - renderHook(() => useKeypress(onKeypress, { isActive: true })); + renderHook(() => useKeypress(onKeypress, { isActive: true }), { wrapper }); const key = { name: 'return', sequence: '\x1B\r' }; act(() => stdin.pressKey(key)); expect(onKeypress).toHaveBeenCalledWith( @@ -199,7 +208,9 @@ describe('useKeypress', () => { }); it('should process a paste as a single event', () => { - renderHook(() => useKeypress(onKeypress, { isActive: true })); + renderHook(() => useKeypress(onKeypress, { isActive: true }), { + wrapper, + }); const pasteText = 'hello world'; act(() => stdin.paste(pasteText)); @@ -215,7 +226,9 @@ describe('useKeypress', () => { }); it('should handle keypress interspersed with pastes', () => { - renderHook(() => useKeypress(onKeypress, { isActive: true })); + renderHook(() => useKeypress(onKeypress, { isActive: true }), { + wrapper, + }); const keyA = { name: 'a', sequence: 'a' }; act(() => stdin.pressKey(keyA)); @@ -239,8 +252,9 @@ describe('useKeypress', () => { }); it('should emit partial paste content if unmounted mid-paste', () => { - const { unmount } = renderHook(() => - useKeypress(onKeypress, { isActive: true }), + const { unmount } = renderHook( + () => useKeypress(onKeypress, { isActive: true }), + { wrapper }, ); const pasteText = 'incomplete paste'; diff --git a/packages/cli/src/ui/hooks/useKeypress.ts b/packages/cli/src/ui/hooks/useKeypress.ts index 920270ee..bead50e6 100644 --- a/packages/cli/src/ui/hooks/useKeypress.ts +++ b/packages/cli/src/ui/hooks/useKeypress.ts @@ -4,414 +4,36 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useEffect, useRef } from 'react'; -import { useStdin } from 'ink'; -import readline from 'readline'; -import { PassThrough } from 'stream'; +import { useEffect } from 'react'; import { - KITTY_CTRL_C, - BACKSLASH_ENTER_DETECTION_WINDOW_MS, - MAX_KITTY_SEQUENCE_LENGTH, -} from '../utils/platformConstants.js'; -import { - KittySequenceOverflowEvent, - logKittySequenceOverflow, - Config, -} from '@google/gemini-cli-core'; -import { FOCUS_IN, FOCUS_OUT } from './useFocus.js'; + useKeypressContext, + KeypressHandler, + Key, +} from '../contexts/KeypressContext.js'; -const ESC = '\u001B'; -export const PASTE_MODE_PREFIX = `${ESC}[200~`; -export const PASTE_MODE_SUFFIX = `${ESC}[201~`; - -export interface Key { - name: string; - ctrl: boolean; - meta: boolean; - shift: boolean; - paste: boolean; - sequence: string; - kittyProtocol?: boolean; -} +export { Key }; /** - * A hook that listens for keypress events from stdin, providing a - * key object that mirrors the one from Node's `readline` module, - * adding a 'paste' flag for characters input as part of a bracketed - * paste (when enabled). - * - * Pastes are currently sent as a single key event where the full paste - * is in the sequence field. + * A hook that listens for keypress events from stdin. * * @param onKeypress - The callback function to execute on each keypress. * @param options - Options to control the hook's behavior. * @param options.isActive - Whether the hook should be actively listening for input. - * @param options.kittyProtocolEnabled - Whether Kitty keyboard protocol is enabled. - * @param options.config - Optional config for telemetry logging. */ export function useKeypress( - onKeypress: (key: Key) => void, - { - isActive, - kittyProtocolEnabled = false, - config, - }: { isActive: boolean; kittyProtocolEnabled?: boolean; config?: Config }, + onKeypress: KeypressHandler, + { isActive }: { isActive: boolean }, ) { - const { stdin, setRawMode } = useStdin(); - const onKeypressRef = useRef(onKeypress); + const { subscribe, unsubscribe } = useKeypressContext(); useEffect(() => { - onKeypressRef.current = onKeypress; - }, [onKeypress]); - - useEffect(() => { - if (!isActive || !stdin.isTTY) { + if (!isActive) { return; } - setRawMode(true); - - const keypressStream = new PassThrough(); - let usePassthrough = false; - const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10); - if ( - nodeMajorVersion < 20 || - process.env['PASTE_WORKAROUND'] === '1' || - process.env['PASTE_WORKAROUND'] === 'true' - ) { - // Prior to node 20, node's built-in readline does not support bracketed - // paste mode. We hack by detecting it with our own handler. - usePassthrough = true; - } - - let isPaste = false; - let pasteBuffer = Buffer.alloc(0); - let kittySequenceBuffer = ''; - let backslashTimeout: NodeJS.Timeout | null = null; - let waitingForEnterAfterBackslash = false; - - // Parse Kitty protocol sequences - const parseKittySequence = (sequence: string): Key | null => { - // Match CSI ; u or ~ - // Format: ESC [ ; u/~ - const kittyPattern = new RegExp(`^${ESC}\\[(\\d+)(;(\\d+))?([u~])$`); - const match = sequence.match(kittyPattern); - if (!match) return null; - - const keyCode = parseInt(match[1], 10); - const modifiers = match[3] ? parseInt(match[3], 10) : 1; - - // Decode modifiers (subtract 1 as per Kitty protocol spec) - const modifierBits = modifiers - 1; - const shift = (modifierBits & 1) === 1; - const alt = (modifierBits & 2) === 2; - const ctrl = (modifierBits & 4) === 4; - - // Handle Escape key (code 27) - if (keyCode === 27) { - return { - name: 'escape', - ctrl, - meta: alt, - shift, - paste: false, - sequence, - kittyProtocol: true, - }; - } - - // Handle Enter key (code 13) - if (keyCode === 13) { - return { - name: 'return', - ctrl, - meta: alt, - shift, - paste: false, - sequence, - kittyProtocol: true, - }; - } - - // Handle Ctrl+letter combinations (a-z) - // ASCII codes: a=97, b=98, c=99, ..., z=122 - if (keyCode >= 97 && keyCode <= 122 && ctrl) { - const letter = String.fromCharCode(keyCode); - return { - name: letter, - ctrl: true, - meta: alt, - shift, - paste: false, - sequence, - kittyProtocol: true, - }; - } - - // Handle other keys as needed - return null; - }; - - const handleKeypress = (_: unknown, key: Key) => { - // Handle VS Code's backslash+return pattern (Shift+Enter) - if (key.name === 'return' && waitingForEnterAfterBackslash) { - // Cancel the timeout since we got the Enter - if (backslashTimeout) { - clearTimeout(backslashTimeout); - backslashTimeout = null; - } - waitingForEnterAfterBackslash = false; - - // Convert to Shift+Enter - onKeypressRef.current({ - ...key, - shift: true, - sequence: '\\\r', // VS Code's Shift+Enter representation - }); - return; - } - - // Handle backslash - hold it to see if Enter follows - if (key.sequence === '\\' && !key.name) { - // Don't pass through the backslash yet - wait to see if Enter follows - waitingForEnterAfterBackslash = true; - - // Set up a timeout to pass through the backslash if no Enter follows - backslashTimeout = setTimeout(() => { - waitingForEnterAfterBackslash = false; - backslashTimeout = null; - // Pass through the backslash since no Enter followed - onKeypressRef.current(key); - }, BACKSLASH_ENTER_DETECTION_WINDOW_MS); - - return; - } - - // If we're waiting for Enter after backslash but got something else, - // pass through the backslash first, then the new key - if (waitingForEnterAfterBackslash && key.name !== 'return') { - if (backslashTimeout) { - clearTimeout(backslashTimeout); - backslashTimeout = null; - } - waitingForEnterAfterBackslash = false; - - // Pass through the backslash that was held - onKeypressRef.current({ - name: '', - sequence: '\\', - ctrl: false, - meta: false, - shift: false, - paste: false, - }); - - // Then continue processing the current key normally - } - - // If readline has already identified an arrow key, pass it through - // immediately, bypassing the Kitty protocol sequence buffering. - if (['up', 'down', 'left', 'right'].includes(key.name)) { - onKeypressRef.current(key); - return; - } - - // Always pass through Ctrl+C immediately, regardless of protocol state - // Check both standard format and Kitty protocol sequence - if ( - (key.ctrl && key.name === 'c') || - key.sequence === `${ESC}${KITTY_CTRL_C}` - ) { - kittySequenceBuffer = ''; - // If it's the Kitty sequence, create a proper key object - if (key.sequence === `${ESC}${KITTY_CTRL_C}`) { - onKeypressRef.current({ - name: 'c', - ctrl: true, - meta: false, - shift: false, - paste: false, - sequence: key.sequence, - kittyProtocol: true, - }); - } else { - onKeypressRef.current(key); - } - return; - } - - // If Kitty protocol is enabled, handle CSI sequences - if (kittyProtocolEnabled) { - // If we have a buffer or this starts a CSI sequence - if ( - kittySequenceBuffer || - (key.sequence.startsWith(`${ESC}[`) && - !key.sequence.startsWith(PASTE_MODE_PREFIX) && - !key.sequence.startsWith(PASTE_MODE_SUFFIX) && - !key.sequence.startsWith(FOCUS_IN) && - !key.sequence.startsWith(FOCUS_OUT)) - ) { - kittySequenceBuffer += key.sequence; - - // Try to parse the buffer as a Kitty sequence - const kittyKey = parseKittySequence(kittySequenceBuffer); - if (kittyKey) { - kittySequenceBuffer = ''; - onKeypressRef.current(kittyKey); - return; - } - - if (config?.getDebugMode()) { - const codes = Array.from(kittySequenceBuffer).map((ch) => - ch.charCodeAt(0), - ); - // Unless the user is sshing over a slow connection, this likely - // indicates this is not a kitty sequence but we have incorrectly - // interpreted it as such. See the examples above for sequences - // such as FOCUS_IN that are not Kitty sequences. - console.warn('Kitty sequence buffer has char codes:', codes); - } - - // If buffer doesn't match expected pattern and is getting long, flush it - if (kittySequenceBuffer.length > MAX_KITTY_SEQUENCE_LENGTH) { - // Log telemetry for buffer overflow - if (config) { - const event = new KittySequenceOverflowEvent( - kittySequenceBuffer.length, - kittySequenceBuffer, - ); - logKittySequenceOverflow(config, event); - } - // Not a Kitty sequence, treat as regular key - kittySequenceBuffer = ''; - } else { - // Wait for more characters - return; - } - } - } - if (key.name === 'paste-start') { - isPaste = true; - } else if (key.name === 'paste-end') { - isPaste = false; - onKeypressRef.current({ - name: '', - ctrl: false, - meta: false, - shift: false, - paste: true, - sequence: pasteBuffer.toString(), - }); - pasteBuffer = Buffer.alloc(0); - } else { - if (isPaste) { - pasteBuffer = Buffer.concat([pasteBuffer, Buffer.from(key.sequence)]); - } else { - // Handle special keys - if (key.name === 'return' && key.sequence === `${ESC}\r`) { - key.meta = true; - } - onKeypressRef.current({ ...key, paste: isPaste }); - } - } - }; - - const handleRawKeypress = (data: Buffer) => { - const pasteModePrefixBuffer = Buffer.from(PASTE_MODE_PREFIX); - const pasteModeSuffixBuffer = Buffer.from(PASTE_MODE_SUFFIX); - - let pos = 0; - while (pos < data.length) { - const prefixPos = data.indexOf(pasteModePrefixBuffer, pos); - const suffixPos = data.indexOf(pasteModeSuffixBuffer, pos); - - // Determine which marker comes first, if any. - const isPrefixNext = - prefixPos !== -1 && (suffixPos === -1 || prefixPos < suffixPos); - const isSuffixNext = - suffixPos !== -1 && (prefixPos === -1 || suffixPos < prefixPos); - - let nextMarkerPos = -1; - let markerLength = 0; - - if (isPrefixNext) { - nextMarkerPos = prefixPos; - } else if (isSuffixNext) { - nextMarkerPos = suffixPos; - } - markerLength = pasteModeSuffixBuffer.length; - - if (nextMarkerPos === -1) { - keypressStream.write(data.slice(pos)); - return; - } - - const nextData = data.slice(pos, nextMarkerPos); - if (nextData.length > 0) { - keypressStream.write(nextData); - } - const createPasteKeyEvent = ( - name: 'paste-start' | 'paste-end', - ): Key => ({ - name, - ctrl: false, - meta: false, - shift: false, - paste: false, - sequence: '', - }); - if (isPrefixNext) { - handleKeypress(undefined, createPasteKeyEvent('paste-start')); - } else if (isSuffixNext) { - handleKeypress(undefined, createPasteKeyEvent('paste-end')); - } - pos = nextMarkerPos + markerLength; - } - }; - - let rl: readline.Interface; - if (usePassthrough) { - rl = readline.createInterface({ - input: keypressStream, - escapeCodeTimeout: 0, - }); - readline.emitKeypressEvents(keypressStream, rl); - keypressStream.on('keypress', handleKeypress); - stdin.on('data', handleRawKeypress); - } else { - rl = readline.createInterface({ input: stdin, escapeCodeTimeout: 0 }); - readline.emitKeypressEvents(stdin, rl); - stdin.on('keypress', handleKeypress); - } - + subscribe(onKeypress); return () => { - if (usePassthrough) { - keypressStream.removeListener('keypress', handleKeypress); - stdin.removeListener('data', handleRawKeypress); - } else { - stdin.removeListener('keypress', handleKeypress); - } - rl.close(); - setRawMode(false); - - // Clean up any pending backslash timeout - if (backslashTimeout) { - clearTimeout(backslashTimeout); - backslashTimeout = null; - } - - // If we are in the middle of a paste, send what we have. - if (isPaste) { - onKeypressRef.current({ - name: '', - ctrl: false, - meta: false, - shift: false, - paste: true, - sequence: pasteBuffer.toString(), - }); - pasteBuffer = Buffer.alloc(0); - } + unsubscribe(onKeypress); }; - }, [isActive, stdin, setRawMode, kittyProtocolEnabled, config]); + }, [isActive, onKeypress, subscribe, unsubscribe]); }