Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions packages/zcode-tui/src/choice-dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,12 +194,13 @@ class ChoiceDialog implements Component {
private readonly title: string,
private readonly prompt: string,
private readonly help: string,
private readonly list: SelectList,
private list: SelectList,
private readonly theme: ZCodeTheme,
private readonly content?: Component,
private readonly contentLabel = "Details",
private readonly maxContentLines = 0,
private readonly maxExpandedContentLines = 0
private readonly maxExpandedContentLines = 0,
private readonly filterList?: (filter: string) => SelectList
) {}

setSelectionPreview(preview: Component | undefined): void {
Expand Down Expand Up @@ -352,7 +353,15 @@ class ChoiceDialog implements Component {

private updateFilter(filter: string): void {
this.filter = filter;
this.list.setFilter(filter);
if (this.filterList) {
const next = this.filterList(filter);
next.onSelect = this.list.onSelect;
next.onCancel = this.list.onCancel;
next.onSelectionChange = this.list.onSelectionChange;
this.list = next;
} else {
this.list.setFilter(filter);
}
const selected = this.list.getSelectedItem();
if (selected) this.list.onSelectionChange?.(selected);
else this.setSelectionPreview(undefined);
Expand Down Expand Up @@ -426,6 +435,7 @@ export function choose(
showSelectedItemDetails?: boolean;
/** Enable 1-9 label hints and quick-select. Only lists with at most 9 items support it. */
numberShortcuts?: boolean;
filterMode?: "prefix" | "substring";
}
): Promise<ChoiceItem | null> {
if (options.items.length === 0) return Promise.resolve(null);
Expand Down Expand Up @@ -490,7 +500,15 @@ export function choose(
options.content,
sanitizeTerminalText(options.contentLabel ?? "Details", { preserveSgr: false }),
maxContentLines,
maxExpandedContentLines
maxExpandedContentLines,
options.filterMode === "substring" ? (filter) => {
const query = filter.toLowerCase();
const matches = searchableItems.filter((item) => (
item.label.toLowerCase().includes(query)
|| item.description?.toLowerCase().includes(query)
));
return new SelectList(matches, maxVisible, theme.select);
} : undefined
);
dialog.numberShortcutCount = numberShortcutsEnabled ? options.items.length : 0;
const previewFor = (item: SelectItem | null): Component | undefined => {
Expand Down
2 changes: 2 additions & 0 deletions packages/zcode-tui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3988,6 +3988,7 @@ class ZCodeTui {
: `Current model: ${this.model}. · session only — saved defaults are unchanged`,
help: "Up/Down choose · Enter switch · Esc cancel",
items: picker.items.map((item) => ({ ...item, payload: item.value })),
filterMode: "substring",
selectedIndex: picker.selectedIndex
});
const modelId = selected?.payload;
Expand Down Expand Up @@ -4047,6 +4048,7 @@ class ZCodeTui {
prompt: "Select the model used for new sessions.",
help: "Up/Down choose · Enter save · Esc back to settings",
items: picker.items,
filterMode: "substring",
selectedIndex: picker.selectedIndex
});
if (!choice) return;
Expand Down
38 changes: 38 additions & 0 deletions test/choice-dialog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,44 @@ import { choose, promptText } from "../packages/zcode-tui/src/choice-dialog.ts";
import { createTheme } from "../packages/zcode-tui/src/theme.ts";

describe("TUI choice dialog", () => {
test("finds model names and aliases inside provider-qualified IDs", async () => {
const host = new Container();
let focused: Component | null = null;
const ui = {
terminal: { rows: 24 }, requestRender() {},
setFocus(component: Component | null) { focused = component; }
} as unknown as TUI;
const pending = choose(ui, host, createTheme(false), {
title: "Select model", prompt: "Choose a model", filterMode: "substring",
items: [
{ value: "account:zai-individual-coding-plan/GLM-5.3", label: "account:zai-individual-coding-plan/GLM-5.3" },
{ value: "opencode-go/glm-5.3", label: "opencode-go/glm-5.3", description: "Fast coding alias", payload: "original-model" },
{ value: "command-code/kimi-k3", label: "command-code/kimi-k3" }
]
});
const input = (text: string) => focused?.handleInput?.(text);
const output = () => host.render(140).join("\n");
for (const query of ["glm", "GLM"]) {
input(query);
expect(output()).toContain("account:zai-individual-coding-plan/GLM-5.3");
expect(output()).toContain("opencode-go/glm-5.3");
expect(output()).not.toContain("command-code/kimi-k3");
input("\x15");
}
input("coding alias");
expect(output()).toContain("opencode-go/glm-5.3");
expect(output()).not.toContain("account:zai-individual-coding-plan/GLM-5.3");
input("\x15");
input("no-such-model");
expect(output()).not.toContain("opencode-go/glm-5.3");
input("\x15");
expect(output()).toContain("command-code/kimi-k3");
input("glm");
input("\x1b[B");
input("\r");
expect(await pending).toMatchObject({ value: "opencode-go/glm-5.3", payload: "original-model" });
});

test("renders fullscreen dialogs as isolated bottom panes", async () => {
const host = new Container();
const focusState: { current: Component | null } = { current: null };
Expand Down
27 changes: 18 additions & 9 deletions test/fixtures/tui-model-resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,36 +7,45 @@ import { join } from "node:path";
let resumed = false;
const defaultModel = "scenario/glm-5.3";
const statePath = join(process.cwd(), ".model-resume-state.json");
const defaultModelPath = join(process.cwd(), ".default-model-state.json");

async function selectedModel(): Promise<string> {
async function readModel(path: string): Promise<string> {
try {
const state = JSON.parse(await readFile(statePath, "utf8")) as { model?: unknown };
const state = JSON.parse(await readFile(path, "utf8")) as { model?: unknown };
return typeof state.model === "string" && state.model.trim() ? state.model : defaultModel;
} catch {
return defaultModel;
}
}

async function saveSessionModel(model: string): Promise<{ model: string }> {
await writeFile(statePath, JSON.stringify({ model }), "utf8");
return { model };
}

await runTui({
initialModel: defaultModel,
initialModel: await readModel(defaultModelPath),
modelOptions: [
{ id: "scenario/kimi-k3", name: "Kimi K3" },
{ alias: "main", id: "scenario/glm-5.3", name: "GLM-5.3" },
{ id: "scenario/glm-5.3-flash", name: "GLM-5.3-Flash" }
],
loadSessionTranscript: async () => [],
readSessionModel: async () => resumed ? { model: await selectedModel() } : undefined,
setTransientModel: async (modelId) => {
await writeFile(statePath, JSON.stringify({ model: modelId }), "utf8");
return { model: modelId };
readSessionModel: async () => resumed ? { model: await readModel(statePath) } : undefined,
setTransientModel: saveSessionModel,
readDefaultModel: () => readModel(defaultModelPath),
setDefaultModel: async (modelId) => {
await writeFile(defaultModelPath, JSON.stringify({ model: modelId }), "utf8");
return await saveSessionModel(modelId);
},
submitPrompt: async (input) => input === "/resume fixture-session" && (resumed = true)
? {
model: defaultModel,
model: await readModel(defaultModelPath),
resetSessionProjection: true,
restoredMessages: [{
messageId: "resumed_assistant",
role: "agent",
model: await selectedModel(),
model: await readModel(statePath),
content: "Restored response from the selected session model."
}],
response: "Resumed session fixture-session."
Expand Down
32 changes: 31 additions & 1 deletion test/tui/model-resume.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,38 @@
import { test } from "bun:test";
import { expect, test } from "bun:test";

import { runAutomatedTuiScenario } from "./harness/run-scenario.ts";
import { ScenarioWorkspace } from "./harness/scenario-workspace.ts";
import { TerminalSession } from "./harness/terminal-session.ts";
import { modelResumeScenario } from "./scenarios/model-resume.ts";

test("TUI restores the last model selected in a resumed session", async () => {
await runAutomatedTuiScenario(modelResumeScenario);
});

test.each(["glm", "GLM"])("TUI saves the default model after searching for %s and uses it in a new session", async (query) => {
await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-default-model-" });
const command = [process.execPath, modelResumeScenario.fixture];
await using session = TerminalSession.start({ command, workspace });
await session.waitForScreen("initial default model", /◈ scenario\/glm-5\.3(?!-flash)/u);
await session.sendAndWait("/settings\r", "open settings", /ZCode settings/u);
await session.sendAndWait("\r", "open default model picker", /Default model/u);
await session.waitForScreen("unfiltered model", /scenario\/kimi-k3/u);
await session.sendAndWait(query, "filter default models", new RegExp(`Filter: ${query}`, "u"));
await session.waitForScreen("matching provider-qualified model", /scenario\/glm-5\.3-flash/u);
await session.assertScreenExcludes("nonmatching model", /scenario\/kimi-k3/u);
await session.sendAndWait(
"\x1b[B\r",
"save filtered default model",
/Default model saved: scenario\/glm-5\.3-flash/u
);
expect(JSON.parse(await workspace.read(".default-model-state.json"))).toEqual({
model: "scenario/glm-5.3-flash"
});
await session.waitForScreen("settings show saved default", /Saved: scenario\/glm-5\.3-flash/u);
await session.sendAndWait("\x1b", "close settings", /◈ scenario\/glm-5\.3-flash/u);
await session.exit();

await using freshSession = TerminalSession.start({ command, workspace });
await freshSession.waitForScreen("new session uses saved default", /◈ scenario\/glm-5\.3-flash/u);
await freshSession.exit();
}, 20_000);
5 changes: 4 additions & 1 deletion test/tui/scenarios/model-resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ export const modelResumeScenario: TuiScenario = {
fixture: join(import.meta.dir, "..", "..", "fixtures", "tui-model-resume.ts"),
async run(session, workspace) {
await session.waitForScreen("initial default model", /◈ scenario\/glm-5\.3(?!-flash)/iu);
await session.sendAndWait("/model\r", "open model picker", /Select model/iu);
await session.sendAndWait("GLM", "filter by model name", /Filter: GLM/u);
await session.waitForScreen("matching provider-qualified model", /scenario\/glm-5\.3-flash/iu);
await session.sendAndWait(
"/model scenario/glm-5.3-flash\r",
"\x1b[B\r",
"switch to flash model",
/Session model now: scenario\/glm-5\.3-flash/iu
);
Expand Down
Loading