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 = {
|
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)');
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
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 () => {
|
it('should suggest commands based on partial altNames', async () => {
|
||||||
const slashCommands = [
|
const slashCommands = [
|
||||||
createTestCommand({
|
createTestCommand({
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue