From a8444fbcae2b89d74d396e00aba6b8b10a66d6c9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Jul 2026 13:49:26 -0500 Subject: [PATCH] =?UTF-8?q?feat(themes):=20cross-surface=20theme=20SDK=20?= =?UTF-8?q?=E2=80=94=20one=20skin=20themes=20CLI,=20TUI,=20and=20desktop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../app/session/hooks/use-hermes-config.ts | 15 +++ .../hooks/use-message-stream/gateway-event.ts | 19 +++ apps/desktop/src/themes/backend-sync.test.ts | 68 +++++++++++ apps/desktop/src/themes/backend-sync.ts | 91 ++++++++++++++ apps/desktop/src/themes/context.tsx | 29 +++-- apps/desktop/src/themes/index.ts | 5 +- apps/desktop/src/themes/skin.test.ts | 50 ++++++++ apps/desktop/src/themes/skin.ts | 113 ++++++++++++++++++ apps/desktop/src/themes/user-themes.ts | 17 ++- apps/shared/package.json | 3 +- apps/shared/src/index.ts | 9 ++ apps/shared/src/skin.ts | 99 +++++++++++++++ hermes_cli/skin_engine.py | 15 ++- skills/hermes-themes/SKILL.md | 99 +++++++++++++++ skills/hermes-themes/templates/skin.yaml | 49 ++++++++ tui_gateway/server.py | 9 +- ui-tui/src/__tests__/theme.test.ts | 28 +++++ ui-tui/src/gatewayTypes.ts | 16 +-- ui-tui/src/theme.ts | 12 +- 19 files changed, 710 insertions(+), 36 deletions(-) create mode 100644 apps/desktop/src/themes/backend-sync.test.ts create mode 100644 apps/desktop/src/themes/backend-sync.ts create mode 100644 apps/desktop/src/themes/skin.test.ts create mode 100644 apps/desktop/src/themes/skin.ts create mode 100644 apps/shared/src/skin.ts create mode 100644 skills/hermes-themes/SKILL.md create mode 100644 skills/hermes-themes/templates/skin.yaml diff --git a/apps/desktop/src/app/session/hooks/use-hermes-config.ts b/apps/desktop/src/app/session/hooks/use-hermes-config.ts index 52681e20a858..198f06a13b40 100644 --- a/apps/desktop/src/app/session/hooks/use-hermes-config.ts +++ b/apps/desktop/src/app/session/hooks/use-hermes-config.ts @@ -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. } diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 9d2ded2e5f6c..2f7f50cec716 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -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 diff --git a/apps/desktop/src/themes/backend-sync.test.ts b/apps/desktop/src/themes/backend-sync.test.ts new file mode 100644 index 000000000000..63b28712e860 --- /dev/null +++ b/apps/desktop/src/themes/backend-sync.test.ts @@ -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() + }) +}) diff --git a/apps/desktop/src/themes/backend-sync.ts b/apps/desktop/src/themes/backend-sync.ts new file mode 100644 index 000000000000..f61468290666 --- /dev/null +++ b/apps/desktop/src/themes/backend-sync.ts @@ -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>({}) + +/** One-shot skin name the ThemeProvider should switch to (it clears this). */ +export const $pendingSkinApply = atom(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) + } +} diff --git a/apps/desktop/src/themes/context.tsx b/apps/desktop/src/themes/context.tsx index 6c24511043f6..7266d8f70fbd 100644 --- a/apps/desktop/src/themes/context.tsx +++ b/apps/desktop/src/themes/context.tsx @@ -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]) -} diff --git a/apps/desktop/src/themes/index.ts b/apps/desktop/src/themes/index.ts index d33c752c077a..62bbfe289d4b 100644 --- a/apps/desktop/src/themes/index.ts +++ b/apps/desktop/src/themes/index.ts @@ -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' diff --git a/apps/desktop/src/themes/skin.test.ts b/apps/desktop/src/themes/skin.test.ts new file mode 100644 index 000000000000..353a690ad4fc --- /dev/null +++ b/apps/desktop/src/themes/skin.test.ts @@ -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) => 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')) + }) +}) diff --git a/apps/desktop/src/themes/skin.ts b/apps/desktop/src/themes/skin.ts new file mode 100644 index 000000000000..217bf7641dd6 --- /dev/null +++ b/apps/desktop/src/themes/skin.ts @@ -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 + } +} diff --git a/apps/desktop/src/themes/user-themes.ts b/apps/desktop/src/themes/user-themes.ts index 4d63fdb00875..3dfba148d4ba 100644 --- a/apps/desktop/src/themes/user-themes.ts +++ b/apps/desktop/src/themes/user-themes.ts @@ -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) ] } diff --git a/apps/shared/package.json b/apps/shared/package.json index d678748333f4..beb2340f4f75 100644 --- a/apps/shared/package.json +++ b/apps/shared/package.json @@ -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": { diff --git a/apps/shared/src/index.ts b/apps/shared/src/index.ts index 9fd163efa007..b951bc4700e9 100644 --- a/apps/shared/src/index.ts +++ b/apps/shared/src/index.ts @@ -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, diff --git a/apps/shared/src/skin.ts b/apps/shared/src/skin.ts new file mode 100644 index 000000000000..b130d190e678 --- /dev/null +++ b/apps/shared/src/skin.ts @@ -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/.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> & { [key: string]: string | undefined } + +/** Branding strings per token. Open-ended for the same reason. */ +export type SkinBranding = Partial> & { [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 +} diff --git a/hermes_cli/skin_engine.py b/hermes_cli/skin_engine.py index b176ca297da9..8f018249cf1f 100644 --- a/hermes_cli/skin_engine.py +++ b/hermes_cli/skin_engine.py @@ -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.) diff --git a/skills/hermes-themes/SKILL.md b/skills/hermes-themes/SKILL.md new file mode 100644 index 000000000000..35d664c5dbad --- /dev/null +++ b/skills/hermes-themes/SKILL.md @@ -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 `/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 `/skins/.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 `/skins/.yaml`. Every top-level + `colors` key from the template should be present. +3. **Activate.** Set `display.skin: ` in `/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 ` 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 `/skins/.yaml` and confirm valid + YAML with the intended `name` and `colors`. +- `read_file` `/config.yaml` and confirm `display.skin: `. +- Ask the user to confirm the new look, or check the current surface repainted. diff --git a/skills/hermes-themes/templates/skin.yaml b/skills/hermes-themes/templates/skin.yaml new file mode 100644 index 000000000000..1316511413b7 --- /dev/null +++ b/skills/hermes-themes/templates/skin.yaml @@ -0,0 +1,49 @@ +# Hermes skin template — themes the CLI, TUI, and desktop GUI from one file. +# Copy to /skins/.yaml, edit the palette, then set +# `display.skin: ` 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: "┊" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index e2d31d154825..60ab97028038 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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 diff --git a/ui-tui/src/__tests__/theme.test.ts b/ui-tui/src/__tests__/theme.test.ts index 6bb84337e702..586fae528c37 100644 --- a/ui-tui/src/__tests__/theme.test.ts +++ b/ui-tui/src/__tests__/theme.test.ts @@ -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') + }) }) diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 649561728702..8ef5ba46fc89 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -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 - colors?: Record - /** Hand-tuned palette for dark terminals (light-authored skins). */ - dark_colors?: Record - help_header?: string - /** Hand-tuned palette for light terminals (dark-authored skins). */ - light_colors?: Record - 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 diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index fa28d8ffd5b9..8d496d924328 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -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, - branding: Record, + 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: {