{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "data-table-filter-command-ai",
  "title": "Data Table AI Filter Command Palette",
  "description": "AI-powered command palette that translates natural language queries into structured table filters. Provider-agnostic — works with any LLM via Vercel AI SDK.",
  "dependencies": [
    "ai",
    "@ai-sdk/react",
    "zod",
    "date-fns",
    "lucide-react",
    "sonner"
  ],
  "registryDependencies": [
    "https://data-table.openstatus.dev/r/data-table.json",
    "https://data-table.openstatus.dev/r/data-table-filter-command.json",
    "https://data-table.openstatus.dev/r/data-table-schema.json",
    "command",
    "kbd",
    "separator"
  ],
  "files": [
    {
      "path": "src/components/data-table/data-table-filter-command-ai/index.tsx",
      "content": "\"use client\";\n\nimport { TextShimmer } from \"@/components/data-table/data-table-filter-command-ai/text-shimmer\";\nimport { useAIFilters } from \"@/components/data-table/data-table-filter-command-ai/use-ai-filters\";\nimport {\n  columnFiltersParserFromSchema,\n  getFieldOptions,\n  getFilterValue,\n  getWordByCaretPosition,\n  replaceInputByFieldType,\n} from \"@/components/data-table/data-table-filter-command/utils\";\nimport { useDataTable } from \"@/components/data-table/data-table-provider\";\nimport type { DataTableFilterField } from \"@/components/data-table/types\";\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandItem,\n  CommandList,\n  CommandSeparator,\n} from \"@/components/ui/command\";\nimport { Kbd } from \"@/components/ui/kbd\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { useHotKey } from \"@/hooks/use-hot-key\";\nimport { useLocalStorage } from \"@/hooks/use-local-storage\";\nimport { isStructuredQuery } from \"@/lib/ai\";\nimport { getCommandHistoryKey } from \"@/lib/constants/local-storage\";\nimport { formatCompactNumber } from \"@/lib/format\";\nimport type { SchemaDefinition } from \"@/lib/store/schema/types\";\nimport type { TableSchemaDefinition } from \"@/lib/table-schema\";\nimport { cn } from \"@/lib/utils\";\nimport { Command as CommandPrimitive } from \"cmdk\";\nimport { formatDistanceToNow } from \"date-fns\";\nimport { LoaderCircle, Search, Sparkles, X } from \"lucide-react\";\nimport React, { useEffect, useMemo, useRef, useState } from \"react\";\nimport { toast } from \"sonner\";\n\ninterface DataTableFilterAICommandProps {\n  /** BYOS schema definition for parsing/serializing filter values */\n  schema: SchemaDefinition;\n  /** Table schema definition for AI context generation */\n  tableSchema: TableSchemaDefinition;\n  /** API endpoint that streams AI filter results */\n  api: string;\n  /** Unique ID for this table (used to namespace localStorage) */\n  tableId?: string;\n}\n\nexport function DataTableFilterAICommand({\n  schema,\n  tableSchema,\n  api,\n  tableId = \"default\",\n}: DataTableFilterAICommandProps) {\n  const {\n    table,\n    isLoading,\n    filterFields: _filterFields,\n    getFacetedUniqueValues,\n  } = useDataTable();\n  const columnFilters = table.getState().columnFilters;\n  const inputRef = useRef<HTMLInputElement>(null);\n  const [open, setOpen] = useState<boolean>(false);\n  const [currentWord, setCurrentWord] = useState<string>(\"\");\n  const isSerializingRef = useRef(false);\n  const filterFields = useMemo(\n    () => _filterFields?.filter((i) => !i.commandDisabled),\n    [_filterFields],\n  );\n  const columnParser = useMemo(\n    () => columnFiltersParserFromSchema({ schema, filterFields }),\n    [schema, filterFields],\n  );\n  const [inputValue, setInputValue] = useState<string>(\n    columnParser.serialize(columnFilters),\n  );\n  const [lastSearches, setLastSearches] = useLocalStorage<\n    {\n      search: string;\n      timestamp: number;\n    }[]\n  >(getCommandHistoryKey(tableId), []);\n\n  // Detect if the current input looks like natural language (for UI hints).\n  // Single-word input that partially matches a field name → structured mode.\n  // Multi-word input without key:value syntax → natural language mode.\n  const isNaturalLanguage = useMemo(() => {\n    const trimmed = inputValue.trim();\n    if (!trimmed) return false;\n    if (isStructuredQuery(trimmed, tableSchema)) return false;\n\n    const words = trimmed.split(/\\s+/);\n\n    // Single word: only treat as natural language if it doesn't match any field\n    if (words.length === 1) {\n      const word = words[0]!.toLowerCase();\n      const matchesField = filterFields.some((f) => {\n        const key = String(f.value).toLowerCase();\n        return key.startsWith(word) || key.includes(word);\n      });\n      if (matchesField) return false;\n    }\n\n    // Multiple words without key:value → natural language\n    return (\n      words.length > 1 ||\n      !filterFields.some((f) => {\n        const key = String(f.value).toLowerCase();\n        return key === words[0]?.toLowerCase();\n      })\n    );\n  }, [inputValue, tableSchema, filterFields]);\n\n  // Track the last AI query for display while loading\n  const [aiQuery, setAIQuery] = useState<string | null>(null);\n\n  // AI filters hook\n  const { infer, isLoading: isAILoading } = useAIFilters({\n    api,\n    tableSchema,\n    onField(key, value) {\n      table.getColumn(key)?.setFilterValue(value);\n    },\n    onFinish(state) {\n      // Final reconciliation: set all validated fields\n      for (const [key, value] of Object.entries(state)) {\n        table.getColumn(key)?.setFilterValue(value);\n      }\n      // Serialize from the validated state directly to avoid stale table state\n      const syntheticFilters = Object.entries(state).map(([id, value]) => ({\n        id,\n        value,\n      }));\n      isSerializingRef.current = true;\n      setInputValue(columnParser.serialize(syntheticFilters));\n    },\n    onComplete() {\n      setAIQuery(null);\n    },\n    onStart() {\n      // Clear existing filters before AI applies new ones (replace-all strategy)\n      for (const field of filterFields) {\n        if (typeof field.value === \"string\") {\n          table.getColumn(field.value)?.setFilterValue(undefined);\n        }\n      }\n    },\n    onError(error) {\n      setAIQuery(null);\n      toast.error(\"Failed to infer filters\", {\n        description: error.message,\n      });\n    },\n  });\n\n  useEffect(() => {\n    if (isSerializingRef.current) {\n      isSerializingRef.current = false;\n      return;\n    }\n    if (currentWord !== \"\" && open) return;\n    if (currentWord !== \"\" && !open) setCurrentWord(\"\");\n    if (inputValue.trim() === \"\" && !open) return;\n\n    const searchParams = columnParser.parse(inputValue);\n\n    const currentFilters = table.getState().columnFilters;\n    const currentEnabledFilters = currentFilters.filter((filter) => {\n      const field = _filterFields?.find((field) => field.value === filter.id);\n      return !field?.commandDisabled;\n    });\n    for (const key of Object.keys(searchParams)) {\n      const value = searchParams[key as keyof typeof searchParams];\n      table.getColumn(key)?.setFilterValue(value);\n    }\n    const currentFiltersToReset = currentEnabledFilters.filter((filter) => {\n      return !(filter.id in searchParams);\n    });\n    for (const filter of currentFiltersToReset) {\n      table.getColumn(filter.id)?.setFilterValue(undefined);\n    }\n\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [inputValue, open, currentWord]);\n\n  useEffect(() => {\n    if (!open) {\n      isSerializingRef.current = true;\n      setInputValue(columnParser.serialize(columnFilters));\n    }\n  }, [columnFilters, filterFields, open]);\n\n  useHotKey(() => setOpen((open) => !open), \"k\");\n\n  useEffect(() => {\n    if (open) {\n      inputRef?.current?.focus();\n    }\n  }, [open]);\n\n  function handleClose() {\n    setOpen(false);\n    const search = inputValue.trim();\n    if (!search) return;\n    const timestamp = Date.now();\n    const searchIndex = lastSearches.findIndex(\n      (item) => item.search === search,\n    );\n    if (searchIndex !== -1) {\n      setLastSearches(\n        lastSearches.map((item, i) =>\n          i === searchIndex ? { ...item, timestamp } : item,\n        ),\n      );\n      return;\n    }\n    setLastSearches([...lastSearches, { search, timestamp }]);\n  }\n\n  function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {\n    if (e.key === \"Escape\") {\n      inputRef?.current?.blur();\n      return;\n    }\n    if (e.key === \"Enter\" && isNaturalLanguage) {\n      // Let cmdk handle Enter if an item is selected (e.g., a suggestion)\n      const selected = document.querySelector(\"[cmdk-item][data-selected]\");\n      if (selected) return;\n\n      e.preventDefault();\n      const query = inputValue.trim();\n      const handled = infer(inputValue);\n      if (handled) {\n        setAIQuery(query);\n        handleClose();\n      }\n    }\n  }\n\n  return (\n    <div>\n      <button\n        type=\"button\"\n        className={cn(\n          \"group border-input bg-background text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground focus-within:border-ring focus-within:ring-ring/50 flex w-full items-center rounded-lg border px-3 transition-all outline-none focus-within:ring-[3px]\",\n          open ? \"hidden\" : \"visible\",\n        )}\n        onClick={() => setOpen(true)}\n      >\n        {isLoading || isAILoading ? (\n          <LoaderCircle className=\"text-muted-foreground group-hover:text-popover-foreground mr-2 h-4 w-4 shrink-0 animate-spin opacity-50\" />\n        ) : (\n          <Search className=\"text-muted-foreground group-hover:text-popover-foreground mr-2 h-4 w-4 shrink-0 opacity-50\" />\n        )}\n        <span className=\"h-11 w-full max-w-sm truncate py-3 text-left text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50 md:max-w-xl lg:max-w-4xl xl:max-w-5xl\">\n          {aiQuery ? (\n            <TextShimmer duration={2}>{aiQuery}</TextShimmer>\n          ) : inputValue.trim() ? (\n            <span className=\"text-foreground\">{inputValue}</span>\n          ) : (\n            <span>Search data table...</span>\n          )}\n        </span>\n        <Kbd className=\"text-muted-foreground group-hover:text-accent-foreground ml-auto\">\n          <span className=\"mr-1\">⌘</span>\n          <span>K</span>\n        </Kbd>\n      </button>\n      <Command\n        className={cn(\n          \"border-border dark:bg-muted/50 overflow-visible rounded-lg border shadow-md [&>div]:border-none\",\n          open ? \"visible\" : \"hidden\",\n        )}\n        filter={(value, search, keywords) =>\n          getFilterValue({ value, search, keywords, currentWord })\n        }\n      >\n        <div\n          data-slot=\"command-input-wrapper\"\n          className=\"flex items-center gap-2 border-b px-3\"\n        >\n          <Search className=\"size-4 shrink-0 opacity-50\" />\n          <CommandPrimitive.Input\n            ref={inputRef}\n            value={inputValue}\n            onValueChange={setInputValue}\n            onKeyDown={handleKeyDown}\n            onBlur={handleClose}\n            onInput={(e: React.FormEvent<HTMLInputElement>) => {\n              const caretPosition = e.currentTarget?.selectionStart || -1;\n              const value = e.currentTarget?.value || \"\";\n              const word = getWordByCaretPosition({ value, caretPosition });\n              setCurrentWord(word);\n            }}\n            placeholder=\"Search data table...\"\n            className=\"text-foreground placeholder:text-muted-foreground flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50\"\n          />\n        </div>\n        <div className=\"relative\">\n          <div className=\"border-border bg-popover text-popover-foreground animate-in absolute top-2 z-10 w-full overflow-hidden rounded-lg border shadow-md outline-hidden\">\n            <CommandList className=\"max-h-[310px]\">\n              {!isNaturalLanguage && (\n                <>\n                  <CommandGroup heading=\"Filter\">\n                    {filterFields.map((field) => {\n                      if (typeof field.value !== \"string\") return null;\n                      if (inputValue.includes(`${field.value}:`)) return null;\n                      return (\n                        <CommandItem\n                          key={field.value}\n                          value={field.value}\n                          onMouseDown={(e) => {\n                            e.preventDefault();\n                            e.stopPropagation();\n                          }}\n                          onSelect={(value) => {\n                            setInputValue((prev) => {\n                              if (currentWord.trim() === \"\") {\n                                const input = `${prev}${value}`;\n                                return `${input}:`;\n                              }\n                              const isStarting = currentWord === prev;\n                              const prefix = isStarting ? \"\" : \" \";\n                              const input = prev.replace(\n                                `${prefix}${currentWord}`,\n                                `${prefix}${value}`,\n                              );\n                              return `${input}:`;\n                            });\n                            setCurrentWord(`${value}:`);\n                          }}\n                          className=\"group\"\n                        >\n                          {field.value}\n                          <CommandItemSuggestions field={field} />\n                        </CommandItem>\n                      );\n                    })}\n                  </CommandGroup>\n                  <CommandSeparator />\n                  <CommandGroup heading=\"Query\">\n                    {filterFields?.map((field) => {\n                      if (typeof field.value !== \"string\") return null;\n                      if (!currentWord.includes(`${field.value}:`)) return null;\n\n                      const column = table.getColumn(field.value);\n                      const facetedValue =\n                        getFacetedUniqueValues?.(table, field.value) ||\n                        column?.getFacetedUniqueValues();\n\n                      const options = getFieldOptions({ field, facetedValue });\n\n                      return options.map((optionValue) => {\n                        return (\n                          <CommandItem\n                            key={`${String(field.value)}:${optionValue}`}\n                            value={`${String(field.value)}:${optionValue}`}\n                            onMouseDown={(e) => {\n                              e.preventDefault();\n                              e.stopPropagation();\n                            }}\n                            onSelect={(value) => {\n                              setInputValue((prev) =>\n                                replaceInputByFieldType({\n                                  prev,\n                                  currentWord,\n                                  optionValue,\n                                  value,\n                                  field,\n                                }),\n                              );\n                              setCurrentWord(\"\");\n                            }}\n                          >\n                            {`${optionValue}`}\n                            {facetedValue?.has(optionValue) ? (\n                              <span className=\"text-muted-foreground ml-auto font-mono\">\n                                {formatCompactNumber(\n                                  facetedValue.get(optionValue) || 0,\n                                )}\n                              </span>\n                            ) : null}\n                          </CommandItem>\n                        );\n                      });\n                    })}\n                  </CommandGroup>\n                  <CommandEmpty>No results found.</CommandEmpty>\n                </>\n              )}\n              {isNaturalLanguage && inputValue.trim() && (\n                <CommandGroup heading=\"Infer\">\n                  <CommandItem\n                    value={`ai:${inputValue.trim()}`}\n                    onMouseDown={(e) => {\n                      e.preventDefault();\n                      e.stopPropagation();\n                    }}\n                    onSelect={() => {\n                      const query = inputValue.trim();\n                      const handled = infer(inputValue);\n                      if (handled) {\n                        setAIQuery(query);\n                        handleClose();\n                      }\n                    }}\n                  >\n                    <Sparkles className=\"size-4 shrink-0\" />\n                    {inputValue.trim()}\n                    <span className=\"text-muted-foreground ml-auto text-xs\">\n                      describe your query to infer filters\n                    </span>\n                  </CommandItem>\n                </CommandGroup>\n              )}\n              {lastSearches.length > 0 && (\n                <>\n                  <CommandSeparator />\n                  <CommandGroup heading=\"Suggestions\">\n                    {lastSearches\n                      .sort((a, b) => b.timestamp - a.timestamp)\n                      .slice(0, 5)\n                      .map((item) => {\n                        return (\n                          <CommandItem\n                            key={`suggestion:${item.search}`}\n                            value={`suggestion:${item.search}`}\n                            onMouseDown={(e) => {\n                              e.preventDefault();\n                              e.stopPropagation();\n                            }}\n                            onSelect={(value) => {\n                              const search = value.replace(\"suggestion:\", \"\");\n                              setInputValue(`${search} `);\n                              setCurrentWord(\"\");\n                            }}\n                            className=\"group\"\n                          >\n                            {item.search}\n                            <span className=\"text-muted-foreground/80 ml-auto truncate group-aria-selected:block\">\n                              {formatDistanceToNow(item.timestamp, {\n                                addSuffix: true,\n                              })}\n                            </span>\n                            <button\n                              type=\"button\"\n                              onMouseDown={(e) => {\n                                e.preventDefault();\n                                e.stopPropagation();\n                              }}\n                              onClick={(e) => {\n                                e.preventDefault();\n                                e.stopPropagation();\n                                setLastSearches(\n                                  lastSearches.filter(\n                                    (i) => i.search !== item.search,\n                                  ),\n                                );\n                              }}\n                              className=\"hover:bg-background ml-1 hidden rounded-md p-0.5 group-aria-selected:block\"\n                            >\n                              <X className=\"h-4 w-4\" />\n                            </button>\n                          </CommandItem>\n                        );\n                      })}\n                  </CommandGroup>\n                </>\n              )}\n            </CommandList>\n            <div\n              className=\"bg-accent/50 text-accent-foreground flex flex-wrap justify-between gap-2 border-t px-2 py-1.5 text-sm\"\n              cmdk-footer=\"\"\n            >\n              <div className=\"flex flex-wrap gap-2\">\n                <span>\n                  Use <Kbd>↑</Kbd> <Kbd>↓</Kbd> to navigate\n                </span>\n                <span>\n                  <Kbd>Enter</Kbd> to query\n                </span>\n                <span>\n                  <Kbd>Esc</Kbd> to close\n                </span>\n                <Separator\n                  orientation=\"vertical\"\n                  className=\"my-auto data-[orientation=vertical]:h-3\"\n                />\n                <span>\n                  Union: <Kbd>regions:a,b</Kbd>\n                </span>\n                <span>\n                  Range: <Kbd>p95:59-340</Kbd>\n                </span>\n                <span>\n                  Spaces: <Kbd>name:&quot;a b&quot;</Kbd>\n                </span>\n                <Separator\n                  orientation=\"vertical\"\n                  className=\"my-auto data-[orientation=vertical]:h-3\"\n                />\n                <span>\n                  AI:{\" \"}\n                  <Kbd>\n                    <Sparkles className=\"size-2.5 shrink-0\" />\n                  </Kbd>\n                </span>\n              </div>\n              {lastSearches.length ? (\n                <button\n                  type=\"button\"\n                  className=\"text-muted-foreground hover:text-accent-foreground\"\n                  onMouseDown={(e) => {\n                    e.preventDefault();\n                    e.stopPropagation();\n                  }}\n                  onClick={() => setLastSearches([])}\n                >\n                  Clear suggestions\n                </button>\n              ) : null}\n            </div>\n          </div>\n        </div>\n      </Command>\n    </div>\n  );\n}\n\nfunction CommandItemSuggestions<TData>({\n  field,\n}: {\n  field: DataTableFilterField<TData>;\n}) {\n  const { table, getFacetedMinMaxValues, getFacetedUniqueValues } =\n    useDataTable();\n  const value = field.value as string;\n  switch (field.type) {\n    case \"checkbox\": {\n      return (\n        <span className=\"text-muted-foreground/80 ml-1 hidden truncate group-aria-selected:block\">\n          {getFacetedUniqueValues\n            ? Array.from(getFacetedUniqueValues(table, value)?.keys() || [])\n                .map((value) => `[${value}]`)\n                .join(\" \")\n            : field.options?.map(({ value }) => `[${value}]`).join(\" \")}\n        </span>\n      );\n    }\n    case \"slider\": {\n      const [min, max] = getFacetedMinMaxValues?.(table, value) || [\n        field.min,\n        field.max,\n      ];\n      return (\n        <span className=\"text-muted-foreground/80 ml-1 hidden truncate group-aria-selected:block\">\n          [{min} - {max}]\n        </span>\n      );\n    }\n    case \"input\": {\n      return (\n        <span className=\"text-muted-foreground/80 ml-1 hidden truncate group-aria-selected:block\">\n          [{`${String(field.value)}`} input]\n        </span>\n      );\n    }\n    default: {\n      return null;\n    }\n  }\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/data-table/data-table-filter-command-ai/text-shimmer.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport type TextShimmerProps = {\n  as?: React.ElementType;\n  duration?: number;\n  spread?: number;\n  children: React.ReactNode;\n} & React.HTMLAttributes<HTMLElement>;\n\nexport function TextShimmer({\n  as = \"span\",\n  className,\n  duration = 4,\n  spread = 20,\n  children,\n  ...props\n}: TextShimmerProps) {\n  const dynamicSpread = Math.min(Math.max(spread, 5), 45);\n  const Component = as;\n\n  return (\n    <Component\n      className={cn(\n        \"bg-size-[200%_auto] bg-clip-text font-medium text-transparent\",\n        \"animate-[shimmer_4s_infinite_linear]\",\n        className,\n      )}\n      style={{\n        backgroundImage: `linear-gradient(to right, var(--muted-foreground) ${50 - dynamicSpread}%, var(--foreground) 50%, var(--muted-foreground) ${50 + dynamicSpread}%)`,\n        animationDuration: `${duration}s`,\n      }}\n      {...props}\n    >\n      {children}\n    </Component>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/data-table/data-table-filter-command-ai/use-ai-filters.ts",
      "content": "import { experimental_useObject as useObject } from \"@ai-sdk/react\";\nimport {\n  diffPartialState,\n  generateAIOutputSchema,\n  isStructuredQuery,\n  parseAIResponse,\n} from \"@/lib/ai\";\nimport type { TableSchemaDefinition } from \"@/lib/table-schema\";\nimport { useCallback, useEffect, useMemo, useRef } from \"react\";\n\nexport type UseAIFiltersOptions = {\n  /** The API endpoint that streams AI filter results */\n  api: string;\n  /** Table schema definition for generating the output schema and detecting structured queries */\n  tableSchema: TableSchemaDefinition;\n  /** Called for each progressively completed field */\n  onField: (key: string, value: unknown) => void;\n  /** Called with the final validated state on stream end */\n  onFinish: (state: Record<string, unknown>) => void;\n  /** Called when the AI call fails */\n  onError?: (error: Error) => void;\n  /** Called before AI filters are applied — use to reset existing filters */\n  onStart?: () => void;\n  /** Called when the stream ends, regardless of validation success — use for cleanup */\n  onComplete?: () => void;\n};\n\nexport function useAIFilters({\n  api,\n  tableSchema,\n  onField,\n  onFinish,\n  onError,\n  onStart,\n  onComplete,\n}: UseAIFiltersOptions) {\n  const prevRef = useRef<Record<string, unknown>>({});\n  const outputSchema = useMemo(\n    () => generateAIOutputSchema(tableSchema),\n    [tableSchema],\n  );\n\n  const { submit, object, isLoading, error } = useObject({\n    api,\n    schema: outputSchema,\n    onFinish({ object }) {\n      prevRef.current = {};\n      if (object) {\n        const validated = parseAIResponse(\n          tableSchema,\n          object as Record<string, unknown>,\n        );\n        if (validated) {\n          onFinish(validated);\n        }\n      }\n      onComplete?.();\n    },\n    onError(error) {\n      onComplete?.();\n      onError?.(error);\n    },\n  });\n\n  // Progressive application via diffPartialState\n  useEffect(() => {\n    if (!object) return;\n    const next = object as Record<string, unknown>;\n    const completed = diffPartialState(prevRef.current, next, tableSchema);\n    for (const { key, value } of completed) {\n      onField(key, value);\n    }\n    prevRef.current = { ...next };\n  }, [object]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  const infer = useCallback(\n    (query: string): boolean => {\n      if (isStructuredQuery(query, tableSchema)) return false;\n      const trimmed = query.trim();\n      if (!trimmed) return false;\n\n      onStart?.();\n      prevRef.current = {};\n      submit({ query: trimmed });\n      return true;\n    },\n    [submit, tableSchema, onStart],\n  );\n\n  return { infer, isLoading, error };\n}\n",
      "type": "registry:hook"
    },
    {
      "path": "src/lib/ai/index.ts",
      "content": "export {\n  generateAIContext,\n  type AIContext,\n  type AIFieldContext,\n} from \"./context\";\nexport { generateAIPrompt, type GenerateAIPromptOptions } from \"./prompt\";\nexport { generateAIOutputSchema } from \"./output-schema\";\nexport { diffPartialState, type CompletedField } from \"./diff-partial\";\nexport { parseAIResponse } from \"./parse-response\";\nexport { isStructuredQuery } from \"./detect\";\nexport {\n  createAIFilterHandler,\n  type AIFilterHandlerOptions,\n} from \"./create-ai-filter-handler\";\n",
      "type": "registry:lib"
    },
    {
      "path": "src/lib/ai/context.ts",
      "content": "import type { TableSchemaDefinition } from \"@/lib/table-schema\";\n\nexport type AIFieldContext = {\n  key: string;\n  label: string;\n  description?: string;\n  dataType: string;\n  filterType: string;\n  allowedValues?: string[];\n  min?: number;\n  max?: number;\n  unit?: string;\n};\n\nexport type AIContext = {\n  fields: AIFieldContext[];\n};\n\n/**\n * Extracts structured context from a table schema definition for use in LLM prompts.\n *\n * Returns metadata about each filterable column: field name, label, data type,\n * filter type, allowed values, bounds, and description. Non-filterable columns\n * are excluded. Command-disabled columns are intentionally included so the AI\n * can set filters (e.g. date ranges) that aren't available in the command palette.\n */\nexport function generateAIContext(schema: TableSchemaDefinition): AIContext {\n  const fields: AIFieldContext[] = [];\n\n  for (const [key, builder] of Object.entries(schema)) {\n    const config = builder._config;\n    if (!config.filter) continue;\n\n    const field: AIFieldContext = {\n      key,\n      label: config.label || key,\n      dataType: config.kind,\n      filterType: config.filter.type,\n    };\n\n    if (config.description) {\n      field.description = config.description;\n    }\n\n    // Allowed values for checkbox filters (enums, booleans, arrays)\n    if (config.filter.type === \"checkbox\" && config.filter.options) {\n      field.allowedValues = config.filter.options.map((o) => String(o.value));\n    } else if (config.kind === \"enum\" && config.enumValues) {\n      field.allowedValues = [...config.enumValues];\n    } else if (\n      config.kind === \"array\" &&\n      config.arrayItem?.kind === \"enum\" &&\n      config.arrayItem.enumValues\n    ) {\n      field.allowedValues = [...config.arrayItem.enumValues];\n    }\n\n    // Bounds for slider filters\n    if (config.filter.type === \"slider\") {\n      if (config.filter.min !== undefined) field.min = config.filter.min;\n      if (config.filter.max !== undefined) field.max = config.filter.max;\n    }\n\n    // Unit from filter or display config\n    if (config.filter.unit) {\n      field.unit = config.filter.unit;\n    } else if (\n      config.display.type === \"number\" &&\n      \"unit\" in config.display &&\n      config.display.unit\n    ) {\n      field.unit = config.display.unit;\n    }\n\n    fields.push(field);\n  }\n\n  return { fields };\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "src/lib/ai/prompt.ts",
      "content": "import type { TableSchemaDefinition } from \"@/lib/table-schema\";\nimport {\n  generateAIContext,\n  type AIContext,\n  type AIFieldContext,\n} from \"./context\";\n\nexport type GenerateAIPromptOptions = {\n  /** Current date/time for resolving relative time expressions like \"last hour\". */\n  now?: Date;\n};\n\nfunction formatField(field: AIFieldContext): string {\n  const parts: string[] = [];\n  parts.push(`- **${field.key}** (label: \"${field.label}\")`);\n  parts.push(`  Type: ${field.dataType}, Filter: ${field.filterType}`);\n\n  if (field.description) {\n    parts.push(`  Description: ${field.description}`);\n  }\n  if (field.allowedValues && field.allowedValues.length > 0) {\n    parts.push(\n      `  Allowed values: ${field.allowedValues.map((v) => `\"${v}\"`).join(\", \")}`,\n    );\n  }\n  if (field.min !== undefined || field.max !== undefined) {\n    const bounds = [];\n    if (field.min !== undefined) bounds.push(`min: ${field.min}`);\n    if (field.max !== undefined) bounds.push(`max: ${field.max}`);\n    parts.push(`  Range: ${bounds.join(\", \")}`);\n  }\n  if (field.unit) {\n    parts.push(`  Unit: ${field.unit}`);\n  }\n\n  return parts.join(\"\\n\");\n}\n\nfunction formatPrompt(\n  context: AIContext,\n  options?: GenerateAIPromptOptions,\n): string {\n  const lines: string[] = [];\n\n  lines.push(\n    \"You are a filter assistant for a data table. Given a natural language query, \" +\n      \"return a JSON object with the appropriate filter values. Only include fields \" +\n      \"that the user's query references. If the query doesn't match any field, return \" +\n      \"an empty object {}.\",\n  );\n\n  lines.push(\"\");\n  lines.push(\"## Available filter fields\");\n  lines.push(\"\");\n\n  for (const field of context.fields) {\n    lines.push(formatField(field));\n    lines.push(\"\");\n  }\n\n  lines.push(\"## Output rules\");\n  lines.push(\"\");\n  lines.push(\"- For **input** filters (text search): return a string value.\");\n  lines.push(\n    \"- For **checkbox** filters (multi-select): return an array of allowed values. Only use values from the allowed values list.\",\n  );\n  lines.push(\n    \"- For **slider** filters (numeric range): return a [min, max] tuple within the field's bounds.\",\n  );\n  lines.push(\n    \"- For **timerange** filters (date range): return a [start, end] tuple of ISO 8601 datetime strings.\",\n  );\n  lines.push(\n    \"- Only include fields relevant to the query. Omit fields the user didn't mention or imply.\",\n  );\n  lines.push(\"- Return valid JSON only. No explanations.\");\n  lines.push(\n    '- The user message includes the current date/time. Use it to resolve relative time expressions like \"last hour\", \"yesterday\", \"past 7 days\".',\n  );\n\n  if (options?.now) {\n    lines.push(\"\");\n    lines.push(`## Current date/time`);\n    lines.push(\"\");\n    lines.push(`Now: ${options.now.toISOString()}`);\n    lines.push(\n      'Use this to resolve relative time expressions like \"last hour\", \"yesterday\", \"past 7 days\".',\n    );\n  }\n\n  return lines.join(\"\\n\");\n}\n\n/**\n * Generates a ready-to-use system prompt string from a table schema.\n *\n * The prompt describes all filterable columns and the expected output format\n * for an LLM to translate natural language queries into structured filter state.\n *\n * @param schema - Table schema definition (from `createTableSchema().definition`)\n * @param options - Optional config. Pass `{ now: new Date() }` to enable relative time resolution.\n */\nexport function generateAIPrompt(\n  schema: TableSchemaDefinition,\n  options?: GenerateAIPromptOptions,\n): string {\n  const context = generateAIContext(schema);\n  return formatPrompt(context, options);\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "src/lib/ai/output-schema.ts",
      "content": "import type { TableSchemaDefinition } from \"@/lib/table-schema\";\nimport { z } from \"zod\";\n\n/**\n * Generates a Zod schema for LLM structured output from a table schema.\n *\n * Each filterable column becomes an optional field in the output schema:\n * - `input` (string) → `z.string().optional()`\n * - `input` (number) → `z.number().optional()`\n * - `checkbox` (enum) → `z.array(z.enum([...values])).optional()`\n * - `checkbox` (boolean) → `z.array(z.enum([\"true\", \"false\"])).optional()`\n * - `checkbox` (number) → `z.array(z.number()).optional()`\n * - `slider` → `z.tuple([z.number(), z.number()]).optional()`\n * - `timerange` → `z.tuple([z.string(), z.string()]).optional()`\n *\n * Command-disabled columns are intentionally included so the AI can infer\n * filters that aren't available in the command palette (e.g. date ranges).\n */\nexport function generateAIOutputSchema(schema: TableSchemaDefinition) {\n  const shape: Record<string, z.ZodType> = {};\n\n  for (const [key, builder] of Object.entries(schema)) {\n    const config = builder._config;\n    if (!config.filter) continue;\n\n    const filterType = config.filter.type;\n    const desc = config.description || config.label || key;\n\n    switch (filterType) {\n      case \"input\": {\n        if (config.kind === \"number\") {\n          shape[key] = z.number().optional().describe(desc);\n        } else {\n          shape[key] = z.string().optional().describe(desc);\n        }\n        break;\n      }\n\n      case \"checkbox\": {\n        const options = config.filter.options;\n        if (options && options.length >= 2) {\n          const values = options.map((o) => String(o.value));\n          shape[key] = z\n            .array(z.enum(values as [string, ...string[]]))\n            .optional()\n            .describe(desc);\n        } else if (options && options.length === 1) {\n          shape[key] = z\n            .array(z.literal(String(options[0]!.value)))\n            .optional()\n            .describe(desc);\n        } else if (config.kind === \"number\") {\n          shape[key] = z.array(z.number()).optional().describe(desc);\n        } else {\n          shape[key] = z.array(z.string()).optional().describe(desc);\n        }\n        break;\n      }\n\n      case \"slider\": {\n        const parts = [desc];\n        if (\n          config.filter.min !== undefined ||\n          config.filter.max !== undefined\n        ) {\n          const bounds = [];\n          if (config.filter.min !== undefined)\n            bounds.push(`min: ${config.filter.min}`);\n          if (config.filter.max !== undefined)\n            bounds.push(`max: ${config.filter.max}`);\n          parts.push(`Range: ${bounds.join(\", \")}`);\n        }\n        if (config.filter.unit) parts.push(`Unit: ${config.filter.unit}`);\n\n        shape[key] = z\n          .tuple([z.number(), z.number()])\n          .optional()\n          .describe(parts.join(\". \"));\n        break;\n      }\n\n      case \"timerange\": {\n        shape[key] = z\n          .tuple([z.string(), z.string()])\n          .optional()\n          .describe(desc + \". ISO 8601 datetime strings [start, end].\");\n        break;\n      }\n    }\n  }\n\n  return z.object(shape as z.core.$ZodLooseShape);\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "src/lib/ai/diff-partial.ts",
      "content": "import type { TableSchemaDefinition } from \"@/lib/table-schema\";\n\nexport type CompletedField = {\n  key: string;\n  value: unknown;\n};\n\n/**\n * Compares two partial objects from an LLM stream and returns newly completed fields.\n *\n * Uses type-aware completeness rules based on the table schema's filter type:\n * - `input` (string/number): complete when non-null and non-undefined\n * - `checkbox` (array): complete on first value, updates as more arrive\n * - `slider` (tuple[2]): complete only when both values are present\n * - `timerange` (tuple[2]): complete only when both values are present\n *\n * @param prev - Previous partial state from the stream (or `{}` for first chunk)\n * @param next - Current partial state from the stream\n * @param schema - Table schema definition for type-aware completeness checks\n * @returns Array of newly completed or updated fields ready for `adapter.setField()`\n */\nexport function diffPartialState(\n  prev: Record<string, unknown>,\n  next: Record<string, unknown>,\n  schema: TableSchemaDefinition,\n): CompletedField[] {\n  const completed: CompletedField[] = [];\n\n  for (const [key, builder] of Object.entries(schema)) {\n    const config = builder._config;\n    if (!config.filter) continue;\n\n    const nextVal = next[key];\n    const prevVal = prev[key];\n\n    // Skip if the field hasn't appeared yet\n    if (nextVal === undefined || nextVal === null) continue;\n\n    const filterType = config.filter.type;\n\n    switch (filterType) {\n      case \"input\": {\n        // Complete when non-null. Emit only if changed.\n        if (nextVal !== prevVal) {\n          completed.push({ key, value: nextVal });\n        }\n        break;\n      }\n\n      case \"checkbox\": {\n        // Array — complete on first value, update as more arrive\n        if (!Array.isArray(nextVal)) break;\n        if (nextVal.length === 0) break;\n\n        const prevArr = Array.isArray(prevVal) ? prevVal : [];\n        if (\n          nextVal.length !== prevArr.length ||\n          nextVal.some((v, i) => v !== prevArr[i])\n        ) {\n          completed.push({ key, value: nextVal });\n        }\n        break;\n      }\n\n      case \"slider\": {\n        // Tuple[2] — wait for both values\n        if (!Array.isArray(nextVal)) break;\n        if (nextVal.length < 2) break;\n        if (typeof nextVal[0] !== \"number\" || typeof nextVal[1] !== \"number\")\n          break;\n\n        const prevArr = Array.isArray(prevVal) ? prevVal : [];\n        if (nextVal[0] !== prevArr[0] || nextVal[1] !== prevArr[1]) {\n          completed.push({ key, value: nextVal });\n        }\n        break;\n      }\n\n      case \"timerange\": {\n        // Tuple[2] — wait for both date strings\n        if (!Array.isArray(nextVal)) break;\n        if (nextVal.length < 2) break;\n        if (typeof nextVal[0] !== \"string\" || typeof nextVal[1] !== \"string\")\n          break;\n\n        const prevArr = Array.isArray(prevVal) ? prevVal : [];\n        if (nextVal[0] !== prevArr[0] || nextVal[1] !== prevArr[1]) {\n          completed.push({ key, value: nextVal });\n        }\n        break;\n      }\n    }\n  }\n\n  return completed;\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "src/lib/ai/parse-response.ts",
      "content": "import type { TableSchemaDefinition } from \"@/lib/table-schema\";\n\n/**\n * Validates and coerces a complete LLM response into clean filter state.\n *\n * - Strips fields not in the schema or not filterable\n * - Converts ISO date strings to Date objects for timerange fields\n * - Validates checkbox values against allowed options\n * - Clamps slider values to min/max bounds\n * - Returns `null` if the response is empty (no valid filters)\n */\nexport function parseAIResponse(\n  schema: TableSchemaDefinition,\n  response: Record<string, unknown>,\n): Record<string, unknown> | null {\n  const result: Record<string, unknown> = {};\n  let hasFields = false;\n\n  for (const [key, builder] of Object.entries(schema)) {\n    const config = builder._config;\n    if (!config.filter) continue;\n\n    const value = response[key];\n    if (value === undefined || value === null) continue;\n\n    const filterType = config.filter.type;\n\n    switch (filterType) {\n      case \"input\": {\n        if (config.kind === \"number\") {\n          if (typeof value === \"number\") {\n            result[key] = value;\n            hasFields = true;\n          }\n        } else {\n          if (typeof value === \"string\" && value.length > 0) {\n            result[key] = value;\n            hasFields = true;\n          }\n        }\n        break;\n      }\n\n      case \"checkbox\": {\n        if (!Array.isArray(value)) break;\n        if (value.length === 0) break;\n\n        const options = config.filter.options;\n        if (options && options.length > 0) {\n          // Build a map from string representation to original typed value\n          const optionMap = new Map(\n            options.map((o) => [String(o.value), o.value]),\n          );\n          // Match AI strings against allowed values, coerce back to original type\n          const valid = value\n            .map(String)\n            .filter((v) => optionMap.has(v))\n            .map((v) => optionMap.get(v)!);\n          if (valid.length > 0) {\n            result[key] = valid;\n            hasFields = true;\n          }\n        } else {\n          result[key] = value;\n          hasFields = true;\n        }\n        break;\n      }\n\n      case \"slider\": {\n        if (!Array.isArray(value)) break;\n        if (value.length < 2) break;\n\n        let [min, max] = value as [number, number];\n        if (typeof min !== \"number\" || typeof max !== \"number\") break;\n\n        // Clamp to bounds\n        if (config.filter.min !== undefined) {\n          min = Math.max(min, config.filter.min);\n        }\n        if (config.filter.max !== undefined) {\n          max = Math.min(max, config.filter.max);\n        }\n\n        if (min > max) break;\n\n        result[key] = [min, max];\n        hasFields = true;\n        break;\n      }\n\n      case \"timerange\": {\n        if (!Array.isArray(value)) break;\n        if (value.length < 2) break;\n\n        const [startStr, endStr] = value as [string, string];\n        if (typeof startStr !== \"string\" || typeof endStr !== \"string\") break;\n\n        const start = new Date(startStr);\n        const end = new Date(endStr);\n\n        if (isNaN(start.getTime()) || isNaN(end.getTime())) break;\n        if (start > end) break;\n\n        result[key] = [start, end];\n        hasFields = true;\n        break;\n      }\n    }\n  }\n\n  return hasFields ? result : null;\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "src/lib/ai/detect.ts",
      "content": "import type { TableSchemaDefinition } from \"@/lib/table-schema\";\n\n/**\n * Determines if the user's input is a structured `key:value` filter query\n * or natural language that should be sent to an AI endpoint.\n *\n * Returns `true` if the input matches the structured query format (i.e.,\n * contains a known field name followed by `:`). Returns `false` for natural\n * language input.\n *\n * @param input - The raw user input from the command palette\n * @param schema - Table schema definition to derive known field names\n */\nexport function isStructuredQuery(\n  input: string,\n  schema: TableSchemaDefinition,\n): boolean {\n  const trimmed = input.trim();\n  if (trimmed.length === 0) return false;\n\n  // Collect known filterable field keys\n  const fieldKeys = new Set<string>();\n  for (const [key, builder] of Object.entries(schema)) {\n    const config = builder._config;\n    if (config.filter && !config.filter.commandDisabled) {\n      fieldKeys.add(key);\n    }\n  }\n\n  // Check if any token in the input starts with `knownField:`\n  // This handles both `regions:ams,gru` and `regions:ams latency:100-500`\n  const tokens = trimmed.split(/\\s+/);\n  for (const token of tokens) {\n    const colonIndex = token.indexOf(\":\");\n    if (colonIndex > 0) {\n      const field = token.substring(0, colonIndex);\n      if (fieldKeys.has(field)) {\n        return true;\n      }\n    }\n  }\n\n  return false;\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "src/lib/ai/create-ai-filter-handler.ts",
      "content": "import type { TableSchemaDefinition } from \"@/lib/table-schema\";\nimport { streamObject } from \"ai\";\nimport type { LanguageModel } from \"ai\";\nimport { generateAIOutputSchema } from \"./output-schema\";\nimport { generateAIPrompt } from \"./prompt\";\n\nexport type AIFilterHandlerOptions = {\n  model: LanguageModel;\n  schema: TableSchemaDefinition;\n};\n\n/**\n * Creates a POST handler that streams AI-inferred filter state from a natural language query.\n *\n * The consumer provides a model and table schema. The handler generates the system prompt\n * and Zod output schema automatically, then streams the structured object response.\n *\n * The static schema description is cached via Anthropic's prompt caching\n * (`cache_control: { type: \"ephemeral\" }`). Only the dynamic timestamp and\n * user query are sent fresh on each request.\n *\n * @example\n * ```ts\n * import { createAnthropic } from \"@ai-sdk/anthropic\";\n * import { createAIFilterHandler } from \"@/lib/ai/create-ai-filter-handler\";\n * import { tableSchema } from \"../table-schema\";\n *\n * const anthropic = createAnthropic({\n *   baseURL: \"https://ai-gateway.vercel.sh/v1\",\n *   apiKey: process.env.AI_GATEWAY_API_KEY,\n * });\n *\n * export const POST = createAIFilterHandler({\n *   model: anthropic(\"anthropic/claude-opus-4.5\"),\n *   schema: tableSchema.definition,\n * });\n * ```\n */\nexport function createAIFilterHandler({\n  model,\n  schema,\n}: AIFilterHandlerOptions) {\n  // Static prompt (cacheable) — generated once, same for every request\n  const staticPrompt = generateAIPrompt(schema);\n  const outputSchema = generateAIOutputSchema(schema);\n\n  return async function POST(req: Request) {\n    const body = await req.json();\n    const query = typeof body?.query === \"string\" ? body.query.trim() : \"\";\n\n    if (!query || query.length > 500) {\n      return Response.json(\n        { error: \"Invalid query: must be a non-empty string (max 500 chars)\" },\n        { status: 400 },\n      );\n    }\n\n    const now = new Date().toISOString();\n\n    try {\n      const result = streamObject({\n        model,\n        schema: outputSchema,\n        messages: [\n          {\n            role: \"system\",\n            content: staticPrompt,\n            providerOptions: {\n              anthropic: { cacheControl: { type: \"ephemeral\" } },\n            },\n          },\n          {\n            role: \"user\",\n            content: `Current date/time: ${now}\\n\\nQuery: ${query}`,\n          },\n        ],\n      });\n\n      return result.toTextStreamResponse();\n    } catch (error) {\n      const message =\n        error instanceof Error ? error.message : \"AI filter inference failed\";\n      const status =\n        error && typeof error === \"object\" && \"statusCode\" in error\n          ? (error.statusCode as number)\n          : 500;\n\n      return Response.json({ error: message }, { status });\n    }\n  };\n}\n",
      "type": "registry:lib"
    }
  ],
  "tailwind": {
    "config": {
      "theme": {
        "keyframes": {
          "shimmer": {
            "0%": {
              "backgroundPosition": "200% 50%"
            },
            "100%": {
              "backgroundPosition": "-200% 50%"
            }
          }
        }
      }
    }
  },
  "type": "registry:block"
}