# Extension placements

> Where extension UI shows in the terminal, desktop and web apps, from slots and panels to dialogs, toasts, menus and composer control.

A placement is a place in the apps where an extension's UI shows. Items added with `ctx.add` stay in their place until you dispose them ([UI model](https://zidane.si/docs/extensions-ui.md)). Dialogs, toasts and layout calls on `ctx.ui` act once and return. This page lists every place, with where it shows in each app.

The three apps share the names. When an app has no such place, it ignores the item, and an `await` on a layout call answers `false`. Code that runs in every app needs no checks: it shows what each app can show.

## A minimal example

```ts
import { Panel, Slot } from 'zidane'

ctx.add(Slot, { at: 'app.statusbar', text: 'deploys: 2', panel: 'deploys' })

ctx.add(Panel, {
  id: 'deploys',
  label: 'Deploys',
  icon: { gui: 'i-ph-rocket-launch', tui: '^' },
  load: () => ({ sections: [{ id: 'list', items: [{ kind: 'row', title: 'main', subtitle: 'running' }] }] }),
})

ctx.commands.register({
  id: 'confirm-deploy',
  title: 'Deploy main',
  run: async ({ sessionId }) => {
    if (await ctx.ui.confirm({ title: 'Deploy main?', danger: true, ...(sessionId ? { sessionId } : {}) }))
      await ctx.ui.toast({ text: 'Deploying main', tone: 'info' })
  },
})
```

A click on the status item opens the panel. The command asks first, then shows a toast.

## Slots

