summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/cli/src/ui/App.tsx30
-rw-r--r--packages/cli/src/ui/components/AutoAcceptIndicator.tsx18
-rw-r--r--packages/cli/src/ui/components/LoadingIndicator.tsx6
-rw-r--r--packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts233
-rw-r--r--packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts39
5 files changed, 314 insertions, 12 deletions
diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx
index 707b8b9a..de1f0bf9 100644
--- a/packages/cli/src/ui/App.tsx
+++ b/packages/cli/src/ui/App.tsx
@@ -12,8 +12,10 @@ import { useGeminiStream } from './hooks/useGeminiStream.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useThemeCommand } from './hooks/useThemeCommand.js';
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
+import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
import { Header } from './components/Header.js';
import { LoadingIndicator } from './components/LoadingIndicator.js';
+import { AutoAcceptIndicator } from './components/AutoAcceptIndicator.js';
import { EditorState, InputPrompt } from './components/InputPrompt.js';
import { Footer } from './components/Footer.js';
import { ThemeDialog } from './components/ThemeDialog.js';
@@ -144,6 +146,7 @@ export const App = ({
);
const { elapsedTime, currentLoadingPhrase } =
useLoadingIndicator(streamingState);
+ const showAutoAcceptIndicator = useAutoAcceptIndicator({ config });
const handleFinalSubmit = useCallback(
(submittedValue: string) => {
@@ -327,23 +330,30 @@ export const App = ({
isLoading={streamingState === StreamingState.Responding}
currentLoadingPhrase={currentLoadingPhrase}
elapsedTime={elapsedTime}
+ rightContent={
+ showAutoAcceptIndicator ? <AutoAcceptIndicator /> : undefined
+ }
/>
{isInputActive && (
- <>
- <Box
- marginTop={1}
- display="flex"
- justifyContent="space-between"
- width="100%"
- >
- <Box>
+ <Box
+ marginTop={1}
+ display="flex"
+ justifyContent="space-between"
+ width="100%"
+ >
+ <Box>
+ <>
<Text color={Colors.SubtleComment}>cwd: </Text>
<Text color={Colors.LightBlue}>
{shortenPath(config.getTargetDir(), 70)}
</Text>
- </Box>
+ </>
</Box>
-
+ {showAutoAcceptIndicator && <AutoAcceptIndicator />}
+ </Box>
+ )}
+ {isInputActive && (
+ <>
<InputPrompt
query={query}
onChange={setQuery}
diff --git a/packages/cli/src/ui/components/AutoAcceptIndicator.tsx b/packages/cli/src/ui/components/AutoAcceptIndicator.tsx
new file mode 100644
index 00000000..dc23d5e2
--- /dev/null
+++ b/packages/cli/src/ui/components/AutoAcceptIndicator.tsx
@@ -0,0 +1,18 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React from 'react';
+import { Box, Text } from 'ink';
+import { Colors } from '../colors.js';
+
+export const AutoAcceptIndicator: React.FC = () => (
+ <Box>
+ <Text color={Colors.AccentGreen}>
+ accepting edits
+ <Text color={Colors.SubtleComment}> (shift + tab to disable)</Text>
+ </Text>
+ </Box>
+);
diff --git a/packages/cli/src/ui/components/LoadingIndicator.tsx b/packages/cli/src/ui/components/LoadingIndicator.tsx
index ca5fb5de..4f342c9d 100644
--- a/packages/cli/src/ui/components/LoadingIndicator.tsx
+++ b/packages/cli/src/ui/components/LoadingIndicator.tsx
@@ -13,12 +13,14 @@ interface LoadingIndicatorProps {
isLoading: boolean;
currentLoadingPhrase: string;
elapsedTime: number;
+ rightContent?: React.ReactNode;
}
export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
isLoading,
currentLoadingPhrase,
elapsedTime,
+ rightContent,
}) => {
if (!isLoading) {
return null; // Don't render anything if not loading
@@ -30,10 +32,10 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
<Spinner type="dots" />
</Box>
<Text color={Colors.AccentPurple}>
- {currentLoadingPhrase} ({elapsedTime}s)
+ {currentLoadingPhrase} (esc to cancel, {elapsedTime}s)
</Text>
<Box flexGrow={1}>{/* Spacer */}</Box>
- <Text color={Colors.SubtleComment}>(ESC to cancel)</Text>
+ {rightContent && <Box>{rightContent}</Box>}
</Box>
);
};
diff --git a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts
new file mode 100644
index 00000000..9973b8eb
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts
@@ -0,0 +1,233 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ describe,
+ it,
+ expect,
+ vi,
+ beforeEach,
+ type MockedFunction,
+ type Mock,
+} from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { useAutoAcceptIndicator } from './useAutoAcceptIndicator.js';
+
+import type { Config as ActualConfigType } from '@gemini-code/server';
+import { useInput, type Key as InkKey } from 'ink';
+
+vi.mock('ink');
+
+vi.mock('@gemini-code/server', async () => {
+ const actualServerModule = (await vi.importActual(
+ '@gemini-code/server',
+ )) as Record<string, unknown>;
+ return {
+ ...actualServerModule,
+ Config: vi.fn(),
+ };
+});
+
+import { Config } from '@gemini-code/server';
+
+interface MockConfigInstanceShape {
+ getAlwaysSkipModificationConfirmation: Mock<() => boolean>;
+ setAlwaysSkipModificationConfirmation: Mock<(value: boolean) => void>;
+ getCoreTools: Mock<() => string[]>;
+ getToolDiscoveryCommand: Mock<() => string | undefined>;
+ getTargetDir: Mock<() => string>;
+ getApiKey: Mock<() => string>;
+ getModel: Mock<() => string>;
+ getSandbox: Mock<() => boolean | string>;
+ getDebugMode: Mock<() => boolean>;
+ getQuestion: Mock<() => string | undefined>;
+ getFullContext: Mock<() => boolean>;
+ getUserAgent: Mock<() => string>;
+ getUserMemory: Mock<() => string>;
+ getGeminiMdFileCount: Mock<() => number>;
+ getToolRegistry: Mock<() => { discoverTools: Mock<() => void> }>;
+}
+
+type UseInputKey = InkKey;
+type UseInputHandler = (input: string, key: UseInputKey) => void;
+
+describe('useAutoAcceptIndicator', () => {
+ let mockConfigInstance: MockConfigInstanceShape;
+ let capturedUseInputHandler: UseInputHandler;
+ let mockedInkUseInput: MockedFunction<typeof useInput>;
+
+ beforeEach(() => {
+ vi.resetAllMocks();
+
+ (
+ Config as unknown as MockedFunction<() => MockConfigInstanceShape>
+ ).mockImplementation(() => {
+ const instanceGetAlwaysSkipMock = vi.fn();
+ const instanceSetAlwaysSkipMock = vi.fn();
+
+ const instance: MockConfigInstanceShape = {
+ getAlwaysSkipModificationConfirmation:
+ instanceGetAlwaysSkipMock as Mock<() => boolean>,
+ setAlwaysSkipModificationConfirmation:
+ instanceSetAlwaysSkipMock as Mock<(value: boolean) => void>,
+ getCoreTools: vi.fn().mockReturnValue([]) as Mock<() => string[]>,
+ getToolDiscoveryCommand: vi.fn().mockReturnValue(undefined) as Mock<
+ () => string | undefined
+ >,
+ getTargetDir: vi.fn().mockReturnValue('.') as Mock<() => string>,
+ getApiKey: vi.fn().mockReturnValue('test-api-key') as Mock<
+ () => string
+ >,
+ getModel: vi.fn().mockReturnValue('test-model') as Mock<() => string>,
+ getSandbox: vi.fn().mockReturnValue(false) as Mock<
+ () => boolean | string
+ >,
+ getDebugMode: vi.fn().mockReturnValue(false) as Mock<() => boolean>,
+ getQuestion: vi.fn().mockReturnValue(undefined) as Mock<
+ () => string | undefined
+ >,
+ getFullContext: vi.fn().mockReturnValue(false) as Mock<() => boolean>,
+ getUserAgent: vi.fn().mockReturnValue('test-user-agent') as Mock<
+ () => string
+ >,
+ getUserMemory: vi.fn().mockReturnValue('') as Mock<() => string>,
+ getGeminiMdFileCount: vi.fn().mockReturnValue(0) as Mock<() => number>,
+ getToolRegistry: vi
+ .fn()
+ .mockReturnValue({ discoverTools: vi.fn() }) as Mock<
+ () => { discoverTools: Mock<() => void> }
+ >,
+ };
+ instanceSetAlwaysSkipMock.mockImplementation((value: boolean) => {
+ instanceGetAlwaysSkipMock.mockReturnValue(value);
+ });
+ return instance;
+ });
+
+ mockedInkUseInput = useInput as MockedFunction<typeof useInput>;
+ mockedInkUseInput.mockImplementation((handler: UseInputHandler) => {
+ capturedUseInputHandler = handler;
+ });
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ mockConfigInstance = new (Config as any)() as MockConfigInstanceShape;
+ });
+
+ it('should initialize with true if config.getAlwaysSkipModificationConfirmation returns true', () => {
+ mockConfigInstance.getAlwaysSkipModificationConfirmation.mockReturnValue(
+ true,
+ );
+ const { result } = renderHook(() =>
+ useAutoAcceptIndicator({
+ config: mockConfigInstance as unknown as ActualConfigType,
+ }),
+ );
+ expect(result.current).toBe(true);
+ expect(
+ mockConfigInstance.getAlwaysSkipModificationConfirmation,
+ ).toHaveBeenCalledTimes(1);
+ });
+
+ it('should initialize with false if config.getAlwaysSkipModificationConfirmation returns false', () => {
+ mockConfigInstance.getAlwaysSkipModificationConfirmation.mockReturnValue(
+ false,
+ );
+ const { result } = renderHook(() =>
+ useAutoAcceptIndicator({
+ config: mockConfigInstance as unknown as ActualConfigType,
+ }),
+ );
+ expect(result.current).toBe(false);
+ expect(
+ mockConfigInstance.getAlwaysSkipModificationConfirmation,
+ ).toHaveBeenCalledTimes(1);
+ });
+
+ it('should toggle the indicator and update config when Shift+Tab is pressed', () => {
+ mockConfigInstance.getAlwaysSkipModificationConfirmation.mockReturnValue(
+ false,
+ );
+ const { result } = renderHook(() =>
+ useAutoAcceptIndicator({
+ config: mockConfigInstance as unknown as ActualConfigType,
+ }),
+ );
+ expect(result.current).toBe(false);
+
+ act(() => {
+ capturedUseInputHandler('', { tab: true, shift: true } as InkKey);
+ });
+ expect(
+ mockConfigInstance.setAlwaysSkipModificationConfirmation,
+ ).toHaveBeenCalledWith(true);
+ expect(result.current).toBe(true);
+
+ act(() => {
+ capturedUseInputHandler('', { tab: true, shift: true } as InkKey);
+ });
+ expect(
+ mockConfigInstance.setAlwaysSkipModificationConfirmation,
+ ).toHaveBeenCalledWith(false);
+ expect(result.current).toBe(false);
+ });
+
+ it('should not toggle if only Tab, only Shift, or other keys are pressed', () => {
+ mockConfigInstance.getAlwaysSkipModificationConfirmation.mockReturnValue(
+ false,
+ );
+ renderHook(() =>
+ useAutoAcceptIndicator({
+ config: mockConfigInstance as unknown as ActualConfigType,
+ }),
+ );
+
+ act(() => {
+ capturedUseInputHandler('', { tab: true, shift: false } as InkKey);
+ });
+ expect(
+ mockConfigInstance.setAlwaysSkipModificationConfirmation,
+ ).not.toHaveBeenCalled();
+
+ act(() => {
+ capturedUseInputHandler('', { tab: false, shift: true } as InkKey);
+ });
+ expect(
+ mockConfigInstance.setAlwaysSkipModificationConfirmation,
+ ).not.toHaveBeenCalled();
+
+ act(() => {
+ capturedUseInputHandler('a', { tab: false, shift: false } as InkKey);
+ });
+ expect(
+ mockConfigInstance.setAlwaysSkipModificationConfirmation,
+ ).not.toHaveBeenCalled();
+ });
+
+ it('should update indicator when config value changes externally (useEffect dependency)', () => {
+ mockConfigInstance.getAlwaysSkipModificationConfirmation.mockReturnValue(
+ false,
+ );
+ const { result, rerender } = renderHook(
+ (props: { config: ActualConfigType }) => useAutoAcceptIndicator(props),
+ {
+ initialProps: {
+ config: mockConfigInstance as unknown as ActualConfigType,
+ },
+ },
+ );
+ expect(result.current).toBe(false);
+
+ mockConfigInstance.getAlwaysSkipModificationConfirmation.mockReturnValue(
+ true,
+ );
+
+ rerender({ config: mockConfigInstance as unknown as ActualConfigType });
+ expect(result.current).toBe(true);
+ expect(
+ mockConfigInstance.getAlwaysSkipModificationConfirmation,
+ ).toHaveBeenCalledTimes(3);
+ });
+});
diff --git a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts
new file mode 100644
index 00000000..17cffd40
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts
@@ -0,0 +1,39 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useState, useEffect } from 'react';
+import { useInput } from 'ink';
+import type { Config } from '@gemini-code/server';
+
+export interface UseAutoAcceptIndicatorArgs {
+ config: Config;
+}
+
+export function useAutoAcceptIndicator({
+ config,
+}: UseAutoAcceptIndicatorArgs): boolean {
+ const currentConfigValue = config.getAlwaysSkipModificationConfirmation();
+ const [showAutoAcceptIndicator, setShowAutoAcceptIndicator] =
+ useState(currentConfigValue);
+
+ useEffect(() => {
+ setShowAutoAcceptIndicator(currentConfigValue);
+ }, [currentConfigValue]);
+
+ useInput((_input, key) => {
+ if (key.tab && key.shift) {
+ const alwaysAcceptModificationConfirmations =
+ !config.getAlwaysSkipModificationConfirmation();
+ config.setAlwaysSkipModificationConfirmation(
+ alwaysAcceptModificationConfirmations,
+ );
+ // Update local state immediately for responsiveness
+ setShowAutoAcceptIndicator(alwaysAcceptModificationConfirmations);
+ }
+ });
+
+ return showAutoAcceptIndicator;
+}