feat(cli): two-stage /model autocomplete with ghost text suggestions (#1641)

* feat(cli): two-stage /model autocomplete with ghost text suggestions

- SlashCommandCompleter: Tab-complete providers first (anthropic:, openrouter:, etc.)
  then models within the selected provider
- SlashCommandAutoSuggest: inline ghost text for slash commands, subcommands,
  and /model provider:model two-stage suggestions
- Custom Tab key binding: accepts provider completion and immediately
  re-triggers completions to show that provider's models
- COMMANDS_BY_CATEGORY: structured format with explicit subcommands for
  tab completion and ghost text (prompt, reasoning, voice, skills, cron, browser)
- SUBCOMMANDS dict auto-extracted from command definitions
- Model/provider info cached 60s for responsive completions

* fix: repair test regression and restore gold color from PR #1622

- Fix test_unknown_command_still_shows_error: patch _cprint instead of
  console.print to match the _cprint switch in process_command()
- Restore gold color on 'Type /help' hint using _DIM + _GOLD constants
  instead of bare \033[2m (was losing the #B8860B gold)
- Use _GOLD constant for ambiguous command message for consistency
- Add clarifying comment on SUBCOMMANDS regex fallback

---------

Co-authored-by: Lars van der Zande <lmvanderzande@gmail.com>
This commit is contained in:
Teknium
2026-03-17 01:47:32 -07:00
committed by GitHub
parent 5ada0b95e9
commit 3744118311
5 changed files with 466 additions and 22 deletions

81
cli.py
View File

@@ -468,7 +468,7 @@ from hermes_cli.banner import (
VERSION, RELEASE_DATE, HERMES_AGENT_LOGO, HERMES_CADUCEUS, COMPACT_BANNER,
build_welcome_banner,
)
from hermes_cli.commands import COMMANDS, SlashCommandCompleter
from hermes_cli.commands import COMMANDS, SlashCommandCompleter, SlashCommandAutoSuggest
from hermes_cli import callbacks as _callbacks
from toolsets import get_all_toolsets, get_toolset_info, resolve_toolset, validate_toolset
@@ -3618,18 +3618,18 @@ class HermesCLI:
full_name = matches[0]
if full_name == typed_base:
# Already an exact token — no expansion possible; fall through
self.console.print(f"[bold red]Unknown command: {cmd_lower}[/]")
self.console.print("[dim #B8860B]Type /help for available commands[/]")
_cprint(f"\033[1;31mUnknown command: {cmd_lower}{_RST}")
_cprint(f"{_DIM}{_GOLD}Type /help for available commands{_RST}")
else:
remainder = cmd_original.strip()[len(typed_base):]
full_cmd = full_name + remainder
return self.process_command(full_cmd)
elif len(matches) > 1:
self.console.print(f"[bold yellow]Ambiguous command: {cmd_lower}[/]")
self.console.print(f"[dim]Did you mean: {', '.join(sorted(matches))}?[/]")
_cprint(f"{_GOLD}Ambiguous command: {cmd_lower}{_RST}")
_cprint(f"{_DIM}Did you mean: {', '.join(sorted(matches))}?{_RST}")
else:
self.console.print(f"[bold red]Unknown command: {cmd_lower}[/]")
self.console.print("[dim #B8860B]Type /help for available commands[/]")
_cprint(f"\033[1;31mUnknown command: {cmd_lower}{_RST}")
_cprint(f"{_DIM}{_GOLD}Type /help for available commands{_RST}")
return True
@@ -5746,6 +5746,34 @@ class HermesCLI:
"""Ctrl+Enter (c-j) inserts a newline. Most terminals send c-j for Ctrl+Enter."""
event.current_buffer.insert_text('\n')
@kb.add('tab', eager=True)
def handle_tab(event):
"""Tab: accept completion and re-trigger if we just completed a provider.
After accepting a provider like 'anthropic:', the completion menu
closes and complete_while_typing doesn't fire (no keystroke).
This binding re-triggers completions so stage-2 models appear
immediately.
"""
buf = event.current_buffer
if buf.complete_state:
completion = buf.complete_state.current_completion
if completion is None:
# Menu open but nothing selected — select first then grab it
buf.go_to_completion(0)
completion = buf.complete_state and buf.complete_state.current_completion
if completion is None:
return
# Accept the selected completion
buf.apply_completion(completion)
# If text now looks like "/model provider:", re-trigger completions
text = buf.document.text_before_cursor
if text.startswith("/model ") and text.endswith(":"):
buf.start_completion()
else:
# No menu open — start completions from scratch
buf.start_completion()
# --- Clarify tool: arrow-key navigation for multiple-choice questions ---
@kb.add('up', filter=Condition(lambda: bool(self._clarify_state) and not self._clarify_freetext))
@@ -6012,6 +6040,39 @@ class HermesCLI:
return cli_ref._get_tui_prompt_fragments()
# Create the input area with multiline (shift+enter), autocomplete, and paste handling
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
def _get_model_completer_info() -> dict:
"""Return provider/model info for /model autocomplete."""
try:
from hermes_cli.models import (
_PROVIDER_LABELS, _PROVIDER_MODELS, normalize_provider,
provider_model_ids,
)
current = getattr(cli_ref, "provider", None) or getattr(cli_ref, "requested_provider", "openrouter")
current = normalize_provider(current)
# Provider map: id -> label (only providers with known models)
providers = {}
for pid, plabel in _PROVIDER_LABELS.items():
providers[pid] = plabel
def models_for(provider_name: str) -> list[str]:
norm = normalize_provider(provider_name)
return provider_model_ids(norm)
return {
"current_provider": current,
"providers": providers,
"models_for": models_for,
}
except Exception:
return {}
_completer = SlashCommandCompleter(
skill_commands_provider=lambda: _skill_commands,
model_completer_provider=_get_model_completer_info,
)
input_area = TextArea(
height=Dimension(min=1, max=8, preferred=1),
prompt=get_prompt,
@@ -6020,8 +6081,12 @@ class HermesCLI:
wrap_lines=True,
read_only=Condition(lambda: bool(cli_ref._command_running)),
history=FileHistory(str(self._history_file)),
completer=SlashCommandCompleter(skill_commands_provider=lambda: _skill_commands),
completer=_completer,
complete_while_typing=True,
auto_suggest=SlashCommandAutoSuggest(
history_suggest=AutoSuggestFromHistory(),
completer=_completer,
),
)
# Dynamic height: accounts for both explicit newlines AND visual

View File

@@ -11,11 +11,13 @@ To add an alias: set ``aliases=("short",)`` on the existing ``CommandDef``.
from __future__ import annotations
import os
import re
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from prompt_toolkit.auto_suggest import AutoSuggest, Suggestion
from prompt_toolkit.completion import Completer, Completion
@@ -32,6 +34,7 @@ class CommandDef:
category: str # "Session", "Configuration", etc.
aliases: tuple[str, ...] = () # alternative names: ("bg",)
args_hint: str = "" # argument placeholder: "<prompt>", "[name]"
subcommands: tuple[str, ...] = () # tab-completable subcommands
cli_only: bool = False # only available in CLI
gateway_only: bool = False # only available in gateway/messaging
@@ -75,17 +78,18 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("provider", "Show available providers and current provider",
"Configuration"),
CommandDef("prompt", "View/set custom system prompt", "Configuration",
cli_only=True, args_hint="[text]"),
cli_only=True, args_hint="[text]", subcommands=("clear",)),
CommandDef("personality", "Set a predefined personality", "Configuration",
args_hint="[name]"),
CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose",
"Configuration", cli_only=True),
CommandDef("reasoning", "Manage reasoning effort and display", "Configuration",
args_hint="[level|show|hide]"),
args_hint="[level|show|hide]",
subcommands=("none", "low", "minimal", "medium", "high", "xhigh", "show", "hide", "on", "off")),
CommandDef("skin", "Show or change the display skin/theme", "Configuration",
cli_only=True, args_hint="[name]"),
CommandDef("voice", "Toggle voice mode", "Configuration",
args_hint="[on|off|tts|status]"),
args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status")),
# Tools & Skills
CommandDef("tools", "List available tools", "Tools & Skills",
@@ -93,9 +97,11 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("toolsets", "List available toolsets", "Tools & Skills",
cli_only=True),
CommandDef("skills", "Search, install, inspect, or manage skills",
"Tools & Skills", cli_only=True),
"Tools & Skills", cli_only=True,
subcommands=("search", "browse", "inspect", "install")),
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
cli_only=True, args_hint="[subcommand]"),
cli_only=True, args_hint="[subcommand]",
subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")),
CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills",
aliases=("reload_mcp",)),
CommandDef("plugins", "List installed plugins and their status",
@@ -169,6 +175,26 @@ for _cmd in COMMAND_REGISTRY:
_cat[f"/{_alias}"] = COMMANDS[f"/{_alias}"]
# Subcommands lookup: "/cmd" -> ["sub1", "sub2", ...]
SUBCOMMANDS: dict[str, list[str]] = {}
for _cmd in COMMAND_REGISTRY:
if _cmd.subcommands:
SUBCOMMANDS[f"/{_cmd.name}"] = list(_cmd.subcommands)
# Also extract subcommands hinted in args_hint via pipe-separated patterns
# e.g. args_hint="[on|off|tts|status]" for commands that don't have explicit subcommands.
# NOTE: If a command already has explicit subcommands, this fallback is skipped.
# Use the `subcommands` field on CommandDef for intentional tab-completable args.
_PIPE_SUBS_RE = re.compile(r"[a-z]+(?:\|[a-z]+)+")
for _cmd in COMMAND_REGISTRY:
key = f"/{_cmd.name}"
if key in SUBCOMMANDS or not _cmd.args_hint:
continue
m = _PIPE_SUBS_RE.search(_cmd.args_hint)
if m:
SUBCOMMANDS[key] = m.group(0).split("|")
# ---------------------------------------------------------------------------
# Gateway helpers
# ---------------------------------------------------------------------------
@@ -237,13 +263,34 @@ def slack_subcommand_map() -> dict[str, str]:
# ---------------------------------------------------------------------------
class SlashCommandCompleter(Completer):
"""Autocomplete for built-in slash commands and optional skill commands."""
"""Autocomplete for built-in slash commands, subcommands, and skill commands."""
def __init__(
self,
skill_commands_provider: Callable[[], Mapping[str, dict[str, Any]]] | None = None,
model_completer_provider: Callable[[], dict[str, Any]] | None = None,
) -> None:
self._skill_commands_provider = skill_commands_provider
# model_completer_provider returns {"current_provider": str,
# "providers": {id: label, ...}, "models_for": callable(provider) -> list[str]}
self._model_completer_provider = model_completer_provider
self._model_info_cache: dict[str, Any] | None = None
self._model_info_cache_time: float = 0
def _get_model_info(self) -> dict[str, Any]:
"""Get cached model/provider info for /model autocomplete."""
import time
now = time.monotonic()
if self._model_info_cache is not None and now - self._model_info_cache_time < 60:
return self._model_info_cache
if self._model_completer_provider is None:
return {}
try:
self._model_info_cache = self._model_completer_provider() or {}
self._model_info_cache_time = now
except Exception:
self._model_info_cache = self._model_info_cache or {}
return self._model_info_cache
def _iter_skill_commands(self) -> Mapping[str, dict[str, Any]]:
if self._skill_commands_provider is None:
@@ -348,6 +395,70 @@ class SlashCommandCompleter(Completer):
yield from self._path_completions(path_word)
return
# Check if we're completing a subcommand (base command already typed)
parts = text.split(maxsplit=1)
base_cmd = parts[0].lower()
if len(parts) > 1 or (len(parts) == 1 and text.endswith(" ")):
sub_text = parts[1] if len(parts) > 1 else ""
sub_lower = sub_text.lower()
# /model gets two-stage completion:
# Stage 1: provider names (with : suffix)
# Stage 2: after "provider:", list that provider's models
if base_cmd == "/model" and " " not in sub_text:
info = self._get_model_info()
if info:
current_prov = info.get("current_provider", "")
providers = info.get("providers", {})
models_for = info.get("models_for")
if ":" in sub_text:
# Stage 2: "anthropic:cl" → models for anthropic
prov_part, model_part = sub_text.split(":", 1)
model_lower = model_part.lower()
if models_for:
try:
prov_models = models_for(prov_part)
except Exception:
prov_models = []
for mid in prov_models:
if mid.lower().startswith(model_lower) and mid.lower() != model_lower:
full = f"{prov_part}:{mid}"
yield Completion(
full,
start_position=-len(sub_text),
display=mid,
)
else:
# Stage 1: providers sorted: non-current first, current last
for pid, plabel in sorted(
providers.items(),
key=lambda kv: (kv[0] == current_prov, kv[0]),
):
display_name = f"{pid}:"
if display_name.lower().startswith(sub_lower):
meta = f"({plabel})" if plabel != pid else ""
if pid == current_prov:
meta = f"(current — {plabel})" if plabel != pid else "(current)"
yield Completion(
display_name,
start_position=-len(sub_text),
display=display_name,
display_meta=meta,
)
return
# Static subcommand completions
if " " not in sub_text and base_cmd in SUBCOMMANDS:
for sub in SUBCOMMANDS[base_cmd]:
if sub.startswith(sub_lower) and sub != sub_lower:
yield Completion(
sub,
start_position=-len(sub_text),
display=sub,
)
return
word = text[1:]
for cmd, desc in COMMANDS.items():
@@ -373,6 +484,90 @@ class SlashCommandCompleter(Completer):
)
# ---------------------------------------------------------------------------
# Inline auto-suggest (ghost text) for slash commands
# ---------------------------------------------------------------------------
class SlashCommandAutoSuggest(AutoSuggest):
"""Inline ghost-text suggestions for slash commands and their subcommands.
Shows the rest of a command or subcommand in dim text as you type.
Falls back to history-based suggestions for non-slash input.
"""
def __init__(
self,
history_suggest: AutoSuggest | None = None,
completer: SlashCommandCompleter | None = None,
) -> None:
self._history = history_suggest
self._completer = completer # Reuse its model cache
def get_suggestion(self, buffer, document):
text = document.text_before_cursor
# Only suggest for slash commands
if not text.startswith("/"):
# Fall back to history for regular text
if self._history:
return self._history.get_suggestion(buffer, document)
return None
parts = text.split(maxsplit=1)
base_cmd = parts[0].lower()
if len(parts) == 1 and not text.endswith(" "):
# Still typing the command name: /upd → suggest "ate"
word = text[1:].lower()
for cmd in COMMANDS:
cmd_name = cmd[1:] # strip leading /
if cmd_name.startswith(word) and cmd_name != word:
return Suggestion(cmd_name[len(word):])
return None
# Command is complete — suggest subcommands or model names
sub_text = parts[1] if len(parts) > 1 else ""
sub_lower = sub_text.lower()
# /model gets two-stage ghost text
if base_cmd == "/model" and " " not in sub_text and self._completer:
info = self._completer._get_model_info()
if info:
providers = info.get("providers", {})
models_for = info.get("models_for")
current_prov = info.get("current_provider", "")
if ":" in sub_text:
# Stage 2: after provider:, suggest model
prov_part, model_part = sub_text.split(":", 1)
model_lower = model_part.lower()
if models_for:
try:
for mid in models_for(prov_part):
if mid.lower().startswith(model_lower) and mid.lower() != model_lower:
return Suggestion(mid[len(model_part):])
except Exception:
pass
else:
# Stage 1: suggest provider name with :
for pid in sorted(providers, key=lambda p: (p == current_prov, p)):
candidate = f"{pid}:"
if candidate.lower().startswith(sub_lower) and candidate.lower() != sub_lower:
return Suggestion(candidate[len(sub_text):])
# Static subcommands
if base_cmd in SUBCOMMANDS and SUBCOMMANDS[base_cmd]:
if " " not in sub_text:
for sub in SUBCOMMANDS[base_cmd]:
if sub.startswith(sub_lower) and sub != sub_lower:
return Suggestion(sub[len(sub_text):])
# Fall back to history
if self._history:
return self._history.get_suggestion(buffer, document)
return None
def _file_size_label(path: str) -> str:
"""Return a compact human-readable file size, or '' on error."""
try:

