From b28a7e76c9f88cc555a25d72e6faf8b4473dafba Mon Sep 17 00:00:00 2001 From: xupeng Date: Wed, 22 Jul 2026 15:51:05 +0800 Subject: [PATCH 1/2] feat(chat): fuzzy subsequence match for slash commands Replace substring-only filtering so inputs like /bmadhelp and /bmhp can match hyphenated names (e.g. bmad-help), with ranked exact/prefix/ substring/subsequence scoring shared across chat and automation menus. --- .../automations/composer-invocations.tsx | 19 ++-- src/components/chat/message-input.tsx | 48 ++++----- src/lib/fuzzy-text-match.test.ts | 91 +++++++++++++++++ src/lib/fuzzy-text-match.ts | 98 +++++++++++++++++++ 4 files changed, 215 insertions(+), 41 deletions(-) create mode 100644 src/lib/fuzzy-text-match.test.ts create mode 100644 src/lib/fuzzy-text-match.ts diff --git a/src/components/automations/composer-invocations.tsx b/src/components/automations/composer-invocations.tsx index de6395e2a..8ac3e1d1d 100644 --- a/src/components/automations/composer-invocations.tsx +++ b/src/components/automations/composer-invocations.tsx @@ -16,6 +16,7 @@ import { } from "@/components/chat/composer/invocation-reference" import type { ReferenceAttrs } from "@/components/chat/composer/types" import { useAgentSkills } from "@/hooks/use-agent-skills" +import { rankByTextMatch } from "@/lib/fuzzy-text-match" import { cn } from "@/lib/utils" import type { AgentSkillItem, @@ -103,23 +104,19 @@ export function useComposerInvocations({ const commands = useMemo(() => { if (!open || triggerChar !== "/" || availableCommands.length === 0) return [] - const f = filter.toLowerCase() - return availableCommands.filter((c) => c.name.toLowerCase().includes(f)) + return rankByTextMatch(filter, availableCommands, (c) => c.name) }, [open, triggerChar, availableCommands, filter]) const matchedSkills = useMemo(() => { // Skills autocomplete is Codex-only and triggered by `$`. if (!isCodex || !open || triggerChar !== "$" || skills.length === 0) return [] - const f = filter.toLowerCase() - if (!f) return skills - const nameMatches: AgentSkillItem[] = [] - const idOnlyMatches: AgentSkillItem[] = [] - for (const skill of skills) { - if (skill.name.toLowerCase().includes(f)) nameMatches.push(skill) - else if (skill.id.toLowerCase().includes(f)) idOnlyMatches.push(skill) - } - return [...nameMatches, ...idOnlyMatches] + return rankByTextMatch( + filter, + skills, + (skill) => skill.name, + (skill) => skill.id + ) }, [isCodex, open, triggerChar, skills, filter]) const count = commands.length + matchedSkills.length diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 1668f5b87..a2c800728 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -142,6 +142,7 @@ import { loadMessageInputDraftV2, saveMessageInputDraftV2, } from "@/lib/message-input-draft" +import { rankByTextMatch } from "@/lib/fuzzy-text-match" import { RichComposer, type RichComposerHandle, @@ -1007,20 +1008,16 @@ export function MessageInput({ const [slashDropdownOpen, setSlashDropdownOpen] = useState(false) const [slashDropdownSearch, setSlashDropdownSearch] = useState("") const slashDropdownInputRef = useRef(null) - const filteredSlashDropdownCommands = useMemo(() => { - const q = slashDropdownSearch.toLowerCase().trim() - if (!q) return slashCommands - const nameMatches: typeof slashCommands = [] - const descOnlyMatches: typeof slashCommands = [] - for (const cmd of slashCommands) { - if (cmd.name.toLowerCase().includes(q)) { - nameMatches.push(cmd) - } else if (cmd.description?.toLowerCase().includes(q)) { - descOnlyMatches.push(cmd) - } - } - return [...nameMatches, ...descOnlyMatches] - }, [slashCommands, slashDropdownSearch]) + const filteredSlashDropdownCommands = useMemo( + () => + rankByTextMatch( + slashDropdownSearch, + slashCommands, + (cmd) => cmd.name, + (cmd) => cmd.description + ), + [slashCommands, slashDropdownSearch] + ) const handleSlashDropdownOpenChange = useCallback((open: boolean) => { setSlashDropdownOpen(open) if (!open) setSlashDropdownSearch("") @@ -1039,28 +1036,19 @@ export function MessageInput({ const filteredSlashCommands = useMemo(() => { if (!slashMenuOpen || slashCommands.length === 0) return [] if (slashTriggerChar !== "/") return [] - const filter = slashFilter.toLowerCase() - return slashCommands.filter((cmd) => - cmd.name.toLowerCase().includes(filter) - ) + return rankByTextMatch(slashFilter, slashCommands, (cmd) => cmd.name) }, [slashMenuOpen, slashCommands, slashTriggerChar, slashFilter]) const filteredSlashSkills = useMemo(() => { // Skills autocomplete is Codex-only and triggered by `$`. if (agentType !== "codex") return [] if (!slashMenuOpen || availableSkills.length === 0) return [] if (slashTriggerChar !== "$") return [] - const filter = slashFilter.toLowerCase() - if (!filter) return availableSkills - const nameMatches: typeof availableSkills = [] - const idOnlyMatches: typeof availableSkills = [] - for (const skill of availableSkills) { - if (skill.name.toLowerCase().includes(filter)) { - nameMatches.push(skill) - } else if (skill.id.toLowerCase().includes(filter)) { - idOnlyMatches.push(skill) - } - } - return [...nameMatches, ...idOnlyMatches] + return rankByTextMatch( + slashFilter, + availableSkills, + (skill) => skill.name, + (skill) => skill.id + ) }, [slashMenuOpen, availableSkills, agentType, slashTriggerChar, slashFilter]) const slashAutocompleteCount = filteredSlashCommands.length + filteredSlashSkills.length diff --git a/src/lib/fuzzy-text-match.test.ts b/src/lib/fuzzy-text-match.test.ts new file mode 100644 index 000000000..a0eb6787d --- /dev/null +++ b/src/lib/fuzzy-text-match.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest" +import { + rankByTextMatch, + scoreTextMatch, + subsequenceFirstIndex, +} from "@/lib/fuzzy-text-match" + +describe("subsequenceFirstIndex", () => { + it("matches contiguous and gapped subsequences", () => { + expect(subsequenceFirstIndex("bmadhelp", "bmad-help")).toBe(0) + expect(subsequenceFirstIndex("bmhp", "bmad-help")).toBe(0) + expect(subsequenceFirstIndex("help", "bmad-help")).toBe(5) + }) + + it("returns -1 when a character is missing", () => { + expect(subsequenceFirstIndex("bmadx", "bmad-help")).toBe(-1) + }) +}) + +describe("scoreTextMatch tiers", () => { + it("orders exact > prefix > substring > subsequence", () => { + const q = "help" + const exact = scoreTextMatch(q, "help")! + const prefix = scoreTextMatch(q, "help-me")! + const substring = scoreTextMatch(q, "bmad-help")! + const subseq = scoreTextMatch("bmhp", "bmad-help")! + + expect(exact).toBeGreaterThan(prefix) + expect(prefix).toBeGreaterThan(substring) + expect(scoreTextMatch("bmadh", "bmad-help")!).toBeLessThan( + scoreTextMatch("bmad-", "bmad-help")! + ) + // subsequence still scores positive + expect(subseq).toBeGreaterThan(0) + }) + + it("returns null when nothing matches", () => { + expect(scoreTextMatch("zzz", "bmad-help")).toBeNull() + }) +}) + +describe("rankByTextMatch", () => { + const cmds = [ + { name: "bmad-help", description: "Show BMAD help" }, + { name: "bmad-create-story", description: "Create a story" }, + { name: "clear", description: "Clear the chat" }, + ] + + it("returns all items for an empty query", () => { + expect(rankByTextMatch("", cmds, (c) => c.name)).toEqual(cmds) + }) + + it("matches hyphenated names via subsequence (bmadhelp / bmhp)", () => { + const ranked = rankByTextMatch("bmadhelp", cmds, (c) => c.name) + expect(ranked.map((c) => c.name)).toEqual(["bmad-help"]) + + const short = rankByTextMatch("bmhp", cmds, (c) => c.name) + expect(short.map((c) => c.name)).toContain("bmad-help") + }) + + it("ranks exact/prefix above subsequence", () => { + const items = [ + { name: "bh" }, + { name: "bmad-help" }, + { name: "batch" }, + ] + const ranked = rankByTextMatch("bh", items, (i) => i.name) + expect(ranked[0].name).toBe("bh") + }) + + it("falls back to secondary field below primary hits", () => { + const items = [ + { name: "clear", description: "bmad helpers" }, + { name: "bmad-help", description: "other" }, + ] + const ranked = rankByTextMatch( + "bmad", + items, + (i) => i.name, + (i) => i.description + ) + // name prefix/substring on bmad-help beats description-only on clear + expect(ranked[0].name).toBe("bmad-help") + expect(ranked.map((i) => i.name)).toContain("clear") + }) + + it("is case-insensitive", () => { + const ranked = rankByTextMatch("BMADHELP", cmds, (c) => c.name) + expect(ranked.map((c) => c.name)).toEqual(["bmad-help"]) + }) +}) diff --git a/src/lib/fuzzy-text-match.ts b/src/lib/fuzzy-text-match.ts new file mode 100644 index 000000000..0f535e509 --- /dev/null +++ b/src/lib/fuzzy-text-match.ts @@ -0,0 +1,98 @@ +/** + * Ranked text matcher for slash-command / skill autocomplete. + * No external fuzzy dependency — tiers are exact → prefix → substring → + * subsequence so short inputs like `bmhp` can still hit `bmad-help`. + * + * Same scoring shape as `file-search-match.ts` (tier dominates; earlier + * match position and shorter candidate win within a tier). + */ + +const TIER_BASE = 100_000_000 + +const TIER_EXACT = 6 +const TIER_PREFIX = 5 +const TIER_SUBSTRING = 4 +const TIER_SUBSEQUENCE = 3 + +function tierScore(tier: number, position: number, length: number): number { + return ( + tier * TIER_BASE - Math.min(position, 9_999) * 1_000 - Math.min(length, 999) + ) +} + +/** + * Index of the first matched character if every char of `query` appears in `s` + * in order (a subsequence), else -1. `query` must be non-empty. + */ +export function subsequenceFirstIndex(query: string, s: string): number { + let qi = 0 + let firstIdx = -1 + for (let si = 0; si < s.length && qi < query.length; si++) { + if (s[si] === query[qi]) { + if (qi === 0) firstIdx = si + qi++ + } + } + return qi === query.length ? firstIdx : -1 +} + +/** + * Score one already-lowercased string against an already-lowercased, + * non-empty `query`. Higher is better; `null` means no match. + */ +export function scoreTextMatch(query: string, text: string): number | null { + if (!query) return null + + if (text === query) { + return tierScore(TIER_EXACT, 0, text.length) + } + if (text.startsWith(query)) { + return tierScore(TIER_PREFIX, 0, text.length) + } + const idx = text.indexOf(query) + if (idx !== -1) { + return tierScore(TIER_SUBSTRING, idx, text.length) + } + const sub = subsequenceFirstIndex(query, text) + if (sub !== -1) { + return tierScore(TIER_SUBSEQUENCE, sub, text.length) + } + return null +} + +/** + * Filter + rank `items` by primary text, with optional secondary field fallback + * (e.g. description / skill id). Empty query returns items unchanged (original + * order). Secondary hits always rank below any primary hit. + */ +export function rankByTextMatch( + query: string, + items: readonly T[], + getPrimary: (item: T) => string, + getSecondary?: (item: T) => string | undefined | null +): T[] { + const q = query.trim().toLowerCase() + if (!q) return items.slice() + + // Drop secondary below every primary tier so a weak name subsequence still + // beats a perfect description / id match (mirrors previous name-first lists). + const secondaryOffset = TIER_EXACT * TIER_BASE + + const scored: { item: T; score: number }[] = [] + for (const item of items) { + const primary = getPrimary(item).toLowerCase() + let score = scoreTextMatch(q, primary) + if (score === null && getSecondary) { + const secondary = getSecondary(item)?.toLowerCase() + if (secondary) { + const sec = scoreTextMatch(q, secondary) + if (sec !== null) score = sec - secondaryOffset + } + } + if (score !== null) scored.push({ item, score }) + } + + // ES2019 stable sort keeps original order for equal scores. + scored.sort((a, b) => b.score - a.score) + return scored.map((s) => s.item) +} From 3df850aa4073190f96d6662b9bdaea775454d017 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Wed, 22 Jul 2026 21:16:07 +0800 Subject: [PATCH 2/2] test(chat): fix prettier lint and lock fuzzy-match ranking invariants The exact/prefix/subsequence ordering test collapsed onto one line under prettier, failing CI; rewrite it to assert the full tier order (adding the missing prefix case) and add coverage for the primary-over-secondary guarantee, stable-sort tie ordering, and empty-primary handling. --- src/lib/fuzzy-text-match.test.ts | 47 ++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/src/lib/fuzzy-text-match.test.ts b/src/lib/fuzzy-text-match.test.ts index a0eb6787d..98ac39908 100644 --- a/src/lib/fuzzy-text-match.test.ts +++ b/src/lib/fuzzy-text-match.test.ts @@ -58,14 +58,51 @@ describe("rankByTextMatch", () => { expect(short.map((c) => c.name)).toContain("bmad-help") }) - it("ranks exact/prefix above subsequence", () => { + it("ranks exact > prefix > subsequence (shorter wins ties)", () => { const items = [ - { name: "bh" }, - { name: "bmad-help" }, - { name: "batch" }, + { name: "batch" }, // subsequence b..h + { name: "bh-tool" }, // prefix + { name: "bh" }, // exact + { name: "bmad-help" }, // subsequence, longer than batch ] const ranked = rankByTextMatch("bh", items, (i) => i.name) - expect(ranked[0].name).toBe("bh") + expect(ranked.map((i) => i.name)).toEqual([ + "bh", + "bh-tool", + "batch", + "bmad-help", + ]) + }) + + it("keeps any primary match above any secondary match", () => { + const items = [ + { name: "zzz", tag: "bh" }, // secondary exact + { name: "batch", tag: "zzz" }, // primary subsequence (b..h) + ] + // Weakest primary (subsequence) still outranks strongest secondary (exact). + const ranked = rankByTextMatch( + "bh", + items, + (i) => i.name, + (i) => i.tag + ) + expect(ranked.map((i) => i.name)).toEqual(["batch", "zzz"]) + }) + + it("preserves input order for equally-scored matches (stable sort)", () => { + const items = [ + { id: 1, name: "abc" }, + { id: 2, name: "abc" }, + { id: 3, name: "abc" }, + ] + const ranked = rankByTextMatch("abc", items, (i) => i.name) + expect(ranked.map((i) => i.id)).toEqual([1, 2, 3]) + }) + + it("skips items with empty primary text without matching", () => { + const items = [{ name: "" }, { name: "bmad-help" }] + const ranked = rankByTextMatch("bmad", items, (i) => i.name) + expect(ranked.map((i) => i.name)).toEqual(["bmad-help"]) }) it("falls back to secondary field below primary hits", () => {