feat(cli): support independent light and dark mode themes

This commit is contained in:
Dan Zaharia
2026-02-26 16:50:26 -05:00
parent 717660997d
commit 98a0a2f0ef
25 changed files with 548 additions and 330 deletions

View File

@@ -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);

View File

@@ -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,

View File

@@ -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);

View File

@@ -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<string, unknown> | 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<string, unknown>
@@ -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

View File

@@ -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);
});

View File

@@ -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: {

View File

@@ -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<typeof themeManager.findThemeByName>,
);
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<typeof themeManager.findThemeByName>)
: 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<typeof themeManager.findThemeByName>)
: 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();

View File

@@ -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;
}

View File

@@ -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(),

View File

@@ -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,

View File

@@ -26,6 +26,8 @@ export const createMockConfig = (overrides: Partial<Config> = {}): 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),

View File

@@ -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);

View File

@@ -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<string>(
() => {
// 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<string>(() => settings.merged.ui.themeLight || DefaultLight.name);
const [highlightedThemeNameDark, setHighlightedThemeNameDark] =
useState<string>(() => 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}
</Text>
</Text>
<Box
flexDirection="row"
paddingLeft={2}
paddingTop={1}
paddingBottom={1}
>
<Text
color={
activeTab === 'light'
? theme.text.primary
: theme.text.secondary
}
underline={activeTab === 'light'}
bold={activeTab === 'light'}
>
{' '}
Light{' '}
</Text>
<Text> | </Text>
<Text
color={
activeTab === 'dark'
? theme.text.primary
: theme.text.secondary
}
underline={activeTab === 'dark'}
bold={activeTab === 'dark'}
>
{' '}
Dark{' '}
</Text>
</Box>
<RadioButtonSelect
items={themeItems}
initialIndex={safeInitialThemeIndex}
@@ -283,9 +325,8 @@ export function ThemeDialog({
showScrollArrows={true}
showNumbers={mode === 'theme'}
renderItem={(item, { titleColor }) => {
// 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}
</Text>
)}
{itemWithExtras.themeWarning && (
<Text color={theme.status.warning}>
{itemWithExtras.themeWarning}
</Text>
)}
</Text>
);
}
@@ -335,48 +371,65 @@ export function ThemeDialog({
<Text bold color={theme.text.primary}>
Preview
</Text>
<Box
borderStyle="single"
borderColor={theme.border.default}
paddingTop={includePadding ? 1 : 0}
paddingBottom={includePadding ? 1 : 0}
paddingLeft={1}
paddingRight={1}
flexDirection="column"
>
{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 (
<>
<Box
borderStyle="single"
borderColor={theme.border.default}
paddingTop={includePadding ? 1 : 0}
paddingBottom={includePadding ? 1 : 0}
paddingLeft={1}
paddingRight={1}
flexDirection="column"
backgroundColor={effectiveBackground}
>
{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,
})}
<Box marginTop={1} />
<DiffRenderer
diffContent={`--- a/util.py
language: 'python',
availableHeight:
isAlternateBuffer === false ? codeBlockHeight : undefined,
maxWidth: colorizeCodeWidth,
settings,
theme: previewTheme,
})}
<Box marginTop={1} />
<DiffRenderer
diffContent={`--- a/util.py
+++ b/util.py
@@ -1,2 +1,2 @@
- print("Hello, " + name)
+ print(f"Hello, {name}!")
`}
availableTerminalHeight={
isAlternateBuffer === false ? diffHeight : undefined
}
terminalWidth={colorizeCodeWidth}
theme={previewTheme}
/>
</Box>
{isDevelopment && (
<Box marginTop={1}>
<ColorsDisplay activeTheme={previewTheme} />
</Box>
)}
availableTerminalHeight={
isAlternateBuffer === false ? diffHeight : undefined
}
terminalWidth={colorizeCodeWidth}
theme={previewTheme}
/>
</Box>
{isDevelopment && (
<Box marginTop={1}>
<ColorsDisplay activeTheme={previewTheme} />
</Box>
)}
</>
);
})()}
</Box>
</Box>
) : (

View File

@@ -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) │
│ │

View File

@@ -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<DiffRendererProps> = ({
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 (
<Box
borderStyle="round"
borderColor={semanticTheme.border.default}
borderColor={semanticColors.border.default}
padding={1}
>
<Text dimColor>No changes detected.</Text>
@@ -284,7 +290,7 @@ const renderDiffContent = (
borderRight={false}
borderBottom={false}
width={terminalWidth}
borderColor={semanticTheme.text.secondary}
borderColor={semanticColors.text.secondary}
></Box>
</Box>,
);
@@ -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(
<Box key={lineKey} flexDirection="row">
<Box
@@ -336,32 +348,35 @@ const renderDiffContent = (
backgroundColor={backgroundColor}
justifyContent="flex-end"
>
<Text color={semanticTheme.text.secondary}>{gutterNumStr}</Text>
<Text color={semanticColors.text.secondary}>{gutterNumStr}</Text>
</Box>
{line.type === 'context' ? (
<>
<Text color={effectiveDefaultColor}>
<Text>{prefixSymbol} </Text>
<Text wrap="wrap">{colorizeLine(displayContent, language)}</Text>
</>
<Text wrap="wrap">
{colorizeLine(displayContent, language, activeTheme)}
</Text>
</Text>
) : (
<Text
backgroundColor={
line.type === 'add'
? semanticTheme.background.diff.added
: semanticTheme.background.diff.removed
? semanticColors.background.diff.added
: semanticColors.background.diff.removed
}
color={effectiveDefaultColor}
wrap="wrap"
>
<Text
color={
line.type === 'add'
? semanticTheme.status.success
: semanticTheme.status.error
? semanticColors.status.success
: semanticColors.status.error
}
>
{prefixSymbol}
</Text>{' '}
{colorizeLine(displayContent, language)}
{colorizeLine(displayContent, language, activeTheme)}
</Text>
)}
</Box>,

View File

@@ -14,8 +14,8 @@ exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > 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[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlterna
'test';
21 + const anotherNew =
'test';
22 console.log('end of second
hunk');
22 console.log('end of
second hunk');
"
`;

View File

@@ -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<typeof vi.fn>
@@ -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',
);
});

View File

@@ -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();
});

View File

@@ -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,

View File

@@ -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<void>;
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 {

View File

@@ -176,6 +176,11 @@ export function colorizeCode({
activeTheme,
);
const effectiveDefaultColor =
activeTheme.defaultColor !== ''
? activeTheme.defaultColor
: activeTheme.colors.Foreground;
return (
<Box key={index} minHeight={1}>
{showLineNumbers && (
@@ -191,7 +196,7 @@ export function colorizeCode({
</Text>
</Box>
)}
<Text color={activeTheme.defaultColor} wrap="wrap">
<Text color={effectiveDefaultColor} wrap="wrap">
{contentToRender}
</Text>
</Box>

View File

@@ -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"');
});

View File

@@ -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);