mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 11:41:04 +00:00
fix(cli): prioritize slash command completions (#3104)
This commit is contained in:
parent
4c67670ef0
commit
81ccbb976c
5 changed files with 308 additions and 14 deletions
|
|
@ -16,6 +16,7 @@ import { getPersistScopeForModelSelection } from '../../config/modelProvidersSco
|
|||
|
||||
export const modelCommand: SlashCommand = {
|
||||
name: 'model',
|
||||
completionPriority: 100,
|
||||
get description() {
|
||||
return t('Switch the model for this session (--fast for suggestion model)');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -242,6 +242,8 @@ export interface SlashCommand {
|
|||
altNames?: string[];
|
||||
description: string;
|
||||
hidden?: boolean;
|
||||
/** Higher values win when slash completion candidates have comparable match quality. */
|
||||
completionPriority?: number;
|
||||
|
||||
kind: CommandKind;
|
||||
|
||||
|
|
|
|||
124
packages/cli/src/ui/hooks/useSlashCompletion.integration.test.ts
Normal file
124
packages/cli/src/ui/hooks/useSlashCompletion.integration.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
const slashCommands = [
|
||||
createTestCommand({
|
||||
|
|
|
|||
|
|
@ -161,6 +161,94 @@ interface PerfectMatchResult {
|
|||
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(
|
||||
parserResult: CommandParserResult,
|
||||
commandContext: CommandContext,
|
||||
|
|
@ -255,14 +343,34 @@ function useCommandSuggestions(
|
|||
try {
|
||||
const fzfResults = await fzfInstance.fzf.find(partial);
|
||||
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) => {
|
||||
const cmd = fzfInstance.commandMap.get(result.item);
|
||||
if (cmd && cmd.description) {
|
||||
uniqueCommands.add(cmd);
|
||||
const originalIndex = cmd ? commandOrder.get(cmd) : undefined;
|
||||
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) {
|
||||
logErrorSafely(
|
||||
error,
|
||||
|
|
@ -475,16 +583,45 @@ export function useSlashCompletion(props: UseSlashCompletionProps): {
|
|||
|
||||
// Memoized helper function for prefix-based filtering to improve performance
|
||||
const getPrefixSuggestions = useMemo(
|
||||
() => (commands: readonly SlashCommand[], partial: string) =>
|
||||
commands.filter(
|
||||
(cmd) =>
|
||||
cmd.description &&
|
||||
!cmd.hidden &&
|
||||
(cmd.name.toLowerCase().startsWith(partial.toLowerCase()) ||
|
||||
cmd.altNames?.some((alt) =>
|
||||
alt.toLowerCase().startsWith(partial.toLowerCase()),
|
||||
)),
|
||||
),
|
||||
() => (commands: readonly SlashCommand[], partial: string) => {
|
||||
const rankedMatches = commands.flatMap((cmd, index) => {
|
||||
if (!cmd.description || cmd.hidden) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const matchedValues = [cmd.name, ...(cmd.altNames ?? [])].filter(
|
||||
(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);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue