bb.commands and bb.commands-ui

The headless bb.commands plugin owns command data and execution. The bb.commands-ui plugin presents that registry in the command palette.

Purpose

bb.commands gives each app command one stable key and one active provider. All consumers use the same catalog, keybindings, and interception chain.

bb.commands-ui supplies the default command palette. The palette is only the UI for the registry and does not own another action registry.

The kernel runs its core CLI verbs itself. The bb.commands.cli contract adds non-core top-level verbs without load-order arbitration.

The bb.commands plugin (data)

bb.commands ships headless because keybindings, menus, direct callers, and the palette all consume its command service.

The plugin owns the keyed command registry, keybindings, the command interception chain, and plugin CLI verbs. It declares no surfaces.

Surfaces

ID kind replaceable props contract sketch notes
This plugin declares no surfaces.

bb.commands-ui owns the command palette. A command-service replacement does not need to implement a UI contract.

Services

The keyed and chain contracts add entries to the bb.commands service. They render no React element.

ID kind replaceable tier interface notes
bb.commands single yes app CommandsService Provides catalog reads, dispatch, context keys, and effective keybindings.
bb.commands.command keyed yes app AppCommand Uses the command ID as its key. One winner supplies each command.
bb.commands.intercept chain no app AppCommandInterceptor Lets each member inspect, change, stop, or continue an invocation.
bb.commands.cli keyed no server CliVerb Uses one top-level non-core CLI verb as its key. A duplicate key fails at load.

bb.commands.command

A command key uses the owner namespace. First-party keys start with bb.. Third-party keys start with the plugin ID.

Examples include bb.commands-ui.palette.open, bb.threads.new, and acme.github.openPullRequest. A plugin can claim another owner's key only when it replaces that command.

The manifest claim names the service contract and the key:

{
  "claims": [
    { "service": "bb.commands.command", "key": "acme.github.openPullRequest" }
  ]
}

The app factory provides the behavior through api.commands.add(). The loader checks the provided key against the static claim before commit.

type CommandId = `${string}.${string}`;
type JsonValue =
  | string
  | number
  | boolean
  | null
  | readonly JsonValue[]
  | { readonly [key: string]: JsonValue };

interface AppCommand {
  id: CommandId;
  title: string;
  description?: string;
  category?: string;
  icon?: string;
  discoverability?: {
    searchable: boolean;
    keywords?: readonly string[];
    order?: number;
  };
  defaultKeybindings?: readonly DefaultKeybinding[];
  isAvailable?(context: AppCommandContext): boolean;
  run(context: AppCommandContext): void | Promise<void>;
}

interface AppCommandContext {
  commandId: CommandId;
  input: JsonValue | null;
  source: "api" | "keybinding" | "menu" | "palette";
  route: {
    projectId: string | null;
    threadId: string | null;
    paneId: string | null;
  };
  signal: AbortSignal;
  navigation: {
    openPanel(options: {
      surface: "bb.thread-ui.sidePanels";
      key: string;
      title?: string;
      params?: JsonValue;
    }): boolean;
  };
}

The active keyed winner owns the title, defaults, availability test, and executor. A winner change updates the catalog and effective default keybindings as one transaction.

A user keybinding override stays attached to the command ID. It does not stay attached to a provider plugin.

Keybindings

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

type KeybindingPlatform = "all" | "mac" | "windows" | "linux" | "web" | "desktop";

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

interface CommandWhen {
  all?: readonly string[];
  none?: readonly string[];
}

interface DefaultKeybinding {
  chord: KeyChord;
  platform?: KeybindingPlatform;
  when?: CommandWhen;
}

interface KeybindingOverride {
  commandId: CommandId;
  bindings: readonly DefaultKeybinding[] | null;
}

null disables all defaults for one command. An absent override uses the active command provider's defaults.

The app dispatches a keybinding only when the command and its when expression are active. It ignores key events during text composition.

The keybinding editor rejects a conflict by default. A caller can use conflict: "replace" to remove the conflicting user override in the same write.

