|
|
@@ -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. */
|