feat: External editor settings (#882)

This commit is contained in:
Leo
2025-06-12 02:21:54 +01:00
committed by GitHub
parent dd53e5c96a
commit 1ef68e0612
23 changed files with 849 additions and 81 deletions

View 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>
);
}

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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 (