Command menu Two trigger characters doing two jobs. Slash opens verbs the product can execute; at-sign opens objects filtered by what the viewer is allowed to see. Both stay invisible until typed, which is what keeps them out of the way. npx shadcn@latest add command npm i cmdk Tokens this needs: --background, --popover, --popover-foreground, --accent, --accent-foreground, --muted-foreground, --border, --chart-1 ──────────────────────────────────────────────────────────────────────── // CommandMenu.tsx ──────────────────────────────────────────────────────────────────────── import { useRef, useState, type KeyboardEvent } from "react"; import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from "@/components/ui/command"; /** * Two triggers, two jobs. A verb is a code path the product runs; an object is * a reference the prompt carries. They are separate lists with separate * handlers so the two never blur into one menu. */ export type Verb = { name: string }; export type Ref = { id: string; name: string }; /** The prompt is tokens rather than a string, so a mention stays a reference * instead of decaying back into prose the model has to resolve. */ export type Token = { kind: "text"; text: string } | { kind: "mention"; ref: Ref }; type Props = { value: Token[]; onChange: (value: Token[]) => void; verbs: Verb[]; objects: Ref[]; /** The picker reveals what exists, so it cannot mount without a permission * filter. Pass the viewer's real check, never `() => true` in production. */ canSee: (ref: Ref) => boolean; onCommand: (verb: Verb, prompt: Token[]) => void; onSend: (prompt: Token[]) => void; }; /** A trigger counts at the start of the text or after a space. "3/4" and * "mail@host" are ordinary typing and open nothing. */ const TRIGGER = /(?:^|\s)([/@])([^\s/@]*)$/; export function CommandMenu({ value, onChange, verbs, objects, canSee, onCommand, onSend }: Props) { const listRef = useRef(null); // The trailing token is what the input edits; everything before it is set. const head = value.slice(0, -1); const tail = value[value.length - 1]; const text = tail?.kind === "text" ? tail.text : ""; const match = TRIGGER.exec(text); // Escape remembers where the trigger was, so the menu stays shut until a // new one is typed. The character itself is left in place. const [dismissed, setDismissed] = useState(null); const at = match ? text.length - match[0].length + (match[0].length - match[1].length - match[2].length) : -1; const open = !!match && dismissed !== at; const mode = match?.[1] === "/" ? "verbs" : "objects"; const query = (match?.[2] ?? "").toLowerCase(); const setText = (t: string) => onChange([...head, { kind: "text", text: t }]); const beforeTrigger = () => text.slice(0, at); const pickVerb = (v: Verb) => { onCommand(v, [...head, { kind: "text", text: beforeTrigger() }]); setText(beforeTrigger()); }; const pickObject = (r: Ref) => onChange([...head, { kind: "text", text: beforeTrigger() }, { kind: "mention", ref: r }, { kind: "text", text: " " }]); const onKeyDown = (e: KeyboardEvent) => { if (open && e.key === "Escape") { e.preventDefault(); setDismissed(at); return; } // The list owns arrow keys and Enter while it is open; focus stays here. if (open && ["ArrowUp", "ArrowDown", "Enter"].includes(e.key)) { e.preventDefault(); listRef.current?.dispatchEvent(new KeyboardEvent("keydown", { key: e.key, bubbles: true })); return; } if (e.key === "Enter") { e.preventDefault(); onSend(value); return; } if (e.key === "Backspace" && text === "" && head.length) { e.preventDefault(); onChange(head.slice(0, -1)); } }; return (
{open && ( Nothing matches {mode === "verbs" ? ( {verbs.filter((v) => v.name.toLowerCase().startsWith(query)).map((v) => ( pickVerb(v)}> {v.name} ))} ) : ( {objects.filter(canSee).filter((r) => r.name.toLowerCase().includes(query)).map((r) => ( pickObject(r)}>{r.name} ))} )} )}
{head.map((t, i) => t.kind === "text" ? ( {t.text} ) : ( @{t.ref.name} ), )} setText(e.target.value)} onKeyDown={onKeyDown} aria-label="Message" aria-expanded={open} role="combobox" aria-controls="command-menu-list" placeholder={head.length ? "" : "Message. / for actions, @ for pages and people"} className="min-w-[8rem] flex-1 bg-transparent outline-none placeholder:text-muted-foreground" />
); } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── import { useState } from "react"; import { CommandMenu, type Token, type Verb } from "./CommandMenu"; /** * Opens on the slash. The prompt already carries one mention, picked with @, * and the viewer is mid-way through typing a verb. Picking a verb runs it and * clears the trigger; picking an object drops a chip into the prompt. */ const VERBS: Verb[] = [{ name: "summarise" }, { name: "translate" }]; const OBJECTS = [ { id: "doc-q3", name: "Q3 forecast", team: "finance" }, { id: "doc-rivera", name: "Rivera account brief", team: "sales" }, { id: "doc-comp", name: "Compensation bands", team: "people" }, ]; const VIEWER_TEAMS = new Set(["finance", "sales"]); export default function Demo() { const [prompt, setPrompt] = useState([ { kind: "text", text: "Compare " }, { kind: "mention", ref: { id: "doc-q3", name: "Q3 forecast" } }, { kind: "text", text: " with /" }, ]); const [ran, setRan] = useState(null); return (
VIEWER_TEAMS.has(OBJECTS.find((o) => o.id === r.id)?.team ?? "")} onCommand={(v) => setRan(v.name)} onSend={() => setRan("sent")} /> {ran &&

ran /{ran}

}
); }