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

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

View file

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

View file

@ -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()
})
})

View file

@ -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<NonNullable<Styles['textWrap']>, 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]