Skip to content
KONIGI

AI Assistants / Input and invocation / Command menu

2 of 7

Command menu

Power users need to name a tool, a file or a person without describing it in a sentence.

Updated September 12, 2026

Problem

Natural language is a bad way to identify a specific thing. Describing which of four documents named “Q3 plan” you meant, in prose, to a system that will guess, is slower and less reliable than pointing at it.

Solution

Two triggers doing two different jobs, and keeping them separate is most of the pattern.

Slash is for verbs. Typing / opens a list of actions the product can take: summarise, translate, generate an image, run a search. These are deterministic instructions rather than requests, and that determinism is the point. A power user reaching for /summarize wants the summarising code path—not a model’s interpretation of a polite request to summarise.

At-mention is for nouns. Typing @ opens a picker of objects: pages, files, people, issues, databases. The result is an unambiguous reference embedded in the prompt, which removes both the description cost and the resolution error. Notion’s assistant works this way, letting pages, people and dates be mentioned directly in a prompt to supply context.

Both are comboboxes, and the ARIA combobox pattern is the specification to build against rather than reinvent. The requirements it sets out are the ones that get missed: the popup’s state has to be exposed, the active option has to be tracked as focus stays in the text field, and arrow keys, Enter and Escape all have defined behaviours. An @-menu that works only with a mouse defeats the purpose, since the entire audience for this pattern is people whose hands are on the keyboard.

Discoverability is the tension. The trigger characters are invisible until typed, which is what keeps the composer clean and what keeps most people from ever finding them. A one-line hint in the empty composer, and a visible control that opens the same menu, cost little and convert a hidden feature into an optional one. What they shouldn’t do is take space in the composer permanently.

The menu also needs an escape route back to plain text. Someone typing a path, a fraction or an email address will trigger a menu they didn’t want, and Escape has to dismiss it and leave the typed character in place rather than eating it.

Use when

The product has a bounded set of actions or a corpus of addressable objects, and people use it often enough to learn a shortcut.

Don’t use when

There are three commands and twelve documents. A menu over a small, stable set is slower than typing, and the trigger characters then only get in the way of people writing ordinary sentences.

Trade-offs

Invisible triggers keep the interface clean and hide the feature from the people who would benefit most. A slash menu also creates two ways to ask for the same thing, and the two can diverge in behaviour, which produces a confusing product where /summarize and asking for a summary give different results. Object mentions introduce a permission surface directly into the composer, since the picker’s results reveal what exists. The menus are a maintenance burden that grows with the product. Every new feature turns up wanting an entry.

Checklist

  • Do slash and at-mention carry distinct meanings, and are they used consistently?
  • Is the menu operable entirely from the keyboard?
  • Does it follow the combobox pattern for state and focus?
  • Does Escape dismiss the menu and keep the typed character?
  • What happens when someone types a file path or an email address?
  • Is the object picker filtered by what the viewer is allowed to see?
  • How does a mentioned object appear once inserted, and can it be removed?
  • Is there a discoverable way in besides the trigger character?
  • Does a command behave identically to the equivalent sentence?
  • What does the menu look like with two hundred matching objects?

Compare

Notion uses @-mentions of pages, people and dates as the main way of giving its assistant context, so scope is composed in the prompt rather than configured elsewhere. Slack inherits its existing mention grammar, which means the AI is addressed with the same conventions as a colleague and the permission model comes along unchanged. Linear leans on a command palette that already governs the whole product, so the assistant is one more verb in a vocabulary users know. ChatGPT keeps the composer mostly free of trigger grammar and puts capability behind explicit controls instead. More discoverable, and slower for anyone using it all day.

Composer is the field these menus open from. Scoped context is what an object mention adds to. Prompt starters solve the same discovery problem for people who haven’t learned the shortcuts. Attachment tray is the alternative when the object is a file from outside the product. AI entry point is the adjacent question of how any of this gets found.

