Port from superagent-ai/grok-cli: description-aware slash-menu fuzzy scoring
This commit is contained in:
@@ -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"]
|
||||
@@ -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
@@ -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]
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}` }))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user