{"slug":"instruction-tools","category":"agents","job":"ask","added":"2026-10-18T20:44Z","title":"Instruction Tool Insert","description":"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.","url":"https://ai-patterns.app/patterns/agents/instruction-tools","markdown":"https://ai-patterns.app/patterns/agents/instruction-tools.md","json":"https://ai-patterns.app/api/patterns/agents/instruction-tools","seenIn":[{"team":"Intercom","source":"https://fin.ai/help/en/articles/13976221-how-to-use-data-connectors-in-fin-procedures"}],"spec":"# Instruction Tool Insert\n\n## Summary\nA 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.\n\n## When to use\n- Authoring agent or workflow instructions in plain language that must also bind to specific tools, connectors, attributes, or tags.\n- When the set of tools is small and categorical, but each tool's targets are many and workspace-specific (connectors, fields, labels).\n- When a step can mix several actions with free text (\"Acknowledge the issue… **Use** Get order info, then **Tag** Damaged item\").\n\n## When not to use\n- A chat composer whose / commands run immediately — use a command menu (Prompt Bar) instead; here, chips are *references* the agent resolves later.\n- Rigid, fully structured workflows with branching — a node-based flowchart (Trigger Condition) shows control flow better than prose.\n- When there is only one level of choice — skip the second step and insert the chip directly.\n\n## Anatomy\n- Numbered step list; each step is a rich-text line (Shift+Enter for a soft line break).\n- 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…\").\n- 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.\n- 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.\n\n## Behavior\n1. 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.\n2. Typing filters the menu; ↑/↓ move the highlight (hover also moves it), Enter/Tab or click selects.\n3. 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.\n4. 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.\n5. 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.\n6. Chip ⋮ → Change reopens step 2 at the chip's position; Remove deletes it. Backspacing into a chip also deletes it whole.\n7. Enter starts a new numbered step; Backspace in an empty step merges back into the previous one.\n8. \"Create new …\" hands off to the host (`onCreateOption`) — don't build creation inline in the menu.\n\n## Content guidelines\n- 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.\n- Target labels are imperative and specific (\"Get order info\", \"Transition issue to 'In progress'\").\n- Step-2 placeholders name the noun being picked: \"Select Data Connector…\", \"Select attribute…\".\n- Keep the first-level list to ~5–7 tools; push breadth into step 2.\n\n## Accessibility\n- 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.\n- Menu rows are `option`s with `aria-selected`; mouse-down on the menu is prevented so clicking never steals focus or drops the picker.\n- The chip's ⋮ is a real button with an accessible name (\"Use Get order info options\") and `aria-haspopup=\"menu\"`.\n- Everything is reachable by keyboard: / to open, arrows + Enter to pick, Escape/Backspace to step back.\n- Serialize chips to structured segments (`{ type: \"tool\", toolId, optionId }`), never to their display text, so renames don't break instructions.\n\n## Related patterns\n- **Prompt Bar** — Use it instead when @ sources and / commands go into a chat message that runs immediately, not saved instructions.\n- **Command Palette** — Use it instead when people need global, modal search for commands rather than inserting a tool into prose.\n- **Create Agent** — Use it as well, since agent setup is where these instructions are usually written.\n- **Agent Triggers** — Use it as well to decide when the agent runs, while the instructions decide what it does.\n- **Tool Call Chip** — Use it as well in the conversation, where the same tools show up as chips once the agent actually calls them.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport {\n  ArrowUp,\n  ChevronsLeftRightEllipsis,\n  Cloud,\n  CreditCard,\n  Diamond,\n  EllipsisVertical,\n  Plus,\n  ShoppingBag,\n  SquareArrowUpRight,\n  Tag,\n  Webhook,\n  type LucideIcon,\n} from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface InstructionToolOption {\n  id: string;\n  label: string;\n  icon?: LucideIcon;\n  /** Tailwind classes for the option's icon, e.g. a brand color. */\n  iconClassName?: string;\n}\n\nexport interface InstructionTool {\n  id: string;\n  /** Name shown in the first-step menu, e.g. \"Use Data Connector\". */\n  label: string;\n  /** Bold verb shown in the pill and chip, e.g. \"Use\". */\n  verb: string;\n  icon: LucideIcon;\n  /** When present, picking the tool opens a second step to pick one of these. */\n  options?: InstructionToolOption[];\n  /** Placeholder for the second step's search, e.g. \"Select Data Connector…\". */\n  optionPlaceholder?: string;\n  /** Footer action in the second step, e.g. \"Create new Data Connector\". */\n  createLabel?: string;\n}\n\nexport type InstructionSegment =\n  | { type: \"text\"; text: string }\n  | { type: \"tool\"; toolId: string; optionId?: string };\n\nexport interface InstructionToolsProps {\n  tools?: InstructionTool[];\n  /** Initial plain-text content, one string per numbered step. */\n  defaultSteps?: string[];\n  placeholder?: string;\n  /**\n   * Opens the / tool menu at the end of the last step on first render,\n   * without moving focus — for demos and onboarding.\n   */\n  defaultPickerOpen?: boolean;\n  onChange?: (steps: InstructionSegment[][]) => void;\n  /** Called when the user picks a tool's \"Create new…\" footer action. */\n  onCreateOption?: (toolId: string) => void;\n  className?: string;\n}\n\nexport const DEFAULT_TOOLS: InstructionTool[] = [\n  {\n    id: \"data-connector\",\n    label: \"Use Data Connector\",\n    verb: \"Use\",\n    icon: Webhook,\n    optionPlaceholder: \"Select Data Connector…\",\n    createLabel: \"Create new Data Connector\",\n    options: [\n      { id: \"upcoming-invoice\", label: \"Get upcoming invoice\", icon: CreditCard, iconClassName: \"text-indigo-500\" },\n      { id: \"order-info\", label: \"Get order info\", icon: ShoppingBag, iconClassName: \"text-lime-600\" },\n      { id: \"transition-issue\", label: \"Transition issue to 'In progress'\", icon: Diamond, iconClassName: \"text-blue-500\" },\n      { id: \"attach-case\", label: \"Attach case to contact\", icon: Cloud, iconClassName: \"text-sky-500\" },\n    ],\n  },\n  {\n    id: \"read-attribute\",\n    label: \"Read an attribute\",\n    verb: \"Read\",\n    icon: ChevronsLeftRightEllipsis,\n    optionPlaceholder: \"Select attribute…\",\n    options: [\n      { id: \"email\", label: \"Customer email\" },\n      { id: \"order-id\", label: \"Order ID\" },\n      { id: \"plan\", label: \"Plan tier\" },\n    ],\n  },\n  {\n    id: \"update-attribute\",\n    label: \"Update an attribute\",\n    verb: \"Update\",\n    icon: ArrowUp,\n    optionPlaceholder: \"Select attribute…\",\n    options: [\n      { id: \"status\", label: \"Order status\" },\n      { id: \"priority\", label: \"Priority\" },\n      { id: \"plan\", label: \"Plan tier\" },\n    ],\n  },\n  {\n    id: \"tag\",\n    label: \"Tag conversation\",\n    verb: \"Tag\",\n    icon: Tag,\n    optionPlaceholder: \"Select tag…\",\n    options: [\n      { id: \"damaged\", label: \"Damaged item\" },\n      { id: \"refund\", label: \"Refund request\" },\n      { id: \"vip\", label: \"VIP\" },\n    ],\n  },\n  { id: \"escalate\", label: \"Escalate\", verb: \"Escalate\", icon: SquareArrowUpRight },\n];\n\ninterface PickerState {\n  anchor: HTMLSpanElement;\n  stage: \"tool\" | \"option\";\n  tool?: InstructionTool;\n  query: string;\n  activeIndex: number;\n  /** Focus the search input when the picker opens (default true). */\n  autoFocus?: boolean;\n}\n\ninterface ChipState {\n  id: string;\n  el: HTMLSpanElement;\n  tool: InstructionTool;\n  option?: InstructionToolOption;\n}\n\nconst INLINE_HOST_CLASS = \"relative mx-0.5 inline-block align-baseline\";\n\nfunction makeHost() {\n  const span = document.createElement(\"span\");\n  span.contentEditable = \"false\";\n  span.className = INLINE_HOST_CLASS;\n  return span;\n}\n\nfunction placeCaret(setup: (range: Range) => void) {\n  const sel = window.getSelection();\n  if (!sel) return;\n  const range = document.createRange();\n  setup(range);\n  range.collapse(true);\n  sel.removeAllRanges();\n  sel.addRange(range);\n}\n\nexport function InstructionTools({\n  tools = DEFAULT_TOOLS,\n  defaultSteps = [\"\"],\n  placeholder = \"Type / to insert a tool\",\n  defaultPickerOpen = false,\n  onChange,\n  onCreateOption,\n  className,\n}: InstructionToolsProps) {\n  const [steps, setSteps] = React.useState(() =>\n    (defaultSteps.length ? defaultSteps : [\"\"]).map((text, i) => ({ id: `step-${i}`, text }))\n  );\n  const editors = React.useRef(new Map<string, HTMLDivElement>());\n  const values = React.useRef(new Map<string, InstructionSegment[]>());\n  const nextId = React.useRef(steps.length);\n  const pendingFocus = React.useRef<string | null>(null);\n\n  React.useEffect(() => {\n    if (!pendingFocus.current) return;\n    const el = editors.current.get(pendingFocus.current);\n    pendingFocus.current = null;\n    if (!el) return;\n    el.focus();\n    placeCaret((r) => {\n      r.selectNodeContents(el);\n      r.collapse(false);\n    });\n  }, [steps]);\n\n  function emit(order = steps) {\n    onChange?.(order.map((s) => values.current.get(s.id) ?? []));\n  }\n\n  function updateSteps(next: typeof steps) {\n    setSteps(next);\n    emit(next);\n  }\n\n  function addStepAfter(id: string) {\n    const step = { id: `step-${nextId.current++}`, text: \"\" };\n    const i = steps.findIndex((s) => s.id === id);\n    pendingFocus.current = step.id;\n    updateSteps([...steps.slice(0, i + 1), step, ...steps.slice(i + 1)]);\n  }\n\n  function removeStep(id: string) {\n    const i = steps.findIndex((s) => s.id === id);\n    if (i <= 0) return;\n    pendingFocus.current = steps[i - 1].id;\n    values.current.delete(id);\n    updateSteps(steps.filter((s) => s.id !== id));\n  }\n\n  return (\n    <ol className={cn(\"w-full space-y-2 text-sm\", className)}>\n      {steps.map((step, i) => (\n        <li key={step.id} className=\"flex gap-3\">\n          <span className=\"w-4 shrink-0 pt-[3px] text-right font-mono text-xs text-muted-foreground tabular-nums\">\n            {i + 1}.\n          </span>\n          <StepEditor\n            initialText={step.text}\n            tools={tools}\n            placeholder={i === 0 ? placeholder : undefined}\n            openOnMount={defaultPickerOpen && step.id === `step-${defaultSteps.length - 1}`}\n            onCreateOption={onCreateOption}\n            registerEditor={(el) => {\n              if (el) editors.current.set(step.id, el);\n              else editors.current.delete(step.id);\n            }}\n            onSegments={(segments) => {\n              values.current.set(step.id, segments);\n              emit();\n            }}\n            onEnter={() => addStepAfter(step.id)}\n            onBackspaceEmpty={() => removeStep(step.id)}\n          />\n        </li>\n      ))}\n    </ol>\n  );\n}\n\ninterface StepEditorProps {\n  initialText: string;\n  tools: InstructionTool[];\n  placeholder?: string;\n  openOnMount?: boolean;\n  onCreateOption?: (toolId: string) => void;\n  registerEditor: (el: HTMLDivElement | null) => void;\n  onSegments: (segments: InstructionSegment[]) => void;\n  onEnter: () => void;\n  onBackspaceEmpty: () => void;\n}\n\nfunction StepEditor({\n  initialText,\n  tools,\n  placeholder,\n  openOnMount,\n  onCreateOption,\n  registerEditor,\n  onSegments,\n  onEnter,\n  onBackspaceEmpty,\n}: StepEditorProps) {\n  const editorRef = React.useRef<HTMLDivElement>(null);\n  const [picker, setPickerState] = React.useState<PickerState | null>(null);\n  // Mirrors `picker` so blur/close handlers fired mid-transition see the latest value.\n  const pickerRef = React.useRef<PickerState | null>(null);\n  const [chips, setChips] = React.useState<ChipState[]>([]);\n  const [isEmpty, setIsEmpty] = React.useState(!initialText);\n  const chipSeq = React.useRef(0);\n\n  function setPicker(next: PickerState | null) {\n    pickerRef.current = next;\n    setPickerState(next);\n  }\n\n  React.useEffect(() => {\n    const el = editorRef.current;\n    if (!el) return;\n    el.textContent = initialText;\n    registerEditor(el);\n    if (openOnMount) {\n      const host = makeHost();\n      el.append(host);\n      // The picker portals into a DOM node that only exists once the editor has mounted.\n      // eslint-disable-next-line react-hooks/set-state-in-effect\n      setPicker({ anchor: host, stage: \"tool\", query: \"\", activeIndex: 0, autoFocus: false });\n    }\n    return () => registerEditor(null);\n    // Mount-only: the editor is uncontrolled after its initial text.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  function sync() {\n    const el = editorRef.current;\n    if (!el) return;\n    // Chips deleted by the browser (backspace, cut) leave their host detached.\n    setChips((prev) => (prev.some((c) => !c.el.isConnected) ? prev.filter((c) => c.el.isConnected) : prev));\n    setIsEmpty(!(el.textContent ?? \"\").trim() && !el.querySelector(\"[data-chip]\"));\n    onSegments(serialize(el, tools));\n  }\n\n  function openPicker(anchor: HTMLSpanElement, stage: PickerState[\"stage\"] = \"tool\", tool?: InstructionTool) {\n    setPicker({ anchor, stage, tool, query: \"\", activeIndex: 0 });\n  }\n\n  /** Swaps the picker's host for `replacement` (or nothing) and returns the caret to the editor. */\n  function closePicker(replacement?: Node, pad = true) {\n    const current = pickerRef.current;\n    const el = editorRef.current;\n    if (!current || !el) return;\n    setPicker(null);\n    el.focus();\n    const anchor = current.anchor;\n    if (!anchor.isConnected) return sync();\n    if (replacement && pad) {\n      // Chips get a trailing space so typing continues outside them.\n      const space = document.createTextNode(\" \");\n      anchor.replaceWith(replacement, space);\n      placeCaret((r) => r.setStart(space, 1));\n    } else if (replacement) {\n      anchor.replaceWith(replacement);\n      placeCaret((r) => r.setStartAfter(replacement));\n    } else {\n      placeCaret((r) => r.setStartBefore(anchor));\n      anchor.remove();\n    }\n    sync();\n  }\n\n  function cancelPicker() {\n    const current = pickerRef.current;\n    if (!current) return;\n    const literal = current.stage === \"tool\" ? `/${current.query}` : \"\";\n    closePicker(literal ? document.createTextNode(literal) : undefined, false);\n  }\n\n  function insertChip(tool: InstructionTool, option?: InstructionToolOption) {\n    const host = makeHost();\n    host.dataset.chip = \"true\";\n    host.dataset.tool = tool.id;\n    if (option) host.dataset.option = option.id;\n    setChips((prev) => [...prev, { id: `chip-${chipSeq.current++}`, el: host, tool, option }]);\n    closePicker(host);\n  }\n\n  function choose(index: number) {\n    const current = pickerRef.current;\n    if (!current) return;\n    if (current.stage === \"tool\") {\n      const tool = filterTools(tools, current.query)[index];\n      if (!tool) return;\n      if (tool.options?.length) {\n        setPicker({ ...current, stage: \"option\", tool, query: \"\", activeIndex: 0 });\n      } else {\n        insertChip(tool);\n      }\n      return;\n    }\n    const tool = current.tool!;\n    const options = filterOptions(tool, current.query);\n    if (index < options.length) {\n      insertChip(tool, options[index]);\n    } else if (tool.createLabel) {\n      closePicker();\n      onCreateOption?.(tool.id);\n    }\n  }\n\n  function editChip(chip: ChipState) {\n    const host = makeHost();\n    chip.el.replaceWith(host);\n    setChips((prev) => prev.filter((c) => c.id !== chip.id));\n    openPicker(host, \"option\", chip.tool);\n  }\n\n  function removeChip(chip: ChipState) {\n    const el = editorRef.current;\n    el?.focus();\n    placeCaret((r) => r.setStartBefore(chip.el));\n    chip.el.remove();\n    sync();\n  }\n\n  function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {\n    const el = editorRef.current;\n    if (!el) return;\n    if (e.key === \"/\" && !e.metaKey && !e.ctrlKey) {\n      const sel = window.getSelection();\n      if (!sel?.rangeCount) return;\n      const caret = sel.getRangeAt(0);\n      const before = document.createRange();\n      before.setStart(el, 0);\n      before.setEnd(caret.startContainer, caret.startOffset);\n      // Only at a word boundary, so \"and/or\" and URLs stay plain text.\n      if (!/(^|\\s)$/.test(before.toString())) return;\n      e.preventDefault();\n      caret.deleteContents();\n      const host = makeHost();\n      caret.insertNode(host);\n      openPicker(host);\n      return;\n    }\n    if (e.key === \"Enter\" && e.shiftKey) {\n      e.preventDefault();\n      document.execCommand(\"insertLineBreak\");\n      return;\n    }\n    if (e.key === \"Enter\") {\n      e.preventDefault();\n      onEnter();\n      return;\n    }\n    if (e.key === \"Backspace\" && isEmpty && !el.textContent) {\n      e.preventDefault();\n      onBackspaceEmpty();\n    }\n  }\n\n  return (\n    <div className=\"relative min-w-0 flex-1\">\n      <div\n        ref={editorRef}\n        contentEditable\n        suppressContentEditableWarning\n        role=\"textbox\"\n        aria-multiline=\"true\"\n        aria-label=\"Instruction step\"\n        onInput={sync}\n        onKeyDown={handleKeyDown}\n        onPaste={(e) => {\n          e.preventDefault();\n          document.execCommand(\"insertText\", false, e.clipboardData.getData(\"text/plain\"));\n        }}\n        className=\"min-h-6 whitespace-pre-wrap break-words leading-7 outline-none\"\n      />\n      {isEmpty && !picker && placeholder && (\n        <span className=\"pointer-events-none absolute left-0 top-0 leading-7 text-muted-foreground\">\n          {placeholder}\n        </span>\n      )}\n\n      {chips.map((chip) =>\n        createPortal(\n          <ToolChip chip={chip} onEdit={() => editChip(chip)} onRemove={() => removeChip(chip)} />,\n          chip.el,\n          chip.id\n        )\n      )}\n\n      {picker &&\n        createPortal(\n          <ToolPicker\n            picker={picker}\n            tools={tools}\n            onQuery={(query) => setPicker({ ...picker, query, activeIndex: 0 })}\n            onActive={(activeIndex) => setPicker({ ...picker, activeIndex })}\n            onChoose={choose}\n            onBack={() => setPicker({ ...picker, stage: \"tool\", tool: undefined, query: \"\", activeIndex: 0 })}\n            onCancel={cancelPicker}\n            onDismiss={() => closePicker()}\n          />,\n          picker.anchor,\n          \"picker\"\n        )}\n    </div>\n  );\n}\n\nfunction filterTools(tools: InstructionTool[], query: string) {\n  const q = query.trim().toLowerCase();\n  return tools.filter((t) => t.label.toLowerCase().includes(q));\n}\n\nfunction filterOptions(tool: InstructionTool, query: string) {\n  const q = query.trim().toLowerCase();\n  return (tool.options ?? []).filter((o) => o.label.toLowerCase().includes(q));\n}\n\nfunction serialize(el: HTMLElement, tools: InstructionTool[]): InstructionSegment[] {\n  const segments: InstructionSegment[] = [];\n  let text = \"\";\n  el.childNodes.forEach((node) => {\n    if (node instanceof HTMLElement && node.dataset.chip === \"true\") {\n      if (text) segments.push({ type: \"text\", text });\n      text = \"\";\n      const tool = tools.find((t) => t.id === node.dataset.tool);\n      if (tool) segments.push({ type: \"tool\", toolId: tool.id, optionId: node.dataset.option });\n    } else if (node instanceof HTMLBRElement) {\n      text += \"\\n\";\n    } else if (node.nodeType === Node.TEXT_NODE) {\n      text += (node.textContent ?? \"\").replace(/ /g, \" \");\n    }\n  });\n  if (text) segments.push({ type: \"text\", text });\n  return segments;\n}\n\ninterface ToolPickerProps {\n  picker: PickerState;\n  tools: InstructionTool[];\n  onQuery: (query: string) => void;\n  onActive: (index: number) => void;\n  onChoose: (index: number) => void;\n  onBack: () => void;\n  onCancel: () => void;\n  onDismiss: () => void;\n}\n\nfunction ToolPicker({ picker, tools, onQuery, onActive, onChoose, onBack, onCancel, onDismiss }: ToolPickerProps) {\n  const { stage, tool, query, activeIndex } = picker;\n  const listId = React.useId();\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const toolItems = stage === \"tool\" ? filterTools(tools, query) : [];\n  const optionItems = stage === \"option\" && tool ? filterOptions(tool, query) : [];\n  const hasCreate = stage === \"option\" && Boolean(tool?.createLabel);\n  const count = stage === \"tool\" ? toolItems.length : optionItems.length + (hasCreate ? 1 : 0);\n  const inputPlaceholder = stage === \"tool\" ? \"Search tools…\" : (tool?.optionPlaceholder ?? \"Search…\");\n\n  // Opened without focus, the input never blurs, so close on an outside click instead.\n  React.useEffect(() => {\n    if (picker.autoFocus !== false) return;\n    function handle(e: MouseEvent) {\n      if (document.activeElement === inputRef.current) return;\n      if (!picker.anchor.contains(e.target as Node)) onCancel();\n    }\n    document.addEventListener(\"mousedown\", handle);\n    return () => document.removeEventListener(\"mousedown\", handle);\n  }, [picker.autoFocus, picker.anchor, onCancel]);\n\n  function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {\n    if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n      e.preventDefault();\n      if (count) onActive((activeIndex + (e.key === \"ArrowDown\" ? 1 : count - 1)) % count);\n    } else if (e.key === \"Enter\" || e.key === \"Tab\") {\n      e.preventDefault();\n      if (count) onChoose(activeIndex);\n    } else if (e.key === \"Escape\") {\n      e.preventDefault();\n      if (stage === \"option\") onBack();\n      else onCancel();\n    } else if (e.key === \"Backspace\" && !query) {\n      e.preventDefault();\n      if (stage === \"option\") onBack();\n      else onDismiss();\n    }\n  }\n\n  return (\n    <>\n      <span className=\"inline-flex items-center rounded-md bg-muted px-1.5 leading-6\">\n        <span className=\"text-foreground\">/</span>\n        {stage === \"option\" && tool && <span className=\"mr-1 font-medium\">{tool.verb}</span>}\n        <input\n          ref={inputRef}\n          autoFocus={picker.autoFocus !== false}\n          value={query}\n          onChange={(e) => onQuery(e.target.value)}\n          onKeyDown={handleKeyDown}\n          onBlur={onCancel}\n          placeholder={inputPlaceholder}\n          role=\"combobox\"\n          aria-expanded\n          aria-controls={listId}\n          aria-activedescendant={count ? `${listId}-${activeIndex}` : undefined}\n          aria-label={inputPlaceholder}\n          style={{ width: `${Math.max(query.length, inputPlaceholder.length) + 1}ch` }}\n          className=\"border-l border-foreground/60 bg-transparent pl-px text-sm outline-none placeholder:text-muted-foreground\"\n        />\n      </span>\n\n      <AnimatePresence mode=\"wait\">\n        <motion.div\n          key={stage}\n          initial={{ opacity: 0, y: -4 }}\n          animate={{ opacity: 1, y: 0 }}\n          exit={{ opacity: 0, y: -4 }}\n          transition={{ duration: 0.12 }}\n          id={listId}\n          role=\"listbox\"\n          // Keep focus in the input while clicking items (and move it there if the picker opened unfocused).\n          onMouseDown={(e) => {\n            e.preventDefault();\n            inputRef.current?.focus();\n          }}\n          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\"\n        >\n          {count === 0 && <p className=\"px-2.5 py-2 text-muted-foreground\">No matches</p>}\n\n          {toolItems.map((t, i) => (\n            <MenuItem\n              key={t.id}\n              id={`${listId}-${i}`}\n              active={i === activeIndex}\n              onHover={() => onActive(i)}\n              onClick={() => onChoose(i)}\n              icon={<t.icon className=\"size-3.5\" />}\n              label={t.label}\n            />\n          ))}\n\n          {optionItems.map((o, i) => {\n            const Icon = o.icon ?? tool!.icon;\n            return (\n              <MenuItem\n                key={o.id}\n                id={`${listId}-${i}`}\n                active={i === activeIndex}\n                onHover={() => onActive(i)}\n                onClick={() => onChoose(i)}\n                icon={<Icon className={cn(\"size-3.5\", o.iconClassName)} />}\n                label={o.label}\n              />\n            );\n          })}\n\n          {hasCreate && (\n            <>\n              <div className=\"mx-1 my-1 border-t border-dashed\" />\n              <MenuItem\n                id={`${listId}-${optionItems.length}`}\n                active={activeIndex === optionItems.length}\n                onHover={() => onActive(optionItems.length)}\n                onClick={() => onChoose(optionItems.length)}\n                icon={<Plus className=\"size-3.5\" />}\n                label={tool!.createLabel!}\n              />\n            </>\n          )}\n        </motion.div>\n      </AnimatePresence>\n    </>\n  );\n}\n\ninterface MenuItemProps {\n  id: string;\n  active: boolean;\n  icon: React.ReactNode;\n  label: string;\n  onHover: () => void;\n  onClick: () => void;\n}\n\nfunction MenuItem({ id, active, icon, label, onHover, onClick }: MenuItemProps) {\n  return (\n    <div\n      id={id}\n      role=\"option\"\n      aria-selected={active}\n      onMouseEnter={onHover}\n      onClick={onClick}\n      className={cn(\n        \"flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5\",\n        active && \"bg-accent text-accent-foreground\"\n      )}\n    >\n      <span className=\"flex size-6 shrink-0 items-center justify-center rounded-md bg-muted\">{icon}</span>\n      <span className=\"truncate\">{label}</span>\n    </div>\n  );\n}\n\ninterface ToolChipProps {\n  chip: ChipState;\n  onEdit: () => void;\n  onRemove: () => void;\n}\n\nfunction ToolChip({ chip, onEdit, onRemove }: ToolChipProps) {\n  const [menuOpen, setMenuOpen] = React.useState(false);\n  const ref = React.useRef<HTMLSpanElement>(null);\n  const { tool, option } = chip;\n\n  React.useEffect(() => {\n    if (!menuOpen) return;\n    function handle(e: MouseEvent) {\n      if (ref.current && !ref.current.contains(e.target as Node)) setMenuOpen(false);\n    }\n    document.addEventListener(\"mousedown\", handle);\n    return () => document.removeEventListener(\"mousedown\", handle);\n  }, [menuOpen]);\n\n  return (\n    <motion.span\n      ref={ref}\n      initial={{ opacity: 0, scale: 0.9 }}\n      animate={{ opacity: 1, scale: 1 }}\n      transition={{ type: \"spring\", stiffness: 500, damping: 30 }}\n      className=\"inline-flex select-none items-center gap-1 rounded-md bg-muted py-0 pl-1.5 pr-0.5 leading-6\"\n    >\n      <tool.icon className=\"size-3.5 shrink-0\" aria-hidden />\n      <span className=\"font-semibold\">{tool.verb}</span>\n      {option && <span>{option.label}</span>}\n      <button\n        type=\"button\"\n        aria-label={`${tool.verb} ${option?.label ?? \"\"} options`.trim()}\n        aria-haspopup=\"menu\"\n        aria-expanded={menuOpen}\n        onMouseDown={(e) => e.preventDefault()}\n        onClick={() => setMenuOpen((v) => !v)}\n        className=\"flex size-5 items-center justify-center rounded text-muted-foreground hover:bg-foreground/10 hover:text-foreground\"\n      >\n        <EllipsisVertical className=\"size-3.5\" />\n      </button>\n\n      <AnimatePresence>\n        {menuOpen && (\n          <motion.span\n            role=\"menu\"\n            initial={{ opacity: 0, y: -4 }}\n            animate={{ opacity: 1, y: 0 }}\n            exit={{ opacity: 0, y: -4 }}\n            transition={{ duration: 0.12 }}\n            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\"\n          >\n            {tool.options?.length ? (\n              <button\n                type=\"button\"\n                role=\"menuitem\"\n                onClick={onEdit}\n                className=\"rounded-md px-2 py-1.5 text-left hover:bg-accent\"\n              >\n                Change\n              </button>\n            ) : null}\n            <button\n              type=\"button\"\n              role=\"menuitem\"\n              onClick={onRemove}\n              className=\"rounded-md px-2 py-1.5 text-left text-destructive hover:bg-destructive/10\"\n            >\n              Remove\n            </button>\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </motion.span>\n  );\n}\n","dependencies":["lucide-react","motion"],"localImports":["@/lib/utils"]}