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:
Petr Houška 2026-10-22 21:29:54 +02:00
parent 90d5cf7846
commit 8c03fef4d3
5 changed files with 348 additions and 85 deletions

View file

@ -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 };

View 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");
});
});

View file

@ -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({

View file

@ -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;
}

View file

@ -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}`);