Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
+969
View File
@@ -0,0 +1,969 @@
import type { SkinBranding, SkinColors } from '@hermes/shared/skin'
import { desaturate, grayOf, liftForContrast, mix, parseColor, relativeLuminance, toHex } from './lib/color.js'
export interface ThemeColors {
primary: string
accent: string
border: string
text: string
muted: string
completionBg: string
completionCurrentBg: string
completionMetaBg: string
completionMetaCurrentBg: string
label: string
ok: string
error: string
warn: string
/** Tool-call markers (● bullet, tool spinner). Defaults to `accent`. */
tool: string
/** Reasoning/thinking body text. Defaults to `muted`. */
thinking: string
/** Code-block syntax highlight. Default to accent/text/border/muted. */
syntaxString: string
syntaxNumber: string
syntaxKeyword: string
syntaxComment: string
prompt: string
sessionLabel: string
sessionBorder: string
statusBg: string
statusFg: string
statusGood: string
statusWarn: string
statusBad: string
statusCritical: string
selectionBg: string
diffAdded: string
diffRemoved: string
diffAddedWord: string
diffRemovedWord: string
shellDollar: string
}
export interface ThemeBrand {
name: string
icon: string
prompt: string
welcome: string
goodbye: string
tool: string
helpHeader: string
}
export interface Theme {
color: ThemeColors
brand: ThemeBrand
bannerLogo: string
bannerHero: string
}
// ── Color math ───────────────────────────────────────────────────────
//
// All generic color computation lives in lib/color.ts (the color primitive);
// this file keeps only the ANSI-256 remapping that is specific to the
// limited-palette Apple Terminal path. contrastRatio/ensureContrast are
// re-exported for existing consumers (tests, /theme-info).
export { contrastRatio, ensureContrast } from './lib/color.js'
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',
'tool'
]
const ANSI_MUTED_FOREGROUNDS: readonly (keyof ThemeColors)[] = ['muted', 'sessionLabel', 'sessionBorder', 'thinking']
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]
}
const rgbLuminance = (red: number, green: number, blue: number): number =>
relativeLuminance(toHex([red, green, blue])) ?? 0
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 = rgbLuminance(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 = parseColor(color)
if (!rgb) {
return color
}
const richAnsi = richEightBitColorNumber(rgb[0], rgb[1], rgb[2])
const richRgb = xtermEightBitRgb(richAnsi)
const ansi =
rgbLuminance(richRgb[0], richRgb[1], richRgb[2]) > ANSI_LIGHT_MAX_LUMINANCE
? bestReadableAnsiColor(rgb[0], rgb[1], rgb[2])
: richAnsi
return `ansi256(${ansi})`
}
const ANSI256_RE = /^ansi256\((\d{1,3})\)$/
/**
* The literal `#rrggbb` a theme tone paints as, or '' when it has none.
*
* The inverse of `normalizeAnsiForeground`: tones are not uniformly hex, since
* a limited-palette terminal rewrites the foregrounds to `ansi256(N)`.
* Consumers needing a real color — OSC 10/11, which only speak `#rrggbb` —
* resolve through here rather than hex-testing the tone, which would silently
* skip exactly the terminals that did the quantizing.
*/
export function themeToneHex(tone: string): string {
const ansi = ANSI256_RE.exec(tone.trim())
if (ansi) {
const n = Number(ansi[1])
return n <= 255 ? toHex(xtermEightBitRgb(n)) : ''
}
const rgb = parseColor(tone)
return rgb ? toHex(rgb) : ''
}
// ── Defaults ─────────────────────────────────────────────────────────
const BRAND: ThemeBrand = {
name: 'Hermes Agent',
icon: '⚕',
prompt: '',
welcome: 'Type your message or /help for commands.',
goodbye: 'Goodbye! ⚕',
tool: '┊',
helpHeader: '(^_^)? Commands'
}
const cleanPromptSymbol = (s: string | undefined, fallback: string) => {
const cleaned = String(s ?? '')
.replace(/\s+/g, ' ')
.trim()
return cleaned || fallback
}
// ── Seeds → palette ──────────────────────────────────────────────────
//
// A palette is BUILT, not enumerated: skins/base themes supply identity seeds
// (text, primary, accent, semantic hues) and every secondary tone is derived
// by the mix ladder against the background. This is the desktop token system
// (seeds → color-mix → tokens) in terminal form — "dim" is definitionally a
// derivative of the theme's own base colors and can never be incoherent.
export interface ThemeSeeds {
accent: string
/** Identity fill override: active list-row chip (derived when omitted). */
activeRow?: string
bg: string
border?: string
error: string
/** Identity tone override: muted/dim text (derived when omitted). */
muted?: string
ok: string
primary: string
prompt?: string
/** Identity fill override: text-selection highlight (derived when omitted). */
selection?: string
shellDollar: string
statusBad: string
statusCritical: string
statusGood: string
statusWarn: string
/** Identity fill override: panel/status surface (derived when omitted). */
surface?: string
text: string
warn: string
}
const DIFF_DARK = {
diffAdded: 'rgb(220,255,220)',
diffRemoved: 'rgb(255,220,220)',
diffAddedWord: 'rgb(36,138,61)',
diffRemovedWord: 'rgb(207,34,46)'
}
const DIFF_LIGHT = {
diffAdded: 'rgb(200,240,200)',
diffRemoved: 'rgb(240,200,200)',
diffAddedWord: 'rgb(27,94,32)',
diffRemovedWord: 'rgb(183,28,28)'
}
export function buildPalette(seeds: ThemeSeeds, isLight: boolean): ThemeColors {
const tones = deriveTones(seeds)
const surface = seeds.surface ?? tones.surface
const activeRow = seeds.activeRow ?? (seeds.surface ? mix(surface, seeds.accent, 0.22) : tones.activeRow)
const muted = seeds.muted ?? tones.muted
return {
primary: seeds.primary,
accent: seeds.accent,
border: seeds.border ?? tones.border,
text: seeds.text,
muted,
completionBg: surface,
completionCurrentBg: activeRow,
completionMetaBg: surface,
completionMetaCurrentBg: activeRow,
label: tones.label,
ok: seeds.ok,
error: seeds.error,
warn: seeds.warn,
// Element tokens: independently settable, but default to their semantic
// parents (tool marker → accent, reasoning body → muted).
tool: seeds.accent,
thinking: muted,
// Code-syntax tokens default to brand tokens (unchanged highlighting)
// but are independently skinnable.
syntaxString: seeds.accent,
syntaxNumber: seeds.text,
syntaxKeyword: seeds.border ?? tones.border,
syntaxComment: muted,
prompt: seeds.prompt ?? seeds.text,
// sessionLabel/sessionBorder track the muted tone — "same role, same
// colour" by design (#11300).
sessionLabel: muted,
sessionBorder: muted,
statusBg: surface,
statusFg: tones.statusFg,
statusGood: seeds.statusGood,
statusWarn: seeds.statusWarn,
statusBad: seeds.statusBad,
statusCritical: seeds.statusCritical,
selectionBg: seeds.selection ?? tones.selection,
...(isLight ? DIFF_LIGHT : DIFF_DARK),
shellDollar: seeds.shellDollar
}
}
export const DARK_SEEDS: ThemeSeeds = {
accent: '#FFBF00',
// The classic Hermes navy surfaces are IDENTITY, not derivation drift —
// keep them as explicit fill seeds (the ladder derives them for skins
// that don't care).
activeRow: '#333355',
bg: '#101014',
border: '#CD7F32',
error: '#ef5350',
ok: '#4caf50',
primary: '#FFD700',
prompt: '#FFF8DC',
selection: '#3a3a55',
shellDollar: '#4dabf7',
statusBad: '#FF8C00',
statusCritical: '#FF6B6B',
statusGood: '#8FBC8F',
statusWarn: '#FFD700',
surface: '#1a1a2e',
text: '#FFF8DC',
warn: '#ffa726'
}
// Light-terminal seeds: darker golds/ambers that stay legible on white.
// The classic light-mode Hermes look was never hand-authored: for years the
// TUI emitted the DARK golds and hosts with xterm's minimumContrastRatio
// (Cursor defaults to 4.5) lifted them against white — hue and saturation
// kept, luminance clamped. These seeds are those exact lifts
// (liftForContrast(dark, '#ffffff', 4.5)), so hosts WITHOUT a contrast pass
// render the same thing Cursor always showed. Text/prompt stay ink — body
// copy historically rendered in the terminal's default near-black fg.
export const LIGHT_SEEDS: ThemeSeeds = {
accent: '#956E00',
bg: '#ffffff',
border: '#A56628',
error: '#C14240',
ok: '#367E39',
primary: '#867000',
prompt: '#2B2014',
shellDollar: '#377BB3',
statusBad: '#A65A00',
statusCritical: '#B94D4D',
statusGood: '#5C7A5C',
statusWarn: '#867000',
text: '#3D2F13',
warn: '#956115'
}
export const DARK_THEME: Theme = {
color: buildPalette(DARK_SEEDS, false),
brand: BRAND,
bannerLogo: '',
bannerHero: ''
}
export const LIGHT_THEME: Theme = {
color: buildPalette(LIGHT_SEEDS, true),
brand: BRAND,
bannerLogo: '',
bannerHero: ''
}
// ── Background-aware readability adaptation ─────────────────────────
//
// Mirrors the desktop app's theme contract (apps/desktop/src/themes): skins
// contribute accent IDENTITY; readability against the actual background is
// the theme engine's job, enforced in one place. Two guards, in the desktop's
// vocabulary:
//
// * `ensureContrast` — foreground-role colors are step-mixed toward the
// readable pole (black on light, white on dark) until they clear a
// minimum WCAG contrast ratio against the real (or assumed) background.
// Hue survives; washout doesn't.
// * Fill polarity — background-role colors (completion menu, status bar,
// selection) must match the terminal's polarity. Unlike the desktop, the
// TUI does not own its canvas — panels sit directly on the terminal's
// background — so a wrong-polarity fill (navy menu on a white terminal)
// falls back to the base palette even when the skin authored it.
// Display shim — the "rendering gotcha" layer, calibrated against the look
// the maintainers standardized on (pixel-sampled from the reference
// screenshot): the beloved cross-polarity rendering is the AUTHORED palette
// displayed RAW — slate's ~1.5:1 pastels on white read as deliberate airy
// hierarchy, not a bug. So the floors are barely-visible rescues only:
// * DISPLAY 1.45 sits just above slate-pastel territory (#c9d1d9 = 1.54,
// passes raw, byte-identical) but just below true invisibility
// (default's cream #FFF8DC = 1.08, gets rescued).
// * SEMANTIC 2.2 for alert colors (ok/error/warn/status) — they carry
// meaning and must never vanish.
// The lift itself is xterm.js's own multiplicative algorithm
// (liftForContrast), so on hosts that run their own minimumContrastRatio the
// two adjustments agree instead of fighting.
// Foreground floors are polarity-aware. On a DARK background the authored
// palette is already bright, so a modest floor only rescues the rare dark
// tone. On a LIGHT background — which in practice means a TRANSPARENT Cursor/
// terminal window compositing over a light editor, where xterm applies NO
// contrast lift of its own (there is no solid bg to measure against) — the
// beloved classic look is the authored palette rendered essentially RAW:
// vivid #FFD700 gold (~1.36:1), not a WCAG-darkened mustard. So the light
// floor is a near-invisible rescue only (catches cream #FFF8DC at 1.08 but
// leaves the golds untouched). Pixel-sampled target: #F5C242 (L61 S90),
// which the previous 1.45 floor crushed to #867000 (L26) — the reported mud.
const DISPLAY_MIN_CONTRAST = 1.45
const SEMANTIC_MIN_CONTRAST = 2.2
const LIGHT_DISPLAY_MIN_CONTRAST = 1.18
const LIGHT_SEMANTIC_MIN_CONTRAST = 1.6
const DISPLAY_FOREGROUNDS: readonly (keyof ThemeColors)[] = [
'primary',
'accent',
'text',
'label',
'prompt',
'statusFg',
'border',
'muted',
'sessionLabel',
'sessionBorder',
'shellDollar'
]
const SEMANTIC_FOREGROUNDS: readonly (keyof ThemeColors)[] = [
'ok',
'error',
'warn',
'statusGood',
'statusWarn',
'statusBad',
'statusCritical'
]
const ADAPTIVE_BACKGROUNDS: readonly (keyof ThemeColors)[] = [
'completionBg',
'completionCurrentBg',
'completionMetaBg',
'completionMetaCurrentBg',
'statusBg',
'selectionBg'
]
// Fill polarity limits: on light terminals a fill must stay light, and vice
// versa — there is no readable middle for a panel fill on the wrong pole.
const LIGHT_BG_MIN_LUMINANCE = 0.4
const DARK_BG_MAX_LUMINANCE = 0.35
function adaptColorsToBackground(colors: ThemeColors, isLight: boolean, base: ThemeColors, bg: string): ThemeColors {
const out = { ...colors }
const displayFloor = isLight ? LIGHT_DISPLAY_MIN_CONTRAST : DISPLAY_MIN_CONTRAST
const semanticFloor = isLight ? LIGHT_SEMANTIC_MIN_CONTRAST : SEMANTIC_MIN_CONTRAST
for (const key of DISPLAY_FOREGROUNDS) {
out[key] = liftForContrast(out[key], bg, displayFloor)
}
for (const key of SEMANTIC_FOREGROUNDS) {
out[key] = liftForContrast(out[key], bg, semanticFloor)
}
for (const key of ADAPTIVE_BACKGROUNDS) {
const luminance = relativeLuminance(out[key])
if (luminance === null) {
continue
}
if (isLight ? luminance < LIGHT_BG_MIN_LUMINANCE : luminance > DARK_BG_MAX_LUMINANCE) {
out[key] = base[key]
}
}
return out
}
/** The background hex adaptation measures contrast against: the OSC-11
* answer when known (cached in HERMES_TUI_BACKGROUND), else the mode's
* assumed pole. */
function referenceBackground(isLight: boolean, env: NodeJS.ProcessEnv = process.env): string {
const cached = (env.HERMES_TUI_BACKGROUND ?? '').trim()
if (cached && backgroundLuminance(cached) !== null) {
return cached.startsWith('#') ? cached : `#${cached}`
}
return isLight ? '#ffffff' : '#101014'
}
// ── Derived tone ladder (the desktop color-mix system) ──────────────
//
// A theme is a handful of SEEDS (text, primary, accent, border, status hues);
// every secondary tone — muted text, labels, surfaces, selection chips — is a
// color-mix derivative of those seeds against the real terminal background,
// exactly like the desktop's `--theme-*` seeds → `--ui-*` color-mix ladder in
// apps/desktop/src/styles.css. Skins therefore cannot ship an incoherent
// "dim": if they don't author a tone, it is DERIVED from their own identity,
// never inherited from another skin's palette.
//
// Mix knobs are the single source of truth for tone hierarchy. (The classic
// prompt_toolkit CLI still reads the built-in skins' complete authored
// palettes; those were generated with the same math.)
export interface ThemeTones {
/** Secondary/dim text: receded accent (dark) / primary-ink blend (light). */
muted: string
/** Field labels: one step brighter than muted, same family. */
label: string
/** Status-bar default text: the gray of slightly-receded text. */
statusFg: string
/** Raised panel fill: background nudged toward the (softened) accent. */
surface: string
/** Active list-row chip: surface tinted with accent. */
activeRow: string
/** Text-selection highlight: bg tinted with the theme's blue (light) or accent (dark). */
selection: string
/** Border fallback: accent receded toward the background. */
border: string
}
/**
* The fitted tone ladder. Knobs are REVERSE-ENGINEERED from the original
* hand-tuned palettes (grid-search over mix/desaturate formula families
* against the pre-refactor literals + every authored skin palette; see the
* "reproduces the original hand-tuned tones" test for the contract):
*
* dark muted #CC9B1F ≈ desaturate(mix(accent, bg, .19), .16) (err 3)
* dark label #DAA520 ≈ desaturate(mix(accent, bg, .13), .16) (err 3)
* dark status #C0C0C0 = grayOf(mix(text, bg, .24)) (err 0)
* light muted #946C08 ≈ desaturate(accent, .05) (err 2)
* light label #8E6B13 ≈ desaturate(mix(accent, text, .03), .15) (err 2)
* light status #6F6F6F = grayOf(mix(text, bg, .30)) (err 1)
* light surface #F5F5F5 ≈ bg + softened accent (err 5)
* light chip #E0D1BF = mix(surface, accent, .25) (err 8)
* light selection #D4E4F7 ≈ mix(bg, shellDollar, .20) (err 7)
*
* The light targets are the LIFT CANON: liftForContrast(dark literal,
* white, 4.5) — what xterm's minimumContrastRatio showed on light hosts
* for years — not hand-picked browns (those read as desaturated mud).
*
* The classic dark navy fills (#1a1a2e/#333355/#3a3a55) are IRREDUCIBLE from
* gold seeds — the search bottoms out at gray, err 1017 — so they remain
* explicit identity seeds on DARK_SEEDS rather than pretending to be math.
*/
export function deriveTones(seeds: {
accent: string
bg: string
primary: string
shellDollar?: string
text: string
}): ThemeTones {
const { accent, bg, text } = seeds
const isLight = (relativeLuminance(bg) ?? 0) > 0.5
// Fill tint keeps most of the accent's chroma — a heavier desaturate here
// read as washed-out ("a little too desat") next to authored fills.
const surface = mix(bg, desaturate(accent, 0.15), isLight ? 0.045 : 0.09)
return {
// Light knobs are fitted to the lift canon (xterm minimumContrastRatio
// 4.5 of the classic dark golds against white — see LIGHT_SEEDS), not
// to ink blends: muted #946C08 ≈ desat(accent .05), label #8E6B13 ≈
// desat(mix(accent, text, .03), .15), statusFg #6F6F6F ≈ gray 30% lift.
muted: isLight ? desaturate(accent, 0.05) : desaturate(mix(accent, bg, 0.19), 0.16),
label: isLight ? desaturate(mix(accent, text, 0.03), 0.15) : desaturate(mix(accent, bg, 0.13), 0.16),
statusFg: grayOf(mix(text, bg, isLight ? 0.3 : 0.24)),
surface,
activeRow: mix(surface, accent, 0.25),
selection: isLight && seeds.shellDollar ? mix(bg, seeds.shellDollar, 0.2) : mix(surface, accent, 0.28),
border: mix(accent, bg, 0.25)
}
}
const TRUE_RE = /^(?:1|true|yes|on)$/
const FALSE_RE = /^(?:0|false|no|off)$/
// 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
// `HERMES_TUI_BACKGROUND` is intentionally generic so a future OSC11
// query helper can cache its answer there too, but additional formats
// (rgb()/hsl()/named colours) would need explicit parsing here first.
const LUMA_LIGHT_THRESHOLD = 0.6
// Strict allow-list: parseInt(..., 16) silently truncates at the first
// non-hex character (e.g. `fffgff` would parse as `fff` and yield a
// false-positive "white" reading), so reject anything that doesn't match
// the canonical 3- or 6-digit shape up front.
const HEX_3_RE = /^[0-9a-f]{3}$/
const HEX_6_RE = /^[0-9a-f]{6}$/
function backgroundLuminance(raw: string): null | number {
const v = raw.trim().toLowerCase()
if (!v) {
return null
}
const hex = v.startsWith('#') ? v.slice(1) : v
const rgb = HEX_6_RE.test(hex)
? [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)]
: HEX_3_RE.test(hex)
? [parseInt(hex[0]! + hex[0]!, 16), parseInt(hex[1]! + hex[1]!, 16), parseInt(hex[2]! + hex[2]!, 16)]
: null
if (!rgb) {
return null
}
// Rec. 709 luma — close enough for "is this background bright".
return (0.2126 * rgb[0]! + 0.7152 * rgb[1]! + 0.0722 * rgb[2]!) / 255
}
// Pick light vs dark with ordered, explainable signals (#11300):
//
// 1. `HERMES_TUI_LIGHT` boolean — `1`/`true`/`yes`/`on` → light;
// `0`/`false`/`no`/`off` → dark. Either explicit value wins
// regardless of any later signal.
// 2. `HERMES_TUI_THEME` named override — `light` / `dark` win over
// every signal below.
// 3. `HERMES_TUI_BACKGROUND` hex hint (3- or 6-digit) — luminance
// ≥ LUMA_LIGHT_THRESHOLD → light.
// 4. `COLORFGBG` last field — XFCE / rxvt / Terminal.app emit
// 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.
//
// Anything we can't decide stays dark — the default Hermes palette
// is the dark one.
export function detectLightMode(
env: NodeJS.ProcessEnv = process.env,
// Injectable so tests can prove the COLORFGBG-over-TERM_PROGRAM
// precedence rule even though the production allow-list is empty.
lightDefaultTermPrograms: ReadonlySet<string> = LIGHT_DEFAULT_TERM_PROGRAMS
): boolean {
const lightFlag = (env.HERMES_TUI_LIGHT ?? '').trim().toLowerCase()
if (TRUE_RE.test(lightFlag)) {
return true
}
if (FALSE_RE.test(lightFlag)) {
return false
}
const themeFlag = (env.HERMES_TUI_THEME ?? '').trim().toLowerCase()
if (themeFlag === 'light') {
return true
}
if (themeFlag === 'dark') {
return false
}
const bgHint = backgroundLuminance(env.HERMES_TUI_BACKGROUND ?? '')
if (bgHint !== null) {
return bgHint >= LUMA_LIGHT_THRESHOLD
}
const colorfgbg = (env.COLORFGBG ?? '').trim()
if (colorfgbg) {
// Validate as a decimal integer before coercing — `Number('')` is 0,
// so a malformed `COLORFGBG='15;'` would otherwise look like an
// authoritative dark slot and incorrectly block the TERM_PROGRAM
// allow-list. Anything that isn't pure digits falls through.
const lastField = colorfgbg.split(';').at(-1) ?? ''
if (/^\d+$/.test(lastField)) {
const bg = Number(lastField)
if (bg === 7 || bg === 15) {
return true
}
// Slots 06 and 814 are the dark half of the 015 ANSI range.
// When COLORFGBG is set we trust it as authoritative — a non-light
// value here shouldn't get overridden by the TERM_PROGRAM allow-list.
if (bg >= 0 && bg < 16) {
return false
}
}
}
const termProgram = (env.TERM_PROGRAM ?? '').trim()
return lightDefaultTermPrograms.has(termProgram)
}
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
)
/**
* The skinless theme for the CURRENT light-mode signals. Unlike the frozen
* module-load DEFAULT_THEME, this re-reads the environment — so it picks up
* the OSC-11 background answer cached into HERMES_TUI_BACKGROUND after
* startup. Used when the terminal background arrives before (or without) a
* gateway skin.
*/
export function defaultThemeForCurrentBackground(env: NodeJS.ProcessEnv = process.env): Theme {
const isLight = detectLightMode(env)
return normalizeThemeForAnsiLightTerminal(isLight ? LIGHT_THEME : DARK_THEME, env, isLight)
}
// ── Skin → Theme ─────────────────────────────────────────────────────
/** The skin's authored canvas as a #-prefixed hex, or null when absent/junk. */
const authoredBackground = (raw: string | undefined): null | string => {
const v = (raw ?? '').trim()
return backgroundLuminance(v) === null ? null : v.startsWith('#') ? v : `#${v}`
}
/**
* A skin that authors a background OWNS its polarity: the TUI paints the
* terminal with it (applySkin → OSC-11), so contrast adaptation, the derived
* tone ladder, and ANSI light-terminal normalization must all run against the
* skin's canvas — not the host profile the skin just covered. (A pure-black
* skin on a light Apple Terminal otherwise gets its text remapped for a light
* background it painted over: invisible.) Host detection still governs skins
* without a background — they render on the terminal's own surface.
*/
export function skinIsLight(colors: SkinColors, env: NodeJS.ProcessEnv = process.env): boolean {
const authored = backgroundLuminance(colors['background'] ?? '')
return authored === null ? detectLightMode(env) : authored >= LUMA_LIGHT_THRESHOLD
}
export function fromSkin(
colors: SkinColors,
branding: SkinBranding,
bannerLogo = '',
bannerHero = '',
toolPrefix = '',
helpHeader = ''
): Theme {
// Polarity: the skin's own canvas when it authors one (see skinIsLight);
// otherwise live host detection (not the module-load snapshot — by the time
// the gateway skin arrives, the OSC-11 probe has usually answered and cached
// itself into HERMES_TUI_BACKGROUND. See #applySkin / syncThemeToTerminalBackground).
const skinBg = authoredBackground(colors['background'])
const isLight = skinIsLight(colors)
const bg = skinBg ?? referenceBackground(isLight)
const base = isLight ? LIGHT_SEEDS : DARK_SEEDS
const d = isLight ? LIGHT_THEME : DARK_THEME
const c = (k: string) => colors[k]
const hasSkinColors = Object.keys(colors).length > 0
// 1. Seeds: the skin's identity. Anything it doesn't define comes from the
// base seeds for this polarity. The base's IDENTITY FILLS (Hermes navy
// surfaces, gold muted) only carry over for the skinless default — a
// skin with its own identity derives its fills from its own seeds.
const identityFills: Partial<ThemeSeeds> = hasSkinColors
? {}
: { activeRow: base.activeRow, muted: base.muted, selection: base.selection, surface: base.surface }
const seeds: ThemeSeeds = {
...identityFills,
accent: c('ui_accent') ?? c('banner_accent') ?? base.accent,
bg,
border: c('ui_border') ?? c('banner_border') ?? base.border,
error: c('ui_error') ?? base.error,
ok: c('ui_ok') ?? base.ok,
primary: c('ui_primary') ?? c('banner_title') ?? base.primary,
prompt: c('prompt') ?? c('banner_text') ?? base.prompt,
shellDollar: c('shell_dollar') ?? base.shellDollar,
statusBad: c('status_bar_bad') ?? base.statusBad,
statusCritical: c('status_bar_critical') ?? c('ui_error') ?? base.statusCritical,
statusGood: c('status_bar_good') ?? c('ui_ok') ?? base.statusGood,
statusWarn: c('status_bar_warn') ?? c('ui_warn') ?? base.statusWarn,
text: c('ui_text') ?? c('banner_text') ?? base.text,
warn: c('ui_warn') ?? base.warn
}
// 2. Derive: every secondary tone is a color-mix of the seeds against the
// REAL background — dim is a derivative of the skin's own identity.
const derived = buildPalette(seeds, isLight)
// 3. Authored tone overrides: a skin may still hand-tune any tone; the
// derived ladder is the default, not a cage. Chip/selection re-derive
// from the FINAL surface so dependents stay coherent with overrides.
// `background` is theme-sdk's cross-surface base: the TUI paints the
// terminal with it (applySkin → setTerminalBackground), so panels/status
// must sit on it too — fall the surface back to it below completion_menu_bg.
const surface = c('completion_menu_bg') ?? c('background') ?? derived.completionBg
// Re-mix the chip only when the skin authored its own surface; otherwise
// the derived value already carries the identity seeds (e.g. Hermes navy).
const activeRow =
c('completion_menu_current_bg') ??
(c('completion_menu_bg') ? mix(surface, seeds.accent, 0.22) : derived.completionCurrentBg)
const assembled: ThemeColors = {
...derived,
muted: c('banner_dim') ?? derived.muted,
label: c('ui_label') ?? derived.label,
completionBg: surface,
completionCurrentBg: activeRow,
completionMetaBg: c('completion_menu_meta_bg') ?? surface,
completionMetaCurrentBg: c('completion_menu_meta_current_bg') ?? activeRow,
sessionLabel: c('session_label') ?? c('banner_dim') ?? derived.sessionLabel,
sessionBorder: c('session_border') ?? c('banner_dim') ?? derived.sessionBorder,
statusBg: c('status_bar_bg') ?? surface,
statusFg: c('status_bar_text') ?? c('ui_text') ?? c('banner_text') ?? derived.statusFg,
selectionBg: c('selection_bg') ?? c('completion_menu_current_bg') ?? derived.selectionBg,
// Element tokens + skinnable diffs (theme-sdk): overridable, else the
// derived defaults (tool→accent, thinking→muted, diff_* → DIFF_* ladder).
// thinking tracks the EFFECTIVE muted (banner_dim override included), not
// just derived.muted, so recoloring muted carries the reasoning body with it.
tool: c('ui_tool') ?? derived.tool,
thinking: c('ui_thinking') ?? c('banner_dim') ?? derived.thinking,
diffAdded: c('diff_added') ?? derived.diffAdded,
diffRemoved: c('diff_removed') ?? derived.diffRemoved,
diffAddedWord: c('diff_added_word') ?? derived.diffAddedWord,
diffRemovedWord: c('diff_removed_word') ?? derived.diffRemovedWord,
// Code-syntax tokens: overridable, else the derived brand-token defaults.
syntaxString: c('syntax_string') ?? derived.syntaxString,
syntaxNumber: c('syntax_number') ?? derived.syntaxNumber,
syntaxKeyword: c('syntax_keyword') ?? derived.syntaxKeyword,
syntaxComment: c('syntax_comment') ?? c('banner_dim') ?? derived.syntaxComment
}
// 4. Guard: contrast floors against the real background + fill polarity.
// Wrong-polarity fills fall back to the DERIVED value, which is
// polarity-correct by construction (mixed from the background itself).
// ANSI-limited light Apple Terminal keeps its bespoke ansi256
// normalization (below) instead.
const adapted = shouldNormalizeAnsiLightTheme(process.env, isLight)
? assembled
: adaptColorsToBackground(assembled, isLight, derived, bg)
return normalizeThemeForAnsiLightTerminal(
{
// The element tokens theme-sdk introduced (ui_primary, ui_text,
// ui_border, ui_ok/warn/error, ui_tool, ui_thinking, shell_dollar,
// status_bar_*, diff_*) are read into `seeds`/`assembled` above and
// flow through buildPalette → adaptColorsToBackground, so `adapted`
// already honors them AND applies #20379's contrast/polarity machinery.
// Emitting a hand-mapped color block here would bypass that adaptation
// and regress theme quality.
color: adapted,
brand: {
name: branding.agent_name ?? d.brand.name,
icon: d.brand.icon,
prompt: cleanPromptSymbol(branding.prompt_symbol, d.brand.prompt),
welcome: branding.welcome ?? d.brand.welcome,
goodbye: branding.goodbye ?? d.brand.goodbye,
tool: toolPrefix || d.brand.tool,
helpHeader: branding.help_header ?? (helpHeader || d.brand.helpHeader)
},
bannerLogo,
bannerHero
},
process.env,
isLight
)
}