{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "option-list",
  "type": "registry:block",
  "title": "Option List",
  "description": "Single or multi-select choices with confirmation actions.",
  "dependencies": [
    "lucide-react",
    "zod"
  ],
  "registryDependencies": [
    "button",
    "separator"
  ],
  "files": [
    {
      "path": "components/tool-ui/option-list/_adapter.tsx",
      "type": "registry:component",
      "target": "components/tool-ui/option-list/_adapter.tsx",
      "content": "/**\n * Adapter: UI and utility re-exports for copy-standalone portability.\n *\n * When copying this component to another project, update these imports\n * to match your project's paths:\n *\n *   cn        → Your Tailwind merge utility (e.g., \"@/lib/utils\", \"~/lib/cn\")\n *   Button    → shadcn/ui Button\n *   Separator → shadcn/ui Separator\n */\n\nexport { cn } from \"@/lib/utils\";\nexport { Button } from \"@/components/ui/button\";\nexport { Separator } from \"@/components/ui/separator\";\n"
    },
    {
      "path": "components/tool-ui/option-list/index.tsx",
      "type": "registry:component",
      "target": "components/tool-ui/option-list/index.tsx",
      "content": "export { OptionList } from \"./option-list\";\nexport type {\n  OptionListProps,\n  OptionListOption,\n  OptionListSelection,\n  SerializableOptionList,\n} from \"./schema\";\n"
    },
    {
      "path": "components/tool-ui/option-list/option-list.tsx",
      "type": "registry:component",
      "target": "components/tool-ui/option-list/option-list.tsx",
      "content": "\"use client\";\n\nimport {\n  useMemo,\n  useState,\n  useCallback,\n  useEffect,\n  useRef,\n  Fragment,\n} from \"react\";\nimport type { KeyboardEvent } from \"react\";\nimport type {\n  OptionListProps,\n  OptionListSelection,\n  OptionListOption,\n} from \"./schema\";\nimport {\n  normalizeSelectionForOptions,\n  parseSelectionToIdSet,\n} from \"./selection\";\nimport { ActionButtons } from \"../shared/action-buttons\";\nimport { normalizeActionsConfig } from \"../shared/actions-config\";\nimport type { Action } from \"../shared/schema\";\nimport { cn, Button, Separator } from \"./_adapter\";\nimport { Check } from \"lucide-react\";\n\nfunction convertIdSetToSelection(\n  selected: Set<string>,\n  mode: \"multi\" | \"single\",\n): OptionListSelection {\n  if (mode === \"single\") {\n    const [first] = selected;\n    return first ?? null;\n  }\n  return Array.from(selected);\n}\n\nfunction areSetsEqual(a: Set<string>, b: Set<string>) {\n  if (a.size !== b.size) return false;\n  for (const val of a) {\n    if (!b.has(val)) return false;\n  }\n  return true;\n}\n\ninterface SelectionIndicatorProps {\n  mode: \"multi\" | \"single\";\n  isSelected: boolean;\n  disabled?: boolean;\n}\n\nfunction SelectionIndicator({\n  mode,\n  isSelected,\n  disabled,\n}: SelectionIndicatorProps) {\n  const shape = mode === \"single\" ? \"rounded-full\" : \"rounded\";\n\n  return (\n    <div\n      className={cn(\n        \"flex size-4 shrink-0 items-center justify-center border-2 transition-colors\",\n        shape,\n        isSelected && \"border-primary bg-primary text-primary-foreground\",\n        !isSelected && \"border-muted-foreground/50\",\n        disabled && \"opacity-50\",\n      )}\n    >\n      {mode === \"multi\" && isSelected && <Check className=\"size-3\" />}\n      {mode === \"single\" && isSelected && (\n        <span className=\"size-2 rounded-full bg-current\" />\n      )}\n    </div>\n  );\n}\n\ninterface OptionItemProps {\n  option: OptionListOption;\n  isSelected: boolean;\n  isDisabled: boolean;\n  selectionMode: \"multi\" | \"single\";\n  isFirst: boolean;\n  isLast: boolean;\n  onToggle: () => void;\n  tabIndex?: number;\n  onFocus?: () => void;\n  buttonRef?: (el: HTMLButtonElement | null) => void;\n}\n\nfunction OptionItem({\n  option,\n  isSelected,\n  isDisabled,\n  selectionMode,\n  isFirst,\n  isLast,\n  onToggle,\n  tabIndex,\n  onFocus,\n  buttonRef,\n}: OptionItemProps) {\n  const hasAdjacentOptions = !isFirst && !isLast;\n\n  return (\n    <Button\n      ref={buttonRef}\n      data-id={option.id}\n      variant=\"ghost\"\n      size=\"lg\"\n      role=\"option\"\n      aria-selected={isSelected}\n      onClick={onToggle}\n      onFocus={onFocus}\n      tabIndex={tabIndex}\n      disabled={isDisabled}\n      className={cn(\n        \"peer group relative h-auto min-h-[50px] w-full justify-start text-left text-sm font-medium\",\n        \"rounded-none border-0 bg-transparent px-0 py-2 text-base shadow-none transition-none hover:bg-transparent! @md/option-list:text-sm\",\n        isFirst && \"pb-2.5\",\n        hasAdjacentOptions && \"py-2.5\",\n      )}\n    >\n      <span\n        className={cn(\n          \"bg-primary/5 absolute inset-0 -mx-3 -my-0.5 rounded-xl opacity-0 transition-opacity group-hover:opacity-100\",\n        )}\n      />\n      <div className=\"relative flex items-start gap-3\">\n        <span className=\"flex h-6 items-center\">\n          <SelectionIndicator\n            mode={selectionMode}\n            isSelected={isSelected}\n            disabled={option.disabled}\n          />\n        </span>\n        {option.icon && (\n          <span className=\"flex h-6 items-center\">{option.icon}</span>\n        )}\n        <div className=\"flex flex-col text-left\">\n          <span className=\"leading-6 text-pretty\">{option.label}</span>\n          {option.description && (\n            <span className=\"text-muted-foreground text-sm font-normal text-pretty\">\n              {option.description}\n            </span>\n          )}\n        </div>\n      </div>\n    </Button>\n  );\n}\n\ninterface OptionListConfirmationProps {\n  id: string;\n  options: OptionListOption[];\n  selectedIds: Set<string>;\n  className?: string;\n}\n\nfunction OptionListConfirmation({\n  id,\n  options,\n  selectedIds,\n  className,\n}: OptionListConfirmationProps) {\n  const confirmedOptions = options.filter((opt) => selectedIds.has(opt.id));\n\n  return (\n    <div\n      className={cn(\n        \"@container/option-list flex w-full max-w-md min-w-80 flex-col\",\n        \"text-foreground\",\n        \"motion-safe:animate-in motion-safe:fade-in motion-safe:blur-in-sm motion-safe:zoom-in-95 motion-safe:duration-300 motion-safe:ease-[cubic-bezier(0.16,1,0.3,1)] motion-safe:fill-mode-both\",\n        className,\n      )}\n      data-slot=\"option-list\"\n      data-tool-ui-id={id}\n      data-receipt=\"true\"\n      role=\"status\"\n      aria-label=\"Confirmed selection\"\n    >\n      <div\n        className={cn(\n          \"bg-card/60 flex w-full flex-col overflow-hidden rounded-2xl border px-5 py-2.5 shadow-xs\",\n        )}\n      >\n        {confirmedOptions.map((option, index) => (\n          <Fragment key={option.id}>\n            {index > 0 && (\n              <Separator className=\"my-1.5\" orientation=\"horizontal\" />\n            )}\n            <div className=\"flex items-start gap-3 py-1\">\n              <span className=\"flex h-6 items-center\">\n                <Check className=\"text-primary size-4 shrink-0\" />\n              </span>\n              {option.icon && (\n                <span className=\"flex h-6 items-center\">{option.icon}</span>\n              )}\n              <div className=\"flex flex-col text-left\">\n                <span className=\"text-base leading-6 font-medium text-pretty @md/option-list:text-sm\">\n                  {option.label}\n                </span>\n                {option.description && (\n                  <span className=\"text-muted-foreground text-sm font-normal text-pretty\">\n                    {option.description}\n                  </span>\n                )}\n              </div>\n            </div>\n          </Fragment>\n        ))}\n      </div>\n    </div>\n  );\n}\n\nexport function OptionList({\n  id,\n  options,\n  selectionMode = \"multi\",\n  minSelections = 1,\n  maxSelections,\n  value,\n  defaultValue,\n  choice,\n  onChange,\n  actions,\n  onAction,\n  onBeforeAction,\n  className,\n}: OptionListProps) {\n  if (process.env[\"NODE_ENV\"] !== \"production\") {\n    if (value !== undefined && defaultValue !== undefined) {\n      console.warn(\n        \"[OptionList] Both `value` (controlled) and `defaultValue` (uncontrolled) were provided. `defaultValue` is ignored when `value` is set.\",\n      );\n    }\n    if (value !== undefined && !onChange) {\n      console.warn(\n        \"[OptionList] `value` was provided without `onChange`. This makes OptionList controlled; selection will not update unless the parent updates `value`.\",\n      );\n    }\n  }\n\n  const effectiveMaxSelections = selectionMode === \"single\" ? 1 : maxSelections;\n  const optionIds = useMemo(\n    () => new Set(options.map((option) => option.id)),\n    [options],\n  );\n\n  const [uncontrolledSelected, setUncontrolledSelected] = useState<Set<string>>(\n    () =>\n      normalizeSelectionForOptions(\n        parseSelectionToIdSet(\n          defaultValue,\n          selectionMode,\n          effectiveMaxSelections,\n        ),\n        optionIds,\n      ),\n  );\n\n  const selectedIds = useMemo(() => {\n    const parsed =\n      value !== undefined\n        ? parseSelectionToIdSet(value, selectionMode, effectiveMaxSelections)\n        : uncontrolledSelected;\n    return normalizeSelectionForOptions(parsed, optionIds);\n  }, [\n    value,\n    uncontrolledSelected,\n    selectionMode,\n    effectiveMaxSelections,\n    optionIds,\n  ]);\n\n  const selectedCount = selectedIds.size;\n\n  const optionStates = useMemo(() => {\n    return options.map((option) => {\n      const isSelected = selectedIds.has(option.id);\n      const isSelectionLocked =\n        selectionMode === \"multi\" &&\n        effectiveMaxSelections !== undefined &&\n        selectedCount >= effectiveMaxSelections &&\n        !isSelected;\n      const isDisabled = option.disabled || isSelectionLocked;\n\n      return { option, isSelected, isDisabled };\n    });\n  }, [\n    options,\n    selectedIds,\n    selectionMode,\n    effectiveMaxSelections,\n    selectedCount,\n  ]);\n\n  const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);\n  const [activeIndex, setActiveIndex] = useState(() => {\n    const firstSelected = optionStates.findIndex(\n      (s) => s.isSelected && !s.isDisabled,\n    );\n    if (firstSelected >= 0) return firstSelected;\n    const firstEnabled = optionStates.findIndex((s) => !s.isDisabled);\n    return firstEnabled >= 0 ? firstEnabled : 0;\n  });\n\n  useEffect(() => {\n    if (optionStates.length === 0) return;\n    setActiveIndex((prev) => {\n      if (\n        prev < 0 ||\n        prev >= optionStates.length ||\n        optionStates[prev].isDisabled\n      ) {\n        const firstEnabled = optionStates.findIndex((s) => !s.isDisabled);\n        return firstEnabled >= 0 ? firstEnabled : 0;\n      }\n      return prev;\n    });\n  }, [optionStates]);\n\n  const updateSelection = useCallback(\n    (next: Set<string>) => {\n      const normalizedNext = normalizeSelectionForOptions(\n        parseSelectionToIdSet(\n          Array.from(next),\n          selectionMode,\n          effectiveMaxSelections,\n        ),\n        optionIds,\n      );\n\n      if (value === undefined) {\n        if (!areSetsEqual(uncontrolledSelected, normalizedNext)) {\n          setUncontrolledSelected(normalizedNext);\n        }\n      }\n\n      onChange?.(convertIdSetToSelection(normalizedNext, selectionMode));\n    },\n    [\n      effectiveMaxSelections,\n      selectionMode,\n      uncontrolledSelected,\n      value,\n      onChange,\n      optionIds,\n    ],\n  );\n\n  const toggleSelection = useCallback(\n    (optionId: string) => {\n      const next = new Set(selectedIds);\n      const isSelected = next.has(optionId);\n\n      if (selectionMode === \"single\") {\n        if (isSelected) {\n          next.delete(optionId);\n        } else {\n          next.clear();\n          next.add(optionId);\n        }\n      } else {\n        if (isSelected) {\n          next.delete(optionId);\n        } else {\n          if (effectiveMaxSelections && next.size >= effectiveMaxSelections) {\n            return;\n          }\n          next.add(optionId);\n        }\n      }\n\n      updateSelection(next);\n    },\n    [effectiveMaxSelections, selectedIds, selectionMode, updateSelection],\n  );\n\n  const toSelectionState = useCallback(\n    (selected: Set<string>): OptionListSelection =>\n      convertIdSetToSelection(selected, selectionMode),\n    [selectionMode],\n  );\n\n  const handleCancel = useCallback((): OptionListSelection => {\n    const empty = new Set<string>();\n    updateSelection(empty);\n    return toSelectionState(empty);\n  }, [toSelectionState, updateSelection]);\n\n  const customActions = useMemo(\n    () => normalizeActionsConfig(actions),\n    [actions],\n  );\n\n  const handleFooterAction = useCallback(\n    async (actionId: string) => {\n      let nextState = toSelectionState(selectedIds);\n\n      if (actionId === \"cancel\") {\n        nextState = handleCancel();\n      }\n\n      await onAction?.(actionId, nextState);\n    },\n    [handleCancel, onAction, selectedIds, toSelectionState],\n  );\n\n  const normalizedFooterActions = useMemo(() => {\n    if (customActions) return customActions;\n    return {\n      items: [\n        { id: \"cancel\", label: \"Clear\", variant: \"ghost\" as const },\n        { id: \"confirm\", label: \"Confirm\", variant: \"default\" as const },\n      ],\n      align: \"right\" as const,\n    } satisfies ReturnType<typeof normalizeActionsConfig>;\n  }, [customActions]);\n\n  const isConfirmDisabled =\n    selectedCount < minSelections || selectedCount === 0;\n  const hasNothingToClear = selectedCount === 0;\n\n  const focusOptionAt = useCallback((index: number) => {\n    const el = optionRefs.current[index];\n    if (el) el.focus();\n    setActiveIndex(index);\n  }, []);\n\n  const findFirstEnabledIndex = useCallback(() => {\n    const idx = optionStates.findIndex((s) => !s.isDisabled);\n    return idx >= 0 ? idx : 0;\n  }, [optionStates]);\n\n  const findLastEnabledIndex = useCallback(() => {\n    for (let i = optionStates.length - 1; i >= 0; i--) {\n      if (!optionStates[i].isDisabled) return i;\n    }\n    return 0;\n  }, [optionStates]);\n\n  const findNextEnabledIndex = useCallback(\n    (start: number, direction: 1 | -1) => {\n      const len = optionStates.length;\n      if (len === 0) return 0;\n      for (let step = 1; step <= len; step++) {\n        const idx = (start + direction * step + len) % len;\n        if (!optionStates[idx].isDisabled) return idx;\n      }\n      return start;\n    },\n    [optionStates],\n  );\n\n  const handleListboxKeyDown = useCallback(\n    (e: KeyboardEvent<HTMLDivElement>) => {\n      if (optionStates.length === 0) return;\n\n      const key = e.key;\n\n      if (key === \"ArrowDown\") {\n        e.preventDefault();\n        e.stopPropagation();\n        focusOptionAt(findNextEnabledIndex(activeIndex, 1));\n        return;\n      }\n\n      if (key === \"ArrowUp\") {\n        e.preventDefault();\n        e.stopPropagation();\n        focusOptionAt(findNextEnabledIndex(activeIndex, -1));\n        return;\n      }\n\n      if (key === \"Home\") {\n        e.preventDefault();\n        e.stopPropagation();\n        focusOptionAt(findFirstEnabledIndex());\n        return;\n      }\n\n      if (key === \"End\") {\n        e.preventDefault();\n        e.stopPropagation();\n        focusOptionAt(findLastEnabledIndex());\n        return;\n      }\n\n      if (key === \"Enter\" || key === \" \") {\n        e.preventDefault();\n        e.stopPropagation();\n        const current = optionStates[activeIndex];\n        if (!current || current.isDisabled) return;\n        toggleSelection(current.option.id);\n        return;\n      }\n\n      if (key === \"Escape\") {\n        e.preventDefault();\n        e.stopPropagation();\n        if (!hasNothingToClear) {\n          handleCancel();\n        }\n      }\n    },\n    [\n      activeIndex,\n      findFirstEnabledIndex,\n      findLastEnabledIndex,\n      findNextEnabledIndex,\n      focusOptionAt,\n      handleCancel,\n      hasNothingToClear,\n      optionStates,\n      toggleSelection,\n    ],\n  );\n\n  const actionsWithDisabledState = useMemo((): Action[] => {\n    return normalizedFooterActions.items.map((action) => {\n      const isDisabledByValidation =\n        (action.id === \"confirm\" && isConfirmDisabled) ||\n        (action.id === \"cancel\" && hasNothingToClear);\n      return {\n        ...action,\n        disabled: action.disabled || isDisabledByValidation,\n        label:\n          action.id === \"confirm\" &&\n          selectionMode === \"multi\" &&\n          selectedCount > 0\n            ? `${action.label} (${selectedCount})`\n            : action.label,\n      };\n    });\n  }, [\n    normalizedFooterActions.items,\n    isConfirmDisabled,\n    hasNothingToClear,\n    selectionMode,\n    selectedCount,\n  ]);\n\n  const isReceipt = choice !== undefined && choice !== null;\n  const viewKey = isReceipt ? `receipt-${String(choice)}` : \"interactive\";\n\n  return (\n    <div key={viewKey} className=\"contents\">\n      {isReceipt ? (\n        <OptionListConfirmation\n          id={id}\n          options={options}\n          selectedIds={normalizeSelectionForOptions(\n            parseSelectionToIdSet(choice, selectionMode),\n            optionIds,\n          )}\n          className={className}\n        />\n      ) : (\n        <div\n          className={cn(\n            \"@container/option-list flex w-full max-w-md min-w-80 flex-col gap-3\",\n            \"text-foreground\",\n            className,\n          )}\n          data-slot=\"option-list\"\n          data-tool-ui-id={id}\n          role=\"group\"\n          aria-label=\"Option list\"\n        >\n          <div\n            className={cn(\n              \"group/list bg-card flex w-full flex-col overflow-hidden rounded-2xl border px-4 py-1.5 shadow-xs\",\n            )}\n            role=\"listbox\"\n            aria-multiselectable={selectionMode === \"multi\"}\n            onKeyDown={handleListboxKeyDown}\n          >\n            {optionStates.map(({ option, isSelected, isDisabled }, index) => {\n              return (\n                <Fragment key={option.id}>\n                  {index > 0 && (\n                    <Separator\n                      className=\"transition-opacity [@media(hover:hover)]:[&:has(+_:hover)]:opacity-0 [@media(hover:hover)]:[.peer:hover+&]:opacity-0\"\n                      orientation=\"horizontal\"\n                    />\n                  )}\n                  <OptionItem\n                    option={option}\n                    isSelected={isSelected}\n                    isDisabled={isDisabled}\n                    selectionMode={selectionMode}\n                    isFirst={index === 0}\n                    isLast={index === optionStates.length - 1}\n                    tabIndex={index === activeIndex ? 0 : -1}\n                    onFocus={() => setActiveIndex(index)}\n                    buttonRef={(el) => {\n                      optionRefs.current[index] = el;\n                    }}\n                    onToggle={() => toggleSelection(option.id)}\n                  />\n                </Fragment>\n              );\n            })}\n          </div>\n\n          <div className=\"@container/actions\">\n            <ActionButtons\n              actions={actionsWithDisabledState}\n              align={normalizedFooterActions.align}\n              confirmTimeout={normalizedFooterActions.confirmTimeout}\n              onAction={handleFooterAction}\n              onBeforeAction={\n                onBeforeAction\n                  ? (actionId) =>\n                      onBeforeAction(actionId, toSelectionState(selectedIds))\n                  : undefined\n              }\n            />\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n"
    },
    {
      "path": "components/tool-ui/option-list/README.md",
      "type": "registry:file",
      "target": "components/tool-ui/option-list/README.md",
      "content": "# Option List\n\nImplementation for the \"option-list\" Tool UI surface.\n\n## Files\n\n- public exports: components/tool-ui/option-list/index.tsx\n- serializable schema + parse helpers: components/tool-ui/option-list/schema.ts\n\n## Companion assets\n\n- Docs page: app/docs/option-list/content.mdx\n- Preset payload: lib/presets/option-list.ts\n\n## Quick check\n\nRun this after edits:\n\npnpm test\n"
    },
    {
      "path": "components/tool-ui/option-list/schema.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/option-list/schema.ts",
      "content": "import { z } from \"zod\";\nimport type { ReactNode } from \"react\";\nimport type { ActionsProp } from \"../shared/actions-config\";\nimport type { EmbeddedActionsProps } from \"../shared/embedded-actions\";\nimport {\n  ActionSchema,\n  SerializableActionSchema,\n  SerializableActionsConfigSchema,\n  ToolUIIdSchema,\n  ToolUIReceiptSchema,\n  ToolUIRoleSchema,\n} from \"../shared/schema\";\nimport { defineToolUiContract } from \"../shared/contract\";\n\nexport const OptionListOptionSchema = z.object({\n  id: z.string().min(1),\n  label: z.string().min(1),\n  description: z.string().optional(),\n  icon: z.custom<ReactNode>().optional(),\n  disabled: z.boolean().optional(),\n});\n\nexport type OptionListSelection = string[] | string | null;\n\nconst OptionListSelectionSchema = z\n  .union([z.array(z.string()), z.string(), z.null()])\n  .optional();\n\ntype OptionListSchemaInvariantInput = {\n  options: Array<{ id: string }>;\n  minSelections?: number;\n  maxSelections?: number;\n  value?: OptionListSelection;\n  defaultValue?: OptionListSelection;\n  choice?: OptionListSelection;\n};\n\nfunction selectionToIds(selection: OptionListSelection | undefined): string[] {\n  if (selection == null) return [];\n  if (typeof selection === \"string\") return [selection];\n  return Array.isArray(selection) ? selection : [];\n}\n\nfunction validateOptionListInvariants(\n  data: OptionListSchemaInvariantInput,\n  ctx: z.RefinementCtx,\n) {\n  if (\n    data.minSelections !== undefined &&\n    data.maxSelections !== undefined &&\n    data.minSelections > data.maxSelections\n  ) {\n    ctx.addIssue({\n      code: z.ZodIssueCode.custom,\n      path: [\"minSelections\"],\n      message: \"`minSelections` cannot be greater than `maxSelections`.\",\n    });\n  }\n\n  const optionIds = new Set<string>();\n  for (let index = 0; index < data.options.length; index++) {\n    const optionId = data.options[index]?.id;\n    if (!optionId) continue;\n\n    if (optionIds.has(optionId)) {\n      ctx.addIssue({\n        code: z.ZodIssueCode.custom,\n        path: [\"options\", index, \"id\"],\n        message: `Duplicate option id \"${optionId}\" is not allowed.`,\n      });\n    } else {\n      optionIds.add(optionId);\n    }\n  }\n\n  const selectionFields: Array<\n    [\"value\" | \"defaultValue\" | \"choice\", OptionListSelection | undefined]\n  > = [\n    [\"value\", data.value],\n    [\"defaultValue\", data.defaultValue],\n    [\"choice\", data.choice],\n  ];\n\n  for (const [fieldName, selection] of selectionFields) {\n    if (selection == null) continue;\n\n    const ids = selectionToIds(selection);\n    ids.forEach((selectionId, index) => {\n      if (!optionIds.has(selectionId)) {\n        ctx.addIssue({\n          code: z.ZodIssueCode.custom,\n          path:\n            typeof selection === \"string\" ? [fieldName] : [fieldName, index],\n          message: `Selection id \"${selectionId}\" must exist in options.`,\n        });\n      }\n    });\n  }\n}\n\nconst OptionListPropsSchemaBase = z.object({\n  /**\n   * Unique identifier for this tool UI instance in the conversation.\n   *\n   * Used for:\n   * - Assistant referencing (\"the options above\")\n   * - Receipt generation (linking selections to their source)\n   * - Narration context\n   *\n   * Should be stable across re-renders, meaningful, and unique within the conversation.\n   *\n   * @example \"option-list-deploy-target\", \"format-selection\"\n   */\n  id: ToolUIIdSchema,\n  role: ToolUIRoleSchema.optional(),\n  receipt: ToolUIReceiptSchema.optional(),\n  options: z.array(OptionListOptionSchema).min(1),\n  selectionMode: z.enum([\"multi\", \"single\"]).optional(),\n  /**\n   * Controlled selection value (advanced / runtime only).\n   *\n   * For Tool UI tool payloads, prefer `defaultValue` (initial selection) and\n   * `choice` (receipt state). Controlled `value` is intentionally excluded\n   * from `SerializableOptionListSchema` to avoid accidental \"controlled but\n   * non-interactive\" states when an LLM includes `value` in args.\n   */\n  value: OptionListSelectionSchema,\n  defaultValue: OptionListSelectionSchema,\n  /**\n   * When set, renders the component in receipt state showing the user's choice.\n   *\n   * In receipt state:\n   * - Only the chosen option(s) are shown\n   * - Actions are hidden\n   * - The component is read-only\n   *\n   * Use this with assistant-ui's `addResult` to show the outcome of a decision.\n   *\n   * @example\n   * ```tsx\n   * // In a toolkit render function:\n   * if (result) {\n   *   return <OptionList {...args} choice={result} />;\n   * }\n   * ```\n   */\n  choice: OptionListSelectionSchema,\n  actions: z\n    .union([z.array(ActionSchema), SerializableActionsConfigSchema])\n    .optional(),\n  minSelections: z.number().min(0).optional(),\n  maxSelections: z.number().min(1).optional(),\n});\n\nexport const OptionListPropsSchema = OptionListPropsSchemaBase.superRefine(\n  validateOptionListInvariants,\n);\n\nexport type OptionListOption = z.infer<typeof OptionListOptionSchema>;\n\nexport type OptionListProps = Omit<\n  z.infer<typeof OptionListPropsSchema>,\n  \"value\" | \"defaultValue\" | \"choice\" | \"actions\"\n> & {\n  /** @see OptionListPropsSchema.id */\n  id: string;\n  value?: OptionListSelection;\n  defaultValue?: OptionListSelection;\n  /** @see OptionListPropsSchema.choice */\n  choice?: OptionListSelection;\n  onChange?: (value: OptionListSelection) => void;\n  actions?: ActionsProp;\n  onAction?: EmbeddedActionsProps<OptionListSelection>[\"onAction\"];\n  onBeforeAction?: EmbeddedActionsProps<OptionListSelection>[\"onBeforeAction\"];\n  className?: string;\n};\n\nexport const SerializableOptionListSchema = OptionListPropsSchemaBase.omit({\n  // Exclude controlled selection from tool/LLM payloads.\n  value: true,\n})\n  .extend({\n    options: z.array(OptionListOptionSchema.omit({ icon: true })),\n    actions: z\n      .union([\n        z.array(SerializableActionSchema),\n        SerializableActionsConfigSchema,\n      ])\n      .optional(),\n  })\n  .strict()\n  .superRefine(validateOptionListInvariants);\n\nexport type SerializableOptionList = z.infer<\n  typeof SerializableOptionListSchema\n>;\n\nconst SerializableOptionListSchemaContract = defineToolUiContract(\n  \"OptionList\",\n  SerializableOptionListSchema,\n);\n\nexport const parseSerializableOptionList: (\n  input: unknown,\n) => SerializableOptionList = SerializableOptionListSchemaContract.parse;\n\nexport const safeParseSerializableOptionList: (\n  input: unknown,\n) => SerializableOptionList | null =\n  SerializableOptionListSchemaContract.safeParse;\n"
    },
    {
      "path": "components/tool-ui/option-list/selection.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/option-list/selection.ts",
      "content": "import type { OptionListSelection } from \"./schema\";\n\nexport function parseSelectionToIdSet(\n  value: OptionListSelection | undefined,\n  mode: \"multi\" | \"single\",\n  maxSelections?: number,\n): Set<string> {\n  if (mode === \"single\") {\n    const single =\n      typeof value === \"string\"\n        ? value\n        : Array.isArray(value)\n          ? value[0]\n          : null;\n    return single ? new Set([single]) : new Set();\n  }\n\n  const arr =\n    typeof value === \"string\" ? [value] : Array.isArray(value) ? value : [];\n\n  return new Set(maxSelections ? arr.slice(0, maxSelections) : arr);\n}\n\nexport function normalizeSelectionForOptions(\n  selection: Set<string>,\n  optionIds: Set<string>,\n): Set<string> {\n  const normalized = new Set<string>();\n  for (const id of selection) {\n    if (optionIds.has(id)) {\n      normalized.add(id);\n    }\n  }\n  return normalized;\n}\n"
    },
    {
      "path": "components/tool-ui/shared/_adapter.tsx",
      "type": "registry:component",
      "target": "components/tool-ui/shared/_adapter.tsx",
      "content": "/**\n * Adapter: UI and utility re-exports for copy-standalone portability.\n *\n * When copying this component to another project, update these imports\n * to match your project's paths:\n *\n *   cn     → Your Tailwind merge utility (e.g., \"@/lib/utils\", \"~/lib/cn\")\n *   Button → shadcn/ui Button\n */\n\nexport { cn } from \"@/lib/utils\";\nexport { Button } from \"@/components/ui/button\";\n"
    },
    {
      "path": "components/tool-ui/shared/action-buttons.tsx",
      "type": "registry:component",
      "target": "components/tool-ui/shared/action-buttons.tsx",
      "content": "\"use client\";\n\nimport type { Action } from \"./schema\";\nimport { cn, Button } from \"./_adapter\";\nimport { useActionButtons } from \"./use-action-buttons\";\n\nexport interface ActionButtonsProps {\n  actions: Action[];\n  onAction: (actionId: string) => void | Promise<void>;\n  onBeforeAction?: (actionId: string) => boolean | Promise<boolean>;\n  confirmTimeout?: number;\n  align?: \"left\" | \"center\" | \"right\";\n  className?: string;\n}\n\nexport function ActionButtons({\n  actions,\n  onAction,\n  onBeforeAction,\n  confirmTimeout = 3000,\n  align = \"right\",\n  className,\n}: ActionButtonsProps) {\n  const { actions: resolvedActions, runAction } = useActionButtons({\n    actions,\n    onAction,\n    onBeforeAction,\n    confirmTimeout,\n  });\n\n  return (\n    <div\n      className={cn(\n        \"flex flex-col gap-3\",\n        \"@sm/actions:flex-row @sm/actions:flex-wrap @sm/actions:items-center @sm/actions:gap-2\",\n        align === \"left\" && \"@sm/actions:justify-start\",\n        align === \"center\" && \"@sm/actions:justify-center\",\n        align === \"right\" && \"@sm/actions:justify-end\",\n        className,\n      )}\n    >\n      {resolvedActions.map((action) => {\n        const label = action.currentLabel;\n        const variant = action.variant || \"default\";\n\n        return (\n          <Button\n            key={action.id}\n            variant={variant}\n            onClick={() => runAction(action.id)}\n            disabled={action.isDisabled}\n            className={cn(\n              \"rounded-full px-4!\",\n              \"justify-center\",\n              \"min-h-11 w-full text-base\",\n              \"@sm/actions:min-h-0 @sm/actions:w-auto @sm/actions:px-3 @sm/actions:py-2 @sm/actions:text-sm\",\n              action.isConfirming &&\n                \"ring-destructive ring-2 ring-offset-2 motion-safe:animate-pulse\",\n            )}\n            aria-label={\n              action.shortcut ? `${label} (${action.shortcut})` : label\n            }\n          >\n            {action.isLoading && (\n              <svg\n                className=\"mr-2 h-4 w-4 motion-safe:animate-spin\"\n                xmlns=\"http://www.w3.org/2000/svg\"\n                fill=\"none\"\n                viewBox=\"0 0 24 24\"\n              >\n                <circle\n                  className=\"opacity-25\"\n                  cx=\"12\"\n                  cy=\"12\"\n                  r=\"10\"\n                  stroke=\"currentColor\"\n                  strokeWidth=\"4\"\n                />\n                <path\n                  className=\"opacity-75\"\n                  fill=\"currentColor\"\n                  d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"\n                />\n              </svg>\n            )}\n            {action.icon && !action.isLoading && (\n              <span className=\"mr-2\">{action.icon}</span>\n            )}\n            {label}\n            {action.shortcut && !action.isLoading && (\n              <kbd className=\"border-border bg-muted ml-2.5 hidden rounded-lg border px-2 py-0.5 font-mono text-xs font-medium sm:inline-block\">\n                {action.shortcut}\n              </kbd>\n            )}\n          </Button>\n        );\n      })}\n    </div>\n  );\n}\n"
    },
    {
      "path": "components/tool-ui/shared/actions-config.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/actions-config.ts",
      "content": "import type { Action, ActionsConfig } from \"./schema\";\n\nexport type ActionsProp = ActionsConfig | Action[];\n\nconst NEGATORY_ACTION_IDS = new Set([\n  \"cancel\",\n  \"dismiss\",\n  \"skip\",\n  \"no\",\n  \"reset\",\n  \"close\",\n  \"decline\",\n  \"reject\",\n  \"back\",\n  \"later\",\n  \"not-now\",\n  \"maybe-later\",\n]);\n\nfunction inferVariant(action: Action): Action {\n  if (action.variant) return action;\n  if (NEGATORY_ACTION_IDS.has(action.id)) {\n    return { ...action, variant: \"ghost\" };\n  }\n  return action;\n}\n\nexport function normalizeActionsConfig(\n  actions?: ActionsProp,\n): ActionsConfig | null {\n  if (!actions) return null;\n\n  const rawItems = Array.isArray(actions) ? actions : (actions.items ?? []);\n\n  if (rawItems.length === 0) {\n    return null;\n  }\n\n  const items = rawItems.map(inferVariant);\n\n  return Array.isArray(actions)\n    ? { items }\n    : {\n        items,\n        align: actions.align,\n        confirmTimeout: actions.confirmTimeout,\n      };\n}\n"
    },
    {
      "path": "components/tool-ui/shared/contract.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/contract.ts",
      "content": "import { z } from \"zod\";\nimport { parseWithSchema, safeParseWithSchema } from \"./parse\";\n\nexport interface ToolUiContract<T> {\n  schema: z.ZodType<T>;\n  parse: (input: unknown) => T;\n  safeParse: (input: unknown) => T | null;\n}\n\nexport function defineToolUiContract<T>(\n  componentName: string,\n  schema: z.ZodType<T>,\n): ToolUiContract<T> {\n  return {\n    schema,\n    parse: (input: unknown) => parseWithSchema(schema, input, componentName),\n    safeParse: (input: unknown) => safeParseWithSchema(schema, input),\n  };\n}\n"
    },
    {
      "path": "components/tool-ui/shared/embedded-actions.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/embedded-actions.ts",
      "content": "import type { ActionsProp } from \"./actions-config\";\n\nexport type EmbeddedActionHandler<TState> = (\n  actionId: string,\n  state: TState,\n) => void | Promise<void>;\n\nexport type EmbeddedBeforeActionHandler<TState> = (\n  actionId: string,\n  state: TState,\n) => boolean | Promise<boolean>;\n\nexport interface EmbeddedActionsProps<TState> {\n  actions?: ActionsProp;\n  onAction?: EmbeddedActionHandler<TState>;\n  onBeforeAction?: EmbeddedBeforeActionHandler<TState>;\n}\n"
    },
    {
      "path": "components/tool-ui/shared/parse.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/parse.ts",
      "content": "import { z } from \"zod\";\n\nfunction formatZodPath(path: Array<string | number | symbol>): string {\n  if (path.length === 0) return \"root\";\n  return path\n    .map((segment) =>\n      typeof segment === \"number\" ? `[${segment}]` : String(segment),\n    )\n    .join(\".\");\n}\n\n/**\n * Format Zod errors into a compact `path: message` string.\n */\nexport function formatZodError(error: z.ZodError): string {\n  const parts = error.issues.map((issue) => {\n    const path = formatZodPath(issue.path);\n    return `${path}: ${issue.message}`;\n  });\n\n  return Array.from(new Set(parts)).join(\"; \");\n}\n\n/**\n * Parse unknown input and throw a readable error.\n */\nexport function parseWithSchema<T>(\n  schema: z.ZodType<T>,\n  input: unknown,\n  name: string,\n): T {\n  const res = schema.safeParse(input);\n  if (!res.success) {\n    throw new Error(`Invalid ${name} payload: ${formatZodError(res.error)}`);\n  }\n  return res.data;\n}\n\n/**\n * Parse unknown input, returning `null` instead of throwing on failure.\n *\n * Use this in assistant-ui `render` functions where `args` stream in\n * incrementally and may be incomplete until the tool call finishes.\n */\nexport function safeParseWithSchema<T>(\n  schema: z.ZodType<T>,\n  input: unknown,\n): T | null {\n  const res = schema.safeParse(input);\n  return res.success ? res.data : null;\n}\n"
    },
    {
      "path": "components/tool-ui/shared/schema.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/schema.ts",
      "content": "import { z } from \"zod\";\nimport type { ReactNode } from \"react\";\n\n/**\n * Tool UI conventions:\n * - Serializable schemas are JSON-safe (no callbacks/ReactNode/`className`).\n * - Schema: `SerializableXSchema`\n * - Parser: `parseSerializableX(input: unknown)` (throws on invalid)\n * - Safe parser: `safeParseSerializableX(input: unknown)` (returns `null` on invalid)\n * - Actions: `LocalActions` for non-receipt actions and `DecisionActions` for consequential actions\n * - Root attrs: `data-tool-ui-id` + `data-slot`\n */\n\n/**\n * Schema for tool UI identity.\n *\n * Every tool UI should have a unique identifier that:\n * - Is stable across re-renders\n * - Is meaningful (not auto-generated)\n * - Is unique within the conversation\n *\n * Format recommendation: `{component-type}-{semantic-identifier}`\n * Examples: \"data-table-expenses-q3\", \"option-list-deploy-target\"\n */\nexport const ToolUIIdSchema = z.string().min(1);\n\nexport type ToolUIId = z.infer<typeof ToolUIIdSchema>;\n\n/**\n * Primary role of a Tool UI surface in a chat context.\n */\nexport const ToolUIRoleSchema = z.enum([\n  \"information\",\n  \"decision\",\n  \"control\",\n  \"state\",\n  \"composite\",\n]);\n\nexport type ToolUIRole = z.infer<typeof ToolUIRoleSchema>;\n\nexport const ToolUIReceiptOutcomeSchema = z.enum([\n  \"success\",\n  \"partial\",\n  \"failed\",\n  \"cancelled\",\n]);\n\nexport type ToolUIReceiptOutcome = z.infer<typeof ToolUIReceiptOutcomeSchema>;\n\n/**\n * Optional receipt metadata: a durable summary of an outcome.\n */\nexport const ToolUIReceiptSchema = z.object({\n  outcome: ToolUIReceiptOutcomeSchema,\n  summary: z.string().min(1),\n  identifiers: z.record(z.string(), z.string()).optional(),\n  at: z.string().datetime(),\n});\n\nexport type ToolUIReceipt = z.infer<typeof ToolUIReceiptSchema>;\n\n/**\n * Base schema for Tool UI payloads (id + optional role/receipt).\n */\nexport const ToolUISurfaceSchema = z.object({\n  id: ToolUIIdSchema,\n  role: ToolUIRoleSchema.optional(),\n  receipt: ToolUIReceiptSchema.optional(),\n});\n\nexport type ToolUISurface = z.infer<typeof ToolUISurfaceSchema>;\n\nexport const ActionSchema = z.object({\n  id: z.string().min(1),\n  label: z.string().min(1),\n  /**\n   * Canonical narration the assistant can use after this action is taken.\n   *\n   * Example: \"I exported the table as CSV.\" / \"I opened the link in a new tab.\"\n   */\n  sentence: z.string().optional(),\n  confirmLabel: z.string().optional(),\n  variant: z\n    .enum([\"default\", \"destructive\", \"secondary\", \"ghost\", \"outline\"])\n    .optional(),\n  icon: z.custom<ReactNode>().optional(),\n  loading: z.boolean().optional(),\n  disabled: z.boolean().optional(),\n  shortcut: z.string().optional(),\n});\n\nexport type Action = z.infer<typeof ActionSchema>;\nexport type LocalAction = Action;\nexport type DecisionAction = Action;\n\nexport const DecisionResultSchema = z.object({\n  kind: z.literal(\"decision\"),\n  version: z.literal(1),\n  decisionId: z.string().min(1),\n  actionId: z.string().min(1),\n  actionLabel: z.string().min(1),\n  at: z.string().datetime(),\n  payload: z.record(z.string(), z.unknown()).optional(),\n});\n\nexport type DecisionResult<\n  TPayload extends Record<string, unknown> = Record<string, unknown>,\n> = Omit<z.infer<typeof DecisionResultSchema>, \"payload\"> & {\n  payload?: TPayload;\n};\n\nexport function createDecisionResult<\n  TPayload extends Record<string, unknown> = Record<string, unknown>,\n>(args: {\n  decisionId: string;\n  action: { id: string; label: string };\n  payload?: TPayload;\n}): DecisionResult<TPayload> {\n  return {\n    kind: \"decision\",\n    version: 1,\n    decisionId: args.decisionId,\n    actionId: args.action.id,\n    actionLabel: args.action.label,\n    at: new Date().toISOString(),\n    payload: args.payload,\n  };\n}\n\nexport const ActionButtonsPropsSchema = z.object({\n  actions: z.array(ActionSchema).min(1),\n  align: z.enum([\"left\", \"center\", \"right\"]).optional(),\n  confirmTimeout: z.number().positive().optional(),\n  className: z.string().optional(),\n});\n\nexport const SerializableActionSchema = ActionSchema.omit({ icon: true });\nexport const SerializableActionsSchema = ActionButtonsPropsSchema.extend({\n  actions: z.array(SerializableActionSchema),\n}).omit({ className: true });\n\nexport interface ActionsConfig {\n  items: Action[];\n  align?: \"left\" | \"center\" | \"right\";\n  confirmTimeout?: number;\n}\n\nexport const SerializableActionsConfigSchema = z.object({\n  items: z.array(SerializableActionSchema).min(1),\n  align: z.enum([\"left\", \"center\", \"right\"]).optional(),\n  confirmTimeout: z.number().positive().optional(),\n});\n\nexport type SerializableActionsConfig = z.infer<\n  typeof SerializableActionsConfigSchema\n>;\n\nexport type SerializableAction = z.infer<typeof SerializableActionSchema>;\n"
    },
    {
      "path": "components/tool-ui/shared/use-action-buttons.tsx",
      "type": "registry:component",
      "target": "components/tool-ui/shared/use-action-buttons.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport type { Action } from \"./schema\";\n\nexport type UseActionButtonsOptions = {\n  actions: Action[];\n  onAction: (actionId: string) => void | Promise<void>;\n  onBeforeAction?: (actionId: string) => boolean | Promise<boolean>;\n  confirmTimeout?: number;\n};\n\nexport type UseActionButtonsResult = {\n  actions: Array<\n    Action & {\n      currentLabel: string;\n      isConfirming: boolean;\n      isExecuting: boolean;\n      isDisabled: boolean;\n      isLoading: boolean;\n    }\n  >;\n  runAction: (actionId: string) => Promise<void>;\n  confirmingActionId: string | null;\n  executingActionId: string | null;\n};\n\ntype ActionExecutionLock = {\n  tryAcquire: () => boolean;\n  release: () => void;\n};\n\nexport function createActionExecutionLock(): ActionExecutionLock {\n  let locked = false;\n\n  return {\n    tryAcquire: () => {\n      if (locked) return false;\n      locked = true;\n      return true;\n    },\n    release: () => {\n      locked = false;\n    },\n  };\n}\n\nexport function useActionButtons(\n  options: UseActionButtonsOptions,\n): UseActionButtonsResult {\n  const { actions, onAction, onBeforeAction, confirmTimeout = 3000 } = options;\n\n  const [confirmingActionId, setConfirmingActionId] = useState<string | null>(\n    null,\n  );\n  const [executingActionId, setExecutingActionId] = useState<string | null>(\n    null,\n  );\n  const executionLockRef = useRef<ActionExecutionLock>(\n    createActionExecutionLock(),\n  );\n\n  useEffect(() => {\n    if (!confirmingActionId) return;\n    const id = setTimeout(() => setConfirmingActionId(null), confirmTimeout);\n    return () => clearTimeout(id);\n  }, [confirmingActionId, confirmTimeout]);\n\n  useEffect(() => {\n    if (!confirmingActionId) return;\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") {\n        setConfirmingActionId(null);\n      }\n    };\n\n    window.addEventListener(\"keydown\", handleKeyDown);\n    return () => window.removeEventListener(\"keydown\", handleKeyDown);\n  }, [confirmingActionId]);\n\n  const runAction = useCallback(\n    async (actionId: string) => {\n      const action = actions.find((a) => a.id === actionId);\n      if (!action) return;\n\n      const isAnyActionExecuting = executingActionId !== null;\n      if (action.disabled || action.loading || isAnyActionExecuting) {\n        return;\n      }\n\n      if (action.confirmLabel && confirmingActionId !== action.id) {\n        setConfirmingActionId(action.id);\n        return;\n      }\n\n      if (!executionLockRef.current.tryAcquire()) {\n        return;\n      }\n\n      if (onBeforeAction) {\n        const shouldProceed = await onBeforeAction(action.id);\n        if (!shouldProceed) {\n          setConfirmingActionId(null);\n          executionLockRef.current.release();\n          return;\n        }\n      }\n\n      try {\n        setExecutingActionId(action.id);\n        await onAction(action.id);\n      } finally {\n        executionLockRef.current.release();\n        setExecutingActionId(null);\n        setConfirmingActionId(null);\n      }\n    },\n    [actions, confirmingActionId, executingActionId, onAction, onBeforeAction],\n  );\n\n  const resolvedActions = useMemo(\n    () =>\n      actions.map((action) => {\n        const isConfirming = confirmingActionId === action.id;\n        const isThisActionExecuting = executingActionId === action.id;\n        const isLoading = action.loading || isThisActionExecuting;\n        const isDisabled =\n          action.disabled ||\n          (executingActionId !== null && !isThisActionExecuting);\n        const currentLabel =\n          isConfirming && action.confirmLabel\n            ? action.confirmLabel\n            : action.label;\n\n        return {\n          ...action,\n          currentLabel,\n          isConfirming,\n          isExecuting: isThisActionExecuting,\n          isDisabled,\n          isLoading,\n        };\n      }),\n    [actions, confirmingActionId, executingActionId],\n  );\n\n  return {\n    actions: resolvedActions,\n    runAction,\n    confirmingActionId,\n    executingActionId,\n  };\n}\n"
    }
  ]
}
