mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
5e106ab1ca
commit
606af8662e
8 changed files with 101 additions and 9 deletions
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue