feat(themes): cross-surface theme SDK — one skin themes CLI, TUI, and desktop

Make the Python skin engine the single source of truth for a canonical theme
shape consumed by every surface, so a skin authored in $HERMES_HOME/skins/*.yaml
(by a user or by Hermes from a prompt) themes the CLI, TUI, and desktop GUI at
once — the theme analogue of the plugin SDK.

- @hermes/shared: canonical `HermesSkin` token shape + `SKIN_COLOR_TOKENS` enum,
  consumed by both TS surfaces (TUI `GatewaySkin` and desktop dedup onto it).
- Desktop: `skinToDesktopTheme` resolver (skin → CSS-var palette, VS Code-style
  derive-from-seed) + `backend-sync` that registers backend skins into the theme
  registry (Appearance/Cmd-K/`/skin`) and applies on a real change. Seeds on
  gateway.ready (never stomps a persisted pick), applies on skin.changed and the
  post-turn `config.get skin` poll (catch-all for agent-edited config.yaml).
- TUI: `fromSkin` now maps the status bar + `background` keys it was dropping.
- Gateway: `config.get skin` also returns the full resolved palette (additive).
- Skill: `hermes-themes` teaches the agent to author + activate a skin.

Each surface keeps its own normalizing resolver (ansi for the TUI, CSS vars for
the desktop, prompt_toolkit/Rich for the CLI).
This commit is contained in:
Brooklyn Nicholson 2026-07-21 13:49:26 -05:00
parent 32a9f2acbc
commit a8444fbcae
19 changed files with 710 additions and 36 deletions

View file

@ -1,8 +1,10 @@
import type { HermesSkin } from '@hermes/shared/skin'
import { type MutableRefObject, useCallback, useRef, useState } from 'react'
import { getHermesConfig, getHermesConfigDefaults } from '@/hermes'
import { BUILTIN_PERSONALITIES, normalizePersonalityValue, personalityNamesFromConfig } from '@/lib/chat-runtime'
import { normalize } from '@/lib/text'
import { $gateway } from '@/store/gateway'
import {
$currentCwd,
getComposerSelectionGeneration,
@ -16,6 +18,9 @@ import {
setIntroPersonality
} from '@/store/session'
import { applyAutoSpeakFromConfig } from '@/store/voice-prefs'
// Leaf import (not the `@/themes` barrel) so this hook doesn't drag in the
// ThemeProvider/profile module graph — keeps it decoupled and test-mockable.
import { ingestBackendSkin } from '@/themes/backend-sync'
const DEFAULT_VOICE_SECONDS = 120
const FAST_TIERS = new Set(['fast', 'priority', 'on'])
@ -111,6 +116,16 @@ export function useHermesConfig({ activeSessionIdRef, refreshProjectBranch }: He
setVoiceMaxRecordingSeconds(recordingLimit(config.voice?.max_recording_seconds))
setSttEnabled(config.stt?.enabled !== false)
applyAutoSpeakFromConfig(config)
// Cross-surface skin sync: a skin Hermes authors/activates from a prompt
// edits config.yaml directly, which never emits `skin.changed`. The
// post-turn config refresh is our catch-all — fetch the resolved palette
// and repaint if the active skin name actually changed (guarded upstream).
void $gateway
.get()
?.request<{ skin?: HermesSkin }>('config.get', { key: 'skin' })
.then(res => ingestBackendSkin(res?.skin, { apply: true }))
.catch(() => undefined)
} catch {
// Config is nice-to-have; chat still works without it.
}

View file

@ -1,3 +1,4 @@
import type { HermesSkin } from '@hermes/shared/skin'
import type { QueryClient } from '@tanstack/react-query'
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
@ -45,6 +46,9 @@ import { clearActiveSessionTodos } from '@/store/todos'
import { recordToolDiff } from '@/store/tool-diffs'
import { reportInstallMethodWarning } from '@/store/updates'
import { notifyWorkspaceChanged, toolChangedPath, toolMayMutateFiles } from '@/store/workspace-events'
// Leaf import (not the `@/themes` barrel) to avoid pulling the ThemeProvider
// module graph into the gateway event hot path.
import { ingestBackendSkin } from '@/themes/backend-sync'
import type { RpcEvent } from '@/types/hermes'
import type { ClientSessionState } from '../../../types'
@ -181,6 +185,21 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
}
if (event.type === 'gateway.ready') {
// Seed the active skin into the desktop theme registry without applying,
// so a fresh connect never overrides the user's persisted desktop theme.
ingestBackendSkin((payload as { skin?: HermesSkin } | undefined)?.skin, { apply: false })
return
} else if (event.type === 'skin.changed') {
// A runtime skin switch (Hermes activating an authored skin, or `/skin`
// on another surface). Only the active profile's change repaints.
const fromActiveProfile =
!event.profile || normalizeProfileKey(event.profile) === normalizeProfileKey($activeGatewayProfile.get())
if (fromActiveProfile) {
ingestBackendSkin(payload as HermesSkin | undefined, { apply: true })
}
return
} else if (event.type === 'session.info') {
// Apply session-scoped fields when the event targets the active

View file

@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { $backendThemes, $pendingSkinApply, __resetBackendSkinSync, ingestBackendSkin } from './backend-sync'
const skin = (name: string) => ({ name, colors: { background: '#101020', ui_accent: '#ff33aa', banner_text: '#eeeeee' } })
describe('ingestBackendSkin', () => {
beforeEach(() => __resetBackendSkinSync())
it('registers a converted skin without applying when apply=false', () => {
ingestBackendSkin(skin('neon'), { apply: false })
expect($backendThemes.get().neon?.name).toBe('neon')
expect($pendingSkinApply.get()).toBeNull()
})
it('applies a new skin name once', () => {
ingestBackendSkin(skin('neon'), { apply: true })
expect($pendingSkinApply.get()).toBe('neon')
})
it('does not re-apply the same skin name', () => {
ingestBackendSkin(skin('neon'), { apply: true })
$pendingSkinApply.set(null)
ingestBackendSkin(skin('neon'), { apply: true })
expect($pendingSkinApply.get()).toBeNull()
})
it('applies again when the skin name changes', () => {
ingestBackendSkin(skin('neon'), { apply: true })
$pendingSkinApply.set(null)
ingestBackendSkin(skin('forest'), { apply: true })
expect($pendingSkinApply.get()).toBe('forest')
})
it('seeds on connect so the first matching poll is a no-op, but a change applies', () => {
ingestBackendSkin(skin('neon'), { apply: false }) // gateway.ready seed
ingestBackendSkin(skin('neon'), { apply: true }) // post-turn poll, unchanged
expect($pendingSkinApply.get()).toBeNull()
ingestBackendSkin(skin('forest'), { apply: true }) // Hermes authored a new skin
expect($pendingSkinApply.get()).toBe('forest')
})
it('treats default as no-opinion: never registers or applies it', () => {
ingestBackendSkin(skin('default'), { apply: true })
expect($pendingSkinApply.get()).toBeNull()
expect($backendThemes.get().default).toBeUndefined()
})
it('does not shadow a built-in name but can still apply it', () => {
ingestBackendSkin(skin('mono'), { apply: true })
expect($backendThemes.get().mono).toBeUndefined()
expect($pendingSkinApply.get()).toBe('mono')
})
it('ignores empty payloads', () => {
ingestBackendSkin(undefined, { apply: true })
ingestBackendSkin({ name: '' }, { apply: true })
expect($pendingSkinApply.get()).toBeNull()
})
})

View file

@ -0,0 +1,91 @@
/**
* Live skin sync from the Hermes backend.
*
* The backend resolves the active skin (built-in or `$HERMES_HOME/skins/*.yaml`)
* and announces it on `gateway.ready` / `skin.changed`, and answers `config.get
* skin` with the same payload. `ingestBackendSkin` folds that into the desktop:
*
* 1. Registers the converted theme in `$backendThemes` so it appears wherever a
* built-in does Appearance, Cmd-K, `/skin` with no per-surface wiring
* (`listAllThemes` merges this store).
* 2. When asked to apply (an explicit change), requests the switch via
* `$pendingSkinApply`, which the ThemeProvider drains through `setTheme`.
*
* `gateway.ready` seeds the baseline WITHOUT applying, so a fresh connect never
* stomps the user's persisted desktop theme; only a genuine name change (Hermes
* authoring/activating a skin from a prompt, or `/skin` elsewhere) repaints.
*/
import type { HermesSkin } from '@hermes/shared/skin'
import { atom } from 'nanostores'
import { BUILTIN_THEMES } from './presets'
import { skinToDesktopTheme } from './skin'
import type { DesktopTheme } from './types'
/** Skins pushed by the backend, keyed by name. Merged by `listAllThemes`. */
export const $backendThemes = atom<Record<string, DesktopTheme>>({})
/** One-shot skin name the ThemeProvider should switch to (it clears this). */
export const $pendingSkinApply = atom<string | null>(null)
// The last skin name we drove onto the desktop. Guards two things: re-applying
// the same skin every post-turn poll, and snapping back after a manual switch —
// only a CHANGE from this value applies. `default` is the "no opinion" sentinel.
let lastSynced: string | null = null
/** Test-only: reset the module's apply guard + registry between cases. */
export function __resetBackendSkinSync(): void {
lastSynced = null
$backendThemes.set({})
$pendingSkinApply.set(null)
}
/**
* Fold a resolved skin into the desktop. `apply: false` (connect-time seed) only
* records the baseline; `apply: true` (runtime change / poll) repaints on a name
* change. Built-in names keep the desktop's own palette but can still be applied.
*/
export function ingestBackendSkin(skin: HermesSkin | undefined | null, { apply }: { apply: boolean }): void {
const name = (skin && typeof skin === 'object' ? skin.name ?? '' : '').trim()
if (!name) {
return
}
// `default` is "no opinion" — the desktop keeps its own default (nous). Record
// it as the baseline so a real skin authored later reads as a change.
if (name === 'default') {
lastSynced = 'default'
return
}
// Built-in names (mono/slate/…) already have a hand-tuned desktop palette — we
// never shadow it, but the name is still a valid apply target.
if (!BUILTIN_THEMES[name]) {
const theme = skinToDesktopTheme(skin as HermesSkin)
if (!theme) {
return
}
const current = $backendThemes.get()
if (JSON.stringify(current[name]) !== JSON.stringify(theme)) {
$backendThemes.set({ ...current, [name]: theme })
}
}
if (!apply) {
// Connect-time seed: record the baseline so a later poll is a no-op.
lastSynced = name
return
}
if (name !== lastSynced) {
lastSynced = name
$pendingSkinApply.set(name)
}
}

