Commands
A command is the only way an action should be spelled. A button that calls an API directly and a palette entry that calls the same API are two implementations that will drift; a button that runs a registered command cannot.
app.commands.register({
id: 'service.restart',
title: 'Restart service',
category: 'Services',
slots: ['palette', 'context'],
when: "$/session/role == 'operator'",
args: [{ name: 'id', type: 'string', required: true }],
run: async (args, ctx) => {
await restart(String(args.id));
ctx.store.set('$/services/lastRestart', args.id);
},
});
whenis a small expression over store paths. Chrome that should not exist for this user does not mount, rather than mounting disabled.slotsis where the command offers itself:palette,hints,context, or anything an application invents.argsare validated beforerun, so a typo in a keybinding fails loudly rather than passingundefinedinto an API call. An arg that declareschoicesalso becomes a sub-menu in the palette - see below.
A command that is a switch
checked is a clause like when, and it makes a command a toggle. A menu
draws the mark, and keepOpen leaves the list up so a run of switches can be
walked rather than reopened one at a time.
app.commands.register({
id: 'view.wrap',
title: 'Wrap Lines',
slots: ['palette'],
checked: '$/ui/editor/wrap',
keepOpen: true,
run: (_args, ctx) => {
ctx.store.set('$/ui/editor/wrap', ctx.store.get<boolean>('$/ui/editor/wrap') !== true);
},
});
A clause rather than a boolean because the definition is registered once and
the state changes under it - and absent rather than false because a menu has
to tell “not a switch” from “a switch that is off”. app.commands.isChecked(id)
returns undefined for the first and false for the second, which is what
keeps the mark’s column out of a menu that has no toggles in it.
The palette
app.layers.open({
id: 'palette',
layer: 'modal',
trapFocus: true,
node: { component: 'CommandPalette', width: 60 },
});
That is the whole wiring. The palette searches the registry itself and runs
what it finds, so choosing “Open a dialog” there and pressing the button that
opens a dialog are the same act reaching the same code. Pass execute={false}
to make it a picker that only reports the choice.
It shows what it knows about each command - category, keybinding, and the description of the highlighted row - and rules between categories, so a registry of forty commands reads as a few groups rather than a wall.
Sub-items come from the command, not from the palette. A command that
declares an argument with choices is asked about rather than run:
app.commands.register({
id: 'app.toast',
title: 'Show a toast',
slots: ['palette'],
args: [{
name: 'tone',
type: 'string',
required: true,
description: 'How loud the toast should be.',
choices: ['info', 'success', 'warning', 'danger'],
}],
run: (args) => notify(app, { tone: args.tone as SemanticVariant, message: 'done' }),
});
Choosing it opens a second level listing the tones - filterable, with escape
going back a level rather than closing - and picking one runs the command with
that argument. choices may be a function, and may be async, so a list can come
from a registry:
choices: () => app.themes.list().map((t) => t.id),
Nothing in the command knows the palette exists. It states what it needs; the palette is one of the things that can ask.
Scopes
A command may be registered at app, screen, region or component scope,
and resolution walks from the most specific outward. That is how table.search
can mean whichever table is focused without every table inventing its own id.
useCommand({
id: 'table.search',
title: 'Search this table',
scope: 'component',
run: () => setSearching(true),
});