Commands, keybindings, and interception

The headless bb.commands plugin owns app commands. It owns the catalog, keybindings, context keys, execution, and the interception chain.

The bb.commands-ui plugin owns the default palette. The palette reads the command catalog and runs its active commands.

The kernel owns core CLI verbs. Plugins add other top-level verbs through the keyed bb.commands.cli service contract.

Use this when

  • Add an action to menus, keybindings, and the palette. Register one app command, and all command sources use it.
  • Give an action a default shortcut. Add keybindings to the command record and let user overrides stay with its ID.
  • Stop or change a command call. Add a member to the shared command interception chain.
  • Add a top-level bb verb. Claim one non-core key through bb.commands.cli.
  • Replace the command palette. Claim its surface and keep the headless command service.

What you build

You build the acme.review plugin. It adds one app command, one interceptor, and the bb review CLI verb.

The app command opens a review side panel. Its shortcut works only on a thread route.

The interceptor stops the command outside a thread. It also adds data to the command input.

Know the command contracts

The command system uses four service contracts and one UI surface.

ID Kind Replaceable Purpose
bb.commands single yes Supplies the active command service.
bb.commands.command keyed yes Supplies one active command for each command ID.
bb.commands.intercept chain no Runs ordered wrappers around app commands.
bb.commands.cli keyed no Supplies one non-core top-level CLI verb for each key.
bb.commands-ui.palette single surface yes Presents the active app command catalog.

The bb.commands plugin declares no surfaces. Menus, keybindings, direct callers, and the palette use its service.

Replaceable claims join the global winner picker. This rule applies to the command service, each command key, and the palette surface.

Steps

1. Claim the command contracts

Put static claims in bb.plugin.jsonc. Use a dot-separated command ID under your plugin ID.

// bb.plugin.jsonc
{
  "id": "acme.review",
  "version": "2.0.0",
  "description": "Review commands for bb threads.",
  "claims": [
    {
      "service": "bb.commands.command",
      "key": "acme.review.open"
    },
    { "service": "bb.commands.intercept" },
    { "service": "bb.commands.cli", "key": "review" }
  ],
  "artifacts": {
    "app": "./dist/app.js",
    "server": "./dist/server.js"
  }
}

The build writes contract.json next to this manifest. The loader validates both files as one unit.

The loader stages all three claims. It commits them only after both factories succeed.

2. Add the app command

The app factory receives a typed api object. All registrations get automatic cleanup when the plugin unloads.

// src/app.tsx
import { definePlugin } from "@get-bb/plugin/app";

function readNote(input: unknown): string | null {
  if (input === null || typeof input !== "object" || Array.isArray(input)) {
    return null;
  }
  if (!("note" in input) || typeof input.note !== "string") return null;
  return input.note;
}

export default definePlugin((api) => {
  api.commands.add({
    id: "acme.review.open",
    title: "Open the review panel",
    description: "Open review details for the current thread.",
    category: "Review",
    icon: "GitPullRequest",
    discoverability: {
      searchable: true,
      keywords: ["pull request", "review", "diff"],
      order: 20,
    },
    defaultKeybindings: [
      {
        chord: { key: "r", mod: true, shift: true },
        platform: "desktop",
        when: { all: ["threadRoute"], none: ["modalOpen"] },
      },
    ],
    isAvailable: ({ route }) => route.threadId !== null,
    async run({ route, input, navigation }) {
      if (route.threadId === null) return;

      navigation.openPanel({
        surface: "bb.thread-ui.sidePanels",
        key: "acme.review.details",
        title: "Review",
        params: {
          threadId: route.threadId,
          note: readNote(input),
        },
      });
    },
  });

  api.commands.intercept({
    id: "acme.review.require-thread",
    matches: (commandId) => commandId === "acme.review.open",
    async run(invocation, next) {
      if (invocation.route.threadId === null) {
        return {
          ok: false,
          error: {
            code: "not_available",
            message: "Open a thread first.",
          },
        };
      }

      const input =
        invocation.input !== null &&
        typeof invocation.input === "object" &&
        !Array.isArray(invocation.input)
          ? { ...invocation.input, checkedBy: "acme.review" }
          : { value: invocation.input, checkedBy: "acme.review" };

      return next({ ...invocation, input });
    },
  });
});

The loader checks acme.review.open against the manifest claim. A missing or different key stops the commit.

The active keyed winner owns all command data. This data includes the title, defaults, availability test, and executor.

3. Understand keybindings

A command provider supplies zero or more default keybindings. The profile stores only user overrides.

interface KeyChord {
  key: string;
  mod?: boolean;
  control?: boolean;
  meta?: boolean;
  alt?: boolean;
  shift?: boolean;
}

interface DefaultKeybinding {
  chord: KeyChord;
  platform?: "all" | "mac" | "windows" | "linux" | "web" | "desktop";
  when?: {
    all?: readonly string[];
    none?: readonly string[];
  };
}

An absent override uses the current winner's defaults. A null override disables all defaults for that command.

The bb.commands plugin stores profile overrides through the kernel bb.preferences port.

A winner change updates the catalog and default keybindings in one transaction. User overrides remain attached to the command ID.

The app checks the command availability and all active when keys. It ignores key events during text composition.

Two installed defaults can use the same chord. bb marks both as conflicts and activates neither one.

The keybinding editor rejects a conflict by default. Use conflict: "replace" to replace the conflicting user override in one write.

4. Understand the interception chain

Every app command source enters the same chain. Palette rows, menus, keybindings, and direct calls have the same behavior.

Each interceptor receives this data:

