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 want | Use | Page |
|---|---|---|
| Text, a count or a badge in a fixed place | A Slot item | Placements |
| A side tab or a block on the project home | A Panel item | Placements |
| A full-screen view or a modal | A Screen item | Placements |
| 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 |
| Your own drawing of a code block, a tool result or an artifact | A Renderer item | Placements |
| A question to the user | ctx.ui.confirm, prompt, select, form | Placements |
| Open some links your own way | A LinkHandler item | Placements |
| Settings pages and a group of your own in the settings sidebar | pages and category in the settings schema, SettingsPage items | Settings |
| React components in place of the data | component on any item | Components |
| A page with its own HTML and scripts | A Webview item | Webview |
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:
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 |
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:
| 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 is a Readable, and store.select(pick) gives a Readable over part of it. Both fit any bindable field:
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:
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) | 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:
| Token | Item | Adds | Page |
|---|---|---|---|
Slot | SlotItem | Text, lines, a count or a badge in one of twelve named places | Slots |
Panel | PanelItem | A side panel tab, a block on the project home, or both | Panels |
Screen | ScreenItem | A full-screen view or a modal, opened with ctx.ui.navigate | Screens |
Renderer | RendererItem | A component for a custom block, a code-block language, a tool's result or an artifact kind | Renderers |
SettingsPage | SettingsPageItem | A page among the extension's pages in the settings sidebar | Settings pages |
Webview | WebviewItem | A sandboxed web page in a panel or a screen | Webview |
Menu | MenuItem | A native menu entry in the desktop app | Menus |
ThemeToken | Theme | A color theme the user can pick | Themes |
Reference | ExtensionReferenceProvider | Chips for ticket ids and URLs found in text | References |
BadgeAction | ExtensionBadgeActions | Context-menu entries on badges, links, files and artifacts | Badge actions |
ContextSection | ContextSectionItem | Rows in the context breakdown or the context panel | Context sections |
LinkHandler | LinkHandlerItem | Opens matching links before the app does | Links |
Space | SpaceItem | A named view of the sessions a query matches, in the space switcher | Sidebar |
SidebarFilter | SidebarFilterItem | A filter the user turns on in the sidebar | Sidebar |
SidebarSort | SidebarSortItem | A sort order the user can pick in the sidebar | Sidebar |
SidebarGroup | SidebarGroupItem | A grouping the user can pick in the sidebar | Sidebar |
SidebarDecoration | SidebarDecorationItem | An icon, a subtitle, chips or a component on matching session rows | Sidebar |
SidebarRow | SidebarRowItem | A row of the extension's own among a space's sessions | Sidebar |
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:
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:
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.
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'):
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 |
ctx.ui.toast, ctx.ui.notify | A toast with optional buttons, an OS notification | Toasts |
ctx.ui.panel.open, close, toggle, state | Drives the side panel | Layout |
ctx.ui.navigate | Shows a screen, a session or a new session | Layout |
ctx.composer.insert, setText, attach | Edits the draft in the composer | Composer |
ctx.composer.completions, autocomplete | A trigger-character menu, and ghost text | Composer |
ctx.desktop | Electron, in the desktop app's main process | Desktop |
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:
| 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 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.
updatecannot 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,BadgeActionandThemeTokenids 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.