Kanzo UI
AI

Assist

A field the model helps fill. One proposal for the field's value, drawn the way the control calls for — ghost text at the caret of a Textarea, whole values under an Input, items for a TagsInput.

What the party is walking into, in the hall's own words.

Usage

import { Field, FieldHelper, FieldLabel, Textarea } from "@kanzo-tech/ui";
import { Assist, AssistProvider } from "@kanzo-tech/ai";
import { createGateway } from "@kanzo-tech/llm";

const gateway = createGateway({ baseURL: "/api/ai" });
<AssistProvider model={gateway("complete")} context="A notice board for a village hall.">
  <Field>
    <FieldLabel>Notice</FieldLabel>
    <Assist value={notice} onValueChange={setNotice}>
      <Textarea rows={4} />
    </Assist>
    <FieldHelper>What the party is walking into.</FieldHelper>
  </Field>
</AssistProvider>

AssistProvider goes once, around the app or the form: it is the one place a product decides which model its fields ask, what the form as a whole is about, where the telemetry goes and in what words. It renders no element. Assist wraps one control and wires it — the value, the keys, the ✨ — so the control is the plain @kanzo-tech/ui primitive and stays one.

One proposal, three presentations

What Assist offers is one thing: a proposal for the field's value — LSP 3.18's InlineCompletionItem, as a field needs it: text, and the range taking it replaces. An empty range at the caret is a continuation; the whole value is a replacement; no range at all is an item added to a list. How it is drawn is decided by the control, not by a prop:

ControlvalueThe proposalAsked
Textareastringa continuation, as ghost text at the caretafter a pause in typing, or from the ✨
Inputstringwhole values, as chips under the field; one replaces the valuefrom the ✨
TagsInputstring[]items, as chips; one is added, and one already there is never offeredfrom the ✨

This used to be two components — Complete for the ghost and Suggest for the strip — with two engines, two contracts and two pages. They were one concept presented twice, and a host choosing between them was making a decision the control had already made.

A line takes candidates, a paragraph takes a continuation. There is no ghost over an Input. A continuation drawn over an <input> can only show what fits in the width that is left — the field cannot scroll to text that is not in its value — so a long offer is unreadable by any gesture and taking it means taking it blind. No reference does it either: Gmail continues a body, Copilot an editor, and every one-line field in the wild offers a list.

What would reverse it: a single-line field whose offers are reliably short enough to fit, measured rather than assumed — or a browser giving an <input> a way to scroll text it does not contain.

Held by packages/ai/src/assist.test.tsx, "offers ghost text after a pause, and Tab takes it" and "offers candidates under the field from the ✨, and a chip replaces the value".

A continuation — Textarea