View file

@ -17,8 +17,9 @@ import { matchesQuery, useMediaQuery } from '@/hooks/use-media-query'
import { persistString, persistStringRecord, storedString, storedStringRecord } from '@/lib/storage'
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
import { $backendThemes, $pendingSkinApply } from './backend-sync'
import { hexToRgb, mix, readableOn } from './color'
import { BUILTIN_THEME_LIST, BUILTIN_THEMES, DEFAULT_SKIN_NAME, DEFAULT_TYPOGRAPHY, nousTheme } from './presets'
import { BUILTIN_THEME_LIST, DEFAULT_SKIN_NAME, DEFAULT_TYPOGRAPHY, nousTheme } from './presets'
import type { DesktopTheme, DesktopThemeColors } from './types'
import { $userThemes, listAllThemes, resolveTheme } from './user-themes'
@ -319,6 +320,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
// import or a plugin registration shows up live in the palette, settings
// grid, and `/skin` without a reload.
const userThemes = useStore($userThemes)
const backendThemes = useStore($backendThemes)
const registryVersion = useStore($registryVersion)
const availableThemes = useMemo(
@ -328,9 +330,9 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
label,
description
})),
// userThemes + registryVersion ARE listAllThemes' reactivity.
// userThemes + backendThemes + registryVersion ARE listAllThemes' reactivity.
// eslint-disable-next-line react-hooks/exhaustive-deps
[userThemes, registryVersion]
[userThemes, backendThemes, registryVersion]
)
const [themeName, setThemeNameState] = useState(() =>
@ -377,6 +379,18 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
modePref.assign(liveProfile(), next)
}, [])
// Drain a backend-driven skin switch (Hermes authoring/activating a skin from a
// prompt, or `/skin` on another surface). setTheme persists it per profile, so
// the choice sticks like any manual pick.
const pendingSkin = useStore($pendingSkinApply)
useEffect(() => {
if (pendingSkin) {
setTheme(pendingSkin)
$pendingSkinApply.set(null)
}
}, [pendingSkin, setTheme])
// The light/dark toggle (Shift+X by default) is owned by the keybind runtime
// (`appearance.toggleMode`) so it shows up in the hotkey map and is rebindable.
@ -389,12 +403,3 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
}
export const useTheme = (): ThemeContextValue => useContext(ThemeContext)
/** Sync the desktop skin with the active Hermes backend theme on connect. */
export function useSyncThemeFromBackend(backendThemeName: string | undefined, setTheme: (name: string) => void) {
useEffect(() => {
if (backendThemeName && BUILTIN_THEMES[backendThemeName]) {
setTheme(backendThemeName)
}
}, [backendThemeName, setTheme])
}

