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).