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 e7dfc271496..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"; @@ -60,9 +60,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/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({ 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: [