interface CommandInvocation {
  commandId: `${string}.${string}`;
  input: JsonValue | null;
  source: "api" | "keybinding" | "menu" | "palette";
  route: {
    projectId: string | null;
    threadId: string | null;
    paneId: string | null;
  };
  signal: AbortSignal;
}

An interceptor can call next() without an argument. It can also call next() with a new input value.

It cannot change commandId, source, or route. It can return an error result to stop the command.

Errors use not_available, not_found, stopped, or failed. An error can also name the plugin that stopped the command.

The chain calls the active command winner after its last member. The runtime rejects a second next() call from one member.

The inspector shows the chain order. Plugin unload removes the member through automatic cleanup.

5. Add the CLI verb

The server factory provides the bb.commands.cli key. The key is the first word after bb.

// src/server.ts
import { defineServerPlugin } from "@get-bb/plugin/server";

export default defineServerPlugin((api) => {
  api.cli.add({
    name: "review",
    summary: "Read review data for a thread",
    usage: "bb review show [--thread <id>]",
    commands: [
      {
        name: "show",
        summary: "Show review data for one thread",
        usage: "bb review show [--thread <id>]",
      },
    ],
    async run(argv, context) {
      const option = argv.indexOf("--thread");
      const threadId =
        option >= 0 ? (argv[option + 1] ?? null) : context.threadId;

      if (threadId === null) {
        return { exitCode: 2, stderr: "A thread ID is necessary.\n" };
      }

      context.signal.throwIfAborted();
      return { exitCode: 0, stdout: `Review data for ${threadId}\n` };
    },
  });
});

A CLI key must match ^[a-z][a-z0-9-]*$. The manifest key and name must match.

The kernel checks its core verb table first. A plugin cannot claim or replace a core verb.

The bb.commands.cli contract is keyed and not replaceable. A duplicate non-core key fails graph validation.

The kernel reads summary, usage, and commands without handler execution. The build includes these fields in contract.json.

The context always has cwd and signal. Its threadId and projectId values can be null.

The combined UTF-8 output limit is 1,048,576 bytes. An oversized result returns plugin_cli_output_too_large with byte counts.

CLI handlers run in the server artifact. Use a typed host role when the handler needs work on another machine.

6. Run a command directly

Use the headless service from a React component. The call still uses availability checks and the interception chain.

import { useCommands } from "@bb/commands/app";

export function ReviewButton() {
  const commands = useCommands();

  return (
    <button
      type="button"
      onClick={() =>
        void commands.run("acme.review.open", { note: "Opened from the toolbar" })
      }
    >
      Review
    </button>
  );
}

The service also supplies catalog reads, keybinding edits, context keys, and subscriptions.

Method Purpose
list, get, has Read active command winners.
run Run one command through the chain.
bindings Read defaults, overrides, conflicts, and effective keybindings.
setBindings, resetBindings, resetAllBindings Change profile overrides.
assertContext Activate a counted context key and return its release function.
subscribe Receive the current snapshot and later revisions.

The palette is only the UI

The bb.commands-ui plugin owns no commands, keybindings, executors, or interceptors. It presents active entries from bb.commands.

Its default palette lists available and searchable commands. Its run() callback uses source: "palette".

The bb.commands-ui.palette.open command opens the palette. The UI plugin owns this command and its default Mod+K keybinding.

A palette replacement changes only the presentation. The current registry and interception chain remain active.

// bb.plugin.jsonc for a separate palette plugin
{
  "id": "acme.compact-palette",
  "version": "2.0.0",
  "claims": [{ "surface": "bb.commands-ui.palette" }],
  "requires": [{ "service": "bb.commands", "range": "^1" }],
  "artifacts": { "app": "./dist/app.js" }
}
// src/app.tsx
import { definePlugin } from "@get-bb/plugin/app";
import { CompactPalette } from "./CompactPalette";

export default definePlugin((api) => {
  api.surfaces.provide(
    "bb.commands-ui.palette",
    ({ props, Original }) => {
      if (props.entries.length > 20) {
        return <CompactPalette {...props} />;
      }
      return <Original {...props} />;
    },
  );
});

This replaceable single surface receives Original. Render it when your replacement does not apply.

The palette receives only active command winners. It cannot select an inactive claimant for a keyed command.

What happens at runtime

The loader plans claims before it executes plugin code. Required service edges control activation order.

The app factory registers the command and chain member. The server factory registers the CLI verb.

A keybinding, menu, palette row, or direct call creates one command invocation. The chain can stop it or change its input.

The active keyed command winner runs last. One command result returns to the original source.

A provider change updates the catalog, availability, and default keybindings as one revision. Subscribers receive one coherent snapshot.

Pitfalls

  • Use dot-separated contract IDs. Use acme.review.open, not a slash-separated ID.
  • Keep a command ID stable. Keybinding overrides and direct callers depend on it.
  • Do not register a palette-only action. Register a command and let the palette list it.
  • Keep isAvailable() and matches() fast. The app can call them during user input.
  • Call next() at most once. The runtime rejects a second call from the same interceptor.
  • Do not change the route, source, or command ID in an interceptor. Change only the input.
  • Do not claim a core CLI verb. The kernel always keeps its recovery commands.
  • Keep CLI output below 1 MiB. Page large results or write them to a suitable file service.

See also

  • Design 11 defines bb.commands, bb.commands.cli, and bb.commands-ui.palette.
  • The kernel design defines the core CLI verb table and recovery behavior.
  • The layout guide defines bb.thread-ui.sidePanels and hierarchical surfaces.