feat(ui): standardize semantic focus colors and enhance history visibility (#20745)

Co-authored-by: jacob314 <jacob314@gmail.com>
This commit is contained in:
Keith Guerin
2026-03-03 16:10:09 -08:00
committed by GitHub
parent 75737c1b44
commit d25088956d
70 changed files with 1427 additions and 406 deletions
+1
View File
@@ -23,6 +23,7 @@ const ansiColors: ColorsTheme = {
Comment: 'gray',
Gray: 'gray',
DarkGray: 'gray',
FocusBackground: 'black',
GradientColors: ['cyan', 'green'],
};
+18 -137
View File
@@ -4,38 +4,25 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { debugLogger } from '@google/gemini-cli-core';
import tinygradient from 'tinygradient';
import tinycolor from 'tinycolor2';
import {
resolveColor,
interpolateColor,
getThemeTypeFromBackgroundColor,
INK_SUPPORTED_NAMES,
INK_NAME_TO_HEX_MAP,
getLuminance,
CSS_NAME_TO_HEX_MAP,
} from './theme.js';
// Define the set of Ink's named colors for quick lookup
export const INK_SUPPORTED_NAMES = new Set([
'black',
'red',
'green',
'yellow',
'blue',
'cyan',
'magenta',
'white',
'gray',
'grey',
'blackbright',
'redbright',
'greenbright',
'yellowbright',
'bluebright',
'cyanbright',
'magentabright',
'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}`]),
);
export {
resolveColor,
interpolateColor,
getThemeTypeFromBackgroundColor,
INK_SUPPORTED_NAMES,
INK_NAME_TO_HEX_MAP,
getLuminance,
CSS_NAME_TO_HEX_MAP,
};
/**
* Checks if a color string is valid (hex, Ink-supported color name, or CSS color name).
@@ -66,45 +53,6 @@ export function isValidColor(color: string): boolean {
return false;
}
/**
* Resolves a CSS color value (name or hex) into an Ink-compatible color string.
* @param colorValue The raw color string (e.g., 'blue', '#ff0000', 'darkkhaki').
* @returns An Ink-compatible color string (hex or name), or undefined if not resolvable.
*/
export function resolveColor(colorValue: string): string | undefined {
const lowerColor = colorValue.toLowerCase();
// 1. Check if it's already a hex code and valid
if (lowerColor.startsWith('#')) {
if (/^#[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$/.test(colorValue)) {
return lowerColor;
} else {
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)
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
if (CSS_NAME_TO_HEX_MAP[lowerColor]) {
return CSS_NAME_TO_HEX_MAP[lowerColor]; // Use mapped hex
}
// 4. Could not resolve
debugLogger.warn(
`[ColorUtils] Could not resolve color "${colorValue}" to an Ink-compatible format.`,
);
return undefined;
}
/**
* Returns a "safe" background color to use in low-color terminals if the
* terminal background is a standard black or white.
@@ -132,73 +80,6 @@ export function getSafeLowColorBackground(
return undefined;
}
export function interpolateColor(
color1: string,
color2: string,
factor: number,
) {
if (factor <= 0 && color1) {
return color1;
}
if (factor >= 1 && color2) {
return color2;
}
if (!color1 || !color2) {
return '';
}
const gradient = tinygradient(color1, color2);
const color = gradient.rgbAt(factor);
return color.toHexString();
}
export function getThemeTypeFromBackgroundColor(
backgroundColor: string | undefined,
): 'light' | 'dark' | undefined {
if (!backgroundColor) {
return undefined;
}
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 color Color string (hex or Ink-supported name)
* @returns Luminance value (0-255)
*/
export function getLuminance(color: string): number {
const resolved = color.toLowerCase();
const hex = INK_NAME_TO_HEX_MAP[resolved] || resolved;
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
// is ambiguous (near the midpoint).
export const LIGHT_THEME_LUMINANCE_THRESHOLD = 140;
@@ -23,6 +23,7 @@ const githubLightColors: ColorsTheme = {
Comment: '#998',
Gray: '#999',
DarkGray: interpolateColor('#999', '#f8f8f8', 0.5),
FocusColor: '#458', // AccentBlue for GitHub branding
GradientColors: ['#458', '#008080'],
};
+1
View File
@@ -23,6 +23,7 @@ const holidayColors: ColorsTheme = {
Comment: '#8FBC8F',
Gray: '#D7F5D3',
DarkGray: interpolateColor('#D7F5D3', '#151B18', 0.5),
FocusColor: '#33F9FF', // AccentCyan for neon pop
GradientColors: ['#FF0000', '#FFFFFF', '#008000'],
};
+4 -1
View File
@@ -26,6 +26,7 @@ const noColorColorsTheme: ColorsTheme = {
DarkGray: '',
InputBackground: '',
MessageBackground: '',
FocusBackground: '',
};
const noColorSemanticColors: SemanticColors = {
@@ -40,6 +41,7 @@ const noColorSemanticColors: SemanticColors = {
primary: '',
message: '',
input: '',
focus: '',
diff: {
added: '',
removed: '',
@@ -47,12 +49,13 @@ const noColorSemanticColors: SemanticColors = {
},
border: {
default: '',
focused: '',
},
ui: {
comment: '',
symbol: '',
active: '',
dark: '',
focus: '',
gradient: [],
},
status: {
@@ -18,6 +18,7 @@ export interface SemanticColors {
primary: string;
message: string;
input: string;
focus: string;
diff: {
added: string;
removed: string;
@@ -25,12 +26,13 @@ export interface SemanticColors {
};
border: {
default: string;
focused: string;
};
ui: {
comment: string;
symbol: string;
active: string;
dark: string;
focus: string;
gradient: string[] | undefined;
};
status: {
@@ -52,6 +54,7 @@ export const lightSemanticColors: SemanticColors = {
primary: lightTheme.Background,
message: lightTheme.MessageBackground!,
input: lightTheme.InputBackground!,
focus: lightTheme.FocusBackground!,
diff: {
added: lightTheme.DiffAdded,
removed: lightTheme.DiffRemoved,
@@ -59,12 +62,13 @@ export const lightSemanticColors: SemanticColors = {
},
border: {
default: lightTheme.DarkGray,
focused: lightTheme.AccentBlue,
},
ui: {
comment: lightTheme.Comment,
symbol: lightTheme.Gray,
active: lightTheme.AccentBlue,
dark: lightTheme.DarkGray,
focus: lightTheme.AccentGreen,
gradient: lightTheme.GradientColors,
},
status: {
@@ -86,6 +90,7 @@ export const darkSemanticColors: SemanticColors = {
primary: darkTheme.Background,
message: darkTheme.MessageBackground!,
input: darkTheme.InputBackground!,
focus: darkTheme.FocusBackground!,
diff: {
added: darkTheme.DiffAdded,
removed: darkTheme.DiffRemoved,
@@ -93,12 +98,13 @@ export const darkSemanticColors: SemanticColors = {
},
border: {
default: darkTheme.DarkGray,
focused: darkTheme.AccentBlue,
},
ui: {
comment: darkTheme.Comment,
symbol: darkTheme.Gray,
active: darkTheme.AccentBlue,
dark: darkTheme.DarkGray,
focus: darkTheme.AccentGreen,
gradient: darkTheme.GradientColors,
},
status: {
+6 -3
View File
@@ -4,8 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { type ColorsTheme, Theme } from './theme.js';
import { type ColorsTheme, Theme, interpolateColor } from './theme.js';
import { type SemanticColors } from './semantic-tokens.js';
import { DEFAULT_SELECTION_OPACITY } from '../constants.js';
const solarizedDarkColors: ColorsTheme = {
type: 'dark',
@@ -38,6 +39,7 @@ const semanticColors: SemanticColors = {
primary: '#002b36',
message: '#073642',
input: '#073642',
focus: interpolateColor('#002b36', '#859900', DEFAULT_SELECTION_OPACITY),
diff: {
added: '#00382f',
removed: '#3d0115',
@@ -45,13 +47,14 @@ const semanticColors: SemanticColors = {
},
border: {
default: '#073642',
focused: '#586e75',
},
ui: {
comment: '#586e75',
symbol: '#93a1a1',
active: '#268bd2',
dark: '#073642',
gradient: ['#268bd2', '#2aa198'],
focus: '#859900',
gradient: ['#268bd2', '#2aa198', '#859900'],
},
status: {
success: '#859900',
@@ -4,8 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { type ColorsTheme, Theme } from './theme.js';
import { type ColorsTheme, Theme, interpolateColor } from './theme.js';
import { type SemanticColors } from './semantic-tokens.js';
import { DEFAULT_SELECTION_OPACITY } from '../constants.js';
const solarizedLightColors: ColorsTheme = {
type: 'light',
@@ -38,6 +39,7 @@ const semanticColors: SemanticColors = {
primary: '#fdf6e3',
message: '#eee8d5',
input: '#eee8d5',
focus: interpolateColor('#fdf6e3', '#859900', DEFAULT_SELECTION_OPACITY),
diff: {
added: '#d7f2d7',
removed: '#f2d7d7',
@@ -45,13 +47,14 @@ const semanticColors: SemanticColors = {
},
border: {
default: '#eee8d5',
focused: '#93a1a1',
},
ui: {
comment: '#93a1a1',
symbol: '#586e75',
active: '#268bd2',
dark: '#eee8d5',
gradient: ['#268bd2', '#2aa198'],
focus: '#859900',
gradient: ['#268bd2', '#2aa198', '#859900'],
},
status: {
success: '#859900',
+12 -3
View File
@@ -22,16 +22,18 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
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 {
createCustomTheme,
validateCustomTheme,
interpolateColor,
getThemeTypeFromBackgroundColor,
resolveColor,
} from './color-utils.js';
} from './theme.js';
import type { SemanticColors } from './semantic-tokens.js';
import {
DEFAULT_BACKGROUND_OPACITY,
DEFAULT_INPUT_BACKGROUND_OPACITY,
DEFAULT_SELECTION_OPACITY,
DEFAULT_BORDER_OPACITY,
} from '../constants.js';
import { ANSI } from './ansi.js';
@@ -369,6 +371,11 @@ class ThemeManager {
colors.Gray,
DEFAULT_BACKGROUND_OPACITY,
),
FocusBackground: interpolateColor(
this.terminalBackground,
activeTheme.colors.FocusColor ?? activeTheme.colors.AccentGreen,
DEFAULT_SELECTION_OPACITY,
),
};
} else {
this.cachedColors = colors;
@@ -402,6 +409,7 @@ class ThemeManager {
primary: this.terminalBackground,
message: colors.MessageBackground!,
input: colors.InputBackground!,
focus: colors.FocusBackground!,
},
border: {
...semanticColors.border,
@@ -410,6 +418,7 @@ class ThemeManager {
ui: {
...semanticColors.ui,
dark: colors.DarkGray,
focus: colors.FocusColor ?? colors.AccentGreen,
},
};
} else {
+177 -13
View File
@@ -8,18 +8,153 @@ import type { CSSProperties } from 'react';
import type { SemanticColors } from './semantic-tokens.js';
import {
resolveColor,
interpolateColor,
getThemeTypeFromBackgroundColor,
} from './color-utils.js';
import type { CustomTheme } from '@google/gemini-cli-core';
import {
DEFAULT_BACKGROUND_OPACITY,
DEFAULT_INPUT_BACKGROUND_OPACITY,
DEFAULT_SELECTION_OPACITY,
DEFAULT_BORDER_OPACITY,
} from '../constants.js';
import tinygradient from 'tinygradient';
import tinycolor from 'tinycolor2';
// Define the set of Ink's named colors for quick lookup
export const INK_SUPPORTED_NAMES = new Set([
'black',
'red',
'green',
'yellow',
'blue',
'cyan',
'magenta',
'white',
'gray',
'grey',
'blackbright',
'redbright',
'greenbright',
'yellowbright',
'bluebright',
'cyanbright',
'magentabright',
'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}`]),
);
// 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 color Color string (hex or Ink-supported name)
* @returns Luminance value (0-255)
*/
export function getLuminance(color: string): number {
const resolved = color.toLowerCase();
const hex = INK_NAME_TO_HEX_MAP[resolved] || resolved;
const colorObj = tinycolor(hex);
if (!colorObj.isValid()) {
return 0;
}
// tinycolor returns 0-1, we need 0-255
return colorObj.getLuminance() * 255;
}
/**
* Resolves a CSS color value (name or hex) into an Ink-compatible color string.
* @param colorValue The raw color string (e.g., 'blue', '#ff0000', 'darkkhaki').
* @returns An Ink-compatible color string (hex or name), or undefined if not resolvable.
*/
export function resolveColor(colorValue: string): string | undefined {
const lowerColor = colorValue.toLowerCase();
// 1. Check if it's already a hex code and valid
if (lowerColor.startsWith('#')) {
if (/^#[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$/.test(colorValue)) {
return lowerColor;
} else {
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)
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
// We can't import CSS_NAME_TO_HEX_MAP here due to circular deps,
// but we can use tinycolor directly for named colors.
const colorObj = tinycolor(lowerColor);
if (colorObj.isValid()) {
return colorObj.toHexString();
}
// 4. Could not resolve
return undefined;
}
export function interpolateColor(
color1: string,
color2: string,
factor: number,
) {
if (factor <= 0 && color1) {
return color1;
}
if (factor >= 1 && color2) {
return color2;
}
if (!color1 || !color2) {
return '';
}
try {
const gradient = tinygradient(color1, color2);
const color = gradient.rgbAt(factor);
return color.toHexString();
} catch (_e) {
return color1;
}
}
export function getThemeTypeFromBackgroundColor(
backgroundColor: string | undefined,
): 'light' | 'dark' | undefined {
if (!backgroundColor) {
return undefined;
}
const resolvedColor = resolveColor(backgroundColor);
if (!resolvedColor) {
return undefined;
}
const luminance = getLuminance(resolvedColor);
return luminance > 128 ? 'light' : 'dark';
}
export type { CustomTheme };
@@ -43,6 +178,8 @@ export interface ColorsTheme {
DarkGray: string;
InputBackground?: string;
MessageBackground?: string;
FocusBackground?: string;
FocusColor?: string;
GradientColors?: string[];
}
@@ -70,7 +207,12 @@ export const lightTheme: ColorsTheme = {
MessageBackground: interpolateColor(
'#FAFAFA',
'#97a0b0',
DEFAULT_BACKGROUND_OPACITY,
DEFAULT_INPUT_BACKGROUND_OPACITY,
),
FocusBackground: interpolateColor(
'#FAFAFA',
'#3CA84B',
DEFAULT_SELECTION_OPACITY,
),
GradientColors: ['#4796E4', '#847ACE', '#C3677F'],
};
@@ -99,7 +241,12 @@ export const darkTheme: ColorsTheme = {
MessageBackground: interpolateColor(
'#1E1E2E',
'#6C7086',
DEFAULT_BACKGROUND_OPACITY,
DEFAULT_INPUT_BACKGROUND_OPACITY,
),
FocusBackground: interpolateColor(
'#1E1E2E',
'#A6E3A1',
DEFAULT_SELECTION_OPACITY,
),
GradientColors: ['#4796E4', '#847ACE', '#C3677F'],
};
@@ -122,6 +269,7 @@ export const ansiTheme: ColorsTheme = {
DarkGray: 'gray',
InputBackground: 'black',
MessageBackground: 'black',
FocusBackground: 'black',
};
export class Theme {
@@ -164,7 +312,7 @@ export class Theme {
interpolateColor(
this.colors.Background,
this.colors.Gray,
DEFAULT_BACKGROUND_OPACITY,
DEFAULT_INPUT_BACKGROUND_OPACITY,
),
input:
this.colors.InputBackground ??
@@ -173,6 +321,13 @@ export class Theme {
this.colors.Gray,
DEFAULT_INPUT_BACKGROUND_OPACITY,
),
focus:
this.colors.FocusBackground ??
interpolateColor(
this.colors.Background,
this.colors.FocusColor ?? this.colors.AccentGreen,
DEFAULT_SELECTION_OPACITY,
),
diff: {
added: this.colors.DiffAdded,
removed: this.colors.DiffRemoved,
@@ -180,12 +335,13 @@ export class Theme {
},
border: {
default: this.colors.DarkGray,
focused: this.colors.AccentBlue,
},
ui: {
comment: this.colors.Gray,
symbol: this.colors.AccentCyan,
active: this.colors.AccentBlue,
dark: this.colors.DarkGray,
focus: this.colors.FocusColor ?? this.colors.AccentGreen,
gradient: this.colors.GradientColors,
},
status: {
@@ -292,8 +448,14 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
MessageBackground: interpolateColor(
customTheme.background?.primary ?? customTheme.Background ?? '',
customTheme.text?.secondary ?? customTheme.Gray ?? '',
DEFAULT_BACKGROUND_OPACITY,
DEFAULT_INPUT_BACKGROUND_OPACITY,
),
FocusBackground: interpolateColor(
customTheme.background?.primary ?? customTheme.Background ?? '',
customTheme.status?.success ?? customTheme.AccentGreen ?? '#3CA84B', // Fallback to a default green if not found
DEFAULT_SELECTION_OPACITY,
),
FocusColor: customTheme.ui?.focus ?? customTheme.AccentGreen,
GradientColors: customTheme.ui?.gradient ?? customTheme.GradientColors,
};
@@ -450,6 +612,7 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
primary: customTheme.background?.primary ?? colors.Background,
message: colors.MessageBackground!,
input: colors.InputBackground!,
focus: colors.FocusBackground!,
diff: {
added: customTheme.background?.diff?.added ?? colors.DiffAdded,
removed: customTheme.background?.diff?.removed ?? colors.DiffRemoved,
@@ -457,12 +620,13 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
},
border: {
default: colors.DarkGray,
focused: customTheme.border?.focused ?? colors.AccentBlue,
},
ui: {
comment: customTheme.ui?.comment ?? colors.Comment,
symbol: customTheme.ui?.symbol ?? colors.Gray,
active: customTheme.ui?.active ?? colors.AccentBlue,
dark: colors.DarkGray,
focus: colors.FocusColor ?? colors.AccentGreen,
gradient: customTheme.ui?.gradient ?? colors.GradientColors,
},
status: {
+1
View File
@@ -23,6 +23,7 @@ const xcodeColors: ColorsTheme = {
Comment: '#007400',
Gray: '#c0c0c0',
DarkGray: interpolateColor('#c0c0c0', '#fff', 0.5),
FocusColor: '#1c00cf', // AccentBlue for more vibrance
GradientColors: ['#1c00cf', '#007400'],
};