From 8c03fef4d3b7ecf7a9e8cc5b1312ff60ec2be149 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Hou=C5=A1ka?= Date: Thu, 22 Oct 2026 21:29:54 +0200 Subject: [PATCH] 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 --- .../components/session-selector-search.ts | 112 ++++++++++++--- .../coding-agent/test/model-search.test.ts | 33 +++++ .../test/session-selector-search.test.ts | 123 +++++++++++++++- packages/tui/src/fuzzy.ts | 135 ++++++++++-------- packages/tui/test/fuzzy.test.ts | 30 ++++ 5 files changed, 348 insertions(+), 85 deletions(-) create mode 100644 packages/coding-agent/test/model-search.test.ts diff --git a/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts b/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts index 9b5bf2327..5b3317faa 100644 --- a/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts +++ b/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts @@ -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(); + +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 }; diff --git a/packages/coding-agent/test/model-search.test.ts b/packages/coding-agent/test/model-search.test.ts new file mode 100644 index 000000000..08465dbe7 --- /dev/null +++ b/packages/coding-agent/test/model-search.test.ts @@ -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"); + }); +}); diff --git a/packages/coding-agent/test/session-selector-search.test.ts b/packages/coding-agent/test/session-selector-search.test.ts index c1aed7137..71d0c694c 100644 --- a/packages/coding-agent/test/session-selector-search.test.ts +++ b/packages/coding-agent/test/session-selector-search.test.ts @@ -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({ diff --git a/packages/tui/src/fuzzy.ts b/packages/tui/src/fuzzy.ts index 1b5352c1c..718798eb9 100644 --- a/packages/tui/src/fuzzy.ts +++ b/packages/tui/src/fuzzy.ts @@ -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; } diff --git a/packages/tui/test/fuzzy.test.ts b/packages/tui/test/fuzzy.test.ts index af4662afa..b9dba3620 100644 --- a/packages/tui/test/fuzzy.test.ts +++ b/packages/tui/test/fuzzy.test.ts @@ -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}`);