import type { GatewayClient } from "@/lib/gatewayClient"; import { ChevronRight } from "lucide-react"; import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, } from "react"; /** * Slash-command autocomplete popover, rendered above the composer in ChatPage. * Mirrors the completion UX of the Ink TUI — type `/`, see matching commands, * arrow keys or click to select, Tab to apply, Enter to submit. * * The parent owns all keyboard handling via `ref.handleKey`, which returns * true when the popover consumed the event, so the composer's Enter/arrow * logic stays in one place. */ export interface CompletionItem { display: string; text: string; meta?: string; } export interface SlashPopoverHandle { /** Returns true if the key was consumed by the popover. */ handleKey(e: React.KeyboardEvent): boolean; } interface Props { input: string; gw: GatewayClient | null; onApply(nextInput: string): void; } interface CompletionResponse { items?: CompletionItem[]; replace_from?: number; } const DEBOUNCE_MS = 60; export const SlashPopover = forwardRef( function SlashPopover({ input, gw, onApply }, ref) { const [items, setItems] = useState([]); const [selected, setSelected] = useState(0); const [replaceFrom, setReplaceFrom] = useState(1); const lastInputRef = useRef(""); // Debounced completion fetch. We never clear `items` in the effect body // (doing so would flag react-hooks/set-state-in-effect); instead the // render guard below hides stale items once the input stops matching. useEffect(() => { const trimmed = input ?? ""; if (!gw || !trimmed.startsWith("/") || trimmed === lastInputRef.current) { if (!trimmed.startsWith("/")) lastInputRef.current = ""; return; } lastInputRef.current = trimmed; const timer = window.setTimeout(async () => { if (lastInputRef.current !== trimmed) return; try { const r = await gw.request("complete.slash", { text: trimmed, }); if (lastInputRef.current !== trimmed) return; setItems(r?.items ?? []); setReplaceFrom(r?.replace_from ?? 1); setSelected(0); } catch { if (lastInputRef.current === trimmed) setItems([]); } }, DEBOUNCE_MS); return () => window.clearTimeout(timer); }, [input, gw]); const apply = useCallback( (item: CompletionItem) => { onApply(input.slice(0, replaceFrom) + item.text); }, [input, replaceFrom, onApply], ); // Only consume keys when the popover is actually visible. Stale items from // a previous slash prefix are ignored once the user deletes the "/". const visible = items.length > 0 && input.startsWith("/"); useImperativeHandle( ref, () => ({ handleKey: (e) => { if (!visible) return false; switch (e.key) { case "ArrowDown": e.preventDefault(); setSelected((s) => (s + 1) % items.length); return true; case "ArrowUp": e.preventDefault(); setSelected((s) => (s - 1 + items.length) % items.length); return true; case "Tab": { e.preventDefault(); const item = items[selected]; if (item) apply(item); return true; } case "Escape": e.preventDefault(); setItems([]); return true; default: return false; } }, }), [visible, items, selected, apply], ); if (!visible) return null; return (
{items.map((it, i) => { const active = i === selected; return ( ); })}
); }, );