fix(ui): show command suggestions even on perfect match and sort them (#15287)

This commit is contained in:
Sandy Tao
2025-12-18 14:05:36 -10:00
committed by GitHub
parent 1e10492e55
commit 70696e364b
5 changed files with 209 additions and 11 deletions
@@ -319,7 +319,7 @@ describe('useSlashCompletion', () => {
unmount!();
});
it('should NOT provide suggestions for a perfectly typed command that is a leaf node', async () => {
it('should provide suggestions even for a perfectly typed command that is a leaf node', async () => {
const slashCommands = [
createTestCommand({
name: 'clear',
@@ -344,14 +344,15 @@ describe('useSlashCompletion', () => {
unmount = hook.unmount;
});
await waitFor(() => {
expect(result.current.suggestions).toHaveLength(0);
expect(result.current.suggestions).toHaveLength(1);
expect(result.current.suggestions[0].label).toBe('clear');
expect(result.current.completionStart).toBe(1);
});
unmount!();
});
it.each([['/?'], ['/usage']])(
'should not suggest commands when altNames is fully typed',
'should suggest commands even when altNames is fully typed',
async (query) => {
const mockSlashCommands = [
createTestCommand({
@@ -387,13 +388,121 @@ describe('useSlashCompletion', () => {
});
await waitFor(() => {
expect(result.current.suggestions).toHaveLength(0);
expect(result.current.suggestions).toHaveLength(1);
expect(result.current.completionStart).toBe(1);
});
unmount!();
},
);
it('should show all matching suggestions even when one is a perfect match', async () => {
const slashCommands = [
createTestCommand({
name: 'review',
description: 'Review code',
action: vi.fn(),
}),
createTestCommand({
name: 'review-frontend',
description: 'Review frontend code',
action: vi.fn(),
}),
createTestCommand({
name: 'oncall:pr-review',
description: 'Review PR as oncall',
action: vi.fn(),
}),
];
const { result, unmount } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/review',
slashCommands,
mockCommandContext,
),
);
await waitFor(() => {
// All three should match 'review' in our fuzzy mock or as prefix/exact
expect(result.current.suggestions.length).toBe(3);
// 'review' should be first because it is an exact match
expect(result.current.suggestions[0].label).toBe('review');
const labels = result.current.suggestions.map((s) => s.label);
expect(labels).toContain('review');
expect(labels).toContain('review-frontend');
expect(labels).toContain('oncall:pr-review');
expect(result.current.isPerfectMatch).toBe(true);
});
unmount();
});
it('should sort exact altName matches to the top', async () => {
const slashCommands = [
createTestCommand({
name: 'help',
altNames: ['?'],
description: 'Show help',
action: vi.fn(),
}),
createTestCommand({
name: 'question-mark',
description: 'Alternative name for help',
action: vi.fn(),
}),
];
const { result, unmount } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/?',
slashCommands,
mockCommandContext,
),
);
await waitFor(() => {
// 'help' should be first because '?' is an exact altName match
expect(result.current.suggestions[0].label).toBe('help');
expect(result.current.isPerfectMatch).toBe(true);
});
unmount();
});
it('should suggest subcommands when a parent command is fully typed without a trailing space', async () => {
const slashCommands = [
createTestCommand({
name: 'chat',
description: 'Manage chat history',
subCommands: [
createTestCommand({ name: 'list', description: 'List chats' }),
createTestCommand({ name: 'save', description: 'Save chat' }),
],
}),
];
const { result, unmount } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/chat',
slashCommands,
mockCommandContext,
),
);
await waitFor(() => {
// Should show subcommands of 'chat'
expect(result.current.suggestions).toHaveLength(2);
expect(result.current.suggestions.map((s) => s.label)).toEqual(
expect.arrayContaining(['list', 'save']),
);
// completionStart should be at the end of '/chat' to append subcommands
expect(result.current.completionStart).toBe(5);
});
unmount();
});
it('should not provide suggestions for a fully typed command that has no sub-commands or argument completion', async () => {
const slashCommands = [
createTestCommand({ name: 'clear', description: 'Clear the screen' }),
@@ -117,6 +117,27 @@ function useCommandParser(
exactMatchAsParent = currentLevel.find(
(cmd) => matchesCommand(cmd, partial) && cmd.subCommands,
);
if (exactMatchAsParent) {
// Only descend if there are NO other matches for the partial at this level.
// This ensures that typing "/memory" still shows "/memory-leak" if it exists.
const otherMatches = currentLevel.filter(
(cmd) =>
cmd !== exactMatchAsParent &&
(cmd.name.toLowerCase().startsWith(partial.toLowerCase()) ||
cmd.altNames?.some((alt) =>
alt.toLowerCase().startsWith(partial.toLowerCase()),
)),
);
if (otherMatches.length === 0) {
leafCommand = exactMatchAsParent;
currentLevel = exactMatchAsParent.subCommands as
| readonly SlashCommand[]
| undefined;
partial = '';
}
}
}
const depth = commandPathParts.length;
@@ -278,7 +299,16 @@ function useCommandSuggestions(
}
if (!signal.aborted) {
const finalSuggestions = potentialSuggestions.map((cmd) => ({
// Sort potentialSuggestions so that exact match (by name or altName) comes first
const sortedSuggestions = [...potentialSuggestions].sort((a, b) => {
const aIsExact = matchesCommand(a, partial);
const bIsExact = matchesCommand(b, partial);
if (aIsExact && !bIsExact) return -1;
if (!aIsExact && bIsExact) return 1;
return 0;
});
const finalSuggestions = sortedSuggestions.map((cmd) => ({
label: cmd.name,
value: cmd.name,
description: cmd.description,
@@ -537,11 +567,7 @@ export function useSlashCompletion(props: UseSlashCompletionProps): {
return;
}
if (isPerfectMatch) {
setSuggestions([]);
} else {
setSuggestions(hookSuggestions);
}
setSuggestions(hookSuggestions);
setIsLoadingSuggestions(isLoading);
setIsPerfectMatch(isPerfectMatch);
setCompletionStart(calculatedStart);