From be59f834204270a7597c3166fabbd50ce7500b5e Mon Sep 17 00:00:00 2001 From: Matthew Morrow Date: Wed, 5 Aug 2026 16:38:18 -0500 Subject: [PATCH 1/2] fix(coding-agent): naturally sort scoped model catalog --- .../components/scoped-models-selector.ts | 31 ++++++++++++++- .../3217-scoped-model-order.test.ts | 39 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts b/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts index e7dfc271496..d1a1090d004 100644 --- a/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts @@ -18,6 +18,30 @@ import { keyText } from "./keybinding-hints.ts"; // EnabledIds: null = all enabled (no filter), string[] = explicit ordered list type EnabledIds = string[] | null; +const MODEL_ID_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); +const CONTEXT_ALIAS_PATTERN = /^(.*)@(\d+(?:\.\d+)?)([km])$/i; + +function parseContextAlias(id: string): { base: string; tokens: number } | undefined { + const match = id.match(CONTEXT_ALIAS_PATTERN); + if (!match) return undefined; + const value = Number(match[2]); + if (!Number.isFinite(value)) return undefined; + return { base: match[1]!, tokens: value * (match[3]!.toLowerCase() === "m" ? 1_000_000 : 1_000) }; +} + +function compareModelIds(left: string, right: string): number { + const leftAlias = parseContextAlias(left); + const rightAlias = parseContextAlias(right); + const baseOrder = MODEL_ID_COLLATOR.compare(leftAlias?.base ?? left, rightAlias?.base ?? right); + if (baseOrder !== 0) return baseOrder; + + // Keep a canonical model before its context aliases, then order aliases by + // their numeric size so @200k appears before @1m. + if (!leftAlias) return rightAlias ? -1 : MODEL_ID_COLLATOR.compare(left, right); + if (!rightAlias) return 1; + return leftAlias.tokens - rightAlias.tokens || MODEL_ID_COLLATOR.compare(left, right); +} + function isEnabled(enabledIds: EnabledIds, id: string): boolean { return enabledIds === null || enabledIds.includes(id); } @@ -60,9 +84,12 @@ function move(enabledIds: EnabledIds, id: string, delta: number): EnabledIds { } function getSortedIds(enabledIds: EnabledIds, allIds: string[]): string[] { - if (enabledIds === null) return allIds; + const sortedAllIds = [...allIds].sort(compareModelIds); + if (enabledIds === null) return sortedAllIds; const enabledSet = new Set(enabledIds); - return [...enabledIds, ...allIds.filter((id) => !enabledSet.has(id))]; + // Preserve the explicit enabled order because Alt+Up/Alt+Down controls it; + // sort the remaining catalog so newly discovered models are predictable. + return [...enabledIds, ...sortedAllIds.filter((id) => !enabledSet.has(id))]; } interface ModelItem { diff --git a/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts b/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts index c44e1b36f3e..dc6b3f21c9a 100644 --- a/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts +++ b/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts @@ -62,6 +62,45 @@ describe("issue #3217 scoped model ordering", () => { expect(changes).toEqual([[orderedIds[1], orderedIds[0], orderedIds[2]]]); }); + it("sorts the unscoped catalog naturally while preserving enabled order", async () => { + const harness = await createHarness({ + models: [ + { id: "gpt-5.6-luna@1m", name: "Luna 1M" }, + { id: "gpt-5.6-luna@200k", name: "Luna 200K" }, + { id: "gpt-5.6-luna", name: "Luna" }, + { id: "gpt-5.6-sol@272k", name: "Sol 272K" }, + { id: "gpt-5.5", name: "GPT-5.5" }, + ], + }); + harnesses.push(harness); + + const provider = harness.models[0]!.provider; + const selector = new ScopedModelsSelectorComponent( + { + allModels: [...harness.models], + enabledModelIds: [`${provider}/gpt-5.6-luna`], + }, + { + onChange: () => {}, + onPersist: () => {}, + onCancel: () => {}, + }, + ); + + const renderedIds = stripAnsi(selector.render(120).join("\\n")) + .split("\\n") + .filter((line) => line.includes(`[${provider}]`)) + .map((line) => line.trim().replace(/^→\s*/, "").split(" [")[0]); + + expect(renderedIds).toEqual([ + "gpt-5.6-luna", + "gpt-5.5", + "gpt-5.6-luna@200k", + "gpt-5.6-luna@1m", + "gpt-5.6-sol@272k", + ]); + }); + it("preserves scoped model order in the /model scoped tab", async () => { const harness = await createHarness({ models: [ From a71deff78c26b8c6df77a2e9e319bab629aa696c Mon Sep 17 00:00:00 2001 From: Matthew Morrow Date: Wed, 5 Aug 2026 17:26:53 -0500 Subject: [PATCH 2/2] fix(coding-agent): sort all model selector variants --- .../interactive/components/model-selector.ts | 7 ++-- .../components/scoped-models-selector.ts | 26 +------------ .../src/modes/interactive/model-search.ts | 22 +++++++++++ .../coding-agent/test/model-selector.test.ts | 37 +++++++++++++++++++ 4 files changed, 64 insertions(+), 28 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/model-selector.ts b/packages/coding-agent/src/modes/interactive/components/model-selector.ts index 6f2668a491b..a32c1304058 100644 --- a/packages/coding-agent/src/modes/interactive/components/model-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/model-selector.ts @@ -11,7 +11,7 @@ import { } from "@earendil-works/pi-tui"; import type { ModelRuntime } from "../../../core/model-runtime.ts"; import type { SettingsManager } from "../../../core/settings-manager.ts"; -import { getModelSelectorSearchText } from "../model-search.ts"; +import { compareModelIds, getModelSelectorSearchText } from "../model-search.ts"; import { theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; import { keyHint } from "./keybinding-hints.ts"; @@ -208,13 +208,14 @@ export class ModelSelectorComponent extends Container implements Focusable { private sortModels(models: ModelItem[]): ModelItem[] { const sorted = [...models]; - // Sort: current model first, then by provider + // Sort: current model first, then by provider and model ID. sorted.sort((a, b) => { const aIsCurrent = modelsAreEqual(this.currentModel, a.model); const bIsCurrent = modelsAreEqual(this.currentModel, b.model); if (aIsCurrent && !bIsCurrent) return -1; if (!aIsCurrent && bIsCurrent) return 1; - return a.provider.localeCompare(b.provider); + const providerOrder = a.provider.localeCompare(b.provider); + return providerOrder !== 0 ? providerOrder : compareModelIds(`${a.provider}/${a.id}`, `${b.provider}/${b.id}`); }); return sorted; } diff --git a/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts b/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts index d1a1090d004..af9d87af7ea 100644 --- a/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts @@ -10,7 +10,7 @@ import { Spacer, Text, } from "@earendil-works/pi-tui"; -import { getModelSearchText } from "../model-search.ts"; +import { compareModelIds, getModelSearchText } from "../model-search.ts"; import { theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; import { keyText } from "./keybinding-hints.ts"; @@ -18,30 +18,6 @@ import { keyText } from "./keybinding-hints.ts"; // EnabledIds: null = all enabled (no filter), string[] = explicit ordered list type EnabledIds = string[] | null; -const MODEL_ID_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); -const CONTEXT_ALIAS_PATTERN = /^(.*)@(\d+(?:\.\d+)?)([km])$/i; - -function parseContextAlias(id: string): { base: string; tokens: number } | undefined { - const match = id.match(CONTEXT_ALIAS_PATTERN); - if (!match) return undefined; - const value = Number(match[2]); - if (!Number.isFinite(value)) return undefined; - return { base: match[1]!, tokens: value * (match[3]!.toLowerCase() === "m" ? 1_000_000 : 1_000) }; -} - -function compareModelIds(left: string, right: string): number { - const leftAlias = parseContextAlias(left); - const rightAlias = parseContextAlias(right); - const baseOrder = MODEL_ID_COLLATOR.compare(leftAlias?.base ?? left, rightAlias?.base ?? right); - if (baseOrder !== 0) return baseOrder; - - // Keep a canonical model before its context aliases, then order aliases by - // their numeric size so @200k appears before @1m. - if (!leftAlias) return rightAlias ? -1 : MODEL_ID_COLLATOR.compare(left, right); - if (!rightAlias) return 1; - return leftAlias.tokens - rightAlias.tokens || MODEL_ID_COLLATOR.compare(left, right); -} - function isEnabled(enabledIds: EnabledIds, id: string): boolean { return enabledIds === null || enabledIds.includes(id); } diff --git a/packages/coding-agent/src/modes/interactive/model-search.ts b/packages/coding-agent/src/modes/interactive/model-search.ts index bab9c5a5b43..d2752246b25 100644 --- a/packages/coding-agent/src/modes/interactive/model-search.ts +++ b/packages/coding-agent/src/modes/interactive/model-search.ts @@ -4,6 +4,28 @@ export interface ModelSearchItem { name?: string; } +const MODEL_ID_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); +const CONTEXT_ALIAS_PATTERN = /^(.*)@(\d+(?:\.\d+)?)([km])$/i; + +function parseContextAlias(id: string): { base: string; tokens: number } | undefined { + const match = id.match(CONTEXT_ALIAS_PATTERN); + if (!match) return undefined; + const value = Number(match[2]); + if (!Number.isFinite(value)) return undefined; + return { base: match[1]!, tokens: value * (match[3]!.toLowerCase() === "m" ? 1_000_000 : 1_000) }; +} + +/** Sort model IDs naturally, keeping a base model before numeric context aliases. */ +export function compareModelIds(left: string, right: string): number { + const leftAlias = parseContextAlias(left); + const rightAlias = parseContextAlias(right); + const baseOrder = MODEL_ID_COLLATOR.compare(leftAlias?.base ?? left, rightAlias?.base ?? right); + if (baseOrder !== 0) return baseOrder; + if (!leftAlias) return rightAlias ? -1 : MODEL_ID_COLLATOR.compare(left, right); + if (!rightAlias) return 1; + return leftAlias.tokens - rightAlias.tokens || MODEL_ID_COLLATOR.compare(left, right); +} + export function getModelSearchText(item: ModelSearchItem): string { const { id, provider } = item; const name = item.name ? ` ${item.name}` : ""; diff --git a/packages/coding-agent/test/model-selector.test.ts b/packages/coding-agent/test/model-selector.test.ts index 060386ee708..9cd822c753a 100644 --- a/packages/coding-agent/test/model-selector.test.ts +++ b/packages/coding-agent/test/model-selector.test.ts @@ -21,6 +21,43 @@ describe("model selector", () => { harness = undefined; }); + it("sorts all-model variants naturally", async () => { + harness = await createHarness({ + models: [ + { id: "gpt-5.6-luna@1m", name: "Luna 1M" }, + { id: "gpt-5.6-luna@200k", name: "Luna 200K" }, + { id: "gpt-5.6-luna", name: "Luna" }, + { id: "gpt-5.6-sol@272k", name: "Sol 272K" }, + { id: "gpt-5.5", name: "GPT-5.5" }, + ], + }); + + const selector = new ModelSelectorComponent( + createFakeTui(), + undefined, + harness.settingsManager, + harness.session.modelRuntime, + [], + () => {}, + () => {}, + ); + + const provider = harness.models[0]!.provider; + const renderedIds = stripAnsi(selector.render(120).join("\\n")) + .split("\\n") + .filter((line) => line.includes(`[${provider}]`)) + .map((line) => line.trim().replace(/^→\s*/, "").split(" [")[0]); + + expect(renderedIds).toEqual([ + "gpt-5.5", + "gpt-5.6-luna", + "gpt-5.6-luna@200k", + "gpt-5.6-luna@1m", + "gpt-5.6-sol@272k", + ]); + selector.dispose(); + }); + it("lists every catalog that failed to refresh", async () => { harness = await createHarness(); vi.spyOn(harness.session.modelRuntime, "refresh").mockResolvedValue({