Codelet logoCodelet

Commands and menus

Registering and running commands, every place codelet draws a menu, and when clauses

A command is a named function. Registering one makes it callable by id — from a keybinding, another extension, or a menu item your manifest declares. This page covers registering and running commands, where codelet draws menus, and the when clauses that scope them.

#Registering and running

activate(context, vscode) {
  context.subscriptions.push(
    vscode.commands.registerCommand("todo.add", (title: string) => {
      // …
    }),
  );
},

vscode.commands.registerCommand(id, callback, thisArg?) returns a Disposable — push it onto context.subscriptions so stopping the extension unregisters it. Run a command, yours or another extension's, with vscode.commands.executeCommand(id, ...args):

await vscode.commands.executeCommand("todo.add", "Buy milk");

vscode.commands.getCommands(filterInternal?) lists every registered id. registerTextEditorCommand is the same registration for a command about the file showing: it does nothing without an active editor, and its edits land through TextEditor.edit as one transaction.

#What puts a command in the palette

Two things, together: a contributes.commands declaration, and a registered handler with the same id.

manifest: {
  contributes: {
    commands: [{ command: "todo.add", title: "Add To Do", category: "To Do", icon: "check" }],
  },
},
activate(context, vscode) {
  context.subscriptions.push(vscode.commands.registerCommand("todo.add", () => { /* … */ }));
},

category prefixes the title in the palette (To Do: Add To Do); icon is used wherever the command is drawn as a button rather than a row. A command declared but never registered — or registered but never declared — doesn't show up at all: the palette needs both halves.

A command that takes arguments has nothing to receive them from the palette, so it should stay undeclared and be named with a leading _ — getCommands(true) filters those out, following the same convention VS Code uses for its own internal commands:

vscode.commands.registerCommand("_todo.open", (id: string) => {
  /* … */
});

contributes.menus.commandPalette is the way to keep a declared command out of the palette — for one that's only ever meant to run from a button, a key, or another extension:

menus: { commandPalette: [{ command: "todo.done", when: "false" }] },

A command no item names there is offered, as in VS Code. A command that's named is offered wherever any of its items' when holds — so two items are two ways of being reachable. Read when the palette opens, against the same keys every other menu is.

Beyond the palette, codelet draws a command in eleven places, keyed by contributes.menus. Each runs a command with a different argument, because each is answering a different question about where the reader clicked.

