From d8a68b24139c54645e5d77cf2dda6dbfe8d21667 Mon Sep 17 00:00:00 2001 From: Kevin Gatera Date: Sun, 4 Oct 2026 20:11:56 -0400 Subject: [PATCH 1/2] fix: match model names within provider-qualified IDs --- packages/zcode-tui/src/choice-dialog.ts | 26 ++++++++++++++--- packages/zcode-tui/src/index.ts | 2 ++ test/choice-dialog.test.ts | 38 +++++++++++++++++++++++++ test/tui/scenarios/model-resume.ts | 5 +++- 4 files changed, 66 insertions(+), 5 deletions(-) diff --git a/packages/zcode-tui/src/choice-dialog.ts b/packages/zcode-tui/src/choice-dialog.ts index f126c064..e7139818 100644 --- a/packages/zcode-tui/src/choice-dialog.ts +++ b/packages/zcode-tui/src/choice-dialog.ts @@ -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 { @@ -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); @@ -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 { if (options.items.length === 0) return Promise.resolve(null); @@ -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 => { diff --git a/packages/zcode-tui/src/index.ts b/packages/zcode-tui/src/index.ts index 63cf8231..8727fb8b 100644 --- a/packages/zcode-tui/src/index.ts +++ b/packages/zcode-tui/src/index.ts @@ -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; @@ -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; diff --git a/test/choice-dialog.test.ts b/test/choice-dialog.test.ts index caa46986..d4345aee 100644 --- a/test/choice-dialog.test.ts +++ b/test/choice-dialog.test.ts @@ -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 }; diff --git a/test/tui/scenarios/model-resume.ts b/test/tui/scenarios/model-resume.ts index beff816e..568b8697 100644 --- a/test/tui/scenarios/model-resume.ts +++ b/test/tui/scenarios/model-resume.ts @@ -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 ); From 5916be2a4d4284b40206b1b4a734aeec015a87ae Mon Sep 17 00:00:00 2001 From: Kingsword Date: Tue, 6 Oct 2026 11:11:53 +0800 Subject: [PATCH 2/2] test(tui): cover default model search and persistence --- test/fixtures/tui-model-resume.ts | 27 +++++++++++++++++--------- test/tui/model-resume.test.ts | 32 ++++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/test/fixtures/tui-model-resume.ts b/test/fixtures/tui-model-resume.ts index f5be7bf8..46dd110f 100644 --- a/test/fixtures/tui-model-resume.ts +++ b/test/fixtures/tui-model-resume.ts @@ -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 { +async function readModel(path: string): Promise { 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." diff --git a/test/tui/model-resume.test.ts b/test/tui/model-resume.test.ts index 88a9bdbb..66cbeed9 100644 --- a/test/tui/model-resume.test.ts +++ b/test/tui/model-resume.test.ts @@ -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);