diff --git a/packages/cli/src/commands/extensions/examples/themes-example/README.md b/packages/cli/src/commands/extensions/examples/themes-example/README.md new file mode 100644 index 0000000000..b8eb87229c --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/themes-example/README.md @@ -0,0 +1,28 @@ +# Themes Example + +This is an example of a Gemini CLI extension that adds a custom theme. + +## How to use + +1. Link this extension: + + ```bash + gemini extensions link packages/cli/src/commands/extensions/examples/themes-example + ``` + +2. Set the theme in your settings file (`~/.gemini/config.yaml`): + + ```yaml + ui: + theme: 'shades-of-green-theme (themes-example)' + ``` + + Alternatively, you can set it through the UI by running `gemini` and then + typing `/theme` and pressing Enter. + +3. **Observe the Changes:** + + After setting the theme, you should see the changes reflected in the Gemini + CLI's UI. The background will be a dark green, the primary text a lighter + green, and various other UI elements will display different shades of green, + as defined in this extension's `gemini-extension.json` file. diff --git a/packages/cli/src/commands/extensions/examples/themes-example/gemini-extension.json b/packages/cli/src/commands/extensions/examples/themes-example/gemini-extension.json new file mode 100644 index 0000000000..47a26c5105 --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/themes-example/gemini-extension.json @@ -0,0 +1,29 @@ +{ + "name": "themes-example", + "version": "1.0.0", + "themes": [ + { + "name": "shades-of-green-theme", + "type": "custom", + "background": { + "primary": "#1a362a" + }, + "text": { + "primary": "#a6e3a1", + "secondary": "#6e8e7a", + "link": "#89e689" + }, + "status": { + "success": "#76c076", + "warning": "#d9e689", + "error": "#b34e4e" + }, + "border": { + "default": "#4a6c5a" + }, + "ui": { + "comment": "#6e8e7a" + } + } + ] +} diff --git a/packages/cli/src/config/extension-manager-themes.spec.ts b/packages/cli/src/config/extension-manager-themes.spec.ts new file mode 100644 index 0000000000..90b0363e92 --- /dev/null +++ b/packages/cli/src/config/extension-manager-themes.spec.ts @@ -0,0 +1,226 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + beforeAll, + afterAll, + beforeEach, + describe, + expect, + it, + vi, + afterEach, +} from 'vitest'; +import { createExtension } from '../test-utils/createExtension.js'; +import { ExtensionManager } from './extension-manager.js'; +import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js'; +import { GEMINI_DIR, type Config } from '@google/gemini-cli-core'; +import { createTestMergedSettings, SettingScope } from './settings.js'; + +describe('ExtensionManager theme loading', () => { + let extensionManager: ExtensionManager; + let userExtensionsDir: string; + let tempHomeDir: string; + + beforeAll(async () => { + tempHomeDir = await fs.promises.mkdtemp( + path.join(fs.realpathSync('/tmp'), 'gemini-cli-test-'), + ); + }); + + afterAll(async () => { + if (tempHomeDir) { + await fs.promises.rm(tempHomeDir, { recursive: true, force: true }); + } + }); + + beforeEach(() => { + process.env['GEMINI_CLI_HOME'] = tempHomeDir; + userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions'); + // Ensure userExtensionsDir is clean for each test + fs.rmSync(userExtensionsDir, { recursive: true, force: true }); + fs.mkdirSync(userExtensionsDir, { recursive: true }); + + extensionManager = new ExtensionManager({ + settings: createTestMergedSettings({ + experimental: { extensionConfig: true }, + security: { blockGitExtensions: false }, + admin: { extensions: { enabled: true }, mcp: { enabled: true } }, + tools: { enableHooks: true }, + }), + requestConsent: async () => true, + requestSetting: async () => '', + workspaceDir: tempHomeDir, + enabledExtensionOverrides: [], + }); + vi.clearAllMocks(); + themeManager.clearExtensionThemes(); + themeManager.loadCustomThemes({}); + themeManager.setActiveTheme(DEFAULT_THEME.name); + }); + + afterEach(() => { + delete process.env['GEMINI_CLI_HOME']; + }); + + it('should register themes from an extension when started', async () => { + const registerSpy = vi.spyOn(themeManager, 'registerExtensionThemes'); + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-theme-extension', + themes: [ + { + name: 'My-Awesome-Theme', + type: 'custom', + text: { + primary: '#FF00FF', + }, + }, + ], + }); + + await extensionManager.loadExtensions(); + + const mockConfig = { + getEnableExtensionReloading: () => false, + getMcpClientManager: () => ({ + startExtension: vi.fn().mockResolvedValue(undefined), + }), + getGeminiClient: () => ({ + isInitialized: () => false, + updateSystemInstruction: vi.fn(), + setTools: vi.fn(), + }), + getHookSystem: () => undefined, + getWorkingDir: () => tempHomeDir, + shouldLoadMemoryFromIncludeDirectories: () => false, + getDebugMode: () => false, + getFileExclusions: () => ({ + isIgnored: () => false, + }), + getGeminiMdFilePaths: () => [], + getMcpServers: () => ({}), + getAllowedMcpServers: () => [], + getSanitizationConfig: () => ({ + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: [], + enableEnvironmentVariableRedaction: false, + }), + getShellExecutionConfig: () => ({ + terminalWidth: 80, + terminalHeight: 24, + showColor: false, + pager: 'cat', + sanitizationConfig: { + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: [], + enableEnvironmentVariableRedaction: false, + }, + }), + getToolRegistry: () => ({ + getTools: () => [], + }), + getProxy: () => undefined, + getFileService: () => ({ + findFiles: async () => [], + }), + getExtensionLoader: () => ({ + getExtensions: () => [], + }), + isTrustedFolder: () => true, + getImportFormat: () => 'tree', + } as unknown as Config; + + await extensionManager.start(mockConfig); + + expect(registerSpy).toHaveBeenCalledWith('my-theme-extension', [ + { + name: 'My-Awesome-Theme', + type: 'custom', + text: { + primary: '#FF00FF', + }, + }, + ]); + }); + + it('should revert to default theme when extension is stopped', async () => { + const extensionName = 'my-theme-extension'; + const themeName = 'My-Awesome-Theme'; + const namespacedThemeName = `${themeName} (${extensionName})`; + + createExtension({ + extensionsDir: userExtensionsDir, + name: extensionName, + themes: [ + { + name: themeName, + type: 'custom', + text: { + primary: '#FF00FF', + }, + }, + ], + }); + + await extensionManager.loadExtensions(); + + const mockConfig = { + getWorkingDir: () => tempHomeDir, + shouldLoadMemoryFromIncludeDirectories: () => false, + getWorkspaceContext: () => ({ + getDirectories: () => [], + }), + getDebugMode: () => false, + getFileService: () => ({ + findFiles: async () => [], + }), + getExtensionLoader: () => ({ + getExtensions: () => [], + }), + isTrustedFolder: () => true, + getImportFormat: () => 'tree', + getFileFilteringOptions: () => ({ + respectGitIgnore: true, + respectGeminiIgnore: true, + }), + getDiscoveryMaxDirs: () => 200, + getMcpClientManager: () => ({ + getMcpInstructions: () => '', + startExtension: vi.fn().mockResolvedValue(undefined), + stopExtension: vi.fn().mockResolvedValue(undefined), + }), + setUserMemory: vi.fn(), + setGeminiMdFileCount: vi.fn(), + setGeminiMdFilePaths: vi.fn(), + getEnableExtensionReloading: () => true, + getGeminiClient: () => ({ + isInitialized: () => false, + updateSystemInstruction: vi.fn(), + setTools: vi.fn(), + }), + getHookSystem: () => undefined, + getProxy: () => undefined, + getAgentRegistry: () => ({ + reload: vi.fn().mockResolvedValue(undefined), + }), + } as unknown as Config; + + await extensionManager.start(mockConfig); + + // Set the active theme to the one from the extension + themeManager.setActiveTheme(namespacedThemeName); + expect(themeManager.getActiveTheme().name).toBe(namespacedThemeName); + + // Stop the extension + await extensionManager.disableExtension(extensionName, SettingScope.User); + + // Check that the active theme has reverted to the default + expect(themeManager.getActiveTheme().name).toBe(DEFAULT_THEME.name); + }); +}); diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 7e05df554a..a7add924be 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -69,6 +69,7 @@ import { ExtensionSettingScope, } from './extensions/extensionSettings.js'; import type { EventEmitter } from 'node:stream'; +import { themeManager } from '../ui/themes/theme-manager.js'; interface ExtensionManagerParams { enabledExtensionOverrides?: string[]; @@ -468,6 +469,20 @@ Would you like to attempt to install via "git clone" instead?`, ); } + protected override async startExtension(extension: GeminiCLIExtension) { + await super.startExtension(extension); + if (extension.themes) { + themeManager.registerExtensionThemes(extension.name, extension.themes); + } + } + + protected override async stopExtension(extension: GeminiCLIExtension) { + await super.stopExtension(extension); + if (extension.themes) { + themeManager.unregisterExtensionThemes(extension.name, extension.themes); + } + } + /** * Loads all installed extensions, should only be called once. */ @@ -698,6 +713,7 @@ Would you like to attempt to install via "git clone" instead?`, resolvedSettings, skills, agents: agentLoadResult.agents, + themes: config.themes, }; this.loadedExtensions = [...this.loadedExtensions, extension]; diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index bafaba59a8..b6256fc83b 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -7,6 +7,7 @@ import type { MCPServerConfig, ExtensionInstallMetadata, + CustomTheme, } from '@google/gemini-cli-core'; import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -27,6 +28,11 @@ export interface ExtensionConfig { contextFileName?: string | string[]; excludeTools?: string[]; settings?: ExtensionSetting[]; + /** + * Custom themes contributed by this extension. + * These themes will be registered when the extension is activated. + */ + themes?: CustomTheme[]; } export interface ExtensionUpdateInfo { diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index ff8ea13c55..9cfde9fab6 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -9,19 +9,17 @@ // to regenerate the settings reference in `docs/get-started/configuration.md`. // -------------------------------------------------------------------------- -import type { - MCPServerConfig, - BugCommandSettings, - TelemetrySettings, - AuthType, - AgentOverride, -} from '@google/gemini-cli-core'; import { DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, DEFAULT_MODEL_CONFIGS, + type MCPServerConfig, + type BugCommandSettings, + type TelemetrySettings, + type AuthType, + type AgentOverride, + type CustomTheme, } from '@google/gemini-cli-core'; -import type { CustomTheme } from '../ui/themes/theme.js'; import type { SessionRetentionSettings } from './settings.js'; import { DEFAULT_MIN_RETENTION } from '../utils/sessionCleanup.js'; diff --git a/packages/cli/src/test-utils/createExtension.ts b/packages/cli/src/test-utils/createExtension.ts index f7ad425f06..56d02e7053 100644 --- a/packages/cli/src/test-utils/createExtension.ts +++ b/packages/cli/src/test-utils/createExtension.ts @@ -9,12 +9,13 @@ import * as path from 'node:path'; import { type MCPServerConfig, type ExtensionInstallMetadata, + type ExtensionSetting, + type CustomTheme, } from '@google/gemini-cli-core'; import { EXTENSIONS_CONFIG_FILENAME, INSTALL_METADATA_FILENAME, } from '../config/extensions/variables.js'; -import type { ExtensionSetting } from '../config/extensions/extensionSettings.js'; export function createExtension({ extensionsDir = 'extensions-dir', @@ -25,12 +26,20 @@ export function createExtension({ mcpServers = {} as Record, installMetadata = undefined as ExtensionInstallMetadata | undefined, settings = undefined as ExtensionSetting[] | undefined, + themes = undefined as CustomTheme[] | undefined, } = {}): string { const extDir = path.join(extensionsDir, name); fs.mkdirSync(extDir, { recursive: true }); fs.writeFileSync( path.join(extDir, EXTENSIONS_CONFIG_FILENAME), - JSON.stringify({ name, version, contextFileName, mcpServers, settings }), + JSON.stringify({ + name, + version, + contextFileName, + mcpServers, + settings, + themes, + }), ); if (addContextFile) { diff --git a/packages/cli/src/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx index 00298d49d3..f04ae5172a 100644 --- a/packages/cli/src/ui/components/ThemeDialog.tsx +++ b/packages/cli/src/ui/components/ThemeDialog.tsx @@ -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]}{' '} + ({match[2]}) + + ); + } + return ( - {item.themeNameDisplay}{' '} + {themeNamePart}{' '} {item.themeTypeDisplay} diff --git a/packages/cli/src/ui/themes/theme-manager.test.ts b/packages/cli/src/ui/themes/theme-manager.test.ts index 02ef4ff633..e80c03c5e1 100644 --- a/packages/cli/src/ui/themes/theme-manager.test.ts +++ b/packages/cli/src/ui/themes/theme-manager.test.ts @@ -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); + }); + }); }); diff --git a/packages/cli/src/ui/themes/theme-manager.ts b/packages/cli/src/ui/themes/theme-manager.ts index ef67f7fc25..c44c5adb98 100644 --- a/packages/cli/src/ui/themes/theme-manager.ts +++ b/packages/cli/src/ui/themes/theme-manager.ts @@ -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 = new Map(); + private settingsThemes: Map = new Map(); + private extensionThemes: Map = new Map(); + private fileThemes: Map = new Map(); constructor() { this.availableThemes = [ @@ -65,7 +68,7 @@ class ThemeManager { * @param customThemesSettings Custom themes from settings. */ loadCustomThemes(customThemesSettings?: Record): 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, diff --git a/packages/cli/src/ui/themes/theme.test.ts b/packages/cli/src/ui/themes/theme.test.ts index b699893766..7240b04fa6 100644 --- a/packages/cli/src/ui/themes/theme.test.ts +++ b/packages/cli/src/ui/themes/theme.test.ts @@ -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 = { ...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( diff --git a/packages/cli/src/ui/themes/theme.ts b/packages/cli/src/ui/themes/theme.ts index 5ba11cb32d..e95799b879 100644 --- a/packages/cli/src/ui/themes/theme.ts +++ b/packages/cli/src/ui/themes/theme.ts @@ -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', diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index d432086155..0f027c989c 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -178,6 +178,57 @@ export interface AgentSettings { overrides?: Record; } +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[]; +} + /** * All information required in CLI to handle an extension. Defined in Core so * that the collection of loaded, active, and inactive extensions can be passed @@ -199,6 +250,11 @@ export interface GeminiCLIExtension { resolvedSettings?: ResolvedExtensionSetting[]; skills?: SkillDefinition[]; agents?: AgentDefinition[]; + /** + * Custom themes contributed by this extension. + * These themes will be registered when the extension is activated. + */ + themes?: CustomTheme[]; } export interface ExtensionInstallMetadata {