mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-28 18:19:28 +00:00
feat(themes): agent-authored skins switch live via a gateway skin watcher
A skin Hermes activates (`hermes config set display.skin X`) or recolors in place now goes live on every surface (CLI, TUI, desktop) within ~half a second, on its own — no `/skin`, no tool-hook timing, no user action. A gateway daemon polls the resolved skin signature `(name, active-file mtime)` every 0.5s and broadcasts `skin.changed` on any real move — a name switch OR a live color edit to the active skin. It routes through the SAME path `/skin` uses, so all surfaces repaint identically. The watcher seeds its baseline at gateway.ready (stdio + ws) so it only fires on a real change; the `/skin` RPC seeds the baseline too so it never double-broadcasts. Subsumes the desktop's post-turn `config.get skin` poll (its skin.changed handler already applies).
This commit is contained in:
parent
91c5c0c1a6
commit
eb454919b2
6 changed files with 117 additions and 40 deletions
|
|
@ -1,10 +1,8 @@
|
|||
import type { HermesSkin } from '@hermes/shared/skin'
|
||||
import { type MutableRefObject, useCallback, useRef, useState } from 'react'
|
||||
|
||||
import { getHermesConfig, getHermesConfigDefaults } from '@/hermes'
|
||||
import { BUILTIN_PERSONALITIES, normalizePersonalityValue, personalityNamesFromConfig } from '@/lib/chat-runtime'
|
||||
import { normalize } from '@/lib/text'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import {
|
||||
$currentCwd,
|
||||
getComposerSelectionGeneration,
|
||||
|
|
@ -18,9 +16,6 @@ import {
|
|||
setIntroPersonality
|
||||
} from '@/store/session'
|
||||
import { applyAutoSpeakFromConfig } from '@/store/voice-prefs'
|
||||
// Leaf import (not the `@/themes` barrel) so this hook doesn't drag in the
|
||||
// ThemeProvider/profile module graph — keeps it decoupled and test-mockable.
|
||||
import { ingestBackendSkin } from '@/themes/backend-sync'
|
||||
|
||||
const DEFAULT_VOICE_SECONDS = 120
|
||||
const FAST_TIERS = new Set(['fast', 'priority', 'on'])
|
||||
|
|
@ -116,16 +111,6 @@ export function useHermesConfig({ activeSessionIdRef, refreshProjectBranch }: He
|
|||
setVoiceMaxRecordingSeconds(recordingLimit(config.voice?.max_recording_seconds))
|
||||
setSttEnabled(config.stt?.enabled !== false)
|
||||
applyAutoSpeakFromConfig(config)
|
||||
|
||||
// Cross-surface skin sync: a skin Hermes authors/activates from a prompt
|
||||
// edits config.yaml directly, which never emits `skin.changed`. The
|
||||
// post-turn config refresh is our catch-all — fetch the resolved palette
|
||||
// and repaint if the active skin name actually changed (guarded upstream).
|
||||
void $gateway
|
||||
.get()
|
||||
?.request<{ skin?: HermesSkin }>('config.get', { key: 'skin' })
|
||||
.then(res => ingestBackendSkin(res?.skin, { apply: true }))
|
||||
.catch(() => undefined)
|
||||
} catch {
|
||||
// Config is nice-to-have; chat still works without it.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ editors or ship built-in presets.
|
|||
- The user asks for a custom look ("make me a synthwave theme", "dark forest
|
||||
vibes", "match my brand colors") for Hermes itself.
|
||||
- The user wants the CLI/TUI/desktop to share one coordinated palette.
|
||||
- The user wants to iterate live ("that coral is too loud, make it teal") — edit
|
||||
the active skin's YAML and every surface repaints as your tool finishes.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
|
@ -68,21 +70,18 @@ full schema in `hermes_cli/skin_engine.py`.
|
|||
`ui_ok`/`ui_warn`/`ui_error` recognizably green/amber/red.
|
||||
2. **Write the file** to `<hermes-home>/skins/<name>.yaml`. Every top-level
|
||||
`colors` key from the template should be present.
|
||||
3. **Activate — never hand-edit `config.yaml`.** Persist the choice with the safe
|
||||
writer via `terminal`:
|
||||
3. **Apply it yourself — never hand-edit `config.yaml`.** Run the safe writer via
|
||||
`terminal`:
|
||||
```
|
||||
hermes config set display.skin <name>
|
||||
```
|
||||
This is the source of truth all surfaces read; it writes valid YAML so it
|
||||
can't corrupt the file (a bad hand-edit can break the running gateway,
|
||||
including the `/` menu).
|
||||
- **Desktop**: repaints automatically after the current turn, and the skin
|
||||
appears in Appearance / `Cmd-K` / `/skin`.
|
||||
- **CLI / TUI**: a running session does not hot-reload a config-file change —
|
||||
you can't switch it live from a tool call. **Tell the user to run
|
||||
`/skin <name>`** for an instant switch (it also persists); otherwise it
|
||||
loads on next start.
|
||||
4. **Confirm** and tell the user how to switch back: `/skin default`.
|
||||
The gateway's skin watcher notices the change and **repaints every surface live
|
||||
within ~a second** — CLI, TUI, and desktop — and the skin appears in
|
||||
Appearance / `Cmd-K` / `/skin`. You apply it; do NOT tell the user to run
|
||||
`/skin` (they still can, but it's your job). The writer emits valid YAML — a
|
||||
hand-edit can corrupt the file and break the live gateway (including `/`).
|
||||
4. **Confirm the new look landed** and tell the user how to revert: run
|
||||
`hermes config set display.skin default` (or they can `/skin default`).
|
||||
|
||||
## Pitfalls
|
||||
|
||||
|
|
@ -98,14 +97,13 @@ full schema in `hermes_cli/skin_engine.py`.
|
|||
- **Never hand-edit `config.yaml` to activate.** Use `hermes config set
|
||||
display.skin <name>` — a stray indent in a manual edit corrupts the file and
|
||||
can break the live gateway (including `/`). One command, always valid.
|
||||
- **A tool call can't live-switch a running CLI/TUI.** Only `/skin <name>`
|
||||
(typed by the user) or a restart applies it in-session — say so instead of
|
||||
claiming it switched.
|
||||
- **You apply it, not the user.** `hermes config set display.skin <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.
|
||||
|
||||
## 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>`.
|
||||
- Ask the user to confirm the new look (desktop repaints on the next turn; CLI/TUI
|
||||
after `/skin <name>` or restart).
|
||||
- The repaint lands as this turn ends — ask the user to confirm the new look.
|
||||
|
|
|
|||
|
|
@ -2015,3 +2015,21 @@ def test_slow_completion_does_not_block_fast_handler(completion_method, server):
|
|||
assert fast_elapsed < 2.0, f"fast handler blocked for {fast_elapsed:.2f}s behind {completion_method}"
|
||||
|
||||
released.set()
|
||||
|
||||
|
||||
def test_broadcast_skin_if_changed_on_any_signature_move(server, monkeypatch):
|
||||
"""A skin the agent changes mid-turn goes live once per real move: a name
|
||||
switch (incl. switch-then-revert) OR an in-place color edit to the active skin
|
||||
(same name, new file mtime). An unchanged signature never re-broadcasts."""
|
||||
emitted = []
|
||||
# switch, no-op, switch, then a color edit (same name, bumped mtime).
|
||||
sigs = iter([("neon", 1.0), ("neon", 1.0), ("forest", 1.0), ("forest", 2.0)])
|
||||
monkeypatch.setattr(server, "_emit", lambda ev, sid, payload=None: emitted.append((ev, payload)))
|
||||
monkeypatch.setattr(server, "_last_skin_sig", None, raising=False)
|
||||
monkeypatch.setattr(server, "_skin_sig", lambda: next(sigs))
|
||||
monkeypatch.setattr(server, "resolve_skin", lambda: {"name": "x", "colors": {}})
|
||||
|
||||
for _ in range(4):
|
||||
server._broadcast_skin_if_changed()
|
||||
|
||||
assert [ev for ev, _ in emitted] == ["skin.changed"] * 3
|
||||
|
|
|
|||
|
|
@ -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", "")
|
||||
|
|
@ -11917,6 +11991,9 @@ def _(rid, params: dict) -> dict:
|
|||
nv = value
|
||||
if key == "skin":
|
||||
_emit("skin.changed", "", resolve_skin())
|
||||
# Keep the reconcile baseline in sync so the per-tool check
|
||||
# doesn't re-broadcast the skin the /skin RPC just applied.
|
||||
_note_skin_broadcast()
|
||||
resp = {"key": key, "value": nv}
|
||||
if key == "personality":
|
||||
resp["history_reset"] = history_reset
|
||||
|
|
@ -12556,15 +12633,8 @@ def _(rid, params: dict) -> dict:
|
|||
if key == "prompt":
|
||||
return _ok(rid, {"prompt": _load_cfg().get("custom_prompt", "")})
|
||||
if key == "skin":
|
||||
# `value` is the active skin name (back-compat, used by the TUI). `skin`
|
||||
# carries the full resolved palette so cross-surface consumers (the
|
||||
# desktop) can rebuild the theme without their own YAML loader.
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"value": (_load_cfg().get("display") or {}).get("skin", "default"),
|
||||
"skin": resolve_skin(),
|
||||
},
|
||||
rid, {"value": (_load_cfg().get("display") or {}).get("skin", "default")}
|
||||
)
|
||||
if key == "indicator":
|
||||
# Normalize so a hand-edited config.yaml with stray casing or
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue