Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions tests/test_tui_gateway_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
15 changes: 14 additions & 1 deletion tui_gateway/methods_complete.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down
49 changes: 49 additions & 0 deletions tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -11395,6 +11395,55 @@ def origin(name: str) -> str:
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:
Expand Down
22 changes: 22 additions & 0 deletions ui-tui/src/__tests__/inlineSlashSkill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand Down
20 changes: 11 additions & 9 deletions ui-tui/src/hooks/useCompletion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -61,6 +59,10 @@ export function completionRequestForInput(
}
}

if (isSlashCommand) {
return { method: 'complete.slash', params: { text: input }, replaceFrom: 1 }
}

if (!pathWord) {
return null
}
Expand Down
Loading