Files
hermes-agent/ui-tui/src/theme.ts

260 lines
6.6 KiB
TypeScript
Raw Normal View History

2026-04-02 19:06:42 -05:00
export interface ThemeColors {
2026-04-02 19:34:30 -05:00
gold: string
amber: string
bronze: string
2026-04-02 19:06:42 -05:00
cornsilk: string
2026-04-02 19:34:30 -05:00
dim: string
completionBg: string
completionCurrentBg: string
2026-04-02 19:06:42 -05:00
2026-04-02 19:34:30 -05:00
label: string
ok: string
error: string
warn: string
2026-04-02 19:06:42 -05:00
prompt: string
sessionLabel: string
sessionBorder: string
2026-04-02 19:34:30 -05:00
statusBg: string
statusFg: string
statusGood: string
statusWarn: string
statusBad: string
2026-04-02 19:06:42 -05:00
statusCritical: string
selectionBg: string
2026-04-08 12:11:55 -05:00
diffAdded: string
diffRemoved: string
diffAddedWord: string
diffRemovedWord: string
2026-04-15 16:34:58 -05:00
shellDollar: string
2026-04-02 19:06:42 -05:00
}
export interface ThemeBrand {
2026-04-02 19:34:30 -05:00
name: string
icon: string
prompt: string
2026-04-02 19:06:42 -05:00
welcome: string
goodbye: string
2026-04-02 19:34:30 -05:00
tool: string
helpHeader: string
2026-04-02 19:06:42 -05:00
}
export interface Theme {
color: ThemeColors
brand: ThemeBrand
2026-04-07 23:59:11 -04:00
bannerLogo: string
bannerHero: string
2026-04-02 19:06:42 -05:00
}
// ── 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'
}
export const DARK_THEME: Theme = {
2026-04-02 19:06:42 -05:00
color: {
2026-04-02 19:34:30 -05:00
gold: '#FFD700',
amber: '#FFBF00',
bronze: '#CD7F32',
2026-04-02 19:06:42 -05:00
cornsilk: '#FFF8DC',
2026-04-02 19:34:30 -05:00
dim: '#B8860B',
completionBg: '#FFFFFF',
completionCurrentBg: mix('#FFFFFF', '#FFBF00', 0.25),
2026-04-02 19:06:42 -05:00
label: '#DAA520',
2026-04-02 19:34:30 -05:00
ok: '#4caf50',
error: '#ef5350',
warn: '#ffa726',
2026-04-02 19:06:42 -05:00
prompt: '#FFF8DC',
sessionLabel: '#B8860B',
sessionBorder: '#B8860B',
2026-04-02 19:34:30 -05:00
statusBg: '#1a1a2e',
statusFg: '#C0C0C0',
statusGood: '#8FBC8F',
statusWarn: '#FFD700',
statusBad: '#FF8C00',
2026-04-08 12:11:55 -05:00
statusCritical: '#FF6B6B',
selectionBg: '#3a3a55',
2026-04-08 12:11:55 -05:00
diffAdded: 'rgb(220,255,220)',
diffRemoved: 'rgb(255,220,220)',
diffAddedWord: 'rgb(36,138,61)',
2026-04-15 16:34:58 -05:00
diffRemovedWord: 'rgb(207,34,46)',
shellDollar: '#4dabf7'
2026-04-02 19:06:42 -05:00
},
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: {
gold: '#8B6914',
amber: '#A0651C',
bronze: '#7A4F1F',
cornsilk: '#3D2F13',
dim: '#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'
2026-04-07 23:59:11 -04:00
},
brand: BRAND,
2026-04-07 23:59:11 -04:00
bannerLogo: '',
bannerHero: ''
2026-04-02 19:06:42 -05:00
}
// Pick light vs dark. Explicit `HERMES_TUI_LIGHT` wins; otherwise sniff
// `COLORFGBG` (set by XFCE Terminal, rxvt, Terminal.app, etc.) — last field is the
// background ANSI index; 7/15 are the "white" slots most light themes emit (#11300).
export function detectLightMode(env: NodeJS.ProcessEnv = process.env): boolean {
const explicit = (env.HERMES_TUI_LIGHT ?? '').trim().toLowerCase()
if (/^(?:1|true|yes|on)$/.test(explicit)) {
return true
}
if (/^(?:0|false|no|off)$/.test(explicit)) {
return false
}
const bg = Number((env.COLORFGBG ?? '').trim().split(';').at(-1))
return bg === 7 || bg === 15
}
export const DEFAULT_THEME: Theme = detectLightMode() ? LIGHT_THEME : DARK_THEME
// ── Skin → Theme ─────────────────────────────────────────────────────
2026-04-07 23:59:11 -04:00
export function fromSkin(
colors: Record<string, string>,
branding: Record<string, string>,
bannerLogo = '',
bannerHero = '',
toolPrefix = '',
helpHeader = ''
2026-04-07 23:59:11 -04:00
): Theme {
2026-04-02 19:06:42 -05:00
const d = DEFAULT_THEME
const c = (k: string) => colors[k]
const amber = c('ui_accent') ?? c('banner_accent') ?? d.color.amber
const accent = c('banner_accent') ?? c('banner_title') ?? d.color.amber
const dim = c('banner_dim') ?? d.color.dim
2026-04-02 19:06:42 -05:00
return {
color: {
2026-04-02 19:34:30 -05:00
gold: c('banner_title') ?? d.color.gold,
amber,
2026-04-02 19:34:30 -05:00
bronze: c('banner_border') ?? d.color.bronze,
cornsilk: c('banner_text') ?? d.color.cornsilk,
dim,
completionBg: c('completion_menu_bg') ?? '#FFFFFF',
completionCurrentBg: c('completion_menu_current_bg') ?? mix('#FFFFFF', accent, 0.25),
2026-04-02 19:06:42 -05:00
2026-04-02 19:34:30 -05:00
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,
2026-04-02 19:06:42 -05:00
prompt: c('prompt') ?? c('banner_text') ?? d.color.prompt,
sessionLabel: c('session_label') ?? dim,
sessionBorder: c('session_border') ?? dim,
2026-04-02 19:34:30 -05:00
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,
2026-04-08 12:11:55 -05:00
statusCritical: d.color.statusCritical,
selectionBg: c('selection_bg') ?? d.color.selectionBg,
2026-04-08 12:11:55 -05:00
diffAdded: d.color.diffAdded,
diffRemoved: d.color.diffRemoved,
diffAddedWord: d.color.diffAddedWord,
2026-04-15 16:34:58 -05:00
diffRemovedWord: d.color.diffRemovedWord,
shellDollar: c('shell_dollar') ?? d.color.shellDollar
2026-04-02 19:06:42 -05:00
},
brand: {
2026-04-02 19:34:30 -05:00
name: branding.agent_name ?? d.brand.name,
icon: d.brand.icon,
prompt: 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)
2026-04-07 23:59:11 -04:00
},
bannerLogo,
bannerHero
2026-04-02 19:06:42 -05:00
}
}