summaryrefslogtreecommitdiff
path: root/packages/cli/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src')
-rw-r--r--packages/cli/src/config/settings.ts1
-rw-r--r--packages/cli/src/ui/App.tsx38
-rw-r--r--packages/cli/src/ui/components/EditorSettingsDialog.tsx168
-rw-r--r--packages/cli/src/ui/components/HistoryItemDisplay.tsx3
-rw-r--r--packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx45
-rw-r--r--packages/cli/src/ui/components/messages/ToolGroupMessage.tsx3
-rw-r--r--packages/cli/src/ui/components/shared/RadioButtonSelect.tsx4
-rw-r--r--packages/cli/src/ui/editors/editorSettingsManager.ts60
-rw-r--r--packages/cli/src/ui/hooks/slashCommandProcessor.test.ts13
-rw-r--r--packages/cli/src/ui/hooks/slashCommandProcessor.ts9
-rw-r--r--packages/cli/src/ui/hooks/useEditorSettings.test.ts283
-rw-r--r--packages/cli/src/ui/hooks/useEditorSettings.ts75
-rw-r--r--packages/cli/src/ui/hooks/useGeminiStream.test.tsx17
-rw-r--r--packages/cli/src/ui/hooks/useGeminiStream.ts3
-rw-r--r--packages/cli/src/ui/hooks/useReactToolScheduler.ts4
15 files changed, 691 insertions, 35 deletions
diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts
index 5e48496e..af1278a6 100644
--- a/packages/cli/src/config/settings.ts
+++ b/packages/cli/src/config/settings.ts
@@ -39,6 +39,7 @@ export interface Settings {
accessibility?: AccessibilitySettings;
telemetry?: boolean;
enableModifyWithExternalEditors?: boolean;
+ preferredEditor?: string;
// Git-aware file filtering settings
fileFiltering?: {
diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx
index dcd2b7ee..98a27716 100644
--- a/packages/cli/src/ui/App.tsx
+++ b/packages/cli/src/ui/App.tsx
@@ -19,6 +19,7 @@ import { useTerminalSize } from './hooks/useTerminalSize.js';
import { useGeminiStream } from './hooks/useGeminiStream.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useThemeCommand } from './hooks/useThemeCommand.js';
+import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
@@ -29,6 +30,7 @@ import { ShellModeIndicator } from './components/ShellModeIndicator.js';
import { InputPrompt } from './components/InputPrompt.js';
import { Footer } from './components/Footer.js';
import { ThemeDialog } from './components/ThemeDialog.js';
+import { EditorSettingsDialog } from './components/EditorSettingsDialog.js';
import { Colors } from './colors.js';
import { Help } from './components/Help.js';
import { loadHierarchicalGeminiMemory } from '../config/config.js';
@@ -45,6 +47,8 @@ import {
type Config,
getCurrentGeminiMdFilename,
ApprovalMode,
+ isEditorAvailable,
+ EditorType,
} from '@gemini-cli/core';
import { useLogger } from './hooks/useLogger.js';
import { StreamingContext } from './contexts/StreamingContext.js';
@@ -82,6 +86,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
const [debugMessage, setDebugMessage] = useState<string>('');
const [showHelp, setShowHelp] = useState<boolean>(false);
const [themeError, setThemeError] = useState<string | null>(null);
+ const [editorError, setEditorError] = useState<string | null>(null);
const [footerHeight, setFooterHeight] = useState<number>(0);
const [corgiMode, setCorgiMode] = useState(false);
const [shellModeActive, setShellModeActive] = useState(false);
@@ -106,6 +111,13 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
handleThemeHighlight,
} = useThemeCommand(settings, setThemeError, addItem);
+ const {
+ isEditorDialogOpen,
+ openEditorDialog,
+ handleEditorSelect,
+ exitEditorDialog,
+ } = useEditorSettings(settings, setEditorError, addItem);
+
const toggleCorgiMode = useCallback(() => {
setCorgiMode((prev) => !prev);
}, []);
@@ -162,6 +174,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
setShowHelp,
setDebugMessage,
openThemeDialog,
+ openEditorDialog,
performMemoryRefresh,
toggleCorgiMode,
showToolDescriptions,
@@ -227,6 +240,16 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
}
}, [config]);
+ const getPreferredEditor = useCallback(() => {
+ const editorType = settings.merged.preferredEditor;
+ const isValidEditor = isEditorAvailable(editorType);
+ if (!isValidEditor) {
+ openEditorDialog();
+ return;
+ }
+ return editorType as EditorType;
+ }, [settings, openEditorDialog]);
+
const { streamingState, submitQuery, initError, pendingHistoryItems } =
useGeminiStream(
config.getGeminiClient(),
@@ -237,6 +260,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
setDebugMessage,
handleSlashCommand,
shellModeActive,
+ getPreferredEditor,
);
const { elapsedTime, currentLoadingPhrase } =
useLoadingIndicator(streamingState);
@@ -409,6 +433,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
item={{ ...item, id: 0 }}
isPending={true}
config={config}
+ isFocused={!isEditorDialogOpen}
/>
))}
</Box>
@@ -444,6 +469,19 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
settings={settings}
/>
</Box>
+ ) : isEditorDialogOpen ? (
+ <Box flexDirection="column">
+ {editorError && (
+ <Box marginBottom={1}>
+ <Text color={Colors.AccentRed}>{editorError}</Text>
+ </Box>
+ )}
+ <EditorSettingsDialog
+ onSelect={handleEditorSelect}
+ settings={settings}
+ onExit={exitEditorDialog}
+ />
+ </Box>
) : (
<>
<LoadingIndicator
diff --git a/packages/cli/src/ui/components/EditorSettingsDialog.tsx b/packages/cli/src/ui/components/EditorSettingsDialog.tsx
new file mode 100644
index 00000000..304354b5
--- /dev/null
+++ b/packages/cli/src/ui/components/EditorSettingsDialog.tsx
@@ -0,0 +1,168 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React, { useState } from 'react';
+import { Box, Text, useInput } from 'ink';
+import { Colors } from '../colors.js';
+import {
+ EDITOR_DISPLAY_NAMES,
+ editorSettingsManager,
+ type EditorDisplay,
+} from '../editors/editorSettingsManager.js';
+import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
+import { LoadedSettings, SettingScope } from '../../config/settings.js';
+import { EditorType, isEditorAvailable } from '@gemini-cli/core';
+
+interface EditorDialogProps {
+ onSelect: (editorType: EditorType | undefined, scope: SettingScope) => void;
+ settings: LoadedSettings;
+ onExit: () => void;
+}
+
+export function EditorSettingsDialog({
+ onSelect,
+ settings,
+ onExit,
+}: EditorDialogProps): React.JSX.Element {
+ const [selectedScope, setSelectedScope] = useState<SettingScope>(
+ SettingScope.User,
+ );
+ const [focusedSection, setFocusedSection] = useState<'editor' | 'scope'>(
+ 'editor',
+ );
+ useInput((_, key) => {
+ if (key.tab) {
+ setFocusedSection((prev) => (prev === 'editor' ? 'scope' : 'editor'));
+ }
+ if (key.escape) {
+ onExit();
+ }
+ });
+
+ const editorItems: EditorDisplay[] =
+ editorSettingsManager.getAvailableEditorDisplays();
+
+ const currentPreference =
+ settings.forScope(selectedScope).settings.preferredEditor;
+ let editorIndex = currentPreference
+ ? editorItems.findIndex(
+ (item: EditorDisplay) => item.type === currentPreference,
+ )
+ : 0;
+ if (editorIndex === -1) {
+ console.error(`Editor is not supported: ${currentPreference}`);
+ editorIndex = 0;
+ }
+
+ const scopeItems = [
+ { label: 'User Settings', value: SettingScope.User },
+ { label: 'Workspace Settings', value: SettingScope.Workspace },
+ ];
+
+ const handleEditorSelect = (editorType: EditorType | 'not_set') => {
+ if (editorType === 'not_set') {
+ onSelect(undefined, selectedScope);
+ return;
+ }
+ onSelect(editorType, selectedScope);
+ };
+
+ const handleScopeSelect = (scope: SettingScope) => {
+ setSelectedScope(scope);
+ setFocusedSection('editor');
+ };
+
+ let otherScopeModifiedMessage = '';
+ const otherScope =
+ selectedScope === SettingScope.User
+ ? SettingScope.Workspace
+ : SettingScope.User;
+ if (settings.forScope(otherScope).settings.preferredEditor !== undefined) {
+ otherScopeModifiedMessage =
+ settings.forScope(selectedScope).settings.preferredEditor !== undefined
+ ? `(Also modified in ${otherScope})`
+ : `(Modified in ${otherScope})`;
+ }
+
+ let mergedEditorName = 'None';
+ if (
+ settings.merged.preferredEditor &&
+ isEditorAvailable(settings.merged.preferredEditor)
+ ) {
+ mergedEditorName =
+ EDITOR_DISPLAY_NAMES[settings.merged.preferredEditor as EditorType];
+ }
+
+ return (
+ <Box
+ borderStyle="round"
+ borderColor={Colors.Gray}
+ flexDirection="row"
+ padding={1}
+ width="100%"
+ >
+ <Box flexDirection="column" width="45%" paddingRight={2}>
+ <Text bold={focusedSection === 'editor'}>
+ {focusedSection === 'editor' ? '> ' : ' '}Select Editor{' '}
+ <Text color={Colors.Gray}>{otherScopeModifiedMessage}</Text>
+ </Text>
+ <RadioButtonSelect
+ items={editorItems.map((item) => ({
+ label: item.name,
+ value: item.type,
+ disabled: item.disabled,
+ }))}
+ initialIndex={editorIndex}
+ onSelect={handleEditorSelect}
+ isFocused={focusedSection === 'editor'}
+ key={selectedScope}
+ />
+
+ <Box marginTop={1} flexDirection="column">
+ <Text bold={focusedSection === 'scope'}>
+ {focusedSection === 'scope' ? '> ' : ' '}Apply To
+ </Text>
+ <RadioButtonSelect
+ items={scopeItems}
+ initialIndex={0}
+ onSelect={handleScopeSelect}
+ isFocused={focusedSection === 'scope'}
+ />
+ </Box>
+
+ <Box marginTop={1}>
+ <Text color={Colors.Gray}>
+ (Use Enter to select, Tab to change focus)
+ </Text>
+ </Box>
+ </Box>
+
+ <Box flexDirection="column" width="55%" paddingLeft={2}>
+ <Text bold>Editor Preference</Text>
+ <Box flexDirection="column" gap={1} marginTop={1}>
+ <Text color={Colors.Gray}>
+ These editors are currently supported. Please note that some editors
+ cannot be used in sandbox mode.
+ </Text>
+ <Text color={Colors.Gray}>
+ Your preferred editor is:{' '}
+ <Text
+ color={
+ mergedEditorName === 'None'
+ ? Colors.AccentRed
+ : Colors.AccentCyan
+ }
+ bold
+ >
+ {mergedEditorName}
+ </Text>
+ .
+ </Text>
+ </Box>
+ </Box>
+ </Box>
+ );
+}
diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx
index 229672ec..fc1b128d 100644
--- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx
+++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx
@@ -24,6 +24,7 @@ interface HistoryItemDisplayProps {
availableTerminalHeight: number;
isPending: boolean;
config?: Config;
+ isFocused?: boolean;
}
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
@@ -31,6 +32,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
availableTerminalHeight,
isPending,
config,
+ isFocused = true,
}) => (
<Box flexDirection="column" key={item.id}>
{/* Render standard message types */}
@@ -76,6 +78,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
groupId={item.id}
availableTerminalHeight={availableTerminalHeight}
config={config}
+ isFocused={isFocused}
/>
)}
</Box>
diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
index af9aba6a..0de85ba4 100644
--- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
@@ -13,7 +13,6 @@ import {
ToolConfirmationOutcome,
ToolExecuteConfirmationDetails,
ToolMcpConfirmationDetails,
- checkHasEditor,
Config,
} from '@gemini-cli/core';
import {
@@ -24,14 +23,16 @@ import {
export interface ToolConfirmationMessageProps {
confirmationDetails: ToolCallConfirmationDetails;
config?: Config;
+ isFocused?: boolean;
}
export const ToolConfirmationMessage: React.FC<
ToolConfirmationMessageProps
-> = ({ confirmationDetails, config }) => {
+> = ({ confirmationDetails, config, isFocused = true }) => {
const { onConfirm } = confirmationDetails;
useInput((_, key) => {
+ if (!isFocused) return;
if (key.escape) {
onConfirm(ToolConfirmationOutcome.Cancel);
}
@@ -86,40 +87,12 @@ export const ToolConfirmationMessage: React.FC<
},
);
- // Conditionally add editor options if editors are installed
- const notUsingSandbox = !process.env.SANDBOX;
const externalEditorsEnabled =
config?.getEnableModifyWithExternalEditors() ?? false;
-
- if (checkHasEditor('vscode') && notUsingSandbox && externalEditorsEnabled) {
- options.push({
- label: 'Modify with VS Code',
- value: ToolConfirmationOutcome.ModifyVSCode,
- });
- }
-
- if (
- checkHasEditor('windsurf') &&
- notUsingSandbox &&
- externalEditorsEnabled
- ) {
- options.push({
- label: 'Modify with Windsurf',
- value: ToolConfirmationOutcome.ModifyWindsurf,
- });
- }
-
- if (checkHasEditor('cursor') && notUsingSandbox && externalEditorsEnabled) {
- options.push({
- label: 'Modify with Cursor',
- value: ToolConfirmationOutcome.ModifyCursor,
- });
- }
-
- if (checkHasEditor('vim') && externalEditorsEnabled) {
+ if (externalEditorsEnabled) {
options.push({
- label: 'Modify with vim',
- value: ToolConfirmationOutcome.ModifyVim,
+ label: 'Modify with external editor',
+ value: ToolConfirmationOutcome.ModifyWithEditor,
});
}
@@ -192,7 +165,11 @@ export const ToolConfirmationMessage: React.FC<
{/* Select Input for Options */}
<Box flexShrink={0}>
- <RadioButtonSelect items={options} onSelect={handleSelect} />
+ <RadioButtonSelect
+ items={options}
+ onSelect={handleSelect}
+ isFocused={isFocused}
+ />
</Box>
</Box>
);
diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
index b01e5f9b..8ce40893 100644
--- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
@@ -17,6 +17,7 @@ interface ToolGroupMessageProps {
toolCalls: IndividualToolCallDisplay[];
availableTerminalHeight: number;
config?: Config;
+ isFocused?: boolean;
}
// Main component renders the border and maps the tools using ToolMessage
@@ -24,6 +25,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
toolCalls,
availableTerminalHeight,
config,
+ isFocused = true,
}) => {
const hasPending = !toolCalls.every(
(t) => t.status === ToolCallStatus.Success,
@@ -84,6 +86,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
<ToolConfirmationMessage
confirmationDetails={tool.confirmationDetails}
config={config}
+ isFocused={isFocused}
/>
)}
</Box>
diff --git a/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx b/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx
index 22b5cecd..5430a442 100644
--- a/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx
+++ b/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx
@@ -19,6 +19,7 @@ import { Colors } from '../../colors.js';
export interface RadioSelectItem<T> {
label: string;
value: T;
+ disabled?: boolean;
}
/**
@@ -97,11 +98,14 @@ export function RadioButtonSelect<T>({
const itemWithThemeProps = props as typeof props & {
themeNameDisplay?: string;
themeTypeDisplay?: string;
+ disabled?: boolean;
};
let textColor = Colors.Foreground;
if (isSelected) {
textColor = Colors.AccentGreen;
+ } else if (itemWithThemeProps.disabled === true) {
+ textColor = Colors.Gray;
}
if (
diff --git a/packages/cli/src/ui/editors/editorSettingsManager.ts b/packages/cli/src/ui/editors/editorSettingsManager.ts
new file mode 100644
index 00000000..2c8210e1
--- /dev/null
+++ b/packages/cli/src/ui/editors/editorSettingsManager.ts
@@ -0,0 +1,60 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ allowEditorTypeInSandbox,
+ checkHasEditorType,
+ type EditorType,
+} from '@gemini-cli/core';
+
+export interface EditorDisplay {
+ name: string;
+ type: EditorType | 'not_set';
+ disabled: boolean;
+}
+
+export const EDITOR_DISPLAY_NAMES: Record<EditorType, string> = {
+ vscode: 'VS Code',
+ windsurf: 'Windsurf',
+ cursor: 'Cursor',
+ vim: 'Vim',
+};
+
+class EditorSettingsManager {
+ private readonly availableEditors: EditorDisplay[];
+
+ constructor() {
+ const editorTypes: EditorType[] = ['vscode', 'windsurf', 'cursor', 'vim'];
+ this.availableEditors = [
+ {
+ name: 'None',
+ type: 'not_set',
+ disabled: false,
+ },
+ ...editorTypes.map((type) => {
+ const hasEditor = checkHasEditorType(type);
+ const isAllowedInSandbox = allowEditorTypeInSandbox(type);
+
+ let labelSuffix = !isAllowedInSandbox
+ ? ' (Not available in sandbox)'
+ : '';
+ labelSuffix = !hasEditor ? ' (Not installed)' : labelSuffix;
+
+ return {
+ name: EDITOR_DISPLAY_NAMES[type] + labelSuffix,
+ type,
+ disabled: !hasEditor || !isAllowedInSandbox,
+ };
+ }),
+ ];
+ }
+
+ getAvailableEditorDisplays(): EditorDisplay[] {
+ return this.availableEditors;
+ }
+}
+
+export const editorSettingsManager = new EditorSettingsManager();
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
index 971d7aac..c2873bd6 100644
--- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
+++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
@@ -97,6 +97,7 @@ describe('useSlashCommandProcessor', () => {
let mockSetShowHelp: ReturnType<typeof vi.fn>;
let mockOnDebugMessage: ReturnType<typeof vi.fn>;
let mockOpenThemeDialog: ReturnType<typeof vi.fn>;
+ let mockOpenEditorDialog: ReturnType<typeof vi.fn>;
let mockPerformMemoryRefresh: ReturnType<typeof vi.fn>;
let mockSetQuittingMessages: ReturnType<typeof vi.fn>;
let mockConfig: Config;
@@ -111,6 +112,7 @@ describe('useSlashCommandProcessor', () => {
mockSetShowHelp = vi.fn();
mockOnDebugMessage = vi.fn();
mockOpenThemeDialog = vi.fn();
+ mockOpenEditorDialog = vi.fn();
mockPerformMemoryRefresh = vi.fn().mockResolvedValue(undefined);
mockSetQuittingMessages = vi.fn();
mockConfig = {
@@ -155,6 +157,7 @@ describe('useSlashCommandProcessor', () => {
mockSetShowHelp,
mockOnDebugMessage,
mockOpenThemeDialog,
+ mockOpenEditorDialog,
mockPerformMemoryRefresh,
mockCorgiMode,
showToolDescriptions,
@@ -322,6 +325,16 @@ describe('useSlashCommandProcessor', () => {
expect(mockSetShowHelp).toHaveBeenCalledWith(true);
expect(commandResult).toBe(true);
});
+
+ it('/editor should open editor dialog and return true', async () => {
+ const { handleSlashCommand } = getProcessor();
+ let commandResult: SlashCommandActionReturn | boolean = false;
+ await act(async () => {
+ commandResult = await handleSlashCommand('/editor');
+ });
+ expect(mockOpenEditorDialog).toHaveBeenCalled();
+ expect(commandResult).toBe(true);
+ });
});
describe('/bug command', () => {
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts
index d343c6ff..23e34402 100644
--- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts
+++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts
@@ -66,6 +66,7 @@ export const useSlashCommandProcessor = (
setShowHelp: React.Dispatch<React.SetStateAction<boolean>>,
onDebugMessage: (message: string) => void,
openThemeDialog: () => void,
+ openEditorDialog: () => void,
performMemoryRefresh: () => Promise<void>,
toggleCorgiMode: () => void,
showToolDescriptions: boolean = false,
@@ -182,6 +183,13 @@ export const useSlashCommandProcessor = (
},
},
{
+ name: 'editor',
+ description: 'open the editor',
+ action: (_mainCommand, _subCommand, _args) => {
+ openEditorDialog();
+ },
+ },
+ {
name: 'stats',
altName: 'usage',
description: 'check session stats',
@@ -745,6 +753,7 @@ Add any other context about the problem here.
setShowHelp,
refreshStatic,
openThemeDialog,
+ openEditorDialog,
clearItems,
performMemoryRefresh,
showMemoryAction,
diff --git a/packages/cli/src/ui/hooks/useEditorSettings.test.ts b/packages/cli/src/ui/hooks/useEditorSettings.test.ts
new file mode 100644
index 00000000..c69c2a31
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useEditorSettings.test.ts
@@ -0,0 +1,283 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+ type MockedFunction,
+} from 'vitest';
+import { act } from 'react';
+import { renderHook } from '@testing-library/react';
+import { useEditorSettings } from './useEditorSettings.js';
+import { LoadedSettings, SettingScope } from '../../config/settings.js';
+import { MessageType, type HistoryItem } from '../types.js';
+import {
+ type EditorType,
+ checkHasEditorType,
+ allowEditorTypeInSandbox,
+} from '@gemini-cli/core';
+
+vi.mock('@gemini-cli/core', async () => {
+ const actual = await vi.importActual('@gemini-cli/core');
+ return {
+ ...actual,
+ checkHasEditorType: vi.fn(() => true),
+ allowEditorTypeInSandbox: vi.fn(() => true),
+ };
+});
+
+const mockCheckHasEditorType = vi.mocked(checkHasEditorType);
+const mockAllowEditorTypeInSandbox = vi.mocked(allowEditorTypeInSandbox);
+
+describe('useEditorSettings', () => {
+ let mockLoadedSettings: LoadedSettings;
+ let mockSetEditorError: MockedFunction<(error: string | null) => void>;
+ let mockAddItem: MockedFunction<
+ (item: Omit<HistoryItem, 'id'>, timestamp: number) => void
+ >;
+
+ beforeEach(() => {
+ vi.resetAllMocks();
+
+ mockLoadedSettings = {
+ setValue: vi.fn(),
+ } as unknown as LoadedSettings;
+
+ mockSetEditorError = vi.fn();
+ mockAddItem = vi.fn();
+
+ // Reset mock implementations to default
+ mockCheckHasEditorType.mockReturnValue(true);
+ mockAllowEditorTypeInSandbox.mockReturnValue(true);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should initialize with dialog closed', () => {
+ const { result } = renderHook(() =>
+ useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem),
+ );
+
+ expect(result.current.isEditorDialogOpen).toBe(false);
+ });
+
+ it('should open editor dialog when openEditorDialog is called', () => {
+ const { result } = renderHook(() =>
+ useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem),
+ );
+
+ act(() => {
+ result.current.openEditorDialog();
+ });
+
+ expect(result.current.isEditorDialogOpen).toBe(true);
+ });
+
+ it('should close editor dialog when exitEditorDialog is called', () => {
+ const { result } = renderHook(() =>
+ useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem),
+ );
+ act(() => {
+ result.current.openEditorDialog();
+ result.current.exitEditorDialog();
+ });
+ expect(result.current.isEditorDialogOpen).toBe(false);
+ });
+
+ it('should handle editor selection successfully', () => {
+ const { result } = renderHook(() =>
+ useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem),
+ );
+
+ const editorType: EditorType = 'vscode';
+ const scope = SettingScope.User;
+
+ act(() => {
+ result.current.openEditorDialog();
+ result.current.handleEditorSelect(editorType, scope);
+ });
+
+ expect(mockLoadedSettings.setValue).toHaveBeenCalledWith(
+ scope,
+ 'preferredEditor',
+ editorType,
+ );
+
+ expect(mockAddItem).toHaveBeenCalledWith(
+ {
+ type: MessageType.INFO,
+ text: 'Editor preference set to "vscode" in User settings.',
+ },
+ expect.any(Number),
+ );
+
+ expect(mockSetEditorError).toHaveBeenCalledWith(null);
+ expect(result.current.isEditorDialogOpen).toBe(false);
+ });
+
+ it('should handle clearing editor preference (undefined editor)', () => {
+ const { result } = renderHook(() =>
+ useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem),
+ );
+
+ const scope = SettingScope.Workspace;
+
+ act(() => {
+ result.current.openEditorDialog();
+ result.current.handleEditorSelect(undefined, scope);
+ });
+
+ expect(mockLoadedSettings.setValue).toHaveBeenCalledWith(
+ scope,
+ 'preferredEditor',
+ undefined,
+ );
+
+ expect(mockAddItem).toHaveBeenCalledWith(
+ {
+ type: MessageType.INFO,
+ text: 'Editor preference cleared in Workspace settings.',
+ },
+ expect.any(Number),
+ );
+
+ expect(mockSetEditorError).toHaveBeenCalledWith(null);
+ expect(result.current.isEditorDialogOpen).toBe(false);
+ });
+
+ it('should handle different editor types', () => {
+ const { result } = renderHook(() =>
+ useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem),
+ );
+
+ const editorTypes: EditorType[] = ['cursor', 'windsurf', 'vim'];
+ const scope = SettingScope.User;
+
+ editorTypes.forEach((editorType) => {
+ act(() => {
+ result.current.handleEditorSelect(editorType, scope);
+ });
+
+ expect(mockLoadedSettings.setValue).toHaveBeenCalledWith(
+ scope,
+ 'preferredEditor',
+ editorType,
+ );
+
+ expect(mockAddItem).toHaveBeenCalledWith(
+ {
+ type: MessageType.INFO,
+ text: `Editor preference set to "${editorType}" in User settings.`,
+ },
+ expect.any(Number),
+ );
+ });
+ });
+
+ it('should handle different setting scopes', () => {
+ const { result } = renderHook(() =>
+ useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem),
+ );
+
+ const editorType: EditorType = 'vscode';
+ const scopes = [SettingScope.User, SettingScope.Workspace];
+
+ scopes.forEach((scope) => {
+ act(() => {
+ result.current.handleEditorSelect(editorType, scope);
+ });
+
+ expect(mockLoadedSettings.setValue).toHaveBeenCalledWith(
+ scope,
+ 'preferredEditor',
+ editorType,
+ );
+
+ expect(mockAddItem).toHaveBeenCalledWith(
+ {
+ type: MessageType.INFO,
+ text: `Editor preference set to "vscode" in ${scope} settings.`,
+ },
+ expect.any(Number),
+ );
+ });
+ });
+
+ it('should not set preference for unavailable editors', () => {
+ const { result } = renderHook(() =>
+ useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem),
+ );
+
+ mockCheckHasEditorType.mockReturnValue(false);
+
+ const editorType: EditorType = 'vscode';
+ const scope = SettingScope.User;
+
+ act(() => {
+ result.current.openEditorDialog();
+ result.current.handleEditorSelect(editorType, scope);
+ });
+
+ expect(mockLoadedSettings.setValue).not.toHaveBeenCalled();
+ expect(mockAddItem).not.toHaveBeenCalled();
+ expect(result.current.isEditorDialogOpen).toBe(true);
+ });
+
+ it('should not set preference for editors not allowed in sandbox', () => {
+ const { result } = renderHook(() =>
+ useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem),
+ );
+
+ mockAllowEditorTypeInSandbox.mockReturnValue(false);
+
+ const editorType: EditorType = 'vscode';
+ const scope = SettingScope.User;
+
+ act(() => {
+ result.current.openEditorDialog();
+ result.current.handleEditorSelect(editorType, scope);
+ });
+
+ expect(mockLoadedSettings.setValue).not.toHaveBeenCalled();
+ expect(mockAddItem).not.toHaveBeenCalled();
+ expect(result.current.isEditorDialogOpen).toBe(true);
+ });
+
+ it('should handle errors during editor selection', () => {
+ const { result } = renderHook(() =>
+ useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem),
+ );
+
+ const errorMessage = 'Failed to save settings';
+ (
+ mockLoadedSettings.setValue as MockedFunction<
+ typeof mockLoadedSettings.setValue
+ >
+ ).mockImplementation(() => {
+ throw new Error(errorMessage);
+ });
+
+ const editorType: EditorType = 'vscode';
+ const scope = SettingScope.User;
+
+ act(() => {
+ result.current.openEditorDialog();
+ result.current.handleEditorSelect(editorType, scope);
+ });
+
+ expect(mockSetEditorError).toHaveBeenCalledWith(
+ `Failed to set editor preference: Error: ${errorMessage}`,
+ );
+ expect(mockAddItem).not.toHaveBeenCalled();
+ expect(result.current.isEditorDialogOpen).toBe(true);
+ });
+});
diff --git a/packages/cli/src/ui/hooks/useEditorSettings.ts b/packages/cli/src/ui/hooks/useEditorSettings.ts
new file mode 100644
index 00000000..1fe3983e
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useEditorSettings.ts
@@ -0,0 +1,75 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useState, useCallback } from 'react';
+import { LoadedSettings, SettingScope } from '../../config/settings.js';
+import { type HistoryItem, MessageType } from '../types.js';
+import {
+ allowEditorTypeInSandbox,
+ checkHasEditorType,
+ EditorType,
+} from '@gemini-cli/core';
+
+interface UseEditorSettingsReturn {
+ isEditorDialogOpen: boolean;
+ openEditorDialog: () => void;
+ handleEditorSelect: (
+ editorType: EditorType | undefined,
+ scope: SettingScope,
+ ) => void;
+ exitEditorDialog: () => void;
+}
+
+export const useEditorSettings = (
+ loadedSettings: LoadedSettings,
+ setEditorError: (error: string | null) => void,
+ addItem: (item: Omit<HistoryItem, 'id'>, timestamp: number) => void,
+): UseEditorSettingsReturn => {
+ const [isEditorDialogOpen, setIsEditorDialogOpen] = useState(false);
+
+ const openEditorDialog = useCallback(() => {
+ setIsEditorDialogOpen(true);
+ }, []);
+
+ const handleEditorSelect = useCallback(
+ (editorType: EditorType | undefined, scope: SettingScope) => {
+ if (
+ editorType &&
+ (!checkHasEditorType(editorType) ||
+ !allowEditorTypeInSandbox(editorType))
+ ) {
+ return;
+ }
+
+ try {
+ loadedSettings.setValue(scope, 'preferredEditor', editorType);
+ addItem(
+ {
+ type: MessageType.INFO,
+ text: `Editor preference ${editorType ? `set to "${editorType}"` : 'cleared'} in ${scope} settings.`,
+ },
+ Date.now(),
+ );
+ setEditorError(null);
+ setIsEditorDialogOpen(false);
+ } catch (error) {
+ setEditorError(`Failed to set editor preference: ${error}`);
+ }
+ },
+ [loadedSettings, setEditorError, addItem],
+ );
+
+ const exitEditorDialog = useCallback(() => {
+ setIsEditorDialogOpen(false);
+ }, []);
+
+ return {
+ isEditorDialogOpen,
+ openEditorDialog,
+ handleEditorSelect,
+ exitEditorDialog,
+ };
+};
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
index 81c7f52b..96dd6aef 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
+++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
@@ -15,11 +15,12 @@ import {
TrackedExecutingToolCall,
TrackedCancelledToolCall,
} from './useReactToolScheduler.js';
-import { Config } from '@gemini-cli/core';
+import { Config, EditorType } from '@gemini-cli/core';
import { Part, PartListUnion } from '@google/genai';
import { UseHistoryManagerReturn } from './useHistoryManager.js';
import { HistoryItem } from '../types.js';
import { Dispatch, SetStateAction } from 'react';
+import { LoadedSettings } from '../../config/settings.js';
// --- MOCKS ---
const mockSendMessageStream = vi
@@ -309,6 +310,15 @@ describe('useGeminiStream', () => {
.mockReturnValue((async function* () {})());
});
+ const mockLoadedSettings: LoadedSettings = {
+ merged: { preferredEditor: 'vscode' },
+ user: { path: '/user/settings.json', settings: {} },
+ workspace: { path: '/workspace/.gemini/settings.json', settings: {} },
+ errors: [],
+ forScope: vi.fn(),
+ setValue: vi.fn(),
+ } as unknown as LoadedSettings;
+
const renderTestHook = (
initialToolCalls: TrackedToolCall[] = [],
geminiClient?: any,
@@ -337,6 +347,7 @@ describe('useGeminiStream', () => {
| boolean
>;
shellModeActive: boolean;
+ loadedSettings: LoadedSettings;
}) =>
useGeminiStream(
props.client,
@@ -347,6 +358,7 @@ describe('useGeminiStream', () => {
props.onDebugMessage,
props.handleSlashCommand,
props.shellModeActive,
+ () => 'vscode' as EditorType,
),
{
initialProps: {
@@ -363,6 +375,7 @@ describe('useGeminiStream', () => {
| boolean
>,
shellModeActive: false,
+ loadedSettings: mockLoadedSettings,
},
},
);
@@ -486,6 +499,7 @@ describe('useGeminiStream', () => {
handleSlashCommand:
mockHandleSlashCommand as unknown as typeof mockHandleSlashCommand,
shellModeActive: false,
+ loadedSettings: mockLoadedSettings,
});
});
@@ -541,6 +555,7 @@ describe('useGeminiStream', () => {
handleSlashCommand:
mockHandleSlashCommand as unknown as typeof mockHandleSlashCommand,
shellModeActive: false,
+ loadedSettings: mockLoadedSettings,
});
});
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts
index 4d6bbcba..56e87fc3 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.ts
+++ b/packages/cli/src/ui/hooks/useGeminiStream.ts
@@ -19,6 +19,7 @@ import {
ToolCallRequestInfo,
logUserPrompt,
GitService,
+ EditorType,
} from '@gemini-cli/core';
import { type Part, type PartListUnion } from '@google/genai';
import {
@@ -83,6 +84,7 @@ export const useGeminiStream = (
import('./slashCommandProcessor.js').SlashCommandActionReturn | boolean
>,
shellModeActive: boolean,
+ getPreferredEditor: () => EditorType | undefined,
) => {
const [initError, setInitError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
@@ -115,6 +117,7 @@ export const useGeminiStream = (
},
config,
setPendingHistoryItem,
+ getPreferredEditor,
);
const pendingToolCallGroupDisplay = useMemo(
diff --git a/packages/cli/src/ui/hooks/useReactToolScheduler.ts b/packages/cli/src/ui/hooks/useReactToolScheduler.ts
index 8ae7ebfb..0faccb2a 100644
--- a/packages/cli/src/ui/hooks/useReactToolScheduler.ts
+++ b/packages/cli/src/ui/hooks/useReactToolScheduler.ts
@@ -21,6 +21,7 @@ import {
ToolCall,
Status as CoreStatus,
logToolCall,
+ EditorType,
} from '@gemini-cli/core';
import { useCallback, useState, useMemo } from 'react';
import {
@@ -69,6 +70,7 @@ export function useReactToolScheduler(
setPendingHistoryItem: React.Dispatch<
React.SetStateAction<HistoryItemWithoutId | null>
>,
+ getPreferredEditor: () => EditorType | undefined,
): [TrackedToolCall[], ScheduleFn, MarkToolsAsSubmittedFn] {
const [toolCallsForDisplay, setToolCallsForDisplay] = useState<
TrackedToolCall[]
@@ -162,12 +164,14 @@ export function useReactToolScheduler(
onAllToolCallsComplete: allToolCallsCompleteHandler,
onToolCallsUpdate: toolCallsUpdateHandler,
approvalMode: config.getApprovalMode(),
+ getPreferredEditor,
}),
[
config,
outputUpdateHandler,
allToolCallsCompleteHandler,
toolCallsUpdateHandler,
+ getPreferredEditor,
],
);