Skip to content

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

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

FieldTypeDoes
atSlotNameRequired. The place, see the table below
sessionIdstringShows the item only with that session. Required for composer.badges. The item goes when the session is deleted
turnIdstringtranscript.turn-end only: draws the item right after that turn. Other slots refuse it
textBindable<string>The text. Up to 500 characters, 40 on a badge
linesBindable<string[]>Lines of a banner (composer.above). Up to 20
countBindable<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
toneBindable<'muted' | 'subtle' | 'accent' | 'success' | 'warn' | 'danger'>Tints the item
color`#rgb` | `#rrggbb`An exact color. Other values are dropped
loaderBindable<boolean>A running arc in place of the icon
highlightBindable<boolean>Raised and breathing: something waits on the user
titleBindable<string>Hover text
panelstringA click opens this panel: your panel's id, <extension>/<id>, or a built-in tab
commandstringA click runs this command. Wins over panel when both are set
ordernumberLower first. Default 100
timeoutMsnumberWithdraws the item after this many ms. Each update starts the wait again
componentComponentRefDrawn instead of the data where the app has it (components)

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

Where each slot shows#

SlotTerminal appDesktop and web apps
app.statusbarA line under the transcript, above the prompt, on the chat screen. Each item starts with the extension idThe left side of the strip at the bottom of the sidebar, after the sidebar.footer items
app.titlebarBadges at the right end of the session header line, also with no session.header itemDesktop: 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.headerA line at the top of the chat, above the transcriptThe right side of the session header, before its buttons
composer.aboveBanner lines above the prompt, each one after a ▸The top of the composer, above its badge row, one line per entry
composer.belowA line under the prompt boxUnder the composer and its footer
composer.badgesA row right above the promptIn the composer's badge row, after the built-in badges (Inbox, Todos, Queued)
sidebar.topRows between the search box and the session list, on the sessions screenRows between the search well and the session list
sidebar.bottomRows right under the session list, on the sessions screenRows between the session list and the strip at the bottom of the sidebar
sidebar.footerA line under the session list, on the sessions screenChips in the strip at the bottom of the sidebar
transcript.turn-endSee belowSee below
home.blockA line under the session list, above the notice lineDesktop: the project home and the welcome screen, above the home blocks. Web: the welcome screen only
settings.sectionThe top of Settings › Extensions & PluginsSettings › 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.

The apps do not draw every field the same way:

FieldTerminal appDesktop and web apps
iconNot drawn, except icon.tui on sidebar.top and sidebar.bottom rowsicon.gui
linescomposer.above only. Elsewhere text showsAny slot, one line per entry
titleNot usedHover text
colorTints the textTints the icon
highlightA ● before the textA raised item that breathes
loaderA spinner glyph before the textA spinner in place of the icon
componentDrawn, without a sessionId propDrawn, with sessionId added to its props. Not on badges
Clickpanel and command on every slot but composer.above and composer.badgespanel 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):

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

TypeScript
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')
  },
})
FieldTypeDoes
idstringRequired. Unique per extension. Its full id is <extension>/<id>
labelstringRequired. 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) => () => voidCall 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
componentComponentRefDrawn over the data where the app has it. Its props get panelId

PanelContent and its item kinds are in the extensions reference. 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 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.
TypeScript
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.

TypeScript
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 appDesktop appWeb app
Side tabA tab in the side rail, drawn from the dataA tab in the side panelA tab in the side panel
Home blockNot drawnA block on the project home, placed by homeNot drawn: the web app has no project home
componentNot drawn: the data showsOn the side tab. Home blocks draw the dataOn 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.

TypeScript
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' }) })
FieldTypeDoes
idstringRequired. Unique per extension. Its full id is <extension>/<id>
titleBindable<string>Required. The title bar, or the modal title
presentation'screen' | 'modal'Default 'screen'
componentComponentRefRequired
fallbackstringMarkdown drawn where the component is missing or fails. Default: the title as a heading
Terminal appDesktop and web apps
'screen'A full-terminal viewThe whole window, with a title bar and a close button
'modal'A centered boxA large centered dialog
CloseEscEsc, a click outside, or the close button
KeysCommands with scope: 'screen:<id>' fire while it is openSame 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.

TypeScript
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' } })
FieldTypeDoes
for{ block } | { language } | { tool } | { artifact }Required. What it draws, see below
componentComponentRefRequired
fallthroughbooleanWhen 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):

TargetMatchesTerminal app propsDesktop 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.

TypeScript
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' } })
FieldTypeDoes
idstringRequired. Unique per extension
titleBindable<string>Required. The page heading
icon{ gui?: string, tui?: string }Its icon in the settings sidebar
ordernumberIts place among the extension's pages, after the pages the schema declares. Lower first, default 100
schemaSettingsSchemaRows drawn like the extension's settings. Values are the extension's own settings values
componentComponentRefDrawn 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. 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.

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

