Skip to content
UI Kit
Accent color

Prompt input for AI products

The input of an AI product with one feature per variant. Files with upload state and limits, slash commands, context added with @ and a meter, modes with time and cost, and dictation that marks unsure words.

One of 3 components in AI and chat

5 variants

The input of an AI product, five ways

The prompt input is where every AI interaction starts, and it carries more than text today. This component grows with its content, sends with Enter and adds a line with Shift Enter, and each variant adds one feature that real AI products need.

With attachments takes files with upload state and clear limits. Slash commands opens a menu of actions when the line starts with a slash. Context with @ adds files, pages or people to the prompt and shows a meter of how much context is used. Modes with estimates lets people pick a mode and see the expected time and cost before sending. Voice dictation turns speech into text and marks the words it is unsure about.

Show limits before people hit them, a file that is too large should say so on drop. Keep the send button disabled only while the field is empty, and offer Stop while an answer runs. Placeholder text can suggest a first prompt, it helps people who do not know what to ask.

Variants

With attachments

Files by drag and drop or picker, each with type, size and upload progress, a clear message when a file is too large and the limits in view.

Open in Studio

Slash commands

A slash opens the commands by group, arrows choose, Enter turns one into a chip at the start of the message.

Open in Studio

Context with @

Documents, projects and people added with @ or a picker, each with its size, and a meter that warns before the context runs full.

Open in Studio

Modes with estimates

Quick answer, research or writing as a segmented choice with a gliding marker, each with its own hint, time and cost, plus web and longer thinking.

Open in Studio

Voice dictation

Dictate instead of typing, with a live waveform, timer and transcript. Words that were hard to hear are underlined while you speak and listed under the field until you send.

Open in Studio

Details

What Prompt input brings along, read from the registry and the source.

Category
AI and chat
Area
App
Variants
5
Version
1.0.0 · since 1.1.0
File
components/ui/prompt-input.tsx
Brings along
utilskiticonreveal
npm packages
None
Languages
English and German, 38 texts of its own

Props

The PromptInputProps interface from components/ui/prompt-input.tsx, with the comments from the code.

  • variantoptional
    PromptInputVariant

    attachmentsslash-commandscontext-mentionsmodesvoice-dictation

  • titleoptional
    string

    Small heading above the field, e.g. the assistant's name

  • descriptionoptional
    string
  • placeholderoptional
    string
  • defaultValueoptional
    string

    Text already in the field

  • attachmentsoptional
    PromptAttachment[]

    attachments

  • maxFilesoptional
    number
  • maxSizeMboptional
    number

    Largest file in MB

  • commandsoptional
    PromptCommand[]

    slash-commands

  • contextsoptional
    PromptContextItem[]

    context-mentions: everything that can be added with @

  • selectedoptional
    string[]

    context-mentions: ids already added

  • contextLimitoptional
    number

    context-mentions: tokens the model can read at once

  • modesoptional
    PromptMode[]

    modes

  • defaultModeoptional
    string
  • transcriptoptional
    string

    voice-dictation: the demo transcript. Words between ~tildes~ are shown as uncertain.

  • autoStartoptional
    boolean

    voice-dictation: start listening once the field scrolls into view

  • onListenoptional
    (push: (text: string) => void) => () => void

    voice-dictation: connect real speech recognition. Call `push` with the text heard so far, return a function that stops listening. Without it the block plays the transcript.

  • noteoptional
    string

    A line under the field, e.g. what happens with files or audio

  • onSubmitoptional
    (value: PromptSubmit) => void

Built-in texts

Prompt input writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 38 texts
messageEnglish MessageGerman Nachricht
sendEnglish SendGerman Senden
attachEnglish Attach filesGerman Dateien anhängen
removeEnglish Remove {name}German {name} entfernen
uploadingEnglish Uploading {n}%German Lädt hoch, {n} %
tooLargeEnglish Larger than {mb} MB, choose a smaller fileGerman Größer als {mb} MB, wähle eine kleinere Datei
tooManyEnglish Up to {n} files per messageGerman Bis zu {n} Dateien je Nachricht
limitsEnglish {count} of {max} files · up to {mb} MB eachGerman {count} von {max} Dateien · je bis {mb} MB
dropEnglish Drop files to attach themGerman Dateien hier ablegen
dropHintEnglish PDF, spreadsheets, images and textGerman PDF, Tabellen, Bilder und Text
sentEnglish SentGerman Gesendet
commandsEnglish CommandsGerman Befehle
chooseEnglish ChooseGerman Auswählen
commandHintEnglish Type / for commandsGerman Tippe / für Befehle
noCommandEnglish No command matches. Keep typing to send it as text.German Kein Befehl passt. Schreib weiter, dann geht es als Text raus.
removeCommandEnglish Remove command {name}German Befehl {name} entfernen
contextEnglish ContextGerman Kontext
addContextEnglish Add contextGerman Kontext hinzufügen
mentionHintEnglish Type @ to add a document, project or personGerman Tippe @ für ein Dokument, Projekt oder eine Person
searchContextEnglish Search documents, projects and peopleGerman Dokumente, Projekte und Personen suchen
noContextEnglish Nothing matches that name.German Dazu passt kein Name.
contextUseEnglish {used} of {limit} tokensGerman {used} von {limit} Tokens
contextFullEnglish Close to the limit, remove a source to keep answers preciseGerman Fast voll, nimm eine Quelle raus, dann bleiben Antworten genau
contextOkEnglish The assistant reads only what is listed hereGerman Der Assistent liest nur, was hier steht
modeEnglish ModeGerman Modus
deepEnglish Think longerGerman Länger nachdenken
webEnglish Search the webGerman Im Web suchen
timeEnglish TimeGerman Dauer
costEnglish CostGerman Kosten
estimateEnglish Estimates, the real value can differGerman Schätzwerte, der echte Wert kann abweichen
dictateEnglish DictateGerman Diktieren
listeningEnglish ListeningGerman Hört zu
stopInsertEnglish Insert textGerman Text einfügen
cancelEnglish Discard recordingGerman Aufnahme verwerfen
unsureEnglish Underlined words were hard to hear, check them before sendingGerman Unterstrichene Wörter waren schwer zu verstehen, prüf sie vor dem Senden
checkWordsEnglish These words were hard to hear, check them before sendingGerman Diese Wörter waren schwer zu verstehen, prüf sie vor dem Senden
micHintEnglish Press the microphone and speakGerman Mikrofon drücken und sprechen
shortcutEnglish Enter sends, Shift Enter adds a lineGerman Enter sendet, Umschalt Enter macht eine neue Zeile

Accessibility

Read from the source of the component.

  • Prompt input handles the keys Escape, Enter, Arrow up, Arrow down, Arrow left, Arrow right, Tab, Backspace and Space itself.
  • Sets aria-activedescendant, aria-autocomplete, aria-checked, aria-controls, aria-expanded, aria-label, aria-live, aria-pressed, aria-selected, aria-valuemax, aria-valuemin, aria-valuenow and aria-valuetext, so screen readers announce purpose and state.
  • Uses the roles combobox, group, listbox, meter, option, radio, radiogroup and status.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 1,232 lines
components/ui/prompt-input.tsx
'use client';

import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useInView, useLocale, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';

export type PromptInputVariant = 'attachments' | 'slash-commands' | 'context-mentions' | 'modes' | 'voice-dictation';

export interface PromptAttachment {
  id: string;
  name: string;
  /** Shown as given, e.g. "2.4 MB" */
  size: string;
  kind: 'pdf' | 'sheet' | 'image' | 'doc' | 'code' | 'video';
  /** 0 to 100, below 100 the file is still uploading */
  progress?: number;
  /** A message when the file cannot be used */
  error?: string;
}

