Merge pull request #68857 from NousResearch/bb/theme-sdk

feat(themes): cross-surface theme SDK — one skin themes CLI, TUI, and desktop, live
This commit is contained in:
brooklyn!
2026-07-21 21:23:44 -05:00
committed by GitHub
30 changed files with 1303 additions and 58 deletions
@@ -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
@@ -0,0 +1,80 @@
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('never registers default in the backend store (desktop keeps its own palette)', () => {
ingestBackendSkin(skin('default'), { apply: true })
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 })
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()
})
})
+88
View File
@@ -0,0 +1,88 @@
/**
* Live skin sync from the Hermes backend.
*
* The backend resolves the active skin (built-in or `$HERMES_HOME/skins/*.yaml`)
* and announces it on `gateway.ready` / `skin.changed`, and answers `config.get
* skin` with the same payload. `ingestBackendSkin` folds that into the desktop:
*
* 1. Registers the converted theme in `$backendThemes` so it appears wherever a
* built-in does — Appearance, Cmd-K, `/skin` — with no per-surface wiring
* (`listAllThemes` merges this store).
* 2. When asked to apply (an explicit change), requests the switch via
* `$pendingSkinApply`, which the ThemeProvider drains through `setTheme`.
*
* `gateway.ready` seeds the baseline WITHOUT applying, so a fresh connect never
* stomps the user's persisted desktop theme; only a genuine name change (Hermes
* authoring/activating a skin from a prompt, or `/skin` elsewhere) repaints.
*/
import type { HermesSkin } from '@hermes/shared/skin'
import { atom } from 'nanostores'
import { BUILTIN_THEMES } from './presets'
import { skinToDesktopTheme } from './skin'
import type { DesktopTheme } from './types'
/** Skins pushed by the backend, keyed by name. Merged by `listAllThemes`. */
export const $backendThemes = atom<Record<string, DesktopTheme>>({})
/** One-shot skin name the ThemeProvider should switch to (it clears this). */
export const $pendingSkinApply = atom<string | null>(null)
// The last skin name we drove onto the desktop. Guards two things: re-applying
// the same skin every post-turn poll, and snapping back after a manual switch —
// only a CHANGE from this value applies. `default` is the "no opinion" sentinel.
let lastSynced: string | null = null
/** Test-only: reset the module's apply guard + registry between cases. */
export function __resetBackendSkinSync(): void {
lastSynced = null
$backendThemes.set({})
$pendingSkinApply.set(null)
}
/**
* Fold a resolved skin into the desktop. `apply: false` (connect-time seed) only
* records the baseline; `apply: true` (runtime change / poll) repaints on a name
* change. Built-in names keep the desktop's own palette but can still be applied.
*/
export function ingestBackendSkin(skin: HermesSkin | undefined | null, { apply }: { apply: boolean }): void {
const name = (skin && typeof skin === 'object' ? skin.name ?? '' : '').trim()
if (!name) {
return
}
// `default` is "no opinion" 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 (name !== 'default' && !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)
}
}
+17 -12
View File
@@ -17,8 +17,9 @@ import { matchesQuery, useMediaQuery } from '@/hooks/use-media-query'
import { persistString, persistStringRecord, storedString, storedStringRecord } from '@/lib/storage'
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
import { $backendThemes, $pendingSkinApply } from './backend-sync'
import { hexToRgb, mix, readableOn } from './color'
import { BUILTIN_THEME_LIST, BUILTIN_THEMES, DEFAULT_SKIN_NAME, DEFAULT_TYPOGRAPHY, nousTheme } from './presets'
import { BUILTIN_THEME_LIST, DEFAULT_SKIN_NAME, DEFAULT_TYPOGRAPHY, nousTheme } from './presets'
import type { DesktopTheme, DesktopThemeColors } from './types'
import { $userThemes, listAllThemes, resolveTheme } from './user-themes'
@@ -319,6 +320,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
// import or a plugin registration shows up live in the palette, settings
// grid, and `/skin` without a reload.
const userThemes = useStore($userThemes)
const backendThemes = useStore($backendThemes)
const registryVersion = useStore($registryVersion)
const availableThemes = useMemo(
@@ -328,9 +330,9 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
label,
description
})),
// userThemes + registryVersion ARE listAllThemes' reactivity.
// userThemes + backendThemes + registryVersion ARE listAllThemes' reactivity.
// eslint-disable-next-line react-hooks/exhaustive-deps
[userThemes, registryVersion]
[userThemes, backendThemes, registryVersion]
)
const [themeName, setThemeNameState] = useState(() =>
@@ -377,6 +379,18 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
modePref.assign(liveProfile(), next)
}, [])
// Drain a backend-driven skin switch (Hermes authoring/activating a skin from a
// prompt, or `/skin` on another surface). setTheme persists it per profile, so
// the choice sticks like any manual pick.
const pendingSkin = useStore($pendingSkinApply)
useEffect(() => {
if (pendingSkin) {
setTheme(pendingSkin)
$pendingSkinApply.set(null)
}
}, [pendingSkin, setTheme])
// The light/dark toggle (Shift+X by default) is owned by the keybind runtime
// (`appearance.toggleMode`) so it shows up in the hotkey map and is rebindable.
@@ -389,12 +403,3 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
}
export const useTheme = (): ThemeContextValue => useContext(ThemeContext)
/** Sync the desktop skin with the active Hermes backend theme on connect. */
export function useSyncThemeFromBackend(backendThemeName: string | undefined, setTheme: (name: string) => void) {
useEffect(() => {
if (backendThemeName && BUILTIN_THEMES[backendThemeName]) {
setTheme(backendThemeName)
}
}, [backendThemeName, setTheme])
}
+4 -1
View File
@@ -1,3 +1,6 @@
export { ThemeProvider, useSyncThemeFromBackend, useTheme } from './context'
export { ingestBackendSkin } from './backend-sync'
export { ThemeProvider, useTheme } from './context'
export { BUILTIN_THEME_LIST, BUILTIN_THEMES, DEFAULT_SKIN_NAME } from './presets'
export { skinToDesktopTheme } from './skin'
export type { DesktopTheme, DesktopThemeColors, DesktopThemeTypography } from './types'
export type { HermesSkin } from '@hermes/shared/skin'
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { luminance, normalizeHex } from './color'
import { skinToDesktopTheme } from './skin'
const withColors = (name: string, colors: Record<string, string>) => skinToDesktopTheme({ name, colors })
describe('skinToDesktopTheme', () => {
it('returns null without a name or colors', () => {
expect(skinToDesktopTheme({ name: 'x' })).toBeNull()
expect(skinToDesktopTheme({ name: '', colors: { background: '#101010' } })).toBeNull()
})
it('maps the accent onto every brand token and keeps a single palette', () => {
const theme = withColors('neon', { background: '#101020', ui_accent: '#ff33aa', banner_text: '#eeeeee' })!
expect(theme.name).toBe('neon')
expect(theme.colors.ring).toBe(theme.colors.primary)
expect(theme.colors.midground).toBe(theme.colors.primary)
// A skin is single-mode: the light/dark toggle must not invert it.
expect(theme.colors).toBe(theme.darkColors)
})
it('seeds the background from status_bar_bg when none is explicit', () => {
const theme = withColors('s', { status_bar_bg: '#0b0b0b', banner_text: '#ffffff' })!
expect(theme.colors.background).toBe('#0b0b0b')
expect(theme.colors.foreground).toBe('#ffffff')
})
it('buckets dark vs light from background luminance', () => {
const dark = withColors('d', { background: '#111111', banner_text: '#eeeeee' })!
const light = withColors('l', { background: '#fafafa', banner_text: '#111111' })!
expect(luminance(dark.colors.background)).toBeLessThan(0.4)
expect(luminance(light.colors.background)).toBeGreaterThan(0.4)
})
it('derives a dark base from light text when no background is given', () => {
const theme = withColors('x', { banner_text: '#eeeeee', ui_accent: '#33ccff' })!
expect(luminance(theme.colors.background)).toBeLessThan(0.4)
})
it('maps ui_error to destructive', () => {
const theme = withColors('e', { background: '#101010', ui_error: '#ff5566' })!
expect(theme.colors.destructive).toBe(normalizeHex('#ff5566'))
})
})
+113
View File
@@ -0,0 +1,113 @@
/**
* Hermes skin → DesktopTheme converter.
*
* A "skin" is the CLI/TUI theme unit: a YAML file in `$HERMES_HOME/skins/` (or a
* built-in) resolved by `hermes_cli/skin_engine.py` and pushed to every surface
* over JSON-RPC (`gateway.ready`, `skin.changed`, `config.get skin`). This is the
* one place the desktop turns that CLI-shaped palette into a `DesktopTheme`, so a
* skin Hermes authors from a prompt lights up all three surfaces from one file.
*
* Skins carry terminal-oriented keys (banner/status/completion). We seed the
* desktop model from the load-bearing few (background, foreground, accent, error)
* and derive every glass/shadcn surface by mixing toward bg/fg — the same "naive
* token converter" strategy as the VS Code importer. A skin is single-mode, so
* both `colors` and `darkColors` get the converted palette; `renderedModeFor`
* still picks `.dark` from the real background luminance.
*/
import type { HermesSkin, SkinColors } from '@hermes/shared/skin'
import { ensureContrast, luminance, mix, normalizeHex, readableOn } from './color'
import type { DesktopTheme, DesktopThemeColors } from './types'
// The accent labels the sidebar in small uppercase text, so it must clear WCAG AA
// for normal text or section headers go invisible — mirrors the VS Code importer.
const ACCENT_MIN_CONTRAST = 4.5
/** First normalizable hex among `keys`, alpha flattened over `backdrop`. */
const pick = (colors: SkinColors, keys: string[], backdrop: string): string | null => {
for (const key of keys) {
const value = normalizeHex(colors[key], backdrop)
if (value) {
return value
}
}
return null
}
const titleCase = (name: string): string => name.charAt(0).toUpperCase() + name.slice(1)
/**
* Convert a resolved skin into a `DesktopTheme`, or null when it carries no
* usable colors (so a broken/empty skin never registers junk).
*/
export function skinToDesktopTheme(skin: HermesSkin): DesktopTheme | null {
const name = (skin.name ?? '').trim()
const colors = skin.colors
if (!name || !colors || typeof colors !== 'object') {
return null
}
// Background is the backdrop every other token flattens alpha over. Skins are
// terminal-first so most only tint chrome — `status_bar_bg` is the closest
// thing to an app surface; `background` is the explicit opt-in for GUI authors.
const seededBg = pick(colors, ['background', 'status_bar_bg'], '#000000')
const foregroundSeed = pick(colors, ['ui_text', 'banner_text', 'status_bar_text'], seededBg ?? '#000000')
// No background given: bucket by foreground luminance (light text ⇒ dark app).
const background = seededBg ?? (foregroundSeed && luminance(foregroundSeed) > 0.5 ? '#141414' : '#f7f7f8')
const dark = luminance(background) < 0.4
const foreground = foregroundSeed ?? (dark ? '#e6e6e6' : '#161616')
const accentSeed =
pick(colors, ['ui_accent', 'banner_accent', 'banner_title'], background) ?? mix(foreground, background, 0.55)
const sidebar = mix(background, foreground, dark ? 0.02 : 0.012)
const accent = ensureContrast(accentSeed, sidebar, ACCENT_MIN_CONTRAST)
const border = pick(colors, ['ui_border', 'banner_border'], background) ?? mix(background, foreground, dark ? 0.16 : 0.14)
const mutedForeground = pick(colors, ['banner_dim', 'session_border'], background) ?? mix(foreground, background, 0.45)
const destructive = pick(colors, ['ui_error'], background) ?? '#e25563'
const palette: DesktopThemeColors = {
background,
foreground,
card: mix(background, foreground, dark ? 0.04 : 0.025),
cardForeground: foreground,
muted: mix(background, foreground, dark ? 0.06 : 0.04),
mutedForeground,
popover: mix(background, foreground, dark ? 0.08 : 0.05),
popoverForeground: foreground,
primary: accent,
primaryForeground: readableOn(accent),
secondary: mix(accent, background, dark ? 0.72 : 0.86),
secondaryForeground: foreground,
accent: mix(accent, background, dark ? 0.82 : 0.88),
accentForeground: foreground,
border,
input: pick(colors, ['completion_menu_bg'], background) ?? mix(background, foreground, dark ? 0.1 : 0.06),
ring: accent,
midground: accent,
midgroundForeground: readableOn(accent),
composerRing: accent,
destructive,
destructiveForeground: readableOn(destructive),
sidebarBackground: sidebar,
sidebarBorder: border,
userBubble: mix(background, accent, dark ? 0.18 : 0.12),
userBubbleBorder: border
}
return {
name,
label: titleCase(name),
description: 'Hermes skin',
// Single palette in both slots: a skin is one-mode, so the light/dark toggle
// shouldn't invert it. renderedModeFor still paints `.dark` from luminance.
colors: palette,
darkColors: palette
}
}
+13 -4
View File
@@ -14,6 +14,7 @@ import { atom, computed } from 'nanostores'
import { registry } from '@/contrib/registry'
import { $backendThemes } from './backend-sync'
import { BUILTIN_THEMES } from './presets'
import type { DesktopTheme, DesktopThemeColors } from './types'
@@ -167,18 +168,26 @@ export function contributedThemes(): DesktopTheme[] {
return out
}
/** Resolve a theme by name across the merged set (built-in + user + contributed). */
/** Resolve a theme by name across the merged set (built-in + user + backend + contributed). */
export function resolveTheme(name: string): DesktopTheme | undefined {
return BUILTIN_THEMES[name] ?? $userThemes.get()[name] ?? contributedThemes().find(theme => theme.name === name)
return (
BUILTIN_THEMES[name] ??
$userThemes.get()[name] ??
$backendThemes.get()[name] ??
contributedThemes().find(theme => theme.name === name)
)
}
/** Built-ins first (stable order), then contributed, then user installs. */
/** Built-ins first (stable order), then contributed, then backend skins, then user installs. */
export function listAllThemes(): DesktopTheme[] {
const user = $userThemes.get()
const backend = $backendThemes.get()
const shadows = (theme: DesktopTheme) => user[theme.name] || backend[theme.name]
return [
...Object.values(BUILTIN_THEMES),
...contributedThemes().filter(theme => !user[theme.name]),
...contributedThemes().filter(theme => !shadows(theme)),
...Object.values(backend).filter(theme => !user[theme.name]),
...Object.values(user)
]
}
+2 -1
View File
@@ -7,7 +7,8 @@
".": "./src/index.ts",
"./billing": "./src/billing-types.ts",
"./billing-policy": "./src/billing-policy.ts",
"./charge-settlement": "./src/charge-settlement.ts"
"./charge-settlement": "./src/charge-settlement.ts",
"./skin": "./src/skin.ts"
},
"types": "./src/index.ts",
"scripts": {
+9
View File
@@ -42,6 +42,15 @@ export {
JsonRpcGatewayClient,
type WebSocketLike
} from './json-rpc-gateway'
export {
type HermesSkin,
SKIN_BRANDING_TOKENS,
SKIN_COLOR_TOKENS,
type SkinBranding,
type SkinBrandingToken,
type SkinColors,
type SkinColorToken
} from './skin'
export {
buildHermesWebSocketUrl,
type GatewayAuthMode,
+110
View File
@@ -0,0 +1,110 @@
/**
* Canonical Hermes skin — the theme SDK's cross-surface contract.
*
* A skin is authored once as YAML in `$HERMES_HOME/skins/<name>.yaml` (or a
* built-in), resolved by the Python skin engine (`hermes_cli/skin_engine.py`),
* and pushed to every surface over JSON-RPC (`gateway.ready`, `skin.changed`,
* `config.get skin`). This is the ONE shape every TypeScript surface consumes;
* each owns a resolver that normalizes it into its render model:
*
* • TUI → `fromSkin` → ansi-safe `Theme` (Ink)
* • Desktop → `skinToDesktopTheme` → CSS custom properties (Tailwind/shadcn)
* • CLI → `hermes_cli/skin_engine` → prompt_toolkit / Rich styles (Python)
*
* Tokens are terminal-first (the CLI is the oldest surface); GUIs derive their
* fuller palettes from the load-bearing few. Every field is optional — a resolver
* falls back to its own default for anything a skin omits.
*/
/** Canonical semantic color tokens a skin may set (the "enum" of the shape). */
export const SKIN_COLOR_TOKENS = [
// Base surface — GUIs + the TUI status bar derive their palette from this.
'background',
// Brand accent + primary.
'ui_accent',
'ui_primary',
'banner_accent',
'banner_title',
// Text.
'ui_text',
'banner_text',
'banner_dim',
// Structure.
'ui_border',
'banner_border',
// Semantic status.
'ui_ok',
'ui_warn',
'ui_error',
'ui_label',
// Element-specific (fall back to accent/muted when unset).
'ui_tool',
'ui_thinking',
'diff_added',
'diff_removed',
'diff_added_word',
'diff_removed_word',
'syntax_string',
'syntax_number',
'syntax_keyword',
'syntax_comment',
// CLI / TUI chrome.
'prompt',
'input_rule',
'response_border',
'shell_dollar',
'selection_bg',
'session_label',
'session_border',
'status_bar_bg',
'status_bar_text',
'status_bar_strong',
'status_bar_dim',
'status_bar_good',
'status_bar_warn',
'status_bar_bad',
'status_bar_critical',
'voice_status_bg',
'completion_menu_bg',
'completion_menu_current_bg',
'completion_menu_meta_bg',
'completion_menu_meta_current_bg'
] as const
export type SkinColorToken = (typeof SKIN_COLOR_TOKENS)[number]
/** Canonical branding/string tokens. */
export const SKIN_BRANDING_TOKENS = [
'agent_name',
'welcome',
'goodbye',
'response_label',
'prompt_symbol',
'help_header'
] as const
export type SkinBrandingToken = (typeof SKIN_BRANDING_TOKENS)[number]
/** Hex color per token. Open-ended so back-compat / niche keys still round-trip. */
export type SkinColors = Partial<Record<SkinColorToken, string>> & { [key: string]: string | undefined }
/** Branding strings per token. Open-ended for the same reason. */
export type SkinBranding = Partial<Record<SkinBrandingToken, string>> & { [key: string]: string | undefined }
/** The resolved skin payload (matches Python's `resolve_skin()`). */
export interface HermesSkin {
name?: string
description?: string
colors?: SkinColors
/** Hand-tuned palette overlay for dark terminals (light-authored skins).
* A resolver picks colors/light_colors/dark_colors by the terminal's
* detected polarity — see the TUI's `themeForSkin`. */
dark_colors?: SkinColors
/** Hand-tuned palette overlay for light terminals (dark-authored skins). */
light_colors?: SkinColors
branding?: SkinBranding
banner_logo?: string
banner_hero?: string
tool_prefix?: string
help_header?: string
}
+14 -1
View File
@@ -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):
@@ -13093,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
@@ -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)
# =========================================================================
+105
View File
@@ -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>``."""
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())
+22 -3
View File
@@ -1,9 +1,15 @@
"""Hermes CLI skin/theme engine.
"""Hermes skin/theme engine — the theme SDK for every surface.
A data-driven skin system that lets users customize the CLI's visual appearance.
Skins are defined as YAML files in ~/.hermes/skins/ or as built-in presets.
A data-driven skin system that lets users (and Hermes itself) customize the
visual appearance across the CLI, the TUI, and the desktop GUI from a single
file. Skins are defined as YAML files in ~/.hermes/skins/ or as built-in presets.
No code changes are needed to add a new skin.
This module is the source of truth: it resolves the active skin, and the gateway
pushes the resolved palette to the TUI and desktop (see tui_gateway's
``resolve_skin`` / ``skin.changed``). A skin dropped in ~/.hermes/skins/ therefore
themes all three surfaces at once — the theme analogue of the plugin SDK.
SKIN YAML SCHEMA
================
@@ -17,6 +23,9 @@ All fields are optional. Missing values inherit from the ``default`` skin.
# Colors: hex values for Rich markup (banner, UI, response box)
colors:
background: "#0e0e12" # App/base surface — the seed the TUI
# status bar and the desktop GUI derive
# their whole palette from (see below).
banner_border: "#CD7F32" # Panel border color
banner_title: "#FFD700" # Panel title text color
banner_accent: "#FFBF00" # Section headers (Available Tools, etc.)
@@ -27,6 +36,16 @@ 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
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)
+30
View File
@@ -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)
+138
View File
@@ -0,0 +1,138 @@
---
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.
- 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
- Write access to the Hermes home dir — `~/.hermes` by default, or `$HERMES_HOME`
/ the active profile's dir. Skins live in `<hermes-home>/skins/`.
- Native tools: `write_file` (create the YAML), `read_file` / `search_files`
(inspect existing skins), `terminal` (activate via `hermes config set`).
## How to Run
1. Pick a lowercase, hyphen-safe `name` (e.g. `synthwave`).
2. Copy `templates/skin.yaml` and fill in the palette (keep every key — missing
keys inherit the `default` skin).
3. `write_file` it to `<hermes-home>/skins/<name>.yaml`.
4. Activate it (see Procedure). Confirm the change landed.
## Quick Reference — element → key
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).
| 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 |
| 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,
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
1. **Design the palette.** Choose a `background` first, then an `ui_accent` that
clears WCAG AA against it (~4.5:1) so labels stay legible — the GUI enforces
contrast but a low-contrast accent still looks washed out. Keep
`ui_ok`/`ui_warn`/`ui_error` recognizably green/amber/red.
2. **Write the file** to `<hermes-home>/skins/<name>.yaml`. Every top-level
`colors` key from the template should be present.
3. **Apply it yourself — never hand-edit `config.yaml`.** Run the safe writer via
`terminal`:
```
hermes config set display.skin <name>
```
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`).
## Tweak the active look (change one thing)
When the user wants to adjust the CURRENT look ("make the tool `●` cyan", "warmer
background"), use the one deterministic command — it edits the ACTIVE skin's ONE
key in place, so everything else (background included) is untouched:
```
hermes skin set <key> <hex> # 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 <name>` / `hermes skin list` switch and
enumerate.
## 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.
- **Never hand-edit `config.yaml` to activate.** Use `hermes config set
display.skin <name>` — a stray indent in a manual edit corrupts the file and
can break the live gateway (including `/`). One command, always valid.
- **You apply it, not the user.** `hermes config set display.skin <name>` 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
- `read_file` the written `<hermes-home>/skins/<name>.yaml` and confirm valid
YAML with the intended `name` and `colors`.
- Run `hermes config get display.skin` and confirm it reports `<name>`.
- The repaint lands as this turn ends — ask the user to confirm the new look.
+49
View File
@@ -0,0 +1,49 @@
# Hermes skin template — themes the CLI, TUI, and desktop GUI from one file.
# Copy to <hermes-home>/skins/<name>.yaml, edit the palette, then set
# `display.skin: <name>` in config.yaml. Missing keys inherit the default skin.
name: synthwave
description: Neon dusk — magenta and cyan on deep indigo
colors:
# Base surface — set this. The GUI derives its palette from it; the TUI
# status bar and CLI chrome seed from it too.
background: "#1a1030"
# Brand accent — buttons, focus rings, GUI primary. Keep it legible (~4.5:1)
# against `background`.
ui_accent: "#ff5fd2"
banner_accent: "#ff5fd2"
# Text hierarchy.
banner_title: "#7ef9ff" # headings / primary
banner_text: "#e6e0ff" # body foreground
ui_text: "#e6e0ff"
banner_dim: "#8a7fb5" # muted / secondary
banner_border: "#3a2a63"
ui_border: "#3a2a63"
# Semantic status.
ui_ok: "#5af7b0"
ui_warn: "#ffcf5f"
ui_error: "#ff6b7d"
# CLI / TUI chrome.
prompt: "#e6e0ff"
input_rule: "#ff5fd2"
response_border: "#7ef9ff"
status_bar_bg: "#120a24"
status_bar_text: "#e6e0ff"
status_bar_good: "#5af7b0"
status_bar_warn: "#ffcf5f"
status_bar_critical: "#ff6b7d"
session_label: "#7ef9ff"
session_border: "#3a2a63"
# Optional flavor — safe to delete.
branding:
agent_name: Hermes Agent
prompt_symbol: "❯"
help_header: "(^_^)? Commands"
tool_prefix: "┊"
+57
View File
@@ -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
+51
View File
@@ -2015,3 +2015,54 @@ 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_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
(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
+3
View File
@@ -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:
+77
View File
@@ -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", "")
@@ -12005,6 +12079,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
+3
View File
@@ -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
+43 -2
View File
@@ -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<typeof vi.fn>) => ({ 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')
})
})
+84
View File
@@ -532,4 +532,88 @@ 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('gives tool + thinking their own keys, defaulting to accent + muted', async () => {
const { fromSkin } = await importThemeWithCleanEnv()
// 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.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 — 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 () => {
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()
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()
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' }, {})
// 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(fromSkin({ ui_error: '#dd2222' }, {}).color.error)
})
})
@@ -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
+9 -9
View File
@@ -454,7 +454,7 @@ function SubagentAccordion({
color={t.color.text}
content={
<>
<Text color={t.color.accent}>● </Text>
<Text color={t.color.tool}>● </Text>
{line}
</>
}
@@ -640,22 +640,22 @@ export const Thinking = memo(function Thinking({
{preview ? (
mode === 'full' ? (
lines.map((line, index) => (
<Text color={t.color.muted} key={index} wrap="wrap-trim">
<Text color={t.color.thinking} key={index} wrap="wrap-trim">
{line || ' '}
{index === lines.length - 1 ? (
<StreamCursor color={t.color.muted} streaming={streaming} visible={active} />
<StreamCursor color={t.color.thinking} streaming={streaming} visible={active} />
) : null}
</Text>
))
) : (
<Text color={t.color.muted} wrap="truncate-end">
<Text color={t.color.thinking} wrap="truncate-end">
{preview}
<StreamCursor color={t.color.muted} streaming={streaming} visible={active} />
<StreamCursor color={t.color.thinking} streaming={streaming} visible={active} />
</Text>
)
) : (
<Text color={t.color.muted}>
<StreamCursor color={t.color.muted} streaming={streaming} visible={active} />
<Text color={t.color.thinking}>
<StreamCursor color={t.color.thinking} streaming={streaming} visible={active} />
</Text>
)}
</Box>
@@ -855,7 +855,7 @@ export const ToolTrail = memo(function ToolTrail({
: [],
content: (
<>
<Spinner color={t.color.accent} variant="tool" /> {label}
<Spinner color={t.color.tool} variant="tool" /> {label}
{tool.startedAt ? ` (${fmtElapsed(now - tool.startedAt)})` : ''}
</>
)
@@ -1072,7 +1072,7 @@ export const ToolTrail = memo(function ToolTrail({
color={group.color}
content={
<>
<Text color={t.color.accent}>● </Text>
<Text color={t.color.tool}>● </Text>
{toolLabel(group)}
{isDelegateGroup ? (
<Text color={t.color.statusFg} dim>
+4 -12
View File
@@ -1,19 +1,11 @@
import type { UsageModelData } from '@hermes/shared/billing'
import type { HermesSkin } from '@hermes/shared/skin'
import type { SessionInfo, SlashCategory, SubagentStatus, Usage } from './types.js'
export interface GatewaySkin {
banner_hero?: string
banner_logo?: string
branding?: Record<string, string>
colors?: Record<string, string>
/** Hand-tuned palette for dark terminals (light-authored skins). */
dark_colors?: Record<string, string>
help_header?: string
/** Hand-tuned palette for light terminals (dark-authored skins). */
light_colors?: Record<string, string>
tool_prefix?: string
}
/** The cross-surface skin contract (canonical shape in `@hermes/shared`).
* Includes the paired light_colors/dark_colors overlays from #20379. */
export type GatewaySkin = HermesSkin
export interface GatewayCompletionItem {
display: string
+4 -4
View File
@@ -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])
}
+42 -2
View File
@@ -25,16 +25,56 @@ type ResettableStream = Pick<NodeJS.WriteStream, 'isTTY' | 'write'> & {
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 {
+58 -7
View File
@@ -1,3 +1,5 @@
import type { SkinBranding, SkinColors } from '@hermes/shared/skin'
import { desaturate, grayOf, liftForContrast, mix, parseColor, relativeLuminance, toHex } from './lib/color.js'
export interface ThemeColors {
@@ -16,6 +18,17 @@ 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
/** Code-block syntax highlight. Default to accent/text/border/muted. */
syntaxString: string
syntaxNumber: string
syntaxKeyword: string
syntaxComment: string
prompt: string
sessionLabel: string
sessionBorder: string
@@ -80,10 +93,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) {
@@ -297,6 +311,18 @@ 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,
// 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).
@@ -763,8 +789,8 @@ export function defaultThemeForCurrentBackground(env: NodeJS.ProcessEnv = proces
// ── Skin → Theme ─────────────────────────────────────────────────────
export function fromSkin(
colors: Record<string, string>,
branding: Record<string, string>,
colors: SkinColors,
branding: SkinBranding,
bannerLogo = '',
bannerHero = '',
toolPrefix = '',
@@ -814,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).
@@ -833,8 +862,23 @@ 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,
selectionBg: c('selection_bg') ?? c('completion_menu_current_bg') ?? derived.selectionBg
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') ?? c('banner_dim') ?? 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,
// 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') ?? c('banner_dim') ?? derived.syntaxComment
}
// 4. Guard: contrast floors against the real background + fill polarity.
@@ -848,6 +892,13 @@ export function fromSkin(
return normalizeThemeForAnsiLightTerminal(
{
// The element tokens theme-sdk introduced (ui_primary, ui_text,
// 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: {