feat(extensions): add support for custom themes in extensions (#17327)

This commit is contained in:
Spencer
2026-01-28 13:58:35 -05:00
committed by GitHub
parent 47f4a3e50e
commit beaa134f0e
13 changed files with 611 additions and 129 deletions
+144 -26
View File
@@ -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,