# AI Input I (`prompt-composer`)

> An editorial AI chat input — attachment dropdown for images and documents, web-search and deep-think toggles, a model selector, and a live character counter.

- **Docs:** https://www.mellowui.com/components/prompt-composer
- **Markdown:** https://www.mellowui.com/components/prompt-composer.md
- **Registry:** https://www.mellowui.com/r/prompt-composer.json
- **Tool prompt:** https://www.mellowui.com/api/prompt/prompt-composer
- **Categories:** interactive, form, ai
- **Dependencies:** none

## AI prompt

Add a PromptComposer component from the mellow library — an AI chat input with an attachment dropdown (image / document file pickers), 'Search web' and 'Deep think' toggle pills, a model selector dropdown, and a send button. Enter submits, Shift+Enter adds a newline. Pass a `models` string array and handle `onSubmit`, which receives { text, model, attachments, webSearch, deepThink }. Attachments are File objects — upload them yourself in the handler.

## Install

```bash
npx shadcn@latest add https://mellowui.com/r/prompt-composer.json
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `models` | `string[]` | `["mellow-4-opus", "mellow-4-sonnet", "mellow-4-mini"]` | Model ids shown in the selector dropdown. |
| `defaultModel` | `string` | `models[0]` | Initially selected model. |
| `placeholder` | `string` | `"Set your question in type…"` | Placeholder text for the prompt textarea. |
| `maxLength` | `number` | `4000` | Character limit, reflected in the live counter. |
| `disabled` | `boolean` | `false` | Disables all input while a response is streaming. |
| `enableSound` | `boolean` | `true` | Play a synthesized typewriter carriage-return (thock + faint bell) on submit. |
| `onSubmit` | `(message: PromptMessage) => void` | — | Called on send with { text, model, attachments, webSearch, deepThink }. |
| `className` | `string` | — | Additional CSS classes. |

## Design notes

- Client component (`"use client"`) — required for animation and refs.
- Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors.
- Respects `prefers-reduced-motion: reduce` where applicable.
- Self-contained — no shared utilities. Copy-paste, not a package.

## Source

```tsx
"use client";

import React, {
  useCallback,
  useEffect,
  useRef,
  useState,
  type CSSProperties,
  type ReactNode,
} from "react";

export interface PromptAttachment {
  id: string;
  file: File;
  kind: "image" | "document";
}

export interface PromptMessage {
  text: string;
  model: string;
  attachments: PromptAttachment[];
  webSearch: boolean;
  deepThink: boolean;
}

export interface PromptComposerProps {
  models?: string[];
  defaultModel?: string;
  placeholder?: string;
  maxLength?: number;
  disabled?: boolean;
  /** Play a typewriter carriage-return sound on submit. */
  enableSound?: boolean;
  onSubmit?: (message: PromptMessage) => void;
  className?: string;
  style?: CSSProperties;
}

const DEFAULT_MODELS = ["mellow-4-opus", "mellow-4-sonnet", "mellow-4-mini"];

let attachmentSeq = 0;

/** Typewriter carriage return — a low filtered-noise thock, then a faint bell. */
function playCarriageReturn(ctx: AudioContext) {
  const thockLen = Math.floor(ctx.sampleRate * 0.06);
  const buf = ctx.createBuffer(1, thockLen, ctx.sampleRate);
  const data = buf.getChannelData(0);
  for (let i = 0; i < thockLen; i++) {
    data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / thockLen, 5) * 0.85;
  }
  const src = ctx.createBufferSource();
  src.buffer = buf;
  const lp = ctx.createBiquadFilter();
  lp.type = "lowpass";
  lp.frequency.value = 1100;
  const thockGain = ctx.createGain();
  thockGain.gain.value = 0.55;
  src.connect(lp);
  lp.connect(thockGain);
  thockGain.connect(ctx.destination);
  src.start();

  const bell = ctx.createOscillator();
  bell.type = "sine";
  bell.frequency.value = 1870;
  const bellGain = ctx.createGain();
  const t = ctx.currentTime + 0.05;
  bellGain.gain.setValueAtTime(0, t);
  bellGain.gain.linearRampToValueAtTime(0.05, t + 0.008);
  bellGain.gain.exponentialRampToValueAtTime(0.0001, t + 0.45);
  bell.connect(bellGain);
  bellGain.connect(ctx.destination);
  bell.start(t);
  bell.stop(t + 0.5);
}

