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

@@ -157,6 +157,12 @@ vi.mock('./components/shared/text-buffer.js');
vi.mock('./hooks/useLogger.js');
vi.mock('./hooks/useInputHistoryStore.js');
vi.mock('./hooks/useHookDisplayState.js');
vi.mock('./hooks/useTerminalTheme.js', () => ({
useTerminalTheme: vi.fn(),
}));
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
// Mock external utilities
vi.mock('../utils/events.js');
@@ -185,7 +191,6 @@ import { useTextBuffer } from './components/shared/text-buffer.js';
import { useLogger } from './hooks/useLogger.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
import { useKeypress, type Key } from './hooks/useKeypress.js';
import { measureElement } from 'ink';
import { useTerminalSize } from './hooks/useTerminalSize.js';
@@ -260,6 +265,7 @@ describe('AppContainer State Management', () => {
const mockedUseKeypress = useKeypress as Mock;
const mockedUseInputHistoryStore = useInputHistoryStore as Mock;
const mockedUseHookDisplayState = useHookDisplayState as Mock;
const mockedUseTerminalTheme = useTerminalTheme as Mock;
const DEFAULT_GEMINI_STREAM_MOCK = {
streamingState: 'idle',
@@ -388,6 +394,7 @@ describe('AppContainer State Management', () => {
currentLoadingPhrase: '',
});
mockedUseHookDisplayState.mockReturnValue([]);
mockedUseTerminalTheme.mockReturnValue(undefined);
// Mock Config
mockConfig = makeFakeConfig();

View File

@@ -141,6 +141,7 @@ import {
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
import { isSlashCommand } from './utils/commandUtils.js';
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
return pendingHistoryItems.some((item) => {
@@ -601,6 +602,9 @@ export const AppContainer = (props: AppContainerProps) => {
initializationResult.themeError,
);
// Poll for terminal background color changes to auto-switch theme
useTerminalTheme(handleThemeSelect, config);
const {
authState,
setAuthState,

View File

@@ -31,8 +31,8 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
Hide Window Title false │
Hide the window title bar
Auto Theme Switching true │
Automatically switch between default light and dark themes based on terminal backgro…
│ │
│ ▼ │
│ │
@@ -77,8 +77,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
Hide Window Title false │
Hide the window title bar
Auto Theme Switching true │
Automatically switch between default light and dark themes based on terminal backgro…
│ │
│ ▼ │
│ │
@@ -123,8 +123,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
Hide Window Title false*
Hide the window title bar
Auto Theme Switching true
Automatically switch between default light and dark themes based on terminal backgro…
│ │
│ ▼ │
│ │
@@ -169,8 +169,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
Hide Window Title false │
Hide the window title bar
Auto Theme Switching true │
Automatically switch between default light and dark themes based on terminal backgro…
│ │
│ ▼ │
│ │
@@ -215,8 +215,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
Hide Window Title false │
Hide the window title bar
Auto Theme Switching true │
Automatically switch between default light and dark themes based on terminal backgro…
│ │
│ ▼ │
│ │
@@ -261,8 +261,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
Hide Window Title false │
Hide the window title bar
Auto Theme Switching true │
Automatically switch between default light and dark themes based on terminal backgro…
│ │
│ ▼ │
│ │
@@ -307,8 +307,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
Hide Window Title false*
Hide the window title bar
Auto Theme Switching true
Automatically switch between default light and dark themes based on terminal backgro…
│ │
│ ▼ │
│ │
@@ -353,8 +353,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
Hide Window Title false │
Hide the window title bar
Auto Theme Switching true │
Automatically switch between default light and dark themes based on terminal backgro…
│ │
│ ▼ │
│ │
@@ -399,8 +399,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
Hide Window Title true*
Hide the window title bar
Auto Theme Switching true │
Automatically switch between default light and dark themes based on terminal backgro…
│ │
│ ▼ │
│ │

View File

@@ -0,0 +1,81 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { TerminalProvider, useTerminalContext } from './TerminalContext.js';
import { vi, describe, it, expect, type Mock } from 'vitest';
import { useEffect, act } from 'react';
import { EventEmitter } from 'node:events';
import { waitFor } from '../../test-utils/async.js';
const mockStdin = new EventEmitter() as unknown as NodeJS.ReadStream &
EventEmitter;
// Add required properties for Ink's StdinProps
(mockStdin as unknown as { write: Mock }).write = vi.fn();
(mockStdin as unknown as { setEncoding: Mock }).setEncoding = vi.fn();
(mockStdin as unknown as { setRawMode: Mock }).setRawMode = vi.fn();
(mockStdin as unknown as { isTTY: boolean }).isTTY = true;
// Mock removeListener specifically as it is used in cleanup
(mockStdin as unknown as { removeListener: Mock }).removeListener = vi.fn(
(event: string, listener: (...args: unknown[]) => void) => {
mockStdin.off(event, listener);
},
);
vi.mock('ink', () => ({
useStdin: () => ({
stdin: mockStdin,
}),
}));
const TestComponent = ({ onColor }: { onColor: (c: string) => void }) => {
const { subscribe } = useTerminalContext();
useEffect(() => {
subscribe(onColor);
}, [subscribe, onColor]);
return null;
};
describe('TerminalContext', () => {
it('should parse OSC 11 response', async () => {
const handleColor = vi.fn();
render(
<TerminalProvider>
<TestComponent onColor={handleColor} />
</TerminalProvider>,
);
act(() => {
mockStdin.emit('data', '\x1b]11;rgb:ffff/ffff/ffff\x1b\\');
});
await waitFor(() => {
expect(handleColor).toHaveBeenCalledWith('rgb:ffff/ffff/ffff');
});
});
it('should handle partial chunks', async () => {
const handleColor = vi.fn();
render(
<TerminalProvider>
<TestComponent onColor={handleColor} />
</TerminalProvider>,
);
act(() => {
mockStdin.emit('data', '\x1b]11;rgb:0000/');
});
expect(handleColor).not.toHaveBeenCalled();
act(() => {
mockStdin.emit('data', '0000/0000\x1b\\');
});
await waitFor(() => {
expect(handleColor).toHaveBeenCalledWith('rgb:0000/0000/0000');
});
});
});

View File

@@ -0,0 +1,96 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useStdin } from 'ink';
import type React from 'react';
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
} from 'react';
import { TerminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
export type TerminalEventHandler = (event: string) => void;
interface TerminalContextValue {
subscribe: (handler: TerminalEventHandler) => void;
unsubscribe: (handler: TerminalEventHandler) => void;
}
const TerminalContext = createContext<TerminalContextValue | undefined>(
undefined,
);
export function useTerminalContext() {
const context = useContext(TerminalContext);
if (!context) {
throw new Error(
'useTerminalContext must be used within a TerminalProvider',
);
}
return context;
}
export function TerminalProvider({ children }: { children: React.ReactNode }) {
const { stdin } = useStdin();
const subscribers = useRef<Set<TerminalEventHandler>>(new Set()).current;
const bufferRef = useRef('');
const subscribe = useCallback(
(handler: TerminalEventHandler) => {
subscribers.add(handler);
},
[subscribers],
);
const unsubscribe = useCallback(
(handler: TerminalEventHandler) => {
subscribers.delete(handler);
},
[subscribers],
);
useEffect(() => {
const handleData = (data: Buffer | string) => {
bufferRef.current +=
typeof data === 'string' ? data : data.toString('utf-8');
// Check for OSC 11 response
const match = bufferRef.current.match(
TerminalCapabilityManager.OSC_11_REGEX,
);
if (match) {
const colorStr = `rgb:${match[1]}/${match[2]}/${match[3]}`;
for (const handler of subscribers) {
handler(colorStr);
}
// Safely remove the processed part + match
if (match.index !== undefined) {
bufferRef.current = bufferRef.current.slice(
match.index + match[0].length,
);
}
} else if (bufferRef.current.length > 4096) {
// Safety valve: if buffer gets too large without a match, trim it.
// We keep the last 1024 bytes to avoid cutting off a partial sequence.
bufferRef.current = bufferRef.current.slice(-1024);
}
};
stdin.on('data', handleData);
return () => {
stdin.removeListener('data', handleData);
};
}, [stdin, subscribers]);
return (
<TerminalContext.Provider value={{ subscribe, unsubscribe }}>
{children}
</TerminalContext.Provider>
);
}

View File

@@ -0,0 +1,167 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderHook } from '../../test-utils/render.js';
import { useTerminalTheme } from './useTerminalTheme.js';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { makeFakeConfig, type Config } from '@google/gemini-cli-core';
import os from 'node:os';
// Mocks
const mockWrite = vi.fn();
const mockSubscribe = vi.fn();
const mockUnsubscribe = vi.fn();
const mockHandleThemeSelect = vi.fn();
vi.mock('ink', async () => ({
useStdout: () => ({
stdout: {
write: mockWrite,
},
}),
}));
vi.mock('../contexts/TerminalContext.js', () => ({
useTerminalContext: () => ({
subscribe: mockSubscribe,
unsubscribe: mockUnsubscribe,
}),
}));
const mockSettings = {
merged: {
ui: {
theme: 'default', // DEFAULT_THEME.name
autoThemeSwitching: true,
terminalBackgroundPollingInterval: 60,
},
},
};
vi.mock('../contexts/SettingsContext.js', () => ({
useSettings: () => mockSettings,
}));
vi.mock('../themes/theme-manager.js', async () => {
const actual = await vi.importActual('../themes/theme-manager.js');
return {
...actual,
themeManager: {
isDefaultTheme: (name: string) =>
name === 'default' || name === 'default-light',
},
DEFAULT_THEME: { name: 'default' },
};
});
vi.mock('../themes/default-light.js', () => ({
DefaultLight: { name: 'default-light' },
}));
describe('useTerminalTheme', () => {
let config: Config;
beforeEach(() => {
vi.useFakeTimers();
config = makeFakeConfig({
targetDir: os.tmpdir(),
});
// Set initial background to ensure the hook passes the startup check.
config.setTerminalBackground('#000000');
// Spy on future updates.
vi.spyOn(config, 'setTerminalBackground');
mockWrite.mockClear();
mockSubscribe.mockClear();
mockUnsubscribe.mockClear();
mockHandleThemeSelect.mockClear();
// Reset any settings modifications
mockSettings.merged.ui.autoThemeSwitching = true;
mockSettings.merged.ui.theme = 'default';
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('should subscribe to terminal background events on mount', () => {
renderHook(() => useTerminalTheme(mockHandleThemeSelect, config));
expect(mockSubscribe).toHaveBeenCalled();
});
it('should unsubscribe on unmount', () => {
const { unmount } = renderHook(() =>
useTerminalTheme(mockHandleThemeSelect, config),
);
unmount();
expect(mockUnsubscribe).toHaveBeenCalled();
});
it('should poll for terminal background', () => {
renderHook(() => useTerminalTheme(mockHandleThemeSelect, config));
// Fast-forward time (1 minute)
vi.advanceTimersByTime(60000);
expect(mockWrite).toHaveBeenCalledWith('\x1b]11;?\x1b\\');
});
it('should not poll if terminal background is undefined at startup', () => {
config.getTerminalBackground = vi.fn().mockReturnValue(undefined);
renderHook(() => useTerminalTheme(mockHandleThemeSelect, config));
// Poll should not happen
vi.advanceTimersByTime(60000);
expect(mockWrite).not.toHaveBeenCalled();
});
it('should switch to light theme when background is light', () => {
renderHook(() => useTerminalTheme(mockHandleThemeSelect, config));
const handler = mockSubscribe.mock.calls[0][0];
// Simulate light background response (white)
handler('rgb:ffff/ffff/ffff');
expect(config.setTerminalBackground).toHaveBeenCalledWith('#ffffff');
expect(mockHandleThemeSelect).toHaveBeenCalledWith(
'default-light',
expect.anything(),
);
});
it('should switch to dark theme when background is dark', () => {
// Start with light theme
mockSettings.merged.ui.theme = 'default-light';
renderHook(() => useTerminalTheme(mockHandleThemeSelect, config));
const handler = mockSubscribe.mock.calls[0][0];
// Simulate dark background response (black)
handler('rgb:0000/0000/0000');
expect(config.setTerminalBackground).toHaveBeenCalledWith('#000000');
expect(mockHandleThemeSelect).toHaveBeenCalledWith(
'default',
expect.anything(),
);
// Reset theme
mockSettings.merged.ui.theme = 'default';
});
it('should not switch theme if autoThemeSwitching is disabled', () => {
mockSettings.merged.ui.autoThemeSwitching = false;
renderHook(() => useTerminalTheme(mockHandleThemeSelect, config));
// Poll should not happen
vi.advanceTimersByTime(60000);
expect(mockWrite).not.toHaveBeenCalled();
mockSettings.merged.ui.autoThemeSwitching = true;
});
});

View File

@@ -0,0 +1,92 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useEffect } from 'react';
import { useStdout } from 'ink';
import {
getLuminance,
parseColor,
shouldSwitchTheme,
} from '../themes/color-utils.js';
import { themeManager, DEFAULT_THEME } from '../themes/theme-manager.js';
import { DefaultLight } from '../themes/default-light.js';
import { useSettings } from '../contexts/SettingsContext.js';
import type { Config } from '@google/gemini-cli-core';
import { useTerminalContext } from '../contexts/TerminalContext.js';
import { SettingScope } from '../../config/settings.js';
import type { UIActions } from '../contexts/UIActionsContext.js';
export function useTerminalTheme(
handleThemeSelect: UIActions['handleThemeSelect'],
config: Config,
) {
const { stdout } = useStdout();
const settings = useSettings();
const { subscribe, unsubscribe } = useTerminalContext();
useEffect(() => {
if (settings.merged.ui.autoThemeSwitching === false) {
return;
}
// Only poll for changes to the terminal background if a terminal background was detected at startup.
if (config.getTerminalBackground() === undefined) {
return;
}
const pollIntervalId = setInterval(() => {
// Only poll if we are using one of the default themes
const currentThemeName = settings.merged.ui.theme;
if (!themeManager.isDefaultTheme(currentThemeName)) {
return;
}
stdout.write('\x1b]11;?\x1b\\');
}, settings.merged.ui.terminalBackgroundPollingInterval * 1000);
const handleTerminalBackground = (colorStr: string) => {
// Parse the response "rgb:rrrr/gggg/bbbb"
const match =
/^rgb:([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})$/.exec(
colorStr,
);
if (!match) return;
const hexColor = parseColor(match[1], match[2], match[3]);
const luminance = getLuminance(hexColor);
config.setTerminalBackground(hexColor);
const currentThemeName = settings.merged.ui.theme;
const newTheme = shouldSwitchTheme(
currentThemeName,
luminance,
DEFAULT_THEME.name,
DefaultLight.name,
);
if (newTheme) {
handleThemeSelect(newTheme, SettingScope.User);
}
};
subscribe(handleTerminalBackground);
return () => {
clearInterval(pollIntervalId);
unsubscribe(handleTerminalBackground);
};
}, [
settings.merged.ui.theme,
settings.merged.ui.autoThemeSwitching,
settings.merged.ui.terminalBackgroundPollingInterval,
stdout,
config,
handleThemeSelect,
subscribe,
unsubscribe,
]);
}

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.

View File

@@ -14,6 +14,7 @@ import {
enableBracketedPasteMode,
disableBracketedPasteMode,
} from '@google/gemini-cli-core';
import { parseColor } from '../themes/color-utils.js';
export type TerminalBackgroundColor = string | undefined;
@@ -36,7 +37,7 @@ export class TerminalCapabilityManager {
// eslint-disable-next-line no-control-regex
private static readonly DEVICE_ATTRIBUTES_REGEX = /\x1b\[\?(\d+)(;\d+)*c/;
// OSC 11 response: OSC 11 ; rgb:rrrr/gggg/bbbb ST (or BEL)
private static readonly OSC_11_REGEX =
static readonly OSC_11_REGEX =
// eslint-disable-next-line no-control-regex
/\x1b\]11;rgb:([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})(\x1b\\|\x07)?/;
// modifyOtherKeys response: CSI > 4 ; level m
@@ -129,7 +130,7 @@ export class TerminalCapabilityManager {
const match = buffer.match(TerminalCapabilityManager.OSC_11_REGEX);
if (match) {
bgReceived = true;
this.terminalBackgroundColor = this.parseColor(
this.terminalBackgroundColor = parseColor(
match[1],
match[2],
match[3],
@@ -234,24 +235,6 @@ export class TerminalCapabilityManager {
isKittyProtocolEnabled(): boolean {
return this.kittyEnabled;
}
private 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)}`;
}
}
export const terminalCapabilityManager =