"use client";
import * as React from "react";
import {
ArrowLeftIcon,
ArrowTopRightOnSquareIcon,
BookOpenIcon,
ChatBubbleLeftRightIcon,
Cog6ToothIcon,
DocumentDuplicateIcon,
DocumentTextIcon,
MagnifyingGlassIcon,
PlusIcon,
SparklesIcon,
UserIcon,
} from "@heroicons-animated/react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
import { CommandMenu, CommandMenuEmpty, CommandMenuFooter, CommandMenuGroup, CommandMenuInput, CommandMenuItem, CommandMenuList, useCommandMenuShortcut } from "@/components/pandacoderz-ui/command-menu";
import { MessageMarkdown } from "@/components/pandacoderz-ui/message";
import { TextShimmer } from "@/components/pandacoderz-ui/text-shimmer";
import { useStreamText } from "@/lib/use-stream-text";
const pages = [
{ title: "Getting started", path: "/docs" },
{ title: "Installation", path: "/docs/installation" },
{ title: "Prompt Input", path: "/docs/components/prompt-input" },
{ title: "Message", path: "/docs/components/message" },
{ title: "AI Chat block", path: "/docs/blocks/ai-chat" },
];
const people = ["Maya Chen", "Jordan Alvarez", "Priya Raman"];
const answers: { match: RegExp; text: string; actions: { label: string; path?: string }[] }[] = [
{ match: /stream|token/i, text: `Yes. Wrap the conversation in **Thread** so the viewport follows new tokens, and append deltas with a functional state update:\n\n\`\`\`ts\nsetText((prev) => prev + chunk);\n\`\`\`\n\nThe Message component's markdown renderer handles partial input while text streams.`, actions: [{ label: "Open Usage guide", path: "/docs/usage" }, { label: "Open Thread docs", path: "/docs/components/thread" }] },
{ match: /install|add|cli/i, text: `Install any item with the shadcn CLI:\n\n\`\`\`bash\nnpx shadcn@latest add https://ui.pandacoderz.dev/r/prompt-input.json\n\`\`\`\n\nBlocks pull in every component they depend on automatically.`, actions: [{ label: "Open Installation", path: "/docs/installation" }] },
{ match: /.*/, text: `This library is a set of composable React components for AI interfaces. Components are small families of parts sharing context, and blocks compose them into full screens like chat, agent runs, and answers with sources.\n\nTry asking about **streaming** or **installing**.`, actions: [{ label: "Browse components", path: "/docs/components" }, { label: "Browse blocks", path: "/docs/blocks" }] },
];
export type CommandPaletteProps = { className?: string };
export default function CommandPalette({ className }: CommandPaletteProps) {
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const [mode, setMode] = React.useState<"list" | "answer">("list");
const [question, setQuestion] = React.useState("");
const [actions, setActions] = React.useState<{ label: string; path?: string }[]>([]);
const [lastAction, setLastAction] = React.useState<string | null>(null);
const { text, isStreaming, start, reset } = useStreamText(12);
const toggle = React.useCallback(() => setOpen((o) => !o), []);
useCommandMenuShortcut(toggle);
React.useEffect(() => {
if (!open) {
setMode("list");
reset();
}
}, [open, reset]);
const ask = (q: string) => {
const found = answers.find((a) => a.match.test(q)) ?? answers.at(-1)!;
setQuestion(q);
setActions(found.actions);
setMode("answer");
void start(found.text);
};
const run = (label: string) => {
setLastAction(label);
setOpen(false);
};
return (
<div data-slot="command-palette" className={cn("relative flex h-full min-h-0 w-full flex-col overflow-hidden rounded-3xl border bg-background shadow-xs", className)}>
{/* Host app chrome, so the palette has something to sit on top of. */}
<header className="flex h-12 shrink-0 items-center justify-between gap-3 border-b px-4">
<div className="flex items-center gap-2 text-sm font-medium">
<span className="flex size-6 items-center justify-center rounded-md bg-brand text-primary-foreground"><BookOpenIcon size={14} className="flex" /></span>
Docs
</div>
<button type="button" onClick={() => setOpen(true)} className="flex h-8 w-64 items-center gap-2 rounded-full border bg-surface px-3 text-xs text-muted-foreground transition-colors hover:bg-accent">
<MagnifyingGlassIcon size={14} className="flex" />
<span className="flex-1 text-left">Search or ask AI…</span>
<KbdGroup><Kbd>⌘</Kbd><Kbd>K</Kbd></KbdGroup>
</button>
</header>
<div className="grid min-h-0 flex-1 md:grid-cols-[minmax(0,14rem)_minmax(0,1fr)]">
<nav className="hidden border-r bg-surface p-3 md:block">
<ul className="flex flex-col gap-0.5 text-sm">
{pages.map((p, i) => <li key={p.path}><span className={cn("block rounded-md px-2 py-1.5", i === 2 ? "bg-brand-soft/60 font-medium text-brand" : "text-muted-foreground")}>{p.title}</span></li>)}
</ul>
</nav>
<article className="min-h-0 overflow-y-auto p-8">
<h1 className="text-2xl font-semibold tracking-tight">Prompt Input</h1>
<p className="mt-2 max-w-prose text-sm leading-6.5 text-muted-foreground">Composable chat input with an auto-resizing textarea and action slots. Press <Kbd>⌘</Kbd> <Kbd>K</Kbd> anywhere in this page to search or ask the assistant a question about it.</p>
<div className="mt-6 space-y-3 opacity-60">
{[100, 88, 94, 70, 96, 60].map((w, i) => <div key={i} className="h-3 rounded bg-muted" style={{ width: `${w}%` }} />)}
</div>
{lastAction ? <p className="mt-6 rounded-xl border bg-card px-3 py-2 text-xs text-muted-foreground">Last palette action: <span className="text-foreground">{lastAction}</span></p> : null}
</article>
</div>
<CommandMenu open={open} onOpenChange={setOpen} query={query} onQueryChange={setQuery}>
{mode === "list" ? (
<>
<CommandMenuInput
placeholder="Search pages, people, or ask a question…"
onKeyDown={(e) => {
if (e.key === "Enter" && query.trim().length > 0 && /\?$|^(how|what|why|should|can|does)\b/i.test(query.trim())) {
e.preventDefault();
ask(query.trim());
}
}}
/>
<CommandMenuList>
<CommandMenuGroup heading="Ask">
<CommandMenuItem alwaysVisible icon={<SparklesIcon size={16} className="flex" />} onSelect={() => ask(query.trim() || "What is this library?")}>
{query.trim() ? <>Ask AI: <span className="text-muted-foreground">“{query.trim()}”</span></> : "Ask AI about this page…"}
</CommandMenuItem>
</CommandMenuGroup>
<CommandMenuGroup heading="Pages">
{pages.map((p) => <CommandMenuItem key={p.path} icon={<DocumentTextIcon size={16} className="flex" />} onSelect={() => run(`Open ${p.title}`)}>{p.title}</CommandMenuItem>)}
</CommandMenuGroup>
<CommandMenuGroup heading="Actions">
<CommandMenuItem icon={<PlusIcon size={16} className="flex" />} shortcut="N" onSelect={() => run("New conversation")}>New conversation</CommandMenuItem>
<CommandMenuItem icon={<ChatBubbleLeftRightIcon size={16} className="flex" />} onSelect={() => run("Open chat")}>Open chat</CommandMenuItem>
<CommandMenuItem icon={<Cog6ToothIcon size={16} className="flex" />} shortcut="," onSelect={() => run("Settings")}>Settings</CommandMenuItem>
</CommandMenuGroup>
<CommandMenuGroup heading="People">
{people.map((p) => <CommandMenuItem key={p} icon={<UserIcon size={16} className="flex" />} onSelect={() => run(`Open ${p}`)}>{p}</CommandMenuItem>)}
</CommandMenuGroup>
<CommandMenuEmpty>No matches. Press Enter to ask AI instead.</CommandMenuEmpty>
</CommandMenuList>
<CommandMenuFooter>
<span><Kbd>↑↓</Kbd> navigate</span><span><Kbd>↵</Kbd> select</span><span className="ml-auto">End with ? to ask AI</span>
</CommandMenuFooter>
</>
) : (
<div className="flex flex-col">
<div className="flex h-12 items-center gap-2 border-b px-3">
<Button size="icon-sm" variant="ghost" className="rounded-full" aria-label="Back" onClick={() => { setMode("list"); reset(); }}><ArrowLeftIcon size={16} className="flex" /></Button>
<SparklesIcon size={14} className="flex text-brand" />
<span className="truncate text-sm">{question}</span>
</div>
<div className="max-h-[50vh] overflow-y-auto px-4 py-3">
{text ? <MessageMarkdown isAnimating={isStreaming} className="text-[13px]">{text}</MessageMarkdown> : <TextShimmer className="text-sm text-muted-foreground" invertLight>Thinking…</TextShimmer>}
</div>
{!isStreaming && text ? (
<div className="flex flex-wrap items-center gap-1.5 border-t p-3">
{actions.map((a) => <Button key={a.label} size="xs" variant="outline" className="rounded-full" onClick={() => run(a.label)}><ArrowTopRightOnSquareIcon size={12} className="flex" /> {a.label}</Button>)}
<Button size="xs" variant="ghost" className="ml-auto rounded-full" onClick={async () => { try { await navigator.clipboard.writeText(text); } catch {} run("Copied answer"); }}><DocumentDuplicateIcon size={12} className="flex" /> Copy</Button>
</div>
) : null}
</div>
)}
</CommandMenu>
</div>
);
}"use client";
import * as React from "react";
import { streamText } from "./mock-stream";
/**
* Drive a piece of text onto the screen as if it were streaming from a model.
* `start(text)` clears and streams; `stop()` freezes what has arrived.
*/
export function useStreamText(delayMs = 14) {
const [text, setText] = React.useState("");
const [isStreaming, setIsStreaming] = React.useState(false);
const abortRef = React.useRef<AbortController | null>(null);
const stop = React.useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setIsStreaming(false);
}, []);
const start = React.useCallback(
async (full: string, onDone?: () => void) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setText("");
setIsStreaming(true);
try {
for await (const piece of streamText(full, controller.signal, delayMs)) {
setText((prev) => prev + piece);
}
if (!controller.signal.aborted) onDone?.();
} catch {
/* aborted */
} finally {
if (abortRef.current === controller) {
abortRef.current = null;
setIsStreaming(false);
}
}
},
[delayMs],
);
const reset = React.useCallback(() => {
stop();
setText("");
}, [stop]);
React.useEffect(() => () => abortRef.current?.abort(), []);
return { text, isStreaming, start, stop, reset, setText };
}What’s inside
- Command Menu with pages, actions, and people groups plus an always-visible Ask AI item.
- Answer mode streams a markdown reply and offers follow-up actions.
- Host chrome mocks a docs page so the palette has context.
Installation
npx shadcn@latest add https://ui.spencerwueste.com/r/command-palette.jsonpnpm dlx shadcn@latest add https://ui.spencerwueste.com/r/command-palette.jsonyarn dlx shadcn@latest add https://ui.spencerwueste.com/r/command-palette.jsonbunx --bun shadcn@latest add https://ui.spencerwueste.com/r/command-palette.jsonInstall the dependencies:
npm install radix-ui @heroicons-animated/react motionAdd the shadcn primitives it builds on:
npx shadcn@latest add button kbdCopy the source into your project:
"use client";
import * as React from "react";
import {
ArrowLeftIcon,
ArrowTopRightOnSquareIcon,
BookOpenIcon,
ChatBubbleLeftRightIcon,
Cog6ToothIcon,
DocumentDuplicateIcon,
DocumentTextIcon,
MagnifyingGlassIcon,
PlusIcon,
SparklesIcon,
UserIcon,
} from "@heroicons-animated/react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
import { CommandMenu, CommandMenuEmpty, CommandMenuFooter, CommandMenuGroup, CommandMenuInput, CommandMenuItem, CommandMenuList, useCommandMenuShortcut } from "@/components/pandacoderz-ui/command-menu";
import { MessageMarkdown } from "@/components/pandacoderz-ui/message";
import { TextShimmer } from "@/components/pandacoderz-ui/text-shimmer";
import { useStreamText } from "@/lib/use-stream-text";
const pages = [
{ title: "Getting started", path: "/docs" },
{ title: "Installation", path: "/docs/installation" },
{ title: "Prompt Input", path: "/docs/components/prompt-input" },
{ title: "Message", path: "/docs/components/message" },
{ title: "AI Chat block", path: "/docs/blocks/ai-chat" },
];
const people = ["Maya Chen", "Jordan Alvarez", "Priya Raman"];
const answers: { match: RegExp; text: string; actions: { label: string; path?: string }[] }[] = [
{ match: /stream|token/i, text: `Yes. Wrap the conversation in **Thread** so the viewport follows new tokens, and append deltas with a functional state update:\n\n\`\`\`ts\nsetText((prev) => prev + chunk);\n\`\`\`\n\nThe Message component's markdown renderer handles partial input while text streams.`, actions: [{ label: "Open Usage guide", path: "/docs/usage" }, { label: "Open Thread docs", path: "/docs/components/thread" }] },
{ match: /install|add|cli/i, text: `Install any item with the shadcn CLI:\n\n\`\`\`bash\nnpx shadcn@latest add https://ui.pandacoderz.dev/r/prompt-input.json\n\`\`\`\n\nBlocks pull in every component they depend on automatically.`, actions: [{ label: "Open Installation", path: "/docs/installation" }] },
{ match: /.*/, text: `This library is a set of composable React components for AI interfaces. Components are small families of parts sharing context, and blocks compose them into full screens like chat, agent runs, and answers with sources.\n\nTry asking about **streaming** or **installing**.`, actions: [{ label: "Browse components", path: "/docs/components" }, { label: "Browse blocks", path: "/docs/blocks" }] },
];
export type CommandPaletteProps = { className?: string };
export default function CommandPalette({ className }: CommandPaletteProps) {
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const [mode, setMode] = React.useState<"list" | "answer">("list");
const [question, setQuestion] = React.useState("");
const [actions, setActions] = React.useState<{ label: string; path?: string }[]>([]);
const [lastAction, setLastAction] = React.useState<string | null>(null);
const { text, isStreaming, start, reset } = useStreamText(12);
const toggle = React.useCallback(() => setOpen((o) => !o), []);
useCommandMenuShortcut(toggle);
React.useEffect(() => {
if (!open) {
setMode("list");
reset();
}
}, [open, reset]);
const ask = (q: string) => {
const found = answers.find((a) => a.match.test(q)) ?? answers.at(-1)!;
setQuestion(q);
setActions(found.actions);
setMode("answer");
void start(found.text);
};
const run = (label: string) => {
setLastAction(label);
setOpen(false);
};
return (
<div data-slot="command-palette" className={cn("relative flex h-full min-h-0 w-full flex-col overflow-hidden rounded-3xl border bg-background shadow-xs", className)}>
{/* Host app chrome, so the palette has something to sit on top of. */}
<header className="flex h-12 shrink-0 items-center justify-between gap-3 border-b px-4">
<div className="flex items-center gap-2 text-sm font-medium">
<span className="flex size-6 items-center justify-center rounded-md bg-brand text-primary-foreground"><BookOpenIcon size={14} className="flex" /></span>
Docs
</div>
<button type="button" onClick={() => setOpen(true)} className="flex h-8 w-64 items-center gap-2 rounded-full border bg-surface px-3 text-xs text-muted-foreground transition-colors hover:bg-accent">
<MagnifyingGlassIcon size={14} className="flex" />
<span className="flex-1 text-left">Search or ask AI…</span>
<KbdGroup><Kbd>⌘</Kbd><Kbd>K</Kbd></KbdGroup>
</button>
</header>
<div className="grid min-h-0 flex-1 md:grid-cols-[minmax(0,14rem)_minmax(0,1fr)]">
<nav className="hidden border-r bg-surface p-3 md:block">
<ul className="flex flex-col gap-0.5 text-sm">
{pages.map((p, i) => <li key={p.path}><span className={cn("block rounded-md px-2 py-1.5", i === 2 ? "bg-brand-soft/60 font-medium text-brand" : "text-muted-foreground")}>{p.title}</span></li>)}
</ul>
</nav>
<article className="min-h-0 overflow-y-auto p-8">
<h1 className="text-2xl font-semibold tracking-tight">Prompt Input</h1>
<p className="mt-2 max-w-prose text-sm leading-6.5 text-muted-foreground">Composable chat input with an auto-resizing textarea and action slots. Press <Kbd>⌘</Kbd> <Kbd>K</Kbd> anywhere in this page to search or ask the assistant a question about it.</p>
<div className="mt-6 space-y-3 opacity-60">
{[100, 88, 94, 70, 96, 60].map((w, i) => <div key={i} className="h-3 rounded bg-muted" style={{ width: `${w}%` }} />)}
</div>
{lastAction ? <p className="mt-6 rounded-xl border bg-card px-3 py-2 text-xs text-muted-foreground">Last palette action: <span className="text-foreground">{lastAction}</span></p> : null}
</article>
</div>
<CommandMenu open={open} onOpenChange={setOpen} query={query} onQueryChange={setQuery}>
{mode === "list" ? (
<>
<CommandMenuInput
placeholder="Search pages, people, or ask a question…"
onKeyDown={(e) => {
if (e.key === "Enter" && query.trim().length > 0 && /\?$|^(how|what|why|should|can|does)\b/i.test(query.trim())) {
e.preventDefault();
ask(query.trim());
}
}}
/>
<CommandMenuList>
<CommandMenuGroup heading="Ask">
<CommandMenuItem alwaysVisible icon={<SparklesIcon size={16} className="flex" />} onSelect={() => ask(query.trim() || "What is this library?")}>
{query.trim() ? <>Ask AI: <span className="text-muted-foreground">“{query.trim()}”</span></> : "Ask AI about this page…"}
</CommandMenuItem>
</CommandMenuGroup>
<CommandMenuGroup heading="Pages">
{pages.map((p) => <CommandMenuItem key={p.path} icon={<DocumentTextIcon size={16} className="flex" />} onSelect={() => run(`Open ${p.title}`)}>{p.title}</CommandMenuItem>)}
</CommandMenuGroup>
<CommandMenuGroup heading="Actions">
<CommandMenuItem icon={<PlusIcon size={16} className="flex" />} shortcut="N" onSelect={() => run("New conversation")}>New conversation</CommandMenuItem>
<CommandMenuItem icon={<ChatBubbleLeftRightIcon size={16} className="flex" />} onSelect={() => run("Open chat")}>Open chat</CommandMenuItem>
<CommandMenuItem icon={<Cog6ToothIcon size={16} className="flex" />} shortcut="," onSelect={() => run("Settings")}>Settings</CommandMenuItem>
</CommandMenuGroup>
<CommandMenuGroup heading="People">
{people.map((p) => <CommandMenuItem key={p} icon={<UserIcon size={16} className="flex" />} onSelect={() => run(`Open ${p}`)}>{p}</CommandMenuItem>)}
</CommandMenuGroup>
<CommandMenuEmpty>No matches. Press Enter to ask AI instead.</CommandMenuEmpty>
</CommandMenuList>
<CommandMenuFooter>
<span><Kbd>↑↓</Kbd> navigate</span><span><Kbd>↵</Kbd> select</span><span className="ml-auto">End with ? to ask AI</span>
</CommandMenuFooter>
</>
) : (
<div className="flex flex-col">
<div className="flex h-12 items-center gap-2 border-b px-3">
<Button size="icon-sm" variant="ghost" className="rounded-full" aria-label="Back" onClick={() => { setMode("list"); reset(); }}><ArrowLeftIcon size={16} className="flex" /></Button>
<SparklesIcon size={14} className="flex text-brand" />
<span className="truncate text-sm">{question}</span>
</div>
<div className="max-h-[50vh] overflow-y-auto px-4 py-3">
{text ? <MessageMarkdown isAnimating={isStreaming} className="text-[13px]">{text}</MessageMarkdown> : <TextShimmer className="text-sm text-muted-foreground" invertLight>Thinking…</TextShimmer>}
</div>
{!isStreaming && text ? (
<div className="flex flex-wrap items-center gap-1.5 border-t p-3">
{actions.map((a) => <Button key={a.label} size="xs" variant="outline" className="rounded-full" onClick={() => run(a.label)}><ArrowTopRightOnSquareIcon size={12} className="flex" /> {a.label}</Button>)}
<Button size="xs" variant="ghost" className="ml-auto rounded-full" onClick={async () => { try { await navigator.clipboard.writeText(text); } catch {} run("Copied answer"); }}><DocumentDuplicateIcon size={12} className="flex" /> Copy</Button>
</div>
) : null}
</div>
)}
</CommandMenu>
</div>
);
}"use client";
import * as React from "react";
import { streamText } from "./mock-stream";
/**
* Drive a piece of text onto the screen as if it were streaming from a model.
* `start(text)` clears and streams; `stop()` freezes what has arrived.
*/
export function useStreamText(delayMs = 14) {
const [text, setText] = React.useState("");
const [isStreaming, setIsStreaming] = React.useState(false);
const abortRef = React.useRef<AbortController | null>(null);
const stop = React.useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setIsStreaming(false);
}, []);
const start = React.useCallback(
async (full: string, onDone?: () => void) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setText("");
setIsStreaming(true);
try {
for await (const piece of streamText(full, controller.signal, delayMs)) {
setText((prev) => prev + piece);
}
if (!controller.signal.aborted) onDone?.();
} catch {
/* aborted */
} finally {
if (abortRef.current === controller) {
abortRef.current = null;
setIsStreaming(false);
}
}
},
[delayMs],
);
const reset = React.useCallback(() => {
stop();
setText("");
}, [stop]);
React.useEffect(() => () => abortRef.current?.abort(), []);
return { text, isStreaming, start, stop, reset, setText };
}The registry item pulls in every component it depends on.
Usage
import CommandPalette from "@/components/blocks/command-palette/command-palette";
export default function Page() {
return (
<div className="h-dvh p-4">
<CommandPalette />
</div>
);
}Give the parent a bounded height. The block fills it and scrolls internally.
Going live
Populate the groups from your router and data, and swap answers for a call to your model with the current page as context. Questions ending in a question mark route straight to answer mode on Enter.