function formatBytes(bytes: number) {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

/** Closes the menu when a pointer event lands outside `ref`. */
function useClickOutside(ref: React.RefObject<HTMLElement | null>, onOutside: () => void) {
  useEffect(() => {
    const handler = (event: PointerEvent) => {
      if (ref.current && !ref.current.contains(event.target as Node)) onOutside();
    };
    document.addEventListener("pointerdown", handler);
    return () => document.removeEventListener("pointerdown", handler);
  }, [ref, onOutside]);
}

function Icon({ path, size = 13 }: { path: ReactNode; size?: number }) {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.8"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className="shrink-0"
    >
      {path}
    </svg>
  );
}

const PATHS = {
  plus: <path d="M12 5v14M5 12h14" />,
  image: (
    <>
      <rect x="3" y="3" width="18" height="18" rx="1" />
      <circle cx="9" cy="9" r="2" />
      <path d="m21 15-3.5-3.5L6 23" />
    </>
  ),
  document: (
    <>
      <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
      <path d="M14 2v6h6M9 13h6M9 17h6" />
    </>
  ),
  globe: (
    <>
      <circle cx="12" cy="12" r="9" />
      <path d="M3 12h18M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18" />
    </>
  ),
  think: (
    <>
      <path d="M12 3a6 6 0 0 1 6 6c0 2.4-1.2 3.7-2.3 5-.8.9-1.2 1.6-1.2 3h-5c0-1.4-.4-2.1-1.2-3C7.2 12.7 6 11.4 6 9a6 6 0 0 1 6-6z" />
      <path d="M9.5 20.5h5M10.5 23h3" />
    </>
  ),
  chevron: <path d="m6 9 6 6 6-6" />,
  arrowUp: <path d="M12 19V5m-6 6 6-6 6 6" />,
  x: <path d="M6 6l12 12M18 6 6 18" />,
};

