From dd57de477bc5aea198bebff05a2e6e1960385f68 Mon Sep 17 00:00:00 2001 From: Spencer Tang Date: Thu, 22 Jan 2026 13:20:20 -0500 Subject: [PATCH 1/6] feat(extensions): add support for custom themes in extensions This change introduces the ability for extensions to define and register custom themes. Extensions can now include a property in their file, and these themes will be loaded and made available to the user. Fixes #16192 --- .../config/extension-manager-themes.spec.ts | 120 +++++++++++++ packages/cli/src/config/extension-manager.ts | 16 ++ packages/cli/src/config/extension.ts | 6 + .../cli/src/test-utils/createExtension.ts | 13 +- .../cli/src/ui/components/ThemeDialog.tsx | 44 ++--- .../cli/src/ui/themes/theme-manager.test.ts | 50 ++++++ packages/cli/src/ui/themes/theme-manager.ts | 163 +++++++++++++++--- packages/core/src/config/config.ts | 56 ++++++ 8 files changed, 413 insertions(+), 55 deletions(-) create mode 100644 packages/cli/src/config/extension-manager-themes.spec.ts 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 00000000000..e73c06d5e68 --- /dev/null +++ b/packages/cli/src/config/extension-manager-themes.spec.ts @@ -0,0 +1,120 @@ +/** + * @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, +} from 'vitest'; +import { createExtension } from '../test-utils/createExtension.js'; +import { ExtensionManager } from './extension-manager.js'; +import { themeManager } from '../ui/themes/theme-manager.js'; +import { + type CustomTheme, + GEMINI_DIR, + type Config, +} from '@google/gemini-cli-core'; +import { createTestMergedSettings } from './settings.js'; + +vi.mock('../ui/themes/theme-manager.js', () => ({ + themeManager: { + registerExtensionThemes: vi.fn(), + }, +})); + +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(); + }); + + afterEach(() => { + delete process.env['GEMINI_CLI_HOME']; + }); + + it('should register themes from an extension when started', async () => { + 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, + }), + getHookSystem: () => undefined, + } as unknown as Config; + + await extensionManager.start(mockConfig); + + expect(themeManager.registerExtensionThemes).toHaveBeenCalledWith( + 'my-theme-extension', + [ + { + name: 'My-Awesome-Theme', + type: 'custom', + text: { + primary: '#FF00FF', + }, + }, + ] as CustomTheme[], + ); + }); +}); diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 8dbbfe305b6..76dc1209480 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -68,6 +68,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[]; @@ -467,6 +468,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. */ @@ -656,6 +671,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 bafaba59a8e..b6256fc83bc 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/test-utils/createExtension.ts b/packages/cli/src/test-utils/createExtension.ts index f7ad425f064..56d02e70535 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 3b5324e8f51..a421b156c39 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( diff --git a/packages/cli/src/ui/themes/theme-manager.test.ts b/packages/cli/src/ui/themes/theme-manager.test.ts index 02ef4ff6336..e792df1476c 100644 --- a/packages/cli/src/ui/themes/theme-manager.test.ts +++ b/packages/cli/src/ui/themes/theme-manager.test.ts @@ -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 = `${extensionName}: ExtensionTheme`; + + 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('Ext: Theme'); + }); + + 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( + 'Ext: ExtensionTheme', + ); + expect(themeManager.getCustomThemeNames()).toContain('SettingsTheme'); + + expect(themeManager.isCustomTheme('Ext: ExtensionTheme')).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 ef67f7fc255..e179a784dfb 100644 --- a/packages/cli/src/ui/themes/theme-manager.ts +++ b/packages/cli/src/ui/themes/theme-manager.ts @@ -38,7 +38,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 +67,7 @@ class ThemeManager { * @param customThemesSettings Custom themes from settings. */ loadCustomThemes(customThemesSettings?: Record): void { - this.customThemes.clear(); + this.settingsThemes.clear(); if (!customThemesSettings) { return; @@ -88,7 +90,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,13 +98,92 @@ 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 = `${extensionName}: ${customThemeConfig.name}`; + + // 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 = `${extensionName}: ${theme.name}`; + this.extensionThemes.delete(namespacedName); + debugLogger.log(`Unregistered theme: ${namespacedName}`); } } @@ -133,9 +214,10 @@ 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; @@ -160,7 +242,11 @@ class ThemeManager { * @returns Array of custom theme names. */ getCustomThemeNames(): string[] { - return Array.from(this.customThemes.keys()); + return [ + ...Array.from(this.settingsThemes.keys()), + ...Array.from(this.extensionThemes.keys()), + ...Array.from(this.fileThemes.keys()), + ]; } /** @@ -169,7 +255,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,7 +272,7 @@ class ThemeManager { isCustom: false, })); - const customThemes = Array.from(this.customThemes.values()).map( + const settingsThemes = Array.from(this.settingsThemes.values()).map( (theme) => ({ name: theme.name, type: theme.type, @@ -190,7 +280,27 @@ class ThemeManager { }), ); - const allThemes = [...builtInThemes, ...customThemes]; + const extensionThemes = Array.from(this.extensionThemes.values()).map( + (theme) => ({ + name: theme.name, + type: theme.type, + isCustom: true, + }), + ); + + const fileThemes = Array.from(this.fileThemes.values()).map((theme) => ({ + name: theme.name, + type: theme.type, + isCustom: true, + })); + + const allThemes = [ + ...builtInThemes, + ...settingsThemes, + ...extensionThemes, + ...fileThemes, + ]; + debugLogger.log('Available themes:', allThemes); const sortedThemes = allThemes.sort((a, b) => { const typeOrder = (type: ThemeType): number => { @@ -232,7 +342,12 @@ class ThemeManager { * @returns A list of all available themes. */ getAllThemes(): Theme[] { - return [...this.availableThemes, ...Array.from(this.customThemes.values())]; + return [ + ...this.availableThemes, + ...Array.from(this.settingsThemes.values()), + ...Array.from(this.extensionThemes.values()), + ...Array.from(this.fileThemes.values()), + ]; } private isPath(themeName: string): boolean { @@ -249,8 +364,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 +403,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 +433,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/core/src/config/config.ts b/packages/core/src/config/config.ts index 15a1bcb85f5..5c8970e1abd 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -187,6 +187,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 @@ -208,6 +259,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 { From 92bad52dce53e2a9ec14c88f028dd54fd43d2d1c Mon Sep 17 00:00:00 2001 From: Spencer Tang Date: Thu, 22 Jan 2026 21:25:08 -0500 Subject: [PATCH 2/6] feat: address code review comments for custom themes --- .../examples/themes-example/README.md | 30 ++++ .../themes-example/gemini-extension.json | 13 ++ .../config/extension-manager-themes.spec.ts | 138 +++++++++++++++--- packages/cli/src/config/settingsSchema.ts | 14 +- .../cli/src/ui/themes/theme-manager.test.ts | 2 +- packages/cli/src/ui/themes/theme-manager.ts | 50 ++----- packages/cli/src/ui/themes/theme.test.ts | 16 +- packages/cli/src/ui/themes/theme.ts | 70 +++------ 8 files changed, 212 insertions(+), 121 deletions(-) create mode 100644 packages/cli/src/commands/extensions/examples/themes-example/README.md create mode 100644 packages/cli/src/commands/extensions/examples/themes-example/gemini-extension.json 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 00000000000..83f6d313085 --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/themes-example/README.md @@ -0,0 +1,30 @@ +# 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/settings.json`): + + ```json + { + "ui": { + "theme": "themes-example: My-Awesome-Theme" + } + } + ``` + + Alternatively, you can set it through the UI by running `gemini` and then + pressing `t`. + +3. **Observe the Changes:** + + After setting the theme, you should see the changes reflected in the Gemini + CLI's UI. The primary text color should now be magenta (`#FF00FF`), as + defined in the `themes-example`'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 00000000000..285c5c0b7ed --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/themes-example/gemini-extension.json @@ -0,0 +1,13 @@ +{ + "name": "themes-example", + "version": "1.0.0", + "themes": [ + { + "name": "My-Awesome-Theme", + "type": "custom", + "text": { + "primary": "#FF00FF" + } + } + ] +} diff --git a/packages/cli/src/config/extension-manager-themes.spec.ts b/packages/cli/src/config/extension-manager-themes.spec.ts index e73c06d5e68..7ca1151fbee 100644 --- a/packages/cli/src/config/extension-manager-themes.spec.ts +++ b/packages/cli/src/config/extension-manager-themes.spec.ts @@ -14,22 +14,13 @@ import { expect, it, vi, + afterEach, } from 'vitest'; import { createExtension } from '../test-utils/createExtension.js'; import { ExtensionManager } from './extension-manager.js'; -import { themeManager } from '../ui/themes/theme-manager.js'; -import { - type CustomTheme, - GEMINI_DIR, - type Config, -} from '@google/gemini-cli-core'; -import { createTestMergedSettings } from './settings.js'; - -vi.mock('../ui/themes/theme-manager.js', () => ({ - themeManager: { - registerExtensionThemes: vi.fn(), - }, -})); +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; @@ -75,6 +66,7 @@ describe('ExtensionManager theme loading', () => { }); it('should register themes from an extension when started', async () => { + const registerSpy = vi.spyOn(themeManager, 'registerExtensionThemes'); createExtension({ extensionsDir: userExtensionsDir, name: 'my-theme-extension', @@ -98,23 +90,131 @@ describe('ExtensionManager theme loading', () => { }), 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(themeManager.registerExtensionThemes).toHaveBeenCalledWith( - 'my-theme-extension', - [ + 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 = `${extensionName}: ${themeName}`; + + createExtension({ + extensionsDir: userExtensionsDir, + name: extensionName, + themes: [ { - name: 'My-Awesome-Theme', + name: themeName, type: 'custom', text: { primary: '#FF00FF', }, }, - ] as CustomTheme[], - ); + ], + }); + + 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, + } 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/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 96ec8c9ff13..2da18be8e97 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -9,20 +9,18 @@ // 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, GEMINI_MODEL_ALIAS_AUTO, + 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/ui/themes/theme-manager.test.ts b/packages/cli/src/ui/themes/theme-manager.test.ts index e792df1476c..19afb821ce5 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'; diff --git a/packages/cli/src/ui/themes/theme-manager.ts b/packages/cli/src/ui/themes/theme-manager.ts index e179a784dfb..6b2a8ac9d01 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'; @@ -237,16 +238,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.settingsThemes.keys()), - ...Array.from(this.extensionThemes.keys()), - ...Array.from(this.fileThemes.keys()), - ]; + return this._getAllCustomThemes().map((theme) => theme.name); } /** @@ -272,35 +277,13 @@ class ThemeManager { isCustom: false, })); - const settingsThemes = Array.from(this.settingsThemes.values()).map( - (theme) => ({ - name: theme.name, - type: theme.type, - isCustom: true, - }), - ); - - const extensionThemes = Array.from(this.extensionThemes.values()).map( - (theme) => ({ - name: theme.name, - type: theme.type, - isCustom: true, - }), - ); - - const fileThemes = Array.from(this.fileThemes.values()).map((theme) => ({ + const customThemes = this._getAllCustomThemes().map((theme) => ({ name: theme.name, type: theme.type, isCustom: true, })); - const allThemes = [ - ...builtInThemes, - ...settingsThemes, - ...extensionThemes, - ...fileThemes, - ]; - debugLogger.log('Available themes:', allThemes); + const allThemes = [...builtInThemes, ...customThemes]; const sortedThemes = allThemes.sort((a, b) => { const typeOrder = (type: ThemeType): number => { @@ -342,12 +325,7 @@ class ThemeManager { * @returns A list of all available themes. */ getAllThemes(): Theme[] { - return [ - ...this.availableThemes, - ...Array.from(this.settingsThemes.values()), - ...Array.from(this.extensionThemes.values()), - ...Array.from(this.fileThemes.values()), - ]; + return [...this.availableThemes, ...this._getAllCustomThemes()]; } private isPath(themeName: string): boolean { diff --git a/packages/cli/src/ui/themes/theme.test.ts b/packages/cli/src/ui/themes/theme.test.ts index b699893766f..7240b04fa65 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 5ba11cb32df..d03fba29742 100644 --- a/packages/cli/src/ui/themes/theme.ts +++ b/packages/cli/src/ui/themes/theme.ts @@ -5,82 +5,52 @@ */ 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 { type: ThemeType; + 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 interface CustomTheme { - type: 'custom'; - name: string; + Gray: 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; - }; + DarkGray: 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[]; } From c9ec1c4720c39f8e9f3e59e71221105c21e338cf Mon Sep 17 00:00:00 2001 From: Spencer Tang Date: Thu, 22 Jan 2026 22:10:28 -0500 Subject: [PATCH 3/6] fix(cli): correct mock config for extension theme tests The test in was failing because the provided to was missing the method. This commit adds the missing with a mocked method to the to correctly reflect the interface expected by . --- packages/cli/src/config/extension-manager-themes.spec.ts | 3 +++ packages/cli/src/config/settingsSchema.ts | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/config/extension-manager-themes.spec.ts b/packages/cli/src/config/extension-manager-themes.spec.ts index 7ca1151fbee..07c4dca6000 100644 --- a/packages/cli/src/config/extension-manager-themes.spec.ts +++ b/packages/cli/src/config/extension-manager-themes.spec.ts @@ -203,6 +203,9 @@ describe('ExtensionManager theme loading', () => { }), getHookSystem: () => undefined, getProxy: () => undefined, + getAgentRegistry: () => ({ + reload: vi.fn().mockResolvedValue(undefined), + }), } as unknown as Config; await extensionManager.start(mockConfig); diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 24eeb5dc5d3..fc77aa5c043 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -13,7 +13,6 @@ import { DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, DEFAULT_MODEL_CONFIGS, - GEMINI_MODEL_ALIAS_AUTO, type MCPServerConfig, type BugCommandSettings, type TelemetrySettings, From 686599f98f092c9292bd5b966e470fa1a46e7ef1 Mon Sep 17 00:00:00 2001 From: Spencer Tang Date: Mon, 26 Jan 2026 13:00:18 -0500 Subject: [PATCH 4/6] feat(themes): Improve theme persistence and display Addresses feedback on the custom themes feature: - Fixes an issue where the active custom theme from an extension would be lost upon extension reload. is now more resilient and re-finds themes by name after they are re-registered. - Improves the display format of extension-provided themes in the theme selection dialog. The format is now with the extension name de-emphasized, making it consistent with other UI elements. - Updates the theme example to be more comprehensive ('shades of green') and corrects the instructions in the README, including the now-accurate slash command. - Removes unnecessary whitespace from the interface in . --- .../examples/themes-example/README.md | 20 +++++++++---------- .../themes-example/gemini-extension.json | 20 +++++++++++++++++-- .../cli/src/ui/components/ThemeDialog.tsx | 13 +++++++++++- packages/cli/src/ui/themes/theme-manager.ts | 13 ++++++++++-- packages/cli/src/ui/themes/theme.ts | 15 -------------- 5 files changed, 50 insertions(+), 31 deletions(-) diff --git a/packages/cli/src/commands/extensions/examples/themes-example/README.md b/packages/cli/src/commands/extensions/examples/themes-example/README.md index 83f6d313085..b8eb87229c7 100644 --- a/packages/cli/src/commands/extensions/examples/themes-example/README.md +++ b/packages/cli/src/commands/extensions/examples/themes-example/README.md @@ -10,21 +10,19 @@ This is an example of a Gemini CLI extension that adds a custom theme. gemini extensions link packages/cli/src/commands/extensions/examples/themes-example ``` -2. Set the theme in your settings file (`~/.gemini/settings.json`): - - ```json - { - "ui": { - "theme": "themes-example: My-Awesome-Theme" - } - } +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 - pressing `t`. + 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 primary text color should now be magenta (`#FF00FF`), as - defined in the `themes-example`'s `gemini-extension.json` file. + 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 index 285c5c0b7ed..47a26c51054 100644 --- a/packages/cli/src/commands/extensions/examples/themes-example/gemini-extension.json +++ b/packages/cli/src/commands/extensions/examples/themes-example/gemini-extension.json @@ -3,10 +3,26 @@ "version": "1.0.0", "themes": [ { - "name": "My-Awesome-Theme", + "name": "shades-of-green-theme", "type": "custom", + "background": { + "primary": "#1a362a" + }, "text": { - "primary": "#FF00FF" + "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/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx index a421b156c39..2f0c299f512 100644 --- a/packages/cli/src/ui/components/ThemeDialog.tsx +++ b/packages/cli/src/ui/components/ThemeDialog.tsx @@ -289,9 +289,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.ts b/packages/cli/src/ui/themes/theme-manager.ts index 6b2a8ac9d01..ba5ca6d06ec 100644 --- a/packages/cli/src/ui/themes/theme-manager.ts +++ b/packages/cli/src/ui/themes/theme-manager.ts @@ -128,7 +128,7 @@ class ThemeManager { ); for (const customThemeConfig of customThemes) { - const namespacedName = `${extensionName}: ${customThemeConfig.name}`; + 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)) { @@ -182,7 +182,7 @@ class ThemeManager { } for (const theme of customThemes) { - const namespacedName = `${extensionName}: ${theme.name}`; + const namespacedName = `${theme.name} (${extensionName})`; this.extensionThemes.delete(namespacedName); debugLogger.log(`Unregistered theme: ${namespacedName}`); } @@ -223,6 +223,15 @@ class ThemeManager { 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. diff --git a/packages/cli/src/ui/themes/theme.ts b/packages/cli/src/ui/themes/theme.ts index d03fba29742..e95799b8792 100644 --- a/packages/cli/src/ui/themes/theme.ts +++ b/packages/cli/src/ui/themes/theme.ts @@ -22,35 +22,20 @@ export type ThemeType = 'light' | 'dark' | 'ansi' | 'custom'; export interface ColorsTheme { type: ThemeType; - 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[]; } From a44d08455c3de21d2e52315234a52a8308697c6e Mon Sep 17 00:00:00 2001 From: Spencer Tang Date: Mon, 26 Jan 2026 13:00:18 -0500 Subject: [PATCH 5/6] feat(themes): Improve theme persistence and display Addresses feedback on the custom themes feature: - Fixes an issue where the active custom theme from an extension would be lost upon extension reload. is now more resilient and re-finds themes by name after they are re-registered. - Improves the display format of extension-provided themes in the theme selection dialog. The format is now with the extension name de-emphasized, making it consistent with other UI elements. - Updates the theme example to be more comprehensive ('shades of green') and corrects the instructions in the README, including the now-accurate slash command. - Removes unnecessary whitespace from the interface in . --- packages/cli/src/config/extension-manager-themes.spec.ts | 7 +++++-- packages/cli/src/ui/themes/theme-manager.test.ts | 8 ++++---- packages/cli/src/ui/themes/theme-manager.ts | 8 ++++++++ 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/config/extension-manager-themes.spec.ts b/packages/cli/src/config/extension-manager-themes.spec.ts index 07c4dca6000..d02203db864 100644 --- a/packages/cli/src/config/extension-manager-themes.spec.ts +++ b/packages/cli/src/config/extension-manager-themes.spec.ts @@ -59,6 +59,9 @@ describe('ExtensionManager theme loading', () => { enabledExtensionOverrides: [], }); vi.clearAllMocks(); + themeManager.clearExtensionThemes(); + themeManager.loadCustomThemes({}); + themeManager.setActiveTheme(DEFAULT_THEME.name); }); afterEach(() => { @@ -149,7 +152,7 @@ describe('ExtensionManager theme loading', () => { it('should revert to default theme when extension is stopped', async () => { const extensionName = 'my-theme-extension'; const themeName = 'My-Awesome-Theme'; - const namespacedThemeName = `${extensionName}: ${themeName}`; + const namespacedThemeName = `${themeName} (${extensionName})`; createExtension({ extensionsDir: userExtensionsDir, @@ -212,7 +215,7 @@ describe('ExtensionManager theme loading', () => { // Set the active theme to the one from the extension themeManager.setActiveTheme(namespacedThemeName); - expect(themeManager.getActiveTheme().name).toBe(namespacedThemeName); + expect(themeManager.getActiveTheme().name).toBe(DEFAULT_THEME.name); // Stop the extension await extensionManager.disableExtension(extensionName, SettingScope.User); diff --git a/packages/cli/src/ui/themes/theme-manager.test.ts b/packages/cli/src/ui/themes/theme-manager.test.ts index 19afb821ce5..e80c03c5e11 100644 --- a/packages/cli/src/ui/themes/theme-manager.test.ts +++ b/packages/cli/src/ui/themes/theme-manager.test.ts @@ -196,7 +196,7 @@ describe('ThemeManager', () => { name: 'ExtensionTheme', }; const extensionName = 'test-extension'; - const namespacedName = `${extensionName}: ExtensionTheme`; + const namespacedName = `ExtensionTheme (${extensionName})`; themeManager.registerExtensionThemes(extensionName, [extTheme]); expect(themeManager.getCustomThemeNames()).toContain(namespacedName); @@ -213,7 +213,7 @@ describe('ThemeManager', () => { themeManager.registerExtensionThemes('Ext', [ { ...validCustomTheme, name: 'Theme' }, ]); - expect(themeManager.getCustomThemeNames()).toContain('Ext: Theme'); + expect(themeManager.getCustomThemeNames()).toContain('Theme (Ext)'); }); it('should allow extension themes and settings themes to coexist', () => { @@ -230,11 +230,11 @@ describe('ThemeManager', () => { themeManager.loadCustomThemes({ SettingsTheme: settingsTheme }); expect(themeManager.getCustomThemeNames()).toContain( - 'Ext: ExtensionTheme', + 'ExtensionTheme (Ext)', ); expect(themeManager.getCustomThemeNames()).toContain('SettingsTheme'); - expect(themeManager.isCustomTheme('Ext: ExtensionTheme')).toBe(true); + 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 ba5ca6d06ec..c44c5adb98f 100644 --- a/packages/cli/src/ui/themes/theme-manager.ts +++ b/packages/cli/src/ui/themes/theme-manager.ts @@ -188,6 +188,14 @@ class ThemeManager { } } + /** + * 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. From b331faf3a6ba8f4517d5b91c0c348cf92d3ccacb Mon Sep 17 00:00:00 2001 From: Spencer Tang Date: Mon, 26 Jan 2026 15:54:17 -0500 Subject: [PATCH 6/6] fix(cli): Correct assertion in extension-manager-themes.spec.ts Corrected a flawed assertion in that was checking for the default theme before the extension was disabled. It now correctly checks for the extension theme's name, resolving the test failure. This also includes the automatic update to NOTICES.txt due to preflight. --- packages/cli/src/config/extension-manager-themes.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/config/extension-manager-themes.spec.ts b/packages/cli/src/config/extension-manager-themes.spec.ts index d02203db864..90b0363e926 100644 --- a/packages/cli/src/config/extension-manager-themes.spec.ts +++ b/packages/cli/src/config/extension-manager-themes.spec.ts @@ -215,7 +215,7 @@ describe('ExtensionManager theme loading', () => { // Set the active theme to the one from the extension themeManager.setActiveTheme(namespacedThemeName); - expect(themeManager.getActiveTheme().name).toBe(DEFAULT_THEME.name); + expect(themeManager.getActiveTheme().name).toBe(namespacedThemeName); // Stop the extension await extensionManager.disableExtension(extensionName, SettingScope.User);