{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "prompt-queue",
  "type": "registry:ui",
  "title": "Prompt Queue",
  "description": "Stack of follow-up prompts queued while an agent works: reorder, edit inline, remove, or Send now to steer. Renders nothing when empty.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://kuro.abstergo.fyi/r/kura-theme.json",
    "https://kuro.abstergo.fyi/r/button.json",
    "https://kuro.abstergo.fyi/r/icon-button.json",
    "https://kuro.abstergo.fyi/r/kura-motion.json",
    "https://kuro.abstergo.fyi/r/use-controllable-state.json",
    "https://kuro.abstergo.fyi/r/use-reduced-motion.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/kura/prompt-queue/index.ts",
      "type": "registry:ui",
      "target": "components/kura/prompt-queue/index.ts",
      "content": "export {\n  PromptQueue,\n  type PromptQueueProps,\n  type QueuedPrompt,\n  type QueuedPromptAttachment,\n} from \"./prompt-queue\";\n"
    },
    {
      "path": "registry/kura/prompt-queue/prompt-queue.tsx",
      "type": "registry:ui",
      "target": "components/kura/prompt-queue/prompt-queue.tsx",
      "content": "\"use client\";\n\nimport {\n  ArrowDown,\n  ArrowUp,\n  FileText,\n  GripVertical,\n  Image as ImageIcon,\n  Pencil,\n  SendHorizontal,\n  X,\n} from \"lucide-react\";\nimport { Reorder, useDragControls } from \"motion/react\";\nimport * as React from \"react\";\nimport { useControllableState } from \"@/hooks/kura/use-controllable-state\";\nimport { useReducedMotion } from \"@/hooks/kura/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { springs } from \"@/lib/kura/motion\";\nimport { Button } from \"@/components/kura/button\";\nimport { IconButton } from \"@/components/kura/icon-button\";\n\nexport interface QueuedPromptAttachment {\n  /** File name, e.g. \"session-flow.png\". */\n  name: string;\n  /** Picks the icon. Default \"file\". */\n  type?: \"image\" | \"file\";\n}\n\nexport interface QueuedPrompt {\n  /** Stable id. */\n  id: string;\n  /** The prompt text. */\n  text: string;\n  attachments?: QueuedPromptAttachment[];\n}\n\nexport interface PromptQueueProps\n  extends Omit<React.ComponentProps<\"div\">, \"title\" | \"children\" | \"defaultValue\"> {\n  /** Queued prompts in send order (controlled). */\n  items?: QueuedPrompt[];\n  /** Initial prompts when uncontrolled. */\n  defaultItems?: QueuedPrompt[];\n  /** Called with the new list after a reorder, edit or remove. */\n  onItemsChange?: (items: QueuedPrompt[]) => void;\n  /** Steer: interrupt the agent and send this prompt now. The prompt leaves the queue first. */\n  onSendNow?: (item: QueuedPrompt) => void;\n  /** Header title. Default \"Queued\". */\n  title?: React.ReactNode;\n  /** Secondary text after the title. Default \"Sends when the agent finishes this turn\". */\n  hint?: React.ReactNode;\n  /** `panel` = a standalone card; `tray` = rounded on top only, to sit flush on a composer. Default \"panel\". */\n  variant?: \"panel\" | \"tray\";\n}\n\n/**\n * Follow-up prompts queued while an agent works, as a compact stack above the composer. Each\n * prompt can be reordered (buttons, Alt+↑/↓, Space-to-grab or drag), edited inline, removed, or\n * sent right away to steer the agent. Renders nothing when the queue is empty.\n */\nexport function PromptQueue({\n  items: itemsProp,\n  defaultItems,\n  onItemsChange,\n  onSendNow,\n  title = \"Queued\",\n  hint = \"Sends when the agent finishes this turn\",\n  variant = \"panel\",\n  className,\n  ...props\n}: PromptQueueProps) {\n  const [items, setItems] = useControllableState<QueuedPrompt[]>({\n    value: itemsProp,\n    defaultValue: defaultItems ?? [],\n    onChange: onItemsChange,\n  });\n  const reduced = useReducedMotion();\n  const id = React.useId();\n  const [editingId, setEditingId] = React.useState<string | null>(null);\n  const [message, setMessage] = React.useState(\"\");\n  const rows = React.useRef(new Map<string, HTMLElement>());\n\n  const focusRow = (rowId: string | undefined) => {\n    if (!rowId) return;\n    requestAnimationFrame(() => rows.current.get(rowId)?.focus());\n  };\n\n  const move = (index: number, by: -1 | 1) => {\n    const to = index + by;\n    if (to < 0 || to >= items.length) return;\n    const next = items.slice();\n    const [it] = next.splice(index, 1);\n    if (!it) return;\n    next.splice(to, 0, it);\n    setItems(next);\n    setMessage(`Moved to position ${to + 1} of ${items.length}`);\n  };\n\n  const remove = (index: number, refocus: boolean) => {\n    const it = items[index];\n    if (!it) return;\n    const next = items.filter((_, i) => i !== index);\n    setItems(next);\n    setMessage(`Removed “${truncate(it.text)}”. ${next.length} queued.`);\n    if (refocus) focusRow((next[index] ?? next[index - 1])?.id);\n  };\n\n  const sendNow = (index: number) => {\n    const it = items[index];\n    if (!it) return;\n    setItems(items.filter((_, i) => i !== index));\n    setMessage(`Sending “${truncate(it.text)}” now`);\n    onSendNow?.(it);\n  };\n\n  const save = (index: number, text: string) => {\n    const it = items[index];\n    const t = text.trim();\n    setEditingId(null);\n    if (!it) return;\n    if (!t) return remove(index, true);\n    if (t !== it.text) setItems(items.map((x, i) => (i === index ? { ...x, text: t } : x)));\n    focusRow(it.id);\n  };\n\n  if (items.length === 0) {\n    return (\n      <span role=\"status\" className=\"sr-only\">\n        {message}\n      </span>\n    );\n  }\n\n  return (\n    <div\n      data-slot=\"prompt-queue\"\n      data-variant={variant}\n      className={cn(\n        \"@container flex min-w-0 flex-col overflow-hidden font-kura-sans text-kura-fg\",\n        variant === \"panel\"\n          ? \"rounded-kura-xl bg-kura-surface shadow-kura-sm\"\n          : \"rounded-t-kura-xl bg-kura-surface-sunken shadow-[inset_0_0_0_1px_var(--kura-border)]\",\n        className,\n      )}\n      {...props}\n    >\n      <div\n        data-slot=\"prompt-queue-header\"\n        className=\"flex h-9 shrink-0 items-center gap-2 border-kura-border-subtle border-b px-3\"\n      >\n        <h3\n          id={`${id}-title`}\n          className=\"flex shrink-0 items-baseline gap-1.5 font-medium text-[13px]\"\n        >\n          {title}\n          <span className=\"font-kura-mono font-normal text-[12px] text-kura-fg-subtle tabular-nums\">\n            {items.length}\n          </span>\n        </h3>\n        {hint != null && (\n          <span\n            data-slot=\"prompt-queue-hint\"\n            className=\"min-w-0 truncate text-[12px] text-kura-fg-subtle\"\n          >\n            {hint}\n          </span>\n        )}\n      </div>\n      <Reorder.Group\n        as=\"ol\"\n        axis=\"y\"\n        values={items.map((it) => it.id)}\n        onReorder={(ids: string[]) => {\n          const byId = new Map(items.map((it) => [it.id, it]));\n          setItems(ids.map((i) => byId.get(i)).filter((x): x is QueuedPrompt => x != null));\n        }}\n        aria-labelledby={`${id}-title`}\n        aria-describedby={`${id}-help`}\n        className=\"flex flex-col py-1\"\n      >\n        {items.map((item, index) => (\n          <QueueRow\n            key={item.id}\n            item={item}\n            index={index}\n            count={items.length}\n            reduced={reduced}\n            editing={editingId === item.id}\n            canSend={onSendNow != null}\n            rowRef={(el) => {\n              if (el) rows.current.set(item.id, el);\n              else rows.current.delete(item.id);\n            }}\n            onMove={(by) => move(index, by)}\n            onEdit={() => setEditingId(item.id)}\n            onCancelEdit={() => {\n              setEditingId(null);\n              focusRow(item.id);\n            }}\n            onSave={(text) => save(index, text)}\n            onRemove={(refocus) => remove(index, refocus)}\n            onSendNow={() => sendNow(index)}\n          />\n        ))}\n      </Reorder.Group>\n      <p id={`${id}-help`} className=\"sr-only\">\n        Alt plus arrow keys or Space then arrows to reorder. Enter to edit, Delete to remove\n        {onSendNow ? \", Control or Command plus Enter to send now\" : \"\"}.\n      </p>\n      <span role=\"status\" className=\"sr-only\">\n        {message}\n      </span>\n    </div>\n  );\n}\n\nfunction truncate(text: string, n = 48) {\n  return text.length > n ? `${text.slice(0, n - 1)}…` : text;\n}\n\ninterface QueueRowProps {\n  item: QueuedPrompt;\n  index: number;\n  count: number;\n  reduced: boolean;\n  editing: boolean;\n  canSend: boolean;\n  rowRef: (el: HTMLElement | null) => void;\n  onMove: (by: -1 | 1) => void;\n  onEdit: () => void;\n  onCancelEdit: () => void;\n  onSave: (text: string) => void;\n  onRemove: (refocus: boolean) => void;\n  onSendNow: () => void;\n}\n\nfunction QueueRow({\n  item,\n  index,\n  count,\n  reduced,\n  editing,\n  canSend,\n  rowRef,\n  onMove,\n  onEdit,\n  onCancelEdit,\n  onSave,\n  onRemove,\n  onSendNow,\n}: QueueRowProps) {\n  const controls = useDragControls();\n  const [dragging, setDragging] = React.useState(false);\n  const attachments = item.attachments ?? [];\n  const firstAttachment = attachments[0];\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLLIElement>) => {\n    if (e.target !== e.currentTarget || editing) return;\n    const mod = e.metaKey || e.ctrlKey;\n    if (e.altKey && (e.key === \"ArrowUp\" || e.key === \"ArrowDown\")) {\n      onMove(e.key === \"ArrowUp\" ? -1 : 1);\n    } else if (mod && e.key === \"Enter\" && canSend) {\n      onSendNow();\n    } else if (!mod && !e.altKey && (e.key === \"Enter\" || e.key === \"F2\")) {\n      onEdit();\n    } else if (e.key === \"Delete\" || e.key === \"Backspace\") {\n      onRemove(true);\n    } else return;\n    e.preventDefault();\n  };\n\n  return (\n    <Reorder.Item\n      as=\"li\"\n      value={item.id}\n      ref={rowRef}\n      dragListener={false}\n      dragControls={controls}\n      layout=\"position\"\n      transition={reduced ? { duration: 0 } : springs.smooth}\n      onDragStart={() => setDragging(true)}\n      onDragEnd={() => setDragging(false)}\n      onKeyDown={onKeyDown}\n      aria-label={`${index + 1} of ${count}: ${item.text}`}\n      aria-keyshortcuts=\"Alt+ArrowUp Alt+ArrowDown Enter Delete\"\n      data-slot=\"prompt-queue-item\"\n      data-state={editing ? \"editing\" : dragging ? \"dragging\" : undefined}\n      className={cn(\n        \"group/item relative mx-1 flex min-w-0 rounded-kura-md bg-transparent outline-none\",\n        \"focus-visible:shadow-[inset_0_0_0_2px_var(--kura-ring)]\",\n        !editing && \"min-h-8 items-center gap-1 pr-1 pl-1 hover:bg-kura-hover\",\n        editing && \"bg-kura-hover p-1.5\",\n        dragging && \"bg-kura-surface-raised shadow-kura-md\",\n      )}\n    >\n      {editing ? (\n        <EditBox\n          initial={item.text}\n          onCancel={onCancelEdit}\n          onSave={onSave}\n          label={`Edit queued prompt ${index + 1}`}\n        />\n      ) : (\n        <>\n          <span className=\"relative grid size-6 shrink-0 place-items-center\">\n            <span\n              aria-hidden\n              className=\"font-kura-mono text-[12px] text-kura-fg-subtle tabular-nums transition-opacity duration-(--kura-duration-instant) group-hover/item:opacity-0\"\n            >\n              {index + 1}\n            </span>\n            <span\n              aria-hidden\n              data-slot=\"prompt-queue-handle\"\n              onPointerDown={(e) => {\n                e.preventDefault();\n                controls.start(e);\n              }}\n              className=\"absolute inset-0 grid cursor-grab touch-none place-items-center rounded-kura-sm text-kura-fg-subtle opacity-0 transition-opacity duration-(--kura-duration-instant) hover:text-kura-fg active:cursor-grabbing group-hover/item:opacity-100\"\n            >\n              <GripVertical className=\"size-3.5\" strokeWidth={1.75} />\n            </span>\n          </span>\n          <span\n            data-slot=\"prompt-queue-text\"\n            title={item.text}\n            className=\"min-w-0 flex-1 truncate py-1.5 text-[13px] leading-5\"\n          >\n            {item.text}\n          </span>\n          {firstAttachment && (\n            <span\n              data-slot=\"prompt-queue-attachments\"\n              className=\"inline-flex h-5 min-w-0 max-w-[40%] shrink items-center gap-1 rounded-kura-sm bg-kura-hover px-1.5 font-kura-mono text-[11px] text-kura-fg-muted\"\n            >\n              {firstAttachment.type === \"image\" ? (\n                <ImageIcon aria-hidden className=\"size-3 shrink-0\" strokeWidth={1.75} />\n              ) : (\n                <FileText aria-hidden className=\"size-3 shrink-0\" strokeWidth={1.75} />\n              )}\n              <span className=\"sr-only @md:not-sr-only @md:truncate\">{firstAttachment.name}</span>\n              {attachments.length > 1 && (\n                <span className=\"shrink-0 text-kura-fg-subtle\">+{attachments.length - 1}</span>\n              )}\n            </span>\n          )}\n          <span\n            data-slot=\"prompt-queue-actions\"\n            className=\"hidden shrink-0 items-center gap-0.5 group-focus-within/item:flex group-hover/item:flex [@media(hover:none)]:flex\"\n          >\n            <IconButton\n              size=\"xs\"\n              label=\"Move up\"\n              icon={<ArrowUp />}\n              disabled={index === 0}\n              onClick={() => onMove(-1)}\n            />\n            <IconButton\n              size=\"xs\"\n              label=\"Move down\"\n              icon={<ArrowDown />}\n              disabled={index === count - 1}\n              onClick={() => onMove(1)}\n            />\n            <IconButton size=\"xs\" label=\"Edit\" icon={<Pencil />} onClick={onEdit} />\n            <IconButton size=\"xs\" label=\"Remove\" icon={<X />} onClick={() => onRemove(false)} />\n            {canSend && (\n              <Button\n                data-slot=\"prompt-queue-send\"\n                variant=\"secondary\"\n                size=\"xs\"\n                className=\"ml-1\"\n                onClick={onSendNow}\n                aria-keyshortcuts=\"Meta+Enter Control+Enter\"\n              >\n                <SendHorizontal />\n                Send now\n              </Button>\n            )}\n          </span>\n        </>\n      )}\n    </Reorder.Item>\n  );\n}\n\nfunction EditBox({\n  initial,\n  label,\n  onSave,\n  onCancel,\n}: {\n  initial: string;\n  label: string;\n  onSave: (text: string) => void;\n  onCancel: () => void;\n}) {\n  const [text, setText] = React.useState(initial);\n  const ref = React.useRef<HTMLTextAreaElement>(null);\n  React.useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    el.focus();\n    el.setSelectionRange(el.value.length, el.value.length);\n  }, []);\n  React.useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    el.style.height = \"auto\";\n    el.style.height = `${Math.min(el.scrollHeight, 160)}px`;\n  });\n  return (\n    <div className=\"flex w-full min-w-0 flex-col gap-1.5\">\n      <textarea\n        ref={ref}\n        aria-label={label}\n        data-slot=\"prompt-queue-editor\"\n        value={text}\n        rows={1}\n        onChange={(e) => setText(e.target.value)}\n        onKeyDown={(e) => {\n          if (e.key === \"Escape\") {\n            e.preventDefault();\n            e.stopPropagation();\n            onCancel();\n          } else if (e.key === \"Enter\" && !e.shiftKey && !e.nativeEvent.isComposing) {\n            e.preventDefault();\n            onSave(text);\n          }\n        }}\n        className=\"w-full resize-none rounded-kura-sm bg-kura-surface px-2 py-1.5 text-[13px] text-kura-fg leading-5 shadow-kura-xs outline-none transition-shadow duration-(--kura-duration-fast) focus:shadow-kura-focus\"\n      />\n      <div className=\"flex items-center justify-end gap-1\">\n        <span className=\"mr-auto truncate pl-1 text-[12px] text-kura-fg-subtle\">\n          Enter to save · Esc to cancel\n        </span>\n        <Button variant=\"ghost\" size=\"xs\" onClick={onCancel}>\n          Cancel\n        </Button>\n        <Button variant=\"primary\" size=\"xs\" onClick={() => onSave(text)}>\n          Save\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
    }
  ],
  "categories": [
    "agent",
    "agent",
    "queue",
    "prompts",
    "follow-up"
  ],
  "docs": "Docs: https://kuro.abstergo.fyi/components/prompt-queue · Markdown for agents: https://kuro.abstergo.fyi/components/prompt-queue.md",
  "meta": {
    "kura": {
      "status": "experimental",
      "whenToUse": [
        "The user types follow-ups while an agent is mid-turn (Claude Code / Codex queued messages) and they should send in order when it finishes.",
        "Letting the user steer: interrupt the agent and send one queued prompt immediately.",
        "Directly above a composer (variant='tray') so queued prompts read as part of the input."
      ],
      "whenNotToUse": [
        "Suggested prompts the user has not written — use prompt-suggestions.",
        "The prompt input itself — use composer (and put PromptQueue above it).",
        "Work the agent plans to do — use task-list."
      ]
    }
  }
}
