fix: deduplicate slash command collisions
This commit is contained in:
@@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Chat: the `/` command menu no longer lists a skill twice when a command shares its name.
|
||||
|
||||
## [1.17.1] - 2026-07-29
|
||||
|
||||
- **Chat tools:** Bash tool cards now show output before a command finishes, keep it in a fixed-height pane, and follow new lines until you scroll away. Long-running commands no longer remain at a 300-second duration, and their timers continue until they finish.
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useI18n } from '@/lib/i18n';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
|
||||
import { commandMatchesSearch, mergeCommandAutocompleteItems } from './commandAutocompleteItems';
|
||||
|
||||
type CommandSource = 'openchamber' | 'opencode' | 'skill';
|
||||
|
||||
@@ -18,6 +19,7 @@ export interface CommandInfo {
|
||||
name: string;
|
||||
source: CommandSource;
|
||||
description?: string;
|
||||
searchAliases?: string[];
|
||||
agent?: string;
|
||||
model?: string;
|
||||
isBuiltIn?: boolean;
|
||||
@@ -191,14 +193,11 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
: []
|
||||
),
|
||||
];
|
||||
const allCommands = [...builtInCommands, ...customCommands, ...skillCommands];
|
||||
const allCommands = mergeCommandAutocompleteItems(builtInCommands, customCommands, skillCommands);
|
||||
|
||||
const allowInitCommand = !hasMessagesInCurrentSession;
|
||||
const filtered = (searchQuery
|
||||
? allCommands.filter(cmd =>
|
||||
fuzzyMatch(cmd.name, searchQuery) ||
|
||||
(cmd.description && fuzzyMatch(cmd.description, searchQuery))
|
||||
)
|
||||
? allCommands.filter(cmd => commandMatchesSearch(cmd, searchQuery))
|
||||
: allCommands).filter(cmd => allowInitCommand || cmd.name !== 'init');
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { commandMatchesSearch, mergeCommandAutocompleteItems } from '../commandAutocompleteItems';
|
||||
|
||||
interface Item {
|
||||
name: string;
|
||||
source: 'openchamber' | 'opencode' | 'skill';
|
||||
description?: string;
|
||||
searchAliases?: string[];
|
||||
isBuiltIn?: boolean;
|
||||
isSkill?: boolean;
|
||||
}
|
||||
|
||||
describe('mergeCommandAutocompleteItems', () => {
|
||||
test('retains the discovered skill and command search metadata for #1550', () => {
|
||||
const commands: Item[] = [{
|
||||
name: 'grill-with-docs',
|
||||
source: 'opencode',
|
||||
description: 'Plugin command description',
|
||||
isSkill: true,
|
||||
}];
|
||||
const skills: Item[] = [{
|
||||
name: 'grill-with-docs',
|
||||
source: 'skill',
|
||||
description: 'Canonical skill description',
|
||||
isSkill: true,
|
||||
}];
|
||||
|
||||
const merged = mergeCommandAutocompleteItems([], commands, skills);
|
||||
|
||||
expect(merged).toEqual([{
|
||||
...skills[0],
|
||||
searchAliases: ['Plugin command description'],
|
||||
}]);
|
||||
expect(commandMatchesSearch(merged[0], 'plugin command')).toBe(true);
|
||||
});
|
||||
|
||||
test('built-ins win collisions with commands and skills without losing search aliases', () => {
|
||||
const builtIn: Item = {
|
||||
name: 'summary',
|
||||
source: 'openchamber',
|
||||
description: 'Summarize this session',
|
||||
isBuiltIn: true,
|
||||
};
|
||||
const command: Item = {
|
||||
name: 'summary',
|
||||
source: 'opencode',
|
||||
description: 'Plugin session digest',
|
||||
};
|
||||
const skill: Item = {
|
||||
name: 'summary',
|
||||
source: 'skill',
|
||||
description: 'Skill session recap',
|
||||
isSkill: true,
|
||||
};
|
||||
|
||||
expect(mergeCommandAutocompleteItems([builtIn], [command], [skill])).toEqual([{
|
||||
...builtIn,
|
||||
searchAliases: ['Plugin session digest', 'Skill session recap'],
|
||||
}]);
|
||||
});
|
||||
|
||||
test('OpenCode built-ins also win collisions with discovered skills', () => {
|
||||
const builtIn: Item = {
|
||||
name: 'review',
|
||||
source: 'opencode',
|
||||
description: 'Review workspace changes',
|
||||
isBuiltIn: true,
|
||||
};
|
||||
const skill: Item = {
|
||||
name: 'review',
|
||||
source: 'skill',
|
||||
description: 'Review skill',
|
||||
isSkill: true,
|
||||
};
|
||||
|
||||
expect(mergeCommandAutocompleteItems([], [builtIn], [skill])).toEqual([{
|
||||
...builtIn,
|
||||
searchAliases: ['Review skill'],
|
||||
}]);
|
||||
});
|
||||
|
||||
test('deduplicates every pairwise source collision by executable precedence', () => {
|
||||
const builtIn: Item = { name: 'compact', source: 'openchamber', isBuiltIn: true };
|
||||
const command: Item = { name: 'compact', source: 'opencode' };
|
||||
const skill: Item = { name: 'compact', source: 'skill', isSkill: true };
|
||||
|
||||
expect(mergeCommandAutocompleteItems([builtIn], [command], [])[0]).toBe(builtIn);
|
||||
expect(mergeCommandAutocompleteItems([builtIn], [], [skill])[0]).toBe(builtIn);
|
||||
expect(mergeCommandAutocompleteItems([], [command], [skill])[0]).toBe(skill);
|
||||
});
|
||||
|
||||
test('OpenCode skill-commands win custom commands and yield to discovered skills', () => {
|
||||
const command: Item = { name: 'deploy', source: 'opencode', description: 'Custom deploy' };
|
||||
const skillCommand: Item = {
|
||||
name: 'deploy',
|
||||
source: 'opencode',
|
||||
description: 'OpenCode skill command',
|
||||
isSkill: true,
|
||||
};
|
||||
const skill: Item = {
|
||||
name: 'deploy',
|
||||
source: 'skill',
|
||||
description: 'Discovered deploy skill',
|
||||
isSkill: true,
|
||||
};
|
||||
|
||||
expect(mergeCommandAutocompleteItems([], [command, skillCommand], [])).toEqual([{
|
||||
...skillCommand,
|
||||
searchAliases: ['Custom deploy'],
|
||||
}]);
|
||||
expect(mergeCommandAutocompleteItems([], [command, skillCommand], [skill])).toEqual([{
|
||||
...skill,
|
||||
searchAliases: ['OpenCode skill command', 'Custom deploy'],
|
||||
}]);
|
||||
});
|
||||
|
||||
test('keeps a case-distinct command when the built-in is disabled', () => {
|
||||
const builtIn: Item = { name: 'init', source: 'openchamber', isBuiltIn: true };
|
||||
const command: Item = { name: 'Init', source: 'opencode', description: 'Custom init' };
|
||||
const merged = mergeCommandAutocompleteItems([builtIn], [command], []);
|
||||
|
||||
expect(merged).toEqual([builtIn, command]);
|
||||
expect(merged.filter((item) => item.name !== 'init')).toEqual([command]);
|
||||
});
|
||||
|
||||
test('keeps first-seen ordering and unrelated commands', () => {
|
||||
const builtIns: Item[] = [{ name: 'undo', source: 'openchamber' }];
|
||||
const commands: Item[] = [
|
||||
{ name: 'test', source: 'opencode' },
|
||||
{ name: 'deploy', source: 'opencode' },
|
||||
];
|
||||
const skills: Item[] = [
|
||||
{ name: 'deploy', source: 'skill', isSkill: true },
|
||||
{ name: 'explain', source: 'skill', isSkill: true },
|
||||
];
|
||||
|
||||
const merged = mergeCommandAutocompleteItems(builtIns, commands, skills);
|
||||
|
||||
expect(merged.map((item) => item.name)).toEqual(['undo', 'test', 'deploy', 'explain']);
|
||||
expect(merged[2]).toBe(skills[0]);
|
||||
});
|
||||
|
||||
test('deduplicates repeated entries within each source without mutating inputs', () => {
|
||||
const first: Item = { name: 'test', source: 'opencode', description: 'First' };
|
||||
const duplicate: Item = { name: 'test', source: 'opencode', description: 'Second' };
|
||||
|
||||
expect(mergeCommandAutocompleteItems([], [first, duplicate], [])).toEqual([{
|
||||
...first,
|
||||
searchAliases: ['Second'],
|
||||
}]);
|
||||
expect(first.searchAliases).toBe(undefined);
|
||||
});
|
||||
|
||||
test('handles empty inputs', () => {
|
||||
expect(mergeCommandAutocompleteItems([], [], [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { fuzzyMatch } from '@/lib/utils';
|
||||
|
||||
export interface CommandAutocompleteSearchItem {
|
||||
name: string;
|
||||
description?: string;
|
||||
searchAliases?: string[];
|
||||
isBuiltIn?: boolean;
|
||||
isSkill?: boolean;
|
||||
}
|
||||
|
||||
function addSearchAliases<T extends CommandAutocompleteSearchItem>(winner: T, duplicate: T): T {
|
||||
const existingAliases = winner.searchAliases ?? [];
|
||||
const aliases = [
|
||||
...existingAliases,
|
||||
...(winner.name === duplicate.name ? [] : [duplicate.name]),
|
||||
...(duplicate.description ? [duplicate.description] : []),
|
||||
...(duplicate.searchAliases ?? []),
|
||||
].filter((alias, index, values) => alias !== winner.description && values.indexOf(alias) === index);
|
||||
const unchanged = aliases.length === existingAliases.length
|
||||
&& aliases.every((alias, index) => alias === existingAliases[index]);
|
||||
|
||||
return unchanged ? winner : { ...winner, searchAliases: aliases };
|
||||
}
|
||||
|
||||
/**
|
||||
* Precedence is local command, discovered skill, OpenCode skill-command, then
|
||||
* custom/plugin command. Identity matches session.command's case-sensitive lookup.
|
||||
*/
|
||||
export function mergeCommandAutocompleteItems<T extends CommandAutocompleteSearchItem>(
|
||||
builtIns: T[],
|
||||
commands: T[],
|
||||
skills: T[],
|
||||
): T[] {
|
||||
const merged: T[] = [];
|
||||
const byName = new Map<string, { index: number; item: T; precedence: number }>();
|
||||
|
||||
const addItems = (items: T[], getPrecedence: (item: T) => number) => {
|
||||
for (const item of items) {
|
||||
const precedence = getPrecedence(item);
|
||||
const identity = item.name;
|
||||
const existing = byName.get(identity);
|
||||
if (!existing) {
|
||||
byName.set(identity, { index: merged.length, item, precedence });
|
||||
merged.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
const winner = precedence > existing.precedence
|
||||
? addSearchAliases(item, existing.item)
|
||||
: addSearchAliases(existing.item, item);
|
||||
merged[existing.index] = winner;
|
||||
byName.set(identity, {
|
||||
index: existing.index,
|
||||
item: winner,
|
||||
precedence: Math.max(existing.precedence, precedence),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
addItems(builtIns, () => 3);
|
||||
addItems(commands, (item) => item.isBuiltIn ? 3 : item.isSkill ? 1 : 0);
|
||||
addItems(skills, () => 2);
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function commandMatchesSearch(command: CommandAutocompleteSearchItem, query: string): boolean {
|
||||
return fuzzyMatch(command.name, query)
|
||||
|| Boolean(command.description && fuzzyMatch(command.description, query))
|
||||
|| Boolean(command.searchAliases?.some((alias) => fuzzyMatch(alias, query)));
|
||||
}
|
||||
Reference in New Issue
Block a user