feat: External editor settings (#882)
This commit is contained in:
parent
dd53e5c96a
commit
1ef68e0612
|
@ -6,6 +6,11 @@ The Gemini CLI supports several built-in commands to help you manage your sessio
|
||||||
|
|
||||||
Slash commands provide meta-level control over the CLI itself. They can typically be executed by typing the command and pressing `Enter`.
|
Slash commands provide meta-level control over the CLI itself. They can typically be executed by typing the command and pressing `Enter`.
|
||||||
|
|
||||||
|
- **`/editor`**
|
||||||
|
|
||||||
|
- **Description:** Allows you to configure your external editor for actions such as modifying Gemini's proposed code change.
|
||||||
|
- **Action:** Opens a dialog for selecting supported editors.
|
||||||
|
|
||||||
- **`/help`** (or **`/?`**)
|
- **`/help`** (or **`/?`**)
|
||||||
|
|
||||||
- **Description:** Displays help information about the Gemini CLI, including available commands and their usage.
|
- **Description:** Displays help information about the Gemini CLI, including available commands and their usage.
|
||||||
|
|
|
@ -39,6 +39,7 @@ export interface Settings {
|
||||||
accessibility?: AccessibilitySettings;
|
accessibility?: AccessibilitySettings;
|
||||||
telemetry?: boolean;
|
telemetry?: boolean;
|
||||||
enableModifyWithExternalEditors?: boolean;
|
enableModifyWithExternalEditors?: boolean;
|
||||||
|
preferredEditor?: string;
|
||||||
|
|
||||||
// Git-aware file filtering settings
|
// Git-aware file filtering settings
|
||||||
fileFiltering?: {
|
fileFiltering?: {
|
||||||
|
|
|
@ -19,6 +19,7 @@ import { useTerminalSize } from './hooks/useTerminalSize.js';
|
||||||
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
||||||
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
||||||
import { useThemeCommand } from './hooks/useThemeCommand.js';
|
import { useThemeCommand } from './hooks/useThemeCommand.js';
|
||||||
|
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
||||||
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
||||||
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
|
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
|
||||||
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
|
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
|
||||||
|
@ -29,6 +30,7 @@ import { ShellModeIndicator } from './components/ShellModeIndicator.js';
|
||||||
import { InputPrompt } from './components/InputPrompt.js';
|
import { InputPrompt } from './components/InputPrompt.js';
|
||||||
import { Footer } from './components/Footer.js';
|
import { Footer } from './components/Footer.js';
|
||||||
import { ThemeDialog } from './components/ThemeDialog.js';
|
import { ThemeDialog } from './components/ThemeDialog.js';
|
||||||
|
import { EditorSettingsDialog } from './components/EditorSettingsDialog.js';
|
||||||
import { Colors } from './colors.js';
|
import { Colors } from './colors.js';
|
||||||
import { Help } from './components/Help.js';
|
import { Help } from './components/Help.js';
|
||||||
import { loadHierarchicalGeminiMemory } from '../config/config.js';
|
import { loadHierarchicalGeminiMemory } from '../config/config.js';
|
||||||
|
@ -45,6 +47,8 @@ import {
|
||||||
type Config,
|
type Config,
|
||||||
getCurrentGeminiMdFilename,
|
getCurrentGeminiMdFilename,
|
||||||
ApprovalMode,
|
ApprovalMode,
|
||||||
|
isEditorAvailable,
|
||||||
|
EditorType,
|
||||||
} from '@gemini-cli/core';
|
} from '@gemini-cli/core';
|
||||||
import { useLogger } from './hooks/useLogger.js';
|
import { useLogger } from './hooks/useLogger.js';
|
||||||
import { StreamingContext } from './contexts/StreamingContext.js';
|
import { StreamingContext } from './contexts/StreamingContext.js';
|
||||||
|
@ -82,6 +86,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||||
const [debugMessage, setDebugMessage] = useState<string>('');
|
const [debugMessage, setDebugMessage] = useState<string>('');
|
||||||
const [showHelp, setShowHelp] = useState<boolean>(false);
|
const [showHelp, setShowHelp] = useState<boolean>(false);
|
||||||
const [themeError, setThemeError] = useState<string | null>(null);
|
const [themeError, setThemeError] = useState<string | null>(null);
|
||||||
|
const [editorError, setEditorError] = useState<string | null>(null);
|
||||||
const [footerHeight, setFooterHeight] = useState<number>(0);
|
const [footerHeight, setFooterHeight] = useState<number>(0);
|
||||||
const [corgiMode, setCorgiMode] = useState(false);
|
const [corgiMode, setCorgiMode] = useState(false);
|
||||||
const [shellModeActive, setShellModeActive] = useState(false);
|
const [shellModeActive, setShellModeActive] = useState(false);
|
||||||
|
@ -106,6 +111,13 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||||
handleThemeHighlight,
|
handleThemeHighlight,
|
||||||
} = useThemeCommand(settings, setThemeError, addItem);
|
} = useThemeCommand(settings, setThemeError, addItem);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isEditorDialogOpen,
|
||||||
|
openEditorDialog,
|
||||||
|
handleEditorSelect,
|
||||||
|
exitEditorDialog,
|
||||||
|
} = useEditorSettings(settings, setEditorError, addItem);
|
||||||
|
|
||||||
const toggleCorgiMode = useCallback(() => {
|
const toggleCorgiMode = useCallback(() => {
|
||||||
setCorgiMode((prev) => !prev);
|
setCorgiMode((prev) => !prev);
|
||||||
}, []);
|
}, []);
|
||||||
|
@ -162,6 +174,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||||
setShowHelp,
|
setShowHelp,
|
||||||
setDebugMessage,
|
setDebugMessage,
|
||||||
openThemeDialog,
|
openThemeDialog,
|
||||||
|
openEditorDialog,
|
||||||
performMemoryRefresh,
|
performMemoryRefresh,
|
||||||
toggleCorgiMode,
|
toggleCorgiMode,
|
||||||
showToolDescriptions,
|
showToolDescriptions,
|
||||||
|
@ -227,6 +240,16 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||||
}
|
}
|
||||||
}, [config]);
|
}, [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 } =
|
const { streamingState, submitQuery, initError, pendingHistoryItems } =
|
||||||
useGeminiStream(
|
useGeminiStream(
|
||||||
config.getGeminiClient(),
|
config.getGeminiClient(),
|
||||||
|
@ -237,6 +260,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||||
setDebugMessage,
|
setDebugMessage,
|
||||||
handleSlashCommand,
|
handleSlashCommand,
|
||||||
shellModeActive,
|
shellModeActive,
|
||||||
|
getPreferredEditor,
|
||||||
);
|
);
|
||||||
const { elapsedTime, currentLoadingPhrase } =
|
const { elapsedTime, currentLoadingPhrase } =
|
||||||
useLoadingIndicator(streamingState);
|
useLoadingIndicator(streamingState);
|
||||||
|
@ -409,6 +433,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||||
item={{ ...item, id: 0 }}
|
item={{ ...item, id: 0 }}
|
||||||
isPending={true}
|
isPending={true}
|
||||||
config={config}
|
config={config}
|
||||||
|
isFocused={!isEditorDialogOpen}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
@ -444,6 +469,19 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||||
settings={settings}
|
settings={settings}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</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
|
<LoadingIndicator
|
||||||
|
|
|
@ -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;
|
availableTerminalHeight: number;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
config?: Config;
|
config?: Config;
|
||||||
|
isFocused?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||||
|
@ -31,6 +32,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||||
availableTerminalHeight,
|
availableTerminalHeight,
|
||||||
isPending,
|
isPending,
|
||||||
config,
|
config,
|
||||||
|
isFocused = true,
|
||||||
}) => (
|
}) => (
|
||||||
<Box flexDirection="column" key={item.id}>
|
<Box flexDirection="column" key={item.id}>
|
||||||
{/* Render standard message types */}
|
{/* Render standard message types */}
|
||||||
|
@ -76,6 +78,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||||
groupId={item.id}
|
groupId={item.id}
|
||||||
availableTerminalHeight={availableTerminalHeight}
|
availableTerminalHeight={availableTerminalHeight}
|
||||||
config={config}
|
config={config}
|
||||||
|
isFocused={isFocused}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
@ -13,7 +13,6 @@ import {
|
||||||
ToolConfirmationOutcome,
|
ToolConfirmationOutcome,
|
||||||
ToolExecuteConfirmationDetails,
|
ToolExecuteConfirmationDetails,
|
||||||
ToolMcpConfirmationDetails,
|
ToolMcpConfirmationDetails,
|
||||||
checkHasEditor,
|
|
||||||
Config,
|
Config,
|
||||||
} from '@gemini-cli/core';
|
} from '@gemini-cli/core';
|
||||||
import {
|
import {
|
||||||
|
@ -24,14 +23,16 @@ import {
|
||||||
export interface ToolConfirmationMessageProps {
|
export interface ToolConfirmationMessageProps {
|
||||||
confirmationDetails: ToolCallConfirmationDetails;
|
confirmationDetails: ToolCallConfirmationDetails;
|
||||||
config?: Config;
|
config?: Config;
|
||||||
|
isFocused?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ToolConfirmationMessage: React.FC<
|
export const ToolConfirmationMessage: React.FC<
|
||||||
ToolConfirmationMessageProps
|
ToolConfirmationMessageProps
|
||||||
> = ({ confirmationDetails, config }) => {
|
> = ({ confirmationDetails, config, isFocused = true }) => {
|
||||||
const { onConfirm } = confirmationDetails;
|
const { onConfirm } = confirmationDetails;
|
||||||
|
|
||||||
useInput((_, key) => {
|
useInput((_, key) => {
|
||||||
|
if (!isFocused) return;
|
||||||
if (key.escape) {
|
if (key.escape) {
|
||||||
onConfirm(ToolConfirmationOutcome.Cancel);
|
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 =
|
const externalEditorsEnabled =
|
||||||
config?.getEnableModifyWithExternalEditors() ?? false;
|
config?.getEnableModifyWithExternalEditors() ?? false;
|
||||||
|
if (externalEditorsEnabled) {
|
||||||
if (checkHasEditor('vscode') && notUsingSandbox && externalEditorsEnabled) {
|
|
||||||
options.push({
|
options.push({
|
||||||
label: 'Modify with VS Code',
|
label: 'Modify with external editor',
|
||||||
value: ToolConfirmationOutcome.ModifyVSCode,
|
value: ToolConfirmationOutcome.ModifyWithEditor,
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -192,7 +165,11 @@ export const ToolConfirmationMessage: React.FC<
|
||||||
|
|
||||||
{/* Select Input for Options */}
|
{/* Select Input for Options */}
|
||||||
<Box flexShrink={0}>
|
<Box flexShrink={0}>
|
||||||
<RadioButtonSelect items={options} onSelect={handleSelect} />
|
<RadioButtonSelect
|
||||||
|
items={options}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
isFocused={isFocused}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
|
@ -17,6 +17,7 @@ interface ToolGroupMessageProps {
|
||||||
toolCalls: IndividualToolCallDisplay[];
|
toolCalls: IndividualToolCallDisplay[];
|
||||||
availableTerminalHeight: number;
|
availableTerminalHeight: number;
|
||||||
config?: Config;
|
config?: Config;
|
||||||
|
isFocused?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main component renders the border and maps the tools using ToolMessage
|
// Main component renders the border and maps the tools using ToolMessage
|
||||||
|
@ -24,6 +25,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||||
toolCalls,
|
toolCalls,
|
||||||
availableTerminalHeight,
|
availableTerminalHeight,
|
||||||
config,
|
config,
|
||||||
|
isFocused = true,
|
||||||
}) => {
|
}) => {
|
||||||
const hasPending = !toolCalls.every(
|
const hasPending = !toolCalls.every(
|
||||||
(t) => t.status === ToolCallStatus.Success,
|
(t) => t.status === ToolCallStatus.Success,
|
||||||
|
@ -84,6 +86,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||||
<ToolConfirmationMessage
|
<ToolConfirmationMessage
|
||||||
confirmationDetails={tool.confirmationDetails}
|
confirmationDetails={tool.confirmationDetails}
|
||||||
config={config}
|
config={config}
|
||||||
|
isFocused={isFocused}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
@ -19,6 +19,7 @@ import { Colors } from '../../colors.js';
|
||||||
export interface RadioSelectItem<T> {
|
export interface RadioSelectItem<T> {
|
||||||
label: string;
|
label: string;
|
||||||
value: T;
|
value: T;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -97,11 +98,14 @@ export function RadioButtonSelect<T>({
|
||||||
const itemWithThemeProps = props as typeof props & {
|
const itemWithThemeProps = props as typeof props & {
|
||||||
themeNameDisplay?: string;
|
themeNameDisplay?: string;
|
||||||
themeTypeDisplay?: string;
|
themeTypeDisplay?: string;
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
let textColor = Colors.Foreground;
|
let textColor = Colors.Foreground;
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
textColor = Colors.AccentGreen;
|
textColor = Colors.AccentGreen;
|
||||||
|
} else if (itemWithThemeProps.disabled === true) {
|
||||||
|
textColor = Colors.Gray;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|
|
@ -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 mockSetShowHelp: ReturnType<typeof vi.fn>;
|
||||||
let mockOnDebugMessage: ReturnType<typeof vi.fn>;
|
let mockOnDebugMessage: ReturnType<typeof vi.fn>;
|
||||||
let mockOpenThemeDialog: ReturnType<typeof vi.fn>;
|
let mockOpenThemeDialog: ReturnType<typeof vi.fn>;
|
||||||
|
let mockOpenEditorDialog: ReturnType<typeof vi.fn>;
|
||||||
let mockPerformMemoryRefresh: ReturnType<typeof vi.fn>;
|
let mockPerformMemoryRefresh: ReturnType<typeof vi.fn>;
|
||||||
let mockSetQuittingMessages: ReturnType<typeof vi.fn>;
|
let mockSetQuittingMessages: ReturnType<typeof vi.fn>;
|
||||||
let mockConfig: Config;
|
let mockConfig: Config;
|
||||||
|
@ -111,6 +112,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
mockSetShowHelp = vi.fn();
|
mockSetShowHelp = vi.fn();
|
||||||
mockOnDebugMessage = vi.fn();
|
mockOnDebugMessage = vi.fn();
|
||||||
mockOpenThemeDialog = vi.fn();
|
mockOpenThemeDialog = vi.fn();
|
||||||
|
mockOpenEditorDialog = vi.fn();
|
||||||
mockPerformMemoryRefresh = vi.fn().mockResolvedValue(undefined);
|
mockPerformMemoryRefresh = vi.fn().mockResolvedValue(undefined);
|
||||||
mockSetQuittingMessages = vi.fn();
|
mockSetQuittingMessages = vi.fn();
|
||||||
mockConfig = {
|
mockConfig = {
|
||||||
|
@ -155,6 +157,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
mockSetShowHelp,
|
mockSetShowHelp,
|
||||||
mockOnDebugMessage,
|
mockOnDebugMessage,
|
||||||
mockOpenThemeDialog,
|
mockOpenThemeDialog,
|
||||||
|
mockOpenEditorDialog,
|
||||||
mockPerformMemoryRefresh,
|
mockPerformMemoryRefresh,
|
||||||
mockCorgiMode,
|
mockCorgiMode,
|
||||||
showToolDescriptions,
|
showToolDescriptions,
|
||||||
|
@ -322,6 +325,16 @@ describe('useSlashCommandProcessor', () => {
|
||||||
expect(mockSetShowHelp).toHaveBeenCalledWith(true);
|
expect(mockSetShowHelp).toHaveBeenCalledWith(true);
|
||||||
expect(commandResult).toBe(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', () => {
|
describe('/bug command', () => {
|
||||||
|
|
|
@ -66,6 +66,7 @@ export const useSlashCommandProcessor = (
|
||||||
setShowHelp: React.Dispatch<React.SetStateAction<boolean>>,
|
setShowHelp: React.Dispatch<React.SetStateAction<boolean>>,
|
||||||
onDebugMessage: (message: string) => void,
|
onDebugMessage: (message: string) => void,
|
||||||
openThemeDialog: () => void,
|
openThemeDialog: () => void,
|
||||||
|
openEditorDialog: () => void,
|
||||||
performMemoryRefresh: () => Promise<void>,
|
performMemoryRefresh: () => Promise<void>,
|
||||||
toggleCorgiMode: () => void,
|
toggleCorgiMode: () => void,
|
||||||
showToolDescriptions: boolean = false,
|
showToolDescriptions: boolean = false,
|
||||||
|
@ -181,6 +182,13 @@ export const useSlashCommandProcessor = (
|
||||||
openThemeDialog();
|
openThemeDialog();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'editor',
|
||||||
|
description: 'open the editor',
|
||||||
|
action: (_mainCommand, _subCommand, _args) => {
|
||||||
|
openEditorDialog();
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'stats',
|
name: 'stats',
|
||||||
altName: 'usage',
|
altName: 'usage',
|
||||||
|
@ -745,6 +753,7 @@ Add any other context about the problem here.
|
||||||
setShowHelp,
|
setShowHelp,
|
||||||
refreshStatic,
|
refreshStatic,
|
||||||
openThemeDialog,
|
openThemeDialog,
|
||||||
|
openEditorDialog,
|
||||||
clearItems,
|
clearItems,
|
||||||
performMemoryRefresh,
|
performMemoryRefresh,
|
||||||
showMemoryAction,
|
showMemoryAction,
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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,
|
TrackedExecutingToolCall,
|
||||||
TrackedCancelledToolCall,
|
TrackedCancelledToolCall,
|
||||||
} from './useReactToolScheduler.js';
|
} from './useReactToolScheduler.js';
|
||||||
import { Config } from '@gemini-cli/core';
|
import { Config, EditorType } from '@gemini-cli/core';
|
||||||
import { Part, PartListUnion } from '@google/genai';
|
import { Part, PartListUnion } from '@google/genai';
|
||||||
import { UseHistoryManagerReturn } from './useHistoryManager.js';
|
import { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||||
import { HistoryItem } from '../types.js';
|
import { HistoryItem } from '../types.js';
|
||||||
import { Dispatch, SetStateAction } from 'react';
|
import { Dispatch, SetStateAction } from 'react';
|
||||||
|
import { LoadedSettings } from '../../config/settings.js';
|
||||||
|
|
||||||
// --- MOCKS ---
|
// --- MOCKS ---
|
||||||
const mockSendMessageStream = vi
|
const mockSendMessageStream = vi
|
||||||
|
@ -309,6 +310,15 @@ describe('useGeminiStream', () => {
|
||||||
.mockReturnValue((async function* () {})());
|
.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 = (
|
const renderTestHook = (
|
||||||
initialToolCalls: TrackedToolCall[] = [],
|
initialToolCalls: TrackedToolCall[] = [],
|
||||||
geminiClient?: any,
|
geminiClient?: any,
|
||||||
|
@ -337,6 +347,7 @@ describe('useGeminiStream', () => {
|
||||||
| boolean
|
| boolean
|
||||||
>;
|
>;
|
||||||
shellModeActive: boolean;
|
shellModeActive: boolean;
|
||||||
|
loadedSettings: LoadedSettings;
|
||||||
}) =>
|
}) =>
|
||||||
useGeminiStream(
|
useGeminiStream(
|
||||||
props.client,
|
props.client,
|
||||||
|
@ -347,6 +358,7 @@ describe('useGeminiStream', () => {
|
||||||
props.onDebugMessage,
|
props.onDebugMessage,
|
||||||
props.handleSlashCommand,
|
props.handleSlashCommand,
|
||||||
props.shellModeActive,
|
props.shellModeActive,
|
||||||
|
() => 'vscode' as EditorType,
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
initialProps: {
|
initialProps: {
|
||||||
|
@ -363,6 +375,7 @@ describe('useGeminiStream', () => {
|
||||||
| boolean
|
| boolean
|
||||||
>,
|
>,
|
||||||
shellModeActive: false,
|
shellModeActive: false,
|
||||||
|
loadedSettings: mockLoadedSettings,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -486,6 +499,7 @@ describe('useGeminiStream', () => {
|
||||||
handleSlashCommand:
|
handleSlashCommand:
|
||||||
mockHandleSlashCommand as unknown as typeof mockHandleSlashCommand,
|
mockHandleSlashCommand as unknown as typeof mockHandleSlashCommand,
|
||||||
shellModeActive: false,
|
shellModeActive: false,
|
||||||
|
loadedSettings: mockLoadedSettings,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -541,6 +555,7 @@ describe('useGeminiStream', () => {
|
||||||
handleSlashCommand:
|
handleSlashCommand:
|
||||||
mockHandleSlashCommand as unknown as typeof mockHandleSlashCommand,
|
mockHandleSlashCommand as unknown as typeof mockHandleSlashCommand,
|
||||||
shellModeActive: false,
|
shellModeActive: false,
|
||||||
|
loadedSettings: mockLoadedSettings,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -19,6 +19,7 @@ import {
|
||||||
ToolCallRequestInfo,
|
ToolCallRequestInfo,
|
||||||
logUserPrompt,
|
logUserPrompt,
|
||||||
GitService,
|
GitService,
|
||||||
|
EditorType,
|
||||||
} from '@gemini-cli/core';
|
} from '@gemini-cli/core';
|
||||||
import { type Part, type PartListUnion } from '@google/genai';
|
import { type Part, type PartListUnion } from '@google/genai';
|
||||||
import {
|
import {
|
||||||
|
@ -83,6 +84,7 @@ export const useGeminiStream = (
|
||||||
import('./slashCommandProcessor.js').SlashCommandActionReturn | boolean
|
import('./slashCommandProcessor.js').SlashCommandActionReturn | boolean
|
||||||
>,
|
>,
|
||||||
shellModeActive: boolean,
|
shellModeActive: boolean,
|
||||||
|
getPreferredEditor: () => EditorType | undefined,
|
||||||
) => {
|
) => {
|
||||||
const [initError, setInitError] = useState<string | null>(null);
|
const [initError, setInitError] = useState<string | null>(null);
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
@ -115,6 +117,7 @@ export const useGeminiStream = (
|
||||||
},
|
},
|
||||||
config,
|
config,
|
||||||
setPendingHistoryItem,
|
setPendingHistoryItem,
|
||||||
|
getPreferredEditor,
|
||||||
);
|
);
|
||||||
|
|
||||||
const pendingToolCallGroupDisplay = useMemo(
|
const pendingToolCallGroupDisplay = useMemo(
|
||||||
|
|
|
@ -21,6 +21,7 @@ import {
|
||||||
ToolCall,
|
ToolCall,
|
||||||
Status as CoreStatus,
|
Status as CoreStatus,
|
||||||
logToolCall,
|
logToolCall,
|
||||||
|
EditorType,
|
||||||
} from '@gemini-cli/core';
|
} from '@gemini-cli/core';
|
||||||
import { useCallback, useState, useMemo } from 'react';
|
import { useCallback, useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
|
@ -69,6 +70,7 @@ export function useReactToolScheduler(
|
||||||
setPendingHistoryItem: React.Dispatch<
|
setPendingHistoryItem: React.Dispatch<
|
||||||
React.SetStateAction<HistoryItemWithoutId | null>
|
React.SetStateAction<HistoryItemWithoutId | null>
|
||||||
>,
|
>,
|
||||||
|
getPreferredEditor: () => EditorType | undefined,
|
||||||
): [TrackedToolCall[], ScheduleFn, MarkToolsAsSubmittedFn] {
|
): [TrackedToolCall[], ScheduleFn, MarkToolsAsSubmittedFn] {
|
||||||
const [toolCallsForDisplay, setToolCallsForDisplay] = useState<
|
const [toolCallsForDisplay, setToolCallsForDisplay] = useState<
|
||||||
TrackedToolCall[]
|
TrackedToolCall[]
|
||||||
|
@ -162,12 +164,14 @@ export function useReactToolScheduler(
|
||||||
onAllToolCallsComplete: allToolCallsCompleteHandler,
|
onAllToolCallsComplete: allToolCallsCompleteHandler,
|
||||||
onToolCallsUpdate: toolCallsUpdateHandler,
|
onToolCallsUpdate: toolCallsUpdateHandler,
|
||||||
approvalMode: config.getApprovalMode(),
|
approvalMode: config.getApprovalMode(),
|
||||||
|
getPreferredEditor,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
config,
|
config,
|
||||||
outputUpdateHandler,
|
outputUpdateHandler,
|
||||||
allToolCallsCompleteHandler,
|
allToolCallsCompleteHandler,
|
||||||
toolCallsUpdateHandler,
|
toolCallsUpdateHandler,
|
||||||
|
getPreferredEditor,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -62,7 +62,6 @@ describe('CoreToolScheduler', () => {
|
||||||
getFunctionDeclarations: () => [],
|
getFunctionDeclarations: () => [],
|
||||||
tools: new Map(),
|
tools: new Map(),
|
||||||
discovery: {} as any,
|
discovery: {} as any,
|
||||||
config: {} as any,
|
|
||||||
registerTool: () => {},
|
registerTool: () => {},
|
||||||
getToolByName: () => mockTool,
|
getToolByName: () => mockTool,
|
||||||
getToolByDisplayName: () => mockTool,
|
getToolByDisplayName: () => mockTool,
|
||||||
|
@ -79,6 +78,7 @@ describe('CoreToolScheduler', () => {
|
||||||
toolRegistry: Promise.resolve(toolRegistry as any),
|
toolRegistry: Promise.resolve(toolRegistry as any),
|
||||||
onAllToolCallsComplete,
|
onAllToolCallsComplete,
|
||||||
onToolCallsUpdate,
|
onToolCallsUpdate,
|
||||||
|
getPreferredEditor: () => 'vscode',
|
||||||
});
|
});
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
|
|
@ -15,6 +15,7 @@ import {
|
||||||
ApprovalMode,
|
ApprovalMode,
|
||||||
EditTool,
|
EditTool,
|
||||||
EditToolParams,
|
EditToolParams,
|
||||||
|
EditorType,
|
||||||
} from '../index.js';
|
} from '../index.js';
|
||||||
import { Part, PartListUnion } from '@google/genai';
|
import { Part, PartListUnion } from '@google/genai';
|
||||||
import { getResponseTextFromParts } from '../utils/generateContentResponseUtilities.js';
|
import { getResponseTextFromParts } from '../utils/generateContentResponseUtilities.js';
|
||||||
|
@ -203,6 +204,7 @@ interface CoreToolSchedulerOptions {
|
||||||
onAllToolCallsComplete?: AllToolCallsCompleteHandler;
|
onAllToolCallsComplete?: AllToolCallsCompleteHandler;
|
||||||
onToolCallsUpdate?: ToolCallsUpdateHandler;
|
onToolCallsUpdate?: ToolCallsUpdateHandler;
|
||||||
approvalMode?: ApprovalMode;
|
approvalMode?: ApprovalMode;
|
||||||
|
getPreferredEditor: () => EditorType | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CoreToolScheduler {
|
export class CoreToolScheduler {
|
||||||
|
@ -212,6 +214,7 @@ export class CoreToolScheduler {
|
||||||
private onAllToolCallsComplete?: AllToolCallsCompleteHandler;
|
private onAllToolCallsComplete?: AllToolCallsCompleteHandler;
|
||||||
private onToolCallsUpdate?: ToolCallsUpdateHandler;
|
private onToolCallsUpdate?: ToolCallsUpdateHandler;
|
||||||
private approvalMode: ApprovalMode;
|
private approvalMode: ApprovalMode;
|
||||||
|
private getPreferredEditor: () => EditorType | undefined;
|
||||||
|
|
||||||
constructor(options: CoreToolSchedulerOptions) {
|
constructor(options: CoreToolSchedulerOptions) {
|
||||||
this.toolRegistry = options.toolRegistry;
|
this.toolRegistry = options.toolRegistry;
|
||||||
|
@ -219,6 +222,7 @@ export class CoreToolScheduler {
|
||||||
this.onAllToolCallsComplete = options.onAllToolCallsComplete;
|
this.onAllToolCallsComplete = options.onAllToolCallsComplete;
|
||||||
this.onToolCallsUpdate = options.onToolCallsUpdate;
|
this.onToolCallsUpdate = options.onToolCallsUpdate;
|
||||||
this.approvalMode = options.approvalMode ?? ApprovalMode.DEFAULT;
|
this.approvalMode = options.approvalMode ?? ApprovalMode.DEFAULT;
|
||||||
|
this.getPreferredEditor = options.getPreferredEditor;
|
||||||
}
|
}
|
||||||
|
|
||||||
private setStatusInternal(
|
private setStatusInternal(
|
||||||
|
@ -484,15 +488,15 @@ export class CoreToolScheduler {
|
||||||
'cancelled',
|
'cancelled',
|
||||||
'User did not allow tool call',
|
'User did not allow tool call',
|
||||||
);
|
);
|
||||||
} else if (
|
} else if (outcome === ToolConfirmationOutcome.ModifyWithEditor) {
|
||||||
outcome === ToolConfirmationOutcome.ModifyVSCode ||
|
|
||||||
outcome === ToolConfirmationOutcome.ModifyWindsurf ||
|
|
||||||
outcome === ToolConfirmationOutcome.ModifyCursor ||
|
|
||||||
outcome === ToolConfirmationOutcome.ModifyVim
|
|
||||||
) {
|
|
||||||
const waitingToolCall = toolCall as WaitingToolCall;
|
const waitingToolCall = toolCall as WaitingToolCall;
|
||||||
if (waitingToolCall?.confirmationDetails?.type === 'edit') {
|
if (waitingToolCall?.confirmationDetails?.type === 'edit') {
|
||||||
const editTool = waitingToolCall.tool as EditTool;
|
const editTool = waitingToolCall.tool as EditTool;
|
||||||
|
const editorType = this.getPreferredEditor();
|
||||||
|
if (!editorType) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.setStatusInternal(callId, 'awaiting_approval', {
|
this.setStatusInternal(callId, 'awaiting_approval', {
|
||||||
...waitingToolCall.confirmationDetails,
|
...waitingToolCall.confirmationDetails,
|
||||||
isModifying: true,
|
isModifying: true,
|
||||||
|
@ -501,7 +505,7 @@ export class CoreToolScheduler {
|
||||||
const modifyResults = await editTool.onModify(
|
const modifyResults = await editTool.onModify(
|
||||||
waitingToolCall.request.args as unknown as EditToolParams,
|
waitingToolCall.request.args as unknown as EditToolParams,
|
||||||
signal,
|
signal,
|
||||||
outcome,
|
editorType,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (modifyResults) {
|
if (modifyResults) {
|
||||||
|
|
|
@ -32,7 +32,6 @@ import fs from 'fs';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import { ApprovalMode, Config } from '../config/config.js';
|
import { ApprovalMode, Config } from '../config/config.js';
|
||||||
import { Content, Part, SchemaUnion } from '@google/genai';
|
import { Content, Part, SchemaUnion } from '@google/genai';
|
||||||
import { ToolConfirmationOutcome } from './tools.js';
|
|
||||||
|
|
||||||
describe('EditTool', () => {
|
describe('EditTool', () => {
|
||||||
let tool: EditTool;
|
let tool: EditTool;
|
||||||
|
@ -634,7 +633,7 @@ describe('EditTool', () => {
|
||||||
const result = await tool.onModify(
|
const result = await tool.onModify(
|
||||||
params,
|
params,
|
||||||
new AbortController().signal,
|
new AbortController().signal,
|
||||||
ToolConfirmationOutcome.ModifyVSCode,
|
'vscode',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mockOpenDiff).toHaveBeenCalledTimes(1);
|
expect(mockOpenDiff).toHaveBeenCalledTimes(1);
|
||||||
|
@ -678,7 +677,7 @@ describe('EditTool', () => {
|
||||||
const result = await tool.onModify(
|
const result = await tool.onModify(
|
||||||
params,
|
params,
|
||||||
new AbortController().signal,
|
new AbortController().signal,
|
||||||
ToolConfirmationOutcome.ModifyVSCode,
|
'vscode',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mockOpenDiff).toHaveBeenCalledTimes(1);
|
expect(mockOpenDiff).toHaveBeenCalledTimes(1);
|
||||||
|
@ -711,7 +710,7 @@ describe('EditTool', () => {
|
||||||
const result1 = await tool.onModify(
|
const result1 = await tool.onModify(
|
||||||
params,
|
params,
|
||||||
new AbortController().signal,
|
new AbortController().signal,
|
||||||
ToolConfirmationOutcome.ModifyVSCode,
|
'vscode',
|
||||||
);
|
);
|
||||||
const firstCall = mockOpenDiff.mock.calls[0];
|
const firstCall = mockOpenDiff.mock.calls[0];
|
||||||
const firstOldPath = firstCall[0];
|
const firstOldPath = firstCall[0];
|
||||||
|
@ -727,7 +726,7 @@ describe('EditTool', () => {
|
||||||
const result2 = await tool.onModify(
|
const result2 = await tool.onModify(
|
||||||
params,
|
params,
|
||||||
new AbortController().signal,
|
new AbortController().signal,
|
||||||
ToolConfirmationOutcome.ModifyVSCode,
|
'vscode',
|
||||||
);
|
);
|
||||||
const secondCall = mockOpenDiff.mock.calls[1];
|
const secondCall = mockOpenDiff.mock.calls[1];
|
||||||
const secondOldPath = secondCall[0];
|
const secondOldPath = secondCall[0];
|
||||||
|
|
|
@ -432,19 +432,6 @@ Expectation for required parameters:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getEditor(outcome: ToolConfirmationOutcome): Promise<EditorType> {
|
|
||||||
switch (outcome) {
|
|
||||||
case ToolConfirmationOutcome.ModifyVSCode:
|
|
||||||
return 'vscode';
|
|
||||||
case ToolConfirmationOutcome.ModifyWindsurf:
|
|
||||||
return 'windsurf';
|
|
||||||
case ToolConfirmationOutcome.ModifyCursor:
|
|
||||||
return 'cursor';
|
|
||||||
default:
|
|
||||||
return 'vim';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates temp files for the current and proposed file contents and opens a diff tool.
|
* Creates temp files for the current and proposed file contents and opens a diff tool.
|
||||||
* When the diff tool is closed, the tool will check if the file has been modified and provide the updated params.
|
* When the diff tool is closed, the tool will check if the file has been modified and provide the updated params.
|
||||||
|
@ -453,7 +440,7 @@ Expectation for required parameters:
|
||||||
async onModify(
|
async onModify(
|
||||||
params: EditToolParams,
|
params: EditToolParams,
|
||||||
_abortSignal: AbortSignal,
|
_abortSignal: AbortSignal,
|
||||||
outcome: ToolConfirmationOutcome,
|
editorType: EditorType,
|
||||||
): Promise<
|
): Promise<
|
||||||
{ updatedParams: EditToolParams; updatedDiff: string } | undefined
|
{ updatedParams: EditToolParams; updatedDiff: string } | undefined
|
||||||
> {
|
> {
|
||||||
|
@ -461,9 +448,7 @@ Expectation for required parameters:
|
||||||
this.tempOldDiffPath = oldPath;
|
this.tempOldDiffPath = oldPath;
|
||||||
this.tempNewDiffPath = newPath;
|
this.tempNewDiffPath = newPath;
|
||||||
|
|
||||||
const editor = await this.getEditor(outcome);
|
await openDiff(this.tempOldDiffPath, this.tempNewDiffPath, editorType);
|
||||||
|
|
||||||
await openDiff(this.tempOldDiffPath, this.tempNewDiffPath, editor);
|
|
||||||
return await this.getUpdatedParamsIfModified(params, _abortSignal);
|
return await this.getUpdatedParamsIfModified(params, _abortSignal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -232,9 +232,6 @@ export enum ToolConfirmationOutcome {
|
||||||
ProceedAlways = 'proceed_always',
|
ProceedAlways = 'proceed_always',
|
||||||
ProceedAlwaysServer = 'proceed_always_server',
|
ProceedAlwaysServer = 'proceed_always_server',
|
||||||
ProceedAlwaysTool = 'proceed_always_tool',
|
ProceedAlwaysTool = 'proceed_always_tool',
|
||||||
ModifyVSCode = 'modify_vscode',
|
ModifyWithEditor = 'modify_with_editor',
|
||||||
ModifyWindsurf = 'modify_windsurf',
|
|
||||||
ModifyCursor = 'modify_cursor',
|
|
||||||
ModifyVim = 'modify_vim',
|
|
||||||
Cancel = 'cancel',
|
Cancel = 'cancel',
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,8 +4,22 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest';
|
import {
|
||||||
import { checkHasEditor, getDiffCommand, openDiff } from './editor.js';
|
vi,
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect,
|
||||||
|
beforeEach,
|
||||||
|
afterEach,
|
||||||
|
type Mock,
|
||||||
|
} from 'vitest';
|
||||||
|
import {
|
||||||
|
checkHasEditorType,
|
||||||
|
getDiffCommand,
|
||||||
|
openDiff,
|
||||||
|
allowEditorTypeInSandbox,
|
||||||
|
isEditorAvailable,
|
||||||
|
} from './editor.js';
|
||||||
import { execSync, spawn } from 'child_process';
|
import { execSync, spawn } from 'child_process';
|
||||||
|
|
||||||
vi.mock('child_process', () => ({
|
vi.mock('child_process', () => ({
|
||||||
|
@ -13,14 +27,14 @@ vi.mock('child_process', () => ({
|
||||||
spawn: vi.fn(),
|
spawn: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('checkHasEditor', () => {
|
describe('checkHasEditorType', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return true for vscode if "code" command exists', () => {
|
it('should return true for vscode if "code" command exists', () => {
|
||||||
(execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/code'));
|
(execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/code'));
|
||||||
expect(checkHasEditor('vscode')).toBe(true);
|
expect(checkHasEditorType('vscode')).toBe(true);
|
||||||
const expectedCommand =
|
const expectedCommand =
|
||||||
process.platform === 'win32' ? 'where.exe code.cmd' : 'command -v code';
|
process.platform === 'win32' ? 'where.exe code.cmd' : 'command -v code';
|
||||||
expect(execSync).toHaveBeenCalledWith(expectedCommand, {
|
expect(execSync).toHaveBeenCalledWith(expectedCommand, {
|
||||||
|
@ -32,12 +46,12 @@ describe('checkHasEditor', () => {
|
||||||
(execSync as Mock).mockImplementation(() => {
|
(execSync as Mock).mockImplementation(() => {
|
||||||
throw new Error();
|
throw new Error();
|
||||||
});
|
});
|
||||||
expect(checkHasEditor('vscode')).toBe(false);
|
expect(checkHasEditorType('vscode')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return true for windsurf if "windsurf" command exists', () => {
|
it('should return true for windsurf if "windsurf" command exists', () => {
|
||||||
(execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/windsurf'));
|
(execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/windsurf'));
|
||||||
expect(checkHasEditor('windsurf')).toBe(true);
|
expect(checkHasEditorType('windsurf')).toBe(true);
|
||||||
expect(execSync).toHaveBeenCalledWith('command -v windsurf', {
|
expect(execSync).toHaveBeenCalledWith('command -v windsurf', {
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
});
|
});
|
||||||
|
@ -47,12 +61,12 @@ describe('checkHasEditor', () => {
|
||||||
(execSync as Mock).mockImplementation(() => {
|
(execSync as Mock).mockImplementation(() => {
|
||||||
throw new Error();
|
throw new Error();
|
||||||
});
|
});
|
||||||
expect(checkHasEditor('windsurf')).toBe(false);
|
expect(checkHasEditorType('windsurf')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return true for cursor if "cursor" command exists', () => {
|
it('should return true for cursor if "cursor" command exists', () => {
|
||||||
(execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/cursor'));
|
(execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/cursor'));
|
||||||
expect(checkHasEditor('cursor')).toBe(true);
|
expect(checkHasEditorType('cursor')).toBe(true);
|
||||||
expect(execSync).toHaveBeenCalledWith('command -v cursor', {
|
expect(execSync).toHaveBeenCalledWith('command -v cursor', {
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
});
|
});
|
||||||
|
@ -62,12 +76,12 @@ describe('checkHasEditor', () => {
|
||||||
(execSync as Mock).mockImplementation(() => {
|
(execSync as Mock).mockImplementation(() => {
|
||||||
throw new Error();
|
throw new Error();
|
||||||
});
|
});
|
||||||
expect(checkHasEditor('cursor')).toBe(false);
|
expect(checkHasEditorType('cursor')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return true for vim if "vim" command exists', () => {
|
it('should return true for vim if "vim" command exists', () => {
|
||||||
(execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/vim'));
|
(execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/vim'));
|
||||||
expect(checkHasEditor('vim')).toBe(true);
|
expect(checkHasEditorType('vim')).toBe(true);
|
||||||
const expectedCommand =
|
const expectedCommand =
|
||||||
process.platform === 'win32' ? 'where.exe vim' : 'command -v vim';
|
process.platform === 'win32' ? 'where.exe vim' : 'command -v vim';
|
||||||
expect(execSync).toHaveBeenCalledWith(expectedCommand, {
|
expect(execSync).toHaveBeenCalledWith(expectedCommand, {
|
||||||
|
@ -79,7 +93,7 @@ describe('checkHasEditor', () => {
|
||||||
(execSync as Mock).mockImplementation(() => {
|
(execSync as Mock).mockImplementation(() => {
|
||||||
throw new Error();
|
throw new Error();
|
||||||
});
|
});
|
||||||
expect(checkHasEditor('vim')).toBe(false);
|
expect(checkHasEditorType('vim')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -153,3 +167,85 @@ describe('openDiff', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('allowEditorTypeInSandbox', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.SANDBOX;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow vim in sandbox mode', () => {
|
||||||
|
process.env.SANDBOX = 'sandbox';
|
||||||
|
expect(allowEditorTypeInSandbox('vim')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow vim when not in sandbox mode', () => {
|
||||||
|
delete process.env.SANDBOX;
|
||||||
|
expect(allowEditorTypeInSandbox('vim')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow vscode in sandbox mode', () => {
|
||||||
|
process.env.SANDBOX = 'sandbox';
|
||||||
|
expect(allowEditorTypeInSandbox('vscode')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow vscode when not in sandbox mode', () => {
|
||||||
|
delete process.env.SANDBOX;
|
||||||
|
expect(allowEditorTypeInSandbox('vscode')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow windsurf in sandbox mode', () => {
|
||||||
|
process.env.SANDBOX = 'sandbox';
|
||||||
|
expect(allowEditorTypeInSandbox('windsurf')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow windsurf when not in sandbox mode', () => {
|
||||||
|
delete process.env.SANDBOX;
|
||||||
|
expect(allowEditorTypeInSandbox('windsurf')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow cursor in sandbox mode', () => {
|
||||||
|
process.env.SANDBOX = 'sandbox';
|
||||||
|
expect(allowEditorTypeInSandbox('cursor')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow cursor when not in sandbox mode', () => {
|
||||||
|
delete process.env.SANDBOX;
|
||||||
|
expect(allowEditorTypeInSandbox('cursor')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isEditorAvailable', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.SANDBOX;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for undefined editor', () => {
|
||||||
|
expect(isEditorAvailable(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for empty string editor', () => {
|
||||||
|
expect(isEditorAvailable('')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for invalid editor type', () => {
|
||||||
|
expect(isEditorAvailable('invalid-editor')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for vscode when installed and not in sandbox mode', () => {
|
||||||
|
(execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/code'));
|
||||||
|
expect(isEditorAvailable('vscode')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for vscode when not installed and not in sandbox mode', () => {
|
||||||
|
(execSync as Mock).mockImplementation(() => {
|
||||||
|
throw new Error();
|
||||||
|
});
|
||||||
|
expect(isEditorAvailable('vscode')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for vscode when installed and in sandbox mode', () => {
|
||||||
|
(execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/code'));
|
||||||
|
process.env.SANDBOX = 'sandbox';
|
||||||
|
expect(isEditorAvailable('vscode')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -8,6 +8,10 @@ import { execSync, spawn } from 'child_process';
|
||||||
|
|
||||||
export type EditorType = 'vscode' | 'windsurf' | 'cursor' | 'vim';
|
export type EditorType = 'vscode' | 'windsurf' | 'cursor' | 'vim';
|
||||||
|
|
||||||
|
function isValidEditorType(editor: string): editor is EditorType {
|
||||||
|
return ['vscode', 'windsurf', 'cursor', 'vim'].includes(editor);
|
||||||
|
}
|
||||||
|
|
||||||
interface DiffCommand {
|
interface DiffCommand {
|
||||||
command: string;
|
command: string;
|
||||||
args: string[];
|
args: string[];
|
||||||
|
@ -32,13 +36,35 @@ const editorCommands: Record<EditorType, { win32: string; default: string }> = {
|
||||||
vim: { win32: 'vim', default: 'vim' },
|
vim: { win32: 'vim', default: 'vim' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function checkHasEditor(editor: EditorType): boolean {
|
export function checkHasEditorType(editor: EditorType): boolean {
|
||||||
const commandConfig = editorCommands[editor];
|
const commandConfig = editorCommands[editor];
|
||||||
const command =
|
const command =
|
||||||
process.platform === 'win32' ? commandConfig.win32 : commandConfig.default;
|
process.platform === 'win32' ? commandConfig.win32 : commandConfig.default;
|
||||||
return commandExists(command);
|
return commandExists(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function allowEditorTypeInSandbox(editor: EditorType): boolean {
|
||||||
|
const notUsingSandbox = !process.env.SANDBOX;
|
||||||
|
if (['vscode', 'windsurf', 'cursor'].includes(editor)) {
|
||||||
|
return notUsingSandbox;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the editor is valid and can be used.
|
||||||
|
* Returns false if preferred editor is not set / invalid / not available / not allowed in sandbox.
|
||||||
|
*/
|
||||||
|
export function isEditorAvailable(editor: string | undefined): boolean {
|
||||||
|
if (editor && isValidEditorType(editor)) {
|
||||||
|
return (
|
||||||
|
checkHasEditorType(editor as EditorType) &&
|
||||||
|
allowEditorTypeInSandbox(editor as EditorType)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the diff command for a specific editor.
|
* Get the diff command for a specific editor.
|
||||||
*/
|
*/
|
||||||
|
|
Loading…
Reference in New Issue