/ web / src / components / SlashPopover.tsx
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  );