mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-14 23:31:13 -07:00
feat(cli): implement automatic theme switching based on terminal background (#17976)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
@@ -12,6 +12,9 @@ import {
|
||||
CSS_NAME_TO_HEX_MAP,
|
||||
INK_SUPPORTED_NAMES,
|
||||
getThemeTypeFromBackgroundColor,
|
||||
getLuminance,
|
||||
parseColor,
|
||||
shouldSwitchTheme,
|
||||
} from './color-utils.js';
|
||||
|
||||
describe('Color Utils', () => {
|
||||
@@ -279,4 +282,149 @@ describe('Color Utils', () => {
|
||||
expect(getThemeTypeFromBackgroundColor('000000')).toBe('dark');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLuminance', () => {
|
||||
it('should calculate luminance correctly', () => {
|
||||
// White: 0.2126*255 + 0.7152*255 + 0.0722*255 = 255
|
||||
expect(getLuminance('#ffffff')).toBeCloseTo(255);
|
||||
// Black: 0.2126*0 + 0.7152*0 + 0.0722*0 = 0
|
||||
expect(getLuminance('#000000')).toBeCloseTo(0);
|
||||
// Pure Red: 0.2126*255 = 54.213
|
||||
expect(getLuminance('#ff0000')).toBeCloseTo(54.213);
|
||||
// Pure Green: 0.7152*255 = 182.376
|
||||
expect(getLuminance('#00ff00')).toBeCloseTo(182.376);
|
||||
// Pure Blue: 0.0722*255 = 18.411
|
||||
expect(getLuminance('#0000ff')).toBeCloseTo(18.411);
|
||||
});
|
||||
|
||||
it('should handle colors without # prefix', () => {
|
||||
expect(getLuminance('ffffff')).toBeCloseTo(255);
|
||||
});
|
||||
|
||||
it('should handle 3-digit hex codes', () => {
|
||||
// #fff -> #ffffff -> 255
|
||||
expect(getLuminance('#fff')).toBeCloseTo(255);
|
||||
// #000 -> #000000 -> 0
|
||||
expect(getLuminance('#000')).toBeCloseTo(0);
|
||||
// #f00 -> #ff0000 -> 54.213
|
||||
expect(getLuminance('#f00')).toBeCloseTo(54.213);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseColor', () => {
|
||||
it('should parse 1-digit components', () => {
|
||||
// F/F/F => #ffffff
|
||||
expect(parseColor('f', 'f', 'f')).toBe('#ffffff');
|
||||
// 0/0/0 => #000000
|
||||
expect(parseColor('0', '0', '0')).toBe('#000000');
|
||||
});
|
||||
|
||||
it('should parse 2-digit components', () => {
|
||||
// ff/ff/ff => #ffffff
|
||||
expect(parseColor('ff', 'ff', 'ff')).toBe('#ffffff');
|
||||
// 80/80/80 => #808080
|
||||
expect(parseColor('80', '80', '80')).toBe('#808080');
|
||||
});
|
||||
|
||||
it('should parse 4-digit components (standard X11)', () => {
|
||||
// ffff/ffff/ffff => #ffffff (65535/65535 * 255 = 255)
|
||||
expect(parseColor('ffff', 'ffff', 'ffff')).toBe('#ffffff');
|
||||
// 0000/0000/0000 => #000000
|
||||
expect(parseColor('0000', '0000', '0000')).toBe('#000000');
|
||||
// 7fff/7fff/7fff => approx #7f7f7f (32767/65535 * 255 = 127.498... -> 127 -> 7f)
|
||||
expect(parseColor('7fff', '7fff', '7fff')).toBe('#7f7f7f');
|
||||
});
|
||||
|
||||
it('should handle mixed case', () => {
|
||||
expect(parseColor('FFFF', 'FFFF', 'FFFF')).toBe('#ffffff');
|
||||
expect(parseColor('Ffff', 'fFFF', 'ffFF')).toBe('#ffffff');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldSwitchTheme', () => {
|
||||
const DEFAULT_THEME = 'default';
|
||||
const DEFAULT_LIGHT_THEME = 'default-light';
|
||||
const LIGHT_THRESHOLD = 140;
|
||||
const DARK_THRESHOLD = 110;
|
||||
|
||||
it('should switch to light theme if luminance > threshold and current is default', () => {
|
||||
// 141 > 140
|
||||
expect(
|
||||
shouldSwitchTheme(
|
||||
DEFAULT_THEME,
|
||||
LIGHT_THRESHOLD + 1,
|
||||
DEFAULT_THEME,
|
||||
DEFAULT_LIGHT_THEME,
|
||||
),
|
||||
).toBe(DEFAULT_LIGHT_THEME);
|
||||
|
||||
// Undefined current theme counts as default
|
||||
expect(
|
||||
shouldSwitchTheme(
|
||||
undefined,
|
||||
LIGHT_THRESHOLD + 1,
|
||||
DEFAULT_THEME,
|
||||
DEFAULT_LIGHT_THEME,
|
||||
),
|
||||
).toBe(DEFAULT_LIGHT_THEME);
|
||||
});
|
||||
|
||||
it('should NOT switch to light theme if luminance <= threshold', () => {
|
||||
// 140 <= 140
|
||||
expect(
|
||||
shouldSwitchTheme(
|
||||
DEFAULT_THEME,
|
||||
LIGHT_THRESHOLD,
|
||||
DEFAULT_THEME,
|
||||
DEFAULT_LIGHT_THEME,
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should NOT switch to light theme if current theme is not default', () => {
|
||||
expect(
|
||||
shouldSwitchTheme(
|
||||
'custom-theme',
|
||||
LIGHT_THRESHOLD + 1,
|
||||
DEFAULT_THEME,
|
||||
DEFAULT_LIGHT_THEME,
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should switch to dark theme if luminance < threshold and current is default light', () => {
|
||||
// 109 < 110
|
||||
expect(
|
||||
shouldSwitchTheme(
|
||||
DEFAULT_LIGHT_THEME,
|
||||
DARK_THRESHOLD - 1,
|
||||
DEFAULT_THEME,
|
||||
DEFAULT_LIGHT_THEME,
|
||||
),
|
||||
).toBe(DEFAULT_THEME);
|
||||
});
|
||||
|
||||
it('should NOT switch to dark theme if luminance >= threshold', () => {
|
||||
// 110 >= 110
|
||||
expect(
|
||||
shouldSwitchTheme(
|
||||
DEFAULT_LIGHT_THEME,
|
||||
DARK_THRESHOLD,
|
||||
DEFAULT_THEME,
|
||||
DEFAULT_LIGHT_THEME,
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should NOT switch to dark theme if current theme is not default light', () => {
|
||||
expect(
|
||||
shouldSwitchTheme(
|
||||
'custom-theme',
|
||||
DARK_THRESHOLD - 1,
|
||||
DEFAULT_THEME,
|
||||
DEFAULT_LIGHT_THEME,
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -286,14 +286,89 @@ export function getThemeTypeFromBackgroundColor(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Parse hex color
|
||||
const hex = backgroundColor.replace(/^#/, '');
|
||||
const luminance = getLuminance(backgroundColor);
|
||||
return luminance > 128 ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the relative luminance of a color.
|
||||
* See https://www.w3.org/TR/WCAG20/#relativeluminancedef
|
||||
*
|
||||
* @param backgroundColor Hex color string (with or without #)
|
||||
* @returns Luminance value (0-255)
|
||||
*/
|
||||
export function getLuminance(backgroundColor: string): number {
|
||||
let hex = backgroundColor.replace(/^#/, '');
|
||||
if (hex.length === 3) {
|
||||
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
||||
}
|
||||
const r = parseInt(hex.substring(0, 2), 16);
|
||||
const g = parseInt(hex.substring(2, 4), 16);
|
||||
const b = parseInt(hex.substring(4, 6), 16);
|
||||
|
||||
// Calculate luminance
|
||||
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
|
||||
return luminance > 128 ? 'light' : 'dark';
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
// Hysteresis thresholds to prevent flickering when the background color
|
||||
// is ambiguous (near the midpoint).
|
||||
export const LIGHT_THEME_LUMINANCE_THRESHOLD = 140;
|
||||
export const DARK_THEME_LUMINANCE_THRESHOLD = 110;
|
||||
|
||||
/**
|
||||
* Determines if the theme should be switched based on background luminance.
|
||||
* Uses hysteresis to prevent flickering.
|
||||
*
|
||||
* @param currentThemeName The name of the currently active theme
|
||||
* @param luminance The calculated relative luminance of the background (0-255)
|
||||
* @param defaultThemeName The name of the default (dark) theme
|
||||
* @param defaultLightThemeName The name of the default light theme
|
||||
* @returns The name of the theme to switch to, or undefined if no switch is needed.
|
||||
*/
|
||||
export function shouldSwitchTheme(
|
||||
currentThemeName: string | undefined,
|
||||
luminance: number,
|
||||
defaultThemeName: string,
|
||||
defaultLightThemeName: string,
|
||||
): string | undefined {
|
||||
const isDefaultTheme =
|
||||
currentThemeName === defaultThemeName || currentThemeName === undefined;
|
||||
const isDefaultLightTheme = currentThemeName === defaultLightThemeName;
|
||||
|
||||
if (luminance > LIGHT_THEME_LUMINANCE_THRESHOLD && isDefaultTheme) {
|
||||
return defaultLightThemeName;
|
||||
} else if (
|
||||
luminance < DARK_THEME_LUMINANCE_THRESHOLD &&
|
||||
isDefaultLightTheme
|
||||
) {
|
||||
return defaultThemeName;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an X11 RGB string (e.g. from OSC 11) into a hex color string.
|
||||
* Supports 1-4 digit hex values per channel (e.g., F, FF, FFF, FFFF).
|
||||
*
|
||||
* @param rHex Red component as hex string
|
||||
* @param gHex Green component as hex string
|
||||
* @param bHex Blue component as hex string
|
||||
* @returns Hex color string (e.g. #RRGGBB)
|
||||
*/
|
||||
export function parseColor(rHex: string, gHex: string, bHex: string): string {
|
||||
const parseComponent = (hex: string) => {
|
||||
const val = parseInt(hex, 16);
|
||||
if (hex.length === 1) return (val / 15) * 255;
|
||||
if (hex.length === 2) return val;
|
||||
if (hex.length === 3) return (val / 4095) * 255;
|
||||
if (hex.length === 4) return (val / 65535) * 255;
|
||||
return val;
|
||||
};
|
||||
|
||||
const r = parseComponent(rHex);
|
||||
const g = parseComponent(gHex);
|
||||
const b = parseComponent(bHex);
|
||||
|
||||
const toHex = (c: number) => Math.round(c).toString(16).padStart(2, '0');
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
}
|
||||
|
||||
@@ -63,6 +63,14 @@ class ThemeManager {
|
||||
this.activeTheme = DEFAULT_THEME;
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user