Merge remote-tracking branch 'origin/main' into vscode-lm-provider

This commit is contained in:
Matt Rubens
2025-01-15 09:18:55 -05:00
9 changed files with 135 additions and 186 deletions

View File

@@ -0,0 +1,5 @@
---
"roo-cline": patch
---
Improvements to fuzzy search in mentions, history, and model lists

View File

@@ -18,7 +18,7 @@
"@vscode/webview-ui-toolkit": "^1.4.0",
"debounce": "^2.1.1",
"fast-deep-equal": "^3.1.3",
"fuse.js": "^7.0.0",
"fzf": "^0.5.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-remark": "^2.1.0",
@@ -7480,12 +7480,10 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/fuse.js": {
"version": "7.0.0",
"license": "Apache-2.0",
"engines": {
"node": ">=10"
}
"node_modules/fzf": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fzf/-/fzf-0.5.2.tgz",
"integrity": "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q=="
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",

View File

@@ -13,7 +13,7 @@
"@vscode/webview-ui-toolkit": "^1.4.0",
"debounce": "^2.1.1",
"fast-deep-equal": "^3.1.3",
"fuse.js": "^7.0.0",
"fzf": "^0.5.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-remark": "^2.1.0",

View File

@@ -3,8 +3,9 @@ import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
import { Virtuoso } from "react-virtuoso"
import React, { memo, useMemo, useState, useEffect } from "react"
import Fuse, { FuseResult } from "fuse.js"
import { Fzf } from "fzf"
import { formatLargeNumber } from "../../utils/format"
import { highlightFzfMatch } from "../../utils/highlight"
type HistoryViewProps = {
onDone: () => void
@@ -67,20 +68,21 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
return taskHistory.filter((item) => item.ts && item.task)
}, [taskHistory])
const fuse = useMemo(() => {
return new Fuse(presentableTasks, {
keys: ["task"],
threshold: 0.6,
shouldSort: true,
isCaseSensitive: false,
ignoreLocation: false,
includeMatches: true,
minMatchCharLength: 1,
const fzf = useMemo(() => {
return new Fzf(presentableTasks, {
selector: item => item.task
})
}, [presentableTasks])
const taskHistorySearchResults = useMemo(() => {
let results = searchQuery ? highlight(fuse.search(searchQuery)) : presentableTasks
let results = presentableTasks
if (searchQuery) {
const searchResults = fzf.find(searchQuery)
results = searchResults.map(result => ({
...result.item,
task: highlightFzfMatch(result.item.task, Array.from(result.positions))
}))
}
// First apply search if needed
const searchResults = searchQuery ? results : presentableTasks;
@@ -104,7 +106,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
return (b.ts || 0) - (a.ts || 0);
}
});
}, [presentableTasks, searchQuery, fuse, sortOption])
}, [presentableTasks, searchQuery, fzf, sortOption])
return (
<>
@@ -463,112 +465,4 @@ const ExportButton = ({ itemId }: { itemId: string }) => (
</VSCodeButton>
)
// https://gist.github.com/evenfrost/1ba123656ded32fb7a0cd4651efd4db0
export const highlight = (
fuseSearchResult: FuseResult<any>[],
highlightClassName: string = "history-item-highlight",
) => {
const set = (obj: Record<string, any>, path: string, value: any) => {
const pathValue = path.split(".")
let i: number
for (i = 0; i < pathValue.length - 1; i++) {
if (pathValue[i] === "__proto__" || pathValue[i] === "constructor") return
obj = obj[pathValue[i]] as Record<string, any>
}
if (pathValue[i] !== "__proto__" && pathValue[i] !== "constructor") {
obj[pathValue[i]] = value
}
}
// Function to merge overlapping regions
const mergeRegions = (regions: [number, number][]): [number, number][] => {
if (regions.length === 0) return regions
// Sort regions by start index
regions.sort((a, b) => a[0] - b[0])
const merged: [number, number][] = [regions[0]]
for (let i = 1; i < regions.length; i++) {
const last = merged[merged.length - 1]
const current = regions[i]
if (current[0] <= last[1] + 1) {
// Overlapping or adjacent regions
last[1] = Math.max(last[1], current[1])
} else {
merged.push(current)
}
}
return merged
}
const generateHighlightedText = (inputText: string, regions: [number, number][] = []) => {
if (regions.length === 0) {
return inputText
}
// Sort and merge overlapping regions
const mergedRegions = mergeRegions(regions)
// Convert regions to a list of parts with their highlight status
const parts: { text: string; highlight: boolean }[] = []
let lastIndex = 0
mergedRegions.forEach(([start, end]) => {
// Add non-highlighted text before this region
if (start > lastIndex) {
parts.push({
text: inputText.substring(lastIndex, start),
highlight: false
})
}
// Add highlighted text
parts.push({
text: inputText.substring(start, end + 1),
highlight: true
})
lastIndex = end + 1
})
// Add any remaining text
if (lastIndex < inputText.length) {
parts.push({
text: inputText.substring(lastIndex),
highlight: false
})
}
// Build final string
return parts
.map(part =>
part.highlight
? `<span class="${highlightClassName}">${part.text}</span>`
: part.text
)
.join('')
}
return fuseSearchResult
.filter(({ matches }) => matches && matches.length)
.map(({ item, matches }) => {
const highlightedItem = { ...item }
matches?.forEach((match) => {
if (match.key && typeof match.value === "string" && match.indices) {
// Merge overlapping regions before generating highlighted text
const mergedIndices = mergeRegions([...match.indices])
set(highlightedItem, match.key, generateHighlightedText(match.value, mergedIndices))
}
})
return highlightedItem
})
}
export default memo(HistoryView)

View File

@@ -1,5 +1,5 @@
import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import Fuse from "fuse.js"
import { Fzf } from "fzf"
import React, { KeyboardEvent, memo, useEffect, useMemo, useRef, useState } from "react"
import { useRemark } from "react-remark"
import { useMount } from "react-use"
@@ -7,7 +7,7 @@ import styled from "styled-components"
import { glamaDefaultModelId } from "../../../../src/shared/api"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
import { highlight } from "../history/HistoryView"
import { highlightFzfMatch } from "../../utils/highlight"
import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions"
const GlamaModelPicker: React.FC = () => {
@@ -72,25 +72,21 @@ const GlamaModelPicker: React.FC = () => {
}))
}, [modelIds])
const fuse = useMemo(() => {
return new Fuse(searchableItems, {
keys: ["html"], // highlight function will update this
threshold: 0.6,
shouldSort: true,
isCaseSensitive: false,
ignoreLocation: false,
includeMatches: true,
minMatchCharLength: 1,
const fzf = useMemo(() => {
return new Fzf(searchableItems, {
selector: item => item.html
})
}, [searchableItems])
const modelSearchResults = useMemo(() => {
let results: { id: string; html: string }[] = searchTerm
? highlight(fuse.search(searchTerm), "model-item-highlight")
: searchableItems
// results.sort((a, b) => a.id.localeCompare(b.id)) NOTE: sorting like this causes ids in objects to be reordered and mismatched
return results
}, [searchableItems, searchTerm, fuse])
if (!searchTerm) return searchableItems
const searchResults = fzf.find(searchTerm)
return searchResults.map(result => ({
...result.item,
html: highlightFzfMatch(result.item.html, Array.from(result.positions), "model-item-highlight")
}))
}, [searchableItems, searchTerm, fzf])
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (!isDropdownVisible) return

View File

@@ -1,11 +1,11 @@
import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import Fuse from "fuse.js"
import { Fzf } from "fzf"
import React, { KeyboardEvent, memo, useEffect, useMemo, useRef, useState } from "react"
import { useRemark } from "react-remark"
import styled from "styled-components"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
import { highlight } from "../history/HistoryView"
import { highlightFzfMatch } from "../../utils/highlight"
const OpenAiModelPicker: React.FC = () => {
const { apiConfiguration, setApiConfiguration, openAiModels, onUpdateApiConfig } = useExtensionState()
@@ -71,25 +71,21 @@ const OpenAiModelPicker: React.FC = () => {
}))
}, [modelIds])
const fuse = useMemo(() => {
return new Fuse(searchableItems, {
keys: ["html"], // highlight function will update this
threshold: 0.6,
shouldSort: true,
isCaseSensitive: false,
ignoreLocation: false,
includeMatches: true,
minMatchCharLength: 1,
const fzf = useMemo(() => {
return new Fzf(searchableItems, {
selector: item => item.html
})
}, [searchableItems])
const modelSearchResults = useMemo(() => {
let results: { id: string; html: string }[] = searchTerm
? highlight(fuse.search(searchTerm), "model-item-highlight")
: searchableItems
// results.sort((a, b) => a.id.localeCompare(b.id)) NOTE: sorting like this causes ids in objects to be reordered and mismatched
return results
}, [searchableItems, searchTerm, fuse])
if (!searchTerm) return searchableItems
const searchResults = fzf.find(searchTerm)
return searchResults.map(result => ({
...result.item,
html: highlightFzfMatch(result.item.html, Array.from(result.positions), "model-item-highlight")
}))
}, [searchableItems, searchTerm, fzf])
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (!isDropdownVisible) return

View File

@@ -1,5 +1,5 @@
import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import Fuse from "fuse.js"
import { Fzf } from "fzf"
import React, { KeyboardEvent, memo, useEffect, useMemo, useRef, useState } from "react"
import { useRemark } from "react-remark"
import { useMount } from "react-use"
@@ -7,7 +7,7 @@ import styled from "styled-components"
import { openRouterDefaultModelId } from "../../../../src/shared/api"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
import { highlight } from "../history/HistoryView"
import { highlightFzfMatch } from "../../utils/highlight"
import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions"
const OpenRouterModelPicker: React.FC = () => {
@@ -71,25 +71,21 @@ const OpenRouterModelPicker: React.FC = () => {
}))
}, [modelIds])
const fuse = useMemo(() => {
return new Fuse(searchableItems, {
keys: ["html"], // highlight function will update this
threshold: 0.6,
shouldSort: true,
isCaseSensitive: false,
ignoreLocation: false,
includeMatches: true,
minMatchCharLength: 1,
const fzf = useMemo(() => {
return new Fzf(searchableItems, {
selector: item => item.html
})
}, [searchableItems])
const modelSearchResults = useMemo(() => {
let results: { id: string; html: string }[] = searchTerm
? highlight(fuse.search(searchTerm), "model-item-highlight")
: searchableItems
// results.sort((a, b) => a.id.localeCompare(b.id)) NOTE: sorting like this causes ids in objects to be reordered and mismatched
return results
}, [searchableItems, searchTerm, fuse])
if (!searchTerm) return searchableItems
const searchResults = fzf.find(searchTerm)
return searchResults.map(result => ({
...result.item,
html: highlightFzfMatch(result.item.html, Array.from(result.positions), "model-item-highlight")
}))
}, [searchableItems, searchTerm, fzf])
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (!isDropdownVisible) return

View File

@@ -1,4 +1,5 @@
import { mentionRegex } from "../../../src/shared/context-mentions"
import { Fzf } from "fzf"
export function insertMention(
text: string,
@@ -147,13 +148,21 @@ export function getContextMenuOptions(
}
}
// Get matching items, separating by type
const matchingItems = queryItems.filter((item) =>
item.value?.toLowerCase().includes(lowerQuery) ||
item.label?.toLowerCase().includes(lowerQuery) ||
item.description?.toLowerCase().includes(lowerQuery)
)
// Create searchable strings array for fzf
const searchableItems = queryItems.map(item => ({
original: item,
searchStr: [item.value, item.label, item.description].filter(Boolean).join(' ')
}))
// Initialize fzf instance for fuzzy search
const fzf = new Fzf(searchableItems, {
selector: item => item.searchStr
})
// Get fuzzy matching items
const matchingItems = query ? fzf.find(query).map(result => result.item.original) : []
// Separate matches by type
const fileMatches = matchingItems.filter(item =>
item.type === ContextMenuOptionType.File ||
item.type === ContextMenuOptionType.Folder
@@ -169,7 +178,18 @@ export function getContextMenuOptions(
// Combine suggestions with matching items in the desired order
if (suggestions.length > 0 || matchingItems.length > 0) {
return [...suggestions, ...fileMatches, ...gitMatches, ...otherMatches]
const allItems = [...suggestions, ...fileMatches, ...gitMatches, ...otherMatches]
// Remove duplicates based on type and value
const seen = new Set()
const deduped = allItems.filter(item => {
const key = `${item.type}-${item.value}`
if (seen.has(key)) return false
seen.add(key)
return true
})
return deduped
}
return [{ type: ContextMenuOptionType.NoResults }]

View File

@@ -0,0 +1,44 @@
export function highlightFzfMatch(text: string, positions: number[], highlightClassName: string = "history-item-highlight") {
if (!positions.length) return text
const parts: { text: string; highlight: boolean }[] = []
let lastIndex = 0
// Sort positions to ensure we process them in order
positions.sort((a, b) => a - b)
positions.forEach((pos) => {
// Add non-highlighted text before this position
if (pos > lastIndex) {
parts.push({
text: text.substring(lastIndex, pos),
highlight: false
})
}
// Add highlighted character
parts.push({
text: text[pos],
highlight: true
})
lastIndex = pos + 1
})
// Add any remaining text
if (lastIndex < text.length) {
parts.push({
text: text.substring(lastIndex),
highlight: false
})
}
// Build final string
return parts
.map(part =>
part.highlight
? `<span class="${highlightClassName}">${part.text}</span>`
: part.text
)
.join('')
}