export interface PromptCommand {
  id: string;
  label: string;
  description: string;
  icon?: IconName;
  /** Heading the command is listed under */
  group?: string;
}

export interface PromptContextItem {
  id: string;
  label: string;
  kind: 'doc' | 'project' | 'person' | 'folder' | 'sheet';
  detail?: string;
  /** Size in tokens, fills the context meter */
  tokens: number;
}

export interface PromptMode {
  id: string;
  label: string;
  icon?: IconName;
  description: string;
  placeholder?: string;
  /** Rough duration, e.g. "about 2 min" */
  time?: string;
  /** Rough cost, e.g. "about 4 credits" */
  cost?: string;
}

export interface PromptSubmit {
  text: string;
  attachments: PromptAttachment[];
  command?: PromptCommand;
  contexts: PromptContextItem[];
  mode?: PromptMode;
}

export interface PromptInputProps {
  variant?: PromptInputVariant;
  /** Small heading above the field, e.g. the assistant's name */
  title?: string;
  description?: string;
  placeholder?: string;
  /** Text already in the field */
  defaultValue?: string;
  /** attachments */
  attachments?: PromptAttachment[];
  maxFiles?: number;
  /** Largest file in MB */
  maxSizeMb?: number;
  /** slash-commands */
  commands?: PromptCommand[];
  /** context-mentions: everything that can be added with @ */
  contexts?: PromptContextItem[];
  /** context-mentions: ids already added */
  selected?: string[];
  /** context-mentions: tokens the model can read at once */
  contextLimit?: number;
  /** modes */
  modes?: PromptMode[];
  defaultMode?: string;
  /** voice-dictation: the demo transcript. Words between ~tildes~ are shown as uncertain. */
  transcript?: string;
  /** voice-dictation: start listening once the field scrolls into view */
  autoStart?: boolean;
  /**
   * voice-dictation: connect real speech recognition. Call `push` with the
   * text heard so far, return a function that stops listening. Without it
   * the block plays the transcript.
   */
  onListen?: (push: (text: string) => void) => () => void;
  /** A line under the field, e.g. what happens with files or audio */
  note?: string;
  onSubmit?: (value: PromptSubmit) => void;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: {
    message: 'Message',
    send: 'Send',
    attach: 'Attach files',
    remove: 'Remove {name}',
    uploading: 'Uploading {n}%',
    tooLarge: 'Larger than {mb} MB, choose a smaller file',
    tooMany: 'Up to {n} files per message',
    limits: '{count} of {max} files · up to {mb} MB each',
    drop: 'Drop files to attach them',
    dropHint: 'PDF, spreadsheets, images and text',
    sent: 'Sent',
    
    commands: 'Commands',
    choose: 'Choose',
    commandHint: 'Type / for commands',
    noCommand: 'No command matches. Keep typing to send it as text.',
    removeCommand: 'Remove command {name}',
    context: 'Context',
    addContext: 'Add context',
    mentionHint: 'Type @ to add a document, project or person',
    searchContext: 'Search documents, projects and people',
    noContext: 'Nothing matches that name.',
    contextUse: '{used} of {limit} tokens',
    contextFull: 'Close to the limit, remove a source to keep answers precise',
    contextOk: 'The assistant reads only what is listed here',
    mode: 'Mode',
    deep: 'Think longer',
    web: 'Search the web',
    time: 'Time',
    cost: 'Cost',
    estimate: 'Estimates, the real value can differ',
    dictate: 'Dictate',
    listening: 'Listening',
    stopInsert: 'Insert text',
    cancel: 'Discard recording',
    unsure: 'Underlined words were hard to hear, check them before sending',
    checkWords: 'These words were hard to hear, check them before sending',
    micHint: 'Press the microphone and speak',
    shortcut: 'Enter sends, Shift Enter adds a line',
  },
  de: {
    message: 'Nachricht',
    send: 'Senden',
    attach: 'Dateien anhängen',
    remove: '{name} entfernen',
    uploading: 'Lädt hoch, {n} %',
    tooLarge: 'Größer als {mb} MB, wähle eine kleinere Datei',
    tooMany: 'Bis zu {n} Dateien je Nachricht',
    limits: '{count} von {max} Dateien · je bis {mb} MB',
    drop: 'Dateien hier ablegen',
    dropHint: 'PDF, Tabellen, Bilder und Text',
    sent: 'Gesendet',
    
    commands: 'Befehle',
    choose: 'Auswählen',
    commandHint: 'Tippe / für Befehle',
    noCommand: 'Kein Befehl passt. Schreib weiter, dann geht es als Text raus.',
    removeCommand: 'Befehl {name} entfernen',
    context: 'Kontext',
    addContext: 'Kontext hinzufügen',
    mentionHint: 'Tippe @ für ein Dokument, Projekt oder eine Person',
    searchContext: 'Dokumente, Projekte und Personen suchen',
    noContext: 'Dazu passt kein Name.',
    contextUse: '{used} von {limit} Tokens',
    contextFull: 'Fast voll, nimm eine Quelle raus, dann bleiben Antworten genau',
    contextOk: 'Der Assistent liest nur, was hier steht',
    mode: 'Modus',
    deep: 'Länger nachdenken',
    web: 'Im Web suchen',
    time: 'Dauer',
    cost: 'Kosten',
    estimate: 'Schätzwerte, der echte Wert kann abweichen',
    dictate: 'Diktieren',
    listening: 'Hört zu',
    stopInsert: 'Text einfügen',
    cancel: 'Aufnahme verwerfen',
    unsure: 'Unterstrichene Wörter waren schwer zu verstehen, prüf sie vor dem Senden',
    checkWords: 'Diese Wörter waren schwer zu verstehen, prüf sie vor dem Senden',
    micHint: 'Mikrofon drücken und sprechen',
    shortcut: 'Enter sendet, Umschalt Enter macht eine neue Zeile',
  },
};

type Texts = (typeof TEXTS)['en'];
type P = PromptInputProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
const useIso = typeof window === 'undefined' ? useEffect : useLayoutEffect;

/* ------------------------------------------------------------------ */
/* Motion, kept in the block so it ships on its own                    */
/* ------------------------------------------------------------------ */

/** State seeded from a prop that starts over when the prop changes (the Studio edits props live) */
function useSynced<T>(value: T) {
  const [state, setState] = useState(value);
  const key = JSON.stringify(value);
  const seen = useRef(key);
  if (seen.current !== key) {
    seen.current = key;
    setState(value);
  }
  return [state, setState] as const;
}

const CSS =
  '@keyframes pin-pop{from{opacity:0;transform:scale(.94)}}@keyframes pin-up{from{opacity:0;transform:translateY(8px)}}@keyframes pin-menu{from{opacity:0;transform:translateY(-6px) scale(.98)}}@keyframes pin-wave{0%,100%{transform:scaleY(.18)}50%{transform:scaleY(1)}}@keyframes pin-swap{from{opacity:0;transform:translateX(12px)}}' +
  '@media (prefers-reduced-motion:no-preference){.pin-pop{animation:pin-pop .32s var(--ease-kit) both}.pin-up{animation:pin-up .45s var(--ease-kit) var(--pin-d,0ms) both}.pin-menu{transform-origin:50% 0;animation:pin-menu .22s var(--ease-kit) both}.pin-wave{animation:pin-wave var(--pin-t,1s) ease-in-out var(--pin-d,0ms) infinite}.pin-swap{animation:pin-swap .4s var(--ease-kit) both}}' +
  '.pin-wave{transform-origin:50% 50%}';

