diff --git a/docs/cli/settings.md b/docs/cli/settings.md
index c7180558b5..de77d2fd2f 100644
--- a/docs/cli/settings.md
+++ b/docs/cli/settings.md
@@ -39,31 +39,33 @@ they appear in the UI.
### UI
-| UI Label | Setting | Description | Default |
-| ------------------------------ | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
-| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
-| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` |
-| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |
-| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
-| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
-| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
-| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
-| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory path in the footer. | `false` |
-| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` |
-| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` |
-| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window remaining percentage. | `true` |
-| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` |
-| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` |
-| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` |
-| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` |
-| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` |
-| Show User Identity | `ui.showUserIdentity` | Show the logged-in user's identity (e.g. email) in the UI. | `true` |
-| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` |
-| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` |
-| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` |
-| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` |
-| Enable Loading Phrases | `ui.accessibility.enableLoadingPhrases` | Enable loading phrases during operations. | `true` |
-| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` |
+| UI Label | Setting | Description | Default |
+| ------------------------------------ | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
+| Auto Theme Switching | `ui.autoThemeSwitching` | Automatically switch between default light and dark themes based on terminal background color. | `true` |
+| Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in seconds to poll the terminal background color. | `60` |
+| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
+| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` |
+| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |
+| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
+| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
+| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
+| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
+| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory path in the footer. | `false` |
+| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` |
+| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` |
+| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window remaining percentage. | `true` |
+| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` |
+| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` |
+| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` |
+| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` |
+| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` |
+| Show User Identity | `ui.showUserIdentity` | Show the logged-in user's identity (e.g. email) in the UI. | `true` |
+| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` |
+| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` |
+| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` |
+| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` |
+| Enable Loading Phrases | `ui.accessibility.enableLoadingPhrases` | Enable loading phrases during operations. | `true` |
+| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` |
### IDE
diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md
index 01f7d8abbd..5a79467fe5 100644
--- a/docs/get-started/configuration.md
+++ b/docs/get-started/configuration.md
@@ -170,6 +170,15 @@ their corresponding top-level category object in your `settings.json` file.
available options.
- **Default:** `undefined`
+- **`ui.autoThemeSwitching`** (boolean):
+ - **Description:** Automatically switch between default light and dark themes
+ based on terminal background color.
+ - **Default:** `true`
+
+- **`ui.terminalBackgroundPollingInterval`** (number):
+ - **Description:** Interval in seconds to poll the terminal background color.
+ - **Default:** `60`
+
- **`ui.customThemes`** (object):
- **Description:** Custom theme definitions.
- **Default:** `{}`
diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts
index 769f928864..63718dad0b 100644
--- a/packages/cli/src/config/settingsSchema.ts
+++ b/packages/cli/src/config/settingsSchema.ts
@@ -351,6 +351,26 @@ const SETTINGS_SCHEMA = {
'The color theme for the UI. See the CLI themes guide for available options.',
showInDialog: false,
},
+ autoThemeSwitching: {
+ type: 'boolean',
+ label: 'Auto Theme Switching',
+ category: 'UI',
+ requiresRestart: false,
+ default: true,
+ description:
+ '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: 60,
+ description:
+ 'Interval in seconds to poll the terminal background color.',
+ showInDialog: true,
+ },
customThemes: {
type: 'object',
label: 'Custom Themes',
diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx
index 4fed48179a..25e3909fe3 100644
--- a/packages/cli/src/gemini.tsx
+++ b/packages/cli/src/gemini.tsx
@@ -98,6 +98,7 @@ import { deleteSession, listSessions } from './utils/sessions.js';
import { createPolicyUpdater } from './config/policy.js';
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
+import { TerminalProvider } from './ui/contexts/TerminalContext.js';
import { setupTerminalAndTheme } from './utils/terminalTheme.js';
import { profiler } from './ui/components/DebugProfiler.js';
@@ -228,19 +229,21 @@ export async function startInteractiveUI(
settings.merged.general.debugKeystrokeLogging
}
>
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx
index a9e997a859..09decd8f47 100644
--- a/packages/cli/src/test-utils/render.tsx
+++ b/packages/cli/src/test-utils/render.tsx
@@ -27,6 +27,7 @@ import {
import { type HistoryItemToolGroup, StreamingState } from '../ui/types.js';
import { ToolActionsProvider } from '../ui/contexts/ToolActionsContext.js';
import { AskUserActionsProvider } from '../ui/contexts/AskUserActionsContext.js';
+import { TerminalProvider } from '../ui/contexts/TerminalContext.js';
import { makeFakeConfig, type Config } from '@google/gemini-cli-core';
import { FakePersistentState } from './persistentStateFake.js';
@@ -317,16 +318,18 @@ export const renderWithProviders = (
-
-
- {component}
-
-
+
+
+
+ {component}
+
+
+
diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx
index 638eb53d5d..237bbff4fa 100644
--- a/packages/cli/src/ui/AppContainer.test.tsx
+++ b/packages/cli/src/ui/AppContainer.test.tsx
@@ -157,6 +157,12 @@ vi.mock('./components/shared/text-buffer.js');
vi.mock('./hooks/useLogger.js');
vi.mock('./hooks/useInputHistoryStore.js');
vi.mock('./hooks/useHookDisplayState.js');
+vi.mock('./hooks/useTerminalTheme.js', () => ({
+ useTerminalTheme: vi.fn(),
+}));
+
+import { useHookDisplayState } from './hooks/useHookDisplayState.js';
+import { useTerminalTheme } from './hooks/useTerminalTheme.js';
// Mock external utilities
vi.mock('../utils/events.js');
@@ -185,7 +191,6 @@ import { useTextBuffer } from './components/shared/text-buffer.js';
import { useLogger } from './hooks/useLogger.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
-import { useHookDisplayState } from './hooks/useHookDisplayState.js';
import { useKeypress, type Key } from './hooks/useKeypress.js';
import { measureElement } from 'ink';
import { useTerminalSize } from './hooks/useTerminalSize.js';
@@ -260,6 +265,7 @@ describe('AppContainer State Management', () => {
const mockedUseKeypress = useKeypress as Mock;
const mockedUseInputHistoryStore = useInputHistoryStore as Mock;
const mockedUseHookDisplayState = useHookDisplayState as Mock;
+ const mockedUseTerminalTheme = useTerminalTheme as Mock;
const DEFAULT_GEMINI_STREAM_MOCK = {
streamingState: 'idle',
@@ -388,6 +394,7 @@ describe('AppContainer State Management', () => {
currentLoadingPhrase: '',
});
mockedUseHookDisplayState.mockReturnValue([]);
+ mockedUseTerminalTheme.mockReturnValue(undefined);
// Mock Config
mockConfig = makeFakeConfig();
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index 6de7a313ed..1909065a80 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -141,6 +141,7 @@ import {
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
import { isSlashCommand } from './utils/commandUtils.js';
+import { useTerminalTheme } from './hooks/useTerminalTheme.js';
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
return pendingHistoryItems.some((item) => {
@@ -601,6 +602,9 @@ export const AppContainer = (props: AppContainerProps) => {
initializationResult.themeError,
);
+ // Poll for terminal background color changes to auto-switch theme
+ useTerminalTheme(handleThemeSelect, config);
+
const {
authState,
setAuthState,
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap
index da745e2843..233c14abdb 100644
--- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap
@@ -31,8 +31,8 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
-│ Hide Window Title false │
-│ Hide the window title bar │
+│ Auto Theme Switching true │
+│ Automatically switch between default light and dark themes based on terminal backgro… │
│ │
│ ▼ │
│ │
@@ -77,8 +77,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
-│ Hide Window Title false │
-│ Hide the window title bar │
+│ Auto Theme Switching true │
+│ Automatically switch between default light and dark themes based on terminal backgro… │
│ │
│ ▼ │
│ │
@@ -123,8 +123,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
-│ Hide Window Title false* │
-│ Hide the window title bar │
+│ Auto Theme Switching true │
+│ Automatically switch between default light and dark themes based on terminal backgro… │
│ │
│ ▼ │
│ │
@@ -169,8 +169,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
-│ Hide Window Title false │
-│ Hide the window title bar │
+│ Auto Theme Switching true │
+│ Automatically switch between default light and dark themes based on terminal backgro… │
│ │
│ ▼ │
│ │
@@ -215,8 +215,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
-│ Hide Window Title false │
-│ Hide the window title bar │
+│ Auto Theme Switching true │
+│ Automatically switch between default light and dark themes based on terminal backgro… │
│ │
│ ▼ │
│ │
@@ -261,8 +261,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
-│ Hide Window Title false │
-│ Hide the window title bar │
+│ Auto Theme Switching true │
+│ Automatically switch between default light and dark themes based on terminal backgro… │
│ │
│ ▼ │
│ │
@@ -307,8 +307,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
-│ Hide Window Title false* │
-│ Hide the window title bar │
+│ Auto Theme Switching true │
+│ Automatically switch between default light and dark themes based on terminal backgro… │
│ │
│ ▼ │
│ │
@@ -353,8 +353,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
-│ Hide Window Title false │
-│ Hide the window title bar │
+│ Auto Theme Switching true │
+│ Automatically switch between default light and dark themes based on terminal backgro… │
│ │
│ ▼ │
│ │
@@ -399,8 +399,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
-│ Hide Window Title true* │
-│ Hide the window title bar │
+│ Auto Theme Switching true │
+│ Automatically switch between default light and dark themes based on terminal backgro… │
│ │
│ ▼ │
│ │
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..dc1ceca62e
--- /dev/null
+++ b/packages/cli/src/ui/contexts/TerminalContext.test.tsx
@@ -0,0 +1,81 @@
+/**
+ * @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, type Mock } from 'vitest';
+import { useEffect, act } from 'react';
+import { EventEmitter } from 'node:events';
+import { waitFor } from '../../test-utils/async.js';
+
+const mockStdin = new EventEmitter() as unknown as NodeJS.ReadStream &
+ EventEmitter;
+// Add required properties for Ink's StdinProps
+(mockStdin as unknown as { write: Mock }).write = vi.fn();
+(mockStdin as unknown as { setEncoding: Mock }).setEncoding = vi.fn();
+(mockStdin as unknown as { setRawMode: 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: 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', async () => {
+ const handleColor = vi.fn();
+ render(
+
+
+ ,
+ );
+
+ act(() => {
+ mockStdin.emit('data', '\x1b]11;rgb:ffff/ffff/ffff\x1b\\');
+ });
+
+ await waitFor(() => {
+ expect(handleColor).toHaveBeenCalledWith('rgb:ffff/ffff/ffff');
+ });
+ });
+
+ it('should handle partial chunks', async () => {
+ const handleColor = vi.fn();
+ render(
+
+
+ ,
+ );
+
+ act(() => {
+ mockStdin.emit('data', '\x1b]11;rgb:0000/');
+ });
+ expect(handleColor).not.toHaveBeenCalled();
+
+ act(() => {
+ mockStdin.emit('data', '0000/0000\x1b\\');
+ });
+
+ await waitFor(() => {
+ 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
new file mode 100644
index 0000000000..e954029207
--- /dev/null
+++ b/packages/cli/src/ui/contexts/TerminalContext.tsx
@@ -0,0 +1,96 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useStdin } from 'ink';
+import type React from 'react';
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useRef,
+} from 'react';
+import { TerminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
+
+export type TerminalEventHandler = (event: string) => void;
+
+interface TerminalContextValue {
+ subscribe: (handler: TerminalEventHandler) => void;
+ unsubscribe: (handler: TerminalEventHandler) => void;
+}
+
+const TerminalContext = createContext(
+ undefined,
+);
+
+export function useTerminalContext() {
+ const context = useContext(TerminalContext);
+ if (!context) {
+ throw new Error(
+ 'useTerminalContext must be used within a TerminalProvider',
+ );
+ }
+ return context;
+}
+
+export function TerminalProvider({ children }: { children: React.ReactNode }) {
+ const { stdin } = useStdin();
+ const subscribers = useRef>(new Set()).current;
+ const bufferRef = useRef('');
+
+ const subscribe = useCallback(
+ (handler: TerminalEventHandler) => {
+ subscribers.add(handler);
+ },
+ [subscribers],
+ );
+
+ const unsubscribe = useCallback(
+ (handler: TerminalEventHandler) => {
+ subscribers.delete(handler);
+ },
+ [subscribers],
+ );
+
+ useEffect(() => {
+ const handleData = (data: Buffer | string) => {
+ bufferRef.current +=
+ typeof data === 'string' ? data : data.toString('utf-8');
+
+ // Check for OSC 11 response
+ const match = bufferRef.current.match(
+ TerminalCapabilityManager.OSC_11_REGEX,
+ );
+ if (match) {
+ const colorStr = `rgb:${match[1]}/${match[2]}/${match[3]}`;
+ for (const handler of subscribers) {
+ handler(colorStr);
+ }
+ // Safely remove the processed part + match
+ if (match.index !== undefined) {
+ bufferRef.current = bufferRef.current.slice(
+ match.index + match[0].length,
+ );
+ }
+ } else if (bufferRef.current.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.
+ bufferRef.current = bufferRef.current.slice(-1024);
+ }
+ };
+
+ stdin.on('data', handleData);
+ return () => {
+ stdin.removeListener('data', handleData);
+ };
+ }, [stdin, subscribers]);
+
+ return (
+
+ {children}
+
+ );
+}
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..da2a9b2c04
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useTerminalTheme.test.tsx
@@ -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;
+ });
+});
diff --git a/packages/cli/src/ui/hooks/useTerminalTheme.ts b/packages/cli/src/ui/hooks/useTerminalTheme.ts
new file mode 100644
index 0000000000..69292616fd
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useTerminalTheme.ts
@@ -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,
+ ]);
+}
diff --git a/packages/cli/src/ui/themes/color-utils.test.ts b/packages/cli/src/ui/themes/color-utils.test.ts
index 89a158af6e..96b5ed404e 100644
--- a/packages/cli/src/ui/themes/color-utils.test.ts
+++ b/packages/cli/src/ui/themes/color-utils.test.ts
@@ -12,6 +12,9 @@ import {
CSS_NAME_TO_HEX_MAP,
INK_SUPPORTED_NAMES,
getThemeTypeFromBackgroundColor,
+ getLuminance,
+ parseColor,
+ shouldSwitchTheme,
} from './color-utils.js';
describe('Color Utils', () => {
@@ -279,4 +282,149 @@ describe('Color Utils', () => {
expect(getThemeTypeFromBackgroundColor('000000')).toBe('dark');
});
});
+
+ describe('getLuminance', () => {
+ it('should calculate luminance correctly', () => {
+ // White: 0.2126*255 + 0.7152*255 + 0.0722*255 = 255
+ expect(getLuminance('#ffffff')).toBeCloseTo(255);
+ // Black: 0.2126*0 + 0.7152*0 + 0.0722*0 = 0
+ expect(getLuminance('#000000')).toBeCloseTo(0);
+ // Pure Red: 0.2126*255 = 54.213
+ expect(getLuminance('#ff0000')).toBeCloseTo(54.213);
+ // Pure Green: 0.7152*255 = 182.376
+ expect(getLuminance('#00ff00')).toBeCloseTo(182.376);
+ // Pure Blue: 0.0722*255 = 18.411
+ expect(getLuminance('#0000ff')).toBeCloseTo(18.411);
+ });
+
+ it('should handle colors without # prefix', () => {
+ expect(getLuminance('ffffff')).toBeCloseTo(255);
+ });
+
+ it('should handle 3-digit hex codes', () => {
+ // #fff -> #ffffff -> 255
+ expect(getLuminance('#fff')).toBeCloseTo(255);
+ // #000 -> #000000 -> 0
+ expect(getLuminance('#000')).toBeCloseTo(0);
+ // #f00 -> #ff0000 -> 54.213
+ expect(getLuminance('#f00')).toBeCloseTo(54.213);
+ });
+ });
+
+ describe('parseColor', () => {
+ it('should parse 1-digit components', () => {
+ // F/F/F => #ffffff
+ expect(parseColor('f', 'f', 'f')).toBe('#ffffff');
+ // 0/0/0 => #000000
+ expect(parseColor('0', '0', '0')).toBe('#000000');
+ });
+
+ it('should parse 2-digit components', () => {
+ // ff/ff/ff => #ffffff
+ expect(parseColor('ff', 'ff', 'ff')).toBe('#ffffff');
+ // 80/80/80 => #808080
+ expect(parseColor('80', '80', '80')).toBe('#808080');
+ });
+
+ it('should parse 4-digit components (standard X11)', () => {
+ // ffff/ffff/ffff => #ffffff (65535/65535 * 255 = 255)
+ expect(parseColor('ffff', 'ffff', 'ffff')).toBe('#ffffff');
+ // 0000/0000/0000 => #000000
+ expect(parseColor('0000', '0000', '0000')).toBe('#000000');
+ // 7fff/7fff/7fff => approx #7f7f7f (32767/65535 * 255 = 127.498... -> 127 -> 7f)
+ expect(parseColor('7fff', '7fff', '7fff')).toBe('#7f7f7f');
+ });
+
+ it('should handle mixed case', () => {
+ expect(parseColor('FFFF', 'FFFF', 'FFFF')).toBe('#ffffff');
+ 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 31d04cc8c5..ecfec6ab08 100644
--- a/packages/cli/src/ui/themes/color-utils.ts
+++ b/packages/cli/src/ui/themes/color-utils.ts
@@ -286,14 +286,89 @@ export function getThemeTypeFromBackgroundColor(
return undefined;
}
- // Parse hex color
- const hex = backgroundColor.replace(/^#/, '');
+ const luminance = getLuminance(backgroundColor);
+ return luminance > 128 ? 'light' : 'dark';
+}
+
+/**
+ * Calculates the relative luminance of a color.
+ * See https://www.w3.org/TR/WCAG20/#relativeluminancedef
+ *
+ * @param backgroundColor Hex color string (with or without #)
+ * @returns Luminance value (0-255)
+ */
+export function getLuminance(backgroundColor: string): number {
+ let hex = backgroundColor.replace(/^#/, '');
+ if (hex.length === 3) {
+ hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
+ }
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
- // Calculate luminance
- const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
-
- return luminance > 128 ? 'light' : 'dark';
+ 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).
+ *
+ * @param rHex Red component as hex string
+ * @param gHex Green component as hex string
+ * @param bHex Blue component as hex string
+ * @returns Hex color string (e.g. #RRGGBB)
+ */
+export function parseColor(rHex: string, gHex: string, bHex: string): string {
+ const parseComponent = (hex: string) => {
+ const val = parseInt(hex, 16);
+ if (hex.length === 1) return (val / 15) * 255;
+ if (hex.length === 2) return val;
+ if (hex.length === 3) return (val / 4095) * 255;
+ if (hex.length === 4) return (val / 65535) * 255;
+ return val;
+ };
+
+ const r = parseComponent(rHex);
+ const g = parseComponent(gHex);
+ const b = parseComponent(bHex);
+
+ const toHex = (c: number) => Math.round(c).toString(16).padStart(2, '0');
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
diff --git a/packages/cli/src/ui/themes/theme-manager.ts b/packages/cli/src/ui/themes/theme-manager.ts
index c44c5adb98..60c7873e52 100644
--- a/packages/cli/src/ui/themes/theme-manager.ts
+++ b/packages/cli/src/ui/themes/theme-manager.ts
@@ -63,6 +63,14 @@ class ThemeManager {
this.activeTheme = DEFAULT_THEME;
}
+ isDefaultTheme(themeName: string | undefined): boolean {
+ return (
+ themeName === undefined ||
+ themeName === DEFAULT_THEME.name ||
+ themeName === DefaultLight.name
+ );
+ }
+
/**
* Loads custom themes from settings.
* @param customThemesSettings Custom themes from settings.
diff --git a/packages/cli/src/ui/utils/terminalCapabilityManager.ts b/packages/cli/src/ui/utils/terminalCapabilityManager.ts
index 349c601ff8..5b2b20a428 100644
--- a/packages/cli/src/ui/utils/terminalCapabilityManager.ts
+++ b/packages/cli/src/ui/utils/terminalCapabilityManager.ts
@@ -14,6 +14,7 @@ import {
enableBracketedPasteMode,
disableBracketedPasteMode,
} from '@google/gemini-cli-core';
+import { parseColor } from '../themes/color-utils.js';
export type TerminalBackgroundColor = string | undefined;
@@ -36,7 +37,7 @@ export class TerminalCapabilityManager {
// eslint-disable-next-line no-control-regex
private static readonly DEVICE_ATTRIBUTES_REGEX = /\x1b\[\?(\d+)(;\d+)*c/;
// OSC 11 response: OSC 11 ; rgb:rrrr/gggg/bbbb ST (or BEL)
- private static readonly OSC_11_REGEX =
+ static readonly OSC_11_REGEX =
// eslint-disable-next-line no-control-regex
/\x1b\]11;rgb:([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})(\x1b\\|\x07)?/;
// modifyOtherKeys response: CSI > 4 ; level m
@@ -129,7 +130,7 @@ export class TerminalCapabilityManager {
const match = buffer.match(TerminalCapabilityManager.OSC_11_REGEX);
if (match) {
bgReceived = true;
- this.terminalBackgroundColor = this.parseColor(
+ this.terminalBackgroundColor = parseColor(
match[1],
match[2],
match[3],
@@ -234,24 +235,6 @@ export class TerminalCapabilityManager {
isKittyProtocolEnabled(): boolean {
return this.kittyEnabled;
}
-
- private parseColor(rHex: string, gHex: string, bHex: string): string {
- const parseComponent = (hex: string) => {
- const val = parseInt(hex, 16);
- if (hex.length === 1) return (val / 15) * 255;
- if (hex.length === 2) return val;
- if (hex.length === 3) return (val / 4095) * 255;
- if (hex.length === 4) return (val / 65535) * 255;
- return val;
- };
-
- const r = parseComponent(rHex);
- const g = parseComponent(gHex);
- const b = parseComponent(bHex);
-
- const toHex = (c: number) => Math.round(c).toString(16).padStart(2, '0');
- return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
- }
}
export const terminalCapabilityManager =
diff --git a/packages/cli/src/utils/events.ts b/packages/cli/src/utils/events.ts
index 4bf19d44ef..7e4be98987 100644
--- a/packages/cli/src/utils/events.ts
+++ b/packages/cli/src/utils/events.ts
@@ -11,6 +11,7 @@ export enum AppEvent {
Flicker = 'flicker',
SelectionWarning = 'selection-warning',
PasteTimeout = 'paste-timeout',
+ TerminalBackground = 'terminal-background',
}
export interface AppEvents {
@@ -18,6 +19,7 @@ export interface AppEvents {
[AppEvent.Flicker]: never[];
[AppEvent.SelectionWarning]: never[];
[AppEvent.PasteTimeout]: never[];
+ [AppEvent.TerminalBackground]: [string];
}
export const appEvents = new EventEmitter();
diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json
index 003faf3ce6..23aa7e1de0 100644
--- a/schemas/settings.schema.json
+++ b/schemas/settings.schema.json
@@ -170,6 +170,20 @@
"markdownDescription": "The color theme for the UI. See the CLI themes guide for available options.\n\n- Category: `UI`\n- Requires restart: `no`",
"type": "string"
},
+ "autoThemeSwitching": {
+ "title": "Auto Theme Switching",
+ "description": "Automatically switch between default light and dark themes based on terminal background color.",
+ "markdownDescription": "Automatically switch between default light and dark themes based on terminal background color.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `true`",
+ "default": true,
+ "type": "boolean"
+ },
+ "terminalBackgroundPollingInterval": {
+ "title": "Terminal Background Polling Interval",
+ "description": "Interval in seconds to poll the terminal background color.",
+ "markdownDescription": "Interval in seconds to poll the terminal background color.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `60`",
+ "default": 60,
+ "type": "number"
+ },
"customThemes": {
"title": "Custom Themes",
"description": "Custom theme definitions.",