From 49fb01f42dd7b236b8cced44d4f0bb87e82b414a Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 8 Jan 2026 20:49:22 +0200 Subject: [PATCH] feat: implement fuzzy matching for autocomplete components and add Fuse.js for improved search functionality --- bun.lock | 11 ++++++---- packages/ui/package.json | 1 + .../chat/AgentMentionAutocomplete.tsx | 6 ++--- .../components/chat/CommandAutocomplete.tsx | 11 +++++----- .../ui/src/components/chat/ModelControls.tsx | 19 ++++++++-------- .../src/components/chat/SkillAutocomplete.tsx | 6 ++--- packages/ui/src/lib/utils.ts | 22 +++++++++++++++++++ 7 files changed, 50 insertions(+), 26 deletions(-) diff --git a/bun.lock b/bun.lock index 1e11061a..a851b9d0 100644 --- a/bun.lock +++ b/bun.lock @@ -74,7 +74,7 @@ }, "packages/desktop": { "name": "@openchamber/desktop", - "version": "1.4.3", + "version": "1.4.4", "dependencies": { "@openchamber/ui": "workspace:*", "@tauri-apps/plugin-notification": "^2.3.3", @@ -97,7 +97,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.4.3", + "version": "1.4.4", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -122,6 +122,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "express": "^5.1.0", + "fuse.js": "^7.1.0", "ghostty-web": "^0.4.0", "heic2any": "^0.0.4", "http-proxy-middleware": "^3.0.5", @@ -167,7 +168,7 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.4.3", + "version": "1.4.4", "dependencies": { "@openchamber/ui": "workspace:*", "@opencode-ai/sdk": "^1.1.1", @@ -188,7 +189,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.4.3", + "version": "1.4.4", "bin": { "openchamber": "./bin/cli.js", }, @@ -1519,6 +1520,8 @@ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "fuse.js": ["fuse.js@7.1.0", "", {}, "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ=="], + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], diff --git a/packages/ui/package.json b/packages/ui/package.json index fd1dc3f9..062f5895 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -34,6 +34,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "express": "^5.1.0", + "fuse.js": "^7.1.0", "ghostty-web": "^0.4.0", "heic2any": "^0.0.4", "http-proxy-middleware": "^3.0.5", diff --git a/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx b/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx index ec9cc094..d3c9dea5 100644 --- a/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx +++ b/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { cn } from '@/lib/utils'; +import { cn, fuzzyMatch } from '@/lib/utils'; import { useConfigStore } from '@/stores/useConfigStore'; import { useAgentsStore } from '@/stores/useAgentsStore'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; @@ -62,9 +62,9 @@ export const AgentMentionAutocomplete = React.forwardRef agent.name.toLowerCase().includes(normalizedQuery)) + ? filtered.filter((agent) => fuzzyMatch(agent.name, normalizedQuery)) : filtered; matches.sort((a, b) => a.name.localeCompare(b.name)); diff --git a/packages/ui/src/components/chat/CommandAutocomplete.tsx b/packages/ui/src/components/chat/CommandAutocomplete.tsx index 8672a471..360464ea 100644 --- a/packages/ui/src/components/chat/CommandAutocomplete.tsx +++ b/packages/ui/src/components/chat/CommandAutocomplete.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { RiCommandLine, RiFileLine, RiFlashlightLine, RiRefreshLine, RiScissorsLine, RiTerminalBoxLine, RiArrowGoBackLine, RiArrowGoForwardLine, RiTimeLine } from '@remixicon/react'; -import { cn } from '@/lib/utils'; -import { opencodeClient } from '@/lib/opencode/client'; +import { cn, fuzzyMatch } from '@/lib/utils'; import { useSessionStore } from '@/stores/useSessionStore'; import { useCommandsStore } from '@/stores/useCommandsStore'; import { useShallow } from 'zustand/react/shallow'; @@ -113,8 +112,8 @@ export const CommandAutocomplete = React.forwardRef - cmd.name.toLowerCase().includes(searchQuery.toLowerCase()) || - (cmd.description && cmd.description.toLowerCase().includes(searchQuery.toLowerCase())) + fuzzyMatch(cmd.name, searchQuery) || + (cmd.description && fuzzyMatch(cmd.description, searchQuery)) ) : allCommands).filter(cmd => allowInitCommand || cmd.name !== 'init'); @@ -148,8 +147,8 @@ export const CommandAutocomplete = React.forwardRef - cmd.name.toLowerCase().includes(searchQuery.toLowerCase()) || - (cmd.description && cmd.description.toLowerCase().includes(searchQuery.toLowerCase())) + fuzzyMatch(cmd.name, searchQuery) || + (cmd.description && fuzzyMatch(cmd.description, searchQuery)) ) : builtInCommands).filter(cmd => allowInitCommand || cmd.name !== 'init'); diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index d4f9e410..4849b045 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -41,7 +41,7 @@ import { getAgentColor } from '@/lib/agentColors'; import { useDeviceInfo } from '@/lib/device'; import { calculateEditPermissionUIState, type BashPermissionSetting } from '@/lib/permissions/editPermissionDefaults'; import { getEditModeColors } from '@/lib/permissions/editModeColors'; -import { cn } from '@/lib/utils'; +import { cn, fuzzyMatch } from '@/lib/utils'; import { useContextStore } from '@/stores/contextStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useSessionStore } from '@/stores/useSessionStore'; @@ -1286,19 +1286,19 @@ export const ModelControls: React.FC = ({ className }) => { const renderMobileModelPanel = () => { if (!isCompact) return null; - const normalizedQuery = mobileModelQuery.trim().toLowerCase(); + const normalizedQuery = mobileModelQuery.trim(); const filteredProviders = providers .map((provider) => { const providerModels = Array.isArray(provider.models) ? provider.models : []; const matchesProvider = normalizedQuery.length === 0 ? true - : provider.name.toLowerCase().includes(normalizedQuery) || provider.id.toLowerCase().includes(normalizedQuery); + : fuzzyMatch(provider.name, normalizedQuery) || fuzzyMatch(provider.id, normalizedQuery); const matchingModels = normalizedQuery.length === 0 ? providerModels : providerModels.filter((model: ProviderModel) => { - const name = getModelDisplayName(model).toLowerCase(); - const id = typeof model.id === 'string' ? model.id.toLowerCase() : ''; - return name.includes(normalizedQuery) || id.includes(normalizedQuery); + const name = getModelDisplayName(model); + const id = typeof model.id === 'string' ? model.id : ''; + return fuzzyMatch(name, normalizedQuery) || fuzzyMatch(id, normalizedQuery); }); return { provider, providerModels: matchingModels, matchesProvider }; }) @@ -1928,13 +1928,12 @@ export const ModelControls: React.FC = ({ className }) => { ); }; - // Filter models based on search query + // Filter models based on search query (fuzzy match) const filterByQuery = (modelName: string, providerName: string, query: string) => { if (!query.trim()) return true; - const lowerQuery = query.toLowerCase(); return ( - modelName.toLowerCase().includes(lowerQuery) || - providerName.toLowerCase().includes(lowerQuery) + fuzzyMatch(modelName, query) || + fuzzyMatch(providerName, query) ); }; diff --git a/packages/ui/src/components/chat/SkillAutocomplete.tsx b/packages/ui/src/components/chat/SkillAutocomplete.tsx index 8893a21d..810ea040 100644 --- a/packages/ui/src/components/chat/SkillAutocomplete.tsx +++ b/packages/ui/src/components/chat/SkillAutocomplete.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { cn } from '@/lib/utils'; +import { cn, fuzzyMatch } from '@/lib/utils'; import { useSkillsStore } from '@/stores/useSkillsStore'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; @@ -36,9 +36,9 @@ export const SkillAutocomplete = React.forwardRef { - const normalizedQuery = searchQuery.trim().toLowerCase(); + const normalizedQuery = searchQuery.trim(); const matches = normalizedQuery.length - ? skills.filter((skill) => skill.name.toLowerCase().includes(normalizedQuery)) + ? skills.filter((skill) => fuzzyMatch(skill.name, normalizedQuery)) : skills; const sorted = [...matches].sort((a, b) => { diff --git a/packages/ui/src/lib/utils.ts b/packages/ui/src/lib/utils.ts index b7933ad7..fa15fdf4 100644 --- a/packages/ui/src/lib/utils.ts +++ b/packages/ui/src/lib/utils.ts @@ -128,3 +128,25 @@ export function formatDirectoryName(path: string | null | undefined, homeDirecto const name = segments.pop() || normalizedPath; return name || "/"; } + +import Fuse from 'fuse.js'; + +/** + * Fuzzy search using Fuse.js with typo tolerance. + * Returns true if query fuzzy-matches target (e.g. "coude" matches "claude") + */ +export function fuzzyMatch(target: string, query: string): boolean { + if (!query) return true; + if (!target) return false; + + // Quick exact substring check first + if (target.toLowerCase().includes(query.toLowerCase())) return true; + + const fuse = new Fuse([target], { + threshold: 0.4, // 0 = exact, 1 = match anything + distance: 100, + ignoreLocation: true, + }); + const results = fuse.search(query); + return results.length > 0; +}