mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 12:54:07 -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', () => {
|
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 = {
|
const mockDefinition = {
|
||||||
name: 'test-agent',
|
name: 'test-agent',
|
||||||
displayName: 'Test Agent',
|
displayName: 'Test Agent',
|
||||||
description: 'test desc',
|
description: 'test desc',
|
||||||
|
kind: 'local',
|
||||||
};
|
};
|
||||||
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
|
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
|
||||||
getDiscoveredDefinition: vi.fn().mockReturnValue(mockDefinition),
|
getDiscoveredDefinition: vi.fn().mockReturnValue(mockDefinition),
|
||||||
@@ -357,19 +358,22 @@ describe('agentsCommand', () => {
|
|||||||
|
|
||||||
const result = await configCommand!.action!(mockContext, 'test-agent');
|
const result = await configCommand!.action!(mockContext, 'test-agent');
|
||||||
|
|
||||||
expect(mockContext.ui.openAgentConfigDialog).not.toHaveBeenCalled();
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
type: 'message',
|
type: 'dialog',
|
||||||
messageType: 'info',
|
dialog: 'agentConfig',
|
||||||
content:
|
props: {
|
||||||
"Configuration for 'test-agent' will be available in the next update.",
|
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 = {
|
const mockDefinition = {
|
||||||
name: 'test-agent',
|
name: 'test-agent',
|
||||||
description: 'test desc',
|
description: 'test desc',
|
||||||
|
kind: 'local',
|
||||||
};
|
};
|
||||||
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
|
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
|
||||||
getDiscoveredDefinition: vi.fn().mockReturnValue(mockDefinition),
|
getDiscoveredDefinition: vi.fn().mockReturnValue(mockDefinition),
|
||||||
@@ -381,10 +385,13 @@ describe('agentsCommand', () => {
|
|||||||
const result = await configCommand!.action!(mockContext, 'test-agent');
|
const result = await configCommand!.action!(mockContext, 'test-agent');
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
type: 'message',
|
type: 'dialog',
|
||||||
messageType: 'info',
|
dialog: 'agentConfig',
|
||||||
content:
|
props: {
|
||||||
"Configuration for 'test-agent' will be available in the next update.",
|
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 {
|
return {
|
||||||
type: 'message',
|
type: 'dialog',
|
||||||
messageType: 'info',
|
dialog: 'agentConfig',
|
||||||
content: `Configuration for '${agentName}' will be available in the next update.`,
|
props: {
|
||||||
|
name: agentName,
|
||||||
|
displayName,
|
||||||
|
definition,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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', () => ({
|
vi.mock('./IdeTrustChangeDialog.js', () => ({
|
||||||
IdeTrustChangeDialog: () => <Text>IdeTrustChangeDialog</Text>,
|
IdeTrustChangeDialog: () => <Text>IdeTrustChangeDialog</Text>,
|
||||||
}));
|
}));
|
||||||
|
vi.mock('./AgentConfigDialog.js', () => ({
|
||||||
|
AgentConfigDialog: () => <Text>AgentConfigDialog</Text>,
|
||||||
|
}));
|
||||||
|
|
||||||
describe('DialogManager', () => {
|
describe('DialogManager', () => {
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
@@ -86,6 +89,10 @@ describe('DialogManager', () => {
|
|||||||
isEditorDialogOpen: false,
|
isEditorDialogOpen: false,
|
||||||
showPrivacyNotice: false,
|
showPrivacyNotice: false,
|
||||||
isPermissionsDialogOpen: false,
|
isPermissionsDialogOpen: false,
|
||||||
|
isAgentConfigDialogOpen: false,
|
||||||
|
selectedAgentName: undefined,
|
||||||
|
selectedAgentDisplayName: undefined,
|
||||||
|
selectedAgentDefinition: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
it('renders nothing by default', () => {
|
it('renders nothing by default', () => {
|
||||||
@@ -148,6 +155,23 @@ describe('DialogManager', () => {
|
|||||||
[{ isEditorDialogOpen: true }, 'EditorSettingsDialog'],
|
[{ isEditorDialogOpen: true }, 'EditorSettingsDialog'],
|
||||||
[{ showPrivacyNotice: true }, 'PrivacyNotice'],
|
[{ showPrivacyNotice: true }, 'PrivacyNotice'],
|
||||||
[{ isPermissionsDialogOpen: true }, 'PermissionsModifyTrustDialog'],
|
[{ 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)(
|
it.each(testCases)(
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import process from 'node:process';
|
|||||||
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
||||||
import { AdminSettingsChangedDialog } from './AdminSettingsChangedDialog.js';
|
import { AdminSettingsChangedDialog } from './AdminSettingsChangedDialog.js';
|
||||||
import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
|
import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
|
||||||
|
import { AgentConfigDialog } from './AgentConfigDialog.js';
|
||||||
|
|
||||||
interface DialogManagerProps {
|
interface DialogManagerProps {
|
||||||
addItem: UseHistoryManagerReturn['addItem'];
|
addItem: UseHistoryManagerReturn['addItem'];
|
||||||
@@ -161,6 +162,31 @@ export const DialogManager = ({
|
|||||||
if (uiState.isModelDialogOpen) {
|
if (uiState.isModelDialogOpen) {
|
||||||
return <ModelDialog onClose={uiActions.closeModelDialog} />;
|
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) {
|
if (uiState.isAuthenticating) {
|
||||||
return (
|
return (
|
||||||
<AuthInProgress
|
<AuthInProgress
|
||||||
|
|||||||
Reference in New Issue
Block a user