feat(settings): Add support for settings enum options (#7719)

This commit is contained in:
Richie Foreman
2025-09-08 10:01:18 -04:00
committed by GitHub
parent 4693137b82
commit 009c24a4b8
7 changed files with 887 additions and 399 deletions

View File

@@ -22,17 +22,17 @@ import { isWorkspaceTrusted } from './trustedFolders.js';
import {
type Settings,
type MemoryImportFormat,
SETTINGS_SCHEMA,
type MergeStrategy,
type SettingsSchema,
type SettingDefinition,
getSettingsSchema,
} from './settingsSchema.js';
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
import { customDeepMerge } from '../utils/deepMerge.js';
function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
let current: SettingDefinition | undefined = undefined;
let currentSchema: SettingsSchema | undefined = SETTINGS_SCHEMA;
let currentSchema: SettingsSchema | undefined = getSettingsSchema();
for (const key of path) {
if (!currentSchema || !currentSchema[key]) {

View File

@@ -5,13 +5,17 @@
*/
import { describe, it, expect } from 'vitest';
import type { Settings } from './settingsSchema.js';
import { SETTINGS_SCHEMA } from './settingsSchema.js';
import {
getSettingsSchema,
type SettingDefinition,
type Settings,
type SettingsSchema,
} from './settingsSchema.js';
describe('SettingsSchema', () => {
describe('SETTINGS_SCHEMA', () => {
describe('getSettingsSchema', () => {
it('should contain all expected top-level settings', () => {
const expectedSettings = [
const expectedSettings: Array<keyof Settings> = [
'mcpServers',
'general',
'ui',
@@ -27,14 +31,12 @@ describe('SettingsSchema', () => {
];
expectedSettings.forEach((setting) => {
expect(
SETTINGS_SCHEMA[setting as keyof typeof SETTINGS_SCHEMA],
).toBeDefined();
expect(getSettingsSchema()[setting as keyof Settings]).toBeDefined();
});
});
it('should have correct structure for each setting', () => {
Object.entries(SETTINGS_SCHEMA).forEach(([_key, definition]) => {
Object.entries(getSettingsSchema()).forEach(([_key, definition]) => {
expect(definition).toHaveProperty('type');
expect(definition).toHaveProperty('label');
expect(definition).toHaveProperty('category');
@@ -48,7 +50,7 @@ describe('SettingsSchema', () => {
});
it('should have correct nested setting structure', () => {
const nestedSettings = [
const nestedSettings: Array<keyof Settings> = [
'general',
'ui',
'ide',
@@ -62,11 +64,9 @@ describe('SettingsSchema', () => {
];
nestedSettings.forEach((setting) => {
const definition = SETTINGS_SCHEMA[
setting as keyof typeof SETTINGS_SCHEMA
] as (typeof SETTINGS_SCHEMA)[keyof typeof SETTINGS_SCHEMA] & {
properties: unknown;
};
const definition = getSettingsSchema()[
setting as keyof Settings
] as SettingDefinition;
expect(definition.type).toBe('object');
expect(definition.properties).toBeDefined();
expect(typeof definition.properties).toBe('object');
@@ -75,35 +75,36 @@ describe('SettingsSchema', () => {
it('should have accessibility nested properties', () => {
expect(
SETTINGS_SCHEMA.ui?.properties?.accessibility?.properties,
getSettingsSchema().ui?.properties?.accessibility?.properties,
).toBeDefined();
expect(
SETTINGS_SCHEMA.ui?.properties?.accessibility.properties
getSettingsSchema().ui?.properties?.accessibility.properties
?.disableLoadingPhrases.type,
).toBe('boolean');
});
it('should have checkpointing nested properties', () => {
expect(
SETTINGS_SCHEMA.general?.properties?.checkpointing.properties?.enabled,
getSettingsSchema().general?.properties?.checkpointing.properties
?.enabled,
).toBeDefined();
expect(
SETTINGS_SCHEMA.general?.properties?.checkpointing.properties?.enabled
.type,
getSettingsSchema().general?.properties?.checkpointing.properties
?.enabled.type,
).toBe('boolean');
});
it('should have fileFiltering nested properties', () => {
expect(
SETTINGS_SCHEMA.context.properties.fileFiltering.properties
getSettingsSchema().context.properties.fileFiltering.properties
?.respectGitIgnore,
).toBeDefined();
expect(
SETTINGS_SCHEMA.context.properties.fileFiltering.properties
getSettingsSchema().context.properties.fileFiltering.properties
?.respectGeminiIgnore,
).toBeDefined();
expect(
SETTINGS_SCHEMA.context.properties.fileFiltering.properties
getSettingsSchema().context.properties.fileFiltering.properties
?.enableRecursiveFileSearch,
).toBeDefined();
});
@@ -112,7 +113,7 @@ describe('SettingsSchema', () => {
const categories = new Set();
// Collect categories from top-level settings
Object.values(SETTINGS_SCHEMA).forEach((definition) => {
Object.values(getSettingsSchema()).forEach((definition) => {
categories.add(definition.category);
// Also collect from nested properties
const defWithProps = definition as typeof definition & {
@@ -137,74 +138,80 @@ describe('SettingsSchema', () => {
});
it('should have consistent default values for boolean settings', () => {
const checkBooleanDefaults = (schema: Record<string, unknown>) => {
Object.entries(schema).forEach(
([_key, definition]: [string, unknown]) => {
const def = definition as {
type?: string;
default?: unknown;
properties?: Record<string, unknown>;
};
if (def.type === 'boolean') {
// Boolean settings can have boolean or undefined defaults (for optional settings)
expect(['boolean', 'undefined']).toContain(typeof def.default);
}
if (def.properties) {
checkBooleanDefaults(def.properties);
}
},
);
const checkBooleanDefaults = (schema: SettingsSchema) => {
Object.entries(schema).forEach(([, definition]) => {
const def = definition as SettingDefinition;
if (def.type === 'boolean') {
// Boolean settings can have boolean or undefined defaults (for optional settings)
expect(['boolean', 'undefined']).toContain(typeof def.default);
}
if (def.properties) {
checkBooleanDefaults(def.properties);
}
});
};
checkBooleanDefaults(SETTINGS_SCHEMA as Record<string, unknown>);
checkBooleanDefaults(getSettingsSchema() as SettingsSchema);
});
it('should have showInDialog property configured', () => {
// Check that user-facing settings are marked for dialog display
expect(SETTINGS_SCHEMA.ui.properties.showMemoryUsage.showInDialog).toBe(
true,
);
expect(SETTINGS_SCHEMA.general.properties.vimMode.showInDialog).toBe(
true,
);
expect(SETTINGS_SCHEMA.ide.properties.enabled.showInDialog).toBe(true);
expect(
SETTINGS_SCHEMA.general.properties.disableAutoUpdate.showInDialog,
getSettingsSchema().ui.properties.showMemoryUsage.showInDialog,
).toBe(true);
expect(SETTINGS_SCHEMA.ui.properties.hideWindowTitle.showInDialog).toBe(
expect(getSettingsSchema().general.properties.vimMode.showInDialog).toBe(
true,
);
expect(getSettingsSchema().ide.properties.enabled.showInDialog).toBe(
true,
);
expect(SETTINGS_SCHEMA.ui.properties.hideTips.showInDialog).toBe(true);
expect(SETTINGS_SCHEMA.ui.properties.hideBanner.showInDialog).toBe(true);
expect(
SETTINGS_SCHEMA.privacy.properties.usageStatisticsEnabled.showInDialog,
getSettingsSchema().general.properties.disableAutoUpdate.showInDialog,
).toBe(true);
expect(
getSettingsSchema().ui.properties.hideWindowTitle.showInDialog,
).toBe(true);
expect(getSettingsSchema().ui.properties.hideTips.showInDialog).toBe(
true,
);
expect(getSettingsSchema().ui.properties.hideBanner.showInDialog).toBe(
true,
);
expect(
getSettingsSchema().privacy.properties.usageStatisticsEnabled
.showInDialog,
).toBe(false);
// Check that advanced settings are hidden from dialog
expect(SETTINGS_SCHEMA.security.properties.auth.showInDialog).toBe(false);
expect(SETTINGS_SCHEMA.tools.properties.core.showInDialog).toBe(false);
expect(SETTINGS_SCHEMA.mcpServers.showInDialog).toBe(false);
expect(SETTINGS_SCHEMA.telemetry.showInDialog).toBe(false);
expect(getSettingsSchema().security.properties.auth.showInDialog).toBe(
false,
);
expect(getSettingsSchema().tools.properties.core.showInDialog).toBe(
false,
);
expect(getSettingsSchema().mcpServers.showInDialog).toBe(false);
expect(getSettingsSchema().telemetry.showInDialog).toBe(false);
// Check that some settings are appropriately hidden
expect(SETTINGS_SCHEMA.ui.properties.theme.showInDialog).toBe(false); // Changed to false
expect(SETTINGS_SCHEMA.ui.properties.customThemes.showInDialog).toBe(
expect(getSettingsSchema().ui.properties.theme.showInDialog).toBe(false); // Changed to false
expect(getSettingsSchema().ui.properties.customThemes.showInDialog).toBe(
false,
); // Managed via theme editor
expect(
SETTINGS_SCHEMA.general.properties.checkpointing.showInDialog,
getSettingsSchema().general.properties.checkpointing.showInDialog,
).toBe(false); // Experimental feature
expect(SETTINGS_SCHEMA.ui.properties.accessibility.showInDialog).toBe(
expect(getSettingsSchema().ui.properties.accessibility.showInDialog).toBe(
false,
); // Changed to false
expect(
SETTINGS_SCHEMA.context.properties.fileFiltering.showInDialog,
getSettingsSchema().context.properties.fileFiltering.showInDialog,
).toBe(false); // Changed to false
expect(
SETTINGS_SCHEMA.general.properties.preferredEditor.showInDialog,
getSettingsSchema().general.properties.preferredEditor.showInDialog,
).toBe(false); // Changed to false
expect(
SETTINGS_SCHEMA.advanced.properties.autoConfigureMemory.showInDialog,
getSettingsSchema().advanced.properties.autoConfigureMemory
.showInDialog,
).toBe(false);
});
@@ -228,80 +235,84 @@ describe('SettingsSchema', () => {
it('should have includeDirectories setting in schema', () => {
expect(
SETTINGS_SCHEMA.context?.properties.includeDirectories,
getSettingsSchema().context?.properties.includeDirectories,
).toBeDefined();
expect(SETTINGS_SCHEMA.context?.properties.includeDirectories.type).toBe(
'array',
);
expect(
SETTINGS_SCHEMA.context?.properties.includeDirectories.category,
getSettingsSchema().context?.properties.includeDirectories.type,
).toBe('array');
expect(
getSettingsSchema().context?.properties.includeDirectories.category,
).toBe('Context');
expect(
SETTINGS_SCHEMA.context?.properties.includeDirectories.default,
getSettingsSchema().context?.properties.includeDirectories.default,
).toEqual([]);
});
it('should have loadMemoryFromIncludeDirectories setting in schema', () => {
expect(
SETTINGS_SCHEMA.context?.properties.loadMemoryFromIncludeDirectories,
getSettingsSchema().context?.properties
.loadMemoryFromIncludeDirectories,
).toBeDefined();
expect(
SETTINGS_SCHEMA.context?.properties.loadMemoryFromIncludeDirectories
getSettingsSchema().context?.properties.loadMemoryFromIncludeDirectories
.type,
).toBe('boolean');
expect(
SETTINGS_SCHEMA.context?.properties.loadMemoryFromIncludeDirectories
getSettingsSchema().context?.properties.loadMemoryFromIncludeDirectories
.category,
).toBe('Context');
expect(
SETTINGS_SCHEMA.context?.properties.loadMemoryFromIncludeDirectories
getSettingsSchema().context?.properties.loadMemoryFromIncludeDirectories
.default,
).toBe(false);
});
it('should have folderTrustFeature setting in schema', () => {
expect(
SETTINGS_SCHEMA.security.properties.folderTrust.properties.enabled,
getSettingsSchema().security.properties.folderTrust.properties.enabled,
).toBeDefined();
expect(
SETTINGS_SCHEMA.security.properties.folderTrust.properties.enabled.type,
getSettingsSchema().security.properties.folderTrust.properties.enabled
.type,
).toBe('boolean');
expect(
SETTINGS_SCHEMA.security.properties.folderTrust.properties.enabled
getSettingsSchema().security.properties.folderTrust.properties.enabled
.category,
).toBe('Security');
expect(
SETTINGS_SCHEMA.security.properties.folderTrust.properties.enabled
getSettingsSchema().security.properties.folderTrust.properties.enabled
.default,
).toBe(false);
expect(
SETTINGS_SCHEMA.security.properties.folderTrust.properties.enabled
getSettingsSchema().security.properties.folderTrust.properties.enabled
.showInDialog,
).toBe(true);
});
it('should have debugKeystrokeLogging setting in schema', () => {
expect(
SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging,
getSettingsSchema().general.properties.debugKeystrokeLogging,
).toBeDefined();
expect(
SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging.type,
getSettingsSchema().general.properties.debugKeystrokeLogging.type,
).toBe('boolean');
expect(
SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging.category,
getSettingsSchema().general.properties.debugKeystrokeLogging.category,
).toBe('General');
expect(
SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging.default,
getSettingsSchema().general.properties.debugKeystrokeLogging.default,
).toBe(false);
expect(
SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging
getSettingsSchema().general.properties.debugKeystrokeLogging
.requiresRestart,
).toBe(false);
expect(
SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging.showInDialog,
getSettingsSchema().general.properties.debugKeystrokeLogging
.showInDialog,
).toBe(true);
expect(
SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging.description,
getSettingsSchema().general.properties.debugKeystrokeLogging
.description,
).toBe('Enable debug logging of keystrokes to the console.');
});
});

View File

@@ -17,6 +17,37 @@ import {
} from '@google/gemini-cli-core';
import type { CustomTheme } from '../ui/themes/theme.js';
export type SettingsType =
| 'boolean'
| 'string'
| 'number'
| 'array'
| 'object'
| 'enum';
export type SettingsValue =
| boolean
| string
| number
| string[]
| object
| undefined;
/**
* Setting datatypes that "toggle" through a fixed list of options
* (e.g. an enum or true/false) rather than allowing for free form input
* (like a number or string).
*/
export const TOGGLE_TYPES: ReadonlySet<SettingsType | undefined> = new Set([
'boolean',
'enum',
]);
interface SettingEnumOption {
value: string | number;
label: string;
}
export enum MergeStrategy {
// Replace the old value with the new value. This is the default.
REPLACE = 'replace',
@@ -29,11 +60,11 @@ export enum MergeStrategy {
}
export interface SettingDefinition {
type: 'boolean' | 'string' | 'number' | 'array' | 'object';
type: SettingsType;
label: string;
category: string;
requiresRestart: boolean;
default: boolean | string | number | string[] | object | undefined;
default: SettingsValue;
description?: string;
parentKey?: string;
childKey?: string;
@@ -41,6 +72,8 @@ export interface SettingDefinition {
properties?: SettingsSchema;
showInDialog?: boolean;
mergeStrategy?: MergeStrategy;
/** Enum type options */
options?: readonly SettingEnumOption[];
}
export interface SettingsSchema {
@@ -55,7 +88,7 @@ export type DnsResolutionOrder = 'ipv4first' | 'verbatim';
* The structure of this object defines the structure of the `Settings` type.
* `as const` is crucial for TypeScript to infer the most specific types possible.
*/
export const SETTINGS_SCHEMA = {
const SETTINGS_SCHEMA = {
// Maintained for compatibility/criticality
mcpServers: {
type: 'object',
@@ -900,7 +933,13 @@ export const SETTINGS_SCHEMA = {
},
},
},
} as const;
} as const satisfies SettingsSchema;
export type SettingsSchemaType = typeof SETTINGS_SCHEMA;
export function getSettingsSchema(): SettingsSchemaType {
return SETTINGS_SCHEMA;
}
type InferSettings<T extends SettingsSchema> = {
-readonly [K in keyof T]?: T[K] extends { properties: SettingsSchema }
@@ -910,7 +949,7 @@ type InferSettings<T extends SettingsSchema> = {
: T[K]['default'];
};
export type Settings = InferSettings<typeof SETTINGS_SCHEMA>;
export type Settings = InferSettings<SettingsSchemaType>;
export interface FooterSettings {
hideCWD?: boolean;

View File

@@ -25,14 +25,31 @@ import { render } from 'ink-testing-library';
import { waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SettingsDialog } from './SettingsDialog.js';
import { LoadedSettings } from '../../config/settings.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { VimModeProvider } from '../contexts/VimModeContext.js';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import { act } from 'react';
import { saveModifiedSettings, TEST_ONLY } from '../../utils/settingsUtils.js';
import {
getSettingsSchema,
type SettingDefinition,
type SettingsSchemaType,
} from '../../config/settingsSchema.js';
// Mock the VimModeContext
const mockToggleVimEnabled = vi.fn();
const mockSetVimMode = vi.fn();
enum TerminalKeys {
ENTER = '\u000D',
TAB = '\t',
UP_ARROW = '\u001B[A',
DOWN_ARROW = '\u001B[B',
LEFT_ARROW = '\u001B[D',
RIGHT_ARROW = '\u001B[C',
ESCAPE = '\u001B',
}
const createMockSettings = (
userSettings = {},
systemSettings = {},
@@ -67,26 +84,12 @@ const createMockSettings = (
new Set(),
);
vi.mock('../contexts/SettingsContext.js', async () => {
const actual = await vi.importActual('../contexts/SettingsContext.js');
let settings = createMockSettings({ 'a.string.setting': 'initial' });
vi.mock('../../config/settingsSchema.js', async (importOriginal) => {
const original =
await importOriginal<typeof import('../../config/settingsSchema.js')>();
return {
...actual,
useSettings: () => ({
settings,
setSetting: (key: string, value: string) => {
settings = createMockSettings({ [key]: value });
},
getSettingDefinition: (key: string) => {
if (key === 'a.string.setting') {
return {
type: 'string',
description: 'A string setting',
};
}
return undefined;
},
}),
...original,
getSettingsSchema: vi.fn(original.getSettingsSchema),
};
});
@@ -136,7 +139,6 @@ describe('SettingsDialog', () => {
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
beforeEach(() => {
vi.clearAllMocks();
// Reset keypress mock state (variables are commented out)
// currentKeypressHandler = null;
// isKeypressActive = false;
@@ -146,6 +148,9 @@ describe('SettingsDialog', () => {
});
afterEach(() => {
TEST_ONLY.clearFlattenedSchema();
vi.clearAllMocks();
vi.resetAllMocks();
// Reset keypress mock state (variables are commented out)
// currentKeypressHandler = null;
// isKeypressActive = false;
@@ -153,44 +158,6 @@ describe('SettingsDialog', () => {
// console.error = originalConsoleError;
});
const createMockSettings = (
userSettings = {},
systemSettings = {},
workspaceSettings = {},
) =>
new LoadedSettings(
{
settings: {
ui: { customThemes: {} },
mcpServers: {},
...systemSettings,
},
path: '/system/settings.json',
},
{
settings: {},
path: '/system/system-defaults.json',
},
{
settings: {
ui: { customThemes: {} },
mcpServers: {},
...userSettings,
},
path: '/user/settings.json',
},
{
settings: {
ui: { customThemes: {} },
mcpServers: {},
...workspaceSettings,
},
path: '/workspace/settings.json',
},
true,
new Set(),
);
describe('Initial Rendering', () => {
it('should render the settings dialog with default state', () => {
const settings = createMockSettings();
@@ -244,15 +211,18 @@ describe('SettingsDialog', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
const { stdin, unmount, lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Press down arrow
stdin.write('\u001B[B'); // Down arrow
await wait();
act(() => {
stdin.write(TerminalKeys.DOWN_ARROW as string); // Down arrow
});
expect(lastFrame()).toContain('● Disable Auto Update');
// The active index should have changed (tested indirectly through behavior)
unmount();
@@ -269,9 +239,9 @@ describe('SettingsDialog', () => {
);
// First go down, then up
stdin.write('\u001B[B'); // Down arrow
stdin.write(TerminalKeys.DOWN_ARROW as string); // Down arrow
await wait();
stdin.write('\u001B[A'); // Up arrow
stdin.write(TerminalKeys.UP_ARROW as string);
await wait();
unmount();
@@ -296,21 +266,25 @@ describe('SettingsDialog', () => {
unmount();
});
it('should not navigate beyond bounds', async () => {
it('wraps around when at the top of the list', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
const { stdin, unmount, lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Try to go up from first item
stdin.write('\u001B[A'); // Up arrow
act(() => {
stdin.write(TerminalKeys.UP_ARROW);
});
await wait();
// Should still be on first item
expect(lastFrame()).toContain('● Folder Trust');
unmount();
});
});
@@ -319,20 +293,142 @@ describe('SettingsDialog', () => {
it('should toggle setting with Enter key', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
const component = (
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
</KeypressProvider>
);
const { stdin, unmount } = render(component);
// Press Enter to toggle current setting
stdin.write('\u000D'); // Enter key
stdin.write(TerminalKeys.DOWN_ARROW as string);
await wait();
stdin.write(TerminalKeys.ENTER as string);
await wait();
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalledWith(
new Set<string>(['general.disableAutoUpdate']),
{
general: {
disableAutoUpdate: true,
},
},
expect.any(LoadedSettings),
SettingScope.User,
);
unmount();
});
describe('enum values', () => {
enum StringEnum {
FOO = 'foo',
BAR = 'bar',
BAZ = 'baz',
}
const SETTING: SettingDefinition = {
type: 'enum',
label: 'Theme',
options: [
{
label: 'Foo',
value: StringEnum.FOO,
},
{
label: 'Bar',
value: StringEnum.BAR,
},
{
label: 'Baz',
value: StringEnum.BAZ,
},
],
category: 'UI',
requiresRestart: false,
default: StringEnum.BAR,
description: 'The color theme for the UI.',
showInDialog: true,
};
const FAKE_SCHEMA: SettingsSchemaType = {
ui: {
showInDialog: false,
properties: {
theme: {
...SETTING,
},
},
},
} as unknown as SettingsSchemaType;
it('toggles enum values with the enter key', async () => {
vi.mocked(getSettingsSchema).mockReturnValue(FAKE_SCHEMA);
const settings = createMockSettings();
const onSelect = vi.fn();
const component = (
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>
);
const { stdin, unmount } = render(component);
// Press Enter to toggle current setting
stdin.write(TerminalKeys.DOWN_ARROW as string);
await wait();
stdin.write(TerminalKeys.ENTER as string);
await wait();
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalledWith(
new Set<string>(['ui.theme']),
{
ui: {
theme: StringEnum.BAZ,
},
},
expect.any(LoadedSettings),
SettingScope.User,
);
unmount();
});
it('loops back when reaching the end of an enum', async () => {
vi.mocked(getSettingsSchema).mockReturnValue(FAKE_SCHEMA);
const settings = createMockSettings();
settings.setValue(SettingScope.User, 'ui.theme', StringEnum.BAZ);
const onSelect = vi.fn();
const component = (
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>
);
const { stdin, unmount } = render(component);
// Press Enter to toggle current setting
stdin.write(TerminalKeys.DOWN_ARROW as string);
await wait();
stdin.write(TerminalKeys.ENTER as string);
await wait();
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalledWith(
new Set<string>(['ui.theme']),
{
ui: {
theme: StringEnum.FOO,
},
},
expect.any(LoadedSettings),
SettingScope.User,
);
unmount();
});
});
it('should toggle setting with Space key', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
@@ -362,7 +458,7 @@ describe('SettingsDialog', () => {
// Navigate to vim mode setting and toggle it
// This would require knowing the exact position, so we'll just test that the mock is called
stdin.write('\u000D'); // Enter key
stdin.write(TerminalKeys.ENTER as string); // Enter key
await wait();
// The mock should potentially be called if vim mode was toggled
@@ -382,7 +478,7 @@ describe('SettingsDialog', () => {
);
// Switch to scope focus
stdin.write('\t'); // Tab key
stdin.write(TerminalKeys.TAB); // Tab key
await wait();
// Select different scope (numbers 1-3 typically available)
@@ -502,7 +598,7 @@ describe('SettingsDialog', () => {
);
// Switch to scope selector
stdin.write('\t'); // Tab
stdin.write(TerminalKeys.TAB as string); // Tab
await wait();
// Change scope
@@ -547,7 +643,7 @@ describe('SettingsDialog', () => {
);
// Try to toggle a setting (this might trigger vim mode toggle)
stdin.write('\u000D'); // Enter
stdin.write(TerminalKeys.ENTER as string); // Enter
await wait();
// Should not crash
@@ -567,13 +663,13 @@ describe('SettingsDialog', () => {
);
// Toggle a setting
stdin.write('\u000D'); // Enter
stdin.write(TerminalKeys.ENTER as string); // Enter
await wait();
// Toggle another setting
stdin.write('\u001B[B'); // Down
stdin.write(TerminalKeys.DOWN_ARROW as string); // Down
await wait();
stdin.write('\u000D'); // Enter
stdin.write(TerminalKeys.ENTER as string); // Enter
await wait();
// Should track multiple modified settings
@@ -592,7 +688,7 @@ describe('SettingsDialog', () => {
// Navigate down many times to test scrolling
for (let i = 0; i < 10; i++) {
stdin.write('\u001B[B'); // Down arrow
stdin.write(TerminalKeys.DOWN_ARROW as string); // Down arrow
await wait(10);
}
@@ -615,7 +711,7 @@ describe('SettingsDialog', () => {
// Navigate to and toggle vim mode setting
// This would require knowing the exact position of vim mode setting
stdin.write('\u000D'); // Enter
stdin.write(TerminalKeys.ENTER as string); // Enter
await wait();
unmount();
@@ -653,7 +749,7 @@ describe('SettingsDialog', () => {
);
// Toggle a non-restart-required setting (like hideTips)
stdin.write('\u000D'); // Enter - toggle current setting
stdin.write(TerminalKeys.ENTER as string); // Enter - toggle current setting
await wait();
// Should save immediately without showing restart prompt
@@ -750,8 +846,8 @@ describe('SettingsDialog', () => {
// Rapid navigation
for (let i = 0; i < 5; i++) {
stdin.write('\u001B[B'); // Down arrow
stdin.write('\u001B[A'); // Up arrow
stdin.write(TerminalKeys.DOWN_ARROW as string);
stdin.write(TerminalKeys.UP_ARROW as string);
}
await wait(100);
@@ -806,9 +902,9 @@ describe('SettingsDialog', () => {
);
// Try to navigate when potentially at bounds
stdin.write('\u001B[B'); // Down
stdin.write(TerminalKeys.DOWN_ARROW as string);
await wait();
stdin.write('\u001B[A'); // Up
stdin.write(TerminalKeys.UP_ARROW as string);
await wait();
unmount();
@@ -917,19 +1013,19 @@ describe('SettingsDialog', () => {
);
// Toggle first setting (should require restart)
stdin.write('\u000D'); // Enter
stdin.write(TerminalKeys.ENTER as string); // Enter
await wait();
// Navigate to next setting and toggle it (should not require restart - e.g., vimMode)
stdin.write('\u001B[B'); // Down
stdin.write(TerminalKeys.DOWN_ARROW as string); // Down
await wait();
stdin.write('\u000D'); // Enter
stdin.write(TerminalKeys.ENTER as string); // Enter
await wait();
// Navigate to another setting and toggle it (should also require restart)
stdin.write('\u001B[B'); // Down
stdin.write(TerminalKeys.DOWN_ARROW as string); // Down
await wait();
stdin.write('\u000D'); // Enter
stdin.write(TerminalKeys.ENTER as string); // Enter
await wait();
// The test verifies that all changes are preserved and the dialog still works
@@ -948,13 +1044,13 @@ describe('SettingsDialog', () => {
);
// Multiple scope changes
stdin.write('\t'); // Tab to scope
stdin.write(TerminalKeys.TAB as string); // Tab to scope
await wait();
stdin.write('2'); // Workspace
await wait();
stdin.write('\t'); // Tab to settings
stdin.write(TerminalKeys.TAB as string); // Tab to settings
await wait();
stdin.write('\t'); // Tab to scope
stdin.write(TerminalKeys.TAB as string); // Tab to scope
await wait();
stdin.write('1'); // User
await wait();

View File

@@ -16,7 +16,6 @@ import {
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import {
getDialogSettingKeys,
getSettingValue,
setPendingSettingValue,
getDisplayValue,
hasRestartRequiredSettings,
@@ -28,11 +27,16 @@ import {
getDefaultValue,
setPendingSettingValueAny,
getNestedValue,
getEffectiveValue,
} from '../../utils/settingsUtils.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { useKeypress } from '../hooks/useKeypress.js';
import chalk from 'chalk';
import { cpSlice, cpLen, stripUnsafeCharacters } from '../utils/textUtils.js';
import {
type SettingsValue,
TOGGLE_TYPES,
} from '../../config/settingsSchema.js';
interface SettingsDialogProps {
settings: LoadedSettings;
@@ -122,15 +126,33 @@ export function SettingsDialog({
value: key,
type: definition?.type,
toggle: () => {
if (definition?.type !== 'boolean') {
// For non-boolean items, toggle will be handled via edit mode.
if (!TOGGLE_TYPES.has(definition?.type)) {
return;
}
const currentValue = getSettingValue(key, pendingSettings, {});
const newValue = !currentValue;
const currentValue = getEffectiveValue(key, pendingSettings, {});
let newValue: SettingsValue;
if (definition?.type === 'boolean') {
newValue = !(currentValue as boolean);
setPendingSettings((prev) =>
setPendingSettingValue(key, newValue as boolean, prev),
);
} else if (definition?.type === 'enum' && definition.options) {
const options = definition.options;
const currentIndex = options?.findIndex(
(opt) => opt.value === currentValue,
);
if (currentIndex !== -1 && currentIndex < options.length - 1) {
newValue = options[currentIndex + 1].value;
} else {
newValue = options[0].value; // loop back to start.
}
setPendingSettings((prev) =>
setPendingSettingValueAny(key, newValue, prev),
);
}
setPendingSettings((prev) =>
setPendingSettingValue(key, newValue, prev),
setPendingSettingValue(key, newValue as boolean, prev),
);
if (!requiresRestart(key)) {

File diff suppressed because it is too large Load Diff

View File

@@ -12,18 +12,19 @@ import type {
import type {
SettingDefinition,
SettingsSchema,
SettingsType,
SettingsValue,
} from '../config/settingsSchema.js';
import { SETTINGS_SCHEMA } from '../config/settingsSchema.js';
import { getSettingsSchema } from '../config/settingsSchema.js';
// The schema is now nested, but many parts of the UI and logic work better
// with a flattened structure and dot-notation keys. This section flattens the
// schema into a map for easier lookups.
function flattenSchema(
schema: SettingsSchema,
prefix = '',
): Record<string, SettingDefinition & { key: string }> {
let result: Record<string, SettingDefinition & { key: string }> = {};
type FlattenedSchema = Record<string, SettingDefinition & { key: string }>;
function flattenSchema(schema: SettingsSchema, prefix = ''): FlattenedSchema {
let result: FlattenedSchema = {};
for (const key in schema) {
const newKey = prefix ? `${prefix}.${key}` : key;
const definition = schema[key];
@@ -35,7 +36,19 @@ function flattenSchema(
return result;
}
const FLATTENED_SCHEMA = flattenSchema(SETTINGS_SCHEMA);
let _FLATTENED_SCHEMA: FlattenedSchema | undefined;
/** Returns a flattened schema, the first call is memoized for future requests. */
export function getFlattenedSchema() {
return (
_FLATTENED_SCHEMA ??
(_FLATTENED_SCHEMA = flattenSchema(getSettingsSchema()))
);
}
function clearFlattenedSchema() {
_FLATTENED_SCHEMA = undefined;
}
/**
* Get all settings grouped by category
@@ -49,7 +62,7 @@ export function getSettingsByCategory(): Record<
Array<SettingDefinition & { key: string }>
> = {};
Object.values(FLATTENED_SCHEMA).forEach((definition) => {
Object.values(getFlattenedSchema()).forEach((definition) => {
const category = definition.category;
if (!categories[category]) {
categories[category] = [];
@@ -66,28 +79,28 @@ export function getSettingsByCategory(): Record<
export function getSettingDefinition(
key: string,
): (SettingDefinition & { key: string }) | undefined {
return FLATTENED_SCHEMA[key];
return getFlattenedSchema()[key];
}
/**
* Check if a setting requires restart
*/
export function requiresRestart(key: string): boolean {
return FLATTENED_SCHEMA[key]?.requiresRestart ?? false;
return getFlattenedSchema()[key]?.requiresRestart ?? false;
}
/**
* Get the default value for a setting
*/
export function getDefaultValue(key: string): SettingDefinition['default'] {
return FLATTENED_SCHEMA[key]?.default;
export function getDefaultValue(key: string): SettingsValue {
return getFlattenedSchema()[key]?.default;
}
/**
* Get all setting keys that require restart
*/
export function getRestartRequiredSettings(): string[] {
return Object.values(FLATTENED_SCHEMA)
return Object.values(getFlattenedSchema())
.filter((definition) => definition.requiresRestart)
.map((definition) => definition.key);
}
@@ -121,7 +134,7 @@ export function getEffectiveValue(
key: string,
settings: Settings,
mergedSettings: Settings,
): SettingDefinition['default'] {
): SettingsValue {
const definition = getSettingDefinition(key);
if (!definition) {
return undefined;
@@ -132,13 +145,13 @@ export function getEffectiveValue(
// Check the current scope's settings first
let value = getNestedValue(settings as Record<string, unknown>, path);
if (value !== undefined) {
return value as SettingDefinition['default'];
return value as SettingsValue;
}
// Check the merged settings for an inherited value
value = getNestedValue(mergedSettings as Record<string, unknown>, path);
if (value !== undefined) {
return value as SettingDefinition['default'];
return value as SettingsValue;
}
// Return default value if no value is set anywhere
@@ -149,16 +162,16 @@ export function getEffectiveValue(
* Get all setting keys from the schema
*/
export function getAllSettingKeys(): string[] {
return Object.keys(FLATTENED_SCHEMA);
return Object.keys(getFlattenedSchema());
}
/**
* Get settings by type
*/
export function getSettingsByType(
type: SettingDefinition['type'],
type: SettingsType,
): Array<SettingDefinition & { key: string }> {
return Object.values(FLATTENED_SCHEMA).filter(
return Object.values(getFlattenedSchema()).filter(
(definition) => definition.type === type,
);
}
@@ -171,7 +184,7 @@ export function getSettingsRequiringRestart(): Array<
key: string;
}
> {
return Object.values(FLATTENED_SCHEMA).filter(
return Object.values(getFlattenedSchema()).filter(
(definition) => definition.requiresRestart,
);
}
@@ -180,21 +193,21 @@ export function getSettingsRequiringRestart(): Array<
* Validate if a setting key exists in the schema
*/
export function isValidSettingKey(key: string): boolean {
return key in FLATTENED_SCHEMA;
return key in getFlattenedSchema();
}
/**
* Get the category for a setting
*/
export function getSettingCategory(key: string): string | undefined {
return FLATTENED_SCHEMA[key]?.category;
return getFlattenedSchema()[key]?.category;
}
/**
* Check if a setting should be shown in the settings dialog
*/
export function shouldShowInDialog(key: string): boolean {
return FLATTENED_SCHEMA[key]?.showInDialog ?? true; // Default to true for backward compatibility
return getFlattenedSchema()[key]?.showInDialog ?? true; // Default to true for backward compatibility
}
/**
@@ -209,7 +222,7 @@ export function getDialogSettingsByCategory(): Record<
Array<SettingDefinition & { key: string }>
> = {};
Object.values(FLATTENED_SCHEMA)
Object.values(getFlattenedSchema())
.filter((definition) => definition.showInDialog !== false)
.forEach((definition) => {
const category = definition.category;
@@ -226,9 +239,9 @@ export function getDialogSettingsByCategory(): Record<
* Get settings by type that should be shown in the dialog
*/
export function getDialogSettingsByType(
type: SettingDefinition['type'],
type: SettingsType,
): Array<SettingDefinition & { key: string }> {
return Object.values(FLATTENED_SCHEMA).filter(
return Object.values(getFlattenedSchema()).filter(
(definition) =>
definition.type === type && definition.showInDialog !== false,
);
@@ -238,7 +251,7 @@ export function getDialogSettingsByType(
* Get all setting keys that should be shown in the dialog
*/
export function getDialogSettingKeys(): string[] {
return Object.values(FLATTENED_SCHEMA)
return Object.values(getFlattenedSchema())
.filter((definition) => definition.showInDialog !== false)
.map((definition) => definition.key);
}
@@ -344,7 +357,7 @@ export function setPendingSettingValue(
*/
export function setPendingSettingValueAny(
key: string,
value: unknown,
value: SettingsValue,
pendingSettings: Settings,
): Settings {
const path = key.split('.');
@@ -415,25 +428,30 @@ export function getDisplayValue(
pendingSettings?: Settings,
): string {
// Prioritize pending changes if user has modified this setting
let value: boolean;
const definition = getSettingDefinition(key);
let value: SettingsValue;
if (pendingSettings && settingExistsInScope(key, pendingSettings)) {
// Show the value from the pending (unsaved) edits when it exists
value = getSettingValue(key, pendingSettings, {});
value = getEffectiveValue(key, pendingSettings, {});
} else if (settingExistsInScope(key, settings)) {
// Show the value defined at the current scope if present
value = getSettingValue(key, settings, {});
value = getEffectiveValue(key, settings, {});
} else {
// Fall back to the schema default when the key is unset in this scope
const defaultValue = getDefaultValue(key);
value = typeof defaultValue === 'boolean' ? defaultValue : false;
value = getDefaultValue(key);
}
const valueString = String(value);
let valueString = String(value);
if (definition?.type === 'enum' && definition.options) {
const option = definition.options?.find((option) => option.value === value);
valueString = option?.label ?? `${value}`;
}
// Check if value is different from default OR if it's in modified settings OR if there are pending changes
const defaultValue = getDefaultValue(key);
const isChangedFromDefault =
typeof defaultValue === 'boolean' ? value !== defaultValue : value === true;
const isChangedFromDefault = value !== defaultValue;
const isInModifiedSettings = modifiedSettings.has(key);
// Mark as modified if setting exists in current scope OR is in modified settings
@@ -476,3 +494,5 @@ export function getEffectiveDisplayValue(
): boolean {
return getSettingValue(key, settings, mergedSettings);
}
export const TEST_ONLY = { clearFlattenedSchema };