From d219f9013206aad5a1361e436ad4a45114e9cd49 Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Tue, 12 Aug 2025 14:05:49 -0700 Subject: [PATCH] Switch from useInput to useKeypress. (#6056) --- packages/cli/src/ui/IdeIntegrationNudge.tsx | 22 +- packages/cli/src/ui/components/AuthDialog.tsx | 38 ++-- .../cli/src/ui/components/AuthInProgress.tsx | 16 +- .../cli/src/ui/components/DebugProfiler.tsx | 16 +- .../ui/components/EditorSettingsDialog.tsx | 22 +- .../ui/components/FolderTrustDialog.test.tsx | 9 +- .../src/ui/components/FolderTrustDialog.tsx | 16 +- .../cli/src/ui/components/SettingsDialog.tsx | 193 +++++++++--------- .../ui/components/ShellConfirmationDialog.tsx | 16 +- .../cli/src/ui/components/ThemeDialog.tsx | 22 +- .../messages/ToolConfirmationMessage.tsx | 18 +- .../components/shared/RadioButtonSelect.tsx | 20 +- .../ui/hooks/useAutoAcceptIndicator.test.ts | 70 ++++--- .../src/ui/hooks/useAutoAcceptIndicator.ts | 43 ++-- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 25 ++- packages/cli/src/ui/hooks/useGeminiStream.ts | 15 +- .../src/ui/privacy/CloudFreePrivacyNotice.tsx | 16 +- .../src/ui/privacy/CloudPaidPrivacyNotice.tsx | 16 +- .../src/ui/privacy/GeminiPrivacyNotice.tsx | 16 +- 19 files changed, 350 insertions(+), 259 deletions(-) diff --git a/packages/cli/src/ui/IdeIntegrationNudge.tsx b/packages/cli/src/ui/IdeIntegrationNudge.tsx index 13f70a75..2be69ad7 100644 --- a/packages/cli/src/ui/IdeIntegrationNudge.tsx +++ b/packages/cli/src/ui/IdeIntegrationNudge.tsx @@ -5,11 +5,12 @@ */ import { DetectedIde, getIdeInfo } from '@google/gemini-cli-core'; -import { Box, Text, useInput } from 'ink'; +import { Box, Text } from 'ink'; import { RadioButtonSelect, RadioSelectItem, } from './components/shared/RadioButtonSelect.js'; +import { useKeypress } from './hooks/useKeypress.js'; export type IdeIntegrationNudgeResult = { userSelection: 'yes' | 'no' | 'dismiss'; @@ -25,14 +26,17 @@ export function IdeIntegrationNudge({ ide, onComplete, }: IdeIntegrationNudgeProps) { - useInput((_input, key) => { - if (key.escape) { - onComplete({ - userSelection: 'no', - isExtensionPreInstalled: false, - }); - } - }); + useKeypress( + (key) => { + if (key.name === 'escape') { + onComplete({ + userSelection: 'no', + isExtensionPreInstalled: false, + }); + } + }, + { isActive: true }, + ); const { displayName: ideName } = getIdeInfo(ide); // Assume extension is already installed if the env variables are set. diff --git a/packages/cli/src/ui/components/AuthDialog.tsx b/packages/cli/src/ui/components/AuthDialog.tsx index ae076ee7..1262f894 100644 --- a/packages/cli/src/ui/components/AuthDialog.tsx +++ b/packages/cli/src/ui/components/AuthDialog.tsx @@ -5,12 +5,13 @@ */ import React, { useState } from 'react'; -import { Box, Text, useInput } from 'ink'; +import { Box, Text } from 'ink'; import { Colors } from '../colors.js'; import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; import { LoadedSettings, SettingScope } from '../../config/settings.js'; import { AuthType } from '@google/gemini-cli-core'; import { validateAuthMethod } from '../../config/auth.js'; +import { useKeypress } from '../hooks/useKeypress.js'; interface AuthDialogProps { onSelect: (authMethod: AuthType | undefined, scope: SettingScope) => void; @@ -108,23 +109,26 @@ export function AuthDialog({ } }; - useInput((_input, key) => { - if (key.escape) { - // Prevent exit if there is an error message. - // This means they user is not authenticated yet. - if (errorMessage) { - return; + useKeypress( + (key) => { + if (key.name === 'escape') { + // Prevent exit if there is an error message. + // This means they user is not authenticated yet. + if (errorMessage) { + return; + } + if (settings.merged.selectedAuthType === undefined) { + // Prevent exiting if no auth method is set + setErrorMessage( + 'You must select an auth method to proceed. Press Ctrl+C twice to exit.', + ); + return; + } + onSelect(undefined, SettingScope.User); } - if (settings.merged.selectedAuthType === undefined) { - // Prevent exiting if no auth method is set - setErrorMessage( - 'You must select an auth method to proceed. Press Ctrl+C twice to exit.', - ); - return; - } - onSelect(undefined, SettingScope.User); - } - }); + }, + { isActive: true }, + ); return ( void; @@ -18,11 +19,14 @@ export function AuthInProgress({ }: AuthInProgressProps): React.JSX.Element { const [timedOut, setTimedOut] = useState(false); - useInput((input, key) => { - if (key.escape || (key.ctrl && (input === 'c' || input === 'C'))) { - onTimeout(); - } - }); + useKeypress( + (key) => { + if (key.name === 'escape' || (key.ctrl && key.name === 'c')) { + onTimeout(); + } + }, + { isActive: true }, + ); useEffect(() => { const timer = setTimeout(() => { diff --git a/packages/cli/src/ui/components/DebugProfiler.tsx b/packages/cli/src/ui/components/DebugProfiler.tsx index 89c40a91..22c16cfb 100644 --- a/packages/cli/src/ui/components/DebugProfiler.tsx +++ b/packages/cli/src/ui/components/DebugProfiler.tsx @@ -4,9 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Text, useInput } from 'ink'; +import { Text } from 'ink'; import { useEffect, useRef, useState } from 'react'; import { Colors } from '../colors.js'; +import { useKeypress } from '../hooks/useKeypress.js'; export const DebugProfiler = () => { const numRenders = useRef(0); @@ -16,11 +17,14 @@ export const DebugProfiler = () => { numRenders.current++; }); - useInput((input, key) => { - if (key.ctrl && input === 'b') { - setShowNumRenders((prev) => !prev); - } - }); + useKeypress( + (key) => { + if (key.ctrl && key.name === 'b') { + setShowNumRenders((prev) => !prev); + } + }, + { isActive: true }, + ); if (!showNumRenders) { return null; diff --git a/packages/cli/src/ui/components/EditorSettingsDialog.tsx b/packages/cli/src/ui/components/EditorSettingsDialog.tsx index 0b45d7f4..3c4c518b 100644 --- a/packages/cli/src/ui/components/EditorSettingsDialog.tsx +++ b/packages/cli/src/ui/components/EditorSettingsDialog.tsx @@ -5,7 +5,7 @@ */ import React, { useState } from 'react'; -import { Box, Text, useInput } from 'ink'; +import { Box, Text } from 'ink'; import { Colors } from '../colors.js'; import { EDITOR_DISPLAY_NAMES, @@ -15,6 +15,7 @@ import { import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; import { LoadedSettings, SettingScope } from '../../config/settings.js'; import { EditorType, isEditorAvailable } from '@google/gemini-cli-core'; +import { useKeypress } from '../hooks/useKeypress.js'; interface EditorDialogProps { onSelect: (editorType: EditorType | undefined, scope: SettingScope) => void; @@ -33,14 +34,17 @@ export function EditorSettingsDialog({ const [focusedSection, setFocusedSection] = useState<'editor' | 'scope'>( 'editor', ); - useInput((_, key) => { - if (key.tab) { - setFocusedSection((prev) => (prev === 'editor' ? 'scope' : 'editor')); - } - if (key.escape) { - onExit(); - } - }); + useKeypress( + (key) => { + if (key.name === 'tab') { + setFocusedSection((prev) => (prev === 'editor' ? 'scope' : 'editor')); + } + if (key.name === 'escape') { + onExit(); + } + }, + { isActive: true }, + ); const editorItems: EditorDisplay[] = editorSettingsManager.getAvailableEditorDisplays(); diff --git a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx index 01394d0f..d1be0b61 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx @@ -5,6 +5,7 @@ */ import { render } from 'ink-testing-library'; +import { waitFor } from '@testing-library/react'; import { vi } from 'vitest'; import { FolderTrustDialog, FolderTrustChoice } from './FolderTrustDialog.js'; @@ -18,12 +19,14 @@ describe('FolderTrustDialog', () => { ); }); - it('should call onSelect with DO_NOT_TRUST when escape is pressed', () => { + it('should call onSelect with DO_NOT_TRUST when escape is pressed', async () => { const onSelect = vi.fn(); const { stdin } = render(); - stdin.write('\u001B'); // Simulate escape key + stdin.write('\x1b'); - expect(onSelect).toHaveBeenCalledWith(FolderTrustChoice.DO_NOT_TRUST); + await waitFor(() => { + expect(onSelect).toHaveBeenCalledWith(FolderTrustChoice.DO_NOT_TRUST); + }); }); }); diff --git a/packages/cli/src/ui/components/FolderTrustDialog.tsx b/packages/cli/src/ui/components/FolderTrustDialog.tsx index 1918998c..30f3ff52 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.tsx @@ -4,13 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Box, Text, useInput } from 'ink'; +import { Box, Text } from 'ink'; import React from 'react'; import { Colors } from '../colors.js'; import { RadioButtonSelect, RadioSelectItem, } from './shared/RadioButtonSelect.js'; +import { useKeypress } from '../hooks/useKeypress.js'; export enum FolderTrustChoice { TRUST_FOLDER = 'trust_folder', @@ -25,11 +26,14 @@ interface FolderTrustDialogProps { export const FolderTrustDialog: React.FC = ({ onSelect, }) => { - useInput((_, key) => { - if (key.escape) { - onSelect(FolderTrustChoice.DO_NOT_TRUST); - } - }); + useKeypress( + (key) => { + if (key.name === 'escape') { + onSelect(FolderTrustChoice.DO_NOT_TRUST); + } + }, + { isActive: true }, + ); const options: Array> = [ { diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index 80e2339f..a09cd76a 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -5,7 +5,7 @@ */ import React, { useState, useEffect } from 'react'; -import { Box, Text, useInput } from 'ink'; +import { Box, Text } from 'ink'; import { Colors } from '../colors.js'; import { LoadedSettings, @@ -31,6 +31,7 @@ import { getDefaultValue, } from '../../utils/settingsUtils.js'; import { useVimMode } from '../contexts/VimModeContext.js'; +import { useKeypress } from '../hooks/useKeypress.js'; interface SettingsDialogProps { settings: LoadedSettings; @@ -256,107 +257,111 @@ export function SettingsDialog({ const showScrollUp = true; const showScrollDown = true; - useInput((input, key) => { - if (key.tab) { - setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings')); - } - if (focusSection === 'settings') { - if (key.upArrow || input === 'k') { - const newIndex = - activeSettingIndex > 0 ? activeSettingIndex - 1 : items.length - 1; - setActiveSettingIndex(newIndex); - // Adjust scroll offset for wrap-around - if (newIndex === items.length - 1) { - setScrollOffset(Math.max(0, items.length - maxItemsToShow)); - } else if (newIndex < scrollOffset) { - setScrollOffset(newIndex); - } - } else if (key.downArrow || input === 'j') { - const newIndex = - activeSettingIndex < items.length - 1 ? activeSettingIndex + 1 : 0; - setActiveSettingIndex(newIndex); - // Adjust scroll offset for wrap-around - if (newIndex === 0) { - setScrollOffset(0); - } else if (newIndex >= scrollOffset + maxItemsToShow) { - setScrollOffset(newIndex - maxItemsToShow + 1); - } - } else if (key.return || input === ' ') { - items[activeSettingIndex]?.toggle(); - } else if ((key.ctrl && input === 'c') || (key.ctrl && input === 'l')) { - // Ctrl+C or Ctrl+L: Clear current setting and reset to default - const currentSetting = items[activeSettingIndex]; - if (currentSetting) { - const defaultValue = getDefaultValue(currentSetting.value); - // Ensure defaultValue is a boolean for setPendingSettingValue - const booleanDefaultValue = - typeof defaultValue === 'boolean' ? defaultValue : false; + useKeypress( + (key) => { + const { name, ctrl } = key; + if (name === 'tab') { + setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings')); + } + if (focusSection === 'settings') { + if (name === 'up' || name === 'k') { + const newIndex = + activeSettingIndex > 0 ? activeSettingIndex - 1 : items.length - 1; + setActiveSettingIndex(newIndex); + // Adjust scroll offset for wrap-around + if (newIndex === items.length - 1) { + setScrollOffset(Math.max(0, items.length - maxItemsToShow)); + } else if (newIndex < scrollOffset) { + setScrollOffset(newIndex); + } + } else if (name === 'down' || name === 'j') { + const newIndex = + activeSettingIndex < items.length - 1 ? activeSettingIndex + 1 : 0; + setActiveSettingIndex(newIndex); + // Adjust scroll offset for wrap-around + if (newIndex === 0) { + setScrollOffset(0); + } else if (newIndex >= scrollOffset + maxItemsToShow) { + setScrollOffset(newIndex - maxItemsToShow + 1); + } + } else if (name === 'return' || name === 'space') { + items[activeSettingIndex]?.toggle(); + } else if (ctrl && (name === 'c' || name === 'l')) { + // Ctrl+C or Ctrl+L: Clear current setting and reset to default + const currentSetting = items[activeSettingIndex]; + if (currentSetting) { + const defaultValue = getDefaultValue(currentSetting.value); + // Ensure defaultValue is a boolean for setPendingSettingValue + const booleanDefaultValue = + typeof defaultValue === 'boolean' ? defaultValue : false; - // Update pending settings to default value - setPendingSettings((prev) => - setPendingSettingValue( - currentSetting.value, - booleanDefaultValue, - prev, - ), - ); - - // Remove from modified settings since it's now at default - setModifiedSettings((prev) => { - const updated = new Set(prev); - updated.delete(currentSetting.value); - return updated; - }); - - // Remove from restart-required settings if it was there - setRestartRequiredSettings((prev) => { - const updated = new Set(prev); - updated.delete(currentSetting.value); - return updated; - }); - - // If this setting doesn't require restart, save it immediately - if (!requiresRestart(currentSetting.value)) { - const immediateSettings = new Set([currentSetting.value]); - const immediateSettingsObject = setPendingSettingValue( - currentSetting.value, - booleanDefaultValue, - {}, + // Update pending settings to default value + setPendingSettings((prev) => + setPendingSettingValue( + currentSetting.value, + booleanDefaultValue, + prev, + ), ); - saveModifiedSettings( - immediateSettings, - immediateSettingsObject, - settings, - selectedScope, - ); + // Remove from modified settings since it's now at default + setModifiedSettings((prev) => { + const updated = new Set(prev); + updated.delete(currentSetting.value); + return updated; + }); + + // Remove from restart-required settings if it was there + setRestartRequiredSettings((prev) => { + const updated = new Set(prev); + updated.delete(currentSetting.value); + return updated; + }); + + // If this setting doesn't require restart, save it immediately + if (!requiresRestart(currentSetting.value)) { + const immediateSettings = new Set([currentSetting.value]); + const immediateSettingsObject = setPendingSettingValue( + currentSetting.value, + booleanDefaultValue, + {}, + ); + + saveModifiedSettings( + immediateSettings, + immediateSettingsObject, + settings, + selectedScope, + ); + } } } } - } - if (showRestartPrompt && input === 'r') { - // Only save settings that require restart (non-restart settings were already saved immediately) - const restartRequiredSettings = - getRestartRequiredFromModified(modifiedSettings); - const restartRequiredSet = new Set(restartRequiredSettings); + if (showRestartPrompt && name === 'r') { + // Only save settings that require restart (non-restart settings were already saved immediately) + const restartRequiredSettings = + getRestartRequiredFromModified(modifiedSettings); + const restartRequiredSet = new Set(restartRequiredSettings); - if (restartRequiredSet.size > 0) { - saveModifiedSettings( - restartRequiredSet, - pendingSettings, - settings, - selectedScope, - ); + if (restartRequiredSet.size > 0) { + saveModifiedSettings( + restartRequiredSet, + pendingSettings, + settings, + selectedScope, + ); + } + + setShowRestartPrompt(false); + setRestartRequiredSettings(new Set()); // Clear restart-required settings + if (onRestartRequest) onRestartRequest(); } - - setShowRestartPrompt(false); - setRestartRequiredSettings(new Set()); // Clear restart-required settings - if (onRestartRequest) onRestartRequest(); - } - if (key.escape) { - onSelect(undefined, selectedScope); - } - }); + if (name === 'escape') { + onSelect(undefined, selectedScope); + } + }, + { isActive: true }, + ); return ( = ({ request }) => { const { commands, onConfirm } = request; - useInput((_, key) => { - if (key.escape) { - onConfirm(ToolConfirmationOutcome.Cancel); - } - }); + useKeypress( + (key) => { + if (key.name === 'escape') { + onConfirm(ToolConfirmationOutcome.Cancel); + } + }, + { isActive: true }, + ); const handleSelect = (item: ToolConfirmationOutcome) => { if (item === ToolConfirmationOutcome.Cancel) { diff --git a/packages/cli/src/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx index 37663447..16ecfc8f 100644 --- a/packages/cli/src/ui/components/ThemeDialog.tsx +++ b/packages/cli/src/ui/components/ThemeDialog.tsx @@ -5,7 +5,7 @@ */ import React, { useCallback, useState } from 'react'; -import { Box, Text, useInput } from 'ink'; +import { Box, Text } from 'ink'; import { Colors } from '../colors.js'; import { themeManager, DEFAULT_THEME } from '../themes/theme-manager.js'; import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; @@ -16,6 +16,7 @@ import { getScopeItems, getScopeMessageForSetting, } from '../../utils/dialogScopeUtils.js'; +import { useKeypress } from '../hooks/useKeypress.js'; interface ThemeDialogProps { /** Callback function when a theme is selected */ @@ -111,14 +112,17 @@ export function ThemeDialog({ 'theme', ); - useInput((input, key) => { - if (key.tab) { - setFocusedSection((prev) => (prev === 'theme' ? 'scope' : 'theme')); - } - if (key.escape) { - onSelect(undefined, selectedScope); - } - }); + useKeypress( + (key) => { + if (key.name === 'tab') { + setFocusedSection((prev) => (prev === 'theme' ? 'scope' : 'theme')); + } + if (key.name === 'escape') { + onSelect(undefined, selectedScope); + } + }, + { isActive: true }, + ); // Generate scope message for theme setting const otherScopeModifiedMessage = getScopeMessageForSetting( diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index 88b25b86..a8813491 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -5,7 +5,7 @@ */ import React from 'react'; -import { Box, Text, useInput } from 'ink'; +import { Box, Text } from 'ink'; import { DiffRenderer } from './DiffRenderer.js'; import { Colors } from '../../colors.js'; import { @@ -20,6 +20,7 @@ import { RadioSelectItem, } from '../shared/RadioButtonSelect.js'; import { MaxSizedBox } from '../shared/MaxSizedBox.js'; +import { useKeypress } from '../../hooks/useKeypress.js'; export interface ToolConfirmationMessageProps { confirmationDetails: ToolCallConfirmationDetails; @@ -56,12 +57,15 @@ export const ToolConfirmationMessage: React.FC< onConfirm(outcome); }; - useInput((input, key) => { - if (!isFocused) return; - if (key.escape || (key.ctrl && (input === 'c' || input === 'C'))) { - handleConfirm(ToolConfirmationOutcome.Cancel); - } - }); + useKeypress( + (key) => { + if (!isFocused) return; + if (key.name === 'escape' || (key.ctrl && key.name === 'c')) { + handleConfirm(ToolConfirmationOutcome.Cancel); + } + }, + { isActive: isFocused }, + ); const handleSelect = (item: ToolConfirmationOutcome) => handleConfirm(item); diff --git a/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx b/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx index 8b0057ca..511d3847 100644 --- a/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx +++ b/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx @@ -5,8 +5,9 @@ */ import React, { useEffect, useState, useRef } from 'react'; -import { Text, Box, useInput } from 'ink'; +import { Text, Box } from 'ink'; import { Colors } from '../../colors.js'; +import { useKeypress } from '../../hooks/useKeypress.js'; /** * Represents a single option for the RadioButtonSelect. @@ -85,9 +86,10 @@ export function RadioButtonSelect({ [], ); - useInput( - (input, key) => { - const isNumeric = showNumbers && /^[0-9]$/.test(input); + useKeypress( + (key) => { + const { sequence, name } = key; + const isNumeric = showNumbers && /^[0-9]$/.test(sequence); // Any key press that is not a digit should clear the number input buffer. if (!isNumeric && numberInputTimer.current) { @@ -95,21 +97,21 @@ export function RadioButtonSelect({ setNumberInput(''); } - if (input === 'k' || key.upArrow) { + if (name === 'k' || name === 'up') { const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1; setActiveIndex(newIndex); onHighlight?.(items[newIndex]!.value); return; } - if (input === 'j' || key.downArrow) { + if (name === 'j' || name === 'down') { const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0; setActiveIndex(newIndex); onHighlight?.(items[newIndex]!.value); return; } - if (key.return) { + if (name === 'return') { onSelect(items[activeIndex]!.value); return; } @@ -120,7 +122,7 @@ export function RadioButtonSelect({ clearTimeout(numberInputTimer.current); } - const newNumberInput = numberInput + input; + const newNumberInput = numberInput + sequence; setNumberInput(newNumberInput); const targetIndex = Number.parseInt(newNumberInput, 10) - 1; @@ -154,7 +156,7 @@ export function RadioButtonSelect({ } } }, - { isActive: isFocused && items.length > 0 }, + { isActive: !!(isFocused && items.length > 0) }, ); const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow); diff --git a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts index bda6c259..657d792b 100644 --- a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts +++ b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts @@ -21,9 +21,9 @@ import { Config as ActualConfigType, ApprovalMode, } from '@google/gemini-cli-core'; -import { useInput, type Key as InkKey } from 'ink'; +import { useKeypress, Key } from './useKeypress.js'; -vi.mock('ink'); +vi.mock('./useKeypress.js'); vi.mock('@google/gemini-cli-core', async () => { const actualServerModule = (await vi.importActual( @@ -53,13 +53,12 @@ interface MockConfigInstanceShape { getToolRegistry: Mock<() => { discoverTools: Mock<() => void> }>; } -type UseInputKey = InkKey; -type UseInputHandler = (input: string, key: UseInputKey) => void; +type UseKeypressHandler = (key: Key) => void; describe('useAutoAcceptIndicator', () => { let mockConfigInstance: MockConfigInstanceShape; - let capturedUseInputHandler: UseInputHandler; - let mockedInkUseInput: MockedFunction; + let capturedUseKeypressHandler: UseKeypressHandler; + let mockedUseKeypress: MockedFunction; beforeEach(() => { vi.resetAllMocks(); @@ -111,10 +110,12 @@ describe('useAutoAcceptIndicator', () => { return instance; }); - mockedInkUseInput = useInput as MockedFunction; - mockedInkUseInput.mockImplementation((handler: UseInputHandler) => { - capturedUseInputHandler = handler; - }); + mockedUseKeypress = useKeypress as MockedFunction; + mockedUseKeypress.mockImplementation( + (handler: UseKeypressHandler, _options) => { + capturedUseKeypressHandler = handler; + }, + ); // eslint-disable-next-line @typescript-eslint/no-explicit-any mockConfigInstance = new (Config as any)() as MockConfigInstanceShape; @@ -163,7 +164,10 @@ describe('useAutoAcceptIndicator', () => { expect(result.current).toBe(ApprovalMode.DEFAULT); act(() => { - capturedUseInputHandler('', { tab: true, shift: true } as InkKey); + capturedUseKeypressHandler({ + name: 'tab', + shift: true, + } as Key); }); expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( ApprovalMode.AUTO_EDIT, @@ -171,7 +175,7 @@ describe('useAutoAcceptIndicator', () => { expect(result.current).toBe(ApprovalMode.AUTO_EDIT); act(() => { - capturedUseInputHandler('y', { ctrl: true } as InkKey); + capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key); }); expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( ApprovalMode.YOLO, @@ -179,7 +183,7 @@ describe('useAutoAcceptIndicator', () => { expect(result.current).toBe(ApprovalMode.YOLO); act(() => { - capturedUseInputHandler('y', { ctrl: true } as InkKey); + capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key); }); expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( ApprovalMode.DEFAULT, @@ -187,7 +191,7 @@ describe('useAutoAcceptIndicator', () => { expect(result.current).toBe(ApprovalMode.DEFAULT); act(() => { - capturedUseInputHandler('y', { ctrl: true } as InkKey); + capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key); }); expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( ApprovalMode.YOLO, @@ -195,7 +199,10 @@ describe('useAutoAcceptIndicator', () => { expect(result.current).toBe(ApprovalMode.YOLO); act(() => { - capturedUseInputHandler('', { tab: true, shift: true } as InkKey); + capturedUseKeypressHandler({ + name: 'tab', + shift: true, + } as Key); }); expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( ApprovalMode.AUTO_EDIT, @@ -203,7 +210,10 @@ describe('useAutoAcceptIndicator', () => { expect(result.current).toBe(ApprovalMode.AUTO_EDIT); act(() => { - capturedUseInputHandler('', { tab: true, shift: true } as InkKey); + capturedUseKeypressHandler({ + name: 'tab', + shift: true, + } as Key); }); expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( ApprovalMode.DEFAULT, @@ -220,37 +230,51 @@ describe('useAutoAcceptIndicator', () => { ); act(() => { - capturedUseInputHandler('', { tab: true, shift: false } as InkKey); + capturedUseKeypressHandler({ + name: 'tab', + shift: false, + } as Key); }); expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled(); act(() => { - capturedUseInputHandler('', { tab: false, shift: true } as InkKey); + capturedUseKeypressHandler({ + name: 'unknown', + shift: true, + } as Key); }); expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled(); act(() => { - capturedUseInputHandler('a', { tab: false, shift: false } as InkKey); + capturedUseKeypressHandler({ + name: 'a', + shift: false, + ctrl: false, + } as Key); }); expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled(); act(() => { - capturedUseInputHandler('y', { tab: true } as InkKey); + capturedUseKeypressHandler({ name: 'y', ctrl: false } as Key); }); expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled(); act(() => { - capturedUseInputHandler('a', { ctrl: true } as InkKey); + capturedUseKeypressHandler({ name: 'a', ctrl: true } as Key); }); expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled(); act(() => { - capturedUseInputHandler('y', { shift: true } as InkKey); + capturedUseKeypressHandler({ name: 'y', shift: true } as Key); }); expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled(); act(() => { - capturedUseInputHandler('a', { ctrl: true, shift: true } as InkKey); + capturedUseKeypressHandler({ + name: 'a', + ctrl: true, + shift: true, + } as Key); }); expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled(); }); diff --git a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts index 8af3cea1..2cc16077 100644 --- a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts +++ b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts @@ -5,8 +5,8 @@ */ import { useState, useEffect } from 'react'; -import { useInput } from 'ink'; import { ApprovalMode, type Config } from '@google/gemini-cli-core'; +import { useKeypress } from './useKeypress.js'; export interface UseAutoAcceptIndicatorArgs { config: Config; @@ -23,27 +23,30 @@ export function useAutoAcceptIndicator({ setShowAutoAcceptIndicator(currentConfigValue); }, [currentConfigValue]); - useInput((input, key) => { - let nextApprovalMode: ApprovalMode | undefined; + useKeypress( + (key) => { + let nextApprovalMode: ApprovalMode | undefined; - if (key.ctrl && input === 'y') { - nextApprovalMode = - config.getApprovalMode() === ApprovalMode.YOLO - ? ApprovalMode.DEFAULT - : ApprovalMode.YOLO; - } else if (key.tab && key.shift) { - nextApprovalMode = - config.getApprovalMode() === ApprovalMode.AUTO_EDIT - ? ApprovalMode.DEFAULT - : ApprovalMode.AUTO_EDIT; - } + if (key.ctrl && key.name === 'y') { + nextApprovalMode = + config.getApprovalMode() === ApprovalMode.YOLO + ? ApprovalMode.DEFAULT + : ApprovalMode.YOLO; + } else if (key.shift && key.name === 'tab') { + nextApprovalMode = + config.getApprovalMode() === ApprovalMode.AUTO_EDIT + ? ApprovalMode.DEFAULT + : ApprovalMode.AUTO_EDIT; + } - if (nextApprovalMode) { - config.setApprovalMode(nextApprovalMode); - // Update local state immediately for responsiveness - setShowAutoAcceptIndicator(nextApprovalMode); - } - }); + if (nextApprovalMode) { + config.setApprovalMode(nextApprovalMode); + // Update local state immediately for responsiveness + setShowAutoAcceptIndicator(nextApprovalMode); + } + }, + { isActive: true }, + ); return showAutoAcceptIndicator; } diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 751b869e..37d63e9a 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -8,7 +8,7 @@ import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; import { renderHook, act, waitFor } from '@testing-library/react'; import { useGeminiStream, mergePartListUnions } from './useGeminiStream.js'; -import { useInput } from 'ink'; +import { useKeypress } from './useKeypress.js'; import { useReactToolScheduler, TrackedToolCall, @@ -71,10 +71,9 @@ vi.mock('./useReactToolScheduler.js', async (importOriginal) => { }; }); -vi.mock('ink', async (importOriginal) => { - const actualInkModule = (await importOriginal()) as any; - return { ...(actualInkModule || {}), useInput: vi.fn() }; -}); +vi.mock('./useKeypress.js', () => ({ + useKeypress: vi.fn(), +})); vi.mock('./shellCommandProcessor.js', () => ({ useShellCommandProcessor: vi.fn().mockReturnValue({ @@ -899,19 +898,23 @@ describe('useGeminiStream', () => { }); describe('User Cancellation', () => { - let useInputCallback: (input: string, key: any) => void; - const mockUseInput = useInput as Mock; + let keypressCallback: (key: any) => void; + const mockUseKeypress = useKeypress as Mock; beforeEach(() => { - // Capture the callback passed to useInput - mockUseInput.mockImplementation((callback) => { - useInputCallback = callback; + // Capture the callback passed to useKeypress + mockUseKeypress.mockImplementation((callback, options) => { + if (options.isActive) { + keypressCallback = callback; + } else { + keypressCallback = () => {}; + } }); }); const simulateEscapeKeyPress = () => { act(() => { - useInputCallback('', { escape: true }); + keypressCallback({ name: 'escape' }); }); }; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 6385d267..6f3cb4fd 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -5,7 +5,6 @@ */ import { useState, useRef, useCallback, useEffect, useMemo } from 'react'; -import { useInput } from 'ink'; import { Config, GeminiClient, @@ -55,6 +54,7 @@ import { TrackedCancelledToolCall, } from './useReactToolScheduler.js'; import { useSessionStats } from '../contexts/SessionContext.js'; +import { useKeypress } from './useKeypress.js'; export function mergePartListUnions(list: PartListUnion[]): PartListUnion { const resultParts: PartListUnion = []; @@ -213,11 +213,14 @@ export const useGeminiStream = ( pendingHistoryItemRef, ]); - useInput((_input, key) => { - if (key.escape) { - cancelOngoingRequest(); - } - }); + useKeypress( + (key) => { + if (key.name === 'escape') { + cancelOngoingRequest(); + } + }, + { isActive: streamingState === StreamingState.Responding }, + ); const prepareQueryForGemini = useCallback( async ( diff --git a/packages/cli/src/ui/privacy/CloudFreePrivacyNotice.tsx b/packages/cli/src/ui/privacy/CloudFreePrivacyNotice.tsx index 25e14281..d4c13097 100644 --- a/packages/cli/src/ui/privacy/CloudFreePrivacyNotice.tsx +++ b/packages/cli/src/ui/privacy/CloudFreePrivacyNotice.tsx @@ -4,12 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Box, Newline, Text, useInput } from 'ink'; +import { Box, Newline, Text } from 'ink'; import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; import { usePrivacySettings } from '../hooks/usePrivacySettings.js'; import { CloudPaidPrivacyNotice } from './CloudPaidPrivacyNotice.js'; import { Config } from '@google/gemini-cli-core'; import { Colors } from '../colors.js'; +import { useKeypress } from '../hooks/useKeypress.js'; interface CloudFreePrivacyNoticeProps { config: Config; @@ -23,11 +24,14 @@ export const CloudFreePrivacyNotice = ({ const { privacyState, updateDataCollectionOptIn } = usePrivacySettings(config); - useInput((input, key) => { - if (privacyState.error && key.escape) { - onExit(); - } - }); + useKeypress( + (key) => { + if (privacyState.error && key.name === 'escape') { + onExit(); + } + }, + { isActive: true }, + ); if (privacyState.isLoading) { return Loading...; diff --git a/packages/cli/src/ui/privacy/CloudPaidPrivacyNotice.tsx b/packages/cli/src/ui/privacy/CloudPaidPrivacyNotice.tsx index e50dcd4b..f0adbb68 100644 --- a/packages/cli/src/ui/privacy/CloudPaidPrivacyNotice.tsx +++ b/packages/cli/src/ui/privacy/CloudPaidPrivacyNotice.tsx @@ -4,8 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Box, Newline, Text, useInput } from 'ink'; +import { Box, Newline, Text } from 'ink'; import { Colors } from '../colors.js'; +import { useKeypress } from '../hooks/useKeypress.js'; interface CloudPaidPrivacyNoticeProps { onExit: () => void; @@ -14,11 +15,14 @@ interface CloudPaidPrivacyNoticeProps { export const CloudPaidPrivacyNotice = ({ onExit, }: CloudPaidPrivacyNoticeProps) => { - useInput((input, key) => { - if (key.escape) { - onExit(); - } - }); + useKeypress( + (key) => { + if (key.name === 'escape') { + onExit(); + } + }, + { isActive: true }, + ); return ( diff --git a/packages/cli/src/ui/privacy/GeminiPrivacyNotice.tsx b/packages/cli/src/ui/privacy/GeminiPrivacyNotice.tsx index 57030ac3..c0eaa74f 100644 --- a/packages/cli/src/ui/privacy/GeminiPrivacyNotice.tsx +++ b/packages/cli/src/ui/privacy/GeminiPrivacyNotice.tsx @@ -4,19 +4,23 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Box, Newline, Text, useInput } from 'ink'; +import { Box, Newline, Text } from 'ink'; import { Colors } from '../colors.js'; +import { useKeypress } from '../hooks/useKeypress.js'; interface GeminiPrivacyNoticeProps { onExit: () => void; } export const GeminiPrivacyNotice = ({ onExit }: GeminiPrivacyNoticeProps) => { - useInput((input, key) => { - if (key.escape) { - onExit(); - } - }); + useKeypress( + (key) => { + if (key.name === 'escape') { + onExit(); + } + }, + { isActive: true }, + ); return (