Download ui-tui/src/theme.ts from SaylorTwift/hermes-agent: direct link, hf CLI and curl.
- Browser
- Download file 35.6 kB
-
https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/ui-tui/src/theme.ts
- Command line
-
hf download hf://SaylorTwift/hermes-agent/ui-tui/src/theme.ts
-
curl -L -o theme.ts https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/ui-tui/src/theme.ts
35.6 kB
| import { contrastRatio, ensureContrast, mix, parseColor, relativeLuminance, toHex } from '@hermes/shared/color' | |
| import type { SkinBranding, SkinColors } from '@hermes/shared/skin' | |
| import { desaturate, grayOf, liftForContrast } 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 βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // | |
| // Generic color computation lives in @hermes/shared/color (the primitives, | |
| // shared with the desktop) and lib/color.ts (TUI-only lifts and re-toning); | |
| // 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 } | |
| 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 10β17 β 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; 0β15 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 0β6 and 8β14 are the dark half of the 0β15 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 | |
| ) | |
| } | |