AI Patterns

agents

Instruction Tool Insert

A numbered instruction editor where typing / opens a two-step inline picker — choose a tool, then its connector or target — inserting an atomic, editable chip into the prose.

  1. 1.
  2. 2.

Type / after a space to pick a tool, then a connector.

"use client";

import * as React from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "motion/react";
import {
  ArrowUp,
  ChevronsLeftRightEllipsis,
  Cloud,
  CreditCard,
  Diamond,
  EllipsisVertical,
  Plus,
  ShoppingBag,
  SquareArrowUpRight,
  Tag,
  Webhook,
  type LucideIcon,
} from "lucide-react";

import { cn } from "@/lib/utils";

export interface InstructionToolOption {
  id: string;
  label: string;
  icon?: LucideIcon;
  /** Tailwind classes for the option's icon, e.g. a brand color. */
  iconClassName?: string;
}

export interface InstructionTool {
  id: string;
  /** Name shown in the first-step menu, e.g. "Use Data Connector". */
  label: string;
  /** Bold verb shown in the pill and chip, e.g. "Use". */
  verb: string;
  icon: LucideIcon;
  /** When present, picking the tool opens a second step to pick one of these. */
  options?: InstructionToolOption[];
  /** Placeholder for the second step's search, e.g. "Select Data Connector…". */
  optionPlaceholder?: string;
  /** Footer action in the second step, e.g. "Create new Data Connector". */
  createLabel?: string;
}

export type InstructionSegment =
  | { type: "text"; text: string }
  | { type: "tool"; toolId: string; optionId?: string };

export interface InstructionToolsProps {
  tools?: InstructionTool[];
  /** Initial plain-text content, one string per numbered step. */
  defaultSteps?: string[];
  placeholder?: string;
  onChange?: (steps: InstructionSegment[][]) => void;
  /** Called when the user picks a tool's "Create new…" footer action. */
  onCreateOption?: (toolId: string) => void;
  className?: string;
}

export const DEFAULT_TOOLS: InstructionTool[] = [
  {
    id: "data-connector",
    label: "Use Data Connector",
    verb: "Use",
    icon: Webhook,
    optionPlaceholder: "Select Data Connector…",
    createLabel: "Create new Data Connector",
    options: [
      { id: "upcoming-invoice", label: "Get upcoming invoice", icon: CreditCard, iconClassName: "text-indigo-500" },
      { id: "order-info", label: "Get order info", icon: ShoppingBag, iconClassName: "text-lime-600" },
      { id: "transition-issue", label: "Transition issue to 'In progress'", icon: Diamond, iconClassName: "text-blue-500" },
      { id: "attach-case", label: "Attach case to contact", icon: Cloud, iconClassName: "text-sky-500" },
    ],
  },
  {
    id: "read-attribute",
    label: "Read an attribute",
    verb: "Read",
    icon: ChevronsLeftRightEllipsis,
    optionPlaceholder: "Select attribute…",
    options: [
      { id: "email", label: "Customer email" },
      { id: "order-id", label: "Order ID" },
      { id: "plan", label: "Plan tier" },
    ],
  },
  {
    id: "update-attribute",
    label: "Update an attribute",
    verb: "Update",
    icon: ArrowUp,
    optionPlaceholder: "Select attribute…",
    options: [
      { id: "status", label: "Order status" },
      { id: "priority", label: "Priority" },
      { id: "plan", label: "Plan tier" },
    ],
  },
  {
    id: "tag",
    label: "Tag conversation",
    verb: "Tag",
    icon: Tag,
    optionPlaceholder: "Select tag…",
    options: [
      { id: "damaged", label: "Damaged item" },
      { id: "refund", label: "Refund request" },
      { id: "vip", label: "VIP" },
    ],
  },
  { id: "escalate", label: "Escalate", verb: "Escalate", icon: SquareArrowUpRight },
];

interface PickerState {
  anchor: HTMLSpanElement;
  stage: "tool" | "option";
  tool?: InstructionTool;
  query: string;
  activeIndex: number;
}

interface ChipState {
  id: string;
  el: HTMLSpanElement;
  tool: InstructionTool;
  option?: InstructionToolOption;
}

const INLINE_HOST_CLASS = "relative mx-0.5 inline-block align-baseline";

function makeHost() {
  const span = document.createElement("span");
  span.contentEditable = "false";
  span.className = INLINE_HOST_CLASS;
  return span;
}

function placeCaret(setup: (range: Range) => void) {
  const sel = window.getSelection();
  if (!sel) return;
  const range = document.createRange();
  setup(range);
  range.collapse(true);
  sel.removeAllRanges();
  sel.addRange(range);
}

export function InstructionTools({
  tools = DEFAULT_TOOLS,
  defaultSteps = [""],
  placeholder = "Type / to insert a tool",
  onChange,
  onCreateOption,
  className,
}: InstructionToolsProps) {
  const [steps, setSteps] = React.useState(() =>
    (defaultSteps.length ? defaultSteps : [""]).map((text, i) => ({ id: `step-${i}`, text }))
  );
  const editors = React.useRef(new Map<string, HTMLDivElement>());
  const values = React.useRef(new Map<string, InstructionSegment[]>());
  const nextId = React.useRef(steps.length);
  const pendingFocus = React.useRef<string | null>(null);

  React.useEffect(() => {
    if (!pendingFocus.current) return;
    const el = editors.current.get(pendingFocus.current);
    pendingFocus.current = null;
    if (!el) return;
    el.focus();
    placeCaret((r) => {
      r.selectNodeContents(el);
      r.collapse(false);
    });
  }, [steps]);

  function emit(order = steps) {
    onChange?.(order.map((s) => values.current.get(s.id) ?? []));
  }

  function updateSteps(next: typeof steps) {
    setSteps(next);
    emit(next);
  }

  function addStepAfter(id: string) {
    const step = { id: `step-${nextId.current++}`, text: "" };
    const i = steps.findIndex((s) => s.id === id);
    pendingFocus.current = step.id;
    updateSteps([...steps.slice(0, i + 1), step, ...steps.slice(i + 1)]);
  }

  function removeStep(id: string) {
    const i = steps.findIndex((s) => s.id === id);
    if (i <= 0) return;
    pendingFocus.current = steps[i - 1].id;
    values.current.delete(id);
    updateSteps(steps.filter((s) => s.id !== id));
  }

  return (
    <ol className={cn("w-full space-y-2 text-sm", className)}>
      {steps.map((step, i) => (
        <li key={step.id} className="flex gap-3">
          <span className="w-4 shrink-0 pt-[3px] text-right font-mono text-xs text-muted-foreground tabular-nums">
            {i + 1}.
          </span>
          <StepEditor
            initialText={step.text}
            tools={tools}
            placeholder={i === 0 ? placeholder : undefined}
            onCreateOption={onCreateOption}
            registerEditor={(el) => {
              if (el) editors.current.set(step.id, el);
              else editors.current.delete(step.id);
            }}
            onSegments={(segments) => {
              values.current.set(step.id, segments);
              emit();
            }}
            onEnter={() => addStepAfter(step.id)}
            onBackspaceEmpty={() => removeStep(step.id)}
          />
        </li>
      ))}
    </ol>
  );
}

interface StepEditorProps {
  initialText: string;
  tools: InstructionTool[];
  placeholder?: string;
  onCreateOption?: (toolId: string) => void;
  registerEditor: (el: HTMLDivElement | null) => void;
  onSegments: (segments: InstructionSegment[]) => void;
  onEnter: () => void;
  onBackspaceEmpty: () => void;
}

