mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-20 18:14:29 -07:00
feat(extensions): add support for custom themes in extensions (#17327)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user