npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@zuilib/ai

v0.5.0

Published

ZUI UI for AI-assisted enterprise apps: assistant dock, thread, streaming markdown, tool calls, approvals, diff review, structured output, citations, provider adapters

Readme

@zuilib/ai

The UI that AI-assisted enterprise apps need, on ZUI tokens and @zuilib/primitives. Provider-agnostic: no SDK, no network. Stream events in, wire callbacks out.

| Import | What | | --- | --- | | @zuilib/ai/assistant-dock | Floating, context-aware assistant dock (not a chatbot); runs suggestions as tasks, streams events into them | | @zuilib/ai/thread | Thread: a list of Messages, every part rendered by its card | | @zuilib/ai/streaming-markdown | Markdown that grows as tokens arrive, with useStreamingText and citation chips | | @zuilib/ai/tool-call-card | One tool invocation: args, status, result, elapsed time | | @zuilib/ai/approval-card | Propose-then-confirm with a risk badge and a confirmation step | | @zuilib/ai/diff-review | Unified or split line diff with per-hunk accept / reject; texts, hunks or a patch | | @zuilib/ai/structured-output-form | Model output as editable ZUI form controls | | @zuilib/ai/citation | Inline [1] chips and a source list | | @zuilib/ai/prompt-input | The chat input: auto-growing textarea, Enter-to-send, toolbar slot, send / stop button | | @zuilib/ai/reasoning | "Working on it" status | | @zuilib/ai/use-thread · /use-tool-call · /use-approval · /use-diff-decisions · /consume-run | The headless hooks and the run driver | | @zuilib/ai/stream-events | The AssistantEvent stream model (types) | | @zuilib/ai/adapters/ai-sdk · /anthropic · /sse | Provider streams and messages mapped to events (types only, no SDK) | | @zuilib/ai/markdown-parser · /diff-engine · /thread-reducer · /safe-href · /icons | The pure helpers |

@import "tailwindcss";
@import "@zuilib/primitives/tailwind.css";
@import "@zuilib/ai/tailwind.css";

Import rules

Default exports, one subpath per component (import ToolCallCard from '@zuilib/ai/tool-call-card'); the barrel @zuilib/ai re-exports the components, hooks and pure helpers by name. Adapters are not on the barrel: a host that talks to the AI SDK, Anthropic or an SSE gateway imports the matching @zuilib/ai/adapters/* subpath, so the root surface stays provider-free. React and React DOM are the required peers; Tailwind is optional and needed only for the Tailwind entry. Base UI, the ZUI primitives and tokens install transitively. The adapters and the pure helpers have no React import, so a server component or a store can use them.

How it composes

provider stream ──adapter──▶ AssistantEvent ──useThread / AssistantDock──▶ Message.parts ──Thread──▶ cards
  • AssistantEvent (@zuilib/ai/stream-events) is the streaming model: {type:'text', delta}, {type:'tool-call', id, toolName, args?, status?}, {type:'tool-result', callId, result?, error?, elapsedMs?}, {type:'approval', id, title, description?, risk?, confirm?, preview?, expiresAt?, respond?}, {type:'diff', id, original?, modified?, hunks?, patch?, ...}, {type:'citation', sources}, {type:'custom', kind, id?, data?}, {type:'error', message}, {type:'done', summary?}. ASSISTANT_EVENT_TYPES is the runtime list of the types, exhaustiveness-checked against the union.
  • MessagePart (@zuilib/ai/message-parts) is the settled model: the same shapes, folded by applyEvent (@zuilib/ai/thread-reducer) into Message.parts.
  • Thread maps parts to cards; AssistantDock does the same inside each task.
  • Adapters turn an AI SDK stream, an Anthropic Messages stream or a generic text/event-stream into AssistantEvents.
  • Custom parts are the extension point: a custom event carries an open kind and a data payload, applyEvent folds it into a CustomPart (an event with the same id replaces the part in place), and a PartRenderers entry under that kind renders it — in Thread directly or through the dock's renderers prop. A kind with no renderer is skipped (or handed to renderUnknownPart), so an old client survives a newer stream. The built-in type keys (text, tool-call, tool-result, approval, diff, citation — exported as BUILTIN_PART_TYPES) are reserved: a custom part whose kind matches one never falls through to the built-in card (its shape would not match) and is treated as unknown instead.

An async generator can pause on an approval: the human's decision comes back as the value of the yield.

async function* run({ signal }: { signal: AbortSignal }): AsyncGenerator<AssistantEvent, void, ApprovalOutcome | undefined> {
  yield { type: 'text', delta: 'Drafting…' }
  const outcome = yield { type: 'approval', id: 'send', title: 'Send 12 emails', risk: 'high' }
  if (outcome !== 'approved') return yield { type: 'done', summary: { summary: 'Nothing sent.' } }
  yield { type: 'done', summary: { summary: <b>Sent.</b>, apply: { label: 'Open thread', run: openThread } } }
}

Component API

Every component is a forwardRef client component ('use client'), default-exported (with a named export alongside) from its own subpath, styled only through the @zuilib/tokens names, built from @zuilib/primitives primitives, using logical CSS properties (ps-, me-, start-, text-start; pnpm check:logical enforces it), and carrying a data-slot on its root and every part. None of them calls a model.

AssistantDock — @zuilib/ai/assistant-dock

const suggestions: AssistantSuggestion[] = [
  { id: 'outreach', label: 'Draft outreach for 2 selected', run: async ({ signal }) => {
    const draft = await api.draftOutreach(ids, { signal })
    return { summary: draft.summary, apply: { label: 'Queue for approval', run: () => queue(draft) } }
  } },
  { id: 'explain', label: 'Explain the change', run: ({ signal }) => fromSSE(fetch('/api/explain', { signal })) },
]
<AssistantDock open={open} onOpenChange={setOpen} shortcut="⌘J" shortcutKey="j" header={{ title: 'Accounts', subtitle: '2 selected' }} suggestions={suggestions} onAsk={(text, { signal }) => api.ask(text, { signal })} />

A task runner, not a chatbot: a spark trigger in a corner and a panel with the header badge, three to five suggestions, the tasks it has run and an optional free-text input. A run returns a summary, a promise of one, or an async iterable of AssistantEvents; the task card shows the streamed parts (text through StreamingMarkdown, tool calls through ToolCallCard, approvals through ApprovalCard, diffs through DiffReview, citations through CitationList) and, once done, the summary and an Apply button. An approval event pauses the run until the human decides on the card; the decision resumes the iterator (as the yield's value, and through the event's respond callback).

| Prop | Type | Default | |------|------|---------| | open / defaultOpen / onOpenChange | boolean / boolean / (open) => void | controlled when open is given; defaultOpen (default false) seeds the uncontrolled dock | | header | { title: string; subtitle?: string } | required, the header badge | | suggestions | AssistantSuggestion[] | required: { id, label, description?, run: (options: { signal }) => RunResult } | | onAsk | (text: string, options: { signal }) => RunResult | without it there is no input | | shortcut / shortcutKey | string | the tooltip label ('⌘J') and the key bound with ⌘ / Ctrl on the document to toggle | | position | 'bottom-right' \| 'bottom-left' \| 'top-right' \| 'top-left' | 'bottom-right' | | strategy | 'fixed' \| 'absolute' | 'fixed' pins the dock to the viewport; 'absolute' positions it inside the nearest relative ancestor (keeps a subtree theme, a demo frame) | | width / height | number \| string | 20rem / content (body capped at 26rem); a number is pixels | | resizable | boolean | false; a drag handle on the panel's free corner, arrow keys resize by 16px (min 240 x 200) | | dismissOnOutsideClick | boolean | false; close on a pointer press outside the panel and trigger. A press in a popup opened from the dock (a portaled listbox, menu or popover reached through its button's aria-controls) counts as inside | | modal | boolean | false; the panel is a role="dialog", Tab cycles inside it and Escape closes it from anywhere on the page. Not aria-modal: the panel renders in place and the page behind it stays in the accessibility tree | | label / suggestionsLabel / placeholder | string | 'Assistant' / 'Suggested here' / 'Ask about what’s on screen…' | | maxTasks | number | 20; the scrollback keeps this many tasks, newest first; past it the oldest finished task is dropped, never a running one | | footer | ReactNode | under the input (a tool-use switch, a model picker) | | renderers | PartRenderers | part renderers for every task's thread: override a built-in card, or render custom parts by their kind | | onTasksChange | (tasks: AssistantTask[]) => void | after every change | | id | string | base for the ids: the root is id, the panel ${id}-panel, the trigger ${id}-trigger | | className / panelClassName / triggerClassName | string | panel layer / panel / trigger button |

RunSummary is { summary: ReactNode; apply?: { label: string; run: () => void } }. RunResult is RunSummary | AsyncIterable<AssistantEvent> | Promise<either> (RunOutput is the non-promise half). AssistantTask is { id: string, label, status: 'running' | 'done' | 'error' | 'stopped', parts: MessagePart[], result?, error? }. Every run / onAsk receives { signal } (RunOptions), aborted when the human presses Stop on the task or the dock unmounts (closing the panel leaves a task running); Stop also returns a streaming iterator, and the parts that arrived stay on the card. A rejected run, or an error event, is a failed task showing the message. Failed and stopped tasks keep a Retry button that calls the same run with a fresh signal. Focus moves into the panel on open (the input, else the first suggestion, else the panel); Escape in the panel closes it and returns focus to the trigger, except in a text field inside a task (an approval's type-to-confirm input keeps Escape); Enter in the input submits.

Phones: below sm (640px) the panel is a sheet across the bottom of the viewport (the top, for top-* positions) — full width, capped at 85dvh, padded past the safe-area inset, with a close button (assistant-dock-close) in its header, since the sheet covers the trigger. width, height and resizable apply from sm up; the trigger clears the home indicator and keeps a 44px target on touch screens.

Parts: AssistantDock.Trigger and AssistantDock.Panel read the root's context and throw outside <AssistantDock>; AssistantDock.Task ({ task, onStop?, onRetry?, onApprovalDecision?, onDecisionsChange?, renderers?, className? }) is a plain presentational card reusable in your own thread. Slots: assistant-dock (root, data-position, data-strategy, data-open, data-modal), assistant-dock-trigger-layer, assistant-dock-trigger, assistant-dock-panel-layer, assistant-dock-panel (data-resizable), assistant-dock-resize, assistant-dock-header, assistant-dock-title, assistant-dock-header-badge, assistant-dock-close (phones only), assistant-dock-body, assistant-dock-suggestions, assistant-dock-suggestion, assistant-dock-tasks, assistant-dock-task (data-status), assistant-dock-task-label, assistant-dock-task-status, assistant-dock-task-parts, assistant-dock-task-stop, assistant-dock-task-result, assistant-dock-task-apply, assistant-dock-task-error, assistant-dock-task-retry, assistant-dock-input, assistant-dock-footer.

Thread — @zuilib/ai/thread

const thread = useThread()
<Thread messages={thread.messages} onApprovalDecision={(id, outcome) => thread.decideApproval(id, outcome)} onDecisionsChange={(id, decisions) => thread.decideDiff(id, decisions)} />
<Thread messages={messages} renderers={{ text: MyMarkdown }} />

| Prop | Type | Default | |------|------|---------| | messages | Message[] | required | | renderers | PartRenderers | overrides of DEFAULT_PART_RENDERERS by part type; any other key renders custom parts of that kind | | renderUnknownPart | (part: CustomPart) => ReactNode | rendered for a custom part whose kind has no renderer; without it the part is skipped | | onApprovalDecision | (id, outcome: 'approved' \| 'rejected', message) => void | an approval part was decided | | onDecisionsChange | (id, decisions, accepted: string, message) => void | a diff part's decisions changed | | roleLabels | Partial<Record<role, string>> | You / Assistant / System / Tool, the article labels | | emptyState | ReactNode | shown when there are no messages | | children | (items: { id, node }[]) => ReactNode | render your own list (a virtualiser) from the already-wrapped messages | | messageClassName / className | string | every message / the root |

The default map: textStreamingMarkdown, tool-callToolCallCard (fed by its tool-result, which renders nothing itself), approvalApprovalCard, diffDiffReview, citationCitationList. A custom part is looked up by its kind instead of its type, so renderers={{ chart: ChartCard }} adds a part type without touching the union; with no renderer for the kind the part is skipped, or handed to renderUnknownPart. The built-in type keys are reserved (BUILTIN_PART_TYPES): a custom part whose kind matches one is treated as unknown, never rendered by the built-in card. The root is role="log"; each message is an <article> (data-role) wrapping every part in thread-part (data-type, and data-kind on a custom part). Messages are keyed by id and memoised (Thread.Message), parts by a stable internal key (the part's id, else type and index), so a stream re-renders only the message it appends to; a custom renderer reads the callbacks with useThreadCallbacks(). Slots: thread, thread-empty, thread-message, thread-part.

Hooks — @zuilib/ai/use-thread and friends

  • useThread({ initialMessages? }){ messages, streaming, error, appendUserMessage(text), startAssistantMessage(), applyEvent(event), consume(run, { signal? }), decideApproval(id, outcome), decideDiff(id, decisions), reset(), dispatch }: a reducer over AssistantEvents (threadReducer from @zuilib/ai/thread-reducer). consume opens an assistant message and drives a run through consumeRun; an approval event waits for decideApproval.
  • consumeRun(run, apply, { signal?, onEvent?, waitForApproval }) (@zuilib/ai/consume-run) drives any RunResult and resolves with the done summary. Abort returns the iterator and resolves, even while an approval is still undecided; an error event returns the iterator too (so an SSE reader is released) and then rejects.
  • useToolCall(run) (@zuilib/ai/use-tool-call) → { status, args, result, error, elapsedMs, start(args), abort(), reset() } for a client-side tool, props-compatible with ToolCallCard. abort() cancels the run in flight and returns the status to queued (keeping args); reset() also clears the args and result.
  • useApproval({ onApprove?, onReject? }) (@zuilib/ai/use-approval) → { decision, busy, error, approve(), reject(), reset() }.
  • useDiffDecisions(hunks, original?, initial?) (@zuilib/ai/use-diff-decisions) → { decisions, accept(i), reject(i), acceptAll(), rejectAll(), reset(), undecided, accepted, complete }.

Adapters — @zuilib/ai/adapters/*

Types only and duck-typed: nothing is imported from ai or @anthropic-ai/sdk. Subpath-only — none of these reach the root barrel.

  • ai-sdk: fromAISDKUIMessage(message) / fromAISDKUIMessages map a UIMessage (v5 parts; v4 content and tool-invocation tolerated) to a ZUI Message; fromAISDKStream(chunks) maps UI message stream chunks (text-delta, tool-input-start, tool-input-available, tool-output-available, tool-output-error, source-url, error, finish) or v4 protocol lines (0:"…", 9:{…}, a:{…}, 3:"…", d:{…}) to events; createAISDKMapper() is the stateful chunk → events function; parseUIMessageStreamLine the v4 line parser. Sources are gathered into one citation event before done.
  • anthropic: fromAnthropicStream(events) maps content_block_start / content_block_delta (text_delta, input_json_delta) / content_block_stop / message_stop / error; a tool_use block is a queued tool-call at start and a running one with parsed args at stop. anthropicToolResult(toolUseId, result, { error?, elapsedMs? }) is the event to push after your app ran the tool. fromAnthropicMessage(message) maps a finished Message (or a user turn with tool_result blocks). createAnthropicMapper() is the stateful mapper.
  • sse: parseSSE(source) reads a Response, a ReadableStream<Uint8Array> or an iterable of strings / bytes into { event?, data, id?, retry? } messages; fromSSE(source, map?) maps them to events. defaultSSEMap: [DONE]done; an error event → error; JSON with a known type (the ASSISTANT_EVENT_TYPES list from @zuilib/ai/stream-events, checked against the union so a new variant cannot silently drop here) is the event as is — custom included, so a server streams {"type":"custom","kind":"chart","data":…} with no custom map; JSON delta / text / content strings, a JSON string, or plain text under no event / text / message → a text delta; anything else is skipped.

StreamingMarkdown — @zuilib/ai/streaming-markdown

<StreamingMarkdown content={answerSoFar} streaming={!done} />
<StreamingMarkdown content={full} charactersPerSecond={160} sources={sources} />
const { text, done } = useStreamingText(source, { enabled: true, charactersPerSecond: 80 })

| Prop | Type | Default | |------|------|---------| | content | string | required, the markdown so far; append as tokens arrive | | streaming | boolean | false; caret after the last block, aria-busy, data-streaming | | charactersPerSecond | number | reveal content at this many characters per second (a demo, smoothing a chunky stream); the caret stays until the reveal catches up | | onLinkClick | (event, href: string) => void | called with the sanitised href; preventDefault() to route yourself | | linkComponent | ComponentType<{ href, className, 'data-slot', children }> | renders every link (a router Link) instead of <a target="_blank"> | | sources | CitationSource[] | [n] / [^n] markers render as Citation chips over sources[n - 1]; a marker with no source stays text; without the prop markers are plain text | | citationAnchor | PopoverAnchor \| false | 'top'; false renders the popover in place | | announce | boolean | true; a visually hidden role="status" region receives each completed sentence, then completeLabel | | announceDelay | number | 400 ms a completed sentence waits before it is announced | | completeLabel | string | 'Response complete' |

Renders ATX and setext headings, paragraphs, bold / italic / inline code, links, images (<img loading="lazy">; an unsafe src renders the alt text), fenced and indented code (data-language), nested ordered / unordered / task lists (an item's nested content is ListItem.blocks), tables with column alignment, blockquotes and rules; no raw HTML. The parser (parseMarkdown(source, { partial, citations }), parseMarkdownIncremental, parseInline(source, { citations }), inlineToText from @zuilib/ai/markdown-parser, pure) tolerates an unfinished document: with partial (set while busy) a lone | header | row is already a table and an unmatched trailing ** / ` / [ / ![ is plain text; once the stream ends a header row without its separator is a paragraph, as in GFM. Blocks are keyed by character offset and parsed incrementally from the last committed parse (read in render, written in an effect), so settled blocks keep their DOM.

The document is never a live region: announcing a growing tree re-reads it. Instead the status region speaks each sentence (., !, ? before whitespace, or a line break) once, debounced, as plain text. useStreamingText runs one interval for the life of the hook (per enabled / charactersPerSecond) and reads the latest source through a ref, so a growing source never restarts it; a source that is not an extension of the previous one restarts the reveal.

Links are untrusted model output: safeHref (@zuilib/ai/safe-href) allows http, https, mailto, tel and relative / #anchor targets; any other scheme (javascript:, data:, file:, protocol-relative //host) renders the link text as plain text with no href (streaming-markdown-link-text); the same filter applies to image src. A safe link is <a target="_blank" rel="noreferrer noopener">.

Slots: streaming-markdown (root), -heading, -paragraph, -code, -pre, -list, -list-item, -table-wrap, -table, -blockquote, -rule, -link, -link-text, -image, -image-text, -status, -caret; citation chips carry the citation slots.

ToolCallCard — @zuilib/ai/tool-call-card

<ToolCallCard name="crm.search" status="success" args={{ query: 'vantage' }} result={{ id: 'ACC-1051' }} elapsedMs={340} />
<ToolCallCard name="crm.update" status="error" args={{ id: 'ACC-1051' }} error="403: write access requires approval" />

| Prop | Type | Default | |------|------|---------| | name | string | required | | description | ReactNode | one line under the name | | args | unknown | a flat object of primitives renders as key / value rows, anything else as pretty JSON | | status | 'queued' \| 'running' \| 'success' \| 'error' | required; badge + icon; running sets aria-busy | | result | unknown | string / number as text, element as is, else JSON; shown on success, or while running as a partial | | maxResultLength | number | 20000 characters of a text / JSON result before a Show all toggle; the result section carries data-truncated while cut | | error | ReactNode | shown on error | | elapsedMs | number | milliseconds, formatted 340ms / 1.2s | | defaultOpen | boolean | open on error only; a later transition to error also opens the card unless the human has toggled it or defaultOpen is set | | open / onOpenChange | boolean / (open) => void | controlled | | statusLabels | Partial<Record<status, string>> | Queued / Running / Done / Failed |

The header is a disclosure <button> (aria-expanded; aria-controls while the body exists). A closed body is unmounted, not hidden, so nothing inside it can take focus or be read. ToolCallValue({ value, slot }) and formatDuration(ms) are exported. Slots: tool-call-card (root, data-status, data-state), -header, -status-icon, -name, -description, -status, -duration, -chevron, -body, -args, -args-value, -result (data-truncated), -result-value, -result-toggle, -error.

ApprovalCard — @zuilib/ai/approval-card

<ApprovalCard title="Email the Vantage sponsor" description="A short note before the renewal." risk="medium" preview={<p>{draft}</p>} onApprove={send} onReject={dismiss} onEdit={openEditor} />
<ApprovalCard title="Roll back notifier" risk="high" expiresAt={Date.now() + 600_000} onApprove={run} onReject={skip} />
<ApprovalCard title="Delete the workspace" risk="high" confirm="type" confirmationPhrase="DELETE" onApprove={purge} onReject={skip} />

| Prop | Type | Default | |------|------|---------| | title | ReactNode | required | | description / preview | ReactNode | the proposal: a draft, a diff, a table | | risk | 'low' \| 'medium' \| 'high' | 'low'; badge, border tint; high puts the approve button in the danger tone and defaults confirm to 'double' | | confirm | 'none' \| 'double' \| 'type' \| 'hold' | 'double' for high, else 'none' | | confirmationPhrase / holdMs | string / number | 'APPROVE' / 1200 ms | | onApprove / onReject | () => void \| Promise<unknown> | required; a returned promise holds the card busy until it settles | | onEdit | () => void | adds an Edit button | | busy | boolean | false; approve spins, the others lock, keys ignored | | decision | 'undecided' \| 'approved' \| 'rejected' \| 'expired' | 'undecided'; a decided card adds the outcome badge, locks its buttons and the keys, sets data-decision | | expiresAt | Date \| number \| string | past it the card shows as expired on its own | | labels | Partial<ApprovalCardLabels> | every text on the card, one key at a time: approve, reject, edit, confirm (the armed button), busy (announced with the spinner), riskLow / riskMedium / riskHigh, approved / rejected / expired. Defaults in DEFAULT_APPROVAL_CARD_LABELS |

Confirmation: double arms the approve button on the first activation (its label becomes labels.confirm, data-armed; Escape, blur or five seconds disarm it) and approves on the second; type shows a field (approval-card-confirm-input) and enables Approve only when it holds confirmationPhrase (Enter in the field approves); hold approves only after the button was held (pointer or Space) for holdMs, with a progress bar (approval-card-hold) while held. High risk never approves on a single Enter.

Keyboard: the root is a focusable role="group" labelled by its title and described by the description and the visually hidden hint. With focus anywhere inside it, Enter activates approve and Escape rejects, except: a text field (input, textarea, select, contenteditable) and a nested popup (dialog, menu, listbox, combobox) keep both keys; a button keeps Enter for itself and Escape still rejects. A rejecting Escape is preventDefaulted and stopped, so an enclosing dialog does not close on it. Slots: approval-card (root, data-risk, data-confirm, data-armed, data-busy, data-decision), -header, -title, -description, -risk, -decision, -preview, -confirm-input, -actions, -edit, -reject, -approve (data-armed, data-holding), -hold, -hint.

DiffReview — @zuilib/ai/diff-review

<DiffReview original={document} modified={proposal} onDecisionsChange={(decisions, text) => setAccepted(text)} />
<DiffReview mode="split" original={before} modified={after} originalLabel="Current" modifiedLabel="Assistant" />
<DiffReview patch={unifiedDiffFromServer} onAcceptHunk={apply} />
<DiffReview original={a} modified={b} computeDiff={(request) => diffWorker.run(request)} />

| Prop | Type | Default | |------|------|---------| | original / modified | string | the two texts | | hunks | DiffHunk[] | precomputed hunks (diffHunks, or your own); wins over patch and the texts | | patch | string | a unified diff, parsed with parsePatch | | computeDiff | (request: DiffComputeRequest) => Promise<DiffHunksResult> | computes the diff asynchronously (typically a Web Worker behind a promise); the card is data-loading / aria-busy until it resolves | | mode | 'unified' \| 'split' | 'unified' | | context | number | 3 equal lines around each change | | originalLabel / modifiedLabel | string | 'Original' / 'Proposed' | | decisions | Record<number, 'accepted' \| 'rejected'> | by hunk index; controlled. Omit it and the component keeps the decisions itself | | onDecisionsChange | (decisions, accepted: string) => void | every change, controlled or not, with the text the decisions produce | | onAcceptHunk / onRejectHunk | (index: number, hunk: DiffHunk) => void | per-hunk buttons appear when either, or onDecisionsChange, is given | | onAcceptAll / onRejectAll | () => void | header buttons appear when either, or onDecisionsChange, is given | | maxLines | number | 5000 lines per side handled by the exact LCS table; beyond it the Myers walk runs | | hardMaxLines | number | 200000 lines per side beyond which the diff is one replace hunk and truncatedNotice shows | | truncatedNotice | ReactNode | 'This diff is too large…' | | lineNumbers | boolean | true |

Without computeDiff, the diff runs on deferred inputs (useDeferredValue): an urgent update (typing into the texts) renders with the previous hunks first and the recomputation follows; the root carries data-loading meanwhile. The accepted text is applyDecisions(original, hunks, decisions) when original is known and applyHunkDecisions(hunks, decisions) (the hunks' own lines) for hunks / patch.

The diff helpers are re-exported here and from @zuilib/ai/diff-engine: diffLines(a, b, options), diffHunks(a, b, context, options), the …WithInfo variants returning { lines | hunks, truncated, algorithm: 'lcs' | 'myers' | 'replace' }, myersDiff(aLines, bLines) (linear-space Myers O(ND): a minimal edit script in O(N + M) memory, an explicit work stack, removals listed before additions within a run), parsePatch(text), toHunks, toSplitRows, applyDecisions, applyHunkDecisions, DEFAULT_MAX_DIFF_LINES, DEFAULT_HARD_MAX_DIFF_LINES. In a container narrower than 384px (Tailwind's @sm container breakpoint: a phone column, an assistant dock) mode="split" renders the unified layout instead; data-mode reports the layout actually shown. Slots: diff-review (root, data-mode, data-algorithm, data-loading), -header, -summary, -actions, -accept-all, -reject-all, -truncated, -empty, -hunk (data-decision), -hunk-header, -hunk-decision, -hunk-accept, -hunk-reject, -table, -row, -cell (data-type).

StructuredOutputForm — @zuilib/ai/structured-output-form

const fields: StructuredField[] = [
  { name: 'company', label: 'Company', type: 'string', required: true },
  { name: 'seats', label: 'Seats', type: 'number' },
  { name: 'term', label: 'Term', type: 'enum', options: ['12', '24', '36'] },
  { name: 'enterprise', label: 'Enterprise plan', type: 'boolean' },
]
<StructuredOutputForm fields={fields} value={value} onValueChange={setValue} errors={errors} onSubmit={save} />

| Prop | Type | Default | |------|------|---------| | fields | StructuredField[] | required: { name, label, type, options?, description?, required?, placeholder? }; type is 'string' \| 'number' \| 'boolean' \| 'enum' or any name a renderField handles; enum options are strings or { value, label } | | value | Record<string, string \| number \| boolean \| null \| undefined> | required, controlled | | onValueChange | (value, changedField: string) => void | required: the next whole value and the field that changed | | onSubmit | (value) => void | | | generated | string[] | every field with a value on mount: the fields wearing the Generated badge until edited | | onFieldChange | (name: string) => void | first edit of a generated field | | errors | Partial<Record<string, ReactNode>> | validation messages by field name, rendered under the control as a FieldError; the field is marked invalid through Field | | renderField | (props: StructuredFieldControlProps) => ReactNode \| undefined | called for every field first: return a control ({ field, value, onValueChange, invalid, generated }) to take the field over — a date picker, a currency input — inside the standard Field, or undefined to keep the built-in | | submitLabel / generatedLabel | string | 'Use these values' / 'Generated' | | footer | ReactNode | before the submit button | | showSubmit | boolean | true |

Each field is the matching control (Input, NumberInput, Switch, NativeSelect) inside a Field, so labels, descriptions, ids and aria-describedby are wired; an unknown type with no renderField falls back to the text input, so a schema from a newer producer stays editable. Slots: structured-output-form (root <form>), -field (data-type, data-generated), -generated, -error, -footer, -submit.

Citation — @zuilib/ai/citation

<p>At-risk MRR fell 8.5% <Citation index={1} source={sources[0]} />.</p>
<CitationList sources={sources} />

| Prop | Type | Default | |------|------|---------| | index | number | required, the number in the chip | | source | CitationSource | required: { id?, title, url?, snippet?, origin? } | | anchor | PopoverAnchor \| false | 'top'; false renders the panel in place under the chip (keeps a subtree theme; an anchored panel portals to <body>) | | className | string | merged onto the chip |

The chip is a real button named "Source n: title" opening a small Popover with the title (a link, target="_blank", when url is given), origin and snippet. The root is a <span> and the panel holds no block elements, so it is phrasing content and sits inside a <p> without a hydration warning. CitationList (Citation.List) takes sources: CitationSource[], title ('Sources') and className. Slots: citation (root), citation-chip, citation-panel, citation-index, citation-title, citation-origin, citation-snippet, citation-list, citation-list-title, citation-list-items, citation-list-item.

Reasoning — @zuilib/ai/reasoning

<Reasoning detail="Comparing usage against last quarter" />

| Prop | Type | Default | |------|------|---------| | label | ReactNode | 'Working on it' | | detail | ReactNode | what the model is doing right now, under the label | | size | 'sm' \| 'md' | 'md' | | icon | ReactNode | the spark; replaces it |

A role="status" aria-live="polite" region with a pulsing spark and three bouncing dots on the tokens keyframes, still under prefers-reduced-motion. Slots: reasoning (root, data-size), -icon, -text, -label, -detail, -dots, -dot.

Message model — @zuilib/ai/message-parts

Pure types shared by the widgets: Message is { id, role: 'user' | 'assistant' | 'system' | 'tool', createdAt?, parts: MessagePart[] } and MessagePart is a discriminated union whose members carry the props of the card that renders them: TextPart { type: 'text', text, streaming? }StreamingMarkdown; ToolCallPart { type: 'tool-call', id, toolName, args?, status, description? } and ToolResultPart { type: 'tool-result', callId, result?, error?, elapsedMs? }ToolCallCard; CitationPart { type: 'citation', sources }CitationList; ApprovalPart { type: 'approval', id, title, description?, risk?, confirm?, preview?, decision?, expiresAt? }ApprovalCard; DiffPart { type: 'diff', id, original?, modified?, hunks?, patch?, originalLabel?, modifiedLabel?, decisions? }DiffReview; CustomPart { type: 'custom', kind, id?, data? } → the PartRenderers entry registered under kind (the open variant: the literal type keeps the union discriminated, kind carries the extension). @zuilib/ai/thread-reducer holds the pure folding: applyEvent(parts, event), finalizeStreamingText, decideApproval, decideDiff, toolResultFor, threadReducer, createMessageId and EMPTY_THREAD.

Icons (SparkIcon, ToolIcon, PencilIcon, ExternalLinkIcon, SendIcon, StopIcon, PaperclipIcon) are at @zuilib/ai/icons.