View File

@@ -9,6 +9,8 @@ from hermes_cli.commands import (
COMMANDS_BY_CATEGORY,
CommandDef,
GATEWAY_KNOWN_COMMANDS,
SUBCOMMANDS,
SlashCommandAutoSuggest,
SlashCommandCompleter,
gateway_help_lines,
resolve_command,
@@ -323,3 +325,182 @@ class TestSlashCommandCompleter:
completions = _completions(completer, "/no-desc")
assert len(completions) == 1
assert "Skill command" in completions[0].display_meta_text
# ── SUBCOMMANDS extraction ──────────────────────────────────────────────
class TestSubcommands:
def test_explicit_subcommands_extracted(self):
"""Commands with explicit subcommands on CommandDef are extracted."""
assert "/prompt" in SUBCOMMANDS
assert "clear" in SUBCOMMANDS["/prompt"]
def test_reasoning_has_subcommands(self):
assert "/reasoning" in SUBCOMMANDS
subs = SUBCOMMANDS["/reasoning"]
assert "high" in subs
assert "show" in subs
assert "hide" in subs
def test_voice_has_subcommands(self):
assert "/voice" in SUBCOMMANDS
assert "on" in SUBCOMMANDS["/voice"]
assert "off" in SUBCOMMANDS["/voice"]
def test_cron_has_subcommands(self):
assert "/cron" in SUBCOMMANDS
assert "list" in SUBCOMMANDS["/cron"]
assert "add" in SUBCOMMANDS["/cron"]
def test_commands_without_subcommands_not_in_dict(self):
"""Plain commands should not appear in SUBCOMMANDS."""
assert "/help" not in SUBCOMMANDS
assert "/quit" not in SUBCOMMANDS
assert "/clear" not in SUBCOMMANDS
# ── Subcommand tab completion ───────────────────────────────────────────
class TestSubcommandCompletion:
def test_subcommand_completion_after_space(self):
"""Typing '/reasoning ' then Tab should show subcommands."""
completions = _completions(SlashCommandCompleter(), "/reasoning ")
texts = {c.text for c in completions}
assert "high" in texts
assert "show" in texts
def test_subcommand_prefix_filters(self):
"""Typing '/reasoning sh' should only show 'show'."""
completions = _completions(SlashCommandCompleter(), "/reasoning sh")
texts = {c.text for c in completions}
assert texts == {"show"}
def test_subcommand_exact_match_suppressed(self):
"""Typing the full subcommand shouldn't re-suggest it."""
completions = _completions(SlashCommandCompleter(), "/reasoning show")
texts = {c.text for c in completions}
assert "show" not in texts
def test_no_subcommands_for_plain_command(self):
"""Commands without subcommands yield nothing after space."""
completions = _completions(SlashCommandCompleter(), "/help ")
assert completions == []
# ── Two-stage /model completion ─────────────────────────────────────────
def _model_completer() -> SlashCommandCompleter:
"""Build a completer with mock model/provider info."""
return SlashCommandCompleter(
model_completer_provider=lambda: {
"current_provider": "openrouter",
"providers": {
"anthropic": "Anthropic",
"openrouter": "OpenRouter",
"nous": "Nous Research",
},
"models_for": lambda p: {
"anthropic": ["claude-sonnet-4-20250514", "claude-opus-4-20250414"],
"openrouter": ["anthropic/claude-sonnet-4", "google/gemini-2.5-pro"],
"nous": ["hermes-3-llama-3.1-405b"],
}.get(p, []),
}
)
class TestModelCompletion:
def test_stage1_shows_providers(self):
completions = _completions(_model_completer(), "/model ")
texts = {c.text for c in completions}
assert "anthropic:" in texts
assert "openrouter:" in texts
assert "nous:" in texts
def test_stage1_current_provider_last(self):
completions = _completions(_model_completer(), "/model ")
texts = [c.text for c in completions]
assert texts[-1] == "openrouter:"
def test_stage1_current_provider_labeled(self):
completions = _completions(_model_completer(), "/model ")
for c in completions:
if c.text == "openrouter:":
assert "current" in c.display_meta_text.lower()
break
else:
raise AssertionError("openrouter: not found in completions")
def test_stage1_prefix_filters(self):
completions = _completions(_model_completer(), "/model an")
texts = {c.text for c in completions}
assert texts == {"anthropic:"}
def test_stage2_shows_models(self):
completions = _completions(_model_completer(), "/model anthropic:")
texts = {c.text for c in completions}
assert "anthropic:claude-sonnet-4-20250514" in texts
assert "anthropic:claude-opus-4-20250414" in texts
def test_stage2_prefix_filters_models(self):
completions = _completions(_model_completer(), "/model anthropic:claude-s")
texts = {c.text for c in completions}
assert "anthropic:claude-sonnet-4-20250514" in texts
assert "anthropic:claude-opus-4-20250414" not in texts
def test_stage2_no_model_provider_returns_empty(self):
completions = _completions(SlashCommandCompleter(), "/model ")
assert completions == []
# ── Ghost text (SlashCommandAutoSuggest) ────────────────────────────────
def _suggestion(text: str, completer=None) -> str | None:
"""Get ghost text suggestion for given input."""
suggest = SlashCommandAutoSuggest(completer=completer)
doc = Document(text=text)
class FakeBuffer:
pass
result = suggest.get_suggestion(FakeBuffer(), doc)
return result.text if result else None
class TestGhostText:
def test_command_name_suggestion(self):
"""/he → 'lp'"""
assert _suggestion("/he") == "lp"
def test_command_name_suggestion_reasoning(self):
"""/rea → 'soning'"""
assert _suggestion("/rea") == "soning"
def test_no_suggestion_for_complete_command(self):
assert _suggestion("/help") is None
def test_subcommand_suggestion(self):
"""/reasoning h → 'igh'"""
assert _suggestion("/reasoning h") == "igh"
def test_subcommand_suggestion_show(self):
"""/reasoning sh → 'ow'"""
assert _suggestion("/reasoning sh") == "ow"
def test_no_suggestion_for_non_slash(self):
assert _suggestion("hello") is None
def test_model_stage1_ghost_text(self):
"""/model a → 'nthropic:'"""
completer = _model_completer()
assert _suggestion("/model a", completer=completer) == "nthropic:"
def test_model_stage2_ghost_text(self):
"""/model anthropic:cl → rest of first matching model"""
completer = _model_completer()
s = _suggestion("/model anthropic:cl", completer=completer)
assert s is not None
assert s.startswith("aude-")

View File

@@ -72,15 +72,17 @@ class TestSlashCommandPrefixMatching:
def test_ambiguous_prefix_shows_suggestions(self):
"""/re matches multiple commands — should show ambiguous message."""
cli_obj = _make_cli()
with patch("cli._cprint") as mock_cprint:
cli_obj.process_command("/re")
printed = " ".join(str(c) for c in cli_obj.console.print.call_args_list)
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
assert "Ambiguous" in printed or "Did you mean" in printed
def test_unknown_command_shows_error(self):
"""/xyz should show unknown command error."""
cli_obj = _make_cli()
with patch("cli._cprint") as mock_cprint:
cli_obj.process_command("/xyz")
printed = " ".join(str(c) for c in cli_obj.console.print.call_args_list)
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
assert "Unknown command" in printed
def test_exact_command_still_works(self):

View File

@@ -72,10 +72,11 @@ class TestCLIQuickCommands:
def test_unknown_command_still_shows_error(self):
cli = self._make_cli({})
with patch("cli._cprint") as mock_cprint:
cli.process_command("/nonexistent")
cli.console.print.assert_called()
args = cli.console.print.call_args_list[0][0][0]
assert "unknown command" in args.lower()
mock_cprint.assert_called()
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
assert "unknown command" in printed.lower()
def test_timeout_shows_error(self):
cli = self._make_cli({"slow": {"type": "exec", "command": "sleep 100"}})