From 009c24a4b8a514e2d552e7f522450021f067332f Mon Sep 17 00:00:00 2001 From: Richie Foreman Date: Mon, 8 Sep 2025 10:01:18 -0400 Subject: [PATCH] feat(settings): Add support for settings `enum` options (#7719) --- packages/cli/src/config/settings.ts | 4 +- .../cli/src/config/settingsSchema.test.ts | 183 ++--- packages/cli/src/config/settingsSchema.ts | 49 +- .../src/ui/components/SettingsDialog.test.tsx | 284 +++++--- .../cli/src/ui/components/SettingsDialog.tsx | 34 +- packages/cli/src/utils/settingsUtils.test.ts | 640 +++++++++++++----- packages/cli/src/utils/settingsUtils.ts | 92 ++- 7 files changed, 887 insertions(+), 399 deletions(-) diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index ab59d9b7f4..53ab1111d9 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -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]) { diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index e182e49ef8..47fc91c108 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -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 = [ '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 = [ '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) => { - Object.entries(schema).forEach( - ([_key, definition]: [string, unknown]) => { - const def = definition as { - type?: string; - default?: unknown; - properties?: Record; - }; - 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); + 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.'); }); }); diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 8784346a6c..381a4c855f 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -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 = 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 = { -readonly [K in keyof T]?: T[K] extends { properties: SettingsSchema } @@ -910,7 +949,7 @@ type InferSettings = { : T[K]['default']; }; -export type Settings = InferSettings; +export type Settings = InferSettings; export interface FooterSettings { hideCWD?: boolean; diff --git a/packages/cli/src/ui/components/SettingsDialog.test.tsx b/packages/cli/src/ui/components/SettingsDialog.test.tsx index e5fc79f1c5..31a1a344fd 100644 --- a/packages/cli/src/ui/components/SettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.test.tsx @@ -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(); 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( , ); // 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( , ); // 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 = ( - , + ); + 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(['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 = ( + + + + ); + + 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(['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 = ( + + + + ); + + 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(['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(); diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index 8cf6e83a64..390fc2f611 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -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)) { diff --git a/packages/cli/src/utils/settingsUtils.test.ts b/packages/cli/src/utils/settingsUtils.test.ts index b6830abc4c..d92b5756a0 100644 --- a/packages/cli/src/utils/settingsUtils.test.ts +++ b/packages/cli/src/utils/settingsUtils.test.ts @@ -25,6 +25,7 @@ import { // Business logic utilities getSettingValue, isSettingModified, + TEST_ONLY, settingExistsInScope, setPendingSettingValue, hasRestartRequiredSettings, @@ -34,15 +35,122 @@ import { isValueInherited, getEffectiveDisplayValue, } from './settingsUtils.js'; +import { + getSettingsSchema, + type SettingDefinition, + type Settings, + type SettingsSchema, + type SettingsSchemaType, +} from '../config/settingsSchema.js'; + +vi.mock('../config/settingsSchema.js', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + getSettingsSchema: vi.fn(), + }; +}); + +function makeMockSettings(settings: unknown): Settings { + return settings as Settings; +} describe('SettingsUtils', () => { + beforeEach(() => { + const SETTINGS_SCHEMA = { + mcpServers: { + type: 'object', + label: 'MCP Servers', + category: 'Advanced', + requiresRestart: true, + default: {} as Record, + description: 'Configuration for MCP servers.', + showInDialog: false, + }, + test: { + type: 'string', + label: 'Test', + category: 'Basic', + requiresRestart: false, + default: 'hello', + description: 'A test field', + showInDialog: true, + }, + advanced: { + type: 'object', + label: 'Advanced', + category: 'Advanced', + requiresRestart: true, + default: {}, + description: 'Advanced settings for power users.', + showInDialog: false, + }, + ui: { + type: 'object', + label: 'UI', + category: 'UI', + requiresRestart: false, + default: {}, + description: 'User interface settings.', + showInDialog: false, + properties: { + theme: { + type: 'string', + label: 'Theme', + category: 'UI', + requiresRestart: false, + default: undefined as string | undefined, + description: 'The color theme for the UI.', + showInDialog: false, + }, + requiresRestart: { + type: 'boolean', + label: 'Requires Restart', + category: 'UI', + default: false, + requiresRestart: true, + }, + accessibility: { + type: 'object', + label: 'Accessibility', + category: 'UI', + requiresRestart: true, + default: {}, + description: 'Accessibility settings.', + showInDialog: false, + properties: { + disableLoadingPhrases: { + type: 'boolean', + label: 'Disable Loading Phrases', + category: 'UI', + requiresRestart: true, + default: false, + description: 'Disable loading phrases for accessibility', + showInDialog: true, + }, + }, + }, + }, + }, + } as const satisfies SettingsSchema; + + vi.mocked(getSettingsSchema).mockReturnValue( + SETTINGS_SCHEMA as unknown as SettingsSchemaType, + ); + }); + afterEach(() => { + TEST_ONLY.clearFlattenedSchema(); + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + describe('Schema Utilities', () => { describe('getSettingsByCategory', () => { it('should group settings by category', () => { const categories = getSettingsByCategory(); - - expect(categories).toHaveProperty('General'); - expect(categories).toHaveProperty('UI'); + expect(categories).toHaveProperty('Advanced'); + expect(categories).toHaveProperty('Basic'); }); it('should include key property in grouped settings', () => { @@ -58,9 +166,9 @@ describe('SettingsUtils', () => { describe('getSettingDefinition', () => { it('should return definition for valid setting', () => { - const definition = getSettingDefinition('ui.showMemoryUsage'); + const definition = getSettingDefinition('ui.theme'); expect(definition).toBeDefined(); - expect(definition?.label).toBe('Show Memory Usage'); + expect(definition?.label).toBe('Theme'); }); it('should return undefined for invalid setting', () => { @@ -71,13 +179,11 @@ describe('SettingsUtils', () => { describe('requiresRestart', () => { it('should return true for settings that require restart', () => { - expect(requiresRestart('advanced.autoConfigureMemory')).toBe(true); - expect(requiresRestart('general.checkpointing.enabled')).toBe(true); + expect(requiresRestart('ui.requiresRestart')).toBe(true); }); it('should return false for settings that do not require restart', () => { - expect(requiresRestart('ui.showMemoryUsage')).toBe(false); - expect(requiresRestart('ui.hideTips')).toBe(false); + expect(requiresRestart('ui.theme')).toBe(false); }); it('should return false for invalid settings', () => { @@ -87,10 +193,8 @@ describe('SettingsUtils', () => { describe('getDefaultValue', () => { it('should return correct default values', () => { - expect(getDefaultValue('ui.showMemoryUsage')).toBe(false); - expect( - getDefaultValue('context.fileFiltering.enableRecursiveFileSearch'), - ).toBe(true); + expect(getDefaultValue('test')).toBe('hello'); + expect(getDefaultValue('ui.requiresRestart')).toBe(false); }); it('should return undefined for invalid settings', () => { @@ -101,19 +205,20 @@ describe('SettingsUtils', () => { describe('getRestartRequiredSettings', () => { it('should return all settings that require restart', () => { const restartSettings = getRestartRequiredSettings(); - expect(restartSettings).toContain('advanced.autoConfigureMemory'); - expect(restartSettings).toContain('general.checkpointing.enabled'); - expect(restartSettings).not.toContain('ui.showMemoryUsage'); + expect(restartSettings).toContain('mcpServers'); + expect(restartSettings).toContain('ui.requiresRestart'); }); }); describe('getEffectiveValue', () => { it('should return value from settings when set', () => { - const settings = { ui: { showMemoryUsage: true } }; - const mergedSettings = { ui: { showMemoryUsage: false } }; + const settings = makeMockSettings({ ui: { requiresRestart: true } }); + const mergedSettings = makeMockSettings({ + ui: { requiresRestart: false }, + }); const value = getEffectiveValue( - 'ui.showMemoryUsage', + 'ui.requiresRestart', settings, mergedSettings, ); @@ -121,11 +226,13 @@ describe('SettingsUtils', () => { }); it('should return value from merged settings when not set in current scope', () => { - const settings = {}; - const mergedSettings = { ui: { showMemoryUsage: true } }; + const settings = makeMockSettings({}); + const mergedSettings = makeMockSettings({ + ui: { requiresRestart: true }, + }); const value = getEffectiveValue( - 'ui.showMemoryUsage', + 'ui.requiresRestart', settings, mergedSettings, ); @@ -133,11 +240,11 @@ describe('SettingsUtils', () => { }); it('should return default value when not set anywhere', () => { - const settings = {}; - const mergedSettings = {}; + const settings = makeMockSettings({}); + const mergedSettings = makeMockSettings({}); const value = getEffectiveValue( - 'ui.showMemoryUsage', + 'ui.requiresRestart', settings, mergedSettings, ); @@ -145,12 +252,12 @@ describe('SettingsUtils', () => { }); it('should handle nested settings correctly', () => { - const settings = { + const settings = makeMockSettings({ ui: { accessibility: { disableLoadingPhrases: true } }, - }; - const mergedSettings = { + }); + const mergedSettings = makeMockSettings({ ui: { accessibility: { disableLoadingPhrases: false } }, - }; + }); const value = getEffectiveValue( 'ui.accessibility.disableLoadingPhrases', @@ -161,8 +268,8 @@ describe('SettingsUtils', () => { }); it('should return undefined for invalid settings', () => { - const settings = {}; - const mergedSettings = {}; + const settings = makeMockSettings({}); + const mergedSettings = makeMockSettings({}); const value = getEffectiveValue( 'invalidSetting', @@ -176,9 +283,8 @@ describe('SettingsUtils', () => { describe('getAllSettingKeys', () => { it('should return all setting keys', () => { const keys = getAllSettingKeys(); - expect(keys).toContain('ui.showMemoryUsage'); + expect(keys).toContain('test'); expect(keys).toContain('ui.accessibility.disableLoadingPhrases'); - expect(keys).toContain('general.checkpointing.enabled'); }); }); @@ -204,7 +310,7 @@ describe('SettingsUtils', () => { describe('isValidSettingKey', () => { it('should return true for valid setting keys', () => { - expect(isValidSettingKey('ui.showMemoryUsage')).toBe(true); + expect(isValidSettingKey('ui.requiresRestart')).toBe(true); expect( isValidSettingKey('ui.accessibility.disableLoadingPhrases'), ).toBe(true); @@ -218,7 +324,7 @@ describe('SettingsUtils', () => { describe('getSettingCategory', () => { it('should return correct category for valid settings', () => { - expect(getSettingCategory('ui.showMemoryUsage')).toBe('UI'); + expect(getSettingCategory('ui.requiresRestart')).toBe('UI'); expect( getSettingCategory('ui.accessibility.disableLoadingPhrases'), ).toBe('UI'); @@ -231,20 +337,13 @@ describe('SettingsUtils', () => { describe('shouldShowInDialog', () => { it('should return true for settings marked to show in dialog', () => { - expect(shouldShowInDialog('ui.showMemoryUsage')).toBe(true); + expect(shouldShowInDialog('ui.requiresRestart')).toBe(true); expect(shouldShowInDialog('general.vimMode')).toBe(true); expect(shouldShowInDialog('ui.hideWindowTitle')).toBe(true); - expect(shouldShowInDialog('privacy.usageStatisticsEnabled')).toBe( - false, - ); }); it('should return false for settings marked to hide from dialog', () => { - expect(shouldShowInDialog('security.auth.selectedType')).toBe(false); - expect(shouldShowInDialog('tools.core')).toBe(false); - expect(shouldShowInDialog('ui.customThemes')).toBe(false); - expect(shouldShowInDialog('ui.theme')).toBe(false); // Changed to false - expect(shouldShowInDialog('general.preferredEditor')).toBe(false); // Changed to false + expect(shouldShowInDialog('ui.theme')).toBe(false); }); it('should return true for invalid settings (default behavior)', () => { @@ -260,9 +359,8 @@ describe('SettingsUtils', () => { expect(categories['UI']).toBeDefined(); const uiSettings = categories['UI']; const uiKeys = uiSettings.map((s) => s.key); - expect(uiKeys).toContain('ui.showMemoryUsage'); - expect(uiKeys).toContain('ui.hideWindowTitle'); - expect(uiKeys).not.toContain('ui.customThemes'); // This is marked false + expect(uiKeys).toContain('ui.requiresRestart'); + expect(uiKeys).toContain('ui.accessibility.disableLoadingPhrases'); expect(uiKeys).not.toContain('ui.theme'); // This is now marked false }); @@ -279,13 +377,8 @@ describe('SettingsUtils', () => { const allSettings = Object.values(categories).flat(); const allKeys = allSettings.map((s) => s.key); - expect(allKeys).toContain('general.vimMode'); - expect(allKeys).toContain('ide.enabled'); - expect(allKeys).toContain('general.disableAutoUpdate'); - expect(allKeys).toContain('ui.showMemoryUsage'); - expect(allKeys).not.toContain('privacy.usageStatisticsEnabled'); - expect(allKeys).not.toContain('security.auth.selectedType'); - expect(allKeys).not.toContain('tools.core'); + expect(allKeys).toContain('test'); + expect(allKeys).toContain('ui.requiresRestart'); expect(allKeys).not.toContain('ui.theme'); // Now hidden expect(allKeys).not.toContain('general.preferredEditor'); // Now hidden }); @@ -296,9 +389,8 @@ describe('SettingsUtils', () => { const booleanSettings = getDialogSettingsByType('boolean'); const keys = booleanSettings.map((s) => s.key); - expect(keys).toContain('ui.showMemoryUsage'); - expect(keys).toContain('general.vimMode'); - expect(keys).toContain('ui.hideWindowTitle'); + expect(keys).toContain('ui.requiresRestart'); + expect(keys).toContain('ui.accessibility.disableLoadingPhrases'); expect(keys).not.toContain('privacy.usageStatisticsEnabled'); expect(keys).not.toContain('security.auth.selectedType'); // Advanced setting expect(keys).not.toContain('security.auth.useExternal'); // Advanced setting @@ -323,30 +415,13 @@ describe('SettingsUtils', () => { const dialogKeys = getDialogSettingKeys(); // Should include settings marked for dialog - expect(dialogKeys).toContain('ui.showMemoryUsage'); - expect(dialogKeys).toContain('general.vimMode'); - expect(dialogKeys).toContain('ui.hideWindowTitle'); - expect(dialogKeys).not.toContain('privacy.usageStatisticsEnabled'); - expect(dialogKeys).toContain('ide.enabled'); - expect(dialogKeys).toContain('general.disableAutoUpdate'); + expect(dialogKeys).toContain('ui.requiresRestart'); // Should include nested settings marked for dialog - expect(dialogKeys).toContain('context.fileFiltering.respectGitIgnore'); - expect(dialogKeys).toContain( - 'context.fileFiltering.respectGeminiIgnore', - ); - expect(dialogKeys).toContain( - 'context.fileFiltering.enableRecursiveFileSearch', - ); + expect(dialogKeys).toContain('ui.accessibility.disableLoadingPhrases'); // Should NOT include settings marked as hidden expect(dialogKeys).not.toContain('ui.theme'); // Hidden - expect(dialogKeys).not.toContain('ui.customThemes'); // Hidden - expect(dialogKeys).not.toContain('general.preferredEditor'); // Hidden - expect(dialogKeys).not.toContain('security.auth.selectedType'); // Advanced - expect(dialogKeys).not.toContain('tools.core'); // Advanced - expect(dialogKeys).not.toContain('mcpServers'); // Advanced - expect(dialogKeys).not.toContain('telemetry'); // Advanced }); it('should return fewer keys than getAllSettingKeys', () => { @@ -358,10 +433,44 @@ describe('SettingsUtils', () => { }); it('should handle nested settings display correctly', () => { + vi.mocked(getSettingsSchema).mockReturnValue({ + context: { + type: 'object', + label: 'Context', + category: 'Context', + requiresRestart: false, + default: {}, + description: 'Settings for managing context provided to the model.', + showInDialog: false, + properties: { + fileFiltering: { + type: 'object', + label: 'File Filtering', + category: 'Context', + requiresRestart: true, + default: {}, + description: 'Settings for git-aware file filtering.', + showInDialog: false, + properties: { + respectGitIgnore: { + type: 'boolean', + label: 'Respect .gitignore', + category: 'Context', + requiresRestart: true, + default: true, + description: 'Respect .gitignore files when searching', + showInDialog: true, + }, + }, + }, + }, + }, + } as unknown as SettingsSchemaType); + // Test the specific issue with fileFiltering.respectGitIgnore const key = 'context.fileFiltering.respectGitIgnore'; - const initialSettings = {}; - const pendingSettings = {}; + const initialSettings = makeMockSettings({}); + const pendingSettings = makeMockSettings({}); // Set the nested setting to true const updatedPendingSettings = setPendingSettingValue( @@ -412,11 +521,13 @@ describe('SettingsUtils', () => { describe('Business Logic Utilities', () => { describe('getSettingValue', () => { it('should return value from settings when set', () => { - const settings = { ui: { showMemoryUsage: true } }; - const mergedSettings = { ui: { showMemoryUsage: false } }; + const settings = makeMockSettings({ ui: { requiresRestart: true } }); + const mergedSettings = makeMockSettings({ + ui: { requiresRestart: false }, + }); const value = getSettingValue( - 'ui.showMemoryUsage', + 'ui.requiresRestart', settings, mergedSettings, ); @@ -424,11 +535,13 @@ describe('SettingsUtils', () => { }); it('should return value from merged settings when not set in current scope', () => { - const settings = {}; - const mergedSettings = { ui: { showMemoryUsage: true } }; + const settings = makeMockSettings({}); + const mergedSettings = makeMockSettings({ + ui: { requiresRestart: true }, + }); const value = getSettingValue( - 'ui.showMemoryUsage', + 'ui.requiresRestart', settings, mergedSettings, ); @@ -436,8 +549,8 @@ describe('SettingsUtils', () => { }); it('should return default value for invalid setting', () => { - const settings = {}; - const mergedSettings = {}; + const settings = makeMockSettings({}); + const mergedSettings = makeMockSettings({}); const value = getSettingValue( 'invalidSetting', @@ -450,43 +563,37 @@ describe('SettingsUtils', () => { describe('isSettingModified', () => { it('should return true when value differs from default', () => { - expect(isSettingModified('ui.showMemoryUsage', true)).toBe(true); + expect(isSettingModified('ui.requiresRestart', true)).toBe(true); expect( - isSettingModified( - 'context.fileFiltering.enableRecursiveFileSearch', - false, - ), + isSettingModified('ui.accessibility.disableLoadingPhrases', true), ).toBe(true); }); it('should return false when value matches default', () => { - expect(isSettingModified('ui.showMemoryUsage', false)).toBe(false); + expect(isSettingModified('ui.requiresRestart', false)).toBe(false); expect( - isSettingModified( - 'context.fileFiltering.enableRecursiveFileSearch', - true, - ), + isSettingModified('ui.accessibility.disableLoadingPhrases', false), ).toBe(false); }); }); describe('settingExistsInScope', () => { it('should return true for top-level settings that exist', () => { - const settings = { ui: { showMemoryUsage: true } }; - expect(settingExistsInScope('ui.showMemoryUsage', settings)).toBe(true); + const settings = makeMockSettings({ ui: { requiresRestart: true } }); + expect(settingExistsInScope('ui.requiresRestart', settings)).toBe(true); }); it('should return false for top-level settings that do not exist', () => { - const settings = {}; - expect(settingExistsInScope('ui.showMemoryUsage', settings)).toBe( + const settings = makeMockSettings({}); + expect(settingExistsInScope('ui.requiresRestart', settings)).toBe( false, ); }); it('should return true for nested settings that exist', () => { - const settings = { + const settings = makeMockSettings({ ui: { accessibility: { disableLoadingPhrases: true } }, - }; + }); expect( settingExistsInScope( 'ui.accessibility.disableLoadingPhrases', @@ -496,7 +603,7 @@ describe('SettingsUtils', () => { }); it('should return false for nested settings that do not exist', () => { - const settings = {}; + const settings = makeMockSettings({}); expect( settingExistsInScope( 'ui.accessibility.disableLoadingPhrases', @@ -506,7 +613,7 @@ describe('SettingsUtils', () => { }); it('should return false when parent exists but child does not', () => { - const settings = { ui: { accessibility: {} } }; + const settings = makeMockSettings({ ui: { accessibility: {} } }); expect( settingExistsInScope( 'ui.accessibility.disableLoadingPhrases', @@ -518,18 +625,18 @@ describe('SettingsUtils', () => { describe('setPendingSettingValue', () => { it('should set top-level setting value', () => { - const pendingSettings = {}; + const pendingSettings = makeMockSettings({}); const result = setPendingSettingValue( - 'ui.showMemoryUsage', + 'ui.hideWindowTitle', true, pendingSettings, ); - expect(result.ui?.showMemoryUsage).toBe(true); + expect(result.ui?.hideWindowTitle).toBe(true); }); it('should set nested setting value', () => { - const pendingSettings = {}; + const pendingSettings = makeMockSettings({}); const result = setPendingSettingValue( 'ui.accessibility.disableLoadingPhrases', true, @@ -540,9 +647,9 @@ describe('SettingsUtils', () => { }); it('should preserve existing nested settings', () => { - const pendingSettings = { + const pendingSettings = makeMockSettings({ ui: { accessibility: { disableLoadingPhrases: false } }, - }; + }); const result = setPendingSettingValue( 'ui.accessibility.disableLoadingPhrases', true, @@ -553,8 +660,8 @@ describe('SettingsUtils', () => { }); it('should not mutate original settings', () => { - const pendingSettings = {}; - setPendingSettingValue('ui.showMemoryUsage', true, pendingSettings); + const pendingSettings = makeMockSettings({}); + setPendingSettingValue('ui.requiresRestart', true, pendingSettings); expect(pendingSettings).toEqual({}); }); @@ -564,16 +671,13 @@ describe('SettingsUtils', () => { it('should return true when modified settings require restart', () => { const modifiedSettings = new Set([ 'advanced.autoConfigureMemory', - 'ui.showMemoryUsage', + 'ui.requiresRestart', ]); expect(hasRestartRequiredSettings(modifiedSettings)).toBe(true); }); it('should return false when no modified settings require restart', () => { - const modifiedSettings = new Set([ - 'ui.showMemoryUsage', - 'ui.hideTips', - ]); + const modifiedSettings = new Set(['test']); expect(hasRestartRequiredSettings(modifiedSettings)).toBe(false); }); @@ -586,20 +690,18 @@ describe('SettingsUtils', () => { describe('getRestartRequiredFromModified', () => { it('should return only settings that require restart', () => { const modifiedSettings = new Set([ - 'advanced.autoConfigureMemory', - 'ui.showMemoryUsage', - 'general.checkpointing.enabled', + 'ui.requiresRestart', + 'test', ]); const result = getRestartRequiredFromModified(modifiedSettings); - expect(result).toContain('advanced.autoConfigureMemory'); - expect(result).toContain('general.checkpointing.enabled'); - expect(result).not.toContain('ui.showMemoryUsage'); + expect(result).toContain('ui.requiresRestart'); + expect(result).not.toContain('test'); }); it('should return empty array when no settings require restart', () => { const modifiedSettings = new Set([ - 'showMemoryUsage', + 'requiresRestart', 'hideTips', ]); const result = getRestartRequiredFromModified(modifiedSettings); @@ -609,13 +711,193 @@ describe('SettingsUtils', () => { }); describe('getDisplayValue', () => { + describe('enum behavior', () => { + enum StringEnum { + FOO = 'foo', + BAR = 'bar', + BAZ = 'baz', + } + + enum NumberEnum { + ONE = 1, + TWO = 2, + THREE = 3, + } + + const SETTING: SettingDefinition = { + type: 'enum', + label: 'Theme', + options: [ + { + value: StringEnum.FOO, + label: 'Foo', + }, + { + value: StringEnum.BAR, + label: 'Bar', + }, + { + value: StringEnum.BAZ, + label: 'Baz', + }, + ], + category: 'UI', + requiresRestart: false, + default: StringEnum.BAR, + description: 'The color theme for the UI.', + showInDialog: false, + }; + + it('handles display of number-based enums', () => { + vi.mocked(getSettingsSchema).mockReturnValue({ + ui: { + properties: { + theme: { + ...SETTING, + options: [ + { + value: NumberEnum.ONE, + label: 'One', + }, + { + value: NumberEnum.TWO, + label: 'Two', + }, + { + value: NumberEnum.THREE, + label: 'Three', + }, + ], + }, + }, + }, + } as unknown as SettingsSchemaType); + + const settings = makeMockSettings({ + ui: { theme: NumberEnum.THREE }, + }); + const mergedSettings = makeMockSettings({ + ui: { theme: NumberEnum.THREE }, + }); + const modifiedSettings = new Set(); + + const result = getDisplayValue( + 'ui.theme', + settings, + mergedSettings, + modifiedSettings, + ); + + expect(result).toBe('Three*'); + }); + + it('handles default values for number-based enums', () => { + vi.mocked(getSettingsSchema).mockReturnValue({ + ui: { + properties: { + theme: { + ...SETTING, + default: NumberEnum.THREE, + options: [ + { + value: NumberEnum.ONE, + label: 'One', + }, + { + value: NumberEnum.TWO, + label: 'Two', + }, + { + value: NumberEnum.THREE, + label: 'Three', + }, + ], + }, + }, + }, + } as unknown as SettingsSchemaType); + const modifiedSettings = new Set(); + + const result = getDisplayValue( + 'ui.theme', + makeMockSettings({}), + makeMockSettings({}), + modifiedSettings, + ); + expect(result).toBe('Three'); + }); + + it('shows the enum display value', () => { + vi.mocked(getSettingsSchema).mockReturnValue({ + ui: { properties: { theme: { ...SETTING } } }, + } as unknown as SettingsSchemaType); + const settings = makeMockSettings({ ui: { theme: StringEnum.BAR } }); + const mergedSettings = makeMockSettings({ + ui: { theme: StringEnum.BAR }, + }); + const modifiedSettings = new Set(); + + const result = getDisplayValue( + 'ui.theme', + settings, + mergedSettings, + modifiedSettings, + ); + expect(result).toBe('Bar*'); + }); + + it('passes through unknown values verbatim', () => { + vi.mocked(getSettingsSchema).mockReturnValue({ + ui: { + properties: { + theme: { ...SETTING }, + }, + }, + } as unknown as SettingsSchemaType); + const settings = makeMockSettings({ ui: { theme: 'xyz' } }); + const mergedSettings = makeMockSettings({ ui: { theme: 'xyz' } }); + const modifiedSettings = new Set(); + + const result = getDisplayValue( + 'ui.theme', + settings, + mergedSettings, + modifiedSettings, + ); + expect(result).toBe('xyz*'); + }); + + it('shows the default value for string enums', () => { + vi.mocked(getSettingsSchema).mockReturnValue({ + ui: { + properties: { + theme: { ...SETTING, default: StringEnum.BAR }, + }, + }, + } as unknown as SettingsSchemaType); + const modifiedSettings = new Set(); + + const result = getDisplayValue( + 'ui.theme', + makeMockSettings({}), + makeMockSettings({}), + modifiedSettings, + ); + expect(result).toBe('Bar'); + }); + }); + it('should show value without * when setting matches default', () => { - const settings = { ui: { showMemoryUsage: false } }; // false matches default, so no * - const mergedSettings = { ui: { showMemoryUsage: false } }; + const settings = makeMockSettings({ + ui: { requiresRestart: false }, + }); // false matches default, so no * + const mergedSettings = makeMockSettings({ + ui: { requiresRestart: false }, + }); const modifiedSettings = new Set(); const result = getDisplayValue( - 'ui.showMemoryUsage', + 'ui.requiresRestart', settings, mergedSettings, modifiedSettings, @@ -624,12 +906,14 @@ describe('SettingsUtils', () => { }); it('should show default value when setting is not in scope', () => { - const settings = {}; // no setting in scope - const mergedSettings = { ui: { showMemoryUsage: false } }; + const settings = makeMockSettings({}); // no setting in scope + const mergedSettings = makeMockSettings({ + ui: { requiresRestart: false }, + }); const modifiedSettings = new Set(); const result = getDisplayValue( - 'ui.showMemoryUsage', + 'ui.requiresRestart', settings, mergedSettings, modifiedSettings, @@ -638,12 +922,14 @@ describe('SettingsUtils', () => { }); it('should show value with * when changed from default', () => { - const settings = { ui: { showMemoryUsage: true } }; // true is different from default (false) - const mergedSettings = { ui: { showMemoryUsage: true } }; + const settings = makeMockSettings({ ui: { requiresRestart: true } }); // true is different from default (false) + const mergedSettings = makeMockSettings({ + ui: { requiresRestart: true }, + }); const modifiedSettings = new Set(); const result = getDisplayValue( - 'ui.showMemoryUsage', + 'ui.requiresRestart', settings, mergedSettings, modifiedSettings, @@ -652,12 +938,14 @@ describe('SettingsUtils', () => { }); it('should show default value without * when setting does not exist in scope', () => { - const settings = {}; // setting doesn't exist in scope, show default - const mergedSettings = { ui: { showMemoryUsage: false } }; + const settings = makeMockSettings({}); // setting doesn't exist in scope, show default + const mergedSettings = makeMockSettings({ + ui: { requiresRestart: false }, + }); const modifiedSettings = new Set(); const result = getDisplayValue( - 'ui.showMemoryUsage', + 'ui.requiresRestart', settings, mergedSettings, modifiedSettings, @@ -666,13 +954,17 @@ describe('SettingsUtils', () => { }); it('should show value with * when user changes from default', () => { - const settings = {}; // setting doesn't exist in scope originally - const mergedSettings = { ui: { showMemoryUsage: false } }; - const modifiedSettings = new Set(['ui.showMemoryUsage']); - const pendingSettings = { ui: { showMemoryUsage: true } }; // user changed to true + const settings = makeMockSettings({}); // setting doesn't exist in scope originally + const mergedSettings = makeMockSettings({ + ui: { requiresRestart: false }, + }); + const modifiedSettings = new Set(['ui.requiresRestart']); + const pendingSettings = makeMockSettings({ + ui: { requiresRestart: true }, + }); // user changed to true const result = getDisplayValue( - 'ui.showMemoryUsage', + 'ui.requiresRestart', settings, mergedSettings, modifiedSettings, @@ -684,21 +976,21 @@ describe('SettingsUtils', () => { describe('isDefaultValue', () => { it('should return true when setting does not exist in scope', () => { - const settings = {}; // setting doesn't exist + const settings = makeMockSettings({}); // setting doesn't exist - const result = isDefaultValue('ui.showMemoryUsage', settings); + const result = isDefaultValue('ui.requiresRestart', settings); expect(result).toBe(true); }); it('should return false when setting exists in scope', () => { - const settings = { ui: { showMemoryUsage: true } }; // setting exists + const settings = makeMockSettings({ ui: { requiresRestart: true } }); // setting exists - const result = isDefaultValue('ui.showMemoryUsage', settings); + const result = isDefaultValue('ui.requiresRestart', settings); expect(result).toBe(false); }); it('should return true when nested setting does not exist in scope', () => { - const settings = {}; // nested setting doesn't exist + const settings = makeMockSettings({}); // nested setting doesn't exist const result = isDefaultValue( 'ui.accessibility.disableLoadingPhrases', @@ -708,9 +1000,9 @@ describe('SettingsUtils', () => { }); it('should return false when nested setting exists in scope', () => { - const settings = { + const settings = makeMockSettings({ ui: { accessibility: { disableLoadingPhrases: true } }, - }; // nested setting exists + }); // nested setting exists const result = isDefaultValue( 'ui.accessibility.disableLoadingPhrases', @@ -722,11 +1014,13 @@ describe('SettingsUtils', () => { describe('isValueInherited', () => { it('should return false for top-level settings that exist in scope', () => { - const settings = { ui: { showMemoryUsage: true } }; - const mergedSettings = { ui: { showMemoryUsage: true } }; + const settings = makeMockSettings({ ui: { requiresRestart: true } }); + const mergedSettings = makeMockSettings({ + ui: { requiresRestart: true }, + }); const result = isValueInherited( - 'ui.showMemoryUsage', + 'ui.requiresRestart', settings, mergedSettings, ); @@ -734,11 +1028,13 @@ describe('SettingsUtils', () => { }); it('should return true for top-level settings that do not exist in scope', () => { - const settings = {}; - const mergedSettings = { ui: { showMemoryUsage: true } }; + const settings = makeMockSettings({}); + const mergedSettings = makeMockSettings({ + ui: { requiresRestart: true }, + }); const result = isValueInherited( - 'ui.showMemoryUsage', + 'ui.requiresRestart', settings, mergedSettings, ); @@ -746,12 +1042,12 @@ describe('SettingsUtils', () => { }); it('should return false for nested settings that exist in scope', () => { - const settings = { + const settings = makeMockSettings({ ui: { accessibility: { disableLoadingPhrases: true } }, - }; - const mergedSettings = { + }); + const mergedSettings = makeMockSettings({ ui: { accessibility: { disableLoadingPhrases: true } }, - }; + }); const result = isValueInherited( 'ui.accessibility.disableLoadingPhrases', @@ -762,10 +1058,10 @@ describe('SettingsUtils', () => { }); it('should return true for nested settings that do not exist in scope', () => { - const settings = {}; - const mergedSettings = { + const settings = makeMockSettings({}); + const mergedSettings = makeMockSettings({ ui: { accessibility: { disableLoadingPhrases: true } }, - }; + }); const result = isValueInherited( 'ui.accessibility.disableLoadingPhrases', @@ -778,11 +1074,13 @@ describe('SettingsUtils', () => { describe('getEffectiveDisplayValue', () => { it('should return value from settings when available', () => { - const settings = { ui: { showMemoryUsage: true } }; - const mergedSettings = { ui: { showMemoryUsage: false } }; + const settings = makeMockSettings({ ui: { requiresRestart: true } }); + const mergedSettings = makeMockSettings({ + ui: { requiresRestart: false }, + }); const result = getEffectiveDisplayValue( - 'ui.showMemoryUsage', + 'ui.requiresRestart', settings, mergedSettings, ); @@ -790,11 +1088,13 @@ describe('SettingsUtils', () => { }); it('should return value from merged settings when not in scope', () => { - const settings = {}; - const mergedSettings = { ui: { showMemoryUsage: true } }; + const settings = makeMockSettings({}); + const mergedSettings = makeMockSettings({ + ui: { requiresRestart: true }, + }); const result = getEffectiveDisplayValue( - 'ui.showMemoryUsage', + 'ui.requiresRestart', settings, mergedSettings, ); @@ -802,11 +1102,11 @@ describe('SettingsUtils', () => { }); it('should return default value for undefined values', () => { - const settings = {}; - const mergedSettings = {}; + const settings = makeMockSettings({}); + const mergedSettings = makeMockSettings({}); const result = getEffectiveDisplayValue( - 'ui.showMemoryUsage', + 'ui.requiresRestart', settings, mergedSettings, ); diff --git a/packages/cli/src/utils/settingsUtils.ts b/packages/cli/src/utils/settingsUtils.ts index d6a114a674..a9a429370a 100644 --- a/packages/cli/src/utils/settingsUtils.ts +++ b/packages/cli/src/utils/settingsUtils.ts @@ -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 { - let result: Record = {}; +type FlattenedSchema = Record; + +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 > = {}; - 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, 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, 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 { - 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 > = {}; - 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 { - 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 };