A slot is a small item in one of twelve named places: a status line, a badge in the title bar, a banner, a row pinned in the sidebar. `ctx.add(Slot, item)` returns a [handle](https://zidane.si/docs/extensions-ui.md#ctxadd-and-handles).

| Field | Type | Does |
| - | - | - |
| `at` | `SlotName` | Required. The place, see the table below |
| `sessionId` | `string` | Shows the item only with that session. Required for `composer.badges`. The item goes when the session is deleted |
| `turnId` | `string` | `transcript.turn-end` only: draws the item right after that turn. Other slots refuse it |
| `text` | `Bindable<string>` | The text. Up to 500 characters, 40 on a badge |
| `lines` | `Bindable<string[]>` | Lines of a banner (`composer.above`). Up to 20 |
| `count` | `Bindable<number \| string>` | A count after the text. Past 999 it shows `999+` |
| `icon` | `{ gui?: string, tui?: string }` | A badge icon class for the desktop and web apps, a glyph for the terminal app |
| `tone` | `Bindable<'muted' \| 'subtle' \| 'accent' \| 'success' \| 'warn' \| 'danger'>` | Tints the item |
| `color` | `` `#rgb` \| `#rrggbb` `` | An exact color. Other values are dropped |
| `loader` | `Bindable<boolean>` | A running arc in place of the icon |
| `highlight` | `Bindable<boolean>` | Raised and breathing: something waits on the user |
| `title` | `Bindable<string>` | Hover text |
| `panel` | `string` | A click opens this panel: your panel's id, `<extension>/<id>`, or a built-in tab |
| `command` | `string` | A click runs this command. Wins over `panel` when both are set |
| `order` | `number` | Lower first. Default 100 |
| `timeoutMs` | `number` | Withdraws the item after this many ms. Each `update` starts the wait again |
| `component` | `ComponentRef` | Drawn instead of the data where the app has it ([components](https://zidane.si/docs/extensions-components.md)) |

`panel` and `command` take a bare id: the apps look for your own panel or command first.

### Where each slot shows

| Slot | Terminal app | Desktop and web apps |
| - | - | - |
| `app.statusbar` | A line under the transcript, above the prompt, on the chat screen. Each item starts with the extension id | The left side of the strip at the bottom of the sidebar, after the `sidebar.footer` items |
| `app.titlebar` | Badges at the right end of the session header line, also with no `session.header` item | Desktop: the window's title bar, right of the window controls and the sidebar toggle, in the main window only. Web: the session header's toolbar |
| `session.header` | A line at the top of the chat, above the transcript | The right side of the session header, before its buttons |
| `composer.above` | Banner lines above the prompt, each one after a `▸` | The top of the composer, above its badge row, one line per entry |
| `composer.below` | A line under the prompt box | Under the composer and its footer |
| `composer.badges` | A row right above the prompt | In the composer's badge row, after the built-in badges (Inbox, Todos, Queued) |
| `sidebar.top` | Rows between the search box and the session list, on the sessions screen | Rows between the search well and the session list |
| `sidebar.bottom` | Rows right under the session list, on the sessions screen | Rows between the session list and the strip at the bottom of the sidebar |
| `sidebar.footer` | A line under the session list, on the sessions screen | Chips in the strip at the bottom of the sidebar |
| `transcript.turn-end` | See [below](#transcriptturn-end) | See [below](#transcriptturn-end) |
| `home.block` | A line under the session list, above the notice line | Desktop: the project home and the welcome screen, above the home blocks. Web: the welcome screen only |
| `settings.section` | The top of Settings › Extensions & Plugins | Settings › Extensions, under the list, in both apps |

`app.titlebar` and `composer.badges` draw badges: at most 8 per extension (`app.titlebar` across every session, `composer.badges` per session), with `text` cut at 40 characters. Keep a title bar badge to a word or a count.

`sidebar.top` and `sidebar.bottom` are blocks: each item is a full-width row of its own, pinned outside the session list, so it stays put while the list scrolls. `sidebar.footer` items are small chips that share the footer strip with the app's status. In the desktop and web apps each block takes at most 30% of the sidebar's height and scrolls on its own past that; the terminal app clips a block at 30% of the screen. An empty block takes no room. See [the sidebar's top and bottom](https://zidane.si/docs/extensions-sidebar.md#top-and-bottom-of-the-sidebar).

The apps do not draw every field the same way:

| Field | Terminal app | Desktop and web apps |
| - | - | - |
| `icon` | Not drawn, except `icon.tui` on `sidebar.top` and `sidebar.bottom` rows | `icon.gui` |
| `lines` | `composer.above` only. Elsewhere `text` shows | Any slot, one line per entry |
| `title` | Not used | Hover text |
| `color` | Tints the text | Tints the icon |
| `highlight` | A `●` before the text | A raised item that breathes |
| `loader` | A spinner glyph before the text | A spinner in place of the icon |
| `component` | Drawn, without a `sessionId` prop | Drawn, with `sessionId` added to its props. Not on badges |
| Click | `panel` and `command` on every slot but `composer.above` and `composer.badges` | `panel` and `command` on every slot |

### `composer.badges`

A composer badge belongs to one session, so `sessionId` is required. An extension shows up to 8 per session: the ninth `ctx.add` throws a `RangeError`. The badge label is `text`; with no text, the extension id.

In the desktop and web apps, a badge with a `command` runs it. A badge with a `panel` toggles that panel. In the desktop app the user can drag badges into their own order, kept per project. The terminal app draws badges as plain labels: no click, no reordering.

The kitchen sink adds one badge per session and lights it up while the session waits on the user (`ui-items.ts`):

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

const badges = new Map<string, Handle<SlotItem>>()
function badge(sessionId: string): Handle<SlotItem> {
  let handle = badges.get(sessionId)
  if (!handle) {
    handle = ctx.add(Slot, { at: 'composer.badges', sessionId, text: 'kitchen', panel: 'board', icon: { gui: 'i-ph-cooking-pot', tui: '◍' } })
    badges.set(sessionId, handle)
  }
  return handle
}
ctx.hooks.on('run:settled', event => badge(event.sessionId).update({ highlight: false, loader: false }))
ctx.hooks.on('session:event', event => badge(event.sessionId).update({ highlight: event.kind === 'needs-attention' }))
```

### `transcript.turn-end`

With `sessionId` and `turnId`, the item draws right after that turn, in every app. If the transcript holds no turn with that id, the item does not draw.

Without `turnId`, the item draws once, at the end of the transcript. The desktop and web apps show it only when no run is streaming and the transcript is not empty. The terminal app shows it all the time, under the transcript, during a run too.

## Panels

A panel is a side panel tab, a block on the project home, or both. Its content is data: `load` returns sections of rows, markdown, stats, charts and custom blocks, plus actions, header controls, a search field and pages. The apps call `load` when the panel shows, and again when it changes or the user filters, searches or asks for more.

```ts
import { Panel, panelViewValue } from 'zidane'

ctx.add(Panel, {
  id: 'board',
  label: 'Kitchen',
  icon: { gui: 'i-ph-cooking-pot', tui: '◍' },
  scope: 'both',
  surfaces: ['side', 'home'],
  home: { order: 10, width: 'half' },
  load: (lc) => {
    const range = panelViewValue(lc.view, 'range', 'all')
    return {
      controls: [{ id: 'range', label: 'Range', value: range, options: [{ id: 'all', label: 'All' }, { id: 'today', label: 'Today' }] }],
      sections: [
        { id: 'rows', title: 'Actions', items: [{ kind: 'row', title: 'Bump', actionId: 'bump' }, { kind: 'markdown', text: `Viewing **${range}** from ${lc.surface}.` }] },
        { id: 'card', items: [{ kind: 'custom', renderer: 'kitchen-card', data: { count: 3 }, fallback: 'Counter: 3' }] },
      ],
      actions: [{ id: 'reset', label: 'Reset', tone: 'danger' }],
    }
  },
  onAction: async (actionId) => {
    if (actionId === 'bump')
      await ctx.commands.run('kitchen-sink.bump')
  },
})
```

| Field | Type | Does |
| - | - | - |
| `id` | `string` | Required. Unique per extension. Its full id is `<extension>/<id>` |
| `label` | `string` | Required. The tab label, or the block title |
| `icon` | `{ gui?: string, tui?: string }` | The tab icon. Without one, the desktop and web apps draw the extension's own mark: its icon, else its logo, else its first letter |
| `scope` | `'session' \| 'project' \| 'both'` | Where the side tab shows. Default `'session'`: only beside a session. `'project'` and `'both'` also show it where only a folder is open; `load` then gets no `sessionId` |
| `surfaces` | `('side' \| 'home')[]` | Default `['side']`. Add `'home'` for a block on the project home. `['home']` alone keeps it out of the side panel |
| `home` | `{ order?: number, width?: 'half' \| 'full' }` | The block's default place on the project home |
| `load` | `(lc) => PanelContent \| Promise<PanelContent>` | Required. The content now. `lc` holds `sessionId`, `cwd`, `scope`, `surface`, `view` (the controls' picked options), `search` (the search field's text), `cursor` (the page asked for), `focus` (what opened the panel) and `global` |
| `subscribe` | `(emit) => () => void` | Call `emit()` when `load` would answer something new, `emit({ sessionId })` when only that session's content changed. Return the unsubscribe |
| `onAction` | `(actionId, payload, lc) => void \| Promise<void>` | Runs when the user clicks a row or an action. The desktop and web apps load the panel again after it |
| `component` | `ComponentRef` | Drawn over the data where the app has it. Its props get `panelId` |

`PanelContent` and its item kinds are in the [extensions reference](https://zidane.si/docs/reference/extensions.md). The apps reload a panel only while it shows. An `update` on the panel's handle also counts as a change.

A `custom` item in a panel draws through a [renderer](#renderers) for its `renderer` id, and shows its `fallback` markdown where none matches.

### Search, filters and Show more

A home block can be browsed. The app keeps what the user picks and loads the panel again with it:

- `controls` with `kind: 'select'` (one option from a menu) or `kind: 'multi'` (several; `view[id]` is a list). Read them with `panelViewValue` and `panelViewValues`. Plain controls stay side by side.
- `search: { placeholder }` adds a search field. `load` gets its text as `lc.search` once typing rests.
- `more: { cursor }` adds Show more. `load` gets `lc.cursor`; the rows it returns are appended to the sections of the same id.

```ts
import { Panel, panelViewValue, panelViewValues } from 'zidane'

// Your own service.
declare const ordersApi: {
  list: (q: { status: readonly string[], sort: string, query?: string, cursor?: string }) => Promise<{ items: { id: string, title: string }[], next?: string }>
}

ctx.add(Panel, {
  id: 'orders',
  label: 'Orders',
  surfaces: ['home'],
  load: async (lc) => {
    const status = panelViewValues(lc.view, 'status', ['open'])
    const sort = panelViewValue(lc.view, 'sort', 'new')
    const page = await ordersApi.list({ status, sort, query: lc.search, cursor: lc.cursor })
    return {
      search: { placeholder: 'Search orders' },
      controls: [
        { id: 'sort', label: 'Sort', value: sort, options: [{ id: 'new', label: 'Newest' }, { id: 'big', label: 'Largest' }] },
        { id: 'status', label: 'Status', kind: 'multi', value: 'open', values: status, options: [{ id: 'open', label: 'Open' }, { id: 'shipped', label: 'Shipped' }] },
      ],
      sections: [{ id: 'orders', items: page.items.map(o => ({ kind: 'row', id: o.id, title: o.title })) }],
      ...(page.next ? { more: { cursor: page.next } } : {}),
    }
  },
})
```

### Starting a chat from a row

A row's `actions` are buttons shown on hover and in its right-click menu. One with `start` opens a new chat draft for the folder, never sent: `where: 'worktree'` runs it in a git worktree on `branch` (a pull request's head with `pullRequest`), `where: 'here'` in the folder (switched to `pullRequest` or `branch` when given). `prompt` is the draft's text.

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

declare const t: { id: string, title: string, branch: string }

const row: PanelRowItem = {
  kind: 'row',
  id: t.id,
  title: t.title,
  actions: [
    { id: 'worktree', label: 'Worktree', icon: { gui: 'i-ph-git-fork' }, start: { where: 'worktree', branch: t.branch, prompt: `${t.id}: ${t.title}` } },
    { id: 'here', label: 'Here', icon: { gui: 'i-ph-folder-simple' }, start: { where: 'here', prompt: `${t.id}: ${t.title}` } },
  ],
}
```

The desktop app's project home runs these. Elsewhere a button with `start` is not shown; the others call `onAction`.

| | Terminal app | Desktop app | Web app |
| - | - | - | - |
| Side tab | A tab in the side rail, drawn from the data | A tab in the side panel | A tab in the side panel |
| Home block | Not drawn | A block on the project home, placed by `home` | Not drawn: the web app has no project home |
| `component` | Not drawn: the data shows | On the side tab. Home blocks draw the data | On the side tab |

## Screens and modals

A screen is a full-screen view, or a modal with `presentation: 'modal'`. It needs a component. `ctx.ui.navigate({ screen: id })` opens it: nothing else does, so pair it with a command.

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

ctx.add(Screen, { id: 'kitchen', title: 'Kitchen', component: { component: 'KitchenScreen' }, fallback: '# Kitchen\nThis app draws no kitchen screen.' })
ctx.add(Screen, { id: 'about', title: 'About the kitchen sink', presentation: 'modal', component: { component: 'KitchenAbout' } })

ctx.commands.register({ id: 'open-kitchen', title: 'Open the kitchen', slash: { name: 'kitchen' }, run: () => void ctx.ui.navigate({ screen: 'kitchen' }) })
```

| Field | Type | Does |
| - | - | - |
| `id` | `string` | Required. Unique per extension. Its full id is `<extension>/<id>` |
| `title` | `Bindable<string>` | Required. The title bar, or the modal title |
| `presentation` | `'screen' \| 'modal'` | Default `'screen'` |
| `component` | `ComponentRef` | Required |
| `fallback` | `string` | Markdown drawn where the component is missing or fails. Default: the title as a heading |

| | Terminal app | Desktop and web apps |
| - | - | - |
| `'screen'` | A full-terminal view | The whole window, with a title bar and a close button |
| `'modal'` | A centered box | A large centered dialog |
| Close | `Esc` | `Esc`, a click outside, or the close button |
| Keys | Commands with `scope: 'screen:<id>'` fire while it is open | Same in the web app. The desktop app does not route `screen:<id>` keys |

## Renderers

A renderer draws one kind of content with your component: a custom block, a fenced code-block language, a tool's result, or an artifact kind's detail.

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

ctx.add(Renderer, { for: { block: 'kitchen-card' }, component: { component: 'KitchenCard' } })
ctx.add(Renderer, { for: { language: 'kitchen' }, component: { component: 'KitchenCode' } })
ctx.add(Renderer, { for: { tool: 'kitchen_count' }, component: { component: 'KitchenToolResult' }, fallthrough: true })
ctx.add(Renderer, { for: { artifact: 'kitchen-sink.counter' }, component: { component: 'KitchenArtifact' } })
```

| Field | Type | Does |
| - | - | - |
| `for` | `{ block } \| { language } \| { tool } \| { artifact }` | Required. What it draws, see below |
| `component` | `ComponentRef` | Required |
| `fallthrough` | `boolean` | When the component returns `null`, the next renderer draws, or the default view |

The props each component gets, besides its own `component.props` (the app's props win a clash):

| Target | Matches | Terminal app props | Desktop and web props |
| - | - | - | - |
| `{ block: id }` | A `custom` block in a command's output or a note (`custom: { renderer: id, data }`), or a `custom` panel item. `id` or `<extension>/<id>` | `{ data }` | `{ renderer, data }` |
| `{ language: 'mermaid' }` | A fenced code block in a reply, any case | `{ language, code }` | `{ language, code }` |
| `{ tool: name }` | A tool's result. A list matches several tools, `'*'` every tool | `{ tool, callId, text }` | `{ tool, callId, text, input, result, ok }` |
| `{ artifact: kind }` | An artifact's detail: the detail pane of the terminal app's artifacts view, the details toggle of an artifact row in the desktop and web apps | `{ artifact }` | `{ artifact }` |

The first renderer that matches draws, in load order. With `fallthrough: true`, a component that returns `null` hands over to the next one; past the last, the app draws its default view. A component that is missing or throws also gives way to the default view.

In the terminal app, fallthrough works for a plain function component only: one wrapped in `memo` or `forwardRef`, or a class, cannot hand over. The terminal app draws a fenced block once its closing fence arrives, and keeps an open one as markdown while it streams.

## Settings pages

A settings page is a page under the extension in Settings: rows from a schema, or a component.

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

ctx.add(SettingsPage, { id: 'quick', title: 'Quick settings', schema: { items: { step: { type: 'number', label: 'Bump step', min: 1, max: 10 } } } })
ctx.add(SettingsPage, { id: 'stats', title: 'Statistics', component: { component: 'KitchenSettings' } })
```

| Field | Type | Does |
| - | - | - |
| `id` | `string` | Required. Unique per extension |
| `title` | `Bindable<string>` | Required. The page heading |
| `icon` | `{ gui?: string, tui?: string }` | Its icon in the settings sidebar |
| `order` | `number` | Its place among the extension's pages, after the pages the [schema](https://zidane.si/docs/extensions-settings.md#pages-and-the-settings-sidebar) declares. Lower first, default 100 |
| `schema` | `SettingsSchema` | Rows drawn like the extension's settings. Values are the extension's own settings values |
| `component` | `ComponentRef` | Drawn in place of the rows. Needs `schema` or `component` |

A page sits with the extension's other pages in the settings sidebar: under the extension's entry in the Extensions group, or under its [category](https://zidane.si/docs/extensions-settings.md#pages-and-the-settings-sidebar). Settings search finds the rows of its `schema`. An extension with only `SettingsPage` items, and no settings of its own, still gets an entry. Every app draws them, the terminal app included (as a tab of its Settings), and a `component` page draws its component where the app has it.

## Menus

A menu item is a native menu entry in the desktop app. It runs a command.

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

ctx.add(Menu, { menu: 'app', label: 'Bump the kitchen counter', command: 'kitchen-sink.bump', accelerator: 'CmdOrCtrl+Shift+Y' })
ctx.add(Menu, { menu: 'session', label: 'Kitchen: about this session', command: 'kitchen-sink.session-info' })
```

| Field | Type | Does |
| - | - | - |
| `menu` | `'app' \| 'session' \| 'tray'` | Required. Which menu, see below |
| `label` | `Bindable<string>` | Required |
| `command` | `string` | Required. The published command id it runs |
| `accelerator` | `string` | An Electron accelerator (`CmdOrCtrl+Shift+K`) |
| `enabled` | `Bindable<boolean>` | `false` greys it out. Default `true` |

| Menu | Where it shows in the desktop app |
| - | - |
| `app` | An Extensions submenu at the end of the application menu |
| `session` | The end of the session header's menu and of a session row's menu on the project home. Not the sidebar's row menu |
| `tray` | A tray icon menu. The tray icon exists only while there are tray items |

The terminal and web apps have no native menus and ignore menu items. Register the same action as a [command](https://zidane.si/docs/extensions-commands.md) so it is reachable everywhere.

## Links

A `LinkHandler` opens links the extension understands before the app does: a ticket URL in a panel of the extension's own, a `vscode://` link in a terminal. Every link the apps open goes to the handlers first. A link no handler takes opens the app's way.

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

ctx.add(LinkHandler, {
  id: 'tickets',
  match: /^https:\/\/linear\.app\/[\w-]+\/issue\//,
  open: async (url, { sessionId }) => {
    const shown = await ctx.ui.panel.open('tickets', { sessionId })
    if (!shown)
      return false
    ctx.logger.info(`opened ${url} in the tickets panel`)
  },
})
```

| Field | Type | Does |
| - | - | - |
| `id` | `string` | Required. Unique per extension |
| `match` | `string \| RegExp \| (url) => boolean` | Required. A URL prefix, a pattern or a test |
| `open` | `(url, { sessionId?, source }) => void \| boolean` | Required. Opens the link. Return `false` (or a promise of it) to pass the link on |
| `priority` | `number` | Higher first, across extensions. Default 0. Equal priorities run in the order they were added |

`source` says where the link was clicked: `transcript`, `chip`, `panel`, `badge`, `terminal` or `other`. File and path links pass the absolute path as `url`. `match` and `open` run where the extension runs, so a link clicked in a window goes to the daemon or the server and back. A handler that returns `false`, throws or passes the 8 second callback timeout passes the link to the next one; a `match` that throws counts as no match. Both count against the extension's error budget.

To call the handlers yourself, as an app does: `extensions.links.open` on the protocol (a `chat` token is enough) answers `{ handled }`.

| Links | Terminal app | Desktop and web apps |
| - | - | - |
| Offered | Artifact and panel rows, extension panel rows, session badges, a badge action's `open`, webview pages | Transcript links, link, PR, branch, reference and file chips, badge menus, artifact rows, panel rows, sidebar badges, git PR links, terminal URL and file links, MCP and server sign-in links |
| Not offered | Transcript and terminal links: they are terminal hyperlinks that the terminal opens itself. Sign-in pages (provider, MCP and integration sign-ins), so an extension cannot take over a sign-in | Provider and integration sign-in pages, and `mailto:` links in a terminal |
| Skip the handlers | No way: every link is offered | Hold `Alt` while clicking. The desktop app's project home links (the repository, Open in editor) have no Alt bypass |

The desktop and web apps route links only while at least one extension has a `LinkHandler`. Terminal links in those apps reach the handlers without a `sessionId`.

## Sidebar

Six tokens add to the session list: spaces, filters, sorts, groupings, row decorations and rows of the extension's own. Each one takes a [session query](https://zidane.si/docs/extensions-sidebar.md#the-session-query) that says which sessions it applies to. [Sidebar](https://zidane.si/docs/extensions-sidebar.md) has their fields and a full example.

```ts
import { SidebarDecoration, Space } from 'zidane'

ctx.add(Space, { id: 'failed', name: 'Failed', query: { field: 'status', eq: 'error' }, sortBy: [{ field: 'updatedAt', dir: 'desc' }] })
ctx.add(SidebarDecoration, { id: 'model', where: { field: 'modelId', exists: true }, chips: [{ template: '{modelId}' }] })
```

| Token | Terminal app | Desktop and web apps |
| - | - | - |
| `Space` | Among the spaces of the sessions list, even with Spaces off | In the space switcher of the sidebar footer, with Spaces on |
| `SidebarFilter`, `SidebarSort`, `SidebarGroup` | In the sessions list's view modal (`Ctrl`+`F`) | In the filter menu beside the sidebar's search box |
| `SidebarDecoration` | Its icon before the title, its subtitle and chips under the counts | Its icon and chips on the row's badge line, its subtitle under the title, its component in their place |
| `SidebarRow` | A row among the sessions, with its child sessions under it | A row among the sessions, with its child sessions folded under it |

## Dialogs

A dialog asks the user and resolves with the answer. Call it from anywhere: a command, a hook, a panel action, a timer.

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

try {
  const name = await ctx.ui.prompt({ title: 'Your name?', placeholder: 'Ada' })
  const dish = await ctx.ui.select({
    title: 'Pick a dish',
    options: [{ id: 'soup', label: 'Soup' }, { id: 'pie', label: 'Pie', description: 'Takes longer' }],
    actions: [{ id: 'random', label: 'Surprise me' }],
  })
  const portions = await ctx.ui.form({
    title: 'Portions',
    schema: { items: { people: { type: 'number', label: 'People', default: 2, min: 1, max: 12 } } },
  })
  ctx.logger.info(`${name ?? 'someone'} picked ${dish?.action ?? dish?.ids.join(', ')} for ${String(portions?.people)}`)
}
catch (error) {
  if (!(error instanceof NoUiError))
    throw error
}
```

| Call | Takes | Resolves |
| - | - | - |
| `confirm` | `title`, `message?`, `confirmLabel?`, `danger?` | `true` or `false` |
| `prompt` | `title`, `message?`, `placeholder?`, `initial?`, `secret?` | The text, or `undefined` when cancelled |
| `select` | `title`, `options` (`id`, `label`, `description?`, `group?`, `icon?`), `multi?`, `placeholder?`, `actions?` | `{ ids, action? }`, or `undefined` |
| `form` | `title`, `schema` (a [settings schema](https://zidane.si/docs/extensions-settings.md)), `values?` | The values by key, or `undefined`. Each value is checked against the schema |
| `askUser` | `QuestionPayload`: questions, as the [`ask_user` tool](https://zidane.si/docs/approvals.md) asks them | `QuestionResponse`: `answers` by question id |
| `presentPlan` | `PlanPayload`: a title, the plan and its steps, as the `present_plan` tool shows them | `PlanResponse`: `decision`, and the user's `comment` |

Every dialog also takes these options:

| Option | Type | Does |
| - | - | - |
| `sessionId` | `string` | Shows it in the window that views this session. Without it: the last active window |
| `timeoutMs` | `number` | Rejects with `DialogTimeoutError` after this many ms |
| `signal` | `AbortSignal` | Closes the dialog on the client and rejects. The extension's own signal is joined in, so unloading closes it too |

With no window that can show it, a dialog rejects at once with `NoUiError`: a command run from a keybinding on a headless server, or a hook while no window is attached. Catch it where that can happen.

| | Terminal app | Desktop and web apps |
| - | - | - |
| Where | A box over the app, titled with the dialog kind and the extension id. Dialogs queue, oldest first. App shortcuts are off while one shows | A dialog over the window, one at a time |
| `confirm` | Two buttons: `Enter` picks, `Y` and `N` answer. `danger` paints the confirm button red | Confirm and Cancel. `danger` uses the danger button |
| `prompt` | A line input. `secret` masks it | A text field. `secret` masks it |
| `select` | A list with a filter line past 6 options, checkboxes with `multi` (`Space` toggles), actions as extra rows | A searchable list with groups, checkboxes and action buttons |
| `form` | The settings form. Each value is checked on submit | The settings rows. The web app shows each refused value under its field and asks again |
| Cancel | `Esc` answers `undefined` (`false` for `confirm`) | `Esc`, a click outside, or Cancel |

In the desktop app, a form answer that fails the schema makes `ctx.ui.form` throw a `TypeError`. Check the values your schema allows, or catch it.

## Toasts and notifications

`ctx.ui.toast` shows a short message, with optional buttons. It resolves with the id of the button the user clicked, or `undefined`.

```ts
const action = await ctx.ui.toast({
  text: 'Counter reset.',
  tone: 'success',
  actions: [{ id: 'undo', label: 'Undo' }],
  timeoutMs: 10_000,
})
if (action === 'undo')
  ctx.logger.info('undo')
```

| Field | Type | Does |
| - | - | - |
| `text` | `string` | Required |
| `tone` | `'info' \| 'success' \| 'warn' \| 'error'` | Default `'info'` |
| `actions` | `{ id, label }[]` | Buttons. The toast resolves with the clicked id |
| `timeoutMs` | `number` | Withdraws the toast after this many ms, counted from when it shows. It then resolves `undefined` |
| `sessionId` | `string` | Shows it in the window that views this session |

`ctx.ui.notify({ title, body?, sound?, sessionId? })` shows an OS notification. It returns nothing.

Toasts and notifications do not fail when no window is open. They wait: up to 20 of them, each for 60 s, delivered in order to the first window that attaches. A toast dropped before any window shows it resolves `undefined`.

| | Terminal app | Desktop and web apps |
| - | - | - |
| Toast | A line near the bottom, after the extension id, colored by tone. The newest 3 show, each for 6 s or `timeoutMs` | A card with an icon per tone and a close button. It stays until `timeoutMs`, a click, or the close button |
| Toast with actions | A box over the app with one button per action. It blocks the app until answered. `Esc` dismisses | The same card, with buttons |
| `notify` | Rings the terminal bell and adds a line to the transcript. `sound` is ignored | Desktop: an OS notification, skipped while the app has focus. Web: the same, and only when the browser allows notifications for the site |

## Panel and navigation control

`ctx.ui.panel` drives the side panel. `ctx.ui.navigate` shows a screen, a session or a new session. Each call goes to the window that views `sessionId`, else the last active window, and resolves `false` when no window takes it.

```ts
ctx.commands.register({
  id: 'toggle-board',
  title: 'Toggle the kitchen panel',
  bind: 'mod+alt+k',
  run: async ({ sessionId }) => void await ctx.ui.panel.toggle('board', sessionId ? { sessionId } : {}),
})
```

| Call | Resolves |
| - | - |
| `panel.open(id, { sessionId?, focus? })` | `true` when the tab opened. `focus` (`{ kind?, uri? }`) reaches the panel's `load` as `lc.focus` |
| `panel.close(id, { sessionId? })` | `true` when the tab was open and closed |
| `panel.toggle(id, { sessionId? })` | `true` when it did either |
| `panel.state({ sessionId? })` | The ids of the open tabs |
| `navigate({ screen })` | `true` when the screen or [webview](https://zidane.si/docs/extensions-webview.md) opened |
| `navigate({ session })` | `true` when the window switched to that session |
| `navigate({ newSession: true })` | `true` when the window showed a new chat |

`id` is your panel's id, `<extension>/<id>`, or a built-in tab.

| | Terminal app | Desktop and web apps |
| - | - | - |
| `panel.open` | Opens the rail on the tab, with `focus`. Always `true` | `true` only for a known tab. `focus` is not passed on |
| `panel.close` | Closes the rail, whatever tab it shows | Closes that tab only if it is the one shown |
| `panel.state` | The one open tab, or none | The open tabs |
| `navigate({ session })` | Switches, even to an unknown id | Switches |

## Composer control

`ctx.composer` edits the draft of a session's composer. Each call resolves `false` when no window took it.

```ts
ctx.commands.register({
  id: 'fill',
  title: 'Fill the composer',
  when: 'session',
  run: async ({ sessionId }) => {
    if (!sessionId)
      return
    await ctx.composer.setText('Tell me about the counter.', { sessionId })
    await ctx.composer.attach([{ name: 'counter.json', mimeType: 'application/json', data: new TextEncoder().encode('{"count":3}') }], { sessionId })
  },
})
```

| Call | Terminal app | Desktop and web apps |
| - | - | - |
| `insert(text, { sessionId? })` | Inserts at the caret | Appends at the end of the draft |
| `setText(text, { sessionId? })` | Replaces the draft | Replaces the draft text and keeps attachments |
| `attach(files, { sessionId? })` with bytes | Saves each file in the app's cache and attaches it like a paste. `false` when any file is refused | Attaches each file. `true` when at least one attached |
| `attach(files, { sessionId? })` with `{ path }` | Reads the file like a pasted path | Desktop: `false`, pass bytes. Web: text files only, read from the server |

Without `sessionId`, the call goes to the focused composer.

## Composer providers

Two providers feed the composer while the user types:

- `ctx.composer.completions.register(provider)` adds a menu on a trigger character, like `@` for files. `provider` has `id`, `trigger` (one character), `label`, `suggest(query, ctx, signal)` returning items, and `parseReferences(text, ctx)` returning the references it finds in the draft.
- `ctx.composer.autocomplete.register(provider)` adds ghost text. `provider` has `id`, `suggest({ text, cursor, cwd, signal })` returning the text to append (or a list to cycle through, or `null`), and `deadlineMs` when the source is slow.

```ts
ctx.composer.completions.register({
  id: 'kitchen-sink.recipes',
  trigger: '%',
  label: 'Kitchen',
  suggest: query => ['toast', 'tea', 'soup'].filter(r => r.startsWith(query)).map(r => ({ id: r, label: r, insertText: `%${r} `, data: r })),
  parseReferences: () => [],
})
ctx.composer.autocomplete.register({
  id: 'kitchen-sink.counter',
  suggest: ({ text, cursor }) => (text.slice(0, cursor).endsWith('kitchen count ') ? '3' : null),
})
```

Both return a `Disposable`. An autocomplete provider runs on each keystroke with a short deadline (150 ms unless `deadlineMs` says more). A slow one only delays the providers after it, never the typing.

## `ctx.desktop`

`ctx.desktop` exists only in the desktop app's main process. It is `undefined` everywhere else: in the terminal app, the daemon, the server, and in the desktop app when the [daemon](https://zidane.si/docs/daemon.md) runs its sessions. Check it before each use (`examples/extension-kitchen-sink/desktop.ts`):

```ts
interface ElectronSubset { app: { getVersion: () => string } }

const desktop = ctx.desktop
if (desktop) {
  const { app } = desktop.electron<ElectronSubset>()
  ctx.logger.info(`desktop ${app.getVersion()}`)
  desktop.windows.on('focus', window => ctx.logger.debug(`window ${window.id} focused (${desktop.windows.list().length} open)`))
}
```

| Member | Type | Does |
| - | - | - |
| `electron` | `<T>() => T` | The live `electron` module. Type the part you use |
| `windows.list` | `() => { id, focused }[]` | The app's windows, without DevTools and dev-browser popups |
| `windows.on` | `(event: 'open' \| 'close' \| 'focus', listener) => Disposable` | Calls `listener({ id })`. Removed with the extension |

For menus, use [menu items](#menus): they work whichever process runs the extension.

## Pitfalls

- A screen opens only through `ctx.ui.navigate`. Register a command for it, or the user cannot reach it.
- A `LinkHandler` with a wide `match` takes links the user expected in the browser. Match narrowly, and return `false` for a link you cannot open.
- `composer.badges` without `sessionId` throws. A badge for "every session" is one `ctx.add` per session, added when the session first runs.
- Icons and hover text do not show in the terminal app. Put what matters in `text`.
- The terminal app blocks on a toast with actions until the user answers. Keep action toasts for things the user must decide.
- `ctx.ui.notify` is skipped while the app has focus in the desktop and web apps. Use a toast for something the user must see now.
- `ctx.composer.attach` with a path fails in the desktop app. Read the file and pass bytes.
- A dialog from a hook can land in another window than the one the user looks at. Pass the `sessionId` the hook gives you.
- `ctx.desktop` is absent when the daemon runs the extension, even inside the desktop app. Code that needs it must also work without it.
