summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJacob Richman <[email protected]>2025-08-15 10:54:00 -0700
committerGitHub <[email protected]>2025-08-15 17:54:00 +0000
commitbd5e49c5ff0d0f5e03e5f42435de00c5a0758d7e (patch)
treec55a474cd3630661ed42d63d8662b8266bdf11d0
parent1a2906a8ad6e9cf7a68441c956af91d189eff417 (diff)
fix(input) Resolve cases where escape was broken (#6304)
-rw-r--r--packages/cli/src/test-utils/render.tsx18
-rw-r--r--packages/cli/src/ui/App.test.tsx70
-rw-r--r--packages/cli/src/ui/App.tsx26
-rw-r--r--packages/cli/src/ui/components/AuthDialog.test.tsx22
-rw-r--r--packages/cli/src/ui/components/FolderTrustDialog.test.tsx10
-rw-r--r--packages/cli/src/ui/components/InputPrompt.test.tsx156
-rw-r--r--packages/cli/src/ui/components/InputPrompt.tsx4
-rw-r--r--packages/cli/src/ui/components/SettingsDialog.test.tsx78
-rw-r--r--packages/cli/src/ui/components/ShellConfirmationDialog.test.tsx18
-rw-r--r--packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx6
-rw-r--r--packages/cli/src/ui/components/shared/RadioButtonSelect.test.tsx24
-rw-r--r--packages/cli/src/ui/contexts/KeypressContext.tsx405
-rw-r--r--packages/cli/src/ui/hooks/useKeypress.test.ts36
-rw-r--r--packages/cli/src/ui/hooks/useKeypress.ts406
14 files changed, 697 insertions, 582 deletions
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<typeof render> =>
+ render(
+ <KeypressProvider kittyProtocolEnabled={true}>
+ {component}
+ </KeypressProvider>,
+ );
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(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -397,7 +397,7 @@ describe('App UI', () => {
};
mockedCheckForUpdates.mockResolvedValue(info);
- const { lastFrame, unmount } = render(
+ const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -427,7 +427,7 @@ describe('App UI', () => {
};
mockedCheckForUpdates.mockResolvedValue(info);
- const { lastFrame, unmount } = render(
+ const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -457,7 +457,7 @@ describe('App UI', () => {
};
mockedCheckForUpdates.mockResolvedValue(info);
- const { lastFrame, unmount } = render(
+ const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -491,7 +491,7 @@ describe('App UI', () => {
mockedCheckForUpdates.mockResolvedValue(info);
const { spawn } = await import('node:child_process');
- const { unmount } = render(
+ const { unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -520,7 +520,7 @@ describe('App UI', () => {
},
});
- const { lastFrame, unmount } = render(
+ const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -539,7 +539,7 @@ describe('App UI', () => {
},
});
- const { lastFrame, unmount } = render(
+ const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -575,7 +575,7 @@ describe('App UI', () => {
},
});
- const { lastFrame, unmount } = render(
+ const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -603,7 +603,7 @@ describe('App UI', () => {
mockConfig.getGeminiMdFileCount.mockReturnValue(1);
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['GEMINI.md']);
- const { lastFrame, unmount } = render(
+ const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -624,7 +624,7 @@ describe('App UI', () => {
mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false);
- const { lastFrame, unmount } = render(
+ const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -645,7 +645,7 @@ describe('App UI', () => {
mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false);
- const { lastFrame, unmount } = render(
+ const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -666,7 +666,7 @@ describe('App UI', () => {
mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false);
- const { lastFrame, unmount } = render(
+ const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -693,7 +693,7 @@ describe('App UI', () => {
mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false);
- const { lastFrame, unmount } = render(
+ const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -718,7 +718,7 @@ describe('App UI', () => {
mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false);
- const { lastFrame, unmount } = render(
+ const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -739,7 +739,7 @@ describe('App UI', () => {
mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false);
- const { lastFrame, unmount } = render(
+ const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -763,7 +763,7 @@ describe('App UI', () => {
mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false);
- const { lastFrame, unmount } = render(
+ const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -785,7 +785,7 @@ describe('App UI', () => {
mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false);
- const { lastFrame, unmount } = render(
+ const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -798,7 +798,7 @@ describe('App UI', () => {
});
it('should display Tips component by default', async () => {
- const { unmount } = render(
+ const { unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -817,7 +817,7 @@ describe('App UI', () => {
},
});
- const { unmount } = render(
+ const { unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -831,7 +831,7 @@ describe('App UI', () => {
it('should display Header component by default', async () => {
const { Header } = await import('./components/Header.js');
- const { unmount } = render(
+ const { unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -849,7 +849,7 @@ describe('App UI', () => {
user: { hideBanner: true },
});
- const { unmount } = render(
+ const { unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -868,7 +868,7 @@ describe('App UI', () => {
workspace: { hideTips: true },
});
- const { unmount } = render(
+ const { unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -898,7 +898,7 @@ describe('App UI', () => {
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(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -913,7 +913,7 @@ describe('App UI', () => {
it('should display a message if NO_COLOR is set', async () => {
process.env.NO_COLOR = 'true';
- const { lastFrame, unmount } = render(
+ const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -928,7 +928,7 @@ describe('App UI', () => {
});
it('should render the initial UI correctly', () => {
- const { lastFrame, unmount } = render(
+ const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -948,7 +948,7 @@ describe('App UI', () => {
thought: null,
});
- const { lastFrame, unmount } = render(
+ const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -978,7 +978,7 @@ describe('App UI', () => {
getUserTier: vi.fn(),
} as unknown as GeminiClient);
- const { unmount, rerender } = render(
+ const { unmount, rerender } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -1020,7 +1020,7 @@ describe('App UI', () => {
clearConsoleMessages: vi.fn(),
});
- const { lastFrame, unmount } = render(
+ const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -1046,7 +1046,7 @@ describe('App UI', () => {
},
});
- const { unmount } = render(
+ const { unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -1068,7 +1068,7 @@ describe('App UI', () => {
},
});
- const { unmount } = render(
+ const { unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -1088,7 +1088,7 @@ describe('App UI', () => {
rows: 24,
});
- const { lastFrame, unmount } = render(
+ const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -1108,7 +1108,7 @@ describe('App UI', () => {
handleFolderTrustSelect: vi.fn(),
});
- const { lastFrame, unmount } = render(
+ const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -1128,7 +1128,7 @@ describe('App UI', () => {
});
mockConfig.isTrustedFolder.mockReturnValue(false);
- const { lastFrame, unmount } = render(
+ const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -1148,7 +1148,7 @@ describe('App UI', () => {
});
mockConfig.isTrustedFolder.mockReturnValue(false);
- const { lastFrame, unmount } = render(
+ const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx
index 70ee0730..9773971b 100644
--- a/packages/cli/src/ui/App.tsx
+++ b/packages/cli/src/ui/App.tsx
@@ -80,6 +80,7 @@ import { useTextBuffer } from './components/shared/text-buffer.js';
import { useVimMode, VimModeProvider } from './contexts/VimModeContext.js';
import { useVim } from './hooks/vim.js';
import { useKeypress, Key } from './hooks/useKeypress.js';
+import { KeypressProvider } from './contexts/KeypressContext.js';
import { useKittyKeyboardProtocol } from './hooks/useKittyKeyboardProtocol.js';
import { keyMatchers, Command } from './keyMatchers.js';
import * as fs from 'fs';
@@ -109,13 +110,21 @@ interface AppProps {
version: string;
}
-export const AppWrapper = (props: AppProps) => (
- <SessionStatsProvider>
- <VimModeProvider settings={props.settings}>
- <App {...props} />
- </VimModeProvider>
- </SessionStatsProvider>
-);
+export const AppWrapper = (props: AppProps) => {
+ const kittyProtocolStatus = useKittyKeyboardProtocol();
+ return (
+ <KeypressProvider
+ kittyProtocolEnabled={kittyProtocolStatus.enabled}
+ config={props.config}
+ >
+ <SessionStatsProvider>
+ <VimModeProvider settings={props.settings}>
+ <App {...props} />
+ </VimModeProvider>
+ </SessionStatsProvider>
+ </KeypressProvider>
+ );
+};
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(
<AuthDialog
onSelect={() => {}}
settings={settings}
@@ -84,7 +84,7 @@ describe('AuthDialog', () => {
[],
);
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
@@ -117,7 +117,7 @@ describe('AuthDialog', () => {
[],
);
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
@@ -150,7 +150,7 @@ describe('AuthDialog', () => {
[],
);
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
@@ -184,7 +184,7 @@ describe('AuthDialog', () => {
[],
);
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
@@ -213,7 +213,7 @@ describe('AuthDialog', () => {
[],
);
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
@@ -244,7 +244,7 @@ describe('AuthDialog', () => {
[],
);
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
@@ -279,7 +279,7 @@ describe('AuthDialog', () => {
[],
);
- const { lastFrame, stdin, unmount } = render(
+ const { lastFrame, stdin, unmount } = renderWithProviders(
<AuthDialog onSelect={onSelect} settings={settings} />,
);
await wait();
@@ -318,7 +318,7 @@ describe('AuthDialog', () => {
[],
);
- const { lastFrame, stdin, unmount } = render(
+ const { lastFrame, stdin, unmount } = renderWithProviders(
<AuthDialog
onSelect={onSelect}
settings={settings}
@@ -360,7 +360,7 @@ describe('AuthDialog', () => {
[],
);
- const { stdin, unmount } = render(
+ const { stdin, unmount } = renderWithProviders(
<AuthDialog onSelect={onSelect} settings={settings} />,
);
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(<FolderTrustDialog onSelect={vi.fn()} />);
+ const { lastFrame } = renderWithProviders(
+ <FolderTrustDialog onSelect={vi.fn()} />,
+ );
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(<FolderTrustDialog onSelect={onSelect} />);
+ const { stdin } = renderWithProviders(
+ <FolderTrustDialog onSelect={onSelect} />,
+ );
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(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
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(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
stdin.write('\u001B[B');
@@ -224,7 +224,7 @@ describe('InputPrompt', () => {
vi.mocked(mockShellHistory.getPreviousCommand).mockReturnValue(
'previous command',
);
- const { stdin, unmount } = render(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
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(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
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(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
stdin.write('\u001B[A'); // Up arrow
@@ -283,7 +283,7 @@ describe('InputPrompt', () => {
props.buffer.setText('/mem');
- const { stdin, unmount } = render(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
// Test up arrow
@@ -309,7 +309,7 @@ describe('InputPrompt', () => {
});
props.buffer.setText('/mem');
- const { stdin, unmount } = render(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
// Test down arrow
@@ -331,7 +331,7 @@ describe('InputPrompt', () => {
});
props.buffer.setText('some text');
- const { stdin, unmount } = render(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
stdin.write('\u001B[A'); // Up arrow
@@ -363,7 +363,9 @@ describe('InputPrompt', () => {
'/test/.gemini-clipboard/clipboard-123.png',
);
- const { stdin, unmount } = render(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(
+ <InputPrompt {...props} />,
+ );
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(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(
+ <InputPrompt {...props} />,
+ );
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(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(
+ <InputPrompt {...props} />,
+ );
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(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(
+ <InputPrompt {...props} />,
+ );
await wait();
stdin.write('\x16'); // Ctrl+V
@@ -454,7 +462,9 @@ describe('InputPrompt', () => {
new Error('Clipboard error'),
);
- const { stdin, unmount } = render(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(
+ <InputPrompt {...props} />,
+ );
await wait();
stdin.write('\x16'); // Ctrl+V
@@ -481,7 +491,7 @@ describe('InputPrompt', () => {
});
props.buffer.setText('/mem');
- const { stdin, unmount } = render(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
stdin.write('\t'); // Press Tab
@@ -504,7 +514,7 @@ describe('InputPrompt', () => {
});
props.buffer.setText('/memory ');
- const { stdin, unmount } = render(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
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(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
stdin.write('\t'); // Press Tab
@@ -549,7 +559,7 @@ describe('InputPrompt', () => {
});
props.buffer.setText('/chat resume fi-');
- const { stdin, unmount } = render(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
stdin.write('\t'); // Press Tab
@@ -568,7 +578,7 @@ describe('InputPrompt', () => {
});
props.buffer.setText('/mem');
- const { stdin, unmount } = render(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
stdin.write('\r');
@@ -599,7 +609,7 @@ describe('InputPrompt', () => {
});
props.buffer.setText('/?');
- const { stdin, unmount } = render(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
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(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
stdin.write('\r'); // Press Enter
@@ -630,7 +640,7 @@ describe('InputPrompt', () => {
});
props.buffer.setText('/clear');
- const { stdin, unmount } = render(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
stdin.write('\r');
@@ -648,7 +658,7 @@ describe('InputPrompt', () => {
});
props.buffer.setText('/clear');
- const { stdin, unmount } = render(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
stdin.write('\r');
@@ -667,7 +677,7 @@ describe('InputPrompt', () => {
});
props.buffer.setText('@src/components/');
- const { stdin, unmount } = render(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
stdin.write('\r');
@@ -684,7 +694,7 @@ describe('InputPrompt', () => {
mockBuffer.cursor = [0, 11];
mockBuffer.lines = ['first line\\'];
- const { stdin, unmount } = render(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
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(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
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(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
stdin.write('\x03'); // Ctrl+C character
@@ -735,7 +745,7 @@ describe('InputPrompt', () => {
suggestions: [{ label: 'Button.tsx', value: 'Button.tsx' }],
});
- const { unmount } = render(<InputPrompt {...props} />);
+ const { unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
// Verify useCompletion was called with correct signature
@@ -763,7 +773,7 @@ describe('InputPrompt', () => {
suggestions: [{ label: 'show', value: 'show' }],
});
- const { unmount } = render(<InputPrompt {...props} />);
+ const { unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
@@ -790,7 +800,7 @@ describe('InputPrompt', () => {
suggestions: [],
});
- const { unmount } = render(<InputPrompt {...props} />);
+ const { unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
@@ -817,7 +827,7 @@ describe('InputPrompt', () => {
suggestions: [],
});
- const { unmount } = render(<InputPrompt {...props} />);
+ const { unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
@@ -844,7 +854,7 @@ describe('InputPrompt', () => {
suggestions: [],
});
- const { unmount } = render(<InputPrompt {...props} />);
+ const { unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
@@ -871,7 +881,7 @@ describe('InputPrompt', () => {
suggestions: [],
});
- const { unmount } = render(<InputPrompt {...props} />);
+ const { unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
// Verify useCompletion was called with the buffer
@@ -899,7 +909,7 @@ describe('InputPrompt', () => {
suggestions: [{ label: 'show', value: 'show' }],
});
- const { unmount } = render(<InputPrompt {...props} />);
+ const { unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
@@ -927,7 +937,7 @@ describe('InputPrompt', () => {
suggestions: [{ label: 'file👍.txt', value: 'file👍.txt' }],
});
- const { unmount } = render(<InputPrompt {...props} />);
+ const { unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
@@ -955,7 +965,7 @@ describe('InputPrompt', () => {
suggestions: [],
});
- const { unmount } = render(<InputPrompt {...props} />);
+ const { unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
@@ -983,7 +993,7 @@ describe('InputPrompt', () => {
suggestions: [{ label: 'my file.txt', value: 'my file.txt' }],
});
- const { unmount } = render(<InputPrompt {...props} />);
+ const { unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
@@ -1011,7 +1021,7 @@ describe('InputPrompt', () => {
suggestions: [],
});
- const { unmount } = render(<InputPrompt {...props} />);
+ const { unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
@@ -1041,7 +1051,7 @@ describe('InputPrompt', () => {
],
});
- const { unmount } = render(<InputPrompt {...props} />);
+ const { unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
@@ -1069,7 +1079,7 @@ describe('InputPrompt', () => {
suggestions: [{ label: 'test-command', value: 'test-command' }],
});
- const { unmount } = render(<InputPrompt {...props} />);
+ const { unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
@@ -1099,7 +1109,7 @@ describe('InputPrompt', () => {
],
});
- const { unmount } = render(<InputPrompt {...props} />);
+ const { unmount } = renderWithProviders(<InputPrompt {...props} />);
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(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(
+ <InputPrompt {...props} />,
+ );
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(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(
+ <InputPrompt {...props} />,
+ );
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(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(
+ <InputPrompt {...props} />,
+ );
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(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(
+ <InputPrompt {...props} />,
+ );
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(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(
+ <InputPrompt {...props} />,
+ );
await wait();
stdin.write('a');
@@ -1197,7 +1217,9 @@ describe('InputPrompt', () => {
props.onEscapePromptChange = onEscapePromptChange;
props.buffer.setText('text to clear');
- const { stdin, unmount } = render(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(
+ <InputPrompt {...props} />,
+ );
await wait();
stdin.write('\x1B');
@@ -1216,7 +1238,9 @@ describe('InputPrompt', () => {
props.onEscapePromptChange = onEscapePromptChange;
props.buffer.setText('some text');
- const { stdin, unmount } = render(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(
+ <InputPrompt {...props} />,
+ );
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(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(
+ <InputPrompt {...props} />,
+ );
await wait();
stdin.write('\x1B');
@@ -1252,7 +1278,9 @@ describe('InputPrompt', () => {
suggestions: [{ label: 'suggestion', value: 'suggestion' }],
});
- const { stdin, unmount } = render(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(
+ <InputPrompt {...props} />,
+ );
await wait();
stdin.write('\x1B');
@@ -1266,7 +1294,9 @@ describe('InputPrompt', () => {
props.onEscapePromptChange = undefined;
props.buffer.setText('some text');
- const { stdin, unmount } = render(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(
+ <InputPrompt {...props} />,
+ );
await wait();
stdin.write('\x1B');
@@ -1276,7 +1306,9 @@ describe('InputPrompt', () => {
});
it('should not interfere with existing keyboard shortcuts', async () => {
- const { stdin, unmount } = render(<InputPrompt {...props} />);
+ const { stdin, unmount } = renderWithProviders(
+ <InputPrompt {...props} />,
+ );
await wait();
stdin.write('\x0C');
@@ -1306,7 +1338,9 @@ describe('InputPrompt', () => {
});
it('invokes reverse search on Ctrl+R', async () => {
- const { stdin, stdout, unmount } = render(<InputPrompt {...props} />);
+ const { stdin, stdout, unmount } = renderWithProviders(
+ <InputPrompt {...props} />,
+ );
await wait();
stdin.write('\x12');
@@ -1322,7 +1356,9 @@ describe('InputPrompt', () => {
});
it('resets reverse search state on Escape', async () => {
- const { stdin, stdout, unmount } = render(<InputPrompt {...props} />);
+ const { stdin, stdout, unmount } = renderWithProviders(
+ <InputPrompt {...props} />,
+ );
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(<InputPrompt {...props} />);
+ const { stdin, stdout, unmount } = renderWithProviders(
+ <InputPrompt {...props} />,
+ );
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(<InputPrompt {...props} />);
+ const { stdin, stdout, unmount } = renderWithProviders(
+ <InputPrompt {...props} />,
+ );
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(<InputPrompt {...props} />);
+ const { stdin, stdout, unmount } = renderWithProviders(
+ <InputPrompt {...props} />,
+ );
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<InputPromptProps> = ({
const [escPressCount, setEscPressCount] = useState(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
const escapeTimerRef = useRef<NodeJS.Timeout | null>(null);
- const kittyProtocolStatus = useKittyKeyboardProtocol();
const [dirs, setDirs] = useState<readonly string[]>(
config.getWorkspaceContext().getDirectories(),
@@ -529,8 +527,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
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(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
@@ -116,7 +116,7 @@ describe('SettingsDialog', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
@@ -129,7 +129,7 @@ describe('SettingsDialog', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
@@ -144,7 +144,7 @@ describe('SettingsDialog', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
- const { stdin, unmount } = render(
+ const { stdin, unmount } = renderWithProviders(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
@@ -160,7 +160,7 @@ describe('SettingsDialog', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
- const { stdin, unmount } = render(
+ const { stdin, unmount } = renderWithProviders(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
@@ -177,7 +177,7 @@ describe('SettingsDialog', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
- const { stdin, unmount } = render(
+ const { stdin, unmount } = renderWithProviders(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
@@ -194,7 +194,7 @@ describe('SettingsDialog', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
- const { stdin, unmount } = render(
+ const { stdin, unmount } = renderWithProviders(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
@@ -212,7 +212,7 @@ describe('SettingsDialog', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
- const { stdin, unmount } = render(
+ const { stdin, unmount } = renderWithProviders(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
@@ -227,7 +227,7 @@ describe('SettingsDialog', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
- const { stdin, unmount } = render(
+ const { stdin, unmount } = renderWithProviders(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
@@ -242,7 +242,7 @@ describe('SettingsDialog', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
- const { stdin, unmount } = render(
+ const { stdin, unmount } = renderWithProviders(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
@@ -261,7 +261,7 @@ describe('SettingsDialog', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
- const { stdin, unmount } = render(
+ const { stdin, unmount } = renderWithProviders(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
@@ -280,7 +280,7 @@ describe('SettingsDialog', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
- const { lastFrame, stdin, unmount } = render(
+ const { lastFrame, stdin, unmount } = renderWithProviders(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
@@ -308,7 +308,7 @@ describe('SettingsDialog', () => {
const settings = createMockSettings();
const onRestartRequest = vi.fn();
- const { unmount } = render(
+ const { unmount } = renderWithProviders(
<SettingsDialog
settings={settings}
onSelect={() => {}}
@@ -327,7 +327,7 @@ describe('SettingsDialog', () => {
const settings = createMockSettings();
const onRestartRequest = vi.fn();
- const { stdin, unmount } = render(
+ const { stdin, unmount } = renderWithProviders(
<SettingsDialog
settings={settings}
onSelect={() => {}}
@@ -349,7 +349,7 @@ describe('SettingsDialog', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
- const { stdin, unmount } = render(
+ const { stdin, unmount } = renderWithProviders(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
@@ -368,7 +368,7 @@ describe('SettingsDialog', () => {
const settings = createMockSettings({ vimMode: true });
const onSelect = vi.fn();
- const { stdin, unmount } = render(
+ const { stdin, unmount } = renderWithProviders(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
@@ -392,7 +392,7 @@ describe('SettingsDialog', () => {
);
const onSelect = vi.fn();
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
@@ -409,7 +409,7 @@ describe('SettingsDialog', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
- const { stdin, unmount } = render(
+ const { stdin, unmount } = renderWithProviders(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
@@ -427,7 +427,7 @@ describe('SettingsDialog', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
- const { stdin, unmount } = render(
+ const { stdin, unmount } = renderWithProviders(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
@@ -449,7 +449,7 @@ describe('SettingsDialog', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
- const { stdin, unmount } = render(
+ const { stdin, unmount } = renderWithProviders(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
@@ -468,7 +468,7 @@ describe('SettingsDialog', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
- const { stdin, unmount } = render(
+ const { stdin, unmount } = renderWithProviders(
<VimModeProvider settings={settings}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</VimModeProvider>,
@@ -492,7 +492,7 @@ describe('SettingsDialog', () => {
);
const onSelect = vi.fn();
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
@@ -505,7 +505,7 @@ describe('SettingsDialog', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
- const { stdin, unmount } = render(
+ const { stdin, unmount } = renderWithProviders(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
@@ -521,7 +521,7 @@ describe('SettingsDialog', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
- const { lastFrame, unmount } = render(
+ const { lastFrame, unmount } = renderWithProviders(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
@@ -541,7 +541,7 @@ describe('SettingsDialog', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
- const { unmount } = render(
+ const { unmount } = renderWithProviders(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
@@ -559,7 +559,7 @@ describe('SettingsDialog', () => {
);
const onSelect = vi.fn();
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
@@ -576,7 +576,7 @@ describe('SettingsDialog', () => {
);
const onSelect = vi.fn();
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
@@ -591,7 +591,7 @@ describe('SettingsDialog', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
- const { stdin, unmount } = render(
+ const { stdin, unmount } = renderWithProviders(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
@@ -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(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
@@ -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(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
@@ -642,7 +642,7 @@ describe('SettingsDialog', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
- const { stdin, unmount } = render(
+ const { stdin, unmount } = renderWithProviders(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
@@ -659,7 +659,7 @@ describe('SettingsDialog', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
- const { lastFrame, stdin, unmount } = render(
+ const { lastFrame, stdin, unmount } = renderWithProviders(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
@@ -692,7 +692,7 @@ describe('SettingsDialog', () => {
);
const onSelect = vi.fn();
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
@@ -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(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
@@ -718,7 +718,7 @@ describe('SettingsDialog', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
- const { stdin, unmount } = render(
+ const { stdin, unmount } = renderWithProviders(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
@@ -763,7 +763,7 @@ describe('SettingsDialog', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
- const { stdin, unmount } = render(
+ const { stdin, unmount } = renderWithProviders(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
@@ -792,7 +792,7 @@ describe('SettingsDialog', () => {
const settings = createMockSettings({ vimMode: true });
const onSelect = vi.fn();
- const { stdin, unmount } = render(
+ const { stdin, unmount } = renderWithProviders(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
@@ -816,7 +816,7 @@ describe('SettingsDialog', () => {
const settings = createMockSettings();
const onRestartRequest = vi.fn();
- const { stdin, unmount } = render(
+ const { stdin, unmount } = renderWithProviders(
<SettingsDialog
settings={settings}
onSelect={() => {}}
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(<ShellConfirmationDialog request={request} />);
+ const { lastFrame } = renderWithProviders(
+ <ShellConfirmationDialog request={request} />,
+ );
expect(lastFrame()).toMatchSnapshot();
});
it('calls onConfirm with ProceedOnce when "Yes, allow once" is selected', () => {
- const { lastFrame } = render(<ShellConfirmationDialog request={request} />);
+ const { lastFrame } = renderWithProviders(
+ <ShellConfirmationDialog request={request} />,
+ );
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(<ShellConfirmationDialog request={request} />);
+ const { lastFrame } = renderWithProviders(
+ <ShellConfirmationDialog request={request} />,
+ );
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(<ShellConfirmationDialog request={request} />);
+ const { lastFrame } = renderWithProviders(
+ <ShellConfirmationDialog request={request} />,
+ );
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(
<ToolConfirmationMessage
confirmationDetails={confirmationDetails}
availableTerminalHeight={30}
@@ -42,7 +42,7 @@ describe('ToolConfirmationMessage', () => {
onConfirm: vi.fn(),
};
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
confirmationDetails={confirmationDetails}
availableTerminalHeight={30}
diff --git a/packages/cli/src/ui/components/shared/RadioButtonSelect.test.tsx b/packages/cli/src/ui/components/shared/RadioButtonSelect.test.tsx
index d4c13ba5..cb6db77f 100644
--- a/packages/cli/src/ui/components/shared/RadioButtonSelect.test.tsx
+++ b/packages/cli/src/ui/components/shared/RadioButtonSelect.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 {
RadioButtonSelect,
@@ -20,21 +20,21 @@ const ITEMS: Array<RadioSelectItem<string>> = [
describe('<RadioButtonSelect />', () => {
it('renders a list of items and matches snapshot', () => {
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
<RadioButtonSelect items={ITEMS} onSelect={() => {}} isFocused={true} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders with the second item selected and matches snapshot', () => {
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
<RadioButtonSelect items={ITEMS} initialIndex={1} onSelect={() => {}} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders with numbers hidden and matches snapshot', () => {
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
<RadioButtonSelect
items={ITEMS}
onSelect={() => {}}
@@ -49,7 +49,7 @@ describe('<RadioButtonSelect />', () => {
label: `Item ${i + 1}`,
value: `item-${i + 1}`,
}));
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
<RadioButtonSelect
items={manyItems}
onSelect={() => {}}
@@ -75,7 +75,7 @@ describe('<RadioButtonSelect />', () => {
themeTypeDisplay: '(Dark)',
},
];
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
<RadioButtonSelect items={themeItems} onSelect={() => {}} />,
);
expect(lastFrame()).toMatchSnapshot();
@@ -86,14 +86,14 @@ describe('<RadioButtonSelect />', () => {
label: `Item ${i + 1}`,
value: `item-${i + 1}`,
}));
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
<RadioButtonSelect items={manyItems} onSelect={() => {}} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders nothing when no items are provided', () => {
- const { lastFrame } = render(
+ const { lastFrame } = renderWithProviders(
<RadioButtonSelect items={[]} onSelect={() => {}} isFocused={true} />,
);
expect(lastFrame()).toBe('');
@@ -103,7 +103,7 @@ describe('<RadioButtonSelect />', () => {
describe('keyboard navigation', () => {
it('should call onSelect when "enter" is pressed', () => {
const onSelect = vi.fn();
- const { stdin } = render(
+ const { stdin } = renderWithProviders(
<RadioButtonSelect items={ITEMS} onSelect={onSelect} />,
);
@@ -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(
<RadioButtonSelect
items={ITEMS}
onSelect={onSelect}
@@ -137,7 +137,7 @@ describe('keyboard navigation', () => {
])('$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(
<RadioButtonSelect
items={ITEMS}
onSelect={onSelect}
@@ -158,7 +158,7 @@ describe('keyboard navigation', () => {
it('should navigate up with arrow key and select with enter', async () => {
const onSelect = vi.fn();
- const { stdin, lastFrame } = render(
+ const { stdin, lastFrame } = renderWithProviders(
<RadioButtonSelect
items={ITEMS}
onSelect={onSelect}
diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx
new file mode 100644
index 00000000..f0c000a0
--- /dev/null
+++ b/packages/cli/src/ui/contexts/KeypressContext.tsx
@@ -0,0 +1,405 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ Config,
+ KittySequenceOverflowEvent,
+ logKittySequenceOverflow,
+} from '@google/gemini-cli-core';
+import { useStdin } from 'ink';
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useRef,
+} from 'react';
+import readline from 'readline';
+import { PassThrough } from 'stream';
+import {
+ BACKSLASH_ENTER_DETECTION_WINDOW_MS,
+ KITTY_CTRL_C,
+ MAX_KITTY_SEQUENCE_LENGTH,
+} from '../utils/platformConstants.js';
+
+import { FOCUS_IN, FOCUS_OUT } from '../hooks/useFocus.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 type KeypressHandler = (key: Key) => void;
+
+interface KeypressContextValue {
+ subscribe: (handler: KeypressHandler) => void;
+ unsubscribe: (handler: KeypressHandler) => void;
+}
+
+const KeypressContext = createContext<KeypressContextValue | undefined>(
+ 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<Set<KeypressHandler>>(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 (
+ <KeypressContext.Provider value={{ subscribe, unsubscribe }}>
+ {children}
+ </KeypressContext.Provider>
+ );
+}
diff --git a/packages/cli/src/ui/hooks/useKeypress.test.ts b/packages/cli/src/ui/hooks/useKeypress.test.ts
index 946ee054..b804eb90 100644
--- a/packages/cli/src/ui/hooks/useKeypress.test.ts
+++ b/packages/cli/src/ui/hooks/useKeypress.test.ts
@@ -4,8 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
+import React from 'react';
import { renderHook, act } from '@testing-library/react';
import { useKeypress, Key } from './useKeypress.js';
+import { KeypressProvider } from '../contexts/KeypressContext.js';
import { useStdin } from 'ink';
import { EventEmitter } from 'events';
import { PassThrough } from 'stream';
@@ -102,6 +104,9 @@ describe('useKeypress', () => {
const onKeypress = vi.fn();
let originalNodeVersion: string;
+ const wrapper = ({ children }: { children: React.ReactNode }) =>
+ React.createElement(KeypressProvider, null, children);
+
beforeEach(() => {
vi.clearAllMocks();
stdin = new MockStdin();
@@ -129,7 +134,9 @@ describe('useKeypress', () => {
};
it('should not listen if isActive is false', () => {
- renderHook(() => useKeypress(onKeypress, { isActive: false }));
+ renderHook(() => useKeypress(onKeypress, { isActive: false }), {
+ wrapper,
+ });
act(() => stdin.pressKey({ name: 'a' }));
expect(onKeypress).not.toHaveBeenCalled();
});
@@ -141,14 +148,15 @@ describe('useKeypress', () => {
{ key: { name: 'up', sequence: '\x1b[A' } },
{ key: { name: 'down', sequence: '\x1b[B' } },
])('should listen for keypress when active for key $key.name', ({ key }) => {
- renderHook(() => useKeypress(onKeypress, { isActive: true }));
+ renderHook(() => useKeypress(onKeypress, { isActive: true }), { wrapper });
act(() => stdin.pressKey(key));
expect(onKeypress).toHaveBeenCalledWith(expect.objectContaining(key));
});
it('should set and release raw mode', () => {
- const { unmount } = renderHook(() =>
- useKeypress(onKeypress, { isActive: true }),
+ const { unmount } = renderHook(
+ () => useKeypress(onKeypress, { isActive: true }),
+ { wrapper },
);
expect(mockSetRawMode).toHaveBeenCalledWith(true);
unmount();
@@ -156,8 +164,9 @@ describe('useKeypress', () => {
});
it('should stop listening after being unmounted', () => {
- const { unmount } = renderHook(() =>
- useKeypress(onKeypress, { isActive: true }),
+ const { unmount } = renderHook(
+ () => useKeypress(onKeypress, { isActive: true }),
+ { wrapper },
);
unmount();
act(() => stdin.pressKey({ name: 'a' }));
@@ -165,7 +174,7 @@ describe('useKeypress', () => {
});
it('should correctly identify alt+enter (meta key)', () => {
- renderHook(() => useKeypress(onKeypress, { isActive: true }));
+ renderHook(() => useKeypress(onKeypress, { isActive: true }), { wrapper });
const key = { name: 'return', sequence: '\x1B\r' };
act(() => stdin.pressKey(key));
expect(onKeypress).toHaveBeenCalledWith(
@@ -199,7 +208,9 @@ describe('useKeypress', () => {
});
it('should process a paste as a single event', () => {
- renderHook(() => useKeypress(onKeypress, { isActive: true }));
+ renderHook(() => useKeypress(onKeypress, { isActive: true }), {
+ wrapper,
+ });
const pasteText = 'hello world';
act(() => stdin.paste(pasteText));
@@ -215,7 +226,9 @@ describe('useKeypress', () => {
});
it('should handle keypress interspersed with pastes', () => {
- renderHook(() => useKeypress(onKeypress, { isActive: true }));
+ renderHook(() => useKeypress(onKeypress, { isActive: true }), {
+ wrapper,
+ });
const keyA = { name: 'a', sequence: 'a' };
act(() => stdin.pressKey(keyA));
@@ -239,8 +252,9 @@ describe('useKeypress', () => {
});
it('should emit partial paste content if unmounted mid-paste', () => {
- const { unmount } = renderHook(() =>
- useKeypress(onKeypress, { isActive: true }),
+ const { unmount } = renderHook(
+ () => useKeypress(onKeypress, { isActive: true }),
+ { wrapper },
);
const pasteText = 'incomplete paste';
diff --git a/packages/cli/src/ui/hooks/useKeypress.ts b/packages/cli/src/ui/hooks/useKeypress.ts
index 920270ee..bead50e6 100644
--- a/packages/cli/src/ui/hooks/useKeypress.ts
+++ b/packages/cli/src/ui/hooks/useKeypress.ts
@@ -4,414 +4,36 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { useEffect, useRef } from 'react';
-import { useStdin } from 'ink';
-import readline from 'readline';
-import { PassThrough } from 'stream';
+import { useEffect } from 'react';
import {
- KITTY_CTRL_C,
- BACKSLASH_ENTER_DETECTION_WINDOW_MS,
- MAX_KITTY_SEQUENCE_LENGTH,
-} from '../utils/platformConstants.js';
-import {
- KittySequenceOverflowEvent,
- logKittySequenceOverflow,
- Config,
-} from '@google/gemini-cli-core';
-import { FOCUS_IN, FOCUS_OUT } from './useFocus.js';
-
-const ESC = '\u001B';
-export const PASTE_MODE_PREFIX = `${ESC}[200~`;
-export const PASTE_MODE_SUFFIX = `${ESC}[201~`;
+ useKeypressContext,
+ KeypressHandler,
+ Key,
+} from '../contexts/KeypressContext.js';
-export interface Key {
- name: string;
- ctrl: boolean;
- meta: boolean;
- shift: boolean;
- paste: boolean;
- sequence: string;
- kittyProtocol?: boolean;
-}
+export { Key };
/**
- * A hook that listens for keypress events from stdin, providing a
- * key object that mirrors the one from Node's `readline` module,
- * adding a 'paste' flag for characters input as part of a bracketed
- * paste (when enabled).
- *
- * Pastes are currently sent as a single key event where the full paste
- * is in the sequence field.
+ * A hook that listens for keypress events from stdin.
*
* @param onKeypress - The callback function to execute on each keypress.
* @param options - Options to control the hook's behavior.
* @param options.isActive - Whether the hook should be actively listening for input.
- * @param options.kittyProtocolEnabled - Whether Kitty keyboard protocol is enabled.
- * @param options.config - Optional config for telemetry logging.
*/
export function useKeypress(
- onKeypress: (key: Key) => void,
- {
- isActive,
- kittyProtocolEnabled = false,
- config,
- }: { isActive: boolean; kittyProtocolEnabled?: boolean; config?: Config },
+ onKeypress: KeypressHandler,
+ { isActive }: { isActive: boolean },
) {
- const { stdin, setRawMode } = useStdin();
- const onKeypressRef = useRef(onKeypress);
-
- useEffect(() => {
- onKeypressRef.current = onKeypress;
- }, [onKeypress]);
+ const { subscribe, unsubscribe } = useKeypressContext();
useEffect(() => {
- if (!isActive || !stdin.isTTY) {
+ if (!isActive) {
return;
}
- setRawMode(true);
-
- const keypressStream = new PassThrough();
- let usePassthrough = false;
- const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10);
- if (
- nodeMajorVersion < 20 ||
- process.env['PASTE_WORKAROUND'] === '1' ||
- process.env['PASTE_WORKAROUND'] === 'true'
- ) {
- // Prior to node 20, node's built-in readline does not support bracketed
- // paste mode. We hack by detecting it with our own handler.
- usePassthrough = true;
- }
-
- let isPaste = false;
- let pasteBuffer = Buffer.alloc(0);
- let kittySequenceBuffer = '';
- let backslashTimeout: NodeJS.Timeout | null = null;
- let waitingForEnterAfterBackslash = false;
-
- // Parse Kitty protocol sequences
- const parseKittySequence = (sequence: string): Key | null => {
- // Match CSI <number> ; <modifiers> u or ~
- // Format: ESC [ <keycode> ; <modifiers> u/~
- const kittyPattern = new RegExp(`^${ESC}\\[(\\d+)(;(\\d+))?([u~])$`);
- const match = sequence.match(kittyPattern);
- if (!match) return null;
-
- const keyCode = parseInt(match[1], 10);
- const modifiers = match[3] ? parseInt(match[3], 10) : 1;
-
- // Decode modifiers (subtract 1 as per Kitty protocol spec)
- const modifierBits = modifiers - 1;
- const shift = (modifierBits & 1) === 1;
- const alt = (modifierBits & 2) === 2;
- const ctrl = (modifierBits & 4) === 4;
-
- // Handle Escape key (code 27)
- if (keyCode === 27) {
- return {
- name: 'escape',
- ctrl,
- meta: alt,
- shift,
- paste: false,
- sequence,
- kittyProtocol: true,
- };
- }
-
- // Handle Enter key (code 13)
- if (keyCode === 13) {
- return {
- name: 'return',
- ctrl,
- meta: alt,
- shift,
- paste: false,
- sequence,
- kittyProtocol: true,
- };
- }
-
- // Handle Ctrl+letter combinations (a-z)
- // ASCII codes: a=97, b=98, c=99, ..., z=122
- if (keyCode >= 97 && keyCode <= 122 && ctrl) {
- const letter = String.fromCharCode(keyCode);
- return {
- name: letter,
- ctrl: true,
- meta: alt,
- shift,
- paste: false,
- sequence,
- kittyProtocol: true,
- };
- }
-
- // Handle other keys as needed
- return null;
- };
-
- const handleKeypress = (_: unknown, key: Key) => {
- // Handle VS Code's backslash+return pattern (Shift+Enter)
- if (key.name === 'return' && waitingForEnterAfterBackslash) {
- // Cancel the timeout since we got the Enter
- if (backslashTimeout) {
- clearTimeout(backslashTimeout);
- backslashTimeout = null;
- }
- waitingForEnterAfterBackslash = false;
-
- // Convert to Shift+Enter
- onKeypressRef.current({
- ...key,
- shift: true,
- sequence: '\\\r', // VS Code's Shift+Enter representation
- });
- return;
- }
-
- // Handle backslash - hold it to see if Enter follows
- if (key.sequence === '\\' && !key.name) {
- // Don't pass through the backslash yet - wait to see if Enter follows
- waitingForEnterAfterBackslash = true;
-
- // Set up a timeout to pass through the backslash if no Enter follows
- backslashTimeout = setTimeout(() => {
- waitingForEnterAfterBackslash = false;
- backslashTimeout = null;
- // Pass through the backslash since no Enter followed
- onKeypressRef.current(key);
- }, BACKSLASH_ENTER_DETECTION_WINDOW_MS);
-
- return;
- }
-
- // If we're waiting for Enter after backslash but got something else,
- // pass through the backslash first, then the new key
- if (waitingForEnterAfterBackslash && key.name !== 'return') {
- if (backslashTimeout) {
- clearTimeout(backslashTimeout);
- backslashTimeout = null;
- }
- waitingForEnterAfterBackslash = false;
-
- // Pass through the backslash that was held
- onKeypressRef.current({
- name: '',
- sequence: '\\',
- ctrl: false,
- meta: false,
- shift: false,
- paste: false,
- });
-
- // Then continue processing the current key normally
- }
-
- // If readline has already identified an arrow key, pass it through
- // immediately, bypassing the Kitty protocol sequence buffering.
- if (['up', 'down', 'left', 'right'].includes(key.name)) {
- onKeypressRef.current(key);
- return;
- }
-
- // Always pass through Ctrl+C immediately, regardless of protocol state
- // Check both standard format and Kitty protocol sequence
- if (
- (key.ctrl && key.name === 'c') ||
- key.sequence === `${ESC}${KITTY_CTRL_C}`
- ) {
- kittySequenceBuffer = '';
- // If it's the Kitty sequence, create a proper key object
- if (key.sequence === `${ESC}${KITTY_CTRL_C}`) {
- onKeypressRef.current({
- name: 'c',
- ctrl: true,
- meta: false,
- shift: false,
- paste: false,
- sequence: key.sequence,
- kittyProtocol: true,
- });
- } else {
- onKeypressRef.current(key);
- }
- return;
- }
-
- // If Kitty protocol is enabled, handle CSI sequences
- if (kittyProtocolEnabled) {
- // If we have a buffer or this starts a CSI sequence
- if (
- kittySequenceBuffer ||
- (key.sequence.startsWith(`${ESC}[`) &&
- !key.sequence.startsWith(PASTE_MODE_PREFIX) &&
- !key.sequence.startsWith(PASTE_MODE_SUFFIX) &&
- !key.sequence.startsWith(FOCUS_IN) &&
- !key.sequence.startsWith(FOCUS_OUT))
- ) {
- kittySequenceBuffer += key.sequence;
-
- // Try to parse the buffer as a Kitty sequence
- const kittyKey = parseKittySequence(kittySequenceBuffer);
- if (kittyKey) {
- kittySequenceBuffer = '';
- onKeypressRef.current(kittyKey);
- return;
- }
-
- if (config?.getDebugMode()) {
- const codes = Array.from(kittySequenceBuffer).map((ch) =>
- ch.charCodeAt(0),
- );
- // Unless the user is sshing over a slow connection, this likely
- // indicates this is not a kitty sequence but we have incorrectly
- // interpreted it as such. See the examples above for sequences
- // such as FOCUS_IN that are not Kitty sequences.
- console.warn('Kitty sequence buffer has char codes:', codes);
- }
-
- // If buffer doesn't match expected pattern and is getting long, flush it
- if (kittySequenceBuffer.length > MAX_KITTY_SEQUENCE_LENGTH) {
- // Log telemetry for buffer overflow
- if (config) {
- const event = new KittySequenceOverflowEvent(
- kittySequenceBuffer.length,
- kittySequenceBuffer,
- );
- logKittySequenceOverflow(config, event);
- }
- // Not a Kitty sequence, treat as regular key
- kittySequenceBuffer = '';
- } else {
- // Wait for more characters
- return;
- }
- }
- }
- if (key.name === 'paste-start') {
- isPaste = true;
- } else if (key.name === 'paste-end') {
- isPaste = false;
- onKeypressRef.current({
- name: '',
- ctrl: false,
- meta: false,
- shift: false,
- paste: true,
- sequence: pasteBuffer.toString(),
- });
- pasteBuffer = Buffer.alloc(0);
- } else {
- if (isPaste) {
- pasteBuffer = Buffer.concat([pasteBuffer, Buffer.from(key.sequence)]);
- } else {
- // Handle special keys
- if (key.name === 'return' && key.sequence === `${ESC}\r`) {
- key.meta = true;
- }
- onKeypressRef.current({ ...key, paste: isPaste });
- }
- }
- };
-
- const handleRawKeypress = (data: Buffer) => {
- const pasteModePrefixBuffer = Buffer.from(PASTE_MODE_PREFIX);
- const pasteModeSuffixBuffer = Buffer.from(PASTE_MODE_SUFFIX);
-
- let pos = 0;
- while (pos < data.length) {
- const prefixPos = data.indexOf(pasteModePrefixBuffer, pos);
- const suffixPos = data.indexOf(pasteModeSuffixBuffer, pos);
-
- // Determine which marker comes first, if any.
- const isPrefixNext =
- prefixPos !== -1 && (suffixPos === -1 || prefixPos < suffixPos);
- const isSuffixNext =
- suffixPos !== -1 && (prefixPos === -1 || suffixPos < prefixPos);
-
- let nextMarkerPos = -1;
- let markerLength = 0;
-
- if (isPrefixNext) {
- nextMarkerPos = prefixPos;
- } else if (isSuffixNext) {
- nextMarkerPos = suffixPos;
- }
- markerLength = pasteModeSuffixBuffer.length;
-
- if (nextMarkerPos === -1) {
- keypressStream.write(data.slice(pos));
- return;
- }
-
- const nextData = data.slice(pos, nextMarkerPos);
- if (nextData.length > 0) {
- keypressStream.write(nextData);
- }
- const createPasteKeyEvent = (
- name: 'paste-start' | 'paste-end',
- ): Key => ({
- name,
- ctrl: false,
- meta: false,
- shift: false,
- paste: false,
- sequence: '',
- });
- if (isPrefixNext) {
- handleKeypress(undefined, createPasteKeyEvent('paste-start'));
- } else if (isSuffixNext) {
- handleKeypress(undefined, createPasteKeyEvent('paste-end'));
- }
- pos = nextMarkerPos + markerLength;
- }
- };
-
- let rl: readline.Interface;
- if (usePassthrough) {
- rl = readline.createInterface({
- input: keypressStream,
- escapeCodeTimeout: 0,
- });
- readline.emitKeypressEvents(keypressStream, rl);
- keypressStream.on('keypress', handleKeypress);
- stdin.on('data', handleRawKeypress);
- } else {
- rl = readline.createInterface({ input: stdin, escapeCodeTimeout: 0 });
- readline.emitKeypressEvents(stdin, rl);
- stdin.on('keypress', handleKeypress);
- }
-
+ subscribe(onKeypress);
return () => {
- if (usePassthrough) {
- keypressStream.removeListener('keypress', handleKeypress);
- stdin.removeListener('data', handleRawKeypress);
- } else {
- stdin.removeListener('keypress', handleKeypress);
- }
- rl.close();
- setRawMode(false);
-
- // Clean up any pending backslash timeout
- if (backslashTimeout) {
- clearTimeout(backslashTimeout);
- backslashTimeout = null;
- }
-
- // If we are in the middle of a paste, send what we have.
- if (isPaste) {
- onKeypressRef.current({
- name: '',
- ctrl: false,
- meta: false,
- shift: false,
- paste: true,
- sequence: pasteBuffer.toString(),
- });
- pasteBuffer = Buffer.alloc(0);
- }
+ unsubscribe(onKeypress);
};
- }, [isActive, stdin, setRawMode, kittyProtocolEnabled, config]);
+ }, [isActive, onKeypress, subscribe, unsubscribe]);
}