From 4e4a55be350ee4aa3d2615cc6e674339c29f6485 Mon Sep 17 00:00:00 2001 From: Abhijit Balaji Date: Mon, 2 Feb 2026 16:39:17 -0800 Subject: [PATCH] feat(cli): implement automatic theme switching based on terminal background (#17976) Co-authored-by: Jacob Richman --- docs/cli/settings.md | 52 +++--- docs/get-started/configuration.md | 9 + packages/cli/src/config/settingsSchema.ts | 20 +++ packages/cli/src/gemini.tsx | 29 +-- packages/cli/src/test-utils/render.tsx | 23 +-- packages/cli/src/ui/AppContainer.test.tsx | 9 +- packages/cli/src/ui/AppContainer.tsx | 4 + .../SettingsDialog.test.tsx.snap | 36 ++-- .../src/ui/contexts/TerminalContext.test.tsx | 81 +++++++++ .../cli/src/ui/contexts/TerminalContext.tsx | 96 ++++++++++ .../src/ui/hooks/useTerminalTheme.test.tsx | 167 ++++++++++++++++++ packages/cli/src/ui/hooks/useTerminalTheme.ts | 92 ++++++++++ .../cli/src/ui/themes/color-utils.test.ts | 148 ++++++++++++++++ packages/cli/src/ui/themes/color-utils.ts | 87 ++++++++- packages/cli/src/ui/themes/theme-manager.ts | 8 + .../src/ui/utils/terminalCapabilityManager.ts | 23 +-- packages/cli/src/utils/events.ts | 2 + schemas/settings.schema.json | 14 ++ 18 files changed, 807 insertions(+), 93 deletions(-) create mode 100644 packages/cli/src/ui/contexts/TerminalContext.test.tsx create mode 100644 packages/cli/src/ui/contexts/TerminalContext.tsx create mode 100644 packages/cli/src/ui/hooks/useTerminalTheme.test.tsx create mode 100644 packages/cli/src/ui/hooks/useTerminalTheme.ts 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.",