Files
boocode/apps/web/src/components/SlashCommandPicker.tsx
indifferentketchup 93d3f86c2b v2.2-paseo-providers: Paseo provider stack + v2.2.1 pane-scoped chat fixes
Ship Paseo-equivalent provider snapshot, AgentComposerBar, ACP dispatch
rewrite with streaming/persist, permission prompts, and agent commands.
Follow-up: pane-scoped chat resolution, CoderMessageList tool timeline,
WS user-delta replace, and inference orphan tool_call stripping.
Archive openspec v2-2; update CHANGELOG and CURRENT.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 15:18:31 +00:00

182 lines
5.8 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
import type { CSSProperties, RefObject } from 'react';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/utils';
export interface SlashCommandItem {
name: string;
description?: string;
}
interface Props {
query: string;
items: SlashCommandItem[];
inputRef: RefObject<HTMLElement | null>;
onSelect: (name: string) => void;
onClose: () => void;
emptyLabel?: string;
}
const DROPDOWN_HEIGHT_BUDGET = 320;
function filterByPrefix(items: SlashCommandItem[], query: string): SlashCommandItem[] {
const q = query.toLowerCase();
const filtered = q ? items.filter((s) => s.name.toLowerCase().startsWith(q)) : items;
return [...filtered].sort((a, b) => a.name.localeCompare(b.name));
}
export function SlashCommandPicker({
query,
items,
inputRef,
onSelect,
onClose,
emptyLabel = 'No commands available',
}: Props) {
const [highlightIndex, setHighlightIndex] = useState(0);
const popoverRef = useRef<HTMLDivElement>(null);
const filtered = useMemo(() => filterByPrefix(items, query), [items, query]);
const [rect, setRect] = useState<DOMRect | null>(
() => inputRef.current?.getBoundingClientRect() ?? null,
);
const [vvTick, setVvTick] = useState(0);
useEffect(() => { setHighlightIndex(0); }, [query]);
useEffect(() => {
function recalc() {
setRect(inputRef.current?.getBoundingClientRect() ?? null);
setVvTick((t) => t + 1);
}
recalc();
const vv = window.visualViewport;
vv?.addEventListener('resize', recalc);
vv?.addEventListener('scroll', recalc);
window.addEventListener('resize', recalc);
return () => {
vv?.removeEventListener('resize', recalc);
vv?.removeEventListener('scroll', recalc);
window.removeEventListener('resize', recalc);
};
}, [inputRef]);
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setHighlightIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlightIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1));
} else if (e.key === 'Enter' || e.key === 'Tab') {
if (filtered.length === 0) return;
e.preventDefault();
const target = filtered[highlightIndex] ?? filtered[0];
if (target) onSelect(target.name);
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [filtered, highlightIndex, onSelect, onClose]);
useEffect(() => {
function handleMouseDown(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener('mousedown', handleMouseDown);
return () => document.removeEventListener('mousedown', handleMouseDown);
}, [onClose]);
useEffect(() => {
const el = popoverRef.current?.querySelector('[data-highlighted="true"]');
if (el) el.scrollIntoView({ block: 'nearest' });
}, [highlightIndex]);
const style = useMemo<CSSProperties>(() => {
if (!rect) return { display: 'none' };
const vv = window.visualViewport;
const vvOffsetTop = vv?.offsetTop ?? 0;
const vvHeight = vv?.height ?? window.innerHeight;
// Visible region in layout-viewport coords (what position:fixed uses)
const visibleTop = vvOffsetTop;
const visibleBottom = vvOffsetTop + vvHeight;
const spaceAbove = rect.top - visibleTop;
const spaceBelow = visibleBottom - rect.bottom;
if (spaceAbove >= Math.min(DROPDOWN_HEIGHT_BUDGET, spaceBelow)) {
// Place above: clamp to visible top
const popupTop = Math.max(visibleTop, rect.top - DROPDOWN_HEIGHT_BUDGET);
return {
position: 'fixed',
top: popupTop,
left: rect.left,
maxHeight: rect.top - popupTop,
};
}
// Place below: clamp to visible bottom
return {
position: 'fixed',
top: rect.bottom,
left: rect.left,
maxHeight: Math.min(DROPDOWN_HEIGHT_BUDGET, visibleBottom - rect.bottom),
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rect, vvTick]);
const popover = filtered.length === 0 ? (
<div
ref={popoverRef}
className="z-50 bg-popover border border-border rounded-md shadow min-w-[320px] p-2"
style={style}
>
<div className="text-xs text-muted-foreground px-2 py-1">
{query ? `No command starts with "/${query}"` : emptyLabel}
</div>
</div>
) : (
<div
ref={popoverRef}
className="z-50 bg-popover border border-border rounded-md shadow min-w-[320px] max-w-[420px] max-h-[320px] overflow-y-auto overscroll-contain touch-pan-y"
style={style}
>
{filtered.map((item, i) => (
<div
key={item.name}
role="option"
aria-selected={i === highlightIndex}
data-highlighted={i === highlightIndex}
className={cn(
'w-full text-left px-2.5 py-2 cursor-pointer block',
i === highlightIndex && 'bg-muted',
)}
onMouseEnter={() => setHighlightIndex(i)}
onClick={() => onSelect(item.name)}
>
<div className="font-mono text-xs font-bold text-foreground">/{item.name}</div>
{item.description && (
<div
className="text-xs text-muted-foreground overflow-hidden"
style={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{item.description}
</div>
)}
</div>
))}
</div>
);
return createPortal(popover, document.body);
}