fix: preserve duplicate slash commands in autocomplete

Show commands with identical names from different sources
Add stable command IDs so selection no longer depends on name dedupe
Keep command insertion flow working across chat and prompt editors
This commit is contained in:
Bohdan Triapitsyn
2026-04-22 22:08:34 +03:00
parent 863be8073b
commit 6886713b8e
5 changed files with 28 additions and 31 deletions
@@ -32,7 +32,7 @@ import { renderMagicPrompt } from '@/lib/magicPrompts';
import { AttachedFilesList } from './FileAttachment';
import { QueuedMessageChips } from './QueuedMessageChips';
import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete';
import { CommandAutocomplete, type CommandAutocompleteHandle } from './CommandAutocomplete';
import { CommandAutocomplete, type CommandAutocompleteHandle, type CommandInfo } from './CommandAutocomplete';
import { SkillAutocomplete, type SkillAutocompleteHandle } from './SkillAutocomplete';
import { cn, formatDirectoryName, isMacOS } from '@/lib/utils';
import { ModelControls } from './ModelControls';
@@ -2470,7 +2470,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
textareaRef.current?.focus();
};
const handleCommandSelect = (command: { name: string; description?: string; agent?: string; model?: string }) => {
const handleCommandSelect = (command: CommandInfo) => {
setMessage(`/${command.name} `);
@@ -7,8 +7,12 @@ import { useCommandsStore } from '@/stores/useCommandsStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
interface CommandInfo {
type CommandSource = 'openchamber' | 'opencode';
export interface CommandInfo {
id: string;
name: string;
source: CommandSource;
description?: string;
agent?: string;
model?: string;
@@ -89,8 +93,10 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
setLoading(true);
try {
const skillNames = new Set(skills.map((skill) => skill.name));
const customCommands: CommandInfo[] = commandsWithMetadata.map(cmd => ({
const customCommands: CommandInfo[] = commandsWithMetadata.map((cmd, index) => ({
id: `opencode:${cmd.scope ?? 'global'}:${cmd.name}:${cmd.agent ?? ''}:${cmd.model ?? ''}:${index}`,
name: cmd.name,
source: 'opencode',
description: cmd.description,
agent: cmd.agent ?? undefined,
model: cmd.model ?? undefined,
@@ -101,30 +107,23 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
const builtInCommands: CommandInfo[] = [
...(hasSession && !hasMessagesInCurrentSession
? [{ name: 'init', description: 'Create/update AGENTS.md file', isBuiltIn: true }]
? [{ id: 'openchamber:init', name: 'init', source: 'openchamber' as const, description: 'Create/update AGENTS.md file', isBuiltIn: true }]
: []
),
...(hasSession // Show when session exists, not when hasMessages
? [
{ name: 'undo', description: 'Undo the last message', isBuiltIn: true },
{ name: 'redo', description: 'Redo previously undone messages', isBuiltIn: true },
{ id: 'openchamber:undo', name: 'undo', source: 'openchamber' as const, description: 'Undo the last message', isBuiltIn: true },
{ id: 'openchamber:redo', name: 'redo', source: 'openchamber' as const, description: 'Redo previously undone messages', isBuiltIn: true },
]
: []
),
{ name: 'compact', description: 'Compress session history using AI to reduce context size', isBuiltIn: true },
{ id: 'openchamber:compact', name: 'compact', source: 'openchamber' as const, description: 'Compress session history using AI to reduce context size', isBuiltIn: true },
...(hasSession
? [{ name: 'summary', description: 'Non-destructive session summary. Optional topic hint after the command.', isOpenChamber: true }]
? [{ id: 'openchamber:summary', name: 'summary', source: 'openchamber' as const, description: 'Non-destructive session summary. Optional topic hint after the command.', isOpenChamber: true }]
: []
),
];
const commandMap = new Map<string, CommandInfo>();
builtInCommands.forEach(cmd => commandMap.set(cmd.name, cmd));
customCommands.forEach(cmd => commandMap.set(cmd.name, cmd));
const allCommands = Array.from(commandMap.values());
const allCommands = [...builtInCommands, ...customCommands];
const allowInitCommand = !hasMessagesInCurrentSession;
const filtered = (searchQuery
@@ -148,19 +147,19 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
const allowInitCommand = !hasMessagesInCurrentSession;
const builtInCommands: CommandInfo[] = [
...(hasSession && !hasMessagesInCurrentSession
? [{ name: 'init', description: 'Create/update AGENTS.md file', isBuiltIn: true }]
? [{ id: 'openchamber:init', name: 'init', source: 'openchamber' as const, description: 'Create/update AGENTS.md file', isBuiltIn: true }]
: []
),
...(hasSession // Show when session exists, not when hasMessages
? [
{ name: 'undo', description: 'Undo the last message', isBuiltIn: true },
{ name: 'redo', description: 'Redo previously undone messages', isBuiltIn: true },
{ id: 'openchamber:undo', name: 'undo', source: 'openchamber' as const, description: 'Undo the last message', isBuiltIn: true },
{ id: 'openchamber:redo', name: 'redo', source: 'openchamber' as const, description: 'Redo previously undone messages', isBuiltIn: true },
]
: []
),
{ name: 'compact', description: 'Compress session history using AI to reduce context size', isBuiltIn: true },
{ id: 'openchamber:compact', name: 'compact', source: 'openchamber' as const, description: 'Compress session history using AI to reduce context size', isBuiltIn: true },
...(hasSession
? [{ name: 'summary', description: 'Non-destructive session summary. Optional topic hint after the command.', isOpenChamber: true }]
? [{ id: 'openchamber:summary', name: 'summary', source: 'openchamber' as const, description: 'Non-destructive session summary. Optional topic hint after the command.', isOpenChamber: true }]
: []
),
];
@@ -307,7 +306,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
return (
<div
key={command.name}
key={command.id}
ref={(el) => { itemRefs.current[index] = el; }}
className={cn(
"flex items-start gap-2 px-3 py-2 cursor-pointer rounded-lg",
@@ -425,5 +424,3 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
});
CommandAutocomplete.displayName = 'CommandAutocomplete';
export type { CommandInfo };
@@ -19,7 +19,7 @@ import type { CreateMultiRunParams, MultiRunModelSelection } from '@/types/multi
import { ModelMultiSelect, generateInstanceId, type ModelSelectionWithId } from './ModelMultiSelect';
import { BranchSelector, useBranchOptions } from './BranchSelector';
import { AgentSelector } from './AgentSelector';
import { CommandAutocomplete, type CommandAutocompleteHandle } from '@/components/chat/CommandAutocomplete';
import { CommandAutocomplete, type CommandAutocompleteHandle, type CommandInfo } from '@/components/chat/CommandAutocomplete';
import { FileMentionAutocomplete, type FileMentionHandle } from '@/components/chat/FileMentionAutocomplete';
import { isDesktopShell } from '@/lib/desktop';
import { useThemeSystem } from '@/contexts/useThemeSystem';
@@ -505,7 +505,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
});
}, [prompt, updateAutocompleteState]);
const handleAutocompleteCommandSelect = React.useCallback((command: { name: string }) => {
const handleAutocompleteCommandSelect = React.useCallback((command: CommandInfo) => {
const nextPrompt = `/${command.name} `;
setPrompt(nextPrompt);
setShowCommandAutocomplete(false);
@@ -13,7 +13,7 @@ import { RiAddLine, RiCloseLine, RiCalendarLine, RiArrowLeftSLine, RiArrowRightS
import { ModelSelector } from '@/components/sections/agents/ModelSelector';
import { AgentSelector } from '@/components/sections/commands/AgentSelector';
import { isPrimaryMode } from '@/components/chat/mobileControlsUtils';
import { CommandAutocomplete, type CommandAutocompleteHandle } from '@/components/chat/CommandAutocomplete';
import { CommandAutocomplete, type CommandAutocompleteHandle, type CommandInfo } from '@/components/chat/CommandAutocomplete';
import { FileMentionAutocomplete, type FileMentionHandle } from '@/components/chat/FileMentionAutocomplete';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
@@ -907,7 +907,7 @@ export function ScheduledTaskEditorDialog(props: {
});
}, [draft.execution.prompt, setPromptValue, updateAutocompleteState]);
const handleCommandSelect = React.useCallback((command: { name: string }) => {
const handleCommandSelect = React.useCallback((command: CommandInfo) => {
const nextPrompt = `/${command.name} `;
setPromptValue(nextPrompt);
setShowCommandAutocomplete(false);
@@ -20,7 +20,7 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
import { ModelMultiSelect, generateInstanceId, type ModelSelectionWithId } from '@/components/multirun/ModelMultiSelect';
import { BranchSelector, useBranchOptions } from '@/components/multirun/BranchSelector';
import { AgentSelector } from '@/components/multirun/AgentSelector';
import { CommandAutocomplete, type CommandAutocompleteHandle } from '@/components/chat/CommandAutocomplete';
import { CommandAutocomplete, type CommandAutocompleteHandle, type CommandInfo } from '@/components/chat/CommandAutocomplete';
import { FileMentionAutocomplete, type FileMentionHandle } from '@/components/chat/FileMentionAutocomplete';
import { isIMECompositionEvent } from '@/lib/ime';
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
@@ -294,7 +294,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
});
}, [prompt, updateAutocompleteState]);
const handleAutocompleteCommandSelect = React.useCallback((command: { name: string }) => {
const handleAutocompleteCommandSelect = React.useCallback((command: CommandInfo) => {
const nextPrompt = `/${command.name} `;
setPrompt(nextPrompt);
setShowCommandAutocomplete(false);