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
19 changes: 8 additions & 11 deletions src/components/automations/composer-invocations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
48 changes: 18 additions & 30 deletions src/components/chat/message-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ import {
loadMessageInputDraftV2,
saveMessageInputDraftV2,
} from "@/lib/message-input-draft"
import { rankByTextMatch } from "@/lib/fuzzy-text-match"
import {
RichComposer,
type RichComposerHandle,
Expand Down Expand Up @@ -1007,20 +1008,16 @@ export function MessageInput({
const [slashDropdownOpen, setSlashDropdownOpen] = useState(false)
const [slashDropdownSearch, setSlashDropdownSearch] = useState("")
const slashDropdownInputRef = useRef<HTMLInputElement>(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("")
Expand All @@ -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
Expand Down
128 changes: 128 additions & 0 deletions src/lib/fuzzy-text-match.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
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 > subsequence (shorter wins ties)", () => {
const items = [
{ 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.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", () => {
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"])
})
})
98 changes: 98 additions & 0 deletions src/lib/fuzzy-text-match.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
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)
}
Loading