fix(cli): prefer command name over alias in slash completion ranking (#5577)

When slash completion fuzzy-matches both a command's primary name and
another command's alias (altName) at the same match strength, the
shorter string wins on fzf score — e.g. typing /re ranks 'clear'
(via its 'reset' alias) above 'resume', because 'reset' is shorter.

Add a name-vs-alias dimension to the sort chain so that a command's
own name always ranks above an alias match, regardless of fzf score.
This resolves 5 known prefix collision cases (/re, /co, /su, /ex, /ta)
where an alias from one command would appear before the intended name
match of another command.

Signed-off-by: Alex <alex.tech.lab@outlook.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
This commit is contained in:
AlexHuang 2026-06-22 13:42:40 +08:00 committed by GitHub
parent 75fc0a5c18
commit d59a0227fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 56 additions and 0 deletions

View file

@ -121,4 +121,52 @@ describe('useSlashCompletion integration', () => {
expect(result.current.suggestions[0]?.value).toBe('model');
expect(result.current.suggestions[1]?.value).toBe('memory');
});
it('prefers command name match over alias match for same-strength prefixes', async () => {
// Real-world conflict: /re matches `resume` (name) and `reset` (alias of clear).
// Without the name-vs-alias sort dimension, fzf gives the shorter `reset` a higher
// score, so `clear` would appear first. The fix ensures `resume` (name match)
// always ranks above any alias match at the same strength level.
const slashCommands = [
createTestCommand({
name: 'clear',
altNames: ['reset', 'new'],
description: 'Clear conversation history',
}),
createTestCommand({
name: 'resume',
altNames: ['continue'],
description: 'Resume a previous session',
}),
createTestCommand({
name: 'recap',
description: 'Show session recap',
}),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/re',
slashCommands,
mockCommandContext,
),
);
await waitFor(() => {
expect(result.current.suggestions.length).toBeGreaterThan(1);
});
// resume and recap are name matches; reset is an alias match.
// Name matches must come before alias matches regardless of fzf score.
const names = result.current.suggestions.map((s) => s.value);
const resumeIndex = names.indexOf('resume');
const recapIndex = names.indexOf('recap');
const clearIndex = names.indexOf('clear');
expect(resumeIndex).toBeGreaterThanOrEqual(0);
expect(recapIndex).toBeGreaterThanOrEqual(0);
expect(clearIndex).toBeGreaterThanOrEqual(0);
expect(resumeIndex).toBeLessThan(clearIndex);
expect(recapIndex).toBeLessThan(clearIndex);
});
});

View file

@ -237,10 +237,18 @@ function compareRankedCommandMatches(
left: RankedCommandMatch,
right: RankedCommandMatch,
): number {
// Name match beats alias match — e.g. /re should prefer `resume` (name)
// over `clear` via its `reset` alias, since users type the primary name
// more often than obscure alternates.
const leftIsName = left.matchedAlias === undefined ? 1 : 0;
const rightIsName = right.matchedAlias === undefined ? 1 : 0;
const nameVsAlias = rightIsName - leftIsName;
return (
right.matchStrength - left.matchStrength ||
right.completionPriority - left.completionPriority ||
right.recentScore - left.recentScore ||
nameVsAlias ||
right.score - left.score ||
left.start - right.start ||
left.itemLength - right.itemLength ||