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

81
cli.py
View file

@ -468,7 +468,7 @@ from hermes_cli.banner import (
VERSION, RELEASE_DATE, HERMES_AGENT_LOGO, HERMES_CADUCEUS, COMPACT_BANNER, VERSION, RELEASE_DATE, HERMES_AGENT_LOGO, HERMES_CADUCEUS, COMPACT_BANNER,
build_welcome_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 hermes_cli import callbacks as _callbacks
from toolsets import get_all_toolsets, get_toolset_info, resolve_toolset, validate_toolset from toolsets import get_all_toolsets, get_toolset_info, resolve_toolset, validate_toolset
@ -3618,18 +3618,18 @@ class HermesCLI:
full_name = matches[0] full_name = matches[0]
if full_name == typed_base: if full_name == typed_base:
# Already an exact token — no expansion possible; fall through # Already an exact token — no expansion possible; fall through
self.console.print(f"[bold red]Unknown command: {cmd_lower}[/]") _cprint(f"\033[1;31mUnknown command: {cmd_lower}{_RST}")
self.console.print("[dim #B8860B]Type /help for available commands[/]") _cprint(f"{_DIM}{_GOLD}Type /help for available commands{_RST}")
else: else:
remainder = cmd_original.strip()[len(typed_base):] remainder = cmd_original.strip()[len(typed_base):]
full_cmd = full_name + remainder full_cmd = full_name + remainder
return self.process_command(full_cmd) return self.process_command(full_cmd)
elif len(matches) > 1: elif len(matches) > 1:
self.console.print(f"[bold yellow]Ambiguous command: {cmd_lower}[/]") _cprint(f"{_GOLD}Ambiguous command: {cmd_lower}{_RST}")
self.console.print(f"[dim]Did you mean: {', '.join(sorted(matches))}?[/]") _cprint(f"{_DIM}Did you mean: {', '.join(sorted(matches))}?{_RST}")
else: else:
self.console.print(f"[bold red]Unknown command: {cmd_lower}[/]") _cprint(f"\033[1;31mUnknown command: {cmd_lower}{_RST}")
self.console.print("[dim #B8860B]Type /help for available commands[/]") _cprint(f"{_DIM}{_GOLD}Type /help for available commands{_RST}")
return True return True
@ -5746,6 +5746,34 @@ class HermesCLI:
"""Ctrl+Enter (c-j) inserts a newline. Most terminals send c-j for Ctrl+Enter.""" """Ctrl+Enter (c-j) inserts a newline. Most terminals send c-j for Ctrl+Enter."""
event.current_buffer.insert_text('\n') 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 --- # --- Clarify tool: arrow-key navigation for multiple-choice questions ---
@kb.add('up', filter=Condition(lambda: bool(self._clarify_state) and not self._clarify_freetext)) @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() return cli_ref._get_tui_prompt_fragments()
# Create the input area with multiline (shift+enter), autocomplete, and paste handling # 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( input_area = TextArea(
height=Dimension(min=1, max=8, preferred=1), height=Dimension(min=1, max=8, preferred=1),
prompt=get_prompt, prompt=get_prompt,
@ -6020,8 +6081,12 @@ class HermesCLI:
wrap_lines=True, wrap_lines=True,
read_only=Condition(lambda: bool(cli_ref._command_running)), read_only=Condition(lambda: bool(cli_ref._command_running)),
history=FileHistory(str(self._history_file)), history=FileHistory(str(self._history_file)),
completer=SlashCommandCompleter(skill_commands_provider=lambda: _skill_commands), completer=_completer,
complete_while_typing=True, complete_while_typing=True,
auto_suggest=SlashCommandAutoSuggest(
history_suggest=AutoSuggestFromHistory(),
completer=_completer,
),
) )
# Dynamic height: accounts for both explicit newlines AND visual # 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 from __future__ import annotations
import os import os
import re
from collections.abc import Callable, Mapping from collections.abc import Callable, Mapping
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from prompt_toolkit.auto_suggest import AutoSuggest, Suggestion
from prompt_toolkit.completion import Completer, Completion from prompt_toolkit.completion import Completer, Completion
@ -32,6 +34,7 @@ class CommandDef:
category: str # "Session", "Configuration", etc. category: str # "Session", "Configuration", etc.
aliases: tuple[str, ...] = () # alternative names: ("bg",) aliases: tuple[str, ...] = () # alternative names: ("bg",)
args_hint: str = "" # argument placeholder: "<prompt>", "[name]" args_hint: str = "" # argument placeholder: "<prompt>", "[name]"
subcommands: tuple[str, ...] = () # tab-completable subcommands
cli_only: bool = False # only available in CLI cli_only: bool = False # only available in CLI
gateway_only: bool = False # only available in gateway/messaging 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", CommandDef("provider", "Show available providers and current provider",
"Configuration"), "Configuration"),
CommandDef("prompt", "View/set custom system prompt", "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", CommandDef("personality", "Set a predefined personality", "Configuration",
args_hint="[name]"), args_hint="[name]"),
CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose", CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose",
"Configuration", cli_only=True), "Configuration", cli_only=True),
CommandDef("reasoning", "Manage reasoning effort and display", "Configuration", 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", CommandDef("skin", "Show or change the display skin/theme", "Configuration",
cli_only=True, args_hint="[name]"), cli_only=True, args_hint="[name]"),
CommandDef("voice", "Toggle voice mode", "Configuration", 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 # Tools & Skills
CommandDef("tools", "List available tools", "Tools & Skills", CommandDef("tools", "List available tools", "Tools & Skills",
@ -93,9 +97,11 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("toolsets", "List available toolsets", "Tools & Skills", CommandDef("toolsets", "List available toolsets", "Tools & Skills",
cli_only=True), cli_only=True),
CommandDef("skills", "Search, install, inspect, or manage skills", 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", 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", CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills",
aliases=("reload_mcp",)), aliases=("reload_mcp",)),
CommandDef("plugins", "List installed plugins and their status", CommandDef("plugins", "List installed plugins and their status",
@ -169,6 +175,26 @@ for _cmd in COMMAND_REGISTRY:
_cat[f"/{_alias}"] = COMMANDS[f"/{_alias}"] _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 # Gateway helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -237,13 +263,34 @@ def slack_subcommand_map() -> dict[str, str]:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class SlashCommandCompleter(Completer): 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__( def __init__(
self, self,
skill_commands_provider: Callable[[], Mapping[str, dict[str, Any]]] | None = None, skill_commands_provider: Callable[[], Mapping[str, dict[str, Any]]] | None = None,
model_completer_provider: Callable[[], dict[str, Any]] | None = None,
) -> None: ) -> None:
self._skill_commands_provider = skill_commands_provider 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]]: def _iter_skill_commands(self) -> Mapping[str, dict[str, Any]]:
if self._skill_commands_provider is None: if self._skill_commands_provider is None:
@ -348,6 +395,70 @@ class SlashCommandCompleter(Completer):
yield from self._path_completions(path_word) yield from self._path_completions(path_word)
return 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:] word = text[1:]
for cmd, desc in COMMANDS.items(): 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: def _file_size_label(path: str) -> str:
"""Return a compact human-readable file size, or '' on error.""" """Return a compact human-readable file size, or '' on error."""
try: try:

