Skip to content
KONIGI Patterns

AI Assistants / Starting a request / Command menu

‹ 2 of 7 ›

Command menu

People need a quick way to choose an action or select a specific item.

Updated September 12, 2026

Problem

Describing an action or a specific file in a prompt can be slow and ambiguous. People need a direct way to choose what they mean.

Solution

Use slash commands to choose actions and @ mentions to select items such as files or pages. Show enough detail to distinguish items with similar names, and only list items the person is allowed to access.

Support keyboard navigation through the menu, including selecting an option and dismissing the menu. Expose its open state and selected option to screen readers. Escape should close the menu without deleting the typed character.

Provide a short hint or visible button so people can discover the shortcuts. Keep actions consistent whether they are chosen from the menu or requested in plain language.

Use when

The app has a defined set of actions or items that people need to select often.

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

Keyboard shortcuts save space but can be hard to discover. Slash commands and plain-language requests create two paths that need consistent behavior. Long menus become harder to search and maintain as the app grows.

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 where the menu opens. Scoped context shows the material added through mentions. Prompt starters help people who do not yet know what to ask.

Attachment tray adds files from outside the app. AI entry point covers how people find AI features.

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 screenshots yet for this pattern. Browse Products to see other examples.