# 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](https://zidane.si/docs/extensions-webview.md).

## A minimal example

The item names `CounterStatus`, and both entries export it:

```ts title="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' } })
  },
})
```

```tsx title="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>
}
```

```tsx title="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](https://zidane.si/docs/extensions-rpc.md).

## 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](https://zidane.si/docs/extensions-placements.md#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](https://zidane.si/docs/extensions-rpc.md)), in its own file:

```ts title="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`):

```tsx title="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](https://zidane.si/docs/ui-components.md). 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:

```tsx title="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

| 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. `Ctrl`+`S` 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 `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.
