fix(cli): unify slash command autocomplete registry
This commit is contained in:
63
cli.py
63
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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}",
|
||||
)
|
||||
|
||||
50
tests/hermes_cli/test_commands.py
Normal file
50
tests/hermes_cli/test_commands.py
Normal 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)
|
||||
Reference in New Issue
Block a user