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]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- Chat: the `/` command menu no longer lists a skill twice when a command shares its name.
|
||||||
|
|
||||||
## [1.17.1] - 2026-07-29
|
## [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.
|
- **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 { useUIStore } from '@/stores/useUIStore';
|
||||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
|
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
|
||||||
|
import { commandMatchesSearch, mergeCommandAutocompleteItems } from './commandAutocompleteItems';
|
||||||
|
|
||||||
type CommandSource = 'openchamber' | 'opencode' | 'skill';
|
type CommandSource = 'openchamber' | 'opencode' | 'skill';
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ export interface CommandInfo {
|
|||||||
name: string;
|
name: string;
|
||||||
source: CommandSource;
|
source: CommandSource;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
searchAliases?: string[];
|
||||||
agent?: string;
|
agent?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
isBuiltIn?: boolean;
|
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 allowInitCommand = !hasMessagesInCurrentSession;
|
||||||
const filtered = (searchQuery
|
const filtered = (searchQuery
|
||||||
? allCommands.filter(cmd =>
|
? allCommands.filter(cmd => commandMatchesSearch(cmd, searchQuery))
|
||||||
fuzzyMatch(cmd.name, searchQuery) ||
|
|
||||||
(cmd.description && fuzzyMatch(cmd.description, searchQuery))
|
|
||||||
)
|
|
||||||
: allCommands).filter(cmd => allowInitCommand || cmd.name !== 'init');
|
: allCommands).filter(cmd => allowInitCommand || cmd.name !== 'init');
|
||||||
|
|
||||||
filtered.sort((a, b) => {
|
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