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.`, + ); } }