Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/search-highlight.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Highlight matching characters in "Recommended models" and model search (thanks @bernaferrari!)
60 changes: 60 additions & 0 deletions webview-ui/src/components/ui/highlighted-text.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import React, { memo } from "react"
import { cn } from "@/lib/utils"

interface HighlightedTextProps {
text: string
matchingPositions?: Set<number>
className?: string
}

export const HighlightedText = memo(({ text, matchingPositions, className }: HighlightedTextProps) => {
if (!matchingPositions || matchingPositions.size === 0) {
return <span className={className}>{text}</span>
}

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 ? (
<span key={lastIndex} className="font-bold text-vscode-textLink-foreground">
{chunk}
</span>
) : (
<span key={lastIndex}>{chunk}</span>
),
)
}
lastIndex = i
}
}

// Push trailing chunk
if (lastIndex < text.length) {
const chunk = text.slice(lastIndex)
const isMatch = matchingPositions.has(text.length - 1)
parts.push(
isMatch ? (
<span key={lastIndex} className="font-bold text-vscode-textLink-foreground">
{chunk}
</span>
) : (
<span key={lastIndex}>{chunk}</span>
),
)
}

return <span className={cn("truncate", className)}>{parts}</span>
})

HighlightedText.displayName = "HighlightedText"
36 changes: 24 additions & 12 deletions webview-ui/src/components/ui/select-dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -26,6 +27,7 @@ export interface DropdownOption {
disabled?: boolean
type?: DropdownOptionType
pinned?: boolean
matchingPositions?: Set<number> // kilocode_change
}

export interface SelectDropdownProps {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -374,7 +381,12 @@ export const SelectDropdown = React.memo(
)}
/>
<div className="flex-1">
<div>{option.label}</div>
<div className="flex items-center">
<HighlightedText
text={option.label}
matchingPositions={option.matchingPositions}
/>
</div>
{option.description && (
<div className="text-[11px] opacity-50 mt-0.5">
{option.description}
Expand Down
76 changes: 52 additions & 24 deletions webview-ui/src/lib/word-boundary-fzf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ interface FzfOptions<T> {

interface FzfResult<T> {
item: T
positions: Set<number>
}

// Single source of truth for word boundary characters
Expand Down Expand Up @@ -45,9 +46,9 @@ export class Fzf<T> {
* @param query The search string
* @returns Array of results with item and metadata, in original order
*/
find(query: string): FzfResult<T>[] {
public find(query: string): FzfResult<T>[] {
if (!query || query.trim() === "") {
return this.items.map((item) => ({ item }))
return this.items.map((item) => ({ item, positions: new Set() }))
}

const normalizedQuery = query.toLowerCase().trim()
Expand All @@ -60,82 +61,109 @@ export class Fzf<T> {

// 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<number>()
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 })
}
}
}

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<number> | 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<number>): Set<number> | 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())
}
}
Loading