export function PromptComposer({
  models = DEFAULT_MODELS,
  defaultModel,
  placeholder = "Set your question in type…",
  maxLength = 4000,
  disabled = false,
  enableSound = true,
  onSubmit,
  className,
  style,
}: PromptComposerProps) {
  const [text, setText] = useState("");
  const [stamped, setStamped] = useState(false);
  const audioCtxRef = useRef<AudioContext | null>(null);
  const [model, setModel] = useState(defaultModel ?? models[0] ?? "");
  const [webSearch, setWebSearch] = useState(false);
  const [deepThink, setDeepThink] = useState(false);
  const [attachments, setAttachments] = useState<PromptAttachment[]>([]);
  const [attachOpen, setAttachOpen] = useState(false);
  const [modelOpen, setModelOpen] = useState(false);

  const textareaRef = useRef<HTMLTextAreaElement>(null);
  const attachMenuRef = useRef<HTMLDivElement>(null);
  const modelMenuRef = useRef<HTMLDivElement>(null);
  const imageInputRef = useRef<HTMLInputElement>(null);
  const docInputRef = useRef<HTMLInputElement>(null);

  useClickOutside(attachMenuRef, useCallback(() => setAttachOpen(false), []));
  useClickOutside(modelMenuRef, useCallback(() => setModelOpen(false), []));

  const autogrow = useCallback(() => {
    const el = textareaRef.current;
    if (!el) return;
    el.style.height = "auto";
    el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
  }, []);

  useEffect(autogrow, [text, autogrow]);

  const addFiles = (files: FileList | null, kind: "image" | "document") => {
    if (!files) return;
    const next = Array.from(files).map((file) => ({
      id: `att-${++attachmentSeq}`,
      file,
      kind,
    }));
    setAttachments((prev) => [...prev, ...next]);
  };

  const canSend = !disabled && (text.trim().length > 0 || attachments.length > 0);

  const submit = () => {
    if (!canSend) return;
    onSubmit?.({ text: text.trim(), model, attachments, webSearch, deepThink });
    setText("");
    setAttachments([]);

    if (enableSound) {
      try {
        const AC =
          window.AudioContext ||
          (window as unknown as { webkitAudioContext: typeof AudioContext })
            .webkitAudioContext;
        if (!audioCtxRef.current || audioCtxRef.current.state === "closed") {
          audioCtxRef.current = new AC();
        }
        const ctx = audioCtxRef.current;
        const play = () => playCarriageReturn(ctx);
        ctx.state === "suspended" ? void ctx.resume().then(play) : play();
      } catch {
        // audio unavailable — animation alone carries the feedback
      }
    }
    if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      setStamped(true);
    }
  };

  return (
    <div
      className={[
        "relative border border-(--rule) bg-(--background) text-(--ink)",
        disabled ? "pointer-events-none opacity-50" : "",
        className,
      ]
        .filter(Boolean)
        .join(" ")}
      style={{
        ...style,
        animation: stamped ? "mlw-pc-stamp 0.26s cubic-bezier(0.34,1.56,0.64,1)" : undefined,
      }}
      onAnimationEnd={() => setStamped(false)}
    >
      <style>{`@keyframes mlw-pc-stamp{0%{transform:none}30%{transform:translateY(3px)}100%{transform:none}}`}</style>
      {/* registration marks */}
      <span aria-hidden="true" className="pointer-events-none absolute -left-px -top-px h-2.5 w-2.5 border-l border-t border-(--ink)" />
      <span aria-hidden="true" className="pointer-events-none absolute -bottom-px -right-px h-2.5 w-2.5 border-b border-r border-(--ink)" />

      <input
        ref={imageInputRef}
        type="file"
        accept="image/*"
        multiple
        className="hidden"
        onChange={(e) => {
          addFiles(e.target.files, "image");
          e.target.value = "";
        }}
      />
      <input
        ref={docInputRef}
        type="file"
        accept=".pdf,.doc,.docx,.txt,.md,.csv,.json"
        multiple
        className="hidden"
        onChange={(e) => {
          addFiles(e.target.files, "document");
          e.target.value = "";
        }}
      />

      {attachments.length > 0 && (
        <ul className="flex flex-wrap gap-2 border-b border-dashed border-(--rule) p-3">
          {attachments.map((att) => (
            <li
              key={att.id}
              className="flex items-center gap-2 border border-(--rule) bg-[rgba(var(--ink-rgb),0.04)] py-1 pl-2 pr-1"
            >
              <span className="text-[rgba(var(--ink-rgb),0.55)]">
                <Icon path={att.kind === "image" ? PATHS.image : PATHS.document} size={12} />
              </span>
              <span className="max-w-36 truncate font-mono text-[0.68rem] tracking-tight">
                {att.file.name}
              </span>
              <span className="font-mono text-[0.6rem] uppercase text-[rgba(var(--ink-rgb),0.4)]">
                {formatBytes(att.file.size)}
              </span>
              <button
                type="button"
                aria-label={`Remove ${att.file.name}`}
                onClick={() => setAttachments((prev) => prev.filter((a) => a.id !== att.id))}
                className="flex h-5 w-5 cursor-pointer items-center justify-center text-[rgba(var(--ink-rgb),0.5)] transition-colors hover:bg-[rgba(var(--ink-rgb),0.1)] hover:text-(--ink)"
              >
                <Icon path={PATHS.x} size={10} />
              </button>
            </li>
          ))}
        </ul>
      )}

      <textarea
        ref={textareaRef}
        value={text}
        rows={2}
        maxLength={maxLength}
        disabled={disabled}
        placeholder={placeholder}
        aria-label="Prompt"
        onChange={(e) => setText(e.target.value)}
        onKeyDown={(e) => {
          if (e.key === "Enter" && !e.shiftKey) {
            e.preventDefault();
            submit();
          }
        }}
        className="block w-full resize-none bg-transparent p-4 text-[0.9375rem] leading-6 text-(--ink) outline-hidden! placeholder:font-serif placeholder:italic placeholder:text-[rgba(var(--ink-rgb),0.35)]"
      />

      <div className="flex items-center justify-between gap-2 border-t border-(--rule) px-2 py-2">
        <div className="flex items-center gap-1">
          {/* attach dropdown */}
          <div ref={attachMenuRef} className="relative">
            <button
              type="button"
              aria-label="Add attachment"
              aria-haspopup="menu"
              aria-expanded={attachOpen}
              onClick={() => setAttachOpen((o) => !o)}
              className={[
                "flex h-8 w-8 cursor-pointer items-center justify-center border transition-colors",
                attachOpen
                  ? "border-(--ink) bg-(--ink) text-(--background)"
                  : "border-(--rule) text-[rgba(var(--ink-rgb),0.6)] hover:border-[rgba(var(--ink-rgb),0.4)] hover:text-(--ink)",
              ].join(" ")}
            >
              <Icon path={PATHS.plus} />
            </button>

            {attachOpen && (
              <div
                role="menu"
                aria-label="Attachment type"
                className="absolute bottom-full left-0 z-10 mb-2 w-44 border border-(--rule) bg-(--background)"
              >
                <p className="border-b border-(--rule) px-3 py-1.5 font-mono text-[0.58rem] uppercase tracking-[0.2em] text-[rgba(var(--ink-rgb),0.45)]">
                  Attach
                </p>
                {(
                  [
                    { label: "Image", icon: PATHS.image, ref: imageInputRef },
                    { label: "Document", icon: PATHS.document, ref: docInputRef },
                  ] as const
                ).map((item) => (
                  <button
                    key={item.label}
                    type="button"
                    role="menuitem"
                    onClick={() => {
                      setAttachOpen(false);
                      item.ref.current?.click();
                    }}
                    className="flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-left text-[0.8125rem] text-(--ink) transition-colors hover:bg-[rgba(var(--ink-rgb),0.07)]"
                  >
                    <span className="text-[rgba(var(--ink-rgb),0.55)]">
                      <Icon path={item.icon} />
                    </span>
                    {item.label}
                  </button>
                ))}
              </div>
            )}
          </div>

          <TogglePill
            label="Search web"
            icon={PATHS.globe}
            pressed={webSearch}
            onToggle={() => setWebSearch((v) => !v)}
          />
          <TogglePill
            label="Deep think"
            icon={PATHS.think}
            pressed={deepThink}
            onToggle={() => setDeepThink((v) => !v)}
          />
        </div>

        <div className="flex items-center gap-2">
          <span
            aria-hidden="true"
            className="hidden font-mono text-[0.6rem] tracking-[0.08em] text-[rgba(var(--ink-rgb),0.35)] sm:block"
          >
            {String(text.length).padStart(4, "0")}/{maxLength}
          </span>

          {/* model dropdown */}
          <div ref={modelMenuRef} className="relative">
            <button
              type="button"
              aria-haspopup="menu"
              aria-expanded={modelOpen}
              aria-label={`Model: ${model}`}
              onClick={() => setModelOpen((o) => !o)}
              className="flex h-8 cursor-pointer items-center gap-1.5 border border-(--rule) px-2.5 font-mono text-[0.68rem] tracking-tight text-(--ink) transition-colors hover:border-[rgba(var(--ink-rgb),0.4)]"
            >
              {model}
              <span
                className={`text-[rgba(var(--ink-rgb),0.5)] transition-transform duration-200 ${modelOpen ? "rotate-180" : ""}`}
              >
                <Icon path={PATHS.chevron} size={11} />
              </span>
            </button>

            {modelOpen && (
              <div
                role="menu"
                aria-label="Model"
                className="absolute bottom-full right-0 z-10 mb-2 w-48 border border-(--rule) bg-(--background)"
              >
                <p className="border-b border-(--rule) px-3 py-1.5 font-mono text-[0.58rem] uppercase tracking-[0.2em] text-[rgba(var(--ink-rgb),0.45)]">
                  Model
                </p>
                {models.map((m, i) => {
                  const active = m === model;
                  return (
                    <button
                      key={m}
                      type="button"
                      role="menuitemradio"
                      aria-checked={active}
                      onClick={() => {
                        setModel(m);
                        setModelOpen(false);
                      }}
                      className={[
                        "flex w-full cursor-pointer items-baseline gap-2.5 px-3 py-2 text-left font-mono text-[0.72rem] transition-colors",
                        active
                          ? "bg-(--ink) text-(--background)"
                          : "text-(--ink) hover:bg-[rgba(var(--ink-rgb),0.07)]",
                      ].join(" ")}
                    >
                      <span className={active ? "opacity-70" : "text-[rgba(var(--ink-rgb),0.35)]"}>
                        {String(i + 1).padStart(2, "0")}
                      </span>
                      {m}
                    </button>
                  );
                })}
              </div>
            )}
          </div>

          <button
            type="button"
            aria-label="Send message"
            disabled={!canSend}
            onClick={submit}
            className={[
              "flex h-8 w-8 items-center justify-center border transition-all duration-150",
              canSend
                ? "cursor-pointer border-(--ink) bg-(--ink) text-(--background) hover:-translate-y-px hover:shadow-[2px_2px_0_rgba(var(--ink-rgb),0.25)] motion-reduce:hover:translate-y-0"
                : "border-(--rule) text-[rgba(var(--ink-rgb),0.25)]",
            ].join(" ")}
          >
            <Icon path={PATHS.arrowUp} size={14} />
          </button>
        </div>
      </div>
    </div>
  );
}

