From 8a9a9275440e3681e9be73d741a5aba429ae501f Mon Sep 17 00:00:00 2001 From: Ali Al Jufairi Date: Sun, 10 Aug 2025 09:04:52 +0900 Subject: [PATCH] feat(ui): add /settings command and UI panel (#4738) Co-authored-by: Jacob Richman --- packages/cli/src/config/settings.ts | 91 +- .../cli/src/config/settingsSchema.test.ts | 253 ++++++ packages/cli/src/config/settingsSchema.ts | 516 +++++++++++ .../cli/src/services/BuiltinCommandLoader.ts | 2 + packages/cli/src/ui/App.tsx | 16 +- .../src/ui/commands/settingsCommand.test.ts | 36 + .../cli/src/ui/commands/settingsCommand.ts | 17 + packages/cli/src/ui/commands/types.ts | 3 +- .../src/ui/components/SettingsDialog.test.tsx | 831 ++++++++++++++++++ .../cli/src/ui/components/SettingsDialog.tsx | 465 ++++++++++ .../cli/src/ui/components/ThemeDialog.tsx | 30 +- .../ui/hooks/slashCommandProcessor.test.ts | 4 + .../cli/src/ui/hooks/slashCommandProcessor.ts | 7 + .../cli/src/ui/hooks/useSettingsCommand.ts | 25 + packages/cli/src/utils/dialogScopeUtils.ts | 64 ++ packages/cli/src/utils/settingsUtils.test.ts | 797 +++++++++++++++++ packages/cli/src/utils/settingsUtils.ts | 473 ++++++++++ 17 files changed, 3521 insertions(+), 109 deletions(-) create mode 100644 packages/cli/src/config/settingsSchema.test.ts create mode 100644 packages/cli/src/config/settingsSchema.ts create mode 100644 packages/cli/src/ui/commands/settingsCommand.test.ts create mode 100644 packages/cli/src/ui/commands/settingsCommand.ts create mode 100644 packages/cli/src/ui/components/SettingsDialog.test.tsx create mode 100644 packages/cli/src/ui/components/SettingsDialog.tsx create mode 100644 packages/cli/src/ui/hooks/useSettingsCommand.ts create mode 100644 packages/cli/src/utils/dialogScopeUtils.ts create mode 100644 packages/cli/src/utils/settingsUtils.test.ts create mode 100644 packages/cli/src/utils/settingsUtils.ts 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; - selectedAuthType?: AuthType; - useExternalAuth?: boolean; - sandbox?: boolean | string; - coreTools?: string[]; - excludeTools?: string[]; - toolDiscoveryCommand?: string; - toolCallCommand?: string; - mcpServerCommand?: string; - mcpServers?: Record; - 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; - - 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; + }; + 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) => { + Object.entries(schema).forEach( + ([_key, definition]: [string, unknown]) => { + const def = definition as { + type?: string; + default?: unknown; + properties?: Record; + }; + if (def.type === 'boolean') { + // Boolean settings can have boolean or undefined defaults (for optional settings) + expect(['boolean', 'undefined']).toContain(typeof def.default); + } + if (def.properties) { + checkBooleanDefaults(def.properties); + } + }, + ); + }; + + checkBooleanDefaults(SETTINGS_SCHEMA as Record); + }); + + 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, + 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, + 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 | 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 = { + -readonly [K in keyof T]?: T[K] extends { properties: SettingsSchema } + ? InferSettings + : T[K]['default'] extends boolean + ? boolean + : T[K]['default']; +}; + +export type Settings = InferSettings; 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} /> + ) : isSettingsDialogOpen ? ( + + closeSettingsDialog()} + onRestartRequest={() => process.exit(0)} + /> + ) : isAuthenticating ? ( <> { 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + {}} + 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( + {}} + 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + + + , + ); + + // 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( + , + ); + + 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + {}} + 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.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(() => + // Deep clone to avoid mutation + structuredClone(settings.forScope(selectedScope).settings), + ); + + // Track which settings have been modified by the user + const [modifiedSettings, setModifiedSettings] = useState>( + new Set(), + ); + + // Track the intended values for modified settings + const [modifiedValues, setModifiedValues] = useState>( + new Map(), + ); + + // Track restart-required settings across scope changes + const [restartRequiredSettings, setRestartRequiredSettings] = useState< + Set + >(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 ( + + + + Settings + + + {showScrollUp && } + {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 ( + + + + + {isActive ? '●' : ''} + + + + + {item.label} + {scopeMessage && ( + {scopeMessage} + )} + + + + + {displayValue} + + + + + ); + })} + {showScrollDown && } + + + + + + {focusSection === 'scope' ? '> ' : ' '}Apply To + + + + + + + (Use Enter to select, Tab to change focus) + + {showRestartPrompt && ( + + To see changes, Gemini CLI must be restarted. Press r to exit and + apply changes now. + + )} + + + ); +} 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, + // Generate scope message for theme setting + const otherScopeModifiedMessage = getScopeMessageForSetting( + 'theme', + selectedScope, + settings, ); - const modifiedInOtherScopes = otherScopes.filter( - (scope) => settings.forScope(scope).settings.theme !== undefined, - ); - - 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, 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([ + 'autoConfigureMaxOldSpaceSize', + 'showMemoryUsage', + ]); + expect(hasRestartRequiredSettings(modifiedSettings)).toBe(true); + }); + + it('should return false when no modified settings require restart', () => { + const modifiedSettings = new Set([ + 'showMemoryUsage', + 'hideTips', + ]); + expect(hasRestartRequiredSettings(modifiedSettings)).toBe(false); + }); + + it('should return false for empty set', () => { + const modifiedSettings = new Set(); + expect(hasRestartRequiredSettings(modifiedSettings)).toBe(false); + }); + }); + + describe('getRestartRequiredFromModified', () => { + it('should return only settings that require restart', () => { + const modifiedSettings = new Set([ + '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([ + '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(); + + 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(); + + 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(); + + 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(); + + 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(['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 { + let result: Record = {}; + 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 +> { + const categories: Record< + string, + Array + > = {}; + + 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, 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, 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, path); + if (value !== undefined) { + return value as SettingDefinition['default']; + } + + // Check the merged settings for an inherited value + value = getNestedValue(mergedSettings as Record, 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 { + 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 +> { + const categories: Record< + string, + Array + > = {}; + + 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 { + 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, path); + return value !== undefined; +} + +/** + * Recursively sets a value in a nested object using a key path array. + */ +function setNestedValue( + obj: Record, + path: string[], + value: unknown, +): Record { + 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, 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, +): 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[] { + return Array.from(modifiedSettings).filter((key) => requiresRestart(key)); +} + +/** + * Save modified settings to the appropriate scope + */ +export function saveModifiedSettings( + modifiedSettings: Set, + pendingSettings: Settings, + loadedSettings: LoadedSettings, + scope: SettingScope, +): void { + modifiedSettings.forEach((settingKey) => { + const path = settingKey.split('.'); + const value = getNestedValue( + pendingSettings as Record, + 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, + 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); +}