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:
co-authored by
Claude Opus 4.7
parent
615ad97928
commit
3ffbdfbcc0
@@ -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) ────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user