From 2c0820c9ff55b663833dbca26963d1686e1e2bae Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 5 Jul 2026 04:34:14 -0700 Subject: [PATCH] feat(cli): autocomplete + ghost text for stacked slash-skill invocations (#58763) Follow-up to #57987: after /skill-a the completer previously went silent for a second /skill token. Now, while the leading tokens form an unbroken skill chain (each token a distinct installed skill, under the 5-cap) and the word under the cursor starts with '/', the completer keeps offering the remaining skill commands, and SlashCommandAutoSuggest ghost-suggests the rest of the next skill name. Instruction text, path-like tokens, and broken chains get no suggestions. The TUI's complete.slash RPC reuses SlashCommandCompleter, so it inherits the behavior with no changes. --- hermes_cli/commands.py | 94 +++++++++++++++++++++++++++++++ tests/hermes_cli/test_commands.py | 90 +++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 69c154ec6b0..3e2d03dc358 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -1355,6 +1355,77 @@ class SlashCommandCompleter(Completer): except Exception: return {} + # -- stacked slash-skill completion helpers --------------------------- + + @staticmethod + def _normalize_skill_token(token: str) -> str: + """Canonicalize a typed skill token to its hyphenated /slug form. + + Mirrors resolve_skill_command_key() in agent/skill_commands.py: + underscores (Telegram bot-command form) are interchangeable with + hyphens. + """ + return "/" + token.lstrip("/").replace("_", "-").lower() + + def _is_skill_command(self, token: str) -> bool: + return self._normalize_skill_token(token) in self._iter_skill_commands() + + def _stacked_skill_completions(self, text: str): + """Offer skill-command completions for stacked invocations. + + After ``/skill-a `` the user may chain more leading skills + (``/skill-a /skill-b do XYZ``). While every whitespace-delimited + token so far resolves to a distinct skill command and the current + word under the cursor starts with ``/``, keep offering the remaining + skill commands. The moment the chain is broken (a non-skill token + appears, the cap is reached, or the user is typing plain instruction + text) we offer nothing — instruction text must never be polluted + with skill suggestions. + """ + try: + from agent.skill_commands import _MAX_STACKED_SKILLS as _cap + except Exception: + _cap = 5 + + tokens = text.split() + if text.endswith(" "): + completed, current_word = tokens, "" + else: + completed, current_word = tokens[:-1], tokens[-1] + + # The chain must be unbroken: every completed token is a distinct + # skill command, and there's room left under the cap. + seen: set[str] = set() + for token in completed: + key = self._normalize_skill_token(token) + if key not in self._iter_skill_commands() or key in seen: + return + seen.add(key) + if len(seen) >= _cap: + return + + # Only suggest while the user is typing another /token — a bare + # space after the chain means they may be starting the instruction. + if not current_word.startswith("/"): + return + + word_key = self._normalize_skill_token(current_word) + for cmd, info in self._iter_skill_commands().items(): + if cmd in seen or not cmd.startswith(word_key): + continue + description = str(info.get("description", "Skill command")) + short_desc = description[:50] + ("..." if len(description) > 50 else "") + # Exact match: append a trailing space so the dropdown stays + # visible and the next stacked token can be typed immediately + # (mirrors _completion_text semantics). + replacement = f"{cmd} " if cmd == word_key else cmd + yield Completion( + replacement, + start_position=-len(current_word), + display=cmd, + display_meta=f"⚡ {short_desc}", + ) + # Commands that open pickers when run without arguments. # These should NOT receive a trailing space in completions because: # - The TUI's submit handler applies completions on Enter if input differs @@ -1889,6 +1960,15 @@ class SlashCommandCompleter(Completer): sub_text = parts[1] if len(parts) > 1 else "" sub_lower = sub_text.lower() + # Stacked slash-skill invocations: after `/skill-a ` the user may + # chain more skills (`/skill-a /skill-b …`), so keep offering + # skill-command completions while the leading-skill chain is + # unbroken (see split_stacked_skill_commands in + # agent/skill_commands.py). + if self._is_skill_command(base_cmd): + yield from self._stacked_skill_completions(text) + return + # Dynamic completions for commands with runtime lists if " " not in sub_text: if base_cmd == "/skin": @@ -2023,6 +2103,20 @@ class SlashCommandAutoSuggest(AutoSuggest): sub_text = parts[1] if len(parts) > 1 else "" sub_lower = sub_text.lower() + # Stacked slash-skill invocations: while the leading tokens form an + # unbroken skill chain and the user is typing another /token, + # ghost-suggest the rest of the next skill name. Otherwise fall + # through to the history fallback for instruction text. + if ( + self._completer is not None + and self._completer._is_skill_command(base_cmd) + ): + for completion in self._completer._stacked_skill_completions(text): + remainder = completion.text[-completion.start_position:] \ + if completion.start_position else completion.text + if remainder.strip(): + return Suggestion(remainder) + # Static subcommands if self._completer is not None and not self._completer._command_allowed(base_cmd): return None diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index d357a7c89a6..969ff559366 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -616,6 +616,75 @@ class TestSlashCommandCompleter: assert "Skill command" in completions[0].display_meta_text +# ── Stacked slash-skill completion ────────────────────────────────────── + + +def _stacked_completer(**extra_skills): + skills = { + "/skill-a": {"description": "Skill A"}, + "/skill-b": {"description": "Skill B"}, + "/skill-c": {"description": "Skill C"}, + **extra_skills, + } + return SlashCommandCompleter(skill_commands_provider=lambda: skills) + + +class TestStackedSkillCompletion: + """Second+ leading skill tokens keep getting completions (stacked + slash-skill invocations, Claude Code v2.1.199 port follow-up).""" + + def test_second_skill_token_completes(self): + completions = _completions(_stacked_completer(), "/skill-a /skill-") + displays = {c.display_text for c in completions} + assert displays == {"/skill-b", "/skill-c"} + + def test_already_typed_skill_not_reoffered(self): + completions = _completions(_stacked_completer(), "/skill-a /skill-a") + displays = {c.display_text for c in completions} + assert "/skill-a" not in displays + + def test_replacement_spans_whole_token(self): + completions = _completions(_stacked_completer(), "/skill-a /skill-b") + # Exact match gets trailing space (keeps dropdown flowing) + assert [c.text for c in completions] == ["/skill-b "] + assert completions[0].start_position == -len("/skill-b") + + def test_no_completions_for_instruction_text(self): + assert _completions(_stacked_completer(), "/skill-a do the") == [] + assert _completions(_stacked_completer(), "/skill-a ") == [] + + def test_chain_broken_by_non_skill_token_stops_completion(self): + completions = _completions( + _stacked_completer(), "/skill-a nope /skill-" + ) + assert completions == [] + + def test_underscore_form_counts_toward_chain(self): + """Telegram underscore form is interchangeable with hyphens.""" + completions = _completions(_stacked_completer(), "/skill_a /skill-") + displays = {c.display_text for c in completions} + assert displays == {"/skill-b", "/skill-c"} + + def test_cap_stops_completions(self): + skills = {f"/stk-{i}": {"description": f"S{i}"} for i in range(8)} + completer = SlashCommandCompleter(skill_commands_provider=lambda: skills) + text = " ".join(f"/stk-{i}" for i in range(5)) + " /stk-" + assert _completions(completer, text) == [] + + def test_below_cap_still_completes(self): + skills = {f"/stk-{i}": {"description": f"S{i}"} for i in range(8)} + completer = SlashCommandCompleter(skill_commands_provider=lambda: skills) + text = " ".join(f"/stk-{i}" for i in range(4)) + " /stk-" + displays = {c.display_text for c in _completions(completer, text)} + assert displays == {"/stk-4", "/stk-5", "/stk-6", "/stk-7"} + + def test_non_skill_base_command_unaffected(self): + """/skills (builtin) still completes its subcommands, not skills.""" + completions = _completions(_stacked_completer(), "/skills ins") + texts = [c.text for c in completions] + assert "install" in texts + + # ── SUBCOMMANDS extraction ────────────────────────────────────────────── @@ -907,6 +976,27 @@ class TestGhostText: def test_no_suggestion_for_non_slash(self): assert _suggestion("hello") is None + # -- stacked slash-skill ghost text ----------------------------------- + + def test_stacked_skill_ghost_text(self): + """/skill-a /ski → ghost-suggest rest of next unused skill name.""" + assert _suggestion("/skill-a /ski", completer=_stacked_completer()) == "ll-b" + # Exact token already typed — nothing left to ghost + assert _suggestion("/skill-a /skill-b", completer=_stacked_completer()) is None + + def test_stacked_skill_ghost_text_skips_used(self): + completer = SlashCommandCompleter( + skill_commands_provider=lambda: { + "/alpha": {"description": "A"}, + "/beta": {"description": "B"}, + } + ) + assert _suggestion("/alpha /a", completer=completer) is None + assert _suggestion("/alpha /b", completer=completer) == "eta" + + def test_stacked_skill_no_ghost_for_instruction(self): + assert _suggestion("/skill-a do", completer=_stacked_completer()) is None + # --------------------------------------------------------------------------- # Telegram command name sanitization