diff --git a/src/__tests__/renderer/hooks/useInputKeyDown.test.ts b/src/__tests__/renderer/hooks/useInputKeyDown.test.ts index 4283d4dc0..d22535000 100644 --- a/src/__tests__/renderer/hooks/useInputKeyDown.test.ts +++ b/src/__tests__/renderer/hooks/useInputKeyDown.test.ts @@ -553,7 +553,7 @@ 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 + // With fuzzy matching, '/r' matches /clear (has 'r') but NOT /run (aiOnly excluded) const deps = createMockDeps({ inputValue: '/r', allSlashCommands: commands }); const { result } = renderHook(() => useInputKeyDown(deps)); const e = createKeyEvent('Enter'); @@ -562,8 +562,8 @@ describe('Slash command autocomplete', () => { result.current.handleInputKeyDown(e); }); - // No matching command after filtering, so setInputValue should not be called - expect(deps.setInputValue).not.toHaveBeenCalled(); + // /clear fuzzy-matches 'r', so it gets selected (but /run is excluded as aiOnly) + expect(deps.setInputValue).toHaveBeenCalledWith('/clear'); }); it('filters out terminalOnly commands in AI mode', () => { diff --git a/src/__tests__/renderer/utils/search.test.ts b/src/__tests__/renderer/utils/search.test.ts index 58d4b2d7b..e36ed3ac7 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, + filterAndSortSlashCommands, + FuzzyMatchResult, +} from '../../../renderer/utils/search'; describe('search utils', () => { describe('fuzzyMatch', () => { @@ -492,4 +497,84 @@ describe('search utils', () => { }); }); }); + + describe('filterAndSortSlashCommands', () => { + const commands = [ + { command: '/clear', description: 'Clear the terminal' }, + { command: '/help', description: 'Show help', aiOnly: true }, + { command: '/reset', description: 'Reset the session' }, + { command: '/run', description: 'Run a command', terminalOnly: true }, + { command: '/review', description: 'Review code changes' }, + ]; + + it('returns all eligible commands for empty search term', () => { + const result = filterAndSortSlashCommands(commands, '', false); + // aiOnly commands excluded in non-terminal mode? No — aiOnly excluded in terminal mode + // terminalOnly excluded in non-terminal mode + expect(result).toHaveLength(4); // /run excluded (terminalOnly, not in terminal mode) + expect(result.map((c) => c.command)).not.toContain('/run'); + }); + + it('filters out terminalOnly commands in AI mode', () => { + const result = filterAndSortSlashCommands(commands, '', false); + expect(result.find((c) => c.command === '/run')).toBeUndefined(); + }); + + it('filters out aiOnly commands in terminal mode', () => { + const result = filterAndSortSlashCommands(commands, '', true); + expect(result.find((c) => c.command === '/help')).toBeUndefined(); + }); + + it('includes terminalOnly commands in terminal mode', () => { + const result = filterAndSortSlashCommands(commands, '', true); + expect(result.find((c) => c.command === '/run')).toBeDefined(); + }); + + it('includes aiOnly commands in AI mode', () => { + const result = filterAndSortSlashCommands(commands, '', false); + expect(result.find((c) => c.command === '/help')).toBeDefined(); + }); + + it('ranks prefix matches higher than fuzzy matches', () => { + const result = filterAndSortSlashCommands(commands, 're', false); + // /reset and /review both prefix-match "re" + // /clear fuzzy-matches "re" (c-l-e-a-r has r and e in order? no — r then e, "clear" has c,l,e,a,r — "re" needs r then e: r is at index 4, no e after that) + expect(result.length).toBeGreaterThanOrEqual(2); + expect(result[0].command).toBe('/reset'); + expect(result[1].command).toBe('/review'); + }); + + it('ranks command name fuzzy match higher than description fuzzy match', () => { + // "cl" prefix-matches /clear + // Nothing else has "cl" in command name + // "Clear the terminal" description has "cl" — but /clear already matched by prefix + const result = filterAndSortSlashCommands(commands, 'cl', false); + expect(result[0].command).toBe('/clear'); + }); + + it('matches description when command name does not match', () => { + // "terminal" matches /clear's description "Clear the terminal" + // and /run's description "Run a command" does NOT contain "terminal" + // Wait — /run is terminalOnly, testing in AI mode so it's excluded + const result = filterAndSortSlashCommands(commands, 'terminal', false); + expect(result.find((c) => c.command === '/clear')).toBeDefined(); + }); + + it('is case-insensitive for prefix matching', () => { + const result = filterAndSortSlashCommands(commands, 'RE', false); + expect(result.length).toBeGreaterThanOrEqual(2); + expect(result[0].command).toBe('/reset'); + }); + + it('returns empty array when nothing matches', () => { + const result = filterAndSortSlashCommands(commands, 'zzz', false); + expect(result).toHaveLength(0); + }); + + it('preserves generic type parameter', () => { + const extendedCommands = [{ command: '/test', description: 'Test', customProp: 42 }]; + const result = filterAndSortSlashCommands(extendedCommands, '', false); + expect(result[0].customProp).toBe(42); + }); + }); }); diff --git a/src/renderer/components/InputArea.tsx b/src/renderer/components/InputArea.tsx index 041d87325..bf8444cf6 100644 --- a/src/renderer/components/InputArea.tsx +++ b/src/renderer/components/InputArea.tsx @@ -24,6 +24,7 @@ import { formatEnterToSendTooltip, } from '../utils/shortcutFormatter'; import type { TabCompletionSuggestion, TabCompletionFilter } from '../hooks'; +import { filterAndSortSlashCommands, type SlashCommandEntry } from '../utils/search'; import type { SummarizeProgress, SummarizeResult, @@ -39,13 +40,6 @@ import { WizardInputPanel } from './InlineWizard'; import { useAgentCapabilities, useScrollIntoView } from '../hooks'; import { getProviderDisplayName } from '../utils/sessionValidation'; -interface SlashCommand { - command: string; - description: string; - terminalOnly?: boolean; - aiOnly?: boolean; -} - interface InputAreaProps { session: Session; theme: Theme; @@ -68,7 +62,7 @@ interface InputAreaProps { setCommandHistorySelectedIndex: (index: number) => void; slashCommandOpen: boolean; setSlashCommandOpen: (open: boolean) => void; - slashCommands: SlashCommand[]; + slashCommands: SlashCommandEntry[]; selectedSlashCommandIndex: number; setSelectedSlashCommandIndex: (index: number) => void; inputRef: React.RefObject; @@ -314,19 +308,13 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) { : legacyHistory; // Use the slash commands passed from App.tsx (already includes custom + Claude commands) - // PERF: Memoize both the lowercase conversion and filtered results to avoid + // PERF: Memoize both the search term extraction and filtered results to avoid // 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); - }); - }, [slashCommands, isTerminalMode, inputValueLower]); + const searchTerm = useMemo(() => inputValue.toLowerCase().replace(/^\//, ''), [inputValue]); + const filteredSlashCommands = useMemo( + () => filterAndSortSlashCommands(slashCommands, searchTerm, isTerminalMode), + [slashCommands, isTerminalMode, searchTerm] + ); // Ensure selectedSlashCommandIndex is valid for the filtered list const safeSelectedIndex = Math.min( diff --git a/src/renderer/components/SessionList.tsx b/src/renderer/components/SessionList.tsx index 6bb322cec..1466c12fc 100644 --- a/src/renderer/components/SessionList.tsx +++ b/src/renderer/components/SessionList.tsx @@ -54,9 +54,15 @@ import { getStatusColor, getContextColor, formatActiveTime } from '../utils/them import { formatShortcutKeys } from '../utils/shortcutFormatter'; import { SessionItem } from './SessionItem'; import { GroupChatList } from './GroupChatList'; -import { useLiveOverlay, useClickOutside, useResizablePanel, useContextMenuPosition } from '../hooks'; +import { + useLiveOverlay, + useClickOutside, + useResizablePanel, + useContextMenuPosition, +} from '../hooks'; import { useGitFileStatus } from '../contexts/GitStatusContext'; import { useUIStore } from '../stores/uiStore'; +import { fuzzyMatch } from '../utils/search'; // ============================================================================ // SessionContextMenu - Right-click context menu for session items @@ -1743,8 +1749,8 @@ function SessionListInner(props: SessionListProps) { // Consolidated session categorization and sorting - computed in a single pass // This replaces 12+ chained useMemo calls with one comprehensive computation const sessionCategories = useMemo(() => { - // Step 1: Filter sessions based on search query - const query = sessionFilter?.toLowerCase() ?? ''; + // Step 1: Filter sessions based on search query (fuzzy match) + const query = sessionFilter ?? ''; const filtered: Session[] = []; for (const s of sessions) { @@ -1755,12 +1761,12 @@ function SessionListInner(props: SessionListProps) { filtered.push(s); } else { // Match session name - if (s.name.toLowerCase().includes(query)) { + if (fuzzyMatch(s.name, query)) { filtered.push(s); continue; } // Match any AI tab name - if (s.aiTabs?.some((tab) => tab.name?.toLowerCase().includes(query))) { + if (s.aiTabs?.some((tab) => tab.name && fuzzyMatch(tab.name, query))) { filtered.push(s); continue; } @@ -1769,8 +1775,8 @@ function SessionListInner(props: SessionListProps) { if ( worktreeChildren?.some( (child) => - child.worktreeBranch?.toLowerCase().includes(query) || - child.name.toLowerCase().includes(query) + (child.worktreeBranch && fuzzyMatch(child.worktreeBranch, query)) || + fuzzyMatch(child.name, query) ) ) { filtered.push(s); @@ -1898,15 +1904,26 @@ function SessionListInner(props: SessionListProps) { }, [sessionFilterOpen]); // Temporarily expand groups when filtering to show matching sessions - // Note: Only depend on sessionFilter and sessions (not filteredSessions which changes reference each render) + // Uses the same matching logic as sessionCategories to stay consistent useEffect(() => { if (sessionFilter) { - // Find groups that contain matching sessions (search session name AND AI tab names) const groupsWithMatches = new Set(); - const query = sessionFilter.toLowerCase(); const matchingSessions = sessions.filter((s) => { - if (s.name.toLowerCase().includes(query)) return true; - if (s.aiTabs?.some((tab) => tab.name?.toLowerCase().includes(query))) return true; + // Skip worktree children (same as sessionCategories) + if (s.parentSessionId) return false; + if (fuzzyMatch(s.name, sessionFilter)) return true; + if (s.aiTabs?.some((tab) => tab.name && fuzzyMatch(tab.name, sessionFilter))) return true; + // Match worktree children branch names (same as sessionCategories) + const worktreeChildren = worktreeChildrenByParentId.get(s.id); + if ( + worktreeChildren?.some( + (child) => + (child.worktreeBranch && fuzzyMatch(child.worktreeBranch, sessionFilter)) || + fuzzyMatch(child.name, sessionFilter) + ) + ) { + return true; + } return false; }); @@ -1936,7 +1953,7 @@ function SessionListInner(props: SessionListProps) { setGroups((prev) => prev.map((g) => ({ ...g, collapsed: true }))); setBookmarksCollapsed(false); } - }, [sessionFilter]); + }, [sessionFilter, sessions, worktreeChildrenByParentId]); // Get the jump number (1-9, 0=10th) for a session based on its position in visibleSessions const getSessionJumpNumber = (sessionId: string): string | null => { diff --git a/src/renderer/hooks/input/useInputKeyDown.ts b/src/renderer/hooks/input/useInputKeyDown.ts index e3c8fabf0..6d3cc6b94 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 { filterAndSortSlashCommands } from '../../utils/search'; // ============================================================================ // Dependencies interface @@ -205,11 +206,20 @@ 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 searchTerm = inputValue.toLowerCase().replace(/^\//, ''); + const filteredCommands = filterAndSortSlashCommands( + allSlashCommands, + searchTerm, + isTerminalMode + ); + + if (filteredCommands.length === 0) { + if (e.key === 'Escape') { + e.preventDefault(); + setSlashCommandOpen(false); + } + return; + } if (e.key === 'ArrowDown') { e.preventDefault(); @@ -219,8 +229,12 @@ 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); + const safeIndex = Math.min( + Math.max(0, selectedSlashCommandIndex), + Math.max(0, filteredCommands.length - 1) + ); + if (filteredCommands[safeIndex]) { + setInputValue(filteredCommands[safeIndex].command); setSlashCommandOpen(false); inputRef.current?.focus(); } diff --git a/src/renderer/utils/search.ts b/src/renderer/utils/search.ts index ba96a113b..229b538f1 100644 --- a/src/renderer/utils/search.ts +++ b/src/renderer/utils/search.ts @@ -117,3 +117,62 @@ export const fuzzyMatchWithScore = (text: string, query: string): FuzzyMatchResu return { matches, score }; }; + +/** + * Slash command interface for filtering + */ +export interface SlashCommandEntry { + command: string; + description: string; + terminalOnly?: boolean; + aiOnly?: boolean; +} + +/** + * Filter and sort slash commands using fuzzy matching with tiered scoring. + * This is the single source of truth for slash command filtering — used by both + * the dropdown renderer (InputArea) and the keyboard handler (useInputKeyDown). + * + * Scoring tiers: + * - Prefix match on command name: 300 + * - Fuzzy match on command name: 100 + fuzzy score + * - Fuzzy match on description: fuzzy score + */ +export const filterAndSortSlashCommands = ( + commands: T[], + searchTerm: string, + isTerminalMode: boolean +): T[] => { + const scored: { cmd: T; score: number }[] = []; + const lowerSearchTerm = searchTerm.toLowerCase(); + for (const cmd of commands) { + if (cmd.terminalOnly && !isTerminalMode) continue; + if (cmd.aiOnly && isTerminalMode) continue; + if (!searchTerm) { + scored.push({ cmd, score: 0 }); + continue; + } + const cmdName = cmd.command.toLowerCase().replace(/^\//, ''); + // Prefix match gets highest priority + if (cmdName.startsWith(lowerSearchTerm)) { + scored.push({ cmd, score: 300 }); + continue; + } + // Fuzzy match on command name + const nameResult = fuzzyMatchWithScore(cmdName, searchTerm); + if (nameResult.matches) { + scored.push({ cmd, score: 100 + nameResult.score }); + continue; + } + // Fuzzy match on description + if (cmd.description) { + const descResult = fuzzyMatchWithScore(cmd.description, searchTerm); + if (descResult.matches) { + scored.push({ cmd, score: descResult.score }); + continue; + } + } + } + scored.sort((a, b) => b.score - a.score); + return scored.map((s) => s.cmd); +};