mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 22:21:22 -07:00
431 lines
13 KiB
TypeScript
431 lines
13 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import type React from 'react';
|
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
|
import { Text } from 'ink';
|
|
import { theme } from '../semantic-colors.js';
|
|
import type {
|
|
LoadableSettingScope,
|
|
LoadedSettings,
|
|
} from '../../config/settings.js';
|
|
import { SettingScope } from '../../config/settings.js';
|
|
import type { AgentDefinition, AgentOverride } from '@google/gemini-cli-core';
|
|
import { getCachedStringWidth } from '../utils/textUtils.js';
|
|
import {
|
|
BaseSettingsDialog,
|
|
type SettingsDialogItem,
|
|
} from './shared/BaseSettingsDialog.js';
|
|
import { getNestedValue, isRecord } from '../../utils/settingsUtils.js';
|
|
|
|
/**
|
|
* Configuration field definition for agent settings
|
|
*/
|
|
interface AgentConfigField {
|
|
key: string;
|
|
label: string;
|
|
description: string;
|
|
type: 'boolean' | 'number' | 'string';
|
|
path: string[]; // Path within AgentOverride, e.g., ['modelConfig', 'generateContentConfig', 'temperature']
|
|
defaultValue: boolean | number | string | undefined;
|
|
}
|
|
|
|
/**
|
|
* Agent configuration fields
|
|
*/
|
|
const AGENT_CONFIG_FIELDS: AgentConfigField[] = [
|
|
{
|
|
key: 'enabled',
|
|
label: 'Enabled',
|
|
description: 'Enable or disable this agent',
|
|
type: 'boolean',
|
|
path: ['enabled'],
|
|
defaultValue: true,
|
|
},
|
|
{
|
|
key: 'model',
|
|
label: 'Model',
|
|
description: "Model to use (e.g., 'gemini-2.0-flash' or 'inherit')",
|
|
type: 'string',
|
|
path: ['modelConfig', 'model'],
|
|
defaultValue: 'inherit',
|
|
},
|
|
{
|
|
key: 'temperature',
|
|
label: 'Temperature',
|
|
description: 'Sampling temperature (0.0 to 2.0)',
|
|
type: 'number',
|
|
path: ['modelConfig', 'generateContentConfig', 'temperature'],
|
|
defaultValue: undefined,
|
|
},
|
|
{
|
|
key: 'topP',
|
|
label: 'Top P',
|
|
description: 'Nucleus sampling parameter (0.0 to 1.0)',
|
|
type: 'number',
|
|
path: ['modelConfig', 'generateContentConfig', 'topP'],
|
|
defaultValue: undefined,
|
|
},
|
|
{
|
|
key: 'topK',
|
|
label: 'Top K',
|
|
description: 'Top-K sampling parameter',
|
|
type: 'number',
|
|
path: ['modelConfig', 'generateContentConfig', 'topK'],
|
|
defaultValue: undefined,
|
|
},
|
|
{
|
|
key: 'maxOutputTokens',
|
|
label: 'Max Output Tokens',
|
|
description: 'Maximum number of tokens to generate',
|
|
type: 'number',
|
|
path: ['modelConfig', 'generateContentConfig', 'maxOutputTokens'],
|
|
defaultValue: undefined,
|
|
},
|
|
{
|
|
key: 'maxTimeMinutes',
|
|
label: 'Max Time (minutes)',
|
|
description: 'Maximum execution time in minutes',
|
|
type: 'number',
|
|
path: ['runConfig', 'maxTimeMinutes'],
|
|
defaultValue: undefined,
|
|
},
|
|
{
|
|
key: 'maxTurns',
|
|
label: 'Max Turns',
|
|
description: 'Maximum number of conversational turns',
|
|
type: 'number',
|
|
path: ['runConfig', 'maxTurns'],
|
|
defaultValue: undefined,
|
|
},
|
|
];
|
|
|
|
interface AgentConfigDialogProps {
|
|
agentName: string;
|
|
displayName: string;
|
|
definition: AgentDefinition;
|
|
settings: LoadedSettings;
|
|
onClose: () => void;
|
|
onSave?: () => void;
|
|
/** Available terminal height for dynamic windowing */
|
|
availableTerminalHeight?: number;
|
|
}
|
|
|
|
/**
|
|
* Set a nested value in an object using a path array, creating intermediate objects as needed
|
|
*/
|
|
function setNestedValue(obj: unknown, path: string[], value: unknown): unknown {
|
|
if (!isRecord(obj)) return obj;
|
|
|
|
const result = { ...obj };
|
|
let current = result;
|
|
|
|
for (let i = 0; i < path.length - 1; i++) {
|
|
const key = path[i];
|
|
if (current[key] === undefined || current[key] === null) {
|
|
current[key] = {};
|
|
} else if (isRecord(current[key])) {
|
|
current[key] = { ...current[key] };
|
|
}
|
|
|
|
const next = current[key];
|
|
if (isRecord(next)) {
|
|
current = next;
|
|
} else {
|
|
// Cannot traverse further through non-objects
|
|
return result;
|
|
}
|
|
}
|
|
|
|
const finalKey = path[path.length - 1];
|
|
if (value === undefined) {
|
|
delete current[finalKey];
|
|
} else {
|
|
current[finalKey] = value;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Get the effective default value for a field from the agent definition
|
|
*/
|
|
function getFieldDefaultFromDefinition(
|
|
field: AgentConfigField,
|
|
definition: AgentDefinition,
|
|
): unknown {
|
|
if (definition.kind !== 'local') return field.defaultValue;
|
|
|
|
if (field.key === 'enabled') {
|
|
return !definition.experimental; // Experimental agents default to disabled
|
|
}
|
|
if (field.key === 'model') {
|
|
return definition.modelConfig?.model ?? 'inherit';
|
|
}
|
|
if (field.key === 'temperature') {
|
|
return definition.modelConfig?.generateContentConfig?.temperature;
|
|
}
|
|
if (field.key === 'topP') {
|
|
return definition.modelConfig?.generateContentConfig?.topP;
|
|
}
|
|
if (field.key === 'topK') {
|
|
return definition.modelConfig?.generateContentConfig?.topK;
|
|
}
|
|
if (field.key === 'maxOutputTokens') {
|
|
return definition.modelConfig?.generateContentConfig?.maxOutputTokens;
|
|
}
|
|
if (field.key === 'maxTimeMinutes') {
|
|
return definition.runConfig?.maxTimeMinutes;
|
|
}
|
|
if (field.key === 'maxTurns') {
|
|
return definition.runConfig?.maxTurns;
|
|
}
|
|
|
|
return field.defaultValue;
|
|
}
|
|
|
|
export function AgentConfigDialog({
|
|
agentName,
|
|
displayName,
|
|
definition,
|
|
settings,
|
|
onClose,
|
|
onSave,
|
|
availableTerminalHeight,
|
|
}: AgentConfigDialogProps): React.JSX.Element {
|
|
// Scope selector state (User by default)
|
|
const [selectedScope, setSelectedScope] = useState<LoadableSettingScope>(
|
|
SettingScope.User,
|
|
);
|
|
|
|
// Pending override state for the selected scope
|
|
const [pendingOverride, setPendingOverride] = useState<AgentOverride>(() => {
|
|
const scopeSettings = settings.forScope(selectedScope).settings;
|
|
const existingOverride = scopeSettings.agents?.overrides?.[agentName];
|
|
return existingOverride ? structuredClone(existingOverride) : {};
|
|
});
|
|
|
|
// Track which fields have been modified
|
|
const [modifiedFields, setModifiedFields] = useState<Set<string>>(new Set());
|
|
|
|
// Update pending override when scope changes
|
|
useEffect(() => {
|
|
const scopeSettings = settings.forScope(selectedScope).settings;
|
|
const existingOverride = scopeSettings.agents?.overrides?.[agentName];
|
|
setPendingOverride(
|
|
existingOverride ? structuredClone(existingOverride) : {},
|
|
);
|
|
setModifiedFields(new Set());
|
|
}, [selectedScope, settings, agentName]);
|
|
|
|
/**
|
|
* Save a specific field value to settings
|
|
*/
|
|
const saveFieldValue = useCallback(
|
|
(fieldKey: string, path: string[], value: unknown) => {
|
|
// Guard against prototype pollution
|
|
if (['__proto__', 'constructor', 'prototype'].includes(agentName)) {
|
|
return;
|
|
}
|
|
// Build the full settings path for agent override
|
|
// e.g., agents.overrides.<agentName>.modelConfig.generateContentConfig.temperature
|
|
const settingsPath = ['agents', 'overrides', agentName, ...path].join(
|
|
'.',
|
|
);
|
|
settings.setValue(selectedScope, settingsPath, value);
|
|
onSave?.();
|
|
},
|
|
[settings, selectedScope, agentName, onSave],
|
|
);
|
|
|
|
// Calculate max label width
|
|
const maxLabelWidth = useMemo(() => {
|
|
let max = 0;
|
|
for (const field of AGENT_CONFIG_FIELDS) {
|
|
const lWidth = getCachedStringWidth(field.label);
|
|
const dWidth = getCachedStringWidth(field.description);
|
|
max = Math.max(max, lWidth, dWidth);
|
|
}
|
|
return max;
|
|
}, []);
|
|
|
|
// Generate items for BaseSettingsDialog
|
|
const items: SettingsDialogItem[] = useMemo(
|
|
() =>
|
|
AGENT_CONFIG_FIELDS.map((field) => {
|
|
const currentValue = getNestedValue(pendingOverride, field.path);
|
|
const defaultValue = getFieldDefaultFromDefinition(field, definition);
|
|
const effectiveValue =
|
|
currentValue !== undefined ? currentValue : defaultValue;
|
|
|
|
let displayValue: string;
|
|
if (field.type === 'boolean') {
|
|
displayValue = effectiveValue ? 'true' : 'false';
|
|
} else if (effectiveValue !== undefined && effectiveValue !== null) {
|
|
displayValue = String(effectiveValue);
|
|
} else {
|
|
displayValue = '(default)';
|
|
}
|
|
|
|
// Add * if modified
|
|
const isModified =
|
|
modifiedFields.has(field.key) || currentValue !== undefined;
|
|
if (isModified && currentValue !== undefined) {
|
|
displayValue += '*';
|
|
}
|
|
|
|
// Get raw value for edit mode
|
|
const rawValue =
|
|
currentValue !== undefined ? currentValue : effectiveValue;
|
|
|
|
return {
|
|
key: field.key,
|
|
label: field.label,
|
|
description: field.description,
|
|
type: field.type,
|
|
displayValue,
|
|
isGreyedOut: currentValue === undefined,
|
|
scopeMessage: undefined,
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
rawValue: rawValue as string | number | boolean | undefined,
|
|
};
|
|
}),
|
|
[pendingOverride, definition, modifiedFields],
|
|
);
|
|
|
|
const maxItemsToShow = 8;
|
|
|
|
// Handle scope changes
|
|
const handleScopeChange = useCallback((scope: LoadableSettingScope) => {
|
|
setSelectedScope(scope);
|
|
}, []);
|
|
|
|
// Handle toggle for boolean fields
|
|
const handleItemToggle = useCallback(
|
|
(key: string, _item: SettingsDialogItem) => {
|
|
const field = AGENT_CONFIG_FIELDS.find((f) => f.key === key);
|
|
if (!field || field.type !== 'boolean') return;
|
|
|
|
const currentValue = getNestedValue(pendingOverride, field.path);
|
|
const defaultValue = getFieldDefaultFromDefinition(field, definition);
|
|
const effectiveValue =
|
|
currentValue !== undefined ? currentValue : defaultValue;
|
|
const newValue = !effectiveValue;
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
const newOverride = setNestedValue(
|
|
pendingOverride,
|
|
field.path,
|
|
newValue,
|
|
) as AgentOverride;
|
|
setPendingOverride(newOverride);
|
|
setModifiedFields((prev) => new Set(prev).add(key));
|
|
|
|
// Save the field value to settings
|
|
saveFieldValue(field.key, field.path, newValue);
|
|
},
|
|
[pendingOverride, definition, saveFieldValue],
|
|
);
|
|
|
|
// Handle edit commit for string/number fields
|
|
const handleEditCommit = useCallback(
|
|
(key: string, newValue: string, _item: SettingsDialogItem) => {
|
|
const field = AGENT_CONFIG_FIELDS.find((f) => f.key === key);
|
|
if (!field) return;
|
|
|
|
let parsed: string | number | undefined;
|
|
if (field.type === 'number') {
|
|
if (newValue.trim() === '') {
|
|
// Empty means clear the override
|
|
parsed = undefined;
|
|
} else {
|
|
const numParsed = Number(newValue.trim());
|
|
if (Number.isNaN(numParsed)) {
|
|
// Invalid number; don't save
|
|
return;
|
|
}
|
|
parsed = numParsed;
|
|
}
|
|
} else {
|
|
// For strings, empty means clear the override
|
|
parsed = newValue.trim() === '' ? undefined : newValue;
|
|
}
|
|
|
|
// Update pending override locally
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
const newOverride = setNestedValue(
|
|
pendingOverride,
|
|
field.path,
|
|
parsed,
|
|
) as AgentOverride;
|
|
|
|
setPendingOverride(newOverride);
|
|
setModifiedFields((prev) => new Set(prev).add(key));
|
|
|
|
// Save the field value to settings
|
|
saveFieldValue(field.key, field.path, parsed);
|
|
},
|
|
[pendingOverride, saveFieldValue],
|
|
);
|
|
|
|
// Handle clear/reset - reset to default value (removes override)
|
|
const handleItemClear = useCallback(
|
|
(key: string, _item: SettingsDialogItem) => {
|
|
const field = AGENT_CONFIG_FIELDS.find((f) => f.key === key);
|
|
if (!field) return;
|
|
|
|
// Remove the override (set to undefined)
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
const newOverride = setNestedValue(
|
|
pendingOverride,
|
|
field.path,
|
|
undefined,
|
|
) as AgentOverride;
|
|
|
|
setPendingOverride(newOverride);
|
|
setModifiedFields((prev) => {
|
|
const updated = new Set(prev);
|
|
updated.delete(key);
|
|
return updated;
|
|
});
|
|
|
|
// Save as undefined to remove the override
|
|
saveFieldValue(field.key, field.path, undefined);
|
|
},
|
|
[pendingOverride, saveFieldValue],
|
|
);
|
|
|
|
return (
|
|
<BaseSettingsDialog
|
|
title={`Configure: ${displayName}`}
|
|
searchEnabled={false}
|
|
items={items}
|
|
showScopeSelector={true}
|
|
selectedScope={selectedScope}
|
|
onScopeChange={handleScopeChange}
|
|
maxItemsToShow={maxItemsToShow}
|
|
availableHeight={availableTerminalHeight}
|
|
maxLabelWidth={maxLabelWidth}
|
|
onItemToggle={handleItemToggle}
|
|
onEditCommit={handleEditCommit}
|
|
onItemClear={handleItemClear}
|
|
onClose={onClose}
|
|
footer={
|
|
modifiedFields.size > 0
|
|
? {
|
|
content: (
|
|
<Text color={theme.text.secondary}>
|
|
Changes saved automatically.
|
|
</Text>
|
|
),
|
|
height: 1,
|
|
}
|
|
: undefined
|
|
}
|
|
/>
|
|
);
|
|
}
|