mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 14:10:37 -07:00
feat: implement AgentConfigDialog for /agents config command (#17370)
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
309
packages/cli/src/ui/components/AgentConfigDialog.test.tsx
Normal file
309
packages/cli/src/ui/components/AgentConfigDialog.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
435
packages/cli/src/ui/components/AgentConfigDialog.tsx
Normal file
435
packages/cli/src/ui/components/AgentConfigDialog.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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)(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user