Merge pull request #75931 from NousResearch/bb/tui-slash-priority

The TUI slash menu leads with the skills you actually use
This commit is contained in:
brooklyn! 2026-08-01 02:01:44 -05:00 committed by GitHub
commit 18627ff009
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 184 additions and 10 deletions

View File

@ -6126,6 +6126,94 @@ def test_complete_slash_reasoning_includes_current_efforts_and_global_scope():
assert {"max", "ultra", "--global"} <= values
_SLASH_FILLER_COUNT = 60
def _slash_skill_fixtures(monkeypatch):
"""Stub a skill install big enough that a flat cap would truncate it."""
filler = {f"/filler-{i:03d}": 0 for i in range(_SLASH_FILLER_COUNT)}
usage = {"work": 297, "research": 84, "clean": 12}
monkeypatch.setattr(
server,
"_skill_usage_lookup",
lambda: (
lambda name: usage.get(name, 0),
lambda name: "bundled" if name.startswith("unused-") else "local",
),
)
monkeypatch.setattr(
"agent.skill_commands.get_skill_commands",
lambda: {
"/work": {"description": "Fresh worktree"},
"/research": {"description": "Look it up"},
"/clean": {"description": "Polish the diff"},
"/unused-bundled": {"description": "Shipped, never opened"},
**{cmd: {"description": "Filler"} for cmd in filler},
},
)
monkeypatch.setattr("agent.skill_bundles.get_skill_bundles", lambda: {})
def _slash_completions(text: str) -> list[dict]:
resp = server.handle_request(
{"id": "1", "method": "complete.slash", "params": {"text": text}}
)
return resp["result"]["items"]
def test_complete_slash_offers_skills_alongside_commands(monkeypatch):
"""A bare `/` must reach the skills, not just the registry.
The completer emits every registry command before the first skill, so one
flat cap spent every row on commands and no skill was reachable at all.
"""
_slash_skill_fixtures(monkeypatch)
kinds = {item["kind"] for item in _slash_completions("/")}
assert kinds == {"command", "skill"}
def test_complete_slash_ranks_skills_by_recorded_usage(monkeypatch):
"""The skills someone actually invokes lead the ones they never opened."""
_slash_skill_fixtures(monkeypatch)
skills = [
item["text"].strip() for item in _slash_completions("/") if item["kind"] == "skill"
]
assert skills[:3] == ["work", "research", "clean"]
def test_complete_slash_prunes_unused_builtins_only_while_browsing(monkeypatch):
"""A bare `/` is browsing and may prune; a typed query is a search.
A search that hides a match is broken, so the never-opened bundled skill
disappears from `/` and comes straight back the moment it is typed for.
"""
_slash_skill_fixtures(monkeypatch)
browsing = {item["text"].strip() for item in _slash_completions("/")}
searching = {item["text"].strip() for item in _slash_completions("/unused")}
assert "unused-bundled" not in browsing
assert "unused-bundled" in searching
def test_complete_slash_leaves_argument_stages_alone(monkeypatch):
"""Ranking applies to the command token, never to a command's own args.
`/details c` completes that command's modes; a skill named /clean also
starts with a `c` and must not be offered as one of them.
"""
_slash_skill_fixtures(monkeypatch)
items = _slash_completions("/details c")
assert [item["text"] for item in items] == ["collapsed", "cycle"]
def test_config_set_reasoning_updates_live_session_and_agent(tmp_path, monkeypatch):
monkeypatch.setattr(server, "_hermes_home", tmp_path)
(tmp_path / "config.yaml").write_text("agent:\n reasoning_effort: medium\n", encoding="utf-8")

View File

