# Extension UI

> Add UI to the zidane apps as data with ctx.add, keep it live with handles and bound stores, and see every UI token.

An extension adds UI as data. `ctx.add(Token, item)` hands an item to every app, and each app draws it with its own widgets: the terminal app with text, the desktop and web apps with the zidane-ui kit. The call returns a handle that updates or withdraws the item. A field can follow a store, so the item changes on its own.

Use data items for most UI: a status line, a badge, a panel, a renderer for a code block. They work in every app with no extra code. When the data is not enough, an item names a component from the extension's `ui.tsx` (desktop and web) and `tui.tsx` (terminal) ([components](https://zidane.si/docs/extensions-components.md)). For a whole page with its own HTML, use a [webview](https://zidane.si/docs/extensions-webview.md).

| You want | Use | Page |
| - | - | - |
| Text, a count or a badge in a fixed place | A `Slot` item | [Placements](https://zidane.si/docs/extensions-placements.md#slots) |
| A side tab or a block on the project home | A `Panel` item | [Placements](https://zidane.si/docs/extensions-placements.md#panels) |
| A full-screen view or a modal | A `Screen` item | [Placements](https://zidane.si/docs/extensions-placements.md#screens-and-modals) |
| A space of sessions, a sidebar filter, sort or grouping, data on session rows, rows of your own | `Space`, `SidebarFilter`, `SidebarSort`, `SidebarGroup`, `SidebarDecoration`, `SidebarRow` | [Sidebar](https://zidane.si/docs/extensions-sidebar.md) |
| Your own drawing of a code block, a tool result or an artifact | A `Renderer` item | [Placements](https://zidane.si/docs/extensions-placements.md#renderers) |
| A question to the user | `ctx.ui.confirm`, `prompt`, `select`, `form` | [Placements](https://zidane.si/docs/extensions-placements.md#dialogs) |
| Open some links your own way | A `LinkHandler` item | [Placements](https://zidane.si/docs/extensions-placements.md#links) |
| Settings pages and a group of your own in the settings sidebar | `pages` and `category` in the settings schema, `SettingsPage` items | [Settings](https://zidane.si/docs/extensions-settings.md#pages-and-the-settings-sidebar) |
| React components in place of the data | `component` on any item | [Components](https://zidane.si/docs/extensions-components.md) |
| A page with its own HTML and scripts | A `Webview` item | [Webview](https://zidane.si/docs/extensions-webview.md) |

## A first item

The kitchen-sink example shows its counter in the statusbar (`examples/extension-kitchen-sink/ui-items.ts`). The text follows a store, and a click runs a command:

```ts title="index.ts"
import { defineExtension, Slot } from 'zidane'
import { z } from 'zod'

export default defineExtension({
  apiVersion: 1,
  id: 'counter',
  setup(ctx) {
    const counter = ctx.storage.store('counter', z.object({ count: z.number() }), { count: 0 }, { scope: 'project' })

    ctx.commands.register({
      id: 'bump',
      title: 'Bump the counter',
      run: () => counter.set(c => ({ count: c.count + 1 })),
    })

    ctx.add(Slot, {
      at: 'app.statusbar',
      text: counter.select(c => `count ${c.count}`),
      title: 'Click to bump',
      command: 'counter.bump',
    })
  },
})
```

Each `counter.set` changes the store, the bound `text` reads it again, and every app redraws the item. No code calls `update`.

## `ctx.add` and handles

`ctx.add` takes a token and an item, and returns a `Handle`:

| Member | Type | Does |
| - | - | - |
| `current` | `Readonly<T>` | The item as the extension last gave it, bound fields still bound |
| `update` | `(patch: Partial<T> \| (current) => T) => void` | Merges `patch` into the item, or replaces it with the function's result. The apps redraw only when the result differs |
| `dispose` | `() => void` | Withdraws the item. A second call does nothing |

```ts
import type { Handle, SlotItem } from 'zidane'
import { Slot } from 'zidane'

let line: Handle<SlotItem> | undefined

function progress(text: string | null): void {
  if (text === null) {
    line?.dispose()
    line = undefined
    return
  }
  if (line)
    line.update({ text })
  else
    line = ctx.add(Slot, { at: 'app.statusbar', text, loader: true })
}
```

This is the pattern of the in-repo `ai-autocomplete` and `goal` extensions: one handle per line, updated in place, disposed when the work ends.

An item lives as long as the owner it was added through. When the extension unloads, or a [scope](https://zidane.si/docs/extensions-lifecycle.md#scopes) is disposed, its items go. `ctx.add` through a disposed owner throws `DisposedError`.

The rules the registry checks when you add or update an item:

| Rule | Error |
| - | - |
| The token must be one of the tokens below, and the item an object | `TypeError` |
| Fields must be valid: a known slot name, a `#rgb` color, a component name that is an identifier, props that survive JSON | `TypeError` |
| `id` is unique per extension for `Panel`, `Screen`, `SettingsPage`, `Webview`, `Reference`, `BadgeAction`, `LinkHandler`, `ThemeToken`, a `ContextSection` panel and the six sidebar tokens | `TypeError` on the second `ctx.add` |
| Every session query, sort, grouping and template of a sidebar item is valid | `UiQueryError` (a `SessionQueryError`) naming the path of each problem |
| `update` cannot change an item's `id`, its slot (`at`) or its `sessionId` | `TypeError`. Dispose the item and add a new one |
| An extension shows at most 8 `composer.badges` items per session, and 8 `app.titlebar` items | `RangeError` on the ninth |
| An extension shows at most 32 `Space`, 64 `SidebarFilter`, 32 `SidebarSort`, 32 `SidebarGroup`, 64 `SidebarDecoration` and 500 `SidebarRow` items (`SIDEBAR_CAPS`) | `RangeError` on the one past the cap |

`Slot`, `Renderer` and `Menu` items have no id: add as many as you need. For `Reference`, `BadgeAction` and `ThemeToken`, ids are shared by every extension and the first one loaded wins. Prefix them with your extension id (`kitchen-sink.ticket`).

## Bound fields

A field typed `Bindable<T>` takes a value or a `Readable<T>`: an object with `get()` and `subscribe(listener)`. The registry reads each bound field when it sends the item, and subscribes to it. A change sends the item again.

| Member | Type | Does |
| - | - | - |
| `get` | `() => T` | The current value |
| `subscribe` | `(listener: (value: T) => void) => () => void` | Calls `listener` after each change. Returns the unsubscribe |

A [store](https://zidane.si/docs/extensions-storage.md#typed-stores) is a `Readable`, and `store.select(pick)` gives a `Readable` over part of it. Both fit any bindable field:

```ts
import { Menu, Slot } from 'zidane'
import { z } from 'zod'

const counter = ctx.storage.store('counter', z.object({ count: z.number() }), { count: 0 }, { scope: 'project' })
const count = counter.select(c => c.count)

ctx.add(Slot, { at: 'composer.below', text: counter.select(c => `counter ${c.count}`) })
ctx.add(Menu, { menu: 'tray', label: counter.select(c => `Counter: ${c.count}`), command: 'counter.bump' })
ctx.add(Slot, { at: 'composer.badges', sessionId: 'session-1', text: 'counter', count })
```

Any object with `get` and `subscribe` works. A value that comes from outside a store:

```ts
import type { Readable } from 'zidane'
import { Slot } from 'zidane'

function clock(): Readable<string> {
  const listeners = new Set<(value: string) => void>()
  const now = () => new Date().toLocaleTimeString()
  const timer = setInterval(() => listeners.forEach(l => l(now())), 1000)
  ctx.onCleanup(() => clearInterval(timer))
  return {
    get: now,
    subscribe: (listener) => {
      listeners.add(listener)
      return () => listeners.delete(listener)
    },
  }
}

ctx.add(Slot, { at: 'app.statusbar', text: clock() })
```

The fields you can bind:

| Item | Bindable fields |
| - | - |
| `SlotItem` | `text`, `lines`, `count`, `tone`, `loader`, `highlight`, `title` |
| `ScreenItem`, `SettingsPageItem`, `WebviewItem` | `title` |
| `MenuItem` | `label`, `enabled` |
| `SpaceItem` | `name`, `query`, `groupBy`, `sortBy`, `newSession` |
| `SidebarFilterItem` | `label`, `query`, `options` |
| `SidebarSortItem` | `label`, `sortBy` |
| `SidebarGroupItem` | `label`, `groupBy` |
| `SidebarDecorationItem` | `where`, `icon`, `tone`, `subtitle`, `chips` |
| `SidebarRowItem` | `title`, `subtitle`, `icon`, `tone`, `status`, `time`, `badges`, `children` |
| `CommandSpec` ([commands](https://zidane.si/docs/extensions-commands.md)) | `title`, `description`, `enabled` |

Only top-level fields bind. A `Readable` nested in an object (inside `component.props`, inside a panel's content) is not read. A panel follows changes through its own `subscribe` instead ([panels](https://zidane.si/docs/extensions-placements.md#panels)).

## Tokens

Every UI token comes from `zidane`. Each one names one kind of item:

| Token | Item | Adds | Page |
| - | - | - | - |
| `Slot` | `SlotItem` | Text, lines, a count or a badge in one of twelve named places | [Slots](https://zidane.si/docs/extensions-placements.md#slots) |
| `Panel` | `PanelItem` | A side panel tab, a block on the project home, or both | [Panels](https://zidane.si/docs/extensions-placements.md#panels) |
| `Screen` | `ScreenItem` | A full-screen view or a modal, opened with `ctx.ui.navigate` | [Screens](https://zidane.si/docs/extensions-placements.md#screens-and-modals) |
| `Renderer` | `RendererItem` | A component for a custom block, a code-block language, a tool's result or an artifact kind | [Renderers](https://zidane.si/docs/extensions-placements.md#renderers) |
| `SettingsPage` | `SettingsPageItem` | A page among the extension's pages in the settings sidebar | [Settings pages](https://zidane.si/docs/extensions-placements.md#settings-pages) |
| `Webview` | `WebviewItem` | A sandboxed web page in a panel or a screen | [Webview](https://zidane.si/docs/extensions-webview.md) |
| `Menu` | `MenuItem` | A native menu entry in the desktop app | [Menus](https://zidane.si/docs/extensions-placements.md#menus) |
| `ThemeToken` | `Theme` | A color theme the user can pick | [Themes](#themes) |
| `Reference` | `ExtensionReferenceProvider` | Chips for ticket ids and URLs found in text | [References](#references) |
| `BadgeAction` | `ExtensionBadgeActions` | Context-menu entries on badges, links, files and artifacts | [Badge actions](#badge-actions) |
| `ContextSection` | `ContextSectionItem` | Rows in the context breakdown or the context panel | [Context sections](#context-sections) |
| `LinkHandler` | `LinkHandlerItem` | Opens matching links before the app does | [Links](https://zidane.si/docs/extensions-placements.md#links) |
| `Space` | `SpaceItem` | A named view of the sessions a query matches, in the space switcher | [Sidebar](https://zidane.si/docs/extensions-sidebar.md#spaces) |
| `SidebarFilter` | `SidebarFilterItem` | A filter the user turns on in the sidebar | [Sidebar](https://zidane.si/docs/extensions-sidebar.md#filters-sorts-and-groupings) |
| `SidebarSort` | `SidebarSortItem` | A sort order the user can pick in the sidebar | [Sidebar](https://zidane.si/docs/extensions-sidebar.md#filters-sorts-and-groupings) |
| `SidebarGroup` | `SidebarGroupItem` | A grouping the user can pick in the sidebar | [Sidebar](https://zidane.si/docs/extensions-sidebar.md#filters-sorts-and-groupings) |
| `SidebarDecoration` | `SidebarDecorationItem` | An icon, a subtitle, chips or a component on matching session rows | [Sidebar](https://zidane.si/docs/extensions-sidebar.md#decorations) |
| `SidebarRow` | `SidebarRowItem` | A row of the extension's own among a space's sessions | [Sidebar](https://zidane.si/docs/extensions-sidebar.md#rows) |

Slots, panels, screens, renderers, settings pages, webviews, menus and link handlers have their own pages, and the six sidebar tokens share [one](https://zidane.si/docs/extensions-sidebar.md). The four below have no place of their own: the apps fold them into what they already draw.

### Themes

`ctx.add(ThemeToken, theme)` adds a theme to the theme picker of every app. A `Theme` spells every color: `id`, `label`, `colors`, `select`, `surfaces` and `syntax`. The kitchen sink's full theme is in `examples/extension-kitchen-sink/theme.ts`. Type it from the token, since no built-in theme is exported to extend:

```ts
import type { ThemeToken } from 'zidane'

type Theme = NonNullable<typeof ThemeToken.__type>
```

Built-in themes win an id clash. Prefix the id with your extension id.

### References

A reference provider finds ids in text (prompts, replies, session titles) and turns each one into a chip. The apps match `patterns`, then call `resolve` for each key. A key that resolves to nothing stays plain text, so a loose pattern does no harm:

```ts
import { Reference } from 'zidane'

ctx.add(Reference, {
  id: 'kitchen-sink.ticket',
  patterns: [{ regex: '\\bKS-(\\d+)\\b', key: '$1' }],
  surfaces: ['prompt', 'assistant'],
  resolve: async key => ({ label: `KS-${key}`, status: 'queued', tone: 'accent', prompt: `kitchen ticket ${key}` }),
})
```

| Field | Type | Does |
| - | - | - |
| `id` | `string` | Required. Shared by every extension: the first one loaded wins |
| `patterns` | `{ regex, key }[]` | Required. Where to find candidates, and how to build the key from the match |
| `surfaces` | `('prompt' \| 'assistant' \| 'title')[]` | Where to look. Default: everywhere |
| `resolve` | `(key, { signal }) => Promise<view \| undefined>` | Required. The chip: `label`, and optional `icon`, `tone`, `color`, `status`, `detail`, `prompt`, `title`, `href`, `card` |

The apps cache each resolved key. When matches overlap, the earliest wins, then the longest. The desktop and web apps draw reference chips. The terminal app does not.

### Badge actions

A badge action adds entries to the right-click menu of badges: links, files, commits, references, artifacts.

```ts
import { BadgeAction } from 'zidane'

ctx.add(BadgeAction, {
  id: 'kitchen-sink.ticket-actions',
  match: { references: ['kitchen-sink.ticket'] },
  actions: () => [{ id: 'serve', label: 'Serve this ticket', group: 'more' }],
  run: async (_actionId, target) => ({ notice: `Served ${target.text}` }),
})
```

| Field | Type | Does |
| - | - | - |
| `id` | `string` | Required. Shared by every extension: the first one loaded wins |
| `match` | `{ kinds?, artifactKinds?, references? }` | Which badges get the entries. Each given list must match. Default: every badge |
| `actions` | `(target) => BadgeAction[] \| undefined` | Required. The entries for one badge: `id`, `label`, `group` (`copy`, `prompt`, `open`, `more`), `shortcut`. Keep it cheap, it runs as the menu opens |
| `run` | `(actionId, target, { sessionId?, cwd?, signal }) => effect` | Required. Returns what the app does next: `copy`, `insert`, `open`, `reveal`, `notice`. A throw shows as a notice |

### Context sections

A context section adds rows to the context breakdown (`placement: 'breakdown'`) or a section to the context panel (`placement: 'panel'`):

```ts
import { ContextSection } from 'zidane'

ctx.add(ContextSection, {
  placement: 'breakdown',
  contributor: () => ({ id: 'kitchen', label: 'Kitchen sink', rows: [{ label: 'Counter', value: '3' }] }),
})
ctx.add(ContextSection, {
  placement: 'panel',
  section: { id: 'kitchen', title: 'Kitchen sink', rows: ({ turns }) => [{ label: 'Turns seen', value: String(turns.length) }] },
})
```

A `breakdown` contributor gets the session's turns and returns a section, a list of sections, or nothing. A `panel` section has an `id` (unique per extension), a `title`, an optional `accentColor`, and `rows`, `load` or both.

## Dialogs, panels and the composer

UI that answers a call, rather than an item that stays, lives on `ctx.ui` and `ctx.composer`:

| Call | Does | Page |
| - | - | - |
| `ctx.ui.confirm`, `prompt`, `select`, `form`, `askUser`, `presentPlan` | Asks the user, and resolves with the answer | [Dialogs](https://zidane.si/docs/extensions-placements.md#dialogs) |
| `ctx.ui.toast`, `ctx.ui.notify` | A toast with optional buttons, an OS notification | [Toasts](https://zidane.si/docs/extensions-placements.md#toasts-and-notifications) |
| `ctx.ui.panel.open`, `close`, `toggle`, `state` | Drives the side panel | [Layout](https://zidane.si/docs/extensions-placements.md#panel-and-navigation-control) |
| `ctx.ui.navigate` | Shows a screen, a session or a new session | [Layout](https://zidane.si/docs/extensions-placements.md#panel-and-navigation-control) |
| `ctx.composer.insert`, `setText`, `attach` | Edits the draft in the composer | [Composer](https://zidane.si/docs/extensions-placements.md#composer-control) |
| `ctx.composer.completions`, `autocomplete` | A trigger-character menu, and ghost text | [Composer](https://zidane.si/docs/extensions-placements.md#composer-providers) |
| `ctx.desktop` | Electron, in the desktop app's main process | [Desktop](https://zidane.si/docs/extensions-placements.md#ctxdesktop) |

## Components

Every item that has a `component` field can name an export of the extension's UI entries: `{ component: 'KitchenBoard', props: { compact: true } }`. A `Screen` requires one. The desktop and web apps look the name up in `ui.tsx`, the terminal app in `tui.tsx`, so both files export the same names. An app without the component, or with one that throws, draws the item's data instead ([components](https://zidane.si/docs/extensions-components.md)).

## In each app

The three apps draw the same items in their own places. Some are not drawn everywhere:

| Token | Terminal app | Desktop app | Web app |
| - | - | - | - |
| `Slot` | Every slot, text only (no icons) | Every slot | Every slot. `home.block` only on the welcome screen |
| `Panel` | Side tabs from data. No home blocks, no `component` | Side tabs and home blocks. `component` on side tabs | Side tabs. No project home, so no home blocks |
| `Screen` | Yes | Yes | Yes |
| `Renderer` | Yes | Yes | Yes |
| `SettingsPage` | A tab of Settings | In the settings sidebar | In the settings sidebar |
| `Webview` | Title and a link to open the page in a browser | Yes | Yes |
| `Menu` | No | Yes | No |
| `ThemeToken` | Yes | Yes | Yes |
| `Reference` | No | Yes | Yes |
| `BadgeAction` | In the artifacts and badge menus | Yes | Yes |
| `ContextSection` | Yes | Yes | Yes |
| `LinkHandler` | Rows, badges and webviews. No transcript or terminal links, no Alt bypass | Every link. `Alt` skips the handlers | Every link. `Alt` skips the handlers |
| `Space` | Yes, even with Spaces off | With Spaces on | With Spaces on |
| `SidebarFilter`, `SidebarSort`, `SidebarGroup` | In the sessions list's view modal | In the sidebar's filter menu | In the sidebar's filter menu |
| `SidebarDecoration` | Icon, subtitle and chips. No `component` | Yes | Yes |
| `SidebarRow` | Yes | Yes | Yes |

[Placements](https://zidane.si/docs/extensions-placements.md) has where each item shows in each app.

## Pitfalls

- An app that lacks a place ignores the item. Do not rely on one item to tell the user something important: pair a slot with a toast, or with a command they can run.
- `update` cannot move an item. To show a badge in another session or another slot, dispose it and add a new one.
- A bound value that makes the item invalid (a color that is not `#rgb`) is refused, and the item keeps its last valid form. The extension's log says why.
- `Reference`, `BadgeAction` and `ThemeToken` ids are global. Two extensions with the same id: the second one silently loses.
- Items belong to the owner that added them. Add short-lived items through `ctx.scope()` and dispose the scope, instead of keeping every handle.
