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 58d4b2d7b4..be47b48071 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,65 @@ 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', () => { + // 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); + }); + + 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 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 041d87325a..8abd020db2 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 { filterSlashCommands, highlightSlashCommand } from '../utils/search'; interface SlashCommand { command: string; @@ -318,14 +319,8 @@ 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 filterSlashCommands(slashCommands, query, isTerminalMode); }, [slashCommands, isTerminalMode, inputValueLower]); // Ensure selectedSlashCommandIndex is valid for the filtered list @@ -527,7 +522,9 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) { }} onMouseEnter={() => setSelectedSlashCommandIndex(idx)} > -
{cmd.command}
+
+ {highlightSlashCommand(cmd.command, inputValueLower.replace(/^\//, ''))} +
{cmd.description}
))} diff --git a/src/renderer/hooks/input/useInputKeyDown.ts b/src/renderer/hooks/input/useInputKeyDown.ts index e3c8fabf0b..eabe191964 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 { filterSlashCommands } from '../../utils/search'; // ============================================================================ // Dependencies interface @@ -205,11 +206,8 @@ 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 = filterSlashCommands(allSlashCommands, query, !!isTerminalMode); if (e.key === 'ArrowDown') { e.preventDefault(); @@ -219,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 ba96a113be..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 }; } @@ -80,7 +86,8 @@ export const fuzzyMatchWithScore = (text: string, query: string): FuzzyMatchResu text[i - 1] === ' ' || text[i - 1] === '-' || text[i - 1] === '_' || - text[i - 1] === '/' + text[i - 1] === '/' || + (extraBoundaryChars && extraBoundaryChars.includes(text[i - 1])) ) { score += 8; } @@ -117,3 +124,119 @@ export const fuzzyMatchWithScore = (text: string, query: string): FuzzyMatchResu return { matches, score }; }; + +/** + * 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, + 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; + 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 29a0700c9c..f0cc16563b 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 { filterSlashCommands, highlightSlashCommand } from '../../renderer/utils/search'; /** * Slash command definition @@ -92,22 +93,17 @@ 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 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]); @@ -291,7 +287,12 @@ export function SlashCommandAutocomplete({ fontWeight: 500, }} > - {cmd.command} + {highlightSlashCommand( + cmd.command, + inputValue && inputValue.startsWith('/') + ? (inputValue || '').toLowerCase().replace(/^\//, '') + : '' + )} {/* Command description */}