refactor(cli): modularize auto-theme switching and improve robustness

- Extracted theme switching logic into a pure `shouldSwitchTheme` function with hysteresis.
- Added a configurable polling interval setting `terminalBackgroundPollingInterval`.
- Improved `TerminalContext` buffer handling and consolidated OSC 11 parsing.
- Added comprehensive unit tests for `useTerminalTheme`, `TerminalContext`, and `shouldSwitchTheme`.
- Fixed TypeScript and ESLint errors in tests.
This commit is contained in:
Abhijit Balaji
2026-01-30 14:11:42 -08:00
parent 27647bceb5
commit 641ebd64d8
7 changed files with 387 additions and 21 deletions

View File

@@ -361,6 +361,16 @@ const SETTINGS_SCHEMA = {
'Automatically switch between default light and dark themes based on terminal background color.',
showInDialog: true,
},
terminalBackgroundPollingInterval: {
type: 'number',
label: 'Terminal Background Polling Interval',
category: 'UI',
requiresRestart: false,
default: 60000,
description:
'Interval in milliseconds to poll the terminal background color.',
showInDialog: true,
},
customThemes: {
type: 'object',
label: 'Custom Themes',

View File

@@ -0,0 +1,76 @@
/**
* @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 } from 'vitest';
import { useEffect , act } from 'react';
import { EventEmitter } from 'node:events';
const mockStdin = new EventEmitter() as unknown as NodeJS.ReadStream &
EventEmitter;
// Add required properties for Ink's StdinProps
(mockStdin as unknown as { write: vi.Mock }).write = vi.fn();
(mockStdin as unknown as { setEncoding: vi.Mock }).setEncoding = vi.fn();
(mockStdin as unknown as { setRawMode: vi.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: vi.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', () => {
const handleColor = vi.fn();
render(
<TerminalProvider>
<TestComponent onColor={handleColor} />
</TerminalProvider>,
);
act(() => {
mockStdin.emit('data', '\x1b]11;rgb:ffff/ffff/ffff\x1b\\');
});
expect(handleColor).toHaveBeenCalledWith('rgb:ffff/ffff/ffff');
});
it('should handle partial chunks', () => {
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\\');
});
expect(handleColor).toHaveBeenCalledWith('rgb:0000/0000/0000');
});
});

View File

@@ -67,9 +67,13 @@ export function TerminalProvider({ children }: { children: React.ReactNode }) {
for (const handler of subscribers) {
handler(colorStr);
}
buffer = buffer.slice(match.index! + match[0].length);
} else if (buffer.length > 1024) {
// Safety valve to prevent infinite buffer growth
// Safely remove the processed part + match
if (match.index !== undefined) {
buffer = buffer.slice(match.index + match[0].length);
}
} else if (buffer.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.
buffer = buffer.slice(-1024);
}
};

View File

@@ -0,0 +1,154 @@
/**
* @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 } 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();
const mockSetTerminalBackground = 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: 100, // Short interval for testing
},
},
};
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(),
});
config.setTerminalBackground = mockSetTerminalBackground;
mockWrite.mockClear();
mockSubscribe.mockClear();
mockUnsubscribe.mockClear();
mockHandleThemeSelect.mockClear();
mockSetTerminalBackground.mockClear();
});
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
vi.advanceTimersByTime(100);
expect(mockWrite).toHaveBeenCalledWith('\x1b]11;?\x1b\\');
});
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)
// OSC 11 response format: rgb:rrrr/gggg/bbbb
handler('rgb:ffff/ffff/ffff');
expect(mockSetTerminalBackground).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(mockSetTerminalBackground).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(100);
expect(mockWrite).not.toHaveBeenCalled();
mockSettings.merged.ui.autoThemeSwitching = true;
});
});

View File

@@ -6,7 +6,11 @@
import { useEffect } from 'react';
import { useStdout } from 'ink';
import { getLuminance, parseColor } from '../themes/color-utils.js';
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';
@@ -37,7 +41,7 @@ export function useTerminalTheme(
}
stdout.write('\x1b]11;?\x1b\\');
}, 3000);
}, settings.merged.ui.terminalBackgroundPollingInterval);
const handleTerminalBackground = (colorStr: string) => {
// Parse the response "rgb:rrrr/gggg/bbbb"
@@ -51,25 +55,17 @@ export function useTerminalTheme(
const luminance = getLuminance(hexColor);
config.setTerminalBackground(hexColor);
// Check if we need to switch theme
const currentThemeName = settings.merged.ui.theme;
const isDefaultTheme =
currentThemeName === DEFAULT_THEME.name ||
currentThemeName === undefined;
const isDefaultLightTheme = currentThemeName === DefaultLight.name;
// Hysteresis thresholds to prevent flickering when the background color
// is ambiguous (near the midpoint).
const LIGHT_THEME_LUMINANCE_THRESHOLD = 140;
const DARK_THEME_LUMINANCE_THRESHOLD = 110;
const newTheme = shouldSwitchTheme(
currentThemeName,
luminance,
DEFAULT_THEME.name,
DefaultLight.name,
);
if (luminance > LIGHT_THEME_LUMINANCE_THRESHOLD && isDefaultTheme) {
handleThemeSelect(DefaultLight.name, SettingScope.User);
} else if (
luminance < DARK_THEME_LUMINANCE_THRESHOLD &&
isDefaultLightTheme
) {
handleThemeSelect(DEFAULT_THEME.name, SettingScope.User);
if (newTheme) {
handleThemeSelect(newTheme, SettingScope.User);
}
};
@@ -82,6 +78,7 @@ export function useTerminalTheme(
}, [
settings.merged.ui.theme,
settings.merged.ui.autoThemeSwitching,
settings.merged.ui.terminalBackgroundPollingInterval,
stdout,
config,
handleThemeSelect,

View File

@@ -14,6 +14,7 @@ import {
getThemeTypeFromBackgroundColor,
getLuminance,
parseColor,
shouldSwitchTheme,
} from './color-utils.js';
describe('Color Utils', () => {
@@ -339,4 +340,91 @@ describe('Color Utils', () => {
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

@@ -309,6 +309,43 @@ export function getLuminance(backgroundColor: string): number {
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).