diff --git a/cli.py b/cli.py index 0cd37ece8..1914769c5 100755 --- a/cli.py +++ b/cli.py @@ -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 diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 9663c165a..f8d50e356 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -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: "", "[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: diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index 3c4fb8201..22678c96b 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -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-") diff --git a/tests/test_cli_prefix_matching.py b/tests/test_cli_prefix_matching.py index d5174555e..eafa324f3 100644 --- a/tests/test_cli_prefix_matching.py +++ b/tests/test_cli_prefix_matching.py @@ -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() - cli_obj.process_command("/re") - printed = " ".join(str(c) for c in cli_obj.console.print.call_args_list) + with patch("cli._cprint") as mock_cprint: + 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 def test_unknown_command_shows_error(self): """/xyz should show unknown command error.""" cli_obj = _make_cli() - cli_obj.process_command("/xyz") - printed = " ".join(str(c) for c in cli_obj.console.print.call_args_list) + with patch("cli._cprint") as mock_cprint: + cli_obj.process_command("/xyz") + printed = " ".join(str(c) for c in mock_cprint.call_args_list) assert "Unknown command" in printed def test_exact_command_still_works(self): diff --git a/tests/test_quick_commands.py b/tests/test_quick_commands.py index e53f7a3e4..9708b1fb3 100644 --- a/tests/test_quick_commands.py +++ b/tests/test_quick_commands.py @@ -72,10 +72,11 @@ class TestCLIQuickCommands: def test_unknown_command_still_shows_error(self): cli = self._make_cli({}) - 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() + with patch("cli._cprint") as mock_cprint: + cli.process_command("/nonexistent") + 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"}})