diff --git a/web/src/hooks/queries/useSlashCommands.ts b/web/src/hooks/queries/useSlashCommands.ts index a507ed1a..40eb325d 100644 --- a/web/src/hooks/queries/useSlashCommands.ts +++ b/web/src/hooks/queries/useSlashCommands.ts @@ -5,6 +5,22 @@ import type { SlashCommand } from '@/types/api' import type { Suggestion } from '@/hooks/useActiveSuggestions' import { queryKeys } from '@/lib/query-keys' +function levenshteinDistance(a: string, b: string): number { + if (a.length === 0) return b.length + if (b.length === 0) return a.length + const matrix: number[][] = [] + for (let i = 0; i <= b.length; i++) matrix[i] = [i] + for (let j = 0; j <= a.length; j++) matrix[0][j] = j + for (let i = 1; i <= b.length; i++) { + for (let j = 1; j <= a.length; j++) { + matrix[i][j] = b[i - 1] === a[j - 1] + ? matrix[i - 1][j - 1] + : Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1) + } + } + return matrix[b.length][a.length] +} + /** * Built-in slash commands per agent type. * These are shown immediately without waiting for RPC. @@ -78,14 +94,38 @@ export function useSlashCommands( }, [agentType, query.data]) const getSuggestions = useCallback(async (queryText: string): Promise => { - // queryText will be like "/clea" - strip the leading slash const searchTerm = queryText.startsWith('/') ? queryText.slice(1).toLowerCase() : queryText.toLowerCase() + if (!searchTerm) { + return commands.map(cmd => ({ + key: `/${cmd.name}`, + text: `/${cmd.name}`, + label: `/${cmd.name}`, + description: cmd.description ?? (cmd.source === 'user' ? 'Custom command' : undefined), + content: cmd.content, + source: cmd.source + })) + } + + const maxDistance = Math.max(2, Math.floor(searchTerm.length / 2)) return commands - .filter(cmd => cmd.name.toLowerCase().startsWith(searchTerm)) - .map(cmd => ({ + .map(cmd => { + const name = cmd.name.toLowerCase() + let score: number + if (name === searchTerm) score = 0 + else if (name.startsWith(searchTerm)) score = 1 + else if (name.includes(searchTerm)) score = 2 + else { + const dist = levenshteinDistance(searchTerm, name) + score = dist <= maxDistance ? 3 + dist : Infinity + } + return { cmd, score } + }) + .filter(item => item.score < Infinity) + .sort((a, b) => a.score - b.score) + .map(({ cmd }) => ({ key: `/${cmd.name}`, text: `/${cmd.name}`, label: `/${cmd.name}`,