diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 7dc8772fe7..de5aa28e5d 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -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', diff --git a/packages/cli/src/ui/contexts/TerminalContext.test.tsx b/packages/cli/src/ui/contexts/TerminalContext.test.tsx new file mode 100644 index 0000000000..6d81943e7f --- /dev/null +++ b/packages/cli/src/ui/contexts/TerminalContext.test.tsx @@ -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( + + + , + ); + + 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( + + + , + ); + + 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'); + }); +}); diff --git a/packages/cli/src/ui/contexts/TerminalContext.tsx b/packages/cli/src/ui/contexts/TerminalContext.tsx index 1ad8c37ad6..bec132f556 100644 --- a/packages/cli/src/ui/contexts/TerminalContext.tsx +++ b/packages/cli/src/ui/contexts/TerminalContext.tsx @@ -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); } }; diff --git a/packages/cli/src/ui/hooks/useTerminalTheme.test.tsx b/packages/cli/src/ui/hooks/useTerminalTheme.test.tsx new file mode 100644 index 0000000000..b7cb0f212d --- /dev/null +++ b/packages/cli/src/ui/hooks/useTerminalTheme.test.tsx @@ -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; + }); +}); diff --git a/packages/cli/src/ui/hooks/useTerminalTheme.ts b/packages/cli/src/ui/hooks/useTerminalTheme.ts index 93b9bb48bc..b70eb30efd 100644 --- a/packages/cli/src/ui/hooks/useTerminalTheme.ts +++ b/packages/cli/src/ui/hooks/useTerminalTheme.ts @@ -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, diff --git a/packages/cli/src/ui/themes/color-utils.test.ts b/packages/cli/src/ui/themes/color-utils.test.ts index 5e40abc9b0..96b5ed404e 100644 --- a/packages/cli/src/ui/themes/color-utils.test.ts +++ b/packages/cli/src/ui/themes/color-utils.test.ts @@ -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(); + }); + }); }); diff --git a/packages/cli/src/ui/themes/color-utils.ts b/packages/cli/src/ui/themes/color-utils.ts index fed63b93ef..ecfec6ab08 100644 --- a/packages/cli/src/ui/themes/color-utils.ts +++ b/packages/cli/src/ui/themes/color-utils.ts @@ -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).