Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/__tests__/renderer/components/InputArea.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -671,8 +671,9 @@ describe('InputArea', () => {
});
render(<InputArea {...props} />);

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', () => {
Expand Down
6 changes: 3 additions & 3 deletions src/__tests__/renderer/hooks/useInputKeyDown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
68 changes: 67 additions & 1 deletion src/__tests__/renderer/utils/search.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
});
17 changes: 9 additions & 8 deletions src/__tests__/web/mobile/SlashCommandAutocomplete.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,18 +109,19 @@ describe('SlashCommandAutocomplete', () => {

it('filters commands by prefix when input starts with /', () => {
render(<SlashCommandAutocomplete {...defaultProps} inputValue="/cl" inputMode="ai" />);
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(<SlashCommandAutocomplete {...defaultProps} inputValue="/CL" inputMode="ai" />);
expect(screen.getByText('/clear')).toBeInTheDocument();
expect(screen.getByText((_, el) => el?.textContent === '/clear')).toBeInTheDocument();
});

it('shows exact match', () => {
render(<SlashCommandAutocomplete {...defaultProps} inputValue="/clear" inputMode="ai" />);
expect(screen.getByText('/clear')).toBeInTheDocument();
expect(screen.getByText((_, el) => el?.textContent === '/clear')).toBeInTheDocument();
});
});

Expand Down Expand Up @@ -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();
});
});

Expand Down Expand Up @@ -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);
});
});

Expand Down
15 changes: 6 additions & 9 deletions src/renderer/components/InputArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -527,7 +522,9 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) {
}}
onMouseEnter={() => setSelectedSlashCommandIndex(idx)}
>
<div className="font-mono text-sm">{cmd.command}</div>
<div className="font-mono text-sm">
{highlightSlashCommand(cmd.command, inputValueLower.replace(/^\//, ''))}
</div>
<div className="text-xs opacity-70 mt-0.5">{cmd.description}</div>
</button>
Comment on lines 523 to 529
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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!

))}
Expand Down
21 changes: 11 additions & 10 deletions src/renderer/hooks/input/useInputKeyDown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand Down
127 changes: 125 additions & 2 deletions src/renderer/utils/search.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import React from 'react';

/**
* Fuzzy search result with scoring information
*/
Expand Down Expand Up @@ -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 };
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 = <T extends SlashCommandLike>(
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
)
);
};
Loading