diff --git a/package-lock.json b/package-lock.json index 720a6534b1..82fe57f61e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17281,6 +17281,7 @@ "strip-ansi": "^7.1.0", "strip-json-comments": "^3.1.1", "tar": "^7.5.1", + "tinygradient": "^1.1.5", "undici": "^7.10.0", "wrap-ansi": "9.0.2", "yargs": "^17.7.2", diff --git a/packages/cli/package.json b/packages/cli/package.json index 956f30ed39..dc8dc52a5b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -58,6 +58,7 @@ "strip-ansi": "^7.1.0", "strip-json-comments": "^3.1.1", "tar": "^7.5.1", + "tinygradient": "^1.1.5", "undici": "^7.10.0", "wrap-ansi": "9.0.2", "yargs": "^17.7.2", diff --git a/packages/cli/src/ui/colors.ts b/packages/cli/src/ui/colors.ts index a20a990973..87ec04b730 100644 --- a/packages/cli/src/ui/colors.ts +++ b/packages/cli/src/ui/colors.ts @@ -50,6 +50,9 @@ export const Colors: ColorsTheme = { get Gray() { return themeManager.getActiveTheme().colors.Gray; }, + get DarkGray() { + return themeManager.getActiveTheme().colors.DarkGray; + }, get GradientColors() { return themeManager.getActiveTheme().colors.GradientColors; }, diff --git a/packages/cli/src/ui/themes/ansi-light.ts b/packages/cli/src/ui/themes/ansi-light.ts index 8ccb65bd96..95807064e2 100644 --- a/packages/cli/src/ui/themes/ansi-light.ts +++ b/packages/cli/src/ui/themes/ansi-light.ts @@ -22,6 +22,7 @@ const ansiLightColors: ColorsTheme = { DiffRemoved: '#FFE5E5', Comment: 'gray', Gray: 'gray', + DarkGray: 'gray', GradientColors: ['blue', 'green'], }; diff --git a/packages/cli/src/ui/themes/ansi.ts b/packages/cli/src/ui/themes/ansi.ts index 21644813a4..335c9abcfc 100644 --- a/packages/cli/src/ui/themes/ansi.ts +++ b/packages/cli/src/ui/themes/ansi.ts @@ -22,6 +22,7 @@ const ansiColors: ColorsTheme = { DiffRemoved: '#4D0000', Comment: 'gray', Gray: 'gray', + DarkGray: 'gray', GradientColors: ['cyan', 'green'], }; diff --git a/packages/cli/src/ui/themes/atom-one-dark.ts b/packages/cli/src/ui/themes/atom-one-dark.ts index 5545971e09..5217a8bf30 100644 --- a/packages/cli/src/ui/themes/atom-one-dark.ts +++ b/packages/cli/src/ui/themes/atom-one-dark.ts @@ -5,6 +5,7 @@ */ import { type ColorsTheme, Theme } from './theme.js'; +import { interpolateColor } from './color-utils.js'; const atomOneDarkColors: ColorsTheme = { type: 'dark', @@ -21,6 +22,7 @@ const atomOneDarkColors: ColorsTheme = { DiffRemoved: '#562B2F', Comment: '#5c6370', Gray: '#5c6370', + DarkGray: interpolateColor('#5c6370', '#282c34', 0.5), GradientColors: ['#61aeee', '#98c379'], }; diff --git a/packages/cli/src/ui/themes/ayu-light.ts b/packages/cli/src/ui/themes/ayu-light.ts index 8410cfb270..393ed44ba6 100644 --- a/packages/cli/src/ui/themes/ayu-light.ts +++ b/packages/cli/src/ui/themes/ayu-light.ts @@ -5,6 +5,7 @@ */ import { type ColorsTheme, Theme } from './theme.js'; +import { interpolateColor } from './color-utils.js'; const ayuLightColors: ColorsTheme = { type: 'light', @@ -21,6 +22,7 @@ const ayuLightColors: ColorsTheme = { DiffRemoved: '#FFCCCC', Comment: '#ABADB1', Gray: '#a6aaaf', + DarkGray: interpolateColor('#a6aaaf', '#f8f9fa', 0.5), GradientColors: ['#399ee6', '#86b300'], }; diff --git a/packages/cli/src/ui/themes/ayu.ts b/packages/cli/src/ui/themes/ayu.ts index 7a3bde778d..71798aacf2 100644 --- a/packages/cli/src/ui/themes/ayu.ts +++ b/packages/cli/src/ui/themes/ayu.ts @@ -5,6 +5,7 @@ */ import { type ColorsTheme, Theme } from './theme.js'; +import { interpolateColor } from './color-utils.js'; const ayuDarkColors: ColorsTheme = { type: 'dark', @@ -21,6 +22,7 @@ const ayuDarkColors: ColorsTheme = { DiffRemoved: '#3D1215', Comment: '#646A71', Gray: '#3D4149', + DarkGray: interpolateColor('#3D4149', '#0b0e14', 0.5), GradientColors: ['#FFB454', '#F26D78'], }; diff --git a/packages/cli/src/ui/themes/color-utils.test.ts b/packages/cli/src/ui/themes/color-utils.test.ts index dfb1be83c8..9d6a11698a 100644 --- a/packages/cli/src/ui/themes/color-utils.test.ts +++ b/packages/cli/src/ui/themes/color-utils.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect } from 'vitest'; import { isValidColor, resolveColor, + interpolateColor, CSS_NAME_TO_HEX_MAP, INK_SUPPORTED_NAMES, } from './color-utils.js'; @@ -218,4 +219,19 @@ describe('Color Utils', () => { } }); }); + + describe('interpolateColor', () => { + it('should interpolate between two colors', () => { + // Midpoint between black (#000000) and white (#ffffff) should be gray + expect(interpolateColor('#000000', '#ffffff', 0.5)).toBe('#7f7f7f'); + }); + + it('should return start color when factor is 0', () => { + expect(interpolateColor('#ff0000', '#0000ff', 0)).toBe('#ff0000'); + }); + + it('should return end color when factor is 1', () => { + expect(interpolateColor('#ff0000', '#0000ff', 1)).toBe('#0000ff'); + }); + }); }); diff --git a/packages/cli/src/ui/themes/color-utils.ts b/packages/cli/src/ui/themes/color-utils.ts index 703752fd9e..1f326a4840 100644 --- a/packages/cli/src/ui/themes/color-utils.ts +++ b/packages/cli/src/ui/themes/color-utils.ts @@ -5,6 +5,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 @@ -231,3 +232,13 @@ export function resolveColor(colorValue: string): string | undefined { ); return undefined; } + +export function interpolateColor( + color1: string, + color2: string, + factor: number, +) { + const gradient = tinygradient(color1, color2); + const color = gradient.rgbAt(factor); + return color.toHexString(); +} diff --git a/packages/cli/src/ui/themes/dracula.ts b/packages/cli/src/ui/themes/dracula.ts index 07261d5124..2cd2802c45 100644 --- a/packages/cli/src/ui/themes/dracula.ts +++ b/packages/cli/src/ui/themes/dracula.ts @@ -5,6 +5,7 @@ */ import { type ColorsTheme, Theme } from './theme.js'; +import { interpolateColor } from './color-utils.js'; const draculaColors: ColorsTheme = { type: 'dark', @@ -21,6 +22,7 @@ const draculaColors: ColorsTheme = { DiffRemoved: '#6e1818', Comment: '#6272a4', Gray: '#6272a4', + DarkGray: interpolateColor('#6272a4', '#282a36', 0.5), GradientColors: ['#ff79c6', '#8be9fd'], }; diff --git a/packages/cli/src/ui/themes/github-dark.ts b/packages/cli/src/ui/themes/github-dark.ts index 2e7b0a2eb8..28c14f598d 100644 --- a/packages/cli/src/ui/themes/github-dark.ts +++ b/packages/cli/src/ui/themes/github-dark.ts @@ -5,6 +5,7 @@ */ import { type ColorsTheme, Theme } from './theme.js'; +import { interpolateColor } from './color-utils.js'; const githubDarkColors: ColorsTheme = { type: 'dark', @@ -21,6 +22,7 @@ const githubDarkColors: ColorsTheme = { DiffRemoved: '#502125', Comment: '#6A737D', Gray: '#6A737D', + DarkGray: interpolateColor('#6A737D', '#24292e', 0.5), GradientColors: ['#79B8FF', '#85E89D'], }; diff --git a/packages/cli/src/ui/themes/github-light.ts b/packages/cli/src/ui/themes/github-light.ts index dcb4bbf006..264a9d7a88 100644 --- a/packages/cli/src/ui/themes/github-light.ts +++ b/packages/cli/src/ui/themes/github-light.ts @@ -5,6 +5,7 @@ */ import { type ColorsTheme, Theme } from './theme.js'; +import { interpolateColor } from './color-utils.js'; const githubLightColors: ColorsTheme = { type: 'light', @@ -21,6 +22,7 @@ const githubLightColors: ColorsTheme = { DiffRemoved: '#FFCCCC', Comment: '#998', Gray: '#999', + DarkGray: interpolateColor('#999', '#f8f8f8', 0.5), GradientColors: ['#458', '#008080'], }; diff --git a/packages/cli/src/ui/themes/googlecode.ts b/packages/cli/src/ui/themes/googlecode.ts index 27fd50e9af..1795451c91 100644 --- a/packages/cli/src/ui/themes/googlecode.ts +++ b/packages/cli/src/ui/themes/googlecode.ts @@ -5,6 +5,7 @@ */ import { type ColorsTheme, Theme, lightTheme } from './theme.js'; +import { interpolateColor } from './color-utils.js'; const googleCodeColors: ColorsTheme = { type: 'light', @@ -21,6 +22,7 @@ const googleCodeColors: ColorsTheme = { DiffRemoved: '#FEDEDE', Comment: '#5f6368', Gray: lightTheme.Gray, + DarkGray: interpolateColor(lightTheme.Gray, '#ffffff', 0.5), GradientColors: ['#066', '#606'], }; diff --git a/packages/cli/src/ui/themes/no-color.ts b/packages/cli/src/ui/themes/no-color.ts index 3e5efb5f94..9accca8851 100644 --- a/packages/cli/src/ui/themes/no-color.ts +++ b/packages/cli/src/ui/themes/no-color.ts @@ -23,6 +23,7 @@ const noColorColorsTheme: ColorsTheme = { DiffRemoved: '', Comment: '', Gray: '', + DarkGray: '', }; const noColorSemanticColors: SemanticColors = { @@ -46,6 +47,7 @@ const noColorSemanticColors: SemanticColors = { ui: { comment: '', symbol: '', + dark: '', gradient: [], }, status: { diff --git a/packages/cli/src/ui/themes/semantic-tokens.ts b/packages/cli/src/ui/themes/semantic-tokens.ts index 56430304e7..75059d2e0c 100644 --- a/packages/cli/src/ui/themes/semantic-tokens.ts +++ b/packages/cli/src/ui/themes/semantic-tokens.ts @@ -27,6 +27,7 @@ export interface SemanticColors { ui: { comment: string; symbol: string; + dark: string; gradient: string[] | undefined; }; status: { @@ -57,6 +58,7 @@ export const lightSemanticColors: SemanticColors = { ui: { comment: lightTheme.Comment, symbol: lightTheme.Gray, + dark: lightTheme.DarkGray, gradient: lightTheme.GradientColors, }, status: { @@ -87,6 +89,7 @@ export const darkSemanticColors: SemanticColors = { ui: { comment: darkTheme.Comment, symbol: darkTheme.Gray, + dark: darkTheme.DarkGray, gradient: darkTheme.GradientColors, }, status: { @@ -117,6 +120,7 @@ export const ansiSemanticColors: SemanticColors = { ui: { comment: ansiTheme.Comment, symbol: ansiTheme.Gray, + dark: ansiTheme.DarkGray, gradient: ansiTheme.GradientColors, }, status: { diff --git a/packages/cli/src/ui/themes/shades-of-purple.ts b/packages/cli/src/ui/themes/shades-of-purple.ts index 6e20240f91..7fc513a618 100644 --- a/packages/cli/src/ui/themes/shades-of-purple.ts +++ b/packages/cli/src/ui/themes/shades-of-purple.ts @@ -9,6 +9,7 @@ * @author Ahmad Awais */ import { type ColorsTheme, Theme } from './theme.js'; +import { interpolateColor } from './color-utils.js'; const shadesOfPurpleColors: ColorsTheme = { type: 'dark', @@ -26,6 +27,7 @@ const shadesOfPurpleColors: ColorsTheme = { DiffRemoved: '#572244', Comment: '#B362FF', // Comment color (same as AccentPurple) Gray: '#726c86', // Gray color + DarkGray: interpolateColor('#726c86', '#2d2b57', 0.5), GradientColors: ['#4d21fc', '#847ace', '#ff628c'], }; diff --git a/packages/cli/src/ui/themes/theme.test.ts b/packages/cli/src/ui/themes/theme.test.ts index 1c06181cac..8a56dd9bae 100644 --- a/packages/cli/src/ui/themes/theme.test.ts +++ b/packages/cli/src/ui/themes/theme.test.ts @@ -8,9 +8,80 @@ import { describe, it, expect } from 'vitest'; import * as themeModule from './theme.js'; import { themeManager } from './theme-manager.js'; -const { validateCustomTheme } = themeModule; +const { validateCustomTheme, createCustomTheme } = themeModule; type CustomTheme = themeModule.CustomTheme; +describe('createCustomTheme', () => { + const baseTheme: CustomTheme = { + type: 'custom', + name: 'Test Theme', + Background: '#000000', + Foreground: '#ffffff', + LightBlue: '#ADD8E6', + AccentBlue: '#0000FF', + AccentPurple: '#800080', + AccentCyan: '#00FFFF', + AccentGreen: '#008000', + AccentYellow: '#FFFF00', + AccentRed: '#FF0000', + DiffAdded: '#00FF00', + DiffRemoved: '#FF0000', + Comment: '#808080', + Gray: '#cccccc', + // DarkGray intentionally omitted to test fallback + }; + + it('should interpolate DarkGray when not provided', () => { + const theme = createCustomTheme(baseTheme); + // Interpolate between Gray (#cccccc) and Background (#000000) at 0.5 + // #cccccc is RGB(204, 204, 204) + // #000000 is RGB(0, 0, 0) + // Midpoint is RGB(102, 102, 102) which is #666666 + expect(theme.colors.DarkGray).toBe('#666666'); + }); + + it('should use provided DarkGray', () => { + const theme = createCustomTheme({ + ...baseTheme, + DarkGray: '#123456', + }); + expect(theme.colors.DarkGray).toBe('#123456'); + }); + + it('should interpolate DarkGray when text.secondary is provided but DarkGray is not', () => { + const customTheme: CustomTheme = { + type: 'custom', + name: 'Test', + text: { + secondary: '#cccccc', // Gray source + }, + background: { + primary: '#000000', // Background source + }, + }; + const theme = createCustomTheme(customTheme); + // Should be interpolated between #cccccc and #000000 at 0.5 -> #666666 + expect(theme.colors.DarkGray).toBe('#666666'); + }); + + it('should prefer text.secondary over Gray for interpolation', () => { + const customTheme: CustomTheme = { + type: 'custom', + name: 'Test', + text: { + secondary: '#cccccc', // Should be used + }, + Gray: '#aaaaaa', // Should be ignored + background: { + primary: '#000000', + }, + }; + const theme = createCustomTheme(customTheme); + // Interpolate between #cccccc and #000000 -> #666666 + expect(theme.colors.DarkGray).toBe('#666666'); + }); +}); + describe('validateCustomTheme', () => { const validTheme: CustomTheme = { type: 'custom', diff --git a/packages/cli/src/ui/themes/theme.ts b/packages/cli/src/ui/themes/theme.ts index 99a7efa6b0..5dc5cfaae4 100644 --- a/packages/cli/src/ui/themes/theme.ts +++ b/packages/cli/src/ui/themes/theme.ts @@ -6,7 +6,7 @@ import type { CSSProperties } from 'react'; import type { SemanticColors } from './semantic-tokens.js'; -import { resolveColor } from './color-utils.js'; +import { resolveColor, interpolateColor } from './color-utils.js'; export type ThemeType = 'light' | 'dark' | 'ansi' | 'custom'; @@ -25,6 +25,7 @@ export interface ColorsTheme { DiffRemoved: string; Comment: string; Gray: string; + DarkGray: string; GradientColors?: string[]; } @@ -74,13 +75,14 @@ export interface CustomTheme { DiffRemoved?: string; Comment?: string; Gray?: string; + DarkGray?: string; GradientColors?: string[]; } export const lightTheme: ColorsTheme = { type: 'light', Background: '#FAFAFA', - Foreground: '', + Foreground: '#383A42', LightBlue: '#89BDCD', AccentBlue: '#3B82F6', AccentPurple: '#8B5CF6', @@ -92,13 +94,14 @@ export const lightTheme: ColorsTheme = { DiffRemoved: '#FFCCCC', Comment: '#008000', Gray: '#97a0b0', + DarkGray: interpolateColor('#97a0b0', '#FAFAFA', 0.5), GradientColors: ['#4796E4', '#847ACE', '#C3677F'], }; export const darkTheme: ColorsTheme = { type: 'dark', Background: '#1E1E2E', - Foreground: '', + Foreground: '#CDD6F4', LightBlue: '#ADD8E6', AccentBlue: '#89B4FA', AccentPurple: '#CBA6F7', @@ -110,6 +113,7 @@ export const darkTheme: ColorsTheme = { DiffRemoved: '#430000', Comment: '#6C7086', Gray: '#6C7086', + DarkGray: interpolateColor('#6C7086', '#1E1E2E', 0.5), GradientColors: ['#4796E4', '#847ACE', '#C3677F'], }; @@ -128,6 +132,7 @@ export const ansiTheme: ColorsTheme = { DiffRemoved: 'red', Comment: 'gray', Gray: 'gray', + DarkGray: 'gray', }; export class Theme { @@ -176,6 +181,7 @@ export class Theme { ui: { comment: this.colors.Gray, symbol: this.colors.AccentCyan, + dark: this.colors.DarkGray, gradient: this.colors.GradientColors, }, status: { @@ -267,6 +273,13 @@ export function createCustomTheme(customTheme: CustomTheme): Theme { customTheme.background?.diff?.removed ?? customTheme.DiffRemoved ?? '', Comment: customTheme.ui?.comment ?? customTheme.Comment ?? '', Gray: customTheme.text?.secondary ?? customTheme.Gray ?? '', + DarkGray: + customTheme.DarkGray ?? + interpolateColor( + customTheme.text?.secondary ?? customTheme.Gray ?? '', + customTheme.background?.primary ?? customTheme.Background ?? '', + 0.5, + ), GradientColors: customTheme.ui?.gradient ?? customTheme.GradientColors, }; @@ -429,6 +442,7 @@ export function createCustomTheme(customTheme: CustomTheme): Theme { ui: { comment: customTheme.ui?.comment ?? colors.Comment, symbol: customTheme.ui?.symbol ?? colors.Gray, + dark: colors.DarkGray, gradient: customTheme.ui?.gradient ?? colors.GradientColors, }, status: { diff --git a/packages/cli/src/ui/themes/xcode.ts b/packages/cli/src/ui/themes/xcode.ts index 690d238638..5d20f35c36 100644 --- a/packages/cli/src/ui/themes/xcode.ts +++ b/packages/cli/src/ui/themes/xcode.ts @@ -5,6 +5,7 @@ */ import { type ColorsTheme, Theme } from './theme.js'; +import { interpolateColor } from './color-utils.js'; const xcodeColors: ColorsTheme = { type: 'light', @@ -21,6 +22,7 @@ const xcodeColors: ColorsTheme = { DiffRemoved: '#FEDEDE', Comment: '#007400', Gray: '#c0c0c0', + DarkGray: interpolateColor('#c0c0c0', '#fff', 0.5), GradientColors: ['#1c00cf', '#007400'], };