function StepEditor({
  initialText,
  tools,
  placeholder,
  onCreateOption,
  registerEditor,
  onSegments,
  onEnter,
  onBackspaceEmpty,
}: StepEditorProps) {
  const editorRef = React.useRef<HTMLDivElement>(null);
  const [picker, setPickerState] = React.useState<PickerState | null>(null);
  // Mirrors `picker` so blur/close handlers fired mid-transition see the latest value.
  const pickerRef = React.useRef<PickerState | null>(null);
  const [chips, setChips] = React.useState<ChipState[]>([]);
  const [isEmpty, setIsEmpty] = React.useState(!initialText);
  const chipSeq = React.useRef(0);

  function setPicker(next: PickerState | null) {
    pickerRef.current = next;
    setPickerState(next);
  }

  React.useEffect(() => {
    const el = editorRef.current;
    if (!el) return;
    el.textContent = initialText;
    registerEditor(el);
    return () => registerEditor(null);
    // Mount-only: the editor is uncontrolled after its initial text.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  function sync() {
    const el = editorRef.current;
    if (!el) return;
    // Chips deleted by the browser (backspace, cut) leave their host detached.
    setChips((prev) => (prev.some((c) => !c.el.isConnected) ? prev.filter((c) => c.el.isConnected) : prev));
    setIsEmpty(!(el.textContent ?? "").trim() && !el.querySelector("[data-chip]"));
    onSegments(serialize(el, tools));
  }

  function openPicker(anchor: HTMLSpanElement, stage: PickerState["stage"] = "tool", tool?: InstructionTool) {
    setPicker({ anchor, stage, tool, query: "", activeIndex: 0 });
  }

  /** Swaps the picker's host for `replacement` (or nothing) and returns the caret to the editor. */
  function closePicker(replacement?: Node, pad = true) {
    const current = pickerRef.current;
    const el = editorRef.current;
    if (!current || !el) return;
    setPicker(null);
    el.focus();
    const anchor = current.anchor;
    if (!anchor.isConnected) return sync();
    if (replacement && pad) {
      // Chips get a trailing space so typing continues outside them.
      const space = document.createTextNode(" ");
      anchor.replaceWith(replacement, space);
      placeCaret((r) => r.setStart(space, 1));
    } else if (replacement) {
      anchor.replaceWith(replacement);
      placeCaret((r) => r.setStartAfter(replacement));
    } else {
      placeCaret((r) => r.setStartBefore(anchor));
      anchor.remove();
    }
    sync();
  }

  function cancelPicker() {
    const current = pickerRef.current;
    if (!current) return;
    const literal = current.stage === "tool" ? `/${current.query}` : "";
    closePicker(literal ? document.createTextNode(literal) : undefined, false);
  }

  function insertChip(tool: InstructionTool, option?: InstructionToolOption) {
    const host = makeHost();
    host.dataset.chip = "true";
    host.dataset.tool = tool.id;
    if (option) host.dataset.option = option.id;
    setChips((prev) => [...prev, { id: `chip-${chipSeq.current++}`, el: host, tool, option }]);
    closePicker(host);
  }

  function choose(index: number) {
    const current = pickerRef.current;
    if (!current) return;
    if (current.stage === "tool") {
      const tool = filterTools(tools, current.query)[index];
      if (!tool) return;
      if (tool.options?.length) {
        setPicker({ ...current, stage: "option", tool, query: "", activeIndex: 0 });
      } else {
        insertChip(tool);
      }
      return;
    }
    const tool = current.tool!;
    const options = filterOptions(tool, current.query);
    if (index < options.length) {
      insertChip(tool, options[index]);
    } else if (tool.createLabel) {
      closePicker();
      onCreateOption?.(tool.id);
    }
  }

  function editChip(chip: ChipState) {
    const host = makeHost();
    chip.el.replaceWith(host);
    setChips((prev) => prev.filter((c) => c.id !== chip.id));
    openPicker(host, "option", chip.tool);
  }

  function removeChip(chip: ChipState) {
    const el = editorRef.current;
    el?.focus();
    placeCaret((r) => r.setStartBefore(chip.el));
    chip.el.remove();
    sync();
  }

  function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
    const el = editorRef.current;
    if (!el) return;
    if (e.key === "/" && !e.metaKey && !e.ctrlKey) {
      const sel = window.getSelection();
      if (!sel?.rangeCount) return;
      const caret = sel.getRangeAt(0);
      const before = document.createRange();
      before.setStart(el, 0);
      before.setEnd(caret.startContainer, caret.startOffset);
      // Only at a word boundary, so "and/or" and URLs stay plain text.
      if (!/(^|\s)$/.test(before.toString())) return;
      e.preventDefault();
      caret.deleteContents();
      const host = makeHost();
      caret.insertNode(host);
      openPicker(host);
      return;
    }
    if (e.key === "Enter" && e.shiftKey) {
      e.preventDefault();
      document.execCommand("insertLineBreak");
      return;
    }
    if (e.key === "Enter") {
      e.preventDefault();
      onEnter();
      return;
    }
    if (e.key === "Backspace" && isEmpty && !el.textContent) {
      e.preventDefault();
      onBackspaceEmpty();
    }
  }

  return (
    <div className="relative min-w-0 flex-1">
      <div
        ref={editorRef}
        contentEditable
        suppressContentEditableWarning
        role="textbox"
        aria-multiline="true"
        aria-label="Instruction step"
        onInput={sync}
        onKeyDown={handleKeyDown}
        onPaste={(e) => {
          e.preventDefault();
          document.execCommand("insertText", false, e.clipboardData.getData("text/plain"));
        }}
        className="min-h-6 whitespace-pre-wrap break-words leading-7 outline-none"
      />
      {isEmpty && !picker && placeholder && (
        <span className="pointer-events-none absolute left-0 top-0 leading-7 text-muted-foreground">
          {placeholder}
        </span>
      )}

      {chips.map((chip) =>
        createPortal(
          <ToolChip chip={chip} onEdit={() => editChip(chip)} onRemove={() => removeChip(chip)} />,
          chip.el,
          chip.id
        )
      )}

      {picker &&
        createPortal(
          <ToolPicker
            picker={picker}
            tools={tools}
            onQuery={(query) => setPicker({ ...picker, query, activeIndex: 0 })}
            onActive={(activeIndex) => setPicker({ ...picker, activeIndex })}
            onChoose={choose}
            onBack={() => setPicker({ ...picker, stage: "tool", tool: undefined, query: "", activeIndex: 0 })}
            onCancel={cancelPicker}
            onDismiss={() => closePicker()}
          />,
          picker.anchor,
          "picker"
        )}
    </div>
  );
}

