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:
parent
5ada0b95e9
commit
3744118311
5 changed files with 466 additions and 22 deletions
81
cli.py
81
cli.py
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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-")
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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"}})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue