mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
feat(cli): implement automatic theme switching based on terminal background (#17976)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
+27
-25
@@ -39,31 +39,33 @@ they appear in the UI.
|
|||||||
|
|
||||||
### UI
|
### UI
|
||||||
|
|
||||||
| UI Label | Setting | Description | Default |
|
| UI Label | Setting | Description | Default |
|
||||||
| ------------------------------ | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
| ------------------------------------ | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||||
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
|
| Auto Theme Switching | `ui.autoThemeSwitching` | Automatically switch between default light and dark themes based on terminal background color. | `true` |
|
||||||
| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` |
|
| Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in seconds to poll the terminal background color. | `60` |
|
||||||
| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |
|
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
|
||||||
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
|
| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` |
|
||||||
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
|
| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |
|
||||||
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
|
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
|
||||||
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
|
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
|
||||||
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory path in the footer. | `false` |
|
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
|
||||||
| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` |
|
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
|
||||||
| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` |
|
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory path in the footer. | `false` |
|
||||||
| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window remaining percentage. | `true` |
|
| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` |
|
||||||
| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` |
|
| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` |
|
||||||
| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` |
|
| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window remaining percentage. | `true` |
|
||||||
| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` |
|
| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` |
|
||||||
| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` |
|
| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` |
|
||||||
| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` |
|
| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` |
|
||||||
| Show User Identity | `ui.showUserIdentity` | Show the logged-in user's identity (e.g. email) in the UI. | `true` |
|
| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` |
|
||||||
| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` |
|
| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` |
|
||||||
| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` |
|
| Show User Identity | `ui.showUserIdentity` | Show the logged-in user's identity (e.g. email) 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` |
|
| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` |
|
||||||
| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` |
|
| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` |
|
||||||
| Enable Loading Phrases | `ui.accessibility.enableLoadingPhrases` | Enable loading phrases during operations. | `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` |
|
||||||
| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` |
|
| 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
|
### IDE
|
||||||
|
|
||||||
|
|||||||
@@ -170,6 +170,15 @@ their corresponding top-level category object in your `settings.json` file.
|
|||||||
available options.
|
available options.
|
||||||
- **Default:** `undefined`
|
- **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):
|
- **`ui.customThemes`** (object):
|
||||||
- **Description:** Custom theme definitions.
|
- **Description:** Custom theme definitions.
|
||||||
- **Default:** `{}`
|
- **Default:** `{}`
|
||||||
|
|||||||
@@ -351,6 +351,26 @@ const SETTINGS_SCHEMA = {
|
|||||||
'The color theme for the UI. See the CLI themes guide for available options.',
|
'The color theme for the UI. See the CLI themes guide for available options.',
|
||||||
showInDialog: false,
|
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: {
|
customThemes: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
label: 'Custom Themes',
|
label: 'Custom Themes',
|
||||||
|
|||||||
+16
-13
@@ -98,6 +98,7 @@ import { deleteSession, listSessions } from './utils/sessions.js';
|
|||||||
import { createPolicyUpdater } from './config/policy.js';
|
import { createPolicyUpdater } from './config/policy.js';
|
||||||
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
|
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
|
||||||
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
|
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
|
||||||
|
import { TerminalProvider } from './ui/contexts/TerminalContext.js';
|
||||||
|
|
||||||
import { setupTerminalAndTheme } from './utils/terminalTheme.js';
|
import { setupTerminalAndTheme } from './utils/terminalTheme.js';
|
||||||
import { profiler } from './ui/components/DebugProfiler.js';
|
import { profiler } from './ui/components/DebugProfiler.js';
|
||||||
@@ -228,19 +229,21 @@ export async function startInteractiveUI(
|
|||||||
settings.merged.general.debugKeystrokeLogging
|
settings.merged.general.debugKeystrokeLogging
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ScrollProvider>
|
<TerminalProvider>
|
||||||
<SessionStatsProvider>
|
<ScrollProvider>
|
||||||
<VimModeProvider settings={settings}>
|
<SessionStatsProvider>
|
||||||
<AppContainer
|
<VimModeProvider settings={settings}>
|
||||||
config={config}
|
<AppContainer
|
||||||
startupWarnings={startupWarnings}
|
config={config}
|
||||||
version={version}
|
startupWarnings={startupWarnings}
|
||||||
resumedSessionData={resumedSessionData}
|
version={version}
|
||||||
initializationResult={initializationResult}
|
resumedSessionData={resumedSessionData}
|
||||||
/>
|
initializationResult={initializationResult}
|
||||||
</VimModeProvider>
|
/>
|
||||||
</SessionStatsProvider>
|
</VimModeProvider>
|
||||||
</ScrollProvider>
|
</SessionStatsProvider>
|
||||||
|
</ScrollProvider>
|
||||||
|
</TerminalProvider>
|
||||||
</MouseProvider>
|
</MouseProvider>
|
||||||
</KeypressProvider>
|
</KeypressProvider>
|
||||||
</SettingsContext.Provider>
|
</SettingsContext.Provider>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
import { type HistoryItemToolGroup, StreamingState } from '../ui/types.js';
|
import { type HistoryItemToolGroup, StreamingState } from '../ui/types.js';
|
||||||
import { ToolActionsProvider } from '../ui/contexts/ToolActionsContext.js';
|
import { ToolActionsProvider } from '../ui/contexts/ToolActionsContext.js';
|
||||||
import { AskUserActionsProvider } from '../ui/contexts/AskUserActionsContext.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 { makeFakeConfig, type Config } from '@google/gemini-cli-core';
|
||||||
import { FakePersistentState } from './persistentStateFake.js';
|
import { FakePersistentState } from './persistentStateFake.js';
|
||||||
@@ -317,16 +318,18 @@ export const renderWithProviders = (
|
|||||||
<MouseProvider
|
<MouseProvider
|
||||||
mouseEventsEnabled={mouseEventsEnabled}
|
mouseEventsEnabled={mouseEventsEnabled}
|
||||||
>
|
>
|
||||||
<ScrollProvider>
|
<TerminalProvider>
|
||||||
<Box
|
<ScrollProvider>
|
||||||
width={terminalWidth}
|
<Box
|
||||||
flexShrink={0}
|
width={terminalWidth}
|
||||||
flexGrow={0}
|
flexShrink={0}
|
||||||
flexDirection="column"
|
flexGrow={0}
|
||||||
>
|
flexDirection="column"
|
||||||
{component}
|
>
|
||||||
</Box>
|
{component}
|
||||||
</ScrollProvider>
|
</Box>
|
||||||
|
</ScrollProvider>
|
||||||
|
</TerminalProvider>
|
||||||
</MouseProvider>
|
</MouseProvider>
|
||||||
</KeypressProvider>
|
</KeypressProvider>
|
||||||
</AskUserActionsProvider>
|
</AskUserActionsProvider>
|
||||||
|
|||||||
@@ -157,6 +157,12 @@ vi.mock('./components/shared/text-buffer.js');
|
|||||||
vi.mock('./hooks/useLogger.js');
|
vi.mock('./hooks/useLogger.js');
|
||||||
vi.mock('./hooks/useInputHistoryStore.js');
|
vi.mock('./hooks/useInputHistoryStore.js');
|
||||||
vi.mock('./hooks/useHookDisplayState.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
|
// Mock external utilities
|
||||||
vi.mock('../utils/events.js');
|
vi.mock('../utils/events.js');
|
||||||
@@ -185,7 +191,6 @@ import { useTextBuffer } from './components/shared/text-buffer.js';
|
|||||||
import { useLogger } from './hooks/useLogger.js';
|
import { useLogger } from './hooks/useLogger.js';
|
||||||
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
||||||
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
|
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
|
||||||
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
|
|
||||||
import { useKeypress, type Key } from './hooks/useKeypress.js';
|
import { useKeypress, type Key } from './hooks/useKeypress.js';
|
||||||
import { measureElement } from 'ink';
|
import { measureElement } from 'ink';
|
||||||
import { useTerminalSize } from './hooks/useTerminalSize.js';
|
import { useTerminalSize } from './hooks/useTerminalSize.js';
|
||||||
@@ -260,6 +265,7 @@ describe('AppContainer State Management', () => {
|
|||||||
const mockedUseKeypress = useKeypress as Mock;
|
const mockedUseKeypress = useKeypress as Mock;
|
||||||
const mockedUseInputHistoryStore = useInputHistoryStore as Mock;
|
const mockedUseInputHistoryStore = useInputHistoryStore as Mock;
|
||||||
const mockedUseHookDisplayState = useHookDisplayState as Mock;
|
const mockedUseHookDisplayState = useHookDisplayState as Mock;
|
||||||
|
const mockedUseTerminalTheme = useTerminalTheme as Mock;
|
||||||
|
|
||||||
const DEFAULT_GEMINI_STREAM_MOCK = {
|
const DEFAULT_GEMINI_STREAM_MOCK = {
|
||||||
streamingState: 'idle',
|
streamingState: 'idle',
|
||||||
@@ -388,6 +394,7 @@ describe('AppContainer State Management', () => {
|
|||||||
currentLoadingPhrase: '',
|
currentLoadingPhrase: '',
|
||||||
});
|
});
|
||||||
mockedUseHookDisplayState.mockReturnValue([]);
|
mockedUseHookDisplayState.mockReturnValue([]);
|
||||||
|
mockedUseTerminalTheme.mockReturnValue(undefined);
|
||||||
|
|
||||||
// Mock Config
|
// Mock Config
|
||||||
mockConfig = makeFakeConfig();
|
mockConfig = makeFakeConfig();
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ import {
|
|||||||
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
|
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
|
||||||
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
|
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
|
||||||
import { isSlashCommand } from './utils/commandUtils.js';
|
import { isSlashCommand } from './utils/commandUtils.js';
|
||||||
|
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
|
||||||
|
|
||||||
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
|
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
|
||||||
return pendingHistoryItems.some((item) => {
|
return pendingHistoryItems.some((item) => {
|
||||||
@@ -601,6 +602,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
initializationResult.themeError,
|
initializationResult.themeError,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Poll for terminal background color changes to auto-switch theme
|
||||||
|
useTerminalTheme(handleThemeSelect, config);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
authState,
|
authState,
|
||||||
setAuthState,
|
setAuthState,
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
|
|||||||
│ Output Format Text │
|
│ Output Format Text │
|
||||||
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
||||||
│ │
|
│ │
|
||||||
│ Hide Window Title false │
|
│ Auto Theme Switching true │
|
||||||
│ Hide the window title bar │
|
│ 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 │
|
│ Output Format Text │
|
||||||
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
||||||
│ │
|
│ │
|
||||||
│ Hide Window Title false │
|
│ Auto Theme Switching true │
|
||||||
│ Hide the window title bar │
|
│ 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 │
|
│ Output Format Text │
|
||||||
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
||||||
│ │
|
│ │
|
||||||
│ Hide Window Title false* │
|
│ Auto Theme Switching true │
|
||||||
│ Hide the window title bar │
|
│ 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 │
|
│ Output Format Text │
|
||||||
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
||||||
│ │
|
│ │
|
||||||
│ Hide Window Title false │
|
│ Auto Theme Switching true │
|
||||||
│ Hide the window title bar │
|
│ 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 │
|
│ Output Format Text │
|
||||||
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
||||||
│ │
|
│ │
|
||||||
│ Hide Window Title false │
|
│ Auto Theme Switching true │
|
||||||
│ Hide the window title bar │
|
│ 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 │
|
│ Output Format Text │
|
||||||
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
||||||
│ │
|
│ │
|
||||||
│ Hide Window Title false │
|
│ Auto Theme Switching true │
|
||||||
│ Hide the window title bar │
|
│ 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 │
|
│ Output Format Text │
|
||||||
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
||||||
│ │
|
│ │
|
||||||
│ Hide Window Title false* │
|
│ Auto Theme Switching true │
|
||||||
│ Hide the window title bar │
|
│ 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 │
|
│ Output Format Text │
|
||||||
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
||||||
│ │
|
│ │
|
||||||
│ Hide Window Title false │
|
│ Auto Theme Switching true │
|
||||||
│ Hide the window title bar │
|
│ 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 │
|
│ Output Format Text │
|
||||||
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
||||||
│ │
|
│ │
|
||||||
│ Hide Window Title true* │
|
│ Auto Theme Switching true │
|
||||||
│ Hide the window title bar │
|
│ Automatically switch between default light and dark themes based on terminal backgro… │
|
||||||
│ │
|
│ │
|
||||||
│ ▼ │
|
│ ▼ │
|
||||||
│ │
|
│ │
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<TerminalProvider>
|
||||||
|
<TestComponent onColor={handleColor} />
|
||||||
|
</TerminalProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<TerminalProvider>
|
||||||
|
<TestComponent onColor={handleColor} />
|
||||||
|
</TerminalProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<TerminalContextValue | undefined>(
|
||||||
|
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<Set<TerminalEventHandler>>(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 (
|
||||||
|
<TerminalContext.Provider value={{ subscribe, unsubscribe }}>
|
||||||
|
{children}
|
||||||
|
</TerminalContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -12,6 +12,9 @@ import {
|
|||||||
CSS_NAME_TO_HEX_MAP,
|
CSS_NAME_TO_HEX_MAP,
|
||||||
INK_SUPPORTED_NAMES,
|
INK_SUPPORTED_NAMES,
|
||||||
getThemeTypeFromBackgroundColor,
|
getThemeTypeFromBackgroundColor,
|
||||||
|
getLuminance,
|
||||||
|
parseColor,
|
||||||
|
shouldSwitchTheme,
|
||||||
} from './color-utils.js';
|
} from './color-utils.js';
|
||||||
|
|
||||||
describe('Color Utils', () => {
|
describe('Color Utils', () => {
|
||||||
@@ -279,4 +282,149 @@ describe('Color Utils', () => {
|
|||||||
expect(getThemeTypeFromBackgroundColor('000000')).toBe('dark');
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -286,14 +286,89 @@ export function getThemeTypeFromBackgroundColor(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse hex color
|
const luminance = getLuminance(backgroundColor);
|
||||||
const hex = backgroundColor.replace(/^#/, '');
|
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 r = parseInt(hex.substring(0, 2), 16);
|
||||||
const g = parseInt(hex.substring(2, 4), 16);
|
const g = parseInt(hex.substring(2, 4), 16);
|
||||||
const b = parseInt(hex.substring(4, 6), 16);
|
const b = parseInt(hex.substring(4, 6), 16);
|
||||||
|
|
||||||
// Calculate luminance
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
}
|
||||||
|
|
||||||
return luminance > 128 ? 'light' : 'dark';
|
// 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)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ class ThemeManager {
|
|||||||
this.activeTheme = DEFAULT_THEME;
|
this.activeTheme = DEFAULT_THEME;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isDefaultTheme(themeName: string | undefined): boolean {
|
||||||
|
return (
|
||||||
|
themeName === undefined ||
|
||||||
|
themeName === DEFAULT_THEME.name ||
|
||||||
|
themeName === DefaultLight.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads custom themes from settings.
|
* Loads custom themes from settings.
|
||||||
* @param customThemesSettings Custom themes from settings.
|
* @param customThemesSettings Custom themes from settings.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
enableBracketedPasteMode,
|
enableBracketedPasteMode,
|
||||||
disableBracketedPasteMode,
|
disableBracketedPasteMode,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
|
import { parseColor } from '../themes/color-utils.js';
|
||||||
|
|
||||||
export type TerminalBackgroundColor = string | undefined;
|
export type TerminalBackgroundColor = string | undefined;
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ export class TerminalCapabilityManager {
|
|||||||
// eslint-disable-next-line no-control-regex
|
// eslint-disable-next-line no-control-regex
|
||||||
private static readonly DEVICE_ATTRIBUTES_REGEX = /\x1b\[\?(\d+)(;\d+)*c/;
|
private static readonly DEVICE_ATTRIBUTES_REGEX = /\x1b\[\?(\d+)(;\d+)*c/;
|
||||||
// OSC 11 response: OSC 11 ; rgb:rrrr/gggg/bbbb ST (or BEL)
|
// 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
|
// 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)?/;
|
/\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
|
// modifyOtherKeys response: CSI > 4 ; level m
|
||||||
@@ -129,7 +130,7 @@ export class TerminalCapabilityManager {
|
|||||||
const match = buffer.match(TerminalCapabilityManager.OSC_11_REGEX);
|
const match = buffer.match(TerminalCapabilityManager.OSC_11_REGEX);
|
||||||
if (match) {
|
if (match) {
|
||||||
bgReceived = true;
|
bgReceived = true;
|
||||||
this.terminalBackgroundColor = this.parseColor(
|
this.terminalBackgroundColor = parseColor(
|
||||||
match[1],
|
match[1],
|
||||||
match[2],
|
match[2],
|
||||||
match[3],
|
match[3],
|
||||||
@@ -234,24 +235,6 @@ export class TerminalCapabilityManager {
|
|||||||
isKittyProtocolEnabled(): boolean {
|
isKittyProtocolEnabled(): boolean {
|
||||||
return this.kittyEnabled;
|
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 =
|
export const terminalCapabilityManager =
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export enum AppEvent {
|
|||||||
Flicker = 'flicker',
|
Flicker = 'flicker',
|
||||||
SelectionWarning = 'selection-warning',
|
SelectionWarning = 'selection-warning',
|
||||||
PasteTimeout = 'paste-timeout',
|
PasteTimeout = 'paste-timeout',
|
||||||
|
TerminalBackground = 'terminal-background',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppEvents {
|
export interface AppEvents {
|
||||||
@@ -18,6 +19,7 @@ export interface AppEvents {
|
|||||||
[AppEvent.Flicker]: never[];
|
[AppEvent.Flicker]: never[];
|
||||||
[AppEvent.SelectionWarning]: never[];
|
[AppEvent.SelectionWarning]: never[];
|
||||||
[AppEvent.PasteTimeout]: never[];
|
[AppEvent.PasteTimeout]: never[];
|
||||||
|
[AppEvent.TerminalBackground]: [string];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const appEvents = new EventEmitter<AppEvents>();
|
export const appEvents = new EventEmitter<AppEvents>();
|
||||||
|
|||||||
@@ -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`",
|
"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"
|
"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": {
|
"customThemes": {
|
||||||
"title": "Custom Themes",
|
"title": "Custom Themes",
|
||||||
"description": "Custom theme definitions.",
|
"description": "Custom theme definitions.",
|
||||||
|
|||||||
Reference in New Issue
Block a user