diff --git a/packages/cli/src/ui/commands/modelCommand.ts b/packages/cli/src/ui/commands/modelCommand.ts index 03454b859..353131d00 100644 --- a/packages/cli/src/ui/commands/modelCommand.ts +++ b/packages/cli/src/ui/commands/modelCommand.ts @@ -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)'); }, diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 9c66fec89..25a7ea292 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -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; diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.integration.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.integration.test.ts new file mode 100644 index 000000000..f7431a118 --- /dev/null +++ b/packages/cli/src/ui/hooks/useSlashCompletion.integration.test.ts @@ -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 & { + 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([]); + 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'); + }); +}); diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts index b813ff8db..22e9d43cb 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts @@ -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({ diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.ts b/packages/cli/src/ui/hooks/useSlashCompletion.ts index 0247523ee..d056733d6 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.ts @@ -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, + 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(); + const commandOrder = new Map(); + commandsToSearch.forEach((cmd, index) => { + commandOrder.set(cmd, index); + }); + const rankedMatches = new Map(); 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); + }, [], );