diff --git a/src/wizard/clack-prompter.test.ts b/src/wizard/clack-prompter.test.ts new file mode 100644 index 0000000000..2954e924fc --- /dev/null +++ b/src/wizard/clack-prompter.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { tokenizedOptionFilter } from "./clack-prompter.js"; + +describe("tokenizedOptionFilter", () => { + it("matches tokens regardless of order", () => { + const option = { + value: "openai/gpt-5.2", + label: "openai/gpt-5.2", + hint: "ctx 400k", + }; + + expect(tokenizedOptionFilter("gpt-5.2 openai/", option)).toBe(true); + expect(tokenizedOptionFilter("openai/ gpt-5.2", option)).toBe(true); + }); + + it("requires all tokens to match", () => { + const option = { + value: "openai/gpt-5.2", + label: "openai/gpt-5.2", + }; + + expect(tokenizedOptionFilter("gpt-5.2 anthropic/", option)).toBe(false); + }); + + it("matches against label, hint, and value", () => { + const option = { + value: "openai/gpt-5.2", + label: "GPT 5.2", + hint: "provider openai", + }; + + expect(tokenizedOptionFilter("provider openai", option)).toBe(true); + expect(tokenizedOptionFilter("openai gpt-5.2", option)).toBe(true); + }); +}); diff --git a/src/wizard/clack-prompter.ts b/src/wizard/clack-prompter.ts index 7fe691d11d..998ed72e5f 100644 --- a/src/wizard/clack-prompter.ts +++ b/src/wizard/clack-prompter.ts @@ -12,6 +12,7 @@ import { text, } from "@clack/prompts"; import { createCliProgress } from "../cli/progress.js"; +import { stripAnsi } from "../terminal/ansi.js"; import { note as emitNote } from "../terminal/note.js"; import { stylePromptHint, stylePromptMessage, stylePromptTitle } from "../terminal/prompt-style.js"; import { theme } from "../terminal/theme.js"; @@ -26,6 +27,30 @@ function guardCancel(value: T | symbol): T { return value; } +function normalizeSearchTokens(search: string): string[] { + return search + .toLowerCase() + .split(/\s+/) + .map((token) => token.trim()) + .filter((token) => token.length > 0); +} + +function buildOptionSearchText(option: Option): string { + const label = stripAnsi(option.label ?? ""); + const hint = stripAnsi(option.hint ?? ""); + const value = String(option.value ?? ""); + return `${label} ${hint} ${value}`.toLowerCase(); +} + +export function tokenizedOptionFilter(search: string, option: Option): boolean { + const tokens = normalizeSearchTokens(search); + if (tokens.length === 0) { + return true; + } + const haystack = buildOptionSearchText(option); + return tokens.every((token) => haystack.includes(token)); +} + export function createClackPrompter(): WizardPrompter { return { intro: async (title) => { @@ -49,14 +74,26 @@ export function createClackPrompter(): WizardPrompter { }), ), multiselect: async (params) => { - const prompt = params.searchable ? autocompleteMultiselect : multiselect; + const options = params.options.map((opt) => { + const base = { value: opt.value, label: opt.label }; + return opt.hint === undefined ? base : { ...base, hint: stylePromptHint(opt.hint) }; + }) as Option<(typeof params.options)[number]["value"]>[]; + + if (params.searchable) { + return guardCancel( + await autocompleteMultiselect({ + message: stylePromptMessage(params.message), + options, + initialValues: params.initialValues, + filter: tokenizedOptionFilter, + }), + ); + } + return guardCancel( - await prompt({ + await multiselect({ message: stylePromptMessage(params.message), - options: params.options.map((opt) => { - const base = { value: opt.value, label: opt.label }; - return opt.hint === undefined ? base : { ...base, hint: stylePromptHint(opt.hint) }; - }) as Option<(typeof params.options)[number]["value"]>[], + options, initialValues: params.initialValues, }), );