From d219f9013206aad5a1361e436ad4a45114e9cd49 Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Tue, 12 Aug 2025 14:05:49 -0700 Subject: Switch from useInput to useKeypress. (#6056) --- packages/cli/src/ui/components/AuthDialog.tsx | 38 ++-- packages/cli/src/ui/components/AuthInProgress.tsx | 16 +- packages/cli/src/ui/components/DebugProfiler.tsx | 16 +- .../cli/src/ui/components/EditorSettingsDialog.tsx | 22 ++- .../src/ui/components/FolderTrustDialog.test.tsx | 9 +- .../cli/src/ui/components/FolderTrustDialog.tsx | 16 +- packages/cli/src/ui/components/SettingsDialog.tsx | 197 +++++++++++---------- .../src/ui/components/ShellConfirmationDialog.tsx | 16 +- packages/cli/src/ui/components/ThemeDialog.tsx | 22 ++- .../messages/ToolConfirmationMessage.tsx | 18 +- .../src/ui/components/shared/RadioButtonSelect.tsx | 20 ++- 11 files changed, 216 insertions(+), 174 deletions(-) (limited to 'packages/cli/src/ui/components') 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; - - // 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, - {}, + 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, + ), ); - 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 (restartRequiredSet.size > 0) { - saveModifiedSettings( - restartRequiredSet, - pendingSettings, - settings, - selectedScope, - ); - } + 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, + ); + } - setShowRestartPrompt(false); - setRestartRequiredSettings(new Set()); // Clear restart-required settings - if (onRestartRequest) onRestartRequest(); - } - if (key.escape) { - onSelect(undefined, selectedScope); - } - }); + setShowRestartPrompt(false); + setRestartRequiredSettings(new Set()); // Clear restart-required settings + if (onRestartRequest) onRestartRequest(); + } + 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); -- cgit v1.2.3