Switch from useInput to useKeypress. (#6056)

This commit is contained in:
Jacob Richman 2025-08-12 14:05:49 -07:00 committed by GitHub
parent 74fd0841d0
commit d219f90132
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 350 additions and 259 deletions

View File

@ -5,11 +5,12 @@
*/ */
import { DetectedIde, getIdeInfo } from '@google/gemini-cli-core'; import { DetectedIde, getIdeInfo } from '@google/gemini-cli-core';
import { Box, Text, useInput } from 'ink'; import { Box, Text } from 'ink';
import { import {
RadioButtonSelect, RadioButtonSelect,
RadioSelectItem, RadioSelectItem,
} from './components/shared/RadioButtonSelect.js'; } from './components/shared/RadioButtonSelect.js';
import { useKeypress } from './hooks/useKeypress.js';
export type IdeIntegrationNudgeResult = { export type IdeIntegrationNudgeResult = {
userSelection: 'yes' | 'no' | 'dismiss'; userSelection: 'yes' | 'no' | 'dismiss';
@ -25,14 +26,17 @@ export function IdeIntegrationNudge({
ide, ide,
onComplete, onComplete,
}: IdeIntegrationNudgeProps) { }: IdeIntegrationNudgeProps) {
useInput((_input, key) => { useKeypress(
if (key.escape) { (key) => {
if (key.name === 'escape') {
onComplete({ onComplete({
userSelection: 'no', userSelection: 'no',
isExtensionPreInstalled: false, isExtensionPreInstalled: false,
}); });
} }
}); },
{ isActive: true },
);
const { displayName: ideName } = getIdeInfo(ide); const { displayName: ideName } = getIdeInfo(ide);
// Assume extension is already installed if the env variables are set. // Assume extension is already installed if the env variables are set.

View File

@ -5,12 +5,13 @@
*/ */
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Box, Text, useInput } from 'ink'; import { Box, Text } from 'ink';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js'; import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { AuthType } from '@google/gemini-cli-core'; import { AuthType } from '@google/gemini-cli-core';
import { validateAuthMethod } from '../../config/auth.js'; import { validateAuthMethod } from '../../config/auth.js';
import { useKeypress } from '../hooks/useKeypress.js';
interface AuthDialogProps { interface AuthDialogProps {
onSelect: (authMethod: AuthType | undefined, scope: SettingScope) => void; onSelect: (authMethod: AuthType | undefined, scope: SettingScope) => void;
@ -108,8 +109,9 @@ export function AuthDialog({
} }
}; };
useInput((_input, key) => { useKeypress(
if (key.escape) { (key) => {
if (key.name === 'escape') {
// Prevent exit if there is an error message. // Prevent exit if there is an error message.
// This means they user is not authenticated yet. // This means they user is not authenticated yet.
if (errorMessage) { if (errorMessage) {
@ -124,7 +126,9 @@ export function AuthDialog({
} }
onSelect(undefined, SettingScope.User); onSelect(undefined, SettingScope.User);
} }
}); },
{ isActive: true },
);
return ( return (
<Box <Box

View File

@ -5,9 +5,10 @@
*/ */
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Box, Text, useInput } from 'ink'; import { Box, Text } from 'ink';
import Spinner from 'ink-spinner'; import Spinner from 'ink-spinner';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
interface AuthInProgressProps { interface AuthInProgressProps {
onTimeout: () => void; onTimeout: () => void;
@ -18,11 +19,14 @@ export function AuthInProgress({
}: AuthInProgressProps): React.JSX.Element { }: AuthInProgressProps): React.JSX.Element {
const [timedOut, setTimedOut] = useState(false); const [timedOut, setTimedOut] = useState(false);
useInput((input, key) => { useKeypress(
if (key.escape || (key.ctrl && (input === 'c' || input === 'C'))) { (key) => {
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
onTimeout(); onTimeout();
} }
}); },
{ isActive: true },
);
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {

View File

@ -4,9 +4,10 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { Text, useInput } from 'ink'; import { Text } from 'ink';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
export const DebugProfiler = () => { export const DebugProfiler = () => {
const numRenders = useRef(0); const numRenders = useRef(0);
@ -16,11 +17,14 @@ export const DebugProfiler = () => {
numRenders.current++; numRenders.current++;
}); });
useInput((input, key) => { useKeypress(
if (key.ctrl && input === 'b') { (key) => {
if (key.ctrl && key.name === 'b') {
setShowNumRenders((prev) => !prev); setShowNumRenders((prev) => !prev);
} }
}); },
{ isActive: true },
);
if (!showNumRenders) { if (!showNumRenders) {
return null; return null;

View File

@ -5,7 +5,7 @@
*/ */
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Box, Text, useInput } from 'ink'; import { Box, Text } from 'ink';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import { import {
EDITOR_DISPLAY_NAMES, EDITOR_DISPLAY_NAMES,
@ -15,6 +15,7 @@ import {
import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js'; import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { EditorType, isEditorAvailable } from '@google/gemini-cli-core'; import { EditorType, isEditorAvailable } from '@google/gemini-cli-core';
import { useKeypress } from '../hooks/useKeypress.js';
interface EditorDialogProps { interface EditorDialogProps {
onSelect: (editorType: EditorType | undefined, scope: SettingScope) => void; onSelect: (editorType: EditorType | undefined, scope: SettingScope) => void;
@ -33,14 +34,17 @@ export function EditorSettingsDialog({
const [focusedSection, setFocusedSection] = useState<'editor' | 'scope'>( const [focusedSection, setFocusedSection] = useState<'editor' | 'scope'>(
'editor', 'editor',
); );
useInput((_, key) => { useKeypress(
if (key.tab) { (key) => {
if (key.name === 'tab') {
setFocusedSection((prev) => (prev === 'editor' ? 'scope' : 'editor')); setFocusedSection((prev) => (prev === 'editor' ? 'scope' : 'editor'));
} }
if (key.escape) { if (key.name === 'escape') {
onExit(); onExit();
} }
}); },
{ isActive: true },
);
const editorItems: EditorDisplay[] = const editorItems: EditorDisplay[] =
editorSettingsManager.getAvailableEditorDisplays(); editorSettingsManager.getAvailableEditorDisplays();

View File

@ -5,6 +5,7 @@
*/ */
import { render } from 'ink-testing-library'; import { render } from 'ink-testing-library';
import { waitFor } from '@testing-library/react';
import { vi } from 'vitest'; import { vi } from 'vitest';
import { FolderTrustDialog, FolderTrustChoice } from './FolderTrustDialog.js'; 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 onSelect = vi.fn();
const { stdin } = render(<FolderTrustDialog onSelect={onSelect} />); const { stdin } = render(<FolderTrustDialog onSelect={onSelect} />);
stdin.write('\u001B'); // Simulate escape key stdin.write('\x1b');
await waitFor(() => {
expect(onSelect).toHaveBeenCalledWith(FolderTrustChoice.DO_NOT_TRUST); expect(onSelect).toHaveBeenCalledWith(FolderTrustChoice.DO_NOT_TRUST);
}); });
});
}); });

View File

@ -4,13 +4,14 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { Box, Text, useInput } from 'ink'; import { Box, Text } from 'ink';
import React from 'react'; import React from 'react';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import { import {
RadioButtonSelect, RadioButtonSelect,
RadioSelectItem, RadioSelectItem,
} from './shared/RadioButtonSelect.js'; } from './shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
export enum FolderTrustChoice { export enum FolderTrustChoice {
TRUST_FOLDER = 'trust_folder', TRUST_FOLDER = 'trust_folder',
@ -25,11 +26,14 @@ interface FolderTrustDialogProps {
export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
onSelect, onSelect,
}) => { }) => {
useInput((_, key) => { useKeypress(
if (key.escape) { (key) => {
if (key.name === 'escape') {
onSelect(FolderTrustChoice.DO_NOT_TRUST); onSelect(FolderTrustChoice.DO_NOT_TRUST);
} }
}); },
{ isActive: true },
);
const options: Array<RadioSelectItem<FolderTrustChoice>> = [ const options: Array<RadioSelectItem<FolderTrustChoice>> = [
{ {

View File

@ -5,7 +5,7 @@
*/ */
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Box, Text, useInput } from 'ink'; import { Box, Text } from 'ink';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import { import {
LoadedSettings, LoadedSettings,
@ -31,6 +31,7 @@ import {
getDefaultValue, getDefaultValue,
} from '../../utils/settingsUtils.js'; } from '../../utils/settingsUtils.js';
import { useVimMode } from '../contexts/VimModeContext.js'; import { useVimMode } from '../contexts/VimModeContext.js';
import { useKeypress } from '../hooks/useKeypress.js';
interface SettingsDialogProps { interface SettingsDialogProps {
settings: LoadedSettings; settings: LoadedSettings;
@ -256,12 +257,14 @@ export function SettingsDialog({
const showScrollUp = true; const showScrollUp = true;
const showScrollDown = true; const showScrollDown = true;
useInput((input, key) => { useKeypress(
if (key.tab) { (key) => {
const { name, ctrl } = key;
if (name === 'tab') {
setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings')); setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings'));
} }
if (focusSection === 'settings') { if (focusSection === 'settings') {
if (key.upArrow || input === 'k') { if (name === 'up' || name === 'k') {
const newIndex = const newIndex =
activeSettingIndex > 0 ? activeSettingIndex - 1 : items.length - 1; activeSettingIndex > 0 ? activeSettingIndex - 1 : items.length - 1;
setActiveSettingIndex(newIndex); setActiveSettingIndex(newIndex);
@ -271,7 +274,7 @@ export function SettingsDialog({
} else if (newIndex < scrollOffset) { } else if (newIndex < scrollOffset) {
setScrollOffset(newIndex); setScrollOffset(newIndex);
} }
} else if (key.downArrow || input === 'j') { } else if (name === 'down' || name === 'j') {
const newIndex = const newIndex =
activeSettingIndex < items.length - 1 ? activeSettingIndex + 1 : 0; activeSettingIndex < items.length - 1 ? activeSettingIndex + 1 : 0;
setActiveSettingIndex(newIndex); setActiveSettingIndex(newIndex);
@ -281,9 +284,9 @@ export function SettingsDialog({
} else if (newIndex >= scrollOffset + maxItemsToShow) { } else if (newIndex >= scrollOffset + maxItemsToShow) {
setScrollOffset(newIndex - maxItemsToShow + 1); setScrollOffset(newIndex - maxItemsToShow + 1);
} }
} else if (key.return || input === ' ') { } else if (name === 'return' || name === 'space') {
items[activeSettingIndex]?.toggle(); items[activeSettingIndex]?.toggle();
} else if ((key.ctrl && input === 'c') || (key.ctrl && input === 'l')) { } else if (ctrl && (name === 'c' || name === 'l')) {
// Ctrl+C or Ctrl+L: Clear current setting and reset to default // Ctrl+C or Ctrl+L: Clear current setting and reset to default
const currentSetting = items[activeSettingIndex]; const currentSetting = items[activeSettingIndex];
if (currentSetting) { if (currentSetting) {
@ -334,7 +337,7 @@ export function SettingsDialog({
} }
} }
} }
if (showRestartPrompt && input === 'r') { if (showRestartPrompt && name === 'r') {
// Only save settings that require restart (non-restart settings were already saved immediately) // Only save settings that require restart (non-restart settings were already saved immediately)
const restartRequiredSettings = const restartRequiredSettings =
getRestartRequiredFromModified(modifiedSettings); getRestartRequiredFromModified(modifiedSettings);
@ -353,10 +356,12 @@ export function SettingsDialog({
setRestartRequiredSettings(new Set()); // Clear restart-required settings setRestartRequiredSettings(new Set()); // Clear restart-required settings
if (onRestartRequest) onRestartRequest(); if (onRestartRequest) onRestartRequest();
} }
if (key.escape) { if (name === 'escape') {
onSelect(undefined, selectedScope); onSelect(undefined, selectedScope);
} }
}); },
{ isActive: true },
);
return ( return (
<Box <Box

View File

@ -5,13 +5,14 @@
*/ */
import { ToolConfirmationOutcome } from '@google/gemini-cli-core'; import { ToolConfirmationOutcome } from '@google/gemini-cli-core';
import { Box, Text, useInput } from 'ink'; import { Box, Text } from 'ink';
import React from 'react'; import React from 'react';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import { import {
RadioButtonSelect, RadioButtonSelect,
RadioSelectItem, RadioSelectItem,
} from './shared/RadioButtonSelect.js'; } from './shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
export interface ShellConfirmationRequest { export interface ShellConfirmationRequest {
commands: string[]; commands: string[];
@ -30,11 +31,14 @@ export const ShellConfirmationDialog: React.FC<
> = ({ request }) => { > = ({ request }) => {
const { commands, onConfirm } = request; const { commands, onConfirm } = request;
useInput((_, key) => { useKeypress(
if (key.escape) { (key) => {
if (key.name === 'escape') {
onConfirm(ToolConfirmationOutcome.Cancel); onConfirm(ToolConfirmationOutcome.Cancel);
} }
}); },
{ isActive: true },
);
const handleSelect = (item: ToolConfirmationOutcome) => { const handleSelect = (item: ToolConfirmationOutcome) => {
if (item === ToolConfirmationOutcome.Cancel) { if (item === ToolConfirmationOutcome.Cancel) {

View File

@ -5,7 +5,7 @@
*/ */
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { Box, Text, useInput } from 'ink'; import { Box, Text } from 'ink';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import { themeManager, DEFAULT_THEME } from '../themes/theme-manager.js'; import { themeManager, DEFAULT_THEME } from '../themes/theme-manager.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
@ -16,6 +16,7 @@ import {
getScopeItems, getScopeItems,
getScopeMessageForSetting, getScopeMessageForSetting,
} from '../../utils/dialogScopeUtils.js'; } from '../../utils/dialogScopeUtils.js';
import { useKeypress } from '../hooks/useKeypress.js';
interface ThemeDialogProps { interface ThemeDialogProps {
/** Callback function when a theme is selected */ /** Callback function when a theme is selected */
@ -111,14 +112,17 @@ export function ThemeDialog({
'theme', 'theme',
); );
useInput((input, key) => { useKeypress(
if (key.tab) { (key) => {
if (key.name === 'tab') {
setFocusedSection((prev) => (prev === 'theme' ? 'scope' : 'theme')); setFocusedSection((prev) => (prev === 'theme' ? 'scope' : 'theme'));
} }
if (key.escape) { if (key.name === 'escape') {
onSelect(undefined, selectedScope); onSelect(undefined, selectedScope);
} }
}); },
{ isActive: true },
);
// Generate scope message for theme setting // Generate scope message for theme setting
const otherScopeModifiedMessage = getScopeMessageForSetting( const otherScopeModifiedMessage = getScopeMessageForSetting(

View File

@ -5,7 +5,7 @@
*/ */
import React from 'react'; import React from 'react';
import { Box, Text, useInput } from 'ink'; import { Box, Text } from 'ink';
import { DiffRenderer } from './DiffRenderer.js'; import { DiffRenderer } from './DiffRenderer.js';
import { Colors } from '../../colors.js'; import { Colors } from '../../colors.js';
import { import {
@ -20,6 +20,7 @@ import {
RadioSelectItem, RadioSelectItem,
} from '../shared/RadioButtonSelect.js'; } from '../shared/RadioButtonSelect.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js'; import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { useKeypress } from '../../hooks/useKeypress.js';
export interface ToolConfirmationMessageProps { export interface ToolConfirmationMessageProps {
confirmationDetails: ToolCallConfirmationDetails; confirmationDetails: ToolCallConfirmationDetails;
@ -56,12 +57,15 @@ export const ToolConfirmationMessage: React.FC<
onConfirm(outcome); onConfirm(outcome);
}; };
useInput((input, key) => { useKeypress(
(key) => {
if (!isFocused) return; if (!isFocused) return;
if (key.escape || (key.ctrl && (input === 'c' || input === 'C'))) { if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
handleConfirm(ToolConfirmationOutcome.Cancel); handleConfirm(ToolConfirmationOutcome.Cancel);
} }
}); },
{ isActive: isFocused },
);
const handleSelect = (item: ToolConfirmationOutcome) => handleConfirm(item); const handleSelect = (item: ToolConfirmationOutcome) => handleConfirm(item);

View File

@ -5,8 +5,9 @@
*/ */
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import { Text, Box, useInput } from 'ink'; import { Text, Box } from 'ink';
import { Colors } from '../../colors.js'; import { Colors } from '../../colors.js';
import { useKeypress } from '../../hooks/useKeypress.js';
/** /**
* Represents a single option for the RadioButtonSelect. * Represents a single option for the RadioButtonSelect.
@ -85,9 +86,10 @@ export function RadioButtonSelect<T>({
[], [],
); );
useInput( useKeypress(
(input, key) => { (key) => {
const isNumeric = showNumbers && /^[0-9]$/.test(input); 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. // Any key press that is not a digit should clear the number input buffer.
if (!isNumeric && numberInputTimer.current) { if (!isNumeric && numberInputTimer.current) {
@ -95,21 +97,21 @@ export function RadioButtonSelect<T>({
setNumberInput(''); setNumberInput('');
} }
if (input === 'k' || key.upArrow) { if (name === 'k' || name === 'up') {
const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1; const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
setActiveIndex(newIndex); setActiveIndex(newIndex);
onHighlight?.(items[newIndex]!.value); onHighlight?.(items[newIndex]!.value);
return; return;
} }
if (input === 'j' || key.downArrow) { if (name === 'j' || name === 'down') {
const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0; const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
setActiveIndex(newIndex); setActiveIndex(newIndex);
onHighlight?.(items[newIndex]!.value); onHighlight?.(items[newIndex]!.value);
return; return;
} }
if (key.return) { if (name === 'return') {
onSelect(items[activeIndex]!.value); onSelect(items[activeIndex]!.value);
return; return;
} }
@ -120,7 +122,7 @@ export function RadioButtonSelect<T>({
clearTimeout(numberInputTimer.current); clearTimeout(numberInputTimer.current);
} }
const newNumberInput = numberInput + input; const newNumberInput = numberInput + sequence;
setNumberInput(newNumberInput); setNumberInput(newNumberInput);
const targetIndex = Number.parseInt(newNumberInput, 10) - 1; const targetIndex = Number.parseInt(newNumberInput, 10) - 1;
@ -154,7 +156,7 @@ export function RadioButtonSelect<T>({
} }
} }
}, },
{ isActive: isFocused && items.length > 0 }, { isActive: !!(isFocused && items.length > 0) },
); );
const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow); const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);

View File

@ -21,9 +21,9 @@ import {
Config as ActualConfigType, Config as ActualConfigType,
ApprovalMode, ApprovalMode,
} from '@google/gemini-cli-core'; } 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 () => { vi.mock('@google/gemini-cli-core', async () => {
const actualServerModule = (await vi.importActual( const actualServerModule = (await vi.importActual(
@ -53,13 +53,12 @@ interface MockConfigInstanceShape {
getToolRegistry: Mock<() => { discoverTools: Mock<() => void> }>; getToolRegistry: Mock<() => { discoverTools: Mock<() => void> }>;
} }
type UseInputKey = InkKey; type UseKeypressHandler = (key: Key) => void;
type UseInputHandler = (input: string, key: UseInputKey) => void;
describe('useAutoAcceptIndicator', () => { describe('useAutoAcceptIndicator', () => {
let mockConfigInstance: MockConfigInstanceShape; let mockConfigInstance: MockConfigInstanceShape;
let capturedUseInputHandler: UseInputHandler; let capturedUseKeypressHandler: UseKeypressHandler;
let mockedInkUseInput: MockedFunction<typeof useInput>; let mockedUseKeypress: MockedFunction<typeof useKeypress>;
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
@ -111,10 +110,12 @@ describe('useAutoAcceptIndicator', () => {
return instance; return instance;
}); });
mockedInkUseInput = useInput as MockedFunction<typeof useInput>; mockedUseKeypress = useKeypress as MockedFunction<typeof useKeypress>;
mockedInkUseInput.mockImplementation((handler: UseInputHandler) => { mockedUseKeypress.mockImplementation(
capturedUseInputHandler = handler; (handler: UseKeypressHandler, _options) => {
}); capturedUseKeypressHandler = handler;
},
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
mockConfigInstance = new (Config as any)() as MockConfigInstanceShape; mockConfigInstance = new (Config as any)() as MockConfigInstanceShape;
@ -163,7 +164,10 @@ describe('useAutoAcceptIndicator', () => {
expect(result.current).toBe(ApprovalMode.DEFAULT); expect(result.current).toBe(ApprovalMode.DEFAULT);
act(() => { act(() => {
capturedUseInputHandler('', { tab: true, shift: true } as InkKey); capturedUseKeypressHandler({
name: 'tab',
shift: true,
} as Key);
}); });
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.AUTO_EDIT, ApprovalMode.AUTO_EDIT,
@ -171,7 +175,7 @@ describe('useAutoAcceptIndicator', () => {
expect(result.current).toBe(ApprovalMode.AUTO_EDIT); expect(result.current).toBe(ApprovalMode.AUTO_EDIT);
act(() => { act(() => {
capturedUseInputHandler('y', { ctrl: true } as InkKey); capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key);
}); });
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.YOLO, ApprovalMode.YOLO,
@ -179,7 +183,7 @@ describe('useAutoAcceptIndicator', () => {
expect(result.current).toBe(ApprovalMode.YOLO); expect(result.current).toBe(ApprovalMode.YOLO);
act(() => { act(() => {
capturedUseInputHandler('y', { ctrl: true } as InkKey); capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key);
}); });
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.DEFAULT, ApprovalMode.DEFAULT,
@ -187,7 +191,7 @@ describe('useAutoAcceptIndicator', () => {
expect(result.current).toBe(ApprovalMode.DEFAULT); expect(result.current).toBe(ApprovalMode.DEFAULT);
act(() => { act(() => {
capturedUseInputHandler('y', { ctrl: true } as InkKey); capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key);
}); });
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.YOLO, ApprovalMode.YOLO,
@ -195,7 +199,10 @@ describe('useAutoAcceptIndicator', () => {
expect(result.current).toBe(ApprovalMode.YOLO); expect(result.current).toBe(ApprovalMode.YOLO);
act(() => { act(() => {
capturedUseInputHandler('', { tab: true, shift: true } as InkKey); capturedUseKeypressHandler({
name: 'tab',
shift: true,
} as Key);
}); });
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.AUTO_EDIT, ApprovalMode.AUTO_EDIT,
@ -203,7 +210,10 @@ describe('useAutoAcceptIndicator', () => {
expect(result.current).toBe(ApprovalMode.AUTO_EDIT); expect(result.current).toBe(ApprovalMode.AUTO_EDIT);
act(() => { act(() => {
capturedUseInputHandler('', { tab: true, shift: true } as InkKey); capturedUseKeypressHandler({
name: 'tab',
shift: true,
} as Key);
}); });
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.DEFAULT, ApprovalMode.DEFAULT,
@ -220,37 +230,51 @@ describe('useAutoAcceptIndicator', () => {
); );
act(() => { act(() => {
capturedUseInputHandler('', { tab: true, shift: false } as InkKey); capturedUseKeypressHandler({
name: 'tab',
shift: false,
} as Key);
}); });
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled(); expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
act(() => { act(() => {
capturedUseInputHandler('', { tab: false, shift: true } as InkKey); capturedUseKeypressHandler({
name: 'unknown',
shift: true,
} as Key);
}); });
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled(); expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
act(() => { act(() => {
capturedUseInputHandler('a', { tab: false, shift: false } as InkKey); capturedUseKeypressHandler({
name: 'a',
shift: false,
ctrl: false,
} as Key);
}); });
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled(); expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
act(() => { act(() => {
capturedUseInputHandler('y', { tab: true } as InkKey); capturedUseKeypressHandler({ name: 'y', ctrl: false } as Key);
}); });
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled(); expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
act(() => { act(() => {
capturedUseInputHandler('a', { ctrl: true } as InkKey); capturedUseKeypressHandler({ name: 'a', ctrl: true } as Key);
}); });
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled(); expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
act(() => { act(() => {
capturedUseInputHandler('y', { shift: true } as InkKey); capturedUseKeypressHandler({ name: 'y', shift: true } as Key);
}); });
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled(); expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
act(() => { act(() => {
capturedUseInputHandler('a', { ctrl: true, shift: true } as InkKey); capturedUseKeypressHandler({
name: 'a',
ctrl: true,
shift: true,
} as Key);
}); });
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled(); expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
}); });

View File

@ -5,8 +5,8 @@
*/ */
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useInput } from 'ink';
import { ApprovalMode, type Config } from '@google/gemini-cli-core'; import { ApprovalMode, type Config } from '@google/gemini-cli-core';
import { useKeypress } from './useKeypress.js';
export interface UseAutoAcceptIndicatorArgs { export interface UseAutoAcceptIndicatorArgs {
config: Config; config: Config;
@ -23,15 +23,16 @@ export function useAutoAcceptIndicator({
setShowAutoAcceptIndicator(currentConfigValue); setShowAutoAcceptIndicator(currentConfigValue);
}, [currentConfigValue]); }, [currentConfigValue]);
useInput((input, key) => { useKeypress(
(key) => {
let nextApprovalMode: ApprovalMode | undefined; let nextApprovalMode: ApprovalMode | undefined;
if (key.ctrl && input === 'y') { if (key.ctrl && key.name === 'y') {
nextApprovalMode = nextApprovalMode =
config.getApprovalMode() === ApprovalMode.YOLO config.getApprovalMode() === ApprovalMode.YOLO
? ApprovalMode.DEFAULT ? ApprovalMode.DEFAULT
: ApprovalMode.YOLO; : ApprovalMode.YOLO;
} else if (key.tab && key.shift) { } else if (key.shift && key.name === 'tab') {
nextApprovalMode = nextApprovalMode =
config.getApprovalMode() === ApprovalMode.AUTO_EDIT config.getApprovalMode() === ApprovalMode.AUTO_EDIT
? ApprovalMode.DEFAULT ? ApprovalMode.DEFAULT
@ -43,7 +44,9 @@ export function useAutoAcceptIndicator({
// Update local state immediately for responsiveness // Update local state immediately for responsiveness
setShowAutoAcceptIndicator(nextApprovalMode); setShowAutoAcceptIndicator(nextApprovalMode);
} }
}); },
{ isActive: true },
);
return showAutoAcceptIndicator; return showAutoAcceptIndicator;
} }

View File

@ -8,7 +8,7 @@
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react'; import { renderHook, act, waitFor } from '@testing-library/react';
import { useGeminiStream, mergePartListUnions } from './useGeminiStream.js'; import { useGeminiStream, mergePartListUnions } from './useGeminiStream.js';
import { useInput } from 'ink'; import { useKeypress } from './useKeypress.js';
import { import {
useReactToolScheduler, useReactToolScheduler,
TrackedToolCall, TrackedToolCall,
@ -71,10 +71,9 @@ vi.mock('./useReactToolScheduler.js', async (importOriginal) => {
}; };
}); });
vi.mock('ink', async (importOriginal) => { vi.mock('./useKeypress.js', () => ({
const actualInkModule = (await importOriginal()) as any; useKeypress: vi.fn(),
return { ...(actualInkModule || {}), useInput: vi.fn() }; }));
});
vi.mock('./shellCommandProcessor.js', () => ({ vi.mock('./shellCommandProcessor.js', () => ({
useShellCommandProcessor: vi.fn().mockReturnValue({ useShellCommandProcessor: vi.fn().mockReturnValue({
@ -899,19 +898,23 @@ describe('useGeminiStream', () => {
}); });
describe('User Cancellation', () => { describe('User Cancellation', () => {
let useInputCallback: (input: string, key: any) => void; let keypressCallback: (key: any) => void;
const mockUseInput = useInput as Mock; const mockUseKeypress = useKeypress as Mock;
beforeEach(() => { beforeEach(() => {
// Capture the callback passed to useInput // Capture the callback passed to useKeypress
mockUseInput.mockImplementation((callback) => { mockUseKeypress.mockImplementation((callback, options) => {
useInputCallback = callback; if (options.isActive) {
keypressCallback = callback;
} else {
keypressCallback = () => {};
}
}); });
}); });
const simulateEscapeKeyPress = () => { const simulateEscapeKeyPress = () => {
act(() => { act(() => {
useInputCallback('', { escape: true }); keypressCallback({ name: 'escape' });
}); });
}; };

View File

@ -5,7 +5,6 @@
*/ */
import { useState, useRef, useCallback, useEffect, useMemo } from 'react'; import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { useInput } from 'ink';
import { import {
Config, Config,
GeminiClient, GeminiClient,
@ -55,6 +54,7 @@ import {
TrackedCancelledToolCall, TrackedCancelledToolCall,
} from './useReactToolScheduler.js'; } from './useReactToolScheduler.js';
import { useSessionStats } from '../contexts/SessionContext.js'; import { useSessionStats } from '../contexts/SessionContext.js';
import { useKeypress } from './useKeypress.js';
export function mergePartListUnions(list: PartListUnion[]): PartListUnion { export function mergePartListUnions(list: PartListUnion[]): PartListUnion {
const resultParts: PartListUnion = []; const resultParts: PartListUnion = [];
@ -213,11 +213,14 @@ export const useGeminiStream = (
pendingHistoryItemRef, pendingHistoryItemRef,
]); ]);
useInput((_input, key) => { useKeypress(
if (key.escape) { (key) => {
if (key.name === 'escape') {
cancelOngoingRequest(); cancelOngoingRequest();
} }
}); },
{ isActive: streamingState === StreamingState.Responding },
);
const prepareQueryForGemini = useCallback( const prepareQueryForGemini = useCallback(
async ( async (

View File

@ -4,12 +4,13 @@
* SPDX-License-Identifier: Apache-2.0 * 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 { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
import { usePrivacySettings } from '../hooks/usePrivacySettings.js'; import { usePrivacySettings } from '../hooks/usePrivacySettings.js';
import { CloudPaidPrivacyNotice } from './CloudPaidPrivacyNotice.js'; import { CloudPaidPrivacyNotice } from './CloudPaidPrivacyNotice.js';
import { Config } from '@google/gemini-cli-core'; import { Config } from '@google/gemini-cli-core';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
interface CloudFreePrivacyNoticeProps { interface CloudFreePrivacyNoticeProps {
config: Config; config: Config;
@ -23,11 +24,14 @@ export const CloudFreePrivacyNotice = ({
const { privacyState, updateDataCollectionOptIn } = const { privacyState, updateDataCollectionOptIn } =
usePrivacySettings(config); usePrivacySettings(config);
useInput((input, key) => { useKeypress(
if (privacyState.error && key.escape) { (key) => {
if (privacyState.error && key.name === 'escape') {
onExit(); onExit();
} }
}); },
{ isActive: true },
);
if (privacyState.isLoading) { if (privacyState.isLoading) {
return <Text color={Colors.Gray}>Loading...</Text>; return <Text color={Colors.Gray}>Loading...</Text>;

View File

@ -4,8 +4,9 @@
* SPDX-License-Identifier: Apache-2.0 * 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 { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
interface CloudPaidPrivacyNoticeProps { interface CloudPaidPrivacyNoticeProps {
onExit: () => void; onExit: () => void;
@ -14,11 +15,14 @@ interface CloudPaidPrivacyNoticeProps {
export const CloudPaidPrivacyNotice = ({ export const CloudPaidPrivacyNotice = ({
onExit, onExit,
}: CloudPaidPrivacyNoticeProps) => { }: CloudPaidPrivacyNoticeProps) => {
useInput((input, key) => { useKeypress(
if (key.escape) { (key) => {
if (key.name === 'escape') {
onExit(); onExit();
} }
}); },
{ isActive: true },
);
return ( return (
<Box flexDirection="column" marginBottom={1}> <Box flexDirection="column" marginBottom={1}>

View File

@ -4,19 +4,23 @@
* SPDX-License-Identifier: Apache-2.0 * 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 { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
interface GeminiPrivacyNoticeProps { interface GeminiPrivacyNoticeProps {
onExit: () => void; onExit: () => void;
} }
export const GeminiPrivacyNotice = ({ onExit }: GeminiPrivacyNoticeProps) => { export const GeminiPrivacyNotice = ({ onExit }: GeminiPrivacyNoticeProps) => {
useInput((input, key) => { useKeypress(
if (key.escape) { (key) => {
if (key.name === 'escape') {
onExit(); onExit();
} }
}); },
{ isActive: true },
);
return ( return (
<Box flexDirection="column" marginBottom={1}> <Box flexDirection="column" marginBottom={1}>