View file

@ -1,3 +1,6 @@
export { ThemeProvider, useSyncThemeFromBackend, useTheme } from './context'
export { ingestBackendSkin } from './backend-sync'
export { ThemeProvider, useTheme } from './context'
export { BUILTIN_THEME_LIST, BUILTIN_THEMES, DEFAULT_SKIN_NAME } from './presets'
export { skinToDesktopTheme } from './skin'
export type { DesktopTheme, DesktopThemeColors, DesktopThemeTypography } from './types'
export type { HermesSkin } from '@hermes/shared/skin'

View file

@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { luminance, normalizeHex } from './color'
import { skinToDesktopTheme } from './skin'
const withColors = (name: string, colors: Record<string, string>) => skinToDesktopTheme({ name, colors })
describe('skinToDesktopTheme', () => {
it('returns null without a name or colors', () => {
expect(skinToDesktopTheme({ name: 'x' })).toBeNull()
expect(skinToDesktopTheme({ name: '', colors: { background: '#101010' } })).toBeNull()
})
it('maps the accent onto every brand token and keeps a single palette', () => {
const theme = withColors('neon', { background: '#101020', ui_accent: '#ff33aa', banner_text: '#eeeeee' })!
expect(theme.name).toBe('neon')
expect(theme.colors.ring).toBe(theme.colors.primary)
expect(theme.colors.midground).toBe(theme.colors.primary)
// A skin is single-mode: the light/dark toggle must not invert it.
expect(theme.colors).toBe(theme.darkColors)
})
it('seeds the background from status_bar_bg when none is explicit', () => {
const theme = withColors('s', { status_bar_bg: '#0b0b0b', banner_text: '#ffffff' })!
expect(theme.colors.background).toBe('#0b0b0b')
expect(theme.colors.foreground).toBe('#ffffff')
})
it('buckets dark vs light from background luminance', () => {
const dark = withColors('d', { background: '#111111', banner_text: '#eeeeee' })!
const light = withColors('l', { background: '#fafafa', banner_text: '#111111' })!
expect(luminance(dark.colors.background)).toBeLessThan(0.4)
expect(luminance(light.colors.background)).toBeGreaterThan(0.4)
})
it('derives a dark base from light text when no background is given', () => {
const theme = withColors('x', { banner_text: '#eeeeee', ui_accent: '#33ccff' })!
expect(luminance(theme.colors.background)).toBeLessThan(0.4)
})
it('maps ui_error to destructive', () => {
const theme = withColors('e', { background: '#101010', ui_error: '#ff5566' })!
expect(theme.colors.destructive).toBe(normalizeHex('#ff5566'))
})
})

