feat: implement fuzzy matching for autocomplete components and add Fuse.js for improved search functionality
This commit is contained in:
@@ -74,7 +74,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@openchamber/desktop",
|
"name": "@openchamber/desktop",
|
||||||
"version": "1.4.3",
|
"version": "1.4.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openchamber/ui": "workspace:*",
|
"@openchamber/ui": "workspace:*",
|
||||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@openchamber/ui",
|
"name": "@openchamber/ui",
|
||||||
"version": "1.4.3",
|
"version": "1.4.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
@@ -122,6 +122,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"fuse.js": "^7.1.0",
|
||||||
"ghostty-web": "^0.4.0",
|
"ghostty-web": "^0.4.0",
|
||||||
"heic2any": "^0.0.4",
|
"heic2any": "^0.0.4",
|
||||||
"http-proxy-middleware": "^3.0.5",
|
"http-proxy-middleware": "^3.0.5",
|
||||||
@@ -167,7 +168,7 @@
|
|||||||
},
|
},
|
||||||
"packages/vscode": {
|
"packages/vscode": {
|
||||||
"name": "openchamber",
|
"name": "openchamber",
|
||||||
"version": "1.4.3",
|
"version": "1.4.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openchamber/ui": "workspace:*",
|
"@openchamber/ui": "workspace:*",
|
||||||
"@opencode-ai/sdk": "^1.1.1",
|
"@opencode-ai/sdk": "^1.1.1",
|
||||||
@@ -188,7 +189,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@openchamber/web",
|
"name": "@openchamber/web",
|
||||||
"version": "1.4.3",
|
"version": "1.4.4",
|
||||||
"bin": {
|
"bin": {
|
||||||
"openchamber": "./bin/cli.js",
|
"openchamber": "./bin/cli.js",
|
||||||
},
|
},
|
||||||
@@ -1519,6 +1520,8 @@
|
|||||||
|
|
||||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
"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=="],
|
"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=="],
|
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"fuse.js": "^7.1.0",
|
||||||
"ghostty-web": "^0.4.0",
|
"ghostty-web": "^0.4.0",
|
||||||
"heic2any": "^0.0.4",
|
"heic2any": "^0.0.4",
|
||||||
"http-proxy-middleware": "^3.0.5",
|
"http-proxy-middleware": "^3.0.5",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn, fuzzyMatch } from '@/lib/utils';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useAgentsStore } from '@/stores/useAgentsStore';
|
import { useAgentsStore } from '@/stores/useAgentsStore';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
@@ -62,9 +62,9 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const normalizedQuery = searchQuery.trim().toLowerCase();
|
const normalizedQuery = searchQuery.trim();
|
||||||
const matches = normalizedQuery.length
|
const matches = normalizedQuery.length
|
||||||
? filtered.filter((agent) => agent.name.toLowerCase().includes(normalizedQuery))
|
? filtered.filter((agent) => fuzzyMatch(agent.name, normalizedQuery))
|
||||||
: filtered;
|
: filtered;
|
||||||
|
|
||||||
matches.sort((a, b) => a.name.localeCompare(b.name));
|
matches.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { RiCommandLine, RiFileLine, RiFlashlightLine, RiRefreshLine, RiScissorsLine, RiTerminalBoxLine, RiArrowGoBackLine, RiArrowGoForwardLine, RiTimeLine } from '@remixicon/react';
|
import { RiCommandLine, RiFileLine, RiFlashlightLine, RiRefreshLine, RiScissorsLine, RiTerminalBoxLine, RiArrowGoBackLine, RiArrowGoForwardLine, RiTimeLine } from '@remixicon/react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn, fuzzyMatch } from '@/lib/utils';
|
||||||
import { opencodeClient } from '@/lib/opencode/client';
|
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
import { useCommandsStore } from '@/stores/useCommandsStore';
|
import { useCommandsStore } from '@/stores/useCommandsStore';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
@@ -113,8 +112,8 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
const allowInitCommand = !hasMessagesInCurrentSession;
|
const allowInitCommand = !hasMessagesInCurrentSession;
|
||||||
const filtered = (searchQuery
|
const filtered = (searchQuery
|
||||||
? allCommands.filter(cmd =>
|
? allCommands.filter(cmd =>
|
||||||
cmd.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
fuzzyMatch(cmd.name, searchQuery) ||
|
||||||
(cmd.description && cmd.description.toLowerCase().includes(searchQuery.toLowerCase()))
|
(cmd.description && fuzzyMatch(cmd.description, searchQuery))
|
||||||
)
|
)
|
||||||
: allCommands).filter(cmd => allowInitCommand || cmd.name !== 'init');
|
: allCommands).filter(cmd => allowInitCommand || cmd.name !== 'init');
|
||||||
|
|
||||||
@@ -148,8 +147,8 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
|
|
||||||
const filtered = (searchQuery
|
const filtered = (searchQuery
|
||||||
? builtInCommands.filter(cmd =>
|
? builtInCommands.filter(cmd =>
|
||||||
cmd.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
fuzzyMatch(cmd.name, searchQuery) ||
|
||||||
(cmd.description && cmd.description.toLowerCase().includes(searchQuery.toLowerCase()))
|
(cmd.description && fuzzyMatch(cmd.description, searchQuery))
|
||||||
)
|
)
|
||||||
: builtInCommands).filter(cmd => allowInitCommand || cmd.name !== 'init');
|
: builtInCommands).filter(cmd => allowInitCommand || cmd.name !== 'init');
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import { getAgentColor } from '@/lib/agentColors';
|
|||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { calculateEditPermissionUIState, type BashPermissionSetting } from '@/lib/permissions/editPermissionDefaults';
|
import { calculateEditPermissionUIState, type BashPermissionSetting } from '@/lib/permissions/editPermissionDefaults';
|
||||||
import { getEditModeColors } from '@/lib/permissions/editModeColors';
|
import { getEditModeColors } from '@/lib/permissions/editModeColors';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn, fuzzyMatch } from '@/lib/utils';
|
||||||
import { useContextStore } from '@/stores/contextStore';
|
import { useContextStore } from '@/stores/contextStore';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
@@ -1286,19 +1286,19 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
|||||||
const renderMobileModelPanel = () => {
|
const renderMobileModelPanel = () => {
|
||||||
if (!isCompact) return null;
|
if (!isCompact) return null;
|
||||||
|
|
||||||
const normalizedQuery = mobileModelQuery.trim().toLowerCase();
|
const normalizedQuery = mobileModelQuery.trim();
|
||||||
const filteredProviders = providers
|
const filteredProviders = providers
|
||||||
.map((provider) => {
|
.map((provider) => {
|
||||||
const providerModels = Array.isArray(provider.models) ? provider.models : [];
|
const providerModels = Array.isArray(provider.models) ? provider.models : [];
|
||||||
const matchesProvider = normalizedQuery.length === 0
|
const matchesProvider = normalizedQuery.length === 0
|
||||||
? true
|
? true
|
||||||
: provider.name.toLowerCase().includes(normalizedQuery) || provider.id.toLowerCase().includes(normalizedQuery);
|
: fuzzyMatch(provider.name, normalizedQuery) || fuzzyMatch(provider.id, normalizedQuery);
|
||||||
const matchingModels = normalizedQuery.length === 0
|
const matchingModels = normalizedQuery.length === 0
|
||||||
? providerModels
|
? providerModels
|
||||||
: providerModels.filter((model: ProviderModel) => {
|
: providerModels.filter((model: ProviderModel) => {
|
||||||
const name = getModelDisplayName(model).toLowerCase();
|
const name = getModelDisplayName(model);
|
||||||
const id = typeof model.id === 'string' ? model.id.toLowerCase() : '';
|
const id = typeof model.id === 'string' ? model.id : '';
|
||||||
return name.includes(normalizedQuery) || id.includes(normalizedQuery);
|
return fuzzyMatch(name, normalizedQuery) || fuzzyMatch(id, normalizedQuery);
|
||||||
});
|
});
|
||||||
return { provider, providerModels: matchingModels, matchesProvider };
|
return { provider, providerModels: matchingModels, matchesProvider };
|
||||||
})
|
})
|
||||||
@@ -1928,13 +1928,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter models based on search query
|
// Filter models based on search query (fuzzy match)
|
||||||
const filterByQuery = (modelName: string, providerName: string, query: string) => {
|
const filterByQuery = (modelName: string, providerName: string, query: string) => {
|
||||||
if (!query.trim()) return true;
|
if (!query.trim()) return true;
|
||||||
const lowerQuery = query.toLowerCase();
|
|
||||||
return (
|
return (
|
||||||
modelName.toLowerCase().includes(lowerQuery) ||
|
fuzzyMatch(modelName, query) ||
|
||||||
providerName.toLowerCase().includes(lowerQuery)
|
fuzzyMatch(providerName, query)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn, fuzzyMatch } from '@/lib/utils';
|
||||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
|
|
||||||
@@ -36,9 +36,9 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
|
|||||||
}, [loadSkills]);
|
}, [loadSkills]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const normalizedQuery = searchQuery.trim().toLowerCase();
|
const normalizedQuery = searchQuery.trim();
|
||||||
const matches = normalizedQuery.length
|
const matches = normalizedQuery.length
|
||||||
? skills.filter((skill) => skill.name.toLowerCase().includes(normalizedQuery))
|
? skills.filter((skill) => fuzzyMatch(skill.name, normalizedQuery))
|
||||||
: skills;
|
: skills;
|
||||||
|
|
||||||
const sorted = [...matches].sort((a, b) => {
|
const sorted = [...matches].sort((a, b) => {
|
||||||
|
|||||||
@@ -128,3 +128,25 @@ export function formatDirectoryName(path: string | null | undefined, homeDirecto
|
|||||||
const name = segments.pop() || normalizedPath;
|
const name = segments.pop() || normalizedPath;
|
||||||
return name || "/";
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user