Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions ui-tui/packages/hermes-ink/src/ink/colorize.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})

55 changes: 53 additions & 2 deletions ui-tui/packages/hermes-ink/src/ink/colorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}

Expand All @@ -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)
}
Comment thread
OutThisLife marked this conversation as resolved.

return type === 'foreground'
? chalk.rgb(firstValue, secondValue, thirdValue)(str)
: chalk.bgRgb(firstValue, secondValue, thirdValue)(str)
Expand Down
22 changes: 21 additions & 1 deletion ui-tui/packages/hermes-ink/src/ink/components/Text.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
24 changes: 20 additions & 4 deletions ui-tui/packages/hermes-ink/src/ink/components/Text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -161,18 +176,19 @@ 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
}

let t6

if ($[0] !== color) {
t6 = color && {
color
if ($[0] !== effectiveColor) {
t6 = effectiveColor && {
color: effectiveColor
}
$[0] = color
$[0] = effectiveColor
$[1] = t6
} else {
t6 = $[1]
Expand Down
69 changes: 64 additions & 5 deletions ui-tui/src/__tests__/forceTruecolor.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}
Expand All @@ -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')
}
Expand All @@ -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()
}
Expand All @@ -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')
}
)
})
})
Loading
Loading