View file

@ -9,6 +9,8 @@ from hermes_cli.commands import (
COMMANDS_BY_CATEGORY, COMMANDS_BY_CATEGORY,
CommandDef, CommandDef,
GATEWAY_KNOWN_COMMANDS, GATEWAY_KNOWN_COMMANDS,
SUBCOMMANDS,
SlashCommandAutoSuggest,
SlashCommandCompleter, SlashCommandCompleter,
gateway_help_lines, gateway_help_lines,
resolve_command, resolve_command,
@ -323,3 +325,182 @@ class TestSlashCommandCompleter:
completions = _completions(completer, "/no-desc") completions = _completions(completer, "/no-desc")
assert len(completions) == 1 assert len(completions) == 1
assert "Skill command" in completions[0].display_meta_text 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): def test_ambiguous_prefix_shows_suggestions(self):
"""/re matches multiple commands — should show ambiguous message.""" """/re matches multiple commands — should show ambiguous message."""
cli_obj = _make_cli() cli_obj = _make_cli()
cli_obj.process_command("/re") with patch("cli._cprint") as mock_cprint:
printed = " ".join(str(c) for c in cli_obj.console.print.call_args_list) cli_obj.process_command("/re")
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
assert "Ambiguous" in printed or "Did you mean" in printed assert "Ambiguous" in printed or "Did you mean" in printed
def test_unknown_command_shows_error(self): def test_unknown_command_shows_error(self):
"""/xyz should show unknown command error.""" """/xyz should show unknown command error."""
cli_obj = _make_cli() cli_obj = _make_cli()
cli_obj.process_command("/xyz") with patch("cli._cprint") as mock_cprint:
printed = " ".join(str(c) for c in cli_obj.console.print.call_args_list) cli_obj.process_command("/xyz")
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
assert "Unknown command" in printed assert "Unknown command" in printed
def test_exact_command_still_works(self): def test_exact_command_still_works(self):

View file

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