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#
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.
| 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) |
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 | See below |
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.
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):
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.
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. 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:
controlswithkind: 'select'(one option from a menu) orkind: 'multi'(several;view[id]is a list). Read them withpanelViewValueandpanelViewValues. Plain controls stay side by side.search: { placeholder }adds a search field.loadgets its text aslc.searchonce typing rests.more: { cursor }adds Show more.loadgetslc.cursor; the rows it returns are appended to the sections of the same id.
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.
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.
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.
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.
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 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. 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.
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 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.
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 that says which sessions it applies to. Sidebar has their fields and a full example.
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 (CtrlF) | 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.
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), values? | The values by key, or undefined. Each value is checked against the schema |
askUser | QuestionPayload: questions, as the ask_user tool 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.
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.
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 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.
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.providerhasid,trigger(one character),label,suggest(query, ctx, signal)returning items, andparseReferences(text, ctx)returning the references it finds in the draft.ctx.composer.autocomplete.register(provider)adds ghost text.providerhasid,suggest({ text, cursor, cwd, signal })returning the text to append (or a list to cycle through, ornull), anddeadlineMswhen the source is slow.
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):
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: 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
LinkHandlerwith a widematchtakes links the user expected in the browser. Match narrowly, and returnfalsefor a link you cannot open. composer.badgeswithoutsessionIdthrows. A badge for "every session" is onectx.addper 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.notifyis skipped while the app has focus in the desktop and web apps. Use a toast for something the user must see now.ctx.composer.attachwith 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
sessionIdthe hook gives you. ctx.desktopis absent when the daemon runs the extension, even inside the desktop app. Code that needs it must also work without it.