/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { CSSProperties } from 'react'; import type { SemanticColors } from './semantic-tokens.js'; import type { CustomTheme } from '@google/gemini-cli-core'; import { DEFAULT_INPUT_BACKGROUND_OPACITY, DEFAULT_SELECTION_OPACITY, DEFAULT_BORDER_OPACITY, } from '../constants.js'; import tinygradient from 'tinygradient'; import tinycolor from 'tinycolor2'; // Define the set of Ink's named colors for quick lookup export const INK_SUPPORTED_NAMES = new Set([ 'black', 'red', 'green', 'yellow', 'blue', 'cyan', 'magenta', 'white', 'gray', 'grey', 'blackbright', 'redbright', 'greenbright', 'yellowbright', 'bluebright', 'cyanbright', 'magentabright', 'whitebright', ]); // Use tinycolor's built-in names map for CSS colors, excluding ones Ink supports export const CSS_NAME_TO_HEX_MAP = Object.fromEntries( Object.entries(tinycolor.names) .filter(([name]) => !INK_SUPPORTED_NAMES.has(name)) .map(([name, hex]) => [name, `#${hex}`]), ); // Mapping for ANSI bright colors that are not in tinycolor's standard CSS names export const INK_NAME_TO_HEX_MAP: Readonly> = { blackbright: '#555555', redbright: '#ff5555', greenbright: '#55ff55', yellowbright: '#ffff55', bluebright: '#5555ff', magentabright: '#ff55ff', cyanbright: '#55ffff', whitebright: '#ffffff', }; /** * Calculates the relative luminance of a color. * See https://www.w3.org/TR/WCAG20/#relativeluminancedef * * @param color Color string (hex or Ink-supported name) * @returns Luminance value (0-255) */ export function getLuminance(color: string): number { const resolved = color.toLowerCase(); const hex = INK_NAME_TO_HEX_MAP[resolved] || resolved; const colorObj = tinycolor(hex); if (!colorObj.isValid()) { return 0; } // tinycolor returns 0-1, we need 0-255 return colorObj.getLuminance() * 255; } /** * 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. */ export function resolveColor(colorValue: string): string | undefined { const lowerColor = colorValue.toLowerCase(); // 1. Check if it's already a hex code and valid if (lowerColor.startsWith('#')) { if (/^#[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$/.test(colorValue)) { return lowerColor; } else { return undefined; } } // Handle hex codes without # if (/^[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$/.test(colorValue)) { return `#${lowerColor}`; } // 2. Check if it's an Ink supported name (lowercase) if (INK_SUPPORTED_NAMES.has(lowerColor)) { return lowerColor; // Use Ink name directly } // 3. Check if it's a known CSS name we can map to hex // We can't import CSS_NAME_TO_HEX_MAP here due to circular deps, // but we can use tinycolor directly for named colors. const colorObj = tinycolor(lowerColor); if (colorObj.isValid()) { return colorObj.toHexString(); } // 4. Could not resolve return undefined; } export function interpolateColor( color1: string, color2: string, factor: number, ) { if (factor <= 0 && color1) { return color1; } if (factor >= 1 && color2) { return color2; } if (!color1 || !color2) { return ''; } try { const gradient = tinygradient(color1, color2); const color = gradient.rgbAt(factor); return color.toHexString(); } catch (_e) { return color1; } } export function getThemeTypeFromBackgroundColor( backgroundColor: string | undefined, ): 'light' | 'dark' | undefined { if (!backgroundColor) { return undefined; } const resolvedColor = resolveColor(backgroundColor); if (!resolvedColor) { return undefined; } const luminance = getLuminance(resolvedColor); return luminance > 128 ? 'light' : 'dark'; } export type { CustomTheme }; export type ThemeType = 'light' | 'dark' | 'ansi' | 'custom'; export interface ColorsTheme { type: ThemeType; 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; InputBackground?: string; MessageBackground?: string; FocusBackground?: string; FocusColor?: string; GradientColors?: string[]; } export const lightTheme: ColorsTheme = { type: 'light', Background: '#FAFAFA', Foreground: '', LightBlue: '#89BDCD', AccentBlue: '#3B82F6', AccentPurple: '#8B5CF6', AccentCyan: '#06B6D4', AccentGreen: '#3CA84B', AccentYellow: '#D5A40A', AccentRed: '#DD4C4C', DiffAdded: '#C6EAD8', DiffRemoved: '#FFCCCC', Comment: '#008000', Gray: '#97a0b0', DarkGray: interpolateColor('#FAFAFA', '#97a0b0', DEFAULT_BORDER_OPACITY), InputBackground: interpolateColor( '#FAFAFA', '#97a0b0', DEFAULT_INPUT_BACKGROUND_OPACITY, ), MessageBackground: interpolateColor( '#FAFAFA', '#97a0b0', DEFAULT_INPUT_BACKGROUND_OPACITY, ), FocusBackground: interpolateColor( '#FAFAFA', '#3CA84B', DEFAULT_SELECTION_OPACITY, ), GradientColors: ['#4796E4', '#847ACE', '#C3677F'], }; export const darkTheme: ColorsTheme = { type: 'dark', Background: '#1E1E2E', Foreground: '', LightBlue: '#ADD8E6', AccentBlue: '#89B4FA', AccentPurple: '#CBA6F7', AccentCyan: '#89DCEB', AccentGreen: '#A6E3A1', AccentYellow: '#F9E2AF', AccentRed: '#F38BA8', DiffAdded: '#28350B', DiffRemoved: '#430000', Comment: '#6C7086', Gray: '#6C7086', DarkGray: interpolateColor('#1E1E2E', '#6C7086', DEFAULT_BORDER_OPACITY), InputBackground: interpolateColor( '#1E1E2E', '#6C7086', DEFAULT_INPUT_BACKGROUND_OPACITY, ), MessageBackground: interpolateColor( '#1E1E2E', '#6C7086', DEFAULT_INPUT_BACKGROUND_OPACITY, ), FocusBackground: interpolateColor( '#1E1E2E', '#A6E3A1', DEFAULT_SELECTION_OPACITY, ), GradientColors: ['#4796E4', '#847ACE', '#C3677F'], }; export const ansiTheme: ColorsTheme = { type: 'ansi', Background: 'black', Foreground: '', LightBlue: 'blue', AccentBlue: 'blue', AccentPurple: 'magenta', AccentCyan: 'cyan', AccentGreen: 'green', AccentYellow: 'yellow', AccentRed: 'red', DiffAdded: 'green', DiffRemoved: 'red', Comment: 'gray', Gray: 'gray', DarkGray: 'gray', InputBackground: 'black', MessageBackground: 'black', FocusBackground: 'black', }; 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>; readonly semanticColors: SemanticColors; /** * 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. */ constructor( readonly name: string, readonly type: ThemeType, rawMappings: Record, readonly colors: ColorsTheme, semanticColors?: SemanticColors, ) { 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, message: this.colors.MessageBackground ?? interpolateColor( this.colors.Background, this.colors.Gray, DEFAULT_INPUT_BACKGROUND_OPACITY, ), input: this.colors.InputBackground ?? interpolateColor( this.colors.Background, this.colors.Gray, DEFAULT_INPUT_BACKGROUND_OPACITY, ), focus: this.colors.FocusBackground ?? interpolateColor( this.colors.Background, this.colors.FocusColor ?? this.colors.AccentGreen, DEFAULT_SELECTION_OPACITY, ), diff: { added: this.colors.DiffAdded, removed: this.colors.DiffRemoved, }, }, border: { default: this.colors.DarkGray, }, ui: { comment: this.colors.Gray, symbol: this.colors.AccentCyan, active: this.colors.AccentBlue, dark: this.colors.DarkGray, focus: this.colors.FocusColor ?? this.colors.AccentGreen, gradient: this.colors.GradientColors, }, status: { error: this.colors.AccentRed, success: this.colors.AccentGreen, warning: this.colors.AccentYellow, }, }; 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 { return resolveColor(colorValue); } /** * 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). */ protected _buildColorMap( hljsTheme: Record, ): Record { const inkTheme: Record = {}; 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, // this enables falling back to the default foreground color. } // We currently only care about the 'color' property for Ink rendering. // Other properties like background, fontStyle, etc., are ignored. } return inkTheme; } } /** * 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 { 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 ?? '', Gray: customTheme.text?.secondary ?? customTheme.Gray ?? '', DarkGray: customTheme.DarkGray ?? interpolateColor( customTheme.background?.primary ?? customTheme.Background ?? '', customTheme.text?.secondary ?? customTheme.Gray ?? '', DEFAULT_BORDER_OPACITY, ), InputBackground: interpolateColor( customTheme.background?.primary ?? customTheme.Background ?? '', customTheme.text?.secondary ?? customTheme.Gray ?? '', DEFAULT_INPUT_BACKGROUND_OPACITY, ), MessageBackground: interpolateColor( customTheme.background?.primary ?? customTheme.Background ?? '', customTheme.text?.secondary ?? customTheme.Gray ?? '', DEFAULT_INPUT_BACKGROUND_OPACITY, ), FocusBackground: interpolateColor( customTheme.background?.primary ?? customTheme.Background ?? '', customTheme.status?.success ?? customTheme.AccentGreen ?? '#3CA84B', // Fallback to a default green if not found DEFAULT_SELECTION_OPACITY, ), FocusColor: customTheme.ui?.focus ?? customTheme.AccentGreen, GradientColors: customTheme.ui?.gradient ?? customTheme.GradientColors, }; // Generate CSS properties mappings based on the custom theme colors const rawMappings: Record = { hljs: { display: 'block', overflowX: 'auto', padding: '0.5em', background: colors.Background, color: colors.Foreground, }, 'hljs-keyword': { color: colors.AccentBlue, }, 'hljs-literal': { color: colors.AccentBlue, }, 'hljs-symbol': { color: colors.AccentBlue, }, 'hljs-name': { color: colors.AccentBlue, }, 'hljs-link': { color: colors.AccentBlue, textDecoration: 'underline', }, 'hljs-built_in': { color: colors.AccentCyan, }, 'hljs-type': { color: colors.AccentCyan, }, 'hljs-number': { color: colors.AccentGreen, }, 'hljs-class': { color: colors.AccentGreen, }, 'hljs-string': { color: colors.AccentYellow, }, 'hljs-meta-string': { color: colors.AccentYellow, }, 'hljs-regexp': { color: colors.AccentRed, }, 'hljs-template-tag': { color: colors.AccentRed, }, 'hljs-subst': { color: colors.Foreground, }, 'hljs-function': { color: colors.Foreground, }, 'hljs-title': { color: colors.Foreground, }, 'hljs-params': { color: colors.Foreground, }, 'hljs-formula': { color: colors.Foreground, }, 'hljs-comment': { color: colors.Comment, fontStyle: 'italic', }, 'hljs-quote': { color: colors.Comment, fontStyle: 'italic', }, 'hljs-doctag': { color: colors.Comment, }, 'hljs-meta': { color: colors.Gray, }, 'hljs-meta-keyword': { color: colors.Gray, }, 'hljs-tag': { color: colors.Gray, }, 'hljs-variable': { color: colors.AccentPurple, }, 'hljs-template-variable': { color: colors.AccentPurple, }, 'hljs-attr': { color: colors.LightBlue, }, 'hljs-attribute': { color: colors.LightBlue, }, 'hljs-builtin-name': { color: colors.LightBlue, }, 'hljs-section': { color: colors.AccentYellow, }, 'hljs-emphasis': { fontStyle: 'italic', }, 'hljs-strong': { fontWeight: 'bold', }, 'hljs-bullet': { color: colors.AccentYellow, }, 'hljs-selector-tag': { color: colors.AccentYellow, }, 'hljs-selector-id': { color: colors.AccentYellow, }, 'hljs-selector-class': { color: colors.AccentYellow, }, 'hljs-selector-attr': { color: colors.AccentYellow, }, 'hljs-selector-pseudo': { color: colors.AccentYellow, }, 'hljs-addition': { backgroundColor: colors.AccentGreen, display: 'inline-block', width: '100%', }, 'hljs-deletion': { backgroundColor: colors.AccentRed, display: 'inline-block', width: '100%', }, }; const semanticColors: SemanticColors = { text: { 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, }, background: { primary: customTheme.background?.primary ?? colors.Background, message: colors.MessageBackground!, input: colors.InputBackground!, focus: colors.FocusBackground!, diff: { added: customTheme.background?.diff?.added ?? colors.DiffAdded, removed: customTheme.background?.diff?.removed ?? colors.DiffRemoved, }, }, border: { default: colors.DarkGray, }, ui: { comment: customTheme.ui?.comment ?? colors.Comment, symbol: customTheme.ui?.symbol ?? colors.Gray, active: customTheme.ui?.active ?? colors.AccentBlue, dark: colors.DarkGray, focus: colors.FocusColor ?? colors.AccentGreen, gradient: customTheme.ui?.gradient ?? colors.GradientColors, }, status: { error: customTheme.status?.error ?? colors.AccentRed, success: customTheme.status?.success ?? colors.AccentGreen, warning: customTheme.status?.warning ?? colors.AccentYellow, }, }; return new Theme( customTheme.name, 'custom', rawMappings, colors, semanticColors, ); } /** * 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): { isValid: boolean; error?: string; warning?: string; } { // Since all fields are optional, we only need to validate the name. if (customTheme.name && !isValidThemeName(customTheme.name)) { return { isValid: false, error: `Invalid theme name: ${customTheme.name}`, }; } return { isValid: true, }; } /** * 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; } /** * 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; }