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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 466 additions and 22 deletions

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