Prompt input
The composer, as a real form — so Enter and the button are one path. The submit button is one control wearing three labels.
Enter sends, Shift+Enter breaks the line. Send nothing to see the error state.
Usage
import {
PromptInput,
PromptInputSubmit,
PromptInputTextarea,
PromptInputToolbar,
} from "@kanzo-tech/ai";<PromptInput onSubmit={send}>
<PromptInputTextarea name="prompt" placeholder="Ask about the board…" />
<PromptInputToolbar>
<PromptInputSubmit status={status} />
</PromptInputToolbar>
</PromptInput>PromptInput is a <form>. That is the point of it: Enter and the button both go through
onSubmit, so there is one code path to test and one place where "send" is decided. A composer
built from a div and an onClick has two, and they drift.
It is an InputGroup, wearing a form
The composer is InputGroup with asChild on it, not an InputGroup
inside a form.
That is load-bearing rather than tidy. InputGroup's recipe selects its own direct children —
has-[>textarea], has-[>[data-align=block-end]] — so an element between the group and the field
silently unsets half of it. The border no longer reacts to focus, the toolbar stops sitting flush,
and nothing errors.
PromptInputToolbar is an InputGroupAddon pinned to align="block-end", which is also not
decoration: a popover trigger living in the toolbar anchors to the composer, so its surface
opens over the text it writes. Hung off a label row above the field instead, it opened against the
panel's edge, far from the value it was about.
Enter sends, Shift+Enter breaks the line
PromptInputTextarea requests the form's submit on Enter, and leaves Shift+Enter to the browser.
isComposing is the trap. An IME commits its candidate with Enter — Japanese, Chinese, Korean,
and every accent picker that works the same way. Without checking event.nativeEvent.isComposing,
the half-typed word is sent instead of finished, and the bug is invisible to anyone testing in
a Latin keyboard layout. The check is here so no consumer has to know it exists.
Your own onKeyDown runs first, and calling preventDefault() in it stops the send — which is how
you add a shortcut of your own without forking the part.
One button, three labels
PromptInputSubmit takes status and changes what it says:
status | Icon | Label | The moment |
|---|---|---|---|
idle · ready | send | Send | there is something to ask |
loading | square | Stop | an answer is arriving |
error | refresh | Retry | the last one failed |
It stays type="submit" in all three, so all three arrive at your onSubmit and the handler is
where the three meanings are told apart. That keeps Stop reachable by Enter as well as by pointer,
which a separate cancel button beside the form does not.
The label is applied as aria-label only when the button has no visible text of its own: an
aria-label over a legible label renames the control, which is worse than not having one. The
status is also mirrored to data-status, so a consumer's own styling can select on it.
status is the same AiStatus the engine hooks report, so a composer wired to
useAiStream or useInlineCompletion is
status={engine.status} and nothing more. The type is
"idle" | "loading" | "ready" | "error", and the control wears three looks rather than four:
ready and idle are one button, because an answer that has arrived leaves the composer ready for
the next question. They are two states of the stream, not of this control.
Choosing which model answers
A model is a value, so the picker is a Select and there is no component
here for it. A value control keeps its value without being told to; reach for a palette instead and
you inherit its rule that a selection is thrown away on click, which is a defect you then have to
override your way out of. AI Elements — the source these shapes come from — puts a Select in this
strip and never needed the override, and ours diverged from that source without saying why.
ModelList and ModelListItem existed here for one release and are deleted, with no alias. The
defect they were built to prevent turned out to be an artefact of the primitive they were built on:
Command pins its selection behaviour to clear because a command is an act, so the control forgot
what it was set to a frame after being told. A Select is a value control by construction — nothing
to override, no silent failure to prevent, and therefore no capability left for a name to carry.
What would reverse it: a host whose model registry is long enough that picking from it is a search
rather than a choice — twenty entries, not four. A Select has no filter input, and at that length the
argument flips back to Command: the palette semantics are still wrong, but a list you cannot search
is worse. The measurement is a real registry, not a hypothetical one.
Held by docs/examples/prompt-input/example-model.tsx, the composition in full;
packages/ai/src/index.ts, which exports no picker.
What the composer asks of it is that it stop looking like a form field. The strip is a row of controls, not a row of inputs, so the trigger drops its border, its fill and its shadow and takes the hover the rest of the row takes:
<SelectTrigger className="w-auto border-none bg-transparent shadow-none text-muted-foreground hover:bg-accent hover:text-foreground">
<SelectValue />
</SelectTrigger>Anatomy
PromptInput (form, rendered as an InputGroup)
├── PromptInputTextarea (the field — Enter submits)
└── PromptInputToolbar (block-end addon)
├── InputGroupText (a hint, a model name, a scope)
└── PromptInputSubmit (ms-auto — it packs to the reading end)Anything else you put in the toolbar is yours: the Select above, a
Popover of attachments, a Toggle for a mode.
They anchor to the composer for the reason above.
API Reference
PromptInput
Renders a form and takes that element's props — onSubmit, action, noValidate.
PromptInputTextarea
Extends InputGroupTextarea.
| Prop | Type | Default |
|---|---|---|
rows | number | 1 |
The field is min-h-16 so an empty composer still looks like somewhere to write.
PromptInputSubmit
Extends InputGroupButton.
| Prop | Type | Default |
|---|---|---|
status | AiStatus | "idle" |
type | string | "submit" |
variant | inherited | "default" |
size | inherited | "icon-sm", or "sm" with children |
Pass children to say it in your own words; the icon and the aria-label both step aside.
PromptInputSubmitProps is exported, so a wrapper can take the same props without restating them.
Message
One turn in a transcript. The speaker is declared once on the row, and every part reads it back off the DOM.
Reasoning
A model's thinking, folded away. It opens itself while the thinking streams and folds a second after it stops, saying how long it took — until the reader touches it, after which it never moves on its own again.