ui(polish) blend background color with theme (#18802)

This commit is contained in:
Jacob Richman
2026-02-12 11:56:07 -08:00
committed by GitHub
parent db00c5abf3
commit 207ac6f2dc
20 changed files with 432 additions and 240 deletions

View File

@@ -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,

View File

@@ -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;

View File

@@ -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(
<InputPrompt {...props} />,
{
uiState: {
terminalBackgroundColor: 'black',
} as Partial<UIState>,
},
);
await waitFor(() => {

View File

@@ -222,7 +222,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
terminalWidth,
activePtyId,
history,
terminalBackgroundColor,
backgroundShells,
backgroundShellHeight,
shortcutsHelpVisible,
@@ -1352,7 +1351,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
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

View File

@@ -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<void>;
/** 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],

View File

@@ -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) │

View File

@@ -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<HalfLinePaddedBoxProps> = ({
backgroundOpacity,
children,
}) => {
const { terminalWidth, terminalBackgroundColor } = useUIState();
const terminalBg = terminalBackgroundColor || 'black';
const { terminalWidth } = useUIState();
const terminalBg = theme.background.primary || 'black';
const isLowColor = isLowColorDepth();

View File

@@ -29,6 +29,11 @@ vi.mock('ink', () => ({
useStdin: () => ({
stdin: mockStdin,
}),
useStdout: () => ({
stdout: {
write: vi.fn(),
},
}),
}));
const TestComponent = ({ onColor }: { onColor: (c: string) => void }) => {

View File

@@ -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<void>;
}
const TerminalContext = createContext<TerminalContextValue | undefined>(
@@ -38,6 +39,7 @@ export function useTerminalContext() {
export function TerminalProvider({ children }: { children: React.ReactNode }) {
const { stdin } = useStdin();
const { stdout } = useStdout();
const subscribers = useRef<Set<TerminalEventHandler>>(new Set()).current;
const bufferRef = useRef('');
@@ -55,6 +57,23 @@ export function TerminalProvider({ children }: { children: React.ReactNode }) {
[subscribers],
);
const queryTerminalBackground = useCallback(
async () =>
new Promise<void>((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 (
<TerminalContext.Provider value={{ subscribe, unsubscribe }}>
<TerminalContext.Provider
value={{ subscribe, unsubscribe, queryTerminalBackground }}
>
{children}
</TerminalContext.Provider>
);

View File

@@ -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<void>;
closeThemeDialog: () => void;
handleThemeHighlight: (themeName: string | undefined) => void;
handleAuthSelect: (

View File

@@ -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', () => ({

View File

@@ -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;
});

View File

@@ -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,
]);
}

View File

@@ -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<void>;
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 || {}),

View File

@@ -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<Record<string, string>> = {
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<Record<string, string>> = {
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

View File

@@ -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');
});
});
});

View File

@@ -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<string, Theme> = new Map();
private extensionThemes: Map<string, Theme> = new Map();
private fileThemes: Map<string, Theme> = 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[] {

View File

@@ -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/;