View file

@ -0,0 +1,113 @@
/**
* Hermes skin DesktopTheme converter.
*
* A "skin" is the CLI/TUI theme unit: a YAML file in `$HERMES_HOME/skins/` (or a
* built-in) resolved by `hermes_cli/skin_engine.py` and pushed to every surface
* over JSON-RPC (`gateway.ready`, `skin.changed`, `config.get skin`). This is the
* one place the desktop turns that CLI-shaped palette into a `DesktopTheme`, so a
* skin Hermes authors from a prompt lights up all three surfaces from one file.
*
* Skins carry terminal-oriented keys (banner/status/completion). We seed the
* desktop model from the load-bearing few (background, foreground, accent, error)
* and derive every glass/shadcn surface by mixing toward bg/fg the same "naive
* token converter" strategy as the VS Code importer. A skin is single-mode, so
* both `colors` and `darkColors` get the converted palette; `renderedModeFor`
* still picks `.dark` from the real background luminance.
*/
import type { HermesSkin, SkinColors } from '@hermes/shared/skin'
import { ensureContrast, luminance, mix, normalizeHex, readableOn } from './color'
import type { DesktopTheme, DesktopThemeColors } from './types'
// The accent labels the sidebar in small uppercase text, so it must clear WCAG AA
// for normal text or section headers go invisible — mirrors the VS Code importer.
const ACCENT_MIN_CONTRAST = 4.5
/** First normalizable hex among `keys`, alpha flattened over `backdrop`. */
const pick = (colors: SkinColors, keys: string[], backdrop: string): string | null => {
for (const key of keys) {
const value = normalizeHex(colors[key], backdrop)
if (value) {
return value
}
}
return null
}
const titleCase = (name: string): string => name.charAt(0).toUpperCase() + name.slice(1)
/**
* Convert a resolved skin into a `DesktopTheme`, or null when it carries no
* usable colors (so a broken/empty skin never registers junk).
*/
export function skinToDesktopTheme(skin: HermesSkin): DesktopTheme | null {
const name = (skin.name ?? '').trim()
const colors = skin.colors
if (!name || !colors || typeof colors !== 'object') {
return null
}
// Background is the backdrop every other token flattens alpha over. Skins are
// terminal-first so most only tint chrome — `status_bar_bg` is the closest
// thing to an app surface; `background` is the explicit opt-in for GUI authors.
const seededBg = pick(colors, ['background', 'status_bar_bg'], '#000000')
const foregroundSeed = pick(colors, ['ui_text', 'banner_text', 'status_bar_text'], seededBg ?? '#000000')
// No background given: bucket by foreground luminance (light text ⇒ dark app).
const background = seededBg ?? (foregroundSeed && luminance(foregroundSeed) > 0.5 ? '#141414' : '#f7f7f8')
const dark = luminance(background) < 0.4
const foreground = foregroundSeed ?? (dark ? '#e6e6e6' : '#161616')
const accentSeed =
pick(colors, ['ui_accent', 'banner_accent', 'banner_title'], background) ?? mix(foreground, background, 0.55)
const sidebar = mix(background, foreground, dark ? 0.02 : 0.012)
const accent = ensureContrast(accentSeed, sidebar, ACCENT_MIN_CONTRAST)
const border = pick(colors, ['ui_border', 'banner_border'], background) ?? mix(background, foreground, dark ? 0.16 : 0.14)
const mutedForeground = pick(colors, ['banner_dim', 'session_border'], background) ?? mix(foreground, background, 0.45)
const destructive = pick(colors, ['ui_error'], background) ?? '#e25563'
const palette: DesktopThemeColors = {
background,
foreground,
card: mix(background, foreground, dark ? 0.04 : 0.025),
cardForeground: foreground,
muted: mix(background, foreground, dark ? 0.06 : 0.04),
mutedForeground,
popover: mix(background, foreground, dark ? 0.08 : 0.05),
popoverForeground: foreground,
primary: accent,
primaryForeground: readableOn(accent),
secondary: mix(accent, background, dark ? 0.72 : 0.86),
secondaryForeground: foreground,
accent: mix(accent, background, dark ? 0.82 : 0.88),
accentForeground: foreground,
border,
input: pick(colors, ['completion_menu_bg'], background) ?? mix(background, foreground, dark ? 0.1 : 0.06),
ring: accent,
midground: accent,
midgroundForeground: readableOn(accent),
composerRing: accent,
destructive,
destructiveForeground: readableOn(destructive),
sidebarBackground: sidebar,
sidebarBorder: border,
userBubble: mix(background, accent, dark ? 0.18 : 0.12),
userBubbleBorder: border
}
return {
name,
label: titleCase(name),
description: 'Hermes skin',
// Single palette in both slots: a skin is one-mode, so the light/dark toggle
// shouldn't invert it. renderedModeFor still paints `.dark` from luminance.
colors: palette,
darkColors: palette
}
}

