export interface ThemeColors { primary: string accent: string border: string text: string muted: string completionBg: string completionCurrentBg: string label: string ok: string error: string warn: 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 ─────────────────────────────────────────────────────── function parseHex(h: string): [number, number, number] | null { const m = /^#?([0-9a-f]{6})$/i.exec(h) if (!m) { return null } const n = parseInt(m[1]!, 16) return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff] } function mix(a: string, b: string, t: number) { const pa = parseHex(a) const pb = parseHex(b) if (!pa || !pb) { return a } const lerp = (i: 0 | 1 | 2) => Math.round(pa[i] + (pb[i] - pa[i]) * t) return '#' + ((1 << 24) | (lerp(0) << 16) | (lerp(1) << 8) | lerp(2)).toString(16).slice(1) } // ── 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 } export const DARK_THEME: Theme = { color: { primary: '#FFD700', accent: '#FFBF00', border: '#CD7F32', text: '#FFF8DC', muted: '#CC9B1F', // Bumped from the old `#B8860B` darkgoldenrod (~53% luminance) which // read as barely-visible on dark terminals for long body text. The // new value sits ~60% luminance — readable without losing the "muted / // secondary" semantic. Field labels still use `label` (65%) which // stays brighter so hierarchy holds. completionBg: '#FFFFFF', completionCurrentBg: mix('#FFFFFF', '#FFBF00', 0.25), label: '#DAA520', ok: '#4caf50', error: '#ef5350', warn: '#ffa726', prompt: '#FFF8DC', // sessionLabel/sessionBorder intentionally track the `dim` value — they // are "same role, same colour" by design. fromSkin's banner_dim fallback // relies on this pairing (#11300). sessionLabel: '#CC9B1F', sessionBorder: '#CC9B1F', statusBg: '#1a1a2e', statusFg: '#C0C0C0', statusGood: '#8FBC8F', statusWarn: '#FFD700', statusBad: '#FF8C00', statusCritical: '#FF6B6B', selectionBg: '#3a3a55', diffAdded: 'rgb(220,255,220)', diffRemoved: 'rgb(255,220,220)', diffAddedWord: 'rgb(36,138,61)', diffRemovedWord: 'rgb(207,34,46)', shellDollar: '#4dabf7' }, brand: BRAND, bannerLogo: '', bannerHero: '' } // Light-terminal palette: darker golds/ambers that stay legible on white // backgrounds. Same shape as DARK_THEME so `fromSkin` still layers on top // cleanly (#11300). export const LIGHT_THEME: Theme = { color: { primary: '#8B6914', accent: '#A0651C', border: '#7A4F1F', text: '#3D2F13', muted: '#7A5A0F', completionBg: '#F5F5F5', completionCurrentBg: mix('#F5F5F5', '#A0651C', 0.25), label: '#7A5A0F', ok: '#2E7D32', error: '#C62828', warn: '#E65100', prompt: '#2B2014', sessionLabel: '#7A5A0F', sessionBorder: '#7A5A0F', statusBg: '#F5F5F5', statusFg: '#333333', statusGood: '#2E7D32', statusWarn: '#8B6914', statusBad: '#D84315', statusCritical: '#B71C1C', selectionBg: '#D4E4F7', diffAdded: 'rgb(200,240,200)', diffRemoved: 'rgb(240,200,200)', diffAddedWord: 'rgb(27,94,32)', diffRemovedWord: 'rgb(183,28,28)', shellDollar: '#1565C0' }, brand: BRAND, bannerLogo: '', bannerHero: '' } const TRUE_RE = /^(?:1|true|yes|on)$/ const FALSE_RE = /^(?:0|false|no|off)$/ // Reserved for future TERM_PROGRAM-based heuristics. Empty by default: // most modern terminals (Ghostty, Warp, iTerm2, Apple_Terminal) ship a // dark profile out of the box, so guessing wrong here is more annoying // than missing a light user — light users can always set // `HERMES_TUI_LIGHT=1` or `HERMES_TUI_THEME=light`. const LIGHT_DEFAULT_TERM_PROGRAMS = new Set() // 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 (currently empty). // // 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 = 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) } export const DEFAULT_THEME: Theme = detectLightMode() ? LIGHT_THEME : DARK_THEME // ── Skin → Theme ───────────────────────────────────────────────────── export function fromSkin( colors: Record, branding: Record, bannerLogo = '', bannerHero = '', toolPrefix = '', helpHeader = '' ): Theme { const d = DEFAULT_THEME const c = (k: string) => colors[k] const accent = c('ui_accent') ?? c('banner_accent') ?? d.color.accent const bannerAccent = c('banner_accent') ?? c('banner_title') ?? d.color.accent const muted = c('banner_dim') ?? d.color.muted const completionBg = c('completion_menu_bg') ?? d.color.completionBg return { color: { primary: c('ui_primary') ?? c('banner_title') ?? d.color.primary, accent, border: c('ui_border') ?? c('banner_border') ?? d.color.border, text: c('ui_text') ?? c('banner_text') ?? d.color.text, muted, completionBg, completionCurrentBg: c('completion_menu_current_bg') ?? mix(completionBg, bannerAccent, 0.25), label: c('ui_label') ?? d.color.label, ok: c('ui_ok') ?? d.color.ok, error: c('ui_error') ?? d.color.error, warn: c('ui_warn') ?? d.color.warn, prompt: c('prompt') ?? c('banner_text') ?? d.color.prompt, sessionLabel: c('session_label') ?? muted, sessionBorder: c('session_border') ?? muted, statusBg: d.color.statusBg, statusFg: d.color.statusFg, statusGood: c('ui_ok') ?? d.color.statusGood, statusWarn: c('ui_warn') ?? d.color.statusWarn, statusBad: d.color.statusBad, statusCritical: d.color.statusCritical, selectionBg: c('selection_bg') ?? d.color.selectionBg, diffAdded: d.color.diffAdded, diffRemoved: d.color.diffRemoved, diffAddedWord: d.color.diffAddedWord, diffRemovedWord: d.color.diffRemovedWord, shellDollar: c('shell_dollar') ?? d.color.shellDollar }, 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 } }