From bfa27d0a68debfac8b122fb895d98f49cea1c159 Mon Sep 17 00:00:00 2001 From: stablegenius49 <16443023+stablegenius49@users.noreply.github.com> Date: Sat, 7 Mar 2026 17:53:41 -0800 Subject: [PATCH] fix(cli): unify slash command autocomplete registry --- cli.py | 63 +------------------------------ hermes_cli/commands.py | 55 +++++++++++++++++++++++++-- tests/hermes_cli/test_commands.py | 50 ++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 66 deletions(-) create mode 100644 tests/hermes_cli/test_commands.py diff --git a/cli.py b/cli.py index 05ed260df..9ce8ae811 100755 --- a/cli.py +++ b/cli.py @@ -43,7 +43,6 @@ from prompt_toolkit.layout.dimension import Dimension from prompt_toolkit.layout.menus import CompletionsMenu from prompt_toolkit.widgets import TextArea from prompt_toolkit.key_binding import KeyBindings -from prompt_toolkit.completion import Completer, Completion from prompt_toolkit import print_formatted_text as _pt_print from prompt_toolkit.formatted_text import ANSI as _PT_ANSI import threading @@ -906,34 +905,6 @@ def build_welcome_banner(console: Console, model: str, cwd: str, tools: List[dic console.print(outer_panel) -# ============================================================================ -# CLI Commands -# ============================================================================ - -COMMANDS = { - "/help": "Show this help message", - "/tools": "List available tools", - "/toolsets": "List available toolsets", - "/model": "Show or change the current model", - "/prompt": "View/set custom system prompt", - "/personality": "Set a predefined personality", - "/clear": "Clear screen and reset conversation (fresh start)", - "/history": "Show conversation history", - "/new": "Start a new conversation (reset history)", - "/reset": "Reset conversation only (keep screen)", - "/retry": "Retry the last message (resend to agent)", - "/undo": "Remove the last user/assistant exchange", - "/save": "Save the current conversation", - "/config": "Show current configuration", - "/cron": "Manage scheduled tasks (list, add, remove)", - "/skills": "Search, install, inspect, or manage skills from online registries", - "/platforms": "Show gateway/messaging platform status", - "/paste": "Check clipboard for an image and attach it", - "/reload-mcp": "Reload MCP servers from config.yaml", - "/quit": "Exit the CLI (also: /exit, /q)", -} - - # ============================================================================ # Skill Slash Commands — dynamic commands generated from installed skills # ============================================================================ @@ -943,38 +914,6 @@ from agent.skill_commands import scan_skill_commands, get_skill_commands, build_ _skill_commands = scan_skill_commands() -class SlashCommandCompleter(Completer): - """Autocomplete for /commands and /skill-name in the input area.""" - - def get_completions(self, document, complete_event): - text = document.text_before_cursor - if not text.startswith("/"): - return - word = text[1:] # strip the leading / - - # Built-in commands - for cmd, desc in COMMANDS.items(): - cmd_name = cmd[1:] - if cmd_name.startswith(word): - yield Completion( - cmd_name, - start_position=-len(word), - display=cmd, - display_meta=desc, - ) - - # Skill commands - for cmd, info in _skill_commands.items(): - cmd_name = cmd[1:] - if cmd_name.startswith(word): - yield Completion( - cmd_name, - start_position=-len(word), - display=cmd, - display_meta=f"⚡ {info['description'][:50]}{'...' if len(info['description']) > 50 else ''}", - ) - - def save_config_value(key_path: str, value: any) -> bool: """ Save a value to the active config file at the specified key path. @@ -2984,7 +2923,7 @@ class HermesCLI: multiline=True, wrap_lines=True, history=FileHistory(str(self._history_file)), - completer=SlashCommandCompleter(), + completer=SlashCommandCompleter(skill_commands_provider=lambda: _skill_commands), complete_while_typing=True, ) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 887476339..4d3448fbe 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -1,9 +1,15 @@ """Slash command definitions and autocomplete for the Hermes CLI. -Contains the COMMANDS dict and the SlashCommandCompleter class. -These are pure data/UI with no HermesCLI state dependency. +Contains the shared built-in ``COMMANDS`` dict and ``SlashCommandCompleter``. +The completer can optionally include dynamic skill slash commands supplied by the +interactive CLI. """ +from __future__ import annotations + +from collections.abc import Callable, Mapping +from typing import Any + from prompt_toolkit.completion import Completer, Completion @@ -29,24 +35,65 @@ COMMANDS = { "/compress": "Manually compress conversation context (flush memories + summarize)", "/usage": "Show token usage for the current session", "/insights": "Show usage insights and analytics (last 30 days)", + "/paste": "Check clipboard for an image and attach it", + "/reload-mcp": "Reload MCP servers from config.yaml", "/quit": "Exit the CLI (also: /exit, /q)", } class SlashCommandCompleter(Completer): - """Autocomplete for /commands in the input area.""" + """Autocomplete for built-in slash commands and optional skill commands.""" + + def __init__( + self, + skill_commands_provider: Callable[[], Mapping[str, dict[str, Any]]] | None = None, + ) -> None: + self._skill_commands_provider = skill_commands_provider + + def _iter_skill_commands(self) -> Mapping[str, dict[str, Any]]: + if self._skill_commands_provider is None: + return {} + try: + return self._skill_commands_provider() or {} + except Exception: + return {} + + @staticmethod + def _completion_text(cmd_name: str, word: str) -> str: + """Return replacement text for a completion. + + When the user has already typed the full command exactly (``/help``), + returning ``help`` would be a no-op and prompt_toolkit suppresses the + menu. Appending a trailing space keeps the dropdown visible and makes + backspacing retrigger it naturally. + """ + return f"{cmd_name} " if cmd_name == word else cmd_name def get_completions(self, document, complete_event): text = document.text_before_cursor if not text.startswith("/"): return + word = text[1:] + for cmd, desc in COMMANDS.items(): cmd_name = cmd[1:] if cmd_name.startswith(word): yield Completion( - cmd_name, + self._completion_text(cmd_name, word), start_position=-len(word), display=cmd, display_meta=desc, ) + + for cmd, info in self._iter_skill_commands().items(): + cmd_name = cmd[1:] + if cmd_name.startswith(word): + description = str(info.get("description", "Skill command")) + short_desc = description[:50] + ("..." if len(description) > 50 else "") + yield Completion( + self._completion_text(cmd_name, word), + start_position=-len(word), + display=cmd, + display_meta=f"⚡ {short_desc}", + ) diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py new file mode 100644 index 000000000..d0bb30369 --- /dev/null +++ b/tests/hermes_cli/test_commands.py @@ -0,0 +1,50 @@ +"""Tests for shared slash command definitions and autocomplete.""" + +from prompt_toolkit.completion import CompleteEvent +from prompt_toolkit.document import Document + +from hermes_cli.commands import COMMANDS, SlashCommandCompleter + + +def _completions(completer: SlashCommandCompleter, text: str): + return list( + completer.get_completions( + Document(text=text), + CompleteEvent(completion_requested=True), + ) + ) + + +class TestCommands: + def test_shared_commands_include_cli_specific_entries(self): + assert COMMANDS["/paste"] == "Check clipboard for an image and attach it" + assert COMMANDS["/reload-mcp"] == "Reload MCP servers from config.yaml" + + +class TestSlashCommandCompleter: + def test_builtin_prefix_completion_uses_shared_registry(self): + completions = _completions(SlashCommandCompleter(), "/re") + texts = {item.text for item in completions} + + assert "reset" in texts + assert "retry" in texts + assert "reload-mcp" in texts + + def test_exact_match_completion_adds_trailing_space(self): + completions = _completions(SlashCommandCompleter(), "/help") + + assert [item.text for item in completions] == ["help "] + + def test_skill_commands_are_completed_from_provider(self): + completer = SlashCommandCompleter( + skill_commands_provider=lambda: { + "/gif-search": {"description": "Search for GIFs across providers"}, + } + ) + + completions = _completions(completer, "/gif") + + assert len(completions) == 1 + assert completions[0].text == "gif-search" + assert str(completions[0].display) == "/gif-search" + assert "⚡ Search for GIFs across providers" == str(completions[0].display_meta)