feat: implement AgentConfigDialog for /agents config command (#17370)

This commit is contained in:
Sandy Tao
2026-01-23 16:10:51 -08:00
committed by GitHub
parent 12a5490bcf
commit 0c134079cc
6 changed files with 821 additions and 14 deletions

View File

@@ -340,11 +340,12 @@ describe('agentsCommand', () => {
});
describe('config sub-command', () => {
it('should open agent config dialog for a valid agent', async () => {
it('should return dialog action for a valid agent', async () => {
const mockDefinition = {
name: 'test-agent',
displayName: 'Test Agent',
description: 'test desc',
kind: 'local',
};
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
getDiscoveredDefinition: vi.fn().mockReturnValue(mockDefinition),
@@ -357,19 +358,22 @@ describe('agentsCommand', () => {
const result = await configCommand!.action!(mockContext, 'test-agent');
expect(mockContext.ui.openAgentConfigDialog).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
content:
"Configuration for 'test-agent' will be available in the next update.",
type: 'dialog',
dialog: 'agentConfig',
props: {
name: 'test-agent',
displayName: 'Test Agent',
definition: mockDefinition,
},
});
});
it('should use name if displayName is missing', async () => {
it('should use name as displayName if displayName is missing', async () => {
const mockDefinition = {
name: 'test-agent',
description: 'test desc',
kind: 'local',
};
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
getDiscoveredDefinition: vi.fn().mockReturnValue(mockDefinition),
@@ -381,10 +385,13 @@ describe('agentsCommand', () => {
const result = await configCommand!.action!(mockContext, 'test-agent');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content:
"Configuration for 'test-agent' will be available in the next update.",
type: 'dialog',
dialog: 'agentConfig',
props: {
name: 'test-agent',
displayName: 'test-agent', // Falls back to name
definition: mockDefinition,
},
});
});

View File

@@ -252,10 +252,16 @@ async function configAction(
};
}
const displayName = definition.displayName || agentName;
return {
type: 'message',
messageType: 'info',
content: `Configuration for '${agentName}' will be available in the next update.`,
type: 'dialog',
dialog: 'agentConfig',
props: {
name: agentName,
displayName,
definition,
},
};
}

View File

@@ -0,0 +1,309 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from 'react';
import { AgentConfigDialog } from './AgentConfigDialog.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import type { AgentDefinition } from '@google/gemini-cli-core';
vi.mock('../contexts/UIStateContext.js', () => ({
useUIState: () => ({
mainAreaWidth: 100,
}),
}));
enum TerminalKeys {
ENTER = '\u000D',
TAB = '\t',
UP_ARROW = '\u001B[A',
DOWN_ARROW = '\u001B[B',
ESCAPE = '\u001B',
}
const createMockSettings = (
userSettings = {},
workspaceSettings = {},
): LoadedSettings => {
const settings = new LoadedSettings(
{
settings: { ui: { customThemes: {} }, mcpServers: {}, agents: {} },
originalSettings: {
ui: { customThemes: {} },
mcpServers: {},
agents: {},
},
path: '/system/settings.json',
},
{
settings: {},
originalSettings: {},
path: '/system/system-defaults.json',
},
{
settings: {
ui: { customThemes: {} },
mcpServers: {},
agents: { overrides: {} },
...userSettings,
},
originalSettings: {
ui: { customThemes: {} },
mcpServers: {},
agents: { overrides: {} },
...userSettings,
},
path: '/user/settings.json',
},
{
settings: {
ui: { customThemes: {} },
mcpServers: {},
agents: { overrides: {} },
...workspaceSettings,
},
originalSettings: {
ui: { customThemes: {} },
mcpServers: {},
agents: { overrides: {} },
...workspaceSettings,
},
path: '/workspace/settings.json',
},
true,
[],
);
// Mock setValue
settings.setValue = vi.fn();
return settings;
};
const createMockAgentDefinition = (
overrides: Partial<AgentDefinition> = {},
): AgentDefinition =>
({
name: 'test-agent',
displayName: 'Test Agent',
description: 'A test agent for testing',
kind: 'local',
modelConfig: {
model: 'inherit',
generateContentConfig: {
temperature: 1.0,
},
},
runConfig: {
maxTimeMinutes: 5,
maxTurns: 10,
},
experimental: false,
...overrides,
}) as AgentDefinition;
describe('AgentConfigDialog', () => {
let mockOnClose: ReturnType<typeof vi.fn>;
let mockOnSave: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
mockOnClose = vi.fn();
mockOnSave = vi.fn();
});
const renderDialog = (
settings: LoadedSettings,
definition: AgentDefinition = createMockAgentDefinition(),
) =>
render(
<KeypressProvider>
<AgentConfigDialog
agentName="test-agent"
displayName="Test Agent"
definition={definition}
settings={settings}
onClose={mockOnClose}
onSave={mockOnSave}
/>
</KeypressProvider>,
);
describe('rendering', () => {
it('should render the dialog with title', () => {
const settings = createMockSettings();
const { lastFrame } = renderDialog(settings);
expect(lastFrame()).toContain('Configure: Test Agent');
});
it('should render all configuration fields', () => {
const settings = createMockSettings();
const { lastFrame } = renderDialog(settings);
const frame = lastFrame();
expect(frame).toContain('Enabled');
expect(frame).toContain('Model');
expect(frame).toContain('Temperature');
expect(frame).toContain('Top P');
expect(frame).toContain('Top K');
expect(frame).toContain('Max Output Tokens');
expect(frame).toContain('Max Time (minutes)');
expect(frame).toContain('Max Turns');
});
it('should render scope selector', () => {
const settings = createMockSettings();
const { lastFrame } = renderDialog(settings);
expect(lastFrame()).toContain('Apply To');
expect(lastFrame()).toContain('User Settings');
expect(lastFrame()).toContain('Workspace Settings');
});
it('should render help text', () => {
const settings = createMockSettings();
const { lastFrame } = renderDialog(settings);
expect(lastFrame()).toContain('Use Enter to select');
expect(lastFrame()).toContain('Tab to change focus');
expect(lastFrame()).toContain('Esc to close');
});
});
describe('keyboard navigation', () => {
it('should close dialog on Escape', async () => {
const settings = createMockSettings();
const { stdin } = renderDialog(settings);
await act(async () => {
stdin.write(TerminalKeys.ESCAPE);
});
await waitFor(() => {
expect(mockOnClose).toHaveBeenCalled();
});
});
it('should navigate down with arrow key', async () => {
const settings = createMockSettings();
const { lastFrame, stdin } = renderDialog(settings);
// Initially first item (Enabled) should be active
expect(lastFrame()).toContain('●');
// Press down arrow
await act(async () => {
stdin.write(TerminalKeys.DOWN_ARROW);
});
await waitFor(() => {
// Model field should now be highlighted
expect(lastFrame()).toContain('Model');
});
});
it('should switch focus with Tab', async () => {
const settings = createMockSettings();
const { lastFrame, stdin } = renderDialog(settings);
// Initially settings section is focused
expect(lastFrame()).toContain('> Configure: Test Agent');
// Press Tab to switch to scope selector
await act(async () => {
stdin.write(TerminalKeys.TAB);
});
await waitFor(() => {
expect(lastFrame()).toContain('> Apply To');
});
});
});
describe('boolean toggle', () => {
it('should toggle enabled field on Enter', async () => {
const settings = createMockSettings();
const { stdin } = renderDialog(settings);
// Press Enter to toggle the first field (Enabled)
await act(async () => {
stdin.write(TerminalKeys.ENTER);
});
await waitFor(() => {
expect(settings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'agents.overrides.test-agent.enabled',
false, // Toggles from true (default) to false
);
expect(mockOnSave).toHaveBeenCalled();
});
});
});
describe('default values', () => {
it('should show values from agent definition as defaults', () => {
const definition = createMockAgentDefinition({
modelConfig: {
model: 'gemini-2.0-flash',
generateContentConfig: {
temperature: 0.7,
},
},
runConfig: {
maxTimeMinutes: 10,
maxTurns: 20,
},
});
const settings = createMockSettings();
const { lastFrame } = renderDialog(settings, definition);
const frame = lastFrame();
expect(frame).toContain('gemini-2.0-flash');
expect(frame).toContain('0.7');
expect(frame).toContain('10');
expect(frame).toContain('20');
});
it('should show experimental agents as disabled by default', () => {
const definition = createMockAgentDefinition({
experimental: true,
});
const settings = createMockSettings();
const { lastFrame } = renderDialog(settings, definition);
// Experimental agents default to disabled
expect(lastFrame()).toContain('false');
});
});
describe('existing overrides', () => {
it('should show existing override values with * indicator', () => {
const settings = createMockSettings({
agents: {
overrides: {
'test-agent': {
enabled: false,
modelConfig: {
model: 'custom-model',
},
},
},
},
});
const { lastFrame } = renderDialog(settings);
const frame = lastFrame();
// Should show the overridden values
expect(frame).toContain('custom-model');
expect(frame).toContain('false');
});
});
});

View File

@@ -0,0 +1,435 @@
/**
* @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';
/**
* 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;
}
/**
* Get a nested value from an object using a path array
*/
function getNestedValue(
obj: Record<string, unknown> | undefined,
path: string[],
): unknown {
if (!obj) return undefined;
let current: unknown = obj;
for (const key of path) {
if (current === null || current === undefined) return undefined;
if (typeof current !== 'object') return undefined;
current = (current as Record<string, unknown>)[key];
}
return current;
}
/**
* Set a nested value in an object using a path array, creating intermediate objects as needed
*/
function setNestedValue(
obj: Record<string, unknown>,
path: string[],
value: unknown,
): Record<string, unknown> {
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 {
current[key] = { ...(current[key] as Record<string, unknown>) };
}
current = current[key] as Record<string, unknown>;
}
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,
}: 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 as Record<string, unknown>,
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,
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 as Record<string, unknown>,
field.path,
);
const defaultValue = getFieldDefaultFromDefinition(field, definition);
const effectiveValue =
currentValue !== undefined ? currentValue : defaultValue;
const newValue = !effectiveValue;
const newOverride = setNestedValue(
pendingOverride as Record<string, unknown>,
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
const newOverride = setNestedValue(
pendingOverride as Record<string, unknown>,
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)
const newOverride = setNestedValue(
pendingOverride as Record<string, unknown>,
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],
);
// Footer content
const footerContent =
modifiedFields.size > 0 ? (
<Text color={theme.text.secondary}>Changes saved automatically.</Text>
) : null;
return (
<BaseSettingsDialog
title={`Configure: ${displayName}`}
searchEnabled={false}
items={items}
showScopeSelector={true}
selectedScope={selectedScope}
onScopeChange={handleScopeChange}
maxItemsToShow={maxItemsToShow}
maxLabelWidth={maxLabelWidth}
onItemToggle={handleItemToggle}
onEditCommit={handleEditCommit}
onItemClear={handleItemClear}
onClose={onClose}
footerContent={footerContent}
/>
);
}

