{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ink/command-palette",
  "title": "Command Palette",
  "description": "VS Code–style command palette with fuzzy search",
  "dependencies": [
    "ink"
  ],
  "registryDependencies": [
    "https://termcn.dev/r/ink/interaction.json",
    "https://termcn.dev/r/ink/terminal-style.json",
    "https://termcn.dev/r/ink/terminal-text.json",
    "https://termcn.dev/r/ink/use-theme.json",
    "https://termcn.dev/r/ink/use-unicode.json"
  ],
  "files": [
    {
      "path": "registry/ui/command-palette.tsx",
      "content": "import { useIsScreenReaderEnabled, Box, Text } from \"ink\";\nimport { useEffect, useId, useState } from \"react\";\n\nimport {\n  FocusScope,\n  useInteraction,\n} from \"@/hooks/use-interaction\";\nimport { useTheme } from \"@/hooks/use-theme\";\nimport { useUnicode } from \"@/hooks/use-unicode\";\nimport { resolveBorderStyle } from \"@/lib/terminal-style\";\nimport {\n  graphemeLength,\n  removeGraphemeBefore,\n} from \"@/lib/terminal-text\";\n\nexport interface Command {\n  id: string;\n  label: string;\n  description?: string;\n  shortcut?: string;\n  onSelect?: () => void;\n  group?: string;\n  disabled?: boolean;\n}\n\nexport interface CommandPaletteProps {\n  commands: Command[];\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  isOpen?: boolean;\n  onClose?: () => void;\n  placeholder?: string;\n  maxItems?: number;\n  returnFocusId?: string;\n  \"aria-label\"?: string;\n}\n\nconst fuzzyMatch = (value: string, query: string): boolean => {\n  if (!query) {\n    return true;\n  }\n  const normalizedValue = value.toLocaleLowerCase();\n  const normalizedQuery = query.toLocaleLowerCase();\n  let queryIndex = 0;\n  for (\n    let valueIndex = 0;\n    valueIndex < normalizedValue.length && queryIndex < normalizedQuery.length;\n    valueIndex += 1\n  ) {\n    if (normalizedValue[valueIndex] === normalizedQuery[queryIndex]) {\n      queryIndex += 1;\n    }\n  }\n  return queryIndex === normalizedQuery.length;\n};\n\nconst fuzzyScore = (value: string, query: string): number => {\n  if (!query) {\n    return 0;\n  }\n  const normalizedValue = value.toLocaleLowerCase();\n  const normalizedQuery = query.toLocaleLowerCase();\n  let score = 0;\n  let queryIndex = 0;\n  let previousMatch = -1;\n  for (\n    let valueIndex = 0;\n    valueIndex < normalizedValue.length && queryIndex < normalizedQuery.length;\n    valueIndex += 1\n  ) {\n    if (normalizedValue[valueIndex] === normalizedQuery[queryIndex]) {\n      score += valueIndex - previousMatch - 1;\n      previousMatch = valueIndex;\n      queryIndex += 1;\n    }\n  }\n  return score;\n};\n\ninterface CommandPaletteContentProps {\n  commands: Command[];\n  controlId: string;\n  label: string;\n  maxItems: number;\n  onClose: () => void;\n  placeholder: string;\n}\n\nconst CommandPaletteContent = ({\n  commands,\n  controlId,\n  label,\n  maxItems,\n  onClose,\n  placeholder,\n}: CommandPaletteContentProps) => {\n  const unicode = useUnicode();\n  const theme = useTheme();\n  const isScreenReaderEnabled = useIsScreenReaderEnabled();\n  const [query, setQuery] = useState(\"\");\n  const [cursor, setCursor] = useState(0);\n  const filtered = commands\n    .filter((command) => fuzzyMatch(command.label, query))\n    .toSorted(\n      (first, second) =>\n        fuzzyScore(first.label, query) - fuzzyScore(second.label, query)\n    )\n    .slice(0, maxItems);\n\n  useEffect(() => {\n    setCursor((current) => Math.max(0, Math.min(current, filtered.length - 1)));\n  }, [filtered.length]);\n\n  const { isFocused } = useInteraction(\n    (input, key) => {\n      if (key.upArrow) {\n        setCursor((current) => Math.max(0, current - 1));\n        return;\n      }\n\n      if (key.downArrow) {\n        setCursor((current) =>\n          Math.max(0, Math.min(filtered.length - 1, current + 1))\n        );\n        return;\n      }\n\n      if (key.home) {\n        setCursor(0);\n        return;\n      }\n\n      if (key.end) {\n        setCursor(Math.max(0, filtered.length - 1));\n        return;\n      }\n\n      if (key.return) {\n        const command = filtered[cursor];\n        if (command && !command.disabled) {\n          command.onSelect?.();\n          onClose();\n        }\n        return;\n      }\n\n      if (key.backspace) {\n        setQuery(\n          (current) =>\n            removeGraphemeBefore(current, graphemeLength(current)).value\n        );\n        setCursor(0);\n        return;\n      }\n\n      if (key.delete || key.tab || key.ctrl || key.meta) {\n        return;\n      }\n\n      if (input) {\n        setQuery((current) => current + input);\n        setCursor(0);\n      }\n    },\n    { autoFocus: true, id: controlId }\n  );\n\n  const groups = new Map<string | undefined, Command[]>();\n  for (const command of filtered) {\n    const groupCommands = groups.get(command.group) ?? [];\n    groupCommands.push(command);\n    groups.set(command.group, groupCommands);\n  }\n  const indexById = new Map(\n    filtered.map((command, index) => [command.id, index] as const)\n  );\n\n  return (\n    <Box\n      flexDirection=\"column\"\n      borderStyle={resolveBorderStyle(\"round\", unicode)}\n      borderColor={theme.colors.focusRing}\n      paddingX={1}\n    >\n      <Text aria-label={label}>{\"\"}</Text>\n      <Box\n        aria-label={`${label} query: ${query || \"empty\"}`}\n        aria-role=\"combobox\"\n        aria-state={{ expanded: true }}\n        borderStyle={resolveBorderStyle(\"single\", unicode)}\n        borderColor={theme.colors.border}\n        paddingX={1}\n      >\n        <Text aria-hidden color={theme.colors.mutedForeground}>\n          {unicode ? \"⌘ \" : \"> \"}\n        </Text>\n        <Text\n          aria-hidden\n          color={query ? theme.colors.foreground : theme.colors.mutedForeground}\n        >\n          {query || placeholder}\n        </Text>\n        {isFocused && !isScreenReaderEnabled && (\n          <Text aria-hidden color={theme.colors.focusRing}>\n            {unicode ? \"█\" : \"|\"}\n          </Text>\n        )}\n      </Box>\n\n      {filtered.length === 0 ? (\n        <Box paddingX={1} paddingY={0}>\n          <Text color={theme.colors.mutedForeground} dimColor>\n            No commands found\n          </Text>\n        </Box>\n      ) : (\n        <Box aria-role=\"listbox\" flexDirection=\"column\">\n          {[...groups.entries()].map(([group, groupCommands]) => (\n            <Box key={group ?? \"_\"} flexDirection=\"column\">\n              {group && (\n                <Box paddingX={1}>\n                  <Text color={theme.colors.mutedForeground} dimColor bold>\n                    {group}\n                  </Text>\n                </Box>\n              )}\n              {groupCommands.map((command) => {\n                const index = indexById.get(command.id) ?? -1;\n                const isCursor = index === cursor;\n                return (\n                  <Box\n                    key={command.id}\n                    aria-label={\n                      command.description\n                        ? `${command.label}: ${command.description}`\n                        : command.label\n                    }\n                    aria-role=\"option\"\n                    aria-state={{\n                      disabled: command.disabled,\n                      selected: isCursor,\n                    }}\n                    paddingX={1}\n                  >\n                    <Box aria-hidden flexGrow={1}>\n                      <Text\n                        color={\n                          isCursor\n                            ? theme.colors.selectionForeground\n                            : theme.colors.foreground\n                        }\n                        bold={isCursor}\n                        inverse={isCursor}\n                        dimColor={command.disabled}\n                      >\n                        {command.label}\n                      </Text>\n                      {command.description && (\n                        <Text color={theme.colors.mutedForeground} dimColor>\n                          {`${unicode ? \" — \" : \" - \"}${command.description}`}\n                        </Text>\n                      )}\n                    </Box>\n                    {command.shortcut && (\n                      <Text aria-hidden color={theme.colors.accent} dimColor>\n                        {command.shortcut}\n                      </Text>\n                    )}\n                  </Box>\n                );\n              })}\n            </Box>\n          ))}\n        </Box>\n      )}\n\n      <Text aria-hidden color={theme.colors.mutedForeground} dimColor>\n        {unicode\n          ? \"↑↓: navigate · Enter: run · Esc: close\"\n          : \"up/down: navigate - Enter: run - Esc: close\"}\n      </Text>\n    </Box>\n  );\n};\n\nexport const CommandPalette = ({\n  commands,\n  open: controlledOpen,\n  defaultOpen = false,\n  onOpenChange,\n  isOpen,\n  onClose,\n  placeholder = \"Type a command...\",\n  maxItems = 8,\n  returnFocusId,\n  \"aria-label\": ariaLabel = \"Command palette\",\n}: CommandPaletteProps) => {\n  const generatedId = useId();\n  const controlId = `command-palette-${generatedId}`;\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const open = controlledOpen ?? isOpen ?? internalOpen;\n\n  const close = () => {\n    if (controlledOpen === undefined && isOpen === undefined) {\n      setInternalOpen(false);\n    }\n    onOpenChange?.(false);\n    onClose?.();\n  };\n\n  if (!open) {\n    return null;\n  }\n\n  return (\n    <FocusScope\n      active={open}\n      initialFocusId={controlId}\n      returnFocusId={returnFocusId}\n      onEscapeKey={close}\n    >\n      <CommandPaletteContent\n        commands={commands}\n        controlId={controlId}\n        label={ariaLabel}\n        maxItems={maxItems}\n        onClose={close}\n        placeholder={placeholder}\n      />\n    </FocusScope>\n  );\n};\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "navigation"
  ],
  "type": "registry:ui"
}