Skip to content

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:

index.ts
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' } })
  },
})
ui.tsx
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>
}
tui.tsx
/** @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:

FieldTypeDoes
componentstringRequired. The export name in ui.tsx and tui.tsx. An identifier, up to 128 characters
propsRecord<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:

ItemProps the app adds
SlotsessionId, in the desktop and web apps, when the item has one
PanelpanelId (desktop and web)
Screen, SettingsPageNone
RendererWhat 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#

ImportVerdict
react, react/jsx-runtime, react-dom, react-dom/clientProvided by the app: the same React instance
zidane-ui/extensions/kitProvided by the app. Import the kit from here
zidane-ui, zidane-ui/extensions/headlessProvided by the web app only. In the desktop app the module fails to load
Other zidane-ui/* pathsLeft out of the bundle, and provided by no app: the module fails to load
zidane and zidane/extensions, as typesFine: types disappear in the bundle
zidane and zidane/extensions, as valuesThe 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
electronRefused. Use ctx.desktop from the extension's index.ts
node:*, bun:*, Node built-insRefused: the entry runs in a browser
A path that leaves the extension's folderRefused (files in node_modules are fine)
Anything else: your files, npm packagesBundled

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:

contract.ts
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):

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:

GroupExports
HooksuseExtension, useRpc, useStore, useSettings
ErrorsExtensionsHostError, extensionsErrorOf
ButtonsbuttonClass, BTN_PRIMARY, BTN_SECONDARY, BTN_DANGER, IconButton, CopyButton
SurfacesRaised, Tray, CodePanel, EmptyState, Meta, SectionLabel, PanelEmpty, PanelLoading, LoadError
Lists and menusPANEL_ROW, PANEL_ROW_HOVER, RowBadge, Menu, MenuRow, MenuSection
Text and statusMarkdown, HighlightedCode, CountBadge, TabPill, Keycap, SegmentSpinner, Tooltip, Segmented
FieldsFormCard, 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#

HookSignatureDoes
useExtension() => { id }The extension the component belongs to. Throws outside an extension component
useRpc<C>(contract: string | { id }) => CA 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:

tui.tsx
/** @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#

ExportMain props
Badgechildren, tone (default 'neutral')
KeyHinthints: { key, label } entries. A click presses the key
EmptyStatetitle, description, children
Listitems ({ id, label, description?, badges?, disabled? }), onSelect, onHighlight, searchable, placeholder, maxVisible (default 10), emptyText, initialId, active. ↑ ↓ move, Enter picks
Formschema and extensionId, values, onSubmit, onCancel, onAction, active. Draws a settings schema. CtrlS submits, Esc cancels
Markdownchildren, dim, streaming
Modaltitle, onClose, disableEscape, fullscreen, maxWidth, sizes and margins
Spinnerlabel
ThemeuseColors, useTheme, useSurfaces, useSelectStyle, useMdStyle

Terminal hooks#

HookSignatureDoes
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 appDesktop appWeb app
Entrytui.tsx, imported in-processui.tsx, bundled by the app's main processui.tsx, bundled by the server and fetched with the session's token
Where the code runsThe app's processThe windowThe browser tab
After an editThe next load of the extension imports it freshMay keep the module it loaded until the window reloadsLoads it again when the extension's folder changes
In daemon modeLoads tui.tsx from the folder the daemon names, on this machineGets the bundle from the daemonGets 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 zidane in ui.tsx bundles, then fails in the app. Import types only, and type RPC clients by hand.
  • Props must be JSON. A function or a Date in component.props is refused at ctx.add.
  • Give useStore in ui.tsx an initial value, and handle undefined in tui.tsx.
  • A store a component reads must be declared by the extension with ctx.storage.store. Without it, the terminal app's set rejects, and the kit shows initial forever.
  • Renderer fallthrough in the terminal app needs a plain function component. Do not wrap it in memo.
  • Do not write .ts or .tsx data files into the extension's folder: the apps take a source change as a reason to reload the extension.