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

This commit is contained in:
Spencer
2026-01-28 13:58:35 -05:00
committed by GitHub
parent 47f4a3e50e
commit beaa134f0e
13 changed files with 611 additions and 129 deletions

View File

@@ -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.

View File

@@ -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"
}
}
]
}

View File

@@ -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);
});
});

View File

@@ -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];

View File

@@ -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 {

View File

@@ -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';

View File

@@ -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<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) {

View File

@@ -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]}{' '}
<Text color={theme.text.secondary}>({match[2]})</Text>
</>
);
}
return (
<Text color={titleColor} wrap="truncate" key={item.key}>
{item.themeNameDisplay}{' '}
{themeNamePart}{' '}
<Text color={theme.text.secondary}>
{item.themeTypeDisplay}
</Text>

View File

@@ -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);
});
});
});

View File

@@ -18,7 +18,8 @@ import { ShadesOfPurple } from './shades-of-purple.js';
import { XCode } from './xcode.js';
import * as fs from 'node:fs';
import * as path from 'node:path';
import type { Theme, ThemeType, CustomTheme } from './theme.js';
import type { Theme, ThemeType } from './theme.js';
import type { CustomTheme } from '@google/gemini-cli-core';
import { createCustomTheme, validateCustomTheme } from './theme.js';
import type { SemanticColors } from './semantic-tokens.js';
import { ANSI } from './ansi.js';
@@ -38,7 +39,9 @@ export const DEFAULT_THEME: Theme = DefaultDark;
class ThemeManager {
private readonly availableThemes: Theme[];
private activeTheme: Theme;
private customThemes: Map<string, Theme> = new Map();
private settingsThemes: Map<string, Theme> = new Map();
private extensionThemes: Map<string, Theme> = new Map();
private fileThemes: Map<string, Theme> = new Map();
constructor() {
this.availableThemes = [
@@ -65,7 +68,7 @@ class ThemeManager {
* @param customThemesSettings Custom themes from settings.
*/
loadCustomThemes(customThemesSettings?: Record<string, CustomTheme>): void {
this.customThemes.clear();
this.settingsThemes.clear();
if (!customThemesSettings) {
return;
@@ -88,7 +91,7 @@ class ThemeManager {
try {
const theme = createCustomTheme(themeWithDefaults);
this.customThemes.set(name, theme);
this.settingsThemes.set(name, theme);
} catch (error) {
debugLogger.warn(`Failed to load custom theme "${name}":`, error);
}
@@ -96,16 +99,103 @@ class ThemeManager {
debugLogger.warn(`Invalid custom theme "${name}": ${validation.error}`);
}
}
// If the current active theme is a custom theme, keep it if still valid
// If the current active theme is a settings theme, keep it if still valid
if (
this.activeTheme &&
this.activeTheme.type === 'custom' &&
this.customThemes.has(this.activeTheme.name)
this.settingsThemes.has(this.activeTheme.name)
) {
this.activeTheme = this.customThemes.get(this.activeTheme.name)!;
this.activeTheme = this.settingsThemes.get(this.activeTheme.name)!;
}
}
/**
* Loads custom themes from extensions.
* @param extensionName The name of the extension providing the themes.
* @param customThemes Custom themes from extensions.
*/
registerExtensionThemes(
extensionName: string,
customThemes?: CustomTheme[],
): void {
if (!customThemes) {
return;
}
debugLogger.log(
`Registering extension themes for "${extensionName}":`,
customThemes,
);
for (const customThemeConfig of customThemes) {
const namespacedName = `${customThemeConfig.name} (${extensionName})`;
// Check for collisions with built-in themes (unlikely with prefix, but safe)
if (this.availableThemes.some((t) => t.name === namespacedName)) {
debugLogger.warn(
`Theme name collision: "${namespacedName}" is a built-in theme. Skipping.`,
);
continue;
}
const validation = validateCustomTheme(customThemeConfig);
if (validation.isValid) {
if (validation.warning) {
debugLogger.warn(`Theme "${namespacedName}": ${validation.warning}`);
}
const themeWithDefaults: CustomTheme = {
...DEFAULT_THEME.colors,
...customThemeConfig,
name: namespacedName,
type: 'custom',
};
try {
const theme = createCustomTheme(themeWithDefaults);
this.extensionThemes.set(namespacedName, theme);
debugLogger.log(`Registered theme: ${namespacedName}`);
} catch (error) {
debugLogger.warn(
`Failed to load custom theme "${namespacedName}":`,
error,
);
}
} else {
debugLogger.warn(
`Invalid custom theme "${namespacedName}": ${validation.error}`,
);
}
}
}
/**
* Unregisters custom themes from extensions.
* @param extensionName The name of the extension.
* @param customThemes Custom themes to unregister.
*/
unregisterExtensionThemes(
extensionName: string,
customThemes?: CustomTheme[],
): void {
if (!customThemes) {
return;
}
for (const theme of customThemes) {
const namespacedName = `${theme.name} (${extensionName})`;
this.extensionThemes.delete(namespacedName);
debugLogger.log(`Unregistered theme: ${namespacedName}`);
}
}
/**
* Clears all registered extension themes.
* This is primarily for testing purposes to reset state between tests.
*/
clearExtensionThemes(): void {
this.extensionThemes.clear();
}
/**
* Sets the active theme.
* @param themeName The name of the theme to set as active.
@@ -133,13 +223,23 @@ class ThemeManager {
const isBuiltIn = this.availableThemes.some(
(t) => t.name === this.activeTheme.name,
);
const isCustom = [...this.customThemes.values()].includes(
this.activeTheme,
);
const isCustom =
[...this.settingsThemes.values()].includes(this.activeTheme) ||
[...this.extensionThemes.values()].includes(this.activeTheme) ||
[...this.fileThemes.values()].includes(this.activeTheme);
if (isBuiltIn || isCustom) {
return this.activeTheme;
}
// If the theme object is no longer valid, try to find it again by name.
// This handles the case where extensions are reloaded and theme objects
// are re-created.
const reloadedTheme = this.findThemeByName(this.activeTheme.name);
if (reloadedTheme) {
this.activeTheme = reloadedTheme;
return this.activeTheme;
}
}
// Fallback to default if no active theme or if it's no longer valid.
@@ -155,12 +255,20 @@ class ThemeManager {
return this.getActiveTheme().semanticColors;
}
private _getAllCustomThemes(): Theme[] {
return [
...Array.from(this.settingsThemes.values()),
...Array.from(this.extensionThemes.values()),
...Array.from(this.fileThemes.values()),
];
}
/**
* Gets a list of custom theme names.
* @returns Array of custom theme names.
*/
getCustomThemeNames(): string[] {
return Array.from(this.customThemes.keys());
return this._getAllCustomThemes().map((theme) => theme.name);
}
/**
@@ -169,7 +277,11 @@ class ThemeManager {
* @returns True if the theme is custom.
*/
isCustomTheme(themeName: string): boolean {
return this.customThemes.has(themeName);
return (
this.settingsThemes.has(themeName) ||
this.extensionThemes.has(themeName) ||
this.fileThemes.has(themeName)
);
}
/**
@@ -182,13 +294,11 @@ class ThemeManager {
isCustom: false,
}));
const customThemes = Array.from(this.customThemes.values()).map(
(theme) => ({
name: theme.name,
type: theme.type,
isCustom: true,
}),
);
const customThemes = this._getAllCustomThemes().map((theme) => ({
name: theme.name,
type: theme.type,
isCustom: true,
}));
const allThemes = [...builtInThemes, ...customThemes];
@@ -232,7 +342,7 @@ class ThemeManager {
* @returns A list of all available themes.
*/
getAllThemes(): Theme[] {
return [...this.availableThemes, ...Array.from(this.customThemes.values())];
return [...this.availableThemes, ...this._getAllCustomThemes()];
}
private isPath(themeName: string): boolean {
@@ -249,8 +359,8 @@ class ThemeManager {
const canonicalPath = fs.realpathSync(path.resolve(themePath));
// 1. Check cache using the canonical path.
if (this.customThemes.has(canonicalPath)) {
return this.customThemes.get(canonicalPath);
if (this.fileThemes.has(canonicalPath)) {
return this.fileThemes.get(canonicalPath);
}
// 2. Perform security check.
@@ -288,7 +398,7 @@ class ThemeManager {
};
const theme = createCustomTheme(themeWithDefaults);
this.customThemes.set(canonicalPath, theme); // Cache by canonical path
this.fileThemes.set(canonicalPath, theme); // Cache by canonical path
return theme;
} catch (error) {
// Any error in the process (file not found, bad JSON, etc.) is caught here.
@@ -318,13 +428,21 @@ class ThemeManager {
return builtInTheme;
}
// Then check custom themes that have been loaded from settings, or file paths
// Then check custom themes that have been loaded from settings, extensions, or file paths
if (this.isPath(themeName)) {
return this.loadThemeFromFile(themeName);
}
if (this.customThemes.has(themeName)) {
return this.customThemes.get(themeName);
if (this.settingsThemes.has(themeName)) {
return this.settingsThemes.get(themeName);
}
if (this.extensionThemes.has(themeName)) {
return this.extensionThemes.get(themeName);
}
if (this.fileThemes.has(themeName)) {
return this.fileThemes.get(themeName);
}
// If it's not a built-in, not in cache, and not a valid file path,

View File

@@ -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<CustomTheme> = { ...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(

View File

@@ -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',