@ -259,7 +259,20 @@ def _(rid, params: dict) -> dict:
),
}
for c in completer.get_completions(doc, None)
][:30]
]
# Rank and bound the list (see _rank_slash_completions) while a
# `/token` is under the cursor — the one stage skills are offered at.
# An argument stage (`/personality `, `/details c`) keeps the order
# its own command chose.
if text.rsplit(" ", 1)[-1].startswith("/"):
usage, origin_of = _skill_usage_lookup()
items = _rank_slash_completions(
items, usage, origin_of, browsing=text == "/"
)
else:
items = items[:_SLASH_COMPLETION_LIMIT]
text_lower = text.lower()
extras = [
{

View File

@ -11402,6 +11402,55 @@ def _skill_usage_lookup():
return usage, origin
_SLASH_COMPLETION_LIMIT = 30
def _rank_slash_completions(
items: list[dict],
usage,
origin_of,
*,
browsing: bool,
) -> list[dict]:
"""Rank and bound slash completions the way the menu should read.
``usage``/``origin_of`` are the callables :func:`_skill_usage_lookup`
returns. Registry commands keep their existing order only the skill
block is reordered, most-used first and A-Z within a tie, so the handful
of skills someone invokes daily lead the ones that shipped with Hermes
and were never opened.
The limit is spent PER KIND rather than on one flat truncation. A flat
cut is positional, not editorial: the completer emits every registry
command before the first skill, so on a 230-skill install a bare ``/``
hit the cap while still inside the command block and offered no skill at
all, and ``/p`` dropped ``/proving-a-fix-works`` (471 uses) while keeping
``/pretext`` (2).
``browsing`` separates the two things a slash means. A bare ``/`` is
BROWSING, so bundled skills with no recorded activity are dropped as
noise. A typed query is SEARCHING, and a search that hides a match is
broken there nothing is pruned, the ranking only reorders.
"""
def name_of(item: dict) -> str:
return str(item.get("text", "")).strip().lstrip("/").lower()
commands = [item for item in items if item.get("kind") != "skill"]
skills = [item for item in items if item.get("kind") == "skill"]
if browsing:
skills = [
item
for item in skills
if origin_of(name_of(item)) != "bundled" or usage(name_of(item)) > 0
]
skills.sort(key=lambda item: (-usage(name_of(item)), name_of(item)))
return commands[:_SLASH_COMPLETION_LIMIT] + skills[:_SLASH_COMPLETION_LIMIT]
def _cli_exec_blocked(argv: list[str]) -> str | None:
"""Return user hint if this argv must not run headless in the gateway process."""
if not argv:

View File

@ -64,6 +64,28 @@ describe('completionRequestForInput — inline skill references', () => {
})
})
it('completes a second slash in a line that starts with a command', () => {
// Only the first slash is an invocation. Routing the whole line to the
// completer offered nothing, so `/work /cle` went dead while
// `do /work then /cle` completed fine.
expect(completionRequestForInput('/work /cle')).toMatchObject({
method: 'complete.slash',
params: { text: '/cle' },
replaceFrom: 7,
skillsOnly: true
})
})
it('leaves a command own arguments to the command', () => {
for (const input of ['/personality alic', '/cron ad', '/details ']) {
expect(completionRequestForInput(input)).toEqual({
method: 'complete.slash',
params: { text: input },
replaceFrom: 1
})
}
})
it('routes a real mid-message path to path completion, not skills', () => {
expect(completionRequestForInput('open src/foo/ba')).toMatchObject({ method: 'complete.path' })
expect(completionRequestForInput('open /usr/lo')).toMatchObject({ method: 'complete.path' })

View File

@ -41,15 +41,13 @@ export function completionRequestForInput(
return null
}
if (isSlashCommand) {
return { method: 'complete.slash', params: { text: input }, replaceFrom: 1 }
}
// A `/token` mid-message is a skill reference dropped into prose. It's only
// reachable here because the path branch below would otherwise claim it: a
// bare `/cle` matches TAB_PATH_RE as an absolute path. Skills win that tie —
// the moment a second `/` is typed the inline trigger stops matching and
// path completion takes back over.
// A `/token` mid-message is a skill reference dropped into prose. Detected
// BEFORE the leading-command shape because only the first slash can be an
// invocation — `/help /cle` is a command whose argument names a skill, and
// routing the whole line to the backend's completer offered nothing at all.
// It only matches a whitespace-preceded slash sitting at the caret, so
// ordinary argument completion (`/cron ad`, `/personality alic`) is
// untouched.
const inline = inlineSlashTrigger(input)
if (inline) {
@ -61,6 +59,10 @@ export function completionRequestForInput(
}
}
if (isSlashCommand) {
return { method: 'complete.slash', params: { text: input }, replaceFrom: 1 }
}
if (!pathWord) {
return null
}