KeyTakes
Tabthe whole continuation
Ctrl / ⌘ + →one word; the rest stays on offer
Alt + ] / Alt + [the next alternative, or back to the previous one
Escnothing — and the ✨ asks again

The offer is made at the caret, and drawn there, with the value holding its own space on both sides. Appended at the end instead, the whole suggestion would vanish the moment the caret moved one character left.

It survives typing that agrees with it. While what the reader types is a prefix of the offer, the ghost is what is left and nothing new is asked — LSP's filterText rule, and the only way to a continuation that feels instant: not asking. Typing anything else lets it go.

Alternatives are the invoked case. Alt + ] asks for an offer different from the ones already made at that point, and keeps them, so Alt + [ goes back without asking again.

An offer that does not fit makes the field taller, and is not cut. The field takes the height the offer needs while it is on the table and gives it back when it goes, so rows is a floor — which is what rows already meant. A live region announces that an offer is ready, once; the ghost itself is aria-hidden, because a polite region re-reading a sentence that arrives a word per frame announces nothing.

Whole values — Input

The line the board shows. A chip replaces it; the ✨ puts it back.

The ✨ asks; the candidates stream into a strip under the field, each arriving as soon as it is complete, so the first is on screen while the model writes the rest. Pressing a chip replaces the value; its ✕ drops it; pressing the ✨ again asks for a different set. The strip shows while the field has focus, so a form of eleven assisted fields is not a wall of chips.

The chips are buttons in the flow, not options in a popover. Pressing one leaves a value in a field and a selection nowhere, which by the menu/listbox rule makes it a command surface. They are @kanzo-tech/ui's Suggestions, in document order, so Tab reaches every one. Each chip is a Suggestion — a Button under the pills' treatment that takes a value and reports onSelect, its props SuggestionProps — and neither knows a model exists, which is why they are ui's and a host can draw a strip of its own with them.

Candidates are structured output, never prose to parse. They are asked for as a JSON schema — a value and a one-sentence rationale each, the rationale shown as the chip's tooltip — so there is no "reply with only the list, no fences" prompt and no fence-stripping after it.

Items — TagsInput

livestock
How the board files the contract. A chip adds one.

The same strip, and a chip adds rather than replaces. A value the list already holds — or that another chip already offers, case-insensitively — is dropped before it is shown.

The ✨ is a mark, not a door

A field the model can help with must look like one before anybody touches it. So the ✨ sits inside the control's box — at the end of a one-line field, in the bottom corner of a paragraph — and is a statement of capability first and a button second: quiet at rest, lit while something is on offer, busy while a request is out.

Once a value has been taken and nothing else is on offer it becomes Undo AI suggestion — right after a continuation is accepted, or once the strip has run out — and puts the old value back, until the reader edits. While chips are still up it stays Suggest different values, because asking for another set is the press a reader with a strip in front of them is likelier to want. That is the revert state of IBM Carbon's AI label: what the model wrote into a field is one press from being unwritten, without the reader having to remember what was there.

  • Nothing keys off :hover. A mark revealed on hover does not exist for touch or the keyboard, and a request fired on hover bills a model for a pointer crossing the field. Focus and a press are the gestures.
  • The name carries the state, not the colour. It is AI assist at rest, Accept suggestion while a continuation is on offer, Suggest different values while chips are up, and Undo AI suggestion after a take — so a reader who cannot see it light up is told the same thing.

Held by packages/ai/src/assist.test.tsx, "marks the field at rest, busy while asking, and lit while offering" and "puts the old value back from the ✨ right after a suggestion was taken".

What the model is told

The field describes itself the way it describes itself to a person: its accessible name and accessible description — the label and helper text Ark's Field wires to the control with aria-labelledby and aria-describedby. On top of that, the instructions a host gives this field, and the context the provider gives the form, read at the moment a field asks so it is never stale.

So an accessible field needs nothing more, and a field without a name is one a screen reader cannot fill either.

Use FieldHelper, not FieldDescription. FieldHelper is Ark's helper text and is wired into the control's aria-describedby; FieldDescription is a plain paragraph for a group and is wired to nothing. Written under a field with FieldDescription, the description is on screen and the model is never told it.

Held by packages/ai/src/assist.test.tsx, "tells the model what the field is: its label, its description and the form".

What the label cannot say goes in the field's context. A field generated from a schema knows more than a person reads in its label — the values it accepts, its limits, what its neighbours already hold. That is facts, not an instruction, so it is its own prop, as many lines as it takes, and told to the model under Field context, before the form's:

<Assist context={() => describeConstraints(field)} onValueChange={setValue} value={value}>
  <Input />
</Assist>

A function is read when the field asks, like the provider's, so it sees the form as it is then. Held by packages/ai/src/assist.test.tsx, "tells the model the field's own context, read when the field asks".

What happened to a proposal

onEvent on the provider reports each proposal's life — the lifecycle VS Code's inline completions report, in one callback, for a host that learns from it:

kindWhen
shownit is on screen
acceptedtaken whole: Tab, the ✨, or a chip
partialtaken a word at a time
rejectedturned down: Esc, or a chip's ✕
ignoredleft to die — the reader typed something else, or selected text
interface AssistEvent {
  kind: "shown" | "accepted" | "partial" | "rejected" | "ignored";
  proposal: Proposal; // { text, range?, rationale? }
  field: string;      // the field's accessible name
}

When asking fails

A refused call, a broken stream or a model that goes silent does not read as "Nothing to suggest." The field says it failed under itself — the error's own message, or the failed translation when it has none — and onFailure on the provider receives what was thrown, whole. Each ask is made once: a failure is not retried, so what arrives is the first attempt's own error, with your gateway's code in it.

How long a model may stay silent is not Assist's to decide: createGateway bounds the request it makes, and a model behind it that sends nothing for 30 s arrives as @kanzo-tech/llm's AiError, code "ai/silent", data.after 30000 — see @kanzo-tech/llm. Anything else arrives as the AI SDK or your gateway threw it, so a host branches on error.code.

Held by packages/ai/src/assist.test.tsx, "says so under the field and hands the provider's onFailure the thrown value", "asks once: a gateway's 504 is the failure, not a retry after it".

API

AssistProvider

PropType
modelLanguageModelWhat every field below asks — gateway("complete")
contextstring | () => string | undefinedWhat the rest of the form says; a function is read when a field asks
onEvent(event: AssistEvent) => voidEach proposal's life
onFailure(error: unknown) => voidWhat a field's ask threw, whole
translationsPartial<AssistTranslations>Every word the fields below draw or announce

Assist

PropType
valuestring | string[]The field's value. A list makes it a tags field
onValueChange(value) => voidCalled with the same type
instructionsstringOne sentence for the model about this field, beyond its label and description
childrenReactElementThe control: Textarea, Input or TagsInput

The types are exported as AssistProviderProps, AssistProps, AssistTranslations, AssistEvent and Proposal. The words are on Internationalisation: every one Assist draws or announces is a key of AssistTranslations, set once on the provider.

What it does not do

  • It does not validate. A taken proposal is a value like any other; the field's own validation applies. See Validation.
  • It does not hold the value. value and onValueChange are the host's, so a form library's field state is the source of truth and Assist writes through it.
  • It does not correct. A continuation inserts at the caret. LSP's range could replace the word under it; a field of prose has not needed that.

On this page