feat(cli): implement automatic theme switching based on terminal background (#17976)

Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
Abhijit Balaji
2026-02-02 16:39:17 -08:00
committed by GitHub
parent f57fd642df
commit 4e4a55be35
18 changed files with 807 additions and 93 deletions

View File

@@ -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();
});
});
});

View File

@@ -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)}`;
}

View File

@@ -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.