{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "prompt",
  "type": "registry:component",
  "title": "Prompt",
  "description": "A field for writing to an assistant, with its files, model and tools in one card.",
  "dependencies": [
    "@base-ui/react",
    "tailwind-variants"
  ],
  "registryDependencies": [
    "https://panelui.dev/r/web/button.json",
    "https://panelui.dev/r/web/menu.json",
    "https://panelui.dev/r/web/select.json",
    "https://panelui.dev/r/web/textarea.json"
  ],
  "cssVars": {
    "light": {
      "success": "oklch(0.696 0.17 162.48)",
      "info": "oklch(0.623 0.214 259.815)"
    },
    "dark": {
      "success": "oklch(0.696 0.17 162.48)",
      "info": "oklch(0.623 0.214 259.815)"
    }
  },
  "files": [
    {
      "path": "components/panelui/prompt.tsx",
      "type": "registry:component",
      "target": "@components/panelui/prompt.tsx",
      "content": "'use client';\n\n/**\n * Prompt — where a message to an assistant is written, with what goes with\n * it: files, a model, tools, and a button that sends it.\n *\n * ```tsx\n * <Prompt onSubmit={({ text, files }) => send(text, files)} status={status} onStop={stop}>\n *   <Prompt.Field>\n *     <Prompt.Attachments />\n *     <Prompt.Textarea placeholder=\"Ask anything\" />\n *     <Prompt.Footer>\n *       <Prompt.Tools>\n *         <Prompt.Menu>\n *           <Prompt.AttachItem />\n *         </Prompt.Menu>\n *         <Prompt.Model items={models} defaultValue=\"swift\" />\n *       </Prompt.Tools>\n *       <Prompt.Submit />\n *     </Prompt.Footer>\n *   </Prompt.Field>\n *   <Prompt.Status>Reading the files</Prompt.Status>\n * </Prompt>\n * ```\n *\n * ## A field in a tray\n *\n * The field is a raised card. What is said about it, rather than in it, sits\n * in bands that the card overlaps: `Prompt.Banner` above for something to do\n * first, `Prompt.Status` below for what the answer is doing, and\n * `Prompt.Context` below, inset, for where the message is going. A band opens\n * and closes with its `open` prop, folding under the card, so a band can come\n * and go without the page jumping by more than its own height.\n *\n * ## Modes\n *\n * A prompt that changes with what the app is doing names its states with\n * `mode`, and each band says which of them it belongs to with its own\n * `mode`. The bands that match open and the others fold, in one movement,\n * so the card moves once, by the difference between them. A band's own\n * `open` still wins over its mode.\n *\n * ## Sending\n *\n * Enter sends and Shift+Enter starts a new line; Enter while a character is\n * still being composed is left to the input method. `onSubmit` is handed the\n * text, trimmed, and the files, and the field is cleared. Nothing is sent\n * while it is empty or while `status` is `submitted` or `streaming`.\n *\n * ## One button, four jobs\n *\n * `Prompt.Submit` sends. While the answer is on its way it stops it instead,\n * calling `onStop`, with a ring turning round it. Given `onVoice`, it is a\n * voice button while the field is empty, and its bars move while `listening`.\n * The icons cross-fade in place, so the button never moves under the pointer.\n *\n * ## Files\n *\n * Files arrive from `Prompt.AttachItem` in the menu or `Prompt.Attach`, from\n * ⌘U or Ctrl+U in the field, from a paste, or dropped on the field.\n * `accept` and `maxFiles` decide which are taken, and `onReject` is told about\n * the rest. Images are shown as thumbnails, anything else as a chip with its\n * name and size.\n */\nimport {\n  Children,\n  createContext,\n  isValidElement,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type ClipboardEvent,\n  type ComponentProps,\n  type DragEvent,\n  type FormEvent,\n  type KeyboardEvent,\n  type ReactElement,\n  type ReactNode,\n  type RefObject,\n} from 'react';\nimport { tv } from 'tailwind-variants';\nimport { Button, type ButtonProps } from '@/components/panelui/button';\nimport { Menu, type MenuContentProps, type MenuItemProps } from '@/components/panelui/menu';\nimport { Select } from '@/components/panelui/select';\nimport { Textarea, type TextareaProps } from '@/components/panelui/textarea';\nimport { mergeProps } from '@base-ui/react/merge-props';\nimport { useRender } from '@base-ui/react/use-render';\nimport type * as ReactTypes from 'react';\nimport type { ElementType as RenderElementType } from 'react';\n\n/** Draws a part as another element, or composes it with a component: `render={<a href=\"/\" />}`. */\ntype RenderProp<Tag extends RenderElementType> = { render?: useRender.ComponentProps<Tag>['render'] };\n\nconst promptVariants = tv({\n  slots: {\n    root: 'isolate flex w-full flex-col',\n    /*\n     * A band is a grid row that goes from no height to its own, so its\n     * opening can be animated without measuring it. The negative margin that\n     * tucks it under the card moves with it, or the card would be pulled\n     * over nothing while the band is shut.\n     */\n    band: 'grid transition-[grid-template-rows,margin] duration-300 ease-[cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none',\n    bandClip: 'min-h-0 overflow-hidden',\n    bandInner:\n      'flex items-center gap-2.5 bg-muted px-4 text-sm text-muted-foreground transition-opacity duration-300 motion-reduce:transition-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-4',\n    bannerAction: 'ms-auto h-7 px-3 text-xs',\n    // The card. It sits over the bands, so its rounded corners show the tray behind them.\n    field: [\n      'relative z-10 flex flex-col rounded-[1.375rem] border bg-card text-card-foreground shadow-sm',\n      'transition-[border-color,box-shadow] duration-200',\n      'focus-within:border-ring/70 has-[textarea:focus-visible]:shadow-md',\n      'data-dragging:border-primary data-dragging:ring-4 data-dragging:ring-primary/10',\n      'data-[status=error]:border-destructive/60',\n      // One line: the parts sit in a row, and the text takes the room between them.\n      'data-[layout=inline]:flex-row data-[layout=inline]:flex-wrap data-[layout=inline]:items-end data-[layout=inline]:gap-1.5 data-[layout=inline]:rounded-[1.75rem] data-[layout=inline]:p-2',\n    ],\n    textarea:\n      'px-4 pb-1 pt-3.5 text-[0.9375rem] leading-6 in-data-[layout=inline]:min-w-0 in-data-[layout=inline]:flex-1 in-data-[layout=inline]:basis-40 in-data-[layout=inline]:px-1.5 in-data-[layout=inline]:py-2',\n    attachments:\n      'flex flex-wrap gap-2 px-3 pt-3 in-data-[layout=inline]:basis-full in-data-[layout=inline]:px-1 in-data-[layout=inline]:pb-1 in-data-[layout=inline]:pt-1',\n    attachment: [\n      'group/attachment relative flex items-center gap-2 rounded-xl border bg-background text-xs',\n      'transition-[opacity,scale] duration-200 ease-out starting:scale-90 starting:opacity-0 motion-reduce:transition-none',\n    ],\n    // Not clipped, so the remove button can sit over the corner; the picture rounds itself.\n    attachmentImage: 'size-14 p-0',\n    attachmentFile: 'h-14 max-w-56 py-2 pe-8 ps-2',\n    attachmentGlyph:\n      'flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground [&_svg]:size-4',\n    attachmentName: 'block truncate font-medium text-foreground',\n    attachmentMeta: 'block truncate text-muted-foreground',\n    attachmentRemove: [\n      'absolute -end-1.5 -top-1.5 flex size-5 cursor-pointer items-center justify-center rounded-full border bg-background text-muted-foreground shadow-xs outline-none',\n      'opacity-0 transition-opacity group-hover/attachment:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100',\n      'hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring [&_svg]:size-3',\n    ],\n    // Bottom-aligned: when the tools wrap, the submit button stays on their last row.\n    footer: 'flex items-end justify-between gap-2 p-2.5 pt-1.5',\n    // The padding makes a row of tools as tall as the submit button, so a single row lines up with it.\n    tools: 'flex min-w-0 flex-wrap items-center gap-1.5 py-0.5',\n    // Switched on, a tool is drawn in the theme's info colour, so it reads as on at a glance.\n    tool: [\n      'aria-pressed:border-[color-mix(in_oklab,var(--info)_40%,transparent)] aria-pressed:bg-[color-mix(in_oklab,var(--info)_12%,transparent)] aria-pressed:text-[var(--info)]',\n      'aria-pressed:hover:not-disabled:bg-[color-mix(in_oklab,var(--info)_18%,transparent)] aria-pressed:hover:not-disabled:text-[var(--info)]',\n    ],\n    // The plus turns to a cross while its menu is open.\n    menuTrigger:\n      'data-popup-open:bg-accent [&_svg]:transition-transform [&_svg]:duration-200 data-popup-open:[&_svg]:rotate-45 motion-reduce:[&_svg]:transition-none',\n    modelValue: 'flex min-w-0 items-center gap-2 [&_svg]:size-4',\n    submit: ['relative size-10 shrink-0 overflow-visible rounded-full p-0', 'disabled:opacity-40'],\n    submitIcon:\n      'absolute inset-0 m-auto flex items-center justify-center transition-[opacity,scale,rotate] duration-200 ease-out motion-reduce:transition-none [&_svg]:size-[18px]',\n    submitRing:\n      'pointer-events-none absolute -inset-[3px] size-[calc(100%+6px)] text-primary transition-opacity duration-200',\n    statusText:\n      'min-w-0 truncate bg-[linear-gradient(90deg,var(--muted-foreground)_35%,var(--foreground)_50%,var(--muted-foreground)_65%)] bg-[length:250%_100%] bg-clip-text text-transparent',\n    statusSpinner: 'ms-auto size-4 animate-spin text-muted-foreground motion-reduce:animate-none',\n    // The strip folds like a band: its tuck under the card moves with it.\n    contextFold:\n      'mx-4 grid transition-[grid-template-rows,margin,opacity] duration-300 ease-[cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none',\n    context: [\n      'relative flex items-center gap-x-4 gap-y-1 rounded-b-[1rem] border bg-muted px-3.5 pb-2 pt-6.5 text-xs text-muted-foreground',\n      'flex-wrap [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-3.5',\n    ],\n    contextItem: 'inline-flex min-w-0 items-center gap-1.5 whitespace-nowrap',\n    contextButton:\n      'cursor-pointer rounded-md outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring',\n    tasksHeader:\n      'flex h-9 w-full cursor-pointer items-center gap-2.5 px-4 text-start outline-none transition-colors hover:text-foreground focus-visible:text-foreground',\n    tasksTitle: 'min-w-0 truncate font-medium text-foreground',\n    tasksCount: 'shrink-0 tabular-nums',\n    tasksChevron: 'ms-auto transition-transform duration-200 motion-reduce:transition-none',\n    tasksBody:\n      'grid transition-[grid-template-rows] duration-300 ease-[cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none',\n    tasksList: [\n      'relative flex flex-col overflow-y-auto overscroll-contain px-2 py-1 [scrollbar-color:var(--border)_transparent] [scrollbar-width:thin]',\n      // Rows fade out at the edges they scroll past, instead of being cut by them.\n      '[mask-image:linear-gradient(to_bottom,transparent,black_0.75rem,black_calc(100%-0.75rem),transparent)]',\n    ],\n    task: 'flex items-start gap-2.5 rounded-lg px-2 py-1 text-sm',\n    taskMark: 'mt-0.5 flex size-4 shrink-0 items-center justify-center',\n    // Pops in as the task finishes.\n    taskDone:\n      'size-4 transition-[scale,opacity] duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] starting:scale-50 starting:opacity-0 motion-reduce:transition-none',\n    taskPending: 'size-3.5 rounded-full border-[1.5px] border-dashed border-muted-foreground/50',\n    taskError:\n      'flex size-4 items-center justify-center rounded-full bg-destructive text-[0.625rem] font-bold leading-none text-white',\n    commandsLabel: 'px-4 pb-1 pt-2.5 text-xs font-medium',\n    commandsList:\n      'flex flex-col overflow-y-auto overscroll-contain px-2 pb-1 [scrollbar-color:var(--border)_transparent] [scrollbar-width:thin]',\n    command:\n      'flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm text-foreground aria-selected:bg-card aria-selected:shadow-xs [&_svg]:text-muted-foreground',\n    commandName: 'shrink-0 font-medium',\n    commandDescription: 'min-w-0 truncate text-muted-foreground',\n    recorder: 'flex flex-col',\n    recorderTranscript: 'min-h-[3.25rem] px-4 pb-1 pt-3.5 text-[0.9375rem] leading-6 text-foreground',\n    recorderListening:\n      'bg-[linear-gradient(90deg,var(--muted-foreground)_35%,var(--foreground)_50%,var(--muted-foreground)_65%)] bg-[length:250%_100%] bg-clip-text text-transparent',\n    recorderControls: 'flex items-center gap-1.5 p-2.5 pt-1.5',\n    // Older sound fades out to the left as it scrolls away.\n    recorderWave:\n      'flex h-10 min-w-0 flex-1 items-center justify-end gap-[2px] overflow-hidden [mask-image:linear-gradient(to_right,transparent,black_18%)]',\n    recorderBar:\n      'w-[3px] shrink-0 rounded-full bg-foreground transition-[height] duration-75 ease-out motion-reduce:transition-none',\n    recorderTime: 'flex shrink-0 items-center gap-1.5 ps-1.5 text-xs font-medium tabular-nums text-muted-foreground',\n    recorderDot: 'size-2 animate-pulse rounded-full bg-destructive motion-reduce:animate-none',\n    recorderDone: 'size-10 p-0 [&_svg]:size-[18px]',\n    hint: 'px-4 pt-2 text-xs text-muted-foreground [&_kbd]:rounded-[0.3rem] [&_kbd]:border [&_kbd]:bg-muted [&_kbd]:px-1 [&_kbd]:py-px [&_kbd]:font-sans [&_kbd]:text-[0.6875rem] [&_kbd]:text-foreground',\n  },\n  variants: {\n    /** Where a band sits against the card. */\n    edge: {\n      top: {\n        bandInner: 'rounded-t-[1.375rem] pb-[calc(0.625rem+1.5rem)] pt-2.5',\n      },\n      bottom: {\n        bandInner: 'rounded-b-[1.375rem] pb-2.5 pt-[calc(0.625rem+1.5rem)]',\n      },\n    },\n    open: { true: { bandInner: 'opacity-100' }, false: { bandInner: 'opacity-0' } },\n  },\n  compoundVariants: [\n    { edge: 'top', open: true, className: { band: 'mb-[-1.5rem] grid-rows-[1fr]' } },\n    { edge: 'top', open: false, className: { band: 'mb-0 grid-rows-[0fr]' } },\n    { edge: 'bottom', open: true, className: { band: 'mt-[-1.5rem] grid-rows-[1fr]' } },\n    { edge: 'bottom', open: false, className: { band: 'mt-0 grid-rows-[0fr]' } },\n  ],\n});\n\nexport type PromptState = 'idle' | 'submitted' | 'streaming' | 'error';\n\nexport interface PromptMessage {\n  /** What was written, trimmed. */\n  text: string;\n  /** The files that were attached, in the order they were added. */\n  files: File[];\n}\n\nexport interface PromptAttachment {\n  id: string;\n  file: File;\n  /** An object URL for an image, to show it by. */\n  preview?: string;\n}\n\nexport type PromptRejectReason = 'accept' | 'max-files';\n\ninterface PromptContextValue {\n  value: string;\n  setValue(value: string): void;\n  attachments: PromptAttachment[];\n  addFiles(files: Iterable<File>): void;\n  removeAttachment(id: string): void;\n  openFileDialog(): void;\n  status: PromptState;\n  /** The state the developer has put the prompt in, which bands open by. */\n  mode?: string;\n  busy: boolean;\n  disabled: boolean;\n  empty: boolean;\n  submit(): void;\n  stop(): void;\n  hasStop: boolean;\n  dragging: boolean;\n  setDragging(dragging: boolean): void;\n  fieldId: string;\n  /** The field's card, for a menu that spans it. */\n  fieldRef: RefObject<HTMLDivElement | null>;\n  /** A part that wants the field's keys first, such as the command list. Returns true when it used the key. */\n  keyHandler: { current: ((event: KeyboardEvent<HTMLTextAreaElement>) => boolean) | null };\n  /** What the field should point a screen reader at while the command list is open. */\n  listbox: { id: string; active?: string } | null;\n  setListbox(listbox: { id: string; active?: string } | null): void;\n}\n\nconst PromptStore = createContext<PromptContextValue | null>(null);\n\nfunction usePrompt(part: string): PromptContextValue {\n  const prompt = useContext(PromptStore);\n  if (!prompt) throw new Error(`${part} must be used inside a <Prompt>.`);\n  return prompt;\n}\n\n/** Whether a file is one `accept` allows: a MIME type, a `type/*` family or an extension. */\nfunction accepts(file: File, accept: string | undefined) {\n  if (!accept) return true;\n  const name = file.name.toLowerCase();\n  return accept\n    .split(',')\n    .map((rule) => rule.trim().toLowerCase())\n    .filter(Boolean)\n    .some((rule) => {\n      if (rule.startsWith('.')) return name.endsWith(rule);\n      if (rule.endsWith('/*')) return file.type.startsWith(rule.slice(0, -1));\n      return file.type === rule;\n    });\n}\n\nfunction attach(file: File): PromptAttachment {\n  return {\n    id: `${file.name}-${file.size}-${Math.random().toString(36).slice(2, 8)}`,\n    file,\n    preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined,\n  };\n}\n\nexport interface PromptProps extends Omit<ComponentProps<'form'>, 'onSubmit' | 'defaultValue'> {\n  /** What is written in the field, when the caller holds it. */\n  value?: string;\n  /** What the field starts with, when the caller does not hold it. */\n  defaultValue?: string;\n  /** Called with what is written, each time it changes, and with `''` when it is sent. */\n  onValueChange?: (value: string) => void;\n  /** Called with the text and the files when the message is sent. */\n  onSubmit?: (message: PromptMessage) => void;\n  /**\n   * Where the answer is. `submitted` and `streaming` stop the field being\n   * sent and turn the submit button into a stop button; `error` marks the\n   * field. Default `idle`.\n   */\n  status?: PromptState;\n  /** Called when the stop button is pressed. Without it, the button cannot be pressed while busy. */\n  onStop?: () => void;\n  /** Turns the whole prompt off. */\n  disabled?: boolean;\n  /** Files attached to begin with, such as those of a message being edited and sent again. */\n  defaultFiles?: File[];\n  /** Which files can be attached, as for `<input type=\"file\">`: `image/*,.pdf`. Default any. */\n  accept?: string;\n  /** How many files can be attached at once. Default no limit. */\n  maxFiles?: number;\n  /** Called with the files that were not taken, and why. */\n  onReject?: (files: File[], reason: PromptRejectReason) => void;\n  /**\n   * The state the prompt is in, named by you: `\"connect\"`, `\"tasks\"`,\n   * `\"writing\"`. Each band given a `mode` of its own opens while this\n   * matches it and folds away otherwise.\n   */\n  mode?: string;\n}\n\nfunction PromptRoot({\n  value: controlledValue,\n  defaultValue = '',\n  onValueChange,\n  onSubmit,\n  status = 'idle',\n  onStop,\n  disabled = false,\n  defaultFiles,\n  accept,\n  maxFiles,\n  onReject,\n  mode,\n  className,\n  children,\n  ...props\n}: PromptProps) {\n  const [internalValue, setInternalValue] = useState(defaultValue);\n  const value = controlledValue ?? internalValue;\n  const [attachments, setAttachments] = useState<PromptAttachment[]>(() => (defaultFiles ?? []).map(attach));\n  const [dragging, setDragging] = useState(false);\n  const fileInput = useRef<HTMLInputElement>(null);\n  const fieldRef = useRef<HTMLDivElement>(null);\n  const keyHandler = useRef<PromptContextValue['keyHandler']['current']>(null);\n  const [listbox, setListbox] = useState<PromptContextValue['listbox']>(null);\n  const fieldId = useId();\n  const busy = status === 'submitted' || status === 'streaming';\n  const empty = value.trim() === '' && attachments.length === 0;\n\n  const setValue = useCallback(\n    (next: string) => {\n      if (controlledValue === undefined) setInternalValue(next);\n      onValueChange?.(next);\n    },\n    [controlledValue, onValueChange]\n  );\n\n  // Kept in a ref as well, for the cleanup on unmount, which sees only the\n  // first render's state otherwise.\n  const live = useRef(attachments);\n  live.current = attachments;\n  useEffect(\n    () => () => {\n      for (const attachment of live.current) if (attachment.preview) URL.revokeObjectURL(attachment.preview);\n    },\n    []\n  );\n\n  const addFiles = useCallback(\n    (incoming: Iterable<File>) => {\n      const list = [...incoming];\n      const refused = list.filter((file) => !accepts(file, accept));\n      const taken = list.filter((file) => accepts(file, accept));\n      if (refused.length > 0) onReject?.(refused, 'accept');\n      const room = maxFiles === undefined ? taken.length : Math.max(0, maxFiles - live.current.length);\n      if (taken.length > room) onReject?.(taken.slice(room), 'max-files');\n      const added = taken.slice(0, room).map(attach);\n      if (added.length > 0) setAttachments((current) => [...current, ...added]);\n    },\n    [accept, maxFiles, onReject]\n  );\n\n  const removeAttachment = useCallback((id: string) => {\n    setAttachments((current) => {\n      const gone = current.find((attachment) => attachment.id === id);\n      if (gone?.preview) URL.revokeObjectURL(gone.preview);\n      return current.filter((attachment) => attachment.id !== id);\n    });\n  }, []);\n\n  const openFileDialog = useCallback(() => fileInput.current?.click(), []);\n\n  const submit = useCallback(() => {\n    if (busy || disabled || empty) return;\n    onSubmit?.({ text: value.trim(), files: attachments.map((attachment) => attachment.file) });\n    setValue('');\n    // The previews are not revoked here: the caller may still be showing the files.\n    setAttachments([]);\n  }, [busy, disabled, empty, onSubmit, value, attachments, setValue]);\n\n  const stop = useCallback(() => onStop?.(), [onStop]);\n\n  const context = useMemo<PromptContextValue>(\n    () => ({\n      value,\n      setValue,\n      attachments,\n      addFiles,\n      removeAttachment,\n      openFileDialog,\n      status,\n      mode,\n      busy,\n      disabled,\n      empty,\n      submit,\n      stop,\n      hasStop: onStop !== undefined,\n      dragging,\n      setDragging,\n      fieldId,\n      fieldRef,\n      keyHandler,\n      listbox,\n      setListbox,\n    }),\n    [\n      value,\n      setValue,\n      attachments,\n      addFiles,\n      removeAttachment,\n      openFileDialog,\n      status,\n      mode,\n      busy,\n      disabled,\n      empty,\n      submit,\n      stop,\n      onStop,\n      dragging,\n      fieldId,\n      listbox,\n    ]\n  );\n\n  const send = (event: FormEvent<HTMLFormElement>) => {\n    event.preventDefault();\n    submit();\n  };\n\n  return (\n    <PromptStore.Provider value={context}>\n      <form\n        data-slot=\"prompt\"\n        data-status={status}\n        data-mode={mode}\n        aria-busy={busy || undefined}\n        className={promptVariants().root({ className })}\n        onSubmit={send}\n        {...props}\n      >\n        {children}\n        <input\n          ref={fileInput}\n          type=\"file\"\n          multiple={maxFiles !== 1}\n          accept={accept}\n          tabIndex={-1}\n          aria-hidden=\"true\"\n          className=\"hidden\"\n          onChange={(event) => {\n            if (event.target.files) addFiles(event.target.files);\n            // Cleared, so choosing the same file again still counts as a change.\n            event.target.value = '';\n          }}\n        />\n      </form>\n    </PromptStore.Provider>\n  );\n}\nPromptRoot.displayName = 'Prompt';\n\n/* ------------------------------------------------------------------ bands */\n\nfunction Band({\n  edge,\n  open,\n  slot,\n  className,\n  children,\n  ...props\n}: ComponentProps<'div'> & { edge: 'top' | 'bottom'; open: boolean; slot: string }) {\n  const slots = promptVariants({ edge, open });\n  const clip = useRef<HTMLDivElement>(null);\n  // Nothing in a shut band can be reached. Set on the element: how React\n  // writes a boolean `inert` differs between versions.\n  useEffect(() => {\n    clip.current?.toggleAttribute('inert', !open);\n  }, [open]);\n  return (\n    <div data-slot={slot} data-open={open ? '' : undefined} className={slots.band()}>\n      <div ref={clip} className={slots.bandClip()}>\n        <div className={slots.bandInner({ className })} {...props}>\n          {children}\n        </div>\n      </div>\n    </div>\n  );\n}\n\n/** A band's `mode`: the prompt mode, or modes, it is open in. */\nexport type PromptBandMode = string | string[];\n\n/**\n * Whether a band is open: its own `open` if given, else whether the\n * prompt's mode is one of the band's, else what the band does by default.\n */\nfunction useBandOpen(\n  part: string,\n  open: boolean | undefined,\n  mode: PromptBandMode | undefined,\n  fallback: (prompt: PromptContextValue) => boolean\n) {\n  const prompt = usePrompt(part);\n  if (open !== undefined) return open;\n  if (mode !== undefined)\n    return prompt.mode !== undefined && (Array.isArray(mode) ? mode : [mode]).includes(prompt.mode);\n  return fallback(prompt);\n}\n\nexport interface PromptBannerProps extends ComponentProps<'div'> {\n  /** Whether it is shown. It folds under the field when it is not. Default: shown, or with `mode`, while the prompt is in that mode. */\n  open?: boolean;\n  /** The prompt modes it is shown in. It folds away in any other. */\n  mode?: PromptBandMode;\n}\n\n/** A band above the field, for something to do before or while writing: connect an account, pick a project. */\nfunction PromptBanner({ open, mode, ...props }: PromptBannerProps) {\n  const shown = useBandOpen('Prompt.Banner', open, mode, () => true);\n  return <Band edge=\"top\" open={shown} slot=\"prompt-banner\" {...props} />;\n}\nPromptBanner.displayName = 'Prompt.Banner';\n\nexport interface PromptBannerActionProps extends ButtonProps {}\n\n/** The banner's button, at its end. */\nfunction PromptBannerAction({ className, size = 'sm', shape = 'pill', ...props }: PromptBannerActionProps) {\n  return (\n    <Button\n      data-slot=\"prompt-banner-action\"\n      size={size}\n      shape={shape}\n      className={promptVariants().bannerAction({ className })}\n      {...props}\n    />\n  );\n}\nPromptBannerAction.displayName = 'Prompt.BannerAction';\n\n/** Runs a background sweep across the text, unless motion is reduced. */\nfunction useShimmer(active: boolean) {\n  const ref = useRef<HTMLSpanElement>(null);\n  useEffect(() => {\n    const node = ref.current;\n    if (!node || !active || typeof node.animate !== 'function') return;\n    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;\n    const animation = node.animate([{ backgroundPosition: '125% 0' }, { backgroundPosition: '-25% 0' }], {\n      duration: 1800,\n      iterations: Infinity,\n      easing: 'linear',\n    });\n    return () => animation.cancel();\n  }, [active]);\n  return ref;\n}\n\nexport interface PromptStatusProps extends ComponentProps<'div'> {\n  /** Whether it is shown. Default: while `status` is `submitted` or `streaming`, or with `mode`, while the prompt is in that mode. */\n  open?: boolean;\n  /** The prompt modes it is shown in. It folds away in any other. */\n  mode?: PromptBandMode;\n  /** Something before the words: the model's mark, a tool's icon. */\n  icon?: ReactNode;\n  /** Whether a spinner turns at its end. Default `true`. */\n  spinner?: boolean;\n}\n\n/** A band under the field saying what the answer is doing, with the words lit by a moving sweep. */\nfunction PromptStatus({ open, mode, icon, spinner = true, children, ...props }: PromptStatusProps) {\n  const shown = useBandOpen('Prompt.Status', open, mode, (prompt) => prompt.busy);\n  const slots = promptVariants();\n  const text = useShimmer(shown);\n  return (\n    <Band edge=\"bottom\" open={shown} slot=\"prompt-status\" role=\"status\" {...props}>\n      {icon}\n      <span ref={text} className={slots.statusText()}>\n        {children}\n      </span>\n      {spinner ? (\n        <svg\n          aria-hidden=\"true\"\n          viewBox=\"0 0 24 24\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"2.5\"\n          strokeLinecap=\"round\"\n          className={slots.statusSpinner()}\n        >\n          <path d=\"M21 12a9 9 0 1 1-6.22-8.56\" />\n        </svg>\n      ) : null}\n    </Band>\n  );\n}\nPromptStatus.displayName = 'Prompt.Status';\n\nexport interface PromptContextProps extends ComponentProps<'div'> {\n  /** Whether it is shown. It folds under the field when it is not. Default: shown, or with `mode`, while the prompt is in that mode. */\n  open?: boolean;\n  /** The prompt modes it is shown in. It folds away in any other. */\n  mode?: PromptBandMode;\n}\n\n/** A strip under the field, inset from its edges, for where the message is going: a branch, a folder, a ticket. */\nfunction PromptContext({ open, mode, className, render, ...props }: PromptContextProps & RenderProp<'div'>) {\n  const shown = useBandOpen('Prompt.Context', open, mode, () => true);\n  const slots = promptVariants();\n  const clip = useRef<HTMLDivElement>(null);\n  useEffect(() => {\n    clip.current?.toggleAttribute('inert', !shown);\n  }, [shown]);\n  const element = useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps({ className: slots.context({ className }) }, props),\n  });\n  return (\n    <div\n      data-slot=\"prompt-context\"\n      data-open={shown ? '' : undefined}\n      className={slots.contextFold({\n        className: shown ? '-mt-5 grid-rows-[1fr] opacity-100' : 'mt-0 grid-rows-[0fr] opacity-0',\n      })}\n    >\n      <div ref={clip} className=\"min-h-0 overflow-hidden\">\n        {element}\n      </div>\n    </div>\n  );\n}\nPromptContext.displayName = 'Prompt.Context';\n\nexport interface PromptContextItemProps extends ComponentProps<'span'> {\n  /** Called when it is pressed. Passing this makes the item a button. */\n  onSelect?: () => void;\n}\n\n/** One fact in the context strip, with an icon before it. */\nfunction PromptContextItem({\n  onSelect,\n  className,\n  children,\n  render,\n  ...props\n}: PromptContextItemProps & RenderProp<'span'>) {\n  const slots = promptVariants();\n  // Called before any branch, so a prop that changes later cannot change the hook count.\n  const rendered = useRender({\n    defaultTagName: 'span',\n    render,\n    props: mergeProps(\n      { 'data-slot': 'prompt-context-item', className: slots.contextItem({ className }), children: <>{children}</> },\n      props\n    ),\n  });\n  if (onSelect) {\n    return (\n      <button\n        type=\"button\"\n        data-slot=\"prompt-context-item\"\n        onClick={onSelect}\n        className={slots.contextItem({ className: [slots.contextButton(), className] })}\n      >\n        {children}\n      </button>\n    );\n  }\n  return rendered;\n}\nPromptContextItem.displayName = 'Prompt.ContextItem';\n\nexport interface PromptHintProps extends ComponentProps<'p'> {}\n\n/** A quiet line under the prompt. Keys written in `<kbd>` are drawn as keys. */\nfunction PromptHint({ className, children, render, ...props }: PromptHintProps & RenderProp<'p'>) {\n  return useRender({\n    defaultTagName: 'p',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'prompt-hint',\n        className: promptVariants().hint({ className }),\n        children: (\n          <>\n            {children ?? (\n              <>\n                <kbd>Shift</kbd> + <kbd>Enter</kbd> for a new line\n              </>\n            )}\n          </>\n        ),\n      },\n      props\n    ),\n  });\n}\nPromptHint.displayName = 'Prompt.Hint';\n\n/* ------------------------------------------------------------------ field */\n\nexport interface PromptFieldProps extends ComponentProps<'div'> {\n  /**\n   * `stacked` puts the text above a footer. `inline` puts every part in one\n   * row, the text between them, for a one-line composer; the row wraps as\n   * the text grows. Default `stacked`.\n   */\n  layout?: 'stacked' | 'inline';\n}\n\n/** The card that holds the text, the files and the footer. Files can be dropped on it. */\nfunction PromptField({\n  layout = 'stacked',\n  className,\n  onDragOver,\n  onDragLeave,\n  onDrop,\n  render,\n  ...props\n}: PromptFieldProps & RenderProp<'div'>) {\n  const prompt = usePrompt('Prompt.Field');\n  const hasFiles = (event: DragEvent) => [...event.dataTransfer.types].includes('Files');\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        ref: prompt.fieldRef,\n        'data-slot': 'prompt-field',\n        'data-layout': layout,\n        'data-status': prompt.status,\n        'data-dragging': prompt.dragging ? '' : undefined,\n        className: promptVariants().field({ className }),\n        onDragOver: (event: ReactTypes.DragEvent<HTMLDivElement>) => {\n          onDragOver?.(event);\n          if (prompt.disabled || !hasFiles(event)) return;\n          event.preventDefault();\n          event.dataTransfer.dropEffect = 'copy';\n          if (!prompt.dragging) prompt.setDragging(true);\n        },\n        onDragLeave: (event: ReactTypes.DragEvent<HTMLDivElement>) => {\n          onDragLeave?.(event);\n          // Leaving for a child of the field is not leaving the field.\n          if (!event.currentTarget.contains(event.relatedTarget as Node | null)) prompt.setDragging(false);\n        },\n        onDrop: (event: ReactTypes.DragEvent<HTMLDivElement>) => {\n          onDrop?.(event);\n          prompt.setDragging(false);\n          if (prompt.disabled || !hasFiles(event)) return;\n          event.preventDefault();\n          prompt.addFiles(event.dataTransfer.files);\n        },\n      },\n      props\n    ),\n  });\n}\nPromptField.displayName = 'Prompt.Field';\n\nexport interface PromptTextareaProps extends Omit<TextareaProps, 'value' | 'defaultValue' | 'onChange' | 'variant'> {}\n\n/** Where the message is written. Enter sends, Shift+Enter starts a new line, and pasted files are attached. */\nfunction PromptTextarea({\n  className,\n  minRows = 2,\n  maxRows = 10,\n  onKeyDown,\n  onPaste,\n  'aria-label': ariaLabel = 'Message',\n  ...props\n}: PromptTextareaProps) {\n  const prompt = usePrompt('Prompt.Textarea');\n  return (\n    <Textarea\n      id={prompt.fieldId}\n      variant=\"ghost\"\n      aria-label={ariaLabel}\n      minRows={minRows}\n      maxRows={maxRows}\n      disabled={prompt.disabled}\n      value={prompt.value}\n      onChange={(event) => prompt.setValue(event.target.value)}\n      aria-controls={prompt.listbox?.id}\n      aria-activedescendant={prompt.listbox?.active}\n      onKeyDown={(event: KeyboardEvent<HTMLTextAreaElement>) => {\n        onKeyDown?.(event);\n        if (event.defaultPrevented) return;\n        if (prompt.keyHandler.current?.(event)) return;\n        // The usual shortcut for attaching, where underlining means nothing.\n        if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'u') {\n          event.preventDefault();\n          prompt.openFileDialog();\n          return;\n        }\n        // `isComposing`: Enter that ends a composition in an input method is not a send.\n        if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {\n          event.preventDefault();\n          prompt.submit();\n        }\n      }}\n      onPaste={(event: ClipboardEvent<HTMLTextAreaElement>) => {\n        onPaste?.(event);\n        const files = event.clipboardData.files;\n        if (event.defaultPrevented || files.length === 0) return;\n        event.preventDefault();\n        prompt.addFiles(files);\n      }}\n      className={promptVariants().textarea({ className })}\n      {...props}\n    />\n  );\n}\nPromptTextarea.displayName = 'Prompt.Textarea';\n\nfunction formatSize(bytes: number) {\n  if (bytes < 1024) return `${bytes} B`;\n  if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;\n  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;\n}\n\nconst XIcon = () => (\n  <svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.5\" strokeLinecap=\"round\">\n    <path d=\"M18 6 6 18M6 6l12 12\" />\n  </svg>\n);\n\nexport interface PromptAttachmentsProps extends ComponentProps<'ul'> {}\n\n/** The files attached so far: images as thumbnails, anything else as a chip. Takes no room while there are none. */\nfunction PromptAttachments({\n  className,\n  'aria-label': ariaLabel = 'Attached files',\n  render,\n  ...props\n}: PromptAttachmentsProps & RenderProp<'ul'>) {\n  const prompt = usePrompt('Prompt.Attachments');\n  const slots = promptVariants();\n  // The hook runs even with nothing attached, so the first file does not\n  // change the hook count.\n  const element = useRender({\n    defaultTagName: 'ul',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'prompt-attachments',\n        'aria-label': ariaLabel,\n        className: slots.attachments({ className }),\n        children: (\n          <>\n            {prompt.attachments.map(({ id, file, preview }) => (\n              <li\n                key={id}\n                className={slots.attachment({ className: preview ? slots.attachmentImage() : slots.attachmentFile() })}\n              >\n                {preview ? (\n                  <img src={preview} alt={file.name} className=\"size-full rounded-[inherit] object-cover\" />\n                ) : (\n                  <>\n                    <span aria-hidden=\"true\" className={slots.attachmentGlyph()}>\n                      <svg\n                        viewBox=\"0 0 24 24\"\n                        fill=\"none\"\n                        stroke=\"currentColor\"\n                        strokeWidth=\"2\"\n                        strokeLinecap=\"round\"\n                        strokeLinejoin=\"round\"\n                      >\n                        <path d=\"M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z\" />\n                        <path d=\"M14 3v5h5\" />\n                      </svg>\n                    </span>\n                    <span className=\"min-w-0\">\n                      <span className={slots.attachmentName()}>{file.name}</span>\n                      <span className={slots.attachmentMeta()}>{formatSize(file.size)}</span>\n                    </span>\n                  </>\n                )}\n                <button\n                  type=\"button\"\n                  aria-label={`Remove ${file.name}`}\n                  className={slots.attachmentRemove()}\n                  onClick={() => prompt.removeAttachment(id)}\n                >\n                  <XIcon />\n                </button>\n              </li>\n            ))}\n          </>\n        ),\n      },\n      props\n    ),\n  });\n  return prompt.attachments.length === 0 ? null : element;\n}\nPromptAttachments.displayName = 'Prompt.Attachments';\n\nexport interface PromptFooterProps extends ComponentProps<'div'> {}\n\n/** The row under the text: tools at the start, the submit button at the end. */\nfunction PromptFooter({ className, render, ...props }: PromptFooterProps & RenderProp<'div'>) {\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps({ 'data-slot': 'prompt-footer', className: promptVariants().footer({ className }) }, props),\n  });\n}\nPromptFooter.displayName = 'Prompt.Footer';\n\nexport interface PromptToolsProps extends ComponentProps<'div'> {}\n\n/** The tools at the start of the footer. Wraps onto a second line where it has to. */\nfunction PromptTools({ className, render, ...props }: PromptToolsProps & RenderProp<'div'>) {\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps({ 'data-slot': 'prompt-tools', className: promptVariants().tools({ className }) }, props),\n  });\n}\nPromptTools.displayName = 'Prompt.Tools';\n\nexport interface PromptToolProps extends Omit<ButtonProps, 'aria-pressed'> {\n  /** What it does, for a screen reader and for the tooltip. Shown beside the icon with `showLabel`. */\n  label: string;\n  /** Whether it is switched on, for a tool that stays on. Leave it out for one that acts once. */\n  pressed?: boolean;\n  /** Called with the new state when a tool with `pressed` is pressed. */\n  onPressedChange?: (pressed: boolean) => void;\n  /** Writes the label beside the icon. */\n  showLabel?: boolean;\n}\n\n/** A round button in the footer, holding an icon. With `pressed`, it switches on and off. */\nfunction PromptTool({\n  label,\n  pressed,\n  onPressedChange,\n  showLabel = false,\n  onClick,\n  className,\n  children,\n  variant = 'outline',\n  ...props\n}: PromptToolProps) {\n  const prompt = usePrompt('Prompt.Tool');\n  return (\n    <Button\n      data-slot=\"prompt-tool\"\n      variant={variant}\n      shape=\"pill\"\n      size={showLabel ? 'md' : 'icon'}\n      aria-label={showLabel ? undefined : label}\n      title={showLabel ? undefined : label}\n      aria-pressed={pressed}\n      disabled={prompt.disabled}\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented && pressed !== undefined) onPressedChange?.(!pressed);\n      }}\n      className={promptVariants().tool({ className: [showLabel ? 'px-3' : undefined, className] })}\n      {...props}\n    >\n      {children}\n      {showLabel ? label : null}\n    </Button>\n  );\n}\nPromptTool.displayName = 'Prompt.Tool';\n\nexport interface PromptAttachProps extends Omit<PromptToolProps, 'label' | 'pressed' | 'onPressedChange'> {\n  /** Default `Attach files`. */\n  label?: string;\n}\n\n/** A tool that opens the file picker. Files can also be pasted, or dropped on the field. */\nfunction PromptAttach({ label = 'Attach files', onClick, children, ...props }: PromptAttachProps) {\n  const prompt = usePrompt('Prompt.Attach');\n  return (\n    <PromptTool\n      label={label}\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented) prompt.openFileDialog();\n      }}\n      {...props}\n    >\n      {children ?? <PaperclipIcon />}\n    </PromptTool>\n  );\n}\nPromptAttach.displayName = 'Prompt.Attach';\n\nexport interface PromptModelItem {\n  value: string;\n  label: string;\n  /** Shown before the label, in the list and on the button once chosen. */\n  icon?: ReactNode;\n  /** A second line in the list. */\n  description?: ReactNode;\n}\n\nexport interface PromptModelProps {\n  /** The models to choose from. */\n  items: readonly PromptModelItem[];\n  /** The chosen model, when the caller holds it. */\n  value?: string;\n  /** The model chosen to begin with, when the caller does not hold it. */\n  defaultValue?: string;\n  /** Called with the model's `value` when another is chosen. */\n  onValueChange?: (value: string) => void;\n  /** `pill` has an edge, like the tools beside it. `ghost` has none until hovered. Default `pill`. */\n  variant?: 'pill' | 'ghost';\n  /** What a screen reader calls the button. Default `Model`. */\n  label?: string;\n  className?: string;\n}\n\n/** Chooses the model the message goes to. The button shows the chosen one's icon and name. */\nfunction PromptModel({\n  items,\n  value,\n  defaultValue,\n  onValueChange,\n  variant = 'pill',\n  label = 'Model',\n  className,\n}: PromptModelProps) {\n  const prompt = usePrompt('Prompt.Model');\n  const slots = promptVariants();\n  return (\n    <Select\n      items={items}\n      value={value}\n      defaultValue={defaultValue ?? (value === undefined ? items[0]?.value : undefined)}\n      onValueChange={(next) => {\n        if (typeof next === 'string') onValueChange?.(next);\n      }}\n      disabled={prompt.disabled}\n    >\n      <Select.Trigger\n        aria-label={label}\n        variant={variant}\n        size=\"md\"\n        className={[variant === 'ghost' ? 'text-foreground' : undefined, className].filter(Boolean).join(' ')}\n      >\n        <Select.Value className={slots.modelValue()}>\n          {(chosen: string | null) => {\n            const item = items.find((entry) => entry.value === chosen);\n            return item ? (\n              <>\n                {item.icon}\n                <span className=\"truncate\">{item.label}</span>\n              </>\n            ) : null;\n          }}\n        </Select.Value>\n      </Select.Trigger>\n      <Select.Content>\n        {items.map((item) => (\n          <Select.Item key={item.value} value={item.value} icon={item.icon} description={item.description}>\n            {item.label}\n          </Select.Item>\n        ))}\n      </Select.Content>\n    </Select>\n  );\n}\nPromptModel.displayName = 'Prompt.Model';\n\nconst PaperclipIcon = () => (\n  <svg\n    aria-hidden=\"true\"\n    viewBox=\"0 0 24 24\"\n    fill=\"none\"\n    stroke=\"currentColor\"\n    strokeWidth=\"2\"\n    strokeLinecap=\"round\"\n    strokeLinejoin=\"round\"\n  >\n    <path d=\"m16 6-8.41 8.59a2 2 0 0 0 2.83 2.83l8.41-8.59a4 4 0 0 0-5.66-5.66l-8.58 8.59a6 6 0 1 0 8.49 8.49L21.5 12\" />\n  </svg>\n);\n\nexport interface PromptMenuProps extends Omit<MenuContentProps, 'children'> {\n  /** What a screen reader and the tooltip call the button. Default `Add`. */\n  label?: string;\n  /** Replaces the plus on the button. */\n  icon?: ReactNode;\n  /** The menu: `Prompt.AttachItem`, and any `Menu` items, check items and submenus. */\n  children?: ReactNode;\n  /** `field` opens the menu as wide as the field, lined up with it, for items with descriptions. Default `auto`. */\n  width?: 'auto' | 'field';\n}\n\n/**\n * One round button that opens a menu of everything that can be added to the\n * message: files, tools, modes. It keeps the footer to one button however\n * many there are.\n */\nfunction PromptMenu({\n  label = 'Add',\n  icon,\n  children,\n  side = 'top',\n  align = 'start',\n  width = 'auto',\n  className,\n  ...props\n}: PromptMenuProps) {\n  const prompt = usePrompt('Prompt.Menu');\n  return (\n    <Menu>\n      <Menu.Trigger\n        disabled={prompt.disabled}\n        aria-label={label}\n        title={label}\n        render={<Button data-slot=\"prompt-menu\" variant=\"outline\" shape=\"pill\" size=\"icon\" />}\n        className={promptVariants().menuTrigger()}\n      >\n        {icon ?? (\n          <svg\n            aria-hidden=\"true\"\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth=\"2\"\n            strokeLinecap=\"round\"\n          >\n            <path d=\"M12 5v14M5 12h14\" />\n          </svg>\n        )}\n      </Menu.Trigger>\n      <Menu.Content\n        side={side}\n        align={align}\n        anchor={width === 'field' ? prompt.fieldRef : undefined}\n        className={\n          [width === 'field' ? 'w-(--anchor-width) rounded-2xl p-1.5' : undefined, className]\n            .filter(Boolean)\n            .join(' ') || undefined\n        }\n        {...props}\n      >\n        {children}\n      </Menu.Content>\n    </Menu>\n  );\n}\nPromptMenu.displayName = 'Prompt.Menu';\n\nexport interface PromptAttachItemProps extends Omit<MenuItemProps, 'children'> {\n  /** Default `Add files`. */\n  children?: ReactNode;\n  /** The keys shown at its end. Default `⌘U`; pass `null` for none. ⌘U and Ctrl+U in the field open the picker. */\n  shortcut?: ReactNode;\n}\n\n/** The menu item that opens the file picker. */\nfunction PromptAttachItem({ children = 'Add files', shortcut = '⌘U', onClick, ...props }: PromptAttachItemProps) {\n  const prompt = usePrompt('Prompt.AttachItem');\n  return (\n    <Menu.Item\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented) prompt.openFileDialog();\n      }}\n      {...props}\n    >\n      <PaperclipIcon />\n      {children}\n      {shortcut === null ? null : <Menu.Shortcut>{shortcut}</Menu.Shortcut>}\n    </Menu.Item>\n  );\n}\nPromptAttachItem.displayName = 'Prompt.AttachItem';\n\n/* ----------------------------------------------------------------- submit */\n\n/** Five bars that move while listening, and rest at fixed heights otherwise. */\nfunction Waveform({ listening }: { listening: boolean }) {\n  const bars = useRef<(SVGRectElement | null)[]>([]);\n  const resting = [6, 12, 16, 10, 6];\n  useEffect(() => {\n    if (!listening || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;\n    const animations = bars.current.map((bar, index) =>\n      bar?.animate([{ scale: '1 0.35' }, { scale: '1 1.15' }, { scale: '1 0.35' }], {\n        duration: 700 + index * 90,\n        delay: index * -160,\n        iterations: Infinity,\n        easing: 'ease-in-out',\n      })\n    );\n    return () => animations.forEach((animation) => animation?.cancel());\n  }, [listening]);\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"currentColor\">\n      {resting.map((height, index) => (\n        <rect\n          key={index}\n          ref={(node) => {\n            bars.current[index] = node;\n          }}\n          x={3.25 + index * 4}\n          y={12 - height / 2}\n          width=\"1.75\"\n          height={height}\n          rx=\"0.875\"\n          style={{ transformOrigin: 'center', transformBox: 'fill-box' }}\n        />\n      ))}\n    </svg>\n  );\n}\n\nexport interface PromptSubmitProps extends Omit<ButtonProps, 'type' | 'children'> {\n  /** Makes the button a voice button while the field is empty. Called when it is pressed. */\n  onVoice?: () => void;\n  /** Whether voice is being heard. The bars move while it is. */\n  listening?: boolean;\n  /** What a screen reader calls the button while it sends. Default `Send`. */\n  sendLabel?: string;\n  /** While it stops the answer. Default `Stop`. */\n  stopLabel?: string;\n  /** While it starts voice. Default `Speak`. */\n  voiceLabel?: string;\n}\n\ntype SubmitMode = 'send' | 'stop' | 'voice';\n\n/** Sends the message. While busy it stops the answer, and with `onVoice` it starts voice while the field is empty. */\nfunction PromptSubmit({\n  onVoice,\n  listening = false,\n  sendLabel = 'Send',\n  stopLabel = 'Stop',\n  voiceLabel = 'Speak',\n  onClick,\n  className,\n  disabled,\n  ...props\n}: PromptSubmitProps) {\n  const prompt = usePrompt('Prompt.Submit');\n  const slots = promptVariants();\n  const mode: SubmitMode = prompt.busy ? 'stop' : prompt.empty && onVoice ? 'voice' : 'send';\n  const label = mode === 'stop' ? stopLabel : mode === 'voice' ? voiceLabel : sendLabel;\n  const off = disabled || prompt.disabled || (mode === 'send' && prompt.empty) || (mode === 'stop' && !prompt.hasStop);\n\n  // Each icon is always there and only the one for the mode is shown, so they\n  // can cross-fade in place.\n  const shown = (which: SubmitMode) =>\n    mode === which ? 'opacity-100 scale-100 rotate-0' : 'opacity-0 scale-50 -rotate-45';\n\n  return (\n    <Button\n      data-slot=\"prompt-submit\"\n      data-mode={mode}\n      type={mode === 'send' ? 'submit' : 'button'}\n      aria-label={label}\n      aria-pressed={mode === 'voice' ? listening : undefined}\n      disabled={off}\n      onClick={(event) => {\n        onClick?.(event);\n        if (event.defaultPrevented) return;\n        if (mode === 'stop') prompt.stop();\n        if (mode === 'voice') onVoice?.();\n      }}\n      className={slots.submit({ className })}\n      {...props}\n    >\n      <span className={slots.submitIcon({ className: shown('send') })}>\n        <svg\n          aria-hidden=\"true\"\n          viewBox=\"0 0 24 24\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"2.25\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        >\n          <path d=\"M12 19V5M5 12l7-7 7 7\" />\n        </svg>\n      </span>\n      <span className={slots.submitIcon({ className: shown('stop') })}>\n        <svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"currentColor\">\n          <rect x=\"7\" y=\"7\" width=\"10\" height=\"10\" rx=\"2\" />\n        </svg>\n      </span>\n      <span className={slots.submitIcon({ className: shown('voice') })}>\n        <Waveform listening={mode === 'voice' && listening} />\n      </span>\n      {/* A ring turning round the button while the answer is on its way. */}\n      <svg\n        aria-hidden=\"true\"\n        viewBox=\"0 0 46 46\"\n        fill=\"none\"\n        className={slots.submitRing({ className: mode === 'stop' ? 'opacity-100' : 'opacity-0' })}\n      >\n        <circle cx=\"23\" cy=\"23\" r=\"21.5\" stroke=\"currentColor\" strokeOpacity=\"0.15\" strokeWidth=\"1.5\" />\n        <circle\n          cx=\"23\"\n          cy=\"23\"\n          r=\"21.5\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeDasharray=\"30 105\"\n          className=\"origin-center animate-spin motion-reduce:animate-none [animation-duration:1.1s]\"\n        />\n      </svg>\n    </Button>\n  );\n}\nPromptSubmit.displayName = 'Prompt.Submit';\n\n/* ------------------------------------------------------------------ tasks */\n\n/** A filled circle in the success colour with a tick drawn in it. */\nconst CheckCircle = ({ className }: { className?: string }) => (\n  <svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"none\" className={className}>\n    <path\n      d=\"M22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12Z\"\n      fill=\"var(--success)\"\n      stroke=\"var(--success)\"\n      strokeWidth=\"1.5\"\n    />\n    <path d=\"M8 12.5L10.5 15L16 9\" stroke=\"white\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n  </svg>\n);\n\nexport type PromptTaskStatus = 'pending' | 'active' | 'done' | 'error';\n\nfunction reducedMotion() {\n  return typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n}\n\n/** A ring that fills as tasks are done, and becomes a check when all of them are. */\nfunction ProgressRing({ done, total }: { done: number; total: number }) {\n  const complete = total > 0 && done === total;\n  const r = 6.25;\n  const length = 2 * Math.PI * r;\n  if (complete) {\n    return <CheckCircle className=\"size-4\" />;\n  }\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" className=\"size-4 -rotate-90\">\n      <circle cx=\"8\" cy=\"8\" r={r} fill=\"none\" stroke=\"currentColor\" strokeOpacity=\"0.2\" strokeWidth=\"2\" />\n      <circle\n        cx=\"8\"\n        cy=\"8\"\n        r={r}\n        fill=\"none\"\n        stroke=\"var(--success)\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n        strokeDasharray={`${(length * done) / Math.max(total, 1)} ${length}`}\n        className=\"transition-[stroke-dasharray] duration-500 ease-out motion-reduce:transition-none\"\n      />\n    </svg>\n  );\n}\n\nexport interface PromptTasksProps extends Omit<ComponentProps<'div'>, 'title'> {\n  /** Whether the band is shown. It folds under the field when it is not. Default: shown, or with `mode`, while the prompt is in that mode. */\n  open?: boolean;\n  /** The prompt modes it is shown in. It folds away in any other. */\n  mode?: PromptBandMode;\n  /** What the agent is doing, on the band's one line: \"Preparing the release\". */\n  title: ReactNode;\n  /** Whether the list is open under the title, when the caller holds it. */\n  expanded?: boolean;\n  /** Whether the list starts open, when the caller does not hold it. Default `false`. */\n  defaultExpanded?: boolean;\n  /** Called when the title is pressed to open or close the list. */\n  onExpandedChange?: (expanded: boolean) => void;\n  /** How tall the list grows before it scrolls, as a CSS length. Default `10rem`. */\n  maxHeight?: string;\n}\n\n/**\n * A band above the field with an agent's tasks: one line with a ring that\n * fills as they are done, opening into the list. The list scrolls past\n * `maxHeight`, and keeps the task in progress in view.\n */\nfunction PromptTasks({\n  open,\n  mode,\n  title,\n  expanded,\n  defaultExpanded = false,\n  onExpandedChange,\n  maxHeight = '10rem',\n  className,\n  children,\n  ...props\n}: PromptTasksProps) {\n  const shown = useBandOpen('Prompt.Tasks', open, mode, () => true);\n  const slots = promptVariants();\n  const [inner, setInner] = useState(defaultExpanded);\n  const isExpanded = expanded ?? inner;\n  const listId = useId();\n  const list = useRef<HTMLOListElement>(null);\n  const tasks = Children.toArray(children).filter(isValidElement) as ReactElement<PromptTaskProps>[];\n  const done = tasks.filter((task) => task.props.status === 'done').length;\n  const active = tasks.findIndex((task) => task.props.status === 'active');\n\n  // The task in progress is kept in the middle of the list, so a long run can\n  // be followed without scrolling. The list scrolls, never the page.\n  useEffect(() => {\n    const node = list.current;\n    const current = node?.querySelector<HTMLElement>('[data-status=active]');\n    if (!node || !current || !isExpanded) return;\n    node.scrollTo({\n      top: current.offsetTop - node.clientHeight / 2 + current.offsetHeight / 2,\n      behavior: reducedMotion() ? 'auto' : 'smooth',\n    });\n  }, [active, isExpanded]);\n\n  const toggle = () => {\n    if (expanded === undefined) setInner(!isExpanded);\n    onExpandedChange?.(!isExpanded);\n  };\n\n  return (\n    <Band\n      edge=\"top\"\n      open={shown}\n      slot=\"prompt-tasks\"\n      className={['flex-col items-stretch gap-0 px-0 pt-0.5', className].filter(Boolean).join(' ')}\n      {...props}\n    >\n      <button\n        type=\"button\"\n        aria-expanded={isExpanded}\n        aria-controls={listId}\n        onClick={toggle}\n        className={slots.tasksHeader()}\n      >\n        <ProgressRing done={done} total={tasks.length} />\n        <span className={slots.tasksTitle()}>{title}</span>\n        <span className={slots.tasksCount()}>\n          {done}/{tasks.length}\n        </span>\n        <svg\n          aria-hidden=\"true\"\n          viewBox=\"0 0 24 24\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"2\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          className={slots.tasksChevron({ className: isExpanded ? 'rotate-180' : undefined })}\n        >\n          <path d=\"m6 9 6 6 6-6\" />\n        </svg>\n      </button>\n      <div className={slots.tasksBody({ className: isExpanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]' })}>\n        <div className=\"min-h-0 overflow-hidden\">\n          <ol ref={list} id={listId} aria-label=\"Tasks\" className={slots.tasksList()} style={{ maxHeight }}>\n            {children}\n          </ol>\n        </div>\n      </div>\n    </Band>\n  );\n}\nPromptTasks.displayName = 'Prompt.Tasks';\n\nexport interface PromptTaskProps extends ComponentProps<'li'> {\n  /** `pending`, `active` (its words are lit while it runs), `done` (a check) or `error`. Default `pending`. */\n  status?: PromptTaskStatus;\n}\n\n/** One task in `Prompt.Tasks`. */\nfunction PromptTask({ status = 'pending', className, children, render, ...props }: PromptTaskProps & RenderProp<'li'>) {\n  const slots = promptVariants();\n  const text = useShimmer(status === 'active');\n  return useRender({\n    defaultTagName: 'li',\n    render,\n    props: mergeProps(\n      {\n        'data-status': status,\n        className: slots.task({ className }),\n        children: (\n          <>\n            <span className={slots.taskMark()}>\n              {status === 'done' ? (\n                <CheckCircle className={slots.taskDone()} />\n              ) : status === 'active' ? (\n                <svg\n                  aria-hidden=\"true\"\n                  viewBox=\"0 0 24 24\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  strokeWidth=\"2.75\"\n                  strokeLinecap=\"round\"\n                  className=\"size-3.5 animate-spin text-foreground motion-reduce:animate-none\"\n                >\n                  <path d=\"M21 12a9 9 0 1 1-6.22-8.56\" />\n                </svg>\n              ) : status === 'error' ? (\n                <span className={slots.taskError()}>!</span>\n              ) : (\n                <span className={slots.taskPending()} />\n              )}\n            </span>\n            <span\n              ref={text}\n              className={\n                status === 'active'\n                  ? slots.statusText({ className: 'whitespace-normal' })\n                  : status === 'done'\n                    ? 'text-muted-foreground line-through decoration-muted-foreground/40'\n                    : status === 'error'\n                      ? 'text-destructive'\n                      : 'text-muted-foreground'\n              }\n            >\n              {children}\n            </span>\n            <span className=\"sr-only\">\n              {status === 'done'\n                ? ', done'\n                : status === 'active'\n                  ? ', in progress'\n                  : status === 'error'\n                    ? ', failed'\n                    : ''}\n            </span>\n          </>\n        ),\n      },\n      props\n    ),\n  });\n}\nPromptTask.displayName = 'Prompt.Task';\n\n/* --------------------------------------------------------------- commands */\n\nexport interface PromptCommandItem {\n  /** What is typed after the trigger to reach it: `summarise` for `/summarise`. */\n  value: string;\n  /** Its name in the list. Default the trigger and the value. */\n  label?: ReactNode;\n  /** What it does, after the name. */\n  description?: ReactNode;\n  icon?: ReactNode;\n}\n\nexport interface PromptCommandsProps {\n  /** The commands. */\n  items: readonly PromptCommandItem[];\n  /** The character that opens the list when the field starts with it. Default `/`. */\n  trigger?: string;\n  /**\n   * Called with the command chosen. With it, the field is cleared and the\n   * command is yours to show; without it, the command is written into the field.\n   */\n  onSelect?: (item: PromptCommandItem) => void;\n  /** The heading over the list. Default `Commands`. */\n  label?: ReactNode;\n  /** How tall the list grows before it scrolls, as a CSS length. Default `12rem`. */\n  maxHeight?: string;\n}\n\n/**\n * A band above the field listing commands while the text is a trigger and a\n * word, `/sum`. The arrow keys move through it, Enter or Tab chooses, Escape\n * puts it away.\n */\nfunction PromptCommands({\n  items,\n  trigger = '/',\n  onSelect,\n  label = 'Commands',\n  maxHeight = '12rem',\n}: PromptCommandsProps) {\n  const prompt = usePrompt('Prompt.Commands');\n  const slots = promptVariants();\n  const listId = useId();\n  const [activeIndex, setActiveIndex] = useState(0);\n  const [dismissed, setDismissed] = useState<string | null>(null);\n  const list = useRef<HTMLDivElement>(null);\n\n  const query =\n    prompt.value.startsWith(trigger) && !/\\s/.test(prompt.value)\n      ? prompt.value.slice(trigger.length).toLowerCase()\n      : null;\n  const matches =\n    query === null\n      ? []\n      : items\n          .filter(\n            (item) =>\n              item.value.toLowerCase().includes(query) ||\n              String(item.label ?? '')\n                .toLowerCase()\n                .includes(query)\n          )\n          // Those whose name starts with what was typed come first.\n          .sort(\n            (a, b) =>\n              Number(!a.value.toLowerCase().startsWith(query)) - Number(!b.value.toLowerCase().startsWith(query))\n          );\n  const open = matches.length > 0 && dismissed !== prompt.value;\n  const active = Math.min(activeIndex, Math.max(matches.length - 1, 0));\n  const optionId = (index: number) => `${listId}-${index}`;\n\n  // A new query starts at the top of what it matches.\n  useEffect(() => setActiveIndex(0), [query]);\n\n  const choose = (item: PromptCommandItem) => {\n    if (onSelect) {\n      onSelect(item);\n      prompt.setValue('');\n    } else {\n      prompt.setValue(`${trigger}${item.value} `);\n    }\n  };\n\n  // The field's keys come here first while the list is open.\n  useEffect(() => {\n    prompt.keyHandler.current = (event) => {\n      if (!open) return false;\n      if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {\n        event.preventDefault();\n        const step = event.key === 'ArrowDown' ? 1 : -1;\n        setActiveIndex((active + step + matches.length) % matches.length);\n        return true;\n      }\n      if ((event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) || event.key === 'Tab') {\n        event.preventDefault();\n        choose(matches[active]!);\n        return true;\n      }\n      if (event.key === 'Escape') {\n        event.preventDefault();\n        setDismissed(prompt.value);\n        return true;\n      }\n      return false;\n    };\n    return () => {\n      prompt.keyHandler.current = null;\n    };\n  });\n\n  // The field points a screen reader at the highlighted command.\n  const { setListbox } = prompt;\n  useEffect(() => {\n    setListbox(open ? { id: listId, active: optionId(active) } : null);\n    return () => setListbox(null);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [open, active, listId, setListbox]);\n\n  // The highlighted row is kept in view inside the list.\n  useEffect(() => {\n    const node = list.current;\n    const row = node?.querySelector<HTMLElement>('[aria-selected=true]');\n    if (!node || !row) return;\n    if (row.offsetTop < node.scrollTop) node.scrollTop = row.offsetTop;\n    else if (row.offsetTop + row.offsetHeight > node.scrollTop + node.clientHeight) {\n      node.scrollTop = row.offsetTop + row.offsetHeight - node.clientHeight;\n    }\n  }, [active]);\n\n  return (\n    <Band edge=\"top\" open={open} slot=\"prompt-commands\" className=\"flex-col items-stretch gap-0 px-0 pt-0\">\n      <div className={slots.commandsLabel()}>{label}</div>\n      <div\n        ref={list}\n        id={listId}\n        role=\"listbox\"\n        aria-label={typeof label === 'string' ? label : 'Commands'}\n        className={slots.commandsList({ className: 'relative' })}\n        style={{ maxHeight }}\n      >\n        {matches.map((item, index) => (\n          <div\n            key={item.value}\n            id={optionId(index)}\n            role=\"option\"\n            aria-selected={index === active}\n            className={slots.command()}\n            // Pressed without taking focus from the field.\n            onMouseDown={(event) => event.preventDefault()}\n            onMouseMove={() => index !== active && setActiveIndex(index)}\n            onClick={() => choose(item)}\n          >\n            {item.icon}\n            <span className={slots.commandName()}>{item.label ?? `${trigger}${item.value}`}</span>\n            {item.description ? <span className={slots.commandDescription()}>{item.description}</span> : null}\n          </div>\n        ))}\n      </div>\n    </Band>\n  );\n}\nPromptCommands.displayName = 'Prompt.Commands';\n\n/* --------------------------------------------------------------- recorder */\n\n/** Enough history to fill a wide field; only as many bars as fit are drawn. */\nconst BARS = 160;\n/** A bar and the gap after it, in pixels: `w-[3px]` and `gap-[2px]`. */\nconst BAR_PITCH = 5;\n\nexport interface PromptRecorderProps extends ComponentProps<'div'> {\n  /** What has been heard so far, written in as it arrives. */\n  transcript?: ReactNode;\n  /** Shown, lit by a moving sweep, until there is a transcript. Default `Listening…`. */\n  placeholder?: ReactNode;\n  /** Called when the cross is pressed, to throw the recording away. */\n  onCancel?: () => void;\n  /** Called when the check is pressed, to keep it. */\n  onDone?: () => void;\n  /**\n   * How loud the input is now, from 0 to 1, to draw from a real microphone.\n   * Without it the wave moves on its own, as a sign that it is listening.\n   */\n  level?: number;\n  /** What a screen reader calls the cross. Default `Cancel recording`. */\n  cancelLabel?: string;\n  /** And the check. Default `Finish recording`. */\n  doneLabel?: string;\n}\n\n/**\n * Takes the place of the text and the footer while voice is recorded: what\n * has been heard so far, the sound as a wave scrolling to the left, how long\n * it has been, and buttons to throw it away or keep it.\n */\nfunction PromptRecorder({\n  transcript,\n  placeholder = 'Listening…',\n  onCancel,\n  onDone,\n  level,\n  cancelLabel = 'Cancel recording',\n  doneLabel = 'Finish recording',\n  className,\n  render,\n  ...props\n}: PromptRecorderProps & RenderProp<'div'>) {\n  const slots = promptVariants();\n  // Starts on a low murmur rather than flat silence, so the wave is never a row of dots.\n  const [levels, setLevels] = useState<number[]>(() =>\n    Array.from({ length: BARS }, (_, i) => 0.12 + 0.06 * Math.sin(i * 1.7))\n  );\n  const [seconds, setSeconds] = useState(0);\n  const latest = useRef(level);\n  latest.current = level;\n  const listening = useShimmer(!transcript);\n  // The wave fills its row from the cross to the timer, however wide that is.\n  const wave = useRef<HTMLDivElement>(null);\n  const [fits, setFits] = useState(48);\n  useEffect(() => {\n    const node = wave.current;\n    if (!node || typeof ResizeObserver === 'undefined') return;\n    const observer = new ResizeObserver(([entry]) => {\n      setFits(Math.max(8, Math.min(BARS, Math.floor((entry!.contentRect.width + 2) / BAR_PITCH))));\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n\n  useEffect(() => {\n    const started = Date.now();\n    const clock = window.setInterval(() => setSeconds(Math.floor((Date.now() - started) / 1000)), 250);\n    if (reducedMotion() && latest.current === undefined) {\n      setLevels(Array.from({ length: BARS }, (_, i) => 0.25 + 0.2 * Math.sin(i / 3)));\n      return () => window.clearInterval(clock);\n    }\n    let t = 0;\n    let smooth = 0.2;\n    const tick = window.setInterval(() => {\n      t += 1;\n      // Speech: phrases that swell and fall, with syllables riding on them.\n      const phrase = Math.max(0, Math.sin(t / 11) * 0.6 + 0.45);\n      const syllable = Math.abs(Math.sin(t * 0.85 + Math.sin(t * 0.31) * 2));\n      const target = latest.current ?? Math.min(1, 0.1 + phrase * (0.3 + syllable * 0.6) + Math.random() * 0.12);\n      smooth += (target - smooth) * 0.55;\n      setLevels((current) => [...current.slice(1), smooth]);\n    }, 70);\n    return () => {\n      window.clearInterval(clock);\n      window.clearInterval(tick);\n    };\n  }, []);\n\n  const time = `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;\n\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'prompt-recorder',\n        className: slots.recorder({ className }),\n        children: (\n          <>\n            <p aria-live=\"polite\" className={slots.recorderTranscript()}>\n              {transcript || (\n                <span ref={listening} className={slots.recorderListening()}>\n                  {placeholder}\n                </span>\n              )}\n            </p>\n            <div className={slots.recorderControls()}>\n              <Button variant=\"ghost\" size=\"icon\" shape=\"pill\" aria-label={cancelLabel} onClick={onCancel}>\n                <XIcon />\n              </Button>\n              <div ref={wave} aria-hidden=\"true\" className={slots.recorderWave()}>\n                {levels.slice(-fits).map((value, index) => (\n                  <span key={index} className={slots.recorderBar()} style={{ height: `${3 + value * 33}px` }} />\n                ))}\n              </div>\n              <span role=\"timer\" aria-label={`Recording, ${time}`} className={slots.recorderTime()}>\n                <span className={slots.recorderDot()} />\n                {time}\n              </span>\n              <Button shape=\"pill\" aria-label={doneLabel} onClick={onDone} className={slots.recorderDone()}>\n                <svg\n                  aria-hidden=\"true\"\n                  viewBox=\"0 0 24 24\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  strokeWidth=\"2.25\"\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                >\n                  <path d=\"M20 6 9 17l-5-5\" />\n                </svg>\n              </Button>\n            </div>\n          </>\n        ),\n      },\n      props\n    ),\n  });\n}\nPromptRecorder.displayName = 'Prompt.Recorder';\n\nexport const Prompt = Object.assign(PromptRoot, {\n  Banner: PromptBanner,\n  BannerAction: PromptBannerAction,\n  Field: PromptField,\n  Attachments: PromptAttachments,\n  Textarea: PromptTextarea,\n  Footer: PromptFooter,\n  Tools: PromptTools,\n  Tool: PromptTool,\n  Attach: PromptAttach,\n  Menu: PromptMenu,\n  AttachItem: PromptAttachItem,\n  Model: PromptModel,\n  Submit: PromptSubmit,\n  Status: PromptStatus,\n  Tasks: PromptTasks,\n  Task: PromptTask,\n  Commands: PromptCommands,\n  Recorder: PromptRecorder,\n  Context: PromptContext,\n  ContextItem: PromptContextItem,\n  Hint: PromptHint,\n});\n\n/* By name as well, for a Server Component, which cannot dot into a client\n   module. */\nexport {\n  PromptBanner,\n  PromptBannerAction,\n  PromptField,\n  PromptAttachments,\n  PromptTextarea,\n  PromptFooter,\n  PromptTools,\n  PromptTool,\n  PromptAttach,\n  PromptMenu,\n  PromptAttachItem,\n  PromptModel,\n  PromptSubmit,\n  PromptStatus,\n  PromptTasks,\n  PromptTask,\n  PromptCommands,\n  PromptRecorder,\n  PromptContext,\n  PromptContextItem,\n  PromptHint,\n};\n"
    }
  ]
}
