From 2dd0171ee53f7259dec54a4cab0ff2370ba24803 Mon Sep 17 00:00:00 2001 From: Ibrahim Khan <2005ibrahimkhan@gmail.com> Date: Thu, 30 Jul 2026 01:18:15 +0000 Subject: [PATCH] fix: deduplicate slash command collisions --- CHANGELOG.md | 2 + .../components/chat/CommandAutocomplete.tsx | 9 +- .../commandAutocompleteItems.test.ts | 157 ++++++++++++++++++ .../chat/commandAutocompleteItems.ts | 70 ++++++++ 4 files changed, 233 insertions(+), 5 deletions(-) create mode 100644 packages/ui/src/components/chat/__tests__/commandAutocompleteItems.test.ts create mode 100644 packages/ui/src/components/chat/commandAutocompleteItems.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d703593..acea3488 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/packages/ui/src/components/chat/CommandAutocomplete.tsx b/packages/ui/src/components/chat/CommandAutocomplete.tsx index c4fe39bc..77070181 100644 --- a/packages/ui/src/components/chat/CommandAutocomplete.tsx +++ b/packages/ui/src/components/chat/CommandAutocomplete.tsx @@ -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 - 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) => { diff --git a/packages/ui/src/components/chat/__tests__/commandAutocompleteItems.test.ts b/packages/ui/src/components/chat/__tests__/commandAutocompleteItems.test.ts new file mode 100644 index 00000000..94fe6d96 --- /dev/null +++ b/packages/ui/src/components/chat/__tests__/commandAutocompleteItems.test.ts @@ -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([]); + }); +}); diff --git a/packages/ui/src/components/chat/commandAutocompleteItems.ts b/packages/ui/src/components/chat/commandAutocompleteItems.ts new file mode 100644 index 00000000..e4ae2fe5 --- /dev/null +++ b/packages/ui/src/components/chat/commandAutocompleteItems.ts @@ -0,0 +1,70 @@ +import { fuzzyMatch } from '@/lib/utils'; + +export interface CommandAutocompleteSearchItem { + name: string; + description?: string; + searchAliases?: string[]; + isBuiltIn?: boolean; + isSkill?: boolean; +} + +function addSearchAliases(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( + builtIns: T[], + commands: T[], + skills: T[], +): T[] { + const merged: T[] = []; + const byName = new Map(); + + 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))); +}