From 625f4f65c7ff8624ac1782c848456a3ffdaca4f4 Mon Sep 17 00:00:00 2001 From: Bernardo Ferrari Date: Fri, 27 Feb 2026 02:51:42 -0300 Subject: [PATCH 1/5] improve model search matching and highlight hits in selectors --- .../tests/unit/model-selector-utils.test.ts | 52 ++++++ .../agent-manager/MultiModelSelector.tsx | 56 +++++-- .../agent-manager/agent-manager.css | 5 + .../src/components/shared/ModelSelector.tsx | 66 ++++++-- .../components/shared/model-selector-utils.ts | 148 ++++++++++++++++++ .../webview-ui/src/styles/chat.css | 5 + 6 files changed, 310 insertions(+), 22 deletions(-) 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..677993fa0af 100644 --- a/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts @@ -3,6 +3,8 @@ import { providerSortKey, isFree, buildTriggerLabel, + WordBoundaryFzf, + buildMatchSegments, KILO_GATEWAY_ID, PROVIDER_ORDER, } from "../../webview-ui/src/components/shared/model-selector-utils" @@ -103,3 +105,53 @@ 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("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) + }) +}) + +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..8b3be9fa5aa 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/MultiModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/MultiModelSelector.tsx @@ -3,7 +3,12 @@ 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, + WordBoundaryFzf, + buildMatchSegments, +} from "../src/components/shared/model-selector-utils" import { type ModelAllocations, MAX_MULTI_VERSIONS, @@ -19,7 +24,11 @@ export { MAX_MULTI_VERSIONS, totalAllocations, allocationsToArray } from "./mult interface ModelGroup { providerName: string - models: EnrichedModel[] + models: FilteredModel[] +} + +interface FilteredModel extends EnrichedModel { + matchingPositions?: Set } const COUNT_OPTIONS = Array.from({ length: MAX_MULTI_VERSIONS }, (_, i) => i + 1) @@ -38,13 +47,36 @@ 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 filtered = createMemo(() => { + const query = search().trim() + if (!query) { + return visibleModels().map((model) => ({ ...model, matchingPositions: new Set() })) + } + + const finder = new WordBoundaryFzf(visibleModels(), (model) => model.name) + const nameMatches = finder.find(query) + const nameMatchesByKey = new Map( + nameMatches.map(({ item, positions }) => [`${item.providerID}/${item.id}`, positions] as const), ) + const normalized = query.toLowerCase() + + return visibleModels().reduce((result, model) => { + const key = `${model.providerID}/${model.id}` + const positions = nameMatchesByKey.get(key) + + if (positions) { + result.push({ ...model, matchingPositions: positions }) + return result + } + + const fallbackMatch = + model.providerName.toLowerCase().includes(normalized) || model.id.toLowerCase().includes(normalized) + if (fallbackMatch) { + result.push({ ...model, matchingPositions: new Set() }) + } + + return result + }, []) }) const groups = createMemo(() => { @@ -106,7 +138,13 @@ export const MultiModelSelector: Component<{ props.onChange(toggleModel(props.allocations, model.providerID, model.id, model.name)) } /> - {model.name} + + + {(segment) => ( + {segment.text} + )} + +