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 */}