mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
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.
This commit is contained in:
parent
1c156736dc
commit
2c0820c9ff
2 changed files with 184 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue