# Command Palette

> ⌘K command palette for agent actions: fuzzy search, groups, recents, nested pages with async loading, and shortcut hints.

- **Slug:** `command-palette` · **Category:** input · **Status:** beta
- **Import:** `import { CommandPalette, CommandPaletteItem, CommandPaletteTrigger } from "@kura-ui/react/command-palette";`
- **Docs:** https://kuro.abstergo.fyi/components/command-palette
- **Registry item:** https://kuro.abstergo.fyi/r/command-palette.json
- **Tags:** command-palette, cmdk, search, launcher, keyboard, actions, menu, agent

## Install

```bash
# shadcn registry (copies source into your project)
npx shadcn@latest add https://kuro.abstergo.fyi/r/command-palette.json
# Kura CLI (same, plus dependency resolution and --json output)
npx @kura-ui/cli add command-palette
# npm package
npm install @kura-ui/react
```

## When to use

- 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.

## When not to use

- 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.

## Props

| Prop | Type | Default | Required | Description |
| --- | --- | --- | --- | --- |
| `items` | `readonly CommandPaletteItem[]` | — | yes | Items: { id, label, description?, icon?, group?, keywords?, shortcut?, onSelect?, children?, loadChildren?, placeholder?, disabled? }. children/loadChildren open a nested page. |
| `open` | `boolean` | — | no | Controlled open state. |
| `defaultOpen` | `boolean` | `false` | no | Initial open state. |
| `onOpenChange` | `(open: boolean) => void` | — | no | Open state changes. |
| `hotkey` | `readonly KeyName[] \| null` | `["mod", "k"]` | no | Global toggle shortcut (works in inputs). null disables it. |
| `trigger` | `React.ReactElement` | — | no | Element rendered as the dialog trigger, e.g. <CommandPaletteTrigger />. |
| `recent` | `string[]` | — | no | Controlled recent item ids (most recent first), shown as “Recent” when the query is empty. |
| `defaultRecent` | `string[]` | — | no | Initial recents when uncontrolled. |
| `onRecentChange` | `(ids: string[]) => void` | — | no | Called when an item is used (persist it to localStorage if you like). |
| `recentLimit` | `number` | `4` | no | Max recents shown. |
| `onSelect` | `(item: CommandPaletteItem) => void` | — | no | Called after any leaf item runs (in addition to item.onSelect). |
| `onQueryChange` | `(query: string) => void` | — | no | Every query change — fetch server results into `items` and toggle `loading`. |
| `loading` | `boolean` | `false` | no | Show skeleton rows and aria-busy (root page). Nested pages with loadChildren handle this themselves. |
| `placeholder` | `string` | `"Search or run a command…"` | no | Search placeholder on the root page. |
| `emptyMessage` | `string` | `"No results"` | no | Shown when nothing matches. |
| `label` | `string` | `"Command palette"` | no | Accessible dialog title (visually hidden). |

Native attributes of the root element, `className` and `ref` are passed through.

## Anatomy

- **CommandPalette** (`data-slot="command-palette"`) — Modal dialog surface (translucent overlay, origin-top pop). data-page = current nested page id.
- **CommandPaletteSearch** (`data-slot="command-palette-search"`) — Search row with breadcrumbs for nested pages.
- **CommandPaletteGroup** (`data-slot="command-palette-group"`) — Group heading (Recent first).
- **CommandPaletteItem** (`data-slot="command-palette-item"`) — role=option row: icon, label, description, shortcut or chevron. data-active, data-disabled.
- **CommandPaletteLoading** (`data-slot="command-palette-loading"`) — Skeleton rows while loading.
- **CommandPaletteFooter** (`data-slot="command-palette-footer"`) — Result count and the Enter action (Run / Open).
- **CommandPaletteTrigger** (`data-slot="command-palette-trigger"`) — Search-field-styled button with the shortcut; pass it as `trigger`.
  - `placeholder`: `string` = `"Search or ask…"` — Button text.
  - `shortcut`: `readonly KeyName[]` = `["mod", "k"]` — Shortcut keys shown on the right.

## Accessibility

- Role: `dialog > combobox + listbox`
- A modal Radix dialog with a visually hidden title; focus moves to the search input on open and returns to the trigger on close.
- The input is a combobox using aria-activedescendant, so screen readers read the highlighted option while focus stays in the field.
- The listbox is aria-busy while loading; the footer's result count is a polite live region.
- Nested pages are announced through the input's label (“Search Change theme…”) and shown as breadcrumbs.
- Reduced motion: The dialog and overlay fade without scale; skeleton rows stop pulsing.

| Keys | Action |
| --- | --- |
| Mod+K | Toggle the palette (configurable) |
| ArrowDown / ArrowUp | Move the highlight (wraps, skips disabled) |
| Home / End | First / last item (empty query) |
| Enter | Run the item or open its page |
| Backspace (empty query) / Escape | Go back one page |
| Escape (root page) | Close |

## Notes for agents

- Mount one palette near the app root; it portals itself. Only one instance should bind ⌘K — pass hotkey={null} to others.
- Use `children` for static sub-pages and `loadChildren` for fetched ones (the loading state is automatic).
- Persist recents with recent/onRecentChange (e.g. localStorage); recents resolve ids anywhere in the item tree.
- Shortcuts on items are display-only; bind them with useHotkey so they also work when the palette is closed.
- scoreCommand(text, query) is exported if you need the same fuzzy ranking elsewhere.

## Examples

### With a trigger (minimal)

```tsx
import { FileSearch, MessageSquarePlus, Rocket } from "lucide-react";
import { CommandPalette, CommandPaletteTrigger } from "@kura-ui/react/command-palette";

const files = [
  "src/app/page.tsx",
  "src/app/layout.tsx",
  "src/lib/auth.ts",
  "src/lib/billing/invoice.ts",
  "src/components/chat/composer.tsx",
  "src/components/chat/message-list.tsx",
  "README.md",
];

export function CommandPaletteMinimal() {
  return (
    <div className="w-72">
      <CommandPalette
        hotkey={null}
        trigger={<CommandPaletteTrigger />}
        items={[
          { id: "new", label: "New chat", icon: <MessageSquarePlus /> },
          { id: "search", label: "Search files", icon: <FileSearch /> },
          { id: "deploy", label: "Deploy preview", icon: <Rocket /> },
        ]}
      />
    </div>
  );
}
```

### Agent actions, recents and nested pages (full)

Controlled open state, ⌘K, recents, a nested “Switch agent…” page and an async “Run workflow…” page.

```tsx
"use client";

import { Bot, Database, FolderGit2, KeyRound, Monitor, Moon, Palette, Play, Square, Sun, Workflow } from "lucide-react";
import * as React from "react";
import { Button } from "@kura-ui/react/button";
import { KbdShortcut } from "@kura-ui/react/kbd-shortcut";
import { CommandPalette, type CommandPaletteItem } from "@kura-ui/react/command-palette";

function buildItems(log: (s: string) => void): CommandPaletteItem[] {
  return [
    {
      id: "run-agent",
      label: "Run agent",
      description: "on the current task",
      icon: <Play />,
      group: "Agent",
      shortcut: ["mod", "enter"],
      onSelect: () => log("Agent started"),
    },
    {
      id: "stop-agent",
      label: "Stop agent",
      icon: <Square />,
      group: "Agent",
      shortcut: ["mod", "."],
      onSelect: () => log("Agent stopped"),
    },
    {
      id: "switch-agent",
      label: "Switch agent…",
      icon: <Bot />,
      group: "Agent",
      placeholder: "Pick an agent…",
      children: [
        {
          id: "agent-research",
          label: "Researcher",
          description: "web + citations",
          onSelect: () => log("Researcher"),
        },
        {
          id: "agent-coder",
          label: "Coder",
          description: "repo + terminal",
          onSelect: () => log("Coder"),
        },
        {
          id: "agent-analyst",
          label: "Analyst",
          description: "SQL + charts",
          onSelect: () => log("Analyst"),
        },
      ],
    },
    {
      id: "workflows",
      label: "Run workflow…",
      icon: <Workflow />,
      group: "Agent",
      loadChildren: async () => {
        await wait(1200);
        return [
          { id: "wf-triage", label: "Triage new issues", onSelect: () => log("Triage running") },
          { id: "wf-digest", label: "Weekly digest", onSelect: () => log("Digest running") },
          { id: "wf-migrate", label: "Migrate schema", disabled: true },
        ];
      },
    },
    {
      id: "context-repo",
      label: "Add repository",
      icon: <FolderGit2 />,
      group: "Context",
      keywords: ["github", "git"],
      onSelect: () => log("Repository added"),
    },
    {
      id: "context-db",
      label: "Connect database",
      icon: <Database />,
      group: "Context",
      keywords: ["postgres", "sql"],
      onSelect: () => log("Database connected"),
    },
    {
      id: "theme",
      label: "Change theme…",
      icon: <Palette />,
      group: "Settings",
      children: [
        { id: "theme-light", label: "Light", icon: <Sun />, onSelect: () => log("Light theme") },
        { id: "theme-dark", label: "Dark", icon: <Moon />, onSelect: () => log("Dark theme") },
        {
          id: "theme-system",
          label: "System",
          icon: <Monitor />,
          onSelect: () => log("System theme"),
        },
      ],
    },
    {
      id: "keys",
      label: "API keys",
      icon: <KeyRound />,
      group: "Settings",
      shortcut: ["mod", ","],
      onSelect: () => log("API keys"),
    },
  ];
}

const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));

export function CommandPaletteFull() {
  const [open, setOpen] = React.useState(false);
  const [last, setLast] = React.useState("Nothing run yet");
  const [recent, setRecent] = React.useState<string[]>(["context-repo", "run-agent"]);
  const items = React.useMemo(() => buildItems(setLast), []);
  return (
    <div className="flex flex-col items-center gap-3">
      <Button variant="secondary" onClick={() => setOpen(true)}>
        Open palette
      </Button>
      <CommandPalette
        items={items}
        open={open}
        onOpenChange={setOpen}
        hotkey={["mod", "k"]}
        recent={recent}
        onRecentChange={setRecent}
        recentLimit={3}
        placeholder="Run an agent, add context, change settings…"
        emptyMessage="No commands match"
        label="Agent commands"
      />
      <KbdShortcut keys={["mod", "k"]} label="or press" />
      <p className="text-kura-fg-subtle text-xs" aria-live="polite">
        {last}
      </p>
    </div>
  );
}
```

### Server-side search (composition)

onQueryChange fetches results into `items` while `loading` shows skeleton rows.

```tsx
"use client";

import { FileSearch } from "lucide-react";
import * as React from "react";
import { CommandPalette, type CommandPaletteItem, CommandPaletteTrigger } from "@kura-ui/react/command-palette";

const files = [
  "src/app/page.tsx",
  "src/app/layout.tsx",
  "src/lib/auth.ts",
  "src/lib/billing/invoice.ts",
  "src/components/chat/composer.tsx",
  "src/components/chat/message-list.tsx",
  "README.md",
];

export function CommandPaletteAsyncSearch() {
  const [results, setResults] = React.useState<CommandPaletteItem[]>([]);
  const [loading, setLoading] = React.useState(false);
  const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);

  const search = (q: string) => {
    if (timer.current) clearTimeout(timer.current);
    if (!q.trim()) {
      setResults([]);
      setLoading(false);
      return;
    }
    setLoading(true);
    timer.current = setTimeout(() => {
      setResults(
        files
          .filter((f) => f.toLowerCase().includes(q.toLowerCase().split(" ")[0] ?? ""))
          .map((f) => ({
            id: f,
            label: f.split("/").pop() ?? f,
            description: f,
            icon: <FileSearch />,
            group: "Files",
          })),
      );
      setLoading(false);
    }, 500);
  };

  return (
    <div className="w-80">
      <CommandPalette
        hotkey={null}
        trigger={
          <CommandPaletteTrigger placeholder="Search the codebase…" shortcut={["mod", "p"]} />
        }
        items={results}
        loading={loading}
        onQueryChange={search}
        placeholder="Type a file name…"
        emptyMessage="Type to search 7 files"
      />
    </div>
  );
}
```

## Dependencies

- npm: `lucide-react`, `radix-ui`
- Kura registry items: `kura-theme`, `kbd`, `kura-keys`, `use-controllable-state`, `use-hotkey`, `utils`
- Related: `slash-command-menu`, `model-picker`, `kbd-shortcut`, `prompt-suggestions`
