mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-03 02:11:48 +00:00
Run the TUI lint autofix and formatter on the PR branch after the sticky prompt and paste recovery changes.
383 lines
11 KiB
TypeScript
383 lines
11 KiB
TypeScript
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<string>()
|
||
|
||
// 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<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)
|
||
}
|
||
|
||
export const DEFAULT_THEME: Theme = detectLightMode() ? LIGHT_THEME : DARK_THEME
|
||
|
||
// ── Skin → Theme ─────────────────────────────────────────────────────
|
||
|
||
export function fromSkin(
|
||
colors: Record<string, string>,
|
||
branding: Record<string, string>,
|
||
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
|
||
}
|
||
}
|