{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"prompt-input","title":"Prompt Input","description":"A message field for chat and AI interfaces that sends on Enter and turns Send into Stop while a reply runs. Search terms: composer, chat input, message input.","dependencies":["lucide-react@1.18.0"],"registryDependencies":["@siteplane/base","@siteplane/button","@siteplane/form","@siteplane/input","@siteplane/input-group","@siteplane/motion","@siteplane/tooltip"],"files":[{"path":"packages/ui/src/components/prompt-input/prompt-input.tsx","content":"\"use client\";\n\nimport { ArrowUpIcon, PaperclipIcon, SquareIcon } from \"lucide-react\";\nimport * as React from \"react\";\n\nimport { useRelativeControlSize } from \"../../system/provider/siteplane-ui-provider\";\nimport { cn } from \"../../system/utils/cn\";\nimport { slotClass } from \"../../system/utils/slot-class\";\nimport { Button, type ButtonBaseProps, type ButtonProps } from \"../button\";\nimport { Form } from \"../form\";\nimport { Input } from \"../input\";\nimport {\n  InputGroup,\n  InputGroupAddon,\n  InputGroupTextarea,\n} from \"../input-group\";\nimport { MotionSwap } from \"../motion\";\nimport { Tooltip, TooltipPopup, TooltipTrigger } from \"../tooltip\";\nimport {\n  sendDraft,\n  submitOnEnterKey,\n  submitState,\n  type PromptInputStatus,\n  type PromptInputSubmitOnEnter,\n} from \"./prompt-input-keys\";\n\nimport \"./prompt-input.css\";\n\nexport type { PromptInputStatus, PromptInputSubmitOnEnter };\n\n/** The words the Prompt Input speaks. Each can be replaced through `labels`. */\nexport type PromptInputLabels = {\n  /** The name of the field for assistive technology. */\n  textarea: string;\n  /** The name of the button that sends the draft. */\n  send: string;\n  /** The name of the button that stops a reply that is running. */\n  stop: string;\n  /** The name and the tooltip of the button that picks files. */\n  attach: string;\n};\n\nconst defaultPromptInputLabels: PromptInputLabels = {\n  attach: \"Attach files\",\n  send: \"Send message\",\n  stop: \"Stop\",\n  textarea: \"Message\",\n};\n\ntype PromptInputContextValue = {\n  allowEmpty: boolean;\n  disabled: boolean;\n  labels: PromptInputLabels;\n  onStop: (() => void) | undefined;\n  setValue: (value: string) => void;\n  status: PromptInputStatus;\n  submitOnEnter: PromptInputSubmitOnEnter;\n  textareaRef: React.RefObject<HTMLTextAreaElement | null>;\n  value: string;\n};\n\nconst PromptInputContext = React.createContext<PromptInputContextValue | null>(\n  null,\n);\n\nfunction usePromptInput(part: string): PromptInputContextValue {\n  const context = React.useContext(PromptInputContext);\n  if (!context) throw new Error(`${part} must be rendered inside PromptInput.`);\n  return context;\n}\n\nexport type PromptInputProps = Omit<\n  React.ComponentProps<\"form\">,\n  \"defaultValue\" | \"onSubmit\"\n> & {\n  /** The draft, for a controlled field. The app clears it when it sends. */\n  value?: string | undefined;\n  /** The draft of an uncontrolled field, which clears itself when it sends. */\n  defaultValue?: string | undefined;\n  onValueChange?: ((value: string) => void) | undefined;\n  /**\n   * Called when the draft is sent, by Enter or by the button, with the draft\n   * trimmed of the white space around it. Never called while a reply runs or\n   * for a draft that has nothing in it, unless `allowEmpty` is set.\n   */\n  onSubmit: (message: { text: string }) => void;\n  /** Where the chat is in a turn (the AI SDK's `status`). A running reply turns Send into Stop. */\n  status?: PromptInputStatus | undefined;\n  /** Called when Stop is pressed. */\n  onStop?: (() => void) | undefined;\n  /** Which Enter sends. Shift+Enter always adds a line. */\n  submitOnEnter?: PromptInputSubmitOnEnter | undefined;\n  /** Lets a draft with no text go, for a message that holds only attachments. */\n  allowEmpty?: boolean | undefined;\n  /** Disables the field, the attach button and Send. */\n  disabled?: boolean | undefined;\n  /** The surface of the field (the InputGroup's). */\n  variant?: React.ComponentProps<typeof InputGroup>[\"variant\"];\n  labels?: Partial<PromptInputLabels> | undefined;\n};\n\n/**\n * The message field of a chat: a form around an InputGroup with a textarea\n * that grows with the draft, an attachments row above it and a toolbar below.\n * It sends on Enter, adds a line on Shift+Enter, never sends while an IME\n * composition is open, and shows Stop while a reply runs. The field stays\n * editable while a reply runs; only sending is held back. The app owns the\n * messages, the files and the transport: `onSubmit` hands over the text.\n */\nexport function PromptInput({\n  allowEmpty = false,\n  children,\n  className,\n  defaultValue = \"\",\n  disabled = false,\n  labels,\n  onStop,\n  onSubmit,\n  onValueChange,\n  status = \"ready\",\n  submitOnEnter = \"always\",\n  value,\n  variant,\n  ...props\n}: PromptInputProps): React.ReactElement {\n  const [uncontrolledDraft, setUncontrolledDraft] =\n    React.useState(defaultValue);\n  const controlled = value !== undefined;\n  const draft = controlled ? value : uncontrolledDraft;\n  const textareaRef = React.useRef<HTMLTextAreaElement | null>(null);\n  const text = { ...defaultPromptInputLabels, ...labels };\n  const state = submitState(status, draft, allowEmpty);\n\n  const setValue = (next: string) => {\n    if (!controlled) setUncontrolledDraft(next);\n    onValueChange?.(next);\n  };\n\n  const context: PromptInputContextValue = {\n    allowEmpty,\n    disabled,\n    labels: text,\n    onStop,\n    setValue,\n    status,\n    submitOnEnter,\n    textareaRef,\n    value: draft,\n  };\n\n  return (\n    <PromptInputContext.Provider value={context}>\n      <Form\n        {...props}\n        className={className}\n        data-slot=\"prompt-input\"\n        onSubmit={(event) => {\n          // Enter and the button both end here. The form never navigates.\n          event.preventDefault();\n          sendDraft({\n            controlled,\n            disabled,\n            draft,\n            // The field keeps focus, so the next message can be typed at once.\n            focus: () => textareaRef.current?.focus({ preventScroll: true }),\n            onSubmit,\n            setValue,\n            state,\n          });\n        }}\n      >\n        <InputGroup variant={variant}>{children}</InputGroup>\n      </Form>\n    </PromptInputContext.Provider>\n  );\n}\n\n/** The row above the field that holds the attachments: an AttachmentGroup. Render it only while there are files. */\nexport function PromptInputAttachments({\n  className,\n  ...props\n}: Omit<\n  React.ComponentProps<typeof InputGroupAddon>,\n  \"align\"\n>): React.ReactElement {\n  return (\n    <InputGroupAddon\n      align=\"block-start\"\n      className={cn(\"sp-prompt-input-attachments\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction assignRef<T>(ref: React.Ref<T> | undefined, value: T | null): void {\n  if (typeof ref === \"function\") ref(value);\n  else if (ref) (ref as React.RefObject<T | null>).current = value;\n}\n\nexport type PromptInputTextareaProps = Omit<\n  React.ComponentProps<typeof InputGroupTextarea>,\n  \"defaultValue\" | \"value\"\n>;\n\n/**\n * The field. It grows from one line to eight, then scrolls, and is named\n * \"Message\". Enter sends as `submitOnEnter` says, and the draft is the\n * PromptInput's, so the field takes no `value`.\n */\nexport function PromptInputTextarea({\n  className,\n  disabled,\n  maxLines = 8,\n  minLines = 1,\n  onChange,\n  onKeyDown,\n  ref,\n  ...props\n}: PromptInputTextareaProps): React.ReactElement {\n  const prompt = usePromptInput(\"PromptInputTextarea\");\n  const { textareaRef } = prompt;\n  const mergedRef = React.useCallback(\n    (node: HTMLTextAreaElement | null) => {\n      textareaRef.current = node;\n      assignRef(ref, node);\n    },\n    [ref, textareaRef],\n  );\n\n  return (\n    <InputGroupTextarea\n      aria-label={prompt.labels.textarea}\n      {...props}\n      className={cn(\"sp-prompt-input-textarea\", className)}\n      disabled={disabled ?? prompt.disabled}\n      maxLines={maxLines}\n      minLines={minLines}\n      onChange={(event) => {\n        onChange?.(event);\n        prompt.setValue(event.target.value);\n      }}\n      onKeyDown={(event) => {\n        onKeyDown?.(event);\n        // The form decides whether this Enter sends, so a draft that cannot\n        // be sent, or a reply that runs, adds no line either.\n        submitOnEnterKey(\n          event,\n          prompt.submitOnEnter,\n          () =>\n            typeof window !== \"undefined\" &&\n            typeof window.matchMedia === \"function\" &&\n            window.matchMedia(\"(pointer: fine)\").matches,\n        );\n      }}\n      ref={mergedRef}\n      value={prompt.value}\n    />\n  );\n}\n\n/** The row below the field. Put two PromptInputToolbarGroups in it: the controls that start the row and the ones that end it. */\nexport function PromptInputToolbar({\n  className,\n  ...props\n}: Omit<\n  React.ComponentProps<typeof InputGroupAddon>,\n  \"align\"\n>): React.ReactElement {\n  return (\n    <InputGroupAddon\n      align=\"block-end\"\n      className={cn(\"sp-prompt-input-toolbar flex-wrap\", className)}\n      {...props}\n    />\n  );\n}\n\nexport type PromptInputToolbarGroupProps = React.ComponentProps<\"div\"> & {\n  /** `end` pushes the group to the end of the row. */\n  align?: \"start\" | \"end\" | undefined;\n};\n\nexport function PromptInputToolbarGroup({\n  align = \"start\",\n  className,\n  ...props\n}: PromptInputToolbarGroupProps): React.ReactElement {\n  return (\n    <div\n      className={cn(slotClass(\"prompt-input-toolbar-group\", props), className)}\n      data-align={align}\n      data-slot=\"prompt-input-toolbar-group\"\n      {...props}\n    />\n  );\n}\n\nexport type PromptInputAttachButtonProps = Omit<\n  ButtonBaseProps,\n  \"onChange\" | \"type\"\n> & {\n  /** The file types the picker offers, as the `accept` of a file input. */\n  accept?: string | undefined;\n  /** Whether the picker takes several files. */\n  multiple?: boolean | undefined;\n  /** Called with the picked files. The app owns them from here. */\n  onFilesSelected: (files: File[]) => void;\n};\n\n/**\n * The button that opens the file picker, with a Paperclip icon and a tooltip.\n * It is the field action of rulebook I4, the bare glyph in the 24 px target.\n * The picker is the kit Input of type file, hidden beside it.\n */\nexport function PromptInputAttachButton({\n  accept,\n  children,\n  disabled,\n  multiple = true,\n  onClick,\n  onFilesSelected,\n  ...props\n}: PromptInputAttachButtonProps): React.ReactElement {\n  const prompt = usePromptInput(\"PromptInputAttachButton\");\n  const picker = React.useRef<HTMLInputElement>(null);\n\n  return (\n    <>\n      <Tooltip>\n        <TooltipTrigger\n          render={\n            <Button\n              aria-label={prompt.labels.attach}\n              data-slot=\"prompt-input-attach-button\"\n              profile=\"field-action\"\n              shape=\"square\"\n              {...props}\n              disabled={disabled ?? prompt.disabled}\n              onClick={(event) => {\n                onClick?.(event);\n                if (!event.defaultPrevented) picker.current?.click();\n              }}\n              type=\"button\"\n            />\n          }\n        >\n          {children ?? <PaperclipIcon />}\n        </TooltipTrigger>\n        <TooltipPopup>{prompt.labels.attach}</TooltipPopup>\n      </Tooltip>\n      {/* The picker waits outside the layout; the button opens it. It stays\n          enabled when the button is not, because a disabled input would dim\n          the whole field. */}\n      <div hidden>\n        <Input\n          accept={accept}\n          aria-label={prompt.labels.attach}\n          multiple={multiple}\n          onChange={(event) => {\n            const input = event.currentTarget;\n            const files = Array.from(input.files ?? []);\n            // Cleared, so the same file can be picked again.\n            input.value = \"\";\n            if (files.length > 0) onFilesSelected(files);\n          }}\n          ref={picker}\n          tabIndex={-1}\n          type=\"file\"\n        />\n      </div>\n    </>\n  );\n}\n\ntype DistributiveOmit<T, K extends PropertyKey> = T extends unknown\n  ? Omit<T, K>\n  : never;\n\nexport type PromptInputSubmitProps = DistributiveOmit<\n  ButtonProps,\n  \"children\" | \"type\"\n>;\n\n/**\n * Send, or Stop while a reply runs. Send is the filled round button with the\n * ArrowUp icon and submits the form; it can be pressed once the draft has\n * something in it. Stop shows a Square, stays pressable and calls `onStop`.\n * The icons fade through each other (rulebook M8), and the button is never\n * the Button's `loading`, which would disable it. Any Button prop, such as\n * `variant`, `shape` or `size`, replaces the default look.\n */\nexport function PromptInputSubmit({\n  className,\n  disabled,\n  onClick,\n  ...props\n}: PromptInputSubmitProps): React.ReactElement {\n  const prompt = usePromptInput(\"PromptInputSubmit\");\n  const size = useRelativeControlSize(\"md\");\n  const state = submitState(prompt.status, prompt.value, prompt.allowEmpty);\n  const stop = state.kind === \"stop\";\n\n  return (\n    <Button\n      aria-label={stop ? prompt.labels.stop : prompt.labels.send}\n      data-action={state.kind}\n      data-slot=\"prompt-input-submit\"\n      shape=\"pill\"\n      size={`icon-${size}`}\n      variant=\"accent\"\n      {...props}\n      className={className}\n      // A disabled composer turns off the field, the attach button and Send;\n      // Stop stays pressable, so a reply that runs can always be stopped.\n      disabled={\n        Boolean(disabled) || (!stop && (prompt.disabled || state.disabled))\n      }\n      onClick={(event) => {\n        onClick?.(event);\n        if (!stop) return;\n        // Stop never submits, also when the reply ends in this very click and\n        // the button is Send again before the browser acts on it.\n        event.preventDefault();\n        prompt.onStop?.();\n      }}\n      type={stop ? \"button\" : \"submit\"}\n    >\n      <MotionSwap\n        className=\"grid place-items-center\"\n        render={<span />}\n        swapKey={state.kind}\n      >\n        {stop ? (\n          <SquareIcon aria-hidden=\"true\" fill=\"currentColor\" />\n        ) : (\n          <ArrowUpIcon aria-hidden=\"true\" />\n        )}\n      </MotionSwap>\n    </Button>\n  );\n}\n","type":"registry:ui","target":"~/src/siteplane/components/prompt-input/prompt-input.tsx"},{"path":"packages/ui/src/components/prompt-input/prompt-input-keys.ts","content":"/*\n * The rules of the Prompt Input that need no DOM: which Enter sends, and what\n * the submit button is for. Kept apart from the component so they are checked\n * without a browser; the component calls them from its handlers.\n */\n\n/**\n * Where the chat is in a turn, in the words of the AI SDK's chat `status`:\n * `ready` waits for a message, `submitted` has sent one and waits for the\n * reply to start, `streaming` receives it and `error` failed, back to a\n * field that sends again.\n */\nexport type PromptInputStatus = \"ready\" | \"submitted\" | \"streaming\" | \"error\";\n\n/**\n * Which Enter sends: `always`, `desktop` only with a fine pointer (a mouse or\n * a trackpad, so a phone's Enter adds a line), `never` (Enter adds a line).\n */\nexport type PromptInputSubmitOnEnter = \"always\" | \"desktop\" | \"never\";\n\n/** What the Prompt Input reads of a key press. */\nexport type PromptInputEnterEvent = {\n  /** True while an IME composition is open (a browser sets it on the keydown). */\n  isComposing: boolean;\n  key: string;\n  /** Safari reports the key that ends an IME composition as 229, with isComposing already false. */\n  keyCode: number;\n  shiftKey: boolean;\n};\n\n/**\n * Whether this key press sends the message. Shift+Enter adds a line. Nothing\n * is sent while an IME composition is open, because that Enter confirms a\n * candidate: `isComposing`, or keyCode 229 for the Enter that ends it in\n * Safari. `pointerFine` is whether the primary pointer is a mouse or a\n * trackpad; only `desktop` reads it.\n */\nexport function shouldSubmitOnEnter(\n  event: PromptInputEnterEvent,\n  mode: PromptInputSubmitOnEnter,\n  pointerFine: boolean,\n): boolean {\n  if (event.key !== \"Enter\" || event.shiftKey) return false;\n  if (event.isComposing || event.keyCode === 229) return false;\n  if (mode === \"never\") return false;\n  return mode === \"always\" || pointerFine;\n}\n\n/** What the submit button does now, and whether it can be pressed. */\nexport type PromptInputSubmitState = {\n  disabled: boolean;\n  kind: \"send\" | \"stop\";\n};\n\n/**\n * Send, or Stop while a reply is on its way (`submitted` or `streaming`). Stop\n * can always be pressed. Send needs a draft with something in it, white space\n * not counting, unless `allowEmpty` lets a draft that holds only attachments\n * go. After an `error` the field sends again.\n */\nexport function submitState(\n  status: PromptInputStatus,\n  draft: string,\n  allowEmpty: boolean,\n): PromptInputSubmitState {\n  if (status === \"submitted\" || status === \"streaming\")\n    return { disabled: false, kind: \"stop\" };\n  return { disabled: !allowEmpty && draft.trim() === \"\", kind: \"send\" };\n}\n\n/**\n * The key event the Prompt Input reads, as a React keyboard event on the\n * textarea has it: the composition flags live on the native event.\n */\nexport type PromptInputKeyDownEvent = {\n  currentTarget: { form: { requestSubmit(): void } | null };\n  defaultPrevented: boolean;\n  key: string;\n  nativeEvent: { isComposing: boolean; keyCode: number };\n  preventDefault(): void;\n  shiftKey: boolean;\n};\n\n/**\n * What the textarea does on a key press. An Enter that sends is cancelled, so\n * it adds no line, and the form is asked to submit; the form decides whether\n * the draft can go. A key that something else already handled is left alone.\n * `pointerFine` is read only when the mode asks for it. Returns whether the\n * key was taken.\n */\nexport function submitOnEnterKey(\n  event: PromptInputKeyDownEvent,\n  mode: PromptInputSubmitOnEnter,\n  pointerFine: () => boolean,\n): boolean {\n  if (event.defaultPrevented) return false;\n  if (\n    !shouldSubmitOnEnter(\n      {\n        isComposing: event.nativeEvent.isComposing,\n        key: event.key,\n        keyCode: event.nativeEvent.keyCode,\n        shiftKey: event.shiftKey,\n      },\n      mode,\n      mode === \"desktop\" && pointerFine(),\n    )\n  )\n    return false;\n  event.preventDefault();\n  event.currentTarget.form?.requestSubmit();\n  return true;\n}\n\n/** What the form needs to send the draft. */\nexport type SendDraftOptions = {\n  /** A controlled draft is the app's to clear. */\n  controlled: boolean;\n  disabled: boolean;\n  draft: string;\n  /** Puts focus back in the field. */\n  focus: () => void;\n  onSubmit: (message: { text: string }) => void;\n  setValue: (value: string) => void;\n  state: PromptInputSubmitState;\n};\n\n/**\n * Sends the draft when it can go: not while the field is disabled, not while a\n * reply runs and not for a draft that has nothing in it (unless `allowEmpty`\n * made the state enabled). The text goes out without the white space around\n * it, an uncontrolled field clears itself, and focus stays in the field.\n * Returns whether it sent.\n */\nexport function sendDraft(options: SendDraftOptions): boolean {\n  const { controlled, disabled, draft, focus, onSubmit, setValue, state } =\n    options;\n  if (disabled || state.kind === \"stop\" || state.disabled) return false;\n  onSubmit({ text: draft.trim() });\n  if (!controlled) setValue(\"\");\n  focus();\n  return true;\n}\n","type":"registry:ui","target":"~/src/siteplane/components/prompt-input/prompt-input-keys.ts"},{"path":"packages/ui/src/components/prompt-input/prompt-input.css","content":"@layer theme, base, components, siteplane, utilities;\n\n@layer siteplane {\n  /* The toolbar under the field holds two groups: the controls that start the\n   * row and the ones that end it. A group may shrink below its controls, and\n   * the end group sits at the end of the row also when the start group is\n   * absent or the row wraps. The InputGroup's block addon owns the row's\n   * padding and gap. */\n  .sp-prompt-input-toolbar-group {\n    display: flex;\n    min-width: 0;\n    align-items: center;\n    gap: var(--sp-rw-space-2);\n  }\n\n  .sp-prompt-input-toolbar-group[data-align=\"end\"] {\n    margin-inline-start: auto;\n  }\n\n  /* The attachments row shrinks with the field, so a row of cards scrolls\n   * inside it instead of widening the field. */\n  .sp-prompt-input-attachments {\n    min-width: 0;\n  }\n}\n","type":"registry:ui","target":"~/src/siteplane/components/prompt-input/prompt-input.css"},{"path":"packages/ui/src/components/prompt-input/index.ts","content":"export * from \"./prompt-input\";\n","type":"registry:ui","target":"~/src/siteplane/components/prompt-input/index.ts"}],"type":"registry:ui","docs":"https://ui.siteplane.io/docs-markdown/components/prompt-input.md","categories":["Chat & AI"],"meta":{"avoidWhen":["Avoid for a multi-line form field such as a comment or a description; use Textarea in a Field.","Avoid for a search box; use Input or Command."],"description":"A message field for chat and AI interfaces that sends on Enter and turns Send into Stop while a reply runs.","knownLimitations":["The field sends nothing. onSubmit hands over the trimmed text, and the application owns the messages, the files, the model and the transport.","onSubmit receives the text only; keep the attached files in your own state and send them with it.","Send becomes Stop while status is submitted or streaming. The field stays editable then, and no message is sent until the status changes.","The model and effort pickers are Menus in the toolbar, not parts of the field; the application keeps the choices and sends them with the message.","The field grows with its draft where the browser supports field-sizing: content, and keeps its starting height elsewhere.","A card that leaves the attachments row is inert while it fades, so move focus to the field before removing it."],"providerNotes":["Builds on Form, Input, Input Group, Button, Tooltip and the motion component, which the registry installs with it.","The tooltip of the attach button is the installed Tooltip. A TooltipProvider sets its delay; without one it opens after Base UI’s default delay."],"releaseStatus":"preview","searchTerms":["prompt-input","prompt","composer","chat input","message input","chat","ai","send","stop"],"useWhen":["Use for the message field of a chat or an AI assistant, where Enter sends, Shift+Enter adds a line and Send becomes Stop while a reply runs.","Use when a message can carry attachments, or when the toolbar holds pickers such as the model."]}}