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:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,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])
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
# =========================================================================
|
||||
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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: "┊"
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user