diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 4f48c696b4..ffd590b877 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -97,6 +97,8 @@ export interface CliArgs { rawOutput: boolean | undefined; acceptRawOutputRisk: boolean | undefined; isCommand: boolean | undefined; + themeMode: 'light' | 'dark' | undefined; + theme: string | undefined; } export async function parseArguments( @@ -128,6 +130,15 @@ export async function parseArguments( nargs: 1, description: `Model`, }) + .option('theme-mode', { + type: 'string', + choices: ['light', 'dark'], + description: 'Force the theme detection mode to light or dark', + }) + .option('theme', { + type: 'string', + description: 'Force a specific theme by name', + }) .option('prompt', { alias: 'p', type: 'string', @@ -880,6 +891,8 @@ export async function loadCliConfig( hooks: settings.hooks || {}, disabledHooks: settings.hooksConfig?.disabled || [], projectHooks: projectHooks || {}, + cliTheme: argv.theme, + cliThemeMode: argv.themeMode, onModelChange: (model: string) => saveModelChange(loadedSettings, model), onReload: async () => { const refreshedSettings = loadSettings(cwd); diff --git a/packages/cli/src/config/settings-validation.test.ts b/packages/cli/src/config/settings-validation.test.ts index baf9b5bbdb..605696503a 100644 --- a/packages/cli/src/config/settings-validation.test.ts +++ b/packages/cli/src/config/settings-validation.test.ts @@ -23,7 +23,8 @@ describe('settings-validation', () => { maxSessionTurns: 10, }, ui: { - theme: 'dark', + themeLight: 'dark', + themeDark: 'dark', }, }; @@ -115,7 +116,8 @@ describe('settings-validation', () => { it('should accept nested valid settings', () => { const validSettings = { ui: { - theme: 'dark', + themeLight: 'dark', + themeDark: 'dark', hideWindowTitle: true, footer: { hideCWD: false, diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 8fd0bd81b0..5d02c97cde 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -202,7 +202,7 @@ describe('Settings Loading and Merging', () => { scope: 'system', path: getSystemSettingsPath(), content: { - ui: { theme: 'system-default' }, + ui: { themeLight: 'system-default', themeDark: 'system-default' }, tools: { sandbox: false }, }, }, @@ -210,7 +210,7 @@ describe('Settings Loading and Merging', () => { scope: 'user', path: USER_SETTINGS_PATH, content: { - ui: { theme: 'dark' }, + ui: { themeLight: 'dark', themeDark: 'dark' }, context: { fileName: 'USER_CONTEXT.md' }, }, }, @@ -238,10 +238,16 @@ describe('Settings Loading and Merging', () => { const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(fs.readFileSync).toHaveBeenCalledWith(path, 'utf-8'); + const expectedSettings = { ...content }; + // Account for migrateDeprecatedSettings + if (expectedSettings.ui) { + expectedSettings.ui.themeLight = content.ui?.themeLight; + expectedSettings.ui.themeDark = content.ui?.themeLight; + } expect( settings[scope as 'system' | 'user' | 'workspace'].settings, - ).toEqual(content); - expect(settings.merged).toMatchObject(content); + ).toEqual(expectedSettings); + expect(settings.merged).toMatchObject(expectedSettings); }, ); @@ -254,7 +260,8 @@ describe('Settings Loading and Merging', () => { ); const systemSettingsContent = { ui: { - theme: 'system-theme', + themeLight: 'system-theme', + themeDark: 'system-theme', }, tools: { sandbox: false, @@ -266,7 +273,8 @@ describe('Settings Loading and Merging', () => { }; const userSettingsContent = { ui: { - theme: 'dark', + themeLight: 'dark', + themeDark: 'dark', }, tools: { sandbox: true, @@ -307,7 +315,8 @@ describe('Settings Loading and Merging', () => { expect(settings.workspace.settings).toEqual(workspaceSettingsContent); expect(settings.merged).toMatchObject({ ui: { - theme: 'system-theme', + themeLight: 'system-theme', + themeDark: 'system-theme', }, tools: { sandbox: false, @@ -350,7 +359,8 @@ describe('Settings Loading and Merging', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const systemDefaultsContent = { ui: { - theme: 'default-theme', + themeLight: 'default-theme', + themeDark: 'default-theme', }, tools: { sandbox: true, @@ -362,7 +372,8 @@ describe('Settings Loading and Merging', () => { }; const userSettingsContent = { ui: { - theme: 'user-theme', + themeLight: 'user-theme', + themeDark: 'user-theme', }, context: { fileName: 'USER_CONTEXT.md', @@ -380,7 +391,8 @@ describe('Settings Loading and Merging', () => { }; const systemSettingsContent = { ui: { - theme: 'system-theme', + themeLight: 'system-theme', + themeDark: 'system-theme', }, telemetry: false, context: { @@ -421,7 +433,10 @@ describe('Settings Loading and Merging', () => { fileName: 'WORKSPACE_CONTEXT.md', }, mcpServers: {}, - ui: { theme: 'system-theme' }, + ui: { + themeLight: 'system-theme', + themeDark: 'system-theme', + }, tools: { sandbox: false }, telemetry: false, }); @@ -645,7 +660,9 @@ describe('Settings Loading and Merging', () => { (p: fs.PathLike) => p === USER_SETTINGS_PATH || p === MOCK_WORKSPACE_SETTINGS_PATH, ); - const userSettingsContent = { ui: { theme: 'dark' } }; + const userSettingsContent = { + ui: { themeLight: 'dark', themeDark: 'dark' }, + }; const workspaceSettingsContent = { tools: { sandbox: true } }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { @@ -1187,14 +1204,16 @@ describe('Settings Loading and Merging', () => { configValue: '$SHARED_VAR', userOnly: '$USER_VAR', ui: { - theme: 'dark', + themeLight: 'dark', + themeDark: 'dark', }, }; const workspaceSettingsContent: TestSettings = { configValue: '$SHARED_VAR', workspaceOnly: '$WORKSPACE_VAR', ui: { - theme: 'light', + themeLight: 'light', + themeDark: 'light', }, }; @@ -1247,7 +1266,7 @@ describe('Settings Loading and Merging', () => { expect((settings.merged as TestSettings)['workspaceOnly']).toBe( 'workspace_value', ); - expect(settings.merged.ui?.theme).toBe('light'); // workspace overrides user + expect(settings.merged.ui?.themeLight).toBe('light'); // workspace overrides user delete process.env['SYSTEM_VAR']; delete process.env['USER_VAR']; @@ -1470,7 +1489,10 @@ describe('Settings Loading and Merging', () => { (p: fs.PathLike) => p === MOCK_ENV_SYSTEM_SETTINGS_PATH, ); const systemSettingsContent = { - ui: { theme: 'env-var-theme' }, + ui: { + themeLight: 'env-var-theme', + themeDark: 'env-var-theme', + }, tools: { sandbox: true }, }; (fs.readFileSync as Mock).mockImplementation( @@ -1692,7 +1714,7 @@ describe('Settings Loading and Merging', () => { it('should merge workspace settings when workspace is trusted', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const userSettingsContent = { - ui: { theme: 'dark' }, + ui: { themeLight: 'dark', themeDark: 'dark' }, tools: { sandbox: false }, }; const workspaceSettingsContent = { @@ -1713,7 +1735,7 @@ describe('Settings Loading and Merging', () => { const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(settings.merged.tools?.sandbox).toBe(true); expect(settings.merged.context?.fileName).toBe('WORKSPACE.md'); - expect(settings.merged.ui?.theme).toBe('dark'); + expect(settings.merged.ui?.themeLight).toBe('dark'); }); it('should NOT merge workspace settings when workspace is not trusted', () => { @@ -1723,7 +1745,7 @@ describe('Settings Loading and Merging', () => { }); (mockFsExistsSync as Mock).mockReturnValue(true); const userSettingsContent = { - ui: { theme: 'dark' }, + ui: { themeLight: 'dark', themeDark: 'dark' }, tools: { sandbox: false }, context: { fileName: 'USER.md' }, }; @@ -1746,7 +1768,7 @@ describe('Settings Loading and Merging', () => { 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 + expect(settings.merged.ui?.themeLight).toBe('dark'); // User setting }); it('should NOT merge workspace settings when workspace trust is undefined', () => { @@ -1756,7 +1778,7 @@ describe('Settings Loading and Merging', () => { }); (mockFsExistsSync as Mock).mockReturnValue(true); const userSettingsContent = { - ui: { theme: 'dark' }, + ui: { themeLight: 'dark', themeDark: 'dark' }, tools: { sandbox: false }, context: { fileName: 'USER.md' }, }; @@ -1805,7 +1827,8 @@ describe('Settings Loading and Merging', () => { }); const userSettingsContent: Settings = { ui: { - theme: 'dark', + themeLight: 'dark', + themeDark: 'dark', }, security: { folderTrust: { @@ -2414,13 +2437,15 @@ describe('Settings Loading and Merging', () => { describe('saveSettings', () => { it('should save settings using updateSettingsFilePreservingFormat', () => { const mockUpdateSettings = vi.mocked(updateSettingsFilePreservingFormat); - const settingsFile = createMockSettings({ ui: { theme: 'dark' } }).user; + const settingsFile = createMockSettings({ + ui: { themeLight: 'dark', themeDark: 'dark' }, + }).user; settingsFile.path = '/mock/settings.json'; saveSettings(settingsFile); expect(mockUpdateSettings).toHaveBeenCalledWith('/mock/settings.json', { - ui: { theme: 'dark' }, + ui: { themeLight: 'dark', themeDark: 'dark' }, }); }); @@ -2471,7 +2496,7 @@ describe('Settings Loading and Merging', () => { extensions: { enabled: false }, }, // A non-admin setting to ensure it's still processed - ui: { theme: 'system-theme' }, + ui: { themeLight: 'system-theme', themeDark: 'system-theme' }, }; (fs.readFileSync as Mock).mockImplementation( @@ -2490,7 +2515,7 @@ describe('Settings Loading and Merging', () => { expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false); // default: false expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true); // default: true expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true); // default: true - expect(loadedSettings.merged.ui?.theme).toBe('system-theme'); // non-admin setting should be loaded + expect(loadedSettings.merged.ui?.themeLight).toBe('system-theme'); // non-admin setting should be loaded // 2. Now, set remote admin settings. loadedSettings.setRemoteAdminSettings({ @@ -2507,7 +2532,7 @@ describe('Settings Loading and Merging', () => { expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false); expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false); // non-admin setting should remain unchanged - expect(loadedSettings.merged.ui?.theme).toBe('system-theme'); + expect(loadedSettings.merged.ui?.themeLight).toBe('system-theme'); }); it('should set remote admin settings and recompute merged settings', () => { @@ -2518,7 +2543,7 @@ describe('Settings Loading and Merging', () => { mcp: { enabled: false }, extensions: { enabled: false }, }, - ui: { theme: 'initial-theme' }, + ui: { themeLight: 'initial-theme', themeDark: 'initial-theme' }, }; (fs.readFileSync as Mock).mockImplementation( @@ -2535,7 +2560,7 @@ describe('Settings Loading and Merging', () => { expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false); expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true); expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true); - expect(loadedSettings.merged.ui?.theme).toBe('initial-theme'); + expect(loadedSettings.merged.ui?.themeLight).toBe('initial-theme'); const newRemoteSettings = { strictModeDisabled: false, @@ -2553,13 +2578,13 @@ describe('Settings Loading and Merging', () => { expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false); expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false); // Non-admin settings should remain untouched - expect(loadedSettings.merged.ui?.theme).toBe('initial-theme'); + expect(loadedSettings.merged.ui?.themeLight).toBe('initial-theme'); }); it('should correctly handle undefined remote admin settings', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const systemSettingsContent = { - ui: { theme: 'initial-theme' }, + ui: { themeLight: 'initial-theme', themeDark: 'initial-theme' }, }; (fs.readFileSync as Mock).mockImplementation( @@ -2704,13 +2729,17 @@ describe('Settings Loading and Merging', () => { const oldSnapshot = loadedSettings.getSnapshot(); const oldUserRef = oldSnapshot.user.settings; - loadedSettings.setValue(SettingScope.User, 'ui.theme', 'high-contrast'); + loadedSettings.setValue( + SettingScope.User, + 'ui.themeLight', + 'high-contrast', + ); const newSnapshot = loadedSettings.getSnapshot(); expect(newSnapshot).not.toBe(oldSnapshot); expect(newSnapshot.user.settings).not.toBe(oldUserRef); - expect(newSnapshot.user.settings.ui?.theme).toBe('high-contrast'); + expect(newSnapshot.user.settings.ui?.themeLight).toBe('high-contrast'); expect(newSnapshot.system.settings).not.toBe(oldSnapshot.system.settings); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 4e9faf5767..f0d7f96e98 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -707,15 +707,17 @@ export function loadSettings( workspaceSettings = resolveEnvVarsInObject(workspaceResult.settings); // 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 (userSettings.ui?.themeLight === 'VS') { + userSettings.ui.themeLight = DefaultLight.name; } - if (workspaceSettings.ui?.theme === 'VS') { - workspaceSettings.ui.theme = DefaultLight.name; - } else if (workspaceSettings.ui?.theme === 'VS2015') { - workspaceSettings.ui.theme = DefaultDark.name; + if (userSettings.ui?.themeDark === 'VS2015') { + userSettings.ui.themeDark = DefaultDark.name; + } + if (workspaceSettings.ui?.themeLight === 'VS') { + workspaceSettings.ui.themeLight = DefaultLight.name; + } + if (workspaceSettings.ui?.themeDark === 'VS2015') { + workspaceSettings.ui.themeDark = DefaultDark.name; } // For the initial trust check, we can only use user and system settings. @@ -886,6 +888,23 @@ export function migrateDeprecatedSettings( const uiSettings = settings.ui as Record | undefined; if (uiSettings) { const newUi = { ...uiSettings }; + let uiModified = false; + + // Migrate ui.theme to themeLight and themeDark + if (newUi['theme'] !== undefined) { + foundDeprecated.push('ui.theme'); + if (newUi['themeLight'] === undefined) { + newUi['themeLight'] = newUi['theme']; + } + if (newUi['themeDark'] === undefined) { + newUi['themeDark'] = newUi['theme']; + } + if (removeDeprecated) { + delete newUi['theme']; + } + uiModified = true; + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const accessibilitySettings = newUi['accessibility'] as | Record @@ -903,10 +922,7 @@ export function migrateDeprecatedSettings( ) ) { newUi['accessibility'] = newAccessibility; - loadedSettings.setValue(scope, 'ui', newUi); - if (!settingsFile.readOnly) { - anyModified = true; - } + uiModified = true; } // Migrate enableLoadingPhrases: false → loadingPhrases: 'off' @@ -917,14 +933,18 @@ export function migrateDeprecatedSettings( ) { if (!enableLP) { newUi['loadingPhrases'] = 'off'; - loadedSettings.setValue(scope, 'ui', newUi); - if (!settingsFile.readOnly) { - anyModified = true; - } + uiModified = true; } foundDeprecated.push('ui.accessibility.enableLoadingPhrases'); } } + + if (uiModified) { + loadedSettings.setValue(scope, 'ui', newUi); + if (!settingsFile.readOnly) { + anyModified = true; + } + } } // Migrate context settings diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 17a916213f..35dfdd1c35 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -239,7 +239,9 @@ describe('SettingsSchema', () => { expect(getSettingsSchema().telemetry.showInDialog).toBe(false); // Check that some settings are appropriately hidden - expect(getSettingsSchema().ui.properties.theme.showInDialog).toBe(false); // Changed to false + expect(getSettingsSchema().ui.properties.themeLight.showInDialog).toBe( + false, + ); // Changed to false expect(getSettingsSchema().ui.properties.customThemes.showInDialog).toBe( false, ); // Managed via theme editor @@ -265,7 +267,8 @@ describe('SettingsSchema', () => { // This test ensures that the Settings type is properly inferred from the schema const settings: Settings = { ui: { - theme: 'dark', + themeLight: 'dark', + themeDark: 'dark', }, context: { includeDirectories: ['/path/to/dir'], @@ -274,7 +277,7 @@ describe('SettingsSchema', () => { }; // TypeScript should not complain about these properties - expect(settings.ui?.theme).toBe('dark'); + expect(settings.ui?.themeLight).toBe('dark'); expect(settings.context?.includeDirectories).toEqual(['/path/to/dir']); expect(settings.context?.loadMemoryFromIncludeDirectories).toBe(true); }); diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 8c0d13e2dd..68fc2c33bd 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -415,14 +415,24 @@ const SETTINGS_SCHEMA = { description: 'User interface settings.', showInDialog: false, properties: { - theme: { + themeLight: { type: 'string', - label: 'Theme', + label: 'Light Theme', category: 'UI', requiresRestart: false, default: undefined as string | undefined, description: - 'The color theme for the UI. See the CLI themes guide for available options.', + 'The color theme for the UI when in light mode. See the CLI themes guide for available options.', + showInDialog: false, + }, + themeDark: { + type: 'string', + label: 'Dark Theme', + category: 'UI', + requiresRestart: false, + default: undefined as string | undefined, + description: + 'The color theme for the UI when in dark mode. See the CLI themes guide for available options.', showInDialog: false, }, autoThemeSwitching: { diff --git a/packages/cli/src/core/theme.test.ts b/packages/cli/src/core/theme.test.ts index eb87a9ee10..3d068fd481 100644 --- a/packages/cli/src/core/theme.test.ts +++ b/packages/cli/src/core/theme.test.ts @@ -23,30 +23,46 @@ describe('theme', () => { mockSettings = { merged: { ui: { - theme: 'test-theme', + themeLight: 'test-light-theme', + themeDark: 'test-dark-theme', }, }, } as unknown as LoadedSettings; }); - it('should return null if theme is found', () => { + it('should return null if themes are found', () => { vi.mocked(themeManager.findThemeByName).mockReturnValue( {} as unknown as ReturnType, ); const result = validateTheme(mockSettings); expect(result).toBeNull(); - expect(themeManager.findThemeByName).toHaveBeenCalledWith('test-theme'); + expect(themeManager.findThemeByName).toHaveBeenCalledWith( + 'test-light-theme', + ); + expect(themeManager.findThemeByName).toHaveBeenCalledWith( + 'test-dark-theme', + ); }); - it('should return error message if theme is not found', () => { - vi.mocked(themeManager.findThemeByName).mockReturnValue(undefined); + it('should return error message if light theme is not found', () => { + vi.mocked(themeManager.findThemeByName).mockImplementation((name) => name === 'test-dark-theme' + ? ({} as unknown as ReturnType) + : undefined); const result = validateTheme(mockSettings); - expect(result).toBe('Theme "test-theme" not found.'); - expect(themeManager.findThemeByName).toHaveBeenCalledWith('test-theme'); + expect(result).toBe('Theme "test-light-theme" not found.'); }); - it('should return null if theme is undefined', () => { - mockSettings.merged.ui.theme = undefined; + it('should return error message if dark theme is not found', () => { + vi.mocked(themeManager.findThemeByName).mockImplementation((name) => name === 'test-light-theme' + ? ({} as unknown as ReturnType) + : undefined); + const result = validateTheme(mockSettings); + expect(result).toBe('Theme "test-dark-theme" not found.'); + }); + + it('should return null if themes are undefined', () => { + mockSettings.merged.ui.themeLight = undefined; + mockSettings.merged.ui.themeDark = undefined; const result = validateTheme(mockSettings); expect(result).toBeNull(); expect(themeManager.findThemeByName).not.toHaveBeenCalled(); diff --git a/packages/cli/src/core/theme.ts b/packages/cli/src/core/theme.ts index f0f58fdbba..2417e3b901 100644 --- a/packages/cli/src/core/theme.ts +++ b/packages/cli/src/core/theme.ts @@ -13,9 +13,13 @@ import { type LoadedSettings } from '../config/settings.js'; * @returns An error message if the theme is not found, otherwise null. */ export function validateTheme(settings: LoadedSettings): string | null { - const effectiveTheme = settings.merged.ui.theme; - if (effectiveTheme && !themeManager.findThemeByName(effectiveTheme)) { - return `Theme "${effectiveTheme}" not found.`; + const themeLight = settings.merged.ui.themeLight; + if (themeLight && !themeManager.findThemeByName(themeLight)) { + return `Theme "${themeLight}" not found.`; + } + const themeDark = settings.merged.ui.themeDark; + if (themeDark && !themeManager.findThemeByName(themeDark)) { + return `Theme "${themeDark}" not found.`; } return null; } diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 2784c5694a..0b02ccd3d1 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -498,6 +498,8 @@ describe('gemini.tsx main function kitty protocol', () => { rawOutput: undefined, acceptRawOutputRisk: undefined, isCommand: undefined, + themeMode: undefined, + theme: undefined, }); await act(async () => { @@ -657,7 +659,10 @@ describe('gemini.tsx main function kitty protocol', () => { merged: { advanced: {}, security: { auth: {} }, - ui: { theme: 'non-existent-theme' }, + ui: { + themeLight: 'non-existent-theme', + themeDark: 'non-existent-theme', + }, }, workspace: { settings: {} }, setValue: vi.fn(), diff --git a/packages/cli/src/gemini_cleanup.test.tsx b/packages/cli/src/gemini_cleanup.test.tsx index fb37bb94ec..08dc6bac3b 100644 --- a/packages/cli/src/gemini_cleanup.test.tsx +++ b/packages/cli/src/gemini_cleanup.test.tsx @@ -69,6 +69,8 @@ vi.mock('./config/config.js', () => ({ getSandbox: vi.fn(() => false), getQuestion: vi.fn(() => ''), isInteractive: () => false, + getCliTheme: vi.fn(() => undefined), + getCliThemeMode: vi.fn(() => undefined), storage: { initialize: vi.fn().mockResolvedValue(undefined) }, } as unknown as Config), parseArguments: vi.fn().mockResolvedValue({}), @@ -207,6 +209,8 @@ describe('gemini.tsx main function cleanup', () => { getSandbox: vi.fn(() => false), getDebugMode: vi.fn(() => false), getPolicyEngine: vi.fn(), + getCliTheme: vi.fn(() => undefined), + getCliThemeMode: vi.fn(() => undefined), getMessageBus: () => ({ subscribe: vi.fn() }), getEnableHooks: vi.fn(() => false), getHookSystem: () => undefined, diff --git a/packages/cli/src/test-utils/mockConfig.ts b/packages/cli/src/test-utils/mockConfig.ts index 8b7c7c520d..657151e243 100644 --- a/packages/cli/src/test-utils/mockConfig.ts +++ b/packages/cli/src/test-utils/mockConfig.ts @@ -26,6 +26,8 @@ export const createMockConfig = (overrides: Partial = {}): Config => }, getDebugMode: vi.fn(() => false), getProjectRoot: vi.fn(() => '/'), + getCliTheme: vi.fn(() => undefined), + getCliThemeMode: vi.fn(() => undefined), refreshAuth: vi.fn().mockResolvedValue(undefined), getRemoteAdminSettings: vi.fn(() => undefined), initialize: vi.fn().mockResolvedValue(undefined), diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index d656169c51..416456c144 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -664,6 +664,7 @@ export const AppContainer = (props: AppContainerProps) => { historyManager.addItem, initializationResult.themeError, refreshStatic, + config, ); // Poll for terminal background color changes to auto-switch theme useTerminalTheme(handleThemeSelect, config, refreshStatic); diff --git a/packages/cli/src/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx index 4bfb623db7..d9fd4a4e56 100644 --- a/packages/cli/src/ui/components/ThemeDialog.tsx +++ b/packages/cli/src/ui/components/ThemeDialog.tsx @@ -9,7 +9,7 @@ import { useCallback, useState } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { themeManager, DEFAULT_THEME } from '../themes/theme-manager.js'; -import { pickDefaultThemeName, type Theme } from '../themes/theme.js'; +import { type Theme } from '../themes/theme.js'; import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; import { DiffRenderer } from './messages/DiffRenderer.js'; import { colorizeCode } from '../utils/CodeColorizer.js'; @@ -44,7 +44,10 @@ interface ThemeDialogProps { terminalWidth: number; } -import { resolveColor } from '../themes/color-utils.js'; +import { resolveColor , getThemeTypeFromBackgroundColor } from '../themes/color-utils.js'; + +import { DefaultLight } from '../themes/default-light.js'; +import { DefaultDark } from '../themes/default.js'; function generateThemeItem( name: string, @@ -52,10 +55,6 @@ function generateThemeItem( fullTheme: Theme | undefined, terminalBackgroundColor: string | undefined, ) { - const isCompatible = fullTheme - ? themeManager.isThemeCompatible(fullTheme, terminalBackgroundColor) - : true; - const themeBackground = fullTheme ? resolveColor(fullTheme.colors.Background) : undefined; @@ -70,10 +69,9 @@ function generateThemeItem( value: name, themeNameDisplay: name, themeTypeDisplay: typeDisplay, - themeWarning: isCompatible ? '' : ' (Incompatible)', themeMatch: isBackgroundMatch ? ' (Matches terminal)' : '', key: name, - isCompatible, + type: fullTheme?.type, }; } @@ -91,50 +89,44 @@ export function ThemeDialog({ SettingScope.User, ); - // Track the currently highlighted theme name - const [highlightedThemeName, setHighlightedThemeName] = useState( - () => { - // If a theme is already set, use it. - if (settings.merged.ui.theme) { - return settings.merged.ui.theme; - } + const initialTab = + terminalBackgroundColor && + getThemeTypeFromBackgroundColor(terminalBackgroundColor) === 'dark' + ? 'dark' + : 'light'; + const [activeTab, setActiveTab] = useState<'light' | 'dark'>(initialTab); - // Otherwise, try to pick a theme that matches the terminal background. - return pickDefaultThemeName( - terminalBackgroundColor, - themeManager.getAllThemes(), - DEFAULT_THEME.name, - 'Default Light', - ); - }, - ); + const [highlightedThemeNameLight, setHighlightedThemeNameLight] = + useState(() => settings.merged.ui.themeLight || DefaultLight.name); + const [highlightedThemeNameDark, setHighlightedThemeNameDark] = + useState(() => settings.merged.ui.themeDark || DefaultDark.name); + + const highlightedThemeName = + activeTab === 'light' + ? highlightedThemeNameLight + : highlightedThemeNameDark; const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); // Generate theme items - const themeItems = themeManager - .getAvailableThemes() - .map((theme) => { - const fullTheme = themeManager.getTheme(theme.name); - const capitalizedType = capitalize(theme.type); - const typeDisplay = theme.name.endsWith(capitalizedType) - ? '' - : capitalizedType; + const allThemeItems = themeManager.getAvailableThemes().map((theme) => { + const fullTheme = themeManager.getTheme(theme.name); + const capitalizedType = capitalize(theme.type); + const typeDisplay = theme.name.endsWith(capitalizedType) + ? '' + : capitalizedType; - return generateThemeItem( - theme.name, - typeDisplay, - fullTheme, - terminalBackgroundColor, - ); - }) - .sort((a, b) => { - // Show compatible themes first - if (a.isCompatible && !b.isCompatible) return -1; - if (!a.isCompatible && b.isCompatible) return 1; - // Then sort by name - return a.label.localeCompare(b.label); - }); + return generateThemeItem( + theme.name, + typeDisplay, + fullTheme, + terminalBackgroundColor, + ); + }); + + const themeItems = allThemeItems + .filter((item) => item.type === activeTab || !item.type) // Filter by tab, allow untyped + .sort((a, b) => a.label.localeCompare(b.label)); // Find the index of the selected theme, but only if it exists in the list const initialThemeIndex = themeItems.findIndex( @@ -145,13 +137,18 @@ export function ThemeDialog({ const handleThemeSelect = useCallback( async (themeName: string) => { - await onSelect(themeName, selectedScope); + // @ts-expect-error adding extra argument for the updated hook + await onSelect(themeName, selectedScope, activeTab); }, - [onSelect, selectedScope], + [onSelect, selectedScope, activeTab], ); const handleThemeHighlight = (themeName: string) => { - setHighlightedThemeName(themeName); + if (activeTab === 'light') { + setHighlightedThemeNameLight(themeName); + } else { + setHighlightedThemeNameDark(themeName); + } onHighlight(themeName); }; @@ -161,9 +158,10 @@ export function ThemeDialog({ const handleScopeSelect = useCallback( async (scope: LoadableSettingScope) => { - await onSelect(highlightedThemeName, scope); + // @ts-expect-error adding extra argument + await onSelect(highlightedThemeName, scope, activeTab); }, - [onSelect, highlightedThemeName], + [onSelect, highlightedThemeName, activeTab], ); const [mode, setMode] = useState<'theme' | 'scope'>('theme'); @@ -178,6 +176,18 @@ export function ThemeDialog({ onCancel(); return true; } + if (mode === 'theme') { + if (key.name === 'left' && activeTab === 'dark') { + setActiveTab('light'); + onHighlight(highlightedThemeNameLight); + return true; + } + if (key.name === 'right' && activeTab === 'light') { + setActiveTab('dark'); + onHighlight(highlightedThemeNameDark); + return true; + } + } return false; }, { isActive: true }, @@ -185,7 +195,7 @@ export function ThemeDialog({ // Generate scope message for theme setting const otherScopeModifiedMessage = getScopeMessageForSetting( - 'ui.theme', + activeTab === 'light' ? 'ui.themeLight' : 'ui.themeDark', selectedScope, settings, ); @@ -273,6 +283,38 @@ export function ThemeDialog({ {otherScopeModifiedMessage} + + + {' '} + Light{' '} + + | + + {' '} + Dark{' '} + + { - // We know item has themeWarning because we put it there, but we need to cast or access safely + // We know item has themeMatch because we put it there, but we need to cast or access safely const itemWithExtras = item as typeof item & { - themeWarning?: string; themeMatch?: string; }; @@ -312,11 +353,6 @@ export function ThemeDialog({ {itemWithExtras.themeMatch} )} - {itemWithExtras.themeWarning && ( - - {itemWithExtras.themeWarning} - - )} ); } @@ -335,48 +371,65 @@ export function ThemeDialog({ Preview - - {colorizeCode({ - code: `# function + {/* Get the Theme object for the highlighted theme, fall back to default if not found */} + {(() => { + const previewTheme = + themeManager.getTheme( + highlightedThemeName || DEFAULT_THEME.name, + ) || DEFAULT_THEME; + + const effectiveBackground = + activeTab === 'light' ? '#ffffff' : '#000000'; + + return ( + <> + + {colorizeCode({ + code: `# function def fibonacci(n): a, b = 0, 1 for _ in range(n): a, b = b, a + b return a`, - language: 'python', - availableHeight: - isAlternateBuffer === false ? codeBlockHeight : undefined, - maxWidth: colorizeCodeWidth, - settings, - })} - - + - - {isDevelopment && ( - - - - )} + availableTerminalHeight={ + isAlternateBuffer === false ? diffHeight : undefined + } + terminalWidth={colorizeCodeWidth} + theme={previewTheme} + /> + + {isDevelopment && ( + + + + )} + + ); + })()} ) : ( diff --git a/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap index 0a5f4a08ae..e776bdf5a1 100644 --- a/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap @@ -4,20 +4,19 @@ exports[`Initial Theme Selection > should default to a dark theme when terminal "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ │ > Select Theme Preview │ -│ ▲ ┌─────────────────────────────────────────────────┐ │ -│ 1. ANSI Dark │ │ │ -│ 2. Atom One Dark │ 1 # function │ │ -│ 3. Ayu Dark │ 2 def fibonacci(n): │ │ -│ ● 4. Default Dark │ 3 a, b = 0, 1 │ │ -│ 5. Dracula Dark │ 4 for _ in range(n): │ │ -│ 6. GitHub Dark │ 5 a, b = b, a + b │ │ -│ 7. Holiday Dark │ 6 return a │ │ -│ 8. Shades Of Purple Dark │ │ │ -│ 9. Solarized Dark │ 1 - print("Hello, " + name) │ │ -│ 10. ANSI Light │ 1 + print(f"Hello, {name}!") │ │ -│ 11. Ayu Light │ │ │ -│ 12. Default Light └─────────────────────────────────────────────────┘ │ -│ ▼ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ Light | Dark │ │ │ +│ │ 1 # function │ │ +│ 1. ANSI Dark │ 2 def fibonacci(n): │ │ +│ 2. Atom One Dark │ 3 a, b = 0, 1 │ │ +│ 3. Ayu Dark │ 4 for _ in range(n): │ │ +│ ● 4. Default Dark │ 5 a, b = b, a + b │ │ +│ 5. Dracula Dark │ 6 return a │ │ +│ 6. GitHub Dark │ │ │ +│ 7. Holiday Dark │ 1 - print("Hello, " + name) │ │ +│ 8. Shades Of Purple Dark │ 1 + print(f"Hello, {name}!") │ │ +│ 9. Solarized Dark │ │ │ +│ └─────────────────────────────────────────────────┘ │ │ │ │ (Use Enter to select, Tab to configure scope, Esc to close) │ │ │ @@ -29,20 +28,19 @@ exports[`Initial Theme Selection > should default to a light theme when terminal "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ │ > Select Theme Preview │ -│ ▲ ┌─────────────────────────────────────────────────┐ │ -│ 1. ANSI Light │ │ │ -│ 2. Ayu Light │ 1 # function │ │ -│ ● 3. Default Light │ 2 def fibonacci(n): │ │ -│ 4. GitHub Light │ 3 a, b = 0, 1 │ │ -│ 5. Google Code Light │ 4 for _ in range(n): │ │ -│ 6. Solarized Light │ 5 a, b = b, a + b │ │ -│ 7. Xcode Light │ 6 return a │ │ -│ 8. ANSI Dark (Incompatible) │ │ │ -│ 9. Atom One Dark (Incompatible) │ 1 - print("Hello, " + name) │ │ -│ 10. Ayu Dark (Incompatible) │ 1 + print(f"Hello, {name}!") │ │ -│ 11. Default Dark (Incompatible) │ │ │ -│ 12. Dracula Dark (Incompatible) └─────────────────────────────────────────────────┘ │ -│ ▼ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ Light | Dark │ │ │ +│ │ 1 # function │ │ +│ 1. ANSI Light │ 2 def fibonacci(n): │ │ +│ 2. Ayu Light │ 3 a, b = 0, 1 │ │ +│ ● 3. Default Light │ 4 for _ in range(n): │ │ +│ 4. GitHub Light │ 5 a, b = b, a + b │ │ +│ 5. Google Code Light │ 6 return a │ │ +│ 6. Solarized Light │ │ │ +│ 7. Xcode Light │ 1 - print("Hello, " + name) │ │ +│ │ 1 + print(f"Hello, {name}!") │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────┘ │ │ │ │ (Use Enter to select, Tab to configure scope, Esc to close) │ │ │ @@ -54,20 +52,19 @@ exports[`Initial Theme Selection > should use the theme from settings even if te "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ │ > Select Theme Preview │ -│ ▲ ┌─────────────────────────────────────────────────┐ │ -│ ● 1. ANSI Dark │ │ │ -│ 2. Atom One Dark │ 1 # function │ │ -│ 3. Ayu Dark │ 2 def fibonacci(n): │ │ -│ 4. Default Dark │ 3 a, b = 0, 1 │ │ -│ 5. Dracula Dark │ 4 for _ in range(n): │ │ -│ 6. GitHub Dark │ 5 a, b = b, a + b │ │ -│ 7. Holiday Dark │ 6 return a │ │ -│ 8. Shades Of Purple Dark │ │ │ -│ 9. Solarized Dark │ 1 - print("Hello, " + name) │ │ -│ 10. ANSI Light │ 1 + print(f"Hello, {name}!") │ │ -│ 11. Ayu Light │ │ │ -│ 12. Default Light └─────────────────────────────────────────────────┘ │ -│ ▼ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ Light | Dark │ │ │ +│ │ 1 # function │ │ +│ 1. ANSI Dark │ 2 def fibonacci(n): │ │ +│ 2. Atom One Dark │ 3 a, b = 0, 1 │ │ +│ 3. Ayu Dark │ 4 for _ in range(n): │ │ +│ ● 4. Default Dark │ 5 a, b = b, a + b │ │ +│ 5. Dracula Dark │ 6 return a │ │ +│ 6. GitHub Dark │ │ │ +│ 7. Holiday Dark │ 1 - print("Hello, " + name) │ │ +│ 8. Shades Of Purple Dark │ 1 + print(f"Hello, {name}!") │ │ +│ 9. Solarized Dark │ │ │ +│ └─────────────────────────────────────────────────┘ │ │ │ │ (Use Enter to select, Tab to configure scope, Esc to close) │ │ │ @@ -93,20 +90,19 @@ exports[`ThemeDialog Snapshots > should render correctly in theme selection mode "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ │ > Select Theme Preview │ -│ ▲ ┌─────────────────────────────────────────────────┐ │ -│ ● 1. ANSI Dark (Matches terminal) │ │ │ -│ 2. Atom One Dark │ 1 # function │ │ -│ 3. Ayu Dark │ 2 def fibonacci(n): │ │ -│ 4. Default Dark │ 3 a, b = 0, 1 │ │ -│ 5. Dracula Dark │ 4 for _ in range(n): │ │ -│ 6. GitHub Dark │ 5 a, b = b, a + b │ │ -│ 7. Holiday Dark │ 6 return a │ │ -│ 8. Shades Of Purple Dark │ │ │ -│ 9. Solarized Dark │ 1 - print("Hello, " + name) │ │ -│ 10. ANSI Light │ 1 + print(f"Hello, {name}!") │ │ -│ 11. Ayu Light │ │ │ -│ 12. Default Light └─────────────────────────────────────────────────┘ │ -│ ▼ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ Light | Dark │ │ │ +│ │ 1 # function │ │ +│ 1. ANSI Dark (Matches terminal) │ 2 def fibonacci(n): │ │ +│ 2. Atom One Dark │ 3 a, b = 0, 1 │ │ +│ 3. Ayu Dark │ 4 for _ in range(n): │ │ +│ ● 4. Default Dark │ 5 a, b = b, a + b │ │ +│ 5. Dracula Dark │ 6 return a │ │ +│ 6. GitHub Dark │ │ │ +│ 7. Holiday Dark │ 1 - print("Hello, " + name) │ │ +│ 8. Shades Of Purple Dark │ 1 + print(f"Hello, {name}!") │ │ +│ 9. Solarized Dark │ │ │ +│ └─────────────────────────────────────────────────┘ │ │ │ │ (Use Enter to select, Tab to configure scope, Esc to close) │ │ │ diff --git a/packages/cli/src/ui/components/messages/DiffRenderer.tsx b/packages/cli/src/ui/components/messages/DiffRenderer.tsx index 83b205ac76..06a2448d3a 100644 --- a/packages/cli/src/ui/components/messages/DiffRenderer.tsx +++ b/packages/cli/src/ui/components/messages/DiffRenderer.tsx @@ -11,6 +11,7 @@ import crypto from 'node:crypto'; import { colorizeCode, colorizeLine } from '../../utils/CodeColorizer.js'; import { MaxSizedBox } from '../shared/MaxSizedBox.js'; import { theme as semanticTheme } from '../../semantic-colors.js'; +import { themeManager } from '../../themes/theme-manager.js'; import type { Theme } from '../../themes/theme.js'; import { useSettings } from '../../contexts/SettingsContext.js'; @@ -177,6 +178,7 @@ export const DiffRenderer: React.FC = ({ tabWidth, availableTerminalHeight, terminalWidth, + theme, ); } }, [ @@ -201,7 +203,11 @@ const renderDiffContent = ( tabWidth = DEFAULT_TAB_WIDTH, availableTerminalHeight: number | undefined, terminalWidth: number, + theme?: Theme, ) => { + const activeTheme = theme || themeManager.getActiveTheme(); + const semanticColors = activeTheme.semanticColors; + // 1. Normalize whitespace (replace tabs with spaces) *before* further processing const normalizedLines = parsedLines.map((line) => ({ ...line, @@ -217,7 +223,7 @@ const renderDiffContent = ( return ( No changes detected. @@ -284,7 +290,7 @@ const renderDiffContent = ( borderRight={false} borderBottom={false} width={terminalWidth} - borderColor={semanticTheme.text.secondary} + borderColor={semanticColors.text.secondary} > , ); @@ -323,10 +329,16 @@ const renderDiffContent = ( const backgroundColor = line.type === 'add' - ? semanticTheme.background.diff.added + ? semanticColors.background.diff.added : line.type === 'del' - ? semanticTheme.background.diff.removed + ? semanticColors.background.diff.removed : undefined; + + const effectiveDefaultColor = + activeTheme.defaultColor !== '' + ? activeTheme.defaultColor + : activeTheme.colors.Foreground; + acc.push( - {gutterNumStr} + {gutterNumStr} {line.type === 'context' ? ( - <> + {prefixSymbol} - {colorizeLine(displayContent, language)} - + + {colorizeLine(displayContent, language, activeTheme)} + + ) : ( {prefixSymbol} {' '} - {colorizeLine(displayContent, language)} + {colorizeLine(displayContent, language, activeTheme)} )} , diff --git a/packages/cli/src/ui/components/messages/__snapshots__/DiffRenderer.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/DiffRenderer.test.tsx.snap index fed8b32bd0..0ea04cbf4d 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/DiffRenderer.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/DiffRenderer.test.tsx.snap @@ -14,8 +14,8 @@ exports[` > with useAlterna 'test'; 21 + const anotherNew = 'test'; -22 console.log('end of second - hunk'); +22 console.log('end of + second hunk'); " `; @@ -107,8 +107,8 @@ exports[` > with useAlterna 'test'; 21 + const anotherNew = 'test'; -22 console.log('end of second - hunk'); +22 console.log('end of + second hunk'); " `; diff --git a/packages/cli/src/ui/contexts/SettingsContext.test.tsx b/packages/cli/src/ui/contexts/SettingsContext.test.tsx index 3124108f90..08c1ce637c 100644 --- a/packages/cli/src/ui/contexts/SettingsContext.test.tsx +++ b/packages/cli/src/ui/contexts/SettingsContext.test.tsx @@ -32,7 +32,7 @@ const mockSnapshot: LoadedSettingsSnapshot = { isTrusted: true, errors: [], merged: createTestMergedSettings({ - ui: { theme: 'default-theme' }, + ui: { themeLight: 'default-theme', themeDark: 'default-theme' }, }), }; @@ -113,11 +113,11 @@ describe('SettingsContext', () => { it('should trigger re-renders when settings change (external event)', () => { const { result } = renderHook(() => useSettingsStore(), { wrapper }); - expect(result.current.settings.merged.ui?.theme).toBe('default-theme'); + expect(result.current.settings.merged.ui?.themeLight).toBe('default-theme'); const newSnapshot = { ...mockSnapshot, - merged: { ui: { theme: 'new-theme' } }, + merged: { ui: { themeLight: 'new-theme', themeDark: 'new-theme' } }, }; ( mockLoadedSettings.getSnapshot as ReturnType @@ -128,19 +128,19 @@ describe('SettingsContext', () => { listeners.forEach((l) => l()); }); - expect(result.current.settings.merged.ui?.theme).toBe('new-theme'); + expect(result.current.settings.merged.ui?.themeLight).toBe('new-theme'); }); it('should call store.setValue when setSetting is called', () => { const { result } = renderHook(() => useSettingsStore(), { wrapper }); act(() => { - result.current.setSetting(SettingScope.User, 'ui.theme', 'dark'); + result.current.setSetting(SettingScope.User, 'ui.themeLight', 'dark'); }); expect(mockLoadedSettings.setValue).toHaveBeenCalledWith( SettingScope.User, - 'ui.theme', + 'ui.themeLight', 'dark', ); }); diff --git a/packages/cli/src/ui/hooks/useTerminalTheme.test.tsx b/packages/cli/src/ui/hooks/useTerminalTheme.test.tsx index d20c6149b0..911e4470a9 100644 --- a/packages/cli/src/ui/hooks/useTerminalTheme.test.tsx +++ b/packages/cli/src/ui/hooks/useTerminalTheme.test.tsx @@ -40,7 +40,8 @@ vi.mock('../contexts/TerminalContext.js', () => ({ const mockSettings = { merged: { ui: { - theme: 'default', // DEFAULT_THEME.name + themeLight: 'default', // DEFAULT_THEME.name + themeDark: 'default', autoThemeSwitching: true, terminalBackgroundPollingInterval: 60, }, @@ -60,6 +61,7 @@ vi.mock('../themes/theme-manager.js', async (importOriginal) => { isDefaultTheme: (name: string) => name === 'default' || name === 'default-light', setTerminalBackground: vi.fn(), + setActiveTheme: vi.fn(), }, DEFAULT_THEME: { name: 'default' }, }; @@ -86,8 +88,10 @@ describe('useTerminalTheme', () => { mockHandleThemeSelect.mockClear(); mockQueryTerminalBackground.mockClear(); vi.mocked(themeManager.setTerminalBackground).mockClear(); + vi.mocked(themeManager.setActiveTheme).mockClear(); mockSettings.merged.ui.autoThemeSwitching = true; - mockSettings.merged.ui.theme = 'default'; + mockSettings.merged.ui.themeLight = 'default'; + mockSettings.merged.ui.themeDark = 'default'; }); afterEach(() => { @@ -133,7 +137,8 @@ describe('useTerminalTheme', () => { unmount(); }); - it('should switch to light theme when background is light and not call refreshStatic directly', () => { + it('should switch to light theme when background is light', () => { + mockSettings.merged.ui.themeLight = 'default-light'; const refreshStatic = vi.fn(); const { unmount } = renderHook(() => useTerminalTheme(mockHandleThemeSelect, config, refreshStatic), @@ -145,19 +150,16 @@ describe('useTerminalTheme', () => { expect(config.setTerminalBackground).toHaveBeenCalledWith('#ffffff'); expect(themeManager.setTerminalBackground).toHaveBeenCalledWith('#ffffff'); - expect(refreshStatic).not.toHaveBeenCalled(); - expect(mockHandleThemeSelect).toHaveBeenCalledWith( - 'default-light', - expect.anything(), - ); + expect(themeManager.setActiveTheme).toHaveBeenCalledWith('default-light'); + expect(refreshStatic).toHaveBeenCalled(); + expect(mockHandleThemeSelect).not.toHaveBeenCalled(); unmount(); }); it('should switch to dark theme when background is dark', () => { - mockSettings.merged.ui.theme = 'default-light'; - + mockSettings.merged.ui.themeLight = 'default-light'; + mockSettings.merged.ui.themeDark = 'default'; config.setTerminalBackground('#ffffff'); - const refreshStatic = vi.fn(); const { unmount } = renderHook(() => useTerminalTheme(mockHandleThemeSelect, config, refreshStatic), @@ -169,13 +171,9 @@ describe('useTerminalTheme', () => { expect(config.setTerminalBackground).toHaveBeenCalledWith('#000000'); expect(themeManager.setTerminalBackground).toHaveBeenCalledWith('#000000'); - expect(refreshStatic).not.toHaveBeenCalled(); - expect(mockHandleThemeSelect).toHaveBeenCalledWith( - 'default', - expect.anything(), - ); - - mockSettings.merged.ui.theme = 'default'; + expect(themeManager.setActiveTheme).toHaveBeenCalledWith('default'); + expect(refreshStatic).toHaveBeenCalled(); + expect(mockHandleThemeSelect).not.toHaveBeenCalled(); unmount(); }); diff --git a/packages/cli/src/ui/hooks/useTerminalTheme.ts b/packages/cli/src/ui/hooks/useTerminalTheme.ts index 5590c2a97c..283089bf03 100644 --- a/packages/cli/src/ui/hooks/useTerminalTheme.ts +++ b/packages/cli/src/ui/hooks/useTerminalTheme.ts @@ -5,18 +5,13 @@ */ import { useEffect } from 'react'; -import { - getLuminance, - parseColor, - shouldSwitchTheme, -} from '../themes/color-utils.js'; -import { themeManager, DEFAULT_THEME } from '../themes/theme-manager.js'; -import { DefaultLight } from '../themes/default-light.js'; +import { parseColor } from '../themes/color-utils.js'; +import { themeManager } from '../themes/theme-manager.js'; import { useSettings } from '../contexts/SettingsContext.js'; import type { Config } from '@google/gemini-cli-core'; import { useTerminalContext } from '../contexts/TerminalContext.js'; -import { SettingScope } from '../../config/settings.js'; import type { UIActions } from '../contexts/UIActionsContext.js'; +import { getActiveThemeName } from '../../utils/terminalTheme.js'; export function useTerminalTheme( handleThemeSelect: UIActions['handleThemeSelect'], @@ -38,12 +33,6 @@ export function useTerminalTheme( } const pollIntervalId = setInterval(() => { - // Only poll if we are using one of the default themes - const currentThemeName = settings.merged.ui.theme; - if (!themeManager.isDefaultTheme(currentThemeName)) { - return; - } - void queryTerminalBackground(); }, settings.merged.ui.terminalBackgroundPollingInterval * 1000); @@ -59,34 +48,22 @@ export function useTerminalTheme( if (!hexColor) return; const previousColor = config.getTerminalBackground(); - const luminance = getLuminance(hexColor); - const currentThemeName = settings.merged.ui.theme; - - const newTheme = shouldSwitchTheme( - currentThemeName, - luminance, - DEFAULT_THEME.name, - DefaultLight.name, - ); - if (previousColor === hexColor) { - if (newTheme) { - void handleThemeSelect(newTheme, SettingScope.User); - } return; } config.setTerminalBackground(hexColor); themeManager.setTerminalBackground(hexColor); - if (newTheme) { - void handleThemeSelect(newTheme, SettingScope.User); - } else { - // The existing theme had its background changed so refresh because - // there may be existing static UI rendered that relies on the old - // background color. - refreshStatic(); - } + const activeThemeName = getActiveThemeName( + settings.merged, + hexColor, + config.getCliTheme(), + config.getCliThemeMode(), + ); + + themeManager.setActiveTheme(activeThemeName); + refreshStatic(); }; subscribe(handleTerminalBackground); @@ -96,9 +73,7 @@ export function useTerminalTheme( unsubscribe(handleTerminalBackground); }; }, [ - settings.merged.ui.theme, - settings.merged.ui.autoThemeSwitching, - settings.merged.ui.terminalBackgroundPollingInterval, + settings.merged, config, handleThemeSelect, subscribe, diff --git a/packages/cli/src/ui/hooks/useThemeCommand.ts b/packages/cli/src/ui/hooks/useThemeCommand.ts index 15eaa875a2..ce9684e7de 100644 --- a/packages/cli/src/ui/hooks/useThemeCommand.ts +++ b/packages/cli/src/ui/hooks/useThemeCommand.ts @@ -15,6 +15,9 @@ import process from 'node:process'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; import { useTerminalContext } from '../contexts/TerminalContext.js'; +import { getActiveThemeName } from '../../utils/terminalTheme.js'; +import type { Config } from '@google/gemini-cli-core'; + interface UseThemeCommandReturn { isThemeDialogOpen: boolean; openThemeDialog: () => void; @@ -22,6 +25,7 @@ interface UseThemeCommandReturn { handleThemeSelect: ( themeName: string, scope: LoadableSettingScope, + themeMode?: 'light' | 'dark', ) => Promise; handleThemeHighlight: (themeName: string | undefined) => void; } @@ -32,6 +36,7 @@ export const useThemeCommand = ( addItem: UseHistoryManagerReturn['addItem'], initialThemeError: string | null, refreshStatic: () => void, + config: Config, ): UseThemeCommandReturn => { const [isThemeDialogOpen, setIsThemeDialogOpen] = useState(!!initialThemeError); @@ -79,12 +84,22 @@ export const useThemeCommand = ( const closeThemeDialog = useCallback(() => { // Re-apply the saved theme to revert any preview changes from highlighting - applyTheme(loadedSettings.merged.ui.theme); + const activeTheme = getActiveThemeName( + loadedSettings.merged, + config.getTerminalBackground(), + config.getCliTheme(), + config.getCliThemeMode(), + ); + applyTheme(activeTheme); setIsThemeDialogOpen(false); - }, [applyTheme, loadedSettings]); + }, [applyTheme, loadedSettings, config]); const handleThemeSelect = useCallback( - async (themeName: string, scope: LoadableSettingScope) => { + async ( + themeName: string, + scope: LoadableSettingScope, + themeMode?: 'light' | 'dark', + ) => { try { const mergedCustomThemes = { ...(loadedSettings.user.settings.ui?.customThemes || {}), @@ -98,18 +113,34 @@ export const useThemeCommand = ( setIsThemeDialogOpen(true); return; } - loadedSettings.setValue(scope, 'ui.theme', themeName); // Update the merged settings + + if (themeMode === 'light') { + loadedSettings.setValue(scope, 'ui.themeLight', themeName); + } else if (themeMode === 'dark') { + loadedSettings.setValue(scope, 'ui.themeDark', themeName); + } else { + loadedSettings.setValue(scope, 'ui.themeLight', themeName); + loadedSettings.setValue(scope, 'ui.themeDark', themeName); + } + if (loadedSettings.merged.ui.customThemes) { themeManager.loadCustomThemes(loadedSettings.merged.ui.customThemes); } - applyTheme(loadedSettings.merged.ui.theme); // Apply the current theme + + const activeTheme = getActiveThemeName( + loadedSettings.merged, + config.getTerminalBackground(), + config.getCliTheme(), + config.getCliThemeMode(), + ); + applyTheme(activeTheme); refreshStatic(); setThemeError(null); } finally { setIsThemeDialogOpen(false); // Close the dialog } }, - [applyTheme, loadedSettings, refreshStatic, setThemeError], + [applyTheme, loadedSettings, refreshStatic, setThemeError, config], ); return { diff --git a/packages/cli/src/ui/utils/CodeColorizer.tsx b/packages/cli/src/ui/utils/CodeColorizer.tsx index 56e34eefa4..ae9783e835 100644 --- a/packages/cli/src/ui/utils/CodeColorizer.tsx +++ b/packages/cli/src/ui/utils/CodeColorizer.tsx @@ -176,6 +176,11 @@ export function colorizeCode({ activeTheme, ); + const effectiveDefaultColor = + activeTheme.defaultColor !== '' + ? activeTheme.defaultColor + : activeTheme.colors.Foreground; + return ( {showLineNumbers && ( @@ -191,7 +196,7 @@ export function colorizeCode({ )} - + {contentToRender} diff --git a/packages/cli/src/utils/commentJson.test.ts b/packages/cli/src/utils/commentJson.test.ts index e92b19cb63..f423b038f3 100644 --- a/packages/cli/src/utils/commentJson.test.ts +++ b/packages/cli/src/utils/commentJson.test.ts @@ -50,7 +50,8 @@ describe('commentJson', () => { updateSettingsFilePreservingFormat(testFilePath, { model: 'gemini-2.5-flash', ui: { - theme: 'dark', + themeLight: 'dark', + themeDark: 'dark', }, }); @@ -59,7 +60,7 @@ describe('commentJson', () => { expect(updatedContent).toContain('// Model configuration'); expect(updatedContent).toContain('// Theme setting'); expect(updatedContent).toContain('"model": "gemini-2.5-flash"'); - expect(updatedContent).toContain('"theme": "dark"'); + expect(updatedContent).toContain('"themeLight": "dark"'); }); it('should handle nested object updates', () => { @@ -74,13 +75,14 @@ describe('commentJson', () => { updateSettingsFilePreservingFormat(testFilePath, { ui: { - theme: 'light', + themeLight: 'light', + themeDark: 'light', showLineNumbers: true, }, }); const updatedContent = fs.readFileSync(testFilePath, 'utf-8'); - expect(updatedContent).toContain('"theme": "light"'); + expect(updatedContent).toContain('"themeLight": "light"'); expect(updatedContent).toContain('"showLineNumbers": true'); }); @@ -220,7 +222,8 @@ describe('commentJson', () => { updateSettingsFilePreservingFormat(testFilePath, { model: 'gemini-2.5-flash', ui: { - theme: 'light', + themeLight: 'light', + themeDark: 'light', }, preservedField: 'keep me', }); @@ -228,7 +231,7 @@ describe('commentJson', () => { const updatedContent = fs.readFileSync(testFilePath, 'utf-8'); expect(updatedContent).toContain('// Configuration'); expect(updatedContent).toContain('"model": "gemini-2.5-flash"'); - expect(updatedContent).toContain('"theme": "light"'); + expect(updatedContent).toContain('"themeLight": "light"'); expect(updatedContent).not.toContain('"existingSetting": "value"'); expect(updatedContent).toContain('"preservedField": "keep me"'); }); diff --git a/packages/cli/src/utils/terminalTheme.ts b/packages/cli/src/utils/terminalTheme.ts index 72315d3fa5..285e73bce1 100644 --- a/packages/cli/src/utils/terminalTheme.ts +++ b/packages/cli/src/utils/terminalTheme.ts @@ -8,12 +8,36 @@ import { type TerminalBackgroundColor, terminalCapabilityManager, } from '../ui/utils/terminalCapabilityManager.js'; -import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js'; -import { pickDefaultThemeName } from '../ui/themes/theme.js'; +import { themeManager } from '../ui/themes/theme-manager.js'; +import { DefaultLight } from '../ui/themes/default-light.js'; +import { DefaultDark } from '../ui/themes/default.js'; import { getThemeTypeFromBackgroundColor } from '../ui/themes/color-utils.js'; -import type { LoadedSettings } from '../config/settings.js'; +import type { LoadedSettings, MergedSettings } from '../config/settings.js'; import { type Config, coreEvents, debugLogger } from '@google/gemini-cli-core'; +export function getActiveThemeName( + settings: MergedSettings, + terminalBackgroundColor: TerminalBackgroundColor, + cliTheme?: string, + cliThemeMode?: 'light' | 'dark', +): string { + if (cliTheme) { + return cliTheme; + } + + let mode = cliThemeMode; + if (!mode && terminalBackgroundColor) { + mode = getThemeTypeFromBackgroundColor(terminalBackgroundColor); + } + + if (mode === 'light') { + return settings.ui?.themeLight || DefaultLight.name; + } else { + // Default to dark + return settings.ui?.themeDark || DefaultDark.name; + } +} + /** * Detects terminal capabilities, loads themes, and sets the active theme. * @param config The application config. @@ -34,23 +58,17 @@ export async function setupTerminalAndTheme( // Load custom themes from settings themeManager.loadCustomThemes(settings.merged.ui.customThemes); - 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 AppContainer.tsx will handle opening the dialog. - debugLogger.warn( - `Warning: Theme "${settings.merged.ui.theme}" not found.`, - ); - } - } else { - // If no theme is set, check terminal background color - const themeName = pickDefaultThemeName( - terminalBackground, - themeManager.getAllThemes(), - DEFAULT_THEME.name, - 'Default Light', - ); - themeManager.setActiveTheme(themeName); + const activeThemeName = getActiveThemeName( + settings.merged, + terminalBackground, + config.getCliTheme(), + config.getCliThemeMode(), + ); + + if (!themeManager.setActiveTheme(activeThemeName)) { + // If the theme is not found during initial load, log a warning and continue. + // The useThemeCommand hook in AppContainer.tsx will handle opening the dialog. + debugLogger.warn(`Warning: Theme "${activeThemeName}" not found.`); } config.setTerminalBackground(terminalBackground); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 8c341073eb..527a1dcc38 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -597,6 +597,8 @@ export interface ConfigParameters { billing?: { overageStrategy?: OverageStrategy; }; + cliTheme?: string; + cliThemeMode?: 'light' | 'dark'; } export class Config implements McpContext { @@ -626,6 +628,8 @@ export class Config implements McpContext { private readonly debugMode: boolean; private readonly question: string | undefined; readonly enableConseca: boolean; + private readonly cliTheme?: string; + private readonly cliThemeMode?: 'light' | 'dark'; private readonly coreTools: string[] | undefined; /** @deprecated Use Policy Engine instead */ @@ -819,6 +823,9 @@ export class Config implements McpContext { this.pendingIncludeDirectories = params.includeDirectories ?? []; this.debugMode = params.debugMode; this.question = params.question; + this.enableConseca = params.enableConseca ?? false; + this.cliTheme = params.cliTheme; + this.cliThemeMode = params.cliThemeMode; this.coreTools = params.coreTools; this.allowedTools = params.allowedTools; @@ -1561,6 +1568,14 @@ export class Config implements McpContext { return this.sandbox; } + getCliTheme(): string | undefined { + return this.cliTheme; + } + + getCliThemeMode(): 'light' | 'dark' | undefined { + return this.cliThemeMode; + } + isRestrictiveSandbox(): boolean { const sandboxConfig = this.getSandbox(); const seatbeltProfile = process.env['SEATBELT_PROFILE']; diff --git a/packages/core/src/services/FolderTrustDiscoveryService.test.ts b/packages/core/src/services/FolderTrustDiscoveryService.test.ts index b6d7d7734a..d576f43fc7 100644 --- a/packages/core/src/services/FolderTrustDiscoveryService.test.ts +++ b/packages/core/src/services/FolderTrustDiscoveryService.test.ts @@ -51,7 +51,7 @@ describe('FolderTrustDiscoveryService', () => { BeforeTool: [{ command: 'test-hook' }], }, general: { vimMode: true }, - ui: { theme: 'Dark' }, + ui: { themeLight: 'Dark', themeDark: 'Dark' }, }; await fs.writeFile( path.join(geminiDir, 'settings.json'),