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