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.
This commit is contained in:
brooklyn! 2026-04-29 20:13:49 -07:00 committed by GitHub
parent 31f70d1f2a
commit 4cc6da84a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 487 additions and 50 deletions

View file

@ -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<void>) {
const saved: Record<string, string | undefined> = {}
@ -25,11 +26,39 @@ async function withCleanEnv(setup: () => void, body: () => Promise<void>) {
}
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')
}
)
})
})

View file

@ -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<Record<(typeof RELEVANT_ENV)[number], string>> = {}) {
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()

View file

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

View file

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

View file

@ -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<string>()
// 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<string>(['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; 015 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)
}