function Style() {
  return (
    <style href="kit-prompt-input" precedence="default">
      {CSS}
    </style>
  );
}

/** Marker inside the active item glides from the old place to the new one */
function useGlide<T extends HTMLElement>(key: unknown) {
  const ref = useRef<T>(null);
  const before = useRef<DOMRect | null>(null);
  if (typeof window !== 'undefined' && ref.current) before.current = ref.current.querySelector('[data-glide]')?.getBoundingClientRect() ?? null;
  useIso(() => {
    const m = ref.current?.querySelector<HTMLElement>('[data-glide]');
    const a = before.current;
    if (!m || !a || prefersReducedMotion()) return;
    const b = m.getBoundingClientRect();
    if (!b.width || (Math.abs(a.left - b.left) < 1 && Math.abs(a.top - b.top) < 1)) return;
    m.animate([{ transformOrigin: '0 0', transform: `translate(${a.left - b.left}px, ${a.top - b.top}px) scale(${a.width / b.width}, ${a.height / b.height})` }, { transformOrigin: '0 0', transform: 'none' }], { duration: 420, easing: 'cubic-bezier(0.2, 0.7, 0.2, 1)' });
  }, [key]);
  return ref;
}

/* ------------------------------------------------------------------ */
/* Shared parts                                                        */
/* ------------------------------------------------------------------ */

const KIND: Record<string, { icon: IconName; tone: string }> = {
  pdf: { icon: 'file', tone: '--destructive' },
  sheet: { icon: 'table', tone: '--success' },
  image: { icon: 'image', tone: '--primary' },
  doc: { icon: 'file-check', tone: '--info' },
  code: { icon: 'code', tone: '--warning' },
  video: { icon: 'video', tone: '--info' },
  project: { icon: 'kanban', tone: '--primary' },
  person: { icon: 'user', tone: '--info' },
  folder: { icon: 'folder', tone: '--warning' },
};

function KindTile({ kind, size = 36 }: { kind: string; size?: number }) {
  const k = KIND[kind] ?? KIND.doc;
  return (
    <span
      aria-hidden
      className="grid shrink-0 place-items-center rounded-lg"
      style={{ width: size, height: size, background: `color-mix(in oklab, var(${k.tone}) 15%, var(--card))`, color: `color-mix(in oklab, var(${k.tone}) 78%, var(--foreground))` }}
    >
      <Icon name={k.icon} size={Math.round(size * 0.48)} />
    </span>
  );
}

const iconBtn =
  'grid size-11 shrink-0 place-items-center rounded-lg text-muted-foreground transition-colors duration-200 hover:bg-accent hover:text-foreground sm:size-9 disabled:pointer-events-none disabled:opacity-40';

function SendButton({ t, disabled }: { t: Texts; disabled: boolean }) {
  return (
    <button
      type="submit"
      aria-label={t.send}
      disabled={disabled}
      className="grid size-11 shrink-0 place-items-center rounded-lg bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)] transition-[transform,background-color,color,box-shadow] duration-200 ease-[var(--ease-kit)] hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))] active:scale-95 disabled:bg-[color-mix(in_oklab,var(--foreground)_8%,var(--card))] disabled:text-subtle-foreground disabled:shadow-none sm:size-9"
    >
      <Icon name="arrow-up" size={18} />
    </button>
  );
}

/** The field: grows with its text, Enter sends */
function Field({
  value,
  onValue,
  onSend,
  onKeyDown,
  placeholder,
  label,
  areaRef,
  className,
  ...aria
}: {
  value: string;
  onValue: (v: string) => void;
  onSend: () => void;
  onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => boolean | void;
  placeholder?: string;
  label: string;
  areaRef?: React.Ref<HTMLTextAreaElement>;
  className?: string;
} & React.AriaAttributes & { role?: string }) {
  return (
    <textarea
      ref={areaRef}
      rows={1}
      value={value}
      aria-label={label}
      placeholder={placeholder}
      onChange={(e) => onValue(e.target.value)}
      onKeyDown={(e) => {
        if (onKeyDown?.(e)) return;
        if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
          e.preventDefault();
          onSend();
        }
      }}
      {...aria}
      className={cn(
        'block max-h-48 min-h-12 w-full resize-none bg-transparent px-4 pt-3.5 text-[15px] leading-relaxed text-foreground outline-none [field-sizing:content] placeholder:text-subtle-foreground focus-visible:outline-none sm:px-4',
        className,
      )}
    />
  );
}

/** The composer surface with a soft ring while focused */
function Box({ children, className, onSubmit, ...rest }: { children: React.ReactNode; className?: string; onSubmit: () => void } & Omit<React.FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>) {
  return (
    <form
      {...rest}
      onSubmit={(e) => {
        e.preventDefault();
        onSubmit();
      }}
      className={cn(
        'relative rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)] transition-[border-color,box-shadow] duration-300 ease-[var(--ease-kit)] focus-within:border-[color-mix(in_oklab,var(--primary)_45%,var(--border))] focus-within:shadow-[var(--highlight),var(--shadow-md),0_0_0_4px_color-mix(in_oklab,var(--primary)_14%,transparent)]',
        className,
      )}
    >
      {children}
    </form>
  );
}

/** What was sent, so the demo shows its result */
function SentList({ items, t }: { items: { id: number; text: string; chips: string[] }[]; t: Texts }) {
  if (!items.length) return null;
  return (
    <ol aria-label={t.sent} className="mb-4 space-y-2.5">
      {items.map((s) => (
        <li key={s.id} className="pin-up ml-auto w-fit max-w-[92%] rounded-2xl rounded-br-md bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] px-4 py-3 text-[14.5px] leading-relaxed text-foreground">
          {s.chips.length > 0 && (
            <span className="mb-1.5 flex flex-wrap gap-1.5">
              {s.chips.map((c) => (
                <span key={c} className="rounded-md bg-background/70 px-2 py-0.5 text-[12px] font-medium text-muted-foreground">
                  {c}
                </span>
              ))}
            </span>
          )}
          <span className="whitespace-pre-wrap break-words">{s.text}</span>
          <span className="mt-1 flex items-center justify-end gap-1 text-[11.5px] text-muted-foreground">
            <Icon name="check" size={12} />
            {t.sent}
          </span>
        </li>
      ))}
    </ol>
  );
}

function useSent() {
  const [items, setItems] = useState<{ id: number; text: string; chips: string[] }[]>([]);
  const n = useRef(0);
  return [items, (text: string, chips: string[] = []) => setItems((s) => [...s.slice(-2), { id: ++n.current, text, chips }])] as const;
}

/** The stage every variant sits on */
function Stage({ title, description, children, className }: { title?: string; description?: string; children: React.ReactNode; className?: string }) {
  return (
    <section className={cn('kit-glow relative overflow-clip bg-surface py-14 sm:py-20 lg:py-24', className)}>
      <div className="kit-container">
        <div className="mx-auto w-full max-w-[44rem]">
          {(title || description) && (
            <Reveal className="mb-6 sm:mb-8">
              {title && <h2 className="text-balance text-[22px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[26px]">{title}</h2>}
              {description && <p className="mt-2 max-w-[34rem] text-pretty text-[15px] leading-relaxed text-muted-foreground">{description}</p>}
            </Reveal>
          )}
          {children}
        </div>
      </div>
    </section>
  );
}

function Hint({ icon, children, className }: { icon?: IconName; children: React.ReactNode; className?: string }) {
  return (
    <p className={cn('flex items-start gap-1.5 text-[12.5px] leading-snug text-muted-foreground', className)}>
      {icon && <Icon name={icon} size={14} className="mt-px shrink-0" />}
      <span>{children}</span>
    </p>
  );
}

