feat: Add fuzzy search for slash commands#685
feat: Add fuzzy search for slash commands#685scriptease wants to merge 2 commits intoRunMaestro:rcfrom
Conversation
📝 WalkthroughWalkthroughReplace prefix-only slash-command filtering with fuzzy subsequence matching, add boundary-aware scoring and index-based highlighting, update UI and hook logic to use the new utilities, and adjust tests to tolerate highlighted/split DOM rendering and updated selected-index clamping. Changes
Sequence Diagram(s)sequenceDiagram
participant Input as InputArea / Mobile UI
participant Hook as useInputKeyDown
participant Utils as search.ts (filter/highlight)
participant Renderer as DOM Renderer
Input->>Hook: user types "/..." (inputValue)
Hook->>Utils: normalize query (strip '/'), call filterSlashCommands(query, isTerminalMode)
Utils->>Utils: fuzzyMatchWithScore & fuzzyMatchWithIndices (boundary-aware)
Utils-->>Hook: return sorted filteredCommands
Hook->>Renderer: provide filteredCommands + highlighted labels (React nodes)
Renderer->>Input: render dropdown (may split text into spans)
Input->>Hook: user activates selection (Tab/Enter)
Hook->>Hook: clamp index -> pick command -> apply selection
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
5ac9ead to
76fc248
Compare
Greptile SummaryThis PR adds fuzzy matching to slash command autocomplete across desktop ( Key changes:
Minor style concerns:
Confidence Score: 5/5Safe to merge — all findings are P2 style/maintainability suggestions with no correctness or data-integrity impact. The core algorithms are logically sound and consistent. The feature is well-tested with edge cases covered. All remaining issues are code-duplication and redundant-dep style concerns that don't affect runtime correctness. No files require special attention. The duplicated pipeline in Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["User types in input (e.g. /splan)"] --> B["Extract query\nquery = inputValue.replace(/^\//, '')"]\n B --> C{query empty?}\n C -- yes --> D["Show all commands\nscore = 0 for all"]\n C -- no --> E["fuzzyMatchWithScore\n(cmd.slice(1), query, '.')"]\n E --> F{matches?}\n F -- no --> G["Filter out command"]\n F -- yes --> H["Keep cmd with score"]\n H --> I["Sort by score desc"]\n I --> J["Render filtered list"]\n J --> K["For each visible cmd:\nfuzzyMatchWithIndices"]\n K --> L{indices found?}\n L -- yes --> M["Render chars as bold/dim spans"]\n L -- no --> N["Render plain command text"]\n M --> O["Display highlighted autocomplete"]\n N --> O
Reviews (1): Last reviewed commit: "Address code review issues:" | Re-trigger Greptile |
| return cmd.command.toLowerCase().startsWith(inputValue.toLowerCase()); | ||
| }); | ||
| // Filter commands based on input and mode (fuzzy matching) | ||
| const shouldFuzzyFilter = inputValue && inputValue.startsWith('/'); |
There was a problem hiding this comment.
Redundant
shouldFuzzyFilter in useMemo deps
shouldFuzzyFilter is a derived boolean directly from inputValue (inputValue && inputValue.startsWith('/')), which is already listed as a useMemo dependency. Including shouldFuzzyFilter in the deps array is redundant — inputValue changing will always cause shouldFuzzyFilter to update and the memo to re-run anyway.
Moving shouldFuzzyFilter inside the useMemo makes the dependency explicit and removes the redundant dep entry.
| 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; | ||
| 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); |
There was a problem hiding this comment.
Filtering logic duplicated between
useInputKeyDown and InputArea
The full fuzzy-filter + sort pipeline (filter by mode → fuzzyMatchWithScore → sort by score) is reproduced identically here and in InputArea.tsx's filteredSlashCommands memo. Both blocks read from the same allSlashCommands/slashCommands prop and produce the same ordered list.
If these two ever diverge (e.g. one adds a tie-breaker or a secondary filter), pressing Tab/Enter could select a different command than the one visually highlighted — a subtle but confusing UX bug.
A shared selector or utility function (e.g. getFilteredSlashCommands(commands, inputValue, isTerminalMode)) would keep both in lockstep and reduce surface area for future drift.
| onMouseEnter={() => setSelectedSlashCommandIndex(idx)} | ||
| > | ||
| <div className="font-mono text-sm">{cmd.command}</div> | ||
| <div className="font-mono text-sm"> | ||
| {(() => { | ||
| 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) ? ( | ||
| <span key={i} style={{ fontWeight: 700 }}> | ||
| {ch} | ||
| </span> | ||
| ) : ( | ||
| <span key={i} style={{ opacity: 0.8 }}> | ||
| {ch} | ||
| </span> | ||
| ) | ||
| ); | ||
| })()} | ||
| </div> | ||
| <div className="text-xs opacity-70 mt-0.5">{cmd.description}</div> | ||
| </button> |
There was a problem hiding this comment.
Duplicated highlight-rendering IIFE across desktop and mobile
The IIFE that calls fuzzyMatchWithIndices and maps characters to <span> elements is copy-pasted verbatim into both InputArea.tsx (here) and SlashCommandAutocomplete.tsx (lines 305–325). Any future changes to highlight style, accessibility attributes, or the index-offset logic need to be applied in two places.
Consider extracting this into a small shared helper, for example a renderHighlightedCommand(command, query) function in a shared utility file. Both components then reduce to a single call: {renderHighlightedCommand(cmd.command, query)}.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/renderer/hooks/input/useInputKeyDown.ts (1)
226-236:⚠️ Potential issue | 🟠 MajorClamp stale slash-command indices before every use.
Line 228 can write
-1whenfilteredCommandsis empty, and Line 231 still decrements stale high values one press at a time after the list shrinks. Desktop only hides that withsafeSelectedIndexduring render, so Arrow navigation can get stuck on invisible entries and Enter/Tab can no-op after an empty-result ArrowDown.Suggested fix
if (e.key === 'ArrowDown') { e.preventDefault(); - setSelectedSlashCommandIndex((prev) => Math.min(prev + 1, filteredCommands.length - 1)); + setSelectedSlashCommandIndex((prev) => + Math.max(0, Math.min(prev + 1, filteredCommands.length - 1)) + ); } else if (e.key === 'ArrowUp') { e.preventDefault(); - setSelectedSlashCommandIndex((prev) => Math.max(prev - 1, 0)); + setSelectedSlashCommandIndex((prev) => + Math.max(0, Math.min(prev - 1, filteredCommands.length - 1)) + ); } else if (e.key === 'Tab' || e.key === 'Enter') { e.preventDefault(); - const clampedIndex = Math.min(selectedSlashCommandIndex, filteredCommands.length - 1); + const clampedIndex = Math.max( + 0, + Math.min(selectedSlashCommandIndex, filteredCommands.length - 1) + ); if (clampedIndex >= 0 && filteredCommands[clampedIndex]) { setInputValue(filteredCommands[clampedIndex].command);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/renderer/hooks/input/useInputKeyDown.ts` around lines 226 - 236, The Arrow key handlers and Enter/Tab use a potentially stale selectedSlashCommandIndex against filteredCommands; update the handlers to always clamp indices against the current filteredCommands.length before using or updating them: in ArrowDown/ArrowUp update setSelectedSlashCommandIndex using a clamp that computes min/max against filteredCommands.length - 1 and 0 (using filteredCommands.length at call time), and before using selectedSlashCommandIndex for Enter/Tab compute a clampedIndex = Math.min(Math.max(selectedSlashCommandIndex, 0), filteredCommands.length - 1) and only call setInputValue(filteredCommands[clampedIndex].command) when clampedIndex >= 0 and filteredCommands[clampedIndex] exists; ensure all uses reference selectedSlashCommandIndex, setSelectedSlashCommandIndex, filteredCommands, and setInputValue so navigation and selection never operate on out-of-range indices.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/web/mobile/SlashCommandAutocomplete.tsx`:
- Around line 96-116: The code builds a `query` from any inputValue which can
leak into highlighting even when fuzzy mode is disabled; change the query
computation inside the `useMemo` for `filteredCommands` so it is only derived
when `shouldFuzzyFilter` is true (e.g. const query = shouldFuzzyFilter ?
(inputValue || '').toLowerCase().replace(/^\//, '') : '';), keep using
`shouldFuzzyFilter` in the fuzzy path that calls `fuzzyMatchWithScore`, and
ensure `shouldFuzzyFilter` remains in the hook deps so highlighting only occurs
for slash queries.
---
Outside diff comments:
In `@src/renderer/hooks/input/useInputKeyDown.ts`:
- Around line 226-236: The Arrow key handlers and Enter/Tab use a potentially
stale selectedSlashCommandIndex against filteredCommands; update the handlers to
always clamp indices against the current filteredCommands.length before using or
updating them: in ArrowDown/ArrowUp update setSelectedSlashCommandIndex using a
clamp that computes min/max against filteredCommands.length - 1 and 0 (using
filteredCommands.length at call time), and before using
selectedSlashCommandIndex for Enter/Tab compute a clampedIndex =
Math.min(Math.max(selectedSlashCommandIndex, 0), filteredCommands.length - 1)
and only call setInputValue(filteredCommands[clampedIndex].command) when
clampedIndex >= 0 and filteredCommands[clampedIndex] exists; ensure all uses
reference selectedSlashCommandIndex, setSelectedSlashCommandIndex,
filteredCommands, and setInputValue so navigation and selection never operate on
out-of-range indices.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: a7f52246-c3ae-4bd2-8594-0ed5735a53f0
📒 Files selected for processing (8)
src/__tests__/renderer/components/InputArea.test.tsxsrc/__tests__/renderer/hooks/useInputKeyDown.test.tssrc/__tests__/renderer/utils/search.test.tssrc/__tests__/web/mobile/SlashCommandAutocomplete.test.tsxsrc/renderer/components/InputArea.tsxsrc/renderer/hooks/input/useInputKeyDown.tssrc/renderer/utils/search.tssrc/web/mobile/SlashCommandAutocomplete.tsx
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
src/__tests__/web/mobile/SlashCommandAutocomplete.test.tsx (1)
277-278: Avoid hard-coding the last AI-mode index.
1is only correct while AI mode exposes exactly two default commands. Adding another AI-visible command will fail this test even if clamping still behaves correctly.💡 Suggested refactor
- // Effect should clamp to last valid index - expect(onSelectedIndexChange).toHaveBeenCalledWith(1); + // Effect should clamp to the last valid AI-visible index + const lastAiIndex = + DEFAULT_SLASH_COMMANDS.filter((cmd) => !cmd.terminalOnly).length - 1; + expect(onSelectedIndexChange).toHaveBeenCalledWith(lastAiIndex);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/__tests__/web/mobile/SlashCommandAutocomplete.test.tsx` around lines 277 - 278, The test currently hard-codes the last AI-mode index as 1 when asserting onSelectedIndexChange; change the assertion to compute the expected last index dynamically (e.g., derive it from the AI-visible commands or the rendered options count) instead of using the literal 1 so the test stays correct if more AI-visible commands are added; locate the assertion around onSelectedIndexChange in SlashCommandAutocomplete.test.tsx and replace the hard-coded 1 with a computed value such as (aiModeCommands.length - 1) or (renderedOptions.length - 1) depending on how the test constructs the command list.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/renderer/hooks/input/useInputKeyDown.ts`:
- Around line 220-222: selectedSlashCommandIndex can be -1 and Math.min
preserves that, causing Enter/Tab to no-op even after items reappear; update the
clamp so it bounds on both sides (e.g. compute clampedIndex = Math.max(0,
Math.min(selectedSlashCommandIndex, filteredCommands.length - 1))) and/or
early-return when filteredCommands.length === 0, then call
setInputValue(filteredCommands[clampedIndex].command) only when
filteredCommands[clampedIndex] exists; reference symbols:
selectedSlashCommandIndex, filteredCommands, clampedIndex, setInputValue.
In `@src/renderer/utils/search.ts`:
- Around line 166-179: The highlighting loop currently prefers a later
boundaryMatch over an earlier firstMatch, causing visuals to disagree with
fuzzyMatchWithScore; update the selection logic in this block (using
variables/funcs lowerText, lowerQuery, isBoundary, canMatchRest, firstMatch,
boundaryMatch, indices) so it reuses the scorer's tie-breaking: prefer
firstMatch when it yields a consecutive/prefix continuation (e.g., j ===
previousChosen + 1 or matches the same "prefix" condition the scorer uses),
otherwise fall back to boundaryMatch; ideally accept a path or flags from
fuzzyMatchWithScore or replicate its prefix/consecutive tie-break rules here so
highlight indices match the scoring path.
---
Nitpick comments:
In `@src/__tests__/web/mobile/SlashCommandAutocomplete.test.tsx`:
- Around line 277-278: The test currently hard-codes the last AI-mode index as 1
when asserting onSelectedIndexChange; change the assertion to compute the
expected last index dynamically (e.g., derive it from the AI-visible commands or
the rendered options count) instead of using the literal 1 so the test stays
correct if more AI-visible commands are added; locate the assertion around
onSelectedIndexChange in SlashCommandAutocomplete.test.tsx and replace the
hard-coded 1 with a computed value such as (aiModeCommands.length - 1) or
(renderedOptions.length - 1) depending on how the test constructs the command
list.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 2e4e068c-9045-4c3b-823b-2060c3ea40c8
📒 Files selected for processing (8)
src/__tests__/renderer/components/InputArea.test.tsxsrc/__tests__/renderer/hooks/useInputKeyDown.test.tssrc/__tests__/renderer/utils/search.test.tssrc/__tests__/web/mobile/SlashCommandAutocomplete.test.tsxsrc/renderer/components/InputArea.tsxsrc/renderer/hooks/input/useInputKeyDown.tssrc/renderer/utils/search.tssrc/web/mobile/SlashCommandAutocomplete.tsx
🚧 Files skipped from review as they are similar to previous changes (4)
- src/web/mobile/SlashCommandAutocomplete.tsx
- src/tests/renderer/hooks/useInputKeyDown.test.ts
- src/tests/renderer/components/InputArea.test.tsx
- src/tests/renderer/utils/search.test.ts
- 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)
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/renderer/components/InputArea.tsx (1)
525-527: Consider memoizing the query string for highlighting.The query
inputValueLower.replace(/^\//, '')is recomputed for each command in the list. While the overhead is minimal for typical command lists, memoizing it would align with the PERF patterns elsewhere in this file.♻️ Suggested change
Extract the query outside the useMemo scope so both filtering and highlighting share it:
const inputValueLower = useMemo(() => inputValue.toLowerCase(), [inputValue]); +const slashQuery = useMemo(() => inputValueLower.replace(/^\//, ''), [inputValueLower]); const filteredSlashCommands = useMemo(() => { - const query = inputValueLower.replace(/^\//, ''); - return filterSlashCommands(slashCommands, query, isTerminalMode); -}, [slashCommands, isTerminalMode, inputValueLower]); + return filterSlashCommands(slashCommands, slashQuery, isTerminalMode); +}, [slashCommands, isTerminalMode, slashQuery]);Then in the render:
<div className="font-mono text-sm"> - {highlightSlashCommand(cmd.command, inputValueLower.replace(/^\//, ''))} + {highlightSlashCommand(cmd.command, slashQuery)} </div>🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/renderer/components/InputArea.tsx` around lines 525 - 527, The code repeatedly calls inputValueLower.replace(/^\//, '') for each command when rendering highlightSlashCommand; compute this cleaned query once (e.g., const slashQuery = inputValueLower.replace(/^\//, '') outside the commands useMemo/render loop) and reuse slashQuery in both the filtering logic and the highlightSlashCommand call to avoid redundant work and align with existing PERF patterns; update references to use slashQuery and keep the existing highlightSlashCommand and inputValueLower identifiers.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@src/renderer/components/InputArea.tsx`:
- Around line 525-527: The code repeatedly calls inputValueLower.replace(/^\//,
'') for each command when rendering highlightSlashCommand; compute this cleaned
query once (e.g., const slashQuery = inputValueLower.replace(/^\//, '') outside
the commands useMemo/render loop) and reuse slashQuery in both the filtering
logic and the highlightSlashCommand call to avoid redundant work and align with
existing PERF patterns; update references to use slashQuery and keep the
existing highlightSlashCommand and inputValueLower identifiers.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: bb02f8d9-d8ab-4d1b-8a38-1150443b210c
📒 Files selected for processing (8)
src/__tests__/renderer/components/InputArea.test.tsxsrc/__tests__/renderer/hooks/useInputKeyDown.test.tssrc/__tests__/renderer/utils/search.test.tssrc/__tests__/web/mobile/SlashCommandAutocomplete.test.tsxsrc/renderer/components/InputArea.tsxsrc/renderer/hooks/input/useInputKeyDown.tssrc/renderer/utils/search.tssrc/web/mobile/SlashCommandAutocomplete.tsx
🚧 Files skipped from review as they are similar to previous changes (6)
- src/tests/renderer/hooks/useInputKeyDown.test.ts
- src/tests/renderer/components/InputArea.test.tsx
- src/tests/web/mobile/SlashCommandAutocomplete.test.tsx
- src/tests/renderer/utils/search.test.ts
- src/web/mobile/SlashCommandAutocomplete.tsx
- src/renderer/utils/search.ts
Hi, I enjoy your project!
I wanted to add fuzzy search to the slash command for skills after having to type the speckit[dot] prefix way too many times.
I reused the fuzzy search used by the @ command to filter files. I made a styling that in my opinion should fit all themes but I am happy to change it to whatever or even no highlighting at all like the @ command".
I messed up trying to push to main in #678, so here is the next attempt, sorry about that!
I hope you like it!
Florian
Summary
/splanto match/speckit.plan,/ohelpto match/openspec.help, etc. Uses the samefuzzyMatchWithScoreengine that powers@mention file completion..as a word boundary character infuzzyMatchWithScore, so dot-separated command names (e.g.,speckit.plan) get proper boundary bonuses.fuzzyMatchWithIndicesutility tosearch.tsfor highlight rendering.InputArea.tsx,useInputKeyDown.ts) and mobile (SlashCommandAutocomplete.tsx).Test plan
search.test.tscovering fuzzy matching for slash commands andfuzzyMatchWithIndices/splan→ should show/speckit.planwiths,p,l,a,nbolded/hist→ should show/historyas top match (prefix still works)/→ should show all commands (no filter)Mobile version
Summary by CodeRabbit
New Features
Bug Fixes
Tests