import { setKeybindings, type TUI } from "@earendil-works/pi-tui"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { KeybindingsManager } from "../src/core/keybindings.ts"; import { ModelSelectorComponent } from "../src/modes/interactive/components/model-selector.ts"; import { initTheme } from "../src/modes/interactive/theme/theme.ts"; import { stripAnsi } from "../src/utils/ansi.ts"; import { createHarness, type Harness } from "./suite/harness.ts"; function createFakeTui(): TUI { return { requestRender: () => {} } as unknown as TUI; } describe("model selector", () => { let harness: Harness | undefined; beforeAll(() => { initTheme("dark"); }); beforeEach(() => { setKeybindings(new KeybindingsManager()); }); afterEach(() => { harness?.cleanup(); harness = undefined; }); it("keeps the current model marked while browsing", async () => { harness = await createHarness({ models: [ { id: "current-model", name: "Current Model", reasoning: true }, { id: "browsed-model", name: "Browsed Model", reasoning: true }, ], }); const currentModel = harness.getModel("current-model")!; const selector = new ModelSelectorComponent( createFakeTui(), currentModel, harness.session.modelRuntime, [], () => {}, () => {}, ); const getModelRow = (id: string): string | undefined => stripAnsi(selector.render(120).join("\n")) .split("\n") .find((line) => line.includes(`${id} [`)) ?.trimEnd(); expect(getModelRow("current-model")).toBe(`→ ✓ current-model [${currentModel.provider}]`); selector.handleInput("\x1b[B"); expect(getModelRow("current-model")).toBe(` ✓ current-model [${currentModel.provider}]`); expect(getModelRow("browsed-model")).toBe(`→ browsed-model [${currentModel.provider}]`); 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("moves by one page with remapped selection page keys (#7629)", async () => { setKeybindings(new KeybindingsManager({ "tui.select.pageUp": "alt+v", "tui.select.pageDown": "ctrl+v" })); harness = await createHarness({ models: Array.from({ length: 12 }, (_, index) => ({ id: `model-${String(index).padStart(2, "0")}` })), }); const onSelect = vi.fn(); const selector = new ModelSelectorComponent( createFakeTui(), undefined, harness.session.modelRuntime, [], onSelect, () => {}, ); selector.handleInput("\x16"); // Ctrl+V selector.handleInput("\x16"); selector.handleInput("\x1bv"); // Alt+V selector.handleInput("\r"); expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: "model-01" })); selector.dispose(); }); it("uses the configured save binding", async () => { setKeybindings(new KeybindingsManager({ "app.models.save": "ctrl+r" })); harness = await createHarness(); const currentModel = harness.getModel()!; const saveDefault = vi.fn(); const selector = new ModelSelectorComponent( createFakeTui(), currentModel, harness.session.modelRuntime, [], () => {}, () => {}, undefined, saveDefault, ); expect(stripAnsi(selector.render(120).join("\n"))).toContain("Ctrl+R to set as default"); selector.handleInput("\x13"); expect(saveDefault).not.toHaveBeenCalled(); selector.handleInput("\x12"); expect(saveDefault).toHaveBeenCalledWith(currentModel); }); it("lists every catalog that failed to refresh", async () => { harness = await createHarness(); vi.spyOn(harness.session.modelRuntime, "refresh").mockResolvedValue({ aborted: false, errors: new Map([ ["openai", new Error("unavailable")], ["anthropic", new Error("unavailable")], ]), }); const selector = new ModelSelectorComponent( createFakeTui(), harness.getModel(), harness.session.modelRuntime, [], () => {}, () => {}, ); await vi.waitFor(() => { const rendered = stripAnsi(selector.render(120).join("\n")); expect(rendered).toContain("Could not refresh 2 model catalogs (openai, anthropic); showing cached models."); }); }); });