fix: deduplicate slash command collisions

This commit is contained in:
Ibrahim Khan
2026-07-30 01:18:15 +00:00
parent a4c7bac303
commit 2dd0171ee5
4 changed files with 233 additions and 5 deletions
+2
View File
@@ -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)));
}