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
+141
-2
@@ -1544,12 +1544,140 @@ class SlashCommandCompleter(Completer):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _tools_completions(sub_text: str, sub_lower: str):
|
||||
"""Yield completions for /tools — subcommand + toolset/MCP-server name.
|
||||
|
||||
Handles both ``/tools <tab>`` (suggesting ``list|disable|enable``) and
|
||||
``/tools enable <tab>`` / ``/tools disable <tab>`` (suggesting toolset
|
||||
keys and MCP server prefixes, filtered by current enable state so the
|
||||
user only sees actionable options).
|
||||
"""
|
||||
SUBS = ("list", "disable", "enable")
|
||||
parts = sub_text.split()
|
||||
trailing_space = sub_text.endswith(" ")
|
||||
|
||||
# Subcommand stage: zero words typed, or completing the first word.
|
||||
if len(parts) == 0 or (len(parts) == 1 and not trailing_space):
|
||||
partial = sub_text if not trailing_space else ""
|
||||
for sub in SUBS:
|
||||
if sub.startswith(partial.lower()) and sub != partial.lower():
|
||||
yield Completion(sub, start_position=-len(partial), display=sub)
|
||||
return
|
||||
|
||||
subcommand = parts[0].lower()
|
||||
if subcommand not in ("enable", "disable"):
|
||||
return
|
||||
|
||||
partial = "" if trailing_space else parts[-1]
|
||||
partial_lower = partial.lower()
|
||||
already = set(parts[1:] if trailing_space else parts[1:-1])
|
||||
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.tools_config import (
|
||||
CONFIGURABLE_TOOLSETS,
|
||||
_get_platform_tools,
|
||||
_get_plugin_toolset_keys,
|
||||
)
|
||||
|
||||
config = load_config()
|
||||
enabled = _get_platform_tools(config, "cli", include_default_mcp_servers=False)
|
||||
|
||||
for ts_key, label, _desc in CONFIGURABLE_TOOLSETS:
|
||||
if ts_key in already or not ts_key.startswith(partial_lower):
|
||||
continue
|
||||
is_on = ts_key in enabled
|
||||
if subcommand == "enable" and is_on:
|
||||
continue
|
||||
if subcommand == "disable" and not is_on:
|
||||
continue
|
||||
yield Completion(
|
||||
ts_key,
|
||||
start_position=-len(partial),
|
||||
display=ts_key,
|
||||
display_meta=label,
|
||||
)
|
||||
|
||||
for ts_key in sorted(_get_plugin_toolset_keys()):
|
||||
if ts_key in already or not ts_key.startswith(partial_lower):
|
||||
continue
|
||||
is_on = ts_key in enabled
|
||||
if subcommand == "enable" and is_on:
|
||||
continue
|
||||
if subcommand == "disable" and not is_on:
|
||||
continue
|
||||
yield Completion(
|
||||
ts_key,
|
||||
start_position=-len(partial),
|
||||
display=ts_key,
|
||||
display_meta="plugin toolset",
|
||||
)
|
||||
|
||||
mcp_servers = config.get("mcp_servers") or {}
|
||||
if isinstance(mcp_servers, dict):
|
||||
for server in sorted(mcp_servers):
|
||||
prefix = f"{server}:"
|
||||
if prefix in already or not prefix.startswith(partial_lower):
|
||||
continue
|
||||
yield Completion(
|
||||
prefix,
|
||||
start_position=-len(partial),
|
||||
display=prefix,
|
||||
display_meta=f"MCP server '{server}'",
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def _handoff_completions(sub_text: str, sub_lower: str):
|
||||
"""Yield platform completions for /handoff.
|
||||
|
||||
Offers connected (enabled + configured) gateway platforms. A recorded
|
||||
home channel is NOT required to list a platform — it's often learned at
|
||||
runtime — so the meta hints whether one is set yet. Completes only the
|
||||
first arg (the platform); once one is chosen, stop.
|
||||
"""
|
||||
parts = sub_text.split()
|
||||
trailing_space = sub_text.endswith(" ")
|
||||
if len(parts) > 1 or (len(parts) == 1 and trailing_space):
|
||||
return
|
||||
partial = "" if (not parts or trailing_space) else parts[-1]
|
||||
partial_lower = partial.lower()
|
||||
try:
|
||||
from gateway.config import load_gateway_config
|
||||
|
||||
gw = load_gateway_config()
|
||||
platforms = gw.get_connected_platforms()
|
||||
except Exception:
|
||||
return
|
||||
for platform in platforms:
|
||||
name = platform.value
|
||||
if not name.startswith(partial_lower):
|
||||
continue
|
||||
try:
|
||||
home = gw.get_home_channel(platform)
|
||||
except Exception:
|
||||
home = None
|
||||
meta = f"→ {home.name}" if home and getattr(home, "name", None) else "send this session here"
|
||||
yield Completion(
|
||||
name,
|
||||
start_position=-len(partial),
|
||||
display=name,
|
||||
display_meta=meta,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _personality_completions(sub_text: str, sub_lower: str):
|
||||
"""Yield completions for /personality from configured personalities."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
personalities = load_config().get("agent", {}).get("personalities", {})
|
||||
# Resolve from the same source the runtime applies personalities —
|
||||
# agent.personalities via the CLI config (which ships the built-ins).
|
||||
# load_config()'s schema has no agent.personalities, so the completer
|
||||
# used to come back empty even with personalities available.
|
||||
from cli import load_cli_config
|
||||
|
||||
personalities = (load_cli_config().get("agent") or {}).get("personalities", {}) or {}
|
||||
if "none".startswith(sub_lower) and "none" != sub_lower:
|
||||
yield Completion(
|
||||
"none",
|
||||
@@ -1602,6 +1730,17 @@ class SlashCommandCompleter(Completer):
|
||||
yield from self._personality_completions(sub_text, sub_lower)
|
||||
return
|
||||
|
||||
# /tools needs multi-word completion (subcommand + toolset name)
|
||||
# so it handles both stages itself, bypassing the single-word
|
||||
# SUBCOMMANDS branch below.
|
||||
if base_cmd == "/tools":
|
||||
yield from self._tools_completions(sub_text, sub_lower)
|
||||
return
|
||||
|
||||
if base_cmd == "/handoff":
|
||||
yield from self._handoff_completions(sub_text, sub_lower)
|
||||
return
|
||||
|
||||
# Static subcommand completions
|
||||
if " " not in sub_text and base_cmd in SUBCOMMANDS and self._command_allowed(base_cmd):
|
||||
for sub in SUBCOMMANDS[base_cmd]:
|
||||
|
||||
Reference in New Issue
Block a user