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
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
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
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The PromptInputProps interface from components/ui/prompt-input.tsx, with the comments from the code.
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
Built-in texts
Prompt input writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 38 texts
| Key | English | German |
|---|---|---|
| message | English Message | German Nachricht |
| send | English Send | German Senden |
| attach | English Attach files | German Dateien anhängen |
| remove | English Remove {name} | German {name} entfernen |
| uploading | English Uploading {n}% | German Lädt hoch, {n} % |
| tooLarge | English Larger than {mb} MB, choose a smaller file | German Größer als {mb} MB, wähle eine kleinere Datei |
| tooMany | English Up to {n} files per message | German Bis zu {n} Dateien je Nachricht |
| limits | English {count} of {max} files · up to {mb} MB each | German {count} von {max} Dateien · je bis {mb} MB |
| drop | English Drop files to attach them | German Dateien hier ablegen |
| dropHint | English PDF, spreadsheets, images and text | German PDF, Tabellen, Bilder und Text |
| sent | English Sent | German Gesendet |
| commands | English Commands | German Befehle |
| choose | English Choose | German Auswählen |
| commandHint | English Type / for commands | German Tippe / für Befehle |
| noCommand | English No command matches. Keep typing to send it as text. | German Kein Befehl passt. Schreib weiter, dann geht es als Text raus. |
| removeCommand | English Remove command {name} | German Befehl {name} entfernen |
| context | English Context | German Kontext |
| addContext | English Add context | German Kontext hinzufügen |
| mentionHint | English Type @ to add a document, project or person | German Tippe @ für ein Dokument, Projekt oder eine Person |
| searchContext | English Search documents, projects and people | German Dokumente, Projekte und Personen suchen |
| noContext | English Nothing matches that name. | German Dazu passt kein Name. |
| contextUse | English {used} of {limit} tokens | German {used} von {limit} Tokens |
| contextFull | English Close to the limit, remove a source to keep answers precise | German Fast voll, nimm eine Quelle raus, dann bleiben Antworten genau |
| contextOk | English The assistant reads only what is listed here | German Der Assistent liest nur, was hier steht |
| mode | English Mode | German Modus |
| deep | English Think longer | German Länger nachdenken |
| web | English Search the web | German Im Web suchen |
| time | English Time | German Dauer |
| cost | English Cost | German Kosten |
| estimate | English Estimates, the real value can differ | German Schätzwerte, der echte Wert kann abweichen |
| dictate | English Dictate | German Diktieren |
| listening | English Listening | German Hört zu |
| stopInsert | English Insert text | German Text einfügen |
| cancel | English Discard recording | German Aufnahme verwerfen |
| unsure | English Underlined words were hard to hear, check them before sending | German Unterstrichene Wörter waren schwer zu verstehen, prüf sie vor dem Senden |
| checkWords | English These words were hard to hear, check them before sending | German Diese Wörter waren schwer zu verstehen, prüf sie vor dem Senden |
| micHint | English Press the microphone and speak | German Mikrofon drücken und sprechen |
| shortcut | English Enter sends, Shift Enter adds a line | German 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
'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.