{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "command-palette",
  "type": "registry:ui",
  "title": "Command Palette",
  "description": "⌘K command palette for agent actions: fuzzy search, groups, recents, nested pages with async loading, and shortcut hints.",
  "dependencies": [
    "lucide-react",
    "radix-ui"
  ],
  "registryDependencies": [
    "https://kuro.abstergo.fyi/r/kura-theme.json",
    "https://kuro.abstergo.fyi/r/kbd.json",
    "https://kuro.abstergo.fyi/r/kura-keys.json",
    "https://kuro.abstergo.fyi/r/use-controllable-state.json",
    "https://kuro.abstergo.fyi/r/use-hotkey.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/kura/command-palette/command-palette.tsx",
      "type": "registry:ui",
      "target": "components/kura/command-palette/command-palette.tsx",
      "content": "// ARIA combobox + listbox: the search input keeps focus and points at the highlighted option with\n// aria-activedescendant, so options are intentionally not focusable and have no own key handlers.\n// biome-ignore-all lint/a11y/useSemanticElements: rich rows cannot be native <option>/<fieldset>.\n// biome-ignore-all lint/a11y/useFocusableInteractive: focus stays in the search input (activedescendant).\n// biome-ignore-all lint/a11y/useKeyWithClickEvents: keyboard selection is handled by the input.\n\"use client\";\n\nimport { ChevronRight, CornerDownLeft, Search } from \"lucide-react\";\nimport { Dialog as DialogPrimitive } from \"radix-ui\";\nimport * as React from \"react\";\nimport { useControllableState } from \"@/hooks/kura/use-controllable-state\";\nimport { useHotkey } from \"@/hooks/kura/use-hotkey\";\nimport { cn } from \"@/lib/utils\";\nimport type { KeyName } from \"@/lib/kura/keys\";\nimport { Kbd } from \"@/components/kura/kbd\";\n\nexport interface CommandPaletteItem {\n  /** Stable id (also used for recents). */\n  id: string;\n  label: string;\n  /** Muted text after the label. */\n  description?: string;\n  /** 16px icon. */\n  icon?: React.ReactNode;\n  /** Group heading. Groups keep first-seen order. */\n  group?: string;\n  /** Extra search terms. */\n  keywords?: readonly string[];\n  /** Shortcut shown on the right (display only — bind it with useHotkey). */\n  shortcut?: readonly KeyName[];\n  /** Run the action. The palette closes afterwards unless the item opens a page. */\n  onSelect?: () => void;\n  /** Open a nested page with these items. */\n  children?: readonly CommandPaletteItem[];\n  /** Open a nested page whose items load asynchronously (a loading state shows meanwhile). */\n  loadChildren?: () => Promise<readonly CommandPaletteItem[]>;\n  /** Search placeholder for the nested page. */\n  placeholder?: string;\n  disabled?: boolean;\n}\n\nexport interface CommandPaletteProps\n  extends Omit<React.ComponentProps<\"div\">, \"children\" | \"onSelect\"> {\n  items: readonly CommandPaletteItem[];\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Global shortcut that toggles the palette. Default [\"mod\", \"k\"]. null disables it. */\n  hotkey?: readonly KeyName[] | null;\n  /** Element that opens the palette (rendered as the dialog trigger), e.g. <CommandPaletteTrigger />. */\n  trigger?: React.ReactElement;\n  /** Controlled recent item ids, most recent first. Shown as “Recent” when the query is empty. */\n  recent?: string[];\n  /** Initial recent ids when uncontrolled. */\n  defaultRecent?: string[];\n  onRecentChange?: (ids: string[]) => void;\n  /** Max recents shown. Default 4. */\n  recentLimit?: number;\n  /** Called after any item is selected (in addition to the item's onSelect). */\n  onSelect?: (item: CommandPaletteItem) => void;\n  /** Called on every query change, e.g. to fetch server results into `items`. */\n  onQueryChange?: (query: string) => void;\n  /** Show loading rows (e.g. while async results arrive). */\n  loading?: boolean;\n  /** Default \"Search or run a command…\". */\n  placeholder?: string;\n  /** Default \"No results\". */\n  emptyMessage?: string;\n  /** Accessible title of the dialog. Default \"Command palette\". */\n  label?: string;\n}\n\ninterface Page {\n  id: string;\n  title: string;\n  items: readonly CommandPaletteItem[];\n  placeholder?: string;\n  loading: boolean;\n}\n\n/** Fuzzy score: contiguous and word-start matches rank higher. 0 = no match. */\nexport function scoreCommand(text: string, query: string): number {\n  const t = text.toLowerCase();\n  const q = query.toLowerCase().trim();\n  if (!q) return 1;\n  if (t === q) return 100;\n  if (t.startsWith(q)) return 80;\n  const idx = t.indexOf(q);\n  if (idx > 0 && /[\\s\\-_/.]/.test(t[idx - 1] ?? \"\")) return 60;\n  if (idx >= 0) return 40;\n  // subsequence\n  let ti = 0;\n  let score = 0;\n  let streak = 0;\n  for (const ch of q) {\n    if (ch === \" \") continue;\n    const found = t.indexOf(ch, ti);\n    if (found === -1) return 0;\n    streak = found === ti ? streak + 1 : 0;\n    score += 1 + streak + (found === 0 || /[\\s\\-_/.]/.test(t[found - 1] ?? \"\") ? 2 : 0);\n    ti = found + 1;\n  }\n  return Math.min(35, score);\n}\n\nfunction itemScore(item: CommandPaletteItem, query: string): number {\n  const label = scoreCommand(item.label, query);\n  const extra = Math.max(\n    scoreCommand(item.description ?? \"\", query) * 0.5,\n    ...(item.keywords ?? []).map((k) => scoreCommand(k, query) * 0.7),\n    item.group ? scoreCommand(item.group, query) * 0.3 : 0,\n  );\n  return Math.max(label, query ? extra : 0);\n}\n\nconst nested = (i: CommandPaletteItem) => !!(i.children || i.loadChildren);\n\n/**\n * ⌘K command palette for agent actions: fuzzy search, groups, recents, nested pages (with async\n * loading) and shortcut hints. Opens with the hotkey or an optional trigger.\n */\nexport function CommandPalette({\n  items,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  hotkey = [\"mod\", \"k\"],\n  trigger,\n  recent: recentProp,\n  defaultRecent,\n  onRecentChange,\n  recentLimit = 4,\n  onSelect,\n  onQueryChange,\n  loading = false,\n  placeholder = \"Search or run a command…\",\n  emptyMessage = \"No results\",\n  label = \"Command palette\",\n  className,\n  ...props\n}: CommandPaletteProps) {\n  const [open, setOpen] = useControllableState({\n    value: openProp,\n    defaultValue: defaultOpen,\n    onChange: onOpenChange,\n  });\n  const [recent, setRecent] = useControllableState<string[]>({\n    value: recentProp,\n    defaultValue: defaultRecent ?? [],\n    onChange: onRecentChange,\n  });\n  const [query, setQueryState] = React.useState(\"\");\n  const [pages, setPages] = React.useState<Page[]>([]);\n  const [active, setActive] = React.useState<string | null>(null);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const pointerOrigin = React.useRef<{ x: number; y: number } | null>(null);\n  const baseId = React.useId();\n  const listId = `${baseId}-list`;\n  const optionId = React.useCallback(\n    (key: string) => `${baseId}-o-${key.replace(/[^a-zA-Z0-9_-]/g, \"_\")}`,\n    [baseId],\n  );\n\n  const setQuery = (q: string) => {\n    setQueryState(q);\n    // A new query re-ranks the list: highlight its best match again.\n    setActive(null);\n    onQueryChange?.(q);\n  };\n\n  useHotkey(hotkey, () => setOpen((o) => !o), { enabled: hotkey !== null, allowInInputs: true });\n\n  // Reset when closed.\n  React.useEffect(() => {\n    if (open) return;\n    setQueryState(\"\");\n    setPages([]);\n    setActive(null);\n    pointerOrigin.current = null;\n  }, [open]);\n\n  const page = pages[pages.length - 1];\n  const source = page ? page.items : items;\n  const isLoading = page ? page.loading : loading;\n\n  // Flatten the whole tree for recents lookup.\n  const byId = React.useMemo(() => {\n    const map = new Map<string, CommandPaletteItem>();\n    const walk = (list: readonly CommandPaletteItem[]) => {\n      for (const i of list) {\n        map.set(i.id, i);\n        if (i.children) walk(i.children);\n      }\n    };\n    walk(items);\n    return map;\n  }, [items]);\n\n  type Row = { key: string; item: CommandPaletteItem; group: string };\n  const rows = React.useMemo<Row[]>(() => {\n    const q = query.trim();\n    const out: Row[] = [];\n    if (!q && !page && recent.length > 0) {\n      for (const id of recent.slice(0, recentLimit)) {\n        const item = byId.get(id);\n        if (item) out.push({ key: `recent:${id}`, item, group: \"Recent\" });\n      }\n    }\n    const scored = source\n      .map((item, index) => ({ item, index, s: itemScore(item, q) }))\n      .filter((r) => r.s > 0);\n    if (q) scored.sort((a, b) => b.s - a.s || a.index - b.index);\n    // Keep groups together, ordered by their best item.\n    const order: string[] = [];\n    const grouped = new Map<string, CommandPaletteItem[]>();\n    for (const { item } of scored) {\n      const g = item.group ?? \"\";\n      if (!grouped.has(g)) {\n        grouped.set(g, []);\n        order.push(g);\n      }\n      grouped.get(g)?.push(item);\n    }\n    for (const g of order)\n      for (const item of grouped.get(g) ?? []) out.push({ key: item.id, item, group: g });\n    return out;\n  }, [query, page, recent, recentLimit, byId, source]);\n\n  const enabledRows = React.useMemo(() => rows.filter((r) => !r.item.disabled), [rows]);\n  // Derived synchronously so Enter right after typing always hits the best match, even before\n  // the effect below has re-synced `active`.\n  const current =\n    active && enabledRows.some((r) => r.key === active) ? active : (enabledRows[0]?.key ?? null);\n\n  React.useEffect(() => {\n    setActive((current) =>\n      current && enabledRows.some((r) => r.key === current)\n        ? current\n        : (enabledRows[0]?.key ?? null),\n    );\n  }, [enabledRows]);\n\n  React.useEffect(() => {\n    if (!active) return;\n    document.getElementById(optionId(active))?.scrollIntoView?.({ block: \"nearest\" });\n  }, [active, optionId]);\n\n  const pushPage = (item: CommandPaletteItem) => {\n    const next: Page = {\n      id: item.id,\n      title: item.label,\n      items: item.children ?? [],\n      placeholder: item.placeholder,\n      loading: !item.children && !!item.loadChildren,\n    };\n    setPages((p) => [...p, next]);\n    setQueryState(\"\");\n    setActive(null);\n    if (!item.children && item.loadChildren) {\n      item\n        .loadChildren()\n        .then((loaded) =>\n          setPages((p) =>\n            p.map((pg) => (pg === next ? { ...pg, items: loaded, loading: false } : pg)),\n          ),\n        )\n        .catch(() =>\n          setPages((p) => p.map((pg) => (pg === next ? { ...pg, items: [], loading: false } : pg))),\n        );\n    }\n    inputRef.current?.focus();\n  };\n\n  const popPage = () => {\n    setPages((p) => p.slice(0, -1));\n    setQueryState(\"\");\n    setActive(null);\n  };\n\n  const choose = (item: CommandPaletteItem) => {\n    if (item.disabled) return;\n    setRecent((list) => [item.id, ...list.filter((id) => id !== item.id)].slice(0, 20));\n    if (nested(item)) {\n      pushPage(item);\n      return;\n    }\n    item.onSelect?.();\n    onSelect?.(item);\n    setOpen(false);\n  };\n\n  const move = (delta: number | \"first\" | \"last\") => {\n    if (!enabledRows.length) return;\n    const i = enabledRows.findIndex((r) => r.key === current);\n    const next =\n      delta === \"first\"\n        ? 0\n        : delta === \"last\"\n          ? enabledRows.length - 1\n          : i === -1\n            ? 0\n            : (i + delta + enabledRows.length) % enabledRows.length;\n    setActive(enabledRows[next]?.key ?? null);\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (e.nativeEvent.isComposing) return;\n    if (e.key === \"ArrowDown\") {\n      e.preventDefault();\n      move(1);\n    } else if (e.key === \"ArrowUp\") {\n      e.preventDefault();\n      move(-1);\n    } else if (e.key === \"Home\" && !query) {\n      e.preventDefault();\n      move(\"first\");\n    } else if (e.key === \"End\" && !query) {\n      e.preventDefault();\n      move(\"last\");\n    } else if (e.key === \"Enter\") {\n      e.preventDefault();\n      const row = rows.find((r) => r.key === current);\n      if (row) choose(row.item);\n    } else if (e.key === \"Backspace\" && !query && pages.length > 0) {\n      e.preventDefault();\n      popPage();\n    } else if (e.key === \"Escape\" && pages.length > 0) {\n      e.preventDefault();\n      e.stopPropagation();\n      popPage();\n    }\n  };\n\n  let lastGroup: string | null = null;\n  const activeRow = rows.find((r) => r.key === current);\n\n  return (\n    <DialogPrimitive.Root open={open} onOpenChange={setOpen}>\n      {trigger && <DialogPrimitive.Trigger asChild>{trigger}</DialogPrimitive.Trigger>}\n      <DialogPrimitive.Portal>\n        <DialogPrimitive.Overlay\n          data-slot=\"command-palette-overlay\"\n          className=\"fixed inset-0 z-50 bg-kura-ink/15 backdrop-blur-[2px] data-[state=closed]:animate-kura-fade-out data-[state=open]:animate-kura-fade-in\"\n        />\n        <DialogPrimitive.Content\n          data-slot=\"command-palette\"\n          data-page={page?.id}\n          aria-describedby={undefined}\n          onOpenAutoFocus={(e) => {\n            e.preventDefault();\n            inputRef.current?.focus();\n          }}\n          className={cn(\n            \"-translate-x-1/2 fixed top-[min(20vh,160px)] left-1/2 z-50 flex max-h-[min(520px,calc(100vh-120px))] w-[min(640px,calc(100vw-32px))] flex-col overflow-hidden\",\n            \"rounded-kura-xl bg-kura-overlay font-kura-sans text-kura-fg shadow-kura-lg outline-none backdrop-blur-xl backdrop-saturate-150\",\n            \"origin-top data-[state=closed]:animate-kura-pop-out data-[state=open]:animate-kura-pop-in\",\n            className,\n          )}\n          {...props}\n        >\n          <DialogPrimitive.Title className=\"sr-only\">{label}</DialogPrimitive.Title>\n          <div\n            data-slot=\"command-palette-search\"\n            className=\"flex h-13 shrink-0 items-center gap-2.5 px-4 shadow-[inset_0_-1px_0_var(--kura-border-subtle)]\"\n          >\n            <Search aria-hidden className=\"size-4 shrink-0 text-kura-fg-subtle\" />\n            {pages.length > 0 && (\n              <span\n                data-slot=\"command-palette-breadcrumbs\"\n                className=\"flex shrink-0 items-center gap-1\"\n              >\n                {pages.map((p) => (\n                  <span\n                    key={p.id}\n                    className=\"rounded-kura-sm bg-kura-active px-1.5 py-0.5 font-medium text-kura-fg-muted text-xs\"\n                  >\n                    {p.title}\n                  </span>\n                ))}\n              </span>\n            )}\n            <input\n              ref={inputRef}\n              role=\"combobox\"\n              aria-expanded\n              aria-controls={listId}\n              aria-autocomplete=\"list\"\n              aria-activedescendant={current ? optionId(current) : undefined}\n              aria-label={page ? `Search ${page.title}` : \"Search commands\"}\n              value={query}\n              onChange={(e) => setQuery(e.currentTarget.value)}\n              onKeyDown={onKeyDown}\n              placeholder={\n                page?.placeholder ??\n                (page\n                  ? `Search ${page.title.replace(/(…|\\.\\.\\.)$/, \"\").toLowerCase()}…`\n                  : placeholder)\n              }\n              className=\"h-full min-w-0 flex-1 bg-transparent text-[15px] text-kura-fg outline-none placeholder:text-kura-fg-subtle\"\n            />\n            <Kbd\n              keys={[\"esc\"]}\n              variant=\"ghost\"\n              size=\"sm\"\n              className=\"shrink-0 text-kura-fg-subtle\"\n            />\n          </div>\n\n          <div\n            id={listId}\n            role=\"listbox\"\n            aria-label={page ? page.title : \"Commands\"}\n            aria-busy={isLoading || undefined}\n            className=\"min-h-0 flex-1 scroll-py-2 overflow-y-auto overscroll-contain p-2 [scrollbar-width:thin]\"\n          >\n            {rows.map((row) => {\n              const heading = row.group !== lastGroup ? row.group : null;\n              lastGroup = row.group;\n              const isActive = row.key === current;\n              const { item } = row;\n              return (\n                <React.Fragment key={row.key}>\n                  {heading !== null && heading !== \"\" && (\n                    <div\n                      role=\"presentation\"\n                      data-slot=\"command-palette-group\"\n                      className=\"select-none px-2.5 pt-3 pb-1.5 font-medium text-[11px] text-kura-fg-subtle first:pt-1\"\n                    >\n                      {heading}\n                    </div>\n                  )}\n                  <div\n                    id={optionId(row.key)}\n                    role=\"option\"\n                    aria-selected={isActive}\n                    aria-disabled={item.disabled || undefined}\n                    data-slot=\"command-palette-item\"\n                    data-active={isActive || undefined}\n                    data-disabled={item.disabled || undefined}\n                    onPointerMove={(e) => {\n                      const o = pointerOrigin.current;\n                      if (!o) {\n                        pointerOrigin.current = { x: e.clientX, y: e.clientY };\n                        return;\n                      }\n                      if (o.x === e.clientX && o.y === e.clientY) return;\n                      if (!item.disabled && !isActive) setActive(row.key);\n                    }}\n                    onClick={() => choose(item)}\n                    className=\"flex h-10 cursor-pointer select-none items-center gap-3 rounded-kura-md px-2.5 text-sm data-active:bg-kura-hover data-disabled:cursor-not-allowed data-disabled:opacity-45\"\n                  >\n                    <span\n                      aria-hidden\n                      className=\"flex size-4 shrink-0 items-center justify-center text-kura-fg-muted [&_svg]:size-4\"\n                    >\n                      {item.icon}\n                    </span>\n                    <span className=\"flex min-w-0 flex-1 items-baseline gap-2\">\n                      <span className=\"truncate text-kura-fg\">{item.label}</span>\n                      {item.description && (\n                        <span className=\"truncate text-[13px] text-kura-fg-subtle\">\n                          {item.description}\n                        </span>\n                      )}\n                    </span>\n                    {item.shortcut && (\n                      <Kbd keys={item.shortcut} variant=\"ghost\" size=\"sm\" className=\"shrink-0\" />\n                    )}\n                    {nested(item) && (\n                      <ChevronRight aria-hidden className=\"size-4 shrink-0 text-kura-fg-subtle\" />\n                    )}\n                  </div>\n                </React.Fragment>\n              );\n            })}\n            {isLoading && (\n              <div\n                data-slot=\"command-palette-loading\"\n                aria-hidden\n                className=\"flex flex-col gap-1 pt-1\"\n              >\n                {[0.62, 0.48, 0.55].map((w, i) => (\n                  <div key={i} className=\"flex h-10 items-center gap-3 px-2.5\">\n                    <span className=\"size-4 animate-kura-pulse rounded-kura-xs bg-kura-active motion-reduce:animate-none\" />\n                    <span\n                      className=\"h-2.5 animate-kura-pulse rounded-full bg-kura-active motion-reduce:animate-none\"\n                      style={{ width: `${w * 100}%`, animationDelay: `${i * 120}ms` }}\n                    />\n                  </div>\n                ))}\n              </div>\n            )}\n            {!isLoading && rows.length === 0 && (\n              <div className=\"px-3 py-10 text-center text-kura-fg-subtle text-sm\">\n                {emptyMessage}\n              </div>\n            )}\n          </div>\n\n          <div\n            data-slot=\"command-palette-footer\"\n            className=\"flex h-10 shrink-0 items-center justify-between gap-3 px-4 text-kura-fg-subtle text-xs shadow-[inset_0_1px_0_var(--kura-border-subtle)]\"\n          >\n            <span className=\"truncate\" aria-live=\"polite\">\n              {isLoading\n                ? \"Loading…\"\n                : pages.length > 0\n                  ? \"⌫ to go back\"\n                  : `${enabledRows.length} ${enabledRows.length === 1 ? \"result\" : \"results\"}`}\n            </span>\n            <span className=\"flex shrink-0 items-center gap-1.5\">\n              {activeRow && nested(activeRow.item) ? \"Open\" : \"Run\"}\n              <span\n                aria-hidden\n                className=\"inline-flex size-[18px] items-center justify-center rounded-kura-xs bg-kura-hover\"\n              >\n                <CornerDownLeft className=\"size-3\" />\n              </span>\n            </span>\n          </div>\n        </DialogPrimitive.Content>\n      </DialogPrimitive.Portal>\n    </DialogPrimitive.Root>\n  );\n}\n\nexport interface CommandPaletteTriggerProps\n  extends Omit<React.ComponentProps<\"button\">, \"children\"> {\n  /** Default \"Search or ask…\". */\n  placeholder?: string;\n  /** Shortcut shown on the right. Default [\"mod\", \"k\"]. */\n  shortcut?: readonly KeyName[];\n}\n\n/** A search-field-looking button that opens the palette. Pass it as `trigger`. */\nexport function CommandPaletteTrigger({\n  placeholder = \"Search or ask…\",\n  shortcut = [\"mod\", \"k\"],\n  className,\n  ...props\n}: CommandPaletteTriggerProps) {\n  return (\n    <button\n      type=\"button\"\n      data-slot=\"command-palette-trigger\"\n      className={cn(\n        \"inline-flex h-9 w-full min-w-0 cursor-pointer items-center gap-2 rounded-kura-lg bg-kura-surface pr-1.5 pl-3 font-kura-sans text-kura-fg-subtle text-sm shadow-kura-sm outline-none\",\n        \"transition-[box-shadow,color] duration-(--kura-duration-fast) ease-kura-out hover:text-kura-fg-muted focus-visible:shadow-kura-focus\",\n        className,\n      )}\n      {...props}\n    >\n      <Search aria-hidden className=\"size-4 shrink-0\" />\n      <span className=\"flex-1 truncate text-left\">{placeholder}</span>\n      <Kbd keys={shortcut} size=\"sm\" />\n    </button>\n  );\n}\n"
    },
    {
      "path": "registry/kura/command-palette/index.ts",
      "type": "registry:ui",
      "target": "components/kura/command-palette/index.ts",
      "content": "export {\n  CommandPalette,\n  type CommandPaletteItem,\n  type CommandPaletteProps,\n  CommandPaletteTrigger,\n  type CommandPaletteTriggerProps,\n  scoreCommand,\n} from \"./command-palette\";\n"
    }
  ],
  "categories": [
    "input",
    "command-palette",
    "cmdk",
    "search",
    "launcher"
  ],
  "docs": "Docs: https://kuro.abstergo.fyi/components/command-palette · Markdown for agents: https://kuro.abstergo.fyi/components/command-palette.md",
  "meta": {
    "kura": {
      "status": "beta",
      "whenToUse": [
        "A global launcher for agent actions: run/stop an agent, switch agents, add context, change settings.",
        "Navigating many commands or entities (files, threads, workflows) by typing.",
        "Multi-step choices as nested pages (“Change theme…” → Light / Dark), including lists fetched on demand."
      ],
      "whenNotToUse": [
        "Commands typed inline inside a prompt — use slash-command-menu.",
        "Choosing a model — use model-picker.",
        "A handful of starter prompts in an empty chat — use prompt-suggestions."
      ]
    }
  }
}
