From a8444fbcae2b89d74d396e00aba6b8b10a66d6c9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Jul 2026 13:49:26 -0500 Subject: [PATCH 01/12] =?UTF-8?q?feat(themes):=20cross-surface=20theme=20S?= =?UTF-8?q?DK=20=E2=80=94=20one=20skin=20themes=20CLI,=20TUI,=20and=20desk?= =?UTF-8?q?top?= 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 52681e20a8..198f06a13b 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 9d2ded2e5f..2f7f50cec7 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 0000000000..63b28712e8 --- /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 0000000000..f614682906 --- /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 6c24511043..7266d8f70f 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 d33c752c07..62bbfe289d 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 0000000000..353a690ad4 --- /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 0000000000..217bf7641d --- /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 4d63fdb008..3dfba148d4 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 d678748333..beb2340f4f 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 9fd163efa0..b951bc4700 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 0000000000..b130d190e6 --- /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 b176ca297d..8f018249cf 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 0000000000..35d664c5db --- /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 0000000000..1316511413 --- /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 e2d31d1548..60ab970280 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 6bb84337e7..586fae528c 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 6495617287..8ef5ba46fc 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 fa28d8ffd5..8d496d9243 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: { From 91c5c0c1a64733072853ee90e3445cfde82c7ce8 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Jul 2026 15:55:31 -0500 Subject: [PATCH 02/12] fix(themes): activate skins via `hermes config set`, never a config.yaml hand-edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skill told the agent to `patch` display.skin into config.yaml; a stray indent corrupts the file and breaks the live gateway (the reported "/ menu broke"), and a raw file edit never live-applies in a running CLI/TUI ("nothing happened"). Route activation through the safe writer (`hermes config set display.skin`), and state plainly that a tool call can't hot-switch a running CLI/TUI — the user runs `/skin ` (desktop still auto-repaints on the next turn). --- skills/hermes-themes/SKILL.md | 38 +++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/skills/hermes-themes/SKILL.md b/skills/hermes-themes/SKILL.md index 35d664c5db..7551d112fb 100644 --- a/skills/hermes-themes/SKILL.md +++ b/skills/hermes-themes/SKILL.md @@ -29,7 +29,7 @@ editors or ship built-in presets. - 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`). + (inspect existing skins), `terminal` (activate via `hermes config set`). ## How to Run @@ -68,14 +68,21 @@ full schema in `hermes_cli/skin_engine.py`. `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`). +3. **Activate — never hand-edit `config.yaml`.** Persist the choice with the safe + writer via `terminal`: + ``` + hermes config set display.skin + ``` + This is the source of truth all surfaces read; it writes valid YAML so it + can't corrupt the file (a bad hand-edit can break the running gateway, + including the `/` menu). + - **Desktop**: repaints automatically after the current turn, and the skin + appears in Appearance / `Cmd-K` / `/skin`. + - **CLI / TUI**: a running session does not hot-reload a config-file change — + you can't switch it live from a tool call. **Tell the user to run + `/skin `** for an instant switch (it also persists); otherwise it + loads on next start. +4. **Confirm** and tell the user how to switch back: `/skin default`. ## Pitfalls @@ -88,12 +95,17 @@ full schema in `hermes_cli/skin_engine.py`. - **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. +- **Never hand-edit `config.yaml` to activate.** Use `hermes config set + display.skin ` — a stray indent in a manual edit corrupts the file and + can break the live gateway (including `/`). One command, always valid. +- **A tool call can't live-switch a running CLI/TUI.** Only `/skin ` + (typed by the user) or a restart applies it in-session — say so instead of + claiming it switched. ## 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. +- Run `hermes config get display.skin` and confirm it reports ``. +- Ask the user to confirm the new look (desktop repaints on the next turn; CLI/TUI + after `/skin ` or restart). From eb454919b22c95a20ded89be52440319aa3614af Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Jul 2026 16:03:37 -0500 Subject: [PATCH 03/12] feat(themes): agent-authored skins switch live via a gateway skin watcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A skin Hermes activates (`hermes config set display.skin X`) or recolors in place now goes live on every surface (CLI, TUI, desktop) within ~half a second, on its own — no `/skin`, no tool-hook timing, no user action. A gateway daemon polls the resolved skin signature `(name, active-file mtime)` every 0.5s and broadcasts `skin.changed` on any real move — a name switch OR a live color edit to the active skin. It routes through the SAME path `/skin` uses, so all surfaces repaint identically. The watcher seeds its baseline at gateway.ready (stdio + ws) so it only fires on a real change; the `/skin` RPC seeds the baseline too so it never double-broadcasts. Subsumes the desktop's post-turn `config.get skin` poll (its skin.changed handler already applies). --- .../app/session/hooks/use-hermes-config.ts | 15 ---- skills/hermes-themes/SKILL.md | 32 ++++--- tests/tui_gateway/test_protocol.py | 18 ++++ tui_gateway/entry.py | 3 + tui_gateway/server.py | 86 +++++++++++++++++-- tui_gateway/ws.py | 3 + 6 files changed, 117 insertions(+), 40 deletions(-) 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 198f06a13b..52681e20a8 100644 --- a/apps/desktop/src/app/session/hooks/use-hermes-config.ts +++ b/apps/desktop/src/app/session/hooks/use-hermes-config.ts @@ -1,10 +1,8 @@ -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, @@ -18,9 +16,6 @@ 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']) @@ -116,16 +111,6 @@ 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/skills/hermes-themes/SKILL.md b/skills/hermes-themes/SKILL.md index 7551d112fb..e64185a542 100644 --- a/skills/hermes-themes/SKILL.md +++ b/skills/hermes-themes/SKILL.md @@ -23,6 +23,8 @@ editors or ship built-in presets. - 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. +- The user wants to iterate live ("that coral is too loud, make it teal") — edit + the active skin's YAML and every surface repaints as your tool finishes. ## Prerequisites @@ -68,21 +70,18 @@ full schema in `hermes_cli/skin_engine.py`. `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 — never hand-edit `config.yaml`.** Persist the choice with the safe - writer via `terminal`: +3. **Apply it yourself — never hand-edit `config.yaml`.** Run the safe writer via + `terminal`: ``` hermes config set display.skin ``` - This is the source of truth all surfaces read; it writes valid YAML so it - can't corrupt the file (a bad hand-edit can break the running gateway, - including the `/` menu). - - **Desktop**: repaints automatically after the current turn, and the skin - appears in Appearance / `Cmd-K` / `/skin`. - - **CLI / TUI**: a running session does not hot-reload a config-file change — - you can't switch it live from a tool call. **Tell the user to run - `/skin `** for an instant switch (it also persists); otherwise it - loads on next start. -4. **Confirm** and tell the user how to switch back: `/skin default`. + The gateway's skin watcher notices the change and **repaints every surface live + within ~a second** — CLI, TUI, and desktop — and the skin appears in + Appearance / `Cmd-K` / `/skin`. You apply it; do NOT tell the user to run + `/skin` (they still can, but it's your job). The writer emits valid YAML — a + hand-edit can corrupt the file and break the live gateway (including `/`). +4. **Confirm the new look landed** and tell the user how to revert: run + `hermes config set display.skin default` (or they can `/skin default`). ## Pitfalls @@ -98,14 +97,13 @@ full schema in `hermes_cli/skin_engine.py`. - **Never hand-edit `config.yaml` to activate.** Use `hermes config set display.skin ` — a stray indent in a manual edit corrupts the file and can break the live gateway (including `/`). One command, always valid. -- **A tool call can't live-switch a running CLI/TUI.** Only `/skin ` - (typed by the user) or a restart applies it in-session — say so instead of - claiming it switched. +- **You apply it, not the user.** `hermes config set display.skin ` is + enough — the gateway's watcher repaints every surface within ~a second. Don't + defer to "type /skin yourself"; that's the old behavior. ## Verification - `read_file` the written `/skins/.yaml` and confirm valid YAML with the intended `name` and `colors`. - Run `hermes config get display.skin` and confirm it reports ``. -- Ask the user to confirm the new look (desktop repaints on the next turn; CLI/TUI - after `/skin ` or restart). +- The repaint lands as this turn ends — ask the user to confirm the new look. diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 2b51d785e1..1bf83a9399 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -2015,3 +2015,21 @@ def test_slow_completion_does_not_block_fast_handler(completion_method, server): assert fast_elapsed < 2.0, f"fast handler blocked for {fast_elapsed:.2f}s behind {completion_method}" released.set() + + +def test_broadcast_skin_if_changed_on_any_signature_move(server, monkeypatch): + """A skin the agent changes mid-turn goes live once per real move: a name + switch (incl. switch-then-revert) OR an in-place color edit to the active skin + (same name, new file mtime). An unchanged signature never re-broadcasts.""" + emitted = [] + # switch, no-op, switch, then a color edit (same name, bumped mtime). + sigs = iter([("neon", 1.0), ("neon", 1.0), ("forest", 1.0), ("forest", 2.0)]) + monkeypatch.setattr(server, "_emit", lambda ev, sid, payload=None: emitted.append((ev, payload))) + monkeypatch.setattr(server, "_last_skin_sig", None, raising=False) + monkeypatch.setattr(server, "_skin_sig", lambda: next(sigs)) + monkeypatch.setattr(server, "resolve_skin", lambda: {"name": "x", "colors": {}}) + + for _ in range(4): + server._broadcast_skin_if_changed() + + assert [ev for ev, _ in emitted] == ["skin.changed"] * 3 diff --git a/tui_gateway/entry.py b/tui_gateway/entry.py index c585cca158..e4c87be4a1 100644 --- a/tui_gateway/entry.py +++ b/tui_gateway/entry.py @@ -398,6 +398,9 @@ def main(): _log_exit("startup write failed (broken stdout pipe before first event)") sys.exit(0) + # Live-apply skins Hermes activates mid-conversation. + server._ensure_skin_watcher() + while True: raw = sys.stdin.readline() if not raw: diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 60ab970280..4ce8e2d866 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2422,6 +2422,80 @@ def resolve_skin() -> dict: return {} +# Signature of the last skin broadcast: (name, active user-file mtime). Lets the +# per-tool reconcile fire ``skin.changed`` on any real move — a name switch OR a +# live color edit to the active skin — and nothing else. +_last_skin_sig: tuple[str, float | None] | None = None + + +def _skin_sig() -> tuple[str, float | None]: + """(active skin name, its user-file mtime). Built-ins have no file, so only + their name moves; a user skin's mtime lets an in-place color edit repaint too.""" + name = str((_load_cfg().get("display") or {}).get("skin") or "default") + override = get_hermes_home_override() + home = override if isinstance(override, str) and override else _hermes_home + try: + mtime: float | None = (Path(home) / "skins" / f"{name}.yaml").stat().st_mtime + except OSError: + mtime = None + return name, mtime + + +def _note_skin_broadcast() -> None: + """Sync the reconcile baseline after the /skin RPC emits, so the per-tool + check doesn't re-broadcast the skin /skin just applied.""" + global _last_skin_sig + try: + _last_skin_sig = _skin_sig() + except Exception: + pass + + +def _broadcast_skin_if_changed() -> None: + """Emit ``skin.changed`` when the active skin moved — the agent switched it + (``hermes config set display.skin``) OR edited the active skin's colors in + place ("I don't like that coral" → tweak the YAML). + + Routes through the SAME live path as ``/skin`` so every surface (TUI + desktop) + repaints, no slash command. The signature check is a dict lookup + one stat, + so polling it is ~free. + """ + global _last_skin_sig + try: + sig = _skin_sig() + except Exception: + return + if sig == _last_skin_sig: + return + _last_skin_sig = sig + try: + _emit("skin.changed", "", resolve_skin()) + except Exception: + pass + + +_skin_watcher_started = False + + +def _ensure_skin_watcher() -> None: + """Poll the config for skin changes and broadcast ``skin.changed`` — so a skin + Hermes activates (``hermes config set display.skin``) or recolors goes live on + every surface within ~half a second, on its own, with no tool-hook or slash + command in the loop. Idempotent; started at gateway.ready.""" + global _skin_watcher_started + if _skin_watcher_started: + return + _skin_watcher_started = True + _note_skin_broadcast() # seed the baseline so only a real change repaints + + def _loop() -> None: + while True: + time.sleep(0.5) + _broadcast_skin_if_changed() + + threading.Thread(target=_loop, name="hermes-skin-watcher", daemon=True).start() + + def _resolve_model() -> str: env = ( os.environ.get("HERMES_MODEL", "") @@ -11917,6 +11991,9 @@ def _(rid, params: dict) -> dict: nv = value if key == "skin": _emit("skin.changed", "", resolve_skin()) + # Keep the reconcile baseline in sync so the per-tool check + # doesn't re-broadcast the skin the /skin RPC just applied. + _note_skin_broadcast() resp = {"key": key, "value": nv} if key == "personality": resp["history_reset"] = history_reset @@ -12556,15 +12633,8 @@ 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"), - "skin": resolve_skin(), - }, + rid, {"value": (_load_cfg().get("display") or {}).get("skin", "default")} ) if key == "indicator": # Normalize so a hand-edited config.yaml with stray casing or diff --git a/tui_gateway/ws.py b/tui_gateway/ws.py index 2ab4798df1..b61ef130cc 100644 --- a/tui_gateway/ws.py +++ b/tui_gateway/ws.py @@ -326,6 +326,9 @@ async def handle_ws(ws: Any) -> None: }, } ) + if ready_ok: + # Live-apply skins Hermes activates mid-conversation. + server._ensure_skin_watcher() if not ready_ok: disconnect_reason = "ready_send_failed" send_failures += 1 From 727f6704a7da7f2e7b22f463d67c7dbf52334952 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Jul 2026 19:37:10 -0500 Subject: [PATCH 04/12] feat(themes): TUI paints its own background from the skin (OSC 11) The TUI inherited the terminal's background; now a skin's `background` paints the whole surface via OSC 11 when a skin is applied, and clears back to the terminal default (OSC 111) on revert and on exit (ridden in through resetTerminalModes). Opt-in: a skin with no `background` leaves the terminal untouched, and the restore only fires if we actually painted. Desktop already themed its own bg; this closes the loop so Hermes owns its background on every surface. --- skills/hermes-themes/SKILL.md | 2 +- ui-tui/src/__tests__/terminalModes.test.ts | 45 ++++++++++++++++++++- ui-tui/src/app/createGatewayEventHandler.ts | 5 +++ ui-tui/src/lib/terminalModes.ts | 44 +++++++++++++++++++- 4 files changed, 91 insertions(+), 5 deletions(-) diff --git a/skills/hermes-themes/SKILL.md b/skills/hermes-themes/SKILL.md index e64185a542..83ad4f0e70 100644 --- a/skills/hermes-themes/SKILL.md +++ b/skills/hermes-themes/SKILL.md @@ -48,7 +48,7 @@ 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. | +| `background` | Base surface. Paints the whole TUI (OSC 11) + seeds the GUI. Set it. | | `ui_accent` / `banner_accent` | Brand accent: buttons, rings, primary. | | `banner_title` | Headings / primary text. | | `banner_text` / `ui_text` | Body foreground. | diff --git a/ui-tui/src/__tests__/terminalModes.test.ts b/ui-tui/src/__tests__/terminalModes.test.ts index d621f4eef4..1ff7aac609 100644 --- a/ui-tui/src/__tests__/terminalModes.test.ts +++ b/ui-tui/src/__tests__/terminalModes.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' -import { resetTerminalModes, TERMINAL_MODE_RESET } from '../lib/terminalModes.js' +import { resetTerminalModes, setTerminalBackground, TERMINAL_MODE_RESET } from '../lib/terminalModes.js' describe('terminal mode reset', () => { it('includes common sticky input modes', () => { @@ -60,3 +60,44 @@ describe('terminal mode reset', () => { } }) }) + +describe('terminal background (OSC 11)', () => { + const tty = (write: ReturnType) => ({ isTTY: true, write }) as unknown as NodeJS.WriteStream + + const written = (fn: (s: NodeJS.WriteStream) => void): string => { + const write = vi.fn() + fn(tty(write)) + + return (write.mock.calls[0]?.[0] as string) ?? '' + } + + // Leave the module's "painted" flag clean so the exact-match reset test above + // (and other files) never see a stray background restore. + afterEach(() => setTerminalBackground('', tty(vi.fn()))) + + it('paints the terminal default background from a valid hex', () => { + expect(written(s => setTerminalBackground('#08201F', s))).toBe('\x1b]11;#08201F\x07') + }) + + it('ignores an invalid hex and non-TTY streams', () => { + expect(written(s => setTerminalBackground('teal', s))).toBe('') + + const write = vi.fn() + setTerminalBackground('#08201f', { isTTY: false, write } as unknown as NodeJS.WriteStream) + expect(write).not.toHaveBeenCalled() + }) + + it('appends the background restore to the exit reset once painted, not before', () => { + expect(written(resetTerminalModes)).not.toContain('\x1b]111\x07') + + setTerminalBackground('#101010', tty(vi.fn())) + expect(written(resetTerminalModes)).toContain('\x1b]111\x07') + }) + + it('clears back to the terminal default when the next skin has no background', () => { + setTerminalBackground('#123456', tty(vi.fn())) + expect(written(s => setTerminalBackground('', s))).toBe('\x1b]111\x07') + // Cleared: a later reset no longer restores. + expect(written(resetTerminalModes)).not.toContain('\x1b]111\x07') + }) +}) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index a4c86b6011..7ff42d53d2 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -18,6 +18,7 @@ import { isTodoDone } from '../lib/liveProgress.js' import { openExternalUrl } from '../lib/openExternalUrl.js' import { rpcErrorMessage } from '../lib/rpc.js' import { topLevelSubagents } from '../lib/subagentTree.js' +import { setTerminalBackground } from '../lib/terminalModes.js' import { formatAbandonedClarify, formatToolCall, stripAnsi } from '../lib/text.js' import { bootSeededPin, invalidateBootBackground, writeBootTheme } from '../lib/themeBoot.js' import { defaultThemeForCurrentBackground, detectLightMode, fromSkin, type Theme } from '../theme.js' @@ -119,6 +120,10 @@ const themesEqual = (a: Theme, b: Theme) => { const applySkin = (s: GatewaySkin) => { lastSkin = s commitTheme(themeForSkin(s)) + // Paint the whole terminal from the skin's `background` (empty ⇒ restore the + // terminal default), so Hermes owns its background instead of inheriting it. + // Opt-in: a skin with no `background` leaves the terminal untouched. + setTerminalBackground(s.colors?.background ?? '') } /** Re-derive the theme from current detection signals (env overrides, cached diff --git a/ui-tui/src/lib/terminalModes.ts b/ui-tui/src/lib/terminalModes.ts index 46712a1d90..32a436ac84 100644 --- a/ui-tui/src/lib/terminalModes.ts +++ b/ui-tui/src/lib/terminalModes.ts @@ -25,16 +25,56 @@ type ResettableStream = Pick & { fd?: number } +// OSC 11 sets the terminal's DEFAULT background — so the whole TUI, not just +// rendered text, takes the skin color. OSC 111 restores the terminal's own +// default. We only reset when we actually painted, so a user who never uses a +// skin background keeps their terminal untouched. +const HEX_RE = /^#[0-9a-f]{6}$/i +const OSC_RESET_BACKGROUND = '\x1b]111\x07' +let _backgroundPainted = false + +/** + * Paint the terminal's default background from a skin (`hex`), or clear it back + * to the terminal default when `hex` is empty/invalid (a skin with no + * `background`, e.g. reverting to `default`). Runtime writes go through the async + * stream so they order cleanly with Ink's frames; the exit-time restore rides + * `resetTerminalModes` (writeSync). No-op off a TTY. + */ +export function setTerminalBackground(hex: string, stream: ResettableStream = process.stdout): void { + if (!stream.isTTY) { + return + } + + if (HEX_RE.test(hex)) { + try { + stream.write(`\x1b]11;${hex}\x07`) + _backgroundPainted = true + } catch { + // Terminal that can't take it just keeps its background. + } + } else if (_backgroundPainted) { + try { + stream.write(OSC_RESET_BACKGROUND) + _backgroundPainted = false + } catch { + // ignore + } + } +} + export function resetTerminalModes(stream: ResettableStream = process.stdout): boolean { if (!stream.isTTY) { return false } + // Append the background restore only if we painted one, so a normal session + // never resets a terminal it didn't touch. + const reset = _backgroundPainted ? TERMINAL_MODE_RESET + OSC_RESET_BACKGROUND : TERMINAL_MODE_RESET const fd = typeof stream.fd === 'number' ? stream.fd : stream === process.stdout ? 1 : undefined if (fd !== undefined) { try { - writeSync(fd, TERMINAL_MODE_RESET) + writeSync(fd, reset) return true } catch { @@ -43,7 +83,7 @@ export function resetTerminalModes(stream: ResettableStream = process.stdout): b } try { - stream.write(TERMINAL_MODE_RESET) + stream.write(reset) return true } catch { From 30ee6f749d551ed1d11045d8f053c7f85f493d5e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Jul 2026 19:41:59 -0500 Subject: [PATCH 05/12] feat(themes): element tokens (ui_tool, ui_thinking) + skinnable diffs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Theming was semantic-only: the gold tool `●` was `accent`, shared with headings/links/chevrons, so "recolor tool calls" was impossible and the agent had no key to point at. Add `ui_tool` (● + tool spinner) and `ui_thinking` (reasoning body) tokens that fall back to accent/muted — defaults unchanged, but now independently settable. Make diffs skinnable too (`diff_*`), which fromSkin previously hardcoded. Document the full element→key map in the skill so Hermes knows which knob turns what. --- apps/shared/src/skin.ts | 7 +++++ hermes_cli/skin_engine.py | 6 +++++ skills/hermes-themes/SKILL.md | 41 +++++++++++++++++------------- ui-tui/src/__tests__/theme.test.ts | 29 +++++++++++++++++++++ ui-tui/src/components/thinking.tsx | 18 ++++++------- ui-tui/src/theme.ts | 36 ++++++++++++++++++++------ 6 files changed, 103 insertions(+), 34 deletions(-) diff --git a/apps/shared/src/skin.ts b/apps/shared/src/skin.ts index b130d190e6..cc518817f0 100644 --- a/apps/shared/src/skin.ts +++ b/apps/shared/src/skin.ts @@ -37,6 +37,13 @@ export const SKIN_COLOR_TOKENS = [ 'ui_warn', 'ui_error', 'ui_label', + // Element-specific (fall back to accent/muted when unset). + 'ui_tool', + 'ui_thinking', + 'diff_added', + 'diff_removed', + 'diff_added_word', + 'diff_removed_word', // CLI / TUI chrome. 'prompt', 'input_rule', diff --git a/hermes_cli/skin_engine.py b/hermes_cli/skin_engine.py index 8f018249cf..843f76cc62 100644 --- a/hermes_cli/skin_engine.py +++ b/hermes_cli/skin_engine.py @@ -36,6 +36,12 @@ All fields are optional. Missing values inherit from the ``default`` skin. ui_ok: "#4caf50" # Success indicators ui_error: "#ef5350" # Error indicators ui_warn: "#ffa726" # Warning indicators + ui_tool: "#FFBF00" # Tool-call markers (● / spinner); falls back to ui_accent + ui_thinking: "#CC9B1F" # Reasoning/thinking text; falls back to banner_dim + diff_added: "#dcffdc" # Diff added-line background (TUI) + diff_removed: "#ffdcdc" # Diff removed-line background + diff_added_word: "#248a3d" # Diff added word-level foreground + diff_removed_word: "#cf222e" # Diff removed word-level foreground prompt: "#FFF8DC" # Prompt text color input_rule: "#CD7F32" # Input area horizontal rule response_border: "#FFD700" # Response box border (ANSI) diff --git a/skills/hermes-themes/SKILL.md b/skills/hermes-themes/SKILL.md index 83ad4f0e70..91c24b4dbd 100644 --- a/skills/hermes-themes/SKILL.md +++ b/skills/hermes-themes/SKILL.md @@ -41,26 +41,33 @@ editors or ship built-in presets. 3. `write_file` it to `/skins/.yaml`. 4. Activate it (see Procedure). Confirm the change landed. -## Quick Reference +## Quick Reference — element → key -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. +Hex (`#rrggbb`). Theming is **semantic**: one key colors every element that plays +that role, so match the element to its key. To recolor a specific element, set the +key in its row (element-specific keys fall back to the shared one when unset). -| Key | Drives | -|---|---| -| `background` | Base surface. Paints the whole TUI (OSC 11) + seeds the GUI. 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. | +| Visible element | Key to set | Falls back to | +|---|---|---| +| App background (whole TUI + GUI) | `background` | terminal default | +| **Tool-call marker** (`●`, tool spinner) | `ui_tool` | `ui_accent` | +| **Thinking / reasoning text** | `ui_thinking` | `banner_dim` | +| Accent — headings, links, chevrons, `Σ` | `ui_accent` / `banner_accent` | — | +| Heading / primary text | `banner_title` / `ui_primary` | — | +| Body / label text, user messages | `ui_text` / `banner_text`, `ui_label` | — | +| Muted / secondary, tree connectors | `banner_dim` | — | +| Borders, rules, gutters | `ui_border` / `banner_border` | — | +| Prompt symbol color | `prompt` | `banner_text` | +| Success / warn / error | `ui_ok` / `ui_warn` / `ui_error` | — | +| Status bar text + usage | `status_bar_text`, `status_bar_good/warn/bad/critical` | — | +| Diff add/remove (line + word) | `diff_added` / `diff_removed` / `diff_added_word` / `diff_removed_word` | built-in | +| Completion menu | `completion_menu_bg` / `completion_menu_current_bg` / `…_meta_bg` | — | -`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`. +Note the sharing: `ui_accent` colors tool markers **and** headings/links/chevrons, +so to recolor *only* tool calls (the classic "change the gold `●`") set `ui_tool`. +`branding` (`agent_name`, `prompt_symbol`, `welcome`, `goodbye`, `help_header`), +`spinner`, and `tool_prefix` are optional flavor; full schema in +`hermes_cli/skin_engine.py`. ## Procedure diff --git a/ui-tui/src/__tests__/theme.test.ts b/ui-tui/src/__tests__/theme.test.ts index 586fae528c..57d03d2eff 100644 --- a/ui-tui/src/__tests__/theme.test.ts +++ b/ui-tui/src/__tests__/theme.test.ts @@ -533,6 +533,35 @@ describe('background-aware adaptation (OSC-11 light terminals)', () => { expect(defaultThemeForCurrentBackground({ HERMES_TUI_BACKGROUND: '#ffffff' }).color).toEqual(LIGHT_THEME.color) }) + it('gives tool + thinking their own keys, defaulting to accent + muted', async () => { + const { fromSkin } = await importThemeWithCleanEnv() + + // Independent override: recolor tool markers without touching accent. + const themed = fromSkin({ ui_accent: '#111111', ui_tool: '#ff0000', ui_thinking: '#00ff00' }, {}) + expect(themed.color.tool).toBe('#ff0000') + expect(themed.color.thinking).toBe('#00ff00') + expect(themed.color.accent).toBe('#111111') + + // Default: tool follows accent, thinking follows muted. + const fallback = fromSkin({ ui_accent: '#abcdef', banner_dim: '#123456' }, {}) + expect(fallback.color.tool).toBe('#abcdef') + expect(fallback.color.thinking).toBe('#123456') + }) + + it('lets skins override diff colors', async () => { + const { fromSkin } = await importThemeWithCleanEnv() + + const { color } = fromSkin( + { diff_added: '#0a0', diff_removed: '#a00', diff_added_word: '#0f0', diff_removed_word: '#f00' }, + {} + ) + + expect(color.diffAdded).toBe('#0a0') + expect(color.diffRemoved).toBe('#a00') + expect(color.diffAddedWord).toBe('#0f0') + expect(color.diffRemovedWord).toBe('#f00') + }) + it('maps the status bar from skin status_bar_* keys', async () => { const { fromSkin } = await importThemeWithCleanEnv() diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 016c99138a..84c85fb4d5 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -454,7 +454,7 @@ function SubagentAccordion({ color={t.color.text} content={ <> - ● + ● {line} } @@ -640,22 +640,22 @@ export const Thinking = memo(function Thinking({ {preview ? ( mode === 'full' ? ( lines.map((line, index) => ( - + {line || ' '} {index === lines.length - 1 ? ( - + ) : null} )) ) : ( - + {preview} - + ) ) : ( - - + + )} @@ -855,7 +855,7 @@ export const ToolTrail = memo(function ToolTrail({ : [], content: ( <> - {label} + {label} {tool.startedAt ? ` (${fmtElapsed(now - tool.startedAt)})` : ''} ) @@ -1072,7 +1072,7 @@ export const ToolTrail = memo(function ToolTrail({ color={group.color} content={ <> - ● + ● {toolLabel(group)} {isDelegateGroup ? ( diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index 8d496d9243..4901a3124f 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -18,6 +18,11 @@ export interface ThemeColors { error: string warn: string + /** Tool-call markers (● bullet, tool spinner). Defaults to `accent`. */ + tool: string + /** Reasoning/thinking body text. Defaults to `muted`. */ + thinking: string + prompt: string sessionLabel: string sessionBorder: string @@ -82,10 +87,11 @@ const ANSI_NORMALIZED_FOREGROUNDS: readonly (keyof ThemeColors)[] = [ 'statusWarn', 'statusBad', 'statusCritical', - 'shellDollar' + 'shellDollar', + 'tool' ] -const ANSI_MUTED_FOREGROUNDS: readonly (keyof ThemeColors)[] = ['muted', 'sessionLabel', 'sessionBorder'] +const ANSI_MUTED_FOREGROUNDS: readonly (keyof ThemeColors)[] = ['muted', 'sessionLabel', 'sessionBorder', 'thinking'] function xtermEightBitRgb(colorNumber: number): [number, number, number] { if (colorNumber >= 232) { @@ -299,6 +305,11 @@ export function buildPalette(seeds: ThemeSeeds, isLight: boolean): ThemeColors { error: seeds.error, warn: seeds.warn, + // Element tokens: independently settable, but default to their semantic + // parents (tool marker → accent, reasoning body → muted). + tool: seeds.accent, + thinking: muted, + prompt: seeds.prompt ?? seeds.text, // sessionLabel/sessionBorder track the muted tone — "same role, same // colour" by design (#11300). @@ -836,7 +847,15 @@ export function fromSkin( sessionBorder: c('session_border') ?? c('banner_dim') ?? derived.sessionBorder, statusBg: c('status_bar_bg') ?? surface, statusFg: c('status_bar_text') ?? derived.statusFg, - selectionBg: c('selection_bg') ?? c('completion_menu_current_bg') ?? derived.selectionBg + selectionBg: c('selection_bg') ?? c('completion_menu_current_bg') ?? derived.selectionBg, + // Element tokens + skinnable diffs (theme-sdk): overridable, else the + // derived defaults (tool→accent, thinking→muted, diff_* → DIFF_* ladder). + tool: c('ui_tool') ?? derived.tool, + thinking: c('ui_thinking') ?? derived.thinking, + diffAdded: c('diff_added') ?? derived.diffAdded, + diffRemoved: c('diff_removed') ?? derived.diffRemoved, + diffAddedWord: c('diff_added_word') ?? derived.diffAddedWord, + diffRemovedWord: c('diff_removed_word') ?? derived.diffRemovedWord } // 4. Guard: contrast floors against the real background + fill polarity. @@ -851,11 +870,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. + // ui_border, ui_ok/warn/error, ui_tool, ui_thinking, shell_dollar, + // status_bar_*, diff_*) are read into `seeds`/`assembled` above 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: { From 2a4e5fac1a517a8f84acab9d89b61f9f2238db6f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Jul 2026 19:51:55 -0500 Subject: [PATCH 06/12] fix(themes): tweak the ACTIVE skin in place, never fork default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changing one color ("make the tool ● cyan") forked `default` — which has no `background` — so applying it reset the terminal to its own (black) default and dropped the active skin's palette. Teach the skill to edit the active skin's file in place for a tweak (watcher repaints on the mtime bump), and to fork a built-in only by carrying its full palette. Hard pitfall: never fork `default` for a tweak. --- skills/hermes-themes/SKILL.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/skills/hermes-themes/SKILL.md b/skills/hermes-themes/SKILL.md index 91c24b4dbd..2258841838 100644 --- a/skills/hermes-themes/SKILL.md +++ b/skills/hermes-themes/SKILL.md @@ -90,6 +90,23 @@ so to recolor *only* tool calls (the classic "change the gold `●`") set `ui_to 4. **Confirm the new look landed** and tell the user how to revert: run `hermes config set display.skin default` (or they can `/skin default`). +## Tweak the active look (change one thing) + +When the user wants to adjust the CURRENT look ("make the tool `●` cyan", "warmer +background"), **edit the active skin in place** — do NOT create a new skin from +`default`, which drops the current palette (background included). + +1. Find the active skin: `hermes config get display.skin`. +2. **If a file exists** at `/skins/.yaml`: `patch` ONLY the + key(s) you're changing (e.g. add/replace `ui_tool: "#00FFFF"`), leaving every + other line untouched. Saving bumps the file's mtime; the watcher repaints live + — no `config set` needed, name unchanged. +3. **If the active skin is a built-in** (no file — e.g. `default`, `mono`): fork + it once, carrying its FULL palette into a new user skin, then change the one + key and `hermes config set display.skin `. Copy `templates/skin.yaml` + and match the built-in's colors; never start from a bare `default` fork that + omits `background`. + ## Pitfalls - **Don't hardcode `~/.hermes`** when a profile is active — resolve the real home @@ -107,6 +124,10 @@ so to recolor *only* tool calls (the classic "change the gold `●`") set `ui_to - **You apply it, not the user.** `hermes config set display.skin ` is enough — the gateway's watcher repaints every surface within ~a second. Don't defer to "type /skin yourself"; that's the old behavior. +- **To change one color, edit the ACTIVE skin — never fork `default`.** Forking + `default` for a tweak drops the current palette: a skin with no `background` + resets the terminal to its own default (often black). Patch the active skin's + file in place so `background` and everything else survive. ## Verification From 4f4f938aef37439d51e34428af47064b5497fa3a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Jul 2026 19:58:54 -0500 Subject: [PATCH 07/12] =?UTF-8?q?feat(themes):=20`hermes=20skin=20set`=20?= =?UTF-8?q?=E2=80=94=20deterministic=20one-color=20tweak,=20bg=20untouched?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changing a single color kept wrecking the rest because the agent hand-authored a new skin (often from `default`, which has no `background`, resetting the terminal to black). Add `hermes skin set `: edits the ACTIVE skin's one key in place (a built-in is forked into an editable copy carrying its full palette), so everything else — background included — is preserved. Plus `skin use` / `skin list`. The skill now points tweaks at this command instead of hand-authoring. --- hermes_cli/main.py | 13 ++++ hermes_cli/skin_cmd.py | 105 ++++++++++++++++++++++++++++++ hermes_cli/subcommands/skin.py | 30 +++++++++ skills/hermes-themes/SKILL.md | 24 +++---- tests/hermes_cli/test_skin_cmd.py | 57 ++++++++++++++++ 5 files changed, 217 insertions(+), 12 deletions(-) create mode 100644 hermes_cli/skin_cmd.py create mode 100644 hermes_cli/subcommands/skin.py create mode 100644 tests/hermes_cli/test_skin_cmd.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index c233594fcf..78fa0d2259 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -422,6 +422,7 @@ from hermes_cli.subcommands.debug import build_debug_parser from hermes_cli.subcommands.backup import build_backup_parser from hermes_cli.subcommands.import_cmd import build_import_cmd_parser from hermes_cli.subcommands.config import build_config_parser +from hermes_cli.subcommands.skin import build_skin_parser from hermes_cli.subcommands.console import build_console_parser from hermes_cli.subcommands.version import build_version_parser from hermes_cli.subcommands.update import build_update_parser @@ -4557,6 +4558,13 @@ def cmd_config(args): config_command(args) +def cmd_skin(args): + """Skin management (list / use / set).""" + from hermes_cli.skin_cmd import skin_command + + skin_command(args) + + def cmd_backup(args): """Back up Hermes home directory to a zip file.""" if getattr(args, "quick", False): @@ -13936,6 +13944,11 @@ def main(): # ========================================================================= build_config_parser(subparsers, cmd_config=cmd_config) + # ========================================================================= + # skin command (parser built in hermes_cli/subcommands/skin.py) + # ========================================================================= + build_skin_parser(subparsers, cmd_skin=cmd_skin) + # ========================================================================= # console command (parser built in hermes_cli/subcommands/console.py) # ========================================================================= diff --git a/hermes_cli/skin_cmd.py b/hermes_cli/skin_cmd.py new file mode 100644 index 0000000000..9e181a2a38 --- /dev/null +++ b/hermes_cli/skin_cmd.py @@ -0,0 +1,105 @@ +"""``hermes skin`` — list, switch, and tweak skins from the CLI. + +``set`` is the load-bearing verb: it changes ONE color of the ACTIVE skin **in +place**, so tweaking (say) the tool marker never disturbs the rest of the look — +background included. Editing the file bumps its mtime; the gateway's skin watcher +repaints every live surface within ~a second. A built-in skin (no file) is forked +into an editable copy that carries its full palette, so the current look is +preserved and only the one key changes. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +from hermes_constants import display_hermes_home, get_hermes_home + +_HEX_RE = re.compile(r"^#[0-9a-fA-F]{6}$") + + +def _skins_dir() -> Path: + return get_hermes_home() / "skins" + + +def _active_skin() -> str: + from hermes_cli.config import load_config + + display = (load_config() or {}).get("display") or {} + return str(display.get("skin") or "default") + + +def _use(name: str) -> None: + """Activate a skin (persists display.skin via the shared config writer).""" + from hermes_cli.config import config_command + + config_command(argparse.Namespace(config_command="set", key="display.skin", value=name, force=True)) + + +def _skin_set(key: str, value: str, skin: str | None) -> int: + import yaml + + if not _HEX_RE.match(value): + print(f"✗ {value!r} is not a #rrggbb hex color", file=sys.stderr) + return 1 + + name = skin or _active_skin() + path = _skins_dir() / f"{name}.yaml" + + if path.exists(): + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + target = name + else: + # Built-in (or missing): fork into an editable copy that keeps its full + # palette, under a fresh name so the built-in stays intact for revert. + from hermes_cli.skin_engine import load_skin + + resolved = load_skin(name) + target = f"{name}-custom" + path = _skins_dir() / f"{target}.yaml" + data = { + "name": target, + "description": f"{name} + custom {key}", + "colors": dict(resolved.colors), + "branding": dict(resolved.branding), + "tool_prefix": resolved.tool_prefix, + } + + if not isinstance(data.get("colors"), dict): + data["colors"] = {} + data["colors"][key] = value + data.setdefault("name", target) + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(yaml.safe_dump(data, sort_keys=False, allow_unicode=True), encoding="utf-8") + + if target != name: + _use(target) + + print(f"✓ {key} = {value} in {display_hermes_home()}/skins/{target}.yaml (live within ~1s)") + return 0 + + +def _skin_list() -> int: + from hermes_cli.skin_engine import list_skins + + active = _active_skin() + for s in list_skins(): + mark = "*" if s["name"] == active else " " + print(f"{mark} {s['name']:<16} {s.get('source', ''):<8} {s.get('description', '')}") + return 0 + + +def skin_command(args) -> None: + """Dispatch ``hermes skin ``.""" + verb = getattr(args, "skin_command", None) + + if verb == "set": + sys.exit(_skin_set(args.key, args.value, getattr(args, "skin", None))) + elif verb == "use": + _use(args.name) + print(f"✓ active skin → {args.name} (live within ~1s)") + else: # list / default + sys.exit(_skin_list()) diff --git a/hermes_cli/subcommands/skin.py b/hermes_cli/subcommands/skin.py new file mode 100644 index 0000000000..ae49c6b2f5 --- /dev/null +++ b/hermes_cli/subcommands/skin.py @@ -0,0 +1,30 @@ +"""``hermes skin`` subcommand parser.""" + +from __future__ import annotations + +from typing import Callable + + +def build_skin_parser(subparsers, *, cmd_skin: Callable) -> None: + """Attach the ``skin`` subcommand to ``subparsers``.""" + skin_parser = subparsers.add_parser( + "skin", + help="List, switch, and tweak skins", + description="Manage Hermes skins. `set` tweaks one color of the active skin in place.", + ) + skin_subparsers = skin_parser.add_subparsers(dest="skin_command") + + skin_subparsers.add_parser("list", help="List available skins") + + skin_use = skin_subparsers.add_parser("use", help="Switch the active skin") + skin_use.add_argument("name", help="Skin name") + + # skin set — change ONE color of the active skin in place (bg untouched). + skin_set = skin_subparsers.add_parser( + "set", help="Set one color of the active skin (e.g. `skin set ui_tool '#00FFFF'`)" + ) + skin_set.add_argument("key", help="Color key (e.g. ui_tool, ui_accent, background)") + skin_set.add_argument("value", help="Hex color (#rrggbb)") + skin_set.add_argument("--skin", help="Target a specific skin instead of the active one") + + skin_parser.set_defaults(func=cmd_skin) diff --git a/skills/hermes-themes/SKILL.md b/skills/hermes-themes/SKILL.md index 2258841838..f95ce61359 100644 --- a/skills/hermes-themes/SKILL.md +++ b/skills/hermes-themes/SKILL.md @@ -93,19 +93,19 @@ so to recolor *only* tool calls (the classic "change the gold `●`") set `ui_to ## Tweak the active look (change one thing) When the user wants to adjust the CURRENT look ("make the tool `●` cyan", "warmer -background"), **edit the active skin in place** — do NOT create a new skin from -`default`, which drops the current palette (background included). +background"), use the one deterministic command — it edits the ACTIVE skin's ONE +key in place, so everything else (background included) is untouched: -1. Find the active skin: `hermes config get display.skin`. -2. **If a file exists** at `/skins/.yaml`: `patch` ONLY the - key(s) you're changing (e.g. add/replace `ui_tool: "#00FFFF"`), leaving every - other line untouched. Saving bumps the file's mtime; the watcher repaints live - — no `config set` needed, name unchanged. -3. **If the active skin is a built-in** (no file — e.g. `default`, `mono`): fork - it once, carrying its FULL palette into a new user skin, then change the one - key and `hermes config set display.skin `. Copy `templates/skin.yaml` - and match the built-in's colors; never start from a bare `default` fork that - omits `background`. +``` +hermes skin set # e.g. hermes skin set ui_tool "#00FFFF" +``` + +It edits the active skin's file (a built-in is forked into an editable copy that +keeps its full palette), the watcher repaints live, and nothing else moves. Do +NOT hand-write a new skin from `default` for a tweak — that drops the current +palette and resets the background. `hermes skin set background "#08201f"` changes +only the background; `hermes skin use ` / `hermes skin list` switch and +enumerate. ## Pitfalls diff --git a/tests/hermes_cli/test_skin_cmd.py b/tests/hermes_cli/test_skin_cmd.py new file mode 100644 index 0000000000..e8f9f17996 --- /dev/null +++ b/tests/hermes_cli/test_skin_cmd.py @@ -0,0 +1,57 @@ +"""`hermes skin set` — deterministic single-color tweak of the active skin. + +The whole point is that changing one token never disturbs the rest of the look +(background especially), which hand-authoring kept getting wrong. +""" + +import yaml + +from hermes_cli import skin_cmd +from hermes_constants import get_hermes_home + + +def _skins(): + d = get_hermes_home() / "skins" + d.mkdir(parents=True, exist_ok=True) + return d + + +def _activate(name: str) -> None: + (get_hermes_home() / "config.yaml").write_text(f"display:\n skin: {name}\n", encoding="utf-8") + + +def test_set_edits_active_user_skin_in_place_preserving_everything_else(): + (_skins() / "oasis.yaml").write_text( + 'name: oasis\ncolors:\n background: "#08201f"\n banner_title: "#f2dfb3"\n', encoding="utf-8" + ) + _activate("oasis") + + assert skin_cmd._skin_set("ui_tool", "#00FFFF", None) == 0 + + data = yaml.safe_load((_skins() / "oasis.yaml").read_text()) + assert data["colors"]["ui_tool"] == "#00FFFF" + assert data["colors"]["background"] == "#08201f" # untouched — the whole point + assert data["colors"]["banner_title"] == "#f2dfb3" + assert data["name"] == "oasis" # no rename, no fork + assert not (_skins() / "oasis-custom.yaml").exists() + + +def test_set_forks_a_builtin_without_inventing_a_background(): + _activate("default") # a built-in — no file + + assert skin_cmd._skin_set("ui_tool", "#00FFFF", None) == 0 + + fork = _skins() / "default-custom.yaml" + assert fork.exists() + data = yaml.safe_load(fork.read_text()) + assert data["colors"]["ui_tool"] == "#00FFFF" + # default has no background, so the fork must not invent one (terminal stays put). + assert "background" not in data["colors"] + # full palette carried over, and it became active. + assert data["colors"].get("banner_title") + assert (get_hermes_home() / "config.yaml").read_text().find("default-custom") != -1 + + +def test_set_rejects_non_hex(): + _activate("default") + assert skin_cmd._skin_set("ui_tool", "teal", None) == 1 From 3ba6eebc7cbb4939a370d3d00afe777be7213802 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Jul 2026 20:01:32 -0500 Subject: [PATCH 08/12] feat(themes): dedicated code-syntax palette keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code highlighting reused brand tokens (accent/text/border/muted), so it couldn't be themed independently. Add syntax_string/number/keyword/comment skin keys → syntax* theme tokens (defaulting to those brand tokens, so defaults are unchanged) and point the highlighter at them. Documented in the element→key map. --- apps/shared/src/skin.ts | 4 ++++ hermes_cli/skin_engine.py | 4 ++++ skills/hermes-themes/SKILL.md | 1 + ui-tui/src/__tests__/theme.test.ts | 17 +++++++++++++++++ ui-tui/src/lib/syntax.ts | 8 ++++---- ui-tui/src/theme.ts | 20 +++++++++++++++++++- 6 files changed, 49 insertions(+), 5 deletions(-) diff --git a/apps/shared/src/skin.ts b/apps/shared/src/skin.ts index cc518817f0..d5c600da43 100644 --- a/apps/shared/src/skin.ts +++ b/apps/shared/src/skin.ts @@ -44,6 +44,10 @@ export const SKIN_COLOR_TOKENS = [ 'diff_removed', 'diff_added_word', 'diff_removed_word', + 'syntax_string', + 'syntax_number', + 'syntax_keyword', + 'syntax_comment', // CLI / TUI chrome. 'prompt', 'input_rule', diff --git a/hermes_cli/skin_engine.py b/hermes_cli/skin_engine.py index 843f76cc62..a2a093b7dc 100644 --- a/hermes_cli/skin_engine.py +++ b/hermes_cli/skin_engine.py @@ -42,6 +42,10 @@ All fields are optional. Missing values inherit from the ``default`` skin. diff_removed: "#ffdcdc" # Diff removed-line background diff_added_word: "#248a3d" # Diff added word-level foreground diff_removed_word: "#cf222e" # Diff removed word-level foreground + syntax_string: "#FFBF00" # Code strings; falls back to ui_accent + syntax_number: "#FFF8DC" # Code numbers; falls back to ui_text + syntax_keyword: "#CD7F32" # Code keywords; falls back to ui_border + syntax_comment: "#CC9B1F" # Code comments; falls back to banner_dim prompt: "#FFF8DC" # Prompt text color input_rule: "#CD7F32" # Input area horizontal rule response_border: "#FFD700" # Response box border (ANSI) diff --git a/skills/hermes-themes/SKILL.md b/skills/hermes-themes/SKILL.md index f95ce61359..15982af0c8 100644 --- a/skills/hermes-themes/SKILL.md +++ b/skills/hermes-themes/SKILL.md @@ -61,6 +61,7 @@ key in its row (element-specific keys fall back to the shared one when unset). | Success / warn / error | `ui_ok` / `ui_warn` / `ui_error` | — | | Status bar text + usage | `status_bar_text`, `status_bar_good/warn/bad/critical` | — | | Diff add/remove (line + word) | `diff_added` / `diff_removed` / `diff_added_word` / `diff_removed_word` | built-in | +| Code syntax (string/number/keyword/comment) | `syntax_string` / `syntax_number` / `syntax_keyword` / `syntax_comment` | accent/text/border/muted | | Completion menu | `completion_menu_bg` / `completion_menu_current_bg` / `…_meta_bg` | — | Note the sharing: `ui_accent` colors tool markers **and** headings/links/chevrons, diff --git a/ui-tui/src/__tests__/theme.test.ts b/ui-tui/src/__tests__/theme.test.ts index 57d03d2eff..3ba6513e2a 100644 --- a/ui-tui/src/__tests__/theme.test.ts +++ b/ui-tui/src/__tests__/theme.test.ts @@ -548,6 +548,23 @@ describe('background-aware adaptation (OSC-11 light terminals)', () => { expect(fallback.color.thinking).toBe('#123456') }) + it('gives code syntax its own keys, defaulting to accent/text/border/muted', async () => { + const { fromSkin } = await importThemeWithCleanEnv() + + const themed = fromSkin( + { syntax_string: '#aa0000', syntax_number: '#00aa00', syntax_keyword: '#0000aa', syntax_comment: '#888888' }, + {} + ) + + expect(themed.color.syntaxString).toBe('#aa0000') + expect(themed.color.syntaxNumber).toBe('#00aa00') + expect(themed.color.syntaxKeyword).toBe('#0000aa') + expect(themed.color.syntaxComment).toBe('#888888') + + const fallback = fromSkin({ ui_accent: '#abcdef' }, {}) + expect(fallback.color.syntaxString).toBe('#abcdef') // string follows accent + }) + it('lets skins override diff colors', async () => { const { fromSkin } = await importThemeWithCleanEnv() diff --git a/ui-tui/src/lib/syntax.ts b/ui-tui/src/lib/syntax.ts index 3b66f6ddc7..519679c1f5 100644 --- a/ui-tui/src/lib/syntax.ts +++ b/ui-tui/src/lib/syntax.ts @@ -80,7 +80,7 @@ export function highlightLine(line: string, lang: string, t: Theme): Token[] { } if (spec.comment && line.trimStart().startsWith(spec.comment)) { - return [[t.color.muted, line]] + return [[t.color.syntaxComment, line]] } const tokens: Token[] = [] @@ -97,11 +97,11 @@ export function highlightLine(line: string, lang: string, t: Theme): Token[] { const ch = tok[0]! if (ch === '"' || ch === "'" || ch === '`') { - tokens.push([t.color.accent, tok]) + tokens.push([t.color.syntaxString, tok]) } else if (ch >= '0' && ch <= '9') { - tokens.push([t.color.text, tok]) + tokens.push([t.color.syntaxNumber, tok]) } else if (spec.keywords.has(tok)) { - tokens.push([t.color.border, tok]) + tokens.push([t.color.syntaxKeyword, tok]) } else { tokens.push(['', tok]) } diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index 4901a3124f..de18534d65 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -23,6 +23,12 @@ export interface ThemeColors { /** Reasoning/thinking body text. Defaults to `muted`. */ thinking: string + /** Code-block syntax highlight. Default to accent/text/border/muted. */ + syntaxString: string + syntaxNumber: string + syntaxKeyword: string + syntaxComment: string + prompt: string sessionLabel: string sessionBorder: string @@ -310,6 +316,13 @@ export function buildPalette(seeds: ThemeSeeds, isLight: boolean): ThemeColors { tool: seeds.accent, thinking: muted, + // Code-syntax tokens default to brand tokens (unchanged highlighting) + // but are independently skinnable. + syntaxString: seeds.accent, + syntaxNumber: seeds.text, + syntaxKeyword: seeds.border ?? tones.border, + syntaxComment: muted, + prompt: seeds.prompt ?? seeds.text, // sessionLabel/sessionBorder track the muted tone — "same role, same // colour" by design (#11300). @@ -855,7 +868,12 @@ export function fromSkin( diffAdded: c('diff_added') ?? derived.diffAdded, diffRemoved: c('diff_removed') ?? derived.diffRemoved, diffAddedWord: c('diff_added_word') ?? derived.diffAddedWord, - diffRemovedWord: c('diff_removed_word') ?? derived.diffRemovedWord + diffRemovedWord: c('diff_removed_word') ?? derived.diffRemovedWord, + // Code-syntax tokens: overridable, else the derived brand-token defaults. + syntaxString: c('syntax_string') ?? derived.syntaxString, + syntaxNumber: c('syntax_number') ?? derived.syntaxNumber, + syntaxKeyword: c('syntax_keyword') ?? derived.syntaxKeyword, + syntaxComment: c('syntax_comment') ?? derived.syntaxComment } // 4. Guard: contrast floors against the real background + fill polarity. From 428a0534ee20c04b33f21b6a92b35a7ec60e4277 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Jul 2026 20:44:08 -0500 Subject: [PATCH 09/12] =?UTF-8?q?test(themes):=20E2E=20live=20skin=20switc?= =?UTF-8?q?h=20=E2=80=94=20config=20write=20=E2=86=92=20skin.changed=20bro?= =?UTF-8?q?adcast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/tui_gateway/test_protocol.py | 33 ++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 1bf83a9399..229ea87f71 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -2017,6 +2017,39 @@ def test_slow_completion_does_not_block_fast_handler(completion_method, server): released.set() +def test_skin_live_switch_end_to_end(server, tmp_path, monkeypatch): + """Real config + skin files: activating a skin (as `hermes config set` does) + makes the per-tool reconcile broadcast skin.changed with the resolved palette. + Exercises _load_cfg → _skin_sig → resolve_skin → _emit with no mocks in between.""" + import hermes_cli.skin_engine as skin_engine + + (tmp_path / "skins").mkdir() + (tmp_path / "skins" / "midnight.yaml").write_text( + "name: midnight\ndescription: t\ncolors:\n banner_title: '#00ffcc'\n background: '#001010'\n" + ) + monkeypatch.setattr(skin_engine, "get_hermes_home", lambda: tmp_path) + monkeypatch.setattr(server, "_hermes_home", tmp_path) + monkeypatch.setattr(server, "_last_skin_sig", None, raising=False) + server._cfg_cache = server._cfg_mtime = server._cfg_path = None + + emitted = [] + monkeypatch.setattr(server, "_emit", lambda ev, sid, payload=None: emitted.append((ev, payload))) + + # Baseline (default) — seeds the signature. + (tmp_path / "config.yaml").write_text("display:\n skin: default\n") + server._broadcast_skin_if_changed() + emitted.clear() + + # Activate midnight, as `hermes config set display.skin midnight` would. + time.sleep(0.01) # ensure the config mtime moves + (tmp_path / "config.yaml").write_text("display:\n skin: midnight\n") + server._broadcast_skin_if_changed() + + assert [ev for ev, _ in emitted] == ["skin.changed"] + assert emitted[0][1]["name"] == "midnight" + assert emitted[0][1]["colors"]["banner_title"] == "#00ffcc" + + def test_broadcast_skin_if_changed_on_any_signature_move(server, monkeypatch): """A skin the agent changes mid-turn goes live once per real move: a name switch (incl. switch-then-revert) OR an in-place color edit to the active skin From 50170fdd2d4d0bec6ab2bf6efecdd1b93064a736 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Jul 2026 20:52:40 -0500 Subject: [PATCH 10/12] fix(themes): reconcile element/syntax tokens with main's derive+adapt pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Element tokens (ui_tool/ui_thinking), skinnable diffs, and code-syntax keys flow through buildPalette → adaptColorsToBackground instead of a hand-mapped color block, so they inherit #20379's contrast/polarity machinery. thinking and syntaxComment track the EFFECTIVE muted (banner_dim override included); the skin's `background` feeds the surface (it also paints the terminal via OSC 11); statusFg falls back through ui_text/banner_text. Tests assert the routing/independence contracts rather than pre-adaptation hexes. --- ui-tui/src/__tests__/theme.test.ts | 26 ++++++++++++++++++-------- ui-tui/src/theme.ts | 13 +++++++++---- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/ui-tui/src/__tests__/theme.test.ts b/ui-tui/src/__tests__/theme.test.ts index 3ba6513e2a..ad54c285fa 100644 --- a/ui-tui/src/__tests__/theme.test.ts +++ b/ui-tui/src/__tests__/theme.test.ts @@ -536,16 +536,21 @@ describe('background-aware adaptation (OSC-11 light terminals)', () => { it('gives tool + thinking their own keys, defaulting to accent + muted', async () => { const { fromSkin } = await importThemeWithCleanEnv() - // Independent override: recolor tool markers without touching accent. - const themed = fromSkin({ ui_accent: '#111111', ui_tool: '#ff0000', ui_thinking: '#00ff00' }, {}) + // Independent override: recoloring tool/thinking doesn't leak into accent. + // (Values flow through #20379's contrast adaptation, so assert the + // independence contract, not raw pre-adaptation hexes.) + const themed = fromSkin({ ui_accent: '#3aa0ff', ui_tool: '#ff0000', ui_thinking: '#00ff00' }, {}) + const baseline = fromSkin({ ui_accent: '#3aa0ff' }, {}) expect(themed.color.tool).toBe('#ff0000') expect(themed.color.thinking).toBe('#00ff00') - expect(themed.color.accent).toBe('#111111') + expect(themed.color.tool).not.toBe(themed.color.accent) + expect(themed.color.accent).toBe(baseline.color.accent) // override didn't touch accent - // Default: tool follows accent, thinking follows muted. - const fallback = fromSkin({ ui_accent: '#abcdef', banner_dim: '#123456' }, {}) - expect(fallback.color.tool).toBe('#abcdef') - expect(fallback.color.thinking).toBe('#123456') + // Default: tool follows accent, thinking follows muted — same source → + // identical after adaptation. + const fallback = fromSkin({ ui_accent: '#3aa0ff', banner_dim: '#8a8a8a' }, {}) + expect(fallback.color.tool).toBe(fallback.color.accent) + expect(fallback.color.thinking).toBe(fallback.color.muted) }) it('gives code syntax its own keys, defaulting to accent/text/border/muted', async () => { @@ -602,8 +607,13 @@ describe('background-aware adaptation (OSC-11 light terminals)', () => { const { fromSkin } = await importThemeWithCleanEnv() const { color } = fromSkin({ background: '#0a0a0a', banner_text: '#fafafa', ui_error: '#dd2222' }, {}) + // background paints the surface → status/completion bg; banner_text → status + // fg; ui_error → critical. Semantic hues flow through contrast adaptation, + // so `statusCritical` is asserted to track `ui_error` identically rather + // than pinning an adapted hex. expect(color.statusBg).toBe('#0a0a0a') + expect(color.completionBg).toBe('#0a0a0a') expect(color.statusFg).toBe('#fafafa') - expect(color.statusCritical).toBe('#dd2222') + expect(color.statusCritical).toBe(fromSkin({ ui_error: '#dd2222' }, {}).color.error) }) }) diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index de18534d65..bfd777a2f0 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -840,7 +840,10 @@ export function fromSkin( // 3. Authored tone overrides: a skin may still hand-tune any tone; the // derived ladder is the default, not a cage. Chip/selection re-derive // from the FINAL surface so dependents stay coherent with overrides. - const surface = c('completion_menu_bg') ?? derived.completionBg + // `background` is theme-sdk's cross-surface base: the TUI paints the + // terminal with it (applySkin → setTerminalBackground), so panels/status + // must sit on it too — fall the surface back to it below completion_menu_bg. + const surface = c('completion_menu_bg') ?? c('background') ?? derived.completionBg // Re-mix the chip only when the skin authored its own surface; otherwise // the derived value already carries the identity seeds (e.g. Hermes navy). @@ -859,12 +862,14 @@ export function fromSkin( sessionLabel: c('session_label') ?? c('banner_dim') ?? derived.sessionLabel, sessionBorder: c('session_border') ?? c('banner_dim') ?? derived.sessionBorder, statusBg: c('status_bar_bg') ?? surface, - statusFg: c('status_bar_text') ?? derived.statusFg, + statusFg: c('status_bar_text') ?? c('ui_text') ?? c('banner_text') ?? derived.statusFg, selectionBg: c('selection_bg') ?? c('completion_menu_current_bg') ?? derived.selectionBg, // Element tokens + skinnable diffs (theme-sdk): overridable, else the // derived defaults (tool→accent, thinking→muted, diff_* → DIFF_* ladder). + // thinking tracks the EFFECTIVE muted (banner_dim override included), not + // just derived.muted, so recoloring muted carries the reasoning body with it. tool: c('ui_tool') ?? derived.tool, - thinking: c('ui_thinking') ?? derived.thinking, + thinking: c('ui_thinking') ?? c('banner_dim') ?? derived.thinking, diffAdded: c('diff_added') ?? derived.diffAdded, diffRemoved: c('diff_removed') ?? derived.diffRemoved, diffAddedWord: c('diff_added_word') ?? derived.diffAddedWord, @@ -873,7 +878,7 @@ export function fromSkin( syntaxString: c('syntax_string') ?? derived.syntaxString, syntaxNumber: c('syntax_number') ?? derived.syntaxNumber, syntaxKeyword: c('syntax_keyword') ?? derived.syntaxKeyword, - syntaxComment: c('syntax_comment') ?? derived.syntaxComment + syntaxComment: c('syntax_comment') ?? c('banner_dim') ?? derived.syntaxComment } // 4. Guard: contrast floors against the real background + fill polarity. From 300a0f15307d2b4406eb0c7af5d41bbe1555974b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Jul 2026 20:58:00 -0500 Subject: [PATCH 11/12] fix(themes): apply a runtime switch back to default on the desktop ingestBackendSkin returned early for name === 'default' even when apply=true, so a real runtime switch to the default skin (/skin default on CLI/TUI, or config.set display.skin=default) emitted skin.changed but never repainted the desktop. 'default' is no-opinion on the PALETTE (the desktop keeps its own nous default, so we still never register a converted theme under it), but it IS a valid apply TARGET: setTheme normalizes 'default' -> nous, so switching back repaints to the desktop default. Skip only the registry step for 'default' and let it flow through the apply guard. Addresses Copilot review. --- apps/desktop/src/themes/backend-sync.test.ts | 16 ++++++++++++++-- apps/desktop/src/themes/backend-sync.ts | 15 ++++++--------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/themes/backend-sync.test.ts b/apps/desktop/src/themes/backend-sync.test.ts index 63b28712e8..504e4e45c8 100644 --- a/apps/desktop/src/themes/backend-sync.test.ts +++ b/apps/desktop/src/themes/backend-sync.test.ts @@ -45,13 +45,25 @@ describe('ingestBackendSkin', () => { expect($pendingSkinApply.get()).toBe('forest') }) - it('treats default as no-opinion: never registers or applies it', () => { + it('never registers default in the backend store (desktop keeps its own palette)', () => { ingestBackendSkin(skin('default'), { apply: true }) - expect($pendingSkinApply.get()).toBeNull() expect($backendThemes.get().default).toBeUndefined() }) + it('does not apply default on the connect-time seed', () => { + ingestBackendSkin(skin('default'), { apply: false }) + + expect($pendingSkinApply.get()).toBeNull() + }) + + it('applies a runtime switch back to default (repaints the desktop to its own default)', () => { + ingestBackendSkin(skin('neon'), { apply: false }) // gateway.ready seed on some skin + ingestBackendSkin(skin('default'), { apply: true }) // Hermes switched back to default + + expect($pendingSkinApply.get()).toBe('default') + }) + it('does not shadow a built-in name but can still apply it', () => { ingestBackendSkin(skin('mono'), { apply: true }) diff --git a/apps/desktop/src/themes/backend-sync.ts b/apps/desktop/src/themes/backend-sync.ts index f614682906..d0e4ddc203 100644 --- a/apps/desktop/src/themes/backend-sync.ts +++ b/apps/desktop/src/themes/backend-sync.ts @@ -53,17 +53,14 @@ export function ingestBackendSkin(skin: HermesSkin | undefined | null, { apply } 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 - } - + // `default` is "no opinion" on the PALETTE — the desktop keeps its own default + // (nous), so we never register a converted theme under `default`. It is still a + // valid apply TARGET though: a runtime switch back to `default` must repaint the + // desktop to its own default (setTheme normalizes `default` → nous). So we only + // skip the registry step here and let it flow through the apply logic below. // 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]) { + if (name !== 'default' && !BUILTIN_THEMES[name]) { const theme = skinToDesktopTheme(skin as HermesSkin) if (!theme) { From ef81d9d4850df09e443c56835c860f7665740e5c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Jul 2026 21:08:08 -0500 Subject: [PATCH 12/12] fix(cli): add skin to _BUILTIN_SUBCOMMANDS for plugin gating The new hermes skin subcommand must be declared so startup plugin discovery can skip when the user targets it. --- hermes_cli/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 78fa0d2259..c5bf779910 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -13101,7 +13101,7 @@ _BUILTIN_SUBCOMMANDS = frozenset( "project", "proxy", "prompt-size", "send", "sessions", "setup", - "skills", "slack", "status", "tools", "uninstall", "update", + "skin", "skills", "slack", "status", "tools", "uninstall", "update", "version", "webhook", "whatsapp", "whatsapp-cloud", "chat", "secrets", "security", # Help-ish invocations — plugin commands not being listed in # top-level --help is an acceptable trade-off for skipping an