Prompt Eingabe für KI-Produkte
Die Eingabe eines KI-Produkts mit einer Fähigkeit je Variante. Dateien mit Upload-Stand und Grenzen, Befehle mit Schrägstrich, Kontext per @ mit Anzeige, Modi mit Dauer und Kosten und Diktat, das unsichere Wörter markiert.
Eine von 3 Components in AI und Chat
Die Eingabe eines KI-Produkts, fünffach
Die Prompt-Eingabe ist der Anfang jeder KI-Interaktion, und sie trägt heute mehr als Text. Diese Component wächst mit dem Inhalt, sendet mit Enter, macht mit Umschalt und Enter eine neue Zeile, und jede Variante ergänzt eine Funktion, die echte KI-Produkte brauchen.
„Mit Anhängen“ nimmt Dateien mit Upload-Status und klaren Grenzen. „Befehle mit Schrägstrich“ öffnet ein Aktionsmenü, wenn die Zeile mit einem Schrägstrich beginnt. „Kontext mit @“ fügt Dateien, Seiten oder Personen hinzu und zeigt an, wie viel Kontext belegt ist. „Modi mit Schätzung“ lässt einen Modus wählen und zeigt vorher Zeit und Kosten. „Diktieren“ macht aus Sprache Text und markiert Wörter, bei denen die Erkennung unsicher ist.
Zeig Grenzen, bevor jemand sie erreicht, eine zu große Datei meldet sich schon beim Ablegen. Sperr den Senden-Knopf nur, solange das Feld leer ist, und biete Stopp an, während eine Antwort läuft. Der Platzhalter kann einen ersten Prompt vorschlagen, das hilft allen, die nicht wissen, was sie fragen sollen.
Variants
Details
Was Prompt-Eingabe mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- AI und Chat
- Bereich
- App
- Variants
- 5
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/prompt-input.tsx
- Bringt mit
- utilskiticonreveal
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 38 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle PromptInputProps aus components/ui/prompt-input.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalPromptInputVariantattachmentsslash-commandscontext-mentionsmodesvoice-dictation
titleoptionalstringSmall heading above the field, e.g. the assistant's name
descriptionoptionalstringplaceholderoptionalstringdefaultValueoptionalstringText already in the field
attachmentsoptionalPromptAttachment[]attachments
maxFilesoptionalnumbermaxSizeMboptionalnumberLargest file in MB
commandsoptionalPromptCommand[]slash-commands
contextsoptionalPromptContextItem[]context-mentions: everything that can be added with @
selectedoptionalstring[]context-mentions: ids already added
contextLimitoptionalnumbercontext-mentions: tokens the model can read at once
modesoptionalPromptMode[]modes
defaultModeoptionalstringtranscriptoptionalstringvoice-dictation: the demo transcript. Words between ~tildes~ are shown as uncertain.
autoStartoptionalbooleanvoice-dictation: start listening once the field scrolls into view
onListenoptional(push: (text: string) => void) => () => voidvoice-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.
noteoptionalstringA line under the field, e.g. what happens with files or audio
onSubmitoptional(value: PromptSubmit) => void
Eingebaute Texte
Diese Texte schreibt Prompt-Eingabe selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 38 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| message | Englisch Message | Deutsch Nachricht |
| send | Englisch Send | Deutsch Senden |
| attach | Englisch Attach files | Deutsch Dateien anhängen |
| remove | Englisch Remove {name} | Deutsch {name} entfernen |
| uploading | Englisch Uploading {n}% | Deutsch Lädt hoch, {n} % |
| tooLarge | Englisch Larger than {mb} MB, choose a smaller file | Deutsch Größer als {mb} MB, wähle eine kleinere Datei |
| tooMany | Englisch Up to {n} files per message | Deutsch Bis zu {n} Dateien je Nachricht |
| limits | Englisch {count} of {max} files · up to {mb} MB each | Deutsch {count} von {max} Dateien · je bis {mb} MB |
| drop | Englisch Drop files to attach them | Deutsch Dateien hier ablegen |
| dropHint | Englisch PDF, spreadsheets, images and text | Deutsch PDF, Tabellen, Bilder und Text |
| sent | Englisch Sent | Deutsch Gesendet |
| commands | Englisch Commands | Deutsch Befehle |
| choose | Englisch Choose | Deutsch Auswählen |
| commandHint | Englisch Type / for commands | Deutsch Tippe / für Befehle |
| noCommand | Englisch No command matches. Keep typing to send it as text. | Deutsch Kein Befehl passt. Schreib weiter, dann geht es als Text raus. |
| removeCommand | Englisch Remove command {name} | Deutsch Befehl {name} entfernen |
| context | Englisch Context | Deutsch Kontext |
| addContext | Englisch Add context | Deutsch Kontext hinzufügen |
| mentionHint | Englisch Type @ to add a document, project or person | Deutsch Tippe @ für ein Dokument, Projekt oder eine Person |
| searchContext | Englisch Search documents, projects and people | Deutsch Dokumente, Projekte und Personen suchen |
| noContext | Englisch Nothing matches that name. | Deutsch Dazu passt kein Name. |
| contextUse | Englisch {used} of {limit} tokens | Deutsch {used} von {limit} Tokens |
| contextFull | Englisch Close to the limit, remove a source to keep answers precise | Deutsch Fast voll, nimm eine Quelle raus, dann bleiben Antworten genau |
| contextOk | Englisch The assistant reads only what is listed here | Deutsch Der Assistent liest nur, was hier steht |
| mode | Englisch Mode | Deutsch Modus |
| deep | Englisch Think longer | Deutsch Länger nachdenken |
| web | Englisch Search the web | Deutsch Im Web suchen |
| time | Englisch Time | Deutsch Dauer |
| cost | Englisch Cost | Deutsch Kosten |
| estimate | Englisch Estimates, the real value can differ | Deutsch Schätzwerte, der echte Wert kann abweichen |
| dictate | Englisch Dictate | Deutsch Diktieren |
| listening | Englisch Listening | Deutsch Hört zu |
| stopInsert | Englisch Insert text | Deutsch Text einfügen |
| cancel | Englisch Discard recording | Deutsch Aufnahme verwerfen |
| unsure | Englisch Underlined words were hard to hear, check them before sending | Deutsch Unterstrichene Wörter waren schwer zu verstehen, prüf sie vor dem Senden |
| checkWords | Englisch These words were hard to hear, check them before sending | Deutsch Diese Wörter waren schwer zu verstehen, prüf sie vor dem Senden |
| micHint | Englisch Press the microphone and speak | Deutsch Mikrofon drücken und sprechen |
| shortcut | Englisch Enter sends, Shift Enter adds a line | Deutsch Enter sendet, Umschalt Enter macht eine neue Zeile |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Prompt-Eingabe reagiert selbst auf die Tasten Escape, Enter, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Tab, Rücktaste und Leertaste.
- Setzt aria-activedescendant, aria-autocomplete, aria-checked, aria-controls, aria-expanded, aria-label, aria-live, aria-pressed, aria-selected, aria-valuemax, aria-valuemin, aria-valuenow und aria-valuetext, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen combobox, group, listbox, meter, option, radio, radiogroup und status.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 1.232 Zeilen
'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}
</>
);
}Häufige Fragen
Wie installiere ich Prompt-Eingabe?
Mit aiw add @ai-website/prompt-input. Der Befehl legt die Datei components/ui/prompt-input.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon und reveal. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Prompt-Eingabe?
5 Varianten, Mit Anhängen, Befehle mit Schrägstrich, Kontext mit @, Modi mit Schätzung und Diktieren. Du wählst sie mit der Prop variant, zum Beispiel variant="slash-commands".
Kann ich die Texte von Prompt-Eingabe übersetzen?
Ja. Prompt-Eingabe bringt 38 eigene Texte auf Deutsch und Englisch mit, etwa „Nachricht“, „Senden“ und „Dateien anhängen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Prompt-Eingabe einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Prompt-Eingabe mit aiw_components und installieren sie mit aiw_add @ai-website/prompt-input. Nenn im Prompt die Variante, etwa variant="attachments", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Prompt-Eingabe ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden PromptInput-Code zum Kopieren.