import { useEffect, useRef } from 'react';
import type { CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import {
Box,
ChevronRight,
FolderGit2,
LayoutGrid,
Pin,
Sparkles,
User,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { cn } from '../../../../lib/utils.js';
type CommandMenuCommand = {
name: string;
description?: string;
namespace?: string;
path?: string;
type?: string;
metadata?: { type?: string; [key: string]: unknown };
[key: string]: unknown;
};
type CommandMenuProps = {
commands?: CommandMenuCommand[];
selectedIndex?: number;
onSelect?: (command: CommandMenuCommand, index: number, isHover: boolean) => void;
onClose: () => void;
position?: { top: number; left: number; bottom?: number };
isOpen?: boolean;
frequentCommands?: CommandMenuCommand[];
};
const namespaceIcons: Record<string, LucideIcon> = {
pinned: Pin,
frequent: Sparkles,
builtin: Box,
project: FolderGit2,
user: User,
other: LayoutGrid,
};
const getCommandKey = (command: CommandMenuCommand) =>
`${command.name}::${command.namespace || command.type || 'other'}::${command.path || ''}`;
const getNamespace = (command: CommandMenuCommand) =>
command.namespace || command.type || 'other';
const getMenuPosition = (position: { top: number; left: number; bottom?: number }): CSSProperties => {
if (typeof window === 'undefined') {
return { position: 'fixed', top: '16px', left: '16px' };
}
if (window.innerWidth < 640) {
return {
position: 'fixed',
bottom: `${position.bottom ?? 90}px`,
left: '16px',
right: '16px',
maxHeight: 'min(50vh, 320px)',
};
}
return {
position: 'fixed',
top: `${Math.max(16, Math.min(position.top, window.innerHeight - 336))}px`,
left: `${position.left}px`,
width: 'min(420px, calc(100vw - 32px))',
maxHeight: '320px',
};
};
export default function CommandMenu({
commands = [],
selectedIndex = -1,
onSelect,
onClose,
position = { top: 0, left: 0 },
isOpen = false,
frequentCommands = [],
}: CommandMenuProps) {
const { t } = useTranslation('chat');
const menuRef = useRef<HTMLDivElement | null>(null);
const selectedItemRef = useRef<HTMLDivElement | null>(null);
const menuPosition = getMenuPosition(position);
useEffect(() => {
if (!isOpen) {
return;
}
const handleClickOutside = (event: MouseEvent) => {
if (!menuRef.current || !(event.target instanceof Node)) {
return;
}
if (!menuRef.current.contains(event.target)) {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen, onClose]);
useEffect(() => {
if (!selectedItemRef.current || !menuRef.current) {
return;
}
const menuRect = menuRef.current.getBoundingClientRect();
const itemRect = selectedItemRef.current.getBoundingClientRect();
if (itemRect.bottom > menuRect.bottom || itemRect.top < menuRect.top) {
selectedItemRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}, [selectedIndex]);
if (!isOpen) {
return null;
}
const containerClass = cn(
'overflow-y-auto rounded-lg border bg-white shadow-lg',
'border-neutral-200 dark:border-neutral-800 dark:bg-neutral-900',
);
if (commands.length === 0) {
return (
<div
ref={menuRef}
className={cn(containerClass, 'px-4 py-5 text-center text-[13px] text-neutral-500 dark:text-neutral-400')}
style={{ ...menuPosition, zIndex: 1000 }}
>
{t('commandMenu.empty', { defaultValue: 'No commands available' })}
</div>
);
}
const hasFrequentCommands = frequentCommands.length > 0;
const frequentCommandKeys = new Set(frequentCommands.map(getCommandKey));
const groupedCommands = commands.reduce<Record<string, CommandMenuCommand[]>>((groups, command) => {
if (hasFrequentCommands && frequentCommandKeys.has(getCommandKey(command))) {
return groups;
}
const namespace = getNamespace(command);
if (!groups[namespace]) {
groups[namespace] = [];
}
groups[namespace].push(command);
return groups;
}, {});
if (hasFrequentCommands) {
groupedCommands.frequent = frequentCommands;
}
const preferredOrder = hasFrequentCommands
? ['pinned', 'frequent', 'builtin', 'project', 'user', 'other']
: ['pinned', 'builtin', 'project', 'user', 'other'];
const extraNamespaces = Object.keys(groupedCommands).filter(
(namespace) => !preferredOrder.includes(namespace),
);
const orderedNamespaces = [...preferredOrder, ...extraNamespaces].filter(
(namespace) => groupedCommands[namespace],
);
const commandIndexByKey = new Map<string, number>();
commands.forEach((command, index) => {
const key = getCommandKey(command);
if (!commandIndexByKey.has(key)) {
commandIndexByKey.set(key, index);
}
});
const showGroupHeaders = orderedNamespaces.length > 1;
return (
<div
ref={menuRef}
role="listbox"
aria-label="Available commands"
className={cn(containerClass, 'p-1')}
style={{ ...menuPosition, zIndex: 1000 }}
>
{orderedNamespaces.map((namespace, groupIdx) => {
const Icon = namespaceIcons[namespace] || namespaceIcons.other;
return (
<div
key={namespace}
className={cn(
'pb-1',
groupIdx > 0 && showGroupHeaders && 'mt-1 border-t border-neutral-100 pt-2 dark:border-neutral-800',
)}
>
{showGroupHeaders ? (
<div className="px-2 pb-1 pt-1.5 text-[11px] font-medium uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
{t(`commandMenu.groups.${namespace}`, {
defaultValue: namespace.charAt(0).toUpperCase() + namespace.slice(1),
})}
</div>
) : null}
{(groupedCommands[namespace] || []).map((command) => {
const commandKey = getCommandKey(command);
const commandIndex = commandIndexByKey.get(commandKey) ?? -1;
const isSelected = commandIndex === selectedIndex;
return (
<div
key={`${namespace}-${command.name}-${command.path || ''}`}
ref={isSelected ? selectedItemRef : null}
role="option"
aria-selected={isSelected}
className={cn(
'group relative flex cursor-pointer items-start gap-2.5 rounded-md px-2 py-2 transition-colors',
isSelected
? 'bg-neutral-100 dark:bg-neutral-800'
: 'hover:bg-neutral-50 dark:hover:bg-neutral-800/60',
)}
onMouseEnter={() =>
onSelect && commandIndex >= 0 && onSelect(command, commandIndex, true)
}
onClick={() =>
onSelect && commandIndex >= 0 && onSelect(command, commandIndex, false)
}
onMouseDown={(event) => event.preventDefault()}
>
<Icon
className="mt-0.5 h-3.5 w-3.5 shrink-0 text-neutral-500 dark:text-neutral-400"
strokeWidth={1.75}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-mono text-[13px] font-medium text-neutral-900 dark:text-neutral-100">
{command.name}
</span>
{command.metadata?.type ? (
<span className="rounded bg-neutral-100 px-1.5 py-0.5 text-[10px] font-medium text-neutral-500 dark:bg-neutral-800 dark:text-neutral-400">
{command.metadata.type}
</span>
) : null}
</div>
{command.description ? (
<div className="mt-0.5 truncate text-[12px] text-neutral-500 dark:text-neutral-400">
{command.description}
</div>
) : null}
</div>
{isSelected ? (
<ChevronRight
className="mt-0.5 h-3.5 w-3.5 shrink-0 text-neutral-400 dark:text-neutral-500"
strokeWidth={2}
/>
) : null}
</div>
);
})}
</div>
);
})}
</div>
);
}