Command menu anatomy Two trigger characters doing two jobs: a slash opening a list of verbs the product can execute, and an at-sign opening a picker of objects filtered by what the viewer can see, with an inserted reference shown in the prompt text. Slash is for verbs, at-sign is for nouns Compare @Q3 forecast with /summarise / verbs summarise translate deterministic @ objects Q3 forecast Rivera account brief filtered by permission the triggers are invisible until typed, which is what keeps them hidden escape dismisses and leaves the character in place 1 2 3 4 1 TWO TRIGGERS, TWO JOBS A verb is a code path. A noun is a reference. Merging them loses both. 2 THE PICKER IS A SURFACE Its results reveal what exists, so it has to be filtered by what the viewer may see. 3 AN UNAMBIGUOUS REFERENCE Describing which of four documents you meant, in prose, to a system that guesses. 4 AN ESCAPE ROUTE A path, a fraction or an email address opens a menu nobody wanted. Build it as a combobox. The whole audience for this pattern has its hands on the keyboard.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

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.

shadcn
npx shadcn@latest add command
npm
cmdk
Tokens
--background--popover--popover-foreground--accent--accent-foreground--muted-foreground--border--chart-1
Compare @Q3 forecast

CommandMenu.tsxSlash lists verbs, at-sign lists objects the viewer may see, Escape closes and keeps the character, a mention becomes a chip.

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<HTMLDivElement>(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<number | null>(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<HTMLInputElement>) => {
    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 (
    <div className="relative">
      {open && (
        <Command ref={listRef} shouldFilter={false} className="absolute bottom-full mb-1 w-72 rounded-lg border shadow-md">
          <CommandList id="command-menu-list">
            <CommandEmpty>Nothing matches</CommandEmpty>
            {mode === "verbs" ? (
              <CommandGroup heading="/ verbs">
                {verbs.filter((v) => v.name.toLowerCase().startsWith(query)).map((v) => (
                  <CommandItem key={v.name} value={v.name} onSelect={() => pickVerb(v)}>
                    {v.name}
                  </CommandItem>
                ))}
              </CommandGroup>
            ) : (
              <CommandGroup heading="@ objects">
                {objects.filter(canSee).filter((r) => r.name.toLowerCase().includes(query)).map((r) => (
                  <CommandItem key={r.id} value={r.id} onSelect={() => pickObject(r)}>{r.name}</CommandItem>
                ))}
              </CommandGroup>
            )}
          </CommandList>
        </Command>
      )}

      <div className="flex flex-wrap items-center gap-1 rounded-lg border bg-background px-3 py-2 text-sm">
        {head.map((t, i) =>
          t.kind === "text" ? (
            <span key={i} className="whitespace-pre">{t.text}</span>
          ) : (
            <span key={i} className="rounded-md bg-chart-1/15 px-1.5 py-0.5 text-xs text-chart-1">@{t.ref.name}</span>
          ),
        )}
        <input
          value={text}
          onChange={(e) => 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"
        />
      </div>
    </div>
  );
}

demo.tsxHow it is called: a prompt holding one mention, opened on a freshly typed slash.

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<Token[]>([
    { kind: "text", text: "Compare " },
    { kind: "mention", ref: { id: "doc-q3", name: "Q3 forecast" } },
    { kind: "text", text: " with /" },
  ]);
  const [ran, setRan] = useState<string | null>(null);

  return (
    <div className="pt-28">
      <CommandMenu
        value={prompt}
        onChange={setPrompt}
        verbs={VERBS}
        objects={OBJECTS}
        canSee={(r) => VIEWER_TEAMS.has(OBJECTS.find((o) => o.id === r.id)?.team ?? "")}
        onCommand={(v) => setRan(v.name)}
        onSend={() => setRan("sent")}
      />
      {ran && <p className="mt-2 text-xs text-muted-foreground">ran /{ran}</p>}
    </div>
  );
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

No captures reference this pattern yet. Captures arrive product by product; see Products for what's in the gallery so far.