From 4cc6da84a155b3c1d4dd81ec6d24285c8d5b7b5e Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Wed, 29 Apr 2026 20:13:49 -0700 Subject: [PATCH] fix(tui): normalize legacy Terminal.app colors (#17695) Keep light Terminal.app TUI colors readable by normalizing non-banner theme tokens into ANSI256-safe buckets while preserving truecolor terminals. --- .../hermes-ink/src/ink/colorize.test.ts | 60 +++++ .../packages/hermes-ink/src/ink/colorize.ts | 55 ++++- .../src/ink/components/Text.test.ts | 22 +- .../hermes-ink/src/ink/components/Text.tsx | 24 +- ui-tui/src/__tests__/forceTruecolor.test.ts | 69 +++++- ui-tui/src/__tests__/theme.test.ts | 53 ++++- ui-tui/src/entry.tsx | 6 +- ui-tui/src/lib/forceTruecolor.ts | 38 ++-- ui-tui/src/theme.ts | 210 +++++++++++++++++- 9 files changed, 487 insertions(+), 50 deletions(-) create mode 100644 ui-tui/packages/hermes-ink/src/ink/colorize.test.ts diff --git a/ui-tui/packages/hermes-ink/src/ink/colorize.test.ts b/ui-tui/packages/hermes-ink/src/ink/colorize.test.ts new file mode 100644 index 0000000000..814b8d91e5 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/colorize.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' + +import { + CHALK_USES_RICH_EIGHT_BIT_DOWNGRADE, + richEightBitColorNumber, + shouldUseRichEightBitDowngradeForLegacyAppleTerminal +} from './colorize.js' + +describe('shouldUseRichEightBitDowngradeForLegacyAppleTerminal', () => { + it('memoizes the current process decision for render hot paths', () => { + expect(typeof CHALK_USES_RICH_EIGHT_BIT_DOWNGRADE).toBe('boolean') + }) + + it('uses Rich-compatible 256-color downgrade on legacy Apple Terminal', () => { + expect( + shouldUseRichEightBitDowngradeForLegacyAppleTerminal({ TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv, 2) + ).toBe(true) + }) + + it('normalizes Apple Terminal names before matching', () => { + expect( + shouldUseRichEightBitDowngradeForLegacyAppleTerminal({ TERM_PROGRAM: ' Apple_Terminal ' } as NodeJS.ProcessEnv, 2) + ).toBe(true) + }) + + it('does not rewrite when Apple Terminal advertises truecolor', () => { + expect( + shouldUseRichEightBitDowngradeForLegacyAppleTerminal( + { COLORTERM: 'truecolor', TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv, + 3 + ) + ).toBe(false) + }) + + it('does not override explicit color environment choices', () => { + expect( + shouldUseRichEightBitDowngradeForLegacyAppleTerminal( + { FORCE_COLOR: '2', TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv, + 2 + ) + ).toBe(false) + expect( + shouldUseRichEightBitDowngradeForLegacyAppleTerminal( + { HERMES_TUI_TRUECOLOR: '1', TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv, + 3 + ) + ).toBe(false) + }) +}) + +describe('richEightBitColorNumber', () => { + it('matches Rich downgrade output for default Hermes skin colors', () => { + expect(richEightBitColorNumber(0xff, 0xd7, 0x00)).toBe(220) + expect(richEightBitColorNumber(0xff, 0xbf, 0x00)).toBe(214) + expect(richEightBitColorNumber(0xcd, 0x7f, 0x32)).toBe(173) + expect(richEightBitColorNumber(0xb8, 0x86, 0x0b)).toBe(136) + expect(richEightBitColorNumber(0xff, 0xf8, 0xdc)).toBe(230) + }) +}) + diff --git a/ui-tui/packages/hermes-ink/src/ink/colorize.ts b/ui-tui/packages/hermes-ink/src/ink/colorize.ts index 2229f70a97..7a8a57a568 100644 --- a/ui-tui/packages/hermes-ink/src/ink/colorize.ts +++ b/ui-tui/packages/hermes-ink/src/ink/colorize.ts @@ -28,6 +28,39 @@ function boostChalkLevelForXtermJs(): boolean { return false } +export function shouldUseRichEightBitDowngradeForLegacyAppleTerminal( + env: NodeJS.ProcessEnv = process.env, + level = chalk.level +): boolean { + const termProgram = (env.TERM_PROGRAM ?? '').trim() + const truecolorOverride = /^(?:1|true|yes|on)$/i.test((env.HERMES_TUI_TRUECOLOR ?? '').trim()) + const advertisesTruecolor = /^(?:truecolor|24bit)$/i.test((env.COLORTERM ?? '').trim()) + + return termProgram === 'Apple_Terminal' && !truecolorOverride && !advertisesTruecolor && !('FORCE_COLOR' in env) && level === 2 +} + +export function richEightBitColorNumber(red: number, green: number, blue: number): number { + const rn = red / 255 + const gn = green / 255 + const bn = blue / 255 + const max = Math.max(rn, gn, bn) + const min = Math.min(rn, gn, bn) + const lightness = (max + min) / 2 + const saturation = max === min ? 0 : lightness > 0.5 ? (max - min) / (2 - max - min) : (max - min) / (max + min) + + if (saturation < 0.15) { + const gray = Math.round(lightness * 25) + + return gray === 0 ? 16 : gray === 25 ? 231 : 231 + gray + } + + const sixRed = red < 95 ? red / 95 : 1 + (red - 95) / 40 + const sixGreen = green < 95 ? green / 95 : 1 + (green - 95) / 40 + const sixBlue = blue < 95 ? blue / 95 : 1 + (blue - 95) / 40 + + return 16 + 36 * Math.round(sixRed) + 6 * Math.round(sixGreen) + Math.round(sixBlue) +} + /** * tmux parses truecolor SGR (\e[48;2;r;g;bm) into its cell buffer correctly, * but its client-side emitter only re-emits truecolor to the outer terminal if @@ -58,15 +91,17 @@ function clampChalkLevelForTmux(): boolean { } // Computed once at module load — terminal/tmux environment doesn't change mid-session. -// Order matters: boost first so the tmux clamp can re-clamp if tmux is running -// inside a VS Code terminal. Exported for debugging — tree-shaken if unused. +// Order matters: boost first; then tmux can still clamp RGB to 256. +// Exported for debugging — tree-shaken if unused. export const CHALK_BOOSTED_FOR_XTERMJS = boostChalkLevelForXtermJs() export const CHALK_CLAMPED_FOR_TMUX = clampChalkLevelForTmux() +export const CHALK_USES_RICH_EIGHT_BIT_DOWNGRADE = shouldUseRichEightBitDowngradeForLegacyAppleTerminal() export type ColorType = 'foreground' | 'background' const RGB_REGEX = /^rgb\(\s?(\d+),\s?(\d+),\s?(\d+)\s?\)$/ const ANSI_REGEX = /^ansi256\(\s?(\d+)\s?\)$/ +const HEX_REGEX = /^#[0-9a-fA-F]{6}$/ export const colorize = (str: string, color: string | undefined, type: ColorType): string => { if (!color) { @@ -128,6 +163,16 @@ export const colorize = (str: string, color: string | undefined, type: ColorType } if (color.startsWith('#')) { + if (HEX_REGEX.test(color) && CHALK_USES_RICH_EIGHT_BIT_DOWNGRADE) { + const value = Number.parseInt(color.slice(1), 16) + const red = (value >> 16) & 0xff + const green = (value >> 8) & 0xff + const blue = value & 0xff + const ansi = richEightBitColorNumber(red, green, blue) + + return type === 'foreground' ? chalk.ansi256(ansi)(str) : chalk.bgAnsi256(ansi)(str) + } + return type === 'foreground' ? chalk.hex(color)(str) : chalk.bgHex(color)(str) } @@ -154,6 +199,12 @@ export const colorize = (str: string, color: string | undefined, type: ColorType const secondValue = Number(matches[2]) const thirdValue = Number(matches[3]) + if (CHALK_USES_RICH_EIGHT_BIT_DOWNGRADE) { + const ansi = richEightBitColorNumber(firstValue, secondValue, thirdValue) + + return type === 'foreground' ? chalk.ansi256(ansi)(str) : chalk.bgAnsi256(ansi)(str) + } + return type === 'foreground' ? chalk.rgb(firstValue, secondValue, thirdValue)(str) : chalk.bgRgb(firstValue, secondValue, thirdValue)(str) diff --git a/ui-tui/packages/hermes-ink/src/ink/components/Text.test.ts b/ui-tui/packages/hermes-ink/src/ink/components/Text.test.ts index 9869189edd..50628d5380 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/Text.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/components/Text.test.ts @@ -1,18 +1,38 @@ import { describe, expect, it } from 'vitest' -import { shouldUseAnsiDim } from './Text.js' +import { dimColorFallback, shouldUseAnsiDim } from './Text.js' describe('shouldUseAnsiDim', () => { it('disables ANSI dim on VTE terminals by default', () => { expect(shouldUseAnsiDim({ VTE_VERSION: '7603' } as NodeJS.ProcessEnv)).toBe(false) }) + it('disables ANSI dim on Apple Terminal by default', () => { + expect(shouldUseAnsiDim({ TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBe(false) + }) + it('keeps ANSI dim enabled elsewhere by default', () => { expect(shouldUseAnsiDim({ TERM: 'xterm-256color' } as NodeJS.ProcessEnv)).toBe(true) }) it('honors explicit env override', () => { expect(shouldUseAnsiDim({ HERMES_TUI_DIM: '1', VTE_VERSION: '7603' } as NodeJS.ProcessEnv)).toBe(true) + expect(shouldUseAnsiDim({ HERMES_TUI_DIM: '1', TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBe(true) expect(shouldUseAnsiDim({ HERMES_TUI_DIM: '0' } as NodeJS.ProcessEnv)).toBe(false) }) }) + +describe('dimColorFallback', () => { + it('renders Apple Terminal dim as muted gray by default', () => { + expect(dimColorFallback({ TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBe('#6B7280') + }) + + it('normalizes Apple Terminal names before matching', () => { + expect(dimColorFallback({ TERM_PROGRAM: ' Apple_Terminal ' } as NodeJS.ProcessEnv)).toBe('#6B7280') + }) + + it('does not apply when dim is explicitly configured', () => { + expect(dimColorFallback({ HERMES_TUI_DIM: '1', TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBeUndefined() + expect(dimColorFallback({ HERMES_TUI_DIM: '0', TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBeUndefined() + }) +}) diff --git a/ui-tui/packages/hermes-ink/src/ink/components/Text.tsx b/ui-tui/packages/hermes-ink/src/ink/components/Text.tsx index d6b7fdccd5..4eb4bc7b96 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/Text.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/Text.tsx @@ -6,6 +6,7 @@ import type { Color, Styles } from '../styles.js' const ENV_ON_RE = /^(?:1|true|yes|on)$/i const ENV_OFF_RE = /^(?:0|false|no|off)$/i +const LEGACY_APPLE_DIM_COLOR: Color = '#6B7280' type BaseProps = { /** * Change text color. Accepts a raw color value (rgb, hex, ansi). @@ -76,9 +77,23 @@ export function shouldUseAnsiDim(env: NodeJS.ProcessEnv = process.env): boolean return false } + if ((env.TERM_PROGRAM ?? '').trim() === 'Apple_Terminal') { + return false + } + return !env.VTE_VERSION } +export function dimColorFallback(env: NodeJS.ProcessEnv = process.env): Color | undefined { + const override = (env.HERMES_TUI_DIM ?? '').trim() + + if (ENV_ON_RE.test(override) || ENV_OFF_RE.test(override)) { + return undefined + } + + return (env.TERM_PROGRAM ?? '').trim() === 'Apple_Terminal' ? LEGACY_APPLE_DIM_COLOR : undefined +} + const memoizedStylesForWrap: Record, Styles> = { wrap: { flexGrow: 0, @@ -161,6 +176,7 @@ export default function Text(t0: Props) { const inverse = t4 === undefined ? false : t4 const wrap = t5 === undefined ? 'wrap' : t5 const effectiveDim = dim && shouldUseAnsiDim() + const effectiveColor = dim && !effectiveDim ? (color ?? dimColorFallback()) : color if (children === undefined || children === null) { return null @@ -168,11 +184,11 @@ export default function Text(t0: Props) { let t6 - if ($[0] !== color) { - t6 = color && { - color + if ($[0] !== effectiveColor) { + t6 = effectiveColor && { + color: effectiveColor } - $[0] = color + $[0] = effectiveColor $[1] = t6 } else { t6 = $[1] diff --git a/ui-tui/src/__tests__/forceTruecolor.test.ts b/ui-tui/src/__tests__/forceTruecolor.test.ts index 7cbf46d2b6..4d97832815 100644 --- a/ui-tui/src/__tests__/forceTruecolor.test.ts +++ b/ui-tui/src/__tests__/forceTruecolor.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' -const ENV_KEYS = ['COLORTERM', 'FORCE_COLOR', 'HERMES_TUI_TRUECOLOR', 'NO_COLOR'] as const +const ENV_KEYS = ['COLORTERM', 'FORCE_COLOR', 'HERMES_TUI_TRUECOLOR', 'NO_COLOR', 'TERM', 'TERM_PROGRAM'] as const +let importId = 0 async function withCleanEnv(setup: () => void, body: () => Promise) { const saved: Record = {} @@ -25,11 +26,39 @@ async function withCleanEnv(setup: () => void, body: () => Promise) { } describe('forceTruecolor', () => { - it('sets COLORTERM=truecolor and FORCE_COLOR=3 when unset', async () => { + it('does not force truecolor by default', async () => { await withCleanEnv( () => {}, async () => { - await import('../lib/forceTruecolor.js?t=' + Date.now()) + await import('../lib/forceTruecolor.js?t=default-' + importId++) + expect(process.env.COLORTERM).toBeUndefined() + expect(process.env.FORCE_COLOR).toBeUndefined() + } + ) + }) + + it('does not infer truecolor from Apple Terminal on pre-Tahoe macOS', async () => { + await withCleanEnv( + () => { + process.env.TERM_PROGRAM = 'Apple_Terminal' + process.env.TERM = 'xterm-256color' + }, + async () => { + const mod = await import('../lib/forceTruecolor.js?t=apple-' + importId++) + expect(mod.shouldForceTruecolor({ TERM_PROGRAM: 'Apple_Terminal' })).toBe(false) + expect(process.env.COLORTERM).toBeUndefined() + expect(process.env.FORCE_COLOR).toBeUndefined() + } + ) + }) + + it('sets COLORTERM=truecolor and FORCE_COLOR=3 when explicitly enabled', async () => { + await withCleanEnv( + () => { + process.env.HERMES_TUI_TRUECOLOR = '1' + }, + async () => { + await import('../lib/forceTruecolor.js?t=enabled-' + importId++) expect(process.env.COLORTERM).toBe('truecolor') expect(process.env.FORCE_COLOR).toBe('3') } @@ -40,9 +69,10 @@ describe('forceTruecolor', () => { await withCleanEnv( () => { process.env.HERMES_TUI_TRUECOLOR = '0' + process.env.TERM_PROGRAM = 'Apple_Terminal' }, async () => { - await import('../lib/forceTruecolor.js?t=optout-' + Date.now()) + await import('../lib/forceTruecolor.js?t=optout-' + importId++) expect(process.env.COLORTERM).toBeUndefined() expect(process.env.FORCE_COLOR).toBeUndefined() } @@ -53,12 +83,41 @@ describe('forceTruecolor', () => { await withCleanEnv( () => { process.env.NO_COLOR = '1' + process.env.HERMES_TUI_TRUECOLOR = '1' }, async () => { - await import('../lib/forceTruecolor.js?t=no-color-' + Date.now()) + await import('../lib/forceTruecolor.js?t=no-color-' + importId++) expect(process.env.COLORTERM).toBeUndefined() expect(process.env.FORCE_COLOR).toBeUndefined() } ) }) + + it('respects existing FORCE_COLOR unless Hermes truecolor is explicit', async () => { + await withCleanEnv( + () => { + process.env.FORCE_COLOR = '' + }, + async () => { + const mod = await import('../lib/forceTruecolor.js?t=force-color-' + importId++) + expect(mod.shouldForceTruecolor(process.env)).toBe(false) + expect(process.env.COLORTERM).toBeUndefined() + expect(process.env.FORCE_COLOR).toBe('') + } + ) + }) + + it('lets explicit Hermes truecolor override existing FORCE_COLOR', async () => { + await withCleanEnv( + () => { + process.env.FORCE_COLOR = '0' + process.env.HERMES_TUI_TRUECOLOR = '1' + }, + async () => { + await import('../lib/forceTruecolor.js?t=explicit-force-' + importId++) + expect(process.env.COLORTERM).toBe('truecolor') + expect(process.env.FORCE_COLOR).toBe('3') + } + ) + }) }) diff --git a/ui-tui/src/__tests__/theme.test.ts b/ui-tui/src/__tests__/theme.test.ts index 888bd9142a..30a047df66 100644 --- a/ui-tui/src/__tests__/theme.test.ts +++ b/ui-tui/src/__tests__/theme.test.ts @@ -16,12 +16,13 @@ const RELEVANT_ENV = [ 'HERMES_TUI_THEME', 'HERMES_TUI_BACKGROUND', 'COLORFGBG', + 'COLORTERM', 'TERM_PROGRAM' ] as const -async function importThemeWithCleanEnv() { +async function importThemeWithEnv(env: Partial> = {}) { for (const key of RELEVANT_ENV) { - vi.stubEnv(key, '') + vi.stubEnv(key, env[key] ?? '') } vi.resetModules() @@ -29,6 +30,10 @@ async function importThemeWithCleanEnv() { return import('../theme.js') } +async function importThemeWithCleanEnv() { + return importThemeWithEnv() +} + afterEach(() => { vi.unstubAllEnvs() vi.resetModules() @@ -84,6 +89,12 @@ describe('detectLightMode', () => { expect(detectLightMode({})).toBe(false) }) + it('defaults Apple Terminal to light when no stronger signal is present', async () => { + const { detectLightMode } = await importThemeWithCleanEnv() + + expect(detectLightMode({ TERM_PROGRAM: 'Apple_Terminal' })).toBe(true) + }) + it('honors HERMES_TUI_LIGHT on/off', async () => { const { detectLightMode } = await importThemeWithCleanEnv() @@ -159,8 +170,8 @@ describe('detectLightMode', () => { it('treats COLORFGBG as authoritative when present so it dominates the TERM_PROGRAM allow-list', async () => { const { detectLightMode } = await importThemeWithCleanEnv() - // Inject a light-default allow-list so the precedence test is - // meaningful even though the production allow-list is empty. + // Injecting the allow-list keeps this precedence rule explicit even if + // production defaults change. const allowList = new Set(['Apple_Terminal']) // Sanity: the allow-list alone WOULD turn this terminal light. @@ -221,6 +232,40 @@ describe('fromSkin', () => { expect(fromSkin({}, {}).brand.icon).toBe(DEFAULT_THEME.brand.icon) }) + it('normalizes non-banner foregrounds on light Apple Terminal', async () => { + const { fromSkin } = await importThemeWithEnv({ TERM_PROGRAM: 'Apple_Terminal' }) + + const theme = fromSkin({ + banner_accent: '#FFBF00', + banner_border: '#CD7F32', + banner_dim: '#B8860B', + banner_text: '#FFF8DC', + banner_title: '#FFD700', + prompt: '#FFF8DC' + }, {}) + + expect(theme.color.primary).toBe('#FFD700') + expect(theme.color.accent).toBe('#FFBF00') + expect(theme.color.border).toBe('#CD7F32') + expect(theme.color.muted).toBe('ansi256(245)') + expect(theme.color.text).toBe('ansi256(136)') + expect(theme.color.prompt).toBe('ansi256(136)') + }) + + it('does not normalize light Apple Terminal when truecolor is advertised', async () => { + const { fromSkin } = await importThemeWithEnv({ COLORTERM: 'truecolor', TERM_PROGRAM: 'Apple_Terminal' }) + const theme = fromSkin({ banner_text: '#FFF8DC' }, {}) + + expect(theme.color.text).toBe('#FFF8DC') + }) + + it('normalizes Apple Terminal names before matching', async () => { + const { fromSkin } = await importThemeWithEnv({ TERM_PROGRAM: ' Apple_Terminal ' }) + const theme = fromSkin({ banner_text: '#FFF8DC' }, {}) + + expect(theme.color.text).toBe('ansi256(136)') + }) + it('passes banner logo/hero', async () => { const { fromSkin } = await importThemeWithCleanEnv() diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx index f1ce52bab5..bd56c7f0f8 100644 --- a/ui-tui/src/entry.tsx +++ b/ui-tui/src/entry.tsx @@ -1,8 +1,6 @@ #!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc -// Must be first import — mutates process.env.FORCE_COLOR / COLORTERM before -// any chalk / supports-color import so the banner gradient renders in -// truecolor instead of being downsampled to 256-color (which collapses -// gold #FFD700 and amber #FFBF00 to the same slot). +// Must be first import. If the user explicitly opts into truecolor, this +// nudges chalk / supports-color before either package is initialized. import './lib/forceTruecolor.js' import type { FrameEvent } from '@hermes/ink' diff --git a/ui-tui/src/lib/forceTruecolor.ts b/ui-tui/src/lib/forceTruecolor.ts index 3e99b6b184..25de7b2dc3 100644 --- a/ui-tui/src/lib/forceTruecolor.ts +++ b/ui-tui/src/lib/forceTruecolor.ts @@ -1,27 +1,25 @@ /** - * Force 24-bit truecolor output before any chalk / supports-color import. + * Targeted 24-bit truecolor override before chalk / supports-color imports. * - * Why this exists: - * The base CLI (Python/Rich) emits banner colors as truecolor ANSI - * (`\033[38;2;R;G;Bm`). The TUI renders through Ink → chalk, whose - * supports-color auto-detection defaults to 256-color on macOS Terminal.app - * and any terminal that does NOT set `COLORTERM=truecolor`. In 256-color - * mode, chalk downsamples `#FFD700` (gold) and `#FFBF00` (amber) to the - * *same* xterm-256 palette slot (220) — collapsing the banner gradient - * into a single flat yellow band. The bronze and dim rows also lose - * contrast against each other. - * - * Terminal.app (macOS 12+), iTerm2, kitty, Alacritty, VS Code, Cursor, - * and WezTerm all render truecolor correctly. The few that don't - * (ancient xterm, some CI environments) can set `HERMES_TUI_TRUECOLOR=0` - * to opt out. - * - * This MUST run before any `chalk` or `supports-color` import. supports-color - * caches its level on first load, so nudging env vars after that point has - * no effect. + * macOS Terminal.app before Tahoe 26 does not support RGB SGR, so do not + * infer truecolor from TERM_PROGRAM=Apple_Terminal. Users can still opt in + * explicitly on terminals that support RGB but do not advertise COLORTERM. */ -if (process.env.HERMES_TUI_TRUECOLOR !== '0' && !process.env.NO_COLOR && !process.env.FORCE_COLOR) { +const TRUE_RE = /^(?:1|true|yes|on)$/i +const FALSE_RE = /^(?:0|false|no|off)$/i + +export function shouldForceTruecolor(env: NodeJS.ProcessEnv = process.env): boolean { + const override = (env.HERMES_TUI_TRUECOLOR ?? '').trim() + + if (FALSE_RE.test(override) || 'NO_COLOR' in env) { + return false + } + + return TRUE_RE.test(override) +} + +if (shouldForceTruecolor()) { if (!process.env.COLORTERM) { process.env.COLORTERM = 'truecolor' } diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index e14b8d2a52..2a55709036 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -76,6 +76,162 @@ function mix(a: string, b: string, t: number) { return '#' + ((1 << 24) | (lerp(0) << 16) | (lerp(1) << 8) | lerp(2)).toString(16).slice(1) } +const XTERM_6_LEVELS = [0, 95, 135, 175, 215, 255] as const +const ANSI_LIGHT_MAX_LUMINANCE = 0.72 +const ANSI_LIGHT_TARGET_LUMINANCE = 0.34 +const ANSI_LIGHT_MIN_SATURATION = 0.22 +const ANSI_MUTED_BUCKET = 245 + +const ANSI_NORMALIZED_FOREGROUNDS: readonly (keyof ThemeColors)[] = [ + 'text', + 'label', + 'ok', + 'error', + 'warn', + 'prompt', + 'statusFg', + 'statusGood', + 'statusWarn', + 'statusBad', + 'statusCritical', + 'shellDollar' +] + +const ANSI_MUTED_FOREGROUNDS: readonly (keyof ThemeColors)[] = ['muted', 'sessionLabel', 'sessionBorder'] + +function xtermEightBitRgb(colorNumber: number): [number, number, number] { + if (colorNumber >= 232) { + const value = 8 + (colorNumber - 232) * 10 + + return [value, value, value] + } + + if (colorNumber >= 16) { + const offset = colorNumber - 16 + + return [ + XTERM_6_LEVELS[Math.floor(offset / 36) % 6]!, + XTERM_6_LEVELS[Math.floor(offset / 6) % 6]!, + XTERM_6_LEVELS[offset % 6]! + ] + } + + return [0, 0, 0] +} + +function channelLuminance(value: number): number { + const normalized = value / 255 + + return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4 +} + +function relativeLuminance(red: number, green: number, blue: number): number { + return 0.2126 * channelLuminance(red) + 0.7152 * channelLuminance(green) + 0.0722 * channelLuminance(blue) +} + +function rgbToHsl(red: number, green: number, blue: number): [number, number, number] { + const rn = red / 255 + const gn = green / 255 + const bn = blue / 255 + const max = Math.max(rn, gn, bn) + const min = Math.min(rn, gn, bn) + const lightness = (max + min) / 2 + + if (max === min) { + return [0, 0, lightness] + } + + const delta = max - min + const saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min) + + const hue = + max === rn + ? (gn - bn) / delta + (gn < bn ? 6 : 0) + : max === gn + ? (bn - rn) / delta + 2 + : (rn - gn) / delta + 4 + + return [hue / 6, saturation, lightness] +} + +function circularDistance(a: number, b: number): number { + const distance = Math.abs(a - b) + + return Math.min(distance, 1 - distance) +} + +// Mirrors @hermes/ink's colorize.ts. Keep local: app code compiles from +// ui-tui/src, while @hermes/ink is bundled separately from packages/. +function richEightBitColorNumber(red: number, green: number, blue: number): number { + const [, saturation, lightness] = rgbToHsl(red, green, blue) + + if (saturation < 0.15) { + const gray = Math.round(lightness * 25) + + return gray === 0 ? 16 : gray === 25 ? 231 : 231 + gray + } + + const sixRed = red < 95 ? red / 95 : 1 + (red - 95) / 40 + const sixGreen = green < 95 ? green / 95 : 1 + (green - 95) / 40 + const sixBlue = blue < 95 ? blue / 95 : 1 + (blue - 95) / 40 + + return 16 + 36 * Math.round(sixRed) + 6 * Math.round(sixGreen) + Math.round(sixBlue) +} + +function bestReadableAnsiColor(red: number, green: number, blue: number): number { + const [hue, saturation, lightness] = rgbToHsl(red, green, blue) + let bestColor = richEightBitColorNumber(red, green, blue) + let bestScore = Number.POSITIVE_INFINITY + + for (let colorNumber = 16; colorNumber <= 255; colorNumber += 1) { + const [candidateRed, candidateGreen, candidateBlue] = xtermEightBitRgb(colorNumber) + const candidateLuminance = relativeLuminance(candidateRed, candidateGreen, candidateBlue) + + if (candidateLuminance > ANSI_LIGHT_MAX_LUMINANCE) { + continue + } + + const [candidateHue, candidateSaturation, candidateLightness] = rgbToHsl( + candidateRed, + candidateGreen, + candidateBlue + ) + + const saturationFloorPenalty = + candidateSaturation < ANSI_LIGHT_MIN_SATURATION ? (ANSI_LIGHT_MIN_SATURATION - candidateSaturation) * 3 : 0 + + const score = + circularDistance(candidateHue, hue) * 4 + + Math.abs(candidateSaturation - Math.max(ANSI_LIGHT_MIN_SATURATION, saturation)) * 0.8 + + Math.abs(candidateLightness - Math.min(lightness, ANSI_LIGHT_TARGET_LUMINANCE)) * 2 + + saturationFloorPenalty + + if (score < bestScore) { + bestColor = colorNumber + bestScore = score + } + } + + return bestColor +} + +function normalizeAnsiForeground(color: string): string { + const rgb = parseHex(color) + + if (!rgb) { + return color + } + + const richAnsi = richEightBitColorNumber(rgb[0], rgb[1], rgb[2]) + const richRgb = xtermEightBitRgb(richAnsi) + + const ansi = relativeLuminance(richRgb[0], richRgb[1], richRgb[2]) > ANSI_LIGHT_MAX_LUMINANCE + ? bestReadableAnsiColor(rgb[0], rgb[1], rgb[2]) + : richAnsi + + return `ansi256(${ansi})` +} + // ── Defaults ───────────────────────────────────────────────────────── const BRAND: ThemeBrand = { @@ -190,12 +346,11 @@ export const LIGHT_THEME: Theme = { const TRUE_RE = /^(?:1|true|yes|on)$/ const FALSE_RE = /^(?:0|false|no|off)$/ -// Reserved for future TERM_PROGRAM-based heuristics. Empty by default: -// most modern terminals (Ghostty, Warp, iTerm2, Apple_Terminal) ship a -// dark profile out of the box, so guessing wrong here is more annoying -// than missing a light user — light users can always set -// `HERMES_TUI_LIGHT=1` or `HERMES_TUI_THEME=light`. -const LIGHT_DEFAULT_TERM_PROGRAMS = new Set() +// TERM_PROGRAM fallback allow-list for terminals whose default profile is +// light and which may not expose COLORFGBG. This currently includes Apple +// Terminal. Explicit HERMES_TUI_THEME / COLORFGBG signals above still win, +// so dark Apple Terminal profiles that advertise a dark background stay dark. +const LIGHT_DEFAULT_TERM_PROGRAMS = new Set(['Apple_Terminal']) // Best-effort RGB → luminance check. Currently only accepts a 3- or // 6-digit hex value (with or without a leading `#`); the env var name @@ -247,7 +402,7 @@ function backgroundLuminance(raw: string): null | number { // slot 7 or 15 on light profiles; 0–15 ranges are otherwise // treated as authoritatively dark so the TERM_PROGRAM // allow-list below cannot override an explicit dark profile. -// 5. `TERM_PROGRAM` light-default allow-list (currently empty). +// 5. `TERM_PROGRAM` light-default allow-list. // // Anything we can't decide stays dark — the default Hermes palette // is the dark one. @@ -313,7 +468,42 @@ export function detectLightMode( return lightDefaultTermPrograms.has(termProgram) } -export const DEFAULT_THEME: Theme = detectLightMode() ? LIGHT_THEME : DARK_THEME +function shouldNormalizeAnsiLightTheme(env: NodeJS.ProcessEnv = process.env, isLight = detectLightMode(env)): boolean { + const colorTerm = (env.COLORTERM ?? '').trim().toLowerCase() + const termProgram = (env.TERM_PROGRAM ?? '').trim() + + return termProgram === 'Apple_Terminal' && colorTerm !== 'truecolor' && colorTerm !== '24bit' && isLight +} + +export function normalizeThemeForAnsiLightTerminal( + theme: Theme, + env: NodeJS.ProcessEnv = process.env, + isLight = detectLightMode(env) +): Theme { + if (!shouldNormalizeAnsiLightTheme(env, isLight)) { + return theme + } + + const color = { ...theme.color } + + for (const key of ANSI_NORMALIZED_FOREGROUNDS) { + color[key] = normalizeAnsiForeground(color[key]) + } + + for (const key of ANSI_MUTED_FOREGROUNDS) { + color[key] = `ansi256(${ANSI_MUTED_BUCKET})` + } + + return { ...theme, color } +} + +const DEFAULT_LIGHT_MODE = detectLightMode() + +export const DEFAULT_THEME: Theme = normalizeThemeForAnsiLightTerminal( + DEFAULT_LIGHT_MODE ? LIGHT_THEME : DARK_THEME, + process.env, + DEFAULT_LIGHT_MODE +) // ── Skin → Theme ───────────────────────────────────────────────────── @@ -333,7 +523,7 @@ export function fromSkin( const muted = c('banner_dim') ?? d.color.muted const completionBg = c('completion_menu_bg') ?? d.color.completionBg - return { + return normalizeThemeForAnsiLightTerminal({ color: { primary: c('ui_primary') ?? c('banner_title') ?? d.color.primary, accent, @@ -379,5 +569,5 @@ export function fromSkin( bannerLogo, bannerHero - } + }, process.env, DEFAULT_LIGHT_MODE) }