mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-17 09:30:58 -07:00
665 lines
19 KiB
TypeScript
665 lines
19 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2026 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { AyuDark } from './builtin/dark/ayu-dark.js';
|
|
import { AyuLight } from './builtin/light/ayu-light.js';
|
|
import { AtomOneDark } from './builtin/dark/atom-one-dark.js';
|
|
import { Dracula } from './builtin/dark/dracula-dark.js';
|
|
import { GitHubDark } from './builtin/dark/github-dark.js';
|
|
import { GitHubLight } from './builtin/light/github-light.js';
|
|
import { GoogleCode } from './builtin/light/googlecode-light.js';
|
|
import { Holiday } from './builtin/dark/holiday-dark.js';
|
|
import { DefaultLight } from './builtin/light/default-light.js';
|
|
import { DefaultDark } from './builtin/dark/default-dark.js';
|
|
import { ShadesOfPurple } from './builtin/dark/shades-of-purple-dark.js';
|
|
import { SolarizedDark } from './builtin/dark/solarized-dark.js';
|
|
import { SolarizedLight } from './builtin/light/solarized-light.js';
|
|
import { XCode } from './builtin/light/xcode-light.js';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import type { Theme, ThemeType, ColorsTheme, CustomTheme } from './theme.js';
|
|
import {
|
|
createCustomTheme,
|
|
validateCustomTheme,
|
|
interpolateColor,
|
|
getThemeTypeFromBackgroundColor,
|
|
resolveColor,
|
|
} from './theme.js';
|
|
import type { SemanticColors } from './semantic-tokens.js';
|
|
import {
|
|
DEFAULT_BACKGROUND_OPACITY,
|
|
DEFAULT_INPUT_BACKGROUND_OPACITY,
|
|
DEFAULT_SELECTION_OPACITY,
|
|
DEFAULT_BORDER_OPACITY,
|
|
} from '../constants.js';
|
|
import { ANSI } from './builtin/dark/ansi-dark.js';
|
|
import { ANSILight } from './builtin/light/ansi-light.js';
|
|
import { NoColorTheme } from './builtin/no-color.js';
|
|
import process from 'node:process';
|
|
import { debugLogger, homedir } from '@google/gemini-cli-core';
|
|
|
|
export interface ThemeDisplay {
|
|
name: string;
|
|
type: ThemeType;
|
|
isCustom?: boolean;
|
|
}
|
|
|
|
export const DEFAULT_THEME: Theme = DefaultDark;
|
|
|
|
class ThemeManager {
|
|
private readonly availableThemes: Theme[];
|
|
private activeTheme: Theme;
|
|
private settingsThemes: Map<string, Theme> = new Map();
|
|
private extensionThemes: Map<string, Theme> = new Map();
|
|
private fileThemes: Map<string, Theme> = new Map();
|
|
private terminalBackground: string | undefined;
|
|
|
|
// Cache for dynamic colors
|
|
private cachedColors: ColorsTheme | undefined;
|
|
private cachedSemanticColors: SemanticColors | undefined;
|
|
private lastCacheKey: string | undefined;
|
|
|
|
private fs: typeof fs;
|
|
private homedir: () => string;
|
|
|
|
constructor(dependencies?: { fs?: typeof fs; homedir?: () => string }) {
|
|
this.fs = dependencies?.fs ?? fs;
|
|
this.homedir = dependencies?.homedir ?? homedir;
|
|
|
|
this.availableThemes = [
|
|
AyuDark,
|
|
AyuLight,
|
|
AtomOneDark,
|
|
Dracula,
|
|
DefaultLight,
|
|
DefaultDark,
|
|
GitHubDark,
|
|
GitHubLight,
|
|
GoogleCode,
|
|
Holiday,
|
|
ShadesOfPurple,
|
|
SolarizedDark,
|
|
SolarizedLight,
|
|
XCode,
|
|
ANSI,
|
|
ANSILight,
|
|
];
|
|
this.activeTheme = DEFAULT_THEME;
|
|
}
|
|
|
|
setTerminalBackground(color: string | undefined): void {
|
|
if (this.terminalBackground !== color) {
|
|
this.terminalBackground = color;
|
|
this.clearCache();
|
|
}
|
|
}
|
|
|
|
getTerminalBackground(): string | undefined {
|
|
return this.terminalBackground;
|
|
}
|
|
|
|
private clearCache(): void {
|
|
this.cachedColors = undefined;
|
|
this.cachedSemanticColors = undefined;
|
|
this.lastCacheKey = undefined;
|
|
}
|
|
|
|
isDefaultTheme(themeName: string | undefined): boolean {
|
|
return (
|
|
themeName === undefined ||
|
|
themeName === DEFAULT_THEME.name ||
|
|
themeName === DefaultLight.name
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Loads custom themes from settings.
|
|
* @param customThemesSettings Custom themes from settings.
|
|
*/
|
|
loadCustomThemes(customThemesSettings?: Record<string, CustomTheme>): void {
|
|
this.settingsThemes.clear();
|
|
|
|
if (!customThemesSettings) {
|
|
return;
|
|
}
|
|
|
|
for (const [name, customThemeConfig] of Object.entries(
|
|
customThemesSettings,
|
|
)) {
|
|
const validation = validateCustomTheme(customThemeConfig);
|
|
if (validation.isValid) {
|
|
if (validation.warning) {
|
|
debugLogger.warn(`Theme "${name}": ${validation.warning}`);
|
|
}
|
|
const themeWithDefaults: CustomTheme = {
|
|
...DEFAULT_THEME.colors,
|
|
...customThemeConfig,
|
|
name: customThemeConfig.name || name,
|
|
type: 'custom',
|
|
};
|
|
|
|
try {
|
|
const theme = createCustomTheme(themeWithDefaults);
|
|
this.settingsThemes.set(name, theme);
|
|
} catch (error) {
|
|
debugLogger.warn(`Failed to load custom theme "${name}":`, error);
|
|
}
|
|
} else {
|
|
debugLogger.warn(`Invalid custom theme "${name}": ${validation.error}`);
|
|
}
|
|
}
|
|
// If the current active theme is a settings theme, keep it if still valid
|
|
if (
|
|
this.activeTheme &&
|
|
this.activeTheme.type === 'custom' &&
|
|
this.settingsThemes.has(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}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if themes for a given extension are already registered.
|
|
* @param extensionName The name of the extension.
|
|
* @returns True if any themes from the extension are registered.
|
|
*/
|
|
hasExtensionThemes(extensionName: string): boolean {
|
|
return Array.from(this.extensionThemes.keys()).some((name) =>
|
|
name.endsWith(`(${extensionName})`),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Clears all registered extension themes.
|
|
* This is primarily for testing purposes to reset state between tests.
|
|
*/
|
|
clearExtensionThemes(): void {
|
|
this.extensionThemes.clear();
|
|
}
|
|
|
|
/**
|
|
* Clears all themes loaded from files.
|
|
* This is primarily for testing purposes to reset state between tests.
|
|
*/
|
|
clearFileThemes(): void {
|
|
this.fileThemes.clear();
|
|
}
|
|
|
|
/**
|
|
* Re-initializes the ThemeManager with new dependencies.
|
|
* This is primarily for testing to allow injecting mocks.
|
|
*/
|
|
reinitialize(dependencies: { fs?: typeof fs; homedir?: () => string }): void {
|
|
if (dependencies.fs) {
|
|
this.fs = dependencies.fs;
|
|
}
|
|
if (dependencies.homedir) {
|
|
this.homedir = dependencies.homedir;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resets the ThemeManager state to defaults.
|
|
* This is for testing purposes to ensure test isolation.
|
|
*/
|
|
resetForTesting(dependencies?: {
|
|
fs?: typeof fs;
|
|
homedir?: () => string;
|
|
}): void {
|
|
if (dependencies) {
|
|
this.reinitialize(dependencies);
|
|
}
|
|
this.settingsThemes.clear();
|
|
this.extensionThemes.clear();
|
|
this.fileThemes.clear();
|
|
this.activeTheme = DEFAULT_THEME;
|
|
this.terminalBackground = undefined;
|
|
this.clearCache();
|
|
}
|
|
setActiveTheme(themeName: string | undefined): boolean {
|
|
const theme = this.findThemeByName(themeName);
|
|
if (!theme) {
|
|
return false;
|
|
}
|
|
if (this.activeTheme !== theme) {
|
|
this.activeTheme = theme;
|
|
this.clearCache();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Gets the currently active theme.
|
|
* @returns The active theme.
|
|
*/
|
|
getActiveTheme(): Theme {
|
|
if (process.env['NO_COLOR']) {
|
|
return NoColorTheme;
|
|
}
|
|
|
|
if (this.activeTheme) {
|
|
const isBuiltIn = this.availableThemes.some(
|
|
(t) => t.name === this.activeTheme.name,
|
|
);
|
|
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.
|
|
this.activeTheme = DEFAULT_THEME;
|
|
return this.activeTheme;
|
|
}
|
|
|
|
/**
|
|
* Gets the colors for the active theme, respecting the terminal background.
|
|
* @returns The theme colors.
|
|
*/
|
|
getColors(): ColorsTheme {
|
|
const activeTheme = this.getActiveTheme();
|
|
const cacheKey = `${activeTheme.name}:${this.terminalBackground}`;
|
|
if (this.cachedColors && this.lastCacheKey === cacheKey) {
|
|
return this.cachedColors;
|
|
}
|
|
|
|
const colors = activeTheme.colors;
|
|
if (
|
|
this.terminalBackground &&
|
|
this.isThemeCompatible(activeTheme, this.terminalBackground)
|
|
) {
|
|
this.cachedColors = {
|
|
...colors,
|
|
Background: this.terminalBackground,
|
|
DarkGray: interpolateColor(
|
|
this.terminalBackground,
|
|
colors.Gray,
|
|
DEFAULT_BORDER_OPACITY,
|
|
),
|
|
InputBackground: interpolateColor(
|
|
this.terminalBackground,
|
|
colors.Gray,
|
|
DEFAULT_INPUT_BACKGROUND_OPACITY,
|
|
),
|
|
MessageBackground: interpolateColor(
|
|
this.terminalBackground,
|
|
colors.Gray,
|
|
DEFAULT_BACKGROUND_OPACITY,
|
|
),
|
|
FocusBackground: interpolateColor(
|
|
this.terminalBackground,
|
|
activeTheme.colors.FocusColor ?? activeTheme.colors.AccentGreen,
|
|
DEFAULT_SELECTION_OPACITY,
|
|
),
|
|
};
|
|
} else {
|
|
this.cachedColors = colors;
|
|
}
|
|
|
|
this.lastCacheKey = cacheKey;
|
|
return this.cachedColors;
|
|
}
|
|
|
|
/**
|
|
* Gets the semantic colors for the active theme.
|
|
* @returns The semantic colors.
|
|
*/
|
|
getSemanticColors(): SemanticColors {
|
|
const activeTheme = this.getActiveTheme();
|
|
const cacheKey = `${activeTheme.name}:${this.terminalBackground}`;
|
|
if (this.cachedSemanticColors && this.lastCacheKey === cacheKey) {
|
|
return this.cachedSemanticColors;
|
|
}
|
|
|
|
const semanticColors = activeTheme.semanticColors;
|
|
if (
|
|
this.terminalBackground &&
|
|
this.isThemeCompatible(activeTheme, this.terminalBackground)
|
|
) {
|
|
const colors = this.getColors();
|
|
this.cachedSemanticColors = {
|
|
...semanticColors,
|
|
background: {
|
|
...semanticColors.background,
|
|
primary: this.terminalBackground,
|
|
message: colors.MessageBackground!,
|
|
input: colors.InputBackground!,
|
|
focus: colors.FocusBackground!,
|
|
},
|
|
border: {
|
|
...semanticColors.border,
|
|
default: colors.DarkGray,
|
|
},
|
|
ui: {
|
|
...semanticColors.ui,
|
|
dark: colors.DarkGray,
|
|
focus: colors.FocusColor ?? colors.AccentGreen,
|
|
},
|
|
};
|
|
} else {
|
|
this.cachedSemanticColors = semanticColors;
|
|
}
|
|
|
|
this.lastCacheKey = cacheKey;
|
|
return this.cachedSemanticColors;
|
|
}
|
|
|
|
isThemeCompatible(
|
|
activeTheme: Theme,
|
|
terminalBackground: string | undefined,
|
|
): boolean {
|
|
if (activeTheme.type === 'ansi') {
|
|
return true;
|
|
}
|
|
|
|
const backgroundType = getThemeTypeFromBackgroundColor(terminalBackground);
|
|
if (!backgroundType) {
|
|
return true;
|
|
}
|
|
|
|
const themeType =
|
|
activeTheme.type === 'custom'
|
|
? getThemeTypeFromBackgroundColor(
|
|
resolveColor(activeTheme.colors.Background) ||
|
|
activeTheme.colors.Background,
|
|
)
|
|
: activeTheme.type;
|
|
|
|
return themeType === backgroundType;
|
|
}
|
|
|
|
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 this._getAllCustomThemes().map((theme) => theme.name);
|
|
}
|
|
|
|
/**
|
|
* Checks if a theme name is a custom theme.
|
|
* @param themeName The theme name to check.
|
|
* @returns True if the theme is custom.
|
|
*/
|
|
isCustomTheme(themeName: string): boolean {
|
|
return (
|
|
this.settingsThemes.has(themeName) ||
|
|
this.extensionThemes.has(themeName) ||
|
|
this.fileThemes.has(themeName)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Returns a list of available theme names.
|
|
*/
|
|
getAvailableThemes(): ThemeDisplay[] {
|
|
const builtInThemes = this.availableThemes.map((theme) => ({
|
|
name: theme.name,
|
|
type: theme.type,
|
|
isCustom: false,
|
|
}));
|
|
|
|
const customThemes = this._getAllCustomThemes().map((theme) => ({
|
|
name: theme.name,
|
|
type: theme.type,
|
|
isCustom: true,
|
|
}));
|
|
|
|
const allThemes = [...builtInThemes, ...customThemes];
|
|
|
|
const sortedThemes = allThemes.sort((a, b) => {
|
|
const typeOrder = (type: ThemeType): number => {
|
|
switch (type) {
|
|
case 'dark':
|
|
return 1;
|
|
case 'light':
|
|
return 2;
|
|
case 'ansi':
|
|
return 3;
|
|
case 'custom':
|
|
return 4; // Custom themes at the end
|
|
default:
|
|
return 5;
|
|
}
|
|
};
|
|
|
|
const typeComparison = typeOrder(a.type) - typeOrder(b.type);
|
|
if (typeComparison !== 0) {
|
|
return typeComparison;
|
|
}
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
|
|
return sortedThemes;
|
|
}
|
|
|
|
/**
|
|
* Gets a theme by name.
|
|
* @param themeName The name of the theme to get.
|
|
* @returns The theme if found, undefined otherwise.
|
|
*/
|
|
getTheme(themeName: string): Theme | undefined {
|
|
return this.findThemeByName(themeName);
|
|
}
|
|
|
|
/**
|
|
* Gets all available themes.
|
|
* @returns A list of all available themes.
|
|
*/
|
|
getAllThemes(): Theme[] {
|
|
return [...this.availableThemes, ...this._getAllCustomThemes()];
|
|
}
|
|
|
|
private isPath(themeName: string): boolean {
|
|
return (
|
|
themeName.endsWith('.json') ||
|
|
themeName.startsWith('.') ||
|
|
path.isAbsolute(themeName)
|
|
);
|
|
}
|
|
|
|
private loadThemeFromFile(themePath: string): Theme | undefined {
|
|
try {
|
|
// realpathSync resolves the path and throws if it doesn't exist.
|
|
const canonicalPath = this.fs.realpathSync(path.resolve(themePath));
|
|
|
|
// 1. Check cache using the canonical path.
|
|
if (this.fileThemes.has(canonicalPath)) {
|
|
return this.fileThemes.get(canonicalPath);
|
|
}
|
|
|
|
// 2. Perform security check.
|
|
const homeDir = path.resolve(this.homedir());
|
|
if (!canonicalPath.startsWith(homeDir)) {
|
|
debugLogger.warn(
|
|
`Theme file at "${themePath}" is outside your home directory. ` +
|
|
`Only load themes from trusted sources.`,
|
|
);
|
|
return undefined;
|
|
}
|
|
|
|
// 3. Read, parse, and validate the theme file.
|
|
const themeContent = this.fs.readFileSync(canonicalPath, 'utf-8');
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
const customThemeConfig = JSON.parse(themeContent) as CustomTheme;
|
|
|
|
const validation = validateCustomTheme(customThemeConfig);
|
|
if (!validation.isValid) {
|
|
debugLogger.warn(
|
|
`Invalid custom theme from file "${themePath}": ${validation.error}`,
|
|
);
|
|
return undefined;
|
|
}
|
|
|
|
if (validation.warning) {
|
|
debugLogger.warn(`Theme from "${themePath}": ${validation.warning}`);
|
|
}
|
|
|
|
// 4. Create and cache the theme.
|
|
const themeWithDefaults: CustomTheme = {
|
|
...DEFAULT_THEME.colors,
|
|
...customThemeConfig,
|
|
name: customThemeConfig.name || canonicalPath,
|
|
type: 'custom',
|
|
};
|
|
|
|
const theme = createCustomTheme(themeWithDefaults);
|
|
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.
|
|
// We can return undefined silently for file-not-found, and warn for others.
|
|
if (
|
|
!(error instanceof Error && 'code' in error && error.code === 'ENOENT')
|
|
) {
|
|
debugLogger.warn(
|
|
`Could not load theme from file "${themePath}":`,
|
|
error,
|
|
);
|
|
}
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
findThemeByName(themeName: string | undefined): Theme | undefined {
|
|
if (!themeName) {
|
|
return DEFAULT_THEME;
|
|
}
|
|
|
|
// First check built-in themes
|
|
const builtInTheme = this.availableThemes.find(
|
|
(theme) => theme.name === themeName,
|
|
);
|
|
if (builtInTheme) {
|
|
return builtInTheme;
|
|
}
|
|
|
|
// Then check custom themes that have been loaded from settings, extensions, or file paths
|
|
if (this.isPath(themeName)) {
|
|
return this.loadThemeFromFile(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,
|
|
// it's not a valid theme.
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
// Export an instance of the ThemeManager
|
|
export const themeManager = new ThemeManager();
|