pi-mono/packages/coding-agent/test/model-selector.test.ts
Petr Houška 3bae85274a fix(tui,coding-agent): handle selection page keys in all scrolling lists
tui.select.pageUp/pageDown were configurable but ignored by SelectList,
SettingsList, editor autocomplete, and most coding-agent selectors. In
fullscreen mode, the default PageUp/PageDown also scrolled the transcript
instead of reaching the focused list.

- add getSelectPageIndex() and handle page keys in SelectList, SettingsList,
  editor autocomplete, and the model, scoped models, auth, user message,
  settings submenu, llama search, and /mcp selectors
- add Component.capturesViewportInput: a focused component that sets it
  receives fullscreen viewport keys instead of the transcript, as a focused
  overlay already does
- let page keys move an open autocomplete menu before app actions, so a
  remapped Ctrl+V pages the menu instead of pasting an image

closes #7629

Co-authored-by: muyiyr <940955635@qq.com>
2026-10-28 19:05:21 +01:00

156 lines
4.7 KiB
TypeScript

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.");
});
});
});