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: [