{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "prompt-input",
  "title": "Prompt Input",
  "description": "The chat composer on an input group: grows with its text, submits on Enter, turns into Stop while streaming, and opens a command list over itself.",
  "dependencies": [
    "@base-ui/react",
    "@hugeicons/core-free-icons",
    "@hugeicons/react",
    "@stylexjs/stylex"
  ],
  "registryDependencies": [
    "https://constella.aayush.cv/r/base.json",
    "https://constella.aayush.cv/r/button.json",
    "https://constella.aayush.cv/r/command.json",
    "https://constella.aayush.cv/r/input-group.json",
    "https://constella.aayush.cv/r/popover.json",
    "https://constella.aayush.cv/r/swap-icon.json",
    "https://constella.aayush.cv/r/textarea.json"
  ],
  "files": [
    {
      "path": "src/components/ai/prompt-input.tsx",
      "content": "\"use client\";\n\nimport { Autocomplete as AutocompletePrimitive } from \"@base-ui/react/autocomplete\";\nimport { ArrowUp02Icon, StopIcon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { createContext, use, useMemo, useRef } from \"react\";\nimport type { ComponentProps, RefObject } from \"react\";\n\nimport { Button } from \"@/components/primitives/button\";\nimport type { ButtonProps } from \"@/components/primitives/button\";\nimport { commandStyles } from \"@/components/primitives/command\";\nimport {\n  InputGroup,\n  InputGroupAddon,\n  InputGroupButton,\n  InputGroupTextarea,\n} from \"@/components/primitives/input-group\";\nimport type {\n  InputGroupAddonProps,\n  InputGroupButtonProps,\n} from \"@/components/primitives/input-group\";\nimport { popoverStyles } from \"@/components/primitives/popover\";\nimport { SwapIcon, SwapIconItem } from \"@/components/primitives/swap-icon\";\nimport type { TextareaProps } from \"@/components/primitives/textarea\";\nimport type { Sx } from \"@/lib/constella/styles\";\nimport { typeStyles } from \"@/lib/constella/styles\";\nimport {\n  colors,\n  fontSizes,\n  lineHeights,\n  media,\n  offsets,\n  radii,\n  shadows,\n  sizes,\n  space,\n  strokes,\n} from \"@/lib/constella/tokens.stylex\";\n\nconst promptInputStates = [\"ready\", \"submitted\", \"streaming\"] as const;\n\ntype PromptInputState = (typeof promptInputStates)[number];\n\ntype Styled<T> = Omit<T, \"className\" | \"style\"> & {\n  sx?: Sx;\n};\n\n// Pills nest in the composer's corner at any radius (inset = outer radius − pill radius);\n// `sm` attachments match them at the default radius.\nconst inset =\n  `max(${space.xxs}, calc(${radii.xl} - ${sizes.controlSm} / 2))` as const;\n// A bar button's leading icon (6px padding past a 1px border) lines up with the composer's text (10px in).\nconst barInset = `calc(${space.xxs} - ${strokes.border})` as const;\nconst invalid =\n  \":has(> [data-slot='input-group-control']:is([aria-invalid='true'], [data-invalid]))\";\nconst wellCorner = `calc(${radii.xl} + ${space.xxs})`;\n// The well frames the prompt, so its own ring would double the edge.\nconst inWell = \":is([data-slot='prompt-input-well'] *)\";\n\nconst styles = stylex.create({\n  form: {\n    width: \"100%\",\n  },\n  // The composer is the roomiest surface, so it takes the top of the corner ladder.\n  group: {\n    borderStartStartRadius: radii.xl,\n    borderStartEndRadius: radii.xl,\n    borderEndStartRadius: radii.xl,\n    borderEndEndRadius: radii.xl,\n    // A surface, not a control: the `raised` fill, and its edge a ring in its shadow, so no border.\n    backgroundColor: colors.raised,\n    borderBlockEndWidth: 0,\n    borderBlockStartWidth: 0,\n    borderInlineEndWidth: 0,\n    borderInlineStartWidth: 0,\n    boxShadow: {\n      default: shadows.defaultRingSubtle,\n      [inWell]: \"var(--shadow-default-ring-subtle)\",\n      [invalid]: shadows.defaultRingInvalid,\n    },\n    height: \"auto\",\n  },\n  // Sets the type itself: typeStyles would override Textarea's touch size, and iOS would zoom.\n  textarea: {\n    fontSize: { default: fontSizes.textBase, [media.coarse]: fontSizes.touch },\n    lineHeight: {\n      default: lineHeights.textBase,\n      [media.coarse]: lineHeights.touch,\n    },\n    maxHeight: {\n      default: `calc(${lineHeights.textBase} * 6 + ${space.xs} * 2)`,\n      [media.coarse]: `calc(${lineHeights.touch} * 6 + ${space.xs} * 2)`,\n    },\n    minHeight: sizes.controlLg,\n    overflowY: \"auto\",\n  },\n  header: {\n    paddingBlockStart: {\n      default: inset,\n      // The group already pads `space.xxs` so focus rings aren't clipped.\n      \":has(> [data-slot='attachment-group'])\": `calc(${inset} - ${space.xxs})`,\n    },\n    paddingInlineEnd: inset,\n    paddingInlineStart: inset,\n  },\n  footer: {\n    justifyContent: \"space-between\",\n    paddingBlockEnd: inset,\n    paddingInlineEnd: inset,\n    paddingInlineStart: inset,\n  },\n  tools: {\n    alignItems: \"center\",\n    display: \"flex\",\n    gap: space.xxs,\n  },\n  // The one corner past the top of the ladder: the composer keeps `xl` inside it.\n  well: {\n    backgroundColor: colors.well,\n    borderStartStartRadius: wellCorner,\n    borderStartEndRadius: wellCorner,\n    borderEndStartRadius: wellCorner,\n    borderEndEndRadius: wellCorner,\n    display: \"flex\",\n    flexDirection: \"column\",\n    paddingBlockEnd: space.xxs,\n    paddingBlockStart: space.xxs,\n    paddingInlineEnd: space.xxs,\n    paddingInlineStart: space.xxs,\n    width: \"100%\",\n  },\n  barButton: {\n    color: {\n      default: colors.textSecondary,\n      \":is([data-popup-open])\": colors.textPrimary,\n      [media.hover]: {\n        default: colors.textSecondary,\n        \":hover:not(:disabled)\": colors.textPrimary,\n      },\n      \":active:not(:disabled)\": colors.textPrimary,\n    },\n  },\n  bar: {\n    alignItems: \"center\",\n    color: colors.textSecondary,\n    display: \"flex\",\n    gap: space.xs,\n    minHeight: sizes.controlSm,\n    // The well's own padding is the gap on its outer side, so each side reads `space.xxs`.\n    paddingBlockEnd: { default: space.xxs, \":last-child\": 0 },\n    paddingBlockStart: { default: space.xxs, \":first-child\": 0 },\n    paddingInlineEnd: barInset,\n    paddingInlineStart: barInset,\n  },\n  // As wide as the composer; a list pads its own top.\n  commandContent: {\n    boxShadow: shadows.lg,\n    maxHeight: \"var(--available-height)\",\n    paddingBlockStart: {\n      default: space.xxs,\n      \":has(> [data-slot='command-list'])\": 0,\n    },\n    width: \"var(--anchor-width)\",\n  },\n});\n\n/** A form around an input group: submit it with Enter or the submit button. */\nfunction PromptInput({\n  children,\n  sx,\n  ...props\n}: Styled<ComponentProps<\"form\">>) {\n  return (\n    <form\n      data-slot=\"prompt-input\"\n      {...props}\n      {...stylex.props(styles.form, sx)}\n    >\n      <InputGroup sx={styles.group}>{children}</InputGroup>\n    </form>\n  );\n}\n\n/** Submits its form on Enter; Shift+Enter, and Enter while an IME is composing, add a line. */\nfunction PromptInputTextarea({\n  onKeyDown,\n  placeholder = \"Ask anything…\",\n  sx,\n  ...props\n}: TextareaProps) {\n  return (\n    <InputGroupTextarea\n      data-prompt-input-textarea=\"\"\n      onKeyDown={(event) => {\n        onKeyDown?.(event);\n        if (\n          event.defaultPrevented ||\n          event.key !== \"Enter\" ||\n          event.shiftKey ||\n          event.nativeEvent.isComposing\n        ) {\n          return;\n        }\n        event.preventDefault();\n        event.currentTarget.form?.requestSubmit();\n      }}\n      placeholder={placeholder}\n      rows={1}\n      {...props}\n      sx={[styles.textarea, sx]}\n    />\n  );\n}\n\n/** Above the text, for attachments. */\nfunction PromptInputHeader({\n  sx,\n  ...props\n}: Omit<InputGroupAddonProps, \"align\">) {\n  return (\n    <InputGroupAddon\n      align=\"block-start\"\n      data-prompt-input-header=\"\"\n      {...props}\n      sx={[styles.header, sx]}\n    />\n  );\n}\n\n/** Below the text: tools at the start, submit at the end. */\nfunction PromptInputFooter({\n  sx,\n  ...props\n}: Omit<InputGroupAddonProps, \"align\">) {\n  return (\n    <InputGroupAddon\n      align=\"block-end\"\n      data-prompt-input-footer=\"\"\n      {...props}\n      sx={[styles.footer, sx]}\n    />\n  );\n}\n\nfunction PromptInputTools({ sx, ...props }: Styled<ComponentProps<\"div\">>) {\n  return (\n    <div\n      data-slot=\"prompt-input-tools\"\n      {...props}\n      {...stylex.props(styles.tools, sx)}\n    />\n  );\n}\n\n/** A tinted tray around a prompt input and the bars that set its context, like a project or apps. */\nfunction PromptInputWell({ sx, ...props }: Styled<ComponentProps<\"div\">>) {\n  return (\n    <div\n      data-slot=\"prompt-input-well\"\n      {...props}\n      {...stylex.props(styles.well, sx)}\n    />\n  );\n}\n\n/** A quiet pill for a bar: secondary text, so the context reads behind the prompt. */\nfunction PromptInputBarButton({\n  size = \"sm\",\n  sx,\n  variant = \"ghost\",\n  ...props\n}: Omit<ButtonProps, \"color\">) {\n  return (\n    <Button\n      data-slot=\"prompt-input-bar-button\"\n      size={size}\n      variant={variant}\n      {...props}\n      sx={[styles.barButton, sx]}\n    />\n  );\n}\n\n/** A row in the well, above or below the prompt input. */\nfunction PromptInputBar({ sx, ...props }: Styled<ComponentProps<\"div\">>) {\n  return (\n    <div\n      data-slot=\"prompt-input-bar\"\n      {...props}\n      {...stylex.props([typeStyles.textSm, styles.bar], sx)}\n    />\n  );\n}\n\n/** Sends the prompt; while an answer streams it turns into Stop, which calls `onStop`. */\nfunction PromptInputSubmit({\n  disabled,\n  onClick,\n  onStop,\n  state = \"ready\",\n  ...props\n}: Omit<InputGroupButtonProps, \"children\" | \"type\"> & {\n  onStop?: () => void;\n  state?: PromptInputState;\n}) {\n  const streaming = state === \"streaming\";\n  return (\n    <InputGroupButton\n      aria-busy={state === \"submitted\" || undefined}\n      aria-label={streaming ? \"Stop\" : \"Send\"}\n      data-slot=\"prompt-input-submit\"\n      data-state={state}\n      disabled={streaming ? false : disabled}\n      size=\"icon-sm\"\n      type={streaming ? \"button\" : \"submit\"}\n      variant=\"primary\"\n      {...props}\n      onClick={(event) => {\n        onClick?.(event);\n        if (streaming) {\n          event.preventDefault();\n          onStop?.();\n        }\n      }}\n    >\n      <SwapIcon aria-hidden value={streaming ? \"stop\" : \"send\"}>\n        <SwapIconItem value=\"send\">\n          <HugeiconsIcon\n            icon={ArrowUp02Icon}\n            size={sizes.icon}\n            strokeWidth={Number(strokes.icon)}\n          />\n        </SwapIconItem>\n        <SwapIconItem value=\"stop\">\n          <HugeiconsIcon\n            icon={StopIcon}\n            size={sizes.icon}\n            strokeWidth={Number(strokes.icon)}\n          />\n        </SwapIconItem>\n      </SwapIcon>\n    </InputGroupButton>\n  );\n}\n\ninterface CommandFrame {\n  /** Takes the textarea, and keeps the composer around it as the popup's anchor. */\n  attach: (input: Element | null) => void;\n  frame: RefObject<Element | null>;\n  open: boolean;\n}\n\nconst CommandFrameContext = createContext<CommandFrame | null>(null);\n\nfunction useCommandFrame() {\n  const frame = use(CommandFrameContext);\n  if (frame === null) {\n    throw new Error(\n      \"Prompt input command parts must be inside a PromptInputCommand.\"\n    );\n  }\n  return frame;\n}\n\ntype PromptInputCommandProps = Omit<\n  AutocompletePrimitive.Root.Props<unknown>,\n  | \"defaultOpen\"\n  | \"defaultValue\"\n  | \"filter\"\n  | \"inline\"\n  | \"mode\"\n  | \"open\"\n  | \"value\"\n> & {\n  open: boolean;\n  /** The text the items are filtered by, like the draft after its `/`. */\n  query: string;\n  /** The draft; controlled, since Base UI keeps a pick's label as its own value. */\n  value: string;\n};\n\n/** A command list the prompt input opens over itself; the app sets `open` and `query` from the draft. */\nfunction PromptInputCommand({\n  onValueChange,\n  open,\n  query,\n  ...props\n}: PromptInputCommandProps) {\n  const frame = useRef<Element | null>(null);\n  const { contains } = AutocompletePrimitive.useFilter();\n  const context = useMemo(\n    () => ({\n      attach: (input: Element | null) => {\n        frame.current = input?.closest(\"[data-slot='input-group']\") ?? null;\n      },\n      frame,\n      open,\n    }),\n    [open]\n  );\n  return (\n    <CommandFrameContext value={context}>\n      <AutocompletePrimitive.Root\n        autoHighlight=\"always\"\n        filter={(item, _value, itemToString) =>\n          contains(item, query, itemToString)\n        }\n        onValueChange={(value, details) => {\n          // A pick runs the item's `onClick`; it never writes the item into the draft.\n          if (details.reason !== \"item-press\") {\n            onValueChange?.(value, details);\n          }\n        }}\n        open={open}\n        {...props}\n      />\n    </CommandFrameContext>\n  );\n}\n\n/** The prompt input's textarea as the command's input: while the list is open, arrows move, Enter, Cmd/Ctrl/Alt+Enter and Tab pick, Esc closes. */\nfunction PromptInputCommandTextarea(props: TextareaProps) {\n  const { attach, open } = useCommandFrame();\n  return (\n    <AutocompletePrimitive.Input\n      onKeyDown={(event) => {\n        // Closed, or moving the caret, it stays a plain textarea.\n        if (!open || event.key === \"Home\" || event.key === \"End\") {\n          event.preventBaseUIHandler();\n          return;\n        }\n        // Base UI picks on a plain Enter only; Tab and Cmd/Ctrl/Alt+Enter pick too, rather than submit.\n        const modifiedEnter =\n          event.key === \"Enter\" &&\n          (event.metaKey || event.ctrlKey || event.altKey);\n        if ((event.key === \"Tab\" && !event.shiftKey) || modifiedEnter) {\n          const highlighted = event.currentTarget.getAttribute(\n            \"aria-activedescendant\"\n          );\n          if (highlighted !== null) {\n            event.preventDefault();\n            event.currentTarget.ownerDocument\n              .querySelector<HTMLElement>(`#${CSS.escape(highlighted)}`)\n              ?.click();\n          }\n        }\n      }}\n      ref={attach}\n      render={<PromptInputTextarea {...props} />}\n    />\n  );\n}\n\n/** The command's popup on the composer's edge, above it unless there's no room. Holds a `CommandList` and `CommandEmpty`. */\nfunction PromptInputCommandContent({\n  align = \"start\",\n  side = \"top\",\n  sideOffset = Number(offsets.popover),\n  sx,\n  ...props\n}: Styled<AutocompletePrimitive.Popup.Props> &\n  Pick<\n    AutocompletePrimitive.Positioner.Props,\n    \"align\" | \"side\" | \"sideOffset\"\n  >) {\n  const { frame } = useCommandFrame();\n  return (\n    <AutocompletePrimitive.Portal>\n      <AutocompletePrimitive.Positioner\n        align={align}\n        anchor={frame}\n        side={side}\n        sideOffset={sideOffset}\n        {...stylex.props(popoverStyles.positioner)}\n      >\n        <AutocompletePrimitive.Popup\n          data-slot=\"prompt-input-command-content\"\n          {...props}\n          {...stylex.props(commandStyles.surface, styles.commandContent, sx)}\n        />\n      </AutocompletePrimitive.Positioner>\n    </AutocompletePrimitive.Portal>\n  );\n}\n\nexport {\n  PromptInput,\n  PromptInputBar,\n  PromptInputBarButton,\n  PromptInputCommand,\n  PromptInputCommandContent,\n  PromptInputCommandTextarea,\n  PromptInputFooter,\n  PromptInputHeader,\n  promptInputStates,\n  PromptInputSubmit,\n  PromptInputTextarea,\n  PromptInputTools,\n  PromptInputWell,\n};\nexport type { PromptInputCommandProps, PromptInputState };\n",
      "type": "registry:component"
    }
  ],
  "docs": "Usage: https://constella.aayush.cv/docs/components/prompt-input/index.md",
  "type": "registry:ui"
}