moa-rl-env / moav2 /src /ui /components /commandPaletteUtils.ts
natnael kahssay
feat: use real moav2 source as RL task suite — symlinked sandbox, 3 real service tasks
ce25387
Raw
History Blame Contribute Delete
5.49 kB
// Pure utility functions for CommandPalette — no React dependencies.
const STORAGE_KEY = 'moa:command-recency'
const MAX_RECENCY_ENTRIES = 200
// --- Fuzzy matching ---
/**
* Returns true if every character in `query` appears in `text` in order (case-insensitive).
* Empty query matches everything.
*/
export function fuzzyMatch(query: string, text: string): boolean {
if (!query) return true
const q = query.toLowerCase()
const t = text.toLowerCase()
let qi = 0
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
if (t[ti] === q[qi]) qi++
}
return qi === q.length
}
/**
* Score a single-token fuzzy match — lower is better (fewer gaps between matched chars).
* Returns -1 if no match. 0 is a perfect consecutive match.
*/
export function fuzzyScore(query: string, text: string): number {
if (!query) return 0
const q = query.toLowerCase()
const t = text.toLowerCase()
let qi = 0
let totalGap = 0
let lastMatchIdx = -1
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
if (t[ti] === q[qi]) {
if (lastMatchIdx >= 0) {
totalGap += (ti - lastMatchIdx - 1)
}
lastMatchIdx = ti
qi++
}
}
if (qi < q.length) return -1 // no match
return totalGap
}
/**
* Token-based fuzzy match: split query into space-separated tokens,
* each token must independently fuzzy-match somewhere in the text.
* Token order does NOT matter — "se d" matches "Delete Session".
*/
export function tokenFuzzyMatch(query: string, text: string): boolean {
const trimmed = query.trim()
if (!trimmed) return true
const tokens = trimmed.split(/\s+/)
return tokens.every(token => fuzzyMatch(token, text))
}
/**
* Score a token-based fuzzy match. Lower is better.
* Returns -1 if any token fails to match.
* Score = sum of per-token fuzzy scores + order penalty.
*/
export function tokenFuzzyScore(query: string, text: string): number {
const trimmed = query.trim()
if (!trimmed) return 0
const tokens = trimmed.split(/\s+/)
const t = text.toLowerCase()
let totalScore = 0
for (const token of tokens) {
const score = fuzzyScore(token, t)
if (score === -1) return -1
totalScore += score
}
// Bonus: if all tokens match in text-order, no penalty.
// If out of order, add a small penalty per out-of-order pair.
const positions = tokens.map(token => {
const q = token.toLowerCase()
// Find first match position of this token
let qi = 0
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
if (t[ti] === q[qi]) {
if (qi === 0) { /* first char position tracked implicitly */ }
qi++
}
}
// Return position of first matched char
return t.indexOf(q[0])
})
// Count out-of-order pairs
let outOfOrder = 0
for (let i = 1; i < positions.length; i++) {
if (positions[i] < positions[i - 1]) outOfOrder++
}
totalScore += outOfOrder * 5 // small penalty for each out-of-order token
return totalScore
}
// --- Recency tracking ---
export type RecencyMap = Record<string, number>
export function getRecencyMap(): RecencyMap {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return {}
return JSON.parse(raw) as RecencyMap
} catch {
return {}
}
}
export function recordUsage(id: string): void {
const map = getRecencyMap()
map[id] = Date.now()
const pruned = pruneRecencyMap(map, MAX_RECENCY_ENTRIES)
localStorage.setItem(STORAGE_KEY, JSON.stringify(pruned))
}
export function pruneRecencyMap(map: RecencyMap, maxSize: number): RecencyMap {
const entries = Object.entries(map)
if (entries.length <= maxSize) return map
// Sort by timestamp descending (most recent first), keep only maxSize
entries.sort((a, b) => b[1] - a[1])
const kept = entries.slice(0, maxSize)
return Object.fromEntries(kept)
}
// --- Sorting ---
export interface SortableItem {
id: string
label: string
category: string
}
/**
* Sort items by recency (most recent first), then alphabetically by label.
* Items with no recency entry sort after items that have one.
*/
export function sortByRecency<T extends SortableItem>(items: T[], recencyMap: RecencyMap): T[] {
return [...items].sort((a, b) => {
const ra = recencyMap[a.id] ?? 0
const rb = recencyMap[b.id] ?? 0
// Both have recency — most recent first
if (ra && rb) return rb - ra
// One has recency, the other doesn't — recency wins
if (ra && !rb) return -1
if (!ra && rb) return 1
// Neither has recency — alphabetical
return a.label.localeCompare(b.label)
})
}
/**
* Sort filtered items by token fuzzy score (best first), then recency, then alphabetical.
*/
export function sortFiltered<T extends SortableItem>(
items: T[],
query: string,
recencyMap: RecencyMap,
): T[] {
if (!query) return sortByRecency(items, recencyMap)
return [...items].sort((a, b) => {
const sa = tokenFuzzyScore(query, a.category + ' ' + a.label)
const sb = tokenFuzzyScore(query, b.category + ' ' + b.label)
// -1 means no match — push to end
if (sa === -1 && sb !== -1) return 1
if (sa !== -1 && sb === -1) return -1
// Better score (lower) first
if (sa !== sb) return sa - sb
// Same score — recency
const ra = recencyMap[a.id] ?? 0
const rb = recencyMap[b.id] ?? 0
if (ra && rb) return rb - ra
if (ra && !rb) return -1
if (!ra && rb) return 1
// Alphabetical
return a.label.localeCompare(b.label)
})
}