hermes-agent/ui-tui/src/theme.ts
Brooklyn Nicholson aedc767c66 feat(tui): put the kawaii face+verb ticker in the status bar, not the thinking panel
The status bar was showing stale lifecycle text ("running…") while the
face+verb stream flickered through the thinking panel as Python pushed
thinking.delta events. That's backwards — the face ticker is the
primary "I'm alive" signal, it belongs in the status bar; the thinking
panel is for substantive reasoning and tool activity.

Status bar now reads `ui.busy`: when true, renders a local `<FaceTicker>`
cycling FACES × VERBS on a 2.5s interval, unaffected by server events.
When false, the bar shows the actual status string (ready, starting
agent…, interrupted, etc.).

Side effect: `scheduleThinkingStatus` still patches `ui.status` with
Python's face text, but while busy the bar ignores that string and uses
the ticker instead. No server-side changes needed — Python keeps
emitting thinking.delta as a liveness heartbeat, the TUI just doesn't
let it fight the status bar.
2026-04-16 20:14:25 -05:00

193 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

export interface ThemeColors {
gold: string
amber: string
bronze: string
cornsilk: string
dim: 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 ─────────────────────────────────────────────────────────
export const DEFAULT_THEME: Theme = {
color: {
gold: '#FFD700',
amber: '#FFBF00',
bronze: '#CD7F32',
cornsilk: '#FFF8DC',
dim: '#B8860B',
completionBg: '#FFFFFF',
completionCurrentBg: mix('#FFFFFF', '#FFBF00', 0.25),
label: '#DAA520',
ok: '#4caf50',
error: '#ef5350',
warn: '#ffa726',
prompt: '#FFF8DC',
sessionLabel: '#B8860B',
sessionBorder: '#B8860B',
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: {
name: 'Hermes Agent',
icon: '⚕',
prompt: '',
welcome: 'Type your message or /help for commands.',
goodbye: 'Goodbye! ⚕',
tool: '┊',
helpHeader: '(^_^)? Commands'
},
bannerLogo: '',
bannerHero: ''
}
// ── 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 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
return {
color: {
gold: c('banner_title') ?? d.color.gold,
amber,
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),
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') ?? dim,
sessionBorder: c('session_border') ?? dim,
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: 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
}
}