Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions packages/cli/src/config/extension-manager-themes.spec.ts
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[],
);
});
});
16 changes: 16 additions & 0 deletions packages/cli/src/config/extension-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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) {

Copy link
Copy Markdown
Contributor

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.

Copy link
Copy Markdown
Contributor Author

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

await super.stopExtension(extension);
if (extension.themes) {
themeManager.unregisterExtensionThemes(extension.name, extension.themes);
}
}

/**
* Loads all installed extensions, should only be called once.
*/
Expand Down Expand Up @@ -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];

Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/config/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand Down
13 changes: 11 additions & 2 deletions packages/cli/src/test-utils/createExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -25,12 +26,20 @@ export function createExtension({
mcpServers = {} as Record<string, MCPServerConfig>,
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) {
Expand Down
44 changes: 11 additions & 33 deletions packages/cli/src/ui/components/ThemeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why did this change?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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(
Expand Down
50 changes: 50 additions & 0 deletions packages/cli/src/ui/themes/theme-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
Loading
Loading