Two installed defaults can still conflict. The catalog marks both defaults as conflicts and activates neither one until the user selects a keybinding.

bb.commands.intercept

Every app command source enters the same chain. A palette row, a keybinding, a menu, and commands.run() have equal behavior.

interface CommandInvocation {
  commandId: CommandId;
  input: JsonValue | null;
  source: AppCommandContext["source"];
  route: AppCommandContext["route"];
  signal: AbortSignal;
}

type CommandOutcome =
  | { ok: true; consumed: boolean }
  | {
      ok: false;
      error: {
        code: "not_available" | "not_found" | "stopped" | "failed";
        message: string;
        byPluginId?: string;
      };
    };

type CommandNext = (invocation?: CommandInvocation) => Promise<CommandOutcome>;

interface AppCommandInterceptor {
  id: string;
  matches(commandId: CommandId): boolean;
  run(invocation: CommandInvocation, next: CommandNext): Promise<CommandOutcome>;
}

An interceptor can call next() with the same invocation. It can also call next() with a new input value.

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

The chain then calls the active keyed command winner. The runtime rejects a second next() call from one interceptor.

The inspector shows each chain member and its order. A plugin unload removes its member through automatic cleanup.

bb.commands service

The app service operates on active command winners. It publishes one revision for each atomic catalog or keybinding change.

Method Signature Purpose
list (options?: CommandListOptions) => readonly CommandCatalogEntry[] Lists active command winners.
get (commandId: CommandId) => CommandCatalogEntry | null Reads one active command winner.
has (commandId: CommandId) => boolean Reports whether an active command exists.
run (commandId: CommandId, input?: JsonValue | null, options?: CommandRunOptions) => Promise<CommandOutcome> Runs a command through the interceptor chain.
bindings () => KeybindingSnapshot Reads defaults, overrides, conflicts, and effective keybindings.
setBindings (commandId: CommandId, bindings: readonly DefaultKeybinding[] | null, options?: SetBindingsOptions) => Promise<void> Writes one profile override.
resetBindings (commandId: CommandId) => Promise<void> Removes one profile override.
resetAllBindings () => Promise<void> Removes all profile overrides.
assertContext (key: string) => () => void Activates one counted context key and returns its release function.
subscribe (listener: (snapshot: CommandsSnapshot) => void) => () => void Sends the current snapshot and later revisions.
interface CommandListOptions {
  availableOnly?: boolean;
  searchableOnly?: boolean;
  query?: string;
}

interface CommandCatalogEntry {
  command: Omit<AppCommand, "run" | "isAvailable">;
  providerPluginId: string;
  available: boolean;
  effectiveKeybindings: readonly DefaultKeybinding[];
  keybindingConflict: boolean;
}

interface CommandRunOptions {
  source?: AppCommandContext["source"];
  signal?: AbortSignal;
}

interface SetBindingsOptions {
  conflict?: "reject" | "replace";
}

interface KeybindingSnapshot {
  revision: number;
  overrides: readonly KeybindingOverride[];
  effective: Readonly<Record<CommandId, readonly DefaultKeybinding[]>>;
  conflicts: readonly {
    chord: KeyChord;
    commandIds: readonly CommandId[];
  }[];
}

interface CommandsSnapshot {
  revision: number;
  commands: readonly CommandCatalogEntry[];
  keybindings: KeybindingSnapshot;
  contextKeys: ReadonlySet<string>;
}

interface CommandsService {
  list(options?: CommandListOptions): readonly CommandCatalogEntry[];
  get(commandId: CommandId): CommandCatalogEntry | null;
  has(commandId: CommandId): boolean;
  run(
    commandId: CommandId,
    input?: JsonValue | null,
    options?: CommandRunOptions,
  ): Promise<CommandOutcome>;
  bindings(): KeybindingSnapshot;
  setBindings(
    commandId: CommandId,
    bindings: readonly DefaultKeybinding[] | null,
    options?: SetBindingsOptions,
  ): Promise<void>;
  resetBindings(commandId: CommandId): Promise<void>;
  resetAllBindings(): Promise<void>;
  assertContext(key: string): () => void;
  subscribe(listener: (snapshot: CommandsSnapshot) => void): () => void;
}