View file

@ -14,6 +14,7 @@ import { atom, computed } from 'nanostores'
import { registry } from '@/contrib/registry'
import { $backendThemes } from './backend-sync'
import { BUILTIN_THEMES } from './presets'
import type { DesktopTheme, DesktopThemeColors } from './types'
@ -167,18 +168,26 @@ export function contributedThemes(): DesktopTheme[] {
return out
}
/** Resolve a theme by name across the merged set (built-in + user + contributed). */
/** Resolve a theme by name across the merged set (built-in + user + backend + contributed). */
export function resolveTheme(name: string): DesktopTheme | undefined {
return BUILTIN_THEMES[name] ?? $userThemes.get()[name] ?? contributedThemes().find(theme => theme.name === name)
return (
BUILTIN_THEMES[name] ??
$userThemes.get()[name] ??
$backendThemes.get()[name] ??
contributedThemes().find(theme => theme.name === name)
)
}
/** Built-ins first (stable order), then contributed, then user installs. */
/** Built-ins first (stable order), then contributed, then backend skins, then user installs. */
export function listAllThemes(): DesktopTheme[] {
const user = $userThemes.get()
const backend = $backendThemes.get()
const shadows = (theme: DesktopTheme) => user[theme.name] || backend[theme.name]
return [
...Object.values(BUILTIN_THEMES),
...contributedThemes().filter(theme => !user[theme.name]),
...contributedThemes().filter(theme => !shadows(theme)),
...Object.values(backend).filter(theme => !user[theme.name]),
...Object.values(user)
]
}

View file

