mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-06 19:31:15 -07:00
feat(cli): support independent light and dark mode themes
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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) │
|
||||
│ │
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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');
|
||||
"
|
||||
`;
|
||||
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user