View File

@@ -58,6 +58,9 @@ vi.mock('./ModelDialog.js', () => ({
vi.mock('./IdeTrustChangeDialog.js', () => ({
IdeTrustChangeDialog: () => <Text>IdeTrustChangeDialog</Text>,
}));
vi.mock('./AgentConfigDialog.js', () => ({
AgentConfigDialog: () => <Text>AgentConfigDialog</Text>,
}));
describe('DialogManager', () => {
const defaultProps = {
@@ -86,6 +89,10 @@ describe('DialogManager', () => {
isEditorDialogOpen: false,
showPrivacyNotice: false,
isPermissionsDialogOpen: false,
isAgentConfigDialogOpen: false,
selectedAgentName: undefined,
selectedAgentDisplayName: undefined,
selectedAgentDefinition: undefined,
};
it('renders nothing by default', () => {
@@ -148,6 +155,23 @@ describe('DialogManager', () => {
[{ isEditorDialogOpen: true }, 'EditorSettingsDialog'],
[{ showPrivacyNotice: true }, 'PrivacyNotice'],
[{ isPermissionsDialogOpen: true }, 'PermissionsModifyTrustDialog'],
[
{
isAgentConfigDialogOpen: true,
selectedAgentName: 'test-agent',
selectedAgentDisplayName: 'Test Agent',
selectedAgentDefinition: {
name: 'test-agent',
kind: 'local',
description: 'Test agent',
inputConfig: { inputSchema: {} },
promptConfig: { systemPrompt: 'test' },
modelConfig: { model: 'inherit' },
runConfig: { maxTimeMinutes: 5 },
},
},
'AgentConfigDialog',
],
];
it.each(testCases)(

View File

@@ -32,6 +32,7 @@ import process from 'node:process';
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import { AdminSettingsChangedDialog } from './AdminSettingsChangedDialog.js';
import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
import { AgentConfigDialog } from './AgentConfigDialog.js';
interface DialogManagerProps {
addItem: UseHistoryManagerReturn['addItem'];
@@ -161,6 +162,31 @@ export const DialogManager = ({
if (uiState.isModelDialogOpen) {
return <ModelDialog onClose={uiActions.closeModelDialog} />;
}
if (
uiState.isAgentConfigDialogOpen &&
uiState.selectedAgentName &&
uiState.selectedAgentDisplayName &&
uiState.selectedAgentDefinition
) {
return (
<Box flexDirection="column">
<AgentConfigDialog
agentName={uiState.selectedAgentName}
displayName={uiState.selectedAgentDisplayName}
definition={uiState.selectedAgentDefinition}
settings={settings}
onClose={uiActions.closeAgentConfigDialog}
onSave={async () => {
// Reload agent registry to pick up changes
const agentRegistry = config?.getAgentRegistry();
if (agentRegistry) {
await agentRegistry.reload();
}
}}
/>
</Box>
);
}
if (uiState.isAuthenticating) {
return (
<AuthInProgress