diff --git a/packages/tui/src/fuzzy.ts b/packages/tui/src/fuzzy.ts index 8b6eec5d..09410238 100644 --- a/packages/tui/src/fuzzy.ts +++ b/packages/tui/src/fuzzy.ts @@ -13,53 +13,79 @@ export function fuzzyMatch(query: string, text: string): FuzzyMatch { const queryLower = query.toLowerCase(); const textLower = text.toLowerCase(); - if (queryLower.length === 0) { - return { matches: true, score: 0 }; - } - - if (queryLower.length > textLower.length) { - return { matches: false, score: 0 }; - } - - let queryIndex = 0; - let score = 0; - let lastMatchIndex = -1; - let consecutiveMatches = 0; - - for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) { - if (textLower[i] === queryLower[queryIndex]) { - 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++; + 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; + + for (let i = 0; i < textLower.length && queryIndex < normalizedQuery.length; i++) { + if (textLower[i] === normalizedQuery[queryIndex]) { + 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 }; + } + + return { matches: true, score }; + }; + + const primaryMatch = matchQuery(queryLower); + if (primaryMatch.matches) { + return primaryMatch; } - if (queryIndex < queryLower.length) { - return { matches: false, score: 0 }; + const alphaNumericMatch = queryLower.match(/^(?[a-z]+)(?[0-9]+)$/); + const numericAlphaMatch = queryLower.match(/^(?[0-9]+)(?[a-z]+)$/); + const swappedQuery = alphaNumericMatch + ? `${alphaNumericMatch.groups?.digits ?? ""}${alphaNumericMatch.groups?.letters ?? ""}` + : numericAlphaMatch + ? `${numericAlphaMatch.groups?.letters ?? ""}${numericAlphaMatch.groups?.digits ?? ""}` + : ""; + + if (!swappedQuery) { + return primaryMatch; } - return { matches: true, score }; + const swappedMatch = matchQuery(swappedQuery); + if (!swappedMatch.matches) { + return primaryMatch; + } + + return { matches: true, score: swappedMatch.score + 5 }; } /** diff --git a/packages/tui/test/fuzzy.test.ts b/packages/tui/test/fuzzy.test.ts index 1f6b86fc..e3e7d1e8 100644 --- a/packages/tui/test/fuzzy.test.ts +++ b/packages/tui/test/fuzzy.test.ts @@ -53,6 +53,11 @@ describe("fuzzyMatch", () => { assert.strictEqual(notAtBoundary.matches, true); assert.ok(atBoundary.score < notAtBoundary.score); }); + + it("matches swapped alpha numeric tokens", () => { + const result = fuzzyMatch("codex52", "gpt-5.2-codex"); + assert.strictEqual(result.matches, true); + }); }); describe("fuzzyFilter", () => {