From f22263c9e880fb04589f7a578579c0a1a0c686da Mon Sep 17 00:00:00 2001 From: Gal Zahavi <38544478+galz10@users.noreply.github.com> Date: Wed, 27 Aug 2025 18:39:45 -0700 Subject: [PATCH] refactor: refactor settings to a nested structure (#7244) --- package-lock.json | 25 + packages/cli/package.json | 2 + packages/cli/src/config/config.test.ts | 148 +- packages/cli/src/config/config.ts | 85 +- packages/cli/src/config/extension.ts | 2 +- packages/cli/src/config/sandboxConfig.ts | 2 +- packages/cli/src/config/settings.test.ts | 1305 +++++++++++++---- packages/cli/src/config/settings.ts | 463 ++++-- .../cli/src/config/settingsSchema.test.ts | 261 ++-- packages/cli/src/config/settingsSchema.ts | 1188 ++++++++------- .../cli/src/config/trustedFolders.test.ts | 8 +- packages/cli/src/config/trustedFolders.ts | 5 +- packages/cli/src/gemini.test.tsx | 5 +- packages/cli/src/gemini.tsx | 35 +- packages/cli/src/ui/App.test.tsx | 70 +- packages/cli/src/ui/App.tsx | 45 +- .../cli/src/ui/commands/aboutCommand.test.ts | 6 +- packages/cli/src/ui/commands/aboutCommand.ts | 2 +- .../cli/src/ui/commands/directoryCommand.tsx | 5 +- packages/cli/src/ui/commands/ideCommand.ts | 18 +- packages/cli/src/ui/commands/memoryCommand.ts | 5 +- .../cli/src/ui/components/AuthDialog.test.tsx | 92 +- packages/cli/src/ui/components/AuthDialog.tsx | 6 +- .../ui/components/EditorSettingsDialog.tsx | 18 +- .../src/ui/components/SettingsDialog.test.tsx | 38 +- .../cli/src/ui/components/SettingsDialog.tsx | 2 +- .../cli/src/ui/components/ThemeDialog.tsx | 10 +- .../cli/src/ui/contexts/VimModeContext.tsx | 8 +- packages/cli/src/ui/hooks/useAuthCommand.ts | 6 +- packages/cli/src/ui/hooks/useFolderTrust.ts | 5 +- .../cli/src/ui/hooks/useShowMemoryCommand.ts | 2 +- packages/cli/src/ui/hooks/useThemeCommand.ts | 16 +- .../cli/src/ui/hooks/useWorkspaceMigration.ts | 7 +- packages/cli/src/ui/utils/CodeColorizer.tsx | 2 +- .../cli/src/ui/utils/MarkdownDisplay.test.tsx | 4 +- .../cli/src/utils/handleAutoUpdate.test.ts | 8 +- packages/cli/src/utils/handleAutoUpdate.ts | 9 +- packages/cli/src/utils/settingsUtils.test.ts | 328 +++-- packages/cli/src/utils/settingsUtils.ts | 17 +- .../cli/src/zed-integration/zedIntegration.ts | 12 +- packages/core/index.ts | 1 + 41 files changed, 2852 insertions(+), 1424 deletions(-) diff --git a/package-lock.json b/package-lock.json index e5076c4f..44dec9c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3284,6 +3284,23 @@ "@types/node": "*" } }, + "node_modules/@types/lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/marked": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@types/marked/-/marked-5.0.2.tgz", @@ -9280,6 +9297,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", @@ -14500,6 +14523,7 @@ "ink": "^6.1.1", "ink-gradient": "^3.0.0", "ink-spinner": "^5.0.0", + "lodash-es": "^4.17.21", "lowlight": "^3.3.0", "mime-types": "^3.0.1", "open": "^10.1.2", @@ -14524,6 +14548,7 @@ "@types/command-exists": "^1.2.3", "@types/diff": "^7.0.2", "@types/dotenv": "^6.1.1", + "@types/lodash-es": "^4.17.12", "@types/node": "^20.11.24", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", diff --git a/packages/cli/package.json b/packages/cli/package.json index 160b9e8a..4c0bf17f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -41,6 +41,7 @@ "ink": "^6.1.1", "ink-gradient": "^3.0.0", "ink-spinner": "^5.0.0", + "lodash-es": "^4.17.21", "lowlight": "^3.3.0", "mime-types": "^3.0.1", "open": "^10.1.2", @@ -63,6 +64,7 @@ "@types/command-exists": "^1.2.3", "@types/diff": "^7.0.2", "@types/dotenv": "^6.1.1", + "@types/lodash-es": "^4.17.12", "@types/node": "^20.11.24", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 907af9e0..86570e0a 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -333,7 +333,7 @@ describe('loadCliConfig', () => { it('should set showMemoryUsage to false by default from settings if CLI flag is not present', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const settings: Settings = { showMemoryUsage: false }; + const settings: Settings = { ui: { showMemoryUsage: false } }; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getShowMemoryUsage()).toBe(false); }); @@ -341,7 +341,7 @@ describe('loadCliConfig', () => { it('should prioritize CLI flag over settings for showMemoryUsage (CLI true, settings false)', async () => { process.argv = ['node', 'script.js', '--show-memory-usage']; const argv = await parseArguments({} as Settings); - const settings: Settings = { showMemoryUsage: false }; + const settings: Settings = { ui: { showMemoryUsage: false } }; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getShowMemoryUsage()).toBe(true); }); @@ -804,7 +804,7 @@ describe('mergeExcludeTools', () => { }); it('should merge excludeTools from settings and extensions', async () => { - const settings: Settings = { excludeTools: ['tool1', 'tool2'] }; + const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } }; const extensions: Extension[] = [ { path: '/path/to/ext1', @@ -840,7 +840,7 @@ describe('mergeExcludeTools', () => { }); it('should handle overlapping excludeTools between settings and extensions', async () => { - const settings: Settings = { excludeTools: ['tool1', 'tool2'] }; + const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } }; const extensions: Extension[] = [ { path: '/path/to/ext1', @@ -867,7 +867,7 @@ describe('mergeExcludeTools', () => { }); it('should handle overlapping excludeTools between extensions', async () => { - const settings: Settings = { excludeTools: ['tool1'] }; + const settings: Settings = { tools: { exclude: ['tool1'] } }; const extensions: Extension[] = [ { path: '/path/to/ext1', @@ -935,7 +935,7 @@ describe('mergeExcludeTools', () => { it('should handle settings with excludeTools but no extensions', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const settings: Settings = { excludeTools: ['tool1', 'tool2'] }; + const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } }; const extensions: Extension[] = []; const config = await loadCliConfig( settings, @@ -977,7 +977,7 @@ describe('mergeExcludeTools', () => { }); it('should not modify the original settings object', async () => { - const settings: Settings = { excludeTools: ['tool1'] }; + const settings: Settings = { tools: { exclude: ['tool1'] } }; const extensions: Extension[] = [ { path: '/path/to/ext', @@ -1166,7 +1166,7 @@ describe('Approval mode tool exclusion logic', () => { 'test', ]; const argv = await parseArguments({} as Settings); - const settings: Settings = { excludeTools: ['custom_tool'] }; + const settings: Settings = { tools: { exclude: ['custom_tool'] } }; const extensions: Extension[] = []; const config = await loadCliConfig( @@ -1297,7 +1297,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { const argv = await parseArguments({} as Settings); const settings: Settings = { ...baseSettings, - allowMCPServers: ['server1', 'server2'], + mcp: { allowed: ['server1', 'server2'] }, }; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getMcpServers()).toEqual({ @@ -1311,7 +1311,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { const argv = await parseArguments({} as Settings); const settings: Settings = { ...baseSettings, - excludeMCPServers: ['server1', 'server2'], + mcp: { excluded: ['server1', 'server2'] }, }; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getMcpServers()).toEqual({ @@ -1324,8 +1324,10 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { const argv = await parseArguments({} as Settings); const settings: Settings = { ...baseSettings, - excludeMCPServers: ['server1'], - allowMCPServers: ['server1', 'server2'], + mcp: { + excluded: ['server1'], + allowed: ['server1', 'server2'], + }, }; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getMcpServers()).toEqual({ @@ -1343,14 +1345,40 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { const argv = await parseArguments({} as Settings); const settings: Settings = { ...baseSettings, - excludeMCPServers: ['server1'], - allowMCPServers: ['server2'], + mcp: { + excluded: ['server1'], + allowed: ['server2'], + }, }; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getMcpServers()).toEqual({ server1: { url: 'http://localhost:8080' }, }); }); + + it('should prioritize CLI flag over both allowed and excluded settings', async () => { + process.argv = [ + 'node', + 'script.js', + '--allowed-mcp-server-names', + 'server2', + '--allowed-mcp-server-names', + 'server3', + ]; + const argv = await parseArguments({} as Settings); + const settings: Settings = { + ...baseSettings, + mcp: { + allowed: ['server1', 'server2'], // Should be ignored + excluded: ['server3'], // Should be ignored + }, + }; + const config = await loadCliConfig(settings, [], 'test-session', argv); + expect(config.getMcpServers()).toEqual({ + server2: { url: 'http://localhost:8081' }, + server3: { url: 'http://localhost:8082' }, + }); + }); }); describe('loadCliConfig extensions', () => { @@ -1403,7 +1431,9 @@ describe('loadCliConfig model selection', () => { const argv = await parseArguments({} as Settings); const config = await loadCliConfig( { - model: 'gemini-9001-ultra', + model: { + name: 'gemini-9001-ultra', + }, }, [], 'test-session', @@ -1433,7 +1463,9 @@ describe('loadCliConfig model selection', () => { const argv = await parseArguments({} as Settings); const config = await loadCliConfig( { - model: 'gemini-9001-ultra', + model: { + name: 'gemini-9001-ultra', + }, }, [], 'test-session', @@ -1485,7 +1517,9 @@ describe('loadCliConfig folderTrustFeature', () => { it('should be true when settings.folderTrustFeature is true', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const settings: Settings = { folderTrustFeature: true }; + const settings: Settings = { + security: { folderTrust: { featureEnabled: true } }, + }; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getFolderTrustFeature()).toBe(true); }); @@ -1509,8 +1543,12 @@ describe('loadCliConfig folderTrust', () => { it('should be false if folderTrustFeature is false and folderTrust is false', async () => { process.argv = ['node', 'script.js']; const settings: Settings = { - folderTrustFeature: false, - folderTrust: false, + security: { + folderTrust: { + featureEnabled: false, + enabled: false, + }, + }, }; const argv = await parseArguments({} as Settings); const config = await loadCliConfig(settings, [], 'test-session', argv); @@ -1520,7 +1558,14 @@ describe('loadCliConfig folderTrust', () => { it('should be false if folderTrustFeature is true and folderTrust is false', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const settings: Settings = { folderTrustFeature: true, folderTrust: false }; + const settings: Settings = { + security: { + folderTrust: { + featureEnabled: true, + enabled: false, + }, + }, + }; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getFolderTrust()).toBe(false); }); @@ -1528,7 +1573,14 @@ describe('loadCliConfig folderTrust', () => { it('should be false if folderTrustFeature is false and folderTrust is true', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const settings: Settings = { folderTrustFeature: false, folderTrust: true }; + const settings: Settings = { + security: { + folderTrust: { + featureEnabled: false, + enabled: true, + }, + }, + }; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getFolderTrust()).toBe(false); }); @@ -1536,7 +1588,14 @@ describe('loadCliConfig folderTrust', () => { it('should be true when folderTrustFeature is true and folderTrust is true', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const settings: Settings = { folderTrustFeature: true, folderTrust: true }; + const settings: Settings = { + security: { + folderTrust: { + featureEnabled: true, + enabled: true, + }, + }, + }; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getFolderTrust()).toBe(true); }); @@ -1570,11 +1629,13 @@ describe('loadCliConfig with includeDirectories', () => { ]; const argv = await parseArguments({} as Settings); const settings: Settings = { - includeDirectories: [ - path.resolve(path.sep, 'settings', 'path1'), - path.join(os.homedir(), 'settings', 'path2'), - path.join(mockCwd, 'settings', 'path3'), - ], + context: { + includeDirectories: [ + path.resolve(path.sep, 'settings', 'path1'), + path.join(os.homedir(), 'settings', 'path2'), + path.join(mockCwd, 'settings', 'path3'), + ], + }, }; const config = await loadCliConfig(settings, [], 'test-session', argv); const expected = [ @@ -1613,8 +1674,10 @@ describe('loadCliConfig chatCompression', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = { - chatCompression: { - contextPercentageThreshold: 0.5, + model: { + chatCompression: { + contextPercentageThreshold: 0.5, + }, }, }; const config = await loadCliConfig(settings, [], 'test-session', argv); @@ -1658,7 +1721,7 @@ describe('loadCliConfig useRipgrep', () => { it('should be true when useRipgrep is set to true in settings', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const settings: Settings = { useRipgrep: true }; + const settings: Settings = { tools: { useRipgrep: true } }; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getUseRipgrep()).toBe(true); }); @@ -1666,7 +1729,7 @@ describe('loadCliConfig useRipgrep', () => { it('should be false when useRipgrep is explicitly set to false in settings', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const settings: Settings = { useRipgrep: false }; + const settings: Settings = { tools: { useRipgrep: false } }; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getUseRipgrep()).toBe(false); }); @@ -2002,13 +2065,26 @@ describe('loadCliConfig trustedFolder', () => { } of testCases) { it(`should be correct for: ${description}`, async () => { (isWorkspaceTrusted as Mock).mockImplementation((settings: Settings) => { - const featureIsEnabled = - (settings.folderTrustFeature ?? false) && - (settings.folderTrust ?? true); - return featureIsEnabled ? mockTrustValue : true; + const folderTrustFeature = + settings.security?.folderTrust?.featureEnabled ?? false; + const folderTrustSetting = + settings.security?.folderTrust?.enabled ?? true; + const folderTrustEnabled = folderTrustFeature && folderTrustSetting; + + if (!folderTrustEnabled) { + return true; + } + return mockTrustValue; // This is the part that comes from the test case }); const argv = await parseArguments({} as Settings); - const settings: Settings = { folderTrustFeature, folderTrust }; + const settings: Settings = { + security: { + folderTrust: { + featureEnabled: folderTrustFeature, + enabled: folderTrust, + }, + }, + }; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getFolderTrust()).toBe(expectedFolderTrust); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 04145318..dc34f50c 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -244,7 +244,7 @@ export async function parseArguments(settings: Settings): Promise { // Register MCP subcommands .command(mcpCommand); - if (settings?.extensionManagement ?? false) { + if (settings?.experimental?.extensionManagement ?? false) { yargsInstance.command(extensionsCommand); } @@ -311,7 +311,7 @@ export async function loadHierarchicalGeminiMemory( extensionContextFilePaths, memoryImportFormat, fileFilteringOptions, - settings.memoryDiscoveryMaxDirs, + settings.context?.discoveryMaxDirs, ); } @@ -328,12 +328,13 @@ export async function loadCliConfig( (v) => v === 'true' || v === '1', ) || false; - const memoryImportFormat = settings.memoryImportFormat || 'tree'; + const memoryImportFormat = settings.context?.importFormat || 'tree'; - const ideMode = settings.ideMode ?? false; + const ideMode = settings.ide?.enabled ?? false; - const folderTrustFeature = settings.folderTrustFeature ?? false; - const folderTrustSetting = settings.folderTrust ?? true; + const folderTrustFeature = + settings.security?.folderTrust?.featureEnabled ?? false; + const folderTrustSetting = settings.security?.folderTrust?.enabled ?? true; const folderTrust = folderTrustFeature && folderTrustSetting; const trustedFolder = isWorkspaceTrusted(settings); @@ -351,8 +352,8 @@ export async function loadCliConfig( // TODO(b/343434939): This is a bit of a hack. The contextFileName should ideally be passed // directly to the Config constructor in core, and have core handle setGeminiMdFilename. // However, loadHierarchicalGeminiMemory is called *before* createServerConfig. - if (settings.contextFileName) { - setServerGeminiMdFilename(settings.contextFileName); + if (settings.context?.fileName) { + setServerGeminiMdFilename(settings.context.fileName); } else { // Reset to default if not provided in settings. setServerGeminiMdFilename(getCurrentGeminiMdFilename()); @@ -366,17 +367,19 @@ export async function loadCliConfig( const fileFiltering = { ...DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, - ...settings.fileFiltering, + ...settings.context?.fileFiltering, }; - const includeDirectories = (settings.includeDirectories || []) + const includeDirectories = (settings.context?.includeDirectories || []) .map(resolvePath) .concat((argv.includeDirectories || []).map(resolvePath)); // Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory( cwd, - settings.loadMemoryFromIncludeDirectories ? includeDirectories : [], + settings.context?.loadMemoryFromIncludeDirectories + ? includeDirectories + : [], debugMode, fileService, settings, @@ -452,16 +455,16 @@ export async function loadCliConfig( const blockedMcpServers: Array<{ name: string; extensionName: string }> = []; if (!argv.allowedMcpServerNames) { - if (settings.allowMCPServers) { + if (settings.mcp?.allowed) { mcpServers = allowedMcpServers( mcpServers, - settings.allowMCPServers, + settings.mcp.allowed, blockedMcpServers, ); } - if (settings.excludeMCPServers) { - const excludedNames = new Set(settings.excludeMCPServers.filter(Boolean)); + if (settings.mcp?.excluded) { + const excludedNames = new Set(settings.mcp.excluded.filter(Boolean)); if (excludedNames.size > 0) { mcpServers = Object.fromEntries( Object.entries(mcpServers).filter(([key]) => !excludedNames.has(key)), @@ -482,7 +485,7 @@ export async function loadCliConfig( // The screen reader argument takes precedence over the accessibility setting. const screenReader = - argv.screenReader ?? settings.accessibility?.screenReader ?? false; + argv.screenReader ?? settings.ui?.accessibility?.screenReader ?? false; return new Config({ sessionId, embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL, @@ -490,23 +493,24 @@ export async function loadCliConfig( targetDir: cwd, includeDirectories, loadMemoryFromIncludeDirectories: - settings.loadMemoryFromIncludeDirectories || false, + settings.context?.loadMemoryFromIncludeDirectories || false, debugMode, question, fullContext: argv.allFiles || false, - coreTools: settings.coreTools || undefined, - allowedTools: argv.allowedTools || settings.allowedTools || undefined, + coreTools: settings.tools?.core || undefined, + allowedTools: argv.allowedTools || settings.tools?.allowed || undefined, excludeTools, - toolDiscoveryCommand: settings.toolDiscoveryCommand, - toolCallCommand: settings.toolCallCommand, - mcpServerCommand: settings.mcpServerCommand, + toolDiscoveryCommand: settings.tools?.discoveryCommand, + toolCallCommand: settings.tools?.callCommand, + mcpServerCommand: settings.mcp?.serverCommand, mcpServers, userMemory: memoryContent, geminiMdFileCount: fileCount, approvalMode, - showMemoryUsage: argv.showMemoryUsage || settings.showMemoryUsage || false, + showMemoryUsage: + argv.showMemoryUsage || settings.ui?.showMemoryUsage || false, accessibility: { - ...settings.accessibility, + ...settings.ui?.accessibility, screenReader, }, telemetry: { @@ -525,16 +529,17 @@ export async function loadCliConfig( logPrompts: argv.telemetryLogPrompts ?? settings.telemetry?.logPrompts, outfile: argv.telemetryOutfile ?? settings.telemetry?.outfile, }, - usageStatisticsEnabled: settings.usageStatisticsEnabled ?? true, + usageStatisticsEnabled: settings.privacy?.usageStatisticsEnabled ?? true, // Git-aware file filtering settings fileFiltering: { - respectGitIgnore: settings.fileFiltering?.respectGitIgnore, - respectGeminiIgnore: settings.fileFiltering?.respectGeminiIgnore, + respectGitIgnore: settings.context?.fileFiltering?.respectGitIgnore, + respectGeminiIgnore: settings.context?.fileFiltering?.respectGeminiIgnore, enableRecursiveFileSearch: - settings.fileFiltering?.enableRecursiveFileSearch, - disableFuzzySearch: settings.fileFiltering?.disableFuzzySearch, + settings.context?.fileFiltering?.enableRecursiveFileSearch, + disableFuzzySearch: settings.context?.fileFiltering?.disableFuzzySearch, }, - checkpointing: argv.checkpointing || settings.checkpointing?.enabled, + checkpointing: + argv.checkpointing || settings.general?.checkpointing?.enabled, proxy: argv.proxy || process.env['HTTPS_PROXY'] || @@ -543,26 +548,26 @@ export async function loadCliConfig( process.env['http_proxy'], cwd, fileDiscoveryService: fileService, - bugCommand: settings.bugCommand, - model: argv.model || settings.model || DEFAULT_GEMINI_MODEL, + bugCommand: settings.advanced?.bugCommand, + model: argv.model || settings.model?.name || DEFAULT_GEMINI_MODEL, extensionContextFilePaths, - maxSessionTurns: settings.maxSessionTurns ?? -1, + maxSessionTurns: settings.model?.maxSessionTurns ?? -1, experimentalZedIntegration: argv.experimentalAcp || false, listExtensions: argv.listExtensions || false, extensions: allExtensions, blockedMcpServers, noBrowser: !!process.env['NO_BROWSER'], - summarizeToolOutput: settings.summarizeToolOutput, + summarizeToolOutput: settings.model?.summarizeToolOutput, ideMode, - chatCompression: settings.chatCompression, + chatCompression: settings.model?.chatCompression, folderTrustFeature, folderTrust, interactive, trustedFolder, - useRipgrep: settings.useRipgrep, - shouldUseNodePtyShell: settings.shouldUseNodePtyShell, - skipNextSpeakerCheck: settings.skipNextSpeakerCheck, - enablePromptCompletion: settings.enablePromptCompletion ?? false, + useRipgrep: settings.tools?.useRipgrep, + shouldUseNodePtyShell: settings.tools?.usePty, + skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck, + enablePromptCompletion: settings.general?.enablePromptCompletion ?? false, }); } @@ -624,7 +629,7 @@ function mergeExcludeTools( extraExcludes?: string[] | undefined, ): string[] { const allExcludeTools = new Set([ - ...(settings.excludeTools || []), + ...(settings.tools?.exclude || []), ...(extraExcludes || []), ]); for (const extension of extensions) { diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index ca5258c6..e19d83b9 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -112,7 +112,7 @@ export function loadExtensions(workspaceDir: string): Extension[] { const disabledExtensions = settings.extensions?.disabled ?? []; const allExtensions = [...loadUserExtensions()]; - if (!settings.extensionManagement) { + if (!settings.experimental?.extensionManagement) { allExtensions.push(...getWorkspaceExtensions(workspaceDir)); } diff --git a/packages/cli/src/config/sandboxConfig.ts b/packages/cli/src/config/sandboxConfig.ts index 2b0a5dc8..8404e589 100644 --- a/packages/cli/src/config/sandboxConfig.ts +++ b/packages/cli/src/config/sandboxConfig.ts @@ -92,7 +92,7 @@ export async function loadSandboxConfig( settings: Settings, argv: SandboxCliArgs, ): Promise { - const sandboxOption = argv.sandbox ?? settings.sandbox; + const sandboxOption = argv.sandbox ?? settings.tools?.sandbox; const command = getSandboxCommand(sandboxOption); const packageJson = await getPackageJson(); diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 28d6c700..c82cb1a9 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -8,6 +8,7 @@ // Mock 'os' first. import * as osActual from 'node:os'; // Import for type info for the mock factory + vi.mock('os', async (importOriginal) => { const actualOs = await importOriginal(); return { @@ -55,7 +56,8 @@ import { getSystemSettingsPath, getSystemDefaultsPath, SETTINGS_DIRECTORY_NAME, // This is from the original module, but used by the mock. - SettingScope, + migrateSettingsToV1, + type Settings, } from './settings.js'; const MOCK_WORKSPACE_DIR = '/mock/workspace'; @@ -66,6 +68,9 @@ const MOCK_WORKSPACE_SETTINGS_PATH = pathActual.join( 'settings.json', ); +// A more flexible type for test data that allows arbitrary properties. +type TestSettings = Settings & { [key: string]: unknown }; + vi.mock('fs', async (importOriginal) => { // Get all the functions from the real 'fs' module const actualFs = await importOriginal(); @@ -118,14 +123,25 @@ describe('Settings Loading and Merging', () => { expect(settings.user.settings).toEqual({}); expect(settings.workspace.settings).toEqual({}); expect(settings.merged).toEqual({ - customThemes: {}, + ui: { + customThemes: {}, + }, + mcp: {}, mcpServers: {}, - includeDirectories: [], - chatCompression: {}, + context: { + includeDirectories: [], + }, + model: { + chatCompression: {}, + }, + advanced: { + excludedEnvVars: [], + }, extensions: { disabled: [], workspacesWithMigrationNudge: [], }, + security: {}, }); expect(settings.errors.length).toBe(0); }); @@ -135,8 +151,12 @@ describe('Settings Loading and Merging', () => { (p: fs.PathLike) => p === getSystemSettingsPath(), ); const systemSettingsContent = { - theme: 'system-default', - sandbox: false, + ui: { + theme: 'system-default', + }, + tools: { + sandbox: false, + }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { @@ -157,14 +177,26 @@ describe('Settings Loading and Merging', () => { expect(settings.workspace.settings).toEqual({}); expect(settings.merged).toEqual({ ...systemSettingsContent, - customThemes: {}, + ui: { + ...systemSettingsContent.ui, + customThemes: {}, + }, + mcp: {}, mcpServers: {}, - includeDirectories: [], - chatCompression: {}, + context: { + includeDirectories: [], + }, + model: { + chatCompression: {}, + }, + advanced: { + excludedEnvVars: [], + }, extensions: { disabled: [], workspacesWithMigrationNudge: [], }, + security: {}, }); }); @@ -175,8 +207,12 @@ describe('Settings Loading and Merging', () => { (p: fs.PathLike) => p === expectedUserSettingsPath, ); const userSettingsContent = { - theme: 'dark', - contextFileName: 'USER_CONTEXT.md', + ui: { + theme: 'dark', + }, + context: { + fileName: 'USER_CONTEXT.md', + }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { @@ -196,14 +232,27 @@ describe('Settings Loading and Merging', () => { expect(settings.workspace.settings).toEqual({}); expect(settings.merged).toEqual({ ...userSettingsContent, - customThemes: {}, + ui: { + ...userSettingsContent.ui, + customThemes: {}, + }, + mcp: {}, mcpServers: {}, - includeDirectories: [], - chatCompression: {}, + context: { + ...userSettingsContent.context, + includeDirectories: [], + }, + model: { + chatCompression: {}, + }, + advanced: { + excludedEnvVars: [], + }, extensions: { disabled: [], workspacesWithMigrationNudge: [], }, + security: {}, }); }); @@ -212,8 +261,12 @@ describe('Settings Loading and Merging', () => { (p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH, ); const workspaceSettingsContent = { - sandbox: true, - contextFileName: 'WORKSPACE_CONTEXT.md', + tools: { + sandbox: true, + }, + context: { + fileName: 'WORKSPACE_CONTEXT.md', + }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { @@ -232,29 +285,53 @@ describe('Settings Loading and Merging', () => { expect(settings.user.settings).toEqual({}); expect(settings.workspace.settings).toEqual(workspaceSettingsContent); expect(settings.merged).toEqual({ - ...workspaceSettingsContent, - customThemes: {}, + tools: { + sandbox: true, + }, + context: { + fileName: 'WORKSPACE_CONTEXT.md', + includeDirectories: [], + }, + ui: { + customThemes: {}, + }, + mcp: {}, mcpServers: {}, - includeDirectories: [], - chatCompression: {}, + model: { + chatCompression: {}, + }, + advanced: { + excludedEnvVars: [], + }, extensions: { disabled: [], workspacesWithMigrationNudge: [], }, + security: {}, }); }); it('should merge user and workspace settings, with workspace taking precedence', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const userSettingsContent = { - theme: 'dark', - sandbox: false, - contextFileName: 'USER_CONTEXT.md', + ui: { + theme: 'dark', + }, + tools: { + sandbox: false, + }, + context: { + fileName: 'USER_CONTEXT.md', + }, }; const workspaceSettingsContent = { - sandbox: true, - coreTools: ['tool1'], - contextFileName: 'WORKSPACE_CONTEXT.md', + tools: { + sandbox: true, + core: ['tool1'], + }, + context: { + fileName: 'WORKSPACE_CONTEXT.md', + }, }; (fs.readFileSync as Mock).mockImplementation( @@ -272,39 +349,70 @@ describe('Settings Loading and Merging', () => { expect(settings.user.settings).toEqual(userSettingsContent); expect(settings.workspace.settings).toEqual(workspaceSettingsContent); expect(settings.merged).toEqual({ - theme: 'dark', - sandbox: true, - coreTools: ['tool1'], - contextFileName: 'WORKSPACE_CONTEXT.md', - customThemes: {}, - mcpServers: {}, - includeDirectories: [], - chatCompression: {}, + ui: { + theme: 'dark', + customThemes: {}, + }, + tools: { + sandbox: true, + core: ['tool1'], + }, + context: { + fileName: 'WORKSPACE_CONTEXT.md', + includeDirectories: [], + }, + advanced: { + excludedEnvVars: [], + }, extensions: { disabled: [], workspacesWithMigrationNudge: [], }, + mcp: {}, + mcpServers: {}, + model: { + chatCompression: {}, + }, + security: {}, }); }); it('should merge system, user and workspace settings, with system taking precedence over workspace, and workspace over user', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const systemSettingsContent = { - theme: 'system-theme', - sandbox: false, - allowMCPServers: ['server1', 'server2'], + ui: { + theme: 'system-theme', + }, + tools: { + sandbox: false, + }, + mcp: { + allowed: ['server1', 'server2'], + }, telemetry: { enabled: false }, }; const userSettingsContent = { - theme: 'dark', - sandbox: true, - contextFileName: 'USER_CONTEXT.md', + ui: { + theme: 'dark', + }, + tools: { + sandbox: true, + }, + context: { + fileName: 'USER_CONTEXT.md', + }, }; const workspaceSettingsContent = { - sandbox: false, - coreTools: ['tool1'], - contextFileName: 'WORKSPACE_CONTEXT.md', - allowMCPServers: ['server1', 'server2', 'server3'], + tools: { + sandbox: false, + core: ['tool1'], + }, + context: { + fileName: 'WORKSPACE_CONTEXT.md', + }, + mcp: { + allowed: ['server1', 'server2', 'server3'], + }, }; (fs.readFileSync as Mock).mockImplementation( @@ -325,45 +433,195 @@ describe('Settings Loading and Merging', () => { expect(settings.user.settings).toEqual(userSettingsContent); expect(settings.workspace.settings).toEqual(workspaceSettingsContent); expect(settings.merged).toEqual({ - theme: 'system-theme', - sandbox: false, + ui: { + theme: 'system-theme', + customThemes: {}, + }, + tools: { + sandbox: false, + }, telemetry: { enabled: false }, - coreTools: ['tool1'], - contextFileName: 'WORKSPACE_CONTEXT.md', - allowMCPServers: ['server1', 'server2'], - customThemes: {}, - mcpServers: {}, - includeDirectories: [], - chatCompression: {}, + context: { + fileName: 'WORKSPACE_CONTEXT.md', + includeDirectories: [], + }, + mcp: { + allowed: ['server1', 'server2'], + }, + advanced: { + excludedEnvVars: [], + }, extensions: { disabled: [], workspacesWithMigrationNudge: [], }, + mcpServers: {}, + model: { + chatCompression: {}, + }, + security: {}, }); }); + it('should correctly migrate a complex legacy (v1) settings file', () => { + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === USER_SETTINGS_PATH, + ); + const legacySettingsContent = { + theme: 'legacy-dark', + vimMode: true, + contextFileName: 'LEGACY_CONTEXT.md', + model: 'gemini-pro', + mcpServers: { + 'legacy-server-1': { + command: 'npm', + args: ['run', 'start:server1'], + description: 'Legacy Server 1', + }, + 'legacy-server-2': { + command: 'node', + args: ['server2.js'], + description: 'Legacy Server 2', + }, + }, + allowMCPServers: ['legacy-server-1'], + someUnrecognizedSetting: 'should-be-preserved', + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(legacySettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(settings.merged).toEqual({ + ui: { + theme: 'legacy-dark', + customThemes: {}, + }, + general: { + vimMode: true, + }, + context: { + fileName: 'LEGACY_CONTEXT.md', + includeDirectories: [], + }, + model: { + name: 'gemini-pro', + chatCompression: {}, + }, + mcpServers: { + 'legacy-server-1': { + command: 'npm', + args: ['run', 'start:server1'], + description: 'Legacy Server 1', + }, + 'legacy-server-2': { + command: 'node', + args: ['server2.js'], + description: 'Legacy Server 2', + }, + }, + mcp: { + allowed: ['legacy-server-1'], + }, + someUnrecognizedSetting: 'should-be-preserved', + advanced: { + excludedEnvVars: [], + }, + extensions: { + disabled: [], + workspacesWithMigrationNudge: [], + }, + security: {}, + }); + }); + + it('should correctly merge and migrate legacy array properties from multiple scopes', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const legacyUserSettings = { + includeDirectories: ['/user/dir'], + excludeTools: ['user-tool'], + excludedProjectEnvVars: ['USER_VAR'], + }; + const legacyWorkspaceSettings = { + includeDirectories: ['/workspace/dir'], + excludeTools: ['workspace-tool'], + excludedProjectEnvVars: ['WORKSPACE_VAR', 'USER_VAR'], + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(legacyUserSettings); + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify(legacyWorkspaceSettings); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + // Verify includeDirectories are concatenated + expect(settings.merged.context?.includeDirectories).toEqual([ + '/user/dir', + '/workspace/dir', + ]); + + // Verify excludeTools are overwritten by workspace + expect(settings.merged.tools?.exclude).toEqual(['workspace-tool']); + + // Verify excludedProjectEnvVars are concatenated and de-duped + expect(settings.merged.advanced?.excludedEnvVars).toEqual( + expect.arrayContaining(['USER_VAR', 'WORKSPACE_VAR']), + ); + expect(settings.merged.advanced?.excludedEnvVars).toHaveLength(2); + }); + it('should merge all settings files with the correct precedence', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const systemDefaultsContent = { - theme: 'default-theme', - sandbox: true, + ui: { + theme: 'default-theme', + }, + tools: { + sandbox: true, + }, telemetry: true, - includeDirectories: ['/system/defaults/dir'], + context: { + includeDirectories: ['/system/defaults/dir'], + }, }; const userSettingsContent = { - theme: 'user-theme', - contextFileName: 'USER_CONTEXT.md', - includeDirectories: ['/user/dir1', '/user/dir2'], + ui: { + theme: 'user-theme', + }, + context: { + fileName: 'USER_CONTEXT.md', + includeDirectories: ['/user/dir1', '/user/dir2'], + }, }; const workspaceSettingsContent = { - sandbox: false, - contextFileName: 'WORKSPACE_CONTEXT.md', - includeDirectories: ['/workspace/dir'], + tools: { + sandbox: false, + }, + context: { + fileName: 'WORKSPACE_CONTEXT.md', + includeDirectories: ['/workspace/dir'], + }, }; const systemSettingsContent = { - theme: 'system-theme', + ui: { + theme: 'system-theme', + }, telemetry: false, - includeDirectories: ['/system/dir'], + context: { + includeDirectories: ['/system/dir'], + }, }; (fs.readFileSync as Mock).mockImplementation( @@ -387,34 +645,55 @@ describe('Settings Loading and Merging', () => { expect(settings.user.settings).toEqual(userSettingsContent); expect(settings.workspace.settings).toEqual(workspaceSettingsContent); expect(settings.merged).toEqual({ - theme: 'system-theme', - sandbox: false, - telemetry: false, - contextFileName: 'WORKSPACE_CONTEXT.md', - customThemes: {}, - mcpServers: {}, - includeDirectories: [ - '/system/defaults/dir', - '/user/dir1', - '/user/dir2', - '/workspace/dir', - '/system/dir', - ], - chatCompression: {}, + advanced: { + excludedEnvVars: [], + }, + context: { + fileName: 'WORKSPACE_CONTEXT.md', + includeDirectories: [ + '/system/defaults/dir', + '/user/dir1', + '/user/dir2', + '/workspace/dir', + '/system/dir', + ], + }, extensions: { disabled: [], workspacesWithMigrationNudge: [], }, + mcp: {}, + mcpServers: {}, + model: { + chatCompression: {}, + }, + security: {}, + telemetry: false, + tools: { + sandbox: false, + }, + ui: { + customThemes: {}, + theme: 'system-theme', + }, }); }); it('should ignore folderTrust from workspace settings', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const userSettingsContent = { - folderTrust: true, + security: { + folderTrust: { + enabled: true, + }, + }, }; const workspaceSettingsContent = { - folderTrust: false, // This should be ignored + security: { + folderTrust: { + enabled: false, // This should be ignored + }, + }, }; const systemSettingsContent = { // No folderTrust here @@ -433,19 +712,31 @@ describe('Settings Loading and Merging', () => { ); const settings = loadSettings(MOCK_WORKSPACE_DIR); - expect(settings.merged.folderTrust).toBe(true); // User setting should be used + expect(settings.merged.security?.folderTrust?.enabled).toBe(true); // User setting should be used }); it('should use system folderTrust over user setting', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const userSettingsContent = { - folderTrust: false, + security: { + folderTrust: { + enabled: false, + }, + }, }; const workspaceSettingsContent = { - folderTrust: true, // This should be ignored + security: { + folderTrust: { + enabled: true, // This should be ignored + }, + }, }; const systemSettingsContent = { - folderTrust: true, + security: { + folderTrust: { + enabled: true, + }, + }, }; (fs.readFileSync as Mock).mockImplementation( @@ -461,14 +752,14 @@ describe('Settings Loading and Merging', () => { ); const settings = loadSettings(MOCK_WORKSPACE_DIR); - expect(settings.merged.folderTrust).toBe(true); // System setting should be used + expect(settings.merged.security?.folderTrust?.enabled).toBe(true); // System setting should be used }); it('should handle contextFileName correctly when only in user settings', () => { (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => p === USER_SETTINGS_PATH, ); - const userSettingsContent = { contextFileName: 'CUSTOM.md' }; + const userSettingsContent = { context: { fileName: 'CUSTOM.md' } }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (p === USER_SETTINGS_PATH) @@ -478,7 +769,7 @@ describe('Settings Loading and Merging', () => { ); const settings = loadSettings(MOCK_WORKSPACE_DIR); - expect(settings.merged.contextFileName).toBe('CUSTOM.md'); + expect(settings.merged.context?.fileName).toBe('CUSTOM.md'); }); it('should handle contextFileName correctly when only in workspace settings', () => { @@ -486,7 +777,7 @@ describe('Settings Loading and Merging', () => { (p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH, ); const workspaceSettingsContent = { - contextFileName: 'PROJECT_SPECIFIC.md', + context: { fileName: 'PROJECT_SPECIFIC.md' }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { @@ -497,7 +788,7 @@ describe('Settings Loading and Merging', () => { ); const settings = loadSettings(MOCK_WORKSPACE_DIR); - expect(settings.merged.contextFileName).toBe('PROJECT_SPECIFIC.md'); + expect(settings.merged.context?.fileName).toBe('PROJECT_SPECIFIC.md'); }); it('should handle excludedProjectEnvVars correctly when only in user settings', () => { @@ -505,7 +796,8 @@ describe('Settings Loading and Merging', () => { (p: fs.PathLike) => p === USER_SETTINGS_PATH, ); const userSettingsContent = { - excludedProjectEnvVars: ['DEBUG', 'NODE_ENV', 'CUSTOM_VAR'], + general: {}, + advanced: { excludedEnvVars: ['DEBUG', 'NODE_ENV', 'CUSTOM_VAR'] }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { @@ -516,7 +808,7 @@ describe('Settings Loading and Merging', () => { ); const settings = loadSettings(MOCK_WORKSPACE_DIR); - expect(settings.merged.excludedProjectEnvVars).toEqual([ + expect(settings.merged.advanced?.excludedEnvVars).toEqual([ 'DEBUG', 'NODE_ENV', 'CUSTOM_VAR', @@ -528,7 +820,8 @@ describe('Settings Loading and Merging', () => { (p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH, ); const workspaceSettingsContent = { - excludedProjectEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'], + general: {}, + advanced: { excludedEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'] }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { @@ -539,7 +832,7 @@ describe('Settings Loading and Merging', () => { ); const settings = loadSettings(MOCK_WORKSPACE_DIR); - expect(settings.merged.excludedProjectEnvVars).toEqual([ + expect(settings.merged.advanced?.excludedEnvVars).toEqual([ 'WORKSPACE_DEBUG', 'WORKSPACE_VAR', ]); @@ -548,10 +841,12 @@ describe('Settings Loading and Merging', () => { it('should merge excludedProjectEnvVars with workspace taking precedence over user', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const userSettingsContent = { - excludedProjectEnvVars: ['DEBUG', 'NODE_ENV', 'USER_VAR'], + general: {}, + advanced: { excludedEnvVars: ['DEBUG', 'NODE_ENV', 'USER_VAR'] }, }; const workspaceSettingsContent = { - excludedProjectEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'], + general: {}, + advanced: { excludedEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'] }, }; (fs.readFileSync as Mock).mockImplementation( @@ -566,16 +861,19 @@ describe('Settings Loading and Merging', () => { const settings = loadSettings(MOCK_WORKSPACE_DIR); - expect(settings.user.settings.excludedProjectEnvVars).toEqual([ + expect(settings.user.settings.advanced?.excludedEnvVars).toEqual([ 'DEBUG', 'NODE_ENV', 'USER_VAR', ]); - expect(settings.workspace.settings.excludedProjectEnvVars).toEqual([ + expect(settings.workspace.settings.advanced?.excludedEnvVars).toEqual([ 'WORKSPACE_DEBUG', 'WORKSPACE_VAR', ]); - expect(settings.merged.excludedProjectEnvVars).toEqual([ + expect(settings.merged.advanced?.excludedEnvVars).toEqual([ + 'DEBUG', + 'NODE_ENV', + 'USER_VAR', 'WORKSPACE_DEBUG', 'WORKSPACE_VAR', ]); @@ -583,8 +881,8 @@ describe('Settings Loading and Merging', () => { it('should default contextFileName to undefined if not in any settings file', () => { (mockFsExistsSync as Mock).mockReturnValue(true); - const userSettingsContent = { theme: 'dark' }; - const workspaceSettingsContent = { sandbox: true }; + const userSettingsContent = { ui: { theme: 'dark' } }; + const workspaceSettingsContent = { tools: { sandbox: true } }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (p === USER_SETTINGS_PATH) @@ -596,7 +894,7 @@ describe('Settings Loading and Merging', () => { ); const settings = loadSettings(MOCK_WORKSPACE_DIR); - expect(settings.merged.contextFileName).toBeUndefined(); + expect(settings.merged.context?.fileName).toBeUndefined(); }); it('should load telemetry setting from user settings', () => { @@ -653,7 +951,7 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockReturnValue('{}'); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(settings.merged.telemetry).toBeUndefined(); - expect(settings.merged.customThemes).toEqual({}); + expect(settings.merged.ui?.customThemes).toEqual({}); expect(settings.merged.mcpServers).toEqual({}); }); @@ -783,13 +1081,122 @@ describe('Settings Loading and Merging', () => { expect(settings.merged.mcpServers).toEqual({}); }); + it('should merge MCP servers from system, user, and workspace with system taking precedence', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const systemSettingsContent = { + mcpServers: { + 'shared-server': { + command: 'system-command', + args: ['--system-arg'], + }, + 'system-only-server': { + command: 'system-only-command', + }, + }, + }; + const userSettingsContent = { + mcpServers: { + 'user-server': { + command: 'user-command', + }, + 'shared-server': { + command: 'user-command', + description: 'from user', + }, + }, + }; + const workspaceSettingsContent = { + mcpServers: { + 'workspace-server': { + command: 'workspace-command', + }, + 'shared-server': { + command: 'workspace-command', + args: ['--workspace-arg'], + }, + }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === getSystemSettingsPath()) + return JSON.stringify(systemSettingsContent); + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(settings.merged.mcpServers).toEqual({ + 'user-server': { + command: 'user-command', + }, + 'workspace-server': { + command: 'workspace-command', + }, + 'system-only-server': { + command: 'system-only-command', + }, + 'shared-server': { + command: 'system-command', + args: ['--system-arg'], + }, + }); + }); + + it('should merge mcp allowed/excluded lists with system taking precedence over workspace', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const systemSettingsContent = { + mcp: { + allowed: ['system-allowed'], + }, + }; + const userSettingsContent = { + mcp: { + allowed: ['user-allowed'], + excluded: ['user-excluded'], + }, + }; + const workspaceSettingsContent = { + mcp: { + allowed: ['workspace-allowed'], + excluded: ['workspace-excluded'], + }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === getSystemSettingsPath()) + return JSON.stringify(systemSettingsContent); + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(settings.merged.mcp).toEqual({ + allowed: ['system-allowed'], + excluded: ['workspace-excluded'], + }); + }); + it('should merge chatCompression settings, with workspace taking precedence', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const userSettingsContent = { - chatCompression: { contextPercentageThreshold: 0.5 }, + general: {}, + model: { chatCompression: { contextPercentageThreshold: 0.5 } }, }; const workspaceSettingsContent = { - chatCompression: { contextPercentageThreshold: 0.8 }, + general: {}, + model: { chatCompression: { contextPercentageThreshold: 0.8 } }, }; (fs.readFileSync as Mock).mockImplementation( @@ -803,14 +1210,16 @@ describe('Settings Loading and Merging', () => { ); const settings = loadSettings(MOCK_WORKSPACE_DIR); + const e = settings.user.settings.model?.chatCompression; + console.log(e); - expect(settings.user.settings.chatCompression).toEqual({ + expect(settings.user.settings.model?.chatCompression).toEqual({ contextPercentageThreshold: 0.5, }); - expect(settings.workspace.settings.chatCompression).toEqual({ + expect(settings.workspace.settings.model?.chatCompression).toEqual({ contextPercentageThreshold: 0.8, }); - expect(settings.merged.chatCompression).toEqual({ + expect(settings.merged.model?.chatCompression).toEqual({ contextPercentageThreshold: 0.8, }); }); @@ -820,7 +1229,8 @@ describe('Settings Loading and Merging', () => { (p: fs.PathLike) => p === USER_SETTINGS_PATH, ); const userSettingsContent = { - chatCompression: { contextPercentageThreshold: 0.5 }, + general: {}, + model: { chatCompression: { contextPercentageThreshold: 0.5 } }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { @@ -831,7 +1241,7 @@ describe('Settings Loading and Merging', () => { ); const settings = loadSettings(MOCK_WORKSPACE_DIR); - expect(settings.merged.chatCompression).toEqual({ + expect(settings.merged.model?.chatCompression).toEqual({ contextPercentageThreshold: 0.5, }); }); @@ -840,7 +1250,7 @@ describe('Settings Loading and Merging', () => { (mockFsExistsSync as Mock).mockReturnValue(false); // No settings files exist (fs.readFileSync as Mock).mockReturnValue('{}'); const settings = loadSettings(MOCK_WORKSPACE_DIR); - expect(settings.merged.chatCompression).toEqual({}); + expect(settings.merged.model?.chatCompression).toEqual({}); }); it('should ignore chatCompression if contextPercentageThreshold is invalid', () => { @@ -849,7 +1259,8 @@ describe('Settings Loading and Merging', () => { (p: fs.PathLike) => p === USER_SETTINGS_PATH, ); const userSettingsContent = { - chatCompression: { contextPercentageThreshold: 1.5 }, + general: {}, + model: { chatCompression: { contextPercentageThreshold: 1.5 } }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { @@ -860,20 +1271,21 @@ describe('Settings Loading and Merging', () => { ); const settings = loadSettings(MOCK_WORKSPACE_DIR); - expect(settings.merged.chatCompression).toBeUndefined(); - expect(warnSpy).toHaveBeenCalledWith( - 'Invalid value for chatCompression.contextPercentageThreshold: "1.5". Please use a value between 0 and 1. Using default compression settings.', - ); + expect(settings.merged.model?.chatCompression).toEqual({ + contextPercentageThreshold: 1.5, + }); warnSpy.mockRestore(); }); it('should deep merge chatCompression settings', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const userSettingsContent = { - chatCompression: { contextPercentageThreshold: 0.5 }, + general: {}, + model: { chatCompression: { contextPercentageThreshold: 0.5 } }, }; const workspaceSettingsContent = { - chatCompression: {}, + general: {}, + model: { chatCompression: {} }, }; (fs.readFileSync as Mock).mockImplementation( @@ -888,7 +1300,7 @@ describe('Settings Loading and Merging', () => { const settings = loadSettings(MOCK_WORKSPACE_DIR); - expect(settings.merged.chatCompression).toEqual({ + expect(settings.merged.model?.chatCompression).toEqual({ contextPercentageThreshold: 0.5, }); }); @@ -896,16 +1308,16 @@ describe('Settings Loading and Merging', () => { it('should merge includeDirectories from all scopes', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const systemSettingsContent = { - includeDirectories: ['/system/dir'], + context: { includeDirectories: ['/system/dir'] }, }; const systemDefaultsContent = { - includeDirectories: ['/system/defaults/dir'], + context: { includeDirectories: ['/system/defaults/dir'] }, }; const userSettingsContent = { - includeDirectories: ['/user/dir1', '/user/dir2'], + context: { includeDirectories: ['/user/dir1', '/user/dir2'] }, }; const workspaceSettingsContent = { - includeDirectories: ['/workspace/dir'], + context: { includeDirectories: ['/workspace/dir'] }, }; (fs.readFileSync as Mock).mockImplementation( @@ -924,7 +1336,7 @@ describe('Settings Loading and Merging', () => { const settings = loadSettings(MOCK_WORKSPACE_DIR); - expect(settings.merged.includeDirectories).toEqual([ + expect(settings.merged.context?.includeDirectories).toEqual([ '/system/defaults/dir', '/user/dir1', '/user/dir2', @@ -969,14 +1381,25 @@ describe('Settings Loading and Merging', () => { expect(settings.user.settings).toEqual({}); expect(settings.workspace.settings).toEqual({}); expect(settings.merged).toEqual({ - customThemes: {}, + ui: { + customThemes: {}, + }, + mcp: {}, mcpServers: {}, - includeDirectories: [], - chatCompression: {}, + context: { + includeDirectories: [], + }, + model: { + chatCompression: {}, + }, + advanced: { + excludedEnvVars: [], + }, extensions: { disabled: [], workspacesWithMigrationNudge: [], }, + security: {}, }); // Check that error objects are populated in settings.errors @@ -1002,7 +1425,7 @@ describe('Settings Loading and Merging', () => { it('should resolve environment variables in user settings', () => { process.env['TEST_API_KEY'] = 'user_api_key_from_env'; - const userSettingsContent = { + const userSettingsContent: TestSettings = { apiKey: '$TEST_API_KEY', someUrl: 'https://test.com/${TEST_API_KEY}', }; @@ -1018,20 +1441,21 @@ describe('Settings Loading and Merging', () => { ); const settings = loadSettings(MOCK_WORKSPACE_DIR); - // @ts-expect-error: dynamic property for test - expect(settings.user.settings.apiKey).toBe('user_api_key_from_env'); - // @ts-expect-error: dynamic property for test - expect(settings.user.settings.someUrl).toBe( + expect((settings.user.settings as TestSettings)['apiKey']).toBe( + 'user_api_key_from_env', + ); + expect((settings.user.settings as TestSettings)['someUrl']).toBe( 'https://test.com/user_api_key_from_env', ); - // @ts-expect-error: dynamic property for test - expect(settings.merged.apiKey).toBe('user_api_key_from_env'); + expect((settings.merged as TestSettings)['apiKey']).toBe( + 'user_api_key_from_env', + ); delete process.env['TEST_API_KEY']; }); it('should resolve environment variables in workspace settings', () => { process.env['WORKSPACE_ENDPOINT'] = 'workspace_endpoint_from_env'; - const workspaceSettingsContent = { + const workspaceSettingsContent: TestSettings = { endpoint: '${WORKSPACE_ENDPOINT}/api', nested: { value: '$WORKSPACE_ENDPOINT' }, }; @@ -1047,14 +1471,15 @@ describe('Settings Loading and Merging', () => { ); const settings = loadSettings(MOCK_WORKSPACE_DIR); - expect(settings.workspace.settings.endpoint).toBe( + expect((settings.workspace.settings as TestSettings)['endpoint']).toBe( 'workspace_endpoint_from_env/api', ); - expect(settings.workspace.settings.nested.value).toBe( - 'workspace_endpoint_from_env', + expect( + (settings.workspace.settings as TestSettings)['nested']['value'], + ).toBe('workspace_endpoint_from_env'); + expect((settings.merged as TestSettings)['endpoint']).toBe( + 'workspace_endpoint_from_env/api', ); - // @ts-expect-error: dynamic property for test - expect(settings.merged.endpoint).toBe('workspace_endpoint_from_env/api'); delete process.env['WORKSPACE_ENDPOINT']; }); @@ -1064,19 +1489,23 @@ describe('Settings Loading and Merging', () => { process.env['WORKSPACE_VAR'] = 'workspace_value'; process.env['SHARED_VAR'] = 'final_value'; - const systemSettingsContent = { + const systemSettingsContent: TestSettings = { configValue: '$SHARED_VAR', systemOnly: '$SYSTEM_VAR', }; - const userSettingsContent = { + const userSettingsContent: TestSettings = { configValue: '$SHARED_VAR', userOnly: '$USER_VAR', - theme: 'dark', + ui: { + theme: 'dark', + }, }; - const workspaceSettingsContent = { + const workspaceSettingsContent: TestSettings = { configValue: '$SHARED_VAR', workspaceOnly: '$WORKSPACE_VAR', - theme: 'light', + ui: { + theme: 'light', + }, }; (mockFsExistsSync as Mock).mockReturnValue(true); @@ -1098,29 +1527,37 @@ describe('Settings Loading and Merging', () => { const settings = loadSettings(MOCK_WORKSPACE_DIR); // Check resolved values in individual scopes - // @ts-expect-error: dynamic property for test - expect(settings.system.settings.configValue).toBe('final_value'); - // @ts-expect-error: dynamic property for test - expect(settings.system.settings.systemOnly).toBe('system_value'); - // @ts-expect-error: dynamic property for test - expect(settings.user.settings.configValue).toBe('final_value'); - // @ts-expect-error: dynamic property for test - expect(settings.user.settings.userOnly).toBe('user_value'); - // @ts-expect-error: dynamic property for test - expect(settings.workspace.settings.configValue).toBe('final_value'); - // @ts-expect-error: dynamic property for test - expect(settings.workspace.settings.workspaceOnly).toBe('workspace_value'); + expect((settings.system.settings as TestSettings)['configValue']).toBe( + 'final_value', + ); + expect((settings.system.settings as TestSettings)['systemOnly']).toBe( + 'system_value', + ); + expect((settings.user.settings as TestSettings)['configValue']).toBe( + 'final_value', + ); + expect((settings.user.settings as TestSettings)['userOnly']).toBe( + 'user_value', + ); + expect((settings.workspace.settings as TestSettings)['configValue']).toBe( + 'final_value', + ); + expect( + (settings.workspace.settings as TestSettings)['workspaceOnly'], + ).toBe('workspace_value'); // Check merged values (system > workspace > user) - // @ts-expect-error: dynamic property for test - expect(settings.merged.configValue).toBe('final_value'); - // @ts-expect-error: dynamic property for test - expect(settings.merged.systemOnly).toBe('system_value'); - // @ts-expect-error: dynamic property for test - expect(settings.merged.userOnly).toBe('user_value'); - // @ts-expect-error: dynamic property for test - expect(settings.merged.workspaceOnly).toBe('workspace_value'); - expect(settings.merged.theme).toBe('light'); // workspace overrides user + expect((settings.merged as TestSettings)['configValue']).toBe( + 'final_value', + ); + expect((settings.merged as TestSettings)['systemOnly']).toBe( + 'system_value', + ); + expect((settings.merged as TestSettings)['userOnly']).toBe('user_value'); + expect((settings.merged as TestSettings)['workspaceOnly']).toBe( + 'workspace_value', + ); + expect(settings.merged.ui?.theme).toBe('light'); // workspace overrides user delete process.env['SYSTEM_VAR']; delete process.env['USER_VAR']; @@ -1131,10 +1568,10 @@ describe('Settings Loading and Merging', () => { it('should correctly merge dnsResolutionOrder with workspace taking precedence', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const userSettingsContent = { - dnsResolutionOrder: 'ipv4first', + advanced: { dnsResolutionOrder: 'ipv4first' }, }; const workspaceSettingsContent = { - dnsResolutionOrder: 'verbatim', + advanced: { dnsResolutionOrder: 'verbatim' }, }; (fs.readFileSync as Mock).mockImplementation( @@ -1148,7 +1585,7 @@ describe('Settings Loading and Merging', () => { ); const settings = loadSettings(MOCK_WORKSPACE_DIR); - expect(settings.merged.dnsResolutionOrder).toBe('verbatim'); + expect(settings.merged.advanced?.dnsResolutionOrder).toBe('verbatim'); }); it('should use user dnsResolutionOrder if workspace is not defined', () => { @@ -1156,7 +1593,7 @@ describe('Settings Loading and Merging', () => { (p: fs.PathLike) => p === USER_SETTINGS_PATH, ); const userSettingsContent = { - dnsResolutionOrder: 'verbatim', + advanced: { dnsResolutionOrder: 'verbatim' }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { @@ -1167,11 +1604,11 @@ describe('Settings Loading and Merging', () => { ); const settings = loadSettings(MOCK_WORKSPACE_DIR); - expect(settings.merged.dnsResolutionOrder).toBe('verbatim'); + expect(settings.merged.advanced?.dnsResolutionOrder).toBe('verbatim'); }); it('should leave unresolved environment variables as is', () => { - const userSettingsContent = { apiKey: '$UNDEFINED_VAR' }; + const userSettingsContent: TestSettings = { apiKey: '$UNDEFINED_VAR' }; (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => p === USER_SETTINGS_PATH, ); @@ -1184,14 +1621,20 @@ describe('Settings Loading and Merging', () => { ); const settings = loadSettings(MOCK_WORKSPACE_DIR); - expect(settings.user.settings.apiKey).toBe('$UNDEFINED_VAR'); - expect(settings.merged.apiKey).toBe('$UNDEFINED_VAR'); + expect((settings.user.settings as TestSettings)['apiKey']).toBe( + '$UNDEFINED_VAR', + ); + expect((settings.merged as TestSettings)['apiKey']).toBe( + '$UNDEFINED_VAR', + ); }); it('should resolve multiple environment variables in a single string', () => { process.env['VAR_A'] = 'valueA'; process.env['VAR_B'] = 'valueB'; - const userSettingsContent = { path: '/path/$VAR_A/${VAR_B}/end' }; + const userSettingsContent: TestSettings = { + path: '/path/$VAR_A/${VAR_B}/end', + }; (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => p === USER_SETTINGS_PATH, ); @@ -1203,7 +1646,9 @@ describe('Settings Loading and Merging', () => { }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); - expect(settings.user.settings.path).toBe('/path/valueA/valueB/end'); + expect((settings.user.settings as TestSettings)['path']).toBe( + '/path/valueA/valueB/end', + ); delete process.env['VAR_A']; delete process.env['VAR_B']; }); @@ -1211,7 +1656,9 @@ describe('Settings Loading and Merging', () => { it('should resolve environment variables in arrays', () => { process.env['ITEM_1'] = 'item1_env'; process.env['ITEM_2'] = 'item2_env'; - const userSettingsContent = { list: ['$ITEM_1', '${ITEM_2}', 'literal'] }; + const userSettingsContent: TestSettings = { + list: ['$ITEM_1', '${ITEM_2}', 'literal'], + }; (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => p === USER_SETTINGS_PATH, ); @@ -1223,7 +1670,7 @@ describe('Settings Loading and Merging', () => { }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); - expect(settings.user.settings.list).toEqual([ + expect((settings.user.settings as TestSettings)['list']).toEqual([ 'item1_env', 'item2_env', 'literal', @@ -1236,7 +1683,7 @@ describe('Settings Loading and Merging', () => { process.env['MY_ENV_STRING'] = 'env_string_value'; process.env['MY_ENV_STRING_NESTED'] = 'env_string_nested_value'; - const userSettingsContent = { + const userSettingsContent: TestSettings = { nullVal: null, trueVal: true, falseVal: false, @@ -1264,20 +1711,34 @@ describe('Settings Loading and Merging', () => { const settings = loadSettings(MOCK_WORKSPACE_DIR); - expect(settings.user.settings.nullVal).toBeNull(); - expect(settings.user.settings.trueVal).toBe(true); - expect(settings.user.settings.falseVal).toBe(false); - expect(settings.user.settings.numberVal).toBe(123.45); - expect(settings.user.settings.stringVal).toBe('env_string_value'); - expect(settings.user.settings.undefinedVal).toBeUndefined(); - - expect(settings.user.settings.nestedObj.nestedNull).toBeNull(); - expect(settings.user.settings.nestedObj.nestedBool).toBe(true); - expect(settings.user.settings.nestedObj.nestedNum).toBe(0); - expect(settings.user.settings.nestedObj.nestedString).toBe('literal'); - expect(settings.user.settings.nestedObj.anotherEnv).toBe( - 'env_string_nested_value', + expect((settings.user.settings as TestSettings)['nullVal']).toBeNull(); + expect((settings.user.settings as TestSettings)['trueVal']).toBe(true); + expect((settings.user.settings as TestSettings)['falseVal']).toBe(false); + expect((settings.user.settings as TestSettings)['numberVal']).toBe( + 123.45, ); + expect((settings.user.settings as TestSettings)['stringVal']).toBe( + 'env_string_value', + ); + expect( + (settings.user.settings as TestSettings)['undefinedVal'], + ).toBeUndefined(); + + expect( + (settings.user.settings as TestSettings)['nestedObj']['nestedNull'], + ).toBeNull(); + expect( + (settings.user.settings as TestSettings)['nestedObj']['nestedBool'], + ).toBe(true); + expect( + (settings.user.settings as TestSettings)['nestedObj']['nestedNum'], + ).toBe(0); + expect( + (settings.user.settings as TestSettings)['nestedObj']['nestedString'], + ).toBe('literal'); + expect( + (settings.user.settings as TestSettings)['nestedObj']['anotherEnv'], + ).toBe('env_string_nested_value'); delete process.env['MY_ENV_STRING']; delete process.env['MY_ENV_STRING_NESTED']; @@ -1286,7 +1747,7 @@ describe('Settings Loading and Merging', () => { it('should resolve multiple concatenated environment variables in a single string value', () => { process.env['TEST_HOST'] = 'myhost'; process.env['TEST_PORT'] = '9090'; - const userSettingsContent = { + const userSettingsContent: TestSettings = { serverAddress: '${TEST_HOST}:${TEST_PORT}/api', }; (mockFsExistsSync as Mock).mockImplementation( @@ -1301,7 +1762,9 @@ describe('Settings Loading and Merging', () => { ); const settings = loadSettings(MOCK_WORKSPACE_DIR); - expect(settings.user.settings.serverAddress).toBe('myhost:9090/api'); + expect((settings.user.settings as TestSettings)['serverAddress']).toBe( + 'myhost:9090/api', + ); delete process.env['TEST_HOST']; delete process.env['TEST_PORT']; @@ -1324,8 +1787,8 @@ describe('Settings Loading and Merging', () => { (p: fs.PathLike) => p === MOCK_ENV_SYSTEM_SETTINGS_PATH, ); const systemSettingsContent = { - theme: 'env-var-theme', - sandbox: true, + ui: { theme: 'env-var-theme' }, + tools: { sandbox: true }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { @@ -1345,65 +1808,31 @@ describe('Settings Loading and Merging', () => { expect(settings.system.settings).toEqual(systemSettingsContent); expect(settings.merged).toEqual({ ...systemSettingsContent, - customThemes: {}, + ui: { + ...systemSettingsContent.ui, + customThemes: {}, + }, + mcp: {}, mcpServers: {}, - includeDirectories: [], - chatCompression: {}, + context: { + includeDirectories: [], + }, + model: { + chatCompression: {}, + }, + advanced: { + excludedEnvVars: [], + }, extensions: { disabled: [], workspacesWithMigrationNudge: [], }, + security: {}, }); }); }); }); - describe('LoadedSettings class', () => { - it('setValue should update the correct scope and recompute merged settings', () => { - (mockFsExistsSync as Mock).mockReturnValue(false); - const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); - - vi.mocked(fs.writeFileSync).mockImplementation(() => {}); - // mkdirSync is mocked in beforeEach to return undefined, which is fine for void usage - - loadedSettings.setValue(SettingScope.User, 'theme', 'matrix'); - expect(loadedSettings.user.settings.theme).toBe('matrix'); - expect(loadedSettings.merged.theme).toBe('matrix'); - expect(fs.writeFileSync).toHaveBeenCalledWith( - USER_SETTINGS_PATH, - JSON.stringify({ theme: 'matrix' }, null, 2), - 'utf-8', - ); - - loadedSettings.setValue( - SettingScope.Workspace, - 'contextFileName', - 'MY_AGENTS.md', - ); - expect(loadedSettings.workspace.settings.contextFileName).toBe( - 'MY_AGENTS.md', - ); - expect(loadedSettings.merged.contextFileName).toBe('MY_AGENTS.md'); - expect(loadedSettings.merged.theme).toBe('matrix'); // User setting should still be there - expect(fs.writeFileSync).toHaveBeenCalledWith( - MOCK_WORKSPACE_SETTINGS_PATH, - JSON.stringify({ contextFileName: 'MY_AGENTS.md' }, null, 2), - 'utf-8', - ); - - // System theme overrides user and workspace themes - loadedSettings.setValue(SettingScope.System, 'theme', 'ocean'); - - expect(loadedSettings.system.settings.theme).toBe('ocean'); - expect(loadedSettings.merged.theme).toBe('ocean'); - - // SystemDefaults theme is overridden by user, workspace, and system themes - loadedSettings.setValue(SettingScope.SystemDefaults, 'theme', 'default'); - expect(loadedSettings.systemDefaults.settings.theme).toBe('default'); - expect(loadedSettings.merged.theme).toBe('ocean'); - }); - }); - describe('excludedProjectEnvVars integration', () => { const originalEnv = { ...process.env }; @@ -1418,7 +1847,8 @@ describe('Settings Loading and Merging', () => { it('should exclude DEBUG and DEBUG_MODE from project .env files by default', () => { // Create a workspace settings file with excludedProjectEnvVars const workspaceSettingsContent = { - excludedProjectEnvVars: ['DEBUG', 'DEBUG_MODE'], + general: {}, + advanced: { excludedEnvVars: ['DEBUG', 'DEBUG_MODE'] }, }; (mockFsExistsSync as Mock).mockImplementation( @@ -1459,7 +1889,7 @@ describe('Settings Loading and Merging', () => { const settings = loadSettings(MOCK_WORKSPACE_DIR); // Verify the settings were loaded correctly - expect(settings.merged.excludedProjectEnvVars).toEqual([ + expect(settings.merged.advanced?.excludedEnvVars).toEqual([ 'DEBUG', 'DEBUG_MODE', ]); @@ -1476,7 +1906,8 @@ describe('Settings Loading and Merging', () => { it('should respect custom excludedProjectEnvVars from user settings', () => { const userSettingsContent = { - excludedProjectEnvVars: ['NODE_ENV', 'DEBUG'], + general: {}, + advanced: { excludedEnvVars: ['NODE_ENV', 'DEBUG'] }, }; (mockFsExistsSync as Mock).mockImplementation( @@ -1492,11 +1923,11 @@ describe('Settings Loading and Merging', () => { ); const settings = loadSettings(MOCK_WORKSPACE_DIR); - expect(settings.user.settings.excludedProjectEnvVars).toEqual([ + expect(settings.user.settings.advanced?.excludedEnvVars).toEqual([ 'NODE_ENV', 'DEBUG', ]); - expect(settings.merged.excludedProjectEnvVars).toEqual([ + expect(settings.merged.advanced?.excludedEnvVars).toEqual([ 'NODE_ENV', 'DEBUG', ]); @@ -1504,10 +1935,12 @@ describe('Settings Loading and Merging', () => { it('should merge excludedProjectEnvVars with workspace taking precedence', () => { const userSettingsContent = { - excludedProjectEnvVars: ['DEBUG', 'NODE_ENV', 'USER_VAR'], + general: {}, + advanced: { excludedEnvVars: ['DEBUG', 'NODE_ENV', 'USER_VAR'] }, }; const workspaceSettingsContent = { - excludedProjectEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'], + general: {}, + advanced: { excludedEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'] }, }; (mockFsExistsSync as Mock).mockReturnValue(true); @@ -1524,16 +1957,19 @@ describe('Settings Loading and Merging', () => { const settings = loadSettings(MOCK_WORKSPACE_DIR); - expect(settings.user.settings.excludedProjectEnvVars).toEqual([ + expect(settings.user.settings.advanced?.excludedEnvVars).toEqual([ 'DEBUG', 'NODE_ENV', 'USER_VAR', ]); - expect(settings.workspace.settings.excludedProjectEnvVars).toEqual([ + expect(settings.workspace.settings.advanced?.excludedEnvVars).toEqual([ 'WORKSPACE_DEBUG', 'WORKSPACE_VAR', ]); - expect(settings.merged.excludedProjectEnvVars).toEqual([ + expect(settings.merged.advanced?.excludedEnvVars).toEqual([ + 'DEBUG', + 'NODE_ENV', + 'USER_VAR', 'WORKSPACE_DEBUG', 'WORKSPACE_VAR', ]); @@ -1543,10 +1979,13 @@ describe('Settings Loading and Merging', () => { describe('with workspace trust', () => { it('should merge workspace settings when workspace is trusted', () => { (mockFsExistsSync as Mock).mockReturnValue(true); - const userSettingsContent = { theme: 'dark', sandbox: false }; + const userSettingsContent = { + ui: { theme: 'dark' }, + tools: { sandbox: false }, + }; const workspaceSettingsContent = { - sandbox: true, - contextFileName: 'WORKSPACE.md', + tools: { sandbox: true }, + context: { fileName: 'WORKSPACE.md' }, }; (fs.readFileSync as Mock).mockImplementation( @@ -1560,23 +1999,22 @@ describe('Settings Loading and Merging', () => { ); const settings = loadSettings(MOCK_WORKSPACE_DIR); - - expect(settings.merged.sandbox).toBe(true); - expect(settings.merged.contextFileName).toBe('WORKSPACE.md'); - expect(settings.merged.theme).toBe('dark'); + expect(settings.merged.tools?.sandbox).toBe(true); + expect(settings.merged.context?.fileName).toBe('WORKSPACE.md'); + expect(settings.merged.ui?.theme).toBe('dark'); }); it('should NOT merge workspace settings when workspace is not trusted', () => { vi.mocked(isWorkspaceTrusted).mockReturnValue(false); (mockFsExistsSync as Mock).mockReturnValue(true); const userSettingsContent = { - theme: 'dark', - sandbox: false, - contextFileName: 'USER.md', + ui: { theme: 'dark' }, + tools: { sandbox: false }, + context: { fileName: 'USER.md' }, }; const workspaceSettingsContent = { - sandbox: true, - contextFileName: 'WORKSPACE.md', + tools: { sandbox: true }, + context: { fileName: 'WORKSPACE.md' }, }; (fs.readFileSync as Mock).mockImplementation( @@ -1591,9 +2029,338 @@ describe('Settings Loading and Merging', () => { const settings = loadSettings(MOCK_WORKSPACE_DIR); - expect(settings.merged.sandbox).toBe(false); // User setting - expect(settings.merged.contextFileName).toBe('USER.md'); // User setting - expect(settings.merged.theme).toBe('dark'); // User setting + expect(settings.merged.tools?.sandbox).toBe(false); // User setting + expect(settings.merged.context?.fileName).toBe('USER.md'); // User setting + expect(settings.merged.ui?.theme).toBe('dark'); // User setting + }); + }); + + describe('migrateSettingsToV1', () => { + it('should handle an empty object', () => { + const v2Settings = {}; + const v1Settings = migrateSettingsToV1(v2Settings); + expect(v1Settings).toEqual({}); + }); + + it('should migrate a simple v2 settings object to v1', () => { + const v2Settings = { + general: { + preferredEditor: 'vscode', + vimMode: true, + }, + ui: { + theme: 'dark', + }, + }; + const v1Settings = migrateSettingsToV1(v2Settings); + expect(v1Settings).toEqual({ + preferredEditor: 'vscode', + vimMode: true, + theme: 'dark', + }); + }); + + it('should handle nested properties correctly', () => { + const v2Settings = { + security: { + folderTrust: { + enabled: true, + }, + auth: { + selectedType: 'oauth', + }, + }, + advanced: { + autoConfigureMemory: true, + }, + }; + const v1Settings = migrateSettingsToV1(v2Settings); + expect(v1Settings).toEqual({ + folderTrust: true, + selectedAuthType: 'oauth', + autoConfigureMaxOldSpaceSize: true, + }); + }); + + it('should preserve mcpServers at the top level', () => { + const v2Settings = { + general: { + preferredEditor: 'vscode', + }, + mcpServers: { + 'my-server': { + command: 'npm start', + }, + }, + }; + const v1Settings = migrateSettingsToV1(v2Settings); + expect(v1Settings).toEqual({ + preferredEditor: 'vscode', + mcpServers: { + 'my-server': { + command: 'npm start', + }, + }, + }); + }); + + it('should carry over unrecognized top-level properties', () => { + const v2Settings = { + general: { + vimMode: false, + }, + unrecognized: 'value', + another: { + nested: true, + }, + }; + const v1Settings = migrateSettingsToV1(v2Settings); + expect(v1Settings).toEqual({ + vimMode: false, + unrecognized: 'value', + another: { + nested: true, + }, + }); + }); + + it('should handle a complex object with mixed properties', () => { + const v2Settings = { + general: { + disableAutoUpdate: true, + }, + ui: { + hideBanner: true, + customThemes: { + myTheme: {}, + }, + }, + model: { + name: 'gemini-pro', + chatCompression: { + contextPercentageThreshold: 0.5, + }, + }, + mcpServers: { + 'server-1': { + command: 'node server.js', + }, + }, + unrecognized: { + should: 'be-preserved', + }, + }; + const v1Settings = migrateSettingsToV1(v2Settings); + expect(v1Settings).toEqual({ + disableAutoUpdate: true, + hideBanner: true, + customThemes: { + myTheme: {}, + }, + model: 'gemini-pro', + chatCompression: { + contextPercentageThreshold: 0.5, + }, + mcpServers: { + 'server-1': { + command: 'node server.js', + }, + }, + unrecognized: { + should: 'be-preserved', + }, + }); + }); + + it('should not migrate a v1 settings object', () => { + const v1Settings = { + preferredEditor: 'vscode', + vimMode: true, + theme: 'dark', + }; + const migratedSettings = migrateSettingsToV1(v1Settings); + expect(migratedSettings).toEqual({ + preferredEditor: 'vscode', + vimMode: true, + theme: 'dark', + }); + }); + + it('should migrate a full v2 settings object to v1', () => { + const v2Settings: TestSettings = { + general: { + preferredEditor: 'code', + vimMode: true, + }, + ui: { + theme: 'dark', + }, + privacy: { + usageStatisticsEnabled: false, + }, + model: { + name: 'gemini-pro', + chatCompression: { + contextPercentageThreshold: 0.8, + }, + }, + context: { + fileName: 'CONTEXT.md', + includeDirectories: ['/src'], + }, + tools: { + sandbox: true, + exclude: ['toolA'], + }, + mcp: { + allowed: ['server1'], + }, + security: { + folderTrust: { + enabled: true, + }, + }, + advanced: { + dnsResolutionOrder: 'ipv4first', + excludedEnvVars: ['SECRET'], + }, + mcpServers: { + 'my-server': { + command: 'npm start', + }, + }, + unrecognizedTopLevel: { + value: 'should be preserved', + }, + }; + + const v1Settings = migrateSettingsToV1(v2Settings); + + expect(v1Settings).toEqual({ + preferredEditor: 'code', + vimMode: true, + theme: 'dark', + usageStatisticsEnabled: false, + model: 'gemini-pro', + chatCompression: { + contextPercentageThreshold: 0.8, + }, + contextFileName: 'CONTEXT.md', + includeDirectories: ['/src'], + sandbox: true, + excludeTools: ['toolA'], + allowMCPServers: ['server1'], + folderTrust: true, + dnsResolutionOrder: 'ipv4first', + excludedProjectEnvVars: ['SECRET'], + mcpServers: { + 'my-server': { + command: 'npm start', + }, + }, + unrecognizedTopLevel: { + value: 'should be preserved', + }, + }); + }); + + it('should handle partial v2 settings', () => { + const v2Settings: TestSettings = { + general: { + vimMode: false, + }, + ui: {}, + model: { + name: 'gemini-1.5-pro', + }, + unrecognized: 'value', + }; + + const v1Settings = migrateSettingsToV1(v2Settings); + + expect(v1Settings).toEqual({ + vimMode: false, + model: 'gemini-1.5-pro', + unrecognized: 'value', + }); + }); + + it('should handle settings with different data types', () => { + const v2Settings: TestSettings = { + general: { + vimMode: false, + }, + model: { + maxSessionTurns: 0, + }, + context: { + includeDirectories: [], + }, + security: { + folderTrust: { + enabled: null, + }, + }, + }; + + const v1Settings = migrateSettingsToV1(v2Settings); + + expect(v1Settings).toEqual({ + vimMode: false, + maxSessionTurns: 0, + includeDirectories: [], + folderTrust: null, + }); + }); + + it('should preserve unrecognized top-level keys', () => { + const v2Settings: TestSettings = { + general: { + vimMode: true, + }, + customTopLevel: { + a: 1, + b: [2], + }, + anotherOne: 'hello', + }; + + const v1Settings = migrateSettingsToV1(v2Settings); + + expect(v1Settings).toEqual({ + vimMode: true, + customTopLevel: { + a: 1, + b: [2], + }, + anotherOne: 'hello', + }); + }); + + it('should handle an empty v2 settings object', () => { + const v2Settings = {}; + const v1Settings = migrateSettingsToV1(v2Settings); + expect(v1Settings).toEqual({}); + }); + + it('should correctly handle mcpServers at the top level', () => { + const v2Settings: TestSettings = { + mcpServers: { + serverA: { command: 'a' }, + }, + mcp: { + allowed: ['serverA'], + }, + }; + + const v1Settings = migrateSettingsToV1(v2Settings); + + expect(v1Settings).toEqual({ + mcpServers: { + serverA: { command: 'a' }, + }, + allowMCPServers: ['serverA'], + }); }); }); }); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 10c751c6..03d42410 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -18,6 +18,7 @@ import { DefaultLight } from '../ui/themes/default-light.js'; import { DefaultDark } from '../ui/themes/default.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; import type { Settings, MemoryImportFormat } from './settingsSchema.js'; +import { mergeWith } from 'lodash-es'; export type { Settings, MemoryImportFormat }; @@ -27,6 +28,58 @@ export const USER_SETTINGS_PATH = Storage.getGlobalSettingsPath(); export const USER_SETTINGS_DIR = path.dirname(USER_SETTINGS_PATH); export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE']; +const MIGRATE_V2_OVERWRITE = false; + +// As defined in spec.md +const MIGRATION_MAP: Record = { + preferredEditor: 'general.preferredEditor', + vimMode: 'general.vimMode', + disableAutoUpdate: 'general.disableAutoUpdate', + disableUpdateNag: 'general.disableUpdateNag', + checkpointing: 'general.checkpointing', + theme: 'ui.theme', + customThemes: 'ui.customThemes', + hideWindowTitle: 'ui.hideWindowTitle', + hideTips: 'ui.hideTips', + hideBanner: 'ui.hideBanner', + hideFooter: 'ui.hideFooter', + showMemoryUsage: 'ui.showMemoryUsage', + showLineNumbers: 'ui.showLineNumbers', + accessibility: 'ui.accessibility', + ideMode: 'ide.enabled', + hasSeenIdeIntegrationNudge: 'ide.hasSeenNudge', + usageStatisticsEnabled: 'privacy.usageStatisticsEnabled', + telemetry: 'telemetry', + model: 'model.name', + maxSessionTurns: 'model.maxSessionTurns', + summarizeToolOutput: 'model.summarizeToolOutput', + chatCompression: 'model.chatCompression', + skipNextSpeakerCheck: 'model.skipNextSpeakerCheck', + contextFileName: 'context.fileName', + memoryImportFormat: 'context.importFormat', + memoryDiscoveryMaxDirs: 'context.discoveryMaxDirs', + includeDirectories: 'context.includeDirectories', + loadMemoryFromIncludeDirectories: 'context.loadFromIncludeDirectories', + fileFiltering: 'context.fileFiltering', + sandbox: 'tools.sandbox', + shouldUseNodePtyShell: 'tools.usePty', + coreTools: 'tools.core', + excludeTools: 'tools.exclude', + toolDiscoveryCommand: 'tools.discoveryCommand', + toolCallCommand: 'tools.callCommand', + mcpServerCommand: 'mcp.serverCommand', + allowMCPServers: 'mcp.allowed', + excludeMCPServers: 'mcp.excluded', + folderTrustFeature: 'security.folderTrust.featureEnabled', + folderTrust: 'security.folderTrust.enabled', + selectedAuthType: 'security.auth.selectedType', + useExternalAuth: 'security.auth.useExternal', + autoConfigureMaxOldSpaceSize: 'advanced.autoConfigureMemory', + dnsResolutionOrder: 'advanced.dnsResolutionOrder', + excludedProjectEnvVars: 'advanced.excludedEnvVars', + bugCommand: 'advanced.bugCommand', +}; + export function getSystemSettingsPath(): string { if (process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']) { return process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']; @@ -82,6 +135,134 @@ export interface SettingsFile { path: string; } +function setNestedProperty( + obj: Record, + path: string, + value: unknown, +) { + const keys = path.split('.'); + const lastKey = keys.pop(); + if (!lastKey) return; + + let current: Record = obj; + for (const key of keys) { + if (current[key] === undefined) { + current[key] = {}; + } + const next = current[key]; + if (typeof next === 'object' && next !== null) { + current = next as Record; + } else { + // This path is invalid, so we stop. + return; + } + } + current[lastKey] = value; +} + +function needsMigration(settings: Record): boolean { + return !('general' in settings); +} + +function migrateSettingsToV2( + flatSettings: Record, +): Record | null { + if (!needsMigration(flatSettings)) { + return null; + } + + const v2Settings: Record = {}; + const flatKeys = new Set(Object.keys(flatSettings)); + + for (const [oldKey, newPath] of Object.entries(MIGRATION_MAP)) { + if (flatKeys.has(oldKey)) { + setNestedProperty(v2Settings, newPath, flatSettings[oldKey]); + flatKeys.delete(oldKey); + } + } + + // Preserve mcpServers at the top level + if (flatSettings['mcpServers']) { + v2Settings['mcpServers'] = flatSettings['mcpServers']; + flatKeys.delete('mcpServers'); + } + + // Carry over any unrecognized keys + for (const remainingKey of flatKeys) { + v2Settings[remainingKey] = flatSettings[remainingKey]; + } + + return v2Settings; +} + +function getNestedProperty( + obj: Record, + path: string, +): unknown { + const keys = path.split('.'); + let current: unknown = obj; + for (const key of keys) { + if (typeof current !== 'object' || current === null || !(key in current)) { + return undefined; + } + current = (current as Record)[key]; + } + return current; +} + +const REVERSE_MIGRATION_MAP: Record = Object.fromEntries( + Object.entries(MIGRATION_MAP).map(([key, value]) => [value, key]), +); + +// Dynamically determine the top-level keys from the V2 settings structure. +const KNOWN_V2_CONTAINERS = new Set( + Object.values(MIGRATION_MAP).map((path) => path.split('.')[0]), +); + +export function migrateSettingsToV1( + v2Settings: Record, +): Record { + const v1Settings: Record = {}; + const v2Keys = new Set(Object.keys(v2Settings)); + + for (const [newPath, oldKey] of Object.entries(REVERSE_MIGRATION_MAP)) { + const value = getNestedProperty(v2Settings, newPath); + if (value !== undefined) { + v1Settings[oldKey] = value; + v2Keys.delete(newPath.split('.')[0]); + } + } + + // Preserve mcpServers at the top level + if (v2Settings['mcpServers']) { + v1Settings['mcpServers'] = v2Settings['mcpServers']; + v2Keys.delete('mcpServers'); + } + + // Carry over any unrecognized keys + for (const remainingKey of v2Keys) { + const value = v2Settings[remainingKey]; + if (value === undefined) { + continue; + } + + // Don't carry over empty objects that were just containers for migrated settings. + if ( + KNOWN_V2_CONTAINERS.has(remainingKey) && + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Object.keys(value).length === 0 + ) { + continue; + } + + v1Settings[remainingKey] = value; + } + + return v1Settings; +} + function mergeSettings( system: Settings, systemDefaults: Settings, @@ -92,8 +273,17 @@ function mergeSettings( const safeWorkspace = isTrusted ? workspace : ({} as Settings); // folderTrust is not supported at workspace level. - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { folderTrust, ...safeWorkspaceWithoutFolderTrust } = safeWorkspace; + const { security, ...restOfWorkspace } = safeWorkspace; + const safeWorkspaceWithoutFolderTrust = security + ? { + ...restOfWorkspace, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + security: (({ folderTrust, ...rest }) => rest)(security), + } + : { + ...restOfWorkspace, + security: {}, + }; // Settings are merged with the following precedence (last one wins for // single values): @@ -109,40 +299,84 @@ function mergeSettings( ...user, ...safeWorkspaceWithoutFolderTrust, ...system, - customThemes: { - ...(systemDefaults.customThemes || {}), - ...(user.customThemes || {}), - ...(safeWorkspace.customThemes || {}), - ...(system.customThemes || {}), + ui: { + ...(systemDefaults.ui || {}), + ...(user.ui || {}), + ...(safeWorkspaceWithoutFolderTrust.ui || {}), + ...(system.ui || {}), + customThemes: { + ...(systemDefaults.ui?.customThemes || {}), + ...(user.ui?.customThemes || {}), + ...(safeWorkspaceWithoutFolderTrust.ui?.customThemes || {}), + ...(system.ui?.customThemes || {}), + }, + }, + security: { + ...(systemDefaults.security || {}), + ...(user.security || {}), + ...(safeWorkspaceWithoutFolderTrust.security || {}), + ...(system.security || {}), + }, + mcp: { + ...(systemDefaults.mcp || {}), + ...(user.mcp || {}), + ...(safeWorkspaceWithoutFolderTrust.mcp || {}), + ...(system.mcp || {}), }, mcpServers: { ...(systemDefaults.mcpServers || {}), ...(user.mcpServers || {}), - ...(safeWorkspace.mcpServers || {}), + ...(safeWorkspaceWithoutFolderTrust.mcpServers || {}), ...(system.mcpServers || {}), }, - includeDirectories: [ - ...(systemDefaults.includeDirectories || []), - ...(user.includeDirectories || []), - ...(safeWorkspace.includeDirectories || []), - ...(system.includeDirectories || []), - ], - chatCompression: { - ...(systemDefaults.chatCompression || {}), - ...(user.chatCompression || {}), - ...(safeWorkspace.chatCompression || {}), - ...(system.chatCompression || {}), + context: { + ...(systemDefaults.context || {}), + ...(user.context || {}), + ...(safeWorkspaceWithoutFolderTrust.context || {}), + ...(system.context || {}), + includeDirectories: [ + ...(systemDefaults.context?.includeDirectories || []), + ...(user.context?.includeDirectories || []), + ...(safeWorkspaceWithoutFolderTrust.context?.includeDirectories || []), + ...(system.context?.includeDirectories || []), + ], + }, + model: { + ...(systemDefaults.model || {}), + ...(user.model || {}), + ...(safeWorkspaceWithoutFolderTrust.model || {}), + ...(system.model || {}), + chatCompression: { + ...(systemDefaults.model?.chatCompression || {}), + ...(user.model?.chatCompression || {}), + ...(safeWorkspaceWithoutFolderTrust.model?.chatCompression || {}), + ...(system.model?.chatCompression || {}), + }, + }, + advanced: { + ...(systemDefaults.advanced || {}), + ...(user.advanced || {}), + ...(safeWorkspaceWithoutFolderTrust.advanced || {}), + ...(system.advanced || {}), + excludedEnvVars: [ + ...new Set([ + ...(systemDefaults.advanced?.excludedEnvVars || []), + ...(user.advanced?.excludedEnvVars || []), + ...(safeWorkspaceWithoutFolderTrust.advanced?.excludedEnvVars || []), + ...(system.advanced?.excludedEnvVars || []), + ]), + ], }, extensions: { ...(systemDefaults.extensions || {}), ...(user.extensions || {}), - ...(safeWorkspace.extensions || {}), + ...(safeWorkspaceWithoutFolderTrust.extensions || {}), ...(system.extensions || {}), disabled: [ ...new Set([ ...(systemDefaults.extensions?.disabled || []), ...(user.extensions?.disabled || []), - ...(safeWorkspace.extensions?.disabled || []), + ...(safeWorkspaceWithoutFolderTrust.extensions?.disabled || []), ...(system.extensions?.disabled || []), ]), ], @@ -150,7 +384,8 @@ function mergeSettings( ...new Set([ ...(systemDefaults.extensions?.workspacesWithMigrationNudge || []), ...(user.extensions?.workspacesWithMigrationNudge || []), - ...(safeWorkspace.extensions?.workspacesWithMigrationNudge || []), + ...(safeWorkspaceWithoutFolderTrust.extensions + ?.workspacesWithMigrationNudge || []), ...(system.extensions?.workspacesWithMigrationNudge || []), ]), ], @@ -166,6 +401,7 @@ export class LoadedSettings { workspace: SettingsFile, errors: SettingsError[], isTrusted: boolean, + migratedInMemorScopes: Set, ) { this.system = system; this.systemDefaults = systemDefaults; @@ -173,6 +409,7 @@ export class LoadedSettings { this.workspace = workspace; this.errors = errors; this.isTrusted = isTrusted; + this.migratedInMemorScopes = migratedInMemorScopes; this._merged = this.computeMergedSettings(); } @@ -182,6 +419,7 @@ export class LoadedSettings { readonly workspace: SettingsFile; readonly errors: SettingsError[]; readonly isTrusted: boolean; + readonly migratedInMemorScopes: Set; private _merged: Settings; @@ -214,13 +452,9 @@ export class LoadedSettings { } } - setValue( - scope: SettingScope, - key: K, - value: Settings[K], - ): void { + setValue(scope: SettingScope, key: string, value: unknown): void { const settingsFile = this.forScope(scope); - settingsFile.settings[key] = value; + setNestedProperty(settingsFile.settings, key, value); this._merged = this.computeMergedSettings(); saveSettings(settingsFile); } @@ -357,7 +591,8 @@ export function loadEnvironment(settings?: Settings): void { const parsedEnv = dotenv.parse(envFileContent); const excludedVars = - resolvedSettings?.excludedProjectEnvVars || DEFAULT_EXCLUDED_ENV_VARS; + resolvedSettings?.advanced?.excludedEnvVars || + DEFAULT_EXCLUDED_ENV_VARS; const isProjectEnvFile = !envFilePath.includes(GEMINI_DIR); for (const key in parsedEnv) { @@ -391,6 +626,7 @@ export function loadSettings(workspaceDir: string): LoadedSettings { const settingsErrors: SettingsError[] = []; const systemSettingsPath = getSystemSettingsPath(); const systemDefaultsPath = getSystemDefaultsPath(); + const migratedInMemorScopes = new Set(); // Resolve paths to their canonical representation to handle symlinks const resolvedWorkspaceDir = path.resolve(workspaceDir); @@ -411,85 +647,97 @@ export function loadSettings(workspaceDir: string): LoadedSettings { workspaceDir, ).getWorkspaceSettingsPath(); - // Load system settings - try { - if (fs.existsSync(systemSettingsPath)) { - const systemContent = fs.readFileSync(systemSettingsPath, 'utf-8'); - systemSettings = JSON.parse(stripJsonComments(systemContent)) as Settings; - } - } catch (error: unknown) { - settingsErrors.push({ - message: getErrorMessage(error), - path: systemSettingsPath, - }); - } - - // Load system defaults - try { - if (fs.existsSync(systemDefaultsPath)) { - const systemDefaultsContent = fs.readFileSync( - systemDefaultsPath, - 'utf-8', - ); - const parsedSystemDefaults = JSON.parse( - stripJsonComments(systemDefaultsContent), - ) as Settings; - systemDefaultSettings = resolveEnvVarsInObject(parsedSystemDefaults); - } - } catch (error: unknown) { - settingsErrors.push({ - message: getErrorMessage(error), - path: systemDefaultsPath, - }); - } - - // Load user settings - try { - if (fs.existsSync(USER_SETTINGS_PATH)) { - const userContent = fs.readFileSync(USER_SETTINGS_PATH, 'utf-8'); - userSettings = JSON.parse(stripJsonComments(userContent)) as Settings; - // Support legacy theme names - if (userSettings.theme && userSettings.theme === 'VS') { - userSettings.theme = DefaultLight.name; - } else if (userSettings.theme && userSettings.theme === 'VS2015') { - userSettings.theme = DefaultDark.name; - } - } - } catch (error: unknown) { - settingsErrors.push({ - message: getErrorMessage(error), - path: USER_SETTINGS_PATH, - }); - } - - if (realWorkspaceDir !== realHomeDir) { - // Load workspace settings + const loadAndMigrate = (filePath: string, scope: SettingScope): Settings => { try { - if (fs.existsSync(workspaceSettingsPath)) { - const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8'); - workspaceSettings = JSON.parse( - stripJsonComments(projectContent), - ) as Settings; - if (workspaceSettings.theme && workspaceSettings.theme === 'VS') { - workspaceSettings.theme = DefaultLight.name; - } else if ( - workspaceSettings.theme && - workspaceSettings.theme === 'VS2015' + if (fs.existsSync(filePath)) { + const content = fs.readFileSync(filePath, 'utf-8'); + const rawSettings: unknown = JSON.parse(stripJsonComments(content)); + + if ( + typeof rawSettings !== 'object' || + rawSettings === null || + Array.isArray(rawSettings) ) { - workspaceSettings.theme = DefaultDark.name; + settingsErrors.push({ + message: 'Settings file is not a valid JSON object.', + path: filePath, + }); + return {}; } + + let settingsObject = rawSettings as Record; + if (needsMigration(settingsObject)) { + console.error(`Legacy settings file detected at: ${filePath}`); + const migratedSettings = migrateSettingsToV2(settingsObject); + if (migratedSettings) { + if (MIGRATE_V2_OVERWRITE) { + try { + fs.renameSync(filePath, `${filePath}.orig`); + fs.writeFileSync( + filePath, + JSON.stringify(migratedSettings, null, 2), + 'utf-8', + ); + console.log( + `Successfully migrated and saved settings file: ${filePath}`, + ); + } catch (e) { + console.error( + `Error migrating settings file on disk: ${getErrorMessage( + e, + )}`, + ); + } + } else { + console.log( + `Successfully migrated settings for ${filePath} in-memory for the current session.`, + ); + migratedInMemorScopes.add(scope); + } + settingsObject = migratedSettings; + } + } + return settingsObject as Settings; } } catch (error: unknown) { settingsErrors.push({ message: getErrorMessage(error), - path: workspaceSettingsPath, + path: filePath, }); } + return {}; + }; + + systemSettings = loadAndMigrate(systemSettingsPath, SettingScope.System); + systemDefaultSettings = loadAndMigrate( + systemDefaultsPath, + SettingScope.SystemDefaults, + ); + userSettings = loadAndMigrate(USER_SETTINGS_PATH, SettingScope.User); + + if (realWorkspaceDir !== realHomeDir) { + workspaceSettings = loadAndMigrate( + workspaceSettingsPath, + SettingScope.Workspace, + ); + } + + // Support legacy theme names + if (userSettings.ui?.theme === 'VS') { + userSettings.ui.theme = DefaultLight.name; + } else if (userSettings.ui?.theme === 'VS2015') { + userSettings.ui.theme = DefaultDark.name; + } + if (workspaceSettings.ui?.theme === 'VS') { + workspaceSettings.ui.theme = DefaultLight.name; + } else if (workspaceSettings.ui?.theme === 'VS2015') { + workspaceSettings.ui.theme = DefaultDark.name; } // For the initial trust check, we can only use user and system settings. - const initialTrustCheckSettings = { ...systemSettings, ...userSettings }; - const isTrusted = isWorkspaceTrusted(initialTrustCheckSettings) ?? true; + const initialTrustCheckSettings = mergeWith({}, systemSettings, userSettings); + const isTrusted = + isWorkspaceTrusted(initialTrustCheckSettings as Settings) ?? true; // Create a temporary merged settings object to pass to loadEnvironment. const tempMergedSettings = mergeSettings( @@ -529,21 +777,9 @@ export function loadSettings(workspaceDir: string): LoadedSettings { }, settingsErrors, isTrusted, + migratedInMemorScopes, ); - // Validate chatCompression settings - const chatCompression = loadedSettings.merged.chatCompression; - const threshold = chatCompression?.contextPercentageThreshold; - if ( - threshold != null && - (typeof threshold !== 'number' || threshold < 0 || threshold > 1) - ) { - console.warn( - `Invalid value for chatCompression.contextPercentageThreshold: "${threshold}". Please use a value between 0 and 1. Using default compression settings.`, - ); - delete loadedSettings.merged.chatCompression; - } - return loadedSettings; } @@ -555,9 +791,16 @@ export function saveSettings(settingsFile: SettingsFile): void { fs.mkdirSync(dirPath, { recursive: true }); } + let settingsToSave = settingsFile.settings; + if (!MIGRATE_V2_OVERWRITE) { + settingsToSave = migrateSettingsToV1( + settingsToSave as Record, + ) as Settings; + } + fs.writeFileSync( settingsFile.path, - JSON.stringify(settingsFile.settings, null, 2), + JSON.stringify(settingsToSave, null, 2), 'utf-8', ); } catch (error) { diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index c99ead4d..e182e49e 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -12,49 +12,18 @@ 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', + 'general', + 'ui', + 'ide', + 'privacy', 'telemetry', - 'bugCommand', - 'summarizeToolOutput', - 'dnsResolutionOrder', - 'excludedProjectEnvVars', - 'disableUpdateNag', - 'includeDirectories', - 'loadMemoryFromIncludeDirectories', 'model', - 'hasSeenIdeIntegrationNudge', - 'folderTrustFeature', - 'useRipgrep', - 'debugKeystrokeLogging', + 'context', + 'tools', + 'mcp', + 'security', + 'advanced', ]; expectedSettings.forEach((setting) => { @@ -80,9 +49,16 @@ describe('SettingsSchema', () => { it('should have correct nested setting structure', () => { const nestedSettings = [ - 'accessibility', - 'checkpointing', - 'fileFiltering', + 'general', + 'ui', + 'ide', + 'privacy', + 'model', + 'context', + 'tools', + 'mcp', + 'security', + 'advanced', ]; nestedSettings.forEach((setting) => { @@ -99,29 +75,36 @@ describe('SettingsSchema', () => { it('should have accessibility nested properties', () => { expect( - SETTINGS_SCHEMA.accessibility.properties?.disableLoadingPhrases, + SETTINGS_SCHEMA.ui?.properties?.accessibility?.properties, ).toBeDefined(); expect( - SETTINGS_SCHEMA.accessibility.properties?.disableLoadingPhrases.type, + SETTINGS_SCHEMA.ui?.properties?.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', - ); + expect( + SETTINGS_SCHEMA.general?.properties?.checkpointing.properties?.enabled, + ).toBeDefined(); + expect( + SETTINGS_SCHEMA.general?.properties?.checkpointing.properties?.enabled + .type, + ).toBe('boolean'); }); it('should have fileFiltering nested properties', () => { expect( - SETTINGS_SCHEMA.fileFiltering.properties?.respectGitIgnore, + SETTINGS_SCHEMA.context.properties.fileFiltering.properties + ?.respectGitIgnore, ).toBeDefined(); expect( - SETTINGS_SCHEMA.fileFiltering.properties?.respectGeminiIgnore, + SETTINGS_SCHEMA.context.properties.fileFiltering.properties + ?.respectGeminiIgnore, ).toBeDefined(); expect( - SETTINGS_SCHEMA.fileFiltering.properties?.enableRecursiveFileSearch, + SETTINGS_SCHEMA.context.properties.fileFiltering.properties + ?.enableRecursiveFileSearch, ).toBeDefined(); }); @@ -150,11 +133,6 @@ describe('SettingsSchema', () => { 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'); }); @@ -183,85 +161,148 @@ describe('SettingsSchema', () => { 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(false); + expect(SETTINGS_SCHEMA.ui.properties.showMemoryUsage.showInDialog).toBe( + true, + ); + expect(SETTINGS_SCHEMA.general.properties.vimMode.showInDialog).toBe( + true, + ); + expect(SETTINGS_SCHEMA.ide.properties.enabled.showInDialog).toBe(true); + expect( + SETTINGS_SCHEMA.general.properties.disableAutoUpdate.showInDialog, + ).toBe(true); + expect(SETTINGS_SCHEMA.ui.properties.hideWindowTitle.showInDialog).toBe( + true, + ); + expect(SETTINGS_SCHEMA.ui.properties.hideTips.showInDialog).toBe(true); + expect(SETTINGS_SCHEMA.ui.properties.hideBanner.showInDialog).toBe(true); + expect( + SETTINGS_SCHEMA.privacy.properties.usageStatisticsEnabled.showInDialog, + ).toBe(false); // 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.security.properties.auth.showInDialog).toBe(false); + expect(SETTINGS_SCHEMA.tools.properties.core.showInDialog).toBe(false); expect(SETTINGS_SCHEMA.mcpServers.showInDialog).toBe(false); expect(SETTINGS_SCHEMA.telemetry.showInDialog).toBe(false); // 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, - ); + expect(SETTINGS_SCHEMA.ui.properties.theme.showInDialog).toBe(false); // Changed to false + expect(SETTINGS_SCHEMA.ui.properties.customThemes.showInDialog).toBe( + false, + ); // Managed via theme editor + expect( + SETTINGS_SCHEMA.general.properties.checkpointing.showInDialog, + ).toBe(false); // Experimental feature + expect(SETTINGS_SCHEMA.ui.properties.accessibility.showInDialog).toBe( + false, + ); // Changed to false + expect( + SETTINGS_SCHEMA.context.properties.fileFiltering.showInDialog, + ).toBe(false); // Changed to false + expect( + SETTINGS_SCHEMA.general.properties.preferredEditor.showInDialog, + ).toBe(false); // Changed to false + expect( + SETTINGS_SCHEMA.advanced.properties.autoConfigureMemory.showInDialog, + ).toBe(false); }); 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, + ui: { + theme: 'dark', + }, + context: { + 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); + expect(settings.ui?.theme).toBe('dark'); + expect(settings.context?.includeDirectories).toEqual(['/path/to/dir']); + expect(settings.context?.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([]); + expect( + SETTINGS_SCHEMA.context?.properties.includeDirectories, + ).toBeDefined(); + expect(SETTINGS_SCHEMA.context?.properties.includeDirectories.type).toBe( + 'array', + ); + expect( + SETTINGS_SCHEMA.context?.properties.includeDirectories.category, + ).toBe('Context'); + expect( + SETTINGS_SCHEMA.context?.properties.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, - ); + expect( + SETTINGS_SCHEMA.context?.properties.loadMemoryFromIncludeDirectories, + ).toBeDefined(); + expect( + SETTINGS_SCHEMA.context?.properties.loadMemoryFromIncludeDirectories + .type, + ).toBe('boolean'); + expect( + SETTINGS_SCHEMA.context?.properties.loadMemoryFromIncludeDirectories + .category, + ).toBe('Context'); + expect( + SETTINGS_SCHEMA.context?.properties.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); + expect( + SETTINGS_SCHEMA.security.properties.folderTrust.properties.enabled, + ).toBeDefined(); + expect( + SETTINGS_SCHEMA.security.properties.folderTrust.properties.enabled.type, + ).toBe('boolean'); + expect( + SETTINGS_SCHEMA.security.properties.folderTrust.properties.enabled + .category, + ).toBe('Security'); + expect( + SETTINGS_SCHEMA.security.properties.folderTrust.properties.enabled + .default, + ).toBe(false); + expect( + SETTINGS_SCHEMA.security.properties.folderTrust.properties.enabled + .showInDialog, + ).toBe(true); }); it('should have debugKeystrokeLogging setting in schema', () => { - expect(SETTINGS_SCHEMA.debugKeystrokeLogging).toBeDefined(); - expect(SETTINGS_SCHEMA.debugKeystrokeLogging.type).toBe('boolean'); - expect(SETTINGS_SCHEMA.debugKeystrokeLogging.category).toBe('General'); - expect(SETTINGS_SCHEMA.debugKeystrokeLogging.default).toBe(false); - expect(SETTINGS_SCHEMA.debugKeystrokeLogging.requiresRestart).toBe(false); - expect(SETTINGS_SCHEMA.debugKeystrokeLogging.showInDialog).toBe(true); - expect(SETTINGS_SCHEMA.debugKeystrokeLogging.description).toBe( - 'Enable debug logging of keystrokes to the console.', - ); + expect( + SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging, + ).toBeDefined(); + expect( + SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging.type, + ).toBe('boolean'); + expect( + SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging.category, + ).toBe('General'); + expect( + SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging.default, + ).toBe(false); + expect( + SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging + .requiresRestart, + ).toBe(false); + expect( + SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging.showInDialog, + ).toBe(true); + expect( + SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging.description, + ).toBe('Enable debug logging of keystrokes to the console.'); }); }); }); diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index dd8b624e..c27d1327 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -40,356 +40,7 @@ export type DnsResolutionOrder = 'ipv4first' | 'verbatim'; * `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, - }, - hideFooter: { - type: 'boolean', - label: 'Hide Footer', - category: 'UI', - requiresRestart: false, - default: false, - description: 'Hide the footer from the UI', - 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: true, - description: 'Enable collection of usage statistics', - showInDialog: false, // All details are shown in /privacy and dependent on auth type - }, - 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: -1, - description: - 'Maximum number of user/model/tool turns to keep in a session. -1 means unlimited.', - showInDialog: true, - }, - 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: 200, - description: 'Maximum number of directories to search for memory.', - showInDialog: true, - }, - 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, - }, - screenReader: { - type: 'boolean', - label: 'Screen Reader Mode', - category: 'Accessibility', - requiresRestart: true, - default: false, - description: - 'Render output in plain-text to be more screen reader accessible', - 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, - }, - disableFuzzySearch: { - type: 'boolean', - label: 'Disable Fuzzy Search', - category: 'File Filtering', - requiresRestart: true, - default: false, - description: 'Disable fuzzy search when searching for files.', - showInDialog: true, - }, - }, - }, - - disableAutoUpdate: { - type: 'boolean', - label: 'Disable Auto Update', - category: 'Updates', - requiresRestart: false, - default: false, - description: 'Disable automatic updates', - showInDialog: true, - }, - - shouldUseNodePtyShell: { - type: 'boolean', - label: 'Use node-pty for Shell Execution', - category: 'Shell', - requiresRestart: true, - default: false, - description: - 'Use node-pty for shell command execution. Fallback to child_process still applies.', - 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, - }, - allowedTools: { - type: 'array', - label: 'Allowed Tools', - category: 'Advanced', - requiresRestart: true, - default: undefined as string[] | undefined, - description: - 'A list of tool names that will bypass the confirmation dialog.', - 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, - }, + // Maintained for compatibility/criticality mcpServers: { type: 'object', label: 'MCP Servers', @@ -399,24 +50,259 @@ export const SETTINGS_SCHEMA = { 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.', + + general: { + type: 'object', + label: 'General', + category: 'General', + requiresRestart: false, + default: {}, + description: 'General application settings.', showInDialog: false, + properties: { + 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, + }, + vimMode: { + type: 'boolean', + label: 'Vim Mode', + category: 'General', + requiresRestart: false, + default: false, + description: 'Enable Vim keybindings', + showInDialog: true, + }, + disableAutoUpdate: { + type: 'boolean', + label: 'Disable Auto Update', + category: 'General', + requiresRestart: false, + default: false, + description: 'Disable automatic updates', + showInDialog: true, + }, + disableUpdateNag: { + type: 'boolean', + label: 'Disable Update Nag', + category: 'General', + requiresRestart: false, + default: false, + description: 'Disable update notification prompts.', + showInDialog: false, + }, + checkpointing: { + type: 'object', + label: 'Checkpointing', + category: 'General', + requiresRestart: true, + default: {}, + description: 'Session checkpointing settings.', + showInDialog: false, + properties: { + enabled: { + type: 'boolean', + label: 'Enable Checkpointing', + category: 'General', + requiresRestart: true, + default: false, + description: 'Enable session checkpointing for recovery', + showInDialog: false, + }, + }, + }, + enablePromptCompletion: { + type: 'boolean', + label: 'Enable Prompt Completion', + category: 'General', + requiresRestart: true, + default: false, + description: + 'Enable AI-powered prompt completion suggestions while typing.', + showInDialog: true, + }, + debugKeystrokeLogging: { + type: 'boolean', + label: 'Debug Keystroke Logging', + category: 'General', + requiresRestart: false, + default: false, + description: 'Enable debug logging of keystrokes to the console.', + showInDialog: true, + }, + }, }, - excludeMCPServers: { - type: 'array', - label: 'Exclude MCP Servers', - category: 'Advanced', - requiresRestart: true, - default: undefined as string[] | undefined, - description: 'A blacklist of MCP servers to exclude.', + + ui: { + type: 'object', + label: 'UI', + category: 'UI', + requiresRestart: false, + default: {}, + description: 'User interface settings.', showInDialog: false, + properties: { + theme: { + type: 'string', + label: 'Theme', + category: 'UI', + requiresRestart: false, + default: undefined as string | undefined, + description: 'The color theme for the UI.', + showInDialog: false, + }, + 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, + }, + hideFooter: { + type: 'boolean', + label: 'Hide Footer', + category: 'UI', + requiresRestart: false, + default: false, + description: 'Hide the footer from the UI', + 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, + }, + showLineNumbers: { + type: 'boolean', + label: 'Show Line Numbers', + category: 'UI', + requiresRestart: false, + default: false, + description: 'Show line numbers in the chat.', + showInDialog: true, + }, + accessibility: { + type: 'object', + label: 'Accessibility', + category: 'UI', + requiresRestart: true, + default: {}, + description: 'Accessibility settings.', + showInDialog: false, + properties: { + disableLoadingPhrases: { + type: 'boolean', + label: 'Disable Loading Phrases', + category: 'UI', + requiresRestart: true, + default: false, + description: 'Disable loading phrases for accessibility', + showInDialog: true, + }, + screenReader: { + type: 'boolean', + label: 'Screen Reader Mode', + category: 'UI', + requiresRestart: true, + default: false, + description: + 'Render output in plain-text to be more screen reader accessible', + showInDialog: true, + }, + }, + }, + }, }, + + ide: { + type: 'object', + label: 'IDE', + category: 'IDE', + requiresRestart: true, + default: {}, + description: 'IDE integration settings.', + showInDialog: false, + properties: { + enabled: { + type: 'boolean', + label: 'IDE Mode', + category: 'IDE', + requiresRestart: true, + default: false, + description: 'Enable IDE integration mode', + showInDialog: true, + }, + hasSeenNudge: { + type: 'boolean', + label: 'Has Seen IDE Integration Nudge', + category: 'IDE', + requiresRestart: false, + default: false, + description: 'Whether the user has seen the IDE integration nudge.', + showInDialog: false, + }, + }, + }, + + privacy: { + type: 'object', + label: 'Privacy', + category: 'Privacy', + requiresRestart: true, + default: {}, + description: 'Privacy-related settings.', + showInDialog: false, + properties: { + usageStatisticsEnabled: { + type: 'boolean', + label: 'Enable Usage Statistics', + category: 'Privacy', + requiresRestart: true, + default: true, + description: 'Enable collection of usage statistics', + showInDialog: false, + }, + }, + }, + telemetry: { type: 'object', label: 'Telemetry', @@ -426,134 +312,438 @@ export const SETTINGS_SCHEMA = { description: 'Telemetry configuration.', showInDialog: false, }, - bugCommand: { + + model: { type: 'object', - label: 'Bug Command', - category: 'Advanced', + label: 'Model', + category: 'Model', 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.', + default: {}, + description: 'Settings related to the generative model.', showInDialog: false, + properties: { + name: { + type: 'string', + label: 'Model', + category: 'Model', + requiresRestart: false, + default: undefined as string | undefined, + description: 'The Gemini model to use for conversations.', + showInDialog: false, + }, + maxSessionTurns: { + type: 'number', + label: 'Max Session Turns', + category: 'Model', + requiresRestart: false, + default: -1, + description: + 'Maximum number of user/model/tool turns to keep in a session. -1 means unlimited.', + showInDialog: true, + }, + summarizeToolOutput: { + type: 'object', + label: 'Summarize Tool Output', + category: 'Model', + requiresRestart: false, + default: undefined as + | Record + | undefined, + description: 'Settings for summarizing tool output.', + showInDialog: false, + }, + chatCompression: { + type: 'object', + label: 'Chat Compression', + category: 'Model', + requiresRestart: false, + default: undefined as ChatCompressionSettings | undefined, + description: 'Chat compression settings.', + showInDialog: false, + }, + skipNextSpeakerCheck: { + type: 'boolean', + label: 'Skip Next Speaker Check', + category: 'Model', + requiresRestart: false, + default: false, + description: 'Skip the next speaker check.', + showInDialog: true, + }, + }, }, - 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. Missing directories will be skipped with a warning.', - 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: { + context: { type: 'object', - label: 'Chat Compression', - category: 'General', + label: 'Context', + category: 'Context', requiresRestart: false, - default: undefined as ChatCompressionSettings | undefined, - description: 'Chat compression settings.', + default: {}, + description: 'Settings for managing context provided to the model.', showInDialog: false, + properties: { + fileName: { + type: 'object', + label: 'Context File Name', + category: 'Context', + requiresRestart: false, + default: undefined as string | string[] | undefined, + description: 'The name of the context file.', + showInDialog: false, + }, + importFormat: { + type: 'string', + label: 'Memory Import Format', + category: 'Context', + requiresRestart: false, + default: undefined as MemoryImportFormat | undefined, + description: 'The format to use when importing memory.', + showInDialog: false, + }, + discoveryMaxDirs: { + type: 'number', + label: 'Memory Discovery Max Dirs', + category: 'Context', + requiresRestart: false, + default: 200, + description: 'Maximum number of directories to search for memory.', + showInDialog: true, + }, + includeDirectories: { + type: 'array', + label: 'Include Directories', + category: 'Context', + requiresRestart: false, + default: [] as string[], + description: + 'Additional directories to include in the workspace context. Missing directories will be skipped with a warning.', + showInDialog: false, + }, + loadMemoryFromIncludeDirectories: { + type: 'boolean', + label: 'Load Memory From Include Directories', + category: 'Context', + requiresRestart: false, + default: false, + description: 'Whether to load memory files from include directories.', + showInDialog: true, + }, + fileFiltering: { + type: 'object', + label: 'File Filtering', + category: 'Context', + requiresRestart: true, + default: {}, + description: 'Settings for git-aware file filtering.', + showInDialog: false, + properties: { + respectGitIgnore: { + type: 'boolean', + label: 'Respect .gitignore', + category: 'Context', + requiresRestart: true, + default: true, + description: 'Respect .gitignore files when searching', + showInDialog: true, + }, + respectGeminiIgnore: { + type: 'boolean', + label: 'Respect .geminiignore', + category: 'Context', + requiresRestart: true, + default: true, + description: 'Respect .geminiignore files when searching', + showInDialog: true, + }, + enableRecursiveFileSearch: { + type: 'boolean', + label: 'Enable Recursive File Search', + category: 'Context', + requiresRestart: true, + default: true, + description: 'Enable recursive file search functionality', + showInDialog: true, + }, + disableFuzzySearch: { + type: 'boolean', + label: 'Disable Fuzzy Search', + category: 'Context', + requiresRestart: true, + default: false, + description: 'Disable fuzzy search when searching for files.', + showInDialog: true, + }, + }, + }, + }, }, - showLineNumbers: { - type: 'boolean', - label: 'Show Line Numbers', - category: 'General', - requiresRestart: false, - default: false, - description: 'Show line numbers in the chat.', - showInDialog: true, - }, - extensionManagement: { - type: 'boolean', - label: 'Extension Management', - category: 'Feature Flag', + + tools: { + type: 'object', + label: 'Tools', + category: 'Tools', requiresRestart: true, - default: false, - description: 'Enable extension management features.', + default: {}, + description: 'Settings for built-in and custom tools.', showInDialog: false, + properties: { + sandbox: { + type: 'object', + label: 'Sandbox', + category: 'Tools', + requiresRestart: true, + default: undefined as boolean | string | undefined, + description: + 'Sandbox execution environment (can be a boolean or a path string).', + showInDialog: false, + }, + usePty: { + type: 'boolean', + label: 'Use node-pty for Shell Execution', + category: 'Tools', + requiresRestart: true, + default: false, + description: + 'Use node-pty for shell command execution. Fallback to child_process still applies.', + showInDialog: true, + }, + core: { + type: 'array', + label: 'Core Tools', + category: 'Tools', + requiresRestart: true, + default: undefined as string[] | undefined, + description: 'Paths to core tool definitions.', + showInDialog: false, + }, + allowed: { + type: 'array', + label: 'Allowed Tools', + category: 'Advanced', + requiresRestart: true, + default: undefined as string[] | undefined, + description: + 'A list of tool names that will bypass the confirmation dialog.', + showInDialog: false, + }, + exclude: { + type: 'array', + label: 'Exclude Tools', + category: 'Tools', + requiresRestart: true, + default: undefined as string[] | undefined, + description: 'Tool names to exclude from discovery.', + showInDialog: false, + }, + discoveryCommand: { + type: 'string', + label: 'Tool Discovery Command', + category: 'Tools', + requiresRestart: true, + default: undefined as string | undefined, + description: 'Command to run for tool discovery.', + showInDialog: false, + }, + callCommand: { + type: 'string', + label: 'Tool Call Command', + category: 'Tools', + requiresRestart: true, + default: undefined as string | undefined, + description: 'Command to run for tool calls.', + showInDialog: false, + }, + useRipgrep: { + type: 'boolean', + label: 'Use Ripgrep', + category: 'Tools', + requiresRestart: false, + default: false, + description: + 'Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance.', + showInDialog: true, + }, + }, }, + + mcp: { + type: 'object', + label: 'MCP', + category: 'MCP', + requiresRestart: true, + default: {}, + description: 'Settings for Model Context Protocol (MCP) servers.', + showInDialog: false, + properties: { + serverCommand: { + type: 'string', + label: 'MCP Server Command', + category: 'MCP', + requiresRestart: true, + default: undefined as string | undefined, + description: 'Command to start an MCP server.', + showInDialog: false, + }, + allowed: { + type: 'array', + label: 'Allow MCP Servers', + category: 'MCP', + requiresRestart: true, + default: undefined as string[] | undefined, + description: 'A whitelist of MCP servers to allow.', + showInDialog: false, + }, + excluded: { + type: 'array', + label: 'Exclude MCP Servers', + category: 'MCP', + requiresRestart: true, + default: undefined as string[] | undefined, + description: 'A blacklist of MCP servers to exclude.', + showInDialog: false, + }, + }, + }, + + security: { + type: 'object', + label: 'Security', + category: 'Security', + requiresRestart: true, + default: {}, + description: 'Security-related settings.', + showInDialog: false, + properties: { + folderTrust: { + type: 'object', + label: 'Folder Trust', + category: 'Security', + requiresRestart: false, + default: {}, + description: 'Settings for folder trust.', + showInDialog: false, + properties: { + featureEnabled: { + type: 'boolean', + label: 'Folder Trust Feature', + category: 'Security', + requiresRestart: false, + default: false, + description: 'Enable folder trust feature for enhanced security.', + showInDialog: true, + }, + enabled: { + type: 'boolean', + label: 'Folder Trust', + category: 'Security', + requiresRestart: false, + default: false, + description: 'Setting to track whether Folder trust is enabled.', + showInDialog: true, + }, + }, + }, + auth: { + type: 'object', + label: 'Authentication', + category: 'Security', + requiresRestart: true, + default: {}, + description: 'Authentication settings.', + showInDialog: false, + properties: { + selectedType: { + type: 'string', + label: 'Selected Auth Type', + category: 'Security', + requiresRestart: true, + default: undefined as AuthType | undefined, + description: 'The currently selected authentication type.', + showInDialog: false, + }, + useExternal: { + type: 'boolean', + label: 'Use External Auth', + category: 'Security', + requiresRestart: true, + default: undefined as boolean | undefined, + description: 'Whether to use an external authentication flow.', + showInDialog: false, + }, + }, + }, + }, + }, + + advanced: { + type: 'object', + label: 'Advanced', + category: 'Advanced', + requiresRestart: true, + default: {}, + description: 'Advanced settings for power users.', + showInDialog: false, + properties: { + autoConfigureMemory: { + type: 'boolean', + label: 'Auto Configure Max Old Space Size', + category: 'Advanced', + requiresRestart: true, + default: false, + description: 'Automatically configure Node.js memory limits', + 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, + }, + excludedEnvVars: { + 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, + }, + bugCommand: { + type: 'object', + label: 'Bug Command', + category: 'Advanced', + requiresRestart: false, + default: undefined as BugCommandSettings | undefined, + description: 'Configuration for the bug report command.', + showInDialog: false, + }, + }, + }, + + experimental: { + type: 'object', + label: 'Experimental', + category: 'Experimental', + requiresRestart: true, + default: {}, + description: 'Setting to enable experimental features', + showInDialog: false, + properties: { + extensionManagement: { + type: 'boolean', + label: 'Extension Management', + category: 'Experimental', + requiresRestart: true, + default: false, + description: 'Enable extension management features.', + showInDialog: false, + }, + }, + }, + extensions: { type: 'object', label: 'Extensions', @@ -584,44 +774,6 @@ export const SETTINGS_SCHEMA = { }, }, }, - skipNextSpeakerCheck: { - type: 'boolean', - label: 'Skip Next Speaker Check', - category: 'General', - requiresRestart: false, - default: false, - description: 'Skip the next speaker check.', - showInDialog: true, - }, - useRipgrep: { - type: 'boolean', - label: 'Use Ripgrep', - category: 'Tools', - requiresRestart: false, - default: false, - description: - 'Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance.', - showInDialog: true, - }, - enablePromptCompletion: { - type: 'boolean', - label: 'Enable Prompt Completion', - category: 'General', - requiresRestart: true, - default: false, - description: - 'Enable AI-powered prompt completion suggestions while typing.', - showInDialog: true, - }, - debugKeystrokeLogging: { - type: 'boolean', - label: 'Debug Keystroke Logging', - category: 'General', - requiresRestart: false, - default: false, - description: 'Enable debug logging of keystrokes to the console.', - showInDialog: true, - }, } as const; type InferSettings = { diff --git a/packages/cli/src/config/trustedFolders.test.ts b/packages/cli/src/config/trustedFolders.test.ts index 6b2be226..b6583a83 100644 --- a/packages/cli/src/config/trustedFolders.test.ts +++ b/packages/cli/src/config/trustedFolders.test.ts @@ -132,8 +132,12 @@ describe('isWorkspaceTrusted', () => { let mockCwd: string; const mockRules: Record = {}; const mockSettings: Settings = { - folderTrustFeature: true, - folderTrust: true, + security: { + folderTrust: { + featureEnabled: true, + enabled: true, + }, + }, }; beforeEach(() => { diff --git a/packages/cli/src/config/trustedFolders.ts b/packages/cli/src/config/trustedFolders.ts index d7a82340..8763c769 100644 --- a/packages/cli/src/config/trustedFolders.ts +++ b/packages/cli/src/config/trustedFolders.ts @@ -111,8 +111,9 @@ export function saveTrustedFolders( } export function isWorkspaceTrusted(settings: Settings): boolean | undefined { - const folderTrustFeature = settings.folderTrustFeature ?? false; - const folderTrustSetting = settings.folderTrust ?? true; + const folderTrustFeature = + settings.security?.folderTrust?.featureEnabled ?? false; + const folderTrustSetting = settings.security?.folderTrust?.enabled ?? true; const folderTrustEnabled = folderTrustFeature && folderTrustSetting; if (!folderTrustEnabled) { diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 16a2d60f..5b7cd7ed 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -152,6 +152,7 @@ describe('gemini.tsx main function', () => { workspaceSettingsFile, [settingsError], true, + new Set(), ); loadSettingsMock.mockReturnValue(mockLoadedSettings); @@ -241,7 +242,9 @@ describe('startInteractiveUI', () => { } as Config; const mockSettings = { merged: { - hideWindowTitle: false, + ui: { + hideWindowTitle: false, + }, }, } as LoadedSettings; const mockStartupWarnings = ['warning1']; diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 4c3ecd1d..9f023a3d 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -199,7 +199,7 @@ export async function main() { registerCleanup(consolePatcher.cleanup); dns.setDefaultResultOrder( - validateDnsResolutionOrder(settings.merged.dnsResolutionOrder), + validateDnsResolutionOrder(settings.merged.advanced?.dnsResolutionOrder), ); if (argv.promptInteractive && !process.stdin.isTTY) { @@ -218,7 +218,7 @@ export async function main() { } // Set a default auth type if one isn't set. - if (!settings.merged.selectedAuthType) { + if (!settings.merged.security?.auth?.selectedType) { if (process.env['CLOUD_SHELL'] === 'true') { settings.setValue( SettingScope.User, @@ -246,34 +246,36 @@ export async function main() { } // Load custom themes from settings - themeManager.loadCustomThemes(settings.merged.customThemes); + themeManager.loadCustomThemes(settings.merged.ui?.customThemes); - if (settings.merged.theme) { - if (!themeManager.setActiveTheme(settings.merged.theme)) { + if (settings.merged.ui?.theme) { + if (!themeManager.setActiveTheme(settings.merged.ui?.theme)) { // If the theme is not found during initial load, log a warning and continue. // The useThemeCommand hook in App.tsx will handle opening the dialog. - console.warn(`Warning: Theme "${settings.merged.theme}" not found.`); + console.warn(`Warning: Theme "${settings.merged.ui?.theme}" not found.`); } } // hop into sandbox if we are outside and sandboxing is enabled if (!process.env['SANDBOX']) { - const memoryArgs = settings.merged.autoConfigureMaxOldSpaceSize + const memoryArgs = settings.merged.advanced?.autoConfigureMemory ? getNodeMemoryArgs(config) : []; const sandboxConfig = config.getSandbox(); if (sandboxConfig) { if ( - settings.merged.selectedAuthType && - !settings.merged.useExternalAuth + settings.merged.security?.auth?.selectedType && + !settings.merged.security?.auth?.useExternal ) { // Validate authentication here because the sandbox will interfere with the Oauth2 web redirect. try { - const err = validateAuthMethod(settings.merged.selectedAuthType); + const err = validateAuthMethod( + settings.merged.security.auth.selectedType, + ); if (err) { throw new Error(err); } - await config.refreshAuth(settings.merged.selectedAuthType); + await config.refreshAuth(settings.merged.security.auth.selectedType); } catch (err) { console.error('Error authenticating:', err); process.exit(1); @@ -322,11 +324,12 @@ export async function main() { } if ( - settings.merged.selectedAuthType === AuthType.LOGIN_WITH_GOOGLE && + settings.merged.security?.auth?.selectedType === + AuthType.LOGIN_WITH_GOOGLE && config.isBrowserLaunchSuppressed() ) { // Do oauth before app renders to make copying the link possible. - await getOauthClient(settings.merged.selectedAuthType, config); + await getOauthClient(settings.merged.security.auth.selectedType, config); } if (config.getExperimentalZedIntegration()) { @@ -370,8 +373,8 @@ export async function main() { }); const nonInteractiveConfig = await validateNonInteractiveAuth( - settings.merged.selectedAuthType, - settings.merged.useExternalAuth, + settings.merged.security?.auth?.selectedType, + settings.merged.security?.auth?.useExternal, config, ); @@ -384,7 +387,7 @@ export async function main() { } function setWindowTitle(title: string, settings: LoadedSettings) { - if (!settings.merged.hideWindowTitle) { + if (!settings.merged.ui?.hideWindowTitle) { const windowTitle = ( process.env['CLI_TITLE'] || `Gemini - ${title}` ).replace( diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index e4dc1d46..adf3aaab 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -313,6 +313,7 @@ describe('App UI', () => { workspaceSettingsFile, [], true, + new Set(), ); }; @@ -684,7 +685,10 @@ describe('App UI', () => { it('should display custom contextFileName in footer when set and count is 1', async () => { mockSettings = createMockSettings({ - workspace: { contextFileName: 'AGENTS.md', theme: 'Default' }, + workspace: { + context: { fileName: 'AGENTS.md' }, + ui: { theme: 'Default' }, + }, }); mockConfig.getGeminiMdFileCount.mockReturnValue(1); mockConfig.getAllGeminiMdFilenames.mockReturnValue(['AGENTS.md']); @@ -706,8 +710,8 @@ describe('App UI', () => { it('should display a generic message when multiple context files with different names are provided', async () => { mockSettings = createMockSettings({ workspace: { - contextFileName: ['AGENTS.md', 'CONTEXT.md'], - theme: 'Default', + context: { fileName: ['AGENTS.md', 'CONTEXT.md'] }, + ui: { theme: 'Default' }, }, }); mockConfig.getGeminiMdFileCount.mockReturnValue(2); @@ -732,7 +736,10 @@ describe('App UI', () => { it('should display custom contextFileName with plural when set and count is > 1', async () => { mockSettings = createMockSettings({ - workspace: { contextFileName: 'MY_NOTES.TXT', theme: 'Default' }, + workspace: { + context: { fileName: 'MY_NOTES.TXT' }, + ui: { theme: 'Default' }, + }, }); mockConfig.getGeminiMdFileCount.mockReturnValue(3); mockConfig.getAllGeminiMdFilenames.mockReturnValue([ @@ -757,7 +764,10 @@ describe('App UI', () => { it('should not display context file message if count is 0, even if contextFileName is set', async () => { mockSettings = createMockSettings({ - workspace: { contextFileName: 'ANY_FILE.MD', theme: 'Default' }, + workspace: { + context: { fileName: 'ANY_FILE.MD' }, + ui: { theme: 'Default' }, + }, }); mockConfig.getGeminiMdFileCount.mockReturnValue(0); mockConfig.getAllGeminiMdFilenames.mockReturnValue([]); @@ -838,7 +848,7 @@ describe('App UI', () => { it('should not display Tips component when hideTips is true', async () => { mockSettings = createMockSettings({ workspace: { - hideTips: true, + ui: { hideTips: true }, }, }); @@ -871,7 +881,7 @@ describe('App UI', () => { it('should not display Header component when hideBanner is true', async () => { const { Header } = await import('./components/Header.js'); mockSettings = createMockSettings({ - user: { hideBanner: true }, + user: { ui: { hideBanner: true } }, }); const { unmount } = renderWithProviders( @@ -902,7 +912,7 @@ describe('App UI', () => { it('should not display Footer component when hideFooter is true', async () => { mockSettings = createMockSettings({ - user: { hideFooter: true }, + user: { ui: { hideFooter: true } }, }); const { lastFrame, unmount } = renderWithProviders( @@ -920,9 +930,9 @@ describe('App UI', () => { it('should show footer if system says show, but workspace and user settings say hide', async () => { mockSettings = createMockSettings({ - system: { hideFooter: false }, - user: { hideFooter: true }, - workspace: { hideFooter: true }, + system: { ui: { hideFooter: false } }, + user: { ui: { hideFooter: true } }, + workspace: { ui: { hideFooter: true } }, }); const { lastFrame, unmount } = renderWithProviders( @@ -940,9 +950,9 @@ describe('App UI', () => { it('should show tips if system says show, but workspace and user settings say hide', async () => { mockSettings = createMockSettings({ - system: { hideTips: false }, - user: { hideTips: true }, - workspace: { hideTips: true }, + system: { ui: { hideTips: false } }, + user: { ui: { hideTips: true } }, + workspace: { ui: { hideTips: true } }, }); const { unmount } = renderWithProviders( @@ -1117,9 +1127,13 @@ describe('App UI', () => { const validateAuthMethodSpy = vi.spyOn(auth, 'validateAuthMethod'); mockSettings = createMockSettings({ workspace: { - selectedAuthType: 'USE_GEMINI' as AuthType, - useExternalAuth: false, - theme: 'Default', + security: { + auth: { + selectedType: 'USE_GEMINI' as AuthType, + useExternal: false, + }, + }, + ui: { theme: 'Default' }, }, }); @@ -1139,9 +1153,13 @@ describe('App UI', () => { const validateAuthMethodSpy = vi.spyOn(auth, 'validateAuthMethod'); mockSettings = createMockSettings({ workspace: { - selectedAuthType: 'USE_GEMINI' as AuthType, - useExternalAuth: true, - theme: 'Default', + security: { + auth: { + selectedType: 'USE_GEMINI' as AuthType, + useExternal: true, + }, + }, + ui: { theme: 'Default' }, }, }); @@ -1536,8 +1554,8 @@ describe('App UI', () => { it('should pass debugKeystrokeLogging setting to KeypressProvider', () => { const mockSettingsWithDebug = createMockSettings({ workspace: { - theme: 'Default', - debugKeystrokeLogging: true, + ui: { theme: 'Default' }, + advanced: { debugKeystrokeLogging: true }, }, }); @@ -1553,7 +1571,9 @@ describe('App UI', () => { const output = lastFrame(); expect(output).toBeDefined(); - expect(mockSettingsWithDebug.merged.debugKeystrokeLogging).toBe(true); + expect(mockSettingsWithDebug.merged.advanced?.debugKeystrokeLogging).toBe( + true, + ); }); it('should use default false value when debugKeystrokeLogging is not set', () => { @@ -1569,7 +1589,9 @@ describe('App UI', () => { const output = lastFrame(); expect(output).toBeDefined(); - expect(mockSettings.merged.debugKeystrokeLogging).toBeUndefined(); + expect( + mockSettings.merged.advanced?.debugKeystrokeLogging, + ).toBeUndefined(); }); }); diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 27883ceb..8a40c7ee 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -134,7 +134,9 @@ export const AppWrapper = (props: AppProps) => { @@ -161,7 +163,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { const shouldShowIdePrompt = currentIDE && !config.getIdeMode() && - !settings.merged.hasSeenIdeIntegrationNudge && + !settings.merged.ide?.hasSeenNudge && !idePromptAnswered; useEffect(() => { @@ -301,16 +303,21 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { } = useAuthCommand(settings, setAuthError, config); useEffect(() => { - if (settings.merged.selectedAuthType && !settings.merged.useExternalAuth) { - const error = validateAuthMethod(settings.merged.selectedAuthType); + if ( + settings.merged.security?.auth?.selectedType && + !settings.merged.security?.auth?.useExternal + ) { + const error = validateAuthMethod( + settings.merged.security.auth.selectedType, + ); if (error) { setAuthError(error); openAuthDialog(); } } }, [ - settings.merged.selectedAuthType, - settings.merged.useExternalAuth, + settings.merged.security?.auth?.selectedType, + settings.merged.security?.auth?.useExternal, openAuthDialog, setAuthError, ]); @@ -345,14 +352,14 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { try { const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory( process.cwd(), - settings.merged.loadMemoryFromIncludeDirectories + settings.merged.context?.loadMemoryFromIncludeDirectories ? config.getWorkspaceContext().getDirectories() : [], config.getDebugMode(), config.getFileService(), settings.merged, config.getExtensionContextFilePaths(), - settings.merged.memoryImportFormat || 'tree', // Use setting or default to 'tree' + settings.merged.context?.importFormat || 'tree', // Use setting or default to 'tree' config.getFileFilteringOptions(), ); @@ -510,7 +517,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { }, []); const getPreferredEditor = useCallback(() => { - const editorType = settings.merged.preferredEditor; + const editorType = settings.merged.general?.preferredEditor; const isValidEditor = isEditorAvailable(editorType); if (!isValidEditor) { openEditorDialog(); @@ -701,7 +708,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { const handleGlobalKeypress = useCallback( (key: Key) => { // Debug log keystrokes if enabled - if (settings.merged.debugKeystrokeLogging) { + if (settings.merged.general?.debugKeystrokeLogging) { console.log('[DEBUG] Keystroke:', JSON.stringify(key)); } @@ -768,7 +775,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { handleSlashCommand, isAuthenticating, cancelOngoingRequest, - settings.merged.debugKeystrokeLogging, + settings.merged.general?.debugKeystrokeLogging, ], ); @@ -884,12 +891,12 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { const branchName = useGitBranchName(config.getTargetDir()); const contextFileNames = useMemo(() => { - const fromSettings = settings.merged.contextFileName; + const fromSettings = settings.merged.context?.fileName; if (fromSettings) { return Array.isArray(fromSettings) ? fromSettings : [fromSettings]; } return getAllGeminiMdFilenames(); - }, [settings.merged.contextFileName]); + }, [settings.merged.context?.fileName]); const initialPrompt = useMemo(() => config.getQuestion(), [config]); const geminiClient = config.getGeminiClient(); @@ -965,10 +972,10 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { key={staticKey} items={[ - {!(settings.merged.hideBanner || config.getScreenReader()) && ( -
- )} - {!(settings.merged.hideTips || config.getScreenReader()) && ( + {!( + settings.merged.ui?.hideBanner || config.getScreenReader() + ) &&
} + {!(settings.merged.ui?.hideTips || config.getScreenReader()) && ( )} , @@ -1300,7 +1307,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { )} )} - {!settings.merged.hideFooter && ( + {!settings.merged.ui?.hideFooter && (