diff --git a/tests/tui_gateway/test_slash_fuzzy.py b/tests/tui_gateway/test_slash_fuzzy.py new file mode 100644 index 0000000000..287019d91b --- /dev/null +++ b/tests/tui_gateway/test_slash_fuzzy.py @@ -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"] diff --git a/tui_gateway/methods_complete.py b/tui_gateway/methods_complete.py index 701c11f0ea..5601a330e4 100644 --- a/tui_gateway/methods_complete.py +++ b/tui_gateway/methods_complete.py @@ -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] diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 2a5c81e91c..0b776f0354 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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] diff --git a/tui_gateway/slash_fuzzy.py b/tui_gateway/slash_fuzzy.py new file mode 100644 index 0000000000..254b811e3a --- /dev/null +++ b/tui_gateway/slash_fuzzy.py @@ -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) diff --git a/ui-tui/src/app/createSlashHandler.ts b/ui-tui/src/app/createSlashHandler.ts index f0d38257e2..3a354c3ef2 100644 --- a/ui-tui/src/app/createSlashHandler.ts +++ b/ui-tui/src/app/createSlashHandler.ts @@ -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}`) diff --git a/ui-tui/src/app/slash/fuzzyScore.test.ts b/ui-tui/src/app/slash/fuzzyScore.test.ts new file mode 100644 index 0000000000..e3acda4574 --- /dev/null +++ b/ui-tui/src/app/slash/fuzzyScore.test.ts @@ -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']) + }) +}) diff --git a/ui-tui/src/app/slash/fuzzyScore.ts b/ui-tui/src/app/slash/fuzzyScore.ts new file mode 100644 index 0000000000..3d721c3c97 --- /dev/null +++ b/ui-tui/src/app/slash/fuzzyScore.ts @@ -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(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) +} diff --git a/ui-tui/src/hooks/useCompletion.ts b/ui-tui/src/hooks/useCompletion.ts index cb9572f261..bcbcb8ab63 100644 --- a/ui-tui/src/hooks/useCompletion.ts +++ b/ui-tui/src/hooks/useCompletion.ts @@ -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}` }))