Skip to content
6

Prompt Input

Chat composer with an auto-growing textarea, Enter to send, attachments, a model picker, and a Stop button while streaming. Ships composable parts.

Preview

Installation

npx shadcn@latest add https://hirael.com/r/prompt-input.json

Code

components/blocks/prompt-input.tsx
'use client';

import * as React from 'react';
import { ArrowUp, ChevronDown, FileText, Paperclip, Square, X } from 'lucide-react';

import { cn } from '@/lib/utils';
import { Button } from '@/registry/hirael/bases/radix/ui/button';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuTrigger,
} from '@/registry/hirael/bases/radix/ui/dropdown-menu';
import { Textarea } from '@/registry/hirael/bases/radix/ui/textarea';

export interface PromptAttachment {
  id: string;
  name: string;
  /** Bytes. */
  size: number;
  type?: string;
  /** Present when the attachment came from the file picker or a paste. */
  file?: File;
}

export interface PromptInputModel {
  id: string;
  label: string;
  hint?: string;
}

interface PromptInputContextValue {
  value: string;
  setValue: (next: string) => void;
  attachments: PromptAttachment[];
  addFiles: (files: Iterable<File>) => void;
  removeAttachment: (id: string) => void;
  disabled: boolean;
  isStreaming: boolean;
  canSubmit: boolean;
  submit: () => void;
  stop: () => void;
  textareaId: string;
}

const PromptInputContext = React.createContext<PromptInputContextValue | null>(null);

const usePromptInput = () => {
  const context = React.useContext(PromptInputContext);
  if (!context) {
    throw new Error('PromptInput parts must be rendered inside <PromptInput>.');
  }
  return context;
};

