summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/cli/src/config/settings.ts91
-rw-r--r--packages/cli/src/config/settingsSchema.test.ts253
-rw-r--r--packages/cli/src/config/settingsSchema.ts516
-rw-r--r--packages/cli/src/services/BuiltinCommandLoader.ts2
-rw-r--r--packages/cli/src/ui/App.tsx16
-rw-r--r--packages/cli/src/ui/commands/settingsCommand.test.ts36
-rw-r--r--packages/cli/src/ui/commands/settingsCommand.ts17
-rw-r--r--packages/cli/src/ui/commands/types.ts3
-rw-r--r--packages/cli/src/ui/components/SettingsDialog.test.tsx831
-rw-r--r--packages/cli/src/ui/components/SettingsDialog.tsx465
-rw-r--r--packages/cli/src/ui/components/ThemeDialog.tsx30
-rw-r--r--packages/cli/src/ui/hooks/slashCommandProcessor.test.ts4
-rw-r--r--packages/cli/src/ui/hooks/slashCommandProcessor.ts7
-rw-r--r--packages/cli/src/ui/hooks/useSettingsCommand.ts25
-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
17 files changed, 3521 insertions, 109 deletions
diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts
index 3c4270d7..36fd50f1 100644
--- a/packages/cli/src/config/settings.ts
+++ b/packages/cli/src/config/settings.ts
@@ -9,18 +9,15 @@ import * as path from 'path';
import { homedir, platform } from 'os';
import * as dotenv from 'dotenv';
import {
- MCPServerConfig,
GEMINI_CONFIG_DIR as GEMINI_DIR,
getErrorMessage,
- BugCommandSettings,
- ChatCompressionSettings,
- TelemetrySettings,
- AuthType,
} from '@google/gemini-cli-core';
import stripJsonComments from 'strip-json-comments';
import { DefaultLight } from '../ui/themes/default-light.js';
import { DefaultDark } from '../ui/themes/default.js';
-import { CustomTheme } from '../ui/themes/theme.js';
+import { Settings, MemoryImportFormat } from './settingsSchema.js';
+
+export type { Settings, MemoryImportFormat };
export const SETTINGS_DIRECTORY_NAME = '.gemini';
export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME);
@@ -44,7 +41,7 @@ export function getWorkspaceSettingsPath(workspaceDir: string): string {
return path.join(workspaceDir, SETTINGS_DIRECTORY_NAME, 'settings.json');
}
-export type DnsResolutionOrder = 'ipv4first' | 'verbatim';
+export type { DnsResolutionOrder } from './settingsSchema.js';
export enum SettingScope {
User = 'User',
@@ -64,86 +61,6 @@ export interface AccessibilitySettings {
disableLoadingPhrases?: boolean;
}
-export interface Settings {
- theme?: string;
- customThemes?: Record<string, CustomTheme>;
- selectedAuthType?: AuthType;
- useExternalAuth?: boolean;
- sandbox?: boolean | string;
- coreTools?: string[];
- excludeTools?: string[];
- toolDiscoveryCommand?: string;
- toolCallCommand?: string;
- mcpServerCommand?: string;
- mcpServers?: Record<string, MCPServerConfig>;
- allowMCPServers?: string[];
- excludeMCPServers?: string[];
- showMemoryUsage?: boolean;
- contextFileName?: string | string[];
- accessibility?: AccessibilitySettings;
- telemetry?: TelemetrySettings;
- usageStatisticsEnabled?: boolean;
- preferredEditor?: string;
- bugCommand?: BugCommandSettings;
- checkpointing?: CheckpointingSettings;
- autoConfigureMaxOldSpaceSize?: boolean;
- /** The model name to use (e.g 'gemini-9.0-pro') */
- model?: string;
-
- // Git-aware file filtering settings
- fileFiltering?: {
- respectGitIgnore?: boolean;
- respectGeminiIgnore?: boolean;
- enableRecursiveFileSearch?: boolean;
- };
-
- hideWindowTitle?: boolean;
-
- hideTips?: boolean;
- hideBanner?: boolean;
-
- // Setting for setting maximum number of user/model/tool turns in a session.
- maxSessionTurns?: number;
-
- // A map of tool names to their summarization settings.
- summarizeToolOutput?: Record<string, SummarizeToolOutputSettings>;
-
- vimMode?: boolean;
- memoryImportFormat?: 'tree' | 'flat';
-
- // Flag to be removed post-launch.
- ideModeFeature?: boolean;
- /// IDE mode setting configured via slash command toggle.
- ideMode?: boolean;
-
- // Flag to be removed post-launch.
- folderTrustFeature?: boolean;
- // Setting to track whether Folder trust is enabled.
- folderTrust?: boolean;
-
- // Setting to track if the user has seen the IDE integration nudge.
- hasSeenIdeIntegrationNudge?: boolean;
-
- // Setting for disabling auto-update.
- disableAutoUpdate?: boolean;
-
- // Setting for disabling the update nag message.
- disableUpdateNag?: boolean;
-
- memoryDiscoveryMaxDirs?: number;
-
- // Environment variables to exclude from project .env files
- excludedProjectEnvVars?: string[];
- dnsResolutionOrder?: DnsResolutionOrder;
-
- includeDirectories?: string[];
-
- loadMemoryFromIncludeDirectories?: boolean;
-
- chatCompression?: ChatCompressionSettings;
- showLineNumbers?: boolean;
-}
-
export interface SettingsError {
message: string;
path: string;
diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts
new file mode 100644
index 00000000..ab820ee1
--- /dev/null
+++ b/packages/cli/src/config/settingsSchema.test.ts
@@ -0,0 +1,253 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect } from 'vitest';
+import { SETTINGS_SCHEMA, Settings } from './settingsSchema.js';
+
+describe('SettingsSchema', () => {
+ describe('SETTINGS_SCHEMA', () => {
+ it('should contain all expected top-level settings', () => {
+ const expectedSettings = [
+ 'theme',
+ 'customThemes',
+ 'showMemoryUsage',
+ 'usageStatisticsEnabled',
+ 'autoConfigureMaxOldSpaceSize',
+ 'preferredEditor',
+ 'maxSessionTurns',
+ 'memoryImportFormat',
+ 'memoryDiscoveryMaxDirs',
+ 'contextFileName',
+ 'vimMode',
+ 'ideMode',
+ 'accessibility',
+ 'checkpointing',
+ 'fileFiltering',
+ 'disableAutoUpdate',
+ 'hideWindowTitle',
+ 'hideTips',
+ 'hideBanner',
+ 'selectedAuthType',
+ 'useExternalAuth',
+ 'sandbox',
+ 'coreTools',
+ 'excludeTools',
+ 'toolDiscoveryCommand',
+ 'toolCallCommand',
+ 'mcpServerCommand',
+ 'mcpServers',
+ 'allowMCPServers',
+ 'excludeMCPServers',
+ 'telemetry',
+ 'bugCommand',
+ 'summarizeToolOutput',
+ 'ideModeFeature',
+ 'dnsResolutionOrder',
+ 'excludedProjectEnvVars',
+ 'disableUpdateNag',
+ 'includeDirectories',
+ 'loadMemoryFromIncludeDirectories',
+ 'model',
+ 'hasSeenIdeIntegrationNudge',
+ 'folderTrustFeature',
+ ];
+
+ expectedSettings.forEach((setting) => {
+ expect(
+ SETTINGS_SCHEMA[setting as keyof typeof SETTINGS_SCHEMA],
+ ).toBeDefined();
+ });
+ });
+
+ it('should have correct structure for each setting', () => {
+ Object.entries(SETTINGS_SCHEMA).forEach(([_key, definition]) => {
+ expect(definition).toHaveProperty('type');
+ expect(definition).toHaveProperty('label');
+ expect(definition).toHaveProperty('category');
+ expect(definition).toHaveProperty('requiresRestart');
+ expect(definition).toHaveProperty('default');
+ expect(typeof definition.type).toBe('string');
+ expect(typeof definition.label).toBe('string');
+ expect(typeof definition.category).toBe('string');
+ expect(typeof definition.requiresRestart).toBe('boolean');
+ });
+ });
+
+ it('should have correct nested setting structure', () => {
+ const nestedSettings = [
+ 'accessibility',
+ 'checkpointing',
+ 'fileFiltering',
+ ];
+
+ nestedSettings.forEach((setting) => {
+ const definition = SETTINGS_SCHEMA[
+ setting as keyof typeof SETTINGS_SCHEMA
+ ] as (typeof SETTINGS_SCHEMA)[keyof typeof SETTINGS_SCHEMA] & {
+ properties: unknown;
+ };
+ expect(definition.type).toBe('object');
+ expect(definition.properties).toBeDefined();
+ expect(typeof definition.properties).toBe('object');
+ });
+ });
+
+ it('should have accessibility nested properties', () => {
+ expect(
+ SETTINGS_SCHEMA.accessibility.properties?.disableLoadingPhrases,
+ ).toBeDefined();
+ expect(
+ SETTINGS_SCHEMA.accessibility.properties?.disableLoadingPhrases.type,
+ ).toBe('boolean');
+ });
+
+ it('should have checkpointing nested properties', () => {
+ expect(SETTINGS_SCHEMA.checkpointing.properties?.enabled).toBeDefined();
+ expect(SETTINGS_SCHEMA.checkpointing.properties?.enabled.type).toBe(
+ 'boolean',
+ );
+ });
+
+ it('should have fileFiltering nested properties', () => {
+ expect(
+ SETTINGS_SCHEMA.fileFiltering.properties?.respectGitIgnore,
+ ).toBeDefined();
+ expect(
+ SETTINGS_SCHEMA.fileFiltering.properties?.respectGeminiIgnore,
+ ).toBeDefined();
+ expect(
+ SETTINGS_SCHEMA.fileFiltering.properties?.enableRecursiveFileSearch,
+ ).toBeDefined();
+ });
+
+ it('should have unique categories', () => {
+ const categories = new Set();
+
+ // Collect categories from top-level settings
+ Object.values(SETTINGS_SCHEMA).forEach((definition) => {
+ categories.add(definition.category);
+ // Also collect from nested properties
+ const defWithProps = definition as typeof definition & {
+ properties?: Record<string, unknown>;
+ };
+ if (defWithProps.properties) {
+ Object.values(defWithProps.properties).forEach(
+ (nestedDef: unknown) => {
+ const nestedDefTyped = nestedDef as { category?: string };
+ if (nestedDefTyped.category) {
+ categories.add(nestedDefTyped.category);
+ }
+ },
+ );
+ }
+ });
+
+ expect(categories.size).toBeGreaterThan(0);
+ expect(categories).toContain('General');
+ expect(categories).toContain('UI');
+ expect(categories).toContain('Mode');
+ expect(categories).toContain('Updates');
+ expect(categories).toContain('Accessibility');
+ expect(categories).toContain('Checkpointing');
+ expect(categories).toContain('File Filtering');
+ expect(categories).toContain('Advanced');
+ });
+
+ it('should have consistent default values for boolean settings', () => {
+ const checkBooleanDefaults = (schema: Record<string, unknown>) => {
+ Object.entries(schema).forEach(
+ ([_key, definition]: [string, unknown]) => {
+ const def = definition as {
+ type?: string;
+ default?: unknown;
+ properties?: Record<string, unknown>;
+ };
+ if (def.type === 'boolean') {
+ // Boolean settings can have boolean or undefined defaults (for optional settings)
+ expect(['boolean', 'undefined']).toContain(typeof def.default);
+ }
+ if (def.properties) {
+ checkBooleanDefaults(def.properties);
+ }
+ },
+ );
+ };
+
+ checkBooleanDefaults(SETTINGS_SCHEMA as Record<string, unknown>);
+ });
+
+ it('should have showInDialog property configured', () => {
+ // Check that user-facing settings are marked for dialog display
+ expect(SETTINGS_SCHEMA.showMemoryUsage.showInDialog).toBe(true);
+ expect(SETTINGS_SCHEMA.vimMode.showInDialog).toBe(true);
+ expect(SETTINGS_SCHEMA.ideMode.showInDialog).toBe(true);
+ expect(SETTINGS_SCHEMA.disableAutoUpdate.showInDialog).toBe(true);
+ expect(SETTINGS_SCHEMA.hideWindowTitle.showInDialog).toBe(true);
+ expect(SETTINGS_SCHEMA.hideTips.showInDialog).toBe(true);
+ expect(SETTINGS_SCHEMA.hideBanner.showInDialog).toBe(true);
+ expect(SETTINGS_SCHEMA.usageStatisticsEnabled.showInDialog).toBe(true);
+
+ // Check that advanced settings are hidden from dialog
+ expect(SETTINGS_SCHEMA.selectedAuthType.showInDialog).toBe(false);
+ expect(SETTINGS_SCHEMA.coreTools.showInDialog).toBe(false);
+ expect(SETTINGS_SCHEMA.mcpServers.showInDialog).toBe(false);
+ expect(SETTINGS_SCHEMA.telemetry.showInDialog).toBe(false);
+
+ // Check that some settings are appropriately hidden
+ expect(SETTINGS_SCHEMA.theme.showInDialog).toBe(false); // Changed to false
+ expect(SETTINGS_SCHEMA.customThemes.showInDialog).toBe(false); // Managed via theme editor
+ expect(SETTINGS_SCHEMA.checkpointing.showInDialog).toBe(false); // Experimental feature
+ expect(SETTINGS_SCHEMA.accessibility.showInDialog).toBe(false); // Changed to false
+ expect(SETTINGS_SCHEMA.fileFiltering.showInDialog).toBe(false); // Changed to false
+ expect(SETTINGS_SCHEMA.preferredEditor.showInDialog).toBe(false); // Changed to false
+ expect(SETTINGS_SCHEMA.autoConfigureMaxOldSpaceSize.showInDialog).toBe(
+ true,
+ );
+ });
+
+ it('should infer Settings type correctly', () => {
+ // This test ensures that the Settings type is properly inferred from the schema
+ const settings: Settings = {
+ theme: 'dark',
+ includeDirectories: ['/path/to/dir'],
+ loadMemoryFromIncludeDirectories: true,
+ };
+
+ // TypeScript should not complain about these properties
+ expect(settings.theme).toBe('dark');
+ expect(settings.includeDirectories).toEqual(['/path/to/dir']);
+ expect(settings.loadMemoryFromIncludeDirectories).toBe(true);
+ });
+
+ it('should have includeDirectories setting in schema', () => {
+ expect(SETTINGS_SCHEMA.includeDirectories).toBeDefined();
+ expect(SETTINGS_SCHEMA.includeDirectories.type).toBe('array');
+ expect(SETTINGS_SCHEMA.includeDirectories.category).toBe('General');
+ expect(SETTINGS_SCHEMA.includeDirectories.default).toEqual([]);
+ });
+
+ it('should have loadMemoryFromIncludeDirectories setting in schema', () => {
+ expect(SETTINGS_SCHEMA.loadMemoryFromIncludeDirectories).toBeDefined();
+ expect(SETTINGS_SCHEMA.loadMemoryFromIncludeDirectories.type).toBe(
+ 'boolean',
+ );
+ expect(SETTINGS_SCHEMA.loadMemoryFromIncludeDirectories.category).toBe(
+ 'General',
+ );
+ expect(SETTINGS_SCHEMA.loadMemoryFromIncludeDirectories.default).toBe(
+ false,
+ );
+ });
+
+ it('should have folderTrustFeature setting in schema', () => {
+ expect(SETTINGS_SCHEMA.folderTrustFeature).toBeDefined();
+ expect(SETTINGS_SCHEMA.folderTrustFeature.type).toBe('boolean');
+ expect(SETTINGS_SCHEMA.folderTrustFeature.category).toBe('General');
+ expect(SETTINGS_SCHEMA.folderTrustFeature.default).toBe(false);
+ expect(SETTINGS_SCHEMA.folderTrustFeature.showInDialog).toBe(true);
+ });
+ });
+});
diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts
new file mode 100644
index 00000000..dc2582ec
--- /dev/null
+++ b/packages/cli/src/config/settingsSchema.ts
@@ -0,0 +1,516 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ MCPServerConfig,
+ BugCommandSettings,
+ TelemetrySettings,
+ AuthType,
+ ChatCompressionSettings,
+} from '@google/gemini-cli-core';
+import { CustomTheme } from '../ui/themes/theme.js';
+
+export interface SettingDefinition {
+ type: 'boolean' | 'string' | 'number' | 'array' | 'object';
+ label: string;
+ category: string;
+ requiresRestart: boolean;
+ default: boolean | string | number | string[] | object | undefined;
+ description?: string;
+ parentKey?: string;
+ childKey?: string;
+ key?: string;
+ properties?: SettingsSchema;
+ showInDialog?: boolean;
+}
+
+export interface SettingsSchema {
+ [key: string]: SettingDefinition;
+}
+
+export type MemoryImportFormat = 'tree' | 'flat';
+export type DnsResolutionOrder = 'ipv4first' | 'verbatim';
+
+/**
+ * The canonical schema for all settings.
+ * 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 = {
+ // UI Settings
+ theme: {
+ type: 'string',
+ label: 'Theme',
+ category: 'UI',
+ requiresRestart: false,
+ default: undefined as string | undefined,
+ description: 'The color theme for the UI.',
+ showInDialog: false,
+ },
+ customThemes: {
+ type: 'object',
+ label: 'Custom Themes',
+ category: 'UI',
+ requiresRestart: false,
+ default: {} as Record<string, CustomTheme>,
+ description: 'Custom theme definitions.',
+ showInDialog: false,
+ },
+ hideWindowTitle: {
+ type: 'boolean',
+ label: 'Hide Window Title',
+ category: 'UI',
+ requiresRestart: true,
+ default: false,
+ description: 'Hide the window title bar',
+ showInDialog: true,
+ },
+ hideTips: {
+ type: 'boolean',
+ label: 'Hide Tips',
+ category: 'UI',
+ requiresRestart: false,
+ default: false,
+ description: 'Hide helpful tips in the UI',
+ showInDialog: true,
+ },
+ hideBanner: {
+ type: 'boolean',
+ label: 'Hide Banner',
+ category: 'UI',
+ requiresRestart: false,
+ default: false,
+ description: 'Hide the application banner',
+ showInDialog: true,
+ },
+ showMemoryUsage: {
+ type: 'boolean',
+ label: 'Show Memory Usage',
+ category: 'UI',
+ requiresRestart: false,
+ default: false,
+ description: 'Display memory usage information in the UI',
+ showInDialog: true,
+ },
+
+ usageStatisticsEnabled: {
+ type: 'boolean',
+ label: 'Enable Usage Statistics',
+ category: 'General',
+ requiresRestart: true,
+ default: false,
+ description: 'Enable collection of usage statistics',
+ showInDialog: true,
+ },
+ autoConfigureMaxOldSpaceSize: {
+ type: 'boolean',
+ label: 'Auto Configure Max Old Space Size',
+ category: 'General',
+ requiresRestart: true,
+ default: false,
+ description: 'Automatically configure Node.js memory limits',
+ showInDialog: true,
+ },
+ preferredEditor: {
+ type: 'string',
+ label: 'Preferred Editor',
+ category: 'General',
+ requiresRestart: false,
+ default: undefined as string | undefined,
+ description: 'The preferred editor to open files in.',
+ showInDialog: false,
+ },
+ maxSessionTurns: {
+ type: 'number',
+ label: 'Max Session Turns',
+ category: 'General',
+ requiresRestart: false,
+ default: undefined as number | undefined,
+ description:
+ 'Maximum number of user/model/tool turns to keep in a session.',
+ showInDialog: false,
+ },
+ memoryImportFormat: {
+ type: 'string',
+ label: 'Memory Import Format',
+ category: 'General',
+ requiresRestart: false,
+ default: undefined as MemoryImportFormat | undefined,
+ description: 'The format to use when importing memory.',
+ showInDialog: false,
+ },
+ memoryDiscoveryMaxDirs: {
+ type: 'number',
+ label: 'Memory Discovery Max Dirs',
+ category: 'General',
+ requiresRestart: false,
+ default: undefined as number | undefined,
+ description: 'Maximum number of directories to search for memory.',
+ showInDialog: false,
+ },
+ contextFileName: {
+ type: 'object',
+ label: 'Context File Name',
+ category: 'General',
+ requiresRestart: false,
+ default: undefined as string | string[] | undefined,
+ description: 'The name of the context file.',
+ showInDialog: false,
+ },
+ vimMode: {
+ type: 'boolean',
+ label: 'Vim Mode',
+ category: 'Mode',
+ requiresRestart: false,
+ default: false,
+ description: 'Enable Vim keybindings',
+ showInDialog: true,
+ },
+ ideMode: {
+ type: 'boolean',
+ label: 'IDE Mode',
+ category: 'Mode',
+ requiresRestart: true,
+ default: false,
+ description: 'Enable IDE integration mode',
+ showInDialog: true,
+ },
+
+ accessibility: {
+ type: 'object',
+ label: 'Accessibility',
+ category: 'Accessibility',
+ requiresRestart: true,
+ default: {},
+ description: 'Accessibility settings.',
+ showInDialog: false,
+ properties: {
+ disableLoadingPhrases: {
+ type: 'boolean',
+ label: 'Disable Loading Phrases',
+ category: 'Accessibility',
+ requiresRestart: true,
+ default: false,
+ description: 'Disable loading phrases for accessibility',
+ showInDialog: true,
+ },
+ },
+ },
+ checkpointing: {
+ type: 'object',
+ label: 'Checkpointing',
+ category: 'Checkpointing',
+ requiresRestart: true,
+ default: {},
+ description: 'Session checkpointing settings.',
+ showInDialog: false,
+ properties: {
+ enabled: {
+ type: 'boolean',
+ label: 'Enable Checkpointing',
+ category: 'Checkpointing',
+ requiresRestart: true,
+ default: false,
+ description: 'Enable session checkpointing for recovery',
+ showInDialog: false,
+ },
+ },
+ },
+ fileFiltering: {
+ type: 'object',
+ label: 'File Filtering',
+ category: 'File Filtering',
+ requiresRestart: true,
+ default: {},
+ description: 'Settings for git-aware file filtering.',
+ showInDialog: false,
+ properties: {
+ respectGitIgnore: {
+ type: 'boolean',
+ label: 'Respect .gitignore',
+ category: 'File Filtering',
+ requiresRestart: true,
+ default: true,
+ description: 'Respect .gitignore files when searching',
+ showInDialog: true,
+ },
+ respectGeminiIgnore: {
+ type: 'boolean',
+ label: 'Respect .geminiignore',
+ category: 'File Filtering',
+ requiresRestart: true,
+ default: true,
+ description: 'Respect .geminiignore files when searching',
+ showInDialog: true,
+ },
+ enableRecursiveFileSearch: {
+ type: 'boolean',
+ label: 'Enable Recursive File Search',
+ category: 'File Filtering',
+ requiresRestart: true,
+ default: true,
+ description: 'Enable recursive file search functionality',
+ showInDialog: true,
+ },
+ },
+ },
+
+ disableAutoUpdate: {
+ type: 'boolean',
+ label: 'Disable Auto Update',
+ category: 'Updates',
+ requiresRestart: false,
+ default: false,
+ description: 'Disable automatic updates',
+ showInDialog: true,
+ },
+
+ selectedAuthType: {
+ type: 'string',
+ label: 'Selected Auth Type',
+ category: 'Advanced',
+ requiresRestart: true,
+ default: undefined as AuthType | undefined,
+ description: 'The currently selected authentication type.',
+ showInDialog: false,
+ },
+ useExternalAuth: {
+ type: 'boolean',
+ label: 'Use External Auth',
+ category: 'Advanced',
+ requiresRestart: true,
+ default: undefined as boolean | undefined,
+ description: 'Whether to use an external authentication flow.',
+ showInDialog: false,
+ },
+ sandbox: {
+ type: 'object',
+ label: 'Sandbox',
+ category: 'Advanced',
+ requiresRestart: true,
+ default: undefined as boolean | string | undefined,
+ description:
+ 'Sandbox execution environment (can be a boolean or a path string).',
+ showInDialog: false,
+ },
+ coreTools: {
+ type: 'array',
+ label: 'Core Tools',
+ category: 'Advanced',
+ requiresRestart: true,
+ default: undefined as string[] | undefined,
+ description: 'Paths to core tool definitions.',
+ showInDialog: false,
+ },
+ excludeTools: {
+ type: 'array',
+ label: 'Exclude Tools',
+ category: 'Advanced',
+ requiresRestart: true,
+ default: undefined as string[] | undefined,
+ description: 'Tool names to exclude from discovery.',
+ showInDialog: false,
+ },
+ toolDiscoveryCommand: {
+ type: 'string',
+ label: 'Tool Discovery Command',
+ category: 'Advanced',
+ requiresRestart: true,
+ default: undefined as string | undefined,
+ description: 'Command to run for tool discovery.',
+ showInDialog: false,
+ },
+ toolCallCommand: {
+ type: 'string',
+ label: 'Tool Call Command',
+ category: 'Advanced',
+ requiresRestart: true,
+ default: undefined as string | undefined,
+ description: 'Command to run for tool calls.',
+ showInDialog: false,
+ },
+ mcpServerCommand: {
+ type: 'string',
+ label: 'MCP Server Command',
+ category: 'Advanced',
+ requiresRestart: true,
+ default: undefined as string | undefined,
+ description: 'Command to start an MCP server.',
+ showInDialog: false,
+ },
+ mcpServers: {
+ type: 'object',
+ label: 'MCP Servers',
+ category: 'Advanced',
+ requiresRestart: true,
+ default: {} as Record<string, MCPServerConfig>,
+ description: 'Configuration for MCP servers.',
+ showInDialog: false,
+ },
+ allowMCPServers: {
+ type: 'array',
+ label: 'Allow MCP Servers',
+ category: 'Advanced',
+ requiresRestart: true,
+ default: undefined as string[] | undefined,
+ description: 'A whitelist of MCP servers to allow.',
+ showInDialog: false,
+ },
+ excludeMCPServers: {
+ type: 'array',
+ label: 'Exclude MCP Servers',
+ category: 'Advanced',
+ requiresRestart: true,
+ default: undefined as string[] | undefined,
+ description: 'A blacklist of MCP servers to exclude.',
+ showInDialog: false,
+ },
+ telemetry: {
+ type: 'object',
+ label: 'Telemetry',
+ category: 'Advanced',
+ requiresRestart: true,
+ default: undefined as TelemetrySettings | undefined,
+ description: 'Telemetry configuration.',
+ showInDialog: false,
+ },
+ bugCommand: {
+ type: 'object',
+ label: 'Bug Command',
+ category: 'Advanced',
+ requiresRestart: false,
+ default: undefined as BugCommandSettings | undefined,
+ description: 'Configuration for the bug report command.',
+ showInDialog: false,
+ },
+ summarizeToolOutput: {
+ type: 'object',
+ label: 'Summarize Tool Output',
+ category: 'Advanced',
+ requiresRestart: false,
+ default: undefined as Record<string, { tokenBudget?: number }> | undefined,
+ description: 'Settings for summarizing tool output.',
+ showInDialog: false,
+ },
+ ideModeFeature: {
+ type: 'boolean',
+ label: 'IDE Mode Feature Flag',
+ category: 'Advanced',
+ requiresRestart: true,
+ default: undefined as boolean | undefined,
+ description: 'Internal feature flag for IDE mode.',
+ showInDialog: false,
+ },
+ dnsResolutionOrder: {
+ type: 'string',
+ label: 'DNS Resolution Order',
+ category: 'Advanced',
+ requiresRestart: true,
+ default: undefined as DnsResolutionOrder | undefined,
+ description: 'The DNS resolution order.',
+ showInDialog: false,
+ },
+ excludedProjectEnvVars: {
+ type: 'array',
+ label: 'Excluded Project Environment Variables',
+ category: 'Advanced',
+ requiresRestart: false,
+ default: ['DEBUG', 'DEBUG_MODE'] as string[],
+ description: 'Environment variables to exclude from project context.',
+ showInDialog: false,
+ },
+ disableUpdateNag: {
+ type: 'boolean',
+ label: 'Disable Update Nag',
+ category: 'Updates',
+ requiresRestart: false,
+ default: false,
+ description: 'Disable update notification prompts.',
+ showInDialog: false,
+ },
+ includeDirectories: {
+ type: 'array',
+ label: 'Include Directories',
+ category: 'General',
+ requiresRestart: false,
+ default: [] as string[],
+ description: 'Additional directories to include in the workspace context.',
+ showInDialog: false,
+ },
+ loadMemoryFromIncludeDirectories: {
+ type: 'boolean',
+ label: 'Load Memory From Include Directories',
+ category: 'General',
+ requiresRestart: false,
+ default: false,
+ description: 'Whether to load memory files from include directories.',
+ showInDialog: true,
+ },
+ model: {
+ type: 'string',
+ label: 'Model',
+ category: 'General',
+ requiresRestart: false,
+ default: undefined as string | undefined,
+ description: 'The Gemini model to use for conversations.',
+ showInDialog: false,
+ },
+ hasSeenIdeIntegrationNudge: {
+ type: 'boolean',
+ label: 'Has Seen IDE Integration Nudge',
+ category: 'General',
+ requiresRestart: false,
+ default: false,
+ description: 'Whether the user has seen the IDE integration nudge.',
+ showInDialog: false,
+ },
+ folderTrustFeature: {
+ type: 'boolean',
+ label: 'Folder Trust Feature',
+ category: 'General',
+ requiresRestart: false,
+ default: false,
+ description: 'Enable folder trust feature for enhanced security.',
+ showInDialog: true,
+ },
+ folderTrust: {
+ type: 'boolean',
+ label: 'Folder Trust',
+ category: 'General',
+ requiresRestart: false,
+ default: false,
+ description: 'Setting to track whether Folder trust is enabled.',
+ showInDialog: true,
+ },
+ chatCompression: {
+ type: 'object',
+ label: 'Chat Compression',
+ category: 'General',
+ requiresRestart: false,
+ default: undefined as ChatCompressionSettings | undefined,
+ description: 'Chat compression settings.',
+ showInDialog: false,
+ },
+ showLineNumbers: {
+ type: 'boolean',
+ label: 'Show Line Numbers',
+ category: 'General',
+ requiresRestart: false,
+ default: false,
+ description: 'Show line numbers in the chat.',
+ showInDialog: true,
+ },
+} as const;
+
+type InferSettings<T extends SettingsSchema> = {
+ -readonly [K in keyof T]?: T[K] extends { properties: SettingsSchema }
+ ? InferSettings<T[K]['properties']>
+ : T[K]['default'] extends boolean
+ ? boolean
+ : T[K]['default'];
+};
+
+export type Settings = InferSettings<typeof SETTINGS_SCHEMA>;
diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts
index c09f7c61..639bb4d8 100644
--- a/packages/cli/src/services/BuiltinCommandLoader.ts
+++ b/packages/cli/src/services/BuiltinCommandLoader.ts
@@ -30,6 +30,7 @@ import { restoreCommand } from '../ui/commands/restoreCommand.js';
import { statsCommand } from '../ui/commands/statsCommand.js';
import { themeCommand } from '../ui/commands/themeCommand.js';
import { toolsCommand } from '../ui/commands/toolsCommand.js';
+import { settingsCommand } from '../ui/commands/settingsCommand.js';
import { vimCommand } from '../ui/commands/vimCommand.js';
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
@@ -72,6 +73,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
statsCommand,
themeCommand,
toolsCommand,
+ settingsCommand,
vimCommand,
setupGithubCommand,
];
diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx
index a52236f8..aff3fac3 100644
--- a/packages/cli/src/ui/App.tsx
+++ b/packages/cli/src/ui/App.tsx
@@ -93,6 +93,8 @@ import ansiEscapes from 'ansi-escapes';
import { OverflowProvider } from './contexts/OverflowContext.js';
import { ShowMoreLines } from './components/ShowMoreLines.js';
import { PrivacyNotice } from './privacy/PrivacyNotice.js';
+import { useSettingsCommand } from './hooks/useSettingsCommand.js';
+import { SettingsDialog } from './components/SettingsDialog.js';
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
import { appEvents, AppEvent } from '../utils/events.js';
import { isNarrowWidth } from './utils/isNarrowWidth.js';
@@ -247,6 +249,9 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
handleThemeHighlight,
} = useThemeCommand(settings, setThemeError, addItem);
+ const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } =
+ useSettingsCommand();
+
const { isFolderTrustDialogOpen, handleFolderTrustSelect } =
useFolderTrust(settings);
@@ -510,6 +515,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
toggleCorgiMode,
setQuittingMessages,
openPrivacyNotice,
+ openSettingsDialog,
toggleVimEnabled,
setIsProcessing,
setGeminiMdFileCount,
@@ -975,6 +981,14 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
terminalWidth={mainAreaWidth}
/>
</Box>
+ ) : isSettingsDialogOpen ? (
+ <Box flexDirection="column">
+ <SettingsDialog
+ settings={settings}
+ onSelect={() => closeSettingsDialog()}
+ onRestartRequest={() => process.exit(0)}
+ />
+ </Box>
) : isAuthenticating ? (
<>
<AuthInProgress
@@ -1164,7 +1178,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
errorCount={errorCount}
showErrorDetails={showErrorDetails}
showMemoryUsage={
- config.getDebugMode() || config.getShowMemoryUsage()
+ config.getDebugMode() || settings.merged.showMemoryUsage || false
}
promptTokenCount={sessionStats.lastPromptTokenCount}
nightly={nightly}
diff --git a/packages/cli/src/ui/commands/settingsCommand.test.ts b/packages/cli/src/ui/commands/settingsCommand.test.ts
new file mode 100644
index 00000000..96d0d511
--- /dev/null
+++ b/packages/cli/src/ui/commands/settingsCommand.test.ts
@@ -0,0 +1,36 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { settingsCommand } from './settingsCommand.js';
+import { type CommandContext } from './types.js';
+import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
+
+describe('settingsCommand', () => {
+ let mockContext: CommandContext;
+
+ beforeEach(() => {
+ mockContext = createMockCommandContext();
+ });
+
+ it('should return a dialog action to open the settings dialog', () => {
+ if (!settingsCommand.action) {
+ throw new Error('The settings command must have an action.');
+ }
+ const result = settingsCommand.action(mockContext, '');
+ expect(result).toEqual({
+ type: 'dialog',
+ dialog: 'settings',
+ });
+ });
+
+ it('should have the correct name and description', () => {
+ expect(settingsCommand.name).toBe('settings');
+ expect(settingsCommand.description).toBe(
+ 'View and edit Gemini CLI settings',
+ );
+ });
+});
diff --git a/packages/cli/src/ui/commands/settingsCommand.ts b/packages/cli/src/ui/commands/settingsCommand.ts
new file mode 100644
index 00000000..26807852
--- /dev/null
+++ b/packages/cli/src/ui/commands/settingsCommand.ts
@@ -0,0 +1,17 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js';
+
+export const settingsCommand: SlashCommand = {
+ name: 'settings',
+ description: 'View and edit Gemini CLI settings',
+ kind: CommandKind.BUILT_IN,
+ action: (_context, _args): OpenDialogActionReturn => ({
+ type: 'dialog',
+ dialog: 'settings',
+ }),
+};
diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts
index 529f4eb8..d4f0b454 100644
--- a/packages/cli/src/ui/commands/types.ts
+++ b/packages/cli/src/ui/commands/types.ts
@@ -102,7 +102,8 @@ export interface MessageActionReturn {
*/
export interface OpenDialogActionReturn {
type: 'dialog';
- dialog: 'auth' | 'theme' | 'editor' | 'privacy';
+
+ dialog: 'help' | 'auth' | 'theme' | 'editor' | 'privacy' | 'settings';
}
/**
diff --git a/packages/cli/src/ui/components/SettingsDialog.test.tsx b/packages/cli/src/ui/components/SettingsDialog.test.tsx
new file mode 100644
index 00000000..ed67dcf9
--- /dev/null
+++ b/packages/cli/src/ui/components/SettingsDialog.test.tsx
@@ -0,0 +1,831 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ *
+ *
+ * This test suite covers:
+ * - Initial rendering and display state
+ * - Keyboard navigation (arrows, vim keys, Tab)
+ * - Settings toggling (Enter, Space)
+ * - Focus section switching between settings and scope selector
+ * - Scope selection and settings persistence across scopes
+ * - Restart-required vs immediate settings behavior
+ * - VimModeContext integration
+ * - Complex user interaction workflows
+ * - Error handling and edge cases
+ * - Display values for inherited and overridden settings
+ *
+ */
+
+import { render } from 'ink-testing-library';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { SettingsDialog } from './SettingsDialog.js';
+import { LoadedSettings, SettingScope } from '../../config/settings.js';
+import { VimModeProvider } from '../contexts/VimModeContext.js';
+
+// Mock the VimModeContext
+const mockToggleVimEnabled = vi.fn();
+const mockSetVimMode = vi.fn();
+
+vi.mock('../contexts/VimModeContext.js', async () => {
+ const actual = await vi.importActual('../contexts/VimModeContext.js');
+ return {
+ ...actual,
+ useVimMode: () => ({
+ vimEnabled: false,
+ vimMode: 'INSERT' as const,
+ toggleVimEnabled: mockToggleVimEnabled,
+ setVimMode: mockSetVimMode,
+ }),
+ };
+});
+
+vi.mock('../../utils/settingsUtils.js', async () => {
+ const actual = await vi.importActual('../../utils/settingsUtils.js');
+ return {
+ ...actual,
+ saveModifiedSettings: vi.fn(),
+ };
+});
+
+// Mock console.log to avoid noise in tests
+const originalConsoleLog = console.log;
+const originalConsoleError = console.error;
+
+describe('SettingsDialog', () => {
+ const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ console.log = vi.fn();
+ console.error = vi.fn();
+ mockToggleVimEnabled.mockResolvedValue(true);
+ });
+
+ afterEach(() => {
+ console.log = originalConsoleLog;
+ console.error = originalConsoleError;
+ });
+
+ const createMockSettings = (
+ userSettings = {},
+ systemSettings = {},
+ workspaceSettings = {},
+ ) =>
+ new LoadedSettings(
+ {
+ settings: { customThemes: {}, mcpServers: {}, ...systemSettings },
+ path: '/system/settings.json',
+ },
+ {
+ settings: {
+ customThemes: {},
+ mcpServers: {},
+ ...userSettings,
+ },
+ path: '/user/settings.json',
+ },
+ {
+ settings: { customThemes: {}, mcpServers: {}, ...workspaceSettings },
+ path: '/workspace/settings.json',
+ },
+ [],
+ );
+
+ describe('Initial Rendering', () => {
+ it('should render the settings dialog with default state', () => {
+ const settings = createMockSettings();
+ const onSelect = vi.fn();
+
+ const { lastFrame } = render(
+ <SettingsDialog settings={settings} onSelect={onSelect} />,
+ );
+
+ const output = lastFrame();
+ expect(output).toContain('Settings');
+ expect(output).toContain('Apply To');
+ expect(output).toContain('Use Enter to select, Tab to change focus');
+ });
+
+ it('should show settings list with default values', () => {
+ const settings = createMockSettings();
+ const onSelect = vi.fn();
+
+ const { lastFrame } = render(
+ <SettingsDialog settings={settings} onSelect={onSelect} />,
+ );
+
+ const output = lastFrame();
+ // Should show some default settings
+ expect(output).toContain('●'); // Active indicator
+ });
+
+ it('should highlight first setting by default', () => {
+ const settings = createMockSettings();
+ const onSelect = vi.fn();
+
+ const { lastFrame } = render(
+ <SettingsDialog settings={settings} onSelect={onSelect} />,
+ );
+
+ const output = lastFrame();
+ // First item should be highlighted with green color and active indicator
+ expect(output).toContain('●');
+ });
+ });
+
+ describe('Settings Navigation', () => {
+ it('should navigate down with arrow key', async () => {
+ const settings = createMockSettings();
+ const onSelect = vi.fn();
+
+ const { stdin, unmount } = render(
+ <SettingsDialog settings={settings} onSelect={onSelect} />,
+ );
+
+ // Press down arrow
+ stdin.write('\u001B[B'); // Down arrow
+ await wait();
+
+ // The active index should have changed (tested indirectly through behavior)
+ unmount();
+ });
+
+ it('should navigate up with arrow key', async () => {
+ const settings = createMockSettings();
+ const onSelect = vi.fn();
+
+ const { stdin, unmount } = render(
+ <SettingsDialog settings={settings} onSelect={onSelect} />,
+ );
+
+ // First go down, then up
+ stdin.write('\u001B[B'); // Down arrow
+ await wait();
+ stdin.write('\u001B[A'); // Up arrow
+ await wait();
+
+ unmount();
+ });
+
+ it('should navigate with vim keys (j/k)', async () => {
+ const settings = createMockSettings();
+ const onSelect = vi.fn();
+
+ const { stdin, unmount } = render(
+ <SettingsDialog settings={settings} onSelect={onSelect} />,
+ );
+
+ // Navigate with vim keys
+ stdin.write('j'); // Down
+ await wait();
+ stdin.write('k'); // Up
+ await wait();
+
+ unmount();
+ });
+
+ it('should not navigate beyond bounds', async () => {
+ const settings = createMockSettings();
+ const onSelect = vi.fn();
+
+ const { stdin, unmount } = render(
+ <SettingsDialog settings={settings} onSelect={onSelect} />,
+ );
+
+ // Try to go up from first item
+ stdin.write('\u001B[A'); // Up arrow
+ await wait();
+
+ // Should still be on first item
+ unmount();
+ });
+ });
+
+ describe('Settings Toggling', () => {
+ it('should toggle setting with Enter key', async () => {
+ const settings = createMockSettings();
+ const onSelect = vi.fn();
+
+ const { stdin, unmount } = render(
+ <SettingsDialog settings={settings} onSelect={onSelect} />,
+ );
+
+ // Press Enter to toggle current setting
+ stdin.write('\u000D'); // Enter key
+ await wait();
+
+ unmount();
+ });
+
+ it('should toggle setting with Space key', async () => {
+ const settings = createMockSettings();
+ const onSelect = vi.fn();
+
+ const { stdin, unmount } = render(
+ <SettingsDialog settings={settings} onSelect={onSelect} />,
+ );
+
+ // Press Space to toggle current setting
+ stdin.write(' '); // Space key
+ await wait();
+
+ unmount();
+ });
+
+ it('should handle vim mode setting specially', async () => {
+ const settings = createMockSettings();
+ const onSelect = vi.fn();
+
+ const { stdin, unmount } = render(
+ <SettingsDialog settings={settings} onSelect={onSelect} />,
+ );
+
+ // 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
+ await wait();
+
+ // The mock should potentially be called if vim mode was toggled
+ unmount();
+ });
+ });
+
+ describe('Scope Selection', () => {
+ it('should switch between scopes', async () => {
+ const settings = createMockSettings();
+ const onSelect = vi.fn();
+
+ const { stdin, unmount } = render(
+ <SettingsDialog settings={settings} onSelect={onSelect} />,
+ );
+
+ // Switch to scope focus
+ stdin.write('\t'); // Tab key
+ await wait();
+
+ // Select different scope (numbers 1-3 typically available)
+ stdin.write('2'); // Select second scope option
+ await wait();
+
+ unmount();
+ });
+
+ it('should reset to settings focus when scope is selected', async () => {
+ const settings = createMockSettings();
+ const onSelect = vi.fn();
+
+ const { lastFrame, stdin, unmount } = render(
+ <SettingsDialog settings={settings} onSelect={onSelect} />,
+ );
+
+ // Switch to scope focus
+ stdin.write('\t'); // Tab key
+ await wait();
+ expect(lastFrame()).toContain('> Apply To');
+
+ // Select a scope
+ stdin.write('1'); // Select first scope option
+ await wait();
+
+ // Should be back to settings focus
+ expect(lastFrame()).toContain(' Apply To');
+
+ unmount();
+ });
+ });
+
+ describe('Restart Prompt', () => {
+ it('should show restart prompt for restart-required settings', async () => {
+ const settings = createMockSettings();
+ const onRestartRequest = vi.fn();
+
+ const { unmount } = render(
+ <SettingsDialog
+ settings={settings}
+ onSelect={() => {}}
+ onRestartRequest={onRestartRequest}
+ />,
+ );
+
+ // This test would need to trigger a restart-required setting change
+ // The exact steps depend on which settings require restart
+ await wait();
+
+ unmount();
+ });
+
+ it('should handle restart request when r is pressed', async () => {
+ const settings = createMockSettings();
+ const onRestartRequest = vi.fn();
+
+ const { stdin, unmount } = render(
+ <SettingsDialog
+ settings={settings}
+ onSelect={() => {}}
+ onRestartRequest={onRestartRequest}
+ />,
+ );
+
+ // Press 'r' key (this would only work if restart prompt is showing)
+ stdin.write('r');
+ await wait();
+
+ // If restart prompt was showing, onRestartRequest should be called
+ unmount();
+ });
+ });
+
+ describe('Escape Key Behavior', () => {
+ it('should call onSelect with undefined when Escape is pressed', async () => {
+ const settings = createMockSettings();
+ const onSelect = vi.fn();
+
+ const { stdin, unmount } = render(
+ <SettingsDialog settings={settings} onSelect={onSelect} />,
+ );
+
+ // Press Escape key
+ stdin.write('\u001B'); // ESC key
+ await wait();
+
+ expect(onSelect).toHaveBeenCalledWith(undefined, SettingScope.User);
+
+ unmount();
+ });
+ });
+
+ describe('Settings Persistence', () => {
+ it('should persist settings across scope changes', async () => {
+ const settings = createMockSettings({ vimMode: true });
+ const onSelect = vi.fn();
+
+ const { stdin, unmount } = render(
+ <SettingsDialog settings={settings} onSelect={onSelect} />,
+ );
+
+ // Switch to scope selector
+ stdin.write('\t'); // Tab
+ await wait();
+
+ // Change scope
+ stdin.write('2'); // Select workspace scope
+ await wait();
+
+ // Settings should be reloaded for new scope
+ unmount();
+ });
+
+ it('should show different values for different scopes', () => {
+ const settings = createMockSettings(
+ { vimMode: true }, // User settings
+ { vimMode: false }, // System settings
+ { autoUpdate: false }, // Workspace settings
+ );
+ const onSelect = vi.fn();
+
+ const { lastFrame } = render(
+ <SettingsDialog settings={settings} onSelect={onSelect} />,
+ );
+
+ // Should show user scope values initially
+ const output = lastFrame();
+ expect(output).toContain('Settings');
+ });
+ });
+
+ describe('Error Handling', () => {
+ it('should handle vim mode toggle errors gracefully', async () => {
+ mockToggleVimEnabled.mockRejectedValue(new Error('Toggle failed'));
+
+ const settings = createMockSettings();
+ const onSelect = vi.fn();
+
+ const { stdin, unmount } = render(
+ <SettingsDialog settings={settings} onSelect={onSelect} />,
+ );
+
+ // Try to toggle a setting (this might trigger vim mode toggle)
+ stdin.write('\u000D'); // Enter
+ await wait();
+
+ // Should not crash
+ unmount();
+ });
+ });
+
+ describe('Complex State Management', () => {
+ it('should track modified settings correctly', async () => {
+ const settings = createMockSettings();
+ const onSelect = vi.fn();
+
+ const { stdin, unmount } = render(
+ <SettingsDialog settings={settings} onSelect={onSelect} />,
+ );
+
+ // Toggle a setting
+ stdin.write('\u000D'); // Enter
+ await wait();
+
+ // Toggle another setting
+ stdin.write('\u001B[B'); // Down
+ await wait();
+ stdin.write('\u000D'); // Enter
+ await wait();
+
+ // Should track multiple modified settings
+ unmount();
+ });
+
+ it('should handle scrolling when there are many settings', async () => {
+ const settings = createMockSettings();
+ const onSelect = vi.fn();
+
+ const { stdin, unmount } = render(
+ <SettingsDialog settings={settings} onSelect={onSelect} />,
+ );
+
+ // Navigate down many times to test scrolling
+ for (let i = 0; i < 10; i++) {
+ stdin.write('\u001B[B'); // Down arrow
+ await wait(10);
+ }
+
+ unmount();
+ });
+ });
+
+ describe('VimMode Integration', () => {
+ it('should sync with VimModeContext when vim mode is toggled', async () => {
+ const settings = createMockSettings();
+ const onSelect = vi.fn();
+
+ const { stdin, unmount } = render(
+ <VimModeProvider settings={settings}>
+ <SettingsDialog settings={settings} onSelect={onSelect} />
+ </VimModeProvider>,
+ );
+
+ // Navigate to and toggle vim mode setting
+ // This would require knowing the exact position of vim mode setting
+ stdin.write('\u000D'); // Enter
+ await wait();
+
+ unmount();
+ });
+ });
+
+ describe('Specific Settings Behavior', () => {
+ it('should show correct display values for settings with different states', () => {
+ const settings = createMockSettings(
+ { vimMode: true, hideTips: false }, // User settings
+ { hideWindowTitle: true }, // System settings
+ { ideMode: false }, // Workspace settings
+ );
+ const onSelect = vi.fn();
+
+ const { lastFrame } = render(
+ <SettingsDialog settings={settings} onSelect={onSelect} />,
+ );
+
+ const output = lastFrame();
+ // Should contain settings labels
+ expect(output).toContain('Settings');
+ });
+
+ it('should handle immediate settings save for non-restart-required settings', async () => {
+ const settings = createMockSettings();
+ const onSelect = vi.fn();
+
+ const { stdin, unmount } = render(
+ <SettingsDialog settings={settings} onSelect={onSelect} />,
+ );
+
+ // Toggle a non-restart-required setting (like hideTips)
+ stdin.write('\u000D'); // Enter - toggle current setting
+ await wait();
+
+ // Should save immediately without showing restart prompt
+ unmount();
+ });
+
+ it('should show restart prompt for restart-required settings', async () => {
+ const settings = createMockSettings();
+ const onSelect = vi.fn();
+
+ const { lastFrame, unmount } = render(
+ <SettingsDialog settings={settings} onSelect={onSelect} />,
+ );
+
+ // This test would need to navigate to a specific restart-required setting
+ // Since we can't easily target specific settings, we test the general behavior
+ await wait();
+
+ // Should not show restart prompt initially
+ expect(lastFrame()).not.toContain(
+ 'To see changes, Gemini CLI must be restarted',
+ );
+
+ unmount();
+ });
+
+ it('should clear restart prompt when switching scopes', async () => {
+ const settings = createMockSettings();
+ const onSelect = vi.fn();
+
+ const { unmount } = render(
+ <SettingsDialog settings={settings} onSelect={onSelect} />,
+ );
+
+ // Restart prompt should be cleared when switching scopes
+ unmount();
+ });
+ });
+
+ describe('Settings Display Values', () => {
+ it('should show correct values for inherited settings', () => {
+ const settings = createMockSettings(
+ {}, // No user settings
+ { vimMode: true, hideWindowTitle: false }, // System settings
+ {}, // No workspace settings
+ );
+ const onSelect = vi.fn();
+
+ const { lastFrame } = render(
+ <SettingsDialog settings={settings} onSelect={onSelect} />,
+ );
+
+ const output = lastFrame();
+ // Settings should show inherited values
+ expect(output).toContain('Settings');
+ });
+
+ it('should show override indicator for overridden settings', () => {
+ const settings = createMockSettings(
+ { vimMode: false }, // User overrides
+ { vimMode: true }, // System default
+ {}, // No workspace settings
+ );
+ const onSelect = vi.fn();
+
+ const { lastFrame } = render(
+ <SettingsDialog settings={settings} onSelect={onSelect} />,
+ );
+
+ const output = lastFrame();
+ // Should show settings with override indicators
+ expect(output).toContain('Settings');
+ });
+ });
+
+ describe('Keyboard Shortcuts Edge Cases', () => {
+ it('should handle rapid key presses gracefully', async () => {
+ const settings = createMockSettings();
+ const onSelect = vi.fn();
+
+ const { stdin, unmount } = render(
+ <SettingsDialog settings={settings} onSelect={onSelect} />,
+ );
+
+ // Rapid navigation
+ for (let i = 0; i < 5; i++) {
+ stdin.write('\u001B[B'); // Down arrow
+ stdin.write('\u001B[A'); // Up arrow
+ }
+ await wait(100);
+
+ // Should not crash
+ unmount();
+ });
+
+ it('should handle Ctrl+C to reset current setting to default', async () => {
+ const settings = createMockSettings({ vimMode: true }); // Start with vimMode enabled
+ const onSelect = vi.fn();
+
+ const { stdin, unmount } = render(
+ <SettingsDialog settings={settings} onSelect={onSelect} />,
+ );
+
+ // Press Ctrl+C to reset current setting to default
+ stdin.write('\u0003'); // Ctrl+C
+ await wait();
+
+ // Should reset the current setting to its default value
+ unmount();
+ });
+
+ it('should handle Ctrl+L to reset current setting to default', async () => {
+ const settings = createMockSettings({ vimMode: true }); // Start with vimMode enabled
+ const onSelect = vi.fn();
+
+ const { stdin, unmount } = render(
+ <SettingsDialog settings={settings} onSelect={onSelect} />,
+ );
+
+ // Press Ctrl+L to reset current setting to default
+ stdin.write('\u000C'); // Ctrl+L
+ await wait();
+
+ // Should reset the current setting to its default value
+ unmount();
+ });
+
+ it('should handle navigation when only one setting exists', async () => {
+ const settings = createMockSettings();
+ const onSelect = vi.fn();
+
+ const { stdin, unmount } = render(
+ <SettingsDialog settings={settings} onSelect={onSelect} />,
+ );
+
+ // Try to navigate when potentially at bounds
+ stdin.write('\u001B[B'); // Down
+ await wait();
+ stdin.write('\u001B[A'); // Up
+ await wait();
+
+ unmount();
+ });
+
+ it('should properly handle Tab navigation between sections', async () => {
+ const settings = createMockSettings();
+ const onSelect = vi.fn();
+
+ const { lastFrame, stdin, unmount } = render(
+ <SettingsDialog settings={settings} onSelect={onSelect} />,
+ );
+
+ // Start in settings section
+ expect(lastFrame()).toContain(' Apply To');
+
+ // Tab to scope section
+ stdin.write('\t');
+ await wait();
+ expect(lastFrame()).toContain('> Apply To');
+
+ // Tab back to settings section
+ stdin.write('\t');
+ await wait();
+ expect(lastFrame()).toContain(' Apply To');
+
+ unmount();
+ });
+ });
+
+ describe('Error Recovery', () => {
+ it('should handle malformed settings gracefully', () => {
+ // Create settings with potentially problematic values
+ const settings = createMockSettings(
+ { vimMode: null as unknown as boolean }, // Invalid value
+ {},
+ {},
+ );
+ const onSelect = vi.fn();
+
+ const { lastFrame } = render(
+ <SettingsDialog settings={settings} onSelect={onSelect} />,
+ );
+
+ // Should still render without crashing
+ expect(lastFrame()).toContain('Settings');
+ });
+
+ it('should handle missing setting definitions gracefully', () => {
+ const settings = createMockSettings();
+ const onSelect = vi.fn();
+
+ // Should not crash even if some settings are missing definitions
+ const { lastFrame } = render(
+ <SettingsDialog settings={settings} onSelect={onSelect} />,
+ );
+
+ expect(lastFrame()).toContain('Settings');
+ });
+ });
+
+ describe('Complex User Interactions', () => {
+ it('should handle complete user workflow: navigate, toggle, change scope, exit', async () => {
+ const settings = createMockSettings();
+ const onSelect = vi.fn();
+
+ const { stdin, unmount } = render(
+ <SettingsDialog settings={settings} onSelect={onSelect} />,
+ );
+
+ // Navigate down a few settings
+ stdin.write('\u001B[B'); // Down
+ await wait();
+ stdin.write('\u001B[B'); // Down
+ await wait();
+
+ // Toggle a setting
+ stdin.write('\u000D'); // Enter
+ await wait();
+
+ // Switch to scope selector
+ stdin.write('\t'); // Tab
+ await wait();
+
+ // Change scope
+ stdin.write('2'); // Select workspace
+ await wait();
+
+ // Go back to settings
+ stdin.write('\t'); // Tab
+ await wait();
+
+ // Navigate and toggle another setting
+ stdin.write('\u001B[B'); // Down
+ await wait();
+ stdin.write(' '); // Space to toggle
+ await wait();
+
+ // Exit
+ stdin.write('\u001B'); // Escape
+ await wait();
+
+ expect(onSelect).toHaveBeenCalledWith(undefined, expect.any(String));
+
+ unmount();
+ });
+
+ it('should allow changing multiple settings without losing pending changes', async () => {
+ const settings = createMockSettings();
+ const onSelect = vi.fn();
+
+ const { stdin, unmount } = render(
+ <SettingsDialog settings={settings} onSelect={onSelect} />,
+ );
+
+ // Toggle first setting (should require restart)
+ stdin.write('\u000D'); // Enter
+ await wait();
+
+ // Navigate to next setting and toggle it (should not require restart - e.g., vimMode)
+ stdin.write('\u001B[B'); // Down
+ await wait();
+ stdin.write('\u000D'); // Enter
+ await wait();
+
+ // Navigate to another setting and toggle it (should also require restart)
+ stdin.write('\u001B[B'); // Down
+ await wait();
+ stdin.write('\u000D'); // Enter
+ await wait();
+
+ // The test verifies that all changes are preserved and the dialog still works
+ // This tests the fix for the bug where changing one setting would reset all pending changes
+ unmount();
+ });
+
+ it('should maintain state consistency during complex interactions', async () => {
+ const settings = createMockSettings({ vimMode: true });
+ const onSelect = vi.fn();
+
+ const { stdin, unmount } = render(
+ <SettingsDialog settings={settings} onSelect={onSelect} />,
+ );
+
+ // Multiple scope changes
+ stdin.write('\t'); // Tab to scope
+ await wait();
+ stdin.write('2'); // Workspace
+ await wait();
+ stdin.write('\t'); // Tab to settings
+ await wait();
+ stdin.write('\t'); // Tab to scope
+ await wait();
+ stdin.write('1'); // User
+ await wait();
+
+ // Should maintain consistent state
+ unmount();
+ });
+
+ it('should handle restart workflow correctly', async () => {
+ const settings = createMockSettings();
+ const onRestartRequest = vi.fn();
+
+ const { stdin, unmount } = render(
+ <SettingsDialog
+ settings={settings}
+ onSelect={() => {}}
+ onRestartRequest={onRestartRequest}
+ />,
+ );
+
+ // This would test the restart workflow if we could trigger it
+ stdin.write('r'); // Try restart key
+ await wait();
+
+ // Without restart prompt showing, this should have no effect
+ expect(onRestartRequest).not.toHaveBeenCalled();
+
+ unmount();
+ });
+ });
+});
diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx
new file mode 100644
index 00000000..80e2339f
--- /dev/null
+++ b/packages/cli/src/ui/components/SettingsDialog.tsx
@@ -0,0 +1,465 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React, { useState, useEffect } from 'react';
+import { Box, Text, useInput } from 'ink';
+import { Colors } from '../colors.js';
+import {
+ LoadedSettings,
+ SettingScope,
+ Settings,
+} from '../../config/settings.js';
+import {
+ getScopeItems,
+ getScopeMessageForSetting,
+} from '../../utils/dialogScopeUtils.js';
+import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
+import {
+ getDialogSettingKeys,
+ getSettingValue,
+ setPendingSettingValue,
+ getDisplayValue,
+ hasRestartRequiredSettings,
+ saveModifiedSettings,
+ getSettingDefinition,
+ isDefaultValue,
+ requiresRestart,
+ getRestartRequiredFromModified,
+ getDefaultValue,
+} from '../../utils/settingsUtils.js';
+import { useVimMode } from '../contexts/VimModeContext.js';
+
+interface SettingsDialogProps {
+ settings: LoadedSettings;
+ onSelect: (settingName: string | undefined, scope: SettingScope) => void;
+ onRestartRequest?: () => void;
+}
+
+const maxItemsToShow = 8;
+
+export function SettingsDialog({
+ settings,
+ onSelect,
+ onRestartRequest,
+}: SettingsDialogProps): React.JSX.Element {
+ // Get vim mode context to sync vim mode changes
+ const { vimEnabled, toggleVimEnabled } = useVimMode();
+
+ // Focus state: 'settings' or 'scope'
+ const [focusSection, setFocusSection] = useState<'settings' | 'scope'>(
+ 'settings',
+ );
+ // Scope selector state (User by default)
+ const [selectedScope, setSelectedScope] = useState<SettingScope>(
+ SettingScope.User,
+ );
+ // Active indices
+ const [activeSettingIndex, setActiveSettingIndex] = useState(0);
+ // Scroll offset for settings
+ const [scrollOffset, setScrollOffset] = useState(0);
+ const [showRestartPrompt, setShowRestartPrompt] = useState(false);
+
+ // Local pending settings state for the selected scope
+ const [pendingSettings, setPendingSettings] = useState<Settings>(() =>
+ // Deep clone to avoid mutation
+ structuredClone(settings.forScope(selectedScope).settings),
+ );
+
+ // Track which settings have been modified by the user
+ const [modifiedSettings, setModifiedSettings] = useState<Set<string>>(
+ new Set(),
+ );
+
+ // Track the intended values for modified settings
+ const [modifiedValues, setModifiedValues] = useState<Map<string, boolean>>(
+ new Map(),
+ );
+
+ // Track restart-required settings across scope changes
+ const [restartRequiredSettings, setRestartRequiredSettings] = useState<
+ Set<string>
+ >(new Set());
+
+ useEffect(() => {
+ setPendingSettings(
+ structuredClone(settings.forScope(selectedScope).settings),
+ );
+ // Don't reset modifiedSettings when scope changes - preserve user's pending changes
+ if (restartRequiredSettings.size === 0) {
+ setShowRestartPrompt(false);
+ }
+ }, [selectedScope, settings, restartRequiredSettings]);
+
+ // Preserve pending changes when scope changes
+ useEffect(() => {
+ if (modifiedSettings.size > 0) {
+ setPendingSettings((prevPending) => {
+ let updatedPending = { ...prevPending };
+
+ // Reapply all modified settings to the new pending settings using stored values
+ modifiedSettings.forEach((key) => {
+ const storedValue = modifiedValues.get(key);
+ if (storedValue !== undefined) {
+ updatedPending = setPendingSettingValue(
+ key,
+ storedValue,
+ updatedPending,
+ );
+ }
+ });
+
+ return updatedPending;
+ });
+ }
+ }, [selectedScope, modifiedSettings, modifiedValues, settings]);
+
+ const generateSettingsItems = () => {
+ const settingKeys = getDialogSettingKeys();
+
+ return settingKeys.map((key: string) => {
+ const currentValue = getSettingValue(key, pendingSettings, {});
+ const definition = getSettingDefinition(key);
+
+ return {
+ label: definition?.label || key,
+ value: key,
+ checked: currentValue,
+ toggle: () => {
+ const newValue = !currentValue;
+
+ setPendingSettings((prev) =>
+ setPendingSettingValue(key, newValue, prev),
+ );
+
+ if (!requiresRestart(key)) {
+ const immediateSettings = new Set([key]);
+ const immediateSettingsObject = setPendingSettingValue(
+ key,
+ newValue,
+ {},
+ );
+
+ console.log(
+ `[DEBUG SettingsDialog] Saving ${key} immediately with value:`,
+ newValue,
+ );
+ saveModifiedSettings(
+ immediateSettings,
+ immediateSettingsObject,
+ settings,
+ selectedScope,
+ );
+
+ // Special handling for vim mode to sync with VimModeContext
+ if (key === 'vimMode' && newValue !== vimEnabled) {
+ // Call toggleVimEnabled to sync the VimModeContext local state
+ toggleVimEnabled().catch((error) => {
+ console.error('Failed to toggle vim mode:', error);
+ });
+ }
+
+ // Capture the current modified settings before updating state
+ const currentModifiedSettings = new Set(modifiedSettings);
+
+ // Remove the saved setting from modifiedSettings since it's now saved
+ setModifiedSettings((prev) => {
+ const updated = new Set(prev);
+ updated.delete(key);
+ return updated;
+ });
+
+ // Remove from modifiedValues as well
+ setModifiedValues((prev) => {
+ const updated = new Map(prev);
+ updated.delete(key);
+ return updated;
+ });
+
+ // Also remove from restart-required settings if it was there
+ setRestartRequiredSettings((prev) => {
+ const updated = new Set(prev);
+ updated.delete(key);
+ return updated;
+ });
+
+ setPendingSettings((_prevPending) => {
+ let updatedPending = structuredClone(
+ settings.forScope(selectedScope).settings,
+ );
+
+ currentModifiedSettings.forEach((modifiedKey) => {
+ if (modifiedKey !== key) {
+ const modifiedValue = modifiedValues.get(modifiedKey);
+ if (modifiedValue !== undefined) {
+ updatedPending = setPendingSettingValue(
+ modifiedKey,
+ modifiedValue,
+ updatedPending,
+ );
+ }
+ }
+ });
+
+ return updatedPending;
+ });
+ } else {
+ // For restart-required settings, store the actual value
+ setModifiedValues((prev) => {
+ const updated = new Map(prev);
+ updated.set(key, newValue);
+ return updated;
+ });
+
+ setModifiedSettings((prev) => {
+ const updated = new Set(prev).add(key);
+ const needsRestart = hasRestartRequiredSettings(updated);
+ console.log(
+ `[DEBUG SettingsDialog] Modified settings:`,
+ Array.from(updated),
+ 'Needs restart:',
+ needsRestart,
+ );
+ if (needsRestart) {
+ setShowRestartPrompt(true);
+ setRestartRequiredSettings((prevRestart) =>
+ new Set(prevRestart).add(key),
+ );
+ }
+ return updated;
+ });
+ }
+ },
+ };
+ });
+ };
+
+ const items = generateSettingsItems();
+
+ // Scope selector items
+ const scopeItems = getScopeItems();
+
+ const handleScopeHighlight = (scope: SettingScope) => {
+ setSelectedScope(scope);
+ };
+
+ const handleScopeSelect = (scope: SettingScope) => {
+ handleScopeHighlight(scope);
+ setFocusSection('settings');
+ };
+
+ // Scroll logic for settings
+ const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);
+ // Always show arrows for consistent UI and to indicate circular navigation
+ const showScrollUp = true;
+ const showScrollDown = true;
+
+ useInput((input, key) => {
+ if (key.tab) {
+ setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings'));
+ }
+ if (focusSection === 'settings') {
+ if (key.upArrow || input === 'k') {
+ const newIndex =
+ activeSettingIndex > 0 ? activeSettingIndex - 1 : items.length - 1;
+ setActiveSettingIndex(newIndex);
+ // Adjust scroll offset for wrap-around
+ if (newIndex === items.length - 1) {
+ setScrollOffset(Math.max(0, items.length - maxItemsToShow));
+ } else if (newIndex < scrollOffset) {
+ setScrollOffset(newIndex);
+ }
+ } else if (key.downArrow || input === 'j') {
+ const newIndex =
+ activeSettingIndex < items.length - 1 ? activeSettingIndex + 1 : 0;
+ setActiveSettingIndex(newIndex);
+ // Adjust scroll offset for wrap-around
+ if (newIndex === 0) {
+ setScrollOffset(0);
+ } else if (newIndex >= scrollOffset + maxItemsToShow) {
+ setScrollOffset(newIndex - maxItemsToShow + 1);
+ }
+ } else if (key.return || input === ' ') {
+ items[activeSettingIndex]?.toggle();
+ } else if ((key.ctrl && input === 'c') || (key.ctrl && input === 'l')) {
+ // Ctrl+C or Ctrl+L: Clear current setting and reset to default
+ const currentSetting = items[activeSettingIndex];
+ if (currentSetting) {
+ const defaultValue = getDefaultValue(currentSetting.value);
+ // Ensure defaultValue is a boolean for setPendingSettingValue
+ const booleanDefaultValue =
+ typeof defaultValue === 'boolean' ? defaultValue : false;
+
+ // Update pending settings to default value
+ setPendingSettings((prev) =>
+ setPendingSettingValue(
+ currentSetting.value,
+ booleanDefaultValue,
+ prev,
+ ),
+ );
+
+ // Remove from modified settings since it's now at default
+ setModifiedSettings((prev) => {
+ const updated = new Set(prev);
+ updated.delete(currentSetting.value);
+ return updated;
+ });
+
+ // Remove from restart-required settings if it was there
+ setRestartRequiredSettings((prev) => {
+ const updated = new Set(prev);
+ updated.delete(currentSetting.value);
+ return updated;
+ });
+
+ // If this setting doesn't require restart, save it immediately
+ if (!requiresRestart(currentSetting.value)) {
+ const immediateSettings = new Set([currentSetting.value]);
+ const immediateSettingsObject = setPendingSettingValue(
+ currentSetting.value,
+ booleanDefaultValue,
+ {},
+ );
+
+ saveModifiedSettings(
+ immediateSettings,
+ immediateSettingsObject,
+ settings,
+ selectedScope,
+ );
+ }
+ }
+ }
+ }
+ if (showRestartPrompt && input === 'r') {
+ // Only save settings that require restart (non-restart settings were already saved immediately)
+ const restartRequiredSettings =
+ getRestartRequiredFromModified(modifiedSettings);
+ const restartRequiredSet = new Set(restartRequiredSettings);
+
+ if (restartRequiredSet.size > 0) {
+ saveModifiedSettings(
+ restartRequiredSet,
+ pendingSettings,
+ settings,
+ selectedScope,
+ );
+ }
+
+ setShowRestartPrompt(false);
+ setRestartRequiredSettings(new Set()); // Clear restart-required settings
+ if (onRestartRequest) onRestartRequest();
+ }
+ if (key.escape) {
+ onSelect(undefined, selectedScope);
+ }
+ });
+
+ return (
+ <Box
+ borderStyle="round"
+ borderColor={Colors.Gray}
+ flexDirection="row"
+ padding={1}
+ width="100%"
+ height="100%"
+ >
+ <Box flexDirection="column" flexGrow={1}>
+ <Text bold color={Colors.AccentBlue}>
+ Settings
+ </Text>
+ <Box height={1} />
+ {showScrollUp && <Text color={Colors.Gray}>▲</Text>}
+ {visibleItems.map((item, idx) => {
+ const isActive =
+ focusSection === 'settings' &&
+ activeSettingIndex === idx + scrollOffset;
+
+ const scopeSettings = settings.forScope(selectedScope).settings;
+ const mergedSettings = settings.merged;
+ const displayValue = getDisplayValue(
+ item.value,
+ scopeSettings,
+ mergedSettings,
+ modifiedSettings,
+ pendingSettings,
+ );
+ const shouldBeGreyedOut = isDefaultValue(item.value, scopeSettings);
+
+ // Generate scope message for this setting
+ const scopeMessage = getScopeMessageForSetting(
+ item.value,
+ selectedScope,
+ settings,
+ );
+
+ return (
+ <React.Fragment key={item.value}>
+ <Box flexDirection="row" alignItems="center">
+ <Box minWidth={2} flexShrink={0}>
+ <Text color={isActive ? Colors.AccentGreen : Colors.Gray}>
+ {isActive ? '●' : ''}
+ </Text>
+ </Box>
+ <Box minWidth={50}>
+ <Text
+ color={isActive ? Colors.AccentGreen : Colors.Foreground}
+ >
+ {item.label}
+ {scopeMessage && (
+ <Text color={Colors.Gray}> {scopeMessage}</Text>
+ )}
+ </Text>
+ </Box>
+ <Box minWidth={3} />
+ <Text
+ color={
+ isActive
+ ? Colors.AccentGreen
+ : shouldBeGreyedOut
+ ? Colors.Gray
+ : Colors.Foreground
+ }
+ >
+ {displayValue}
+ </Text>
+ </Box>
+ <Box height={1} />
+ </React.Fragment>
+ );
+ })}
+ {showScrollDown && <Text color={Colors.Gray}>▼</Text>}
+
+ <Box height={1} />
+
+ <Box marginTop={1} flexDirection="column">
+ <Text bold={focusSection === 'scope'} wrap="truncate">
+ {focusSection === 'scope' ? '> ' : ' '}Apply To
+ </Text>
+ <RadioButtonSelect
+ items={scopeItems}
+ initialIndex={0}
+ onSelect={handleScopeSelect}
+ onHighlight={handleScopeHighlight}
+ isFocused={focusSection === 'scope'}
+ showNumbers={focusSection === 'scope'}
+ />
+ </Box>
+
+ <Box height={1} />
+ <Text color={Colors.Gray}>
+ (Use Enter to select, Tab to change focus)
+ </Text>
+ {showRestartPrompt && (
+ <Text color={Colors.AccentYellow}>
+ To see changes, Gemini CLI must be restarted. Press r to exit and
+ apply changes now.
+ </Text>
+ )}
+ </Box>
+ </Box>
+ );
+}
diff --git a/packages/cli/src/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx
index 7c38bb4b..37663447 100644
--- a/packages/cli/src/ui/components/ThemeDialog.tsx
+++ b/packages/cli/src/ui/components/ThemeDialog.tsx
@@ -12,6 +12,10 @@ import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { DiffRenderer } from './messages/DiffRenderer.js';
import { colorizeCode } from '../utils/CodeColorizer.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
+import {
+ getScopeItems,
+ getScopeMessageForSetting,
+} from '../../utils/dialogScopeUtils.js';
interface ThemeDialogProps {
/** Callback function when a theme is selected */
@@ -76,11 +80,7 @@ export function ThemeDialog({
// If not found, fall back to the first theme
const safeInitialThemeIndex = initialThemeIndex >= 0 ? initialThemeIndex : 0;
- const scopeItems = [
- { label: 'User Settings', value: SettingScope.User },
- { label: 'Workspace Settings', value: SettingScope.Workspace },
- { label: 'System Settings', value: SettingScope.System },
- ];
+ const scopeItems = getScopeItems();
const handleThemeSelect = useCallback(
(themeName: string) => {
@@ -120,23 +120,13 @@ export function ThemeDialog({
}
});
- const otherScopes = Object.values(SettingScope).filter(
- (scope) => scope !== selectedScope,
- );
-
- const modifiedInOtherScopes = otherScopes.filter(
- (scope) => settings.forScope(scope).settings.theme !== undefined,
+ // Generate scope message for theme setting
+ const otherScopeModifiedMessage = getScopeMessageForSetting(
+ 'theme',
+ selectedScope,
+ settings,
);
- let otherScopeModifiedMessage = '';
- if (modifiedInOtherScopes.length > 0) {
- const modifiedScopesStr = modifiedInOtherScopes.join(', ');
- otherScopeModifiedMessage =
- settings.forScope(selectedScope).settings.theme !== undefined
- ? `(Also modified in ${modifiedScopesStr})`
- : `(Modified in ${modifiedScopesStr})`;
- }
-
// Constants for calculating preview pane layout.
// These values are based on the JSX structure below.
const PREVIEW_PANE_WIDTH_PERCENTAGE = 0.55;
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
index 37407689..66c1b883 100644
--- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
+++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
@@ -147,6 +147,7 @@ describe('useSlashCommandProcessor', () => {
vi.fn(), // toggleCorgiMode
mockSetQuittingMessages,
vi.fn(), // openPrivacyNotice
+ vi.fn(), // openSettingsDialog
vi.fn(), // toggleVimEnabled
setIsProcessing,
),
@@ -864,6 +865,9 @@ describe('useSlashCommandProcessor', () => {
vi.fn(), // toggleCorgiMode
mockSetQuittingMessages,
vi.fn(), // openPrivacyNotice
+
+ vi.fn(), // openSettingsDialog
+ vi.fn(), // toggleVimEnabled
vi.fn().mockResolvedValue(false), // toggleVimEnabled
vi.fn(), // setIsProcessing
),
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts
index ca08abb1..b4ce0d4d 100644
--- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts
+++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts
@@ -50,6 +50,7 @@ export const useSlashCommandProcessor = (
toggleCorgiMode: () => void,
setQuittingMessages: (message: HistoryItem[]) => void,
openPrivacyNotice: () => void,
+ openSettingsDialog: () => void,
toggleVimEnabled: () => Promise<boolean>,
setIsProcessing: (isProcessing: boolean) => void,
setGeminiMdFileCount: (count: number) => void,
@@ -359,6 +360,11 @@ export const useSlashCommandProcessor = (
case 'privacy':
openPrivacyNotice();
return { type: 'handled' };
+ case 'settings':
+ openSettingsDialog();
+ return { type: 'handled' };
+ case 'help':
+ return { type: 'handled' };
default: {
const unhandled: never = result.dialog;
throw new Error(
@@ -512,6 +518,7 @@ export const useSlashCommandProcessor = (
openPrivacyNotice,
openEditorDialog,
setQuittingMessages,
+ openSettingsDialog,
setShellConfirmationRequest,
setSessionShellAllowlist,
setIsProcessing,
diff --git a/packages/cli/src/ui/hooks/useSettingsCommand.ts b/packages/cli/src/ui/hooks/useSettingsCommand.ts
new file mode 100644
index 00000000..42f535df
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useSettingsCommand.ts
@@ -0,0 +1,25 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useState, useCallback } from 'react';
+
+export function useSettingsCommand() {
+ const [isSettingsDialogOpen, setIsSettingsDialogOpen] = useState(false);
+
+ const openSettingsDialog = useCallback(() => {
+ setIsSettingsDialogOpen(true);
+ }, []);
+
+ const closeSettingsDialog = useCallback(() => {
+ setIsSettingsDialogOpen(false);
+ }, []);
+
+ return {
+ isSettingsDialogOpen,
+ openSettingsDialog,
+ closeSettingsDialog,
+ };
+}
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);
+}