refactor(desktop): slash block-list derives from the Python command registry; only 5 TS-only names stay hand-typed

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.
This commit is contained in:
teknium1
2026-09-12 20:39:38 -07:00
committed by Teknium
parent 057c2c85fc
commit 458595a20b
7 changed files with 198 additions and 58 deletions

View File

@@ -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`;

View File

@@ -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<string, DesktopSlashArgumentMode | null>,
@@ -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)
}
}
})
})

View File

@@ -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<DesktopUnavailableReason, readonly string[]> = {
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<Record<string, string>> = 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<DesktopUnavailableReason, readonly string[]> = {
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) }))
)
]

View File

@@ -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"
}

View File

@@ -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)}

View File

@@ -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)}")

View File

@@ -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