feat(ui): implement standardized selection background with interpolation

This commit is contained in:
Keith Guerin
2026-02-28 23:11:50 -08:00
parent 4e812232a8
commit e90f2c1339
18 changed files with 541 additions and 303 deletions

View File

@@ -25,6 +25,7 @@ describe('ColorsDisplay', () => {
primary: '#000000',
message: '#111111',
input: '#222222',
selection: '#333333',
diff: {
added: '#003300',
removed: '#330000',

View File

@@ -32,6 +32,30 @@ type ColorRow = StandardColorRow | GradientColorRow | BackgroundColorRow;
const VALUE_COLUMN_WIDTH = 10;
const COLOR_DESCRIPTIONS: Record<string, string> = {
'text.primary': 'Primary text color (uses terminal default if blank)',
'text.secondary': 'Secondary/dimmed text color',
'text.link': 'Hyperlink and highlighting color',
'text.accent': 'Accent color for emphasis',
'text.response':
'Color for model response text (uses terminal default if blank)',
'background.primary': 'Main terminal background color',
'background.message': 'Subtle background for message blocks',
'background.input': 'Background for the input prompt',
'background.selection': 'Background highlight for selected/focused items',
'background.diff.added': 'Background for added lines in diffs',
'background.diff.removed': 'Background for removed lines in diffs',
'border.default': 'Standard border color',
'border.focused': 'Border color when an element is focused',
'ui.comment': 'Color for code comments and metadata',
'ui.symbol': 'Color for technical symbols and UI icons',
'ui.dark': 'Deeply dimmed color for subtle UI elements',
'ui.focus': 'Color for focused or selected UI elements (e.g. menu items)',
'status.error': 'Color for error messages and critical status',
'status.success': 'Color for success messages and positive status',
'status.warning': 'Color for warnings and cautionary status',
};
interface ColorsDisplayProps {
activeTheme: Theme;
}
@@ -179,20 +203,28 @@ export const ColorsDisplay: React.FC<ColorsDisplayProps> = ({
function renderStandardRow({ name, value }: StandardColorRow) {
const isHex = value.startsWith('#');
const displayColor = isHex ? value : theme.text.primary;
const description = COLOR_DESCRIPTIONS[name] || '';
return (
<Box key={name} flexDirection="row" paddingX={1}>
<Box width={VALUE_COLUMN_WIDTH}>
<Text color={displayColor}>{value || '(blank)'}</Text>
</Box>
<Box flexGrow={1}>
<Text color={displayColor}>{name}</Text>
<Box flexGrow={1} flexDirection="row">
<Box width="30%">
<Text color={displayColor}>{name}</Text>
</Box>
<Box flexGrow={1} paddingLeft={1}>
<Text color={theme.text.secondary}>{description}</Text>
</Box>
</Box>
</Box>
);
}
function renderGradientRow({ name, value }: GradientColorRow) {
const description = COLOR_DESCRIPTIONS[name] || '';
return (
<Box key={name} flexDirection="row" paddingX={1}>
<Box width={VALUE_COLUMN_WIDTH} flexDirection="column">
@@ -202,16 +234,23 @@ function renderGradientRow({ name, value }: GradientColorRow) {
</Text>
))}
</Box>
<Box flexGrow={1}>
<Gradient colors={value}>
<Text>{name}</Text>
</Gradient>
<Box flexGrow={1} flexDirection="row">
<Box width="30%">
<Gradient colors={value}>
<Text>{name}</Text>
</Gradient>
</Box>
<Box flexGrow={1} paddingLeft={1}>
<Text color={theme.text.secondary}>{description}</Text>
</Box>
</Box>
</Box>
);
}
function renderBackgroundRow({ name, value }: BackgroundColorRow) {
const description = COLOR_DESCRIPTIONS[name] || '';
return (
<Box key={name} flexDirection="row" paddingX={1}>
<Box
@@ -224,8 +263,13 @@ function renderBackgroundRow({ name, value }: BackgroundColorRow) {
{value || 'default'}
</Text>
</Box>
<Box flexGrow={1} paddingLeft={1}>
<Text color={theme.text.primary}>{name}</Text>
<Box flexGrow={1} flexDirection="row" paddingLeft={1}>
<Box width="30%">
<Text color={theme.text.primary}>{name}</Text>
</Box>
<Box flexGrow={1} paddingLeft={1}>
<Text color={theme.text.secondary}>{description}</Text>
</Box>
</Box>
</Box>
);

View File

@@ -98,6 +98,7 @@ describe('<Header />', () => {
primary: '',
message: '',
input: '',
selection: '',
diff: { added: '', removed: '' },
},
border: {

View File

@@ -484,7 +484,10 @@ const SessionItem = ({
));
return (
<Box flexDirection="row">
<Box
flexDirection="row"
backgroundColor={isActive ? theme.background.selection : undefined}
>
<Text color={textColor()} dimColor={isDisabled}>
{prefix}
</Text>

View File

@@ -97,7 +97,11 @@ export function SuggestionsDisplay({
);
return (
<Box key={`${suggestion.value}-${originalIndex}`} flexDirection="row">
<Box
key={`${suggestion.value}-${originalIndex}`}
flexDirection="row"
backgroundColor={isActive ? theme.background.selection : undefined}
>
<Box
{...(mode === 'slash'
? { width: commandColumnWidth, flexShrink: 0 as const }

View File

@@ -18,43 +18,68 @@ exports[`Initial Theme Selection > should default to a dark theme when terminal
│ 11. Ayu Light │ │ │
│ 12. Default Light └─────────────────────────────────────────────────┘ │
│ ▼ │
│ ╭────────────────────────────────────────────────╮
│ │ DEVELOPER TOOLS (Not visible to users) │
│ │ │
│ │ How do colors get applied? │
│ │ • Hex: Rendered exactly by modern terminals. │
│ │ Not overridden by app themes. │
│ │ • Blank: Uses your terminal's default │
│ │ foreground/background. │
│ │ • Compatibility: On older terminals, hex is │
│ │ approximated to the nearest ANSI color. │
│ │ • ANSI Names: 'red', 'green', etc. are
│ │ mapped to your terminal app's palette. │
│ │ │
│ │ Value Name │
│ │ #1E1E2E background.primary
│ │ #2a2b3c background.message
│ │ #313243 background.input
│ │ #28350B background.diff.added
│ │ #430000 background.diff.removed
│ │ (blank) text.primary
│ │ #6C7086 text.secondary
│ │ #89B4FA text.link
│ │ #CBA6F7 text.accent
│ │ (blank) text.response
│ │ #3d3f51 border.default
│ │ #89B4FA border.focused
│ │ #6C7086 ui.comment
│ │ #89DCEB ui.symbol
│ │ #3d3f51 ui.dark
│ │ #89B4FA ui.focus
│ │ #F38BA8 status.error
│ │ #A6E3A1 status.success
│ │ #F9E2AF status.warning
│ │ #4796E4 ui.gradient
│ │ #847ACE
│ │ #C3677F
╰────────────────────────────────────────────────╯
│ ╭────────────────────────────────────────────────╮ │
│ │ DEVELOPER TOOLS (Not visible to users) │ │
│ │ │ │
│ │ How do colors get applied? │ │
│ │ • Hex: Rendered exactly by modern terminals. │ │
│ │ Not overridden by app themes. │ │
│ │ • Blank: Uses your terminal's default │ │
│ │ foreground/background. │ │
│ │ • Compatibility: On older terminals, hex is │ │
│ │ approximated to the nearest ANSI color. │ │
│ │ • ANSI Names: 'red', 'green', etc. are mapped │ │
│ │ to your terminal app's palette. │ │
│ │ │ │
│ │ Value Name │ │
│ │ #1E1E backgroun Main terminal background │ │
│ │ d.primary color │ │
│ │ #2a2… backgroun Subtle background for │ │
│ │ d.message message blocks │ │
│ │ #313… backgroun Background for the input │ │
│ │ d.input prompt │ │
│ │ #39… background. Background highlight for │ │
│ │ selection selected/focused items │ │
│ │ #283… backgrou Background for added lines │ │
│ │ nd.diff. in diffs │ │
│ │ added │ │
│ │ #430… backgroun Background for removed │ │
│ │ d.diff.re lines in diffs │ │
│ │ moved │ │
│ │ (blank text.prim Primary text color (uses │ │
│ │ ) ary terminal default if blank) │ │
│ │ #6C7086 text.secon Secondary/dimmed text │ │
│ │ dary color │ │
│ │ #89B4FA text.link Hyperlink and highlighting │ │
│ │ color │ │
│ │ #CBA6F7 text.accen Accent color for │ │
│ │ t emphasis │ │
│ (blank) text.res Color for model response │
│ │ ponse text (uses terminal default │ │
│ │ if blank) │ │
│ │ #3d3f51 border.def Standard border color │ │
│ │ ault │ │
│ │ #89B4FAborder.fo Border color when an │ │
│ │ cused element is focused │ │
│ │ #6C7086ui.comme Color for code comments and │ │
│ │ nt metadata │ │
│ │ #89DCE ui.symbol Color for technical symbols │ │
│ │ B and UI icons │ │
│ │ #3d3f5 ui.dark Deeply dimmed color for │ │
│ │ 1 subtle UI elements │ │
│ │ #89B4F ui.focus Color for focused or │ │
│ │ A selected UI elements (e.g. │ │
│ │ menu items) │ │
│ │ #F38BA8status.err Color for error messages │ │
│ │ or and critical status │ │
│ │ #A6E3A1status.suc Color for success messages │ │
│ │ cess and positive status │ │
│ │ #F9E2A status.wa Color for warnings and │ │
│ │ F rning cautionary status │ │
│ │ #4796E4 ui.gradien │ │
│ │ #847ACE t │ │
│ │ #C3677F │ │
│ ╰─────────────────────────────────────────────────╯ │
│ │
│ (Use Enter to select, Tab to configure scope, Esc to close) │
│ │
@@ -80,43 +105,68 @@ exports[`Initial Theme Selection > should default to a light theme when terminal
│ 11. Default Dark (Incompatible) │ │ │
│ 12. Dracula Dark (Incompatible) └─────────────────────────────────────────────────┘ │
│ ▼ │
│ ╭────────────────────────────────────────────────╮
│ │ DEVELOPER TOOLS (Not visible to users) │
│ │ │
│ │ How do colors get applied? │
│ │ • Hex: Rendered exactly by modern terminals. │
│ │ Not overridden by app themes. │
│ │ • Blank: Uses your terminal's default │
│ │ foreground/background. │
│ │ • Compatibility: On older terminals, hex is │
│ │ approximated to the nearest ANSI color. │
│ │ • ANSI Names: 'red', 'green', etc. are
│ │ mapped to your terminal app's palette. │
│ │ │
│ │ Value Name │
│ │ #FAFAFA background.primary
│ │ #eaecee background.message
│ │ #e2e4e8 background.input
│ │ #C6EAD8 background.diff.added
│ │ #FFCCCC background.diff.removed
│ │ (blank) text.primary
│ │ #97a0b0 text.secondary
│ │ #3B82F6 text.link
│ │ #8B5CF6 text.accent
│ │ (blank) text.response
│ │ #d2d6dc border.default
│ │ #3B82F6 border.focused
│ │ #97a0b0 ui.comment
│ │ #06B6D4 ui.symbol
│ │ #d2d6dc ui.dark
│ │ #3B82F6 ui.focus
│ │ #DD4C4C status.error
│ │ #3CA84B status.success
│ │ #D5A40A status.warning
│ │ #4796E4 ui.gradient
│ │ #847ACE
│ │ #C3677F
╰────────────────────────────────────────────────╯
│ ╭────────────────────────────────────────────────╮ │
│ │ DEVELOPER TOOLS (Not visible to users) │ │
│ │ │ │
│ │ How do colors get applied? │ │
│ │ • Hex: Rendered exactly by modern terminals. │ │
│ │ Not overridden by app themes. │ │
│ │ • Blank: Uses your terminal's default │ │
│ │ foreground/background. │ │
│ │ • Compatibility: On older terminals, hex is │ │
│ │ approximated to the nearest ANSI color. │ │
│ │ • ANSI Names: 'red', 'green', etc. are mapped │ │
│ │ to your terminal app's palette. │ │
│ │ │ │
│ │ Value Name │ │
│ │ #FAFA backgroun Main terminal background │ │
│ │ d.primary color │ │
│ │ #eae… backgroun Subtle background for │ │
│ │ d.message message blocks │ │
│ │ #e2e… backgroun Background for the input │ │
│ │ d.input prompt │ │
│ │ #d4… background. Background highlight for │ │
│ │ selection selected/focused items │ │
│ │ #C6E… backgrou Background for added lines │ │
│ │ nd.diff. in diffs │ │
│ │ added │ │
│ │ #FFC… backgroun Background for removed │ │
│ │ d.diff.re lines in diffs │ │
│ │ moved │ │
│ │ (blank text.prim Primary text color (uses │ │
│ │ ) ary terminal default if blank) │ │
│ │ #97a0b0 text.secon Secondary/dimmed text │ │
│ │ dary color │ │
│ │ #3B82F6 text.link Hyperlink and highlighting │ │
│ │ color │ │
│ │ #8B5CF6 text.accen Accent color for │ │
│ │ t emphasis │ │
│ (blank) text.res Color for model response │
│ │ ponse text (uses terminal default │ │
│ │ if blank) │ │
│ │ #d2d6dc border.def Standard border color │ │
│ │ ault │ │
│ │ #3B82F6border.fo Border color when an │ │
│ │ cused element is focused │ │
│ │ #97a0b0ui.comme Color for code comments and │ │
│ │ nt metadata │ │
│ │ #06B6D ui.symbol Color for technical symbols │ │
│ │ 4 and UI icons │ │
│ │ #d2d6d ui.dark Deeply dimmed color for │ │
│ │ c subtle UI elements │ │
│ │ #3B82F ui.focus Color for focused or │ │
│ │ 6 selected UI elements (e.g. │ │
│ │ menu items) │ │
│ │ #DD4C4Cstatus.err Color for error messages │ │
│ │ or and critical status │ │
│ │ #3CA84Bstatus.suc Color for success messages │ │
│ │ cess and positive status │ │
│ │ #D5A40 status.wa Color for warnings and │ │
│ │ A rning cautionary status │ │
│ │ #4796E4 ui.gradien │ │
│ │ #847ACE t │ │
│ │ #C3677F │ │
│ ╰─────────────────────────────────────────────────╯ │
│ │
│ (Use Enter to select, Tab to configure scope, Esc to close) │
│ │
@@ -142,43 +192,68 @@ exports[`Initial Theme Selection > should use the theme from settings even if te
│ 11. Ayu Light │ │ │
│ 12. Default Light └─────────────────────────────────────────────────┘ │
│ ▼ │
│ ╭────────────────────────────────────────────────╮
│ │ DEVELOPER TOOLS (Not visible to users) │
│ │ │
│ │ How do colors get applied? │
│ │ • Hex: Rendered exactly by modern terminals. │
│ │ Not overridden by app themes. │
│ │ • Blank: Uses your terminal's default │
│ │ foreground/background. │
│ │ • Compatibility: On older terminals, hex is │
│ │ approximated to the nearest ANSI color. │
│ │ • ANSI Names: 'red', 'green', etc. are
│ │ mapped to your terminal app's palette. │
│ │ │
│ │ Value Name │
│ │ #1E1E2E background.primary
│ │ #2a2b3c background.message
│ │ #313243 background.input
│ │ #28350B background.diff.added
│ │ #430000 background.diff.removed
│ │ (blank) text.primary
│ │ #6C7086 text.secondary
│ │ #89B4FA text.link
│ │ #CBA6F7 text.accent
│ │ (blank) text.response
│ │ #3d3f51 border.default
│ │ #89B4FA border.focused
│ │ #6C7086 ui.comment
│ │ #89DCEB ui.symbol
│ │ #3d3f51 ui.dark
│ │ #89B4FA ui.focus
│ │ #F38BA8 status.error
│ │ #A6E3A1 status.success
│ │ #F9E2AF status.warning
│ │ #4796E4 ui.gradient
│ │ #847ACE
│ │ #C3677F
╰────────────────────────────────────────────────╯
│ ╭────────────────────────────────────────────────╮ │
│ │ DEVELOPER TOOLS (Not visible to users) │ │
│ │ │ │
│ │ How do colors get applied? │ │
│ │ • Hex: Rendered exactly by modern terminals. │ │
│ │ Not overridden by app themes. │ │
│ │ • Blank: Uses your terminal's default │ │
│ │ foreground/background. │ │
│ │ • Compatibility: On older terminals, hex is │ │
│ │ approximated to the nearest ANSI color. │ │
│ │ • ANSI Names: 'red', 'green', etc. are mapped │ │
│ │ to your terminal app's palette. │ │
│ │ │ │
│ │ Value Name │ │
│ │ #1E1E backgroun Main terminal background │ │
│ │ d.primary color │ │
│ │ #2a2… backgroun Subtle background for │ │
│ │ d.message message blocks │ │
│ │ #313… backgroun Background for the input │ │
│ │ d.input prompt │ │
│ │ #39… background. Background highlight for │ │
│ │ selection selected/focused items │ │
│ │ #283… backgrou Background for added lines │ │
│ │ nd.diff. in diffs │ │
│ │ added │ │
│ │ #430… backgroun Background for removed │ │
│ │ d.diff.re lines in diffs │ │
│ │ moved │ │
│ │ (blank text.prim Primary text color (uses │ │
│ │ ) ary terminal default if blank) │ │
│ │ #6C7086 text.secon Secondary/dimmed text │ │
│ │ dary color │ │
│ │ #89B4FA text.link Hyperlink and highlighting │ │
│ │ color │ │
│ │ #CBA6F7 text.accen Accent color for │ │
│ │ t emphasis │ │
│ (blank) text.res Color for model response │
│ │ ponse text (uses terminal default │ │
│ │ if blank) │ │
│ │ #3d3f51 border.def Standard border color │ │
│ │ ault │ │
│ │ #89B4FAborder.fo Border color when an │ │
│ │ cused element is focused │ │
│ │ #6C7086ui.comme Color for code comments and │ │
│ │ nt metadata │ │
│ │ #89DCE ui.symbol Color for technical symbols │ │
│ │ B and UI icons │ │
│ │ #3d3f5 ui.dark Deeply dimmed color for │ │
│ │ 1 subtle UI elements │ │
│ │ #89B4F ui.focus Color for focused or │ │
│ │ A selected UI elements (e.g. │ │
│ │ menu items) │ │
│ │ #F38BA8status.err Color for error messages │ │
│ │ or and critical status │ │
│ │ #A6E3A1status.suc Color for success messages │ │
│ │ cess and positive status │ │
│ │ #F9E2A status.wa Color for warnings and │ │
│ │ F rning cautionary status │ │
│ │ #4796E4 ui.gradien │ │
│ │ #847ACE t │ │
│ │ #C3677F │ │
│ ╰─────────────────────────────────────────────────╯ │
│ │
│ (Use Enter to select, Tab to configure scope, Esc to close) │
│ │
@@ -218,43 +293,66 @@ exports[`ThemeDialog Snapshots > should render correctly in theme selection mode
│ 11. Ayu Light │ │ │
│ 12. Default Light └─────────────────────────────────────────────────┘ │
│ ▼ │
│ ╭────────────────────────────────────────────────╮
│ │ DEVELOPER TOOLS (Not visible to users) │
│ │ │
│ │ How do colors get applied? │
│ │ • Hex: Rendered exactly by modern terminals. │
│ │ Not overridden by app themes. │
│ │ • Blank: Uses your terminal's default │
│ │ foreground/background. │
│ │ • Compatibility: On older terminals, hex is │
│ │ approximated to the nearest ANSI color. │
│ │ • ANSI Names: 'red', 'green', etc. are
│ │ mapped to your terminal app's palette. │
│ │ │
│ │ Value Name │
│ │ #1E1E2E background.primary
│ │ #2a2b3c background.message
│ │ #313243 background.input
│ │ #28350B background.diff.added
│ │ #430000 background.diff.removed
│ │ (blank) text.primary
│ │ #6C7086 text.secondary
│ │ #89B4FA text.link
│ │ #CBA6F7 text.accent
│ │ (blank) text.response │
│ │ #3d3f51 border.default
│ │ #89B4FA border.focused │
│ │ #6C7086 ui.comment
│ │ #6C7086 ui.symbol
│ │ #3d3f51 ui.dark
│ │ #89B4FA ui.focus
│ │ #F38BA8 status.error
│ │ #A6E3A1 status.success
│ │ #F9E2AF status.warning
│ │ #4796E4 ui.gradient
│ │ #847ACE
│ │ #C3677F
╰────────────────────────────────────────────────╯
│ ╭────────────────────────────────────────────────╮ │
│ │ DEVELOPER TOOLS (Not visible to users) │ │
│ │ │ │
│ │ How do colors get applied? │ │
│ │ • Hex: Rendered exactly by modern terminals. │ │
│ │ Not overridden by app themes. │ │
│ │ • Blank: Uses your terminal's default │ │
│ │ foreground/background. │ │
│ │ • Compatibility: On older terminals, hex is │ │
│ │ approximated to the nearest ANSI color. │ │
│ │ • ANSI Names: 'red', 'green', etc. are mapped │ │
│ │ to your terminal app's palette. │ │
│ │ │ │
│ │ Value Name │ │
│ │ #1E1E backgroun Main terminal background │ │
│ │ d.primary color │ │
│ │ #2a2… backgroun Subtle background for │ │
│ │ d.message message blocks │ │
│ │ #313… backgroun Background for the input │ │
│ │ d.input prompt │ │
│ │ #283… backgrou Background for added lines │ │
│ │ nd.diff. in diffs │ │
│ │ added │ │
│ │ #430… backgroun Background for removed │
│ │ d.diff.re lines in diffs │ │
│ │ moved │ │
│ │ (blank text.prim Primary text color (uses │ │
│ │ ) ary terminal default if blank) │ │
│ │ #6C7086 text.secon Secondary/dimmed text │ │
│ │ dary color │ │
│ │ #89B4FA text.link Hyperlink and highlighting │ │
│ │ color │ │
│ │ #CBA6F7 text.accen Accent color for │ │
│ │ t emphasis │ │
│ │ (blank) text.res Color for model response │ │
│ │ ponse text (uses terminal default │ │
│ if blank) │
│ │ #3d3f51 border.def Standard border color │ │
│ │ ault │ │
│ │ #89B4FAborder.fo Border color when an │ │
│ │ cused element is focused │ │
│ │ #6C7086ui.comme Color for code comments and │ │
│ │ nt metadata │ │
│ │ #6C708 ui.symbol Color for technical symbols │ │
│ │ 6 and UI icons │ │
│ │ #3d3f5 ui.dark Deeply dimmed color for │ │
│ │ 1 subtle UI elements │ │
│ │ #89B4F ui.focus Color for focused or │ │
│ │ A selected UI elements (e.g. │ │
│ │ menu items) │ │
│ │ #F38BA8status.err Color for error messages │ │
│ │ or and critical status │ │
│ │ #A6E3A1status.suc Color for success messages │ │
│ │ cess and positive status │ │
│ │ #F9E2A status.wa Color for warnings and │ │
│ │ F rning cautionary status │ │
│ │ #4796E4 ui.gradien │ │
│ │ #847ACE t │ │
│ │ #C3677F │ │
│ ╰─────────────────────────────────────────────────╯ │
│ │
│ (Use Enter to select, Tab to configure scope, Esc to close) │
│ │

View File

@@ -137,7 +137,13 @@ export function BaseSelectionList<
)}.`;
return (
<Box key={item.key} alignItems="flex-start">
<Box
key={item.key}
alignItems="flex-start"
backgroundColor={
isSelected ? theme.background.selection : undefined
}
>
{/* Radio button indicator */}
<Box minWidth={2} flexShrink={0}>
<Text

View File

@@ -514,7 +514,14 @@ export function BaseSettingsDialog({
return (
<React.Fragment key={item.key}>
<Box marginX={1} flexDirection="row" alignItems="flex-start">
<Box
marginX={1}
flexDirection="row"
alignItems="flex-start"
backgroundColor={
isActive ? theme.background.selection : undefined
}
>
<Box minWidth={2} flexShrink={0}>
<Text
color={isActive ? theme.ui.focus : theme.text.secondary}

View File

@@ -37,6 +37,7 @@ export const EXPAND_HINT_DURATION_MS = 5000;
export const DEFAULT_BACKGROUND_OPACITY = 0.16;
export const DEFAULT_INPUT_BACKGROUND_OPACITY = 0.24;
export const DEFAULT_SELECTION_OPACITY = 0.2;
export const DEFAULT_BORDER_OPACITY = 0.4;
export const KEYBOARD_SHORTCUTS_URL =

View File

@@ -23,6 +23,7 @@ const ansiColors: ColorsTheme = {
Comment: 'gray',
Gray: 'gray',
DarkGray: 'gray',
SelectionBackground: 'black',
GradientColors: ['cyan', 'green'],
};

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;

View File

@@ -26,6 +26,7 @@ const noColorColorsTheme: ColorsTheme = {
DarkGray: '',
InputBackground: '',
MessageBackground: '',
SelectionBackground: '',
};
const noColorSemanticColors: SemanticColors = {
@@ -40,6 +41,7 @@ const noColorSemanticColors: SemanticColors = {
primary: '',
message: '',
input: '',
selection: '',
diff: {
added: '',
removed: '',

View File

@@ -18,6 +18,7 @@ export interface SemanticColors {
primary: string;
message: string;
input: string;
selection: string;
diff: {
added: string;
removed: string;

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,11 @@ const semanticColors: SemanticColors = {
primary: '#002b36',
message: '#073642',
input: '#073642',
selection: interpolateColor(
'#002b36',
'#859900',
DEFAULT_SELECTION_OPACITY,
),
diff: {
added: '#00382f',
removed: '#3d0115',

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 solarizedLightColors: ColorsTheme = {
type: 'light',
@@ -38,6 +39,11 @@ const semanticColors: SemanticColors = {
primary: '#fdf6e3',
message: '#eee8d5',
input: '#eee8d5',
selection: interpolateColor(
'#fdf6e3',
'#859900',
DEFAULT_SELECTION_OPACITY,
),
diff: {
added: '#d7f2d7',
removed: '#f2d7d7',

View File

@@ -22,16 +22,16 @@ 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 {
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 +369,11 @@ class ThemeManager {
colors.Gray,
DEFAULT_BACKGROUND_OPACITY,
),
SelectionBackground: interpolateColor(
this.terminalBackground,
colors.AccentGreen,
DEFAULT_SELECTION_OPACITY,
),
};
} else {
this.cachedColors = colors;
@@ -402,6 +407,7 @@ class ThemeManager {
primary: this.terminalBackground,
message: colors.MessageBackground!,
input: colors.InputBackground!,
selection: colors.SelectionBackground!,
},
border: {
...semanticColors.border,

View File

@@ -32,6 +32,7 @@ describe('createCustomTheme', () => {
DiffRemoved: '#FF0000',
Comment: '#808080',
Gray: '#cccccc',
SelectionBackground: '#004000',
// DarkGray intentionally omitted to test fallback
};
@@ -103,6 +104,7 @@ describe('validateCustomTheme', () => {
DiffRemoved: '#FF0000',
Comment: '#808080',
Gray: '#808080',
SelectionBackground: '#004000',
};
it('should return isValid: true for a valid theme', () => {
@@ -153,6 +155,7 @@ describe('themeManager.loadCustomThemes', () => {
AccentRed: '#F00',
Comment: '#888',
Gray: '#888',
SelectionBackground: '#040',
};
it('should use values from DEFAULT_THEME when DiffAdded and DiffRemoved are not provided', () => {

View File

@@ -18,8 +18,150 @@ 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 +185,7 @@ export interface ColorsTheme {
DarkGray: string;
InputBackground?: string;
MessageBackground?: string;
SelectionBackground?: string;
GradientColors?: string[];
}
@@ -72,6 +215,11 @@ export const lightTheme: ColorsTheme = {
'#97a0b0',
DEFAULT_BACKGROUND_OPACITY,
),
SelectionBackground: interpolateColor(
'#FAFAFA',
'#3CA84B',
DEFAULT_SELECTION_OPACITY,
),
GradientColors: ['#4796E4', '#847ACE', '#C3677F'],
};
@@ -101,6 +249,11 @@ export const darkTheme: ColorsTheme = {
'#6C7086',
DEFAULT_BACKGROUND_OPACITY,
),
SelectionBackground: interpolateColor(
'#1E1E2E',
'#A6E3A1',
DEFAULT_SELECTION_OPACITY,
),
GradientColors: ['#4796E4', '#847ACE', '#C3677F'],
};
@@ -122,6 +275,7 @@ export const ansiTheme: ColorsTheme = {
DarkGray: 'gray',
InputBackground: 'black',
MessageBackground: 'black',
SelectionBackground: 'black',
};
export class Theme {
@@ -173,6 +327,13 @@ export class Theme {
this.colors.Gray,
DEFAULT_INPUT_BACKGROUND_OPACITY,
),
selection:
this.colors.SelectionBackground ??
interpolateColor(
this.colors.Background,
this.colors.AccentGreen,
DEFAULT_SELECTION_OPACITY,
),
diff: {
added: this.colors.DiffAdded,
removed: this.colors.DiffRemoved,
@@ -295,6 +456,11 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
customTheme.text?.secondary ?? customTheme.Gray ?? '',
DEFAULT_BACKGROUND_OPACITY,
),
SelectionBackground: interpolateColor(
customTheme.background?.primary ?? customTheme.Background ?? '',
customTheme.status?.success ?? customTheme.AccentGreen ?? '#3CA84B', // Fallback to a default green if not found
DEFAULT_SELECTION_OPACITY,
),
GradientColors: customTheme.ui?.gradient ?? customTheme.GradientColors,
};
@@ -451,6 +617,7 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
primary: customTheme.background?.primary ?? colors.Background,
message: colors.MessageBackground!,
input: colors.InputBackground!,
selection: colors.SelectionBackground!,
diff: {
added: customTheme.background?.diff?.added ?? colors.DiffAdded,
removed: customTheme.background?.diff?.removed ?? colors.DiffRemoved,