function TogglePill({
  label,
  icon,
  pressed,
  onToggle,
}: {
  label: string;
  icon: ReactNode;
  pressed: boolean;
  onToggle: () => void;
}) {
  return (
    <button
      type="button"
      aria-pressed={pressed}
      onClick={onToggle}
      className={[
        "flex h-8 cursor-pointer items-center gap-1.5 border px-2.5 font-mono text-[0.62rem] uppercase tracking-[0.14em] transition-colors",
        pressed
          ? "border-(--ink) bg-(--ink) text-(--background)"
          : "border-(--rule) text-[rgba(var(--ink-rgb),0.55)] hover:border-[rgba(var(--ink-rgb),0.4)] hover:text-(--ink)",
      ].join(" ")}
    >
      <Icon path={icon} size={12} />
      <span className="hidden sm:inline">{label}</span>
    </button>
  );
}

export default PromptComposer;

```

## Demo

```tsx
"use client";

import React, { useState } from "react";
import { PromptComposer, type PromptMessage } from "../mellow/prompt-composer";

export default function PromptComposerDemo() {
  const [last, setLast] = useState<PromptMessage | null>(null);

  return (
    <div className="w-full max-w-xl p-4">
      <p className="mb-3 font-mono text-[0.62rem] uppercase tracking-[0.22em] text-[rgba(var(--ink-rgb),0.45)]">
        AI Input I — ask anything
      </p>

      <PromptComposer
        models={["mellow-4-opus", "mellow-4-sonnet", "mellow-4-mini"]}
        onSubmit={setLast}
      />

      {last && (
        <p className="mt-3 font-mono text-[0.68rem] leading-5 text-[rgba(var(--ink-rgb),0.55)]">
          sent → “{last.text || "(attachments only)"}” · {last.model}
          {last.webSearch ? " · web" : ""}
          {last.deepThink ? " · deep-think" : ""}
          {last.attachments.length > 0 ? ` · ${last.attachments.length} file(s)` : ""}
        </p>
      )}
    </div>
  );
}

```