function filterTools(tools: InstructionTool[], query: string) {
  const q = query.trim().toLowerCase();
  return tools.filter((t) => t.label.toLowerCase().includes(q));
}

function filterOptions(tool: InstructionTool, query: string) {
  const q = query.trim().toLowerCase();
  return (tool.options ?? []).filter((o) => o.label.toLowerCase().includes(q));
}

function serialize(el: HTMLElement, tools: InstructionTool[]): InstructionSegment[] {
  const segments: InstructionSegment[] = [];
  let text = "";
  el.childNodes.forEach((node) => {
    if (node instanceof HTMLElement && node.dataset.chip === "true") {
      if (text) segments.push({ type: "text", text });
      text = "";
      const tool = tools.find((t) => t.id === node.dataset.tool);
      if (tool) segments.push({ type: "tool", toolId: tool.id, optionId: node.dataset.option });
    } else if (node instanceof HTMLBRElement) {
      text += "\n";
    } else if (node.nodeType === Node.TEXT_NODE) {
      text += (node.textContent ?? "").replace(/ /g, " ");
    }
  });
  if (text) segments.push({ type: "text", text });
  return segments;
}

interface ToolPickerProps {
  picker: PickerState;
  tools: InstructionTool[];
  onQuery: (query: string) => void;
  onActive: (index: number) => void;
  onChoose: (index: number) => void;
  onBack: () => void;
  onCancel: () => void;
  onDismiss: () => void;
}

