feat(web): add fuzzy slash command discovery

This commit is contained in:
Turtle
2026-08-04 21:02:03 +08:00
parent 55e0dc3565
commit ec297c0ca0
10 changed files with 172 additions and 13 deletions

View File

@@ -2,9 +2,10 @@
* CommandService (`ctx.command`): the '/' command source over the
* session-keyed directory, the client-contribution registry, and the
* per-session popupSelect controllers. Candidate synthesis merges the host
* catalog with contributions by availability, then query/position filtering;
* a host/contribution name collision fails loud. Every execute addresses the
* session's agent by sessionId — sessions are always agent-backed.
* catalog with contributions by availability, then fuzzy query/position
* filtering; a host/contribution name collision fails loud. Every execute
* addresses the session's agent by sessionId — sessions are always
* agent-backed.
*/
import { Service } from 'cordis'
import type { Context } from 'cordis'
@@ -27,6 +28,69 @@ interface LiveState {
readonly popups: Map<SessionId, PopupSelectController<ClientSessionContext>>
}
/** One fuzzy match with its stable source position. */
interface RankedCandidate {
readonly candidate: SlashCandidate
readonly index: number
readonly prefix: boolean
readonly score: number
}
/** Extra weight for command-name starts and separator boundaries. */
function boundaryBonus(name: string, index: number): number {
return index === 0 || name.charAt(index - 1) === '-' || name.charAt(index - 1) === '_' ? 8 : 0
}
/**
* Score the strongest ordered-subsequence alignment in O(name × query).
* Boundary and adjacent matches earn weight; skipped and leading characters
* cost weight.
*/
function fuzzyScore(name: string, query: string): number | undefined {
if (query === '') return 0
if (query.length > name.length) return undefined
const noMatch = Number.NEGATIVE_INFINITY
let previous = Array<number>(name.length).fill(noMatch)
for (let index = 0; index < name.length; index++) {
if (name.charAt(index) === query.charAt(0)) previous[index] = 1 + boundaryBonus(name, index) - index
}
for (let queryIndex = 1; queryIndex < query.length; queryIndex++) {
const current = Array<number>(name.length).fill(noMatch)
let bestGapped = noMatch
for (let index = 0; index < name.length; index++) {
const gappedIndex = index - 2
if (gappedIndex >= 0) {
const prior = previous[gappedIndex] ?? noMatch
if (prior !== noMatch) bestGapped = Math.max(bestGapped, prior + gappedIndex)
}
if (name.charAt(index) !== query.charAt(queryIndex)) continue
const bonus = 1 + boundaryBonus(name, index)
const adjacent = index > 0 ? previous[index - 1] ?? noMatch : noMatch
if (adjacent !== noMatch) current[index] = adjacent + bonus + 4
if (bestGapped !== noMatch) current[index] = Math.max(current[index] ?? noMatch, bestGapped + bonus + 1 - index)
}
previous = current
}
let best = noMatch
for (const score of previous) best = Math.max(best, score)
return best === noMatch ? undefined : best
}
/** Case-insensitive fuzzy filtering with stable ordering for equal matches. */
function fuzzyCandidates(candidates: readonly SlashCandidate[], rawQuery: string): readonly SlashCandidate[] {
const query = rawQuery.toLowerCase()
if (query === '') return candidates
const ranked: RankedCandidate[] = []
candidates.forEach((candidate, index) => {
const name = candidate.name.toLowerCase()
const score = fuzzyScore(name, query)
if (score !== undefined) ranked.push({ candidate, index, prefix: name.startsWith(query), score })
})
ranked.sort((left, right) =>
Number(right.prefix) - Number(left.prefix) || right.score - left.score || left.index - right.index)
return ranked.map(match => match.candidate)
}
/** Command surface: session-keyed directory + '/' source + contribution registry + per-session popups. */
export class CommandService extends Service implements CommandServiceContract {
static inject = ['slash', 'sessions', 'connection']
@@ -147,7 +211,7 @@ export class CommandService extends Service implements CommandServiceContract {
}
}
/** Menu candidates: host catalog + contribution availability, then query/position filtering. */
/** Menu candidates: host catalog + contribution availability, then position filtering and fuzzy name ranking. */
private async candidates(session: ClientSessionContext, req: CandidateRequest): Promise<readonly SlashCandidate[]> {
const list = await this.directory.ensureReady(session.sessionId, req.signal)
const rows: SlashCandidate[] = []
@@ -163,9 +227,10 @@ export class CommandService extends Service implements CommandServiceContract {
}
rows.push({ name: contribution.name, description: contribution.description })
}
return rows
.filter(c => c.name.startsWith(req.query))
.filter(c => req.position === 'leading' || c.hint === undefined)
return fuzzyCandidates(
rows.filter(c => req.position === 'leading' || c.hint === undefined),
req.query,
)
}
/** Decision table, menu column: contribution/decorated-host → popup; host input → claim; host bare → detached execute. */