/* ------------------------------------------------------------------ */
/* attachments                                                         */
/* ------------------------------------------------------------------ */

function Attachments({ t, title, description, placeholder, defaultValue = '', attachments = [], maxFiles = 10, maxSizeMb = 25, note, onSubmit, className }: P) {
  const [files, setFiles] = useSynced(attachments);
  const [text, setText] = useState(defaultValue);
  const [drag, setDrag] = useState(false);
  const [notice, setNotice] = useState('');
  const [sent, send] = useSent();
  const input = useRef<HTMLInputElement>(null);
  const seq = useRef(0);
  const uid = useId();

  // Uploads still running move on to 100 %
  const busy = files.some((f) => !f.error && (f.progress ?? 100) < 100);
  useEffect(() => {
    if (!busy) return;
    if (prefersReducedMotion()) return setFiles((fs) => fs.map((f) => ({ ...f, progress: f.error ? f.progress : 100 })));
    const id = window.setInterval(() => setFiles((fs) => fs.map((f) => (f.error || (f.progress ?? 100) >= 100 ? f : { ...f, progress: Math.min(100, (f.progress ?? 0) + 3) }))), 150);
    return () => window.clearInterval(id);
  }, [busy]);

  const add = (list: FileList | null) => {
    if (!list) return;
    const room = maxFiles - files.length;
    if (list.length > room) setNotice(fill(t.tooMany, { n: maxFiles }));
    const next: PromptAttachment[] = Array.from(list)
      .slice(0, Math.max(0, room))
      .map((f) => {
        const mb = f.size / 1048576;
        const ext = f.name.split('.').pop()?.toLowerCase() ?? '';
        const kind: PromptAttachment['kind'] = f.type.startsWith('image/') ? 'image' : ext === 'pdf' ? 'pdf' : ['xlsx', 'csv', 'numbers', 'ods'].includes(ext) ? 'sheet' : ['js', 'ts', 'tsx', 'py', 'json', 'html', 'css'].includes(ext) ? 'code' : f.type.startsWith('video/') ? 'video' : 'doc';
        return { id: `${uid}-${++seq.current}`, name: f.name, size: mb < 1 ? `${Math.max(1, Math.round(f.size / 1024))} KB` : `${round(mb, 1)} MB`, kind, progress: 0, error: mb > maxSizeMb ? fill(t.tooLarge, { mb: maxSizeMb }) : undefined };
      });
    setFiles((fs) => [...fs, ...next]);
  };

  const ready = files.filter((f) => !f.error);
  const canSend = (text.trim().length > 0 || ready.length > 0) && !busy;
  const submit = () => {
    if (!canSend) return;
    onSubmit?.({ text, attachments: ready, contexts: [] });
    send(text.trim() || ready.map((f) => f.name).join(', '), ready.map((f) => f.name));
    setText('');
    setFiles([]);
    setNotice('');
  };

  return (
    <Stage title={title} description={description} className={className}>
      <SentList items={sent} t={t} />
      <Reveal effect="scale" delay={80}>
        <Box
          onSubmit={submit}
          onDragOver={(e) => {
            e.preventDefault();
            setDrag(true);
          }}
          onDragLeave={(e) => {
            if (!e.currentTarget.contains(e.relatedTarget as Node)) setDrag(false);
          }}
          onDrop={(e) => {
            e.preventDefault();
            setDrag(false);
            add(e.dataTransfer.files);
          }}
        >
          {files.length > 0 && (
            <ul aria-label={t.attach} className="grid grid-cols-1 gap-2 p-2.5 pb-0 sm:grid-cols-2">
              {files.map((f) => {
                const p = f.progress ?? 100;
                const loading = !f.error && p < 100;
                return (
                  <li
                    key={f.id}
                    className={cn(
                      'pin-pop relative flex min-w-0 items-center gap-3 overflow-hidden rounded-xl border bg-background py-2 pl-2 pr-1',
                      f.error ? 'border-[color-mix(in_oklab,var(--destructive)_45%,var(--border))]' : 'border-border',
                    )}
                  >
                    {f.kind === 'image' ? (
                      <svg aria-hidden viewBox="0 0 36 36" className="size-9 shrink-0 rounded-lg">
                        <rect width="36" height="36" rx="8" style={{ fill: 'color-mix(in oklab, var(--primary) 22%, var(--card))' }} />
                        <circle cx="25" cy="11" r="4" style={{ fill: 'color-mix(in oklab, var(--warning) 75%, var(--card))' }} />
                        <path d="M0 29 L11 17 L19 25 L25 20 L36 30 V36 H0 Z" style={{ fill: 'color-mix(in oklab, var(--primary) 62%, var(--card))' }} />
                      </svg>
                    ) : (
                      <KindTile kind={f.kind} />
                    )}
                    <span className="min-w-0 flex-1">
                      <span className="block truncate text-[13.5px] font-medium text-foreground">{f.name}</span>
                      <span className={cn('block text-[12px] tabular-nums', f.error ? 'text-pretty leading-snug text-destructive' : 'truncate text-muted-foreground')} aria-live="polite">
                        {f.error ?? (loading ? fill(t.uploading, { n: Math.round(p) }) : f.size)}
                      </span>
                    </span>
                    <button type="button" aria-label={fill(t.remove, { name: f.name })} onClick={() => setFiles((fs) => fs.filter((x) => x.id !== f.id))} className={cn(iconBtn, 'sm:size-8')}>
                      <Icon name="x" size={15} />
                    </button>
                    {loading && (
                      <span aria-hidden className="absolute inset-x-0 bottom-0 h-0.5 bg-[color-mix(in_oklab,var(--primary)_14%,transparent)]">
                        <span className="block h-full origin-left bg-primary transition-transform duration-150 ease-linear" style={{ transform: `scaleX(${round(p / 100, 3)})` }} />
                      </span>
                    )}
                  </li>
                );
              })}
            </ul>
          )}
          <Field value={text} onValue={setText} onSend={submit} label={t.message} placeholder={placeholder} />
          <div className="flex items-center gap-1.5 p-2 pt-1 sm:p-2.5 sm:pt-1">
            <input ref={input} type="file" multiple hidden onChange={(e) => (add(e.target.files), (e.target.value = ''))} />
            <button type="button" aria-label={t.attach} onClick={() => input.current?.click()} disabled={files.length >= maxFiles} className={iconBtn}>
              <Icon name="paperclip" size={18} />
            </button>
            <span className="min-w-0 flex-1 truncate text-[12px] tabular-nums text-subtle-foreground">{fill(t.limits, { count: files.length, max: maxFiles, mb: maxSizeMb })}</span>
            <SendButton t={t} disabled={!canSend} />
          </div>
          {drag && (
            <div aria-hidden className="pin-pop pointer-events-none absolute inset-1.5 grid place-items-center rounded-xl border-2 border-dashed border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_9%,var(--card))] text-center">
              <span>
                <Icon name="cloud-upload" size={26} className="mx-auto text-primary" />
                <span className="mt-2 block text-[14px] font-medium text-foreground">{t.drop}</span>
                <span className="block text-[12.5px] text-muted-foreground">{t.dropHint}</span>
              </span>
            </div>
          )}
        </Box>
      </Reveal>
      <div className="mt-3 flex flex-col gap-1.5 px-1 sm:flex-row sm:items-start sm:justify-between sm:gap-6">
        {note && <Hint icon="lock">{note}</Hint>}
        <Hint className="max-sm:hidden sm:shrink-0">{t.shortcut}</Hint>
      </div>
      {notice && (
        <p role="status" className="pin-up mt-2 px-1 text-[12.5px] text-destructive">
          {notice}
        </p>
      )}
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* slash-commands                                                      */
/* ------------------------------------------------------------------ */

function SlashCommands({ t, title, description, placeholder, defaultValue = '', commands = [], note, onSubmit, className }: P) {
  const [text, setText] = useState(defaultValue);
  const [cmd, setCmd] = useState<PromptCommand | undefined>(undefined);
  const [active, setActive] = useState(0);
  const [closed, setClosed] = useState(false);
  const [sent, send] = useSent();
  const area = useRef<HTMLTextAreaElement>(null);
  const listId = useId();

  const m = /^\/(\S*)$/.exec(text);
  const query = m ? m[1].toLowerCase() : null;
  const list = query === null ? [] : commands.filter((c) => c.label.toLowerCase().includes(query) || c.id.includes(query));
  const open = query !== null && !cmd && !closed;
  const glide = useGlide<HTMLDivElement>(`${active}-${query}`);
  useEffect(() => setActive(0), [query]);

  const pick = (c: PromptCommand) => {
    setCmd(c);
    setText('');
    area.current?.focus();
  };
  const submit = () => {
    if (!text.trim() && !cmd) return;
    onSubmit?.({ text, command: cmd, attachments: [], contexts: [] });
    send(text.trim() || cmd!.label, cmd ? [`/${cmd.label}`] : []);
    setText('');
    setCmd(undefined);
  };

  const groups = list.reduce<Record<string, PromptCommand[]>>((acc, c) => ((acc[c.group ?? ''] ??= []).push(c), acc), {});
  let index = -1;

  return (
    <Stage title={title} description={description} className={className}>
      <SentList items={sent} t={t} />
      <Reveal effect="scale" delay={80} className="relative">
        <Box onSubmit={submit}>
          <div className="flex items-start">
            {cmd && (
              <span className="pin-pop ml-3 mt-3 inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] pl-2.5 pr-1 text-[13px] font-medium text-[color-mix(in_oklab,var(--primary)_70%,var(--foreground))]">
                <Icon name={cmd.icon ?? 'sparkle'} size={14} />
                {cmd.label}
                <button type="button" aria-label={fill(t.removeCommand, { name: cmd.label })} onClick={() => (setCmd(undefined), area.current?.focus())} className="relative grid size-6 place-items-center rounded-md after:absolute after:-inset-2.5 after:content-[''] hover:bg-background/60">
                  <Icon name="x" size={12} />
                </button>
              </span>
            )}
            <Field
              areaRef={area}
              value={text}
              onValue={(v) => (setText(v), setClosed(false))}
              onSend={submit}
              label={t.message}
              placeholder={cmd ? cmd.description : placeholder}
              role="combobox"
              aria-expanded={open}
              aria-controls={open ? listId : undefined}
              aria-autocomplete="list"
              aria-activedescendant={open && list[active] ? `${listId}-${list[active].id}` : undefined}
              className={cmd ? 'pl-2' : undefined}
              onKeyDown={(e) => {
                if (e.key === 'Backspace' && !text && cmd) {
                  setCmd(undefined);
                  return true;
                }
                if (!open) return;
                if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
                  e.preventDefault();
                  const d = e.key === 'ArrowDown' ? 1 : -1;
                  setActive((a) => (list.length ? (a + d + list.length) % list.length : 0));
                  return true;
                }
                if ((e.key === 'Enter' || e.key === 'Tab') && list[active]) {
                  e.preventDefault();
                  pick(list[active]);
                  return true;
                }
                if (e.key === 'Escape') {
                  setClosed(true);
                  return true;
                }
              }}
            />
          </div>
          <div className="flex items-center gap-1.5 p-2 pt-1 sm:p-2.5 sm:pt-1">
            <button type="button" aria-label={t.commands} onClick={() => (setText('/'), setCmd(undefined), setClosed(false), area.current?.focus())} className={iconBtn}>
              <Icon name="command" size={18} />
            </button>
            <span className="min-w-0 flex-1 truncate text-[12px] text-subtle-foreground">{t.commandHint}</span>
            <SendButton t={t} disabled={!text.trim() && !cmd} />
          </div>
        </Box>
        {open && (
          <div ref={glide} className="pin-menu relative mt-2 overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-lg)]">
            <div id={listId} role="listbox" aria-label={t.commands} className="kit-scrollbar relative max-h-[19rem] overflow-y-auto p-1.5">
              {list.length === 0 && <p className="px-3 py-4 text-[13.5px] text-muted-foreground">{t.noCommand}</p>}
              {Object.entries(groups).map(([g, cs]) => (
                <div key={g || 'all'} role="group" aria-label={g || t.commands}>
                  {g && <p className="px-3 pb-1 pt-2.5 text-[11.5px] font-medium text-subtle-foreground">{g}</p>}
                  {cs.map((c) => {
                    const i = ++index;
                    const on = i === active;
                    return (
                      <div
                        key={c.id}
                        id={`${listId}-${c.id}`}
                        role="option"
                        aria-selected={on}
                        onPointerMove={() => setActive(i)}
                        onMouseDown={(e) => e.preventDefault()}
                        onClick={() => pick(c)}
                        className="relative flex min-h-12 cursor-pointer items-center gap-3 rounded-xl px-2.5 py-2"
                      >
                        {on && <span data-glide aria-hidden className="absolute inset-0 rounded-xl bg-accent" />}
                        <span className="relative grid size-9 shrink-0 place-items-center rounded-lg border border-border bg-background text-muted-foreground">
                          <Icon name={c.icon ?? 'sparkle'} size={17} />
                        </span>
                        <span className="relative min-w-0 flex-1">
                          <span className="block text-[14px] font-medium text-foreground">{c.label}</span>
                          <span className="block truncate text-[12.5px] text-muted-foreground">{c.description}</span>
                        </span>
                        <span className="relative text-[12px] text-subtle-foreground max-sm:hidden">/{c.id}</span>
                      </div>
                    );
                  })}
                </div>
              ))}
            </div>
            <div className="flex items-center gap-3 border-t border-border bg-surface px-3.5 py-2 text-[11.5px] text-muted-foreground max-sm:hidden">
              <span className="flex items-center gap-1">
                <Icon name="arrows-up-down" size={13} />
                {t.choose}
              </span>
              <span className="flex items-center gap-1">
                <Icon name="corner-down-left" size={13} />
                {list[active]?.label ?? ''}
              </span>
            </div>
          </div>
        )}
      </Reveal>
      <div className="mt-3 flex flex-col gap-1.5 px-1 sm:flex-row sm:justify-between sm:gap-6">
        {note && <Hint icon="info">{note}</Hint>}
        <Hint className="max-sm:hidden sm:shrink-0">{t.shortcut}</Hint>
      </div>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* context-mentions                                                    */
/* ------------------------------------------------------------------ */

const compact = (n: number, de: boolean) => (n >= 1000 ? `${round(n / 1000, 1).toString().replace('.', de ? ',' : '.')}k` : String(n));

function ContextMentions({ t, title, description, placeholder, defaultValue = '', contexts = [], selected = [], contextLimit = 128000, note, onSubmit, className }: P) {
  const [ids, setIds] = useState(selected);
  const [text, setText] = useState(defaultValue);
  const [picker, setPicker] = useState(false);
  const [search, setSearch] = useState('');
  const [active, setActive] = useState(0);
  const [sent, send] = useSent();
  const area = useRef<HTMLTextAreaElement>(null);
  const searchRef = useRef<HTMLInputElement>(null);
  const listId = useId();
  const de = useLocale() === 'de';

  const chosen = ids.map((id) => contexts.find((c) => c.id === id)).filter(Boolean) as PromptContextItem[];
  const used = chosen.reduce((s, c) => s + c.tokens, 0);
  const share = Math.min(1, used / contextLimit);
  const full = share >= 0.8;

  // "@name" at the end of the text opens the list inline
  const at = /(^|\s)@(\S*)$/.exec(text);
  const q = (picker ? search : at?.[2] ?? '').toLowerCase();
  const open = picker || !!at;
  const options = contexts.filter((c) => !ids.includes(c.id) && (c.label.toLowerCase().includes(q) || (c.detail ?? '').toLowerCase().includes(q)));
  const glide = useGlide<HTMLDivElement>(`${active}-${q}`);
  useEffect(() => setActive(0), [q, open]);
  useEffect(() => {
    if (picker) searchRef.current?.focus();
  }, [picker]);

  const add = (c: PromptContextItem) => {
    setIds((s) => [...s, c.id]);
    if (at) setText(text.slice(0, text.length - at[2].length - 1));
    setPicker(false);
    setSearch('');
    area.current?.focus();
  };
  const keys = (e: React.KeyboardEvent) => {
    if (!open) return false;
    if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
      e.preventDefault();
      const d = e.key === 'ArrowDown' ? 1 : -1;
      setActive((a) => (options.length ? (a + d + options.length) % options.length : 0));
      return true;
    }
    if (e.key === 'Enter' && options[active]) {
      e.preventDefault();
      add(options[active]);
      return true;
    }
    if (e.key === 'Escape') {
      setPicker(false);
      if (at) setText(text.slice(0, text.length - at[2].length - 1));
      area.current?.focus();
      return true;
    }
    return false;
  };
  const submit = () => {
    if (!text.trim()) return;
    onSubmit?.({ text, attachments: [], contexts: chosen });
    send(text.trim(), chosen.map((c) => `@${c.label}`));
    setText('');
  };

  return (
    <Stage title={title} description={description} className={className}>
      <SentList items={sent} t={t} />
      <Reveal effect="scale" delay={80} className="relative">
        <Box onSubmit={submit}>
          <div className="flex min-h-12 flex-wrap items-center gap-1.5 border-b border-border px-2.5 py-2">
            <span className="mr-1 pl-1 text-[12px] font-medium text-subtle-foreground">{t.context}</span>
            {chosen.length === 0 && <span className="text-[12.5px] text-muted-foreground">{t.mentionHint}</span>}
            {chosen.map((c) => (
              <span key={c.id} className="pin-pop inline-flex h-8 max-w-full items-center gap-1.5 rounded-lg border border-border bg-background pl-1.5 pr-0.5 text-[13px] text-foreground">
                <KindTile kind={c.kind} size={22} />
                <span className="min-w-0 truncate">{c.label}</span>
                <span className="text-[11.5px] tabular-nums text-subtle-foreground">{compact(c.tokens, de)}</span>
                <button type="button" aria-label={fill(t.remove, { name: c.label })} onClick={() => setIds((s) => s.filter((x) => x !== c.id))} className="relative grid size-7 place-items-center rounded-md text-muted-foreground after:absolute after:-inset-2 after:content-[''] hover:bg-accent hover:text-foreground">
                  <Icon name="x" size={12} />
                </button>
              </span>
            ))}
          </div>
          <Field
            areaRef={area}
            value={text}
            onValue={setText}
            onSend={submit}
            label={t.message}
            placeholder={placeholder}
            onKeyDown={(e) => keys(e)}
            role="combobox"
            aria-expanded={open && !picker}
            aria-controls={open && !picker ? listId : undefined}
            aria-autocomplete="list"
            aria-activedescendant={open && !picker && options[active] ? `${listId}-${options[active].id}` : undefined}
          />
          <div className="flex items-center gap-2 p-2 pt-1 sm:gap-3 sm:p-2.5 sm:pt-1">
            <button
              type="button"
              onMouseDown={(e) => picker && e.preventDefault()}
              onClick={() => setPicker((v) => !v)}
              aria-expanded={picker}
              className="inline-flex h-11 shrink-0 items-center gap-1.5 rounded-lg border border-border px-2.5 text-[13px] font-medium text-foreground transition-colors duration-200 hover:bg-accent sm:h-9"
            >
              <Icon name="at-sign" size={15} className="text-primary" />
              {t.addContext}
            </button>
            <div className="min-w-0 flex-1">
              <p className={cn('truncate text-right text-[12px] tabular-nums', full ? 'text-warning-foreground' : 'text-muted-foreground')}>
                <span className="max-sm:hidden">{fill(t.contextUse, { used: compact(used, de), limit: compact(contextLimit, de) })}</span>
                <span className="sm:hidden">
                  {compact(used, de)} / {compact(contextLimit, de)}
                </span>
              </p>
              <div
                role="meter"
                aria-label={t.context}
                aria-valuemin={0}
                aria-valuemax={contextLimit}
                aria-valuenow={used}
                aria-valuetext={fill(t.contextUse, { used: compact(used, de), limit: compact(contextLimit, de) })}
                className="mt-1.5 h-1 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_9%,transparent)]"
              >
                <div className={cn('h-full origin-left rounded-full transition-[transform,background-color] duration-700 ease-[var(--ease-kit)]', full ? 'bg-warning' : 'bg-primary')} style={{ transform: `scaleX(${round(share, 3)})` }} />
              </div>
            </div>
            <SendButton t={t} disabled={!text.trim()} />
          </div>
        </Box>
        {open && (
          <div ref={glide} className="pin-menu relative mt-2 overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-lg)]">
            {picker && (
              <label className="flex items-center gap-2 border-b border-border px-3.5">
                <Icon name="search" size={16} className="shrink-0 text-muted-foreground" />
                <span className="sr-only">{t.searchContext}</span>
                <input
                  ref={searchRef}
                  value={search}
                  onChange={(e) => setSearch(e.target.value)}
                  onKeyDown={(e) => keys(e)}
                  onBlur={(e) => !e.currentTarget.closest('.pin-menu')?.contains(e.relatedTarget as Node) && setPicker(false)}
                  placeholder={t.searchContext}
                  role="combobox"
                  aria-expanded
                  aria-controls={listId}
                  aria-activedescendant={options[active] ? `${listId}-${options[active].id}` : undefined}
                  className="h-12 w-0 min-w-0 flex-1 bg-transparent text-[14.5px] text-foreground outline-none placeholder:text-subtle-foreground focus-visible:outline-none"
                />
              </label>
            )}
            <div id={listId} role="listbox" aria-label={t.addContext} className="kit-scrollbar relative max-h-[17rem] overflow-y-auto p-1.5">
              {options.length === 0 && <p className="px-3 py-4 text-[13.5px] text-muted-foreground">{t.noContext}</p>}
              {options.map((c, i) => (
                <div
                  key={c.id}
                  id={`${listId}-${c.id}`}
                  role="option"
                  aria-selected={i === active}
                  onPointerMove={() => setActive(i)}
                  onMouseDown={(e) => e.preventDefault()}
                  onClick={() => add(c)}
                  className="relative flex min-h-12 cursor-pointer items-center gap-3 rounded-xl px-2.5 py-2"
                >
                  {i === active && <span data-glide aria-hidden className="absolute inset-0 rounded-xl bg-accent" />}
                  <span className="relative">
                    <KindTile kind={c.kind} size={34} />
                  </span>
                  <span className="relative min-w-0 flex-1">
                    <span className="block truncate text-[14px] font-medium text-foreground">{c.label}</span>
                    {c.detail && <span className="block truncate text-[12.5px] text-muted-foreground">{c.detail}</span>}
                  </span>
                  <span className="relative shrink-0 text-[12px] tabular-nums text-subtle-foreground">{compact(c.tokens, de)}</span>
                </div>
              ))}
            </div>
          </div>
        )}
      </Reveal>
      <div className="mt-3 px-1">
        {full ? (
          <Hint icon="alert-circle" className="pin-up text-warning-foreground">
            {t.contextFull}
          </Hint>
        ) : (
          <Hint icon="shield">{note ?? t.contextOk}</Hint>
        )}
      </div>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* modes                                                               */
/* ------------------------------------------------------------------ */

function Modes({ t, title, description, placeholder, defaultValue = '', modes = [], defaultMode, note, onSubmit, className }: P) {
  const [mode, setMode] = useState(defaultMode ?? modes[0]?.id);
  const [text, setText] = useState(defaultValue);
  const [deep, setDeep] = useState(false);
  const [web, setWeb] = useState(true);
  const [sent, send] = useSent();
  const glide = useGlide<HTMLDivElement>(mode);
  const current = modes.find((m) => m.id === mode) ?? modes[0];
  const tabs = useRef<(HTMLButtonElement | null)[]>([]);

  const submit = () => {
    if (!text.trim()) return;
    onSubmit?.({ text, mode: current, attachments: [], contexts: [] });
    send(text.trim(), [current?.label, deep && t.deep, web && t.web].filter(Boolean) as string[]);
    setText('');
  };
  const toggle = (on: boolean, set: (v: boolean) => void, icon: IconName, label: string) => (
    <button
      type="button"
      aria-pressed={on}
      onClick={() => set(!on)}
      className={cn(
        'inline-flex h-11 items-center gap-1.5 rounded-lg border px-2.5 text-[13px] font-medium transition-colors duration-200 sm:h-8',
        on ? 'border-[color-mix(in_oklab,var(--primary)_40%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-[color-mix(in_oklab,var(--primary)_70%,var(--foreground))]' : 'border-border text-muted-foreground hover:bg-accent hover:text-foreground',
      )}
    >
      <Icon name={icon} size={14} className="max-sm:hidden" />
      <span className="whitespace-nowrap">{label}</span>
    </button>
  );

  return (
    <Stage title={title} description={description} className={className}>
      <SentList items={sent} t={t} />
      <Reveal effect="scale" delay={80}>
        <Box onSubmit={submit}>
          <div className="p-2 pb-0">
            <div
              ref={glide}
              role="radiogroup"
              aria-label={t.mode}
              className="grid gap-1 rounded-xl bg-[color-mix(in_oklab,var(--foreground)_5%,transparent)] p-1"
              style={{ gridTemplateColumns: `repeat(${Math.max(1, modes.length)}, minmax(0, 1fr))` }}
            >
              {modes.map((m, i) => {
                const on = m.id === mode;
                return (
                  <button
                    key={m.id}
                    ref={(el) => {
                      tabs.current[i] = el;
                    }}
                    type="button"
                    role="radio"
                    aria-checked={on}
                    tabIndex={on ? 0 : -1}
                    onClick={() => setMode(m.id)}
                    onKeyDown={(e) => {
                      const d = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
                      if (!d) return;
                      e.preventDefault();
                      const n = (i + d + modes.length) % modes.length;
                      setMode(modes[n].id);
                      tabs.current[n]?.focus();
                    }}
                    className={cn('relative flex min-h-11 flex-col items-center justify-center gap-0.5 rounded-lg px-1 py-1.5 text-[12px] font-medium transition-colors duration-200 sm:flex-row sm:gap-1.5 sm:text-[13.5px]', on ? 'text-foreground' : 'text-muted-foreground hover:text-foreground')}
                  >
                    {on && <span data-glide aria-hidden className="absolute inset-0 rounded-lg bg-card shadow-[var(--highlight),var(--shadow-sm)]" />}
                    <Icon name={m.icon ?? 'sparkle'} size={16} className={cn('relative', on && 'text-primary')} />
                    <span className="relative truncate">{m.label}</span>
                  </button>
                );
              })}
            </div>
          </div>
          <Field value={text} onValue={setText} onSend={submit} label={`${t.message}, ${current?.label ?? ''}`} placeholder={current?.placeholder ?? placeholder} />
          <div className="flex items-center gap-1.5 p-2 pt-1 sm:p-2.5 sm:pt-1">
            {toggle(web, setWeb, 'globe', t.web)}
            {toggle(deep, setDeep, 'lightbulb', t.deep)}
            <span className="flex-1" />
            <SendButton t={t} disabled={!text.trim()} />
          </div>
        </Box>
      </Reveal>
      {current && (
        <div key={current.id} className="pin-swap mt-3 grid grid-cols-1 gap-3 rounded-xl border border-border bg-card/60 p-3.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center sm:gap-6 sm:px-4" aria-live="polite">
          <p className="text-pretty text-[13.5px] leading-relaxed text-muted-foreground">
            <span className="font-medium text-foreground">{current.label}.</span> {current.description}
          </p>
          {(current.time || current.cost) && (
            <dl className="flex gap-5 text-[12.5px] sm:justify-end">
              {current.time && (
                <div>
                  <dt className="text-subtle-foreground">{t.time}</dt>
                  <dd className="mt-0.5 flex items-center gap-1 font-medium tabular-nums text-foreground">
                    <Icon name="clock" size={13} className="text-muted-foreground" />
                    {current.time}
                  </dd>
                </div>
              )}
              {current.cost && (
                <div>
                  <dt className="text-subtle-foreground">{t.cost}</dt>
                  <dd className="mt-0.5 flex items-center gap-1 font-medium tabular-nums text-foreground">
                    <Icon name="coins" size={13} className="text-muted-foreground" />
                    {current.cost}
                  </dd>
                </div>
              )}
            </dl>
          )}
        </div>
      )}
      <div className="mt-2.5 flex flex-col gap-1.5 px-1 sm:flex-row sm:justify-between sm:gap-6">
        <Hint icon="info">{note ?? t.estimate}</Hint>
        <Hint className="max-sm:hidden sm:shrink-0">{t.shortcut}</Hint>
      </div>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* voice-dictation                                                     */
/* ------------------------------------------------------------------ */

/** Bar heights of the waveform, fixed so server and browser agree */
const BARS = Array.from({ length: 64 }, (_, i) => round(0.35 + 0.65 * Math.abs(Math.sin(i * 1.7) * Math.cos(i * 0.45)), 2));

function Transcript({ text }: { text: string }) {
  return (
    <>
      {text.split(/(~[^~]+~?)/g).map((part, i) =>
        part.startsWith('~') ? (
          <span key={i} className="underline decoration-[color-mix(in_oklab,var(--warning)_80%,transparent)] decoration-dotted decoration-2 underline-offset-4">
            {part.replace(/~/g, '')}
          </span>
        ) : (
          <span key={i}>{part}</span>
        ),
      )}
    </>
  );
}

function VoiceDictation({ t, title, description, placeholder, defaultValue = '', transcript = '', autoStart, onListen, note, onSubmit, className }: P) {
  const [text, setText] = useState(defaultValue);
  const [listening, setListening] = useState(false);
  const [heard, setHeard] = useState('');
  const [secs, setSecs] = useState(0);
  const [doubts, setDoubts] = useState<string[]>([]);
  const [sent, send] = useSent();
  const stopRef = useRef<() => void>(() => {});
  const [ref, inView] = useInView<HTMLDivElement>('0px 0px -20% 0px');
  const started = useRef(false);

  const start = () => {
    setHeard('');
    setSecs(0);
    setListening(true);
    if (onListen) {
      stopRef.current = onListen(setHeard);
      return;
    }
    const words = transcript.split(' ');
    if (prefersReducedMotion()) {
      setHeard(transcript);
      stopRef.current = () => {};
      return;
    }
    let i = 0;
    const id = window.setInterval(() => {
      i++;
      setHeard(words.slice(0, i).join(' '));
      if (i >= words.length) window.clearInterval(id);
    }, 230);
    stopRef.current = () => window.clearInterval(id);
  };
  useEffect(() => {
    if (!listening) return;
    const id = window.setInterval(() => setSecs((s) => s + 1), 1000);
    return () => window.clearInterval(id);
  }, [listening]);
  useEffect(() => {
    if (!autoStart || !inView || started.current || prefersReducedMotion()) return;
    // The lock is set when the timer fires: strict mode clears the first timer and
    // starts a second one, and scrolling away and back never restarts a finished run
    const id = window.setTimeout(() => {
      started.current = true;
      start();
    }, 700);
    return () => window.clearTimeout(id);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [autoStart, inView]);
  useEffect(() => () => stopRef.current(), []);

  const finish = (keep: boolean) => {
    stopRef.current();
    setListening(false);
    if (!keep || !heard) return;
    // The field shows plain text, the unsure words move into the hint below
    setDoubts((d) => [...d, ...Array.from(heard.matchAll(/~([^~]+)~/g), (m) => m[1])]);
    setText((v) => (v ? `${v} ` : '') + heard.replace(/~/g, ''));
  };
  const submit = () => {
    if (!text.trim()) return;
    const clean = text.trim();
    onSubmit?.({ text: clean, attachments: [], contexts: [] });
    send(clean);
    setText('');
    setDoubts([]);
  };
  const check = doubts.filter((w) => text.includes(w));
  const unsure = listening ? /~/.test(heard) : check.length > 0;
  const clock = `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, '0')}`;

  return (
    <Stage title={title} description={description} className={className}>
      <SentList items={sent} t={t} />
      <Reveal effect="scale" delay={80}>
        <div ref={ref}>
          <Box onSubmit={submit}>
            {listening ? (
              <div className="px-4 pb-2 pt-3.5" aria-live="polite">
                <p className="flex items-center gap-2 text-[12.5px] font-medium text-[color-mix(in_oklab,var(--destructive)_75%,var(--foreground))]">
                  <span className="relative flex size-2">
                    <span className="absolute inset-0 animate-ping rounded-full bg-destructive opacity-60 motion-reduce:animate-none" />
                    <span className="relative size-2 rounded-full bg-destructive" />
                  </span>
                  {t.listening}
                  <span className="tabular-nums text-muted-foreground">{clock}</span>
                </p>
                <p className="mt-2 min-h-12 text-pretty text-[15px] leading-relaxed text-foreground">
                  {heard ? <Transcript text={heard} /> : <span className="text-subtle-foreground">{t.micHint}</span>}
                  <span aria-hidden className="ml-0.5 inline-block h-[1.05em] w-0.5 translate-y-[0.15em] animate-kit-pulse bg-primary" />
                </p>
                <div aria-hidden className="mt-2 flex h-10 items-center justify-between">
                  {BARS.map((h, i) => (
                    <span
                      key={i}
                      className={cn('pin-wave w-[3px] rounded-full bg-[color-mix(in_oklab,var(--primary)_70%,var(--foreground))] sm:w-1', i % 2 && 'max-sm:hidden')}
                      style={{ height: `${Math.round(h * 100)}%`, '--pin-d': `${(i % 9) * 90}ms`, '--pin-t': `${900 + (i % 5) * 140}ms` } as React.CSSProperties}
                    />
                  ))}
                </div>
              </div>
            ) : (
              <Field value={text} onValue={setText} onSend={submit} label={t.message} placeholder={placeholder} />
            )}
            <div className="flex items-center gap-1.5 p-2 pt-1 sm:p-2.5 sm:pt-1">
              {listening ? (
                <>
                  <button type="button" onClick={() => finish(false)} aria-label={t.cancel} className={iconBtn}>
                    <Icon name="trash" size={17} />
                  </button>
                  <span className="flex-1" />
                  <button type="button" onClick={() => finish(true)} className="inline-flex h-11 items-center gap-1.5 rounded-lg bg-primary px-4 text-[13.5px] font-medium text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)] hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))] sm:h-9">
                    <Icon name="check" size={16} />
                    {t.stopInsert}
                  </button>
                </>
              ) : (
                <>
                  <button
                    type="button"
                    onClick={start}
                    className="inline-flex h-11 items-center gap-1.5 rounded-lg border border-border px-3 text-[13px] font-medium text-foreground transition-colors duration-200 hover:bg-accent sm:h-9"
                  >
                    <Icon name="mic" size={16} className="text-primary" />
                    {t.dictate}
                  </button>
                  <span className="flex-1" />
                  <SendButton t={t} disabled={!text.trim()} />
                </>
              )}
            </div>
          </Box>
        </div>
      </Reveal>
      <div className="mt-3 space-y-1.5 px-1">
        {unsure && (
          <Hint icon="alert-circle" className="pin-up text-warning-foreground">
            {listening ? t.unsure : t.checkWords}
            {!listening && (
              <span className="mt-1 flex flex-wrap gap-1.5">
                {check.map((w) => (
                  <span key={w} className="rounded-md bg-[color-mix(in_oklab,var(--warning)_16%,transparent)] px-1.5 py-0.5 font-medium">
                    {w}
                  </span>
                ))}
              </span>
            )}
          </Hint>
        )}
        {note && <Hint icon="lock">{note}</Hint>}
      </div>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */

/**
 * The input of an AI product, one feature per variant: attachments with
 * upload state and limits, slash commands, context added with @ and a
 * context meter, modes with time and cost, and dictation that marks the
 * words it is unsure about.
 */
export function PromptInput({ variant = 'attachments', labels, ...props }: PromptInputProps) {
  const t = useTexts(TEXTS, labels);
  const p: P = { ...props, t };
  const body = (() => {
    switch (variant) {
      case 'slash-commands':
        return <SlashCommands {...p} />;
      case 'context-mentions':
        return <ContextMentions {...p} />;
      case 'modes':
        return <Modes {...p} />;
      case 'voice-dictation':
        return <VoiceDictation {...p} />;
      default:
        return <Attachments {...p} />;
    }
  })();
  return (
    <>
      <Style />
      {body}
    </>
  );
}

Frequent questions

How do I install Prompt input?

With aiw add @ai-website/prompt-input. The command puts components/ui/prompt-input.tsx into your project, the code is yours from then on. It brings along icon and reveal. It needs no further npm packages.

Which variants does Prompt input have?

5 variants, With attachments, Slash commands, Context with @, Modes with estimates and Voice dictation. You pick one with the variant prop, for example variant="slash-commands".

Can I translate the texts of Prompt input?

Yes. Prompt input carries 38 texts of its own in English and German, such as “Message”, “Send” and “Attach files”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Prompt input?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Prompt input with aiw_components and install it with aiw_add @ai-website/prompt-input. Name the variant in your prompt, for example variant="attachments", and the assistant writes your content straight into the props.

Can I adjust Prompt input without code?

Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching PromptInput code to copy.

Often searched

Jump to

Copied to the clipboard