function ToolPicker({ picker, tools, onQuery, onActive, onChoose, onBack, onCancel, onDismiss }: ToolPickerProps) {
  const { stage, tool, query, activeIndex } = picker;
  const listId = React.useId();
  const toolItems = stage === "tool" ? filterTools(tools, query) : [];
  const optionItems = stage === "option" && tool ? filterOptions(tool, query) : [];
  const hasCreate = stage === "option" && Boolean(tool?.createLabel);
  const count = stage === "tool" ? toolItems.length : optionItems.length + (hasCreate ? 1 : 0);
  const inputPlaceholder = stage === "tool" ? "Search tools…" : (tool?.optionPlaceholder ?? "Search…");

  function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
    if (e.key === "ArrowDown" || e.key === "ArrowUp") {
      e.preventDefault();
      if (count) onActive((activeIndex + (e.key === "ArrowDown" ? 1 : count - 1)) % count);
    } else if (e.key === "Enter" || e.key === "Tab") {
      e.preventDefault();
      if (count) onChoose(activeIndex);
    } else if (e.key === "Escape") {
      e.preventDefault();
      if (stage === "option") onBack();
      else onCancel();
    } else if (e.key === "Backspace" && !query) {
      e.preventDefault();
      if (stage === "option") onBack();
      else onDismiss();
    }
  }

  return (
    <>
      <span className="inline-flex items-center rounded-md bg-muted px-1.5 leading-6">
        <span className="text-foreground">/</span>
        {stage === "option" && tool && <span className="mr-1 font-medium">{tool.verb}</span>}
        <input
          autoFocus
          value={query}
          onChange={(e) => onQuery(e.target.value)}
          onKeyDown={handleKeyDown}
          onBlur={onCancel}
          placeholder={inputPlaceholder}
          role="combobox"
          aria-expanded
          aria-controls={listId}
          aria-activedescendant={count ? `${listId}-${activeIndex}` : undefined}
          aria-label={inputPlaceholder}
          style={{ width: `${Math.max(query.length, inputPlaceholder.length) + 1}ch` }}
          className="border-l border-foreground/60 bg-transparent pl-px text-sm outline-none placeholder:text-muted-foreground"
        />
      </span>

      <AnimatePresence mode="wait">
        <motion.div
          key={stage}
          initial={{ opacity: 0, y: -4 }}
          animate={{ opacity: 1, y: 0 }}
          exit={{ opacity: 0, y: -4 }}
          transition={{ duration: 0.12 }}
          id={listId}
          role="listbox"
          // Keep focus in the input while clicking items.
          onMouseDown={(e) => e.preventDefault()}
          className="absolute left-0 top-full z-30 mt-2 w-72 max-w-[80vw] overflow-hidden rounded-xl border bg-popover p-1 text-sm text-popover-foreground shadow-md"
        >
          {count === 0 && <p className="px-2.5 py-2 text-muted-foreground">No matches</p>}

          {toolItems.map((t, i) => (
            <MenuItem
              key={t.id}
              id={`${listId}-${i}`}
              active={i === activeIndex}
              onHover={() => onActive(i)}
              onClick={() => onChoose(i)}
              icon={<t.icon className="size-3.5" />}
              label={t.label}
            />
          ))}

          {optionItems.map((o, i) => {
            const Icon = o.icon ?? tool!.icon;
            return (
              <MenuItem
                key={o.id}
                id={`${listId}-${i}`}
                active={i === activeIndex}
                onHover={() => onActive(i)}
                onClick={() => onChoose(i)}
                icon={<Icon className={cn("size-3.5", o.iconClassName)} />}
                label={o.label}
              />
            );
          })}

          {hasCreate && (
            <>
              <div className="mx-1 my-1 border-t border-dashed" />
              <MenuItem
                id={`${listId}-${optionItems.length}`}
                active={activeIndex === optionItems.length}
                onHover={() => onActive(optionItems.length)}
                onClick={() => onChoose(optionItems.length)}
                icon={<Plus className="size-3.5" />}
                label={tool!.createLabel!}
              />
            </>
          )}
        </motion.div>
      </AnimatePresence>
    </>
  );
}

