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
This commit is contained in:
Petr Houška 2026-10-23 21:10:01 +02:00
parent 5e106ab1ca
commit 606af8662e
8 changed files with 101 additions and 9 deletions

View file

@ -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));
}

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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();

View file

@ -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 },

View file

@ -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]);

View file

@ -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", () => {