Skip to content

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). For a whole page with its own HTML, use a webview.

You wantUsePage
Text, a count or a badge in a fixed placeA Slot itemPlacements
A side tab or a block on the project homeA Panel itemPlacements
A full-screen view or a modalA Screen itemPlacements
A space of sessions, a sidebar filter, sort or grouping, data on session rows, rows of your ownSpace, SidebarFilter, SidebarSort, SidebarGroup, SidebarDecoration, SidebarRowSidebar
Your own drawing of a code block, a tool result or an artifactA Renderer itemPlacements
A question to the userctx.ui.confirm, prompt, select, formPlacements
Open some links your own wayA LinkHandler itemPlacements
Settings pages and a group of your own in the settings sidebarpages and category in the settings schema, SettingsPage itemsSettings
React components in place of the datacomponent on any itemComponents
A page with its own HTML and scriptsA Webview itemWebview

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:

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:

MemberTypeDoes
currentReadonly<T>The item as the extension last gave it, bound fields still bound
update(patch: Partial<T> | (current) => T) => voidMerges patch into the item, or replaces it with the function's result. The apps redraw only when the result differs
dispose() => voidWithdraws the item. A second call does nothing
TypeScript
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 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:

RuleError
The token must be one of the tokens below, and the item an objectTypeError
Fields must be valid: a known slot name, a #rgb color, a component name that is an identifier, props that survive JSONTypeError
id is unique per extension for Panel, Screen, SettingsPage, Webview, Reference, BadgeAction, LinkHandler, ThemeToken, a ContextSection panel and the six sidebar tokensTypeError on the second ctx.add
Every session query, sort, grouping and template of a sidebar item is validUiQueryError (a SessionQueryError) naming the path of each problem
update cannot change an item's id, its slot (at) or its sessionIdTypeError. Dispose the item and add a new one
An extension shows at most 8 composer.badges items per session, and 8 app.titlebar itemsRangeError 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.

MemberTypeDoes
get() => TThe current value
subscribe(listener: (value: T) => void) => () => voidCalls listener after each change. Returns the unsubscribe

A store is a Readable, and store.select(pick) gives a Readable over part of it. Both fit any bindable field:

TypeScript
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:

TypeScript
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:

ItemBindable fields
SlotItemtext, lines, count, tone, loader, highlight, title
ScreenItem, SettingsPageItem, WebviewItemtitle
MenuItemlabel, enabled
SpaceItemname, query, groupBy, sortBy, newSession
SidebarFilterItemlabel, query, options
SidebarSortItemlabel, sortBy
SidebarGroupItemlabel, groupBy
SidebarDecorationItemwhere, icon, tone, subtitle, chips
SidebarRowItemtitle, subtitle, icon, tone, status, time, badges, children
CommandSpec (commands)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).

Tokens#

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

TokenItemAddsPage
SlotSlotItemText, lines, a count or a badge in one of twelve named placesSlots
PanelPanelItemA side panel tab, a block on the project home, or bothPanels
ScreenScreenItemA full-screen view or a modal, opened with ctx.ui.navigateScreens
RendererRendererItemA component for a custom block, a code-block language, a tool's result or an artifact kindRenderers
SettingsPageSettingsPageItemA page among the extension's pages in the settings sidebarSettings pages
WebviewWebviewItemA sandboxed web page in a panel or a screenWebview
MenuMenuItemA native menu entry in the desktop appMenus
ThemeTokenThemeA color theme the user can pickThemes
ReferenceExtensionReferenceProviderChips for ticket ids and URLs found in textReferences
BadgeActionExtensionBadgeActionsContext-menu entries on badges, links, files and artifactsBadge actions
ContextSectionContextSectionItemRows in the context breakdown or the context panelContext sections
LinkHandlerLinkHandlerItemOpens matching links before the app doesLinks
SpaceSpaceItemA named view of the sessions a query matches, in the space switcherSidebar
SidebarFilterSidebarFilterItemA filter the user turns on in the sidebarSidebar
SidebarSortSidebarSortItemA sort order the user can pick in the sidebarSidebar
SidebarGroupSidebarGroupItemA grouping the user can pick in the sidebarSidebar
SidebarDecorationSidebarDecorationItemAn icon, a subtitle, chips or a component on matching session rowsSidebar
SidebarRowSidebarRowItemA row of the extension's own among a space's sessionsSidebar

Slots, panels, screens, renderers, settings pages, webviews, menus and link handlers have their own pages, and the six sidebar tokens share one. 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:

TypeScript
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:

TypeScript
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}` }),
})
FieldTypeDoes
idstringRequired. 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.

TypeScript
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}` }),
})
FieldTypeDoes
idstringRequired. 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[] | undefinedRequired. 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 }) => effectRequired. 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'):

TypeScript
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:

CallDoesPage
ctx.ui.confirm, prompt, select, form, askUser, presentPlanAsks the user, and resolves with the answerDialogs
ctx.ui.toast, ctx.ui.notifyA toast with optional buttons, an OS notificationToasts
ctx.ui.panel.open, close, toggle, stateDrives the side panelLayout
ctx.ui.navigateShows a screen, a session or a new sessionLayout
ctx.composer.insert, setText, attachEdits the draft in the composerComposer
ctx.composer.completions, autocompleteA trigger-character menu, and ghost textComposer
ctx.desktopElectron, in the desktop app's main processDesktop

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).

In each app#

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

TokenTerminal appDesktop appWeb app
SlotEvery slot, text only (no icons)Every slotEvery slot. home.block only on the welcome screen
PanelSide tabs from data. No home blocks, no componentSide tabs and home blocks. component on side tabsSide tabs. No project home, so no home blocks
ScreenYesYesYes
RendererYesYesYes
SettingsPageA tab of SettingsIn the settings sidebarIn the settings sidebar
WebviewTitle and a link to open the page in a browserYesYes
MenuNoYesNo
ThemeTokenYesYesYes
ReferenceNoYesYes
BadgeActionIn the artifacts and badge menusYesYes
ContextSectionYesYesYes
LinkHandlerRows, badges and webviews. No transcript or terminal links, no Alt bypassEvery link. Alt skips the handlersEvery link. Alt skips the handlers
SpaceYes, even with Spaces offWith Spaces onWith Spaces on
SidebarFilter, SidebarSort, SidebarGroupIn the sessions list's view modalIn the sidebar's filter menuIn the sidebar's filter menu
SidebarDecorationIcon, subtitle and chips. No componentYesYes
SidebarRowYesYesYes

Placements 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.