From 458595a20b494cb09f4899ff0a4ba925309fec5c Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:39:38 -0700 Subject: [PATCH] refactor(desktop): slash block-list derives from the Python command registry; only 5 TS-only names stay hand-typed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 34 of the 46 `NO_DESKTOP_SURFACE` rows in desktop-slash-commands.ts were a byte-for-byte copy of `desktop=` on the matching CommandDef in hermes_cli/commands.py (7 more were aliases of those rows). The live `commands.catalog` already carries that metadata; the static list was the offline fallback and would silently drift on the next registry edit. Now `hermes_cli/commands.py::desktop_surface_registry()` is the one author of `/name -> desktop` (aliases included). `scripts/dump_desktop_slash_registry.py` writes it to apps/desktop/src/lib/desktop-slash-registry.json, which the desktop imports as its offline fallback (`registryUnavailableSpecs`). Five names the Python registry has never heard of stay in an explicit `TS_ONLY_NO_DESKTOP_SURFACE` with the reason WHY: `/density /details /logs /mouse` are Ink-process-local display toggles (handled in ui-tui/src/app/slash/commands/core.ts; advertised via `_TUI_EXTRA`), and `/pets` is the plural typo of the desktop's own `/pet` action. `/switch` was never a block-list row (it is a `/resume` alias) — not a finding. Cross-language contract: tests/hermes_cli/test_desktop_slash_registry.py asserts the committed JSON == desktop_surface_registry() and that every alias carries its canonical value; desktop-slash-commands.test.ts asserts every dumped row is unavailable/unsuggested offline with the dumped reason and that the TS-only set is disjoint from the dump. Both sides fail on drift (sabotage: flipping one `desktop=` in commands.py -> Python test "stale"; adding `/clear` to the TS-only list -> vitest disjointness fails; dropping `registryUnavailableSpecs()` -> 4 existing vitest cases fail). Behavior change: none for users. `/model` (`desktop="hidden"`) keeps its local picker spec; `hidden` is a popover flag read from the live catalog. Sites: apps/desktop/src/lib/desktop-slash-commands.ts::NO_DESKTOP_SURFACE (46 rows) -> hermes_cli/commands.py::desktop_surface_registry (41 rows via the dump) + TS_ONLY_NO_DESKTOP_SURFACE (5 rows). apps/desktop/src/AGENTS.md updated. --- apps/desktop/src/AGENTS.md | 10 +- .../src/lib/desktop-slash-commands.test.ts | 35 ++++++- .../desktop/src/lib/desktop-slash-commands.ts | 97 ++++++++----------- .../src/lib/desktop-slash-registry.json | 44 +++++++++ hermes_cli/commands.py | 16 +++ scripts/dump_desktop_slash_registry.py | 27 ++++++ .../hermes_cli/test_desktop_slash_registry.py | 27 ++++++ 7 files changed, 198 insertions(+), 58 deletions(-) create mode 100644 apps/desktop/src/lib/desktop-slash-registry.json create mode 100755 scripts/dump_desktop_slash_registry.py create mode 100644 tests/hermes_cli/test_desktop_slash_registry.py diff --git a/apps/desktop/src/AGENTS.md b/apps/desktop/src/AGENTS.md index 9afe8dc8dd..48d4d65b4b 100644 --- a/apps/desktop/src/AGENTS.md +++ b/apps/desktop/src/AGENTS.md @@ -28,8 +28,14 @@ via `/api/gateway/*`). Never re-parent the gateway under the backend — `gatewa - The backend already provides everything: `commands.catalog` and `complete.slash` include built-ins, user `quick_commands`, AND skill-derived commands. No new RPC is needed to see skills. - `src/lib/desktop-slash-commands.ts` is the load-bearing file: `DESKTOP_COMMAND_SPECS` (built-ins - and their desktop surfaces) + `NO_DESKTOP_SURFACE` block-lists (terminal-only / messaging-only / - picker-owned / settings-owned / advanced). `isDesktopSlashCommand(name)` gates **execution** (true + and their desktop surfaces) + the block-list. A command's desktop disposition (terminal-only / + messaging-only / settings-owned / advanced / hidden) is authored ONCE, as `desktop=` on its + `CommandDef` in `hermes_cli/commands.py`; the live `commands.catalog` carries it, and + `src/lib/desktop-slash-registry.json` (regenerate with `scripts/dump_desktop_slash_registry.py`; + `tests/hermes_cli/test_desktop_slash_registry.py` + the vitest file fail on drift) is the offline + fallback. Only names the Python registry has never heard of (`/density`, `/details`, `/logs`, + `/mouse` — Ink-local; `/pets`) live in `TS_ONLY_NO_DESKTOP_SURFACE`. `isDesktopSlashCommand(name)` + gates **execution** (true for built-ins AND any non-built-in so typed skill/quick commands run); `isDesktopSlashSuggestion(name)` gates **discovery** — used by BOTH completion paths in `app/chat/composer/hooks/use-slash-completions.ts` and by `filterDesktopCommandsCatalog`; diff --git a/apps/desktop/src/lib/desktop-slash-commands.test.ts b/apps/desktop/src/lib/desktop-slash-commands.test.ts index c45a78d0a4..43726561eb 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.test.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.test.ts @@ -16,8 +16,10 @@ import { rankSkillCommands, rememberDesktopCommandsCatalog, resolveDesktopCommand, - slashCompletionGroup + slashCompletionGroup, + TS_ONLY_NO_DESKTOP_SURFACE } from './desktop-slash-commands' +import desktopSlashRegistry from './desktop-slash-registry.json' function registryCatalog( modes: Record, @@ -471,3 +473,34 @@ describe('rankSkillCommands', () => { expect(ranked.map(row => row.text)).toEqual(['/sessions', '/research']) }) }) + +describe('registry-derived block-list (contract with hermes_cli/commands.py)', () => { + beforeEach(() => rememberDesktopCommandsCatalog(undefined)) + + it('marks every registry row with a reason unavailable offline, without a hand-typed copy', () => { + for (const [name, reason] of Object.entries(desktopSlashRegistry)) { + if (reason === 'hidden') { + continue + } + + const spec = resolveDesktopCommand(name) + + // A desktop-owned action (e.g. /model picker) may override the registry. + if (spec?.surface.kind === 'unavailable') { + expect(spec.surface.reason).toBe(reason) + } + + expect(isDesktopSlashSuggestion(name)).toBe(false) + } + }) + + it('keeps the TS-only list disjoint from the registry dump', () => { + for (const names of Object.values(TS_ONLY_NO_DESKTOP_SURFACE)) { + for (const name of names) { + expect(name in desktopSlashRegistry, `${name} is in the Python registry — drop the TS row`).toBe(false) + expect(isDesktopSlashSuggestion(name)).toBe(false) + expect(isDesktopSlashCommand(name)).toBe(false) + } + } + }) +}) diff --git a/apps/desktop/src/lib/desktop-slash-commands.ts b/apps/desktop/src/lib/desktop-slash-commands.ts index 696eb401ef..f1decc521d 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.ts @@ -1,5 +1,7 @@ import { peekCachedSlashCompletion } from '@/lib/slash-completion-cache' +import desktopSlashRegistry from './desktop-slash-registry.json' + export interface CommandsCatalogSection { name: string pairs: [string, string][] @@ -280,65 +282,50 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [ } ] -// Known commands with no desktop surface (and no alias) — a flat name list -// per reason beats 40 identical object literals. -const NO_DESKTOP_SURFACE: Record = { - terminal: [ - '/busy', - '/clear', - '/config', - '/copy', - '/cron', - '/density', - '/details', - '/exit', - '/footer', - '/gateway', - '/history', - '/image', - '/indicator', - '/logs', - '/mouse', - '/paste', - '/platforms', - '/plugins', - '/quit', - '/redraw', - '/reload', - '/restart', - '/sb', - '/set-home', - '/sethome', - '/snap', - '/snapshot', - '/statusbar', - '/toolsets', - '/update', - '/verbose' - ], - messaging: ['/approve', '/deny'], - settings: ['/skills', '/pets', '/login'], - advanced: [ - '/curator', - '/fast', - '/insights', - '/kanban', - '/reasoning', - '/reload-mcp', - '/reload_mcp', - '/reload-skills', - '/reload_skills' - ], - // /voice arms SERVER-side capture (voice.record → PortAudio on the backend - // host) — meaningless on desktop, which has its own composer-native voice - // conversation (mic menu / Ctrl+B) with client-side capture and playback. - // Point the user at the button instead of a generic "advanced" shrug. - 'composer-voice': ['/voice'] +/** + * Offline fallback for the registry's `desktop=` metadata, dumped from + * `hermes_cli/commands.py::desktop_surface_registry` by + * `scripts/dump_desktop_slash_registry.py`. The live `commands.catalog` answers + * first (`specFromCatalog`); this copy only covers the gap before the backend + * replies. A Python test and `desktop-slash-commands.test.ts` both fail when + * the JSON drifts from either side, so the Python registry stays the single + * place a command's desktop disposition is authored. + */ +const REGISTRY_DESKTOP_SURFACE: Readonly> = desktopSlashRegistry + +/** + * Commands the Python registry has never heard of, so they cannot ride the + * dump above. `/density`, `/details`, `/logs`, `/mouse` are Ink-process-local + * display toggles handled inside `ui-tui/src/app/slash/commands/core.ts` + * (three are advertised through `tui_gateway/server.py::_TUI_EXTRA`); `/pets` + * is the plural typo of the desktop's own `/pet` action and points at the + * sidebar instead of falling through as an unknown skill. + */ +export const TS_ONLY_NO_DESKTOP_SURFACE: Record = { + advanced: [], + 'composer-voice': [], + messaging: [], + settings: ['/pets'], + terminal: ['/density', '/details', '/logs', '/mouse'] +} + +const LOCAL_SPEC_NAMES = new Set(DESKTOP_COMMAND_SPECS.flatMap(spec => [spec.name, ...(spec.aliases ?? [])])) + +/** Registry rows with a real unavailability reason. `hidden` (e.g. `/model`) is + * a popover flag on an executable command and is read from the live catalog + * by `specFromCatalog`; a local spec always wins over the dump. */ +function registryUnavailableSpecs(): DesktopCommandSpec[] { + return Object.entries(REGISTRY_DESKTOP_SURFACE).flatMap(([name, value]) => { + const reason = asUnavailableReason(value) + + return reason && !LOCAL_SPEC_NAMES.has(name) ? [{ name, surface: unavailable(reason) }] : [] + }) } const ALL_SPECS: readonly DesktopCommandSpec[] = [ ...DESKTOP_COMMAND_SPECS, - ...(Object.entries(NO_DESKTOP_SURFACE) as [DesktopUnavailableReason, readonly string[]][]).flatMap( + ...registryUnavailableSpecs(), + ...(Object.entries(TS_ONLY_NO_DESKTOP_SURFACE) as [DesktopUnavailableReason, readonly string[]][]).flatMap( ([reason, names]) => names.map(name => ({ name, surface: unavailable(reason) })) ) ] diff --git a/apps/desktop/src/lib/desktop-slash-registry.json b/apps/desktop/src/lib/desktop-slash-registry.json new file mode 100644 index 0000000000..d8a05d279d --- /dev/null +++ b/apps/desktop/src/lib/desktop-slash-registry.json @@ -0,0 +1,44 @@ +{ + "/approve": "messaging", + "/busy": "terminal", + "/clear": "terminal", + "/config": "terminal", + "/copy": "terminal", + "/cron": "terminal", + "/curator": "advanced", + "/deny": "messaging", + "/exit": "terminal", + "/fast": "advanced", + "/footer": "terminal", + "/gateway": "terminal", + "/history": "terminal", + "/image": "terminal", + "/indicator": "terminal", + "/insights": "advanced", + "/kanban": "advanced", + "/login": "settings", + "/model": "hidden", + "/paste": "terminal", + "/platforms": "terminal", + "/plugins": "terminal", + "/quit": "terminal", + "/reasoning": "advanced", + "/redraw": "terminal", + "/reload": "terminal", + "/reload-mcp": "advanced", + "/reload-skills": "advanced", + "/reload_mcp": "advanced", + "/reload_skills": "advanced", + "/restart": "terminal", + "/sb": "terminal", + "/set-home": "terminal", + "/sethome": "terminal", + "/skills": "settings", + "/snap": "terminal", + "/snapshot": "terminal", + "/statusbar": "terminal", + "/toolsets": "terminal", + "/update": "terminal", + "/verbose": "terminal", + "/voice": "composer-voice" +} diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 5bc9eabeb4..75f8ba5515 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -325,6 +325,22 @@ def command_desktop_meta(cmd: CommandDef) -> dict[str, str | None]: return {"argument_mode": infer_argument_mode(cmd), "desktop": cmd.desktop} +def desktop_surface_registry() -> dict[str, str]: + """``/name`` (and every alias) -> ``desktop`` disposition, for each command that has one. + + The desktop app reads this live from ``commands.catalog``; the copy committed at + ``apps/desktop/src/lib/desktop-slash-registry.json`` (``scripts/dump_desktop_slash_registry.py``) + is its offline fallback before the catalog answers, so the registry stays the ONLY place a + command's desktop disposition is authored. A test on each side fails when the two drift. + """ + return { + f"/{key}": cmd.desktop + for cmd in COMMAND_REGISTRY + if cmd.desktop + for key in (cmd.name, *cmd.aliases) + } + + # Every name and alias -> its CommandDef. _COMMAND_LOOKUP: dict[str, CommandDef] = { key: cmd for cmd in COMMAND_REGISTRY for key in (cmd.name, *cmd.aliases)} diff --git a/scripts/dump_desktop_slash_registry.py b/scripts/dump_desktop_slash_registry.py new file mode 100755 index 0000000000..24a1116d9b --- /dev/null +++ b/scripts/dump_desktop_slash_registry.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Regenerate apps/desktop/src/lib/desktop-slash-registry.json from COMMAND_REGISTRY. + +Run after changing any ``desktop=`` value or alias in ``hermes_cli/commands.py``; +``tests/hermes_cli/test_commands.py`` fails until the committed copy matches. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +OUT = ROOT / "apps" / "desktop" / "src" / "lib" / "desktop-slash-registry.json" + + +def render() -> str: + sys.path.insert(0, str(ROOT)) + from hermes_cli.commands import desktop_surface_registry + + return json.dumps(desktop_surface_registry(), indent=2, sort_keys=True) + "\n" + + +if __name__ == "__main__": + OUT.write_text(render(), encoding="utf-8") + print(f"wrote {OUT.relative_to(ROOT)}") diff --git a/tests/hermes_cli/test_desktop_slash_registry.py b/tests/hermes_cli/test_desktop_slash_registry.py new file mode 100644 index 0000000000..556f3338c8 --- /dev/null +++ b/tests/hermes_cli/test_desktop_slash_registry.py @@ -0,0 +1,27 @@ +"""The desktop's offline slash block-list is a dump of COMMAND_REGISTRY, never hand-authored.""" + +import json +from pathlib import Path + +from hermes_cli.commands import COMMAND_REGISTRY, desktop_surface_registry, resolve_command + +ROOT = Path(__file__).resolve().parents[2] +DUMP = ROOT / "apps" / "desktop" / "src" / "lib" / "desktop-slash-registry.json" + + +def test_committed_desktop_dump_matches_registry(): + """Editing a ``desktop=`` value or alias without re-running the dump script is a drift.""" + committed = json.loads(DUMP.read_text(encoding="utf-8")) + assert committed == desktop_surface_registry(), ( + "apps/desktop/src/lib/desktop-slash-registry.json is stale — run " + "scripts/dump_desktop_slash_registry.py" + ) + + +def test_desktop_surface_registry_covers_every_alias_with_its_canonical_value(): + registry = desktop_surface_registry() + for cmd in COMMAND_REGISTRY: + for key in (cmd.name, *cmd.aliases): + assert registry.get(f"/{key}") == cmd.desktop, key + for key in registry: + assert resolve_command(key) is not None, key