diff --git a/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts b/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts index dae4533679f..b717dfeedc7 100644 --- a/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts @@ -3,9 +3,13 @@ import { providerSortKey, isFree, buildTriggerLabel, + filterModels, + WordBoundaryFzf, + buildMatchSegments, KILO_GATEWAY_ID, PROVIDER_ORDER, } from "../../webview-ui/src/components/shared/model-selector-utils" +import type { EnrichedModel } from "../../webview-ui/src/context/provider" const labels = { select: "Select model", noProviders: "No providers", notSet: "Not set" } @@ -103,3 +107,133 @@ describe("buildTriggerLabel", () => { expect(buildTriggerLabel(undefined, raw, false, "", true, labels)).toBe("Select model") }) }) + +describe("WordBoundaryFzf", () => { + const items = ["Claude Sonnet", "gpt-5", "OpenAI o3", "Gemini Flash"] + const finder = new WordBoundaryFzf(items, (item) => item) + + it("returns all items with empty positions for empty query", () => { + const result = finder.find("") + expect(result).toHaveLength(items.length) + expect(result.every((entry) => entry.positions.size === 0)).toBe(true) + }) + + it("matches acronym across words and captures matching positions", () => { + const result = finder.find("clso") + expect(result).toHaveLength(1) + expect(result[0].item).toBe("Claude Sonnet") + expect(Array.from(result[0].positions).sort((a, b) => a - b)).toEqual([0, 1, 7, 8]) + }) + + it("supports punctuation in the query by splitting at boundaries", () => { + const result = finder.find("gpt-") + expect(result).toHaveLength(1) + expect(result[0].item).toBe("gpt-5") + }) + + it("does not treat separator-only queries as empty search", () => { + const result = finder.find("-") + expect(result).toHaveLength(0) + }) + + it("requires matches to start at word boundaries", () => { + const result = finder.find("aude") + expect(result).toHaveLength(0) + }) + + it("requires all words in multi-word queries to match", () => { + const result = finder.find("claude flash") + expect(result).toHaveLength(0) + }) + + it("does not allow multi-word queries to reuse the same token", () => { + const result = finder.find("so so") + expect(result).toHaveLength(0) + }) + + it("allows repeated multi-word queries when distinct tokens exist", () => { + const repeatedFinder = new WordBoundaryFzf(["So So"], (item) => item) + const result = repeatedFinder.find("so so") + expect(result).toHaveLength(1) + expect(result[0].item).toBe("So So") + }) + + it("requires multi-word query terms to appear in token order (documented behavior)", () => { + const result = finder.find("sonnet claude") + expect(result).toHaveLength(0) + }) +}) + +describe("filterModels", () => { + const model = (value: Partial) => + ({ + id: "model", + name: "Model", + providerID: "provider", + providerName: "Provider", + ...value, + }) as EnrichedModel + + const items = [ + model({ providerID: "openai", providerName: "OpenAI", id: "gpt-5", name: "GPT Five" }), + model({ providerID: "anthropic", providerName: "Anthropic", id: "claude-3-5-sonnet", name: "Claude Sonnet" }), + model({ providerID: "google", providerName: "Google", id: "gemini-2.5-pro", name: "Gemini Flash" }), + ] + + it("returns all models unchanged for empty query", () => { + const result = filterModels(items, "") + expect(result.models).toBe(items) + expect(result.positions.size).toBe(0) + }) + + it("adds matchingPositions for name matches", () => { + const result = filterModels(items, "clso") + expect(result.models).toHaveLength(1) + expect(result.models[0].id).toBe("claude-3-5-sonnet") + expect(Array.from(result.positions.get("anthropic/claude-3-5-sonnet") ?? new Set()).sort((a, b) => a - b)).toEqual([ + 0, + 1, + 7, + 8, + ]) + }) + + it("uses fallback providerName and id matches without matchingPositions", () => { + const byProvider = filterModels(items, "google") + expect(byProvider.models).toHaveLength(1) + expect(byProvider.models[0].id).toBe("gemini-2.5-pro") + expect(byProvider.positions.has("google/gemini-2.5-pro")).toBe(false) + + const byID = filterModels(items, "3-5") + expect(byID.models).toHaveLength(1) + expect(byID.models[0].id).toBe("claude-3-5-sonnet") + expect(byID.positions.has("anthropic/claude-3-5-sonnet")).toBe(false) + }) + + it("excludes models that match neither name nor provider nor id", () => { + const result = filterModels(items, "zzzz") + expect(result.models).toHaveLength(0) + expect(result.positions.size).toBe(0) + }) + + it("preserves input ordering", () => { + const result = filterModels(items, "5") + expect(result.models.map((item) => item.id)).toEqual(["gpt-5", "claude-3-5-sonnet", "gemini-2.5-pro"]) + }) +}) + +describe("buildMatchSegments", () => { + it("returns a single non-highlighted segment without matches", () => { + expect(buildMatchSegments("Claude Sonnet")).toEqual([{ text: "Claude Sonnet", highlight: false }]) + }) + + it("splits contiguous highlight groups into segments", () => { + const segments = buildMatchSegments("Claude Sonnet", new Set([0, 1, 7, 8])) + expect(segments).toEqual([ + { text: "Cl", highlight: true }, + { text: "aude ", highlight: false }, + { text: "So", highlight: true }, + { text: "nnet", highlight: false }, + ]) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/MultiModelSelector.tsx b/packages/kilo-vscode/webview-ui/agent-manager/MultiModelSelector.tsx index e497771b9fd..7b8b39695fc 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/MultiModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/MultiModelSelector.tsx @@ -3,7 +3,14 @@ import { Icon } from "@kilocode/kilo-ui/icon" import { useProvider } from "../src/context/provider" import type { EnrichedModel } from "../src/context/provider" import { useLanguage } from "../src/context/language" -import { KILO_GATEWAY_ID, providerSortKey } from "../src/components/shared/model-selector-utils" +import { + KILO_GATEWAY_ID, + providerSortKey, + filterModels, + modelKey, + WordBoundaryFzf, + buildMatchSegments, +} from "../src/components/shared/model-selector-utils" import { type ModelAllocations, MAX_MULTI_VERSIONS, @@ -38,18 +45,13 @@ export const MultiModelSelector: Component<{ return models().filter((m) => m.providerID === KILO_GATEWAY_ID || c.includes(m.providerID)) }) - const filtered = createMemo(() => { - const q = search().toLowerCase() - if (!q) return visibleModels() - return visibleModels().filter( - (m) => - m.name.toLowerCase().includes(q) || m.providerName.toLowerCase().includes(q) || m.id.toLowerCase().includes(q), - ) - }) + const finder = createMemo(() => new WordBoundaryFzf(visibleModels(), (model) => model.name)) + + const filtered = createMemo(() => filterModels(visibleModels(), search(), finder())) const groups = createMemo(() => { const map = new Map() - for (const m of filtered()) { + for (const m of filtered().models) { let group = map.get(m.providerID) if (!group) { group = { providerName: m.providerName, models: [] } @@ -106,7 +108,13 @@ export const MultiModelSelector: Component<{ props.onChange(toggleModel(props.allocations, model.providerID, model.id, model.name)) } /> - {model.name} + + + {(segment) => ( + {segment.text} + )} + +