Port from superagent-ai/grok-cli: description-aware slash-menu fuzzy scoring

This commit is contained in:
Teknium
2026-08-06 21:22:44 -07:00
parent 530d37820c
commit 1405d330e7
8 changed files with 440 additions and 12 deletions
+120
View File
@@ -0,0 +1,120 @@
"""Tests for the description-aware slash fuzzy scorer (grok-cli port).
Covers ``tui_gateway.slash_fuzzy`` (scoring tiers, catalog merge, stable
ordering) and how ``_rank_slash_completions`` consumes the ``score_of``
lookup: skill rows sort by fuzzy score first, then usage, then name.
"""
import math
from tui_gateway.server import _rank_slash_completions
from tui_gateway.slash_fuzzy import (
fuzzy_rank_slash_items,
normalize_slash_search_query,
score_slash_completion_item,
tokenize_search_text,
)
def _item(text, meta="", kind="command"):
return {"text": text, "display": text, "meta": meta, "kind": kind}
def test_normalize_slash_search_query():
assert normalize_slash_search_query(" /Model ") == "model"
assert normalize_slash_search_query("//help") == "help"
assert normalize_slash_search_query("plain") == "plain"
def test_tokenize_search_text_includes_full_value_and_words():
assert tokenize_search_text("Commit & Push") == ["commit & push", "commit", "push"]
def test_score_tiers_name_before_description():
item = _item("/recaps ", "Turn session recaps on/off")
assert score_slash_completion_item(item, "recaps") == 0
assert score_slash_completion_item(item, "rec") == 1
assert score_slash_completion_item(item, "caps") == 2
assert score_slash_completion_item(item, "session") == 3
assert score_slash_completion_item(item, "sess") == 4
assert score_slash_completion_item(item, "essio") == 5
assert math.isinf(score_slash_completion_item(item, "zzz"))
def test_name_match_beats_description_match():
# "recap" hits the description too, but the name tier must win.
item = _item("/recap", "Turn session recaps on/off")
assert score_slash_completion_item(item, "recap") == 0
def test_fuzzy_rank_merges_description_matches_from_catalog():
prefix_hits = [_item("/summon")]
catalog = [
_item("/summon"),
_item("/recaps", "Show a summary of the session"),
_item("/help", "Show available commands"),
]
ranked, score_of = fuzzy_rank_slash_items(prefix_hits, catalog, "summ")
texts = [item["text"] for item in ranked]
assert texts == ["/summon", "/recaps"] # name prefix (1) before description (4)
assert score_of(ranked[0]) == 1
assert score_of(ranked[1]) == 4
assert math.isinf(score_of(_item("/help", "Show available commands")))
def test_fuzzy_rank_is_stable_within_a_tier():
items = [_item("/mod-b"), _item("/mod-a")]
ranked, _ = fuzzy_rank_slash_items(items, [], "mod")
assert [item["text"] for item in ranked] == ["/mod-b", "/mod-a"]
def test_fuzzy_rank_drops_non_matching_prefix_rows():
ranked, _ = fuzzy_rank_slash_items([_item("/other")], [], "model")
assert ranked == []
def test_rank_slash_completions_uses_score_before_usage():
# Without a scorer, usage sorts skills; with one, score leads and usage
# only breaks ties within a tier.
name_hit = _item("/summarize", "Condense text", kind="skill")
desc_hit = _item("/notes", "Write a summary of a meeting", kind="skill")
items = [desc_hit, name_hit]
usage = {"notes": 50, "summarize": 1}.get
def usage_of(name):
return usage(name, 0)
def origin_of(_name):
return "user"
scores = {id(name_hit): 1.0, id(desc_hit): 4.0}
ranked = _rank_slash_completions(
items,
usage_of,
origin_of,
browsing=False,
score_of=lambda item: scores.get(id(item), math.inf),
)
assert [item["text"] for item in ranked] == ["/summarize", "/notes"]
# Sanity: without score_of the heavier-used skill leads.
ranked_plain = _rank_slash_completions(items, usage_of, origin_of, browsing=False)
assert [item["text"] for item in ranked_plain] == ["/notes", "/summarize"]
def test_rank_slash_completions_ties_break_on_usage_then_name():
a = _item("/beta", kind="skill")
b = _item("/alpha", kind="skill")
items = [a, b]
ranked = _rank_slash_completions(
items,
lambda name: {"alpha": 3, "beta": 3}.get(name, 0),
lambda _name: "user",
browsing=False,
score_of=lambda item: 1.0,
)
assert [item["text"] for item in ranked] == ["/alpha", "/beta"]
+31 -1
View File
@@ -266,9 +266,39 @@ def _(rid, params: dict) -> dict:
# An argument stage (`/personality `, `/details c`) keeps the order
# its own command chose.
if text.rsplit(" ", 1)[-1].startswith("/"):
score_of = None
# Description-aware fuzzy scoring (ported from grok-cli's slash
# menu) at the command-token stage: the completer above only
# emits name-prefix matches, so merge in catalog entries whose
# name SUBSTRING or DESCRIPTION words match the query — typing
# `/summary` surfaces a command whose description mentions
# summaries. Command matches always outrank description matches.
if " " not in text and len(text) > 1:
from tui_gateway.slash_fuzzy import (
fuzzy_rank_slash_items,
normalize_slash_search_query,
)
universe = [
{
"text": c.text,
"display": to_plain_text(c.display) if c.display else c.text,
"meta": to_plain_text(c.display_meta) if c.display_meta else "",
"kind": (
"skill"
if c.text.strip().lstrip("/").lower() in skill_names
else "command"
),
}
for c in completer.get_completions(Document("/", 1), None)
]
items, score_of = fuzzy_rank_slash_items(
items, universe, normalize_slash_search_query(text)
)
usage, origin_of = _skill_usage_lookup()
items = _rank_slash_completions(
items, usage, origin_of, browsing=text == "/"
items, usage, origin_of, browsing=text == "/", score_of=score_of
)
else:
items = items[:_SLASH_COMPLETION_LIMIT]
+13 -1
View File
@@ -11975,6 +11975,7 @@ def _rank_slash_completions(
origin_of,
*,
browsing: bool,
score_of=None,
) -> list[dict]:
"""Rank and bound slash completions the way the menu should read.
@@ -11984,6 +11985,12 @@ def _rank_slash_completions(
of skills someone invokes daily lead the ones that shipped with Hermes
and were never opened.
``score_of`` (optional) is the fuzzy-match scorer from
:func:`tui_gateway.slash_fuzzy.fuzzy_rank_slash_items` — when a typed
query produced scores, they lead the skill sort so a name match beats a
description match before usage breaks ties. Commands arrive already
score-sorted and keep their order either way.
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 ``/``
@@ -12010,7 +12017,12 @@ def _rank_slash_completions(
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)))
if score_of is not None:
skills.sort(
key=lambda item: (score_of(item), -usage(name_of(item)), name_of(item))
)
else:
skills.sort(key=lambda item: (-usage(name_of(item)), name_of(item)))
return commands[:_SLASH_COMPLETION_LIMIT] + skills[:_SLASH_COMPLETION_LIMIT]
+91
View File
@@ -0,0 +1,91 @@
"""Description-aware fuzzy scoring for slash-menu completions.
Ported from superagent-ai/grok-cli ``src/ui/slash-menu.ts`` (mirrored on the
TUI client in ``ui-tui/src/app/slash/fuzzyScore.ts``): candidates are scored
in tiers — exact match on the command token (0), prefix (1), substring (2) —
and the DESCRIPTION text is tokenized and matched at a +3 offset (exact word
3, word prefix 4, word substring 5). Typing ``/summary`` thus surfaces a
command whose description mentions summaries even though no command name
starts with it. Lower score wins; ``math.inf`` means no match.
"""
from __future__ import annotations
import math
import re
from typing import Callable
_TOKEN_SPLIT = re.compile(r"[^a-z0-9]+")
def tokenize_search_text(value: str) -> list[str]:
"""Lowercase ``value`` and return it alongside its alphanumeric words."""
normalized = value.lower()
return [normalized, *[t for t in _TOKEN_SPLIT.split(normalized) if t]]
def normalize_slash_search_query(query: str) -> str:
"""Trim, drop leading slashes, lowercase — ``/Model`` and ``model`` alike."""
return query.strip().lstrip("/").lower()
def _score_fields(fields: list[str], query: str, offset: int) -> float:
for field in fields:
if field == query or f"/{field}" == query:
return offset
for field in fields:
if field.startswith(query) or f"/{field}".startswith(query):
return offset + 1
for field in fields:
if query in field:
return offset + 2
return math.inf
def score_slash_completion_item(item: dict, query: str) -> float:
"""Score one completion item dict (``text`` + ``meta``) against ``query``.
``text`` is the replacement token (may carry a leading slash or trailing
space); ``meta`` is the human description. Lower is better; ``math.inf``
means no match at all.
"""
name = str(item.get("text", "")).strip().lstrip("/")
command_fields = tokenize_search_text(name)
description_fields = tokenize_search_text(str(item.get("meta", "")))
return min(
_score_fields(command_fields, query, 0),
_score_fields(description_fields, query, 3),
)
def fuzzy_rank_slash_items(
items: list[dict], catalog: list[dict], query: str
) -> tuple[list[dict], Callable[[dict], float]]:
"""Merge description/substring matches into ``items`` and sort by score.
``items`` are the completer's own (prefix-filtered) rows and keep their
identity; ``catalog`` is the full command/skill universe, from which any
entry the prefix filter missed but the fuzzy scorer matches is appended.
Returns the score-sorted rows (stable within a tier) plus a ``score_of``
lookup for downstream rankers to use as a leading sort key.
"""
seen = {str(item.get("text", "")).strip() for item in items}
merged = list(items)
for item in catalog:
if str(item.get("text", "")).strip() in seen:
continue
if not math.isinf(score_slash_completion_item(item, query)):
merged.append(item)
scores: dict[int, float] = {}
scored: list[tuple[float, int, dict]] = []
for index, item in enumerate(merged):
score = score_slash_completion_item(item, query)
if math.isinf(score):
continue
scores[id(item)] = score
scored.append((score, index, item))
scored.sort(key=lambda entry: (entry[0], entry[1]))
ranked = [item for _, _, item in scored]
return ranked, lambda item: scores.get(id(item), math.inf)
+14 -7
View File
@@ -5,6 +5,7 @@ import { launchWidget } from '../sdk/host.js'
import { getWidgetApp } from '../sdk/registry.js'
import type { SlashHandlerContext } from './interfaces.js'
import { scoreSlashMenuItem } from './slash/fuzzyScore.js'
import { findSlashCommand } from './slash/registry.js'
import type { SlashRunCtx } from './slash/types.js'
import { getUiState } from './uiStore.js'
@@ -69,13 +70,19 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
return handler(`${exact}${argTail}`)
}
} else {
const matches = [
...new Set(
Object.entries(catalog.canon)
.filter(([alias]) => alias.startsWith(needle))
.map(([, canon]) => canon)
)
]
// Tiered name scoring (ported from grok-cli's slash menu): prefix
// matches rank above substring matches, so `/hea` still resolves to
// /heartbeat while `/beat` now finds it too instead of dead-ending.
// Only the best tier survives — a substring hit never widens an
// unambiguous prefix hit into an "ambiguous command" complaint.
// Description tiers (score >= 3) are a completion-menu concern and
// never auto-execute a command here.
const scored = Object.entries(catalog.canon)
.map(([alias, canon]) => ({ canon, score: scoreSlashMenuItem({ id: alias.slice(1) }, needle.slice(1)) }))
.filter(entry => entry.score < 3)
const best = Math.min(...scored.map(entry => entry.score))
const matches = [...new Set(scored.filter(entry => entry.score === best).map(entry => entry.canon))]
if (matches.length === 1 && matches[0]!.toLowerCase() !== needle) {
return handler(`${matches[0]}${argTail}`)
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest'
import { normalizeSlashSearchQuery, rankSlashItems, scoreSlashMenuItem, tokenizeSearchText } from './fuzzyScore.js'
describe('normalizeSlashSearchQuery', () => {
it('trims, strips leading slashes, and lowercases', () => {
expect(normalizeSlashSearchQuery(' /Model ')).toBe('model')
expect(normalizeSlashSearchQuery('//help')).toBe('help')
expect(normalizeSlashSearchQuery('plain')).toBe('plain')
})
})
describe('tokenizeSearchText', () => {
it('returns the full lowercased value plus alphanumeric word tokens', () => {
expect(tokenizeSearchText('Commit & Push')).toEqual(['commit & push', 'commit', 'push'])
})
})
describe('scoreSlashMenuItem', () => {
const item = { aliases: ['recap', 'summary'], description: 'Turn session recaps on/off', id: 'recaps', label: 'recaps' }
it('scores exact name matches at tier 0', () => {
expect(scoreSlashMenuItem(item, 'recaps')).toBe(0)
})
it('scores exact alias matches at tier 0', () => {
expect(scoreSlashMenuItem(item, 'summary')).toBe(0)
})
it('scores name prefixes at tier 1 and name substrings at tier 2', () => {
expect(scoreSlashMenuItem(item, 'rec')).toBe(1)
expect(scoreSlashMenuItem(item, 'caps')).toBe(2)
})
it('scores description matches at the +3 offset, below any name tier', () => {
expect(scoreSlashMenuItem({ description: 'Turn session recaps on/off', id: 'other' }, 'session')).toBe(3)
expect(scoreSlashMenuItem({ description: 'Turn session recaps on/off', id: 'other' }, 'sess')).toBe(4)
expect(scoreSlashMenuItem({ description: 'Turn session recaps on/off', id: 'other' }, 'essio')).toBe(5)
})
it('prefers the name tier when both name and description match', () => {
expect(scoreSlashMenuItem(item, 'recap')).toBe(0)
})
it('returns Infinity when nothing matches', () => {
expect(scoreSlashMenuItem(item, 'zzz')).toBe(Number.POSITIVE_INFINITY)
})
})
describe('rankSlashItems', () => {
const apps = [
{ help: 'Show available commands', id: 'help' },
{ help: 'Start a countdown timer', id: 'clock' },
{ help: 'Select a model', id: 'models' }
]
const toScoreItem = (app: (typeof apps)[number]) => ({ description: app.help, id: app.id })
it('returns the list untouched for an empty query', () => {
expect(rankSlashItems(apps, '/', toScoreItem)).toEqual(apps)
})
it('surfaces description matches the prefix filter would miss', () => {
expect(rankSlashItems(apps, '/timer', toScoreItem).map(app => app.id)).toEqual(['clock'])
})
it('ranks name matches above description matches and drops non-matches', () => {
const ranked = rankSlashItems(
[{ help: 'model picker widget', id: 'gallery' }, ...apps],
'/model',
toScoreItem
)
expect(ranked.map(app => app.id)).toEqual(['models', 'gallery'])
})
it('keeps original order within a score tier', () => {
const ranked = rankSlashItems(
[
{ help: '', id: 'mod-b' },
{ help: '', id: 'mod-a' }
],
'/mod',
toScoreItem
)
expect(ranked.map(app => app.id)).toEqual(['mod-b', 'mod-a'])
})
})
+77
View File
@@ -0,0 +1,77 @@
/** Description-aware fuzzy scoring for the slash-command menu.
*
* Ported from superagent-ai/grok-cli `src/ui/slash-menu.ts`: candidates are
* scored in tiers — exact match on id/label/alias (0), prefix (1), substring
* (2) — and the DESCRIPTION text is tokenized and matched at a +3 offset
* (exact word 3, word prefix 4, word substring 5). Typing `/summary` thus
* surfaces a command whose description mentions summaries even though no
* command name starts with it. Lower score wins; `Infinity` means no match.
*/
export interface SlashScoreItem {
aliases?: string[]
description?: string
id: string
label?: string
}
/** Lowercase the value and return it alongside its alphanumeric word tokens. */
export function tokenizeSearchText(value: string): string[] {
const normalized = value.toLowerCase()
return [normalized, ...normalized.split(/[^a-z0-9]+/).filter(Boolean)]
}
/** Trim, drop leading slashes, lowercase — `/Model ` and `model` score alike. */
export function normalizeSlashSearchQuery(query: string): string {
return query.trim().replace(/^\/+/, '').toLowerCase()
}
function scoreFields(fields: string[], query: string, offset: number): number {
for (const field of fields) {
if (field === query || `/${field}` === query) {
return offset
}
}
for (const field of fields) {
if (field.startsWith(query) || `/${field}`.startsWith(query)) {
return offset + 1
}
}
for (const field of fields) {
if (field.includes(query)) {
return offset + 2
}
}
return Number.POSITIVE_INFINITY
}
/** Score one item against a normalized query. Lower is better; Infinity = no match. */
export function scoreSlashMenuItem(item: SlashScoreItem, query: string): number {
const commandFields = [item.id, item.label ?? '', ...(item.aliases ?? [])]
.filter(Boolean)
.flatMap(tokenizeSearchText)
const descriptionFields = tokenizeSearchText(item.description ?? '')
return Math.min(scoreFields(commandFields, query, 0), scoreFields(descriptionFields, query, 3))
}
/** Filter and stable-sort `items` by score (then original order). An empty
* query returns the list untouched so browsing keeps the caller's order. */
export function rankSlashItems<T>(items: T[], query: string, toScoreItem: (item: T) => SlashScoreItem): T[] {
const normalized = normalizeSlashSearchQuery(query)
if (!normalized) {
return items
}
return items
.map((item, index) => ({ index, item, score: scoreSlashMenuItem(toScoreItem(item), normalized) }))
.filter(entry => entry.score !== Number.POSITIVE_INFINITY)
.sort((a, b) => a.score - b.score || a.index - b.index)
.map(entry => entry.item)
}
+5 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import type { CompletionItem } from '../app/interfaces.js'
import { rankSlashItems } from '../app/slash/fuzzyScore.js'
import { inlineSlashTrigger, looksLikeSlashCommand } from '../domain/slash.js'
import type { GatewayClient } from '../gatewayClient.js'
import type { CompletionResponse } from '../gatewayTypes.js'
@@ -9,15 +10,16 @@ import { listWidgetApps } from '../sdk/registry.js'
/** Client-side widget apps live in the TUI's registry, not the gateway — so
* `/` completions merge their title/metadata here. Registry-driven: a new
* app surfaces automatically, no hardcoded lists on either side. */
* app surfaces automatically, no hardcoded lists on either side. Matching is
* description-aware (ported from grok-cli's slash menu): `/timer` surfaces a
* widget whose help text mentions timers, not just id-prefix hits. */
export function mergeWidgetAppItems(input: string, items: CompletionItem[]): CompletionItem[] {
// Only complete the command NAME position (no args typed yet).
if (input.includes(' ')) {
return items
}
const local = listWidgetApps()
.filter(app => `/${app.id}`.startsWith(input.toLowerCase()))
const local = rankSlashItems(listWidgetApps(), input, app => ({ description: app.help, id: app.id }))
.filter(app => !items.some(item => item.text === `/${app.id}`))
.map(app => ({ display: `/${app.id}`, meta: app.help, text: `/${app.id}` }))