PromptInput
Where the user talks to the model. The box holds an auto-growing textarea
(capped at maxRows, then it scrolls) and a toolbar row with the send
button. The root is a real <form>; the send button submits it.
On phones the textarea renders at 16px, so iOS does not zoom on focus, and
the toolbar buttons have 44px targets on a coarse pointer.
Enter sends and Shift+Enter makes a newline (IME composition never sends);
set submitKey="mod-enter" to keep Enter as a newline and send on
⌘/Ctrl+Enter — for prompts that are prose first. onSubmit receives the
trimmed text and is never called with an empty string.
import PromptInput, {usePromptInput} from '@zuilib/ai/prompt-input'
Basic
Uncontrolled, the box clears itself after a send. Controlled
(value + onValueChange), clearing after a send is yours — keep the text
on failure so nothing is lost.
Extension points
Three slots, all inside the box, top to bottom:
| Slot | Where | For |
|---|---|---|
header | Above the textarea | Attachment tray, context chips, a reply-to preview |
toolbar | Under the text, before the send button | Model picker, attach button, tool toggles, dropdowns |
actions | End of the toolbar row, replacing the built-in send / stop | A custom send button, a send-with-options split button |
Anything rendered in a slot can reach the composer through
usePromptInput(): read the text, insert at the caret,
trigger the same guarded send as Enter. PromptInput.ToolbarButton is the
matching icon button for toolbar actions.
Toolbar: custom buttons and dropdowns
The toolbar is a flex row that wraps on narrow panels. Put any control in
it — the send button keeps itself at the end (ms-auto). Use
PromptInput.ToolbarButton for icon actions (ghost, muted, the sm
control square, type="button" so it never submits the form — give it an
aria-label), and any ZUI Menu / NativeSelect / Select for dropdowns.
Anchored Headless UI panels (Menu.Items, Select and Combobox options)
portal to <body> — React context still reaches them, so usePromptInput()
works inside a menu, but a subtree theme override does not; pass
anchor={false} to keep the panel inside a themed subtree (the same
trade-off as everywhere else in ZUI).
Custom controls: usePromptInput()
Any component inside the box can call usePromptInput() — this is how a
template picker fills the textarea, a slash-command menu rewrites it, or a
voice button appends a transcription:
What the hook returns:
| Prop | Type | Description |
|---|---|---|
value | string | The current text |
setValue | (value: string) => void | Replaces the text. Uncontrolled, the box updates itself; controlled, it reaches the parent through onValueChange, which must apply it |
insert | (snippet: string) => void | Inserts at the caret (replacing a selection), then restores focus there |
submit | () => void | The same guarded send as Enter: trims, no-ops when empty / responding / disabled, clears an uncontrolled box |
focus | () => void | Focuses the textarea |
responding | boolean | A run is in flight |
disabled | boolean | |
empty | boolean | Nothing but whitespace — the send button's disabled state, for a custom send |
The hook throws outside <PromptInput>; it works from header, toolbar
and actions, including inside portalled panels opened from them.
Header: attachments and context chips
header renders above the textarea, inside the border — the attachment
tray of a chat, or chips naming what the model can see:
The slot renders only when header is given, so an empty tray adds no
blank row — pass null while there are no attachments.
Replacing the send button: actions
When the built-in icon send is not enough — a labeled button, a
send-with-options split — pass actions. It replaces the send / stop
entirely; wire your own controls to usePromptInput() so they stay
guarded the same way:
A type="submit" button submits the form, which routes through the same
guarded send — you rarely need to call composer.submit() yourself here.
With actions given, showing a Stop while responding is also yours.
While a run is in flight
While responding, sends are blocked. With onStop the send button becomes a
Stop button that aborts the run — pass the same abort the
AssistantDock tasks use.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
onSubmit | (text: string) => void | — | The send; receives the trimmed text, never empty |
value / defaultValue | string | — | Controlled / uncontrolled text |
onValueChange | (value: string) => void | — | |
submitKey | 'enter' | 'mod-enter' | 'enter' | Enter sends, or ⌘/Ctrl+Enter sends |
responding | boolean | — | A run is in flight; sends are blocked |
onStop | () => void | — | Shown as the Stop button while responding |
minRows / maxRows | number | 1 / 8 | Autogrow floor and cap |
header | ReactNode | — | Above the textarea: attachments, context chips |
toolbar | ReactNode | — | The row before the send button |
actions | ReactNode | — | Replaces the built-in send / stop button |
label | string | 'Message' | The textarea's accessible name |
submitLabel / stopLabel | string | 'Send message' / 'Stop' | |
className / textareaClassName | string | — | Merged last onto the box / the textarea |
Everything else a <textarea> takes (maxLength, autoFocus, name,
onPaste…) passes through to the textarea; ref reaches it too.
PromptInput.ToolbarButton takes every ZUI
Button prop.