diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9b021d8d6..418c5d3c4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/modes/components/model-browser.ts b/packages/coding-agent/src/modes/components/model-browser.ts index 2634a4752..8eec17093 100644 --- a/packages/coding-agent/src/modes/components/model-browser.ts +++ b/packages/coding-agent/src/modes/components/model-browser.ts @@ -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(); + 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; diff --git a/packages/coding-agent/test/model-browser.test.ts b/packages/coding-agent/test/model-browser.test.ts new file mode 100644 index 000000000..a8c34e8dd --- /dev/null +++ b/packages/coding-agent/test/model-browser.test.ts @@ -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"); + }); +});