mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
Fix slash command suggestion filtering (#1664)
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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)'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user