diff options
Diffstat (limited to 'packages/cli/src')
| -rw-r--r-- | packages/cli/src/config/settings.ts | 1 | ||||
| -rw-r--r-- | packages/cli/src/ui/App.tsx | 38 | ||||
| -rw-r--r-- | packages/cli/src/ui/components/EditorSettingsDialog.tsx | 168 | ||||
| -rw-r--r-- | packages/cli/src/ui/components/HistoryItemDisplay.tsx | 3 | ||||
| -rw-r--r-- | packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx | 45 | ||||
| -rw-r--r-- | packages/cli/src/ui/components/messages/ToolGroupMessage.tsx | 3 | ||||
| -rw-r--r-- | packages/cli/src/ui/components/shared/RadioButtonSelect.tsx | 4 | ||||
| -rw-r--r-- | packages/cli/src/ui/editors/editorSettingsManager.ts | 60 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/slashCommandProcessor.test.ts | 13 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/slashCommandProcessor.ts | 9 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useEditorSettings.test.ts | 283 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useEditorSettings.ts | 75 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useGeminiStream.test.tsx | 17 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useGeminiStream.ts | 3 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useReactToolScheduler.ts | 4 |
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, ], ); |
