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
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 @@ -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');
Expand All @@ -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', () => {
Expand Down
87 changes: 86 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,
filterAndSortSlashCommands,
FuzzyMatchResult,
} from '../../../renderer/utils/search';

describe('search utils', () => {
describe('fuzzyMatch', () => {
Expand Down Expand Up @@ -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);
});
});
});
28 changes: 8 additions & 20 deletions src/renderer/components/InputArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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<HTMLTextAreaElement>;
Expand Down Expand Up @@ -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(
Expand Down
43 changes: 30 additions & 13 deletions src/renderer/components/SessionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
}
Expand All @@ -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);
Expand Down Expand Up @@ -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<string>();
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;
});

Expand Down Expand Up @@ -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 => {
Expand Down
28 changes: 21 additions & 7 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 { filterAndSortSlashCommands } from '../../utils/search';

// ============================================================================
// Dependencies interface
Expand Down Expand Up @@ -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();
Expand All @@ -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();
}
Expand Down
59 changes: 59 additions & 0 deletions src/renderer/utils/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <T extends SlashCommandEntry>(
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);
};