diff --git a/apps/desktop/src/app/session/hooks/use-hermes-config.ts b/apps/desktop/src/app/session/hooks/use-hermes-config.ts index 198f06a13b40..52681e20a858 100644 --- a/apps/desktop/src/app/session/hooks/use-hermes-config.ts +++ b/apps/desktop/src/app/session/hooks/use-hermes-config.ts @@ -1,10 +1,8 @@ -import type { HermesSkin } from '@hermes/shared/skin' import { type MutableRefObject, useCallback, useRef, useState } from 'react' import { getHermesConfig, getHermesConfigDefaults } from '@/hermes' import { BUILTIN_PERSONALITIES, normalizePersonalityValue, personalityNamesFromConfig } from '@/lib/chat-runtime' import { normalize } from '@/lib/text' -import { $gateway } from '@/store/gateway' import { $currentCwd, getComposerSelectionGeneration, @@ -18,9 +16,6 @@ import { setIntroPersonality } from '@/store/session' import { applyAutoSpeakFromConfig } from '@/store/voice-prefs' -// Leaf import (not the `@/themes` barrel) so this hook doesn't drag in the -// ThemeProvider/profile module graph — keeps it decoupled and test-mockable. -import { ingestBackendSkin } from '@/themes/backend-sync' const DEFAULT_VOICE_SECONDS = 120 const FAST_TIERS = new Set(['fast', 'priority', 'on']) @@ -116,16 +111,6 @@ export function useHermesConfig({ activeSessionIdRef, refreshProjectBranch }: He setVoiceMaxRecordingSeconds(recordingLimit(config.voice?.max_recording_seconds)) setSttEnabled(config.stt?.enabled !== false) applyAutoSpeakFromConfig(config) - - // Cross-surface skin sync: a skin Hermes authors/activates from a prompt - // edits config.yaml directly, which never emits `skin.changed`. The - // post-turn config refresh is our catch-all — fetch the resolved palette - // and repaint if the active skin name actually changed (guarded upstream). - void $gateway - .get() - ?.request<{ skin?: HermesSkin }>('config.get', { key: 'skin' }) - .then(res => ingestBackendSkin(res?.skin, { apply: true })) - .catch(() => undefined) } catch { // Config is nice-to-have; chat still works without it. } diff --git a/skills/hermes-themes/SKILL.md b/skills/hermes-themes/SKILL.md index 7551d112fb07..e64185a542ca 100644 --- a/skills/hermes-themes/SKILL.md +++ b/skills/hermes-themes/SKILL.md @@ -23,6 +23,8 @@ editors or ship built-in presets. - The user asks for a custom look ("make me a synthwave theme", "dark forest vibes", "match my brand colors") for Hermes itself. - The user wants the CLI/TUI/desktop to share one coordinated palette. +- The user wants to iterate live ("that coral is too loud, make it teal") — edit + the active skin's YAML and every surface repaints as your tool finishes. ## Prerequisites @@ -68,21 +70,18 @@ full schema in `hermes_cli/skin_engine.py`. `ui_ok`/`ui_warn`/`ui_error` recognizably green/amber/red. 2. **Write the file** to `/skins/.yaml`. Every top-level `colors` key from the template should be present. -3. **Activate — never hand-edit `config.yaml`.** Persist the choice with the safe - writer via `terminal`: +3. **Apply it yourself — never hand-edit `config.yaml`.** Run the safe writer via + `terminal`: ``` hermes config set display.skin ``` - This is the source of truth all surfaces read; it writes valid YAML so it - can't corrupt the file (a bad hand-edit can break the running gateway, - including the `/` menu). - - **Desktop**: repaints automatically after the current turn, and the skin - appears in Appearance / `Cmd-K` / `/skin`. - - **CLI / TUI**: a running session does not hot-reload a config-file change — - you can't switch it live from a tool call. **Tell the user to run - `/skin `** for an instant switch (it also persists); otherwise it - loads on next start. -4. **Confirm** and tell the user how to switch back: `/skin default`. + The gateway's skin watcher notices the change and **repaints every surface live + within ~a second** — CLI, TUI, and desktop — and the skin appears in + Appearance / `Cmd-K` / `/skin`. You apply it; do NOT tell the user to run + `/skin` (they still can, but it's your job). The writer emits valid YAML — a + hand-edit can corrupt the file and break the live gateway (including `/`). +4. **Confirm the new look landed** and tell the user how to revert: run + `hermes config set display.skin default` (or they can `/skin default`). ## Pitfalls @@ -98,14 +97,13 @@ full schema in `hermes_cli/skin_engine.py`. - **Never hand-edit `config.yaml` to activate.** Use `hermes config set display.skin ` — a stray indent in a manual edit corrupts the file and can break the live gateway (including `/`). One command, always valid. -- **A tool call can't live-switch a running CLI/TUI.** Only `/skin ` - (typed by the user) or a restart applies it in-session — say so instead of - claiming it switched. +- **You apply it, not the user.** `hermes config set display.skin ` is + enough — the gateway's watcher repaints every surface within ~a second. Don't + defer to "type /skin yourself"; that's the old behavior. ## Verification - `read_file` the written `/skins/.yaml` and confirm valid YAML with the intended `name` and `colors`. - Run `hermes config get display.skin` and confirm it reports ``. -- Ask the user to confirm the new look (desktop repaints on the next turn; CLI/TUI - after `/skin ` or restart). +- The repaint lands as this turn ends — ask the user to confirm the new look. diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 2b51d785e103..1bf83a9399c5 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -2015,3 +2015,21 @@ def test_slow_completion_does_not_block_fast_handler(completion_method, server): assert fast_elapsed < 2.0, f"fast handler blocked for {fast_elapsed:.2f}s behind {completion_method}" released.set() + + +def test_broadcast_skin_if_changed_on_any_signature_move(server, monkeypatch): + """A skin the agent changes mid-turn goes live once per real move: a name + switch (incl. switch-then-revert) OR an in-place color edit to the active skin + (same name, new file mtime). An unchanged signature never re-broadcasts.""" + emitted = [] + # switch, no-op, switch, then a color edit (same name, bumped mtime). + sigs = iter([("neon", 1.0), ("neon", 1.0), ("forest", 1.0), ("forest", 2.0)]) + monkeypatch.setattr(server, "_emit", lambda ev, sid, payload=None: emitted.append((ev, payload))) + monkeypatch.setattr(server, "_last_skin_sig", None, raising=False) + monkeypatch.setattr(server, "_skin_sig", lambda: next(sigs)) + monkeypatch.setattr(server, "resolve_skin", lambda: {"name": "x", "colors": {}}) + + for _ in range(4): + server._broadcast_skin_if_changed() + + assert [ev for ev, _ in emitted] == ["skin.changed"] * 3 diff --git a/tui_gateway/entry.py b/tui_gateway/entry.py index c585cca158fd..e4c87be4a113 100644 --- a/tui_gateway/entry.py +++ b/tui_gateway/entry.py @@ -398,6 +398,9 @@ def main(): _log_exit("startup write failed (broken stdout pipe before first event)") sys.exit(0) + # Live-apply skins Hermes activates mid-conversation. + server._ensure_skin_watcher() + while True: raw = sys.stdin.readline() if not raw: diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 60ab97028038..4ce8e2d86674 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2422,6 +2422,80 @@ def resolve_skin() -> dict: return {} +# Signature of the last skin broadcast: (name, active user-file mtime). Lets the +# per-tool reconcile fire ``skin.changed`` on any real move — a name switch OR a +# live color edit to the active skin — and nothing else. +_last_skin_sig: tuple[str, float | None] | None = None + + +def _skin_sig() -> tuple[str, float | None]: + """(active skin name, its user-file mtime). Built-ins have no file, so only + their name moves; a user skin's mtime lets an in-place color edit repaint too.""" + name = str((_load_cfg().get("display") or {}).get("skin") or "default") + override = get_hermes_home_override() + home = override if isinstance(override, str) and override else _hermes_home + try: + mtime: float | None = (Path(home) / "skins" / f"{name}.yaml").stat().st_mtime + except OSError: + mtime = None + return name, mtime + + +def _note_skin_broadcast() -> None: + """Sync the reconcile baseline after the /skin RPC emits, so the per-tool + check doesn't re-broadcast the skin /skin just applied.""" + global _last_skin_sig + try: + _last_skin_sig = _skin_sig() + except Exception: + pass + + +def _broadcast_skin_if_changed() -> None: + """Emit ``skin.changed`` when the active skin moved — the agent switched it + (``hermes config set display.skin``) OR edited the active skin's colors in + place ("I don't like that coral" → tweak the YAML). + + Routes through the SAME live path as ``/skin`` so every surface (TUI + desktop) + repaints, no slash command. The signature check is a dict lookup + one stat, + so polling it is ~free. + """ + global _last_skin_sig + try: + sig = _skin_sig() + except Exception: + return + if sig == _last_skin_sig: + return + _last_skin_sig = sig + try: + _emit("skin.changed", "", resolve_skin()) + except Exception: + pass + + +_skin_watcher_started = False + + +def _ensure_skin_watcher() -> None: + """Poll the config for skin changes and broadcast ``skin.changed`` — so a skin + Hermes activates (``hermes config set display.skin``) or recolors goes live on + every surface within ~half a second, on its own, with no tool-hook or slash + command in the loop. Idempotent; started at gateway.ready.""" + global _skin_watcher_started + if _skin_watcher_started: + return + _skin_watcher_started = True + _note_skin_broadcast() # seed the baseline so only a real change repaints + + def _loop() -> None: + while True: + time.sleep(0.5) + _broadcast_skin_if_changed() + + threading.Thread(target=_loop, name="hermes-skin-watcher", daemon=True).start() + + def _resolve_model() -> str: env = ( os.environ.get("HERMES_MODEL", "") @@ -11917,6 +11991,9 @@ def _(rid, params: dict) -> dict: nv = value if key == "skin": _emit("skin.changed", "", resolve_skin()) + # Keep the reconcile baseline in sync so the per-tool check + # doesn't re-broadcast the skin the /skin RPC just applied. + _note_skin_broadcast() resp = {"key": key, "value": nv} if key == "personality": resp["history_reset"] = history_reset @@ -12556,15 +12633,8 @@ def _(rid, params: dict) -> dict: if key == "prompt": return _ok(rid, {"prompt": _load_cfg().get("custom_prompt", "")}) if key == "skin": - # `value` is the active skin name (back-compat, used by the TUI). `skin` - # carries the full resolved palette so cross-surface consumers (the - # desktop) can rebuild the theme without their own YAML loader. return _ok( - rid, - { - "value": (_load_cfg().get("display") or {}).get("skin", "default"), - "skin": resolve_skin(), - }, + rid, {"value": (_load_cfg().get("display") or {}).get("skin", "default")} ) if key == "indicator": # Normalize so a hand-edited config.yaml with stray casing or diff --git a/tui_gateway/ws.py b/tui_gateway/ws.py index 2ab4798df120..b61ef130cc45 100644 --- a/tui_gateway/ws.py +++ b/tui_gateway/ws.py @@ -326,6 +326,9 @@ async def handle_ws(ws: Any) -> None: }, } ) + if ready_ok: + # Live-apply skins Hermes activates mid-conversation. + server._ensure_skin_watcher() if not ready_ok: disconnect_reason = "ready_send_failed" send_failures += 1