feat(coding-agent): improved model search ranking by match quality

- Prioritized fuzzy match quality over the most-recently-used model order when filtering the model browser.
- Added bucketing to match scores to ensure stable sorting when match quality is identical.
- Added unit tests to verify that exact query matches take precedence over the MRU model.
This commit is contained in:
can1357
2026-07-12 00:00:35 +02:00
parent e7955ddf3c
commit 666327608c
3 changed files with 78 additions and 4 deletions
+1
View File
@@ -21,6 +21,7 @@
- Fixed `/btw` side-channel turns on Codex models such as `gpt-5.6-luna` by preserving the session websocket preference instead of forcing SSE, and made Esc dismiss the active `/btw` panel before interrupting loop/maintenance work. ([#5213](https://github.com/can1357/oh-my-pi/issues/5213))
- Fixed the Model Hub role-assignment strip hiding the selected chip once the row overflowed; the strip now scrolls horizontally, truncating passed chips behind a leading ellipsis so the selection (plus one chip of lookahead) stays visible.
- Fixed mouse hover and clicks in the /models Roles view landing one row above the pointer (the row mapping subtracted the status row twice).
- Fixed model search keeping the most-recently-used model on top of the results: match quality now ranks first (an exact `gpt-5.5` beats the active `gpt-5.6-sol`), with MRU order only breaking ties between equally good matches.
## [16.4.5] - 2026-07-11
@@ -13,7 +13,7 @@ import { buildModel } from "@oh-my-pi/pi-catalog/build";
import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
import {
type Component,
fuzzyFilter,
fuzzyRank,
Input,
matchesKey,
ScrollView,
@@ -432,10 +432,19 @@ export class ModelBrowser implements Component {
if (query.trim()) {
// Match against the displayed "provider/id" string so the user can
// type what they see: bare names, provider prefixes, or scoped
// queries all flow through the same fuzzy matcher. Skip role rank
// so a weakly matching default doesn't trump a stronger match.
const matches = fuzzyFilter(this.#baseItems, query, ({ provider, id }) => `${provider}/${id}`);
// queries all flow through the same fuzzy matcher.
const ranked = fuzzyRank(this.#baseItems, query, ({ provider, id }) => `${provider}/${id}`);
const matches = ranked.map(result => result.item);
// Match quality is the primary key while searching: an exact
// "gpt-5.5" must beat the MRU (or role-assigned) "gpt-5.6", so
// role rank is skipped and MRU only breaks ties. Scores are
// bucketed so sub-point position noise (provider-name length)
// can't split equally good matches; within a bucket the stable
// sort keeps sortModelItems' MRU/version order.
sortModelItems(matches, { roles: this.#roles, mruOrder: this.#mruOrder, skipRoleRank: true });
const buckets = new Map<ModelBrowserItem, number>();
for (const result of ranked) buckets.set(result.item, Math.round(result.score / 10));
matches.sort((a, b) => (buckets.get(a) ?? 0) - (buckets.get(b) ?? 0));
items = matches;
} else {
items = this.#baseItems;
@@ -0,0 +1,64 @@
import { describe, expect, test } from "bun:test";
import type { Model } from "@oh-my-pi/pi-ai";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import {
buildBrowserItems,
ModelBrowser,
sortModelItems,
} from "@oh-my-pi/pi-coding-agent/modes/components/model-browser";
function makeModel(provider: string, id: string): Model {
return buildModel({
id,
name: id,
api: "ollama-chat",
provider,
baseUrl: "https://example.com",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 1024,
});
}
/** Browser preloaded with `models`, MRU-sorted like the hub does on sync. */
function makeBrowser(models: Model[], mruOrder: string[]): ModelBrowser {
const browser = new ModelBrowser(Settings.isolated({}));
const items = buildBrowserItems(models);
sortModelItems(items, { mruOrder });
browser.setMruOrder(mruOrder);
browser.setItems(items);
return browser;
}
describe("ModelBrowser search ranking", () => {
test("an exact query match outranks the MRU model", () => {
// Regression: with gpt-5.6-sol as the active (MRU) model, typing
// "gpt-5.5" must select gpt-5.5, not keep the MRU pinned on top.
const browser = makeBrowser(
[
makeModel("openai-codex", "gpt-5.6-sol"),
makeModel("openai-codex", "gpt-5.6-luna"),
makeModel("openai-codex", "gpt-5.5"),
makeModel("openai-codex", "gpt-5.4"),
],
["openai-codex/gpt-5.6-sol", "openai-codex/gpt-5.6-luna"],
);
browser.setQuery("gpt-5.5");
expect(browser.getSelected()?.selector).toBe("openai-codex/gpt-5.5");
});
test("MRU breaks ties between equally good matches", () => {
// Same model id under two providers: match quality is identical, so
// the recently used provider must win over alphabetical order.
const browser = makeBrowser([makeModel("g0i", "gpt-5.5"), makeModel("zenmux", "gpt-5.5")], ["zenmux/gpt-5.5"]);
browser.setQuery("gpt-5.5");
expect(browser.getSelected()?.selector).toBe("zenmux/gpt-5.5");
});
});