From e72bea0495c5d9c8df8fb5d45a09791db00953a7 Mon Sep 17 00:00:00 2001 From: Florian Agsteiner Date: Sun, 29 Mar 2026 10:48:09 +0200 Subject: [PATCH 1/2] feat: Add fuzzy search for slash commands --- src/__tests__/renderer/utils/search.test.ts | 54 ++++++++++++++++++++- src/renderer/components/InputArea.tsx | 49 +++++++++++++++---- src/renderer/hooks/input/useInputKeyDown.ts | 21 ++++++-- src/renderer/utils/search.ts | 28 ++++++++++- src/web/mobile/SlashCommandAutocomplete.tsx | 52 +++++++++++++++----- 5 files changed, 174 insertions(+), 30 deletions(-) diff --git a/src/__tests__/renderer/utils/search.test.ts b/src/__tests__/renderer/utils/search.test.ts index 58d4b2d7b4..4c49fbd71d 100644 --- a/src/__tests__/renderer/utils/search.test.ts +++ b/src/__tests__/renderer/utils/search.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { fuzzyMatch, fuzzyMatchWithScore, FuzzyMatchResult } from '../../../renderer/utils/search'; +import { + fuzzyMatch, + fuzzyMatchWithScore, + fuzzyMatchWithIndices, + FuzzyMatchResult, +} from '../../../renderer/utils/search'; describe('search utils', () => { describe('fuzzyMatch', () => { @@ -492,4 +497,51 @@ describe('search utils', () => { }); }); }); + + describe('fuzzyMatchWithIndices', () => { + it('returns empty array for empty query', () => { + expect(fuzzyMatchWithIndices('anything', '')).toEqual([]); + }); + + it('returns empty array for non-match', () => { + expect(fuzzyMatchWithIndices('history', 'xyz')).toEqual([]); + }); + + it('returns correct indices for prefix match', () => { + expect(fuzzyMatchWithIndices('history', 'hist')).toEqual([0, 1, 2, 3]); + }); + + it('returns correct indices for fuzzy match across dot boundary', () => { + const indices = fuzzyMatchWithIndices('speckit.plan', 'splan'); + expect(indices).toHaveLength(5); + expect(indices[0]).toBe(0); // s + }); + + it('returns empty array when query is longer than text', () => { + expect(fuzzyMatchWithIndices('hi', 'history')).toEqual([]); + }); + }); + + describe('slash command fuzzy matching', () => { + it('matches boundary-anchored abbreviation (splan → speckit.plan)', () => { + expect(fuzzyMatchWithScore('speckit.plan', 'splan').matches).toBe(true); + }); + + it('ranks prefix match above fuzzy match', () => { + const prefix = fuzzyMatchWithScore('speckit.plan', 'spec'); + const fuzzy = fuzzyMatchWithScore('speckit.plan', 'splan'); + expect(prefix.score).toBeGreaterThan(fuzzy.score); + }); + + it('matches across dot boundaries', () => { + expect(fuzzyMatchWithScore('openspec.plan', 'oplan').matches).toBe(true); + expect(fuzzyMatchWithScore('speckit.list', 'slist').matches).toBe(true); + }); + + it('gives dot boundary bonus', () => { + const dotBoundary = fuzzyMatchWithScore('hello.world', 'w'); + const midWord = fuzzyMatchWithScore('hewollo', 'w'); + expect(dotBoundary.score).toBeGreaterThan(midWord.score); + }); + }); }); diff --git a/src/renderer/components/InputArea.tsx b/src/renderer/components/InputArea.tsx index 041d87325a..1ee1b1d028 100644 --- a/src/renderer/components/InputArea.tsx +++ b/src/renderer/components/InputArea.tsx @@ -38,6 +38,7 @@ import { SummarizeProgressOverlay } from './SummarizeProgressOverlay'; import { WizardInputPanel } from './InlineWizard'; import { useAgentCapabilities, useScrollIntoView } from '../hooks'; import { getProviderDisplayName } from '../utils/sessionValidation'; +import { fuzzyMatchWithScore, fuzzyMatchWithIndices } from '../utils/search'; interface SlashCommand { command: string; @@ -318,14 +319,21 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) { // recalculating on every render - inputValue changes on every keystroke const inputValueLower = useMemo(() => inputValue.toLowerCase(), [inputValue]); const filteredSlashCommands = useMemo(() => { - return slashCommands.filter((cmd) => { - // Check if command is only available in terminal mode - if (cmd.terminalOnly && !isTerminalMode) return false; - // Check if command is only available in AI mode - if (cmd.aiOnly && isTerminalMode) return false; - // Check if command matches input - return cmd.command.toLowerCase().startsWith(inputValueLower); - }); + const query = inputValueLower.replace(/^\//, ''); + return slashCommands + .filter((cmd) => { + if (cmd.terminalOnly && !isTerminalMode) return false; + if (cmd.aiOnly && isTerminalMode) return false; + if (!query) return true; + return fuzzyMatchWithScore(cmd.command.slice(1), query).matches; + }) + .sort((a, b) => { + if (!query) return 0; + return ( + fuzzyMatchWithScore(b.command.slice(1), query).score - + fuzzyMatchWithScore(a.command.slice(1), query).score + ); + }); }, [slashCommands, isTerminalMode, inputValueLower]); // Ensure selectedSlashCommandIndex is valid for the filtered list @@ -527,7 +535,30 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) { }} onMouseEnter={() => setSelectedSlashCommandIndex(idx)} > -
{cmd.command}
+
+ {(() => { + const query = inputValueLower.replace(/^\//, ''); + if (!query) return cmd.command; + // Match indices on the part after "/", then offset by 1 for the "/" + const indices = new Set( + fuzzyMatchWithIndices(cmd.command.slice(1).toLowerCase(), query).map( + (i) => i + 1 + ) + ); + if (indices.size === 0) return cmd.command; + return Array.from(cmd.command).map((ch, i) => + indices.has(i) ? ( + + {ch} + + ) : ( + + {ch} + + ) + ); + })()} +
{cmd.description}
))} diff --git a/src/renderer/hooks/input/useInputKeyDown.ts b/src/renderer/hooks/input/useInputKeyDown.ts index e3c8fabf0b..59c2515898 100644 --- a/src/renderer/hooks/input/useInputKeyDown.ts +++ b/src/renderer/hooks/input/useInputKeyDown.ts @@ -16,6 +16,7 @@ import { useInputContext } from '../../contexts/InputContext'; import { useSessionStore, selectActiveSession } from '../../stores/sessionStore'; import { useUIStore } from '../../stores/uiStore'; import { useSettingsStore } from '../../stores/settingsStore'; +import { fuzzyMatchWithScore } from '../../utils/search'; // ============================================================================ // Dependencies interface @@ -205,11 +206,21 @@ export function useInputKeyDown(deps: InputKeyDownDeps): InputKeyDownReturn { // Handle slash command autocomplete if (slashCommandOpen) { const isTerminalMode = activeSession?.inputMode === 'terminal'; - const filteredCommands = allSlashCommands.filter((cmd) => { - if ('terminalOnly' in cmd && cmd.terminalOnly && !isTerminalMode) return false; - if ('aiOnly' in cmd && cmd.aiOnly && isTerminalMode) return false; - return cmd.command.toLowerCase().startsWith(inputValue.toLowerCase()); - }); + const query = inputValue.toLowerCase().replace(/^\//, ''); + const filteredCommands = allSlashCommands + .filter((cmd) => { + if ('terminalOnly' in cmd && cmd.terminalOnly && !isTerminalMode) return false; + if ('aiOnly' in cmd && cmd.aiOnly && isTerminalMode) return false; + if (!query) return true; + return fuzzyMatchWithScore(cmd.command.slice(1), query).matches; + }) + .sort((a, b) => { + if (!query) return 0; + return ( + fuzzyMatchWithScore(b.command.slice(1), query).score - + fuzzyMatchWithScore(a.command.slice(1), query).score + ); + }); if (e.key === 'ArrowDown') { e.preventDefault(); diff --git a/src/renderer/utils/search.ts b/src/renderer/utils/search.ts index ba96a113be..ab2b53546e 100644 --- a/src/renderer/utils/search.ts +++ b/src/renderer/utils/search.ts @@ -74,13 +74,14 @@ export const fuzzyMatchWithScore = (text: string, query: string): FuzzyMatchResu score += 5; } - // Bonus for match at word boundary (after space, dash, underscore, or start) + // Bonus for match at word boundary (after space, dash, underscore, dot, or start) if ( i === 0 || text[i - 1] === ' ' || text[i - 1] === '-' || text[i - 1] === '_' || - text[i - 1] === '/' + text[i - 1] === '/' || + text[i - 1] === '.' ) { score += 8; } @@ -117,3 +118,26 @@ export const fuzzyMatchWithScore = (text: string, query: string): FuzzyMatchResu return { matches, score }; }; + +/** + * Returns the indices in `text` that match `query` as a fuzzy subsequence. + * Uses the same greedy left-to-right algorithm as fuzzyMatch. + * Returns empty array if no match. + */ +export const fuzzyMatchWithIndices = (text: string, query: string): number[] => { + if (!query || query.length > text.length) return []; + + const lowerText = text.toLowerCase(); + const lowerQuery = query.toLowerCase(); + const indices: number[] = []; + let qi = 0; + + for (let i = 0; i < lowerText.length && qi < lowerQuery.length; i++) { + if (lowerText[i] === lowerQuery[qi]) { + indices.push(i); + qi++; + } + } + + return qi === lowerQuery.length ? indices : []; +}; diff --git a/src/web/mobile/SlashCommandAutocomplete.tsx b/src/web/mobile/SlashCommandAutocomplete.tsx index 29a0700c9c..ade4cff5a3 100644 --- a/src/web/mobile/SlashCommandAutocomplete.tsx +++ b/src/web/mobile/SlashCommandAutocomplete.tsx @@ -12,10 +12,11 @@ * - Scrollable list for many commands */ -import React, { useEffect, useRef, useCallback } from 'react'; +import React, { useEffect, useRef, useCallback, useMemo } from 'react'; import { useThemeColors } from '../components/ThemeProvider'; import type { InputMode } from './CommandInputBar'; import { MIN_TOUCH_TARGET } from './constants'; +import { fuzzyMatchWithScore, fuzzyMatchWithIndices } from '../../renderer/utils/search'; /** * Slash command definition @@ -92,17 +93,24 @@ export function SlashCommandAutocomplete({ const colors = useThemeColors(); const containerRef = useRef(null); - // Filter commands based on input and mode - const filteredCommands = commands.filter((cmd) => { - // Check if command is only available in terminal mode - if (cmd.terminalOnly && inputMode !== 'terminal') return false; - // Check if command is only available in AI mode - if (cmd.aiOnly && inputMode === 'terminal') return false; - // If input is empty or doesn't start with /, show all commands (opened via button) - if (!inputValue || !inputValue.startsWith('/')) return true; - // Check if command matches input (case insensitive) - return cmd.command.toLowerCase().startsWith(inputValue.toLowerCase()); - }); + // Filter commands based on input and mode (fuzzy matching) + const filteredCommands = useMemo(() => { + const query = (inputValue || '').toLowerCase().replace(/^\//, ''); + return commands + .filter((cmd) => { + if (cmd.terminalOnly && inputMode !== 'terminal') return false; + if (cmd.aiOnly && inputMode === 'terminal') return false; + if (!inputValue || !inputValue.startsWith('/') || !query) return true; + return fuzzyMatchWithScore(cmd.command.slice(1), query).matches; + }) + .sort((a, b) => { + if (!query) return 0; + return ( + fuzzyMatchWithScore(b.command.slice(1), query).score - + fuzzyMatchWithScore(a.command.slice(1), query).score + ); + }); + }, [commands, inputMode, inputValue]); // Clamp selectedIndex to valid range when filtered list changes useEffect(() => { @@ -291,7 +299,25 @@ export function SlashCommandAutocomplete({ fontWeight: 500, }} > - {cmd.command} + {(() => { + const query = (inputValue || '').toLowerCase().replace(/^\//, ''); + if (!query) return cmd.command; + const indices = new Set( + fuzzyMatchWithIndices(cmd.command.slice(1).toLowerCase(), query).map((i) => i + 1) + ); + if (indices.size === 0) return cmd.command; + return Array.from(cmd.command).map((ch, i) => + indices.has(i) ? ( + + {ch} + + ) : ( + + {ch} + + ) + ); + })()} {/* Command description */}
Date: Sun, 29 Mar 2026 09:57:00 +0200 Subject: [PATCH 2/2] Address code review feedback: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract shared filterSlashCommands() and highlightSlashCommand() utilities to eliminate duplicated filter/sort pipeline across InputArea, useInputKeyDown, and mobile SlashCommandAutocomplete - Make '.' boundary scoring opt-in via extraBoundaryChars param to avoid affecting shared fuzzyMatchWithScore callers (file search, tab switcher, etc.) - Add suffix-safety lookahead to fuzzyMatchWithIndices so boundary-preferred positions are only chosen when remaining query chars can still match - Pre-compute scores once per command (map→filter→sort) instead of calling fuzzyMatchWithScore twice per comparison in sort - Clamp selectedSlashCommandIndex before Enter/Tab acceptance on desktop - Clamp to last valid index (not 0) on mobile when filtered list shrinks - Align mobile sort guard with filter guard (shouldFuzzyFilter inside useMemo) - Remove redundant shouldFuzzyFilter from useMemo deps - Fix tests for fuzzy matching (function matchers for split-span text, updated query assumptions) --- .../renderer/components/InputArea.test.tsx | 5 +- .../renderer/hooks/useInputKeyDown.test.ts | 6 +- src/__tests__/renderer/utils/search.test.ts | 34 +++-- .../mobile/SlashCommandAutocomplete.test.tsx | 17 +-- src/renderer/components/InputArea.tsx | 40 +----- src/renderer/hooks/input/useInputKeyDown.ts | 30 ++--- src/renderer/utils/search.ts | 121 ++++++++++++++++-- src/web/mobile/SlashCommandAutocomplete.tsx | 47 ++----- 8 files changed, 173 insertions(+), 127 deletions(-) diff --git a/src/__tests__/renderer/components/InputArea.test.tsx b/src/__tests__/renderer/components/InputArea.test.tsx index 9e302be645..66ab199188 100644 --- a/src/__tests__/renderer/components/InputArea.test.tsx +++ b/src/__tests__/renderer/components/InputArea.test.tsx @@ -671,8 +671,9 @@ describe('InputArea', () => { }); render(); - expect(screen.getByText('/clear')).toBeInTheDocument(); - expect(screen.queryByText('/help')).not.toBeInTheDocument(); + // Fuzzy highlight splits text into spans, so use a function matcher + expect(screen.getByText((_, el) => el?.textContent === '/clear')).toBeInTheDocument(); + expect(screen.queryByText((_, el) => el?.textContent === '/help')).not.toBeInTheDocument(); }); it('shows terminalOnly commands in terminal mode', () => { diff --git a/src/__tests__/renderer/hooks/useInputKeyDown.test.ts b/src/__tests__/renderer/hooks/useInputKeyDown.test.ts index 4283d4dc08..34854bd359 100644 --- a/src/__tests__/renderer/hooks/useInputKeyDown.test.ts +++ b/src/__tests__/renderer/hooks/useInputKeyDown.test.ts @@ -552,9 +552,9 @@ describe('Slash command autocomplete', () => { it('filters out aiOnly commands in terminal mode', () => { setActiveSession({ inputMode: 'terminal' }); - // Only /run is aiOnly, so it should be filtered out - // Input is '/r' which only matches /run - const deps = createMockDeps({ inputValue: '/r', allSlashCommands: commands }); + // /run is aiOnly, so it should be filtered out in terminal mode + // Use '/run' which exactly matches only the aiOnly command + const deps = createMockDeps({ inputValue: '/run', allSlashCommands: commands }); const { result } = renderHook(() => useInputKeyDown(deps)); const e = createKeyEvent('Enter'); diff --git a/src/__tests__/renderer/utils/search.test.ts b/src/__tests__/renderer/utils/search.test.ts index 4c49fbd71d..be47b48071 100644 --- a/src/__tests__/renderer/utils/search.test.ts +++ b/src/__tests__/renderer/utils/search.test.ts @@ -512,36 +512,50 @@ describe('search utils', () => { }); it('returns correct indices for fuzzy match across dot boundary', () => { - const indices = fuzzyMatchWithIndices('speckit.plan', 'splan'); + // With '.' as extra boundary, "splan" should match s(0) then p(7) at ".plan" boundary + const indices = fuzzyMatchWithIndices('speckit.plan', 'splan', '.'); expect(indices).toHaveLength(5); expect(indices[0]).toBe(0); // s + expect(indices[1]).toBe(8); // p (after dot, not index 1) + }); + + it('falls back to greedy when no boundary match exists', () => { + const indices = fuzzyMatchWithIndices('abcdef', 'ace'); + expect(indices).toEqual([0, 2, 4]); }); it('returns empty array when query is longer than text', () => { expect(fuzzyMatchWithIndices('hi', 'history')).toEqual([]); }); + + it('falls back to greedy when boundary choice would prevent remaining match', () => { + // "aa" in "ab.a": boundary 'a' is at index 3 (after '.'), but picking it + // for qi=0 leaves no chars for qi=1. Should fall back to index 0. + const indices = fuzzyMatchWithIndices('ab.a', 'aa', '.'); + expect(indices).toEqual([0, 3]); + }); }); describe('slash command fuzzy matching', () => { it('matches boundary-anchored abbreviation (splan → speckit.plan)', () => { - expect(fuzzyMatchWithScore('speckit.plan', 'splan').matches).toBe(true); + expect(fuzzyMatchWithScore('speckit.plan', 'splan', '.').matches).toBe(true); }); it('ranks prefix match above fuzzy match', () => { - const prefix = fuzzyMatchWithScore('speckit.plan', 'spec'); - const fuzzy = fuzzyMatchWithScore('speckit.plan', 'splan'); + const prefix = fuzzyMatchWithScore('speckit.plan', 'spec', '.'); + const fuzzy = fuzzyMatchWithScore('speckit.plan', 'splan', '.'); expect(prefix.score).toBeGreaterThan(fuzzy.score); }); it('matches across dot boundaries', () => { - expect(fuzzyMatchWithScore('openspec.plan', 'oplan').matches).toBe(true); - expect(fuzzyMatchWithScore('speckit.list', 'slist').matches).toBe(true); + expect(fuzzyMatchWithScore('openspec.plan', 'oplan', '.').matches).toBe(true); + expect(fuzzyMatchWithScore('speckit.list', 'slist', '.').matches).toBe(true); }); - it('gives dot boundary bonus', () => { - const dotBoundary = fuzzyMatchWithScore('hello.world', 'w'); - const midWord = fuzzyMatchWithScore('hewollo', 'w'); - expect(dotBoundary.score).toBeGreaterThan(midWord.score); + it('gives dot boundary bonus only when opted in', () => { + const withDot = fuzzyMatchWithScore('hello.world', 'w', '.'); + const withoutDot = fuzzyMatchWithScore('hello.world', 'w'); + expect(withDot.score).toBeGreaterThan(withoutDot.score); }); }); }); diff --git a/src/__tests__/web/mobile/SlashCommandAutocomplete.test.tsx b/src/__tests__/web/mobile/SlashCommandAutocomplete.test.tsx index 66ffad66ee..814b214ea8 100644 --- a/src/__tests__/web/mobile/SlashCommandAutocomplete.test.tsx +++ b/src/__tests__/web/mobile/SlashCommandAutocomplete.test.tsx @@ -109,18 +109,19 @@ describe('SlashCommandAutocomplete', () => { it('filters commands by prefix when input starts with /', () => { render(); - expect(screen.getByText('/clear')).toBeInTheDocument(); - expect(screen.queryByText('/history')).not.toBeInTheDocument(); + // Fuzzy highlight splits text into spans, so use a function matcher + expect(screen.getByText((_, el) => el?.textContent === '/clear')).toBeInTheDocument(); + expect(screen.queryByText((_, el) => el?.textContent === '/history')).not.toBeInTheDocument(); }); it('filtering is case insensitive', () => { render(); - expect(screen.getByText('/clear')).toBeInTheDocument(); + expect(screen.getByText((_, el) => el?.textContent === '/clear')).toBeInTheDocument(); }); it('shows exact match', () => { render(); - expect(screen.getByText('/clear')).toBeInTheDocument(); + expect(screen.getByText((_, el) => el?.textContent === '/clear')).toBeInTheDocument(); }); }); @@ -184,8 +185,8 @@ describe('SlashCommandAutocomplete', () => { inputMode="ai" /> ); - expect(screen.getByText('/custom1')).toBeInTheDocument(); - expect(screen.queryByText('/custom2')).not.toBeInTheDocument(); + expect(screen.getByText((_, el) => el?.textContent === '/custom1')).toBeInTheDocument(); + expect(screen.queryByText((_, el) => el?.textContent === '/custom2')).not.toBeInTheDocument(); }); }); @@ -273,8 +274,8 @@ describe('SlashCommandAutocomplete', () => { ); // Index 5 is out of range for default commands (only 2 in AI mode) - // Effect should clamp to 0 - expect(onSelectedIndexChange).toHaveBeenCalledWith(0); + // Effect should clamp to last valid index + expect(onSelectedIndexChange).toHaveBeenCalledWith(1); }); }); diff --git a/src/renderer/components/InputArea.tsx b/src/renderer/components/InputArea.tsx index 1ee1b1d028..8abd020db2 100644 --- a/src/renderer/components/InputArea.tsx +++ b/src/renderer/components/InputArea.tsx @@ -38,7 +38,7 @@ import { SummarizeProgressOverlay } from './SummarizeProgressOverlay'; import { WizardInputPanel } from './InlineWizard'; import { useAgentCapabilities, useScrollIntoView } from '../hooks'; import { getProviderDisplayName } from '../utils/sessionValidation'; -import { fuzzyMatchWithScore, fuzzyMatchWithIndices } from '../utils/search'; +import { filterSlashCommands, highlightSlashCommand } from '../utils/search'; interface SlashCommand { command: string; @@ -320,20 +320,7 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) { const inputValueLower = useMemo(() => inputValue.toLowerCase(), [inputValue]); const filteredSlashCommands = useMemo(() => { const query = inputValueLower.replace(/^\//, ''); - return slashCommands - .filter((cmd) => { - if (cmd.terminalOnly && !isTerminalMode) return false; - if (cmd.aiOnly && isTerminalMode) return false; - if (!query) return true; - return fuzzyMatchWithScore(cmd.command.slice(1), query).matches; - }) - .sort((a, b) => { - if (!query) return 0; - return ( - fuzzyMatchWithScore(b.command.slice(1), query).score - - fuzzyMatchWithScore(a.command.slice(1), query).score - ); - }); + return filterSlashCommands(slashCommands, query, isTerminalMode); }, [slashCommands, isTerminalMode, inputValueLower]); // Ensure selectedSlashCommandIndex is valid for the filtered list @@ -536,28 +523,7 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) { onMouseEnter={() => setSelectedSlashCommandIndex(idx)} >
- {(() => { - const query = inputValueLower.replace(/^\//, ''); - if (!query) return cmd.command; - // Match indices on the part after "/", then offset by 1 for the "/" - const indices = new Set( - fuzzyMatchWithIndices(cmd.command.slice(1).toLowerCase(), query).map( - (i) => i + 1 - ) - ); - if (indices.size === 0) return cmd.command; - return Array.from(cmd.command).map((ch, i) => - indices.has(i) ? ( - - {ch} - - ) : ( - - {ch} - - ) - ); - })()} + {highlightSlashCommand(cmd.command, inputValueLower.replace(/^\//, ''))}
{cmd.description}
diff --git a/src/renderer/hooks/input/useInputKeyDown.ts b/src/renderer/hooks/input/useInputKeyDown.ts index 59c2515898..eabe191964 100644 --- a/src/renderer/hooks/input/useInputKeyDown.ts +++ b/src/renderer/hooks/input/useInputKeyDown.ts @@ -16,7 +16,7 @@ import { useInputContext } from '../../contexts/InputContext'; import { useSessionStore, selectActiveSession } from '../../stores/sessionStore'; import { useUIStore } from '../../stores/uiStore'; import { useSettingsStore } from '../../stores/settingsStore'; -import { fuzzyMatchWithScore } from '../../utils/search'; +import { filterSlashCommands } from '../../utils/search'; // ============================================================================ // Dependencies interface @@ -207,20 +207,7 @@ export function useInputKeyDown(deps: InputKeyDownDeps): InputKeyDownReturn { if (slashCommandOpen) { const isTerminalMode = activeSession?.inputMode === 'terminal'; const query = inputValue.toLowerCase().replace(/^\//, ''); - const filteredCommands = allSlashCommands - .filter((cmd) => { - if ('terminalOnly' in cmd && cmd.terminalOnly && !isTerminalMode) return false; - if ('aiOnly' in cmd && cmd.aiOnly && isTerminalMode) return false; - if (!query) return true; - return fuzzyMatchWithScore(cmd.command.slice(1), query).matches; - }) - .sort((a, b) => { - if (!query) return 0; - return ( - fuzzyMatchWithScore(b.command.slice(1), query).score - - fuzzyMatchWithScore(a.command.slice(1), query).score - ); - }); + const filteredCommands = filterSlashCommands(allSlashCommands, query, !!isTerminalMode); if (e.key === 'ArrowDown') { e.preventDefault(); @@ -230,11 +217,14 @@ export function useInputKeyDown(deps: InputKeyDownDeps): InputKeyDownReturn { setSelectedSlashCommandIndex((prev) => Math.max(prev - 1, 0)); } else if (e.key === 'Tab' || e.key === 'Enter') { e.preventDefault(); - if (filteredCommands[selectedSlashCommandIndex]) { - setInputValue(filteredCommands[selectedSlashCommandIndex].command); - setSlashCommandOpen(false); - inputRef.current?.focus(); - } + if (filteredCommands.length === 0) return; + const clampedIndex = Math.max( + 0, + Math.min(selectedSlashCommandIndex, filteredCommands.length - 1) + ); + setInputValue(filteredCommands[clampedIndex].command); + setSlashCommandOpen(false); + inputRef.current?.focus(); } else if (e.key === 'Escape') { e.preventDefault(); setSlashCommandOpen(false); diff --git a/src/renderer/utils/search.ts b/src/renderer/utils/search.ts index ab2b53546e..f45e431248 100644 --- a/src/renderer/utils/search.ts +++ b/src/renderer/utils/search.ts @@ -1,3 +1,5 @@ +import React from 'react'; + /** * Fuzzy search result with scoring information */ @@ -40,7 +42,11 @@ export const fuzzyMatch = (text: string, query: string): boolean => { * @param query - The search query * @returns FuzzyMatchResult with matches boolean and score for ranking */ -export const fuzzyMatchWithScore = (text: string, query: string): FuzzyMatchResult => { +export const fuzzyMatchWithScore = ( + text: string, + query: string, + extraBoundaryChars?: string +): FuzzyMatchResult => { if (!query) { return { matches: true, score: 0 }; } @@ -74,14 +80,14 @@ export const fuzzyMatchWithScore = (text: string, query: string): FuzzyMatchResu score += 5; } - // Bonus for match at word boundary (after space, dash, underscore, dot, or start) + // Bonus for match at word boundary (after space, dash, underscore, or start) if ( i === 0 || text[i - 1] === ' ' || text[i - 1] === '-' || text[i - 1] === '_' || text[i - 1] === '/' || - text[i - 1] === '.' + (extraBoundaryChars && extraBoundaryChars.includes(text[i - 1])) ) { score += 8; } @@ -120,24 +126,117 @@ export const fuzzyMatchWithScore = (text: string, query: string): FuzzyMatchResu }; /** - * Returns the indices in `text` that match `query` as a fuzzy subsequence. - * Uses the same greedy left-to-right algorithm as fuzzyMatch. + * Returns the indices in `text` that match `query` as a fuzzy subsequence, + * preferring boundary-anchored positions (after separator chars). * Returns empty array if no match. */ -export const fuzzyMatchWithIndices = (text: string, query: string): number[] => { +export const fuzzyMatchWithIndices = ( + text: string, + query: string, + extraBoundaryChars?: string +): number[] => { if (!query || query.length > text.length) return []; const lowerText = text.toLowerCase(); const lowerQuery = query.toLowerCase(); + const defaultBoundary = ' -_/'; + const boundaryChars = extraBoundaryChars ? defaultBoundary + extraBoundaryChars : defaultBoundary; + + const isBoundary = (i: number) => i === 0 || boundaryChars.includes(text[i - 1]); + + // Check if lowerQuery[from..] is a subsequence of lowerText[after..] + const canMatchRest = (after: number, from: number): boolean => { + let q = from; + for (let j = after; j < lowerText.length && q < lowerQuery.length; j++) { + if (lowerText[j] === lowerQuery[q]) q++; + } + return q === lowerQuery.length; + }; + + // For each query char, prefer a boundary-anchored position, but only if + // the remaining query can still be matched after that position. const indices: number[] = []; let qi = 0; - - for (let i = 0; i < lowerText.length && qi < lowerQuery.length; i++) { - if (lowerText[i] === lowerQuery[qi]) { - indices.push(i); - qi++; + let ti = 0; + + while (qi < lowerQuery.length && ti < lowerText.length) { + let firstMatch = -1; + let boundaryMatch = -1; + + for (let j = ti; j < lowerText.length; j++) { + if (lowerText[j] === lowerQuery[qi]) { + if (firstMatch === -1) firstMatch = j; + if (isBoundary(j) && canMatchRest(j + 1, qi + 1)) { + boundaryMatch = j; + break; + } + } } + + if (firstMatch === -1) return []; // no match possible + + const chosen = boundaryMatch !== -1 ? boundaryMatch : firstMatch; + indices.push(chosen); + ti = chosen + 1; + qi++; } return qi === lowerQuery.length ? indices : []; }; + +/** + * Slash command definition shape accepted by shared helpers. + */ +interface SlashCommandLike { + command: string; + terminalOnly?: boolean; + aiOnly?: boolean; +} + +/** + * Filter and sort slash commands by fuzzy match against query. + * Single-pass scoring: each command is scored at most once. + */ +export const filterSlashCommands = ( + commands: T[], + query: string, + isTerminalMode: boolean +): T[] => { + return commands + .filter((cmd) => { + if (cmd.terminalOnly && !isTerminalMode) return false; + if (cmd.aiOnly && isTerminalMode) return false; + return true; + }) + .map((cmd) => { + const { matches, score } = query + ? fuzzyMatchWithScore(cmd.command.slice(1), query, '.') + : { matches: true, score: 0 }; + return { cmd, matches, score }; + }) + .filter(({ matches }) => matches) + .sort((a, b) => b.score - a.score) + .map(({ cmd }) => cmd); +}; + +/** + * Render a slash command with fuzzy-matched characters highlighted. + * Returns a React node: plain string when no query, spans with bold/dim otherwise. + */ +export const highlightSlashCommand = (command: string, query: string): React.ReactNode => { + if (!query) return command; + const indices = new Set( + fuzzyMatchWithIndices(command.slice(1).toLowerCase(), query, '.').map((i) => i + 1) + ); + if (indices.size === 0) return command; + return Array.from(command).map((ch, i) => + React.createElement( + 'span', + { + key: i, + style: indices.has(i) ? { fontWeight: 700 } : { opacity: 0.8 }, + }, + ch + ) + ); +}; diff --git a/src/web/mobile/SlashCommandAutocomplete.tsx b/src/web/mobile/SlashCommandAutocomplete.tsx index ade4cff5a3..f0cc16563b 100644 --- a/src/web/mobile/SlashCommandAutocomplete.tsx +++ b/src/web/mobile/SlashCommandAutocomplete.tsx @@ -16,7 +16,7 @@ import React, { useEffect, useRef, useCallback, useMemo } from 'react'; import { useThemeColors } from '../components/ThemeProvider'; import type { InputMode } from './CommandInputBar'; import { MIN_TOUCH_TARGET } from './constants'; -import { fuzzyMatchWithScore, fuzzyMatchWithIndices } from '../../renderer/utils/search'; +import { filterSlashCommands, highlightSlashCommand } from '../../renderer/utils/search'; /** * Slash command definition @@ -95,27 +95,15 @@ export function SlashCommandAutocomplete({ // Filter commands based on input and mode (fuzzy matching) const filteredCommands = useMemo(() => { - const query = (inputValue || '').toLowerCase().replace(/^\//, ''); - return commands - .filter((cmd) => { - if (cmd.terminalOnly && inputMode !== 'terminal') return false; - if (cmd.aiOnly && inputMode === 'terminal') return false; - if (!inputValue || !inputValue.startsWith('/') || !query) return true; - return fuzzyMatchWithScore(cmd.command.slice(1), query).matches; - }) - .sort((a, b) => { - if (!query) return 0; - return ( - fuzzyMatchWithScore(b.command.slice(1), query).score - - fuzzyMatchWithScore(a.command.slice(1), query).score - ); - }); + const shouldFuzzyFilter = inputValue && inputValue.startsWith('/'); + const query = shouldFuzzyFilter ? (inputValue || '').toLowerCase().replace(/^\//, '') : ''; + return filterSlashCommands(commands, query, inputMode === 'terminal'); }, [commands, inputMode, inputValue]); // Clamp selectedIndex to valid range when filtered list changes useEffect(() => { if (filteredCommands.length > 0 && selectedIndex >= filteredCommands.length) { - onSelectedIndexChange?.(0); + onSelectedIndexChange?.(filteredCommands.length - 1); } }, [filteredCommands.length, selectedIndex, onSelectedIndexChange]); @@ -299,25 +287,12 @@ export function SlashCommandAutocomplete({ fontWeight: 500, }} > - {(() => { - const query = (inputValue || '').toLowerCase().replace(/^\//, ''); - if (!query) return cmd.command; - const indices = new Set( - fuzzyMatchWithIndices(cmd.command.slice(1).toLowerCase(), query).map((i) => i + 1) - ); - if (indices.size === 0) return cmd.command; - return Array.from(cmd.command).map((ch, i) => - indices.has(i) ? ( - - {ch} - - ) : ( - - {ch} - - ) - ); - })()} + {highlightSlashCommand( + cmd.command, + inputValue && inputValue.startsWith('/') + ? (inputValue || '').toLowerCase().replace(/^\//, '') + : '' + )}
{/* Command description */}