mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
feat: External editor settings (#882)
This commit is contained in:
@@ -39,6 +39,7 @@ export interface Settings {
|
||||
accessibility?: AccessibilitySettings;
|
||||
telemetry?: boolean;
|
||||
enableModifyWithExternalEditors?: boolean;
|
||||
preferredEditor?: string;
|
||||
|
||||
// Git-aware file filtering settings
|
||||
fileFiltering?: {
|
||||
|
||||
@@ -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
|
||||
|
||||
168
packages/cli/src/ui/components/EditorSettingsDialog.tsx
Normal file
168
packages/cli/src/ui/components/EditorSettingsDialog.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
if (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) {
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
60
packages/cli/src/ui/editors/editorSettingsManager.ts
Normal file
60
packages/cli/src/ui/editors/editorSettingsManager.ts
Normal file
@@ -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();
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
@@ -181,6 +182,13 @@ export const useSlashCommandProcessor = (
|
||||
openThemeDialog();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'editor',
|
||||
description: 'open the editor',
|
||||
action: (_mainCommand, _subCommand, _args) => {
|
||||
openEditorDialog();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'stats',
|
||||
altName: 'usage',
|
||||
@@ -745,6 +753,7 @@ Add any other context about the problem here.
|
||||
setShowHelp,
|
||||
refreshStatic,
|
||||
openThemeDialog,
|
||||
openEditorDialog,
|
||||
clearItems,
|
||||
performMemoryRefresh,
|
||||
showMemoryAction,
|
||||
|
||||
283
packages/cli/src/ui/hooks/useEditorSettings.test.ts
Normal file
283
packages/cli/src/ui/hooks/useEditorSettings.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
75
packages/cli/src/ui/hooks/useEditorSettings.ts
Normal file
75
packages/cli/src/ui/hooks/useEditorSettings.ts
Normal file
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user