Configure: improve searchable model picker token matching

This commit is contained in:
Benjamin Jesuiter
2026-02-17 08:40:10 +01:00
parent 01fcac0726
commit daef91800c
2 changed files with 78 additions and 6 deletions

View File

@@ -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);
});
});

View File

@@ -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<T>(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<T>(option: Option<T>): string {
const label = stripAnsi(option.label ?? "");
const hint = stripAnsi(option.hint ?? "");
const value = String(option.value ?? "");
return `${label} ${hint} ${value}`.toLowerCase();
}
export function tokenizedOptionFilter<T>(search: string, option: Option<T>): 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,
}),
);