Files
gemini-cli/packages/cli/src/ui/themes/theme.ts
T

509 lines
14 KiB
TypeScript
Raw Normal View History

2025-04-22 18:37:58 -07:00
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CSSProperties } from 'react';
import type { SemanticColors } from './semantic-tokens.js';
2025-12-18 10:36:48 -08:00
import {
resolveColor,
interpolateColor,
getThemeTypeFromBackgroundColor,
} from './color-utils.js';
2025-05-08 16:00:55 -07:00
import type { CustomTheme } from '@google/gemini-cli-core';
import { DEFAULT_BORDER_OPACITY } from '../constants.js';
export type { CustomTheme };
2025-07-20 16:51:18 +09:00
export type ThemeType = 'light' | 'dark' | 'ansi' | 'custom';
2025-05-08 16:00:55 -07:00
2025-04-23 17:37:09 -07:00
export interface ColorsTheme {
2025-05-08 16:00:55 -07:00
type: ThemeType;
2025-04-23 17:37:09 -07:00
Background: string;
Foreground: string;
LightBlue: string;
AccentBlue: string;
AccentPurple: string;
AccentCyan: string;
AccentGreen: string;
AccentYellow: string;
AccentRed: string;
2025-07-23 15:39:22 -07:00
DiffAdded: string;
DiffRemoved: string;
2025-06-05 14:35:47 -07:00
Comment: string;
2025-04-23 17:37:09 -07:00
Gray: string;
2025-11-01 16:50:51 -07:00
DarkGray: string;
2025-04-24 11:56:23 -07:00
GradientColors?: string[];
2025-04-23 17:37:09 -07:00
}
export const lightTheme: ColorsTheme = {
2025-05-08 16:00:55 -07:00
type: 'light',
2025-04-23 17:37:09 -07:00
Background: '#FAFAFA',
Foreground: '',
2025-06-04 10:41:03 -07:00
LightBlue: '#89BDCD',
2025-04-23 17:37:09 -07:00
AccentBlue: '#3B82F6',
AccentPurple: '#8B5CF6',
AccentCyan: '#06B6D4',
2025-06-04 10:41:03 -07:00
AccentGreen: '#3CA84B',
AccentYellow: '#D5A40A',
AccentRed: '#DD4C4C',
2025-07-23 15:39:22 -07:00
DiffAdded: '#C6EAD8',
DiffRemoved: '#FFCCCC',
2025-06-05 14:35:47 -07:00
Comment: '#008000',
2025-07-23 15:39:22 -07:00
Gray: '#97a0b0',
2025-11-01 16:50:51 -07:00
DarkGray: interpolateColor('#97a0b0', '#FAFAFA', 0.5),
2025-04-24 11:56:23 -07:00
GradientColors: ['#4796E4', '#847ACE', '#C3677F'],
2025-04-23 17:37:09 -07:00
};
export const darkTheme: ColorsTheme = {
2025-05-08 16:00:55 -07:00
type: 'dark',
2025-04-23 17:37:09 -07:00
Background: '#1E1E2E',
Foreground: '',
2025-04-23 17:37:09 -07:00
LightBlue: '#ADD8E6',
AccentBlue: '#89B4FA',
AccentPurple: '#CBA6F7',
AccentCyan: '#89DCEB',
AccentGreen: '#A6E3A1',
AccentYellow: '#F9E2AF',
AccentRed: '#F38BA8',
2025-07-23 15:39:22 -07:00
DiffAdded: '#28350B',
DiffRemoved: '#430000',
2025-06-05 14:35:47 -07:00
Comment: '#6C7086',
Gray: '#6C7086',
2025-11-01 16:50:51 -07:00
DarkGray: interpolateColor('#6C7086', '#1E1E2E', 0.5),
2025-04-24 11:56:23 -07:00
GradientColors: ['#4796E4', '#847ACE', '#C3677F'],
};
export const ansiTheme: ColorsTheme = {
2025-05-08 16:00:55 -07:00
type: 'ansi',
2025-04-24 11:56:23 -07:00
Background: 'black',
Foreground: '',
2025-04-24 11:56:23 -07:00
LightBlue: 'blue',
AccentBlue: 'blue',
AccentPurple: 'magenta',
2025-05-31 11:10:52 -07:00
AccentCyan: 'cyan',
AccentGreen: 'green',
AccentYellow: 'yellow',
2025-04-24 11:56:23 -07:00
AccentRed: 'red',
2025-07-23 15:39:22 -07:00
DiffAdded: 'green',
DiffRemoved: 'red',
2025-06-05 14:35:47 -07:00
Comment: 'gray',
2025-04-24 11:56:23 -07:00
Gray: 'gray',
2025-11-01 16:50:51 -07:00
DarkGray: 'gray',
2025-04-23 17:37:09 -07:00
};
2025-04-22 18:37:58 -07:00
export class Theme {
/**
* The default foreground color for text when no specific highlight rule applies.
* This is an Ink-compatible color string (hex or name).
*/
readonly defaultColor: string;
/**
* Stores the mapping from highlight.js class names (e.g., 'hljs-keyword')
* to Ink-compatible color strings (hex or name).
*/
protected readonly _colorMap: Readonly<Record<string, string>>;
readonly semanticColors: SemanticColors;
2025-04-22 18:37:58 -07:00
/**
* Creates a new Theme instance.
* @param name The name of the theme.
* @param rawMappings The raw CSSProperties mappings from a react-syntax-highlighter theme object.
*/
2025-04-23 17:37:09 -07:00
constructor(
readonly name: string,
2025-05-08 16:00:55 -07:00
readonly type: ThemeType,
2025-04-23 17:37:09 -07:00
rawMappings: Record<string, CSSProperties>,
readonly colors: ColorsTheme,
semanticColors?: SemanticColors,
2025-04-23 17:37:09 -07:00
) {
this.semanticColors = semanticColors ?? {
text: {
primary: this.colors.Foreground,
secondary: this.colors.Gray,
link: this.colors.AccentBlue,
accent: this.colors.AccentPurple,
response: this.colors.Foreground,
},
background: {
primary: this.colors.Background,
diff: {
added: this.colors.DiffAdded,
removed: this.colors.DiffRemoved,
},
},
border: {
default: interpolateColor(
this.colors.Background,
this.colors.Foreground,
DEFAULT_BORDER_OPACITY,
),
focused: this.colors.AccentBlue,
},
ui: {
comment: this.colors.Gray,
symbol: this.colors.AccentCyan,
2025-11-01 16:50:51 -07:00
dark: this.colors.DarkGray,
gradient: this.colors.GradientColors,
},
status: {
error: this.colors.AccentRed,
success: this.colors.AccentGreen,
warning: this.colors.AccentYellow,
},
};
2025-04-22 18:37:58 -07:00
this._colorMap = Object.freeze(this._buildColorMap(rawMappings)); // Build and freeze the map
// Determine the default foreground color
const rawDefaultColor = rawMappings['hljs']?.color;
this.defaultColor =
(rawDefaultColor ? Theme._resolveColor(rawDefaultColor) : undefined) ??
''; // Default to empty string if not found or resolvable
}
/**
* Gets the Ink-compatible color string for a given highlight.js class name.
* @param hljsClass The highlight.js class name (e.g., 'hljs-keyword', 'hljs-string').
* @returns The corresponding Ink color string (hex or name) if it exists.
*/
getInkColor(hljsClass: string): string | undefined {
return this._colorMap[hljsClass];
}
/**
* Resolves a CSS color value (name or hex) into an Ink-compatible color string.
* @param colorValue The raw color string (e.g., 'blue', '#ff0000', 'darkkhaki').
* @returns An Ink-compatible color string (hex or name), or undefined if not resolvable.
*/
private static _resolveColor(colorValue: string): string | undefined {
2025-07-20 16:51:18 +09:00
return resolveColor(colorValue);
2025-04-22 18:37:58 -07:00
}
/**
* Builds the internal map from highlight.js class names to Ink-compatible color strings.
* This method is protected and primarily intended for use by the constructor.
* @param hljsTheme The raw CSSProperties mappings from a react-syntax-highlighter theme object.
* @returns An Ink-compatible theme map (Record<string, string>).
*/
protected _buildColorMap(
hljsTheme: Record<string, CSSProperties>,
): Record<string, string> {
const inkTheme: Record<string, string> = {};
for (const key in hljsTheme) {
// Ensure the key starts with 'hljs-' or is 'hljs' for the base style
if (!key.startsWith('hljs-') && key !== 'hljs') {
continue; // Skip keys not related to highlighting classes
}
const style = hljsTheme[key];
if (style?.color) {
const resolvedColor = Theme._resolveColor(style.color);
if (resolvedColor !== undefined) {
// Use the original key from the hljsTheme (e.g., 'hljs-keyword')
inkTheme[key] = resolvedColor;
}
// If color is not resolvable, it's omitted from the map,
2025-07-21 17:54:44 -04:00
// this enables falling back to the default foreground color.
2025-04-22 18:37:58 -07:00
}
// We currently only care about the 'color' property for Ink rendering.
// Other properties like background, fontStyle, etc., are ignored.
}
return inkTheme;
}
}
2025-07-20 16:51:18 +09:00
/**
* Creates a Theme instance from a custom theme configuration.
* @param customTheme The custom theme configuration.
* @returns A new Theme instance.
*/
export function createCustomTheme(customTheme: CustomTheme): Theme {
2025-08-07 16:11:35 -07:00
const colors: ColorsTheme = {
type: 'custom',
Background: customTheme.background?.primary ?? customTheme.Background ?? '',
Foreground: customTheme.text?.primary ?? customTheme.Foreground ?? '',
LightBlue: customTheme.text?.link ?? customTheme.LightBlue ?? '',
AccentBlue: customTheme.text?.link ?? customTheme.AccentBlue ?? '',
AccentPurple: customTheme.text?.accent ?? customTheme.AccentPurple ?? '',
AccentCyan: customTheme.text?.link ?? customTheme.AccentCyan ?? '',
AccentGreen: customTheme.status?.success ?? customTheme.AccentGreen ?? '',
AccentYellow: customTheme.status?.warning ?? customTheme.AccentYellow ?? '',
AccentRed: customTheme.status?.error ?? customTheme.AccentRed ?? '',
DiffAdded:
customTheme.background?.diff?.added ?? customTheme.DiffAdded ?? '',
DiffRemoved:
customTheme.background?.diff?.removed ?? customTheme.DiffRemoved ?? '',
Comment: customTheme.ui?.comment ?? customTheme.Comment ?? '',
2025-08-07 16:11:35 -07:00
Gray: customTheme.text?.secondary ?? customTheme.Gray ?? '',
2025-11-01 16:50:51 -07:00
DarkGray:
customTheme.DarkGray ??
interpolateColor(
customTheme.text?.secondary ?? customTheme.Gray ?? '',
customTheme.background?.primary ?? customTheme.Background ?? '',
0.5,
),
2025-08-07 16:11:35 -07:00
GradientColors: customTheme.ui?.gradient ?? customTheme.GradientColors,
};
2025-07-20 16:51:18 +09:00
// Generate CSS properties mappings based on the custom theme colors
const rawMappings: Record<string, CSSProperties> = {
hljs: {
display: 'block',
overflowX: 'auto',
padding: '0.5em',
2025-08-07 16:11:35 -07:00
background: colors.Background,
color: colors.Foreground,
2025-07-20 16:51:18 +09:00
},
'hljs-keyword': {
2025-08-07 16:11:35 -07:00
color: colors.AccentBlue,
2025-07-20 16:51:18 +09:00
},
'hljs-literal': {
2025-08-07 16:11:35 -07:00
color: colors.AccentBlue,
2025-07-20 16:51:18 +09:00
},
'hljs-symbol': {
2025-08-07 16:11:35 -07:00
color: colors.AccentBlue,
2025-07-20 16:51:18 +09:00
},
'hljs-name': {
2025-08-07 16:11:35 -07:00
color: colors.AccentBlue,
2025-07-20 16:51:18 +09:00
},
'hljs-link': {
2025-08-07 16:11:35 -07:00
color: colors.AccentBlue,
2025-07-20 16:51:18 +09:00
textDecoration: 'underline',
},
'hljs-built_in': {
2025-08-07 16:11:35 -07:00
color: colors.AccentCyan,
2025-07-20 16:51:18 +09:00
},
'hljs-type': {
2025-08-07 16:11:35 -07:00
color: colors.AccentCyan,
2025-07-20 16:51:18 +09:00
},
'hljs-number': {
2025-08-07 16:11:35 -07:00
color: colors.AccentGreen,
2025-07-20 16:51:18 +09:00
},
'hljs-class': {
2025-08-07 16:11:35 -07:00
color: colors.AccentGreen,
2025-07-20 16:51:18 +09:00
},
'hljs-string': {
2025-08-07 16:11:35 -07:00
color: colors.AccentYellow,
2025-07-20 16:51:18 +09:00
},
'hljs-meta-string': {
2025-08-07 16:11:35 -07:00
color: colors.AccentYellow,
2025-07-20 16:51:18 +09:00
},
'hljs-regexp': {
2025-08-07 16:11:35 -07:00
color: colors.AccentRed,
2025-07-20 16:51:18 +09:00
},
'hljs-template-tag': {
2025-08-07 16:11:35 -07:00
color: colors.AccentRed,
2025-07-20 16:51:18 +09:00
},
'hljs-subst': {
2025-08-07 16:11:35 -07:00
color: colors.Foreground,
2025-07-20 16:51:18 +09:00
},
'hljs-function': {
2025-08-07 16:11:35 -07:00
color: colors.Foreground,
2025-07-20 16:51:18 +09:00
},
'hljs-title': {
2025-08-07 16:11:35 -07:00
color: colors.Foreground,
2025-07-20 16:51:18 +09:00
},
'hljs-params': {
2025-08-07 16:11:35 -07:00
color: colors.Foreground,
2025-07-20 16:51:18 +09:00
},
'hljs-formula': {
2025-08-07 16:11:35 -07:00
color: colors.Foreground,
2025-07-20 16:51:18 +09:00
},
'hljs-comment': {
2025-08-07 16:11:35 -07:00
color: colors.Comment,
2025-07-20 16:51:18 +09:00
fontStyle: 'italic',
},
'hljs-quote': {
2025-08-07 16:11:35 -07:00
color: colors.Comment,
2025-07-20 16:51:18 +09:00
fontStyle: 'italic',
},
'hljs-doctag': {
2025-08-07 16:11:35 -07:00
color: colors.Comment,
2025-07-20 16:51:18 +09:00
},
'hljs-meta': {
2025-08-07 16:11:35 -07:00
color: colors.Gray,
2025-07-20 16:51:18 +09:00
},
'hljs-meta-keyword': {
2025-08-07 16:11:35 -07:00
color: colors.Gray,
2025-07-20 16:51:18 +09:00
},
'hljs-tag': {
2025-08-07 16:11:35 -07:00
color: colors.Gray,
2025-07-20 16:51:18 +09:00
},
'hljs-variable': {
2025-08-07 16:11:35 -07:00
color: colors.AccentPurple,
2025-07-20 16:51:18 +09:00
},
'hljs-template-variable': {
2025-08-07 16:11:35 -07:00
color: colors.AccentPurple,
2025-07-20 16:51:18 +09:00
},
'hljs-attr': {
2025-08-07 16:11:35 -07:00
color: colors.LightBlue,
2025-07-20 16:51:18 +09:00
},
'hljs-attribute': {
2025-08-07 16:11:35 -07:00
color: colors.LightBlue,
2025-07-20 16:51:18 +09:00
},
'hljs-builtin-name': {
2025-08-07 16:11:35 -07:00
color: colors.LightBlue,
2025-07-20 16:51:18 +09:00
},
'hljs-section': {
2025-08-07 16:11:35 -07:00
color: colors.AccentYellow,
2025-07-20 16:51:18 +09:00
},
'hljs-emphasis': {
fontStyle: 'italic',
},
'hljs-strong': {
fontWeight: 'bold',
},
'hljs-bullet': {
2025-08-07 16:11:35 -07:00
color: colors.AccentYellow,
2025-07-20 16:51:18 +09:00
},
'hljs-selector-tag': {
2025-08-07 16:11:35 -07:00
color: colors.AccentYellow,
2025-07-20 16:51:18 +09:00
},
'hljs-selector-id': {
2025-08-07 16:11:35 -07:00
color: colors.AccentYellow,
2025-07-20 16:51:18 +09:00
},
'hljs-selector-class': {
2025-08-07 16:11:35 -07:00
color: colors.AccentYellow,
2025-07-20 16:51:18 +09:00
},
'hljs-selector-attr': {
2025-08-07 16:11:35 -07:00
color: colors.AccentYellow,
2025-07-20 16:51:18 +09:00
},
'hljs-selector-pseudo': {
2025-08-07 16:11:35 -07:00
color: colors.AccentYellow,
2025-07-20 16:51:18 +09:00
},
'hljs-addition': {
2025-08-07 16:11:35 -07:00
backgroundColor: colors.AccentGreen,
2025-07-20 16:51:18 +09:00
display: 'inline-block',
width: '100%',
},
'hljs-deletion': {
2025-08-07 16:11:35 -07:00
backgroundColor: colors.AccentRed,
2025-07-20 16:51:18 +09:00
display: 'inline-block',
width: '100%',
},
};
2025-08-07 16:11:35 -07:00
const semanticColors: SemanticColors = {
text: {
2025-09-10 10:57:07 -07:00
primary: customTheme.text?.primary ?? colors.Foreground,
secondary: customTheme.text?.secondary ?? colors.Gray,
link: customTheme.text?.link ?? colors.AccentBlue,
accent: customTheme.text?.accent ?? colors.AccentPurple,
response:
customTheme.text?.response ??
customTheme.text?.primary ??
colors.Foreground,
2025-08-07 16:11:35 -07:00
},
background: {
2025-09-10 10:57:07 -07:00
primary: customTheme.background?.primary ?? colors.Background,
2025-08-07 16:11:35 -07:00
diff: {
2025-09-10 10:57:07 -07:00
added: customTheme.background?.diff?.added ?? colors.DiffAdded,
removed: customTheme.background?.diff?.removed ?? colors.DiffRemoved,
2025-08-07 16:11:35 -07:00
},
},
border: {
default:
customTheme.border?.default ??
interpolateColor(
colors.Background,
colors.Foreground,
DEFAULT_BORDER_OPACITY,
),
2025-09-10 10:57:07 -07:00
focused: customTheme.border?.focused ?? colors.AccentBlue,
2025-08-07 16:11:35 -07:00
},
ui: {
2025-09-10 10:57:07 -07:00
comment: customTheme.ui?.comment ?? colors.Comment,
symbol: customTheme.ui?.symbol ?? colors.Gray,
2025-11-01 16:50:51 -07:00
dark: colors.DarkGray,
2025-09-10 10:57:07 -07:00
gradient: customTheme.ui?.gradient ?? colors.GradientColors,
2025-08-07 16:11:35 -07:00
},
status: {
2025-09-10 10:57:07 -07:00
error: customTheme.status?.error ?? colors.AccentRed,
success: customTheme.status?.success ?? colors.AccentGreen,
warning: customTheme.status?.warning ?? colors.AccentYellow,
2025-08-07 16:11:35 -07:00
},
};
return new Theme(
customTheme.name,
'custom',
rawMappings,
colors,
semanticColors,
);
2025-07-20 16:51:18 +09:00
}
/**
* Validates a custom theme configuration.
* @param customTheme The custom theme to validate.
* @returns An object with isValid boolean and error message if invalid.
*/
export function validateCustomTheme(customTheme: Partial<CustomTheme>): {
isValid: boolean;
error?: string;
warning?: string;
2025-07-20 16:51:18 +09:00
} {
2025-08-07 16:11:35 -07:00
// Since all fields are optional, we only need to validate the name.
2025-07-20 16:51:18 +09:00
if (customTheme.name && !isValidThemeName(customTheme.name)) {
return {
isValid: false,
error: `Invalid theme name: ${customTheme.name}`,
};
}
return {
isValid: true,
};
2025-07-20 16:51:18 +09:00
}
/**
* Checks if a theme name is valid.
* @param name The theme name to validate.
* @returns True if the theme name is valid.
*/
function isValidThemeName(name: string): boolean {
// Theme name should be non-empty and not contain invalid characters
return name.trim().length > 0 && name.trim().length <= 50;
}
2025-12-18 10:36:48 -08:00
/**
* Picks a default theme name based on terminal background color.
* It first tries to find a theme with an exact background color match.
* If no match is found, it falls back to a light or dark theme based on the
* luminance of the background color.
* @param terminalBackground The hex color string of the terminal background.
* @param availableThemes A list of available themes to search through.
* @param defaultDarkThemeName The name of the fallback dark theme.
* @param defaultLightThemeName The name of the fallback light theme.
* @returns The name of the chosen theme.
*/
export function pickDefaultThemeName(
terminalBackground: string | undefined,
availableThemes: readonly Theme[],
defaultDarkThemeName: string,
defaultLightThemeName: string,
): string {
if (terminalBackground) {
const lowerTerminalBackground = terminalBackground.toLowerCase();
for (const theme of availableThemes) {
if (!theme.colors.Background) continue;
// resolveColor can return undefined
const themeBg = resolveColor(theme.colors.Background)?.toLowerCase();
if (themeBg === lowerTerminalBackground) {
return theme.name;
}
}
}
const themeType = getThemeTypeFromBackgroundColor(terminalBackground);
if (themeType === 'light') {
return defaultLightThemeName;
}
return defaultDarkThemeName;
}