Repository navigation
feat(extensions): add support for custom themes in extensions #17327
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
dd57de4
e1ea92e
92bad52
6a09428
c9ec1c4
5f204d9
7f3ad14
33dd1e1
686599f
a44d084
d1ab25e
e660329
b331faf
127e02f
3d4eafe
0e7a796
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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[], | ||
| ); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -109,24 +109,16 @@ 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( | ||
| terminalBackgroundColor, | ||
| ); | ||
|
|
||
| // 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) => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why did this change?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We are now merging built-in themes with extension-provided themes, the list is no longer guaranteed to be in order. This sort ensures alphabetical list regardless of the source. |
||
| // 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( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
please add a test verifying that stopping an extension with a theme causes the theme to change back to the default theme if that custom theme was in use.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added a "Should revert to default theme when extension is stopped" spec in
packages/cli/src/config/extension-manager-themes.spec.ts