From d815b2118d791ebf6b8839da9d108dc3934a5395 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 08:38:34 +0000 Subject: [PATCH 1/4] feat(vscode): add fuzzy search to model selector - Add subsequence fuzzy matching for model search - Support searching by provider name - Normalize spaces to dashes for better matching (e.g., 'gpt 5.4' matches 'gpt-5.4') Fixes #8404 --- .../src/components/shared/ModelSelector.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx index 1ae56f28f2b..cd9dc341383 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx @@ -34,6 +34,16 @@ import { ModelPreview } from "./ModelPreview" // Row / group key helpers — single source of truth for key formatting // --------------------------------------------------------------------------- +function fuzzyMatch(query: string, target: string): boolean { + const q = query.toLowerCase().replace(/\s+/g, "") + const t = target.toLowerCase().replace(/\s+/g, "") + let qi = 0 + for (let ti = 0; ti < t.length && qi < q.length; ti++) { + if (t[ti] === q[qi]) qi++ + } + return qi === q.length +} + const CLEAR_KEY = "clear" const FAVORITES_KEY = "favorites" const RECOMMENDED_KEY = "recommended" @@ -167,11 +177,11 @@ export const ModelSelectorBase: Component = (props) => { // Flat filtered list for keyboard navigation const filtered = createMemo(() => { - const q = debouncedSearch().toLowerCase() + const q = debouncedSearch() if (!q) { return visibleModels() } - return visibleModels().filter((m) => m.name.toLowerCase().includes(q)) + return visibleModels().filter((m) => fuzzyMatch(q, m.name) || fuzzyMatch(q, m.id) || fuzzyMatch(q, m.providerName)) }) // Live set of favorited keys — drives star icon visual state (filled vs outline). From 9dd44993ede1a37f099e906cecfb6b79ae144df7 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 8 Apr 2026 11:05:56 +0200 Subject: [PATCH 2/4] fix(vscode): trim whitespace-only search queries in model selector --- .../webview-ui/src/components/shared/ModelSelector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx index cd9dc341383..4c99925df50 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx @@ -177,7 +177,7 @@ export const ModelSelectorBase: Component = (props) => { // Flat filtered list for keyboard navigation const filtered = createMemo(() => { - const q = debouncedSearch() + const q = debouncedSearch().trim() if (!q) { return visibleModels() } From 4b1e81b113356cf211c3994af8cc1a39c2840bc8 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 8 Apr 2026 11:10:58 +0200 Subject: [PATCH 3/4] fix(vscode): use word-boundary matching instead of fuzzy match in model selector Port the word-boundary matching algorithm from the legacy word-boundary-fzf.ts. This splits text at camelCase transitions and delimiters, so 'clso' matches 'Claude Sonnet' (Cl+So) and multi-word queries like 'gpt 5' require each fragment to match independently. --- .../src/components/shared/ModelSelector.tsx | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx index 4c99925df50..3dc1b2dbbaf 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx @@ -34,14 +34,36 @@ import { ModelPreview } from "./ModelPreview" // Row / group key helpers — single source of truth for key formatting // --------------------------------------------------------------------------- -function fuzzyMatch(query: string, target: string): boolean { - const q = query.toLowerCase().replace(/\s+/g, "") - const t = target.toLowerCase().replace(/\s+/g, "") - let qi = 0 - for (let ti = 0; ti < t.length && qi < q.length; ti++) { - if (t[ti] === q[qi]) qi++ +// Word boundary matching ported from legacy word-boundary-fzf.ts. +// Splits at camelCase transitions and common delimiters so that e.g. +// "clso" matches "Claude Sonnet" (Cl + So), "gpt-5" splits into ["gpt","5"]. +const WORD_BOUNDARY = /(?=[A-Z])|[[\]_.:\s/\\(){}-]+/ + +function acronymMatch(text: string, query: string): boolean { + const words = text + .split(WORD_BOUNDARY) + .filter((w) => w.length > 0) + .map((w) => w.toLowerCase()) + + const attempt = (wi: number, qi: number): boolean => { + if (qi === query.length) return true + if (wi >= words.length) return false + const word = words[wi]! + let consumed = 0 + while (qi + consumed < query.length && consumed < word.length && word[consumed] === query[qi + consumed]) consumed++ + if (consumed > 0 && attempt(wi + 1, qi + consumed)) return true + return attempt(wi + 1, qi) } - return qi === q.length + + return attempt(0, 0) +} + +function searchMatch(query: string, text: string): boolean { + const q = query.toLowerCase().trim() + if (!q) return true + const parts = q.split(WORD_BOUNDARY).filter((w) => w.length > 0) + if (parts.length === 0) return true + return parts.every((p) => acronymMatch(text, p)) } const CLEAR_KEY = "clear" @@ -181,7 +203,9 @@ export const ModelSelectorBase: Component = (props) => { if (!q) { return visibleModels() } - return visibleModels().filter((m) => fuzzyMatch(q, m.name) || fuzzyMatch(q, m.id) || fuzzyMatch(q, m.providerName)) + return visibleModels().filter( + (m) => searchMatch(q, m.name) || searchMatch(q, m.id) || searchMatch(q, m.providerName), + ) }) // Live set of favorited keys — drives star icon visual state (filled vs outline). From d771f29a6069353bfdf1c52ede3513e3b1600c90 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 8 Apr 2026 11:19:32 +0200 Subject: [PATCH 4/4] refactor(vscode): extract search matching into testable utility Move word-boundary matching logic from ModelSelector.tsx into webview-ui/src/utils/search-match.ts with 37 unit tests ported from the legacy word-boundary-fzf.ts test suite. --- .../tests/unit/search-match.test.ts | 200 ++++++++++++++++++ .../src/components/shared/ModelSelector.tsx | 33 +-- .../webview-ui/src/utils/search-match.ts | 59 ++++++ 3 files changed, 260 insertions(+), 32 deletions(-) create mode 100644 packages/kilo-vscode/tests/unit/search-match.test.ts create mode 100644 packages/kilo-vscode/webview-ui/src/utils/search-match.ts diff --git a/packages/kilo-vscode/tests/unit/search-match.test.ts b/packages/kilo-vscode/tests/unit/search-match.test.ts new file mode 100644 index 00000000000..c12cf433abc --- /dev/null +++ b/packages/kilo-vscode/tests/unit/search-match.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect } from "bun:test" +import { acronymMatch, searchMatch } from "../../webview-ui/src/utils/search-match" + +// --------------------------------------------------------------------------- +// acronymMatch — low-level word-boundary matching +// --------------------------------------------------------------------------- + +describe("acronymMatch", () => { + describe("basic word boundary matching", () => { + it("matches at word start", () => { + expect(acronymMatch("fool org", "foo")).toBe(true) + expect(acronymMatch("the fool", "foo")).toBe(true) + }) + + it("does not match arbitrary substrings", () => { + expect(acronymMatch("faoboc", "foo")).toBe(false) + expect(acronymMatch("barfoo", "foo")).toBe(false) + }) + + it("matches prefix of a single word", () => { + expect(acronymMatch("foobar", "foob")).toBe(true) + }) + + it("matches exact word", () => { + expect(acronymMatch("test", "test")).toBe(true) + expect(acronymMatch("testing", "test")).toBe(true) + expect(acronymMatch("the test", "test")).toBe(true) + }) + }) + + describe("word separators", () => { + it("recognizes space", () => { + expect(acronymMatch("hello world", "wor")).toBe(true) + }) + + it("recognizes hyphen", () => { + expect(acronymMatch("hello-world", "wor")).toBe(true) + }) + + it("recognizes underscore", () => { + expect(acronymMatch("hello_world", "wor")).toBe(true) + }) + + it("recognizes slash", () => { + expect(acronymMatch("hello/world", "wor")).toBe(true) + }) + + it("recognizes dot", () => { + expect(acronymMatch("hello.world", "wor")).toBe(true) + }) + + it("recognizes parentheses", () => { + expect(acronymMatch("Grok Code Fast 1 (free)", "free")).toBe(true) + }) + }) + + describe("acronym matching", () => { + it("matches acronyms from word starts", () => { + expect(acronymMatch("Claude Sonnet", "clso")).toBe(true) + }) + + it("matches partial acronyms", () => { + expect(acronymMatch("Claude Sonnet 3.5", "cls")).toBe(true) + }) + + it("matches direct and acronym", () => { + expect(acronymMatch("clso tool", "clso")).toBe(true) + expect(acronymMatch("Claude Sonnet", "clso")).toBe(true) + }) + + it("does not match non-boundary acronym", () => { + expect(acronymMatch("aclbso", "clso")).toBe(false) + }) + }) + + describe("camelCase and PascalCase", () => { + it("recognizes camelCase boundary", () => { + expect(acronymMatch("gitRebase", "gr")).toBe(true) + }) + + it("matches PascalCase acronyms", () => { + expect(acronymMatch("NewFileCreation", "nfc")).toBe(true) + }) + + it("splits camelCase at uppercase transitions", () => { + expect(acronymMatch("parseMarkdownContent", "pmc")).toBe(true) + }) + + it("handles mixed case scenarios", () => { + expect(acronymMatch("gitRebase", "gitr")).toBe(true) + expect(acronymMatch("GitRebase", "gitr")).toBe(true) + }) + }) + + describe("backtracking", () => { + it("matches word that appears later in text", () => { + expect(acronymMatch("google gemini", "gemini")).toBe(true) + expect(acronymMatch("gemini pro", "gemini")).toBe(true) + expect(acronymMatch("google em emini", "gemini")).toBe(true) + }) + + it("matches partial word that appears later", () => { + expect(acronymMatch("Microsoft Copilot", "copilot")).toBe(true) + expect(acronymMatch("GitHub Copilot", "copilot")).toBe(true) + }) + + it("still respects word boundaries with backtracking", () => { + expect(acronymMatch("google gemini", "gemini")).toBe(true) + expect(acronymMatch("googlegemini", "gemini")).toBe(false) + }) + }) + + describe("edge cases", () => { + it("handles empty text", () => { + expect(acronymMatch("", "foo")).toBe(false) + }) + + it("handles empty query", () => { + expect(acronymMatch("foo", "")).toBe(true) + }) + + it("handles special characters in text", () => { + expect(acronymMatch("foo-bar", "foob")).toBe(true) + }) + }) +}) + +// --------------------------------------------------------------------------- +// searchMatch — high-level search with trimming and multi-word support +// --------------------------------------------------------------------------- + +describe("searchMatch", () => { + describe("empty and whitespace queries", () => { + it("returns true for empty query", () => { + expect(searchMatch("", "anything")).toBe(true) + }) + + it("returns true for whitespace-only query", () => { + expect(searchMatch(" ", "anything")).toBe(true) + }) + }) + + describe("case insensitivity", () => { + it("matches case-insensitively", () => { + expect(searchMatch("foo", "Foo Bar")).toBe(true) + expect(searchMatch("foo", "FOO BAZ")).toBe(true) + expect(searchMatch("FoO", "foo qux")).toBe(true) + }) + }) + + describe("trimming", () => { + it("trims leading spaces", () => { + expect(searchMatch(" foo", "foo bar")).toBe(true) + }) + + it("trims trailing spaces", () => { + expect(searchMatch("foo ", "foo bar")).toBe(true) + }) + + it("trims spaces on both sides", () => { + expect(searchMatch(" foo ", "foo bar")).toBe(true) + }) + }) + + describe("multi-word queries", () => { + it("matches when all words present", () => { + expect(searchMatch("claude sonnet", "Claude Sonnet 3.5")).toBe(true) + }) + + it("does not match when any word missing", () => { + expect(searchMatch("claude sonnet", "Claude Opus")).toBe(false) + expect(searchMatch("claude sonnet", "GPT Sonnet")).toBe(false) + }) + }) + + describe("real-world model search", () => { + it("finds model with hyphen in query", () => { + expect(searchMatch("gpt-5", "OpenAI: gpt-5 mini")).toBe(true) + expect(searchMatch("gpt-5", "OpenAI: gpt-4")).toBe(false) + }) + + it("finds model when hyphen omitted from query", () => { + expect(searchMatch("gpt5", "OpenAI: gpt-5 mini")).toBe(true) + }) + + it("finds all models with trailing hyphen", () => { + expect(searchMatch("gpt-", "OpenAI: gpt-5 mini")).toBe(true) + expect(searchMatch("gpt-", "OpenAI: gpt-4")).toBe(true) + expect(searchMatch("gpt-", "Anthropic: claude-3")).toBe(false) + }) + + it("matches file paths", () => { + expect(searchMatch("code", "src/services/code-index/manager.ts")).toBe(true) + }) + + it("matches mode selector options by label+value", () => { + expect(searchMatch("cod", "Code code Write code")).toBe(true) + }) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx index 3dc1b2dbbaf..dc7b8191361 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx @@ -29,43 +29,12 @@ import { sanitizeName, } from "./model-selector-utils" import { ModelPreview } from "./ModelPreview" +import { searchMatch } from "../../utils/search-match" // --------------------------------------------------------------------------- // Row / group key helpers — single source of truth for key formatting // --------------------------------------------------------------------------- -// Word boundary matching ported from legacy word-boundary-fzf.ts. -// Splits at camelCase transitions and common delimiters so that e.g. -// "clso" matches "Claude Sonnet" (Cl + So), "gpt-5" splits into ["gpt","5"]. -const WORD_BOUNDARY = /(?=[A-Z])|[[\]_.:\s/\\(){}-]+/ - -function acronymMatch(text: string, query: string): boolean { - const words = text - .split(WORD_BOUNDARY) - .filter((w) => w.length > 0) - .map((w) => w.toLowerCase()) - - const attempt = (wi: number, qi: number): boolean => { - if (qi === query.length) return true - if (wi >= words.length) return false - const word = words[wi]! - let consumed = 0 - while (qi + consumed < query.length && consumed < word.length && word[consumed] === query[qi + consumed]) consumed++ - if (consumed > 0 && attempt(wi + 1, qi + consumed)) return true - return attempt(wi + 1, qi) - } - - return attempt(0, 0) -} - -function searchMatch(query: string, text: string): boolean { - const q = query.toLowerCase().trim() - if (!q) return true - const parts = q.split(WORD_BOUNDARY).filter((w) => w.length > 0) - if (parts.length === 0) return true - return parts.every((p) => acronymMatch(text, p)) -} - const CLEAR_KEY = "clear" const FAVORITES_KEY = "favorites" const RECOMMENDED_KEY = "recommended" diff --git a/packages/kilo-vscode/webview-ui/src/utils/search-match.ts b/packages/kilo-vscode/webview-ui/src/utils/search-match.ts new file mode 100644 index 00000000000..dfcc6ca0167 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/utils/search-match.ts @@ -0,0 +1,59 @@ +/** + * Word-boundary matching for model/provider search. + * + * Splits text at camelCase transitions and common delimiters so that e.g. + * "clso" matches "Claude Sonnet" (Cl + So) and "gpt5" matches "gpt-5". + * Multi-word queries like "claude sonnet" require every fragment to match + * independently. + * + * Ported from legacy word-boundary-fzf.ts. + */ + +// Split at positions before uppercase letters (camelCase/PascalCase) +// and at common delimiters: hyphen, underscore, dot, colon, whitespace, +// forward/back slash, brackets, parentheses. +const WORD_BOUNDARY = /(?=[A-Z])|[[\]_.:\s/\\(){}-]+/ + +/** + * Match a single query fragment against text using word-boundary acronym + * matching. Each character in `query` must match the start of a word in + * `text`, consuming consecutive characters from the same word before moving + * to the next. + * + * Examples: + * - acronymMatch("Claude Sonnet", "clso") → true (Cl + So) + * - acronymMatch("gitRebase", "gr") → true (git + Rebase) + * - acronymMatch("faoboc", "foo") → false (no word boundary) + */ +export function acronymMatch(text: string, query: string): boolean { + const words = text + .split(WORD_BOUNDARY) + .filter((w) => w.length > 0) + .map((w) => w.toLowerCase()) + + const attempt = (wi: number, qi: number): boolean => { + if (qi === query.length) return true + if (wi >= words.length) return false + const word = words[wi]! + let consumed = 0 + while (qi + consumed < query.length && consumed < word.length && word[consumed] === query[qi + consumed]) consumed++ + if (consumed > 0 && attempt(wi + 1, qi + consumed)) return true + return attempt(wi + 1, qi) + } + + return attempt(0, 0) +} + +/** + * High-level search: trims and lowercases the query, splits multi-word + * queries at word boundaries, and requires every fragment to match. + * + * Returns `true` when `query` is empty/whitespace-only. + */ +export function searchMatch(query: string, text: string): boolean { + const q = query.toLowerCase().trim() + if (!q) return true + const parts = q.split(WORD_BOUNDARY).filter((w) => w.length > 0) + if (parts.length === 0) return true + return parts.every((p) => acronymMatch(text, p)) +}