mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-02-01 22:48:03 +00:00
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:
@@ -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',
|
||||
|
||||
76
packages/cli/src/ui/contexts/TerminalContext.test.tsx
Normal file
76
packages/cli/src/ui/contexts/TerminalContext.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
154
packages/cli/src/ui/hooks/useTerminalTheme.test.tsx
Normal file
154
packages/cli/src/ui/hooks/useTerminalTheme.test.tsx
Normal 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;
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user