The app plugin uses the kernel bb.preferences port to store overrides. A replacement service must preserve command IDs and override values.

bb.commands.cli service

Each key is one top-level verb after bb. The key must match ^[a-z][a-z0-9-]*$.

The manifest declares the key. The server factory provides the handler through api.cli.add().

The kernel checks its core verb table before it asks bb.commands.cli for a key. A plugin cannot claim or replace a core verb.

The kernel owns bb plugin and all recovery verbs. The kernel page defines the complete core verb table.

Method Signature Purpose
run (argv: readonly string[], context: CliInvocationContext) => CliResult | Promise<CliResult> Runs the selected plugin CLI verb.
const CLI_OUTPUT_MAX_BYTES = 1_048_576;

interface CliSubcommand {
  name: string;
  summary: string;
  usage: string;
}

interface CliVerb {
  name: string;
  summary: string;
  usage?: string;
  commands?: readonly CliSubcommand[];
  run(
    argv: readonly string[],
    context: CliInvocationContext,
  ): CliResult | Promise<CliResult>;
}

interface CliInvocationContext {
  cwd: string;
  threadId: string | null;
  projectId: string | null;
  signal: AbortSignal;
}

interface CliResult {
  exitCode: number;
  stdout?: string;
  stderr?: string;
}

interface CliExecutionResult {
  exitCode: number;
  stdout: string;
  stderr: string;
  error?: CliOutputLimitError;
}

interface CliOutputLimitError {
  code: "plugin_cli_output_too_large";
  message: string;
  maxBytes: number;
  stdoutBytes: number;
  stderrBytes: number;
  totalBytes: number;
}

The CLI boundary accepts only a CliResult. It normalizes absent output to empty strings.

The boundary limits the combined UTF-8 output to CLI_OUTPUT_MAX_BYTES. It returns a CliOutputLimitError when a result exceeds the limit.

The command help page uses summary, usage, and commands without executing the handler. These fields also appear in contract.json.

The kernel rejects a duplicate non-core key during graph validation. This rule replaces the old sorted-first CLI selection.

Exports

The headless plugin exports its service tokens, data contracts, and app adapters. It exports no UI component.

@bb/commands/contracts exports the command service tokens and shared contract types.

@bb/commands/app exports the public app helpers.

Export Type Purpose
bbCommands ServiceToken<CommandsService> Resolves the current bb.commands app service.
useCommands () => CommandsService Reads the current service in React.
useCommand (commandId: CommandId) => CommandCatalogEntry | null Subscribes to one active command.
useShortcut (commandId: CommandId) => KeyChord | null Reads one effective primary keybinding.
useCommandContext (key: string, active: boolean) => void Asserts a context key while a component is active.
useIsCommandModifierHeld () => boolean Reports sustained use of the platform command modifier.
formatKeyChord (chord: KeyChord, platform?: string) => string Formats a keybinding for display.
matchKeyChord (event: KeyboardEvent, chord: KeyChord) => boolean Tests one normalized key event.

@bb/commands/server exports bbCommandsCli, CLI_OUTPUT_MAX_BYTES, and the CLI contract types.

Host roles

bb.commands declares no host role. CLI verb handlers run in the server artifact.

A handler that needs machine work must use its own typed host role. It must not receive a hidden host client from this contract.

Example

This plugin adds one app command, one command interceptor, and one top-level CLI verb.

// bb.plugin.jsonc
{
  "id": "acme.github",
  "version": "2.0.0",
  "claims": [
    {
      "service": "bb.commands.command",
      "key": "acme.github.openPullRequest"
    },
    { "service": "bb.commands.intercept" },
    { "service": "bb.commands.cli", "key": "gh-pr" }
  ],
  "artifacts": {
    "app": "./dist/app.js",
    "server": "./dist/server.js"
  }
}
// src/app.tsx
import { definePlugin } from "@get-bb/plugin/app";

