mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-09 04:41:19 -07:00
feat(extensions): add support for custom themes in extensions (#17327)
This commit is contained in:
@@ -109,15 +109,6 @@ export function ThemeDialog({
|
||||
},
|
||||
);
|
||||
|
||||
// Generate theme items filtered by selected scope
|
||||
const customThemes =
|
||||
selectedScope === SettingScope.User
|
||||
? settings.user.settings.ui?.customThemes || {}
|
||||
: settings.merged.ui.customThemes;
|
||||
const builtInThemes = themeManager
|
||||
.getAvailableThemes()
|
||||
.filter((theme) => theme.type !== 'custom');
|
||||
const customThemeNames = Object.keys(customThemes);
|
||||
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
|
||||
const terminalThemeType = getThemeTypeFromBackgroundColor(
|
||||
@@ -125,8 +116,9 @@ export function ThemeDialog({
|
||||
);
|
||||
|
||||
// Generate theme items
|
||||
const themeItems = [
|
||||
...builtInThemes.map((theme) => {
|
||||
const themeItems = themeManager
|
||||
.getAvailableThemes()
|
||||
.map((theme) => {
|
||||
const fullTheme = themeManager.getTheme(theme.name);
|
||||
const themeBackground = fullTheme
|
||||
? resolveColor(fullTheme.colors.Background)
|
||||
@@ -140,28 +132,14 @@ export function ThemeDialog({
|
||||
terminalBackgroundColor,
|
||||
terminalThemeType,
|
||||
);
|
||||
}),
|
||||
...customThemeNames.map((name) => {
|
||||
const themeConfig = customThemes[name];
|
||||
const bg = themeConfig.background?.primary ?? themeConfig.Background;
|
||||
const themeBackground = bg ? resolveColor(bg) : undefined;
|
||||
|
||||
return generateThemeItem(
|
||||
name,
|
||||
'Custom',
|
||||
'custom',
|
||||
themeBackground,
|
||||
terminalBackgroundColor,
|
||||
terminalThemeType,
|
||||
);
|
||||
}),
|
||||
].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);
|
||||
});
|
||||
})
|
||||
.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);
|
||||
});
|
||||
|
||||
// Find the index of the selected theme, but only if it exists in the list
|
||||
const initialThemeIndex = themeItems.findIndex(
|
||||
@@ -314,9 +292,20 @@ export function ThemeDialog({
|
||||
};
|
||||
|
||||
if (item.themeNameDisplay && item.themeTypeDisplay) {
|
||||
const match = item.themeNameDisplay.match(/^(.*) \((.*)\)$/);
|
||||
let themeNamePart: React.ReactNode = item.themeNameDisplay;
|
||||
if (match) {
|
||||
themeNamePart = (
|
||||
<>
|
||||
{match[1]}{' '}
|
||||
<Text color={theme.text.secondary}>({match[2]})</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text color={titleColor} wrap="truncate" key={item.key}>
|
||||
{item.themeNameDisplay}{' '}
|
||||
{themeNamePart}{' '}
|
||||
<Text color={theme.text.secondary}>
|
||||
{item.themeTypeDisplay}
|
||||
</Text>
|
||||
|
||||
@@ -11,7 +11,7 @@ if (process.env['NO_COLOR'] !== undefined) {
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { themeManager, DEFAULT_THEME } from './theme-manager.js';
|
||||
import type { CustomTheme } from './theme.js';
|
||||
import type { CustomTheme } from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import type * as osActual from 'node:os';
|
||||
@@ -188,4 +188,54 @@ describe('ThemeManager', () => {
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extension themes', () => {
|
||||
it('should register and unregister themes from extensions with namespacing', () => {
|
||||
const extTheme: CustomTheme = {
|
||||
...validCustomTheme,
|
||||
name: 'ExtensionTheme',
|
||||
};
|
||||
const extensionName = 'test-extension';
|
||||
const namespacedName = `ExtensionTheme (${extensionName})`;
|
||||
|
||||
themeManager.registerExtensionThemes(extensionName, [extTheme]);
|
||||
expect(themeManager.getCustomThemeNames()).toContain(namespacedName);
|
||||
expect(themeManager.isCustomTheme(namespacedName)).toBe(true);
|
||||
|
||||
themeManager.unregisterExtensionThemes(extensionName, [extTheme]);
|
||||
expect(themeManager.getCustomThemeNames()).not.toContain(namespacedName);
|
||||
expect(themeManager.isCustomTheme(namespacedName)).toBe(false);
|
||||
});
|
||||
|
||||
it('should not allow extension themes to overwrite built-in themes even with prefixing', () => {
|
||||
// availableThemes has 'Ayu'.
|
||||
// We verify that it DOES prefix, so it won't collide even if extension name is similar.
|
||||
themeManager.registerExtensionThemes('Ext', [
|
||||
{ ...validCustomTheme, name: 'Theme' },
|
||||
]);
|
||||
expect(themeManager.getCustomThemeNames()).toContain('Theme (Ext)');
|
||||
});
|
||||
|
||||
it('should allow extension themes and settings themes to coexist', () => {
|
||||
const extTheme: CustomTheme = {
|
||||
...validCustomTheme,
|
||||
name: 'ExtensionTheme',
|
||||
};
|
||||
const settingsTheme: CustomTheme = {
|
||||
...validCustomTheme,
|
||||
name: 'SettingsTheme',
|
||||
};
|
||||
|
||||
themeManager.registerExtensionThemes('Ext', [extTheme]);
|
||||
themeManager.loadCustomThemes({ SettingsTheme: settingsTheme });
|
||||
|
||||
expect(themeManager.getCustomThemeNames()).toContain(
|
||||
'ExtensionTheme (Ext)',
|
||||
);
|
||||
expect(themeManager.getCustomThemeNames()).toContain('SettingsTheme');
|
||||
|
||||
expect(themeManager.isCustomTheme('ExtensionTheme (Ext)')).toBe(true);
|
||||
expect(themeManager.isCustomTheme('SettingsTheme')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,8 @@ import { ShadesOfPurple } from './shades-of-purple.js';
|
||||
import { XCode } from './xcode.js';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import type { Theme, ThemeType, CustomTheme } from './theme.js';
|
||||
import type { Theme, ThemeType } from './theme.js';
|
||||
import type { CustomTheme } from '@google/gemini-cli-core';
|
||||
import { createCustomTheme, validateCustomTheme } from './theme.js';
|
||||
import type { SemanticColors } from './semantic-tokens.js';
|
||||
import { ANSI } from './ansi.js';
|
||||
@@ -38,7 +39,9 @@ export const DEFAULT_THEME: Theme = DefaultDark;
|
||||
class ThemeManager {
|
||||
private readonly availableThemes: Theme[];
|
||||
private activeTheme: Theme;
|
||||
private customThemes: Map<string, Theme> = new Map();
|
||||
private settingsThemes: Map<string, Theme> = new Map();
|
||||
private extensionThemes: Map<string, Theme> = new Map();
|
||||
private fileThemes: Map<string, Theme> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.availableThemes = [
|
||||
@@ -65,7 +68,7 @@ class ThemeManager {
|
||||
* @param customThemesSettings Custom themes from settings.
|
||||
*/
|
||||
loadCustomThemes(customThemesSettings?: Record<string, CustomTheme>): void {
|
||||
this.customThemes.clear();
|
||||
this.settingsThemes.clear();
|
||||
|
||||
if (!customThemesSettings) {
|
||||
return;
|
||||
@@ -88,7 +91,7 @@ class ThemeManager {
|
||||
|
||||
try {
|
||||
const theme = createCustomTheme(themeWithDefaults);
|
||||
this.customThemes.set(name, theme);
|
||||
this.settingsThemes.set(name, theme);
|
||||
} catch (error) {
|
||||
debugLogger.warn(`Failed to load custom theme "${name}":`, error);
|
||||
}
|
||||
@@ -96,16 +99,103 @@ class ThemeManager {
|
||||
debugLogger.warn(`Invalid custom theme "${name}": ${validation.error}`);
|
||||
}
|
||||
}
|
||||
// If the current active theme is a custom theme, keep it if still valid
|
||||
// If the current active theme is a settings theme, keep it if still valid
|
||||
if (
|
||||
this.activeTheme &&
|
||||
this.activeTheme.type === 'custom' &&
|
||||
this.customThemes.has(this.activeTheme.name)
|
||||
this.settingsThemes.has(this.activeTheme.name)
|
||||
) {
|
||||
this.activeTheme = this.customThemes.get(this.activeTheme.name)!;
|
||||
this.activeTheme = this.settingsThemes.get(this.activeTheme.name)!;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads custom themes from extensions.
|
||||
* @param extensionName The name of the extension providing the themes.
|
||||
* @param customThemes Custom themes from extensions.
|
||||
*/
|
||||
registerExtensionThemes(
|
||||
extensionName: string,
|
||||
customThemes?: CustomTheme[],
|
||||
): void {
|
||||
if (!customThemes) {
|
||||
return;
|
||||
}
|
||||
|
||||
debugLogger.log(
|
||||
`Registering extension themes for "${extensionName}":`,
|
||||
customThemes,
|
||||
);
|
||||
|
||||
for (const customThemeConfig of customThemes) {
|
||||
const namespacedName = `${customThemeConfig.name} (${extensionName})`;
|
||||
|
||||
// Check for collisions with built-in themes (unlikely with prefix, but safe)
|
||||
if (this.availableThemes.some((t) => t.name === namespacedName)) {
|
||||
debugLogger.warn(
|
||||
`Theme name collision: "${namespacedName}" is a built-in theme. Skipping.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const validation = validateCustomTheme(customThemeConfig);
|
||||
if (validation.isValid) {
|
||||
if (validation.warning) {
|
||||
debugLogger.warn(`Theme "${namespacedName}": ${validation.warning}`);
|
||||
}
|
||||
const themeWithDefaults: CustomTheme = {
|
||||
...DEFAULT_THEME.colors,
|
||||
...customThemeConfig,
|
||||
name: namespacedName,
|
||||
type: 'custom',
|
||||
};
|
||||
|
||||
try {
|
||||
const theme = createCustomTheme(themeWithDefaults);
|
||||
this.extensionThemes.set(namespacedName, theme);
|
||||
debugLogger.log(`Registered theme: ${namespacedName}`);
|
||||
} catch (error) {
|
||||
debugLogger.warn(
|
||||
`Failed to load custom theme "${namespacedName}":`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
debugLogger.warn(
|
||||
`Invalid custom theme "${namespacedName}": ${validation.error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters custom themes from extensions.
|
||||
* @param extensionName The name of the extension.
|
||||
* @param customThemes Custom themes to unregister.
|
||||
*/
|
||||
unregisterExtensionThemes(
|
||||
extensionName: string,
|
||||
customThemes?: CustomTheme[],
|
||||
): void {
|
||||
if (!customThemes) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const theme of customThemes) {
|
||||
const namespacedName = `${theme.name} (${extensionName})`;
|
||||
this.extensionThemes.delete(namespacedName);
|
||||
debugLogger.log(`Unregistered theme: ${namespacedName}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all registered extension themes.
|
||||
* This is primarily for testing purposes to reset state between tests.
|
||||
*/
|
||||
clearExtensionThemes(): void {
|
||||
this.extensionThemes.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the active theme.
|
||||
* @param themeName The name of the theme to set as active.
|
||||
@@ -133,13 +223,23 @@ class ThemeManager {
|
||||
const isBuiltIn = this.availableThemes.some(
|
||||
(t) => t.name === this.activeTheme.name,
|
||||
);
|
||||
const isCustom = [...this.customThemes.values()].includes(
|
||||
this.activeTheme,
|
||||
);
|
||||
const isCustom =
|
||||
[...this.settingsThemes.values()].includes(this.activeTheme) ||
|
||||
[...this.extensionThemes.values()].includes(this.activeTheme) ||
|
||||
[...this.fileThemes.values()].includes(this.activeTheme);
|
||||
|
||||
if (isBuiltIn || isCustom) {
|
||||
return this.activeTheme;
|
||||
}
|
||||
|
||||
// If the theme object is no longer valid, try to find it again by name.
|
||||
// This handles the case where extensions are reloaded and theme objects
|
||||
// are re-created.
|
||||
const reloadedTheme = this.findThemeByName(this.activeTheme.name);
|
||||
if (reloadedTheme) {
|
||||
this.activeTheme = reloadedTheme;
|
||||
return this.activeTheme;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to default if no active theme or if it's no longer valid.
|
||||
@@ -155,12 +255,20 @@ class ThemeManager {
|
||||
return this.getActiveTheme().semanticColors;
|
||||
}
|
||||
|
||||
private _getAllCustomThemes(): Theme[] {
|
||||
return [
|
||||
...Array.from(this.settingsThemes.values()),
|
||||
...Array.from(this.extensionThemes.values()),
|
||||
...Array.from(this.fileThemes.values()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a list of custom theme names.
|
||||
* @returns Array of custom theme names.
|
||||
*/
|
||||
getCustomThemeNames(): string[] {
|
||||
return Array.from(this.customThemes.keys());
|
||||
return this._getAllCustomThemes().map((theme) => theme.name);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -169,7 +277,11 @@ class ThemeManager {
|
||||
* @returns True if the theme is custom.
|
||||
*/
|
||||
isCustomTheme(themeName: string): boolean {
|
||||
return this.customThemes.has(themeName);
|
||||
return (
|
||||
this.settingsThemes.has(themeName) ||
|
||||
this.extensionThemes.has(themeName) ||
|
||||
this.fileThemes.has(themeName)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,13 +294,11 @@ class ThemeManager {
|
||||
isCustom: false,
|
||||
}));
|
||||
|
||||
const customThemes = Array.from(this.customThemes.values()).map(
|
||||
(theme) => ({
|
||||
name: theme.name,
|
||||
type: theme.type,
|
||||
isCustom: true,
|
||||
}),
|
||||
);
|
||||
const customThemes = this._getAllCustomThemes().map((theme) => ({
|
||||
name: theme.name,
|
||||
type: theme.type,
|
||||
isCustom: true,
|
||||
}));
|
||||
|
||||
const allThemes = [...builtInThemes, ...customThemes];
|
||||
|
||||
@@ -232,7 +342,7 @@ class ThemeManager {
|
||||
* @returns A list of all available themes.
|
||||
*/
|
||||
getAllThemes(): Theme[] {
|
||||
return [...this.availableThemes, ...Array.from(this.customThemes.values())];
|
||||
return [...this.availableThemes, ...this._getAllCustomThemes()];
|
||||
}
|
||||
|
||||
private isPath(themeName: string): boolean {
|
||||
@@ -249,8 +359,8 @@ class ThemeManager {
|
||||
const canonicalPath = fs.realpathSync(path.resolve(themePath));
|
||||
|
||||
// 1. Check cache using the canonical path.
|
||||
if (this.customThemes.has(canonicalPath)) {
|
||||
return this.customThemes.get(canonicalPath);
|
||||
if (this.fileThemes.has(canonicalPath)) {
|
||||
return this.fileThemes.get(canonicalPath);
|
||||
}
|
||||
|
||||
// 2. Perform security check.
|
||||
@@ -288,7 +398,7 @@ class ThemeManager {
|
||||
};
|
||||
|
||||
const theme = createCustomTheme(themeWithDefaults);
|
||||
this.customThemes.set(canonicalPath, theme); // Cache by canonical path
|
||||
this.fileThemes.set(canonicalPath, theme); // Cache by canonical path
|
||||
return theme;
|
||||
} catch (error) {
|
||||
// Any error in the process (file not found, bad JSON, etc.) is caught here.
|
||||
@@ -318,13 +428,21 @@ class ThemeManager {
|
||||
return builtInTheme;
|
||||
}
|
||||
|
||||
// Then check custom themes that have been loaded from settings, or file paths
|
||||
// Then check custom themes that have been loaded from settings, extensions, or file paths
|
||||
if (this.isPath(themeName)) {
|
||||
return this.loadThemeFromFile(themeName);
|
||||
}
|
||||
|
||||
if (this.customThemes.has(themeName)) {
|
||||
return this.customThemes.get(themeName);
|
||||
if (this.settingsThemes.has(themeName)) {
|
||||
return this.settingsThemes.get(themeName);
|
||||
}
|
||||
|
||||
if (this.extensionThemes.has(themeName)) {
|
||||
return this.extensionThemes.get(themeName);
|
||||
}
|
||||
|
||||
if (this.fileThemes.has(themeName)) {
|
||||
return this.fileThemes.get(themeName);
|
||||
}
|
||||
|
||||
// If it's not a built-in, not in cache, and not a valid file path,
|
||||
|
||||
@@ -5,11 +5,15 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as themeModule from './theme.js';
|
||||
import {
|
||||
createCustomTheme,
|
||||
validateCustomTheme,
|
||||
pickDefaultThemeName,
|
||||
darkTheme,
|
||||
type Theme,
|
||||
} from './theme.js';
|
||||
import { themeManager } from './theme-manager.js';
|
||||
|
||||
const { validateCustomTheme, createCustomTheme } = themeModule;
|
||||
type CustomTheme = themeModule.CustomTheme;
|
||||
import type { CustomTheme } from '@google/gemini-cli-core';
|
||||
|
||||
describe('createCustomTheme', () => {
|
||||
const baseTheme: CustomTheme = {
|
||||
@@ -152,7 +156,6 @@ describe('themeManager.loadCustomThemes', () => {
|
||||
};
|
||||
|
||||
it('should use values from DEFAULT_THEME when DiffAdded and DiffRemoved are not provided', () => {
|
||||
const { darkTheme } = themeModule;
|
||||
const legacyTheme: Partial<CustomTheme> = { ...baseTheme };
|
||||
delete legacyTheme.DiffAdded;
|
||||
delete legacyTheme.DiffRemoved;
|
||||
@@ -170,12 +173,11 @@ describe('themeManager.loadCustomThemes', () => {
|
||||
});
|
||||
|
||||
describe('pickDefaultThemeName', () => {
|
||||
const { pickDefaultThemeName } = themeModule;
|
||||
const mockThemes = [
|
||||
{ name: 'Dark Theme', type: 'dark', colors: { Background: '#000000' } },
|
||||
{ name: 'Light Theme', type: 'light', colors: { Background: '#ffffff' } },
|
||||
{ name: 'Blue Theme', type: 'dark', colors: { Background: '#0000ff' } },
|
||||
] as unknown as themeModule.Theme[];
|
||||
] as unknown as Theme[];
|
||||
|
||||
it('should return exact match if found', () => {
|
||||
expect(
|
||||
|
||||
@@ -5,13 +5,19 @@
|
||||
*/
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import type { SemanticColors } from './semantic-tokens.js';
|
||||
|
||||
import {
|
||||
resolveColor,
|
||||
interpolateColor,
|
||||
getThemeTypeFromBackgroundColor,
|
||||
} from './color-utils.js';
|
||||
|
||||
import type { CustomTheme } from '@google/gemini-cli-core';
|
||||
|
||||
export type { CustomTheme };
|
||||
|
||||
export type ThemeType = 'light' | 'dark' | 'ansi' | 'custom';
|
||||
|
||||
export interface ColorsTheme {
|
||||
@@ -33,57 +39,6 @@ export interface ColorsTheme {
|
||||
GradientColors?: string[];
|
||||
}
|
||||
|
||||
export interface CustomTheme {
|
||||
type: 'custom';
|
||||
name: string;
|
||||
|
||||
text?: {
|
||||
primary?: string;
|
||||
secondary?: string;
|
||||
link?: string;
|
||||
accent?: string;
|
||||
response?: string;
|
||||
};
|
||||
background?: {
|
||||
primary?: string;
|
||||
diff?: {
|
||||
added?: string;
|
||||
removed?: string;
|
||||
};
|
||||
};
|
||||
border?: {
|
||||
default?: string;
|
||||
focused?: string;
|
||||
};
|
||||
ui?: {
|
||||
comment?: string;
|
||||
symbol?: string;
|
||||
gradient?: string[];
|
||||
};
|
||||
status?: {
|
||||
error?: string;
|
||||
success?: string;
|
||||
warning?: string;
|
||||
};
|
||||
|
||||
// Legacy properties (all optional)
|
||||
Background?: string;
|
||||
Foreground?: string;
|
||||
LightBlue?: string;
|
||||
AccentBlue?: string;
|
||||
AccentPurple?: string;
|
||||
AccentCyan?: string;
|
||||
AccentGreen?: string;
|
||||
AccentYellow?: string;
|
||||
AccentRed?: string;
|
||||
DiffAdded?: string;
|
||||
DiffRemoved?: string;
|
||||
Comment?: string;
|
||||
Gray?: string;
|
||||
DarkGray?: string;
|
||||
GradientColors?: string[];
|
||||
}
|
||||
|
||||
export const lightTheme: ColorsTheme = {
|
||||
type: 'light',
|
||||
Background: '#FAFAFA',
|
||||
|
||||
Reference in New Issue
Block a user