From 606af8662ec4c3f5b9047c0593b4138f3db5c30d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Hou=C5=A1ka?= Date: Fri, 23 Oct 2026 21:10:01 +0200 Subject: [PATCH] feat(coding-agent): show model names in model selectors when they add information /model and /scoped-models listed only `id [provider]`, so renamed overrides from models.json were invisible in the list. Rows now show the name in parentheses when it has words the id lacks, e.g. `minimax/minimax-m3 (MiniMax M3 [parasail]) [openrouter]`. Names that only restate the id with different casing, separators, or a dropped namespace/version suffix stay hidden. closes #5916 --- .../interactive/components/model-selector.ts | 6 +++-- .../components/scoped-models-selector.ts | 6 +++-- .../src/modes/interactive/model-search.ts | 23 ++++++++++++++++ .../coding-agent/test/model-search.test.ts | 27 ++++++++++++++++++- .../coding-agent/test/model-selector.test.ts | 26 ++++++++++++++++++ .../test/scoped-models-selector.test.ts | 15 +++++++++++ .../3217-scoped-model-order.test.ts | 4 +-- ...l-selector-filter-resets-selection.test.ts | 3 +-- 8 files changed, 101 insertions(+), 9 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 c8489c845..d603b8bef 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 { refreshModelCatalogs } from "../model-catalog-refresh.ts"; -import { getModelSelectorSearchText } from "../model-search.ts"; +import { getDistinctModelName, getModelSelectorSearchText } from "../model-search.ts"; import { theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; import { keyDisplayText, keyHint } from "./keybinding-hints.ts"; @@ -325,8 +325,10 @@ export class ModelSelectorComponent extends Container implements Focusable { const cursor = isSelected ? theme.fg("accent", "→ ") : " "; const currentMarker = isCurrent ? theme.fg("accent", "✓ ") : " "; const modelText = isSelected ? theme.fg("accent", item.id) : item.id; + const name = getDistinctModelName({ id: item.id, name: item.model.name }); + const nameText = name ? theme.fg("muted", ` (${name})`) : ""; const providerBadge = theme.fg("muted", `[${item.provider}]`); - const line = `${cursor}${currentMarker}${modelText} ${providerBadge}${defaultBadge}`; + const line = `${cursor}${currentMarker}${modelText}${nameText} ${providerBadge}${defaultBadge}`; this.listContainer.addChild(new Text(line, 0, 0)); } 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 89c486de6..54a6f97cc 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 { getDistinctModelName, getModelSearchText } from "../model-search.ts"; import { theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; import { keyDisplayText } from "./keybinding-hints.ts"; @@ -252,9 +252,11 @@ export class ScopedModelsSelectorComponent extends Container implements Focusabl const id = item.model?.id ?? item.fullId; const styledId = item.model ? id : theme.strikethrough(id); const modelText = isSelected ? theme.fg("accent", styledId) : styledId; + const name = item.model ? getDistinctModelName(item.model) : undefined; + const nameText = name ? theme.fg("muted", ` (${name})`) : ""; const providerBadge = theme.fg("muted", item.model ? ` [${item.model.provider}]` : " [unavailable]"); const status = item.model && item.enabled ? theme.fg("accent", "✓ ") : " "; - this.listContainer.addChild(new Text(`${prefix}${status}${modelText}${providerBadge}`, 0, 0)); + this.listContainer.addChild(new Text(`${prefix}${status}${modelText}${nameText}${providerBadge}`, 0, 0)); } // Add scroll indicator if needed diff --git a/packages/coding-agent/src/modes/interactive/model-search.ts b/packages/coding-agent/src/modes/interactive/model-search.ts index bab9c5a5b..fc183168f 100644 --- a/packages/coding-agent/src/modes/interactive/model-search.ts +++ b/packages/coding-agent/src/modes/interactive/model-search.ts @@ -10,6 +10,29 @@ export function getModelSearchText(item: ModelSearchItem): string { return `${id} ${provider} ${provider}/${id} ${provider} ${id}${name}`; } +const splitModelLabel = (value: string): string[] => + value + .toLowerCase() + .split(/[^\p{L}\p{N}]+/u) + .filter(Boolean); + +/** + * Returns the model name when it has words the ID does not, e.g. a renamed override like + * "MiniMax M3 [rasmus@openrouter, parasail]" for "minimax/minimax-m3". Names that only + * restate the ID with different casing, separators, or a dropped namespace/version suffix + * ("Claude Sonnet 4.5 (EU)" for "eu.anthropic.claude-sonnet-4-5-20250929-v1:0") return undefined. + */ +export function getDistinctModelName(item: { id: string; name?: string }): string | undefined { + const name = item.name?.trim(); + if (!name) return undefined; + const idWords = splitModelLabel(item.id); + const idTokens = new Set(idWords); + const compactId = idWords.join(""); + // Single-character words must match a whole ID token: "4" is not covered by "mistral-small-2603". + const isCovered = (word: string) => idTokens.has(word) || (word.length > 1 && compactId.includes(word)); + return splitModelLabel(name).every(isCovered) ? undefined : name; +} + /** * The /model selector search should rank exact provider-prefixed queries before proxy-provider IDs * like openrouter/openai/gpt-5, so keep the bare model ID out of the leading position. diff --git a/packages/coding-agent/test/model-search.test.ts b/packages/coding-agent/test/model-search.test.ts index 08465dbe7..61b5b0a08 100644 --- a/packages/coding-agent/test/model-search.test.ts +++ b/packages/coding-agent/test/model-search.test.ts @@ -1,6 +1,31 @@ import { fuzzyFilter } from "@earendil-works/pi-tui"; import { describe, expect, it } from "vitest"; -import { getModelSelectorSearchText } from "../src/modes/interactive/model-search.ts"; +import { getDistinctModelName, getModelSelectorSearchText } from "../src/modes/interactive/model-search.ts"; + +// #5916 +describe("getDistinctModelName", () => { + it("hides names that restate the ID", () => { + expect(getDistinctModelName({ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" })).toBeUndefined(); + expect(getDistinctModelName({ id: "openai/gpt-oss-120b", name: "OpenAI: gpt-oss-120b" })).toBeUndefined(); + expect( + getDistinctModelName({ id: "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", name: "Claude Sonnet 4.5 (EU)" }), + ).toBeUndefined(); + expect(getDistinctModelName({ id: "gpt-5", name: "" })).toBeUndefined(); + expect(getDistinctModelName({ id: "gpt-5" })).toBeUndefined(); + }); + + it("shows names with words the ID lacks", () => { + const name = "MiniMax M3 [rasmus@openrouter, parasail]"; + expect(getDistinctModelName({ id: "minimax/minimax-m3", name })).toBe(name); + expect(getDistinctModelName({ id: "gemini-3.1-flash-lite-image", name: "Nano Banana 2 Lite" })).toBe( + "Nano Banana 2 Lite", + ); + }); + + it("requires single-character words to match a whole ID token", () => { + expect(getDistinctModelName({ id: "mistral-small-2603", name: "Mistral Small 4" })).toBe("Mistral Small 4"); + }); +}); describe("model selector search ranking", () => { // #11220 diff --git a/packages/coding-agent/test/model-selector.test.ts b/packages/coding-agent/test/model-selector.test.ts index ac011e309..7ba204ac7 100644 --- a/packages/coding-agent/test/model-selector.test.ts +++ b/packages/coding-agent/test/model-selector.test.ts @@ -56,6 +56,32 @@ describe("model selector", () => { selector.dispose(); }); + // #5916 + it("shows model names that differ from the ID", async () => { + harness = await createHarness({ + models: [ + { id: "minimax/minimax-m3", name: "MiniMax M3 [parasail]" }, + { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, + ], + }); + const provider = harness.getModel("minimax/minimax-m3")!.provider; + const selector = new ModelSelectorComponent( + createFakeTui(), + undefined, + harness.session.modelRuntime, + [], + () => {}, + () => {}, + ); + + const rows = stripAnsi(selector.render(120).join("\n")) + .split("\n") + .map((line) => line.slice(4).trimEnd()); + expect(rows).toContain(`minimax/minimax-m3 (MiniMax M3 [parasail]) [${provider}]`); + expect(rows).toContain(`claude-sonnet-4-5 [${provider}]`); + selector.dispose(); + }); + it("uses the configured save binding", async () => { setKeybindings(new KeybindingsManager({ "app.models.save": "ctrl+r" })); harness = await createHarness(); diff --git a/packages/coding-agent/test/scoped-models-selector.test.ts b/packages/coding-agent/test/scoped-models-selector.test.ts index 80c22cfa5..aac0d370b 100644 --- a/packages/coding-agent/test/scoped-models-selector.test.ts +++ b/packages/coding-agent/test/scoped-models-selector.test.ts @@ -58,6 +58,21 @@ describe("scoped models selector", () => { return selector; } + // #5916 + it("shows model names that differ from the ID", async () => { + const selector = await createSelector([ + { id: "model-a", name: "Model A", enabled: true }, + { id: "model-b", name: "Renamed B", enabled: true }, + ]); + const provider = harness!.models[0].provider; + + const rows = stripAnsi(selector.render(120).join("\n")) + .split("\n") + .map((line) => line.slice(4).trimEnd()); + expect(rows).toContain(`model-a [${provider}]`); + expect(rows).toContain(`model-b (Renamed B) [${provider}]`); + }); + it("marks every model after enabling all", async () => { const models = [ { id: "model-a", name: "Model A", enabled: true }, 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 6553e8fad..4273ff070 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 @@ -94,8 +94,8 @@ describe("issue #3217 scoped model ordering", () => { .split("\n") .filter((line) => line.includes(`[${modelOne.provider}]`)); const orderedIds = renderedLines.slice(0, 3).map((line) => { - const [modelId] = line.trim().replace(/^→\s*/, "").split(" ["); - return modelId?.replace(/^✓\s*/, "").trim() ?? ""; + const [modelId] = line.trim().replace(/^→\s*/, "").replace(/^✓\s*/, "").split(" "); + return modelId ?? ""; }); expect(orderedIds).toEqual([modelTwo.id, modelOne.id, modelThree.id]); diff --git a/packages/coding-agent/test/suite/regressions/7209-model-selector-filter-resets-selection.test.ts b/packages/coding-agent/test/suite/regressions/7209-model-selector-filter-resets-selection.test.ts index 50b49be54..6c86b1d19 100644 --- a/packages/coding-agent/test/suite/regressions/7209-model-selector-filter-resets-selection.test.ts +++ b/packages/coding-agent/test/suite/regressions/7209-model-selector-filter-resets-selection.test.ts @@ -15,8 +15,7 @@ function selectedModelId(rendered: string): string | undefined { const line = rendered.split("\n").find((l) => l.startsWith("→ ")); if (!line) return undefined; const rest = line.replace(/^→\s*/, ""); - const id = rest.split(" [")[0]?.replace(/^✓\s*/, ""); - return id?.trim() || undefined; + return rest.replace(/^✓\s*/, "").split(" ")[0] || undefined; } describe("model selector filter resets selection to top", () => {