menus: {
  "editor/title": [
    { command: "markdown.showPreview", when: "editorLangId == markdown", group: "navigation" },
  ],
  "explorer/context": [
    { command: "markdown.showPreview", when: "resourceLangId == markdown" },
  ],
},
Every contribution point
PlaceDrawswhen keysCommand receives
editor/titleButton(s) at the right of the tab bar, over the file showingeditorLangId, resourceLangId, resourceFilename, resourceExtname, resourceScheme, or activeWebviewPanelId for an extension's own tabNothing
view/titleButton(s) over a contributed view's own rowsviewNothing
explorer/contextRow menu of the file tree, under Rename/DeleteresourceLangId, resourceFilename, resourceExtname, resourceScheme, explorerResourceIsFolderThe row's Uri (the root's, for a click below the last row)
view/item/contextRow menu of a contributed treeview, viewItem (the row's contextValue), plus the four resource* keys if the row set resourceUriThe element the TreeDataProvider returned
editor/title/contextTab's own context menu, under its three ClosesresourceLangId, resourceFilename, resourceExtname, resourceSchemeThe tab's Uri
editor/contextRight-click in the document itselfeditorLangId, resourceLangId, resourceFilename, resourceExtname, resourceSchemeThe showing file's Uri
scm/titleOver a source control's own headingscmProviderThe SourceControl
scm/inputBoxBeside a source control's commit boxscmProviderThe SourceControl
scm/resourceGroup/contextOn a resource group's headingscmProvider, scmResourceGroup, scmResourceGroupStateThe SourceControlResourceGroup
scm/resourceState/contextOn a row in a resource groupscmProvider, scmResourceGroup, scmResourceGroupState, scmResourceStateThe SourceControlResourceState
globalActivityRow of the gear at the foot of the activity barThe window's own keys alone — no resourceNothing
commandPaletteNothing — only says whether a command is offered thereSame as the window state at the moment the palette opens—

editorLangId is only set where the file in question is also the one showing — editor/title and editor/context get it, the three context menus over a row or a tab that need not be showing don't. The four scm/* places are drawn by an extension's own pane (codelet/extensions/scm) rather than by the workbench's chrome directly; read codelet.scm.actions for how a source control provider reaches them.

globalActivity is the odd one out: it is about the workbench rather than about anything in it, so there is no resource to write a when against and nothing handed to the command. It is where VSCode keeps Settings and where codelet's settings extension puts its own row, above the theme control the workbench draws there itself — the place for a gesture that is the reader's rather than the project's.

Write the handler to take the argument and fall back to the file showing, so one command answers a button, a row, and a tab that isn't even open:

vscode.commands.registerCommand("markdown.showPreview", async (resource?: Uri) => {
  const document = resource
    ? await vscode.workspace.openTextDocument(resource)
    : vscode.window.activeTextEditor?.document;
  // …
});

#group and order

In the two title bars — editor/title and view/title — "navigation" is the row of buttons itself; any other group name is the … overflow menu at the end of them. A context menu has no such split: every group renders, and the group name is only what sorts them. group@order ("navigation@1") sorts within one group either way.

A menu item may name a group of items instead of a command — contributes.submenus declares the group, and { submenu: id } puts it somewhere. In a context menu that's a row which opens rows of its own; in a title bar it's a button that opens a menu, which is the only way to get one out of a button you contributed.

contributes: {
  submenus: [{ id: "todo.open", label: "New Todo", icon: "add" }],
  menus: {
    "view/title": [{ submenu: "todo.open", when: "view == files", group: "navigation" }],
    // The rows, under the submenu's own id — it is a place you named for yourself.
    "todo.open": [{ command: "todo.addHere" }, { command: "todo.addToList" }],
  },
},

The icon is what a title bar draws it as, the label being its tooltip there and its row everywhere else. Inside a submenu, when and group@order mean exactly what they mean in any other place, and the items are matched against the same keys the submenu's own placement was — so a submenu in explorer/context can read resourceLangId in its rows.

Note

A submenu whose every item was filtered out by a when is left out entirely rather than drawn as an empty menu. Submenus may hold submenus; one that ends up holding itself stops where it comes round again. editor/tab takes no submenu — a tab is one row of text with nowhere for a menu to open — and neither does commandPalette, which is a list of commands.

#Rows a manifest can't know

contributes.menus is read once, when your extension is installed — which is what lets the server render your button before anything has activated. A list that changes while the page is open can't live there. codelet.window.registerMenuItems is the other half: the workbench asks you for the rest of a submenu's rows each time it's built, and appends them after the manifest's.

context.subscriptions.push(
  codelet.window.registerMenuItems("todo.open", () =>
    lists().map((list) => ({
      label: `New Todo in ${list.name}`,
      icon: "add",
      // Read as a manifest's `group` is: the menu rules where it changes, so these sit under a
      // line, below whatever the manifest already put there.
      group: "lists",
      run: () => addTo(list),
    })),
  ),
);

A row here carries run() rather than a command id — there's nothing to register a command for when the row itself only exists at runtime. Return [] for "nothing to add", and keep the callback cheap: it's asked during a render, not when the menu opens. Naming a submenu no manifest declared registers nothing, and a second registration for one id replaces the first.

There's no VS Code equivalent, and there couldn't be: there the workbench holds the lists a menu like this is built from (its own terminal dropdown is chrome, not an extension's), where here you hold them.

#when clauses

An expression over context keys: a bare key, !key, key == "value", key != "value", joined with && and ||. Anything the grammar doesn't reach — parentheses, in — reads as false, as does a key that was never set.

Beyond the menu-specific keys in the table above, two describe the window itself:

KeyValue
workbenchState"empty" until the tree showing holds a file anywhere, "folder" once it does
workspaceFolderCount"0" or "1", matching workbenchState

Everything else comes from setContext, which any extension can call to name a key of its own and drive its own views or menu items with it:

await vscode.commands.executeCommand("setContext", "todo.hasItems", items.length > 0);
views: { todo: [{ id: "todo.list", name: "To Do", when: "todo.hasItems" }] },

A container is its views: every view whose when fails takes its icon or tab strip entry with it, so setContext is also how a whole activity bar icon appears and disappears.

#Worked example

A button on the tab bar, scoped to one language, that opens a panel — and a button back on that panel's own tab bar to return to the file. This is exactly what codelet/extensions/markdown does; see Markdown for the shipped version.

import { defineExtension } from "codelet/extensions";
import type { Uri } from "codelet/extensions";

const COMMAND = "notes.preview";
const BACK = "notes.showSource";
const VIEW_TYPE = "notes.preview";

export const notesPreview = defineExtension({
  manifest: {
    name: "notes-preview",
    contributes: {
      commands: [
        { command: COMMAND, title: "Preview Notes", icon: "preview" },
        { command: BACK, title: "Show Source", icon: "file" },
      ],
      menus: {
        "editor/title": [
          { command: COMMAND, when: "editorLangId == notes", group: "navigation" },
          { command: BACK, when: `activeWebviewPanelId == ${VIEW_TYPE}`, group: "navigation" },
        ],
      },
    },
  },
  activate(context, vscode) {
    let panel: ReturnType<typeof vscode.window.createWebviewPanel> | undefined;
    let shownUri: Uri | undefined;

    context.subscriptions.push(
      vscode.commands.registerCommand(COMMAND, async () => {
        const document = vscode.window.activeTextEditor?.document;
        if (!document) return;
        shownUri = document.uri;
        panel ??= vscode.window.createWebviewPanel(
          VIEW_TYPE,
          "Notes Preview",
          vscode.ViewColumn.Beside,
        );
        panel.webview.html = `<pre>${document.getText()}</pre>`;
        panel.reveal();
      }),
      vscode.commands.registerCommand(BACK, async () => {
        if (shownUri) await vscode.window.showTextDocument(shownUri);
      }),
    );
  },
});

Two menu items, one command each, and no shared state beyond which file was last previewed — activeWebviewPanelId is what lets the second button find its way home without either command being handed an argument.