interface MenuItemProps {
  id: string;
  active: boolean;
  icon: React.ReactNode;
  label: string;
  onHover: () => void;
  onClick: () => void;
}

function MenuItem({ id, active, icon, label, onHover, onClick }: MenuItemProps) {
  return (
    <div
      id={id}
      role="option"
      aria-selected={active}
      onMouseEnter={onHover}
      onClick={onClick}
      className={cn(
        "flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5",
        active && "bg-accent text-accent-foreground"
      )}
    >
      <span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-muted">{icon}</span>
      <span className="truncate">{label}</span>
    </div>
  );
}

interface ToolChipProps {
  chip: ChipState;
  onEdit: () => void;
  onRemove: () => void;
}

function ToolChip({ chip, onEdit, onRemove }: ToolChipProps) {
  const [menuOpen, setMenuOpen] = React.useState(false);
  const ref = React.useRef<HTMLSpanElement>(null);
  const { tool, option } = chip;

  React.useEffect(() => {
    if (!menuOpen) return;
    function handle(e: MouseEvent) {
      if (ref.current && !ref.current.contains(e.target as Node)) setMenuOpen(false);
    }
    document.addEventListener("mousedown", handle);
    return () => document.removeEventListener("mousedown", handle);
  }, [menuOpen]);

  return (
    <motion.span
      ref={ref}
      initial={{ opacity: 0, scale: 0.9 }}
      animate={{ opacity: 1, scale: 1 }}
      transition={{ type: "spring", stiffness: 500, damping: 30 }}
      className="inline-flex select-none items-center gap-1 rounded-md bg-muted py-0 pl-1.5 pr-0.5 leading-6"
    >
      <tool.icon className="size-3.5 shrink-0" aria-hidden />
      <span className="font-semibold">{tool.verb}</span>
      {option && <span>{option.label}</span>}
      <button
        type="button"
        aria-label={`${tool.verb} ${option?.label ?? ""} options`.trim()}
        aria-haspopup="menu"
        aria-expanded={menuOpen}
        onMouseDown={(e) => e.preventDefault()}
        onClick={() => setMenuOpen((v) => !v)}
        className="flex size-5 items-center justify-center rounded text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
      >
        <EllipsisVertical className="size-3.5" />
      </button>

      <AnimatePresence>
        {menuOpen && (
          <motion.span
            role="menu"
            initial={{ opacity: 0, y: -4 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: -4 }}
            transition={{ duration: 0.12 }}
            className="absolute right-0 top-full z-30 mt-1 flex w-40 flex-col rounded-lg border bg-popover p-1 text-sm shadow-md"
          >
            {tool.options?.length ? (
              <button
                type="button"
                role="menuitem"
                onClick={onEdit}
                className="rounded-md px-2 py-1.5 text-left hover:bg-accent"
              >
                Change
              </button>
            ) : null}
            <button
              type="button"
              role="menuitem"
              onClick={onRemove}
              className="rounded-md px-2 py-1.5 text-left text-destructive hover:bg-destructive/10"
            >
              Remove
            </button>
          </motion.span>
        )}
      </AnimatePresence>
    </motion.span>
  );
}

A UX spec for this pattern — written for agents implementing or reusing it, not the code.

# Instruction Tool Insert

## Summary
A numbered instruction editor for an agent's playbook where typing `/` mid-sentence opens a two-step inline picker: first choose a tool ("Use Data Connector", "Read an attribute", "Escalate"…), then — for tools that need one — choose its target ("Get order info"). The result lands in the prose as an atomic chip ("**Use** Get order info ⋮"), so instructions read like natural language while referencing concrete, machine-resolvable actions.

