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
+141 -2
View File
@@ -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]: