desktop: registry-driven slash commands + first-class /resume & /handoff (#42351)

* desktop: surface /tools, /save, /personality and fix /help skill count

Move /tools and /save out of TERMINAL_ONLY_COMMANDS and /personality out of
ADVANCED_COMMANDS so they appear in the desktop slash palette and execute via
the existing slash.exec → command.dispatch fallback. The backend gateway already
accepts these through slash.exec (none are in _PENDING_INPUT_COMMANDS or the
skill list), so no backend change is required.

Recompute skill_count in filterDesktopCommandsCatalog from the filtered pairs.
Previously the /help footer echoed the unfiltered backend total — e.g. "60
skill commands available" while only ~29 actually appeared in the rendered
list, because the desktop hides terminal-only, picker-owned, and advanced
commands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* desktop: keep slash popover live while typing args

The trigger regex `(?:^|[\s])([@/])([^\s@/]*)$` stopped matching the moment
the user typed a space after a slash command, so the popover never showed arg
completions for `/personality`, `/tools`, etc. — even though the backend's
`complete.slash` already returns them with a `replace_from` indicator.

Split the trigger detection so `/` allows args (`/cmd arg1 arg2`) while `@`
keeps the strict no-space behavior. Restrict the slash command name to
`[a-zA-Z][\w-]*` so file paths like `src/foo/bar` don't accidentally trigger
the popover.

Rewrite arg-completion items in useSlashCompletions to insert the full
`/personality alice` token instead of stranding `/alice`: when `replace_from`
is past the command base, prepend the existing prefix to each item's text so
the chip serializer produces a coherent replacement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* cli: complete toolset names after /tools enable|disable

SlashCommandCompleter previously only auto-derived the first subcommand level
from args_hint, so `/tools enable <tab>` yielded nothing — the user had to
remember every toolset key (web, file, spotify, …) and every MCP server prefix.

Add `_tools_completions` that handles both stages: subcommand (list|disable|enable)
and tool name. Filter by current enable state so `/tools enable <tab>` only
offers disabled toolsets and `/tools disable <tab>` only offers enabled ones —
no point suggesting a no-op. MCP server prefixes (server:) come from the
saved mcp_servers config; per-tool completion under a server would require
runtime MCP introspection and is left as follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* desktop: registry-driven slash commands with first-class pickers

Collapse the if/else slash dispatch into one DESKTOP_COMMAND_SPECS table
that drives popover suggestions, per-type composer pills, and execution.

- /resume, /sessions, /switch: inline session completions (like /skin) plus
  a "Browse all sessions…" entry that opens a dedicated session picker overlay
- /handoff: inline platform completion + handoff.request/handoff.state
  gateway bridge so desktop reaches CLI parity
- colored per-type pills (command/skill/theme) in the composer
- strip ANSI and fix width/alignment of slash output in the chat panel

* desktop: fold repeated slash session/output boilerplate into one helper

runExec, /title, /help and the unavailable case each re-derived the same
ensure-session → bail-with-notify → build-renderSlashOutput dance.
withSlashOutput() returns {sessionId, render} or null, so each handler is
a two-line resolve instead of an eight-line preamble.

* desktop: keep backend meta on slash arg completions

Arg suggestions (/personality <name>, /tools enable <toolset>, /handoff
<platform>) were having their meta overwritten with the parent command's
registry description: desktopSlashDescription("/personality none") canonicalizes
back to /personality and returns its blurb. Skip the lookup for arg rows so the
backend's own display_meta ("clear personality overlay", etc.) survives.

* cli: list real personalities in /personality completion

_personality_completions resolved load_config().agent.personalities — but that
schema has no agent.personalities key, so completion always returned just
`none` even though the runtime (load_cli_config().agent.personalities) ships a
dozen built-ins (helpful, kawaii, pirate, …). Read from the same source the
command actually applies, so `/personality ` surfaces the real options.

* desktop: expand bare arg-commands to their options on pick

Picking a command like /personality from the slash popover committed it
immediately instead of advancing to its argument list. Mark arg-taking
commands (/skin, /resume, /handoff, /personality, /tools) in the registry
and, when one is picked bare, insert "/cmd " as plain text and re-open the
popover on its inline options — mirroring typing "/cmd " by hand. Arg picks
(serialized text already contains a space) still commit a single pill.

Also realign trigger-popover loading test with the redesigned popover (the
/help empty-state hint shows when resolved, not while the spinner is up);
the merge from main reintroduced the pre-redesign expectation.

* tui_gateway: fold session-db close into a context manager

Both handoff RPCs repeated the same `db, close_db = _session_db_handle()`
+ `finally: if close_db: db.close()` dance. Turn the helper into a
`_session_db` contextmanager that owns the close, so callers just
`with _session_db(session) as db:`.

* desktop: unblock handoff retries and exact resume ids

Clear timed-out desktop handoffs through the gateway so retries are not stuck behind a pending row, and let typed /resume session ids bypass the loaded sidebar cache.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
brooklyn!
2026-06-11 01:49:24 +00:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 615ad97928
commit 3ffbdfbcc0
31 changed files with 1973 additions and 537 deletions
+163
View File
@@ -691,6 +691,169 @@ class TestSubcommandCompletion:
completions = _completions(SlashCommandCompleter(), "/help ")
assert completions == []
def test_tools_subcommand_completion(self):
"""`/tools ` should suggest list, disable, enable."""
completions = _completions(SlashCommandCompleter(), "/tools ")
texts = {c.text for c in completions}
assert texts == {"list", "disable", "enable"}
def test_tools_subcommand_prefix_filters(self):
completions = _completions(SlashCommandCompleter(), "/tools en")
texts = {c.text for c in completions}
assert texts == {"enable"}
def test_tools_enable_completes_toolset_names(self, monkeypatch):
"""`/tools enable ` should suggest currently-disabled toolsets."""
from hermes_cli import commands as commands_mod
# `web` is enabled, `spotify` is disabled — enabling should only offer
# the disabled ones.
monkeypatch.setattr(
"hermes_cli.tools_config._get_platform_tools",
lambda *_a, **_k: {"web", "file"},
)
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {})
monkeypatch.setattr(
"hermes_cli.tools_config._get_plugin_toolset_keys",
lambda: set(),
)
completions = _completions(SlashCommandCompleter(), "/tools enable ")
texts = {c.text for c in completions}
# Should include disabled toolsets, exclude already-enabled ones.
assert "web" not in texts
assert "file" not in texts
assert "spotify" in texts
def test_tools_disable_completes_enabled_toolsets_only(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.tools_config._get_platform_tools",
lambda *_a, **_k: {"web", "file"},
)
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {})
monkeypatch.setattr(
"hermes_cli.tools_config._get_plugin_toolset_keys",
lambda: set(),
)
completions = _completions(SlashCommandCompleter(), "/tools disable ")
texts = {c.text for c in completions}
# Should include enabled toolsets, exclude disabled ones.
assert texts == {"web", "file"}
def test_tools_enable_partial_filters(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.tools_config._get_platform_tools",
lambda *_a, **_k: set(),
)
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {})
monkeypatch.setattr(
"hermes_cli.tools_config._get_plugin_toolset_keys",
lambda: set(),
)
completions = _completions(SlashCommandCompleter(), "/tools enable sp")
texts = {c.text for c in completions}
assert texts == {"spotify"}
def test_tools_enable_skips_already_listed(self, monkeypatch):
"""If the user already typed a name, don't suggest it again."""
monkeypatch.setattr(
"hermes_cli.tools_config._get_platform_tools",
lambda *_a, **_k: set(),
)
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {})
monkeypatch.setattr(
"hermes_cli.tools_config._get_plugin_toolset_keys",
lambda: set(),
)
completions = _completions(SlashCommandCompleter(), "/tools enable spotify ")
texts = {c.text for c in completions}
assert "spotify" not in texts
def test_tools_suggests_mcp_server_prefixes(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.tools_config._get_platform_tools",
lambda *_a, **_k: set(),
)
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"mcp_servers": {"github": {}, "linear": {}}},
)
monkeypatch.setattr(
"hermes_cli.tools_config._get_plugin_toolset_keys",
lambda: set(),
)
completions = _completions(SlashCommandCompleter(), "/tools enable git")
texts = {c.text for c in completions}
assert "github:" in texts
def _fake_gateway(self, monkeypatch, platforms):
"""Patch load_gateway_config with a fake whose connected platforms are
the keys of `platforms` (name -> home as None or a (chat_id, name) tuple).
"""
from types import SimpleNamespace
enums = {name: SimpleNamespace(value=name) for name in platforms}
homes = {
name: (None if home is None else SimpleNamespace(chat_id=home[0], name=home[1]))
for name, home in platforms.items()
}
fake = SimpleNamespace(
get_connected_platforms=lambda: list(enums.values()),
get_home_channel=lambda p: homes[p.value],
)
monkeypatch.setattr("gateway.config.load_gateway_config", lambda: fake)
def test_handoff_completes_connected_platforms(self, monkeypatch):
"""`/handoff ` offers connected platforms, with or without a home channel."""
self._fake_gateway(
monkeypatch,
{
"telegram": ("123", "Me"),
"discord": None, # no home channel yet -> still listed
},
)
texts = {c.text for c in _completions(SlashCommandCompleter(), "/handoff ")}
assert texts == {"telegram", "discord"}
def test_handoff_filters_by_prefix(self, monkeypatch):
self._fake_gateway(
monkeypatch,
{
"telegram": ("1", "H"),
"signal": ("2", "H"),
},
)
texts = {c.text for c in _completions(SlashCommandCompleter(), "/handoff te")}
assert texts == {"telegram"}
def test_handoff_no_completion_after_platform_chosen(self, monkeypatch):
self._fake_gateway(monkeypatch, {"telegram": ("1", "H")})
assert _completions(SlashCommandCompleter(), "/handoff telegram ") == []
def test_handoff_completion_swallows_config_errors(self, monkeypatch):
def _boom():
raise RuntimeError("no gateway config")
monkeypatch.setattr("gateway.config.load_gateway_config", _boom)
assert _completions(SlashCommandCompleter(), "/handoff ") == []
def test_personality_completes_configured_personalities(self):
"""`/personality ` lists real personalities, not just `none`.
Regression: the completer read load_config().agent.personalities, a path
that never exists, so it always came back empty. It must resolve from the
CLI config the runtime actually applies (which ships built-ins).
"""
texts = {c.text for c in _completions(SlashCommandCompleter(), "/personality ")}
assert "none" in texts
assert len(texts) > 1
# ── Ghost text (SlashCommandAutoSuggest) ────────────────────────────────