From b6ae94308fdb50aa24d40532676433b8122416d7 Mon Sep 17 00:00:00 2001 From: Felipe Gobbi Date: Thu, 26 Feb 2026 13:34:02 -0300 Subject: [PATCH 1/7] fix: enable fuzzy matching for session filter in Left Bar The agent filter was using exact substring matching (.includes()), making it impossible to find agents with non-contiguous character queries. Now uses the existing fuzzyMatch() utility for consistent behavior with File Search Modal and Tab Switcher. Co-Authored-By: Claude Opus 4.6 --- src/renderer/components/SessionList.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/renderer/components/SessionList.tsx b/src/renderer/components/SessionList.tsx index 6bb322cec..e6cfb5227 100644 --- a/src/renderer/components/SessionList.tsx +++ b/src/renderer/components/SessionList.tsx @@ -57,6 +57,7 @@ import { GroupChatList } from './GroupChatList'; 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 +1744,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 +1756,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 +1770,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); From f2292e526360a81e72a9915f55fa20aa0f1408c3 Mon Sep 17 00:00:00 2001 From: Felipe Gobbi Date: Thu, 26 Feb 2026 14:00:22 -0300 Subject: [PATCH 2/7] fix: enable fuzzy matching for slash command autocomplete in input area Slash command search was using startsWith() prefix matching, requiring exact contiguous character input. Now uses fuzzyMatchWithScore() with 3-tier ranking (prefix > fuzzy name > fuzzy description) in InputArea and fuzzyMatch() in useInputKeyDown for consistent keyboard navigation. Co-Authored-By: Claude Opus 4.6 --- src/renderer/components/InputArea.tsx | 46 ++++++++++++++++----- src/renderer/hooks/input/useInputKeyDown.ts | 10 ++++- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/src/renderer/components/InputArea.tsx b/src/renderer/components/InputArea.tsx index 041d87325..aef3fb798 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 { fuzzyMatchWithScore } from '../utils/search'; import type { SummarizeProgress, SummarizeResult, @@ -314,19 +315,42 @@ 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 searchTerm = useMemo(() => inputValue.toLowerCase().replace(/^\//, ''), [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 scored: { cmd: (typeof slashCommands)[number]; score: number }[] = []; + for (const cmd of slashCommands) { + 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(searchTerm)) { + 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); + }, [slashCommands, isTerminalMode, searchTerm]); // Ensure selectedSlashCommandIndex is valid for the filtered list const safeSelectedIndex = Math.min( diff --git a/src/renderer/hooks/input/useInputKeyDown.ts b/src/renderer/hooks/input/useInputKeyDown.ts index e3c8fabf0..7074e6a25 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 { fuzzyMatch } from '../../utils/search'; // ============================================================================ // Dependencies interface @@ -205,10 +206,17 @@ export function useInputKeyDown(deps: InputKeyDownDeps): InputKeyDownReturn { // Handle slash command autocomplete if (slashCommandOpen) { const isTerminalMode = activeSession?.inputMode === 'terminal'; + const searchTerm = 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 cmd.command.toLowerCase().startsWith(inputValue.toLowerCase()); + if (!searchTerm) return true; + const cmdName = cmd.command.toLowerCase().replace(/^\//, ''); + return ( + cmdName.startsWith(searchTerm) || + fuzzyMatch(cmdName, searchTerm) || + (cmd.description ? fuzzyMatch(cmd.description, searchTerm) : false) + ); }); if (e.key === 'ArrowDown') { From 049345af248bbf50843217c62f0c9435aa549cdb Mon Sep 17 00:00:00 2001 From: Felipe Gobbi Date: Thu, 26 Feb 2026 15:48:16 -0300 Subject: [PATCH 3/7] fix: update fuzzy autocomplete test for new matching behavior Update useInputKeyDown test to expect /clear fuzzy match for '/r' input since fuzzy matching now matches 'r' anywhere in command names. Co-Authored-By: Claude Opus 4.6 --- src/__tests__/renderer/hooks/useInputKeyDown.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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', () => { From 96f0cdefd3d6fe502251088d24adfd49c4e87806 Mon Sep 17 00:00:00 2001 From: Felipe Gobbi Date: Thu, 26 Feb 2026 16:14:13 -0300 Subject: [PATCH 4/7] fix: resolve slash command index mismatch between dropdown and keyboard handler Extract shared filterAndSortSlashCommands() utility to ensure both InputArea (visual dropdown) and useInputKeyDown (Enter/Tab handler) produce identically ordered command lists. Previously, InputArea sorted by relevance score while useInputKeyDown kept original array order, causing the wrong command to be inserted when pressing Enter/Tab. Also fixes SessionList group auto-expansion to use fuzzyMatch() consistently instead of substring .includes(), matching the main sessionCategories filter. Co-Authored-By: Claude Opus 4.6 --- src/renderer/components/InputArea.tsx | 39 ++------------ src/renderer/components/SessionList.tsx | 12 +++-- src/renderer/hooks/input/useInputKeyDown.ts | 18 +++---- src/renderer/utils/search.ts | 58 +++++++++++++++++++++ 4 files changed, 77 insertions(+), 50 deletions(-) diff --git a/src/renderer/components/InputArea.tsx b/src/renderer/components/InputArea.tsx index aef3fb798..994f51923 100644 --- a/src/renderer/components/InputArea.tsx +++ b/src/renderer/components/InputArea.tsx @@ -24,7 +24,7 @@ import { formatEnterToSendTooltip, } from '../utils/shortcutFormatter'; import type { TabCompletionSuggestion, TabCompletionFilter } from '../hooks'; -import { fuzzyMatchWithScore } from '../utils/search'; +import { filterAndSortSlashCommands } from '../utils/search'; import type { SummarizeProgress, SummarizeResult, @@ -318,39 +318,10 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) { // PERF: Memoize both the search term extraction and filtered results to avoid // recalculating on every render - inputValue changes on every keystroke const searchTerm = useMemo(() => inputValue.toLowerCase().replace(/^\//, ''), [inputValue]); - const filteredSlashCommands = useMemo(() => { - const scored: { cmd: (typeof slashCommands)[number]; score: number }[] = []; - for (const cmd of slashCommands) { - 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(searchTerm)) { - 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); - }, [slashCommands, isTerminalMode, searchTerm]); + 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 e6cfb5227..d17dba416 100644 --- a/src/renderer/components/SessionList.tsx +++ b/src/renderer/components/SessionList.tsx @@ -54,7 +54,12 @@ 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'; @@ -1904,10 +1909,9 @@ function SessionListInner(props: SessionListProps) { 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; + if (fuzzyMatch(s.name, sessionFilter)) return true; + if (s.aiTabs?.some((tab) => tab.name && fuzzyMatch(tab.name, sessionFilter))) return true; return false; }); diff --git a/src/renderer/hooks/input/useInputKeyDown.ts b/src/renderer/hooks/input/useInputKeyDown.ts index 7074e6a25..17d3ea3b7 100644 --- a/src/renderer/hooks/input/useInputKeyDown.ts +++ b/src/renderer/hooks/input/useInputKeyDown.ts @@ -16,7 +16,7 @@ import { useInputContext } from '../../contexts/InputContext'; import { useSessionStore, selectActiveSession } from '../../stores/sessionStore'; import { useUIStore } from '../../stores/uiStore'; import { useSettingsStore } from '../../stores/settingsStore'; -import { fuzzyMatch } from '../../utils/search'; +import { filterAndSortSlashCommands } from '../../utils/search'; // ============================================================================ // Dependencies interface @@ -207,17 +207,11 @@ export function useInputKeyDown(deps: InputKeyDownDeps): InputKeyDownReturn { if (slashCommandOpen) { const isTerminalMode = activeSession?.inputMode === 'terminal'; const searchTerm = 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; - if (!searchTerm) return true; - const cmdName = cmd.command.toLowerCase().replace(/^\//, ''); - return ( - cmdName.startsWith(searchTerm) || - fuzzyMatch(cmdName, searchTerm) || - (cmd.description ? fuzzyMatch(cmd.description, searchTerm) : false) - ); - }); + const filteredCommands = filterAndSortSlashCommands( + allSlashCommands, + searchTerm, + isTerminalMode + ); if (e.key === 'ArrowDown') { e.preventDefault(); diff --git a/src/renderer/utils/search.ts b/src/renderer/utils/search.ts index ba96a113b..58d81815c 100644 --- a/src/renderer/utils/search.ts +++ b/src/renderer/utils/search.ts @@ -117,3 +117,61 @@ 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 }[] = []; + 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(searchTerm)) { + 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); +}; From 2ba935c57849e8e92e5663aaf1c89573a5d7cfbb Mon Sep 17 00:00:00 2001 From: Felipe Gobbi Date: Thu, 26 Feb 2026 16:20:49 -0300 Subject: [PATCH 5/7] fix: normalize searchTerm case in filterAndSortSlashCommands Prefix match compared lowercased cmdName against potentially mixed-case searchTerm, making it case-sensitive while fuzzyMatchWithScore was case-insensitive. Now lowercases searchTerm once at function entry. Co-Authored-By: Claude Opus 4.6 --- src/renderer/utils/search.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/utils/search.ts b/src/renderer/utils/search.ts index 58d81815c..229b538f1 100644 --- a/src/renderer/utils/search.ts +++ b/src/renderer/utils/search.ts @@ -144,6 +144,7 @@ export const filterAndSortSlashCommands = ( 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; @@ -153,7 +154,7 @@ export const filterAndSortSlashCommands = ( } const cmdName = cmd.command.toLowerCase().replace(/^\//, ''); // Prefix match gets highest priority - if (cmdName.startsWith(searchTerm)) { + if (cmdName.startsWith(lowerSearchTerm)) { scored.push({ cmd, score: 300 }); continue; } From 56c64d6171f9a794d0b4f5e3e07cafe215b35741 Mon Sep 17 00:00:00 2001 From: Felipe Gobbi Date: Thu, 26 Feb 2026 16:27:57 -0300 Subject: [PATCH 6/7] fix: address remaining review issues for fuzzy matching PR - Clamp selectedSlashCommandIndex in useInputKeyDown before accessing filteredCommands, preventing silent no-op when index exceeds list length after the user continues typing - Align SessionList group-expansion useEffect with sessionCategories memo: add worktree children branch/name matching and parentSessionId filtering, fix missing sessions and worktreeChildrenByParentId dependencies - Add 11 tests for filterAndSortSlashCommands covering mode filtering, prefix vs fuzzy ranking, case insensitivity, description matching, empty results, and generic type preservation - Remove duplicate SlashCommand interface from InputArea.tsx, import SlashCommandEntry from search.ts instead Co-Authored-By: Claude Opus 4.6 --- src/__tests__/renderer/utils/search.test.ts | 87 ++++++++++++++++++++- src/renderer/components/InputArea.tsx | 11 +-- src/renderer/components/SessionList.tsx | 18 ++++- src/renderer/hooks/input/useInputKeyDown.ts | 8 +- 4 files changed, 109 insertions(+), 15 deletions(-) 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 994f51923..bf8444cf6 100644 --- a/src/renderer/components/InputArea.tsx +++ b/src/renderer/components/InputArea.tsx @@ -24,7 +24,7 @@ import { formatEnterToSendTooltip, } from '../utils/shortcutFormatter'; import type { TabCompletionSuggestion, TabCompletionFilter } from '../hooks'; -import { filterAndSortSlashCommands } from '../utils/search'; +import { filterAndSortSlashCommands, type SlashCommandEntry } from '../utils/search'; import type { SummarizeProgress, SummarizeResult, @@ -40,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; @@ -69,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; diff --git a/src/renderer/components/SessionList.tsx b/src/renderer/components/SessionList.tsx index d17dba416..1466c12fc 100644 --- a/src/renderer/components/SessionList.tsx +++ b/src/renderer/components/SessionList.tsx @@ -1904,14 +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 matchingSessions = sessions.filter((s) => { + // 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; }); @@ -1941,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 17d3ea3b7..de62affa7 100644 --- a/src/renderer/hooks/input/useInputKeyDown.ts +++ b/src/renderer/hooks/input/useInputKeyDown.ts @@ -221,8 +221,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(); } From 013d812abc19120cee07334e7f05d2802a628afb Mon Sep 17 00:00:00 2001 From: Felipe Gobbi Date: Thu, 26 Feb 2026 18:19:11 -0300 Subject: [PATCH 7/7] fix: guard arrow nav on empty filteredCommands in slash command handler When filteredCommands.length === 0, ArrowDown/ArrowUp/Enter/Tab handlers would compute invalid indices (e.g. Math.min(prev+1, -1)). Added early return guard that only allows Escape when no commands match. Co-Authored-By: Claude Opus 4.6 --- src/renderer/hooks/input/useInputKeyDown.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/renderer/hooks/input/useInputKeyDown.ts b/src/renderer/hooks/input/useInputKeyDown.ts index de62affa7..6d3cc6b94 100644 --- a/src/renderer/hooks/input/useInputKeyDown.ts +++ b/src/renderer/hooks/input/useInputKeyDown.ts @@ -213,6 +213,14 @@ export function useInputKeyDown(deps: InputKeyDownDeps): InputKeyDownReturn { isTerminalMode ); + if (filteredCommands.length === 0) { + if (e.key === 'Escape') { + e.preventDefault(); + setSlashCommandOpen(false); + } + return; + } + if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedSlashCommandIndex((prev) => Math.min(prev + 1, filteredCommands.length - 1));