@ -7,7 +7,8 @@
".": "./src/index.ts",
"./billing": "./src/billing-types.ts",
"./billing-policy": "./src/billing-policy.ts",
"./charge-settlement": "./src/charge-settlement.ts"
"./charge-settlement": "./src/charge-settlement.ts",
"./skin": "./src/skin.ts"
},
"types": "./src/index.ts",
"scripts": {

View file

@ -42,6 +42,15 @@ export {
JsonRpcGatewayClient,
type WebSocketLike
} from './json-rpc-gateway'
export {
type HermesSkin,
SKIN_BRANDING_TOKENS,
SKIN_COLOR_TOKENS,
type SkinBranding,
type SkinBrandingToken,
type SkinColors,
type SkinColorToken
} from './skin'
export {
buildHermesWebSocketUrl,
type GatewayAuthMode,

99
apps/shared/src/skin.ts Normal file
View file

@ -0,0 +1,99 @@
/**
* Canonical Hermes skin the theme SDK's cross-surface contract.
*
* A skin is authored once as YAML in `$HERMES_HOME/skins/<name>.yaml` (or a
* built-in), resolved by the Python skin engine (`hermes_cli/skin_engine.py`),
* and pushed to every surface over JSON-RPC (`gateway.ready`, `skin.changed`,
* `config.get skin`). This is the ONE shape every TypeScript surface consumes;
* each owns a resolver that normalizes it into its render model:
*
* TUI `fromSkin` ansi-safe `Theme` (Ink)
* Desktop `skinToDesktopTheme` CSS custom properties (Tailwind/shadcn)
* CLI `hermes_cli/skin_engine` prompt_toolkit / Rich styles (Python)
*
* Tokens are terminal-first (the CLI is the oldest surface); GUIs derive their
* fuller palettes from the load-bearing few. Every field is optional a resolver
* falls back to its own default for anything a skin omits.
*/
/** Canonical semantic color tokens a skin may set (the "enum" of the shape). */
export const SKIN_COLOR_TOKENS = [
// Base surface — GUIs + the TUI status bar derive their palette from this.
'background',
// Brand accent + primary.
'ui_accent',
'ui_primary',
'banner_accent',
'banner_title',
// Text.
'ui_text',
'banner_text',
'banner_dim',
// Structure.
'ui_border',
'banner_border',
// Semantic status.
'ui_ok',
'ui_warn',
'ui_error',
'ui_label',
// CLI / TUI chrome.
'prompt',
'input_rule',
'response_border',
'shell_dollar',
'selection_bg',
'session_label',
'session_border',
'status_bar_bg',
'status_bar_text',
'status_bar_strong',
'status_bar_dim',
'status_bar_good',
'status_bar_warn',
'status_bar_bad',
'status_bar_critical',
'voice_status_bg',
'completion_menu_bg',
'completion_menu_current_bg',
'completion_menu_meta_bg',
'completion_menu_meta_current_bg'
] as const
export type SkinColorToken = (typeof SKIN_COLOR_TOKENS)[number]
/** Canonical branding/string tokens. */
export const SKIN_BRANDING_TOKENS = [
'agent_name',
'welcome',
'goodbye',
'response_label',
'prompt_symbol',
'help_header'
] as const
export type SkinBrandingToken = (typeof SKIN_BRANDING_TOKENS)[number]
/** Hex color per token. Open-ended so back-compat / niche keys still round-trip. */
export type SkinColors = Partial<Record<SkinColorToken, string>> & { [key: string]: string | undefined }
/** Branding strings per token. Open-ended for the same reason. */
export type SkinBranding = Partial<Record<SkinBrandingToken, string>> & { [key: string]: string | undefined }
/** The resolved skin payload (matches Python's `resolve_skin()`). */
export interface HermesSkin {
name?: string
description?: string
colors?: SkinColors
/** Hand-tuned palette overlay for dark terminals (light-authored skins).
* A resolver picks colors/light_colors/dark_colors by the terminal's
* detected polarity see the TUI's `themeForSkin`. */
dark_colors?: SkinColors
/** Hand-tuned palette overlay for light terminals (dark-authored skins). */
light_colors?: SkinColors
branding?: SkinBranding
banner_logo?: string
banner_hero?: string
tool_prefix?: string
help_header?: string
}

View file

@ -1,9 +1,15 @@
"""Hermes CLI skin/theme engine.
"""Hermes skin/theme engine — the theme SDK for every surface.
A data-driven skin system that lets users customize the CLI's visual appearance.
Skins are defined as YAML files in ~/.hermes/skins/ or as built-in presets.
A data-driven skin system that lets users (and Hermes itself) customize the
visual appearance across the CLI, the TUI, and the desktop GUI from a single
file. Skins are defined as YAML files in ~/.hermes/skins/ or as built-in presets.
No code changes are needed to add a new skin.
This module is the source of truth: it resolves the active skin, and the gateway
pushes the resolved palette to the TUI and desktop (see tui_gateway's
``resolve_skin`` / ``skin.changed``). A skin dropped in ~/.hermes/skins/ therefore
themes all three surfaces at once the theme analogue of the plugin SDK.
SKIN YAML SCHEMA
================
@ -17,6 +23,9 @@ All fields are optional. Missing values inherit from the ``default`` skin.
# Colors: hex values for Rich markup (banner, UI, response box)
colors:
background: "#0e0e12" # App/base surface — the seed the TUI
# status bar and the desktop GUI derive
# their whole palette from (see below).
banner_border: "#CD7F32" # Panel border color
banner_title: "#FFD700" # Panel title text color
banner_accent: "#FFBF00" # Section headers (Available Tools, etc.)

View file

@ -0,0 +1,99 @@
---
name: hermes-themes
description: "Author a Hermes color theme that skins every surface."
version: 1.0.0
platforms: [linux, macos, windows]
metadata:
hermes:
tags: [theme, skin, appearance, cli, tui, desktop, self-config]
related_skills: []
---
# Hermes Themes Skill
Author a Hermes **skin** — one YAML file that themes the CLI, the TUI, and the
desktop GUI at once. The skin engine (`hermes_cli/skin_engine.py`) resolves the
active skin and the gateway pushes it to every surface, so a file dropped in
`~/.hermes/skins/` is the theme analogue of a plugin: no code, all surfaces. This
skill covers writing a good skin and activating it; it does not build GUI theme
editors or ship built-in presets.
## When to Use
- The user asks for a custom look ("make me a synthwave theme", "dark forest
vibes", "match my brand colors") for Hermes itself.
- The user wants the CLI/TUI/desktop to share one coordinated palette.
## Prerequisites
- Write access to the Hermes home dir — `~/.hermes` by default, or `$HERMES_HOME`
/ the active profile's dir. Skins live in `<hermes-home>/skins/`.
- Native tools: `write_file` (create the YAML), `read_file` / `search_files`
(inspect existing skins), `patch` (set `display.skin`).
## How to Run
1. Pick a lowercase, hyphen-safe `name` (e.g. `synthwave`).
2. Copy `templates/skin.yaml` and fill in the palette (keep every key — missing
keys inherit the `default` skin).
3. `write_file` it to `<hermes-home>/skins/<name>.yaml`.
4. Activate it (see Procedure). Confirm the change landed.
## Quick Reference
Load-bearing color keys (hex, `#rrggbb`). The desktop GUI derives its whole
palette from these; the TUI and CLI read the terminal-oriented ones directly.
| Key | Drives |
|---|---|
| `background` | Base surface — GUI + TUI status bar seed. Set it. |
| `ui_accent` / `banner_accent` | Brand accent: buttons, rings, primary. |
| `banner_title` | Headings / primary text. |
| `banner_text` / `ui_text` | Body foreground. |
| `banner_border` / `ui_border` | Borders. |
| `banner_dim` | Muted / secondary text. |
| `ui_ok` / `ui_warn` / `ui_error` | Semantic status colors. |
| `status_bar_bg` / `status_bar_text` | TUI status bar. |
| `response_border` | CLI response box. |
`branding` (`agent_name`, `welcome`, `goodbye`, `prompt_symbol`, `help_header`),
`spinner` (faces/verbs/wings), and `tool_prefix` are optional flavor. See the
full schema in `hermes_cli/skin_engine.py`.
## Procedure
1. **Design the palette.** Choose a `background` first, then an `ui_accent` that
clears WCAG AA against it (~4.5:1) so labels stay legible — the GUI enforces
contrast but a low-contrast accent still looks washed out. Keep
`ui_ok`/`ui_warn`/`ui_error` recognizably green/amber/red.
2. **Write the file** to `<hermes-home>/skins/<name>.yaml`. Every top-level
`colors` key from the template should be present.
3. **Activate.** Set `display.skin: <name>` in `<hermes-home>/config.yaml` with
`patch` (create the `display:` block if absent). This is the source of truth
all surfaces read.
- **Desktop**: repaints automatically after the current turn (and the skin
appears in Appearance / `Cmd-K` / `/skin`).
- **CLI / TUI**: run `/skin <name>` for an immediate switch, or it loads on
next start.
4. **Confirm** and tell the user how to switch back (`/skin default`).
## Pitfalls
- **Don't hardcode `~/.hermes`** when a profile is active — resolve the real home
from `$HERMES_HOME` first, falling back to `~/.hermes`.
- **Keep `#rrggbb` hex.** Shorthand `#rgb`, `rgb()`, and named colors are not
guaranteed to parse on every surface.
- **Set `background`.** Without it the GUI has to guess a base surface from text
luminance — usable, but you lose control of the app background.
- **Name collisions**: a skin named like a desktop built-in (`mono`, `slate`,
`cyberpunk`, `nous`, `midnight`, `ember`) won't override that built-in on the
GUI. Pick a fresh name.
- **Don't rebuild config.yaml**`patch` only the `display.skin` line so you
don't clobber the user's other settings.
## Verification
- `read_file` the written `<hermes-home>/skins/<name>.yaml` and confirm valid
YAML with the intended `name` and `colors`.
- `read_file` `<hermes-home>/config.yaml` and confirm `display.skin: <name>`.
- Ask the user to confirm the new look, or check the current surface repainted.

View file

@ -0,0 +1,49 @@
# Hermes skin template — themes the CLI, TUI, and desktop GUI from one file.
# Copy to <hermes-home>/skins/<name>.yaml, edit the palette, then set
# `display.skin: <name>` in config.yaml. Missing keys inherit the default skin.
name: synthwave
description: Neon dusk — magenta and cyan on deep indigo
colors:
# Base surface — set this. The GUI derives its palette from it; the TUI
# status bar and CLI chrome seed from it too.
background: "#1a1030"
# Brand accent — buttons, focus rings, GUI primary. Keep it legible (~4.5:1)
# against `background`.
ui_accent: "#ff5fd2"
banner_accent: "#ff5fd2"
# Text hierarchy.
banner_title: "#7ef9ff" # headings / primary
banner_text: "#e6e0ff" # body foreground
ui_text: "#e6e0ff"
banner_dim: "#8a7fb5" # muted / secondary
banner_border: "#3a2a63"
ui_border: "#3a2a63"
# Semantic status.
ui_ok: "#5af7b0"
ui_warn: "#ffcf5f"
ui_error: "#ff6b7d"
# CLI / TUI chrome.
prompt: "#e6e0ff"
input_rule: "#ff5fd2"
response_border: "#7ef9ff"
status_bar_bg: "#120a24"
status_bar_text: "#e6e0ff"
status_bar_good: "#5af7b0"
status_bar_warn: "#ffcf5f"
status_bar_critical: "#ff6b7d"
session_label: "#7ef9ff"
session_border: "#3a2a63"
# Optional flavor — safe to delete.
branding:
agent_name: Hermes Agent
prompt_symbol: ""
help_header: "(^_^)? Commands"
tool_prefix: "┊"

View file

@ -12556,8 +12556,15 @@ def _(rid, params: dict) -> dict:
if key == "prompt":
return _ok(rid, {"prompt": _load_cfg().get("custom_prompt", "")})
if key == "skin":
# `value` is the active skin name (back-compat, used by the TUI). `skin`
# carries the full resolved palette so cross-surface consumers (the
# desktop) can rebuild the theme without their own YAML loader.
return _ok(
rid, {"value": (_load_cfg().get("display") or {}).get("skin", "default")}
rid,
{
"value": (_load_cfg().get("display") or {}).get("skin", "default"),
"skin": resolve_skin(),
},
)
if key == "indicator":
# Normalize so a hand-edited config.yaml with stray casing or

View file

@ -532,4 +532,32 @@ describe('background-aware adaptation (OSC-11 light terminals)', () => {
// …then the OSC-11 answer lands and is cached into the env slot.
expect(defaultThemeForCurrentBackground({ HERMES_TUI_BACKGROUND: '#ffffff' }).color).toEqual(LIGHT_THEME.color)
})
it('maps the status bar from skin status_bar_* keys', async () => {
const { fromSkin } = await importThemeWithCleanEnv()
const { color } = fromSkin(
{
status_bar_bg: '#101020',
status_bar_text: '#e0e0e0',
status_bar_bad: '#ff8800',
status_bar_critical: '#ff0000'
},
{}
)
expect(color.statusBg).toBe('#101020')
expect(color.statusFg).toBe('#e0e0e0')
expect(color.statusBad).toBe('#ff8800')
expect(color.statusCritical).toBe('#ff0000')
})
it('falls the status bar back to background + semantic colors', async () => {
const { fromSkin } = await importThemeWithCleanEnv()
const { color } = fromSkin({ background: '#0a0a0a', banner_text: '#fafafa', ui_error: '#dd2222' }, {})
expect(color.statusBg).toBe('#0a0a0a')
expect(color.statusFg).toBe('#fafafa')
expect(color.statusCritical).toBe('#dd2222')
})
})

View file

@ -1,19 +1,11 @@
import type { UsageModelData } from '@hermes/shared/billing'
import type { HermesSkin } from '@hermes/shared/skin'
import type { SessionInfo, SlashCategory, SubagentStatus, Usage } from './types.js'
export interface GatewaySkin {
banner_hero?: string
banner_logo?: string
branding?: Record<string, string>
colors?: Record<string, string>
/** Hand-tuned palette for dark terminals (light-authored skins). */
dark_colors?: Record<string, string>
help_header?: string
/** Hand-tuned palette for light terminals (dark-authored skins). */
light_colors?: Record<string, string>
tool_prefix?: string
}
/** The cross-surface skin contract (canonical shape in `@hermes/shared`).
* Includes the paired light_colors/dark_colors overlays from #20379. */
export type GatewaySkin = HermesSkin
export interface GatewayCompletionItem {
display: string

View file

@ -1,3 +1,5 @@
import type { SkinBranding, SkinColors } from '@hermes/shared/skin'
import { desaturate, grayOf, liftForContrast, mix, parseColor, relativeLuminance, toHex } from './lib/color.js'
export interface ThemeColors {
@ -763,8 +765,8 @@ export function defaultThemeForCurrentBackground(env: NodeJS.ProcessEnv = proces
// ── Skin → Theme ─────────────────────────────────────────────────────
export function fromSkin(
colors: Record<string, string>,
branding: Record<string, string>,
colors: SkinColors,
branding: SkinBranding,
bannerLogo = '',
bannerHero = '',
toolPrefix = '',
@ -848,6 +850,12 @@ export function fromSkin(
return normalizeThemeForAnsiLightTerminal(
{
// The element tokens theme-sdk introduced (ui_primary, ui_text,
// ui_border, ui_ok/warn/error, shell_dollar, status_bar_*) are read
// above into `seeds` 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: {