export default definePlugin((api) => {
  api.commands.add({
    id: "acme.github.openPullRequest",
    title: "Open the pull request",
    description: "Open the pull request for the current thread.",
    category: "GitHub",
    discoverability: {
      searchable: true,
      keywords: ["github", "pr", "review"],
    },
    defaultKeybindings: [
      {
        chord: { key: "p", mod: true, shift: true },
        platform: "desktop",
        when: { all: ["threadRoute"], none: ["modalOpen"] },
      },
    ],
    isAvailable: ({ route }) => route.threadId !== null,
    async run({ route, navigation }) {
      if (route.threadId === null) return;
      navigation.openPanel({
        surface: "bb.thread-ui.sidePanels",
        key: "acme.github.pullRequest",
        params: { threadId: route.threadId },
      });
    },
  });

  api.commands.intercept({
    id: "acme.github.require-thread",
    matches: (commandId) => commandId === "acme.github.openPullRequest",
    async run(invocation, next) {
      if (invocation.route.threadId === null) {
        return {
          ok: false,
          error: {
            code: "not_available",
            message: "Open a thread first.",
          },
        };
      }
      return next();
    },
  });
});
// src/server.ts
import { defineServerPlugin } from "@get-bb/plugin/server";

export default defineServerPlugin((api) => {
  api.cli.add({
    name: "gh-pr",
    summary: "Read pull request data for a thread",
    usage: "bb gh-pr show [--thread <id>]",
    commands: [
      {
        name: "show",
        summary: "Show the pull request for one thread",
        usage: "bb gh-pr show [--thread <id>]",
      },
    ],
    async run(argv, context) {
      const threadId = readThreadId(argv) ?? context.threadId;
      if (threadId === null) {
        return { exitCode: 2, stderr: "A thread ID is necessary.\n" };
      }

      const pullRequest = await loadPullRequest(threadId, context.signal);
      return {
        exitCode: 0,
        stdout: `${pullRequest.url}\n`,
      };
    },
  });
});

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

The bb.commands-ui plugin

bb.commands-ui presents the command registry. It owns no command records, keybindings, executors, or interception rules.

The palette is only the UI for the bb.commands registry. It does not hold a second action registry.

Surfaces

ID kind replaceable props contract sketch notes
bb.commands-ui.palette single yes CommandPaletteProps Presents the active bb.commands registry. The winner receives Original.

The plugin claims bb.layout.modals as a list item. Its default modal implementation declares the palette as a child surface.

bb.layout.modals
└─ bb.commands-ui modal implementation declares
   └─ bb.commands-ui.palette

The palette exists only while the active modal implementation declares this child. This behavior follows the hierarchical surface rule.

bb.commands-ui.palette

The surface receives a view of active command winners. It cannot select an inactive claimant for a command key.

import type { ComponentType } from "react";
import type {
  CommandId,
  CommandOutcome,
  KeyChord,
} from "@bb/commands/contracts";

interface CommandPaletteEntry {
  id: CommandId;
  title: string;
  description: string | null;
  category: string | null;
  icon: string | null;
  shortcut: KeyChord | null;
  keywords: readonly string[];
  providerPluginId: string;
}

interface CommandPaletteProps {
  open: boolean;
  query: string;
  selectedCommandId: CommandId | null;
  entries: readonly CommandPaletteEntry[];
  setQuery(query: string): void;
  setSelectedCommand(commandId: CommandId | null): void;
  close(): void;
  run(commandId: CommandId): Promise<CommandOutcome>;
}

type CommandPaletteProviderProps = {
  props: CommandPaletteProps;
  Original: ComponentType<CommandPaletteProps>;
};

The default implementation gets entries from CommandsService.list({ availableOnly: true, searchableOnly: true }).

Its run() callback calls the service with source: "palette".

The bb.commands-ui.palette.open command opens this surface. Its default keybinding is Mod+K when that keybinding has no conflict.

The first-party UI plugin claims that command key through bb.commands.command. The headless plugin does not own the palette-open command.

A surface replacement changes only the presentation. The current registry, keybindings, and interception chain remain active.

Services

