Extension commands
Register one command as a palette entry, a slash command and a keybinding, add dynamic palette entries, and switch keymap modes.
A command is one action an extension offers. ctx.commands.register makes it a palette entry, a / command and a keybinding at once, each optional. ctx.commands.provider adds palette entries computed when the palette opens. ctx.keymap turns on layers of bindings (modes).
Use a command for anything the user starts. For something the model starts, register a tool. For a reusable prompt with no code, write a Markdown command.
Register a command#
From the kitchen-sink example (examples/extension-kitchen-sink/commands.ts):
ctx.commands.register({
id: 'bump',
title: 'Bump the counter',
description: 'Adds one to the counter.',
group: 'Kitchen sink',
keywords: 'counter increment',
palette: { featured: true },
bind: 'mod+shift+y',
hint: true,
slash: {
name: 'kitchen-bump',
aliases: ['kbump'],
argumentHint: '[by]',
complete: prefix => ['1', '5', '10'].filter(v => v.startsWith(prefix)).map(value => ({ value, label: `+${value}` })),
},
run: async ({ args, source }) => {
const by = Number.parseInt(args, 10) || 1
ctx.logger.info(`bump by ${by} from ${source}`)
},
})The command is published as <extension id>.<id>: kitchen-sink.bump. Other code runs it by that id.
| Field | Type | Does |
|---|---|---|
id | string | Required. Unique within the extension |
title | Bindable<string> | Required. The palette row. A Readable keeps it live (bindable fields) |
description | Bindable<string> | Secondary text in the palette |
group | string | Palette section |
keywords | string | Extra words the palette matches |
icon | { gui?: string, tui?: string } | An icon name for the desktop and web apps, and a glyph for the terminal app |
when | 'always' | 'session' | 'idle' | When the command is offered: always, only with a session, or only while no run is going. Default 'always' |
enabled | Bindable<boolean> | false hides it from the palette and turns its keybinding off |
palette | boolean | { featured?, suggested? } | false keeps it out of the palette. featured lists it when the palette opens. suggested lists it under Suggested |
bind | string | string[] | Default keybindings. Users can rebind them (keybindings) |
scope | KeyScope | Where focus must be for the binding to fire. Default 'global' |
editable | boolean | The binding also fires while a text field has focus |
mode | string | The binding fires only while this mode is on top |
hint | boolean | Shows the binding in the footer and statusbar hints |
slash | { name, aliases?, complete?, argumentHint? } | Also a / command. complete(prefix) returns argument suggestions { value, label?, description? } |
run | (invocation) => void | false | Promise<…> | Runs the command. From a keybinding, false passes the key on |
steps | CommandStep[] | A / pipeline, instead of run (steps) |
The invocation#
run gets one object. Its fields depend on how the command started:
| Field | Type | Holds |
|---|---|---|
source | 'palette' | 'slash' | 'key' | 'menu' | 'api' | What started it |
sessionId | string | undefined | The session in view, when there is one |
args | string | Text after /<name> . Empty from other sources unless commands.run passed some |
signal | AbortSignal | Aborts when the user cancels or the extension unloads |
emit, runAgent, generateData, interaction, spawnAgents, appendContext, session, data, argv, attachments, cwd, logger | The step context. A / invocation carries it in full. Other sources leave out the members that need a session when none is active |
The step context members, as / commands get them:
| Member | Does |
|---|---|
emit(block) | Shows a block in the transcript and saves it. The model never sees it |
runAgent(prompt, { display? }) | Runs the conversation with prompt as a user turn and resolves with the result. Waits for the session's run slot |
generateData({ schema, prompt, … }) | One silent model call that returns JSON checked against schema. Nothing goes to the transcript |
spawnAgents(tasks, { maxConcurrent? }) | Runs isolated child agents in parallel and resolves when all settle |
appendContext(text, { display? }) | Saves a note the model reads from the next run on, without starting one |
interaction | Confirm, forms and plan approval. Forwards to the dialogs |
session | The surrounding session |
data | A bag shared across steps |
argv, attachments, cwd, logger, signal | The words of args, pasted files, the working folder, a logger, an abort signal |
spawnAgents, appendContext, session and interaction are absent on hosts without them. The type marks every member optional, so check before you call.
Slash pipelines#
steps runs a / command as a pipeline. Each step gets the step context. A step returns { data } to merge into data for the next steps. A step that returns nothing goes on. From the kitchen-sink example:
ctx.commands.register({
id: 'recipe',
title: 'Cook a recipe',
slash: { name: 'kitchen-recipe', argumentHint: '<recipe>' },
steps: [
{
run: (step) => {
const recipe = step.args.trim()
if (!recipe) {
step.emit({ text: 'Name a recipe: `/kitchen-recipe tea`.' })
return undefined
}
step.emit({ text: `**${recipe}**` })
return { data: { recipe } }
},
},
{
run: async (step) => {
const recipe = step.data.recipe as string | undefined
if (recipe)
await step.runAgent(`Suggest one twist for the recipe "${recipe}". One sentence.`)
},
},
],
})A command with steps needs slash. From the palette or a key, a steps-only command puts /<name> in the composer for the user to finish. Give it run too if those should run it at once.
Keybindings#
bind takes a chord or a list of chords. A chord is modifiers and a key joined by +: ctrl+alt+c, shift+f5. mod is Cmd on macOS and Ctrl elsewhere. One string may list alternatives: 'ctrl+w | cmd+w'.
The app refuses some chords and logs a warning:
- Esc and CtrlC. They stay with the app.
- A bare printable key (
j) on aneditablebinding. It would fire on every keystroke. - A chord it cannot parse.
Users can rebind every command in their keybindings (terminal keys, desktop keyboard). ctx.keymap.bindings(commandId) returns the chords in effect after their changes.
Scopes and fall-through#
scope limits a binding to one area. The binding fires only when focus is there:
| Scope | Focus is in |
|---|---|
global | Anywhere but a modal. The default |
composer | The prompt |
transcript | The transcript |
sidebar | The session list |
modal | A modal dialog |
panel:<id> | The extension's panel <id> |
screen:<id> | The extension's screen <id> |
A binding does not fire while a text field has focus unless it is editable. when: 'session' needs a session in view. when: 'idle' also needs that session to have no run going.
When run returns false, the key goes on to the next binding, then to the app. The kitchen-sink example inserts text into the composer only when a session is in view:
ctx.commands.register({
id: 'insert-count',
title: 'Insert the counter',
bind: 'ctrl+alt+c',
scope: 'composer',
editable: true,
when: 'session',
run: async ({ sessionId }) => {
if (!sessionId)
return false
await ctx.composer.insert('counter=1', { sessionId })
},
})Keymap modes#
A mode is a layer of bindings. A binding with mode set fires only while that mode is on top of the mode stack. ctx.keymap.mode.push(name) turns a mode on and returns a Disposable that turns it off. From the kitchen-sink example:
import type { Disposable } from 'zidane'
const COOKING = 'kitchen-sink.cooking'
let cooking: Disposable | undefined
ctx.commands.register({
id: 'cooking-mode',
title: 'Toggle cooking mode',
bind: 'mod+shift+u',
run: () => {
if (cooking) {
cooking.dispose()
cooking = undefined
}
else {
cooking = ctx.keymap.mode.push(COOKING)
}
},
})
ctx.commands.register({ id: 'cooking-bump', title: 'Bump', palette: false, bind: 'j', mode: COOKING, hint: true, run: () => ctx.logger.info('bump') })
ctx.commands.register({ id: 'cooking-exit', title: 'Leave cooking mode', palette: false, bind: 'q', mode: COOKING, run: () => cooking?.dispose() })| Member | Does |
|---|---|
mode.push(name, { priority?, sessionId? }) | Turns the mode on. A higher priority stays on top of modes pushed later. sessionId limits it to the windows that show that session |
mode.current() | The mode on top, if any |
bindings(commandId) | The chords in effect for a command, after user changes |
Name modes <extension id>.<name>, so two extensions do not share one by accident.
Read and press keys#
Three more members read the whole keymap and press a key as the user would:
| Member | Does |
|---|---|
list() | Every command the app offers, built-in and extension, with the chords in effect after user changes |
active({ sessionId? }) | The mode stack, the mode on top last. With sessionId, the modes of that session too |
dispatch(chord, { sessionId? }) | Presses chord in the window that shows sessionId (else the window used last). Resolves true when a binding took the key |
A list() entry is { id, title, bindings, scope, mode?, editable, extensionId? }. extensionId is absent for a built-in command or key. A cheat sheet panel lists them:
const lines = ctx.keymap.list()
.filter(entry => entry.bindings.length > 0)
.map(entry => `${entry.bindings.join(', ')}: ${entry.title}`)
ctx.logger.info(lines.join('\n'))
const modes = ctx.keymap.active()
if (modes.at(-1) !== 'kitchen-sink.cooking')
await ctx.keymap.dispatch('mod+shift+u')dispatch sends the chord as written: the window turns mod into Cmd or Ctrl. It rejects with NoUiError when no window is open, throws a TypeError for a chord it cannot parse, and resolves false when the window fails to answer.
Dynamic palette entries#
ctx.commands.provider adds palette entries that items computes when the palette opens. run handles a pick. From the kitchen-sink example:
const recipes = [{ id: 'tea', title: 'Tea' }, { id: 'toast', title: 'Toast' }]
ctx.commands.provider({
id: 'recipes',
group: 'Kitchen recipes',
items: ({ query }) => recipes
.filter(r => r.title.toLowerCase().includes(query.toLowerCase()))
.map(r => ({ id: r.id, title: `Recipe: ${r.title}` })),
run: (itemId) => {
const recipe = recipes.find(r => r.id === itemId)
return recipe
? { kind: 'insert', text: `/kitchen-recipe ${recipe.id}` }
: { kind: 'notify', text: 'That recipe is gone.', tone: 'warn' }
},
})| Field | Type | Does |
|---|---|---|
id | string | Required. Unique within the extension |
group | string | Section for items that set none |
items | ({ query, sessionId?, busy }) => items | Required. The entries: { id, title, description?, group?, keywords?, icon?, kind?, checked?, keybinding?, disabled?, searchOnly? } |
run | (itemId, { sessionId? }) => result | Required. Runs a pick |
subscribe | (refresh) => unsubscribe | Call refresh when the items change while the palette is open |
run may return a follow-up:
| Result | Does |
|---|---|
{ kind: 'items', title?, placeholder?, items } | Opens a nested list |
{ kind: 'prompt', text } | Sends text as a prompt |
{ kind: 'insert', text } | Puts text in the composer |
{ kind: 'notify', text, tone? } | Shows a short message |
| Nothing | Closes the palette |
Run and list commands#
| Member | Does |
|---|---|
commands.run(id, { args?, sessionId? }) | Runs any command by its published id, built-in ones included. Resolves false when there is none |
commands.list() | Every command the host offers: { id, title, slash?, bind? } |
Bindable fields#
title, description and enabled take a value or a Readable. A store selector keeps the palette row in step with the data:
import { z } from 'zod'
const counter = ctx.storage.store('counter', z.object({ count: z.number() }), { count: 0 }, { scope: 'project' })
ctx.commands.register({
id: 'show-count',
title: counter.select(c => `Counter: ${c.count}`),
enabled: counter.select(c => c.count > 0),
run: () => ctx.logger.info(String(counter.get().count)),
})In each app#
| App | Palette and / | Keys |
|---|---|---|
| Terminal app | The palette and the prompt's / menu list extension commands next to the built-in ones | The app's own keys run first. Extension bindings get the keys it leaves. hint shows the binding in the footer, for global and composer commands, while their mode is on top |
| Desktop app | The palette and the composer's / menu | Extension bindings run before the app's shortcuts: a key one of them can take is claimed, then runs the command. Other keys go to the app |
| Web app | The palette and the composer's / menu | The page dispatches keys by scope, editable, mode and priority, as the other apps do |
In daemon mode, commands run in the daemon. The window lists them, sends keys and picks, and draws the results.
ctx.keymap.dispatch goes to one window. The terminal app presses the chord through its own key handlers and answers true when one of them took it; a chord for a session the window does not show answers false. The desktop and web apps press it in the page, through the same key dispatch as a typed key.
Pitfalls#
- From the palette or a key,
runAgentandemitare absent when no session is active. Usewhen: 'session', or check before you call. - A command bound to a key can run with no window to show a dialog, on a headless server for example. Catch
NoUiError(UI). falsematters only from a keybinding. From the palette or/, it does nothing.- A refused chord is dropped with a warning in the log, not an error. Check the log if a key does nothing.
- Command ids are unique within the extension. A second
registerwith the sameidis ignored, with a warning. A/name or alias another command already took is dropped the same way: the first registration wins.
Related#
- UI: dialogs, toasts and the composer that commands drive.
- Hooks: the
promptinterceptor sees typed text that is not a/command. - Extensions: command sources and Markdown commands.