diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx
index 10ad4281ef..de0afc9c50 100644
--- a/packages/cli/src/test-utils/render.tsx
+++ b/packages/cli/src/test-utils/render.tsx
@@ -33,6 +33,9 @@ import { makeFakeConfig, type Config } from '@google/gemini-cli-core';
import { FakePersistentState } from './persistentStateFake.js';
import { AppContext, type AppState } from '../ui/contexts/AppContext.js';
import { createMockSettings } from './settings.js';
+import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js';
+import { DefaultLight } from '../ui/themes/default-light.js';
+import { pickDefaultThemeName } from '../ui/themes/theme.js';
export const persistentStateMock = new FakePersistentState();
@@ -150,8 +153,8 @@ const baseMockUiState = {
terminalWidth: 120,
terminalHeight: 40,
currentModel: 'gemini-pro',
+ terminalBackgroundColor: 'black',
cleanUiDetailsVisible: false,
- terminalBackgroundColor: undefined,
activePtyId: undefined,
backgroundShells: new Map(),
backgroundShellHeight: 0,
@@ -298,6 +301,15 @@ export const renderWithProviders = (
mainAreaWidth,
};
+ themeManager.setTerminalBackground(baseState.terminalBackgroundColor);
+ const themeName = pickDefaultThemeName(
+ baseState.terminalBackgroundColor,
+ themeManager.getAllThemes(),
+ DEFAULT_THEME.name,
+ DefaultLight.name,
+ );
+ themeManager.setActiveTheme(themeName);
+
const finalUIActions = { ...mockUIActions, ...uiActions };
const allToolCalls = (finalUiState.pendingHistoryItems || [])
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index 4c590c21eb..17e54f4771 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -496,7 +496,7 @@ export const AppContainer = (props: AppContainerProps) => {
);
coreEvents.off(CoreEvent.AgentsDiscovered, handleAgentsDiscovered);
};
- }, []);
+ }, [settings]);
const { consoleMessages, clearConsoleMessages: clearConsoleMessagesState } =
useConsoleMessages();
@@ -612,7 +612,7 @@ export const AppContainer = (props: AppContainerProps) => {
);
// Poll for terminal background color changes to auto-switch theme
- useTerminalTheme(handleThemeSelect, config);
+ useTerminalTheme(handleThemeSelect, config, refreshStatic);
const {
authState,
diff --git a/packages/cli/src/ui/colors.ts b/packages/cli/src/ui/colors.ts
index 87ec04b730..0825527cf5 100644
--- a/packages/cli/src/ui/colors.ts
+++ b/packages/cli/src/ui/colors.ts
@@ -15,7 +15,7 @@ export const Colors: ColorsTheme = {
return themeManager.getActiveTheme().colors.Foreground;
},
get Background() {
- return themeManager.getActiveTheme().colors.Background;
+ return themeManager.getColors().Background;
},
get LightBlue() {
return themeManager.getActiveTheme().colors.LightBlue;
@@ -51,7 +51,7 @@ export const Colors: ColorsTheme = {
return themeManager.getActiveTheme().colors.Gray;
},
get DarkGray() {
- return themeManager.getActiveTheme().colors.DarkGray;
+ return themeManager.getColors().DarkGray;
},
get GradientColors() {
return themeManager.getActiveTheme().colors.GradientColors;
diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx
index 31448cf6df..8257cd8acc 100644
--- a/packages/cli/src/ui/components/InputPrompt.test.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.test.tsx
@@ -1549,7 +1549,6 @@ describe('InputPrompt', () => {
{ color: 'black', name: 'black' },
{ color: '#000000', name: '#000000' },
{ color: '#000', name: '#000' },
- { color: undefined, name: 'default (black)' },
{ color: 'white', name: 'white' },
{ color: '#ffffff', name: '#ffffff' },
{ color: '#fff', name: '#fff' },
@@ -1619,6 +1618,11 @@ describe('InputPrompt', () => {
const { stdout, unmount } = renderWithProviders(
,
+ {
+ uiState: {
+ terminalBackgroundColor: 'black',
+ } as Partial,
+ },
);
await waitFor(() => {
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index 5cb23ac433..d9f0f34288 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -222,7 +222,6 @@ export const InputPrompt: React.FC = ({
terminalWidth,
activePtyId,
history,
- terminalBackgroundColor,
backgroundShells,
backgroundShellHeight,
shortcutsHelpVisible,
@@ -1352,7 +1351,7 @@ export const InputPrompt: React.FC = ({
const useBackgroundColor = config.getUseBackgroundColor();
const isLowColor = isLowColorDepth();
- const terminalBg = terminalBackgroundColor || 'black';
+ const terminalBg = theme.background.primary || 'black';
// We should fallback to lines if the background color is disabled OR if it is
// enabled but we are in a low color depth terminal where we don't have a safe
diff --git a/packages/cli/src/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx
index f04ae5172a..65e26aae49 100644
--- a/packages/cli/src/ui/components/ThemeDialog.tsx
+++ b/packages/cli/src/ui/components/ThemeDialog.tsx
@@ -9,7 +9,7 @@ import { useCallback, useState } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { themeManager, DEFAULT_THEME } from '../themes/theme-manager.js';
-import { pickDefaultThemeName } from '../themes/theme.js';
+import { pickDefaultThemeName, type Theme } from '../themes/theme.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { DiffRenderer } from './messages/DiffRenderer.js';
import { colorizeCode } from '../utils/CodeColorizer.js';
@@ -27,7 +27,10 @@ import { useUIState } from '../contexts/UIStateContext.js';
interface ThemeDialogProps {
/** Callback function when a theme is selected */
- onSelect: (themeName: string, scope: LoadableSettingScope) => void;
+ onSelect: (
+ themeName: string,
+ scope: LoadableSettingScope,
+ ) => void | Promise;
/** Callback function when the dialog is cancelled */
onCancel: () => void;
@@ -40,24 +43,21 @@ interface ThemeDialogProps {
terminalWidth: number;
}
-import {
- getThemeTypeFromBackgroundColor,
- resolveColor,
-} from '../themes/color-utils.js';
+import { resolveColor } from '../themes/color-utils.js';
function generateThemeItem(
name: string,
typeDisplay: string,
- themeType: string,
- themeBackground: string | undefined,
+ fullTheme: Theme | undefined,
terminalBackgroundColor: string | undefined,
- terminalThemeType: 'light' | 'dark' | undefined,
) {
- const isCompatible =
- themeType === 'custom' ||
- terminalThemeType === undefined ||
- themeType === 'ansi' ||
- themeType === terminalThemeType;
+ const isCompatible = fullTheme
+ ? themeManager.isThemeCompatible(fullTheme, terminalBackgroundColor)
+ : true;
+
+ const themeBackground = fullTheme
+ ? resolveColor(fullTheme.colors.Background)
+ : undefined;
const isBackgroundMatch =
terminalBackgroundColor &&
@@ -111,26 +111,17 @@ export function ThemeDialog({
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
- const terminalThemeType = getThemeTypeFromBackgroundColor(
- terminalBackgroundColor,
- );
-
// Generate theme items
const themeItems = themeManager
.getAvailableThemes()
.map((theme) => {
const fullTheme = themeManager.getTheme(theme.name);
- const themeBackground = fullTheme
- ? resolveColor(fullTheme.colors.Background)
- : undefined;
return generateThemeItem(
theme.name,
capitalize(theme.type),
- theme.type,
- themeBackground,
+ fullTheme,
terminalBackgroundColor,
- terminalThemeType,
);
})
.sort((a, b) => {
@@ -149,8 +140,8 @@ export function ThemeDialog({
const safeInitialThemeIndex = initialThemeIndex >= 0 ? initialThemeIndex : 0;
const handleThemeSelect = useCallback(
- (themeName: string) => {
- onSelect(themeName, selectedScope);
+ async (themeName: string) => {
+ await onSelect(themeName, selectedScope);
refreshStatic();
},
[onSelect, selectedScope, refreshStatic],
@@ -166,8 +157,8 @@ export function ThemeDialog({
}, []);
const handleScopeSelect = useCallback(
- (scope: LoadableSettingScope) => {
- onSelect(highlightedThemeName, scope);
+ async (scope: LoadableSettingScope) => {
+ await onSelect(highlightedThemeName, scope);
refreshStatic();
},
[onSelect, highlightedThemeName, refreshStatic],
diff --git a/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap
index 86eb35c24d..ab402d263f 100644
--- a/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap
@@ -90,18 +90,18 @@ exports[`ThemeDialog Snapshots > should render correctly in theme selection mode
│ │
│ > Select Theme Preview │
│ ▲ ┌────────────────────────────────────────────────────────────┐ │
-│ 1. ANSI Dark │ │ │
-│ 2. ANSI Light Light │ 1 # function │ │
-│ 3. Atom One Dark │ 2 def fibonacci(n): │ │
-│ 4. Ayu Dark │ 3 a, b = 0, 1 │ │
-│ 5. Ayu Light Light │ 4 for _ in range(n): │ │
-│ ● 6. Default Dark │ 5 a, b = b, a + b │ │
-│ 7. Default Light Light │ 6 return a │ │
-│ 8. Dracula Dark │ │ │
-│ 9. GitHub Dark │ 1 - print("Hello, " + name) │ │
-│ 10. GitHub Light Light │ 1 + print(f"Hello, {name}!") │ │
-│ 11. Google Code Light │ │ │
-│ 12. Holiday Dark └────────────────────────────────────────────────────────────┘ │
+│ ● 1. ANSI Dark (Matches terminal) │ │ │
+│ 2. Atom One Dark │ 1 # function │ │
+│ 3. Ayu Dark │ 2 def fibonacci(n): │ │
+│ 4. Default Dark │ 3 a, b = 0, 1 │ │
+│ 5. Dracula Dark │ 4 for _ in range(n): │ │
+│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
+│ 7. Holiday Dark │ 6 return a │ │
+│ 8. Shades Of Purple Dark │ │ │
+│ 9. ANSI Light Light (Incompatible) │ 1 - print("Hello, " + name) │ │
+│ 10. Ayu Light Light (Incompatible) │ 1 + print(f"Hello, {name}!") │ │
+│ 11. Default Light Light (Incompatible) │ │ │
+│ 12. GitHub Light Light (Incompatible) └────────────────────────────────────────────────────────────┘ │
│ ▼ │
│ │
│ (Use Enter to select, Tab to configure scope, Esc to close) │
diff --git a/packages/cli/src/ui/components/shared/HalfLinePaddedBox.tsx b/packages/cli/src/ui/components/shared/HalfLinePaddedBox.tsx
index 0b15c58beb..add5353245 100644
--- a/packages/cli/src/ui/components/shared/HalfLinePaddedBox.tsx
+++ b/packages/cli/src/ui/components/shared/HalfLinePaddedBox.tsx
@@ -8,6 +8,7 @@ import type React from 'react';
import { useMemo } from 'react';
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import { useUIState } from '../../contexts/UIStateContext.js';
+import { theme } from '../../semantic-colors.js';
import {
interpolateColor,
resolveColor,
@@ -52,8 +53,8 @@ const HalfLinePaddedBoxInternal: React.FC = ({
backgroundOpacity,
children,
}) => {
- const { terminalWidth, terminalBackgroundColor } = useUIState();
- const terminalBg = terminalBackgroundColor || 'black';
+ const { terminalWidth } = useUIState();
+ const terminalBg = theme.background.primary || 'black';
const isLowColor = isLowColorDepth();
diff --git a/packages/cli/src/ui/contexts/TerminalContext.test.tsx b/packages/cli/src/ui/contexts/TerminalContext.test.tsx
index dc1ceca62e..509cd3c9c5 100644
--- a/packages/cli/src/ui/contexts/TerminalContext.test.tsx
+++ b/packages/cli/src/ui/contexts/TerminalContext.test.tsx
@@ -29,6 +29,11 @@ vi.mock('ink', () => ({
useStdin: () => ({
stdin: mockStdin,
}),
+ useStdout: () => ({
+ stdout: {
+ write: vi.fn(),
+ },
+ }),
}));
const TestComponent = ({ onColor }: { onColor: (c: string) => void }) => {
diff --git a/packages/cli/src/ui/contexts/TerminalContext.tsx b/packages/cli/src/ui/contexts/TerminalContext.tsx
index e954029207..20d6b097ae 100644
--- a/packages/cli/src/ui/contexts/TerminalContext.tsx
+++ b/packages/cli/src/ui/contexts/TerminalContext.tsx
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { useStdin } from 'ink';
+import { useStdin, useStdout } from 'ink';
import type React from 'react';
import {
createContext,
@@ -20,6 +20,7 @@ export type TerminalEventHandler = (event: string) => void;
interface TerminalContextValue {
subscribe: (handler: TerminalEventHandler) => void;
unsubscribe: (handler: TerminalEventHandler) => void;
+ queryTerminalBackground: () => Promise;
}
const TerminalContext = createContext(
@@ -38,6 +39,7 @@ export function useTerminalContext() {
export function TerminalProvider({ children }: { children: React.ReactNode }) {
const { stdin } = useStdin();
+ const { stdout } = useStdout();
const subscribers = useRef>(new Set()).current;
const bufferRef = useRef('');
@@ -55,6 +57,23 @@ export function TerminalProvider({ children }: { children: React.ReactNode }) {
[subscribers],
);
+ const queryTerminalBackground = useCallback(
+ async () =>
+ new Promise((resolve) => {
+ const handler = () => {
+ unsubscribe(handler);
+ resolve();
+ };
+ subscribe(handler);
+ TerminalCapabilityManager.queryBackgroundColor(stdout);
+ setTimeout(() => {
+ unsubscribe(handler);
+ resolve();
+ }, 100);
+ }),
+ [stdout, subscribe, unsubscribe],
+ );
+
useEffect(() => {
const handleData = (data: Buffer | string) => {
bufferRef.current +=
@@ -89,7 +108,9 @@ export function TerminalProvider({ children }: { children: React.ReactNode }) {
}, [stdin, subscribers]);
return (
-
+
{children}
);
diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx
index 0fb98c34ff..c80507f9d7 100644
--- a/packages/cli/src/ui/contexts/UIActionsContext.tsx
+++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx
@@ -20,7 +20,10 @@ import type { SessionInfo } from '../../utils/sessionUtils.js';
import { type NewAgentsChoice } from '../components/NewAgentsNotification.js';
export interface UIActions {
- handleThemeSelect: (themeName: string, scope: LoadableSettingScope) => void;
+ handleThemeSelect: (
+ themeName: string,
+ scope: LoadableSettingScope,
+ ) => Promise;
closeThemeDialog: () => void;
handleThemeHighlight: (themeName: string | undefined) => void;
handleAuthSelect: (
diff --git a/packages/cli/src/ui/hooks/useSnowfall.test.tsx b/packages/cli/src/ui/hooks/useSnowfall.test.tsx
index 004af733ca..321da83090 100644
--- a/packages/cli/src/ui/hooks/useSnowfall.test.tsx
+++ b/packages/cli/src/ui/hooks/useSnowfall.test.tsx
@@ -16,7 +16,11 @@ import type { UIState } from '../contexts/UIStateContext.js';
vi.mock('../themes/theme-manager.js', () => ({
themeManager: {
getActiveTheme: vi.fn(),
+ setTerminalBackground: vi.fn(),
+ getAllThemes: vi.fn(() => []),
+ setActiveTheme: vi.fn(),
},
+ DEFAULT_THEME: { name: 'Default' },
}));
vi.mock('../themes/holiday.js', () => ({
diff --git a/packages/cli/src/ui/hooks/useTerminalTheme.test.tsx b/packages/cli/src/ui/hooks/useTerminalTheme.test.tsx
index da2a9b2c04..21eabba6cc 100644
--- a/packages/cli/src/ui/hooks/useTerminalTheme.test.tsx
+++ b/packages/cli/src/ui/hooks/useTerminalTheme.test.tsx
@@ -15,6 +15,7 @@ const mockWrite = vi.fn();
const mockSubscribe = vi.fn();
const mockUnsubscribe = vi.fn();
const mockHandleThemeSelect = vi.fn();
+const mockQueryTerminalBackground = vi.fn();
vi.mock('ink', async () => ({
useStdout: () => ({
@@ -28,6 +29,7 @@ vi.mock('../contexts/TerminalContext.js', () => ({
useTerminalContext: () => ({
subscribe: mockSubscribe,
unsubscribe: mockUnsubscribe,
+ queryTerminalBackground: mockQueryTerminalBackground,
}),
}));
@@ -52,6 +54,7 @@ vi.mock('../themes/theme-manager.js', async () => {
themeManager: {
isDefaultTheme: (name: string) =>
name === 'default' || name === 'default-light',
+ setTerminalBackground: vi.fn(),
},
DEFAULT_THEME: { name: 'default' },
};
@@ -78,6 +81,7 @@ describe('useTerminalTheme', () => {
mockSubscribe.mockClear();
mockUnsubscribe.mockClear();
mockHandleThemeSelect.mockClear();
+ mockQueryTerminalBackground.mockClear();
// Reset any settings modifications
mockSettings.merged.ui.autoThemeSwitching = true;
mockSettings.merged.ui.theme = 'default';
@@ -89,37 +93,37 @@ describe('useTerminalTheme', () => {
});
it('should subscribe to terminal background events on mount', () => {
- renderHook(() => useTerminalTheme(mockHandleThemeSelect, config));
+ renderHook(() => useTerminalTheme(mockHandleThemeSelect, config, vi.fn()));
expect(mockSubscribe).toHaveBeenCalled();
});
it('should unsubscribe on unmount', () => {
const { unmount } = renderHook(() =>
- useTerminalTheme(mockHandleThemeSelect, config),
+ useTerminalTheme(mockHandleThemeSelect, config, vi.fn()),
);
unmount();
expect(mockUnsubscribe).toHaveBeenCalled();
});
it('should poll for terminal background', () => {
- renderHook(() => useTerminalTheme(mockHandleThemeSelect, config));
+ renderHook(() => useTerminalTheme(mockHandleThemeSelect, config, vi.fn()));
// Fast-forward time (1 minute)
vi.advanceTimersByTime(60000);
- expect(mockWrite).toHaveBeenCalledWith('\x1b]11;?\x1b\\');
+ expect(mockQueryTerminalBackground).toHaveBeenCalled();
});
it('should not poll if terminal background is undefined at startup', () => {
config.getTerminalBackground = vi.fn().mockReturnValue(undefined);
- renderHook(() => useTerminalTheme(mockHandleThemeSelect, config));
+ renderHook(() => useTerminalTheme(mockHandleThemeSelect, config, vi.fn()));
// Poll should not happen
vi.advanceTimersByTime(60000);
- expect(mockWrite).not.toHaveBeenCalled();
+ expect(mockQueryTerminalBackground).not.toHaveBeenCalled();
});
it('should switch to light theme when background is light', () => {
- renderHook(() => useTerminalTheme(mockHandleThemeSelect, config));
+ renderHook(() => useTerminalTheme(mockHandleThemeSelect, config, vi.fn()));
const handler = mockSubscribe.mock.calls[0][0];
@@ -137,7 +141,7 @@ describe('useTerminalTheme', () => {
// Start with light theme
mockSettings.merged.ui.theme = 'default-light';
- renderHook(() => useTerminalTheme(mockHandleThemeSelect, config));
+ renderHook(() => useTerminalTheme(mockHandleThemeSelect, config, vi.fn()));
const handler = mockSubscribe.mock.calls[0][0];
@@ -156,11 +160,11 @@ describe('useTerminalTheme', () => {
it('should not switch theme if autoThemeSwitching is disabled', () => {
mockSettings.merged.ui.autoThemeSwitching = false;
- renderHook(() => useTerminalTheme(mockHandleThemeSelect, config));
+ renderHook(() => useTerminalTheme(mockHandleThemeSelect, config, vi.fn()));
// Poll should not happen
vi.advanceTimersByTime(60000);
- expect(mockWrite).not.toHaveBeenCalled();
+ expect(mockQueryTerminalBackground).not.toHaveBeenCalled();
mockSettings.merged.ui.autoThemeSwitching = true;
});
diff --git a/packages/cli/src/ui/hooks/useTerminalTheme.ts b/packages/cli/src/ui/hooks/useTerminalTheme.ts
index 69292616fd..b3ac7522bc 100644
--- a/packages/cli/src/ui/hooks/useTerminalTheme.ts
+++ b/packages/cli/src/ui/hooks/useTerminalTheme.ts
@@ -5,7 +5,6 @@
*/
import { useEffect } from 'react';
-import { useStdout } from 'ink';
import {
getLuminance,
parseColor,
@@ -22,10 +21,11 @@ import type { UIActions } from '../contexts/UIActionsContext.js';
export function useTerminalTheme(
handleThemeSelect: UIActions['handleThemeSelect'],
config: Config,
+ refreshStatic: () => void,
) {
- const { stdout } = useStdout();
const settings = useSettings();
- const { subscribe, unsubscribe } = useTerminalContext();
+ const { subscribe, unsubscribe, queryTerminalBackground } =
+ useTerminalContext();
useEffect(() => {
if (settings.merged.ui.autoThemeSwitching === false) {
@@ -44,7 +44,7 @@ export function useTerminalTheme(
return;
}
- stdout.write('\x1b]11;?\x1b\\');
+ void queryTerminalBackground();
}, settings.merged.ui.terminalBackgroundPollingInterval * 1000);
const handleTerminalBackground = (colorStr: string) => {
@@ -58,6 +58,8 @@ export function useTerminalTheme(
const hexColor = parseColor(match[1], match[2], match[3]);
const luminance = getLuminance(hexColor);
config.setTerminalBackground(hexColor);
+ themeManager.setTerminalBackground(hexColor);
+ refreshStatic();
const currentThemeName = settings.merged.ui.theme;
@@ -69,7 +71,7 @@ export function useTerminalTheme(
);
if (newTheme) {
- handleThemeSelect(newTheme, SettingScope.User);
+ void handleThemeSelect(newTheme, SettingScope.User);
}
};
@@ -83,10 +85,11 @@ export function useTerminalTheme(
settings.merged.ui.theme,
settings.merged.ui.autoThemeSwitching,
settings.merged.ui.terminalBackgroundPollingInterval,
- stdout,
config,
handleThemeSelect,
subscribe,
unsubscribe,
+ queryTerminalBackground,
+ refreshStatic,
]);
}
diff --git a/packages/cli/src/ui/hooks/useThemeCommand.ts b/packages/cli/src/ui/hooks/useThemeCommand.ts
index 790019db15..d1d17da428 100644
--- a/packages/cli/src/ui/hooks/useThemeCommand.ts
+++ b/packages/cli/src/ui/hooks/useThemeCommand.ts
@@ -13,12 +13,16 @@ import type {
import { MessageType } from '../types.js';
import process from 'node:process';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
+import { useTerminalContext } from '../contexts/TerminalContext.js';
interface UseThemeCommandReturn {
isThemeDialogOpen: boolean;
openThemeDialog: () => void;
closeThemeDialog: () => void;
- handleThemeSelect: (themeName: string, scope: LoadableSettingScope) => void;
+ handleThemeSelect: (
+ themeName: string,
+ scope: LoadableSettingScope,
+ ) => Promise;
handleThemeHighlight: (themeName: string | undefined) => void;
}
@@ -30,8 +34,9 @@ export const useThemeCommand = (
): UseThemeCommandReturn => {
const [isThemeDialogOpen, setIsThemeDialogOpen] =
useState(!!initialThemeError);
+ const { queryTerminalBackground } = useTerminalContext();
- const openThemeDialog = useCallback(() => {
+ const openThemeDialog = useCallback(async () => {
if (process.env['NO_COLOR']) {
addItem(
{
@@ -42,8 +47,14 @@ export const useThemeCommand = (
);
return;
}
+
+ // Ensure we have an up to date terminal background color when opening the
+ // theme dialog as the user may have just changed it before opening the
+ // dialog.
+ await queryTerminalBackground();
+
setIsThemeDialogOpen(true);
- }, [addItem]);
+ }, [addItem, queryTerminalBackground]);
const applyTheme = useCallback(
(themeName: string | undefined) => {
@@ -72,7 +83,7 @@ export const useThemeCommand = (
}, [applyTheme, loadedSettings]);
const handleThemeSelect = useCallback(
- (themeName: string, scope: LoadableSettingScope) => {
+ async (themeName: string, scope: LoadableSettingScope) => {
try {
const mergedCustomThemes = {
...(loadedSettings.user.settings.ui?.customThemes || {}),
diff --git a/packages/cli/src/ui/themes/color-utils.ts b/packages/cli/src/ui/themes/color-utils.ts
index ecfec6ab08..476703a7fc 100644
--- a/packages/cli/src/ui/themes/color-utils.ts
+++ b/packages/cli/src/ui/themes/color-utils.ts
@@ -6,149 +6,7 @@
import { debugLogger } from '@google/gemini-cli-core';
import tinygradient from 'tinygradient';
-
-// Mapping from common CSS color names (lowercase) to hex codes (lowercase)
-// Excludes names directly supported by Ink
-export const CSS_NAME_TO_HEX_MAP: Readonly> = {
- aliceblue: '#f0f8ff',
- antiquewhite: '#faebd7',
- aqua: '#00ffff',
- aquamarine: '#7fffd4',
- azure: '#f0ffff',
- beige: '#f5f5dc',
- bisque: '#ffe4c4',
- blanchedalmond: '#ffebcd',
- blueviolet: '#8a2be2',
- brown: '#a52a2a',
- burlywood: '#deb887',
- cadetblue: '#5f9ea0',
- chartreuse: '#7fff00',
- chocolate: '#d2691e',
- coral: '#ff7f50',
- cornflowerblue: '#6495ed',
- cornsilk: '#fff8dc',
- crimson: '#dc143c',
- darkblue: '#00008b',
- darkcyan: '#008b8b',
- darkgoldenrod: '#b8860b',
- darkgray: '#a9a9a9',
- darkgrey: '#a9a9a9',
- darkgreen: '#006400',
- darkkhaki: '#bdb76b',
- darkmagenta: '#8b008b',
- darkolivegreen: '#556b2f',
- darkorange: '#ff8c00',
- darkorchid: '#9932cc',
- darkred: '#8b0000',
- darksalmon: '#e9967a',
- darkseagreen: '#8fbc8f',
- darkslateblue: '#483d8b',
- darkslategray: '#2f4f4f',
- darkslategrey: '#2f4f4f',
- darkturquoise: '#00ced1',
- darkviolet: '#9400d3',
- deeppink: '#ff1493',
- deepskyblue: '#00bfff',
- dimgray: '#696969',
- dimgrey: '#696969',
- dodgerblue: '#1e90ff',
- firebrick: '#b22222',
- floralwhite: '#fffaf0',
- forestgreen: '#228b22',
- fuchsia: '#ff00ff',
- gainsboro: '#dcdcdc',
- ghostwhite: '#f8f8ff',
- gold: '#ffd700',
- goldenrod: '#daa520',
- greenyellow: '#adff2f',
- honeydew: '#f0fff0',
- hotpink: '#ff69b4',
- indianred: '#cd5c5c',
- indigo: '#4b0082',
- ivory: '#fffff0',
- khaki: '#f0e68c',
- lavender: '#e6e6fa',
- lavenderblush: '#fff0f5',
- lawngreen: '#7cfc00',
- lemonchiffon: '#fffacd',
- lightblue: '#add8e6',
- lightcoral: '#f08080',
- lightcyan: '#e0ffff',
- lightgoldenrodyellow: '#fafad2',
- lightgray: '#d3d3d3',
- lightgrey: '#d3d3d3',
- lightgreen: '#90ee90',
- lightpink: '#ffb6c1',
- lightsalmon: '#ffa07a',
- lightseagreen: '#20b2aa',
- lightskyblue: '#87cefa',
- lightslategray: '#778899',
- lightslategrey: '#778899',
- lightsteelblue: '#b0c4de',
- lightyellow: '#ffffe0',
- lime: '#00ff00',
- limegreen: '#32cd32',
- linen: '#faf0e6',
- maroon: '#800000',
- mediumaquamarine: '#66cdaa',
- mediumblue: '#0000cd',
- mediumorchid: '#ba55d3',
- mediumpurple: '#9370db',
- mediumseagreen: '#3cb371',
- mediumslateblue: '#7b68ee',
- mediumspringgreen: '#00fa9a',
- mediumturquoise: '#48d1cc',
- mediumvioletred: '#c71585',
- midnightblue: '#191970',
- mintcream: '#f5fffa',
- mistyrose: '#ffe4e1',
- moccasin: '#ffe4b5',
- navajowhite: '#ffdead',
- navy: '#000080',
- oldlace: '#fdf5e6',
- olive: '#808000',
- olivedrab: '#6b8e23',
- orange: '#ffa500',
- orangered: '#ff4500',
- orchid: '#da70d6',
- palegoldenrod: '#eee8aa',
- palegreen: '#98fb98',
- paleturquoise: '#afeeee',
- palevioletred: '#db7093',
- papayawhip: '#ffefd5',
- peachpuff: '#ffdab9',
- peru: '#cd853f',
- pink: '#ffc0cb',
- plum: '#dda0dd',
- powderblue: '#b0e0e6',
- purple: '#800080',
- rebeccapurple: '#663399',
- rosybrown: '#bc8f8f',
- royalblue: '#4169e1',
- saddlebrown: '#8b4513',
- salmon: '#fa8072',
- sandybrown: '#f4a460',
- seagreen: '#2e8b57',
- seashell: '#fff5ee',
- sienna: '#a0522d',
- silver: '#c0c0c0',
- skyblue: '#87ceeb',
- slateblue: '#6a5acd',
- slategray: '#708090',
- slategrey: '#708090',
- snow: '#fffafa',
- springgreen: '#00ff7f',
- steelblue: '#4682b4',
- tan: '#d2b48c',
- teal: '#008080',
- thistle: '#d8bfd8',
- tomato: '#ff6347',
- turquoise: '#40e0d0',
- violet: '#ee82ee',
- wheat: '#f5deb3',
- whitesmoke: '#f5f5f5',
- yellowgreen: '#9acd32',
-};
+import tinycolor from 'tinycolor2';
// Define the set of Ink's named colors for quick lookup
export const INK_SUPPORTED_NAMES = new Set([
@@ -172,6 +30,13 @@ export const INK_SUPPORTED_NAMES = new Set([
'whitebright',
]);
+// Use tinycolor's built-in names map for CSS colors, excluding ones Ink supports
+export const CSS_NAME_TO_HEX_MAP = Object.fromEntries(
+ Object.entries(tinycolor.names)
+ .filter(([name]) => !INK_SUPPORTED_NAMES.has(name))
+ .map(([name, hex]) => [name, `#${hex}`]),
+);
+
/**
* Checks if a color string is valid (hex, Ink-supported color name, or CSS color name).
* This function uses the same validation logic as the Theme class's _resolveColor method
@@ -217,12 +82,19 @@ export function resolveColor(colorValue: string): string | undefined {
return undefined;
}
}
+
+ // Handle hex codes without #
+ if (/^[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$/.test(colorValue)) {
+ return `#${lowerColor}`;
+ }
+
// 2. Check if it's an Ink supported name (lowercase)
- else if (INK_SUPPORTED_NAMES.has(lowerColor)) {
+ if (INK_SUPPORTED_NAMES.has(lowerColor)) {
return lowerColor; // Use Ink name directly
}
+
// 3. Check if it's a known CSS name we can map to hex
- else if (CSS_NAME_TO_HEX_MAP[lowerColor]) {
+ if (CSS_NAME_TO_HEX_MAP[lowerColor]) {
return CSS_NAME_TO_HEX_MAP[lowerColor]; // Use mapped hex
}
@@ -286,27 +158,45 @@ export function getThemeTypeFromBackgroundColor(
return undefined;
}
- const luminance = getLuminance(backgroundColor);
+ const resolvedColor = resolveColor(backgroundColor);
+ if (!resolvedColor) {
+ return undefined;
+ }
+
+ const luminance = getLuminance(resolvedColor);
return luminance > 128 ? 'light' : 'dark';
}
+// Mapping for ANSI bright colors that are not in tinycolor's standard CSS names
+export const INK_NAME_TO_HEX_MAP: Readonly> = {
+ blackbright: '#555555',
+ redbright: '#ff5555',
+ greenbright: '#55ff55',
+ yellowbright: '#ffff55',
+ bluebright: '#5555ff',
+ magentabright: '#ff55ff',
+ cyanbright: '#55ffff',
+ whitebright: '#ffffff',
+};
+
/**
* Calculates the relative luminance of a color.
* See https://www.w3.org/TR/WCAG20/#relativeluminancedef
*
- * @param backgroundColor Hex color string (with or without #)
+ * @param color Color string (hex or Ink-supported name)
* @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);
+export function getLuminance(color: string): number {
+ const resolved = color.toLowerCase();
+ const hex = INK_NAME_TO_HEX_MAP[resolved] || resolved;
- return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+ const colorObj = tinycolor(hex);
+ if (!colorObj.isValid()) {
+ return 0;
+ }
+
+ // tinycolor returns 0-1, we need 0-255
+ return colorObj.getLuminance() * 255;
}
// Hysteresis thresholds to prevent flickering when the background color
diff --git a/packages/cli/src/ui/themes/theme-manager.test.ts b/packages/cli/src/ui/themes/theme-manager.test.ts
index e80c03c5e1..40f55ec860 100644
--- a/packages/cli/src/ui/themes/theme-manager.test.ts
+++ b/packages/cli/src/ui/themes/theme-manager.test.ts
@@ -59,6 +59,7 @@ describe('ThemeManager', () => {
// Reset themeManager state
themeManager.loadCustomThemes({});
themeManager.setActiveTheme(DEFAULT_THEME.name);
+ themeManager.setTerminalBackground(undefined);
});
afterEach(() => {
@@ -238,4 +239,114 @@ describe('ThemeManager', () => {
expect(themeManager.isCustomTheme('SettingsTheme')).toBe(true);
});
});
+
+ describe('terminalBackground override', () => {
+ it('should store and retrieve terminal background', () => {
+ themeManager.setTerminalBackground('#123456');
+ expect(themeManager.getTerminalBackground()).toBe('#123456');
+ themeManager.setTerminalBackground(undefined);
+ expect(themeManager.getTerminalBackground()).toBeUndefined();
+ });
+
+ it('should override background.primary in semantic colors when terminal background is set', () => {
+ const color = '#1a1a1a';
+ themeManager.setTerminalBackground(color);
+ const semanticColors = themeManager.getSemanticColors();
+ expect(semanticColors.background.primary).toBe(color);
+ });
+
+ it('should override Background in colors when terminal background is set', () => {
+ const color = '#1a1a1a';
+ themeManager.setTerminalBackground(color);
+ const colors = themeManager.getColors();
+ expect(colors.Background).toBe(color);
+ });
+
+ it('should re-calculate dependent semantic colors when terminal background is set', () => {
+ themeManager.setTerminalBackground('#000000');
+ const semanticColors = themeManager.getSemanticColors();
+
+ // border.default should be interpolated from background (#000000) and Gray
+ // ui.dark should be interpolated from Gray and background (#000000)
+ expect(semanticColors.border.default).toBeDefined();
+ expect(semanticColors.ui.dark).toBeDefined();
+ expect(semanticColors.border.default).not.toBe(
+ DEFAULT_THEME.semanticColors.border.default,
+ );
+ });
+
+ it('should return original semantic colors when terminal background is NOT set', () => {
+ themeManager.setTerminalBackground(undefined);
+ const semanticColors = themeManager.getSemanticColors();
+ expect(semanticColors).toEqual(DEFAULT_THEME.semanticColors);
+ });
+
+ it('should NOT override background when theme is incompatible (Light theme on Dark terminal)', () => {
+ themeManager.setActiveTheme('Default Light');
+ const darkTerminalBg = '#000000';
+ themeManager.setTerminalBackground(darkTerminalBg);
+
+ const semanticColors = themeManager.getSemanticColors();
+ expect(semanticColors.background.primary).toBe(
+ themeManager.getTheme('Default Light')!.colors.Background,
+ );
+
+ const colors = themeManager.getColors();
+ expect(colors.Background).toBe(
+ themeManager.getTheme('Default Light')!.colors.Background,
+ );
+ });
+
+ it('should NOT override background when theme is incompatible (Dark theme on Light terminal)', () => {
+ themeManager.setActiveTheme('Default');
+ const lightTerminalBg = '#FFFFFF';
+ themeManager.setTerminalBackground(lightTerminalBg);
+
+ const semanticColors = themeManager.getSemanticColors();
+ expect(semanticColors.background.primary).toBe(
+ themeManager.getTheme('Default')!.colors.Background,
+ );
+
+ const colors = themeManager.getColors();
+ expect(colors.Background).toBe(
+ themeManager.getTheme('Default')!.colors.Background,
+ );
+ });
+
+ it('should override background for custom theme when compatible', () => {
+ themeManager.loadCustomThemes({
+ MyDark: {
+ name: 'MyDark',
+ type: 'custom',
+ Background: '#000000',
+ Foreground: '#ffffff',
+ },
+ });
+ themeManager.setActiveTheme('MyDark');
+
+ const darkTerminalBg = '#1a1a1a';
+ themeManager.setTerminalBackground(darkTerminalBg);
+
+ const semanticColors = themeManager.getSemanticColors();
+ expect(semanticColors.background.primary).toBe(darkTerminalBg);
+ });
+
+ it('should NOT override background for custom theme when incompatible', () => {
+ themeManager.loadCustomThemes({
+ MyLight: {
+ name: 'MyLight',
+ type: 'custom',
+ Background: '#ffffff',
+ Foreground: '#000000',
+ },
+ });
+ themeManager.setActiveTheme('MyLight');
+
+ const darkTerminalBg = '#000000';
+ themeManager.setTerminalBackground(darkTerminalBg);
+
+ const semanticColors = themeManager.getSemanticColors();
+ expect(semanticColors.background.primary).toBe('#ffffff');
+ });
+ });
});
diff --git a/packages/cli/src/ui/themes/theme-manager.ts b/packages/cli/src/ui/themes/theme-manager.ts
index 7452d093f8..3ee4d5af1a 100644
--- a/packages/cli/src/ui/themes/theme-manager.ts
+++ b/packages/cli/src/ui/themes/theme-manager.ts
@@ -18,10 +18,16 @@ import { ShadesOfPurple } from './shades-of-purple.js';
import { XCode } from './xcode.js';
import * as fs from 'node:fs';
import * as path from 'node:path';
-import type { Theme, ThemeType } from './theme.js';
+import type { Theme, ThemeType, ColorsTheme } from './theme.js';
import type { CustomTheme } from '@google/gemini-cli-core';
import { createCustomTheme, validateCustomTheme } from './theme.js';
import type { SemanticColors } from './semantic-tokens.js';
+import {
+ interpolateColor,
+ getThemeTypeFromBackgroundColor,
+ resolveColor,
+} from './color-utils.js';
+import { DEFAULT_BORDER_OPACITY } from '../constants.js';
import { ANSI } from './ansi.js';
import { ANSILight } from './ansi-light.js';
import { NoColorTheme } from './no-color.js';
@@ -42,6 +48,12 @@ class ThemeManager {
private settingsThemes: Map = new Map();
private extensionThemes: Map = new Map();
private fileThemes: Map = new Map();
+ private terminalBackground: string | undefined;
+
+ // Cache for dynamic colors
+ private cachedColors: ColorsTheme | undefined;
+ private cachedSemanticColors: SemanticColors | undefined;
+ private lastCacheKey: string | undefined;
constructor() {
this.availableThemes = [
@@ -63,6 +75,23 @@ class ThemeManager {
this.activeTheme = DEFAULT_THEME;
}
+ setTerminalBackground(color: string | undefined): void {
+ if (this.terminalBackground !== color) {
+ this.terminalBackground = color;
+ this.clearCache();
+ }
+ }
+
+ getTerminalBackground(): string | undefined {
+ return this.terminalBackground;
+ }
+
+ private clearCache(): void {
+ this.cachedColors = undefined;
+ this.cachedSemanticColors = undefined;
+ this.lastCacheKey = undefined;
+ }
+
isDefaultTheme(themeName: string | undefined): boolean {
return (
themeName === undefined ||
@@ -214,7 +243,10 @@ class ThemeManager {
if (!theme) {
return false;
}
- this.activeTheme = theme;
+ if (this.activeTheme !== theme) {
+ this.activeTheme = theme;
+ this.clearCache();
+ }
return true;
}
@@ -255,12 +287,104 @@ class ThemeManager {
return this.activeTheme;
}
+ /**
+ * Gets the colors for the active theme, respecting the terminal background.
+ * @returns The theme colors.
+ */
+ getColors(): ColorsTheme {
+ const activeTheme = this.getActiveTheme();
+ const cacheKey = `${activeTheme.name}:${this.terminalBackground}`;
+ if (this.cachedColors && this.lastCacheKey === cacheKey) {
+ return this.cachedColors;
+ }
+
+ const colors = activeTheme.colors;
+ if (
+ this.terminalBackground &&
+ this.isThemeCompatible(activeTheme, this.terminalBackground)
+ ) {
+ this.cachedColors = {
+ ...colors,
+ Background: this.terminalBackground,
+ DarkGray: interpolateColor(colors.Gray, this.terminalBackground, 0.5),
+ };
+ } else {
+ this.cachedColors = colors;
+ }
+
+ this.lastCacheKey = cacheKey;
+ return this.cachedColors;
+ }
+
/**
* Gets the semantic colors for the active theme.
* @returns The semantic colors.
*/
getSemanticColors(): SemanticColors {
- return this.getActiveTheme().semanticColors;
+ const activeTheme = this.getActiveTheme();
+ const cacheKey = `${activeTheme.name}:${this.terminalBackground}`;
+ if (this.cachedSemanticColors && this.lastCacheKey === cacheKey) {
+ return this.cachedSemanticColors;
+ }
+
+ const semanticColors = activeTheme.semanticColors;
+ if (
+ this.terminalBackground &&
+ this.isThemeCompatible(activeTheme, this.terminalBackground)
+ ) {
+ this.cachedSemanticColors = {
+ ...semanticColors,
+ background: {
+ ...semanticColors.background,
+ primary: this.terminalBackground,
+ },
+ border: {
+ ...semanticColors.border,
+ default: interpolateColor(
+ this.terminalBackground,
+ activeTheme.colors.Gray,
+ DEFAULT_BORDER_OPACITY,
+ ),
+ },
+ ui: {
+ ...semanticColors.ui,
+ dark: interpolateColor(
+ activeTheme.colors.Gray,
+ this.terminalBackground,
+ 0.5,
+ ),
+ },
+ };
+ } else {
+ this.cachedSemanticColors = semanticColors;
+ }
+
+ this.lastCacheKey = cacheKey;
+ return this.cachedSemanticColors;
+ }
+
+ isThemeCompatible(
+ activeTheme: Theme,
+ terminalBackground: string | undefined,
+ ): boolean {
+ if (activeTheme.type === 'ansi') {
+ return true;
+ }
+
+ const backgroundType = getThemeTypeFromBackgroundColor(terminalBackground);
+ if (!backgroundType) {
+ return true;
+ }
+
+ const themeType =
+ activeTheme.type === 'custom'
+ ? getThemeTypeFromBackgroundColor(
+ resolveColor(activeTheme.colors.Background) ||
+ activeTheme.colors.Background,
+ )
+ : activeTheme.type;
+
+ return themeType === backgroundType;
}
private _getAllCustomThemes(): Theme[] {
diff --git a/packages/cli/src/ui/utils/terminalCapabilityManager.ts b/packages/cli/src/ui/utils/terminalCapabilityManager.ts
index 8fa2146072..447c79ce91 100644
--- a/packages/cli/src/ui/utils/terminalCapabilityManager.ts
+++ b/packages/cli/src/ui/utils/terminalCapabilityManager.ts
@@ -44,6 +44,16 @@ export class TerminalCapabilityManager {
private static readonly DEVICE_ATTRIBUTES_QUERY = '\x1b[c';
private static readonly MODIFY_OTHER_KEYS_QUERY = '\x1b[>4;?m';
+ /**
+ * Triggers a terminal background color query.
+ * @param stdout The stdout stream to write to.
+ */
+ static queryBackgroundColor(stdout: {
+ write: (data: string) => void | boolean;
+ }): void {
+ stdout.write(TerminalCapabilityManager.OSC_11_QUERY);
+ }
+
// Kitty keyboard flags: CSI ? flags u
// eslint-disable-next-line no-control-regex
private static readonly KITTY_REGEX = /\x1b\[\?(\d+)u/;
@@ -56,7 +66,7 @@ export class TerminalCapabilityManager {
// OSC 11 response: OSC 11 ; rgb:rrrr/gggg/bbbb ST (or BEL)
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)?/;
+ /\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
// eslint-disable-next-line no-control-regex
private static readonly MODIFY_OTHER_KEYS_REGEX = /\x1b\[>4;(\d+)m/;
diff --git a/packages/cli/src/utils/terminalTheme.ts b/packages/cli/src/utils/terminalTheme.ts
index 7707d24a8c..72315d3fa5 100644
--- a/packages/cli/src/utils/terminalTheme.ts
+++ b/packages/cli/src/utils/terminalTheme.ts
@@ -54,18 +54,17 @@ export async function setupTerminalAndTheme(
}
config.setTerminalBackground(terminalBackground);
+ themeManager.setTerminalBackground(terminalBackground);
if (terminalBackground !== undefined) {
const currentTheme = themeManager.getActiveTheme();
- if (currentTheme.type !== 'ansi' && currentTheme.type !== 'custom') {
+ if (!themeManager.isThemeCompatible(currentTheme, terminalBackground)) {
const backgroundType =
getThemeTypeFromBackgroundColor(terminalBackground);
- if (backgroundType && currentTheme.type !== backgroundType) {
- coreEvents.emitFeedback(
- 'warning',
- `Theme '${currentTheme.name}' (${currentTheme.type}) might look incorrect on your ${backgroundType} terminal background. Type /theme to change theme.`,
- );
- }
+ coreEvents.emitFeedback(
+ 'warning',
+ `Theme '${currentTheme.name}' (${currentTheme.type}) might look incorrect on your ${backgroundType} terminal background. Type /theme to change theme.`,
+ );
}
}