{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"context-picker","type":"registry:ui","title":"Context Picker","description":"The @-mention menu for adding context to a prompt — files, web pages and knowledge bases with search, recent items, access states and token cost.","author":"Scrim UI (https://scrimui.dev)","categories":["files"],"docs":"https://scrimui.dev/components/context-picker","dependencies":[],"registryDependencies":[],"files":[{"path":"src/showcase/context-picker/context-picker.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\n\n/**\n * The `@`-menu for pulling context into the current turn — files, web pages,\n * knowledge bases and connected apps.\n *\n * The line this component holds: **context is not tools.** An item here is\n * data that joins this conversation's context window (and costs tokens);\n * it never enables an action. Tool availability belongs to the composer.\n *\n * Item status is per-source, not global: `permission-required` items are\n * listed but must be granted before they can be selected, `connecting`\n * sources are on their way, and `unavailable` items stay visible with their\n * reason instead of vanishing (a silently missing file reads as a bug).\n */\n\n/* ------------------------------------------------------------------ */\n/* Types                                                               */\n/* ------------------------------------------------------------------ */\n\nexport type ContextSourceKind = \"file\" | \"web\" | \"knowledge\" | \"app\";\n\nexport type ContextItemStatus = \"available\" | \"permission-required\" | \"connecting\" | \"unavailable\";\n\nexport type ContextItem = {\n  id: string;\n  kind: ContextSourceKind;\n  title: string;\n  /** Path, URL, or source detail shown under the title. */\n  detail?: string;\n  status?: ContextItemStatus;\n  /** Context cost of adding this item, surfaced so selection stays informed. */\n  tokens?: number;\n  /** Recently used — floated into a \"Recent\" section when the search is empty. */\n  recent?: boolean;\n};\n\nexport type ContextPickerProps = {\n  items: ContextItem[];\n  /** Controlled selection. Omit and pass defaultSelectedIds for uncontrolled. */\n  selectedIds?: string[];\n  defaultSelectedIds?: string[];\n  onSelectionChange?: (ids: string[]) => void;\n  /** Fired when the user grants access to a permission-required item. */\n  onRequestAccess?: (item: ContextItem) => void;\n  defaultOpen?: boolean;\n  defaultQuery?: string;\n  triggerLabel?: string;\n  searchPlaceholder?: string;\n  emptyText?: string;\n  className?: string;\n};\n\n/* ------------------------------------------------------------------ */\n/* Icons                                                               */\n/* ------------------------------------------------------------------ */\n\nconst ICON_PROPS = {\n  viewBox: \"0 0 24 24\",\n  fill: \"none\",\n  stroke: \"currentColor\",\n  strokeWidth: 2,\n  strokeLinecap: \"round\",\n  strokeLinejoin: \"round\",\n} as const;\n\nfunction AtSignIcon() {\n  return (\n    <svg {...ICON_PROPS} width=\"14\" height=\"14\">\n      <circle cx=\"12\" cy=\"12\" r=\"4\" />\n      <path d=\"M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8\" />\n    </svg>\n  );\n}\n\nfunction FileIcon() {\n  return (\n    <svg {...ICON_PROPS} width=\"15\" height=\"15\">\n      <path d=\"M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z\" />\n      <path d=\"M14 2v4a2 2 0 0 0 2 2h4\" />\n    </svg>\n  );\n}\n\nfunction GlobeIcon() {\n  return (\n    <svg {...ICON_PROPS} width=\"15\" height=\"15\">\n      <circle cx=\"12\" cy=\"12\" r=\"10\" />\n      <path d=\"M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20\" />\n      <path d=\"M2 12h20\" />\n    </svg>\n  );\n}\n\nfunction KnowledgeIcon() {\n  return (\n    <svg {...ICON_PROPS} width=\"15\" height=\"15\">\n      <path d=\"M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20\" />\n    </svg>\n  );\n}\n\nfunction AppIcon() {\n  return (\n    <svg {...ICON_PROPS} width=\"15\" height=\"15\">\n      <rect width=\"7\" height=\"7\" x=\"3\" y=\"3\" rx=\"1\" />\n      <rect width=\"7\" height=\"7\" x=\"14\" y=\"3\" rx=\"1\" />\n      <rect width=\"7\" height=\"7\" x=\"14\" y=\"14\" rx=\"1\" />\n      <rect width=\"7\" height=\"7\" x=\"3\" y=\"14\" rx=\"1\" />\n    </svg>\n  );\n}\n\nfunction XIcon() {\n  return (\n    <svg {...ICON_PROPS} width=\"11\" height=\"11\">\n      <path d=\"M18 6 6 18\" />\n      <path d=\"m6 6 12 12\" />\n    </svg>\n  );\n}\n\nfunction LockIcon() {\n  return (\n    <svg {...ICON_PROPS} width=\"13\" height=\"13\">\n      <rect width=\"18\" height=\"11\" x=\"3\" y=\"11\" rx=\"2\" ry=\"2\" />\n      <path d=\"M7 11V7a5 5 0 0 1 10 0v4\" />\n    </svg>\n  );\n}\n\nfunction kindIcon(kind: ContextSourceKind) {\n  switch (kind) {\n    case \"file\":\n      return <FileIcon />;\n    case \"web\":\n      return <GlobeIcon />;\n    case \"knowledge\":\n      return <KnowledgeIcon />;\n    case \"app\":\n      return <AppIcon />;\n  }\n}\n\n/* ------------------------------------------------------------------ */\n/* Helpers                                                             */\n/* ------------------------------------------------------------------ */\n\nconst KIND_LABELS: Record<ContextSourceKind, string> = {\n  file: \"Files\",\n  web: \"Web pages\",\n  knowledge: \"Knowledge bases\",\n  app: \"Apps\",\n};\n\nconst KIND_ORDER: ContextSourceKind[] = [\"file\", \"web\", \"knowledge\", \"app\"];\n\nfunction formatTokens(n: number): string {\n  if (n >= 1000) return `≈ ${(n / 1000).toFixed(1).replace(/\\.0$/, \"\")}k tokens`;\n  return `≈ ${n} tokens`;\n}\n\n/* ------------------------------------------------------------------ */\n/* ContextPicker                                                       */\n/* ------------------------------------------------------------------ */\n\nexport function ContextPicker({\n  items,\n  selectedIds: selectedIdsProp,\n  defaultSelectedIds = [],\n  onSelectionChange,\n  onRequestAccess,\n  defaultOpen = false,\n  defaultQuery = \"\",\n  triggerLabel = \"Add context\",\n  searchPlaceholder = \"Search files, pages, sources…\",\n  emptyText = \"No matching context.\",\n  className = \"\",\n}: ContextPickerProps) {\n  const idBase = React.useId();\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n\n  const [open, setOpen] = React.useState(defaultOpen);\n  const [query, setQuery] = React.useState(defaultQuery);\n  const [internalSelected, setInternalSelected] = React.useState<string[]>(defaultSelectedIds);\n  const selectedIds = selectedIdsProp ?? internalSelected;\n\n  const q = query.trim().toLowerCase();\n  const matches = items.filter(\n    (it) => !q || it.title.toLowerCase().includes(q) || (it.detail ?? \"\").toLowerCase().includes(q),\n  );\n\n  /* Items the keyboard can land on: selectable, or permission-required\n     (Enter requests access). Connecting/unavailable rows are inert. */\n  const actionable = matches.filter((it) => {\n    const status = it.status ?? \"available\";\n    return status === \"available\" || status === \"permission-required\";\n  });\n\n  const [activeId, setActiveId] = React.useState<string | undefined>(actionable[0]?.id);\n  const [prevActionableKey, setPrevActionableKey] = React.useState(\"\");\n  const actionableKey = actionable.map((it) => it.id).join(\"\");\n  if (actionableKey !== prevActionableKey) {\n    setPrevActionableKey(actionableKey);\n    if (!activeId || !actionable.some((it) => it.id === activeId)) {\n      setActiveId(actionable[0]?.id);\n    }\n  }\n\n  const selectedItems = selectedIds\n    .map((id) => items.find((it) => it.id === id))\n    .filter((it): it is ContextItem => Boolean(it));\n  const selectedTokens = selectedItems.reduce((sum, it) => sum + (it.tokens ?? 0), 0);\n\n  function setSelection(ids: string[]) {\n    setInternalSelected(ids);\n    onSelectionChange?.(ids);\n  }\n\n  function toggle(item: ContextItem) {\n    if (selectedIds.includes(item.id)) {\n      setSelection(selectedIds.filter((id) => id !== item.id));\n    } else {\n      setSelection([...selectedIds, item.id]);\n    }\n  }\n\n  function activate(item: ContextItem) {\n    const status = item.status ?? \"available\";\n    if (status === \"permission-required\") {\n      onRequestAccess?.(item);\n    } else if (status === \"available\") {\n      toggle(item);\n    }\n  }\n\n  function openPanel() {\n    setOpen(true);\n    window.setTimeout(() => inputRef.current?.focus(), 0);\n  }\n\n  /* Close on outside pointer-down. Listener lives in an effect; the state\n     write happens in the event callback, not the effect body. */\n  React.useEffect(() => {\n    if (!open) return;\n    function onPointerDown(e: PointerEvent) {\n      if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);\n    }\n    document.addEventListener(\"pointerdown\", onPointerDown);\n    return () => document.removeEventListener(\"pointerdown\", onPointerDown);\n  }, [open]);\n\n  function onInputKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {\n    if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n      e.preventDefault();\n      if (actionable.length === 0) return;\n      const i = actionable.findIndex((it) => it.id === activeId);\n      const next =\n        e.key === \"ArrowDown\"\n          ? actionable[(i + 1 + actionable.length) % actionable.length]\n          : actionable[(i - 1 + actionable.length) % actionable.length];\n      setActiveId(next.id);\n    } else if (e.key === \"Enter\") {\n      e.preventDefault();\n      const item = actionable.find((it) => it.id === activeId);\n      if (item) activate(item);\n    } else if (e.key === \"Escape\") {\n      e.preventDefault();\n      setOpen(false);\n    }\n  }\n\n  const recentItems = !q ? matches.filter((it) => it.recent) : [];\n  const recentIds = new Set(recentItems.map((it) => it.id));\n  const grouped = KIND_ORDER.map((kind) => ({\n    kind,\n    items: matches.filter((it) => it.kind === kind && !recentIds.has(it.id)),\n  })).filter((g) => g.items.length > 0);\n\n  function renderOption(item: ContextItem) {\n    const status = item.status ?? \"available\";\n    const selected = selectedIds.includes(item.id);\n    const inert = status === \"connecting\" || status === \"unavailable\";\n    const active = item.id === activeId && !inert;\n    return (\n      <li\n        key={item.id}\n        id={`${idBase}-option-${item.id}`}\n        role=\"option\"\n        aria-selected={selected}\n        aria-disabled={inert || undefined}\n        onMouseEnter={() => !inert && setActiveId(item.id)}\n        onClick={() => activate(item)}\n        className={`flex cursor-pointer items-center gap-2.5 px-3 py-2 ${\n          active ? \"bg-zinc-100 dark:bg-zinc-800\" : \"\"\n        } ${inert ? \"cursor-default opacity-60\" : \"\"}`}\n      >\n        <span className=\"shrink-0 text-zinc-400 dark:text-zinc-500\">{kindIcon(item.kind)}</span>\n        <span className=\"min-w-0 flex-1\">\n          <span className=\"block truncate text-[13px] font-medium text-zinc-800 dark:text-zinc-100\">\n            {item.title}\n          </span>\n          {item.detail && (\n            <span className=\"block truncate text-xs text-zinc-500 dark:text-zinc-400\">{item.detail}</span>\n          )}\n        </span>\n        {status === \"permission-required\" && (\n          <button\n            type=\"button\"\n            onClick={(e) => {\n              e.stopPropagation();\n              onRequestAccess?.(item);\n            }}\n            className=\"inline-flex shrink-0 items-center gap-1 rounded-md border border-zinc-200 px-2 py-0.5 text-[11px] font-medium text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800\"\n          >\n            <LockIcon />\n            Grant\n          </button>\n        )}\n        {status === \"connecting\" && (\n          <span className=\"shrink-0 text-[11px] text-zinc-400 dark:text-zinc-500\">Connecting…</span>\n        )}\n        {status === \"unavailable\" && (\n          <span className=\"shrink-0 text-[11px] text-zinc-400 dark:text-zinc-500\">Unavailable</span>\n        )}\n        {status === \"available\" && item.tokens != null && (\n          <span className=\"shrink-0 text-[11px] tabular-nums text-zinc-400 dark:text-zinc-500\">\n            {formatTokens(item.tokens)}\n          </span>\n        )}\n        {selected && (\n          <span className=\"shrink-0 text-blue-600 dark:text-blue-400\" aria-label=\"Selected\">\n            <svg {...ICON_PROPS} width=\"14\" height=\"14\">\n              <path d=\"M20 6 9 17l-5-5\" />\n            </svg>\n          </span>\n        )}\n      </li>\n    );\n  }\n\n  return (\n    <div ref={rootRef} className={`relative ${className}`}>\n      {/* Selected context — chips so removal is one click, no reopening. */}\n      {selectedItems.length > 0 && (\n        <ul className=\"mb-2 flex flex-wrap items-center gap-1.5\" aria-label=\"Selected context\">\n          {selectedItems.map((item) => (\n            <li\n              key={item.id}\n              className=\"inline-flex items-center gap-1.5 rounded-lg border border-zinc-200 bg-zinc-50 py-1 pl-2 pr-1 text-xs font-medium text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800/70 dark:text-zinc-200\"\n            >\n              <span className=\"text-zinc-400 dark:text-zinc-500\">{kindIcon(item.kind)}</span>\n              <span className=\"max-w-[180px] truncate\">{item.title}</span>\n              <button\n                type=\"button\"\n                onClick={() => toggle(item)}\n                aria-label={`Remove ${item.title} from context`}\n                className=\"rounded p-0.5 text-zinc-400 hover:bg-zinc-200 hover:text-zinc-600 dark:hover:bg-zinc-700 dark:hover:text-zinc-300\"\n              >\n                <XIcon />\n              </button>\n            </li>\n          ))}\n          {selectedTokens > 0 && (\n            <li className=\"pl-1 text-[11px] tabular-nums text-zinc-400 dark:text-zinc-500\">\n              {formatTokens(selectedTokens)}\n            </li>\n          )}\n        </ul>\n      )}\n\n      <button\n        type=\"button\"\n        onClick={() => (open ? setOpen(false) : openPanel())}\n        aria-expanded={open}\n        aria-controls={`${idBase}-listbox`}\n        className=\"inline-flex items-center gap-1.5 rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-[13px] font-medium text-zinc-600 transition-colors hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:bg-zinc-800\"\n      >\n        <AtSignIcon />\n        {triggerLabel}\n      </button>\n\n      {open && (\n        <div className=\"absolute bottom-full left-0 z-20 mb-2 w-[320px] overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-900\">\n          <div className=\"border-b border-zinc-100 px-3 py-2 dark:border-zinc-800\">\n            <input\n              ref={inputRef}\n              role=\"combobox\"\n              aria-expanded=\"true\"\n              aria-controls={`${idBase}-listbox`}\n              aria-activedescendant={activeId ? `${idBase}-option-${activeId}` : undefined}\n              aria-label=\"Search context\"\n              value={query}\n              onChange={(e) => setQuery(e.target.value)}\n              onKeyDown={onInputKeyDown}\n              placeholder={searchPlaceholder}\n              className=\"w-full bg-transparent text-[13px] text-zinc-800 outline-none placeholder:text-zinc-400 dark:text-zinc-100 dark:placeholder:text-zinc-500\"\n            />\n          </div>\n\n          <div className=\"max-h-[280px] overflow-y-auto\">\n            {matches.length === 0 ? (\n              <div className=\"px-3 py-6 text-center text-xs text-zinc-500 dark:text-zinc-400\">\n                <p>{emptyText}</p>\n                {q && (\n                  <button\n                    type=\"button\"\n                    onClick={() => setQuery(\"\")}\n                    className=\"mt-2 rounded-md border border-zinc-200 px-2.5 py-1 text-[11px] font-medium text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800\"\n                  >\n                    Clear search\n                  </button>\n                )}\n              </div>\n            ) : (\n              <ul role=\"listbox\" id={`${idBase}-listbox`} aria-label=\"Available context\" className=\"py-1\">\n                {recentItems.length > 0 && (\n                  <>\n                    <li className=\"px-3 pb-1 pt-2 text-[11px] font-medium uppercase tracking-wide text-zinc-400 dark:text-zinc-500\">\n                      Recent\n                    </li>\n                    {recentItems.map(renderOption)}\n                  </>\n                )}\n                {grouped.map((g) => (\n                  <React.Fragment key={g.kind}>\n                    <li className=\"px-3 pb-1 pt-2 text-[11px] font-medium uppercase tracking-wide text-zinc-400 dark:text-zinc-500\">\n                      {KIND_LABELS[g.kind]}\n                    </li>\n                    {g.items.map(renderOption)}\n                  </React.Fragment>\n                ))}\n              </ul>\n            )}\n          </div>\n\n          {selectedItems.length > 0 && (\n            <div className=\"border-t border-zinc-100 px-3 py-1.5 text-[11px] text-zinc-500 dark:border-zinc-800 dark:text-zinc-400\">\n              {selectedItems.length} in this turn\n              {selectedTokens > 0 && ` · ${formatTokens(selectedTokens)}`}\n            </div>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n","type":"registry:ui","target":"components/ui/context-picker.tsx"}]}