summaryrefslogtreecommitdiff
path: root/packages/cli/src/utils
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/utils')
-rw-r--r--packages/cli/src/utils/dialogScopeUtils.ts64
-rw-r--r--packages/cli/src/utils/settingsUtils.test.ts797
-rw-r--r--packages/cli/src/utils/settingsUtils.ts473
3 files changed, 1334 insertions, 0 deletions
diff --git a/packages/cli/src/utils/dialogScopeUtils.ts b/packages/cli/src/utils/dialogScopeUtils.ts
new file mode 100644
index 00000000..c175f9c8
--- /dev/null
+++ b/packages/cli/src/utils/dialogScopeUtils.ts
@@ -0,0 +1,64 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { SettingScope, LoadedSettings } from '../config/settings.js';
+import { settingExistsInScope } from './settingsUtils.js';
+
+/**
+ * Shared scope labels for dialog components that need to display setting scopes
+ */
+export const SCOPE_LABELS = {
+ [SettingScope.User]: 'User Settings',
+ [SettingScope.Workspace]: 'Workspace Settings',
+ [SettingScope.System]: 'System Settings',
+} as const;
+
+/**
+ * Helper function to get scope items for radio button selects
+ */
+export function getScopeItems() {
+ return [
+ { label: SCOPE_LABELS[SettingScope.User], value: SettingScope.User },
+ {
+ label: SCOPE_LABELS[SettingScope.Workspace],
+ value: SettingScope.Workspace,
+ },
+ { label: SCOPE_LABELS[SettingScope.System], value: SettingScope.System },
+ ];
+}
+
+/**
+ * Generate scope message for a specific setting
+ */
+export function getScopeMessageForSetting(
+ settingKey: string,
+ selectedScope: SettingScope,
+ settings: LoadedSettings,
+): string {
+ const otherScopes = Object.values(SettingScope).filter(
+ (scope) => scope !== selectedScope,
+ );
+
+ const modifiedInOtherScopes = otherScopes.filter((scope) => {
+ const scopeSettings = settings.forScope(scope).settings;
+ return settingExistsInScope(settingKey, scopeSettings);
+ });
+
+ if (modifiedInOtherScopes.length === 0) {
+ return '';
+ }
+
+ const modifiedScopesStr = modifiedInOtherScopes.join(', ');
+ const currentScopeSettings = settings.forScope(selectedScope).settings;
+ const existsInCurrentScope = settingExistsInScope(
+ settingKey,
+ currentScopeSettings,
+ );
+
+ return existsInCurrentScope
+ ? `(Also modified in ${modifiedScopesStr})`
+ : `(Modified in ${modifiedScopesStr})`;
+}
diff --git a/packages/cli/src/utils/settingsUtils.test.ts b/packages/cli/src/utils/settingsUtils.test.ts
new file mode 100644
index 00000000..2aeb1da3
--- /dev/null
+++ b/packages/cli/src/utils/settingsUtils.test.ts
@@ -0,0 +1,797 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+ // Schema utilities
+ getSettingsByCategory,
+ getSettingDefinition,
+ requiresRestart,
+ getDefaultValue,
+ getRestartRequiredSettings,
+ getEffectiveValue,
+ getAllSettingKeys,
+ getSettingsByType,
+ getSettingsRequiringRestart,
+ isValidSettingKey,
+ getSettingCategory,
+ shouldShowInDialog,
+ getDialogSettingsByCategory,
+ getDialogSettingsByType,
+ getDialogSettingKeys,
+ // Business logic utilities
+ getSettingValue,
+ isSettingModified,
+ settingExistsInScope,
+ setPendingSettingValue,
+ hasRestartRequiredSettings,
+ getRestartRequiredFromModified,
+ getDisplayValue,
+ isDefaultValue,
+ isValueInherited,
+ getEffectiveDisplayValue,
+} from './settingsUtils.js';
+
+describe('SettingsUtils', () => {
+ describe('Schema Utilities', () => {
+ describe('getSettingsByCategory', () => {
+ it('should group settings by category', () => {
+ const categories = getSettingsByCategory();
+
+ expect(categories).toHaveProperty('General');
+ expect(categories).toHaveProperty('Accessibility');
+ expect(categories).toHaveProperty('Checkpointing');
+ expect(categories).toHaveProperty('File Filtering');
+ expect(categories).toHaveProperty('UI');
+ expect(categories).toHaveProperty('Mode');
+ expect(categories).toHaveProperty('Updates');
+ });
+
+ it('should include key property in grouped settings', () => {
+ const categories = getSettingsByCategory();
+
+ Object.entries(categories).forEach(([_category, settings]) => {
+ settings.forEach((setting) => {
+ expect(setting.key).toBeDefined();
+ });
+ });
+ });
+ });
+
+ describe('getSettingDefinition', () => {
+ it('should return definition for valid setting', () => {
+ const definition = getSettingDefinition('showMemoryUsage');
+ expect(definition).toBeDefined();
+ expect(definition?.label).toBe('Show Memory Usage');
+ });
+
+ it('should return undefined for invalid setting', () => {
+ const definition = getSettingDefinition('invalidSetting');
+ expect(definition).toBeUndefined();
+ });
+ });
+
+ describe('requiresRestart', () => {
+ it('should return true for settings that require restart', () => {
+ expect(requiresRestart('autoConfigureMaxOldSpaceSize')).toBe(true);
+ expect(requiresRestart('checkpointing.enabled')).toBe(true);
+ });
+
+ it('should return false for settings that do not require restart', () => {
+ expect(requiresRestart('showMemoryUsage')).toBe(false);
+ expect(requiresRestart('hideTips')).toBe(false);
+ });
+
+ it('should return false for invalid settings', () => {
+ expect(requiresRestart('invalidSetting')).toBe(false);
+ });
+ });
+
+ describe('getDefaultValue', () => {
+ it('should return correct default values', () => {
+ expect(getDefaultValue('showMemoryUsage')).toBe(false);
+ expect(getDefaultValue('fileFiltering.enableRecursiveFileSearch')).toBe(
+ true,
+ );
+ });
+
+ it('should return undefined for invalid settings', () => {
+ expect(getDefaultValue('invalidSetting')).toBeUndefined();
+ });
+ });
+
+ describe('getRestartRequiredSettings', () => {
+ it('should return all settings that require restart', () => {
+ const restartSettings = getRestartRequiredSettings();
+ expect(restartSettings).toContain('autoConfigureMaxOldSpaceSize');
+ expect(restartSettings).toContain('checkpointing.enabled');
+ expect(restartSettings).not.toContain('showMemoryUsage');
+ });
+ });
+
+ describe('getEffectiveValue', () => {
+ it('should return value from settings when set', () => {
+ const settings = { showMemoryUsage: true };
+ const mergedSettings = { showMemoryUsage: false };
+
+ const value = getEffectiveValue(
+ 'showMemoryUsage',
+ settings,
+ mergedSettings,
+ );
+ expect(value).toBe(true);
+ });
+
+ it('should return value from merged settings when not set in current scope', () => {
+ const settings = {};
+ const mergedSettings = { showMemoryUsage: true };
+
+ const value = getEffectiveValue(
+ 'showMemoryUsage',
+ settings,
+ mergedSettings,
+ );
+ expect(value).toBe(true);
+ });
+
+ it('should return default value when not set anywhere', () => {
+ const settings = {};
+ const mergedSettings = {};
+
+ const value = getEffectiveValue(
+ 'showMemoryUsage',
+ settings,
+ mergedSettings,
+ );
+ expect(value).toBe(false); // default value
+ });
+
+ it('should handle nested settings correctly', () => {
+ const settings = {
+ accessibility: { disableLoadingPhrases: true },
+ };
+ const mergedSettings = {
+ accessibility: { disableLoadingPhrases: false },
+ };
+
+ const value = getEffectiveValue(
+ 'accessibility.disableLoadingPhrases',
+ settings,
+ mergedSettings,
+ );
+ expect(value).toBe(true);
+ });
+
+ it('should return undefined for invalid settings', () => {
+ const settings = {};
+ const mergedSettings = {};
+
+ const value = getEffectiveValue(
+ 'invalidSetting',
+ settings,
+ mergedSettings,
+ );
+ expect(value).toBeUndefined();
+ });
+ });
+
+ describe('getAllSettingKeys', () => {
+ it('should return all setting keys', () => {
+ const keys = getAllSettingKeys();
+ expect(keys).toContain('showMemoryUsage');
+ expect(keys).toContain('accessibility.disableLoadingPhrases');
+ expect(keys).toContain('checkpointing.enabled');
+ });
+ });
+
+ describe('getSettingsByType', () => {
+ it('should return only boolean settings', () => {
+ const booleanSettings = getSettingsByType('boolean');
+ expect(booleanSettings.length).toBeGreaterThan(0);
+ booleanSettings.forEach((setting) => {
+ expect(setting.type).toBe('boolean');
+ });
+ });
+ });
+
+ describe('getSettingsRequiringRestart', () => {
+ it('should return only settings that require restart', () => {
+ const restartSettings = getSettingsRequiringRestart();
+ expect(restartSettings.length).toBeGreaterThan(0);
+ restartSettings.forEach((setting) => {
+ expect(setting.requiresRestart).toBe(true);
+ });
+ });
+ });
+
+ describe('isValidSettingKey', () => {
+ it('should return true for valid setting keys', () => {
+ expect(isValidSettingKey('showMemoryUsage')).toBe(true);
+ expect(isValidSettingKey('accessibility.disableLoadingPhrases')).toBe(
+ true,
+ );
+ });
+
+ it('should return false for invalid setting keys', () => {
+ expect(isValidSettingKey('invalidSetting')).toBe(false);
+ expect(isValidSettingKey('')).toBe(false);
+ });
+ });
+
+ describe('getSettingCategory', () => {
+ it('should return correct category for valid settings', () => {
+ expect(getSettingCategory('showMemoryUsage')).toBe('UI');
+ expect(getSettingCategory('accessibility.disableLoadingPhrases')).toBe(
+ 'Accessibility',
+ );
+ });
+
+ it('should return undefined for invalid settings', () => {
+ expect(getSettingCategory('invalidSetting')).toBeUndefined();
+ });
+ });
+
+ describe('shouldShowInDialog', () => {
+ it('should return true for settings marked to show in dialog', () => {
+ expect(shouldShowInDialog('showMemoryUsage')).toBe(true);
+ expect(shouldShowInDialog('vimMode')).toBe(true);
+ expect(shouldShowInDialog('hideWindowTitle')).toBe(true);
+ expect(shouldShowInDialog('usageStatisticsEnabled')).toBe(true);
+ });
+
+ it('should return false for settings marked to hide from dialog', () => {
+ expect(shouldShowInDialog('selectedAuthType')).toBe(false);
+ expect(shouldShowInDialog('coreTools')).toBe(false);
+ expect(shouldShowInDialog('customThemes')).toBe(false);
+ expect(shouldShowInDialog('theme')).toBe(false); // Changed to false
+ expect(shouldShowInDialog('preferredEditor')).toBe(false); // Changed to false
+ });
+
+ it('should return true for invalid settings (default behavior)', () => {
+ expect(shouldShowInDialog('invalidSetting')).toBe(true);
+ });
+ });
+
+ describe('getDialogSettingsByCategory', () => {
+ it('should only return settings marked for dialog display', async () => {
+ const categories = getDialogSettingsByCategory();
+
+ // Should include UI settings that are marked for dialog
+ expect(categories['UI']).toBeDefined();
+ const uiSettings = categories['UI'];
+ const uiKeys = uiSettings.map((s) => s.key);
+ expect(uiKeys).toContain('showMemoryUsage');
+ expect(uiKeys).toContain('hideWindowTitle');
+ expect(uiKeys).not.toContain('customThemes'); // This is marked false
+ expect(uiKeys).not.toContain('theme'); // This is now marked false
+ });
+
+ it('should not include Advanced category settings', () => {
+ const categories = getDialogSettingsByCategory();
+
+ // Advanced settings should be filtered out
+ expect(categories['Advanced']).toBeUndefined();
+ });
+
+ it('should include settings with showInDialog=true', () => {
+ const categories = getDialogSettingsByCategory();
+
+ const allSettings = Object.values(categories).flat();
+ const allKeys = allSettings.map((s) => s.key);
+
+ expect(allKeys).toContain('vimMode');
+ expect(allKeys).toContain('ideMode');
+ expect(allKeys).toContain('disableAutoUpdate');
+ expect(allKeys).toContain('showMemoryUsage');
+ expect(allKeys).toContain('usageStatisticsEnabled');
+ expect(allKeys).not.toContain('selectedAuthType');
+ expect(allKeys).not.toContain('coreTools');
+ expect(allKeys).not.toContain('theme'); // Now hidden
+ expect(allKeys).not.toContain('preferredEditor'); // Now hidden
+ });
+ });
+
+ describe('getDialogSettingsByType', () => {
+ it('should return only boolean dialog settings', () => {
+ const booleanSettings = getDialogSettingsByType('boolean');
+
+ const keys = booleanSettings.map((s) => s.key);
+ expect(keys).toContain('showMemoryUsage');
+ expect(keys).toContain('vimMode');
+ expect(keys).toContain('hideWindowTitle');
+ expect(keys).toContain('usageStatisticsEnabled');
+ expect(keys).not.toContain('selectedAuthType'); // Advanced setting
+ expect(keys).not.toContain('useExternalAuth'); // Advanced setting
+ });
+
+ it('should return only string dialog settings', () => {
+ const stringSettings = getDialogSettingsByType('string');
+
+ const keys = stringSettings.map((s) => s.key);
+ // Note: theme and preferredEditor are now hidden from dialog
+ expect(keys).not.toContain('theme'); // Now marked false
+ expect(keys).not.toContain('preferredEditor'); // Now marked false
+ expect(keys).not.toContain('selectedAuthType'); // Advanced setting
+
+ // Most string settings are now hidden, so let's just check they exclude advanced ones
+ expect(keys.every((key) => !key.startsWith('tool'))).toBe(true); // No tool-related settings
+ });
+ });
+
+ describe('getDialogSettingKeys', () => {
+ it('should return only settings marked for dialog display', () => {
+ const dialogKeys = getDialogSettingKeys();
+
+ // Should include settings marked for dialog
+ expect(dialogKeys).toContain('showMemoryUsage');
+ expect(dialogKeys).toContain('vimMode');
+ expect(dialogKeys).toContain('hideWindowTitle');
+ expect(dialogKeys).toContain('usageStatisticsEnabled');
+ expect(dialogKeys).toContain('ideMode');
+ expect(dialogKeys).toContain('disableAutoUpdate');
+
+ // Should include nested settings marked for dialog
+ expect(dialogKeys).toContain('fileFiltering.respectGitIgnore');
+ expect(dialogKeys).toContain('fileFiltering.respectGeminiIgnore');
+ expect(dialogKeys).toContain('fileFiltering.enableRecursiveFileSearch');
+
+ // Should NOT include settings marked as hidden
+ expect(dialogKeys).not.toContain('theme'); // Hidden
+ expect(dialogKeys).not.toContain('customThemes'); // Hidden
+ expect(dialogKeys).not.toContain('preferredEditor'); // Hidden
+ expect(dialogKeys).not.toContain('selectedAuthType'); // Advanced
+ expect(dialogKeys).not.toContain('coreTools'); // Advanced
+ expect(dialogKeys).not.toContain('mcpServers'); // Advanced
+ expect(dialogKeys).not.toContain('telemetry'); // Advanced
+ });
+
+ it('should return fewer keys than getAllSettingKeys', () => {
+ const allKeys = getAllSettingKeys();
+ const dialogKeys = getDialogSettingKeys();
+
+ expect(dialogKeys.length).toBeLessThan(allKeys.length);
+ expect(dialogKeys.length).toBeGreaterThan(0);
+ });
+
+ it('should handle nested settings display correctly', () => {
+ // Test the specific issue with fileFiltering.respectGitIgnore
+ const key = 'fileFiltering.respectGitIgnore';
+ const initialSettings = {};
+ const pendingSettings = {};
+
+ // Set the nested setting to true
+ const updatedPendingSettings = setPendingSettingValue(
+ key,
+ true,
+ pendingSettings,
+ );
+
+ // Check if the setting exists in pending settings
+ const existsInPending = settingExistsInScope(
+ key,
+ updatedPendingSettings,
+ );
+ expect(existsInPending).toBe(true);
+
+ // Get the value from pending settings
+ const valueFromPending = getSettingValue(
+ key,
+ updatedPendingSettings,
+ {},
+ );
+ expect(valueFromPending).toBe(true);
+
+ // Test getDisplayValue should show the pending change
+ const displayValue = getDisplayValue(
+ key,
+ initialSettings,
+ {},
+ new Set(),
+ updatedPendingSettings,
+ );
+ expect(displayValue).toBe('true*'); // Should show true with * indicating change
+
+ // Test that modified settings also show the * indicator
+ const modifiedSettings = new Set([key]);
+ const displayValueWithModified = getDisplayValue(
+ key,
+ initialSettings,
+ {},
+ modifiedSettings,
+ {},
+ );
+ expect(displayValueWithModified).toBe('true*'); // Should show true* because it's in modified settings and default is true
+ });
+ });
+ });
+
+ describe('Business Logic Utilities', () => {
+ describe('getSettingValue', () => {
+ it('should return value from settings when set', () => {
+ const settings = { showMemoryUsage: true };
+ const mergedSettings = { showMemoryUsage: false };
+
+ const value = getSettingValue(
+ 'showMemoryUsage',
+ settings,
+ mergedSettings,
+ );
+ expect(value).toBe(true);
+ });
+
+ it('should return value from merged settings when not set in current scope', () => {
+ const settings = {};
+ const mergedSettings = { showMemoryUsage: true };
+
+ const value = getSettingValue(
+ 'showMemoryUsage',
+ settings,
+ mergedSettings,
+ );
+ expect(value).toBe(true);
+ });
+
+ it('should return default value for invalid setting', () => {
+ const settings = {};
+ const mergedSettings = {};
+
+ const value = getSettingValue(
+ 'invalidSetting',
+ settings,
+ mergedSettings,
+ );
+ expect(value).toBe(false); // Default fallback
+ });
+ });
+
+ describe('isSettingModified', () => {
+ it('should return true when value differs from default', () => {
+ expect(isSettingModified('showMemoryUsage', true)).toBe(true);
+ expect(
+ isSettingModified('fileFiltering.enableRecursiveFileSearch', false),
+ ).toBe(true);
+ });
+
+ it('should return false when value matches default', () => {
+ expect(isSettingModified('showMemoryUsage', false)).toBe(false);
+ expect(
+ isSettingModified('fileFiltering.enableRecursiveFileSearch', true),
+ ).toBe(false);
+ });
+ });
+
+ describe('settingExistsInScope', () => {
+ it('should return true for top-level settings that exist', () => {
+ const settings = { showMemoryUsage: true };
+ expect(settingExistsInScope('showMemoryUsage', settings)).toBe(true);
+ });
+
+ it('should return false for top-level settings that do not exist', () => {
+ const settings = {};
+ expect(settingExistsInScope('showMemoryUsage', settings)).toBe(false);
+ });
+
+ it('should return true for nested settings that exist', () => {
+ const settings = {
+ accessibility: { disableLoadingPhrases: true },
+ };
+ expect(
+ settingExistsInScope('accessibility.disableLoadingPhrases', settings),
+ ).toBe(true);
+ });
+
+ it('should return false for nested settings that do not exist', () => {
+ const settings = {};
+ expect(
+ settingExistsInScope('accessibility.disableLoadingPhrases', settings),
+ ).toBe(false);
+ });
+
+ it('should return false when parent exists but child does not', () => {
+ const settings = { accessibility: {} };
+ expect(
+ settingExistsInScope('accessibility.disableLoadingPhrases', settings),
+ ).toBe(false);
+ });
+ });
+
+ describe('setPendingSettingValue', () => {
+ it('should set top-level setting value', () => {
+ const pendingSettings = {};
+ const result = setPendingSettingValue(
+ 'showMemoryUsage',
+ true,
+ pendingSettings,
+ );
+
+ expect(result.showMemoryUsage).toBe(true);
+ });
+
+ it('should set nested setting value', () => {
+ const pendingSettings = {};
+ const result = setPendingSettingValue(
+ 'accessibility.disableLoadingPhrases',
+ true,
+ pendingSettings,
+ );
+
+ expect(result.accessibility?.disableLoadingPhrases).toBe(true);
+ });
+
+ it('should preserve existing nested settings', () => {
+ const pendingSettings = {
+ accessibility: { disableLoadingPhrases: false },
+ };
+ const result = setPendingSettingValue(
+ 'accessibility.disableLoadingPhrases',
+ true,
+ pendingSettings,
+ );
+
+ expect(result.accessibility?.disableLoadingPhrases).toBe(true);
+ });
+
+ it('should not mutate original settings', () => {
+ const pendingSettings = {};
+ setPendingSettingValue('showMemoryUsage', true, pendingSettings);
+
+ expect(pendingSettings).toEqual({});
+ });
+ });
+
+ describe('hasRestartRequiredSettings', () => {
+ it('should return true when modified settings require restart', () => {
+ const modifiedSettings = new Set<string>([
+ 'autoConfigureMaxOldSpaceSize',
+ 'showMemoryUsage',
+ ]);
+ expect(hasRestartRequiredSettings(modifiedSettings)).toBe(true);
+ });
+
+ it('should return false when no modified settings require restart', () => {
+ const modifiedSettings = new Set<string>([
+ 'showMemoryUsage',
+ 'hideTips',
+ ]);
+ expect(hasRestartRequiredSettings(modifiedSettings)).toBe(false);
+ });
+
+ it('should return false for empty set', () => {
+ const modifiedSettings = new Set<string>();
+ expect(hasRestartRequiredSettings(modifiedSettings)).toBe(false);
+ });
+ });
+
+ describe('getRestartRequiredFromModified', () => {
+ it('should return only settings that require restart', () => {
+ const modifiedSettings = new Set<string>([
+ 'autoConfigureMaxOldSpaceSize',
+ 'showMemoryUsage',
+ 'checkpointing.enabled',
+ ]);
+ const result = getRestartRequiredFromModified(modifiedSettings);
+
+ expect(result).toContain('autoConfigureMaxOldSpaceSize');
+ expect(result).toContain('checkpointing.enabled');
+ expect(result).not.toContain('showMemoryUsage');
+ });
+
+ it('should return empty array when no settings require restart', () => {
+ const modifiedSettings = new Set<string>([
+ 'showMemoryUsage',
+ 'hideTips',
+ ]);
+ const result = getRestartRequiredFromModified(modifiedSettings);
+
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe('getDisplayValue', () => {
+ it('should show value without * when setting matches default', () => {
+ const settings = { showMemoryUsage: false }; // false matches default, so no *
+ const mergedSettings = { showMemoryUsage: false };
+ const modifiedSettings = new Set<string>();
+
+ const result = getDisplayValue(
+ 'showMemoryUsage',
+ settings,
+ mergedSettings,
+ modifiedSettings,
+ );
+ expect(result).toBe('false'); // matches default, no *
+ });
+
+ it('should show default value when setting is not in scope', () => {
+ const settings = {}; // no setting in scope
+ const mergedSettings = { showMemoryUsage: false };
+ const modifiedSettings = new Set<string>();
+
+ const result = getDisplayValue(
+ 'showMemoryUsage',
+ settings,
+ mergedSettings,
+ modifiedSettings,
+ );
+ expect(result).toBe('false'); // shows default value
+ });
+
+ it('should show value with * when changed from default', () => {
+ const settings = { showMemoryUsage: true }; // true is different from default (false)
+ const mergedSettings = { showMemoryUsage: true };
+ const modifiedSettings = new Set<string>();
+
+ const result = getDisplayValue(
+ 'showMemoryUsage',
+ settings,
+ mergedSettings,
+ modifiedSettings,
+ );
+ expect(result).toBe('true*');
+ });
+
+ 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 = { showMemoryUsage: false };
+ const modifiedSettings = new Set<string>();
+
+ const result = getDisplayValue(
+ 'showMemoryUsage',
+ settings,
+ mergedSettings,
+ modifiedSettings,
+ );
+ expect(result).toBe('false'); // default value (false) without *
+ });
+
+ it('should show value with * when user changes from default', () => {
+ const settings = {}; // setting doesn't exist in scope originally
+ const mergedSettings = { showMemoryUsage: false };
+ const modifiedSettings = new Set<string>(['showMemoryUsage']);
+ const pendingSettings = { showMemoryUsage: true }; // user changed to true
+
+ const result = getDisplayValue(
+ 'showMemoryUsage',
+ settings,
+ mergedSettings,
+ modifiedSettings,
+ pendingSettings,
+ );
+ expect(result).toBe('true*'); // changed from default (false) to true
+ });
+ });
+
+ describe('isDefaultValue', () => {
+ it('should return true when setting does not exist in scope', () => {
+ const settings = {}; // setting doesn't exist
+
+ const result = isDefaultValue('showMemoryUsage', settings);
+ expect(result).toBe(true);
+ });
+
+ it('should return false when setting exists in scope', () => {
+ const settings = { showMemoryUsage: true }; // setting exists
+
+ const result = isDefaultValue('showMemoryUsage', 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 result = isDefaultValue(
+ 'accessibility.disableLoadingPhrases',
+ settings,
+ );
+ expect(result).toBe(true);
+ });
+
+ it('should return false when nested setting exists in scope', () => {
+ const settings = { accessibility: { disableLoadingPhrases: true } }; // nested setting exists
+
+ const result = isDefaultValue(
+ 'accessibility.disableLoadingPhrases',
+ settings,
+ );
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('isValueInherited', () => {
+ it('should return false for top-level settings that exist in scope', () => {
+ const settings = { showMemoryUsage: true };
+ const mergedSettings = { showMemoryUsage: true };
+
+ const result = isValueInherited(
+ 'showMemoryUsage',
+ settings,
+ mergedSettings,
+ );
+ expect(result).toBe(false);
+ });
+
+ it('should return true for top-level settings that do not exist in scope', () => {
+ const settings = {};
+ const mergedSettings = { showMemoryUsage: true };
+
+ const result = isValueInherited(
+ 'showMemoryUsage',
+ settings,
+ mergedSettings,
+ );
+ expect(result).toBe(true);
+ });
+
+ it('should return false for nested settings that exist in scope', () => {
+ const settings = {
+ accessibility: { disableLoadingPhrases: true },
+ };
+ const mergedSettings = {
+ accessibility: { disableLoadingPhrases: true },
+ };
+
+ const result = isValueInherited(
+ 'accessibility.disableLoadingPhrases',
+ settings,
+ mergedSettings,
+ );
+ expect(result).toBe(false);
+ });
+
+ it('should return true for nested settings that do not exist in scope', () => {
+ const settings = {};
+ const mergedSettings = {
+ accessibility: { disableLoadingPhrases: true },
+ };
+
+ const result = isValueInherited(
+ 'accessibility.disableLoadingPhrases',
+ settings,
+ mergedSettings,
+ );
+ expect(result).toBe(true);
+ });
+ });
+
+ describe('getEffectiveDisplayValue', () => {
+ it('should return value from settings when available', () => {
+ const settings = { showMemoryUsage: true };
+ const mergedSettings = { showMemoryUsage: false };
+
+ const result = getEffectiveDisplayValue(
+ 'showMemoryUsage',
+ settings,
+ mergedSettings,
+ );
+ expect(result).toBe(true);
+ });
+
+ it('should return value from merged settings when not in scope', () => {
+ const settings = {};
+ const mergedSettings = { showMemoryUsage: true };
+
+ const result = getEffectiveDisplayValue(
+ 'showMemoryUsage',
+ settings,
+ mergedSettings,
+ );
+ expect(result).toBe(true);
+ });
+
+ it('should return default value for undefined values', () => {
+ const settings = {};
+ const mergedSettings = {};
+
+ const result = getEffectiveDisplayValue(
+ 'showMemoryUsage',
+ settings,
+ mergedSettings,
+ );
+ expect(result).toBe(false); // Default value
+ });
+ });
+ });
+});
diff --git a/packages/cli/src/utils/settingsUtils.ts b/packages/cli/src/utils/settingsUtils.ts
new file mode 100644
index 00000000..f4363400
--- /dev/null
+++ b/packages/cli/src/utils/settingsUtils.ts
@@ -0,0 +1,473 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { Settings, SettingScope, LoadedSettings } from '../config/settings.js';
+import {
+ SETTINGS_SCHEMA,
+ SettingDefinition,
+ SettingsSchema,
+} 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 }> = {};
+ for (const key in schema) {
+ const newKey = prefix ? `${prefix}.${key}` : key;
+ const definition = schema[key];
+ result[newKey] = { ...definition, key: newKey };
+ if (definition.properties) {
+ result = { ...result, ...flattenSchema(definition.properties, newKey) };
+ }
+ }
+ return result;
+}
+
+const FLATTENED_SCHEMA = flattenSchema(SETTINGS_SCHEMA);
+
+/**
+ * Get all settings grouped by category
+ */
+export function getSettingsByCategory(): Record<
+ string,
+ Array<SettingDefinition & { key: string }>
+> {
+ const categories: Record<
+ string,
+ Array<SettingDefinition & { key: string }>
+ > = {};
+
+ Object.values(FLATTENED_SCHEMA).forEach((definition) => {
+ const category = definition.category;
+ if (!categories[category]) {
+ categories[category] = [];
+ }
+ categories[category].push(definition);
+ });
+
+ return categories;
+}
+
+/**
+ * Get a setting definition by key
+ */
+export function getSettingDefinition(
+ key: string,
+): (SettingDefinition & { key: string }) | undefined {
+ return FLATTENED_SCHEMA[key];
+}
+
+/**
+ * Check if a setting requires restart
+ */
+export function requiresRestart(key: string): boolean {
+ return FLATTENED_SCHEMA[key]?.requiresRestart ?? false;
+}
+
+/**
+ * Get the default value for a setting
+ */
+export function getDefaultValue(key: string): SettingDefinition['default'] {
+ return FLATTENED_SCHEMA[key]?.default;
+}
+
+/**
+ * Get all setting keys that require restart
+ */
+export function getRestartRequiredSettings(): string[] {
+ return Object.values(FLATTENED_SCHEMA)
+ .filter((definition) => definition.requiresRestart)
+ .map((definition) => definition.key);
+}
+
+/**
+ * Recursively gets a value from a nested object using a key path array.
+ */
+function getNestedValue(obj: Record<string, unknown>, path: string[]): unknown {
+ const [first, ...rest] = path;
+ if (!first || !(first in obj)) {
+ return undefined;
+ }
+ const value = obj[first];
+ if (rest.length === 0) {
+ return value;
+ }
+ if (value && typeof value === 'object' && value !== null) {
+ return getNestedValue(value as Record<string, unknown>, rest);
+ }
+ return undefined;
+}
+
+/**
+ * Get the effective value for a setting, considering inheritance from higher scopes
+ * Always returns a value (never undefined) - falls back to default if not set anywhere
+ */
+export function getEffectiveValue(
+ key: string,
+ settings: Settings,
+ mergedSettings: Settings,
+): SettingDefinition['default'] {
+ const definition = getSettingDefinition(key);
+ if (!definition) {
+ return undefined;
+ }
+
+ const path = key.split('.');
+
+ // Check the current scope's settings first
+ let value = getNestedValue(settings as Record<string, unknown>, path);
+ if (value !== undefined) {
+ return value as SettingDefinition['default'];
+ }
+
+ // 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 default value if no value is set anywhere
+ return definition.default;
+}
+
+/**
+ * Get all setting keys from the schema
+ */
+export function getAllSettingKeys(): string[] {
+ return Object.keys(FLATTENED_SCHEMA);
+}
+
+/**
+ * Get settings by type
+ */
+export function getSettingsByType(
+ type: SettingDefinition['type'],
+): Array<SettingDefinition & { key: string }> {
+ return Object.values(FLATTENED_SCHEMA).filter(
+ (definition) => definition.type === type,
+ );
+}
+
+/**
+ * Get settings that require restart
+ */
+export function getSettingsRequiringRestart(): Array<
+ SettingDefinition & {
+ key: string;
+ }
+> {
+ return Object.values(FLATTENED_SCHEMA).filter(
+ (definition) => definition.requiresRestart,
+ );
+}
+
+/**
+ * Validate if a setting key exists in the schema
+ */
+export function isValidSettingKey(key: string): boolean {
+ return key in FLATTENED_SCHEMA;
+}
+
+/**
+ * Get the category for a setting
+ */
+export function getSettingCategory(key: string): string | undefined {
+ return FLATTENED_SCHEMA[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
+}
+
+/**
+ * Get all settings that should be shown in the dialog, grouped by category
+ */
+export function getDialogSettingsByCategory(): Record<
+ string,
+ Array<SettingDefinition & { key: string }>
+> {
+ const categories: Record<
+ string,
+ Array<SettingDefinition & { key: string }>
+ > = {};
+
+ Object.values(FLATTENED_SCHEMA)
+ .filter((definition) => definition.showInDialog !== false)
+ .forEach((definition) => {
+ const category = definition.category;
+ if (!categories[category]) {
+ categories[category] = [];
+ }
+ categories[category].push(definition);
+ });
+
+ return categories;
+}
+
+/**
+ * Get settings by type that should be shown in the dialog
+ */
+export function getDialogSettingsByType(
+ type: SettingDefinition['type'],
+): Array<SettingDefinition & { key: string }> {
+ return Object.values(FLATTENED_SCHEMA).filter(
+ (definition) =>
+ definition.type === type && definition.showInDialog !== false,
+ );
+}
+
+/**
+ * Get all setting keys that should be shown in the dialog
+ */
+export function getDialogSettingKeys(): string[] {
+ return Object.values(FLATTENED_SCHEMA)
+ .filter((definition) => definition.showInDialog !== false)
+ .map((definition) => definition.key);
+}
+
+// ============================================================================
+// BUSINESS LOGIC UTILITIES (Higher-level utilities for setting operations)
+// ============================================================================
+
+/**
+ * Get the current value for a setting in a specific scope
+ * Always returns a value (never undefined) - falls back to default if not set anywhere
+ */
+export function getSettingValue(
+ key: string,
+ settings: Settings,
+ mergedSettings: Settings,
+): boolean {
+ const definition = getSettingDefinition(key);
+ if (!definition) {
+ return false; // Default fallback for invalid settings
+ }
+
+ const value = getEffectiveValue(key, settings, mergedSettings);
+ // Ensure we return a boolean value, converting from the more general type
+ if (typeof value === 'boolean') {
+ return value;
+ }
+ // Fall back to default value, ensuring it's a boolean
+ const defaultValue = definition.default;
+ if (typeof defaultValue === 'boolean') {
+ return defaultValue;
+ }
+ return false; // Final fallback
+}
+
+/**
+ * Check if a setting value is modified from its default
+ */
+export function isSettingModified(key: string, value: boolean): boolean {
+ const defaultValue = getDefaultValue(key);
+ // Handle type comparison properly
+ if (typeof defaultValue === 'boolean') {
+ return value !== defaultValue;
+ }
+ // If default is not a boolean, consider it modified if value is true
+ return value === true;
+}
+
+/**
+ * Check if a setting exists in the original settings file for a scope
+ */
+export function settingExistsInScope(
+ key: string,
+ scopeSettings: Settings,
+): boolean {
+ const path = key.split('.');
+ const value = getNestedValue(scopeSettings as Record<string, unknown>, path);
+ return value !== undefined;
+}
+
+/**
+ * Recursively sets a value in a nested object using a key path array.
+ */
+function setNestedValue(
+ obj: Record<string, unknown>,
+ path: string[],
+ value: unknown,
+): Record<string, unknown> {
+ const [first, ...rest] = path;
+ if (!first) {
+ return obj;
+ }
+
+ if (rest.length === 0) {
+ obj[first] = value;
+ return obj;
+ }
+
+ if (!obj[first] || typeof obj[first] !== 'object') {
+ obj[first] = {};
+ }
+
+ setNestedValue(obj[first] as Record<string, unknown>, rest, value);
+ return obj;
+}
+
+/**
+ * Set a setting value in the pending settings
+ */
+export function setPendingSettingValue(
+ key: string,
+ value: boolean,
+ pendingSettings: Settings,
+): Settings {
+ const path = key.split('.');
+ const newSettings = JSON.parse(JSON.stringify(pendingSettings));
+ setNestedValue(newSettings, path, value);
+ return newSettings;
+}
+
+/**
+ * Check if any modified settings require a restart
+ */
+export function hasRestartRequiredSettings(
+ modifiedSettings: Set<string>,
+): boolean {
+ return Array.from(modifiedSettings).some((key) => requiresRestart(key));
+}
+
+/**
+ * Get the restart required settings from a set of modified settings
+ */
+export function getRestartRequiredFromModified(
+ modifiedSettings: Set<string>,
+): string[] {
+ return Array.from(modifiedSettings).filter((key) => requiresRestart(key));
+}
+
+/**
+ * Save modified settings to the appropriate scope
+ */
+export function saveModifiedSettings(
+ modifiedSettings: Set<string>,
+ pendingSettings: Settings,
+ loadedSettings: LoadedSettings,
+ scope: SettingScope,
+): void {
+ modifiedSettings.forEach((settingKey) => {
+ const path = settingKey.split('.');
+ const value = getNestedValue(
+ pendingSettings as Record<string, unknown>,
+ path,
+ );
+
+ if (value === undefined) {
+ return;
+ }
+
+ const existsInOriginalFile = settingExistsInScope(
+ settingKey,
+ loadedSettings.forScope(scope).settings,
+ );
+
+ const isDefaultValue = value === getDefaultValue(settingKey);
+
+ if (existsInOriginalFile || !isDefaultValue) {
+ // This is tricky because setValue only works on top-level keys.
+ // We need to set the whole parent object.
+ const [parentKey] = path;
+ if (parentKey) {
+ // Ensure value is a boolean for setPendingSettingValue
+ const booleanValue = typeof value === 'boolean' ? value : false;
+ const newParentValue = setPendingSettingValue(
+ settingKey,
+ booleanValue,
+ loadedSettings.forScope(scope).settings,
+ )[parentKey as keyof Settings];
+
+ loadedSettings.setValue(
+ scope,
+ parentKey as keyof Settings,
+ newParentValue,
+ );
+ }
+ }
+ });
+}
+
+/**
+ * Get the display value for a setting, showing current scope value with default change indicator
+ */
+export function getDisplayValue(
+ key: string,
+ settings: Settings,
+ _mergedSettings: Settings,
+ modifiedSettings: Set<string>,
+ pendingSettings?: Settings,
+): string {
+ // Prioritize pending changes if user has modified this setting
+ let value: boolean;
+ if (pendingSettings && settingExistsInScope(key, pendingSettings)) {
+ // Show the value from the pending (unsaved) edits when it exists
+ value = getSettingValue(key, pendingSettings, {});
+ } else if (settingExistsInScope(key, settings)) {
+ // Show the value defined at the current scope if present
+ value = getSettingValue(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;
+ }
+
+ const valueString = String(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 isInModifiedSettings = modifiedSettings.has(key);
+ const hasPendingChanges =
+ pendingSettings && settingExistsInScope(key, pendingSettings);
+
+ // Add * indicator when value differs from default, is in modified settings, or has pending changes
+ if (isChangedFromDefault || isInModifiedSettings || hasPendingChanges) {
+ return `${valueString}*`; // * indicates changed from default value
+ }
+
+ return valueString;
+}
+
+/**
+ * Check if a setting doesn't exist in current scope (should be greyed out)
+ */
+export function isDefaultValue(key: string, settings: Settings): boolean {
+ return !settingExistsInScope(key, settings);
+}
+
+/**
+ * Check if a setting value is inherited (not set at current scope)
+ */
+export function isValueInherited(
+ key: string,
+ settings: Settings,
+ _mergedSettings: Settings,
+): boolean {
+ return !settingExistsInScope(key, settings);
+}
+
+/**
+ * Get the effective value for display, considering inheritance
+ * Always returns a boolean value (never undefined)
+ */
+export function getEffectiveDisplayValue(
+ key: string,
+ settings: Settings,
+ mergedSettings: Settings,
+): boolean {
+ return getSettingValue(key, settings, mergedSettings);
+}