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:
Teknium 2026-07-05 04:34:14 -07:00 committed by GitHub
parent 1c156736dc
commit 2c0820c9ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 184 additions and 0 deletions

View file

@ -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

View file

@ -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