mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-17 01:21:10 -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:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
|
||||
81
packages/cli/src/ui/contexts/TerminalContext.test.tsx
Normal file
81
packages/cli/src/ui/contexts/TerminalContext.test.tsx
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
96
packages/cli/src/ui/contexts/TerminalContext.tsx
Normal file
96
packages/cli/src/ui/contexts/TerminalContext.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
167
packages/cli/src/ui/hooks/useTerminalTheme.test.tsx
Normal file
167
packages/cli/src/ui/hooks/useTerminalTheme.test.tsx
Normal file
@@ -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;
|
||||
});
|
||||
});
|
||||
92
packages/cli/src/ui/hooks/useTerminalTheme.ts
Normal file
92
packages/cli/src/ui/hooks/useTerminalTheme.ts
Normal file
@@ -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,
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user