## When to use
- Authoring agent or workflow instructions in plain language that must also bind to specific tools, connectors, attributes, or tags.
- When the set of tools is small and categorical, but each tool's targets are many and workspace-specific (connectors, fields, labels).
- When a step can mix several actions with free text ("Acknowledge the issue… **Use** Get order info, then **Tag** Damaged item").

## When not to use
- A chat composer whose / commands run immediately — use a command menu (Prompt Bar) instead; here, chips are *references* the agent resolves later.
- Rigid, fully structured workflows with branching — a node-based flowchart (Trigger Condition) shows control flow better than prose.
- When there is only one level of choice — skip the second step and insert the chip directly.

## Anatomy
- Numbered step list; each step is a rich-text line (Shift+Enter for a soft line break).
- Inline picker pill at the caret: `/` + search input. Step 1 placeholder "Search tools…"; step 2 shows the chosen verb in bold ("/**Use**") with a placeholder naming what to pick ("Select Data Connector…").
- Anchored menu below the pill: icon tile + label rows. Step 2 options carry their source app's icon/color, and an optional dashed-separated "Create new …" footer.
- Inserted chip: tool icon, bold verb, target label, and a ⋮ overflow menu (Change, Remove). The chip is atomic — it is deleted as a whole and its text is not editable.

## Behavior
1. Typing `/` at a word boundary (start of step or after whitespace) replaces it with the picker pill and focuses its search input. A `/` inside a word ("and/or", URLs) stays literal text.
2. Typing filters the menu; ↑/↓ move the highlight (hover also moves it), Enter/Tab or click selects.
3. Selecting a tool with targets swaps the pill to step 2 in place (menu cross-fades); a tool without targets ("Escalate") inserts its chip immediately.
4. Selecting a target replaces the pill with the chip (subtle spring scale-in) followed by a space, and returns the caret to the text right after it.
5. Backing out: Backspace on an empty search or Escape in step 2 returns to step 1; Backspace on empty step 1 removes the pill; Escape or blurring in step 1 restores the literal "/query" text so nothing typed is lost.
6. Chip ⋮ → Change reopens step 2 at the chip's position; Remove deletes it. Backspacing into a chip also deletes it whole.
7. Enter starts a new numbered step; Backspace in an empty step merges back into the previous one.
8. "Create new …" hands off to the host (`onCreateOption`) — don't build creation inline in the menu.

## Content guidelines
- Tool labels are verb phrases ("Use Data Connector", "Tag conversation"); the chip shows only the short verb ("Use", "Tag") so it reads as part of the sentence.
- Target labels are imperative and specific ("Get order info", "Transition issue to 'In progress'").
- Step-2 placeholders name the noun being picked: "Select Data Connector…", "Select attribute…".
- Keep the first-level list to ~5–7 tools; push breadth into step 2.

## Accessibility
- The search input is a `combobox` controlling a `listbox`; the highlighted row is exposed via `aria-activedescendant` so screen readers announce it while focus stays in the input.
- Menu rows are `option`s with `aria-selected`; mouse-down on the menu is prevented so clicking never steals focus or drops the picker.
- The chip's ⋮ is a real button with an accessible name ("Use Get order info options") and `aria-haspopup="menu"`.
- Everything is reachable by keyboard: / to open, arrows + Enter to pick, Escape/Backspace to step back.
- Serialize chips to structured segments (`{ type: "tool", toolId, optionId }`), never to their display text, so renames don't break instructions.

## Related patterns
- Prompt Bar — @ sources and / commands in a chat composer (single-step, executes immediately).
- Command Palette — global, modal command search.
- Create Agent / Agent Triggers — where these instructions typically live.
- Tool Call Chip — how the same tools appear once the agent actually runs them.