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