Skip to content

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):

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

FieldTypeDoes
idstringRequired. Unique within the extension
titleBindable<string>Required. The palette row. A Readable keeps it live (bindable fields)
descriptionBindable<string>Secondary text in the palette
groupstringPalette section
keywordsstringExtra 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'
enabledBindable<boolean>false hides it from the palette and turns its keybinding off
paletteboolean | { featured?, suggested? }false keeps it out of the palette. featured lists it when the palette opens. suggested lists it under Suggested
bindstring | string[]Default keybindings. Users can rebind them (keybindings)
scopeKeyScopeWhere focus must be for the binding to fire. Default 'global'
editablebooleanThe binding also fires while a text field has focus
modestringThe binding fires only while this mode is on top
hintbooleanShows 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
stepsCommandStep[]A / pipeline, instead of run (steps)

The invocation#

run gets one object. Its fields depend on how the command started:

FieldTypeHolds
source'palette' | 'slash' | 'key' | 'menu' | 'api'What started it
sessionIdstring | undefinedThe session in view, when there is one
argsstringText after /<name> . Empty from other sources unless commands.run passed some
signalAbortSignalAborts when the user cancels or the extension unloads
emit, runAgent, generateData, interaction, spawnAgents, appendContext, session, data, argv, attachments, cwd, loggerThe 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:

MemberDoes
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
interactionConfirm, forms and plan approval. Forwards to the dialogs
sessionThe surrounding session
dataA bag shared across steps
argv, attachments, cwd, logger, signalThe 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:

TypeScript
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 an editable binding. 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:

ScopeFocus is in
globalAnywhere but a modal. The default
composerThe prompt
transcriptThe transcript
sidebarThe session list
modalA 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:

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

TypeScript
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() })
MemberDoes
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:

MemberDoes
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:

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

TypeScript
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' }
  },
})
FieldTypeDoes
idstringRequired. Unique within the extension
groupstringSection for items that set none
items({ query, sessionId?, busy }) => itemsRequired. The entries: { id, title, description?, group?, keywords?, icon?, kind?, checked?, keybinding?, disabled?, searchOnly? }
run(itemId, { sessionId? }) => resultRequired. Runs a pick
subscribe(refresh) => unsubscribeCall refresh when the items change while the palette is open

run may return a follow-up:

ResultDoes
{ 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
NothingCloses the palette

Run and list commands#

MemberDoes
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:

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

AppPalette and /Keys
Terminal appThe palette and the prompt's / menu list extension commands next to the built-in onesThe 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 appThe palette and the composer's / menuExtension 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 appThe palette and the composer's / menuThe 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, runAgent and emit are absent when no session is active. Use when: '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).
  • false matters 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 register with the same id is ignored, with a warning. A / name or alias another command already took is dropped the same way: the first registration wins.
  • UI: dialogs, toasts and the composer that commands drive.
  • Hooks: the prompt interceptor sees typed text that is not a / command.
  • Extensions: command sources and Markdown commands.