Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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"
}
}
]
}
226 changes: 226 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,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);
});
});
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 @@ -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[];
Expand Down Expand Up @@ -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) {

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

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
14 changes: 6 additions & 8 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Loading
Loading