mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
feat(cli): add fuzzy matching for command suggestions (#6633)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com> Co-authored-by: Sandy Tao <sandytao520@icloud.com>
This commit is contained in:
@@ -10,9 +10,135 @@ import { describe, it, expect, vi } from 'vitest';
|
|||||||
import { renderHook, waitFor } from '@testing-library/react';
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
import { useSlashCompletion } from './useSlashCompletion.js';
|
import { useSlashCompletion } from './useSlashCompletion.js';
|
||||||
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
||||||
|
import { CommandKind } from '../commands/types.js';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { Suggestion } from '../components/SuggestionsDisplay.js';
|
import type { Suggestion } from '../components/SuggestionsDisplay.js';
|
||||||
|
|
||||||
|
// Test utility type and helper function for creating test SlashCommands
|
||||||
|
type TestSlashCommand = Omit<SlashCommand, 'kind'> &
|
||||||
|
Partial<Pick<SlashCommand, 'kind'>>;
|
||||||
|
|
||||||
|
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<typeof import('fzf')>('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.
|
// Test harness to capture the state from the hook's callbacks.
|
||||||
function useTestHarnessForSlashCompletion(
|
function useTestHarnessForSlashCompletion(
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
@@ -50,20 +176,26 @@ describe('useSlashCompletion', () => {
|
|||||||
describe('Top-Level Commands', () => {
|
describe('Top-Level Commands', () => {
|
||||||
it('should suggest all top-level commands for the root slash', async () => {
|
it('should suggest all top-level commands for the root slash', async () => {
|
||||||
const slashCommands = [
|
const slashCommands = [
|
||||||
{ name: 'help', altNames: ['?'], description: 'Show help' },
|
createTestCommand({
|
||||||
{
|
name: 'help',
|
||||||
|
altNames: ['?'],
|
||||||
|
description: 'Show help',
|
||||||
|
}),
|
||||||
|
createTestCommand({
|
||||||
name: 'stats',
|
name: 'stats',
|
||||||
altNames: ['usage'],
|
altNames: ['usage'],
|
||||||
description: 'check session stats. Usage: /stats [model|tools]',
|
description: 'check session stats. Usage: /stats [model|tools]',
|
||||||
},
|
}),
|
||||||
{ name: 'clear', description: 'Clear the screen' },
|
createTestCommand({ name: 'clear', description: 'Clear the screen' }),
|
||||||
{
|
createTestCommand({
|
||||||
name: 'memory',
|
name: 'memory',
|
||||||
description: 'Manage memory',
|
description: 'Manage memory',
|
||||||
subCommands: [{ name: 'show', description: 'Show memory' }],
|
subCommands: [
|
||||||
},
|
createTestCommand({ name: 'show', description: 'Show memory' }),
|
||||||
{ name: 'chat', description: 'Manage chat history' },
|
],
|
||||||
] as unknown as SlashCommand[];
|
}),
|
||||||
|
createTestCommand({ name: 'chat', description: 'Manage chat history' }),
|
||||||
|
];
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTestHarnessForSlashCompletion(
|
useTestHarnessForSlashCompletion(
|
||||||
true,
|
true,
|
||||||
@@ -81,8 +213,8 @@ describe('useSlashCompletion', () => {
|
|||||||
|
|
||||||
it('should filter commands based on partial input', async () => {
|
it('should filter commands based on partial input', async () => {
|
||||||
const slashCommands = [
|
const slashCommands = [
|
||||||
{ name: 'memory', description: 'Manage memory' },
|
createTestCommand({ name: 'memory', description: 'Manage memory' }),
|
||||||
] as unknown as SlashCommand[];
|
];
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTestHarnessForSlashCompletion(
|
useTestHarnessForSlashCompletion(
|
||||||
true,
|
true,
|
||||||
@@ -92,19 +224,21 @@ describe('useSlashCompletion', () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
expect(result.current.suggestions).toEqual([
|
expect(result.current.suggestions).toEqual([
|
||||||
{ label: 'memory', value: 'memory', description: 'Manage memory' },
|
{ label: 'memory', value: 'memory', description: 'Manage memory' },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should suggest commands based on partial altNames', async () => {
|
it('should suggest commands based on partial altNames', async () => {
|
||||||
const slashCommands = [
|
const slashCommands = [
|
||||||
{
|
createTestCommand({
|
||||||
name: 'stats',
|
name: 'stats',
|
||||||
altNames: ['usage'],
|
altNames: ['usage'],
|
||||||
description: 'check session stats. Usage: /stats [model|tools]',
|
description: 'check session stats. Usage: /stats [model|tools]',
|
||||||
},
|
}),
|
||||||
] as unknown as SlashCommand[];
|
];
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTestHarnessForSlashCompletion(
|
useTestHarnessForSlashCompletion(
|
||||||
true,
|
true,
|
||||||
@@ -114,6 +248,7 @@ describe('useSlashCompletion', () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
expect(result.current.suggestions).toEqual([
|
expect(result.current.suggestions).toEqual([
|
||||||
{
|
{
|
||||||
label: 'stats',
|
label: 'stats',
|
||||||
@@ -122,11 +257,16 @@ describe('useSlashCompletion', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should NOT provide suggestions for a perfectly typed command that is a leaf node', async () => {
|
it('should NOT provide suggestions for a perfectly typed command that is a leaf node', async () => {
|
||||||
const slashCommands = [
|
const slashCommands = [
|
||||||
{ name: 'clear', description: 'Clear the screen', action: vi.fn() },
|
createTestCommand({
|
||||||
] as unknown as SlashCommand[];
|
name: 'clear',
|
||||||
|
description: 'Clear the screen',
|
||||||
|
action: vi.fn(),
|
||||||
|
}),
|
||||||
|
];
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTestHarnessForSlashCompletion(
|
useTestHarnessForSlashCompletion(
|
||||||
true,
|
true,
|
||||||
@@ -143,19 +283,19 @@ describe('useSlashCompletion', () => {
|
|||||||
'should not suggest commands when altNames is fully typed',
|
'should not suggest commands when altNames is fully typed',
|
||||||
async (query) => {
|
async (query) => {
|
||||||
const mockSlashCommands = [
|
const mockSlashCommands = [
|
||||||
{
|
createTestCommand({
|
||||||
name: 'help',
|
name: 'help',
|
||||||
altNames: ['?'],
|
altNames: ['?'],
|
||||||
description: 'Show help',
|
description: 'Show help',
|
||||||
action: vi.fn(),
|
action: vi.fn(),
|
||||||
},
|
}),
|
||||||
{
|
createTestCommand({
|
||||||
name: 'stats',
|
name: 'stats',
|
||||||
altNames: ['usage'],
|
altNames: ['usage'],
|
||||||
description: 'check session stats. Usage: /stats [model|tools]',
|
description: 'check session stats. Usage: /stats [model|tools]',
|
||||||
action: vi.fn(),
|
action: vi.fn(),
|
||||||
},
|
}),
|
||||||
] as unknown as SlashCommand[];
|
];
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTestHarnessForSlashCompletion(
|
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 () => {
|
it('should not provide suggestions for a fully typed command that has no sub-commands or argument completion', async () => {
|
||||||
const slashCommands = [
|
const slashCommands = [
|
||||||
{ name: 'clear', description: 'Clear the screen' },
|
createTestCommand({ name: 'clear', description: 'Clear the screen' }),
|
||||||
] as unknown as SlashCommand[];
|
];
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTestHarnessForSlashCompletion(
|
useTestHarnessForSlashCompletion(
|
||||||
true,
|
true,
|
||||||
@@ -188,8 +328,8 @@ describe('useSlashCompletion', () => {
|
|||||||
|
|
||||||
it('should not provide suggestions for an unknown command', async () => {
|
it('should not provide suggestions for an unknown command', async () => {
|
||||||
const slashCommands = [
|
const slashCommands = [
|
||||||
{ name: 'help', description: 'Show help' },
|
createTestCommand({ name: 'help', description: 'Show help' }),
|
||||||
] as unknown as SlashCommand[];
|
];
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTestHarnessForSlashCompletion(
|
useTestHarnessForSlashCompletion(
|
||||||
true,
|
true,
|
||||||
@@ -206,15 +346,15 @@ describe('useSlashCompletion', () => {
|
|||||||
describe('Sub-Commands', () => {
|
describe('Sub-Commands', () => {
|
||||||
it('should suggest sub-commands for a parent command', async () => {
|
it('should suggest sub-commands for a parent command', async () => {
|
||||||
const slashCommands = [
|
const slashCommands = [
|
||||||
{
|
createTestCommand({
|
||||||
name: 'memory',
|
name: 'memory',
|
||||||
description: 'Manage memory',
|
description: 'Manage memory',
|
||||||
subCommands: [
|
subCommands: [
|
||||||
{ name: 'show', description: 'Show memory' },
|
createTestCommand({ name: 'show', description: 'Show memory' }),
|
||||||
{ name: 'add', description: 'Add to memory' },
|
createTestCommand({ name: 'add', description: 'Add to memory' }),
|
||||||
],
|
],
|
||||||
},
|
}),
|
||||||
] as unknown as SlashCommand[];
|
];
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTestHarnessForSlashCompletion(
|
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 () => {
|
it('should suggest all sub-commands when the query ends with the parent command and a space', async () => {
|
||||||
const slashCommands = [
|
const slashCommands = [
|
||||||
{
|
createTestCommand({
|
||||||
name: 'memory',
|
name: 'memory',
|
||||||
description: 'Manage memory',
|
description: 'Manage memory',
|
||||||
subCommands: [
|
subCommands: [
|
||||||
{ name: 'show', description: 'Show memory' },
|
createTestCommand({ name: 'show', description: 'Show memory' }),
|
||||||
{ name: 'add', description: 'Add to memory' },
|
createTestCommand({ name: 'add', description: 'Add to memory' }),
|
||||||
],
|
],
|
||||||
},
|
}),
|
||||||
] as unknown as SlashCommand[];
|
];
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTestHarnessForSlashCompletion(
|
useTestHarnessForSlashCompletion(
|
||||||
true,
|
true,
|
||||||
@@ -265,15 +405,15 @@ describe('useSlashCompletion', () => {
|
|||||||
|
|
||||||
it('should filter sub-commands by prefix', async () => {
|
it('should filter sub-commands by prefix', async () => {
|
||||||
const slashCommands = [
|
const slashCommands = [
|
||||||
{
|
createTestCommand({
|
||||||
name: 'memory',
|
name: 'memory',
|
||||||
description: 'Manage memory',
|
description: 'Manage memory',
|
||||||
subCommands: [
|
subCommands: [
|
||||||
{ name: 'show', description: 'Show memory' },
|
createTestCommand({ name: 'show', description: 'Show memory' }),
|
||||||
{ name: 'add', description: 'Add to memory' },
|
createTestCommand({ name: 'add', description: 'Add to memory' }),
|
||||||
],
|
],
|
||||||
},
|
}),
|
||||||
] as unknown as SlashCommand[];
|
];
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTestHarnessForSlashCompletion(
|
useTestHarnessForSlashCompletion(
|
||||||
true,
|
true,
|
||||||
@@ -283,22 +423,24 @@ describe('useSlashCompletion', () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
expect(result.current.suggestions).toEqual([
|
expect(result.current.suggestions).toEqual([
|
||||||
{ label: 'add', value: 'add', description: 'Add to memory' },
|
{ label: 'add', value: 'add', description: 'Add to memory' },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should provide no suggestions for an invalid sub-command', async () => {
|
it('should provide no suggestions for an invalid sub-command', async () => {
|
||||||
const slashCommands = [
|
const slashCommands = [
|
||||||
{
|
createTestCommand({
|
||||||
name: 'memory',
|
name: 'memory',
|
||||||
description: 'Manage memory',
|
description: 'Manage memory',
|
||||||
subCommands: [
|
subCommands: [
|
||||||
{ name: 'show', description: 'Show memory' },
|
createTestCommand({ name: 'show', description: 'Show memory' }),
|
||||||
{ name: 'add', description: 'Add to memory' },
|
createTestCommand({ name: 'add', description: 'Add to memory' }),
|
||||||
],
|
],
|
||||||
},
|
}),
|
||||||
] as unknown as SlashCommand[];
|
];
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTestHarnessForSlashCompletion(
|
useTestHarnessForSlashCompletion(
|
||||||
true,
|
true,
|
||||||
@@ -327,18 +469,18 @@ describe('useSlashCompletion', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const slashCommands = [
|
const slashCommands = [
|
||||||
{
|
createTestCommand({
|
||||||
name: 'chat',
|
name: 'chat',
|
||||||
description: 'Manage chat history',
|
description: 'Manage chat history',
|
||||||
subCommands: [
|
subCommands: [
|
||||||
{
|
createTestCommand({
|
||||||
name: 'resume',
|
name: 'resume',
|
||||||
description: 'Resume a saved chat',
|
description: 'Resume a saved chat',
|
||||||
completion: mockCompletionFn,
|
completion: mockCompletionFn,
|
||||||
},
|
}),
|
||||||
],
|
],
|
||||||
},
|
}),
|
||||||
] as unknown as SlashCommand[];
|
];
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTestHarnessForSlashCompletion(
|
useTestHarnessForSlashCompletion(
|
||||||
@@ -370,18 +512,18 @@ describe('useSlashCompletion', () => {
|
|||||||
.mockResolvedValue(['my-chat-tag-1', 'my-chat-tag-2', 'my-channel']);
|
.mockResolvedValue(['my-chat-tag-1', 'my-chat-tag-2', 'my-channel']);
|
||||||
|
|
||||||
const slashCommands = [
|
const slashCommands = [
|
||||||
{
|
createTestCommand({
|
||||||
name: 'chat',
|
name: 'chat',
|
||||||
description: 'Manage chat history',
|
description: 'Manage chat history',
|
||||||
subCommands: [
|
subCommands: [
|
||||||
{
|
createTestCommand({
|
||||||
name: 'resume',
|
name: 'resume',
|
||||||
description: 'Resume a saved chat',
|
description: 'Resume a saved chat',
|
||||||
completion: mockCompletionFn,
|
completion: mockCompletionFn,
|
||||||
},
|
}),
|
||||||
],
|
],
|
||||||
},
|
}),
|
||||||
] as unknown as SlashCommand[];
|
];
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTestHarnessForSlashCompletion(
|
useTestHarnessForSlashCompletion(
|
||||||
@@ -404,18 +546,18 @@ describe('useSlashCompletion', () => {
|
|||||||
it('should handle completion function that returns null', async () => {
|
it('should handle completion function that returns null', async () => {
|
||||||
const completionFn = vi.fn().mockResolvedValue(null);
|
const completionFn = vi.fn().mockResolvedValue(null);
|
||||||
const slashCommands = [
|
const slashCommands = [
|
||||||
{
|
createTestCommand({
|
||||||
name: 'chat',
|
name: 'chat',
|
||||||
description: 'Manage chat history',
|
description: 'Manage chat history',
|
||||||
subCommands: [
|
subCommands: [
|
||||||
{
|
createTestCommand({
|
||||||
name: 'resume',
|
name: 'resume',
|
||||||
description: 'Resume a saved chat',
|
description: 'Resume a saved chat',
|
||||||
completion: completionFn,
|
completion: completionFn,
|
||||||
},
|
}),
|
||||||
],
|
],
|
||||||
},
|
}),
|
||||||
] as unknown as SlashCommand[];
|
];
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTestHarnessForSlashCompletion(
|
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<string[]>((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());
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,10 +4,351 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* 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 { Suggestion } from '../components/SuggestionsDisplay.js';
|
||||||
import type { CommandContext, SlashCommand } from '../commands/types.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<string[]>;
|
||||||
|
commandMap: Map<string, SlashCommand>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<Suggestion[]>([]);
|
||||||
|
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<SlashCommand>();
|
||||||
|
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 {
|
export interface UseSlashCompletionProps {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
query: string | null;
|
query: string | null;
|
||||||
@@ -34,147 +375,120 @@ export function useSlashCompletion(props: UseSlashCompletionProps): {
|
|||||||
const [completionStart, setCompletionStart] = useState(-1);
|
const [completionStart, setCompletionStart] = useState(-1);
|
||||||
const [completionEnd, setCompletionEnd] = useState(-1);
|
const [completionEnd, setCompletionEnd] = useState(-1);
|
||||||
|
|
||||||
useEffect(() => {
|
// Simplified cache for AsyncFzf instances - WeakMap handles automatic cleanup
|
||||||
if (!enabled || query === null) {
|
const fzfInstanceCache = useMemo(
|
||||||
return;
|
() => new WeakMap<readonly SlashCommand[], FzfCommandCacheEntry>(),
|
||||||
}
|
[],
|
||||||
|
|
||||||
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) => cmd.name === part || cmd.altNames?.includes(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) =>
|
|
||||||
(cmd.name === partial || cmd.altNames?.includes(partial)) &&
|
|
||||||
cmd.subCommands,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (exactMatchAsParent) {
|
// Helper function to create or retrieve cached AsyncFzf instance for a command level
|
||||||
leafCommand = exactMatchAsParent;
|
const getFzfForCommands = useMemo(
|
||||||
currentLevel = exactMatchAsParent.subCommands;
|
() => (commands: readonly SlashCommand[]) => {
|
||||||
partial = '';
|
if (!commands || commands.length === 0) {
|
||||||
}
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsPerfectMatch(false);
|
// Check if we already have a cached instance
|
||||||
if (!hasTrailingSpace) {
|
const cached = fzfInstanceCache.get(commands);
|
||||||
if (leafCommand && partial === '' && leafCommand.action) {
|
if (cached) {
|
||||||
setIsPerfectMatch(true);
|
return cached;
|
||||||
} else if (currentLevel) {
|
|
||||||
const perfectMatch = currentLevel.find(
|
|
||||||
(cmd) =>
|
|
||||||
(cmd.name === partial || cmd.altNames?.includes(partial)) &&
|
|
||||||
cmd.action,
|
|
||||||
);
|
|
||||||
if (perfectMatch) {
|
|
||||||
setIsPerfectMatch(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const depth = commandPathParts.length;
|
// Create new fzf instance
|
||||||
const isArgumentCompletion =
|
const commandItems: string[] = [];
|
||||||
leafCommand?.completion &&
|
const commandMap = new Map<string, SlashCommand>();
|
||||||
(hasTrailingSpace ||
|
|
||||||
(rawParts.length > depth && depth > 0 && partial !== ''));
|
|
||||||
|
|
||||||
if (hasTrailingSpace || exactMatchAsParent) {
|
commands.forEach((cmd) => {
|
||||||
setCompletionStart(query.length);
|
if (cmd.description) {
|
||||||
setCompletionEnd(query.length);
|
commandItems.push(cmd.name);
|
||||||
} else if (partial) {
|
commandMap.set(cmd.name, cmd);
|
||||||
if (isArgumentCompletion) {
|
|
||||||
const commandSoFar = `/${commandPathParts.join(' ')}`;
|
if (cmd.altNames) {
|
||||||
const argStartIndex =
|
cmd.altNames.forEach((alt) => {
|
||||||
commandSoFar.length + (commandPathParts.length > 0 ? 1 : 0);
|
commandItems.push(alt);
|
||||||
setCompletionStart(argStartIndex);
|
commandMap.set(alt, cmd);
|
||||||
} else {
|
});
|
||||||
setCompletionStart(query.length - partial.length);
|
|
||||||
}
|
}
|
||||||
setCompletionEnd(query.length);
|
}
|
||||||
} else {
|
});
|
||||||
setCompletionStart(1);
|
|
||||||
setCompletionEnd(query.length);
|
if (commandItems.length === 0) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isArgumentCompletion) {
|
try {
|
||||||
const fetchAndSetSuggestions = async () => {
|
const instance: FzfCommandCacheEntry = {
|
||||||
setIsLoadingSuggestions(true);
|
fzf: new AsyncFzf(commandItems, {
|
||||||
const argString = rawParts.slice(depth).join(' ');
|
fuzzy: 'v2',
|
||||||
const results =
|
casing: 'case-insensitive', // Explicitly enforce case-insensitivity
|
||||||
(await leafCommand!.completion!(commandContext, argString)) || [];
|
}),
|
||||||
const finalSuggestions = results.map((s) => ({ label: s, value: s }));
|
commandMap,
|
||||||
setSuggestions(finalSuggestions);
|
|
||||||
setIsLoadingSuggestions(false);
|
|
||||||
};
|
};
|
||||||
fetchAndSetSuggestions();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const commandsToSearch = currentLevel || [];
|
// Cache the instance - WeakMap will handle automatic cleanup
|
||||||
if (commandsToSearch.length > 0) {
|
fzfInstanceCache.set(commands, instance);
|
||||||
let potentialSuggestions = commandsToSearch.filter(
|
|
||||||
|
return instance;
|
||||||
|
} catch (error) {
|
||||||
|
logErrorSafely(error, 'FZF instance creation');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fzfInstanceCache],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Memoized helper function for prefix-based filtering to improve performance
|
||||||
|
const getPrefixSuggestions = useMemo(
|
||||||
|
() => (commands: readonly SlashCommand[], partial: string) =>
|
||||||
|
commands.filter(
|
||||||
(cmd) =>
|
(cmd) =>
|
||||||
cmd.description &&
|
cmd.description &&
|
||||||
(cmd.name.startsWith(partial) ||
|
(cmd.name.toLowerCase().startsWith(partial.toLowerCase()) ||
|
||||||
cmd.altNames?.some((alt) => alt.startsWith(partial))),
|
cmd.altNames?.some((alt) =>
|
||||||
|
alt.toLowerCase().startsWith(partial.toLowerCase()),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (potentialSuggestions.length > 0 && !hasTrailingSpace) {
|
// Use extracted hooks for better separation of concerns
|
||||||
const perfectMatch = potentialSuggestions.find(
|
const parserResult = useCommandParser(query, slashCommands);
|
||||||
(s) => s.name === partial || s.altNames?.includes(partial),
|
const { suggestions: hookSuggestions, isLoading } = useCommandSuggestions(
|
||||||
|
parserResult,
|
||||||
|
commandContext,
|
||||||
|
getFzfForCommands,
|
||||||
|
getPrefixSuggestions,
|
||||||
);
|
);
|
||||||
if (perfectMatch && perfectMatch.action) {
|
const { start: calculatedStart, end: calculatedEnd } = useCompletionPositions(
|
||||||
potentialSuggestions = [];
|
query,
|
||||||
}
|
parserResult,
|
||||||
}
|
);
|
||||||
|
const { isPerfectMatch } = usePerfectMatch(parserResult);
|
||||||
|
|
||||||
const finalSuggestions = potentialSuggestions.map((cmd) => ({
|
// Update external state - this is now much simpler and focused
|
||||||
label: cmd.name,
|
useEffect(() => {
|
||||||
value: cmd.name,
|
if (!enabled || query === null) {
|
||||||
description: cmd.description,
|
setSuggestions([]);
|
||||||
}));
|
setIsLoadingSuggestions(false);
|
||||||
|
setIsPerfectMatch(false);
|
||||||
setSuggestions(finalSuggestions);
|
setCompletionStart(-1);
|
||||||
|
setCompletionEnd(-1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSuggestions([]);
|
setSuggestions(hookSuggestions);
|
||||||
|
setIsLoadingSuggestions(isLoading);
|
||||||
|
setIsPerfectMatch(isPerfectMatch);
|
||||||
|
setCompletionStart(calculatedStart);
|
||||||
|
setCompletionEnd(calculatedEnd);
|
||||||
}, [
|
}, [
|
||||||
enabled,
|
enabled,
|
||||||
query,
|
query,
|
||||||
slashCommands,
|
hookSuggestions,
|
||||||
commandContext,
|
isLoading,
|
||||||
|
isPerfectMatch,
|
||||||
|
calculatedStart,
|
||||||
|
calculatedEnd,
|
||||||
setSuggestions,
|
setSuggestions,
|
||||||
setIsLoadingSuggestions,
|
setIsLoadingSuggestions,
|
||||||
setIsPerfectMatch,
|
setIsPerfectMatch,
|
||||||
|
|||||||
Reference in New Issue
Block a user