bb.commands-ui declares no data service. It consumes one required service edge.

service edge range use
bb.commands required ^1 Reads command winners, availability, search metadata, keybindings, and command outcomes.

The kernel starts bb.commands-ui after it resolves bb.commands. A service winner change restarts the UI with a fresh handle.

This edge keeps the palette independent from the first-party data implementation. A replacement service can keep the same contract.

Exports

@bb/commands-ui/components exports exact first-party components. A module import does not follow a surface winner change.

Export Type Purpose
CommandPalette ComponentType<CommandPaletteProps> Renders the first-party command palette.
ShortcutHint ComponentType<{ chord: KeyChord | null }> Renders one platform-correct shortcut hint.
CommandMenuItem ComponentType<{ commandId: CommandId; input?: JsonValue }> Renders a menu row for an active command.

Host roles

bb.commands-ui declares no host role. It uses the bb.commands service through its required edge.

Example

This plugin replaces the palette presentation. It uses the same headless command registry.

// bb.plugin.jsonc
{
  "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} />;
    },
  );
});

The replacement receives only active registry entries. It runs commands through the same bb.commands service and interception chain.

Covers

This table covers the headless command contracts and the palette presentation together.

old item ID new contract/verb note
app.slots.commandPaletteAction bb.commands.command keyed service: api.commands.add() The bb.commands-ui palette reads the keyed command catalog.
app.contracts.PluginCommandPaletteActionRegistration bb.commands.command keyed service: AppCommand The new contract adds common command metadata and keybindings.
app.contracts.PluginCommandPaletteActionContext.openPanel bb.commands.command keyed service: AppCommandContext.navigation.openPanel() The new target names the Threads side-panel surface and key.
app.contracts.PluginCommandPaletteActionContext bb.commands.command keyed service: AppCommandContext The context includes route, source, input, cancellation, and navigation.
app.contracts.PluginCommandPaletteActionRegistration.isAvailable bb.commands.command keyed service: AppCommand.isAvailable() Availability applies to the palette, menus, keybindings, and direct runs.
app.contracts.PluginCommandPaletteActionRegistration.run bb.commands.command keyed service: AppCommand.run() All command sources use the same executor.
server.cli server factory: api.cli The direct API is backed by bb.commands.cli.
server.cli.register server factory: api.cli.add() The factory stages one keyed CLI claim.
server.cli.registration bb.commands.cli service: CliVerb The keyed contract replaces the old registration object.
server.cli.commandInfo bb.commands.cli service: CliSubcommand Help metadata stays static.
server.cli.context bb.commands.cli service: CliInvocationContext The new context makes all four fields present and nullable where necessary.
server.cli.result bb.commands.cli service: CliResult The handler still returns an exit code and optional output.
server.cli.outputLimit @bb/commands/server: CLI_OUTPUT_MAX_BYTES The combined output limit stays 1 MiB.
server.cli.registration.name CliVerb.name and the bb.commands.cli key The manifest claim and handler must use the same top-level verb.
server.cli.registration.summary CliVerb.summary The kernel can render help without handler execution.
server.cli.registration.commands CliVerb.commands The optional subcommand help list remains.
server.cli.registration.run CliVerb.run() The server runs the resolved keyed provider.
server.cli.commandInfo.name CliSubcommand.name The field keeps its purpose.
server.cli.commandInfo.summary CliSubcommand.summary The field keeps its purpose.
server.cli.commandInfo.usage CliSubcommand.usage The field keeps its purpose.
server.cli.context.cwd CliInvocationContext.cwd The kernel supplies an absolute invocation directory.
server.cli.context.threadId CliInvocationContext.threadId A missing route context becomes null.
server.cli.context.projectId CliInvocationContext.projectId A missing route context becomes null.
server.cli.context.signal CliInvocationContext.signal The signal is always present and stops on disconnect or shutdown.
server.cli.executionResult CLI boundary: CliExecutionResult The boundary still normalizes output and reports limit failures.
server.cli.outputLimitError CLI boundary: CliOutputLimitError The error keeps byte counts and the stable code.