Skip to content

Menus

Menus are data-only collections of command-backed actions available through ctx.menus. The host chooses whether a menu appears as a popover, dropdown, context menu, or another suitable surface.

Use a menu for short lists of actions, grouped choices, checks, and switches. Use a dialog when the plugin needs a decision. Plugins are currently headless and cannot contribute custom sheet components.

Request registry.menu to access ctx.menus. Also request registry.command when the plugin registers item commands or opens and closes the menu itself:

{
"recall": {
"permissions": ["registry.menu", "registry.command"]
}
}

Each item points to a command instead of embedding executable behavior:

const registration = await ctx.menus.register({
id: "focus-tools.note-actions",
name: "Note actions",
entries: [
{
type: "item",
id: "archive",
label: "Archive note",
icon: "ArchiveIcon",
order: 100,
command: {
id: "focus-tools.note.archive",
payload: { noteId: "note_123" },
},
},
],
});

A menu has a stable id, diagnostic name, and an entries array. Recall assigns ownership and registration time from the active sandbox. Menu and item ids should be stable and namespaced to the plugin. Duplicate ids are rejected.

A menu can mix standalone items and groups:

EntryPurpose
ItemA selectable row that may run a command.
CheckAn item with control: { type: "check", value }.
SwitchAn item with control: { type: "switch", value }.
GroupA type: "group" entry containing related items.

Items can also provide description, rightIcon, variant, and config.disabled. Set config.closeOnSelect when the menu should remain open or close after selection according to the host’s supported behavior.

Top-level entries and items within a group are sorted by order. Lower values appear first; ids provide a stable tie-breaker.

const settingsGroup = {
type: "group" as const,
id: "display",
label: "Display",
order: 200,
items: [
{
type: "item" as const,
id: "compact",
label: "Compact view",
icon: "Layers01Icon",
order: 100,
control: { type: "switch" as const, value: true },
command: { id: "focus-tools.compact.toggle" },
},
],
};

Registering a menu creates two commands:

menu.<menu-id>.open
menu.<menu-id>.close
await ctx.commands.run("menu.focus-tools.note-actions.open");
await ctx.commands.run("menu.focus-tools.note-actions.close");

Closing a menu that is already closed is safe. A menu item forwards its command.payload unchanged, so the receiving command must validate it.

Use update to change entries without registering a second menu. The returned menu is normalized again, and an open host surface can react to the update:

await ctx.menus.update("focus-tools.note-actions", (menu) => ({
...menu,
entries: menu.entries.map((entry) =>
entry.type === "item" && entry.id === "archive"
? { ...entry, config: { ...entry.config, disabled: true } }
: entry,
),
}));

Keep the same menu id when updating it. Treat values returned by get and list as read-only.

Dispose the registration when the plugin unloads. Disposal removes the menu, closes it if necessary, and removes its generated commands:

await registration.dispose();

Use on(event, listener) for every menu or pass { id, listener } to observe one menu:

const subscription = await ctx.menus.on("update", {
id: "focus-tools.note-actions",
listener({ menu }) {
console.log("Menu updated", menu);
},
});
await subscription.dispose();

Supported events are register, update, unregister, listChange, open, and close. Targeted subscriptions are available for every event except listChange.

MethodPurpose
register(menu)Register a menu and return its disposable handle.
update(id, updater)Replace an existing menu with the normalized result.
unregister(id)Remove a menu by id.
get(id)Read one registered menu.
list()Read a snapshot of all menus.
on(event, listener)Subscribe globally or by menu id.