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:
@@ -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`;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) }))
|
||||
)
|
||||
]
|
||||
|
||||
44
apps/desktop/src/lib/desktop-slash-registry.json
Normal file
44
apps/desktop/src/lib/desktop-slash-registry.json
Normal 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"
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
27
scripts/dump_desktop_slash_registry.py
Executable file
27
scripts/dump_desktop_slash_registry.py
Executable 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)}")
|
||||
27
tests/hermes_cli/test_desktop_slash_registry.py
Normal file
27
tests/hermes_cli/test_desktop_slash_registry.py
Normal 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
|
||||
Reference in New Issue
Block a user