Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down