fix(cli): unify slash command autocomplete registry

This commit is contained in:
stablegenius49 2026-03-07 17:53:41 -08:00 committed by teknium1
parent 5a20c486e3
commit bfa27d0a68
3 changed files with 102 additions and 66 deletions

63
cli.py
View file

@ -43,7 +43,6 @@ from prompt_toolkit.layout.dimension import Dimension
from prompt_toolkit.layout.menus import CompletionsMenu from prompt_toolkit.layout.menus import CompletionsMenu
from prompt_toolkit.widgets import TextArea from prompt_toolkit.widgets import TextArea
from prompt_toolkit.key_binding import KeyBindings 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 import print_formatted_text as _pt_print
from prompt_toolkit.formatted_text import ANSI as _PT_ANSI from prompt_toolkit.formatted_text import ANSI as _PT_ANSI
import threading import threading
@ -906,34 +905,6 @@ def build_welcome_banner(console: Console, model: str, cwd: str, tools: List[dic
console.print(outer_panel) 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 # 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() _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: def save_config_value(key_path: str, value: any) -> bool:
""" """
Save a value to the active config file at the specified key path. Save a value to the active config file at the specified key path.
@ -2984,7 +2923,7 @@ class HermesCLI:
multiline=True, multiline=True,
wrap_lines=True, wrap_lines=True,
history=FileHistory(str(self._history_file)), history=FileHistory(str(self._history_file)),
completer=SlashCommandCompleter(), completer=SlashCommandCompleter(skill_commands_provider=lambda: _skill_commands),
complete_while_typing=True, complete_while_typing=True,
) )

View file

@ -1,9 +1,15 @@
"""Slash command definitions and autocomplete for the Hermes CLI. """Slash command definitions and autocomplete for the Hermes CLI.
Contains the COMMANDS dict and the SlashCommandCompleter class. Contains the shared built-in ``COMMANDS`` dict and ``SlashCommandCompleter``.
These are pure data/UI with no HermesCLI state dependency. 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 from prompt_toolkit.completion import Completer, Completion
@ -29,24 +35,65 @@ COMMANDS = {
"/compress": "Manually compress conversation context (flush memories + summarize)", "/compress": "Manually compress conversation context (flush memories + summarize)",
"/usage": "Show token usage for the current session", "/usage": "Show token usage for the current session",
"/insights": "Show usage insights and analytics (last 30 days)", "/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)", "/quit": "Exit the CLI (also: /exit, /q)",
} }
class SlashCommandCompleter(Completer): 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): def get_completions(self, document, complete_event):
text = document.text_before_cursor text = document.text_before_cursor
if not text.startswith("/"): if not text.startswith("/"):
return return
word = text[1:] word = text[1:]
for cmd, desc in COMMANDS.items(): for cmd, desc in COMMANDS.items():
cmd_name = cmd[1:] cmd_name = cmd[1:]
if cmd_name.startswith(word): if cmd_name.startswith(word):
yield Completion( yield Completion(
cmd_name, self._completion_text(cmd_name, word),
start_position=-len(word), start_position=-len(word),
display=cmd, display=cmd,
display_meta=desc, 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}",
)

View file

@ -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)