Fix slash command suggestion filtering (#1664)

This commit is contained in:
Bogdan
2026-06-25 11:13:01 +08:00
committed by GitHub
parent cb689cc33a
commit 3157ee715b
3 changed files with 949 additions and 125 deletions
+13 -9
View File
@@ -4,7 +4,7 @@ import { useNotifications } from 'src/context/notifications.js';
import { Text } from 'src/ink.js';
import { logEvent } from 'src/services/analytics/index.js';
import { useDebounceCallback } from 'usehooks-ts';
import { type Command, getCommandName } from '../commands.js';
import type { Command } from '../commands.js';
import { getModeFromInput, getValueFromInput } from '../components/PromptInput/inputModes.js';
import type { SuggestionItem, SuggestionType } from '../components/PromptInput/PromptInputFooterSuggestions.js';
import { useIsModalOverlayActive, useRegisterOverlay } from '../context/overlayContext.js';
@@ -22,7 +22,16 @@ import { generateProgressiveArgumentHint, parseArguments } from '../utils/argume
import { getShellCompletions, type ShellCompletionType } from '../utils/bash/shellCompletion.js';
import { formatLogMetadata } from '../utils/format.js';
import { getSessionIdFromLog, searchSessionsByCustomTitle } from '../utils/sessionStorage.js';
import { applyCommandSuggestion, findMidInputSlashCommand, generateCommandSuggestions, getBestCommandMatch, getCommandSuggestionForEnter, isCommandInput } from '../utils/suggestions/commandSuggestions.js';
import {
applyCommandSuggestion,
findCommandByExactName,
findMidInputSlashCommand,
generateCommandSuggestions,
getBestCommandMatch,
getCommandSuggestionForEnter,
getCommandSuggestionsMaxWidth,
isCommandInput
} from '../utils/suggestions/commandSuggestions.js';
import { getDirectoryCompletions, getPathCompletions, isPathLikeToken } from '../utils/suggestions/directoryCompletion.js';
import { getShellHistoryCompletion } from '../utils/suggestions/shellHistoryCompletion.js';
import { getSlackChannelSuggestions, hasSlackMcpServer } from '../utils/suggestions/slackChannelSuggestions.js';
@@ -377,12 +386,7 @@ export function useTypeahead({
// Compute max column width from ALL commands once (not filtered results)
// This prevents layout shift when filtering
const allCommandsMaxWidth = useMemo(() => {
const visibleCommands = commands.filter(cmd => !cmd.isHidden);
if (visibleCommands.length === 0) return undefined;
const maxLen = Math.max(...visibleCommands.map(cmd => getCommandName(cmd).length));
return maxLen + 6; // +1 for "/" prefix, +5 for padding
}, [commands]);
const allCommandsMaxWidth = useMemo(() => getCommandSuggestionsMaxWidth(commands), [commands]);
const [maxColumnWidth, setMaxColumnWidth] = useState<number | undefined>(undefined);
const mcpResources = useAppState(s => s.mcp.resources);
const store = useAppStateStore();
@@ -731,7 +735,7 @@ export function useTypeahead({
// If input has a space after the command, don't show suggestions
// This prevents Enter from selecting a different command after Tab completion
if (spaceIndex !== -1) {
const exactMatch = commands.find(cmd => getCommandName(cmd) === commandName);
const exactMatch = findCommandByExactName(commands, commandName);
if (exactMatch || hasRealArguments) {
// Priority 1: Static argumentHint (only on first trailing space for backwards compat)
if (exactMatch?.argumentHint && hasExactlyOneTrailingSpace) {
+719 -33
View File
@@ -7,7 +7,10 @@ import {
} from '../settings/settingsCache.js'
import {
applyCommandSuggestion,
findCommandByExactName,
getBestCommandMatch,
getCommandSuggestionForEnter,
getCommandSuggestionsMaxWidth,
generateCommandSuggestions,
} from './commandSuggestions.js'
@@ -18,6 +21,8 @@ function promptCommand({
kind,
pluginName,
localizationKey,
aliases,
isHidden,
}: {
name: string
getDescription: () => string
@@ -32,6 +37,8 @@ function promptCommand({
kind?: 'workflow'
pluginName?: string
localizationKey?: LocalizationKey
aliases?: string[]
isHidden?: boolean
}): Command {
return {
type: 'prompt',
@@ -42,6 +49,8 @@ function promptCommand({
source,
kind,
localizationKey,
aliases,
isHidden,
pluginInfo: pluginName
? {
pluginManifest: {
@@ -68,7 +77,7 @@ afterEach(() => {
})
describe('generateCommandSuggestions localization', () => {
test('searches localized built-in descriptions with a stable command array', () => {
test('renders localized built-in descriptions with a stable command array', () => {
const commands = [
promptCommand({
name: 'review',
@@ -79,27 +88,21 @@ describe('generateCommandSuggestions localization', () => {
]
useLanguage('english')
expect(
generateCommandSuggestions('/pull', commands).map(
item => item.displayText,
),
).toContain('/review')
const englishSuggestions = generateCommandSuggestions('/review', commands)
expect(englishSuggestions[0]?.displayText).toBe('/review')
expect(englishSuggestions[0]?.description).toBe('Review a pull request')
useLanguage('vietnamese')
const suggestions = generateCommandSuggestions('/\u0111\u00e1nh', commands)
const suggestions = generateCommandSuggestions('/review', commands)
expect(suggestions[0]?.displayText).toBe('/review')
expect(suggestions[0]?.description).toBe(
'\u0110\u00e1nh gi\u00e1 pull request',
)
useLanguage('english')
const englishSuggestions = generateCommandSuggestions('/pull', commands)
expect(englishSuggestions[0]?.displayText).toBe('/review')
expect(englishSuggestions[0]?.description).toBe('Review a pull request')
expect(generateCommandSuggestions('/\u0111\u00e1nh', commands)).toEqual([])
})
test('searches localized bundled descriptions with a stable command array', () => {
test('renders localized bundled descriptions with a stable command array', () => {
const commands = [
promptCommand({
name: 'loop',
@@ -111,23 +114,24 @@ describe('generateCommandSuggestions localization', () => {
]
useLanguage('english')
expect(
generateCommandSuggestions('/interval', commands).map(
item => item.displayText,
),
).toContain('/loop')
const englishSuggestion = generateCommandSuggestions('/loop', commands)[0]
expect(englishSuggestion?.displayText).toBe('/loop')
expect(englishSuggestion?.description).toContain(
'Run a prompt on a fixed interval',
)
expect(englishSuggestion?.description).toContain('(bundled)')
useLanguage('vietnamese')
const suggestions = generateCommandSuggestions('/kho\u1ea3ng', commands)
const loopSuggestion = suggestions.find(item => item.displayText === '/loop')
const loopSuggestion = generateCommandSuggestions('/loop', commands)[0]
expect(loopSuggestion).toBeDefined()
expect(loopSuggestion?.displayText).toBe('/loop')
expect(loopSuggestion?.description).toContain(
'kho\u1ea3ng th\u1eddi gian',
)
expect(generateCommandSuggestions('/kho\u1ea3ng', commands)).toEqual([])
})
test('does not index external English descriptions as Vietnamese text', () => {
test('localizes only OpenClaude-owned descriptions', () => {
const commands = [
promptCommand({
name: 'project-review',
@@ -155,24 +159,29 @@ describe('generateCommandSuggestions localization', () => {
]
useLanguage('vietnamese')
const vietnameseMatches = generateCommandSuggestions(
'/\u0111\u00e1nh',
commands,
).map(item => item.displayText)
const reviewSuggestions = generateCommandSuggestions('/review', commands)
expect(vietnameseMatches).toContain('/builtin-review')
expect(vietnameseMatches).not.toContain('/project-review')
expect(vietnameseMatches).not.toContain('/plugin-review')
expect(vietnameseMatches).not.toContain('/workflow-review')
const builtinSuggestion = reviewSuggestions.find(
item => item.displayText === '/builtin-review',
)
const projectSuggestion = reviewSuggestions.find(
item => item.displayText === '/project-review',
)
const workflowSuggestion = reviewSuggestions.find(
item => item.displayText === '/workflow-review',
)
const pluginSuggestion = generateCommandSuggestions(
'/plugin-review',
commands,
).find(item => item.displayText === '/plugin-review')
expect(pluginSuggestion?.description).toBe(
'(MyPlugin) Review a pull request',
expect(builtinSuggestion?.description).toBe(
'\u0110\u00e1nh gi\u00e1 pull request',
)
expect(projectSuggestion?.description).toBe('Review a pull request (project)')
expect(workflowSuggestion?.description).toBe('Review a pull request')
expect(pluginSuggestion?.description).toBe('(MyPlugin) Review a pull request')
})
test('passes the selected duplicate command row as the slash command override', () => {
@@ -364,7 +373,10 @@ describe('generateCommandSuggestions localization', () => {
const commands = [
unnameable,
promptCommand({ name: 'provider', getDescription: () => 'Manage providers' }),
promptCommand({
name: 'provider',
getDescription: () => 'Manage providers',
}),
]
expect(() =>
@@ -375,6 +387,317 @@ describe('generateCommandSuggestions localization', () => {
).toContain('/provider')
})
test('a command whose name getter throws is dropped from the bare "/" list', () => {
const unnameable: Command = {
type: 'local-jsx',
get name(): string {
throw new Error('no name')
},
get description(): string {
return 'broken'
},
isHidden: false,
progressMessage: 'running',
contentLength: 0,
getPromptForCommand: async () => [],
} as unknown as Command
const commands = [
unnameable,
promptCommand({
name: 'provider',
getDescription: () => 'Manage providers',
}),
]
expect(() => generateCommandSuggestions('/', commands)).not.toThrow()
expect(
generateCommandSuggestions('/', commands).map(i => i.displayText),
).toEqual(['/provider'])
})
test('a throwing alias getter does not break typed suggestions', () => {
const brokenAlias: Command = {
type: 'local-jsx',
name: 'help',
get aliases(): string[] {
throw new Error('no aliases')
},
get description(): string {
return 'Display help'
},
isHidden: false,
progressMessage: 'running',
contentLength: 0,
getPromptForCommand: async () => [],
} as unknown as Command
expect(() =>
generateCommandSuggestions('/help', [brokenAlias]),
).not.toThrow()
expect(
generateCommandSuggestions('/help', [brokenAlias]).map(i => i.displayText),
).toEqual(['/help'])
})
test('a throwing isHidden getter does not break typed suggestions', () => {
const brokenHidden: Command = {
type: 'local-jsx',
name: 'provider',
get description(): string {
return 'Manage providers'
},
get isHidden(): boolean {
throw new Error('no hidden state')
},
progressMessage: 'running',
contentLength: 0,
getPromptForCommand: async () => [],
} as unknown as Command
expect(() =>
generateCommandSuggestions('/prov', [brokenHidden]),
).not.toThrow()
expect(
generateCommandSuggestions('/prov', [brokenHidden]).map(i => i.displayText),
).toEqual(['/provider'])
})
test('exact-name Enter ignores commands whose name getter throws', () => {
const unnameable: Command = {
type: 'local-jsx',
get name(): string {
throw new Error('no name')
},
get description(): string {
return 'broken'
},
isHidden: false,
progressMessage: 'running',
contentLength: 0,
getPromptForCommand: async () => [],
} as unknown as Command
const provider = promptCommand({
name: 'provider',
getDescription: () => 'Manage providers',
})
const commands = [unnameable, provider]
const suggestion = generateCommandSuggestions('/prov', commands)[0]
expect(() =>
getCommandSuggestionForEnter('/provider', suggestion, commands),
).not.toThrow()
expect(getCommandSuggestionForEnter('/provider', suggestion, commands)).toBe(
'provider',
)
})
test('exact command lookup ignores commands whose name getter throws', () => {
const unnameable: Command = {
type: 'local-jsx',
get name(): string {
throw new Error('no name')
},
get description(): string {
return 'broken'
},
isHidden: false,
progressMessage: 'running',
contentLength: 0,
getPromptForCommand: async () => [],
} as unknown as Command
const provider = promptCommand({
name: 'provider',
getDescription: () => 'Manage providers',
})
expect(() =>
findCommandByExactName([unnameable, provider], 'provider'),
).not.toThrow()
expect(findCommandByExactName([unnameable, provider], 'provider')).toBe(
provider,
)
})
test('best prefix lookup ignores commands whose name getter starts throwing after indexing', () => {
let nameReads = 0
const flakyName: Command = {
type: 'local-jsx',
get name(): string {
nameReads += 1
if (nameReads > 1) {
throw new Error('name changed')
}
return 'problem'
},
get description(): string {
return 'Problem command'
},
isHidden: false,
progressMessage: 'running',
contentLength: 0,
getPromptForCommand: async () => [],
} as unknown as Command
const provider = promptCommand({
name: 'provider',
getDescription: () => 'Manage providers',
})
let match: ReturnType<typeof getBestCommandMatch> | undefined
expect(() => {
match = getBestCommandMatch('pro', [flakyName, provider])
}).not.toThrow()
expect(match).toEqual({ suffix: 'vider', fullCommand: 'provider' })
})
test('applying a visible command suggestion survives a name getter that starts throwing after render', () => {
let nameReads = 0
const flakyName: Command = {
type: 'local-jsx',
get name(): string {
nameReads += 1
if (nameReads > 1) {
throw new Error('name changed')
}
return 'problem'
},
get description(): string {
return 'Problem command'
},
isHidden: false,
progressMessage: 'running',
contentLength: 0,
getPromptForCommand: async () => [],
} as unknown as Command
const suggestion = generateCommandSuggestions('/pro', [flakyName])[0]
let submittedValue: string | undefined
let submittedOverride: Command | undefined
expect(suggestion?.displayText).toBe('/problem')
expect(() =>
applyCommandSuggestion(
suggestion!,
true,
[flakyName],
value => {
submittedValue = value
},
() => {},
(value, _isSlashCommand, override) => {
submittedValue = value
submittedOverride = override
},
),
).not.toThrow()
expect(submittedValue).toBe('/problem ')
expect(submittedOverride).toBe(flakyName)
})
test('exact string application uses safe command lookup when earlier command metadata is broken', () => {
const unnameable: Command = {
type: 'local-jsx',
get name(): string {
throw new Error('no name')
},
get description(): string {
return 'broken'
},
isHidden: false,
progressMessage: 'running',
contentLength: 0,
getPromptForCommand: async () => [],
} as unknown as Command
const provider = promptCommand({
name: 'provider',
getDescription: () => 'Manage providers',
})
let submittedValue: string | undefined
let submittedOverride: Command | undefined
expect(() =>
applyCommandSuggestion(
'provider',
true,
[unnameable, provider],
value => {
submittedValue = value
},
() => {},
(value, _isSlashCommand, override) => {
submittedValue = value
submittedOverride = override
},
),
).not.toThrow()
expect(submittedValue).toBe('/provider ')
expect(submittedOverride).toBe(provider)
})
test('max-width calculation ignores unnameable commands and survives hidden getter failures', () => {
const unnameable: Command = {
type: 'local-jsx',
get name(): string {
throw new Error('no name')
},
get description(): string {
return 'broken'
},
isHidden: false,
progressMessage: 'running',
contentLength: 0,
getPromptForCommand: async () => [],
} as unknown as Command
const brokenHidden: Command = {
type: 'local-jsx',
name: 'wide-command',
get description(): string {
return 'Wide command'
},
get isHidden(): boolean {
throw new Error('no hidden state')
},
progressMessage: 'running',
contentLength: 0,
getPromptForCommand: async () => [],
} as unknown as Command
const metadataPoisoned: Command = {
type: 'local-jsx',
name: 'metadata-poisoned-command',
get aliases(): string[] {
throw new Error('aliases should not be read')
},
get description(): string {
throw new Error('description should not be read')
},
isHidden: false,
progressMessage: 'running',
contentLength: 0,
getPromptForCommand: async () => [],
} as unknown as Command
const hidden = promptCommand({
name: 'hidden-but-wider-command',
getDescription: () => 'Hidden command',
isHidden: true,
})
expect(() =>
getCommandSuggestionsMaxWidth([
unnameable,
brokenHidden,
metadataPoisoned,
hidden,
]),
).not.toThrow()
expect(
getCommandSuggestionsMaxWidth([
unnameable,
brokenHidden,
metadataPoisoned,
hidden,
]),
).toBe('metadata-poisoned-command'.length + 6)
})
// The highlight in the dropdown is index 0, so the FIRST result must be the
// best (shortest exact-prefix) match — this is what stops the selection from
// sticking to an unrelated recently-used command like /simplify.
@@ -394,3 +717,366 @@ describe('generateCommandSuggestions localization', () => {
)
})
})
describe('generateCommandSuggestions identifier filtering', () => {
// Fixture chosen so that WITHOUT the pruning fix, Fuse returns all five
// commands for /c (verified empirically: [/cost, /clear, /compact,
// /context, /release-notes]). With the fix, only the four commands whose
// name starts with 'c' survive.
function buildCommands() {
return [
promptCommand({
name: 'clear',
getDescription: () => 'Clear conversation',
}),
promptCommand({
name: 'compact',
getDescription: () => 'Compact and summarize the conversation',
}),
promptCommand({
name: 'context',
getDescription: () => 'Manage context windows',
}),
promptCommand({
name: 'cost',
getDescription: () => 'Show token usage and cost',
}),
promptCommand({
name: 'release-notes',
getDescription: () => 'Show changes concerning configuration',
}),
]
}
test('one-letter prefix prunes description-only matches to exactly the name-prefix set', () => {
const names = generateCommandSuggestions('/c', buildCommands()).map(
item => item.displayText,
)
// Exact-set assertion (order-independent). arrayContaining would pass
// even with the fix reverted; this fails.
expect(names.sort()).toEqual(
['/clear', '/compact', '/context', '/cost'].sort(),
)
})
test('two-letter prefix prunes both description matches AND non-prefix name matches', () => {
// Without the fix, Fuse also returns /clear (fuzzy on "Compact and
// summarize..."). The fix must drop both /clear and /release-notes.
const names = generateCommandSuggestions('/co', buildCommands()).map(
item => item.displayText,
)
expect(names.sort()).toEqual(
['/compact', '/context', '/cost'].sort(),
)
})
test('three-letter query drops description-only matches when no typed characters appear in command identifiers', () => {
// 'len' is > 2 and matches nothing in the command name, aliases, or
// separator-delimited command parts. Description-only matches should not
// appear in slash typeahead.
const commands = [
promptCommand({
name: 'review',
getDescription: () => 'Review a pull request with length checks',
}),
promptCommand({
name: 'cost',
getDescription: () => 'Show token usage and cost',
}),
]
const names = generateCommandSuggestions('/len', commands).map(
item => item.displayText,
)
expect(names).toEqual([])
})
test('substring name match is kept while description-only matches are dropped', () => {
const commands = [
promptCommand({
name: 'review',
getDescription: () => 'Review a pull request',
}),
promptCommand({
name: 'status',
getDescription: () => 'Show a detailed view of status',
}),
]
const names = generateCommandSuggestions('/view', commands).map(
item => item.displayText,
)
expect(names).toEqual(['/review'])
})
test('substring name match does not depend on Fuse returning the command', () => {
const longCommandName = `prefix-${'a'.repeat(160)}needle`
const commands = [
promptCommand({
name: longCommandName,
getDescription: () => 'Long command name',
}),
]
const names = generateCommandSuggestions('/needle', commands).map(
item => item.displayText,
)
expect(names).toEqual([`/${longCommandName}`])
})
test('substring after a separator is kept while description-only matches are dropped', () => {
const commands = [
promptCommand({
name: 'agents-consilium',
getDescription: () => 'Manage agent council sessions',
}),
promptCommand({
name: 'status',
getDescription: () => 'Display consilium status',
}),
]
const names = generateCommandSuggestions('/sili', commands).map(
item => item.displayText,
)
expect(names).toEqual(['/agents-consilium'])
})
test('single-character query with no identifier substring matches returns empty results', () => {
// No command identifier contains 'z', but Fuse can match description text
// such as "summarize". Slash typeahead should drop description-only
// matches even at length 1.
const names = generateCommandSuggestions('/z', buildCommands()).map(
item => item.displayText,
)
expect(names).toEqual([])
})
test('query with zero results returns empty array without crashing', () => {
const names = generateCommandSuggestions('/qq', buildCommands()).map(
item => item.displayText,
)
expect(names).toEqual([])
})
test('word-boundary on hyphenated name counts as a prefix match', () => {
const commands = [
promptCommand({
name: 'memory-show',
getDescription: () => 'Show memory contents',
}),
promptCommand({
name: 'summary',
getDescription: () => 'Summarize what the memory tool can do',
}),
]
const names = generateCommandSuggestions('/show', commands).map(
item => item.displayText,
)
expect(names).toEqual(['/memory-show'])
})
test('word-boundary on underscore-separated name counts as a prefix match', () => {
const commands = [
promptCommand({
name: 'user_profile',
getDescription: () => 'Show the user profile',
}),
promptCommand({
name: 'profiler',
getDescription: () => 'CPU profiler that has nothing to do with users',
}),
]
const names = generateCommandSuggestions('/profile', commands).map(
item => item.displayText,
)
// /profile is a prefix of 'profiler' (name) and a word-boundary match on
// 'user_profile'. Both should survive.
expect(names.sort()).toEqual(['/profiler', '/user_profile'].sort())
})
test('word-boundary on colon-separated name counts as a prefix match', () => {
const commands = [
promptCommand({
name: 'plugin:reload',
getDescription: () => 'Reload a plugin',
}),
promptCommand({
name: 'reloader',
getDescription: () => 'Background reloader for unrelated tasks',
}),
]
const names = generateCommandSuggestions('/reload', commands).map(
item => item.displayText,
)
expect(names.sort()).toEqual(['/plugin:reload', '/reloader'].sort())
})
test('separator-delimited part prefix survives alongside name prefix matches', () => {
const commands = [
promptCommand({
name: 'x-release-yyy',
getDescription: () => 'release notes for x',
}),
promptCommand({
name: 'reboot',
getDescription: () => 'restart everything',
}),
]
const names = generateCommandSuggestions('/re', commands).map(
item => item.displayText,
)
expect(names.sort()).toEqual(['/reboot', '/x-release-yyy'].sort())
})
test('ranks exact, alias, prefix, part-prefix, and substring identifier matches in that order', () => {
const commands = [
promptCommand({
name: 'review',
getDescription: () => 'Substring name match',
}),
promptCommand({
name: 'thing-view',
getDescription: () => 'Part prefix match',
}),
promptCommand({
name: 'help',
aliases: ['view'],
getDescription: () => 'Exact alias match',
}),
promptCommand({
name: 'view-all',
getDescription: () => 'Prefix name match',
}),
promptCommand({
name: 'view',
getDescription: () => 'Exact name match',
}),
promptCommand({
name: 'status',
getDescription: () => 'Description-only view match',
}),
]
const names = generateCommandSuggestions('/view', commands).map(
item => item.displayText,
)
expect(names).toEqual([
'/view',
'/help (view)',
'/view-all',
'/thing-view',
'/review',
])
})
test('hidden exact command is still prepended without letting hidden commands join substring results', () => {
const commands = [
promptCommand({
name: 'voice',
getDescription: () => 'Hidden exact command',
isHidden: true,
}),
promptCommand({
name: 'voice-memo',
getDescription: () => 'Visible prefix sibling',
}),
promptCommand({
name: 'invoice',
getDescription: () => 'Visible substring sibling',
}),
promptCommand({
name: 'hidden-voice-helper',
getDescription: () => 'Hidden substring sibling',
isHidden: true,
}),
]
const names = generateCommandSuggestions('/voice', commands).map(
item => item.displayText,
)
expect(names).toEqual(['/voice', '/voice-memo', '/invoice'])
})
test('alias prefix match is kept, and a non-matching name with overlapping description is dropped', () => {
const commands = [
promptCommand({
name: 'documentation',
aliases: ['docs'],
getDescription: () => 'Show the docs',
}),
promptCommand({
name: 'disk-usage',
getDescription: () => 'Display docs-related disk metrics',
}),
]
const names = generateCommandSuggestions('/doc', commands).map(
item => item.displayText,
)
// /documentation matches by name prefix and alias prefix; /disk-usage's
// only relation to 'doc' is via description.
expect(names).toEqual(['/documentation (docs)'])
})
test('alias substring match is kept while description-only matches are dropped', () => {
const commands = [
promptCommand({
name: 'help',
aliases: ['sosextra'],
getDescription: () => 'Display help information',
}),
promptCommand({
name: 'status',
getDescription: () => 'Display extra status information',
}),
]
const names = generateCommandSuggestions('/extra', commands).map(
item => item.displayText,
)
expect(names).toEqual(['/help (sosextra)'])
})
test('alias that prefix-matches the query saves a command whose name does not', () => {
const commands = [
promptCommand({
name: 'help',
aliases: ['halp', 'sosextra'],
getDescription: () => 'Display help information',
}),
promptCommand({
name: 'housekeeping',
getDescription: () => 'Clean caches to help with disk space',
}),
]
// 'sosextra' is the only alias prefix-matched by 'sos'. The fix must
// surface /help via its alias and drop /housekeeping (description-only).
const names = generateCommandSuggestions('/sos', commands).map(
item => item.displayText,
)
expect(names).toEqual(['/help (sosextra)'])
})
})
+217 -83
View File
@@ -2,7 +2,6 @@ import Fuse from 'fuse.js'
import {
type Command,
formatDescriptionWithSource,
getCommand,
getCommandName,
} from '../../commands.js'
import type { SuggestionItem } from '../../components/PromptInput/PromptInputFooterSuggestions.js'
@@ -53,6 +52,18 @@ function safeCommandName(command: Command): string | null {
}
}
function safeCommandAliases(
command: Command,
commandName: string,
): string[] | undefined {
try {
return command.aliases
} catch (err) {
warnBrokenCommand(commandName, err)
return undefined
}
}
// Treat these characters as word separators for command search
const SEPARATORS = /[:_-]/g
@@ -81,8 +92,10 @@ let fuseCache: {
fuse: Fuse<CommandSearchItem>
} | null = null
function getCommandFuse(commands: Command[]): Fuse<CommandSearchItem> {
const snapshots = getCommandSearchSnapshots(commands)
function getCommandFuseForSnapshots(
commands: Command[],
snapshots: CommandSearchSnapshot[],
): Fuse<CommandSearchItem> {
const signature = getCommandSearchSignature(snapshots)
if (
@@ -150,12 +163,7 @@ function getCommandSearchSnapshots(
if (commandName === null) {
continue
}
let aliases: string[] | undefined
try {
aliases = command.aliases
} catch {
aliases = undefined
}
const aliases = safeCommandAliases(command, commandName)
snapshots.push({
aliases,
command,
@@ -186,12 +194,13 @@ function getCommandSearchSignature(
* Commands have a name string and a type property.
*/
function isCommandMetadata(metadata: unknown): metadata is Command {
const maybeCommand = metadata as { type?: unknown }
return (
typeof metadata === 'object' &&
metadata !== null &&
'name' in metadata &&
typeof (metadata as { name: unknown }).name === 'string' &&
'type' in metadata
(maybeCommand.type === 'prompt' ||
maybeCommand.type === 'local' ||
maybeCommand.type === 'local-jsx')
)
}
@@ -283,7 +292,10 @@ export function getBestCommandMatch(
if (!isCommandMetadata(suggestion.metadata)) {
continue
}
const name = getCommandName(suggestion.metadata)
const name = safeCommandName(suggestion.metadata)
if (name === null) {
continue
}
if (name.toLowerCase().startsWith(query)) {
const suffix = name.slice(partialCommand.length)
// Only return if there's something to complete
@@ -312,11 +324,13 @@ export function getCommandSuggestionForEnter(
? input.slice(1).toLowerCase().trim()
: ''
const exactCommands = exactCommandName
? commands.filter(cmd => getCommandName(cmd).toLowerCase() === exactCommandName)
? commands.filter(
cmd => safeCommandName(cmd)?.toLowerCase() === exactCommandName,
)
: []
return exactCommands.length === 1
? getCommandName(exactCommands[0]!)
? safeCommandName(exactCommands[0]!) ?? suggestion
: suggestion
}
@@ -334,6 +348,56 @@ export function hasCommandArgs(input: string): boolean {
return true
}
export function findCommandByExactName(
commands: Command[],
commandName: string,
): Command | undefined {
return commands.find(command => safeCommandName(command) === commandName)
}
function findCommandByNameOrAlias(
commands: Command[],
commandName: string,
): Command | undefined {
for (const command of commands) {
const safeName = safeCommandName(command)
if (safeName === null) {
continue
}
if (safeName === commandName) {
return command
}
if (safeCommandAliases(command, safeName)?.includes(commandName)) {
return command
}
}
return undefined
}
function commandNameFromSuggestionDisplay(displayText: string): string | null {
return displayText.match(/^\/([^\s(]+)/)?.[1] ?? null
}
export function getCommandSuggestionsMaxWidth(
commands: Command[],
): number | undefined {
const visibleNames: string[] = []
for (const command of commands) {
const commandName = safeCommandName(command)
if (commandName === null || safeIsHidden(command)) {
continue
}
visibleNames.push(commandName)
}
if (visibleNames.length === 0) {
return undefined
}
return Math.max(...visibleNames.map(name => name.length)) + 6
}
/**
* Formats a command with proper notation
*/
@@ -349,8 +413,7 @@ export function formatCommand(command: string): string {
* settings, plugins, etc). Built-in commands (local, local-jsx) are
* defined once in code and can't have duplicates.
*/
function getCommandId(cmd: Command): string {
const commandName = getCommandName(cmd)
function getCommandId(cmd: Command, commandName = getCommandName(cmd)): string {
if (cmd.type === 'prompt') {
// For plugin commands, include the repository to disambiguate
if (cmd.source === 'plugin' && cmd.pluginInfo?.repository) {
@@ -373,8 +436,8 @@ function findMatchedAlias(
if (!aliases || aliases.length === 0 || query === '') {
return undefined
}
// Check if query is a prefix of any alias (case-insensitive)
return aliases.find(alias => alias.toLowerCase().startsWith(query))
// Show the alias when the typed slash query visibly matches it.
return aliases.find(alias => alias.toLowerCase().includes(query))
}
/**
@@ -384,23 +447,35 @@ function findMatchedAlias(
function createCommandSuggestionItem(
cmd: Command,
matchedAlias?: string,
commandName = getCommandName(cmd),
renderedDescription = getRenderedCommandDescription(cmd),
): SuggestionItem {
const commandName = getCommandName(cmd)
// Only show the alias if the user typed it
const aliasText = matchedAlias ? ` (${matchedAlias})` : ''
const isWorkflow = cmd.type === 'prompt' && cmd.kind === 'workflow'
const fullDescription = getRenderedCommandDescription(cmd)
return {
id: getCommandId(cmd),
id: getCommandId(cmd, commandName),
displayText: `/${commandName}${aliasText}`,
tag: isWorkflow ? 'workflow' : undefined,
description: fullDescription,
description: renderedDescription,
metadata: cmd,
}
}
function createCommandSuggestionItemFromSnapshot(
snapshot: CommandSearchSnapshot,
matchedAlias?: string,
): SuggestionItem {
return createCommandSuggestionItem(
snapshot.command,
matchedAlias,
snapshot.commandName,
snapshot.renderedDescription,
)
}
function getRenderedCommandDescription(cmd: Command): string {
// Command descriptions can be dynamic getters that read live state and may
// throw (e.g. a backend returning null). This runs for every command while
@@ -460,62 +535,74 @@ export function generateCommandSuggestions(
}
const query = input.slice(1).toLowerCase().trim()
const snapshots = getCommandSearchSnapshots(commands)
// When just typing '/' without additional text
if (query === '') {
const visibleCommands = commands.filter(cmd => !safeIsHidden(cmd))
const visibleCommands = snapshots.filter(snapshot => !snapshot.isHidden)
// Find recently used skills (only prompt commands have usage tracking)
const recentlyUsed: Command[] = []
const recentlyUsed: CommandSearchSnapshot[] = []
const commandsWithScores = visibleCommands
.filter(cmd => cmd.type === 'prompt')
.map(cmd => ({
cmd,
score: getSkillUsageScore(getCommandName(cmd)),
.filter(snapshot => snapshot.command.type === 'prompt')
.map(snapshot => ({
snapshot,
score: getSkillUsageScore(snapshot.commandName),
}))
.filter(item => item.score > 0)
.sort((a, b) => b.score - a.score)
// Take top 5 recently used skills
for (const item of commandsWithScores.slice(0, 5)) {
recentlyUsed.push(item.cmd)
recentlyUsed.push(item.snapshot)
}
// Create a set of recently used command IDs to avoid duplicates
const recentlyUsedIds = new Set(recentlyUsed.map(cmd => getCommandId(cmd)))
const recentlyUsedIds = new Set(
recentlyUsed.map(snapshot =>
getCommandId(snapshot.command, snapshot.commandName),
),
)
// Categorize remaining commands (excluding recently used)
const builtinCommands: Command[] = []
const userCommands: Command[] = []
const projectCommands: Command[] = []
const policyCommands: Command[] = []
const otherCommands: Command[] = []
const builtinCommands: CommandSearchSnapshot[] = []
const userCommands: CommandSearchSnapshot[] = []
const projectCommands: CommandSearchSnapshot[] = []
const policyCommands: CommandSearchSnapshot[] = []
const otherCommands: CommandSearchSnapshot[] = []
visibleCommands.forEach(cmd => {
visibleCommands.forEach(snapshot => {
// Skip if already in recently used
if (recentlyUsedIds.has(getCommandId(cmd))) {
if (
recentlyUsedIds.has(
getCommandId(snapshot.command, snapshot.commandName),
)
) {
return
}
const cmd = snapshot.command
if (cmd.type === 'local' || cmd.type === 'local-jsx') {
builtinCommands.push(cmd)
builtinCommands.push(snapshot)
} else if (
cmd.type === 'prompt' &&
(cmd.source === 'userSettings' || cmd.source === 'localSettings')
) {
userCommands.push(cmd)
userCommands.push(snapshot)
} else if (cmd.type === 'prompt' && cmd.source === 'projectSettings') {
projectCommands.push(cmd)
projectCommands.push(snapshot)
} else if (cmd.type === 'prompt' && cmd.source === 'policySettings') {
policyCommands.push(cmd)
policyCommands.push(snapshot)
} else {
otherCommands.push(cmd)
otherCommands.push(snapshot)
}
})
// Sort each category alphabetically
const sortAlphabetically = (a: Command, b: Command) =>
getCommandName(a).localeCompare(getCommandName(b))
const sortAlphabetically = (
a: CommandSearchSnapshot,
b: CommandSearchSnapshot,
) => a.commandName.localeCompare(b.commandName)
builtinCommands.sort(sortAlphabetically)
userCommands.sort(sortAlphabetically)
@@ -532,7 +619,7 @@ export function generateCommandSuggestions(
...projectCommands,
...policyCommands,
...otherCommands,
].map(cmd => createCommandSuggestionItem(cmd)))
].map(snapshot => createCommandSuggestionItemFromSnapshot(snapshot)))
}
// The Fuse index filters hidden commands, so an exact hidden command name
@@ -541,56 +628,96 @@ export function generateCommandSuggestions(
// Prepend rather than early-return so visible prefix siblings (e.g.
// /voice-memo) still appear below, and getBestCommandMatch can still find
// a non-empty suffix.
let hiddenExact = commands.find(
cmd => cmd.isHidden && getCommandName(cmd).toLowerCase() === query,
let hiddenExact = snapshots.find(
snapshot =>
snapshot.isHidden && snapshot.commandName.toLowerCase() === query,
)
if (
hiddenExact &&
commands.some(
cmd => !cmd.isHidden && getCommandName(cmd).toLowerCase() === query,
snapshots.some(
snapshot =>
!snapshot.isHidden && snapshot.commandName.toLowerCase() === query,
)
) {
hiddenExact = undefined
}
const fuse = getCommandFuse(commands)
const fuse = getCommandFuseForSnapshots(commands, snapshots)
const searchResults = fuse.search(query)
const fuseResultByCommand = new Map<Command, (typeof searchResults)[number]>()
for (const result of searchResults) {
fuseResultByCommand.set(result.item.command, result)
}
// Sort results prioritizing exact/prefix command name matches over fuzzy description matches
// Rank identifier matches before using Fuse score and usage as tiebreakers.
// Priority order:
// 1. Exact name match (highest)
// 2. Exact alias match
// 3. Prefix name match
// 4. Prefix alias match
// 5. Fuzzy match (lowest)
// Precompute per-item values once to avoid O(n log n) recomputation in comparator
const withMeta = searchResults.map(r => {
const name = r.item.commandName.toLowerCase()
const aliases = r.item.aliasKey?.map(alias => alias.toLowerCase()) ?? []
const usage =
r.item.command.type === 'prompt'
? getSkillUsageScore(getCommandName(r.item.command))
: 0
return { r, name, aliases, usage }
})
// 5. Prefix command part
// 6. Substring name/alias/part matches
// Precompute normalized identifier fields once; sorting can then compare
// strings without rereading command metadata.
const withMeta = snapshots
.filter(snapshot => !snapshot.isHidden)
.map(snapshot => {
const { aliases: snapshotAliases, command, commandName } = snapshot
const commandParts = commandName.split(SEPARATORS).filter(Boolean)
const name = commandName.toLowerCase()
const aliases = snapshotAliases?.map(alias => alias.toLowerCase()) ?? []
const parts =
commandParts.length > 1
? commandParts.map(part => part.toLowerCase())
: []
return {
r: fuseResultByCommand.get(command),
snapshot,
command,
commandName,
name,
aliases,
parts,
}
})
const sortedResults = withMeta.sort((a, b) => {
// Slash typeahead should only show rows where the typed characters are
// visible in a command identifier: the command name or an alias. Separator-
// delimited command-name parts still affect ranking for word-boundary hits.
// Fuse contributes ranking, but description-only and typo-fuzzy matches are
// not eligible for display.
const includesQuery = (value: string) => value.includes(query)
const startsWithQuery = (value: string) => value.startsWith(query)
const matchesIdentifier = (item: (typeof withMeta)[number]) =>
includesQuery(item.name) || item.aliases.some(includesQuery)
const getMatchRank = (item: (typeof withMeta)[number]): number => {
if (item.name === query) return 0
if (item.aliases.some(alias => alias === query)) return 1
if (startsWithQuery(item.name)) return 2
if (item.aliases.some(startsWithQuery)) return 3
if (item.parts.some(startsWithQuery)) return 4
if (includesQuery(item.name)) return 5
if (item.aliases.some(includesQuery)) return 6
return 7
}
const filteredMeta = withMeta.filter(matchesIdentifier).map(item => ({
...item,
usage:
item.command.type === 'prompt'
? getSkillUsageScore(item.commandName)
: 0,
}))
const sortedResults = filteredMeta.sort((a, b) => {
const aName = a.name
const bName = b.name
const aAliases = a.aliases
const bAliases = b.aliases
// Check for exact name match (highest priority)
const aExactName = aName === query
const bExactName = bName === query
if (aExactName && !bExactName) return -1
if (bExactName && !aExactName) return 1
// Check for exact alias match
const aExactAlias = aAliases.some(alias => alias === query)
const bExactAlias = bAliases.some(alias => alias === query)
if (aExactAlias && !bExactAlias) return -1
if (bExactAlias && !aExactAlias) return 1
const rankDiff = getMatchRank(a) - getMatchRank(b)
if (rankDiff !== 0) return rankDiff
// Check for prefix name match
const aPrefixName = aName.startsWith(query)
@@ -617,7 +744,7 @@ export function generateCommandSuggestions(
}
// For similar match types, use Fuse score with usage as tiebreaker
const scoreDiff = (a.r.score ?? 0) - (b.r.score ?? 0)
const scoreDiff = (a.r?.score ?? 1) - (b.r?.score ?? 1)
if (Math.abs(scoreDiff) > 0.1) {
return scoreDiff
}
@@ -630,18 +757,17 @@ export function generateCommandSuggestions(
// from different sources (e.g., projectSettings vs userSettings) may have different
// implementations and should both be available to the user
const fuseSuggestions = sortedResults.map(result => {
const cmd = result.r.item.command
// Only show alias in parentheses if the user typed an alias
const matchedAlias = findMatchedAlias(query, cmd.aliases)
return createCommandSuggestionItem(cmd, matchedAlias)
const matchedAlias = findMatchedAlias(query, result.snapshot.aliases)
return createCommandSuggestionItemFromSnapshot(result.snapshot, matchedAlias)
})
// Skip the prepend defensively if the command is already present; duplicate
// ids confuse React keys and selection state.
if (hiddenExact) {
const hiddenId = getCommandId(hiddenExact)
const hiddenId = getCommandId(hiddenExact.command, hiddenExact.commandName)
if (!fuseSuggestions.some(s => s.id === hiddenId)) {
return ensureUniqueSuggestionIds([
createCommandSuggestionItem(hiddenExact),
createCommandSuggestionItemFromSnapshot(hiddenExact),
...fuseSuggestions,
])
}
@@ -669,12 +795,20 @@ export function applyCommandSuggestion(
let commandObj: Command | undefined
if (typeof suggestion === 'string') {
commandName = suggestion
commandObj = shouldExecute ? getCommand(commandName, commands) : undefined
commandObj = shouldExecute
? findCommandByNameOrAlias(commands, commandName)
: undefined
} else {
if (!isCommandMetadata(suggestion.metadata)) {
return // Invalid suggestion, nothing to apply
}
commandName = getCommandName(suggestion.metadata)
commandName =
safeCommandName(suggestion.metadata) ??
commandNameFromSuggestionDisplay(suggestion.displayText) ??
''
if (commandName === '') {
return
}
commandObj = suggestion.metadata
}