fix(cli): prioritize slash command completions (#3104)

This commit is contained in:
易良 2026-04-11 11:04:58 +08:00 committed by GitHub
parent 4c67670ef0
commit 81ccbb976c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 308 additions and 14 deletions

View file

@ -16,6 +16,7 @@ import { getPersistScopeForModelSelection } from '../../config/modelProvidersSco
export const modelCommand: SlashCommand = { export const modelCommand: SlashCommand = {
name: 'model', name: 'model',
completionPriority: 100,
get description() { get description() {
return t('Switch the model for this session (--fast for suggestion model)'); return t('Switch the model for this session (--fast for suggestion model)');
}, },

View file

@ -242,6 +242,8 @@ export interface SlashCommand {
altNames?: string[]; altNames?: string[];
description: string; description: string;
hidden?: boolean; hidden?: boolean;
/** Higher values win when slash completion candidates have comparable match quality. */
completionPriority?: number;
kind: CommandKind; kind: CommandKind;

View file

@ -0,0 +1,124 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/** @vitest-environment jsdom */
import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { useState } from 'react';
import { useSlashCompletion } from './useSlashCompletion.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import { CommandKind } from '../commands/types.js';
import type { Suggestion } from '../components/SuggestionsDisplay.js';
type TestSlashCommand = Omit<SlashCommand, 'kind'> & {
kind?: CommandKind;
completionPriority?: number;
};
function createTestCommand(command: TestSlashCommand): SlashCommand {
return {
kind: CommandKind.BUILT_IN,
...command,
} as SlashCommand;
}
function useTestHarnessForSlashCompletion(
enabled: boolean,
query: string | null,
slashCommands: readonly SlashCommand[],
commandContext: CommandContext,
) {
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
const [isPerfectMatch, setIsPerfectMatch] = useState(false);
const { completionStart, completionEnd } = useSlashCompletion({
enabled,
query,
slashCommands,
commandContext,
setSuggestions,
setIsLoadingSuggestions,
setIsPerfectMatch,
});
return {
suggestions,
isLoadingSuggestions,
isPerfectMatch,
completionStart,
completionEnd,
};
}
describe('useSlashCompletion integration', () => {
const mockCommandContext = {} as CommandContext;
it('prefers higher completionPriority over weaker fuzzy matches', async () => {
const slashCommands = [
createTestCommand({
name: 'approval-mode',
description: 'View or change the approval mode for tool usage',
}),
createTestCommand({
name: 'model',
description: 'Switch the model for this session',
completionPriority: 100,
}),
createTestCommand({
name: 'memory',
description: 'Manage memory',
}),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/mo',
slashCommands,
mockCommandContext,
),
);
await waitFor(() => {
expect(result.current.suggestions.length).toBeGreaterThan(1);
});
expect(result.current.suggestions[0]?.value).toBe('model');
expect(result.current.suggestions[1]?.value).toBe('approval-mode');
});
it('prefers higher completionPriority for same-strength prefix matches', async () => {
const slashCommands = [
createTestCommand({
name: 'memory',
description: 'Manage memory',
}),
createTestCommand({
name: 'model',
description: 'Switch the model for this session',
completionPriority: 100,
}),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/m',
slashCommands,
mockCommandContext,
),
);
await waitFor(() => {
expect(result.current.suggestions.length).toBeGreaterThan(1);
});
expect(result.current.suggestions[0]?.value).toBe('model');
expect(result.current.suggestions[1]?.value).toBe('memory');
});
});

View file

@ -246,6 +246,36 @@ describe('useSlashCompletion', () => {
}); });
}); });
it('should prefer higher completionPriority when match quality ties', async () => {
const slashCommands = [
createTestCommand({
name: 'mock',
description: 'Mock command',
}),
createTestCommand({
name: 'model',
description: 'Model command',
completionPriority: 100,
}),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/mo',
slashCommands,
mockCommandContext,
),
);
await waitFor(() => {
expect(result.current.suggestions.map((s) => s.value)).toEqual([
'model',
'mock',
]);
});
});
it('should suggest commands based on partial altNames', async () => { it('should suggest commands based on partial altNames', async () => {
const slashCommands = [ const slashCommands = [
createTestCommand({ createTestCommand({

View file

@ -161,6 +161,94 @@ interface PerfectMatchResult {
isPerfectMatch: boolean; isPerfectMatch: boolean;
} }
const enum CommandMatchStrength {
FUZZY = 0,
SEGMENT_PREFIX = 1,
PREFIX = 2,
EXACT = 3,
}
interface RankedCommandMatch {
command: SlashCommand;
matchStrength: CommandMatchStrength;
completionPriority: number;
score: number;
start: number;
itemLength: number;
originalIndex: number;
}
function getCompletionPriority(command: SlashCommand): number {
return command.completionPriority ?? 0;
}
function isSegmentBoundary(value: string, start: number): boolean {
if (start <= 0) {
return false;
}
return ['-', '_', '/', ' '].includes(value[start - 1] ?? '');
}
function getCommandMatchStrength(
matchedValue: string,
query: string,
start: number,
): CommandMatchStrength {
const normalizedValue = matchedValue.toLowerCase();
const normalizedQuery = query.toLowerCase();
if (normalizedValue === normalizedQuery) {
return CommandMatchStrength.EXACT;
}
if (normalizedValue.startsWith(normalizedQuery)) {
return CommandMatchStrength.PREFIX;
}
if (
start > 0 &&
normalizedValue.slice(start).startsWith(normalizedQuery) &&
isSegmentBoundary(normalizedValue, start)
) {
return CommandMatchStrength.SEGMENT_PREFIX;
}
return CommandMatchStrength.FUZZY;
}
function compareRankedCommandMatches(
left: RankedCommandMatch,
right: RankedCommandMatch,
): number {
return (
right.matchStrength - left.matchStrength ||
right.completionPriority - left.completionPriority ||
right.score - left.score ||
left.start - right.start ||
left.itemLength - right.itemLength ||
left.originalIndex - right.originalIndex
);
}
function createRankedCommandMatch(
command: SlashCommand,
matchedValue: string,
query: string,
result: Pick<FzfCommandResult, 'score' | 'start'>,
originalIndex: number,
): RankedCommandMatch {
return {
command,
matchStrength: getCommandMatchStrength(matchedValue, query, result.start),
completionPriority: getCompletionPriority(command),
score: result.score,
start: result.start,
itemLength: matchedValue.length,
originalIndex,
};
}
function useCommandSuggestions( function useCommandSuggestions(
parserResult: CommandParserResult, parserResult: CommandParserResult,
commandContext: CommandContext, commandContext: CommandContext,
@ -255,14 +343,34 @@ function useCommandSuggestions(
try { try {
const fzfResults = await fzfInstance.fzf.find(partial); const fzfResults = await fzfInstance.fzf.find(partial);
if (signal.aborted) return; if (signal.aborted) return;
const uniqueCommands = new Set<SlashCommand>(); const commandOrder = new Map<SlashCommand, number>();
commandsToSearch.forEach((cmd, index) => {
commandOrder.set(cmd, index);
});
const rankedMatches = new Map<SlashCommand, RankedCommandMatch>();
fzfResults.forEach((result: FzfCommandResult) => { fzfResults.forEach((result: FzfCommandResult) => {
const cmd = fzfInstance.commandMap.get(result.item); const cmd = fzfInstance.commandMap.get(result.item);
if (cmd && cmd.description) { const originalIndex = cmd ? commandOrder.get(cmd) : undefined;
uniqueCommands.add(cmd); if (cmd && cmd.description && originalIndex !== undefined) {
const rankedMatch = createRankedCommandMatch(
cmd,
result.item,
partial,
result,
originalIndex,
);
const existingRank = rankedMatches.get(cmd);
if (
!existingRank ||
compareRankedCommandMatches(rankedMatch, existingRank) < 0
) {
rankedMatches.set(cmd, rankedMatch);
}
} }
}); });
potentialSuggestions = Array.from(uniqueCommands); potentialSuggestions = Array.from(rankedMatches.values())
.sort(compareRankedCommandMatches)
.map((match) => match.command);
} catch (error) { } catch (error) {
logErrorSafely( logErrorSafely(
error, error,
@ -475,16 +583,45 @@ export function useSlashCompletion(props: UseSlashCompletionProps): {
// Memoized helper function for prefix-based filtering to improve performance // Memoized helper function for prefix-based filtering to improve performance
const getPrefixSuggestions = useMemo( const getPrefixSuggestions = useMemo(
() => (commands: readonly SlashCommand[], partial: string) => () => (commands: readonly SlashCommand[], partial: string) => {
commands.filter( const rankedMatches = commands.flatMap((cmd, index) => {
(cmd) => if (!cmd.description || cmd.hidden) {
cmd.description && return [];
!cmd.hidden && }
(cmd.name.toLowerCase().startsWith(partial.toLowerCase()) ||
cmd.altNames?.some((alt) => const matchedValues = [cmd.name, ...(cmd.altNames ?? [])].filter(
alt.toLowerCase().startsWith(partial.toLowerCase()), (value) => value.toLowerCase().startsWith(partial.toLowerCase()),
)), );
),
if (matchedValues.length === 0) {
return [];
}
const bestMatch = matchedValues
.map((matchedValue) =>
createRankedCommandMatch(
cmd,
matchedValue,
partial,
{
score:
matchedValue.toLowerCase() === partial.toLowerCase()
? 100
: 80,
start: 0,
},
index,
),
)
.sort(compareRankedCommandMatches)[0];
return bestMatch ? [bestMatch] : [];
});
return rankedMatches
.sort(compareRankedCommandMatches)
.map((match) => match.command);
},
[], [],
); );