fix(cli): unify slash command autocomplete registry
This commit is contained in:
parent
5a20c486e3
commit
bfa27d0a68
3 changed files with 102 additions and 66 deletions
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.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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}",
|
||||||
|
)
|
||||||
|
|
|
||||||
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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue