diff --git a/.changeset/search-highlight.md b/.changeset/search-highlight.md new file mode 100644 index 00000000000..1c99afb6fdb --- /dev/null +++ b/.changeset/search-highlight.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Highlight matching characters in "Recommended models" and model search (thanks @bernaferrari!) diff --git a/webview-ui/src/components/ui/highlighted-text.tsx b/webview-ui/src/components/ui/highlighted-text.tsx new file mode 100644 index 00000000000..361ffa47783 --- /dev/null +++ b/webview-ui/src/components/ui/highlighted-text.tsx @@ -0,0 +1,60 @@ +import React, { memo } from "react" +import { cn } from "@/lib/utils" + +interface HighlightedTextProps { + text: string + matchingPositions?: Set + className?: string +} + +export const HighlightedText = memo(({ text, matchingPositions, className }: HighlightedTextProps) => { + if (!matchingPositions || matchingPositions.size === 0) { + return {text} + } + + const parts: React.ReactNode[] = [] + let lastIndex = 0 + + // Iterate through the string one character at a time or grouped + // Grouping is more efficient for React rendering + for (let i = 0; i < text.length; i++) { + const isMatch = matchingPositions.has(i) + const isLastMatch = i > 0 && matchingPositions.has(i - 1) + + if (isMatch !== isLastMatch) { + // specific transition, push previous chunk + if (i > lastIndex) { + const chunk = text.slice(lastIndex, i) + parts.push( + isLastMatch ? ( + + {chunk} + + ) : ( + {chunk} + ), + ) + } + lastIndex = i + } + } + + // Push trailing chunk + if (lastIndex < text.length) { + const chunk = text.slice(lastIndex) + const isMatch = matchingPositions.has(text.length - 1) + parts.push( + isMatch ? ( + + {chunk} + + ) : ( + {chunk} + ), + ) + } + + return {parts} +}) + +HighlightedText.displayName = "HighlightedText" diff --git a/webview-ui/src/components/ui/select-dropdown.tsx b/webview-ui/src/components/ui/select-dropdown.tsx index 1c92df86069..3d43e25e952 100644 --- a/webview-ui/src/components/ui/select-dropdown.tsx +++ b/webview-ui/src/components/ui/select-dropdown.tsx @@ -9,6 +9,7 @@ import { useRooPortal } from "./hooks/useRooPortal" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui" import { StandardTooltip } from "@/components/ui" import { IconProps } from "@radix-ui/react-icons/dist/types" // kilocode_change +import { HighlightedText } from "./highlighted-text" // kilocode_change export enum DropdownOptionType { ITEM = "item", @@ -26,6 +27,7 @@ export interface DropdownOption { disabled?: boolean type?: DropdownOptionType pinned?: boolean + matchingPositions?: Set // kilocode_change } export interface SelectDropdownProps { @@ -134,26 +136,31 @@ export const SelectDropdown = React.memo( // Filter options based on search value using memoized Fzf instance const filteredOptions = React.useMemo(() => { - // If search is disabled or no search value, return all options without filtering - if (disableSearch || !searchValue) return options + // Get fuzzy matching items AND their positions + const matchingResults = fzfInstance.find(searchValue) + const matchMap = new Map(matchingResults.map((r) => [r.item.original.value, r.positions])) - // Get fuzzy matching items - only perform search if we have a search value - const matchingItems = fzfInstance.find(searchValue).map((result) => result.item.original) - - // Always include separators, shortcuts, and labels - return options.filter((option) => { + // Always include separators, shortcuts, and labels, and matched items + return options.reduce((acc, option) => { if ( option.type === DropdownOptionType.SEPARATOR || option.type === DropdownOptionType.SHORTCUT || option.type === DropdownOptionType.LABEL // kilocode_change: include LABEL in filtered results ) { - return true + acc.push(option) + return acc } // Include if it's in the matching items - return matchingItems.some((item) => item.value === option.value) - }) - }, [options, searchValue, fzfInstance, disableSearch]) + const positions = matchMap.get(option.value) + if (positions) { + // Clone option to add matching positions without mutating original + acc.push({ ...option, matchingPositions: positions }) + } + + return acc + }, [] as DropdownOption[]) + }, [options, searchValue, fzfInstance]) // Group options by type and handle separators and labels // kilocode_change start: improved handling for section labels @@ -374,7 +381,12 @@ export const SelectDropdown = React.memo( )} />
-
{option.label}
+
+ +
{option.description && (
{option.description} diff --git a/webview-ui/src/lib/word-boundary-fzf.ts b/webview-ui/src/lib/word-boundary-fzf.ts index 811137b8322..c0cfb01cde8 100644 --- a/webview-ui/src/lib/word-boundary-fzf.ts +++ b/webview-ui/src/lib/word-boundary-fzf.ts @@ -15,6 +15,7 @@ interface FzfOptions { interface FzfResult { item: T + positions: Set } // Single source of truth for word boundary characters @@ -45,9 +46,9 @@ export class Fzf { * @param query The search string * @returns Array of results with item and metadata, in original order */ - find(query: string): FzfResult[] { + public find(query: string): FzfResult[] { if (!query || query.trim() === "") { - return this.items.map((item) => ({ item })) + return this.items.map((item) => ({ item, positions: new Set() })) } const normalizedQuery = query.toLowerCase().trim() @@ -60,24 +61,34 @@ export class Fzf { // If no words after splitting (e.g., query was just punctuation), return all items if (queryWords.length === 0) { - return this.items.map((item) => ({ item })) + return this.items.map((item) => ({ item, positions: new Set() })) } for (const item of this.items) { const text = this.selector(item) + const tokens = this.tokenize(text) // For multi-word queries, all words must match if (queryWords.length > 1) { - const allMatch = queryWords.every((word) => this.matchAcronym(text, word)) + const allPositions = new Set() + const allMatch = queryWords.every((word) => { + const positions = this.matchAcronym(tokens, word) + if (positions) { + positions.forEach((p) => allPositions.add(p)) + return true + } + return false + }) if (allMatch) { - results.push({ item }) + results.push({ item, positions: allPositions }) } } else { // Single word query - use the filtered word, not the original query // This handles cases like "gpt-" which becomes ["gpt"] - if (this.matchAcronym(text, queryWords[0])) { - results.push({ item }) + const positions = this.matchAcronym(tokens, queryWords[0]) + if (positions) { + results.push({ item, positions }) } } } @@ -85,57 +96,74 @@ export class Fzf { return results } + private tokenize(text: string): { word: string; index: number }[] { + const tokens: { word: string; index: number }[] = [] + let currentIndex = 0 + const words = text.split(WORD_BOUNDARY_REGEX).filter((w) => w.length > 0) + + for (const word of words) { + const index = text.indexOf(word, currentIndex) + if (index !== -1) { + tokens.push({ word, index }) + currentIndex = index + word.length + } + } + return tokens + } + /** - * Match query as an acronym against text. + * Match query as an acronym against text tokens. * For example, "clso" matches "Claude Sonnet" (Cl + So) * Each character in the query should match the start of a word in the text. */ - private matchAcronym(text: string, query: string): boolean { - // Split original text to find word boundaries (including camelCase transitions) - // Then lowercase the words for case-insensitive matching - const words = text - .split(WORD_BOUNDARY_REGEX) - .filter((w) => w.length > 0) - .map((w) => w.toLowerCase()) + private matchAcronym(tokens: { word: string; index: number }[], query: string): Set | null { + // Lowercase the words for case-insensitive matching + const lowerWords = tokens.map((t) => t.word.toLowerCase()) // Recursive helper function to try matching from a given word index - const tryMatch = (wordIdx: number, queryIdx: number): boolean => { + const tryMatch = (wordIdx: number, queryIdx: number, currentPositions: Set): Set | null => { // Base case: we've consumed the entire query if (queryIdx === query.length) { - return true + return currentPositions } // Base case: no more words to try - if (wordIdx >= words.length) { - return false + if (wordIdx >= lowerWords.length) { + return null } - const word = words[wordIdx] + const word = lowerWords[wordIdx] + const token = tokens[wordIdx] // Try to match as many consecutive characters as possible from this word let matchedInWord = 0 + // Helper to clone set for branching + const nextPositions = new Set(currentPositions) + while ( queryIdx + matchedInWord < query.length && matchedInWord < word.length && word[matchedInWord] === query[queryIdx + matchedInWord] ) { + nextPositions.add(token.index + matchedInWord) matchedInWord++ } // If we matched something, try to continue from the next word if (matchedInWord > 0) { - if (tryMatch(wordIdx + 1, queryIdx + matchedInWord)) { - return true + const result = tryMatch(wordIdx + 1, queryIdx + matchedInWord, nextPositions) + if (result) { + return result } // If continuing didn't work, fall through to try skipping this word } // Try skipping this word and continuing with the next // This allows backtracking when a partial match doesn't lead to a full match - return tryMatch(wordIdx + 1, queryIdx) + return tryMatch(wordIdx + 1, queryIdx, currentPositions) } - return tryMatch(0, 0) + return tryMatch(0, 0, new Set()) } }