diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts index 575202ce98..0bcb3863ce 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts @@ -691,6 +691,40 @@ describe('useSlashCompletion', () => { }); unmount(); }); + + it('should rank primary name prefix matches higher than alias prefix matches', async () => { + const slashCommands = [ + createTestCommand({ + name: 'footer', + altNames: ['statusline'], + description: 'Configure footer', + }), + createTestCommand({ + name: 'stats', + altNames: ['usage'], + description: 'Check stats', + }), + ]; + + const { result, unmount } = await renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/stat', + slashCommands, + mockCommandContext, + ), + ); + + await resolveMatch(); + + await waitFor(() => { + // 'stats' should be first because 'stat' is a prefix match on its name + // while 'footer' only matches 'stat' via its alias 'statusline' + expect(result.current.suggestions[0].label).toBe('stats'); + expect(result.current.suggestions[1].label).toBe('footer'); + }); + unmount(); + }); }); describe('Sub-Commands', () => { diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.ts b/packages/cli/src/ui/hooks/useSlashCompletion.ts index 4afa8e2241..7b06fdc1f4 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.ts @@ -272,13 +272,45 @@ function useCommandSuggestions( } if (!signal.aborted) { - // Sort potentialSuggestions so that exact match (by name or altName) comes first + // Sort potentialSuggestions so that exact name/prefix match comes first, + // prioritizing primary name over altNames. + const lowerPartial = partial.toLowerCase(); 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; + // 1. Exact name match + const aNameExact = a.name.toLowerCase() === lowerPartial; + const bNameExact = b.name.toLowerCase() === lowerPartial; + if (aNameExact && !bNameExact) return -1; + if (!aNameExact && bNameExact) return 1; + + // 2. Exact altName match + const aAltExact = + a.altNames?.some((alt) => alt.toLowerCase() === lowerPartial) || + false; + const bAltExact = + b.altNames?.some((alt) => alt.toLowerCase() === lowerPartial) || + false; + if (aAltExact && !bAltExact) return -1; + if (!aAltExact && bAltExact) return 1; + + // 3. Prefix name match + const aNamePrefix = a.name.toLowerCase().startsWith(lowerPartial); + const bNamePrefix = b.name.toLowerCase().startsWith(lowerPartial); + if (aNamePrefix && !bNamePrefix) return -1; + if (!aNamePrefix && bNamePrefix) return 1; + + // 4. Prefix altName match + const aAltPrefix = + a.altNames?.some((alt) => + alt.toLowerCase().startsWith(lowerPartial), + ) || false; + const bAltPrefix = + b.altNames?.some((alt) => + alt.toLowerCase().startsWith(lowerPartial), + ) || false; + if (aAltPrefix && !bAltPrefix) return -1; + if (!aAltPrefix && bAltPrefix) return 1; + + return 0; // Maintain FZF score order for other matches }); const finalSuggestions = sortedSuggestions.map((cmd) => {