mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
The theme engine picked light/dark adaptation from the HOST terminal (detectLightMode) even when the skin authors its own background — which the TUI then paints onto the terminal via OSC-11. On a light-mode Apple Terminal without truecolor, a pure-black skin (e.g. Bloomberg) got its foregrounds ansi256-bucketed *for a light background that no longer exists*: theme.color.text became 'ansi256(214)', which also fails the OSC-10 hex gate, so the terminal default fg stayed the light profile's near-black — markdown body text rendered black-on-black. fromSkin now resolves polarity from the skin's authored background when present (skinIsLight), uses that background as the reference canvas for the derived tone ladder and contrast adaptation, and only falls back to host detection for skins without a background (they render on the terminal's own surface). The paired light_colors/dark_colors pick in themeForSkin follows the same rule.
944 lines
34 KiB
TypeScript
944 lines
34 KiB
TypeScript
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})`
|
||
}
|
||
|
||
// ── 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
|
||
)
|
||
}
|