# 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](https://zidane.si/docs/extensions-catalogs.md#tools). For a reusable prompt with no code, write a [Markdown command](https://zidane.si/docs/extensions.md#markdown-commands).

## Register a command

From the kitchen-sink example (`examples/extension-kitchen-sink/commands.ts`):

```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](#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](#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](#keymap-modes) 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](#slash-pipelines)) |

## 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](https://zidane.si/docs/extensions-ui.md) |
| `session` | The surrounding session |
| `data` | A bag shared across [steps](#slash-pipelines) |
| `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:

```ts
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 `Ctrl`+`C`. 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](https://zidane.si/docs/tui-keys.md), [desktop keyboard](https://zidane.si/docs/desktop-keyboard.md)). `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:

```ts
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:

```ts
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:

```ts
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:

```ts
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:

```ts
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, `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](https://zidane.si/docs/extensions-ui.md)).
- `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.

## Related

- [UI](https://zidane.si/docs/extensions-ui.md): dialogs, toasts and the composer that commands drive.
- [Hooks](https://zidane.si/docs/extensions-hooks.md#prompt): the `prompt` interceptor sees typed text that is not a `/` command.
- [Extensions](https://zidane.si/docs/extensions.md#commands): command sources and Markdown commands.