const 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`;
};

let attachmentSeq = 0;

const toAttachment = (file: File): PromptAttachment => {
  attachmentSeq += 1;
  return {
    id: `${file.name}-${file.size}-${attachmentSeq}`,
    name: file.name,
    size: file.size,
    type: file.type,
    file,
  };
};

interface PromptInputProps extends Omit<React.ComponentProps<'form'>, 'onSubmit' | 'defaultValue' | 'value'> {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  defaultAttachments?: PromptAttachment[];
  onAttachmentsChange?: (attachments: PromptAttachment[]) => void;
  /**
   * Called with the trimmed text and the current attachments. In
   * uncontrolled mode the text clears afterwards; when `value` is
   * controlled, clear it yourself here. Attachments always clear.
   */
  onSubmit?: (value: string, options: { attachments: PromptAttachment[] }) => void;
  disabled?: boolean;
  /** Turns the submit button into a Stop button that calls `onStop`. */
  isStreaming?: boolean;
  onStop?: () => void;
}

const PromptInput = ({
  value: valueProp,
  defaultValue = '',
  onValueChange,
  defaultAttachments,
  onAttachmentsChange,
  onSubmit,
  disabled = false,
  isStreaming = false,
  onStop,
  className,
  children,
  ...props
}: PromptInputProps) => {
  const [internalValue, setInternalValue] = React.useState(defaultValue);
  const isControlled = valueProp !== undefined;
  const value = isControlled ? valueProp : internalValue;
  const [attachments, setAttachments] = React.useState<PromptAttachment[]>(() => defaultAttachments ?? []);
  const textareaId = React.useId();

  const setValue = React.useCallback(
    (next: string) => {
      if (!isControlled) setInternalValue(next);
      onValueChange?.(next);
    },
    [isControlled, onValueChange],
  );

  const addFiles = React.useCallback(
    (files: Iterable<File>) => {
      const added = Array.from(files).map(toAttachment);
      if (added.length === 0) return;
      const next = [...attachments, ...added];
      setAttachments(next);
      onAttachmentsChange?.(next);
    },
    [attachments, onAttachmentsChange],
  );

  const removeAttachment = React.useCallback(
    (id: string) => {
      const next = attachments.filter((a) => a.id !== id);
      setAttachments(next);
      onAttachmentsChange?.(next);
    },
    [attachments, onAttachmentsChange],
  );

  const canSubmit = !disabled && (value.trim().length > 0 || attachments.length > 0);

  const submit = React.useCallback(() => {
    if (isStreaming || !canSubmit) return;
    onSubmit?.(value.trim(), { attachments });
    if (!isControlled) setInternalValue('');
    if (attachments.length > 0) {
      setAttachments([]);
      onAttachmentsChange?.([]);
    }
  }, [isStreaming, canSubmit, onSubmit, value, attachments, isControlled, onAttachmentsChange]);

  const stop = React.useCallback(() => onStop?.(), [onStop]);

  const context = React.useMemo<PromptInputContextValue>(
    () => ({
      value,
      setValue,
      attachments,
      addFiles,
      removeAttachment,
      disabled,
      isStreaming,
      canSubmit,
      submit,
      stop,
      textareaId,
    }),
    [
      value,
      setValue,
      attachments,
      addFiles,
      removeAttachment,
      disabled,
      isStreaming,
      canSubmit,
      submit,
      stop,
      textareaId,
    ],
  );

  return (
    <PromptInputContext.Provider value={context}>
      <form
        data-slot="prompt-input"
        data-streaming={isStreaming || undefined}
        data-disabled={disabled || undefined}
        onSubmit={(event) => {
          event.preventDefault();
          submit();
        }}
        className={cn(
          'flex w-full flex-col gap-2 rounded-xl border border-input bg-card p-2 text-card-foreground shadow-xs transition-[border-color,box-shadow] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 data-[disabled]:opacity-60 motion-reduce:transition-none',
          className,
        )}
        {...props}
      >
        {children}
      </form>
    </PromptInputContext.Provider>
  );
};

interface PromptInputTextareaProps extends Omit<
  React.ComponentProps<'textarea'>,
  'value' | 'defaultValue' | 'onChange'
> {
  /** Grows with the text up to this many rows, then scrolls. */
  maxRows?: number;
  minRows?: number;
}

const PromptInputTextarea = ({
  maxRows = 6,
  minRows = 1,
  placeholder = 'Ask anything',
  className,
  onKeyDown,
  onPaste,
  ...props
}: PromptInputTextareaProps) => {
  const { value, setValue, submit, addFiles, disabled, textareaId } = usePromptInput();
  const ref = React.useRef<HTMLTextAreaElement>(null);

  React.useLayoutEffect(() => {
    const el = ref.current;
    if (!el) return;
    el.style.height = 'auto';
    const styles = getComputedStyle(el);
    const lineHeight = parseFloat(styles.lineHeight) || 24;
    const padding = (parseFloat(styles.paddingTop) || 0) + (parseFloat(styles.paddingBottom) || 0);
    const min = lineHeight * minRows + padding;
    const max = lineHeight * maxRows + padding;
    el.style.height = `${Math.min(Math.max(el.scrollHeight, min), max)}px`;
    el.style.overflowY = el.scrollHeight > max ? 'auto' : 'hidden';
  }, [value, maxRows, minRows]);

  const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
    onKeyDown?.(event);
    if (event.defaultPrevented) return;
    // Enter sends, Shift+Enter breaks the line. An IME that is still
    // composing (Japanese, Chinese, Korean input) uses Enter to commit
    // the candidate, so leave those alone.
    if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
      event.preventDefault();
      submit();
    }
  };

  const handlePaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
    onPaste?.(event);
    if (event.defaultPrevented) return;
    const files = event.clipboardData?.files;
    if (files && files.length > 0) {
      event.preventDefault();
      addFiles(files);
    }
  };

  return (
    <Textarea
      ref={ref}
      id={textareaId}
      data-slot="prompt-input-textarea"
      rows={minRows}
      value={value}
      disabled={disabled}
      placeholder={placeholder}
      onChange={(event) => setValue(event.target.value)}
      onKeyDown={handleKeyDown}
      onPaste={handlePaste}
      className={cn(
        'min-h-0 resize-none rounded-none border-0 bg-transparent px-2 py-1.5 text-sm leading-6 shadow-none focus-visible:ring-0 dark:bg-transparent',
        className,
      )}
      {...props}
    />
  );
};

type PromptInputToolbarProps = React.ComponentProps<'div'>;

const PromptInputToolbar = ({ className, ...props }: PromptInputToolbarProps) => {
  return <div data-slot="prompt-input-toolbar" className={cn('flex items-center gap-1', className)} {...props} />;
};

type PromptInputActionsProps = React.ComponentProps<'div'>;

/** Start-side group in the toolbar; whatever follows it is pushed to the end. */
const PromptInputActions = ({ className, ...props }: PromptInputActionsProps) => {
  return (
    <div data-slot="prompt-input-actions" className={cn('me-auto flex items-center gap-1', className)} {...props} />
  );
};

interface PromptInputAttachProps extends Omit<React.ComponentProps<typeof Button>, 'onChange' | 'type'> {
  accept?: string;
  multiple?: boolean;
}

const PromptInputAttach = ({ accept, multiple = true, className, children, ...props }: PromptInputAttachProps) => {
  const { addFiles, disabled } = usePromptInput();
  const inputRef = React.useRef<HTMLInputElement>(null);

  return (
    <>
      <input
        ref={inputRef}
        type="file"
        data-slot="prompt-input-attach-input"
        className="hidden"
        tabIndex={-1}
        accept={accept}
        multiple={multiple}
        onChange={(event) => {
          if (event.target.files) addFiles(event.target.files);
          // Reset so picking the same file twice still fires a change.
          event.target.value = '';
        }}
      />
      <Button
        type="button"
        data-slot="prompt-input-attach"
        variant="ghost"
        size="icon-sm"
        disabled={disabled}
        aria-label="Attach files"
        onClick={() => inputRef.current?.click()}
        className={cn('text-muted-foreground hover:text-foreground', className)}
        {...props}
      >
        {children ?? <Paperclip aria-hidden />}
      </Button>
    </>
  );
};

type PromptInputAttachmentsProps = React.ComponentProps<'div'>;

/** Renders a chip per attachment; pass children to lay them out yourself. */
const PromptInputAttachments = ({ className, children, ...props }: PromptInputAttachmentsProps) => {
  const { attachments, removeAttachment } = usePromptInput();
  if (children == null && attachments.length === 0) return null;

  return (
    <div data-slot="prompt-input-attachments" className={cn('flex flex-wrap gap-1.5 px-1 pt-1', className)} {...props}>
      {children ??
        attachments.map((attachment) => (
          <PromptInputAttachment
            key={attachment.id}
            name={attachment.name}
            size={attachment.size}
            onRemove={() => removeAttachment(attachment.id)}
          />
        ))}
    </div>
  );
};

interface PromptInputAttachmentProps extends Omit<React.ComponentProps<'div'>, 'children'> {
  name: string;
  /** Bytes. */
  size?: number;
  icon?: React.ReactNode;
  onRemove?: () => void;
}

const PromptInputAttachment = ({ name, size, icon, onRemove, className, ...props }: PromptInputAttachmentProps) => {
  return (
    <div
      data-slot="prompt-input-attachment"
      className={cn(
        'inline-flex max-w-full items-center gap-1.5 rounded-md border border-border bg-muted/40 py-1 pe-1 ps-2 text-xs text-foreground',
        className,
      )}
      {...props}
    >
      <span className="shrink-0 text-muted-foreground [&_svg]:size-3.5">{icon ?? <FileText aria-hidden />}</span>
      <span className="max-w-40 truncate">{name}</span>
      {size != null ? (
        <span className="shrink-0 font-mono text-[10px] text-muted-foreground tabular-nums">{formatBytes(size)}</span>
      ) : null}
      {onRemove ? (
        <Button
          type="button"
          variant="ghost"
          size="icon-xs"
          onClick={onRemove}
          aria-label={`Remove ${name}`}
          className="ms-0.5 size-5 rounded-sm text-muted-foreground hover:text-foreground"
        >
          <X className="size-3" aria-hidden />
        </Button>
      ) : null}
    </div>
  );
};

interface PromptInputModelSelectProps {
  models: PromptInputModel[];
  value?: string;
  defaultValue?: string;
  onValueChange?: (id: string) => void;
  align?: 'start' | 'end';
  className?: string;
}

const PromptInputModelSelect = ({
  models,
  value: valueProp,
  defaultValue,
  onValueChange,
  align = 'end',
  className,
}: PromptInputModelSelectProps) => {
  const { disabled } = usePromptInput();
  const [internalValue, setInternalValue] = React.useState(() => defaultValue ?? models[0]?.id ?? '');
  const isControlled = valueProp !== undefined;
  const value = isControlled ? valueProp : internalValue;
  const selected = models.find((m) => m.id === value) ?? models[0];

  const handleChange = (id: string) => {
    if (!isControlled) setInternalValue(id);
    onValueChange?.(id);
  };

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button
          type="button"
          data-slot="prompt-input-model-select"
          variant="ghost"
          size="sm"
          disabled={disabled}
          aria-label={`Model: ${selected?.label ?? 'none'}`}
          className={cn(
            'gap-1 px-2 font-mono text-xs text-muted-foreground hover:text-foreground data-[state=open]:bg-accent data-[state=open]:text-foreground',
            className,
          )}
        >
          <span className="truncate">{selected?.label}</span>
          <ChevronDown className="size-3 opacity-70" aria-hidden />
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align={align} className="w-64" data-slot="prompt-input-model-select-content">
        <DropdownMenuRadioGroup value={value} onValueChange={handleChange}>
          {models.map((model) => (
            <DropdownMenuRadioItem key={model.id} value={model.id} className="flex-col items-start gap-0.5">
              <span className="text-sm text-foreground">{model.label}</span>
              {model.hint ? <span className="text-xs text-muted-foreground">{model.hint}</span> : null}
            </DropdownMenuRadioItem>
          ))}
        </DropdownMenuRadioGroup>
      </DropdownMenuContent>
    </DropdownMenu>
  );
};

type PromptInputSubmitProps = Omit<React.ComponentProps<typeof Button>, 'type' | 'children'>;

const PromptInputSubmit = ({ className, ...props }: PromptInputSubmitProps) => {
  const { canSubmit, isStreaming, stop } = usePromptInput();

  if (isStreaming) {
    return (
      <Button
        type="button"
        data-slot="prompt-input-submit"
        data-state="streaming"
        size="icon-sm"
        aria-label="Stop generating"
        onClick={stop}
        className={cn('rounded-full', className)}
        {...props}
      >
        <Square className="size-3 fill-current" aria-hidden />
      </Button>
    );
  }

  return (
    <Button
      type="submit"
      data-slot="prompt-input-submit"
      data-state="idle"
      size="icon-sm"
      aria-label="Send message"
      disabled={!canSubmit}
      className={cn('rounded-full', className)}
      {...props}
    >
      <ArrowUp aria-hidden />
    </Button>
  );
};

type PromptInputHintProps = React.ComponentProps<'p'>;

const PromptInputHint = ({
  className,
  children = 'Enter to send · Shift+Enter for a new line',
  ...props
}: PromptInputHintProps) => {
  return (
    <p
      data-slot="prompt-input-hint"
      className={cn('px-1 font-mono text-[10px] text-muted-foreground', className)}
      {...props}
    >
      {children}
    </p>
  );
};

export {
  PromptInput,
  PromptInputTextarea,
  PromptInputToolbar,
  PromptInputActions,
  PromptInputAttach,
  PromptInputAttachments,
  PromptInputAttachment,
  PromptInputModelSelect,
  PromptInputSubmit,
  PromptInputHint,
  usePromptInput,
};

const MODELS: PromptInputModel[] = [
  {
    id: 'plinth-2-pro',
    label: 'Plinth 2 Pro',
    hint: 'Slower and more careful, for hard problems',
  },
  {
    id: 'plinth-2-flash',
    label: 'Plinth 2 Flash',
    hint: 'Fast and good enough for most work',
  },
  {
    id: 'plinth-mini',
    label: 'Plinth Mini',
    hint: 'Cheapest, for short tasks',
  },
];

const MAX_CHARS = 2000;

const SEED_ATTACHMENTS: PromptAttachment[] = [
  {
    id: 'seed-report',
    name: 'q3-latency-report.pdf',
    size: 248_320,
    type: 'application/pdf',
  },
];

const CharacterCounter = ({ max }: { max: number }) => {
  const { value } = usePromptInput();
  const over = value.length > max;
  return (
    <span
      data-slot="prompt-input-counter"
      aria-live="polite"
      className={cn('px-1 font-mono text-[10px] tabular-nums', over ? 'text-destructive' : 'text-muted-foreground')}
    >
      {value.length.toLocaleString()} / {max.toLocaleString()}
    </span>
  );
};

interface Sent {
  text: string;
  attachments: number;
  model: string;
}

const PromptInputBlock = () => {
  const [model, setModel] = React.useState(MODELS[0].id);
  const [isStreaming, setIsStreaming] = React.useState(false);
  const [lastSent, setLastSent] = React.useState<Sent | null>(null);
  const timer = React.useRef<number | undefined>(undefined);

  React.useEffect(() => () => window.clearTimeout(timer.current), []);

  const stop = () => {
    window.clearTimeout(timer.current);
    setIsStreaming(false);
  };

  const handleSubmit = (text: string, { attachments }: { attachments: PromptAttachment[] }) => {
    setLastSent({ text, attachments: attachments.length, model });
    setIsStreaming(true);
    timer.current = window.setTimeout(() => setIsStreaming(false), 2500);
  };

  const modelLabel = MODELS.find((m) => m.id === lastSent?.model)?.label;

  return (
    <section data-slot="prompt-input-block" className="flex w-full justify-center bg-background p-6 sm:p-10">
      <div className="flex w-full max-w-2xl flex-col gap-3">
        <span className="font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">New message</span>

        <PromptInput
          onSubmit={handleSubmit}
          isStreaming={isStreaming}
          onStop={stop}
          defaultAttachments={SEED_ATTACHMENTS}
          aria-label="Message composer"
        >
          <PromptInputAttachments />
          <PromptInputTextarea placeholder="Ask about the report, or paste a stack trace" />
          <PromptInputToolbar>
            <PromptInputActions>
              <PromptInputAttach accept="image/*,.pdf,.txt,.md,.csv" />
            </PromptInputActions>
            <PromptInputModelSelect models={MODELS} value={model} onValueChange={setModel} />
            <PromptInputSubmit />
          </PromptInputToolbar>
          <div className="flex items-center justify-between gap-3">
            <PromptInputHint />
            <CharacterCounter max={MAX_CHARS} />
          </div>
        </PromptInput>

        <p
          aria-live="polite"
          className="flex min-h-5 items-center gap-2 px-1 font-mono text-[10px] text-muted-foreground"
        >
          {isStreaming ? (
            <>
              <span
                aria-hidden
                className="size-1.5 rounded-full bg-accent-cool animate-pulse motion-reduce:animate-none"
              />
              Generating a reply, press Stop to cancel
            </>
          ) : lastSent ? (
            <>
              <span className="uppercase tracking-[0.1em]">Sent</span>
              <span className="truncate text-foreground">{lastSent.text || '(no text)'}</span>
              <span className="shrink-0">
                · {lastSent.attachments} {lastSent.attachments === 1 ? 'attachment' : 'attachments'} · {modelLabel}
              </span>
            </>
          ) : (
            'Nothing sent yet'
          )}
        </p>
      </div>
    </section>
  );
};

export default PromptInputBlock;

Dependencies

shadcn registry

buttondropdown-menutextarea

npm

lucide-react

Shipped