SlashPopover.tsx
1 import type { GatewayClient } from "@/lib/gatewayClient"; 2 import { ListItem } from "@nous-research/ui/ui/components/list-item"; 3 import { ChevronRight } from "lucide-react"; 4 import { 5 forwardRef, 6 useCallback, 7 useEffect, 8 useImperativeHandle, 9 useRef, 10 useState, 11 } from "react"; 12 13 /** 14 * Slash-command autocomplete popover, rendered above the composer in ChatPage. 15 * Mirrors the completion UX of the Ink TUI — type `/`, see matching commands, 16 * arrow keys or click to select, Tab to apply, Enter to submit. 17 * 18 * The parent owns all keyboard handling via `ref.handleKey`, which returns 19 * true when the popover consumed the event, so the composer's Enter/arrow 20 * logic stays in one place. 21 */ 22 23 export interface CompletionItem { 24 display: string; 25 text: string; 26 meta?: string; 27 } 28 29 export interface SlashPopoverHandle { 30 /** Returns true if the key was consumed by the popover. */ 31 handleKey(e: React.KeyboardEvent<HTMLTextAreaElement>): boolean; 32 } 33 34 interface Props { 35 input: string; 36 gw: GatewayClient | null; 37 onApply(nextInput: string): void; 38 } 39 40 interface CompletionResponse { 41 items?: CompletionItem[]; 42 replace_from?: number; 43 } 44 45 const DEBOUNCE_MS = 60; 46 47 export const SlashPopover = forwardRef<SlashPopoverHandle, Props>( 48 function SlashPopover({ input, gw, onApply }, ref) { 49 const [items, setItems] = useState<CompletionItem[]>([]); 50 const [selected, setSelected] = useState(0); 51 const [replaceFrom, setReplaceFrom] = useState(1); 52 const lastInputRef = useRef<string>(""); 53 54 // Debounced completion fetch. We never clear `items` in the effect body 55 // (doing so would flag react-hooks/set-state-in-effect); instead the 56 // render guard below hides stale items once the input stops matching. 57 useEffect(() => { 58 const trimmed = input ?? ""; 59 60 if (!gw || !trimmed.startsWith("/") || trimmed === lastInputRef.current) { 61 if (!trimmed.startsWith("/")) lastInputRef.current = ""; 62 return; 63 } 64 lastInputRef.current = trimmed; 65 66 const timer = window.setTimeout(async () => { 67 if (lastInputRef.current !== trimmed) return; 68 try { 69 const r = await gw.request<CompletionResponse>("complete.slash", { 70 text: trimmed, 71 }); 72 if (lastInputRef.current !== trimmed) return; 73 setItems(r?.items ?? []); 74 setReplaceFrom(r?.replace_from ?? 1); 75 setSelected(0); 76 } catch { 77 if (lastInputRef.current === trimmed) setItems([]); 78 } 79 }, DEBOUNCE_MS); 80 81 return () => window.clearTimeout(timer); 82 }, [input, gw]); 83 84 const apply = useCallback( 85 (item: CompletionItem) => { 86 onApply(input.slice(0, replaceFrom) + item.text); 87 }, 88 [input, replaceFrom, onApply], 89 ); 90 91 // Only consume keys when the popover is actually visible. Stale items from 92 // a previous slash prefix are ignored once the user deletes the "/". 93 const visible = items.length > 0 && input.startsWith("/"); 94 95 useImperativeHandle( 96 ref, 97 () => ({ 98 handleKey: (e) => { 99 if (!visible) return false; 100 101 switch (e.key) { 102 case "ArrowDown": 103 e.preventDefault(); 104 setSelected((s) => (s + 1) % items.length); 105 return true; 106 107 case "ArrowUp": 108 e.preventDefault(); 109 setSelected((s) => (s - 1 + items.length) % items.length); 110 return true; 111 112 case "Tab": { 113 e.preventDefault(); 114 const item = items[selected]; 115 if (item) apply(item); 116 return true; 117 } 118 119 case "Escape": 120 e.preventDefault(); 121 setItems([]); 122 return true; 123 124 default: 125 return false; 126 } 127 }, 128 }), 129 [visible, items, selected, apply], 130 ); 131 132 if (!visible) return null; 133 134 return ( 135 <div 136 className="absolute bottom-full left-0 right-0 mb-2 max-h-64 overflow-y-auto rounded-md border border-border bg-popover shadow-xl text-sm" 137 role="listbox" 138 > 139 {items.map((it, i) => { 140 const active = i === selected; 141 142 return ( 143 <ListItem 144 key={`${it.text}-${i}`} 145 active={active} 146 role="option" 147 aria-selected={active} 148 onMouseEnter={() => setSelected(i)} 149 onClick={() => apply(it)} 150 className="px-3 py-1.5" 151 > 152 <ChevronRight 153 className={`h-3 w-3 shrink-0 ${active ? "text-primary" : "text-transparent"}`} 154 /> 155 156 <span className="font-mono text-xs shrink-0 truncate"> 157 {it.display} 158 </span> 159 160 {it.meta && ( 161 <span className="text-[0.7rem] text-muted-foreground/70 truncate ml-auto"> 162 {it.meta} 163 </span> 164 )} 165 </ListItem> 166 ); 167 })} 168 </div> 169 ); 170 }, 171 );