Kanzo UI
AI

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.

Amber Hall · the board and the roster

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:

statusIconLabelThe moment
idle · readysendSendthere is something to ask
loadingsquareStopan answer is arriving
errorrefreshRetrythe 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.

PropTypeDefault
rowsnumber1

The field is min-h-16 so an empty composer still looks like somewhere to write.

PromptInputSubmit

Extends InputGroupButton.

PropTypeDefault
statusAiStatus"idle"
typestring"submit"
variantinherited"default"
sizeinherited"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.

On this page