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

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