diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts index e7033f044b..c85bee3d07 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts @@ -10,9 +10,135 @@ import { describe, it, expect, vi } from 'vitest'; import { renderHook, waitFor } from '@testing-library/react'; import { useSlashCompletion } from './useSlashCompletion.js'; import type { CommandContext, SlashCommand } from '../commands/types.js'; +import { CommandKind } from '../commands/types.js'; import { useState } from 'react'; import type { Suggestion } from '../components/SuggestionsDisplay.js'; +// Test utility type and helper function for creating test SlashCommands +type TestSlashCommand = Omit & + Partial>; + +function createTestCommand(command: TestSlashCommand): SlashCommand { + return { + kind: CommandKind.BUILT_IN, // default for tests + ...command, + }; +} + +// Track AsyncFzf constructor calls for cache testing +let asyncFzfConstructorCalls = 0; +const resetConstructorCallCount = () => { + asyncFzfConstructorCalls = 0; +}; +const getConstructorCallCount = () => asyncFzfConstructorCalls; + +// Centralized fuzzy matching simulation logic +// Note: This is a simplified reimplementation that may diverge from real fzf behavior. +// Integration tests in useSlashCompletion.integration.test.ts use the real fzf library +// to catch any behavioral differences and serve as our "canary in a coal mine." +function simulateFuzzyMatching(items: readonly string[], query: string) { + const results = []; + if (query) { + const lowerQuery = query.toLowerCase(); + for (const item of items) { + const lowerItem = item.toLowerCase(); + + // Exact match gets highest score + if (lowerItem === lowerQuery) { + results.push({ + item, + positions: [], + score: 100, + start: 0, + end: item.length, + }); + continue; + } + + // Prefix match gets high score + if (lowerItem.startsWith(lowerQuery)) { + results.push({ + item, + positions: [], + score: 80, + start: 0, + end: query.length, + }); + continue; + } + + // Fuzzy matching: check if query chars appear in order + let queryIndex = 0; + let score = 0; + for ( + let i = 0; + i < lowerItem.length && queryIndex < lowerQuery.length; + i++ + ) { + if (lowerItem[i] === lowerQuery[queryIndex]) { + queryIndex++; + score += 10 - i; // Earlier matches get higher scores + } + } + + // If all query characters were found in order, include this item + if (queryIndex === lowerQuery.length) { + results.push({ + item, + positions: [], + score, + start: 0, + end: query.length, + }); + } + } + } + + // Sort by score descending (better matches first) + results.sort((a, b) => b.score - a.score); + return Promise.resolve(results); +} + +// Mock the fzf module to provide a working fuzzy search implementation for tests +vi.mock('fzf', async () => { + const actual = await vi.importActual('fzf'); + return { + ...actual, + AsyncFzf: vi.fn().mockImplementation((items, _options) => { + asyncFzfConstructorCalls++; + return { + find: vi + .fn() + .mockImplementation((query: string) => + simulateFuzzyMatching(items, query), + ), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + }), + }; +}); + +// Default mock behavior helper - now uses centralized logic +const createDefaultAsyncFzfMock = + () => (items: readonly string[], _options: unknown) => { + asyncFzfConstructorCalls++; + return { + find: vi + .fn() + .mockImplementation((query: string) => + simulateFuzzyMatching(items, query), + ), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + }; + +// Export test utilities +export { + resetConstructorCallCount, + getConstructorCallCount, + createDefaultAsyncFzfMock, +}; + // Test harness to capture the state from the hook's callbacks. function useTestHarnessForSlashCompletion( enabled: boolean, @@ -50,20 +176,26 @@ describe('useSlashCompletion', () => { describe('Top-Level Commands', () => { it('should suggest all top-level commands for the root slash', async () => { const slashCommands = [ - { name: 'help', altNames: ['?'], description: 'Show help' }, - { + createTestCommand({ + name: 'help', + altNames: ['?'], + description: 'Show help', + }), + createTestCommand({ name: 'stats', altNames: ['usage'], description: 'check session stats. Usage: /stats [model|tools]', - }, - { name: 'clear', description: 'Clear the screen' }, - { + }), + createTestCommand({ name: 'clear', description: 'Clear the screen' }), + createTestCommand({ name: 'memory', description: 'Manage memory', - subCommands: [{ name: 'show', description: 'Show memory' }], - }, - { name: 'chat', description: 'Manage chat history' }, - ] as unknown as SlashCommand[]; + subCommands: [ + createTestCommand({ name: 'show', description: 'Show memory' }), + ], + }), + createTestCommand({ name: 'chat', description: 'Manage chat history' }), + ]; const { result } = renderHook(() => useTestHarnessForSlashCompletion( true, @@ -81,8 +213,8 @@ describe('useSlashCompletion', () => { it('should filter commands based on partial input', async () => { const slashCommands = [ - { name: 'memory', description: 'Manage memory' }, - ] as unknown as SlashCommand[]; + createTestCommand({ name: 'memory', description: 'Manage memory' }), + ]; const { result } = renderHook(() => useTestHarnessForSlashCompletion( true, @@ -92,19 +224,21 @@ describe('useSlashCompletion', () => { ), ); - expect(result.current.suggestions).toEqual([ - { label: 'memory', value: 'memory', description: 'Manage memory' }, - ]); + await waitFor(() => { + expect(result.current.suggestions).toEqual([ + { label: 'memory', value: 'memory', description: 'Manage memory' }, + ]); + }); }); it('should suggest commands based on partial altNames', async () => { const slashCommands = [ - { + createTestCommand({ name: 'stats', altNames: ['usage'], description: 'check session stats. Usage: /stats [model|tools]', - }, - ] as unknown as SlashCommand[]; + }), + ]; const { result } = renderHook(() => useTestHarnessForSlashCompletion( true, @@ -114,19 +248,25 @@ describe('useSlashCompletion', () => { ), ); - expect(result.current.suggestions).toEqual([ - { - label: 'stats', - value: 'stats', - description: 'check session stats. Usage: /stats [model|tools]', - }, - ]); + await waitFor(() => { + expect(result.current.suggestions).toEqual([ + { + label: 'stats', + value: 'stats', + description: 'check session stats. Usage: /stats [model|tools]', + }, + ]); + }); }); it('should NOT provide suggestions for a perfectly typed command that is a leaf node', async () => { const slashCommands = [ - { name: 'clear', description: 'Clear the screen', action: vi.fn() }, - ] as unknown as SlashCommand[]; + createTestCommand({ + name: 'clear', + description: 'Clear the screen', + action: vi.fn(), + }), + ]; const { result } = renderHook(() => useTestHarnessForSlashCompletion( true, @@ -143,19 +283,19 @@ describe('useSlashCompletion', () => { 'should not suggest commands when altNames is fully typed', async (query) => { const mockSlashCommands = [ - { + createTestCommand({ name: 'help', altNames: ['?'], description: 'Show help', action: vi.fn(), - }, - { + }), + createTestCommand({ name: 'stats', altNames: ['usage'], description: 'check session stats. Usage: /stats [model|tools]', action: vi.fn(), - }, - ] as unknown as SlashCommand[]; + }), + ]; const { result } = renderHook(() => useTestHarnessForSlashCompletion( @@ -172,8 +312,8 @@ describe('useSlashCompletion', () => { it('should not provide suggestions for a fully typed command that has no sub-commands or argument completion', async () => { const slashCommands = [ - { name: 'clear', description: 'Clear the screen' }, - ] as unknown as SlashCommand[]; + createTestCommand({ name: 'clear', description: 'Clear the screen' }), + ]; const { result } = renderHook(() => useTestHarnessForSlashCompletion( true, @@ -188,8 +328,8 @@ describe('useSlashCompletion', () => { it('should not provide suggestions for an unknown command', async () => { const slashCommands = [ - { name: 'help', description: 'Show help' }, - ] as unknown as SlashCommand[]; + createTestCommand({ name: 'help', description: 'Show help' }), + ]; const { result } = renderHook(() => useTestHarnessForSlashCompletion( true, @@ -206,15 +346,15 @@ describe('useSlashCompletion', () => { describe('Sub-Commands', () => { it('should suggest sub-commands for a parent command', async () => { const slashCommands = [ - { + createTestCommand({ name: 'memory', description: 'Manage memory', subCommands: [ - { name: 'show', description: 'Show memory' }, - { name: 'add', description: 'Add to memory' }, + createTestCommand({ name: 'show', description: 'Show memory' }), + createTestCommand({ name: 'add', description: 'Add to memory' }), ], - }, - ] as unknown as SlashCommand[]; + }), + ]; const { result } = renderHook(() => useTestHarnessForSlashCompletion( @@ -236,15 +376,15 @@ describe('useSlashCompletion', () => { it('should suggest all sub-commands when the query ends with the parent command and a space', async () => { const slashCommands = [ - { + createTestCommand({ name: 'memory', description: 'Manage memory', subCommands: [ - { name: 'show', description: 'Show memory' }, - { name: 'add', description: 'Add to memory' }, + createTestCommand({ name: 'show', description: 'Show memory' }), + createTestCommand({ name: 'add', description: 'Add to memory' }), ], - }, - ] as unknown as SlashCommand[]; + }), + ]; const { result } = renderHook(() => useTestHarnessForSlashCompletion( true, @@ -265,15 +405,15 @@ describe('useSlashCompletion', () => { it('should filter sub-commands by prefix', async () => { const slashCommands = [ - { + createTestCommand({ name: 'memory', description: 'Manage memory', subCommands: [ - { name: 'show', description: 'Show memory' }, - { name: 'add', description: 'Add to memory' }, + createTestCommand({ name: 'show', description: 'Show memory' }), + createTestCommand({ name: 'add', description: 'Add to memory' }), ], - }, - ] as unknown as SlashCommand[]; + }), + ]; const { result } = renderHook(() => useTestHarnessForSlashCompletion( true, @@ -283,22 +423,24 @@ describe('useSlashCompletion', () => { ), ); - expect(result.current.suggestions).toEqual([ - { label: 'add', value: 'add', description: 'Add to memory' }, - ]); + await waitFor(() => { + expect(result.current.suggestions).toEqual([ + { label: 'add', value: 'add', description: 'Add to memory' }, + ]); + }); }); it('should provide no suggestions for an invalid sub-command', async () => { const slashCommands = [ - { + createTestCommand({ name: 'memory', description: 'Manage memory', subCommands: [ - { name: 'show', description: 'Show memory' }, - { name: 'add', description: 'Add to memory' }, + createTestCommand({ name: 'show', description: 'Show memory' }), + createTestCommand({ name: 'add', description: 'Add to memory' }), ], - }, - ] as unknown as SlashCommand[]; + }), + ]; const { result } = renderHook(() => useTestHarnessForSlashCompletion( true, @@ -327,18 +469,18 @@ describe('useSlashCompletion', () => { ); const slashCommands = [ - { + createTestCommand({ name: 'chat', description: 'Manage chat history', subCommands: [ - { + createTestCommand({ name: 'resume', description: 'Resume a saved chat', completion: mockCompletionFn, - }, + }), ], - }, - ] as unknown as SlashCommand[]; + }), + ]; const { result } = renderHook(() => useTestHarnessForSlashCompletion( @@ -370,18 +512,18 @@ describe('useSlashCompletion', () => { .mockResolvedValue(['my-chat-tag-1', 'my-chat-tag-2', 'my-channel']); const slashCommands = [ - { + createTestCommand({ name: 'chat', description: 'Manage chat history', subCommands: [ - { + createTestCommand({ name: 'resume', description: 'Resume a saved chat', completion: mockCompletionFn, - }, + }), ], - }, - ] as unknown as SlashCommand[]; + }), + ]; const { result } = renderHook(() => useTestHarnessForSlashCompletion( @@ -404,18 +546,18 @@ describe('useSlashCompletion', () => { it('should handle completion function that returns null', async () => { const completionFn = vi.fn().mockResolvedValue(null); const slashCommands = [ - { + createTestCommand({ name: 'chat', description: 'Manage chat history', subCommands: [ - { + createTestCommand({ name: 'resume', description: 'Resume a saved chat', completion: completionFn, - }, + }), ], - }, - ] as unknown as SlashCommand[]; + }), + ]; const { result } = renderHook(() => useTestHarnessForSlashCompletion( @@ -431,4 +573,474 @@ describe('useSlashCompletion', () => { }); }); }); + + describe('Fuzzy Matching', () => { + const fuzzyTestCommands = [ + createTestCommand({ + name: 'help', + altNames: ['?'], + description: 'Show help', + }), + createTestCommand({ + name: 'history', + description: 'Show command history', + }), + createTestCommand({ name: 'hello', description: 'Hello world command' }), + createTestCommand({ + name: 'config', + altNames: ['configure'], + description: 'Configure settings', + }), + createTestCommand({ name: 'clear', description: 'Clear the screen' }), + ]; + + it('should match commands with fuzzy search for partial queries', async () => { + const { result } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/he', + fuzzyTestCommands, + mockCommandContext, + ), + ); + + await waitFor(() => { + expect(result.current.suggestions.length).toBeGreaterThan(0); + }); + + const labels = result.current.suggestions.map((s) => s.label); + expect(labels).toEqual(expect.arrayContaining(['help', 'hello'])); + }); + + it('should handle case-insensitive fuzzy matching', async () => { + const { result } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/HeLp', + fuzzyTestCommands, + mockCommandContext, + ), + ); + + await waitFor(() => { + expect(result.current.suggestions.length).toBeGreaterThan(0); + }); + + const labels = result.current.suggestions.map((s) => s.label); + expect(labels).toContain('help'); + }); + + it('should provide typo-tolerant matching', async () => { + const { result } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/hlp', + fuzzyTestCommands, + mockCommandContext, + ), + ); + + await waitFor(() => { + expect(result.current.suggestions.length).toBeGreaterThan(0); + }); + + const labels = result.current.suggestions.map((s) => s.label); + expect(labels).toContain('help'); + }); + + it('should match against alternative names with fuzzy search', async () => { + const { result } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/conf', + fuzzyTestCommands, + mockCommandContext, + ), + ); + + await waitFor(() => { + expect(result.current.suggestions.length).toBeGreaterThan(0); + }); + + const labels = result.current.suggestions.map((s) => s.label); + expect(labels).toContain('config'); + }); + + it('should fallback to prefix matching when AsyncFzf find fails', async () => { + // Mock console.error to avoid noise in test output + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + // Import the mocked AsyncFzf + const { AsyncFzf } = await import('fzf'); + + // Create a failing find method for this specific test + const mockFind = vi + .fn() + .mockRejectedValue(new Error('AsyncFzf find failed')); + + // Mock AsyncFzf to return an instance with failing find + vi.mocked(AsyncFzf).mockImplementation( + (_items, _options) => + ({ + finder: vi.fn(), + find: mockFind, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + ); + + const testCommands = [ + createTestCommand({ name: 'clear', description: 'Clear the screen' }), + createTestCommand({ + name: 'config', + description: 'Configure settings', + }), + createTestCommand({ name: 'chat', description: 'Start chat' }), + ]; + + const { result } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/cle', + testCommands, + mockCommandContext, + ), + ); + + await waitFor(() => { + expect(result.current.suggestions.length).toBeGreaterThan(0); + }); + + // Should still get suggestions via prefix matching fallback + const labels = result.current.suggestions.map((s) => s.label); + expect(labels).toContain('clear'); + expect(labels).not.toContain('config'); // Doesn't start with 'cle' + expect(labels).not.toContain('chat'); // Doesn't start with 'cle' + + // Verify the error was logged + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[Fuzzy search - falling back to prefix matching]', + expect.any(Error), + ); + }); + + consoleErrorSpy.mockRestore(); + + // Reset AsyncFzf mock to default behavior for other tests + vi.mocked(AsyncFzf).mockImplementation(createDefaultAsyncFzfMock()); + }); + + it('should show all commands for empty partial query', async () => { + const { result } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/', + fuzzyTestCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions.length).toBe(fuzzyTestCommands.length); + }); + + it('should handle AsyncFzf errors gracefully and fallback to prefix matching', async () => { + // Mock console.error to avoid noise in test output + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + // Import the mocked AsyncFzf + const { AsyncFzf } = await import('fzf'); + + // Create a failing find method for this specific test + const mockFind = vi + .fn() + .mockRejectedValue(new Error('AsyncFzf error in find')); + + // Mock AsyncFzf to return an instance with failing find + vi.mocked(AsyncFzf).mockImplementation( + (_items, _options) => + ({ + finder: vi.fn(), + find: mockFind, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + ); + + const testCommands = [ + { name: 'test', description: 'Test command' }, + { name: 'temp', description: 'Temporary command' }, + ] as unknown as SlashCommand[]; + + const { result } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/te', + testCommands, + mockCommandContext, + ), + ); + + await waitFor(() => { + expect(result.current.suggestions.length).toBeGreaterThan(0); + }); + + // Should get suggestions via prefix matching fallback + const labels = result.current.suggestions.map((s) => s.label); + expect(labels).toEqual(expect.arrayContaining(['test', 'temp'])); + + // Verify the error was logged + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[Fuzzy search - falling back to prefix matching]', + expect.any(Error), + ); + }); + + consoleErrorSpy.mockRestore(); + + // Reset AsyncFzf mock to default behavior for other tests + vi.mocked(AsyncFzf).mockImplementation(createDefaultAsyncFzfMock()); + }); + + it('should cache AsyncFzf instances for performance', async () => { + // Reset constructor call count and ensure mock is set up correctly + resetConstructorCallCount(); + + // Import the mocked AsyncFzf + const { AsyncFzf } = await import('fzf'); + vi.mocked(AsyncFzf).mockImplementation(createDefaultAsyncFzfMock()); + + const { result, rerender } = renderHook( + ({ query }) => + useTestHarnessForSlashCompletion( + true, + query, + fuzzyTestCommands, + mockCommandContext, + ), + { initialProps: { query: '/he' } }, + ); + + await waitFor(() => { + expect(result.current.suggestions.length).toBeGreaterThan(0); + }); + + const firstResults = result.current.suggestions.map((s) => s.label); + const callCountAfterFirst = getConstructorCallCount(); + expect(callCountAfterFirst).toBeGreaterThan(0); + + // Rerender with same query - should use cached instance + rerender({ query: '/he' }); + + await waitFor(() => { + expect(result.current.suggestions.length).toBeGreaterThan(0); + }); + + const secondResults = result.current.suggestions.map((s) => s.label); + const callCountAfterSecond = getConstructorCallCount(); + + // Should have same number of constructor calls (reused cached instance) + expect(callCountAfterSecond).toBe(callCountAfterFirst); + expect(secondResults).toEqual(firstResults); + + // Different query should still use same cached instance for same command set + rerender({ query: '/hel' }); + + await waitFor(() => { + expect(result.current.suggestions.length).toBeGreaterThan(0); + }); + + const thirdCallCount = getConstructorCallCount(); + expect(thirdCallCount).toBe(callCountAfterFirst); // Same constructor call count + }); + + it('should not return duplicate suggestions when query matches both name and altNames', async () => { + const commandsWithAltNames = [ + createTestCommand({ + name: 'config', + altNames: ['configure', 'conf'], + description: 'Configure settings', + }), + createTestCommand({ + name: 'help', + altNames: ['?'], + description: 'Show help', + }), + ]; + + const { result } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/con', + commandsWithAltNames, + mockCommandContext, + ), + ); + + await waitFor(() => { + expect(result.current.suggestions.length).toBeGreaterThan(0); + }); + + const labels = result.current.suggestions.map((s) => s.label); + const uniqueLabels = new Set(labels); + + // Should not have duplicates + expect(labels.length).toBe(uniqueLabels.size); + expect(labels).toContain('config'); + }); + }); + describe('Race Condition Handling', () => { + it('should handle rapid input changes without race conditions', async () => { + const mockDelayedCompletion = vi + .fn() + .mockImplementation( + async (_context: CommandContext, partialArg: string) => { + // Simulate network delay with different delays for different inputs + const delay = partialArg.includes('slow') ? 200 : 50; + await new Promise((resolve) => setTimeout(resolve, delay)); + return [`suggestion-for-${partialArg}`]; + }, + ); + + const slashCommands = [ + createTestCommand({ + name: 'test', + description: 'Test command', + completion: mockDelayedCompletion, + }), + ]; + + const { result, rerender } = renderHook( + ({ query }) => + useTestHarnessForSlashCompletion( + true, + query, + slashCommands, + mockCommandContext, + ), + { initialProps: { query: '/test slowquery' } }, + ); + + // Quickly change to a faster query + rerender({ query: '/test fastquery' }); + + await waitFor(() => { + expect(result.current.suggestions.length).toBeGreaterThan(0); + }); + + // Should show suggestions for the latest query only + const labels = result.current.suggestions.map((s) => s.label); + expect(labels).toContain('suggestion-for-fastquery'); + expect(labels).not.toContain('suggestion-for-slowquery'); + }); + + it('should not update suggestions if component unmounts during async operation', async () => { + let resolveCompletion: (value: string[]) => void; + const mockCompletion = vi.fn().mockImplementation( + async () => + new Promise((resolve) => { + resolveCompletion = resolve; + }), + ); + + const slashCommands = [ + createTestCommand({ + name: 'test', + description: 'Test command', + completion: mockCompletion, + }), + ]; + + const { unmount } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/test query', + slashCommands, + mockCommandContext, + ), + ); + + // Start the async operation + await waitFor(() => { + expect(mockCompletion).toHaveBeenCalled(); + }); + + // Unmount before completion resolves + unmount(); + + // Now resolve the completion + resolveCompletion!(['late-suggestion']); + + // Wait a bit to ensure any pending updates would have been processed + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Since the component is unmounted, suggestions should remain empty + // and no state update errors should occur + expect(true).toBe(true); // Test passes if no errors are thrown + }); + }); + + describe('Error Logging', () => { + it('should log errors to the console', async () => { + // Mock console.error to capture log calls + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + // Import the mocked AsyncFzf + const { AsyncFzf } = await import('fzf'); + + // Create a failing find method with error containing sensitive-looking data + const sensitiveError = new Error( + 'Database connection failed: user=admin, pass=secret123', + ); + const mockFind = vi.fn().mockRejectedValue(sensitiveError); + + // Mock AsyncFzf to return an instance with failing find + vi.mocked(AsyncFzf).mockImplementation( + (_items, _options) => + ({ + find: mockFind, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + ); + + const testCommands = [ + createTestCommand({ name: 'test', description: 'Test command' }), + ]; + + const { result } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/test', + testCommands, + mockCommandContext, + ), + ); + + await waitFor(() => { + expect(result.current.suggestions.length).toBeGreaterThan(0); + }); + + // Should get fallback suggestions + const labels = result.current.suggestions.map((s) => s.label); + expect(labels).toContain('test'); + + // Verify error logging occurred + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[Fuzzy search - falling back to prefix matching]', + sensitiveError, + ); + }); + + consoleErrorSpy.mockRestore(); + + // Reset AsyncFzf mock to default behavior + vi.mocked(AsyncFzf).mockImplementation(createDefaultAsyncFzfMock()); + }); + }); }); diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.ts b/packages/cli/src/ui/hooks/useSlashCompletion.ts index ebd42658b0..62bad6a3ca 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.ts @@ -4,10 +4,351 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; +import { AsyncFzf } from 'fzf'; import type { Suggestion } from '../components/SuggestionsDisplay.js'; import type { CommandContext, SlashCommand } from '../commands/types.js'; +// Type alias for improved type safety based on actual fzf result structure +type FzfCommandResult = { + item: string; + start: number; + end: number; + score: number; + positions?: number[]; // Optional - fzf doesn't always provide match positions depending on algorithm/options used +}; + +// Interface for FZF command cache entry +interface FzfCommandCacheEntry { + fzf: AsyncFzf; + commandMap: Map; +} + +// Utility function to safely handle errors without information disclosure +function logErrorSafely(error: unknown, context: string): void { + if (error instanceof Error) { + // Log full error details securely for debugging + console.error(`[${context}]`, error); + } else { + console.error(`[${context}] Non-error thrown:`, error); + } +} + +// Shared utility function for command matching logic +function matchesCommand(cmd: SlashCommand, query: string): boolean { + return ( + cmd.name.toLowerCase() === query.toLowerCase() || + cmd.altNames?.some((alt) => alt.toLowerCase() === query.toLowerCase()) || + false + ); +} + +interface CommandParserResult { + hasTrailingSpace: boolean; + commandPathParts: string[]; + partial: string; + currentLevel: readonly SlashCommand[] | undefined; + leafCommand: SlashCommand | null; + exactMatchAsParent: SlashCommand | undefined; + isArgumentCompletion: boolean; +} + +function useCommandParser( + query: string | null, + slashCommands: readonly SlashCommand[], +): CommandParserResult { + return useMemo(() => { + if (!query) { + return { + hasTrailingSpace: false, + commandPathParts: [], + partial: '', + currentLevel: slashCommands, + leafCommand: null, + exactMatchAsParent: undefined, + isArgumentCompletion: false, + }; + } + + const fullPath = query.substring(1) || ''; + const hasTrailingSpace = !!query.endsWith(' '); + const rawParts = fullPath.split(/\s+/).filter((p) => p); + let commandPathParts = rawParts; + let partial = ''; + + if (!hasTrailingSpace && rawParts.length > 0) { + partial = rawParts[rawParts.length - 1]; + commandPathParts = rawParts.slice(0, -1); + } + + let currentLevel: readonly SlashCommand[] | undefined = slashCommands; + let leafCommand: SlashCommand | null = null; + + for (const part of commandPathParts) { + if (!currentLevel) { + leafCommand = null; + currentLevel = []; + break; + } + const found: SlashCommand | undefined = currentLevel.find((cmd) => + matchesCommand(cmd, part), + ); + if (found) { + leafCommand = found; + currentLevel = found.subCommands as readonly SlashCommand[] | undefined; + } else { + leafCommand = null; + currentLevel = []; + break; + } + } + + let exactMatchAsParent: SlashCommand | undefined; + if (!hasTrailingSpace && currentLevel) { + exactMatchAsParent = currentLevel.find( + (cmd) => matchesCommand(cmd, partial) && cmd.subCommands, + ); + + if (exactMatchAsParent) { + leafCommand = exactMatchAsParent; + currentLevel = exactMatchAsParent.subCommands; + partial = ''; + } + } + + const depth = commandPathParts.length; + const isArgumentCompletion = !!( + leafCommand?.completion && + (hasTrailingSpace || + (rawParts.length > depth && depth > 0 && partial !== '')) + ); + + return { + hasTrailingSpace, + commandPathParts, + partial, + currentLevel, + leafCommand, + exactMatchAsParent, + isArgumentCompletion, + }; + }, [query, slashCommands]); +} + +interface SuggestionsResult { + suggestions: Suggestion[]; + isLoading: boolean; +} + +interface CompletionPositions { + start: number; + end: number; +} + +interface PerfectMatchResult { + isPerfectMatch: boolean; +} + +function useCommandSuggestions( + parserResult: CommandParserResult, + commandContext: CommandContext, + getFzfForCommands: ( + commands: readonly SlashCommand[], + ) => FzfCommandCacheEntry | null, + getPrefixSuggestions: ( + commands: readonly SlashCommand[], + partial: string, + ) => SlashCommand[], +): SuggestionsResult { + const [suggestions, setSuggestions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + const abortController = new AbortController(); + const { signal } = abortController; + + const { + isArgumentCompletion, + leafCommand, + commandPathParts, + partial, + currentLevel, + } = parserResult; + + if (isArgumentCompletion) { + const fetchAndSetSuggestions = async () => { + if (signal.aborted) return; + + // Safety check: ensure leafCommand and completion exist + if (!leafCommand?.completion) { + console.warn( + 'Attempted argument completion without completion function', + ); + return; + } + + setIsLoading(true); + try { + const rawParts = [...commandPathParts]; + if (partial) rawParts.push(partial); + const depth = commandPathParts.length; + const argString = rawParts.slice(depth).join(' '); + const results = + (await leafCommand.completion(commandContext, argString)) || []; + + if (!signal.aborted) { + const finalSuggestions = results.map((s) => ({ + label: s, + value: s, + })); + setSuggestions(finalSuggestions); + setIsLoading(false); + } + } catch (error) { + if (!signal.aborted) { + logErrorSafely(error, 'Argument completion'); + setSuggestions([]); + setIsLoading(false); + } + } + }; + fetchAndSetSuggestions(); + return () => abortController.abort(); + } + + const commandsToSearch = currentLevel || []; + if (commandsToSearch.length > 0) { + const performFuzzySearch = async () => { + if (signal.aborted) return; + let potentialSuggestions: SlashCommand[] = []; + + if (partial === '') { + // If no partial query, show all available commands + potentialSuggestions = commandsToSearch.filter( + (cmd) => cmd.description, + ); + } else { + // Use fuzzy search for non-empty partial queries with fallback + const fzfInstance = getFzfForCommands(commandsToSearch); + if (fzfInstance) { + try { + const fzfResults = await fzfInstance.fzf.find(partial); + if (signal.aborted) return; + const uniqueCommands = new Set(); + fzfResults.forEach((result: FzfCommandResult) => { + const cmd = fzfInstance.commandMap.get(result.item); + if (cmd && cmd.description) { + uniqueCommands.add(cmd); + } + }); + potentialSuggestions = Array.from(uniqueCommands); + } catch (error) { + logErrorSafely( + error, + 'Fuzzy search - falling back to prefix matching', + ); + // Fallback to prefix-based filtering + potentialSuggestions = getPrefixSuggestions( + commandsToSearch, + partial, + ); + } + } else { + // Fallback to prefix-based filtering when fzf instance creation fails + potentialSuggestions = getPrefixSuggestions( + commandsToSearch, + partial, + ); + } + } + + if (!signal.aborted) { + const finalSuggestions = potentialSuggestions.map((cmd) => ({ + label: cmd.name, + value: cmd.name, + description: cmd.description, + })); + + setSuggestions(finalSuggestions); + } + }; + + performFuzzySearch().catch((error) => { + logErrorSafely(error, 'Unexpected fuzzy search error'); + if (!signal.aborted) { + // Ultimate fallback: show no suggestions rather than confusing the user + // with all available commands when their query clearly doesn't match anything + setSuggestions([]); + } + }); + return () => abortController.abort(); + } + + setSuggestions([]); + return () => abortController.abort(); + }, [parserResult, commandContext, getFzfForCommands, getPrefixSuggestions]); + + return { suggestions, isLoading }; +} + +function useCompletionPositions( + query: string | null, + parserResult: CommandParserResult, +): CompletionPositions { + return useMemo(() => { + if (!query) { + return { start: -1, end: -1 }; + } + + const { hasTrailingSpace, partial, exactMatchAsParent } = parserResult; + + // Set completion start/end positions + if (hasTrailingSpace || exactMatchAsParent) { + return { start: query.length, end: query.length }; + } else if (partial) { + if (parserResult.isArgumentCompletion) { + const commandSoFar = `/${parserResult.commandPathParts.join(' ')}`; + const argStartIndex = + commandSoFar.length + + (parserResult.commandPathParts.length > 0 ? 1 : 0); + return { start: argStartIndex, end: query.length }; + } else { + return { start: query.length - partial.length, end: query.length }; + } + } else { + return { start: 1, end: query.length }; + } + }, [query, parserResult]); +} + +function usePerfectMatch( + parserResult: CommandParserResult, +): PerfectMatchResult { + return useMemo(() => { + const { hasTrailingSpace, partial, leafCommand, currentLevel } = + parserResult; + + if (hasTrailingSpace) { + return { isPerfectMatch: false }; + } + + if (leafCommand && partial === '' && leafCommand.action) { + return { isPerfectMatch: true }; + } + + if (currentLevel) { + const perfectMatch = currentLevel.find( + (cmd) => matchesCommand(cmd, partial) && cmd.action, + ); + if (perfectMatch) { + return { isPerfectMatch: true }; + } + } + + return { isPerfectMatch: false }; + }, [parserResult]); +} + export interface UseSlashCompletionProps { enabled: boolean; query: string | null; @@ -34,147 +375,120 @@ export function useSlashCompletion(props: UseSlashCompletionProps): { const [completionStart, setCompletionStart] = useState(-1); const [completionEnd, setCompletionEnd] = useState(-1); - useEffect(() => { - if (!enabled || query === null) { - return; - } + // Simplified cache for AsyncFzf instances - WeakMap handles automatic cleanup + const fzfInstanceCache = useMemo( + () => new WeakMap(), + [], + ); - const fullPath = query?.substring(1) || ''; - const hasTrailingSpace = !!query?.endsWith(' '); - const rawParts = fullPath.split(/\s+/).filter((p) => p); - let commandPathParts = rawParts; - let partial = ''; - - if (!hasTrailingSpace && rawParts.length > 0) { - partial = rawParts[rawParts.length - 1]; - commandPathParts = rawParts.slice(0, -1); - } - - let currentLevel: readonly SlashCommand[] | undefined = slashCommands; - let leafCommand: SlashCommand | null = null; - - for (const part of commandPathParts) { - if (!currentLevel) { - leafCommand = null; - currentLevel = []; - break; + // Helper function to create or retrieve cached AsyncFzf instance for a command level + const getFzfForCommands = useMemo( + () => (commands: readonly SlashCommand[]) => { + if (!commands || commands.length === 0) { + return null; } - const found: SlashCommand | undefined = currentLevel.find( - (cmd) => cmd.name === part || cmd.altNames?.includes(part), - ); - if (found) { - leafCommand = found; - currentLevel = found.subCommands as readonly SlashCommand[] | undefined; - } else { - leafCommand = null; - currentLevel = []; - break; + + // Check if we already have a cached instance + const cached = fzfInstanceCache.get(commands); + if (cached) { + return cached; } - } - let exactMatchAsParent: SlashCommand | undefined; - if (!hasTrailingSpace && currentLevel) { - exactMatchAsParent = currentLevel.find( - (cmd) => - (cmd.name === partial || cmd.altNames?.includes(partial)) && - cmd.subCommands, - ); + // Create new fzf instance + const commandItems: string[] = []; + const commandMap = new Map(); - if (exactMatchAsParent) { - leafCommand = exactMatchAsParent; - currentLevel = exactMatchAsParent.subCommands; - partial = ''; - } - } + commands.forEach((cmd) => { + if (cmd.description) { + commandItems.push(cmd.name); + commandMap.set(cmd.name, cmd); - setIsPerfectMatch(false); - if (!hasTrailingSpace) { - if (leafCommand && partial === '' && leafCommand.action) { - setIsPerfectMatch(true); - } else if (currentLevel) { - const perfectMatch = currentLevel.find( - (cmd) => - (cmd.name === partial || cmd.altNames?.includes(partial)) && - cmd.action, - ); - if (perfectMatch) { - setIsPerfectMatch(true); + if (cmd.altNames) { + cmd.altNames.forEach((alt) => { + commandItems.push(alt); + commandMap.set(alt, cmd); + }); + } } + }); + + if (commandItems.length === 0) { + return null; } - } - const depth = commandPathParts.length; - const isArgumentCompletion = - leafCommand?.completion && - (hasTrailingSpace || - (rawParts.length > depth && depth > 0 && partial !== '')); + try { + const instance: FzfCommandCacheEntry = { + fzf: new AsyncFzf(commandItems, { + fuzzy: 'v2', + casing: 'case-insensitive', // Explicitly enforce case-insensitivity + }), + commandMap, + }; - if (hasTrailingSpace || exactMatchAsParent) { - setCompletionStart(query.length); - setCompletionEnd(query.length); - } else if (partial) { - if (isArgumentCompletion) { - const commandSoFar = `/${commandPathParts.join(' ')}`; - const argStartIndex = - commandSoFar.length + (commandPathParts.length > 0 ? 1 : 0); - setCompletionStart(argStartIndex); - } else { - setCompletionStart(query.length - partial.length); + // Cache the instance - WeakMap will handle automatic cleanup + fzfInstanceCache.set(commands, instance); + + return instance; + } catch (error) { + logErrorSafely(error, 'FZF instance creation'); + return null; } - setCompletionEnd(query.length); - } else { - setCompletionStart(1); - setCompletionEnd(query.length); - } + }, + [fzfInstanceCache], + ); - if (isArgumentCompletion) { - const fetchAndSetSuggestions = async () => { - setIsLoadingSuggestions(true); - const argString = rawParts.slice(depth).join(' '); - const results = - (await leafCommand!.completion!(commandContext, argString)) || []; - const finalSuggestions = results.map((s) => ({ label: s, value: s })); - setSuggestions(finalSuggestions); - setIsLoadingSuggestions(false); - }; - fetchAndSetSuggestions(); - return; - } - - const commandsToSearch = currentLevel || []; - if (commandsToSearch.length > 0) { - let potentialSuggestions = commandsToSearch.filter( + // Memoized helper function for prefix-based filtering to improve performance + const getPrefixSuggestions = useMemo( + () => (commands: readonly SlashCommand[], partial: string) => + commands.filter( (cmd) => cmd.description && - (cmd.name.startsWith(partial) || - cmd.altNames?.some((alt) => alt.startsWith(partial))), - ); + (cmd.name.toLowerCase().startsWith(partial.toLowerCase()) || + cmd.altNames?.some((alt) => + alt.toLowerCase().startsWith(partial.toLowerCase()), + )), + ), + [], + ); - if (potentialSuggestions.length > 0 && !hasTrailingSpace) { - const perfectMatch = potentialSuggestions.find( - (s) => s.name === partial || s.altNames?.includes(partial), - ); - if (perfectMatch && perfectMatch.action) { - potentialSuggestions = []; - } - } + // Use extracted hooks for better separation of concerns + const parserResult = useCommandParser(query, slashCommands); + const { suggestions: hookSuggestions, isLoading } = useCommandSuggestions( + parserResult, + commandContext, + getFzfForCommands, + getPrefixSuggestions, + ); + const { start: calculatedStart, end: calculatedEnd } = useCompletionPositions( + query, + parserResult, + ); + const { isPerfectMatch } = usePerfectMatch(parserResult); - const finalSuggestions = potentialSuggestions.map((cmd) => ({ - label: cmd.name, - value: cmd.name, - description: cmd.description, - })); - - setSuggestions(finalSuggestions); + // Update external state - this is now much simpler and focused + useEffect(() => { + if (!enabled || query === null) { + setSuggestions([]); + setIsLoadingSuggestions(false); + setIsPerfectMatch(false); + setCompletionStart(-1); + setCompletionEnd(-1); return; } - setSuggestions([]); + setSuggestions(hookSuggestions); + setIsLoadingSuggestions(isLoading); + setIsPerfectMatch(isPerfectMatch); + setCompletionStart(calculatedStart); + setCompletionEnd(calculatedEnd); }, [ enabled, query, - slashCommands, - commandContext, + hookSuggestions, + isLoading, + isPerfectMatch, + calculatedStart, + calculatedEnd, setSuggestions, setIsLoadingSuggestions, setIsPerfectMatch,