summaryrefslogtreecommitdiff
path: root/packages/cli/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src')
-rw-r--r--packages/cli/src/ui/IdeIntegrationNudge.tsx22
-rw-r--r--packages/cli/src/ui/components/AuthDialog.tsx38
-rw-r--r--packages/cli/src/ui/components/AuthInProgress.tsx16
-rw-r--r--packages/cli/src/ui/components/DebugProfiler.tsx16
-rw-r--r--packages/cli/src/ui/components/EditorSettingsDialog.tsx22
-rw-r--r--packages/cli/src/ui/components/FolderTrustDialog.test.tsx9
-rw-r--r--packages/cli/src/ui/components/FolderTrustDialog.tsx16
-rw-r--r--packages/cli/src/ui/components/SettingsDialog.tsx189
-rw-r--r--packages/cli/src/ui/components/ShellConfirmationDialog.tsx16
-rw-r--r--packages/cli/src/ui/components/ThemeDialog.tsx22
-rw-r--r--packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx18
-rw-r--r--packages/cli/src/ui/components/shared/RadioButtonSelect.tsx20
-rw-r--r--packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts70
-rw-r--r--packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts43
-rw-r--r--packages/cli/src/ui/hooks/useGeminiStream.test.tsx25
-rw-r--r--packages/cli/src/ui/hooks/useGeminiStream.ts15
-rw-r--r--packages/cli/src/ui/privacy/CloudFreePrivacyNotice.tsx16
-rw-r--r--packages/cli/src/ui/privacy/CloudPaidPrivacyNotice.tsx16
-rw-r--r--packages/cli/src/ui/privacy/GeminiPrivacyNotice.tsx16
19 files changed, 348 insertions, 257 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 (
<Box
diff --git a/packages/cli/src/ui/components/AuthInProgress.tsx b/packages/cli/src/ui/components/AuthInProgress.tsx
index f05efe1d..53377c7c 100644
--- a/packages/cli/src/ui/components/AuthInProgress.tsx
+++ b/packages/cli/src/ui/components/AuthInProgress.tsx
@@ -5,9 +5,10 @@
*/
import React, { useState, useEffect } from 'react';
-import { Box, Text, useInput } from 'ink';
+import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import { Colors } from '../colors.js';
+import { useKeypress } from '../hooks/useKeypress.js';
interface AuthInProgressProps {
onTimeout: () => 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(<FolderTrustDialog onSelect={onSelect} />);
- 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<FolderTrustDialogProps> = ({
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<RadioSelectItem<FolderTrustChoice>> = [
{
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,
- ),
- );
+ // 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 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;
- });
+ // 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,
- {},
- );
+ // 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,
- );
+ 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();
- }
- 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 (
<Box
diff --git a/packages/cli/src/ui/components/ShellConfirmationDialog.tsx b/packages/cli/src/ui/components/ShellConfirmationDialog.tsx
index ec137a6d..04e57364 100644
--- a/packages/cli/src/ui/components/ShellConfirmationDialog.tsx
+++ b/packages/cli/src/ui/components/ShellConfirmationDialog.tsx
@@ -5,13 +5,14 @@
*/
import { ToolConfirmationOutcome } from '@google/gemini-cli-core';
-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 interface ShellConfirmationRequest {
commands: string[];
@@ -30,11 +31,14 @@ export const ShellConfirmationDialog: React.FC<
> = ({ 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<T>({
[],
);
- 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<T>({
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<T>({
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<T>({
}
}
},
- { 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<typeof useInput>;
+ let capturedUseKeypressHandler: UseKeypressHandler;
+ let mockedUseKeypress: MockedFunction<typeof useKeypress>;
beforeEach(() => {
vi.resetAllMocks();
@@ -111,10 +110,12 @@ describe('useAutoAcceptIndicator', () => {
return instance;
});
- mockedInkUseInput = useInput as MockedFunction<typeof useInput>;
- mockedInkUseInput.mockImplementation((handler: UseInputHandler) => {
- capturedUseInputHandler = handler;
- });
+ mockedUseKeypress = useKeypress as MockedFunction<typeof useKeypress>;
+ 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 <Text color={Colors.Gray}>Loading...</Text>;
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 (
<Box flexDirection="column" marginBottom={1}>
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 (
<Box flexDirection="column" marginBottom={1}>