Extension components
Draw extension items with your own React components, ui.tsx for the desktop and web apps and tui.tsx for the terminal app, and know what gets bundled.
A component replaces the data drawing of an item with your own React code. An extension has two optional entries: ui.tsx for the desktop and web apps, drawn with the zidane-ui kit, and tui.tsx for the terminal app, drawn with OpenTUI and the zidane/tui kit. An item names a component by its export name, and each app looks it up in its own entry.
Use a component when the data items cannot show what you need: a chart, a form with its own logic, a live counter with buttons. Keep the data on the item anyway. An app without the component, or with one that fails, draws the data instead. For a page with its own HTML, use a webview.
A minimal example#
The item names CounterStatus, and both entries export it:
import { defineExtension, Slot } from 'zidane'
import { z } from 'zod'
export default defineExtension({
apiVersion: 1,
id: 'counter',
setup(ctx) {
ctx.storage.store('counter', z.object({ count: z.number() }), { count: 0 }, { scope: 'project' })
ctx.add(Slot, { at: 'app.statusbar', text: 'counter', component: { component: 'CounterStatus' } })
},
})import { Meta, useStore } from 'zidane-ui/extensions/kit'
export function CounterStatus() {
const [counter] = useStore<{ count: number }>('counter', { scope: 'project', initial: { count: 0 } })
return <Meta title="Counter">{`count ${counter.count}`}</Meta>
}/** @jsxImportSource @opentui/react */
import { Badge, useStore } from 'zidane/tui'
export function CounterStatus() {
const [counter] = useStore<{ count: number }>('counter', { scope: 'project' })
return <Badge tone="brand">{`count ${counter?.count ?? 0}`}</Badge>
}The store lives in the extension (index.ts). The components read it through useStore, in every window, and stay in sync. Components never import the extension's runtime code: they reach it through stores, settings and RPC.
Naming a component#
component takes a ComponentRef:
| Field | Type | Does |
|---|---|---|
component | string | Required. The export name in ui.tsx and tui.tsx. An identifier, up to 128 characters |
props | Record<string, unknown> | Props passed to the component. They cross processes, so they must survive JSON |
Every item with a component field takes one: Slot, Panel, Screen (required there), Renderer (required there) and SettingsPage. Give both entries the same export names. An entry may skip a component: that app draws the data.
The apps add their own props to yours, and theirs win a clash:
| Item | Props the app adds |
|---|---|
Slot | sessionId, in the desktop and web apps, when the item has one |
Panel | panelId (desktop and web) |
Screen, SettingsPage | None |
Renderer | What it draws: data, code, the tool result or the artifact (renderers) |
The entries#
The definition's ui and tui fields name the entries. Without them, the app takes the first of ui.tsx, ui.ts, ui.jsx, ui.js (and tui.tsx, tui.ts, tui.jsx, tui.js) in the extension's folder. A named entry must sit inside the folder.
./ui resolves to ui.tsx. So keep the extension's own UI code (the ctx.add calls) in another file, like the kitchen sink's ui-items.ts, and never put ui.ts next to ui.tsx.
ui.tsx: desktop and web#
The app bundles ui.tsx into one browser module and loads it next to its own React and zidane-ui. Each component draws behind an error boundary.
Imports#
| Import | Verdict |
|---|---|
react, react/jsx-runtime, react-dom, react-dom/client | Provided by the app: the same React instance |
zidane-ui/extensions/kit | Provided by the app. Import the kit from here |
zidane-ui, zidane-ui/extensions/headless | Provided by the web app only. In the desktop app the module fails to load |
Other zidane-ui/* paths | Left out of the bundle, and provided by no app: the module fails to load |
zidane and zidane/extensions, as types | Fine: types disappear in the bundle |
zidane and zidane/extensions, as values | The bundler lets them through, but no app provides them: the module fails to load |
Any other zidane/* | Refused at bundle time. Reach the backend through useRpc and useStore |
@opentui/* | Refused. Import it from tui.tsx |
electron | Refused. Use ctx.desktop from the extension's index.ts |
node:*, bun:*, Node built-ins | Refused: the entry runs in a browser |
| A path that leaves the extension's folder | Refused (files in node_modules are fine) |
| Anything else: your files, npm packages | Bundled |
The bundler reports every refused import at once. A failed bundle shows on the extension's row in Settings, and its items draw their data.
The rule on values matters for RPC. Take a contract made with Rpc.define (RPC), in its own file:
import { Rpc } from 'zidane'
import { z } from 'zod'
export const KitchenRpc = Rpc.define({
id: 'kitchen-sink',
methods: {
bump: { input: z.object({ by: z.number().int().min(1) }), output: z.object({ count: z.number() }) },
count: { output: z.number() },
reset: { output: z.object({ count: z.number() }), scope: 'admin' },
},
})The contract is a value from zidane, so ui.tsx imports only its type, and types the client by hand (examples/extension-kitchen-sink/ui.tsx):
import type { KitchenRpc } from './contract'
import { buttonClass, useRpc } from 'zidane-ui/extensions/kit'
interface KitchenClient {
bump: (input: { by: number }) => Promise<{ count: number }>
count: () => Promise<number>
reset: () => Promise<{ count: number }>
}
// Fails to compile when the contract loses a method.
const _checked: keyof typeof KitchenRpc.methods extends keyof KitchenClient ? true : never = true
void _checked
export function BumpButton() {
const rpc = useRpc<KitchenClient>('kitchen-sink')
return <button type="button" className={buttonClass({ variant: 'primary' })} onClick={() => void rpc.bump({ by: 1 })}>Bump</button>
}The web kit#
zidane-ui/extensions/kit is the part of zidane-ui an extension may use. The same components draw the apps:
| Group | Exports |
|---|---|
| Hooks | useExtension, useRpc, useStore, useSettings |
| Errors | ExtensionsHostError, extensionsErrorOf |
| Buttons | buttonClass, BTN_PRIMARY, BTN_SECONDARY, BTN_DANGER, IconButton, CopyButton |
| Surfaces | Raised, Tray, CodePanel, EmptyState, Meta, SectionLabel, PanelEmpty, PanelLoading, LoadError |
| Lists and menus | PANEL_ROW, PANEL_ROW_HOVER, RowBadge, Menu, MenuRow, MenuSection |
| Text and status | Markdown, HighlightedCode, CountBadge, TabPill, Keycap, SegmentSpinner, Tooltip, Segmented |
| Fields | FormCard, FormSection, SwitchRow, SliderRow, SegmentedRow, ToggleSwitch, ErrorNote, MetaChip, RowText, INPUT_CLASS, TEXTAREA_CLASS, FIELD_LABEL_CLASS |
The components are documented with the UI kit. The app's theme applies to them, so an extension looks like the app in every theme.
Web hooks#
| Hook | Signature | Does |
|---|---|---|
useExtension | () => { id } | The extension the component belongs to. Throws outside an extension component |
useRpc | <C>(contract: string | { id }) => C | A client for an RPC contract, by id. Each method takes (input?, { signal? }) and returns a promise. Errors reject with an ExtensionsHostError whose type is the declared error name |
useStore | <T>(name, { initial, scope?, sessionId? }) => [T, (value: T) => Promise<void>] | A store the extension declared with ctx.storage.store, live. initial is required: it shows until the value arrives. scope defaults to 'user' |
useSettings | () => { values, locked(key), set(key, value) } | The extension's settings, defaults filled. set rejects for a locked or invalid value |
A store write from a component goes through the same checks as one from the extension, and every window sees it.
tui.tsx: the terminal app#
The terminal app imports tui.tsx in its own process, not bundled. react, @opentui/core, @opentui/react and zidane/tui resolve to the app's live modules, so hooks and the theme work. zidane, zidane/extensions, zidane/extensions, zidane/extensions/testing, zidane/providers, zidane/providers/google and zidane/oauth resolve to the app's copy too. Start the file with the OpenTUI JSX pragma:
/** @jsxImportSource @opentui/react */
import { useState } from 'react'
import { Badge, List, useRpc } from 'zidane/tui'
import { KitchenRpc } from './contract'
export function KitchenBoard() {
const rpc = useRpc(KitchenRpc)
const [error, setError] = useState<string>()
return (
<box style={{ flexDirection: 'column' }}>
<List
items={[{ id: 'bump', label: 'Bump +1' }, { id: 'reset', label: 'Reset' }]}
onSelect={(item) => {
const done = item.id === 'bump' ? rpc.bump({ by: 1 }) : rpc.reset(undefined)
done.then(() => setError(undefined), (e: Error) => setError(e.message))
}}
/>
{error ? <Badge tone="error">{error}</Badge> : null}
</box>
)
}Here useRpc takes the contract itself and returns a typed client. The terminal app may import values from zidane, so no hand-written client is needed.
The terminal kit#
| Export | Main props |
|---|---|
Badge | children, tone (default 'neutral') |
KeyHint | hints: { key, label } entries. A click presses the key |
EmptyState | title, description, children |
List | items ({ id, label, description?, badges?, disabled? }), onSelect, onHighlight, searchable, placeholder, maxVisible (default 10), emptyText, initialId, active. ↑ ↓ move, Enter picks |
Form | schema and extensionId, values, onSubmit, onCancel, onAction, active. Draws a settings schema. CtrlS submits, Esc cancels |
Markdown | children, dim, streaming |
Modal | title, onClose, disableEscape, fullscreen, maxWidth, sizes and margins |
Spinner | label |
| Theme | useColors, useTheme, useSurfaces, useSelectStyle, useMdStyle |
Terminal hooks#
| Hook | Signature | Does |
|---|---|---|
useExtension | () => { id, displayName?, version? } | The extension the component belongs to |
useRpc | (contract) => RpcClient<contract> | A typed client for a contract made with Rpc.define, with events.on and events.subscribe |
useStore | <T>(name, { scope?, sessionId? }?) => [T | undefined, set] | A store the extension declared, live. undefined until it is known: there is no initial. For 'session' scope, sessionId defaults to the session on screen |
useSettings | () => Record<string, value> | The extension's settings by key, live and read-only |
The two kits differ on purpose: each follows its app's host. Wrap the differences in your own small hooks, as the kitchen sink does with useCounter in each file.
Fallbacks and errors#
An app draws the item's data, not the component, while the entry loads, when it failed to bundle or import, when it lacks the export, and when the component throws. What the data is depends on the item: a slot's text, a screen's fallback markdown, a panel's content, a renderer's default view. One failing component does not affect the others.
In each app#
| Terminal app | Desktop app | Web app | |
|---|---|---|---|
| Entry | tui.tsx, imported in-process | ui.tsx, bundled by the app's main process | ui.tsx, bundled by the server and fetched with the session's token |
| Where the code runs | The app's process | The window | The browser tab |
| After an edit | The next load of the extension imports it fresh | May keep the module it loaded until the window reloads | Loads it again when the extension's folder changes |
| In daemon mode | Loads tui.tsx from the folder the daemon names, on this machine | Gets the bundle from the daemon | Gets the bundle from the server |
A terminal window attached to a daemon on another machine has no local folder: its items draw their data.
Pitfalls#
- A value import of
zidaneinui.tsxbundles, then fails in the app. Import types only, and type RPC clients by hand. - Props must be JSON. A function or a
Dateincomponent.propsis refused atctx.add. - Give
useStoreinui.tsxaninitialvalue, and handleundefinedintui.tsx. - A store a component reads must be declared by the extension with
ctx.storage.store. Without it, the terminal app'ssetrejects, and the kit showsinitialforever. - Renderer fallthrough in the terminal app needs a plain function component. Do not wrap it in
memo. - Do not write
.tsor.tsxdata files into the extension's folder: the apps take a source change as a reason to reload the extension.