TypeScript
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' })
FieldTypeDoes
menu'app' | 'session' | 'tray'Required. Which menu, see below
labelBindable<string>Required
commandstringRequired. The published command id it runs
acceleratorstringAn Electron accelerator (CmdOrCtrl+Shift+K)
enabledBindable<boolean>false greys it out. Default true
MenuWhere it shows in the desktop app
appAn Extensions submenu at the end of the application menu
sessionThe end of the session header's menu and of a session row's menu on the project home. Not the sidebar's row menu
trayA 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 so it is reachable everywhere.

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.

TypeScript
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`)
  },
})
FieldTypeDoes
idstringRequired. Unique per extension
matchstring | RegExp | (url) => booleanRequired. A URL prefix, a pattern or a test
open(url, { sessionId?, source }) => void | booleanRequired. Opens the link. Return false (or a promise of it) to pass the link on
prioritynumberHigher 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 }.

LinksTerminal appDesktop and web apps
OfferedArtifact and panel rows, extension panel rows, session badges, a badge action's open, webview pagesTranscript 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 offeredTranscript 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-inProvider and integration sign-in pages, and mailto: links in a terminal
Skip the handlersNo way: every link is offeredHold 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.

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 that says which sessions it applies to. Sidebar has their fields and a full example.

TypeScript
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}' }] })
TokenTerminal appDesktop and web apps
SpaceAmong the spaces of the sessions list, even with Spaces offIn the space switcher of the sidebar footer, with Spaces on
SidebarFilter, SidebarSort, SidebarGroupIn the sessions list's view modal (CtrlF)In the filter menu beside the sidebar's search box
SidebarDecorationIts icon before the title, its subtitle and chips under the countsIts icon and chips on the row's badge line, its subtitle under the title, its component in their place
SidebarRowA row among the sessions, with its child sessions under itA 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.

TypeScript
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
}
CallTakesResolves
confirmtitle, message?, confirmLabel?, danger?true or false
prompttitle, message?, placeholder?, initial?, secret?The text, or undefined when cancelled
selecttitle, options (id, label, description?, group?, icon?), multi?, placeholder?, actions?{ ids, action? }, or undefined
formtitle, schema (a settings schema), values?The values by key, or undefined. Each value is checked against the schema
askUserQuestionPayload: questions, as the ask_user tool asks themQuestionResponse: answers by question id
presentPlanPlanPayload: a title, the plan and its steps, as the present_plan tool shows themPlanResponse: decision, and the user's comment

Every dialog also takes these options:

OptionTypeDoes
sessionIdstringShows it in the window that views this session. Without it: the last active window
timeoutMsnumberRejects with DialogTimeoutError after this many ms
signalAbortSignalCloses 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 appDesktop and web apps
WhereA box over the app, titled with the dialog kind and the extension id. Dialogs queue, oldest first. App shortcuts are off while one showsA dialog over the window, one at a time
confirmTwo buttons: Enter picks, Y and N answer. danger paints the confirm button redConfirm and Cancel. danger uses the danger button
promptA line input. secret masks itA text field. secret masks it
selectA list with a filter line past 6 options, checkboxes with multi (Space toggles), actions as extra rowsA searchable list with groups, checkboxes and action buttons
formThe settings form. Each value is checked on submitThe settings rows. The web app shows each refused value under its field and asks again
CancelEsc 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.

TypeScript
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')
FieldTypeDoes
textstringRequired
tone'info' | 'success' | 'warn' | 'error'Default 'info'
actions{ id, label }[]Buttons. The toast resolves with the clicked id
timeoutMsnumberWithdraws the toast after this many ms, counted from when it shows. It then resolves undefined
sessionIdstringShows 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 appDesktop and web apps
ToastA line near the bottom, after the extension id, colored by tone. The newest 3 show, each for 6 s or timeoutMsA card with an icon per tone and a close button. It stays until timeoutMs, a click, or the close button
Toast with actionsA box over the app with one button per action. It blocks the app until answered. Esc dismissesThe same card, with buttons
notifyRings the terminal bell and adds a line to the transcript. sound is ignoredDesktop: 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.

TypeScript
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 } : {}),
})
CallResolves
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 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 appDesktop and web apps
panel.openOpens the rail on the tab, with focus. Always truetrue only for a known tab. focus is not passed on
panel.closeCloses the rail, whatever tab it showsCloses that tab only if it is the one shown
panel.stateThe one open tab, or noneThe open tabs
navigate({ session })Switches, even to an unknown idSwitches

Composer control#

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

TypeScript
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 })
  },
})
CallTerminal appDesktop and web apps
insert(text, { sessionId? })Inserts at the caretAppends at the end of the draft
setText(text, { sessionId? })Replaces the draftReplaces the draft text and keeps attachments
attach(files, { sessionId? }) with bytesSaves each file in the app's cache and attaches it like a paste. false when any file is refusedAttaches each file. true when at least one attached
attach(files, { sessionId? }) with { path }Reads the file like a pasted pathDesktop: 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.
TypeScript
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 runs its sessions. Check it before each use (examples/extension-kitchen-sink/desktop.ts):

TypeScript
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)`))
}
MemberTypeDoes
electron<T>() => TThe 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) => DisposableCalls listener({ id }). Removed with the extension

For menus, use menu items: 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.