mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(tui,coding-agent): rank contiguous fuzzy matches and search session fields separately
fuzzyMatch scored only the greedy leftmost alignment, so a query could be scattered over an earlier unrelated part of the text (opus via "openrouter", astra via "radius radius/gpt") and the real match ranked last. It now also scores every contiguous occurrence and keeps the best alignment. /resume matched one concatenated id/name/messages/cwd string. Scattered matches in megabytes of message text or in UUIDs were noise, and the position penalty buried real hits deep in a transcript. Fields are now matched separately: name and cwd fuzzy, id and messages exact only, with no position penalty. Ranking is name text, then id/message/cwd text, then scattered name/cwd matches. Lowercased message text is cached per session. closes #11220
This commit is contained in:
parent
90d5cf7846
commit
8c03fef4d3
5 changed files with 348 additions and 85 deletions
|
|
@ -23,8 +23,83 @@ function normalizeWhitespaceLower(text: string): string {
|
|||
return text.toLowerCase().replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function getSessionSearchText(session: SessionInfo): string {
|
||||
return `${session.id} ${session.name ?? ""} ${session.allMessagesText} ${session.cwd}`;
|
||||
/**
|
||||
* Match tiers, best first. Each token is scored by the best field it matches; tiers dominate
|
||||
* fuzzy scores so an exact hit anywhere beats a scattered subsequence in a short field.
|
||||
*/
|
||||
const TIER_NAME = 0; // query text appears in the session name
|
||||
const TIER_TEXT = 1; // query text appears in the session id, messages, or cwd
|
||||
const TIER_FUZZY = 2; // query characters appear scattered in the session name or cwd
|
||||
const TIER_WEIGHT = 1_000_000;
|
||||
|
||||
interface LowercaseCache {
|
||||
source: string;
|
||||
lower: string;
|
||||
normalized?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lowercased message text per session. allMessagesText holds every user/assistant message and
|
||||
* can be megabytes, so lowercase it once instead of on every keystroke.
|
||||
*/
|
||||
const messageTextCache = new WeakMap<SessionInfo, LowercaseCache>();
|
||||
|
||||
function getMessageText(session: SessionInfo): LowercaseCache {
|
||||
let cached = messageTextCache.get(session);
|
||||
if (!cached || cached.source !== session.allMessagesText) {
|
||||
cached = { source: session.allMessagesText, lower: session.allMessagesText.toLowerCase() };
|
||||
messageTextCache.set(session, cached);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
function getNormalizedMessageText(session: SessionInfo): string {
|
||||
const cached = getMessageText(session);
|
||||
cached.normalized ??= cached.lower.replace(/\s+/g, " ").trim();
|
||||
return cached.normalized;
|
||||
}
|
||||
|
||||
function tierScore(tier: number, score = 0): number {
|
||||
return tier * TIER_WEIGHT + score;
|
||||
}
|
||||
|
||||
/** Exact (case-insensitive) text match. Messages and ids are only matched exactly: scattered matches in them are noise. */
|
||||
function matchLiteralToken(session: SessionInfo, value: string): number | undefined {
|
||||
const name = session.name?.toLowerCase() ?? "";
|
||||
if (name.includes(value)) return tierScore(TIER_NAME, fuzzyMatch(value, name).score);
|
||||
if (session.id.toLowerCase().includes(value)) return tierScore(TIER_TEXT);
|
||||
if (getMessageText(session).lower.includes(value)) return tierScore(TIER_TEXT);
|
||||
if (session.cwd.toLowerCase().includes(value)) return tierScore(TIER_TEXT);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function matchFuzzyToken(session: SessionInfo, value: string): number | undefined {
|
||||
const literal = matchLiteralToken(session, value.toLowerCase());
|
||||
if (literal !== undefined) return literal;
|
||||
|
||||
let best: number | undefined;
|
||||
for (const field of [session.name ?? "", session.cwd]) {
|
||||
const m = fuzzyMatch(value, field);
|
||||
if (m.matches && (best === undefined || m.score < best)) best = m.score;
|
||||
}
|
||||
return best === undefined ? undefined : tierScore(TIER_FUZZY, best);
|
||||
}
|
||||
|
||||
function matchPhraseToken(session: SessionInfo, phrase: string): number | undefined {
|
||||
const name = normalizeWhitespaceLower(session.name ?? "");
|
||||
if (name.includes(phrase)) return tierScore(TIER_NAME, fuzzyMatch(phrase, name).score);
|
||||
if (session.id.toLowerCase().includes(phrase)) return tierScore(TIER_TEXT);
|
||||
if (getNormalizedMessageText(session).includes(phrase)) return tierScore(TIER_TEXT);
|
||||
if (normalizeWhitespaceLower(session.cwd).includes(phrase)) return tierScore(TIER_TEXT);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function matchRegex(session: SessionInfo, regex: RegExp): number | undefined {
|
||||
if (session.name && regex.test(session.name)) return tierScore(TIER_NAME);
|
||||
if (regex.test(session.id) || regex.test(session.allMessagesText) || regex.test(session.cwd)) {
|
||||
return tierScore(TIER_TEXT);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function hasSessionName(session: SessionInfo): boolean {
|
||||
|
|
@ -113,41 +188,32 @@ export function parseSearchQuery(query: string): ParsedSearchQuery {
|
|||
return { mode: "tokens", tokens, regex: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Each field is matched separately, so a token never matches across field boundaries.
|
||||
* Different tokens may match different fields.
|
||||
*/
|
||||
export function matchSession(session: SessionInfo, parsed: ParsedSearchQuery): MatchResult {
|
||||
const text = getSessionSearchText(session);
|
||||
|
||||
if (parsed.mode === "regex") {
|
||||
if (!parsed.regex) {
|
||||
return { matches: false, score: 0 };
|
||||
}
|
||||
const idx = text.search(parsed.regex);
|
||||
if (idx < 0) return { matches: false, score: 0 };
|
||||
return { matches: true, score: idx * 0.1 };
|
||||
}
|
||||
|
||||
if (parsed.tokens.length === 0) {
|
||||
return { matches: true, score: 0 };
|
||||
const score = matchRegex(session, parsed.regex);
|
||||
return score === undefined ? { matches: false, score: 0 } : { matches: true, score };
|
||||
}
|
||||
|
||||
let totalScore = 0;
|
||||
let normalizedText: string | null = null;
|
||||
|
||||
for (const token of parsed.tokens) {
|
||||
let score: number | undefined;
|
||||
if (token.kind === "phrase") {
|
||||
if (normalizedText === null) {
|
||||
normalizedText = normalizeWhitespaceLower(text);
|
||||
}
|
||||
const phrase = normalizeWhitespaceLower(token.value);
|
||||
if (!phrase) continue;
|
||||
const idx = normalizedText.indexOf(phrase);
|
||||
if (idx < 0) return { matches: false, score: 0 };
|
||||
totalScore += idx * 0.1;
|
||||
continue;
|
||||
score = matchPhraseToken(session, phrase);
|
||||
} else {
|
||||
score = matchFuzzyToken(session, token.value);
|
||||
}
|
||||
|
||||
const m = fuzzyMatch(token.value, text);
|
||||
if (!m.matches) return { matches: false, score: 0 };
|
||||
totalScore += m.score;
|
||||
if (score === undefined) return { matches: false, score: 0 };
|
||||
totalScore += score;
|
||||
}
|
||||
|
||||
return { matches: true, score: totalScore };
|
||||
|
|
|
|||
33
packages/coding-agent/test/model-search.test.ts
Normal file
33
packages/coding-agent/test/model-search.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { fuzzyFilter } from "@earendil-works/pi-tui";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getModelSelectorSearchText } from "../src/modes/interactive/model-search.ts";
|
||||
|
||||
describe("model selector search ranking", () => {
|
||||
// #11220
|
||||
it("ranks models containing the query above scattered matches", () => {
|
||||
const models = [
|
||||
{ provider: "openrouter", id: "openai/gpt-oss-120b", name: "OpenAI: gpt-oss-120b" },
|
||||
{ provider: "openrouter", id: "anthropic/claude-sonnet-4.5", name: "Anthropic: Claude Sonnet 4.5" },
|
||||
{ provider: "openrouter", id: "anthropic/claude-opus-4.1", name: "Anthropic: Claude Opus 4.1" },
|
||||
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
|
||||
{ provider: "anthropic", id: "claude-opus-4-1", name: "Claude Opus 4.1" },
|
||||
];
|
||||
|
||||
const result = fuzzyFilter(models, "opus", getModelSelectorSearchText).map((m) => m.id);
|
||||
|
||||
expect(result.slice(0, 2).sort()).toEqual(["anthropic/claude-opus-4.1", "claude-opus-4-1"]);
|
||||
});
|
||||
|
||||
// #10065
|
||||
it("ranks the typed model above models sharing a long provider prefix", () => {
|
||||
const models = [
|
||||
{ provider: "fireworks", id: "accounts/fireworks/models/deepseek-v4-flash-0731" },
|
||||
{ provider: "fireworks", id: "accounts/fireworks/models/kimi-k3" },
|
||||
{ provider: "fireworks", id: "accounts/fireworks/routers/firerouter", name: "FireRouter" },
|
||||
];
|
||||
|
||||
const result = fuzzyFilter(models, "firerouter", getModelSelectorSearchText);
|
||||
|
||||
expect(result[0]?.id).toBe("accounts/fireworks/routers/firerouter");
|
||||
});
|
||||
});
|
||||
|
|
@ -81,19 +81,20 @@ describe("session selector search", () => {
|
|||
it("relevance sort orders by score and tie-breaks by modified desc", () => {
|
||||
const sessions: SessionInfo[] = [
|
||||
makeSession({
|
||||
id: "late",
|
||||
id: "message",
|
||||
modified: new Date("2026-01-03T00:00:00.000Z"),
|
||||
allMessagesText: "xxxx brave",
|
||||
allMessagesText: "brave",
|
||||
}),
|
||||
makeSession({
|
||||
id: "early",
|
||||
id: "name",
|
||||
name: "Brave work",
|
||||
modified: new Date("2026-01-01T00:00:00.000Z"),
|
||||
allMessagesText: "brave xxxx",
|
||||
allMessagesText: "xxxx",
|
||||
}),
|
||||
];
|
||||
|
||||
const result1 = filterAndSortSessions(sessions, '"brave"', "relevance");
|
||||
expect(result1.map((s) => s.id)).toEqual(["early", "late"]);
|
||||
expect(result1.map((s) => s.id)).toEqual(["name", "message"]);
|
||||
|
||||
const tieSessions: SessionInfo[] = [
|
||||
makeSession({
|
||||
|
|
@ -112,6 +113,118 @@ describe("session selector search", () => {
|
|||
expect(result2.map((s) => s.id)).toEqual(["newer", "older"]);
|
||||
});
|
||||
|
||||
// #11220
|
||||
it("ignores the position of a match in message text", () => {
|
||||
const filler = "lorem ipsum dolor sit amet ".repeat(400);
|
||||
const sessions: SessionInfo[] = [
|
||||
makeSession({
|
||||
id: "early",
|
||||
modified: new Date("2026-01-01T00:00:00.000Z"),
|
||||
allMessagesText: `opus ${filler}`,
|
||||
}),
|
||||
makeSession({
|
||||
id: "deep",
|
||||
modified: new Date("2026-01-02T00:00:00.000Z"),
|
||||
allMessagesText: `${filler} we discussed the opus model`,
|
||||
}),
|
||||
makeSession({
|
||||
id: "scattered",
|
||||
modified: new Date("2026-01-03T00:00:00.000Z"),
|
||||
allMessagesText: filler,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = filterAndSortSessions(sessions, "opus", "relevance");
|
||||
expect(result.map((s) => s.id)).toEqual(["deep", "early"]);
|
||||
});
|
||||
|
||||
// #11024
|
||||
it("does not match scattered characters in session ids", () => {
|
||||
const sessions: SessionInfo[] = [
|
||||
makeSession({
|
||||
id: "019e9024-aaaa-7bbb-8ccc-dddddddddddd",
|
||||
modified: new Date("2026-01-03T00:00:00.000Z"),
|
||||
allMessagesText: "unrelated",
|
||||
}),
|
||||
makeSession({
|
||||
id: "01a1ffff-aaaa-7bbb-8ccc-dddddddddddd",
|
||||
name: "#1024 TDD bare env",
|
||||
modified: new Date("2026-01-01T00:00:00.000Z"),
|
||||
allMessagesText: "unrelated",
|
||||
}),
|
||||
];
|
||||
|
||||
expect(filterAndSortSessions(sessions, "1024", "relevance").map((s) => s.name)).toEqual(["#1024 TDD bare env"]);
|
||||
expect(filterAndSortSessions(sessions, "9024-aa", "relevance").map((s) => s.id)).toEqual([
|
||||
"019e9024-aaaa-7bbb-8ccc-dddddddddddd",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ranks name text, then message text, then scattered name matches", () => {
|
||||
const sessions: SessionInfo[] = [
|
||||
makeSession({
|
||||
id: "scattered-name",
|
||||
name: "c-a-c-h-e notes",
|
||||
modified: new Date("2026-01-04T00:00:00.000Z"),
|
||||
allMessagesText: "unrelated",
|
||||
}),
|
||||
makeSession({
|
||||
id: "message",
|
||||
name: "Other work",
|
||||
modified: new Date("2026-01-03T00:00:00.000Z"),
|
||||
allMessagesText: "A discussion mentioning the cache.",
|
||||
}),
|
||||
makeSession({
|
||||
id: "name",
|
||||
name: "Cache work",
|
||||
modified: new Date("2026-01-01T00:00:00.000Z"),
|
||||
allMessagesText: "Summary",
|
||||
}),
|
||||
];
|
||||
|
||||
expect(filterAndSortSessions(sessions, "cache", "relevance").map((s) => s.id)).toEqual([
|
||||
"name",
|
||||
"message",
|
||||
"scattered-name",
|
||||
]);
|
||||
});
|
||||
|
||||
it("matches each token within a single field", () => {
|
||||
const sessions: SessionInfo[] = [
|
||||
makeSession({
|
||||
id: "split",
|
||||
name: "Fuzzy",
|
||||
modified: new Date("2026-01-01T00:00:00.000Z"),
|
||||
allMessagesText: "search ranking",
|
||||
}),
|
||||
];
|
||||
|
||||
expect(filterAndSortSessions(sessions, "fuzzy ranking", "relevance").map((s) => s.id)).toEqual(["split"]);
|
||||
expect(filterAndSortSessions(sessions, "zzysea", "relevance")).toEqual([]);
|
||||
expect(filterAndSortSessions(sessions, "re:fuzzy search", "relevance")).toEqual([]);
|
||||
});
|
||||
|
||||
it("ranks regex name matches first", () => {
|
||||
const sessions: SessionInfo[] = [
|
||||
makeSession({
|
||||
id: "message",
|
||||
modified: new Date("2026-01-02T00:00:00.000Z"),
|
||||
allMessagesText: "brave",
|
||||
}),
|
||||
makeSession({
|
||||
id: "name",
|
||||
name: "Brave",
|
||||
modified: new Date("2026-01-01T00:00:00.000Z"),
|
||||
allMessagesText: "xxxx",
|
||||
}),
|
||||
];
|
||||
|
||||
expect(filterAndSortSessions(sessions, "re:\\bbrave\\b", "relevance").map((s) => s.id)).toEqual([
|
||||
"name",
|
||||
"message",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty list for invalid regex", () => {
|
||||
const sessions: SessionInfo[] = [
|
||||
makeSession({
|
||||
|
|
|
|||
|
|
@ -9,66 +9,87 @@ export interface FuzzyMatch {
|
|||
score: number;
|
||||
}
|
||||
|
||||
function scorePositions(textLower: string, positions: readonly number[]): number {
|
||||
let score = 0;
|
||||
let lastMatchIndex = -1;
|
||||
let consecutiveMatches = 0;
|
||||
|
||||
for (const i of positions) {
|
||||
// Reward consecutive matches
|
||||
if (lastMatchIndex === i - 1) {
|
||||
consecutiveMatches++;
|
||||
score -= consecutiveMatches * 5;
|
||||
} else {
|
||||
consecutiveMatches = 0;
|
||||
// Penalize gaps
|
||||
if (lastMatchIndex >= 0) {
|
||||
score += (i - lastMatchIndex - 1) * 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Reward word boundary matches
|
||||
if (i === 0 || /[\s\-_./:]/.test(textLower[i - 1]!)) {
|
||||
score -= 10;
|
||||
}
|
||||
|
||||
// Slight penalty for later matches
|
||||
score += i * 0.1;
|
||||
|
||||
lastMatchIndex = i;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function matchQuery(normalizedQuery: string, textLower: string): FuzzyMatch {
|
||||
if (normalizedQuery.length === 0) {
|
||||
return { matches: true, score: 0 };
|
||||
}
|
||||
|
||||
if (normalizedQuery.length > textLower.length) {
|
||||
return { matches: false, score: 0 };
|
||||
}
|
||||
|
||||
// Greedy alignment: each query character at its first occurrence after the previous one.
|
||||
const positions: number[] = [];
|
||||
let lastMatchIndex = -1;
|
||||
for (let queryIndex = 0; queryIndex < normalizedQuery.length; queryIndex++) {
|
||||
const i = textLower.indexOf(normalizedQuery[queryIndex]!, lastMatchIndex + 1);
|
||||
if (i === -1) {
|
||||
return { matches: false, score: 0 };
|
||||
}
|
||||
positions.push(i);
|
||||
lastMatchIndex = i;
|
||||
}
|
||||
|
||||
let score = scorePositions(textLower, positions);
|
||||
|
||||
// The greedy alignment can scatter the query over an earlier, unrelated part of the text,
|
||||
// e.g. "opus" in "openrouter openrouter/anthropic/claude-opus" takes o, p, u from "openrouter".
|
||||
// Also score every contiguous occurrence and keep the best alignment.
|
||||
for (
|
||||
let start = textLower.indexOf(normalizedQuery);
|
||||
start !== -1;
|
||||
start = textLower.indexOf(normalizedQuery, start + 1)
|
||||
) {
|
||||
for (let k = 0; k < positions.length; k++) {
|
||||
positions[k] = start + k;
|
||||
}
|
||||
score = Math.min(score, scorePositions(textLower, positions));
|
||||
}
|
||||
|
||||
if (normalizedQuery === textLower) {
|
||||
score -= 100;
|
||||
}
|
||||
|
||||
return { matches: true, score };
|
||||
}
|
||||
|
||||
export function fuzzyMatch(query: string, text: string): FuzzyMatch {
|
||||
const queryLower = query.toLowerCase();
|
||||
const textLower = text.toLowerCase();
|
||||
|
||||
const matchQuery = (normalizedQuery: string): FuzzyMatch => {
|
||||
if (normalizedQuery.length === 0) {
|
||||
return { matches: true, score: 0 };
|
||||
}
|
||||
|
||||
if (normalizedQuery.length > textLower.length) {
|
||||
return { matches: false, score: 0 };
|
||||
}
|
||||
|
||||
let queryIndex = 0;
|
||||
let score = 0;
|
||||
let lastMatchIndex = -1;
|
||||
let consecutiveMatches = 0;
|
||||
|
||||
while (queryIndex < normalizedQuery.length) {
|
||||
const i = textLower.indexOf(normalizedQuery[queryIndex]!, lastMatchIndex + 1);
|
||||
if (i === -1) break;
|
||||
|
||||
const isWordBoundary = i === 0 || /[\s\-_./:]/.test(textLower[i - 1]!);
|
||||
|
||||
// Reward consecutive matches
|
||||
if (lastMatchIndex === i - 1) {
|
||||
consecutiveMatches++;
|
||||
score -= consecutiveMatches * 5;
|
||||
} else {
|
||||
consecutiveMatches = 0;
|
||||
// Penalize gaps
|
||||
if (lastMatchIndex >= 0) {
|
||||
score += (i - lastMatchIndex - 1) * 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Reward word boundary matches
|
||||
if (isWordBoundary) {
|
||||
score -= 10;
|
||||
}
|
||||
|
||||
// Slight penalty for later matches
|
||||
score += i * 0.1;
|
||||
|
||||
lastMatchIndex = i;
|
||||
queryIndex++;
|
||||
}
|
||||
|
||||
if (queryIndex < normalizedQuery.length) {
|
||||
return { matches: false, score: 0 };
|
||||
}
|
||||
|
||||
if (normalizedQuery === textLower) {
|
||||
score -= 100;
|
||||
}
|
||||
|
||||
return { matches: true, score };
|
||||
};
|
||||
|
||||
const primaryMatch = matchQuery(queryLower);
|
||||
const primaryMatch = matchQuery(queryLower, textLower);
|
||||
if (primaryMatch.matches) {
|
||||
return primaryMatch;
|
||||
}
|
||||
|
|
@ -85,7 +106,7 @@ export function fuzzyMatch(query: string, text: string): FuzzyMatch {
|
|||
return primaryMatch;
|
||||
}
|
||||
|
||||
const swappedMatch = matchQuery(swappedQuery);
|
||||
const swappedMatch = matchQuery(swappedQuery, textLower);
|
||||
if (!swappedMatch.matches) {
|
||||
return primaryMatch;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,23 @@ describe("fuzzyMatch", () => {
|
|||
assert.ok(atBoundary.score < notAtBoundary.score);
|
||||
});
|
||||
|
||||
// #11220: greedy alignment took o, p, u from "openrouter", so the real "opus" scored worst
|
||||
it("scores a contiguous occurrence instead of an earlier scattered alignment", () => {
|
||||
const opus = fuzzyMatch("opus", "openrouter openrouter/anthropic/claude-opus-4.1");
|
||||
const sonnet = fuzzyMatch("opus", "openrouter openrouter/anthropic/claude-sonnet-4.5");
|
||||
const gptOss = fuzzyMatch("opus", "openrouter openrouter/openai/gpt-oss-120b");
|
||||
|
||||
assert.ok(opus.score < sonnet.score);
|
||||
assert.ok(opus.score < gptOss.score);
|
||||
});
|
||||
|
||||
it("uses the best contiguous occurrence, not the first", () => {
|
||||
const embeddedFirst = fuzzyMatch("opus", "xxopus opus");
|
||||
const boundaryOnly = fuzzyMatch("opus", "xxxxxx opus");
|
||||
|
||||
assert.strictEqual(embeddedFirst.score, boundaryOnly.score);
|
||||
});
|
||||
|
||||
it("matches swapped alpha numeric tokens", () => {
|
||||
const result = fuzzyMatch("codex52", "gpt-5.2-codex");
|
||||
assert.strictEqual(result.matches, true);
|
||||
|
|
@ -103,6 +120,19 @@ describe("fuzzyFilter", () => {
|
|||
assert.ok(result.map((r) => r.name).includes("foobar"));
|
||||
});
|
||||
|
||||
// #11220: "radius radius/gpt" contains a, s, t, r, a in order, so every radius model matched there
|
||||
it("ranks a model containing the query above models matching a shared provider prefix", () => {
|
||||
const items = [
|
||||
"radius radius/gpt-5.4 radius gpt-5.4 GPT 5.4",
|
||||
"radius radius/agentic radius agentic Agentic",
|
||||
"radius radius/gpt-6-sol radius gpt-6-sol GPT 6 Sol",
|
||||
"radius radius/gpt-6-astra radius gpt-6-astra GPT 6 Astra",
|
||||
];
|
||||
const result = fuzzyFilter(items, "astra", (x: string) => x);
|
||||
|
||||
assert.strictEqual(result[0], items[3]);
|
||||
});
|
||||
|
||||
it("matches slash-separated provider/model queries against reordered text", () => {
|
||||
const item = { id: "gpt-5.5", provider: "openai-codex" };
|
||||
const result = fuzzyFilter([item], "openai-codex/gpt-5.5", (model) => `${model.id} ${model.provider}`);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue