Command LayerWire any VS Code command to a trigger, entirely in Command Layer does one thing: resolve arguments from context and dispatch a command. It runs no processes, manages no terminals, and writes no files. The commands come from VS Code and from the extensions you already have. It's useful in two ways. Reach. VS Code lets you bind commands to keystrokes. Command Layer extends that to everything else — text matching a pattern, the current selection, a file you right-clicked, terminal output, a status bar button, a sidebar entry, an editor event.
Write Curation. Gather the commands you actually use, from wherever they come from, into one menu that fits what you're doing — no hunting the Command Palette, no remembering ids.
Select something, right-click, Selection Actions... — three commands from three sources in one list. InstallSearch for Command Layer in the Extensions view (
The extension id is All configuration starts empty. Nothing happens until you add actions, and a link from outside VS Code can run any command, see From outside VS Code. How it workstrigger → resolve → dispatch
ActionsAn action is one command plus its arguments.
An action with no arguments can be written as a bare command id:
Arguments
Placeholders resolve when the command runs. This is the thing
Typed argumentsA plain string produces a string. Wrap it to produce something else:
Its template may be a structure (preferred — nothing is parsed, so
nothing needs escaping) or a string that gets parsed, in which case
substituted values are escaped for you. Typed arguments compose:
Running several commandsUse VS Code's own
Command Layer adds no sequencing of its own. ConfigurationSeven lists of actions, all empty by default, each named for what triggers it:
Plus Each list has a matching menu entry named after it — Text Actions..., File Actions..., Selection Actions..., Global Actions... — plus All Actions..., which shows everything applicable in one picker, grouped and narrowest first:
All of them always show a picker, even with one action, so a generic entry never does something unannounced. The same goes for clicking a link, in a document or in terminal output: a link shows the matched text, not which action will run. Clicking something that names its action runs it directly — a CodeLens, a hover entry, a lightbulb entry, a status bar button, a view item.
Text actions
One matching action runs directly; several show a picker.
Fields: File and selection actionsAlways offered; add
A selection entry with a Global actionsAnchored to nothing — reachable from the palette, a view, or a keybinding. Give one an
Status bar
Event actionsThe only actions that run without you initiating them.
Scoping with
|
| Where | Reached by |
|---|---|
| Document text | link click, Text Actions..., CodeLens, lightbulb, hover |
| Selection | Selection Actions... |
| Explorer, editor body, tab right-click, Source Control | File Actions... |
| Editor tab-bar button | whatever you set in commandLayer.editorTitleAction |
| Terminal output | link click |
| Terminal right-click | Global Actions... |
| Command Palette | Global Actions... |
| Keybinding | commandLayer.action.<id> |
| Status bar | configured buttons |
| Views | any configured node |
| Editor events | workspaceOpen, fileSave |
File actions are multi-select aware: right-click five files and
${selectedFiles} holds all five, quoted and space-joined.
Surfaces
A textActions entry is one subject with several renderings. show
picks which:
| Surface | Appearance |
|---|---|
link |
Underlined, ctrl-clickable |
codeLens |
Clickable line above the match |
codeAction |
In the lightbulb / Quick Fix menu |
hover |
Clickable links on mouse hover |
decoration |
Styled in place — visual only, no click |
{ "match": "TODO\\(([^)]+)\\)", "show": ["codeLens", "decoration"],
"decoration": { "color": "#e5c07b", "fontWeight": "bold" },
"actions": [ { "title": "Copy owner", "command": "commandLayer.copyToClipboard", "args": ["$1"] } ] }
Without show, an entry uses the defaults:
{
"commandLayer.defaults.surfaces": ["link", "codeLens", "codeAction", "hover"],
"commandLayer.defaults.decorationStyle": { "textDecoration": "underline dotted" }
}
decoration is left out because highlighting every match is intrusive.
show: [] means the entry appears nowhere — useful while testing one in
isolation.
Surfaces apply to textActions only. The other lists have one
appearance each: a picker entry, a terminal link, a button.
The editor tab-bar button
Empty by default, so no button appears. Give it an action and it shows up at the top-right of the editor:
{
"commandLayer.editorTitleAction": {
"command": "commandLayer.runAllActions",
"tooltip": "Command Layer"
}
}
Any command works, not just the pickers — when applies too, so the
button can come and go with context.
Views
Two containers, each filled by its own setting, each appearing only when its setting is non-empty:
{
"commandLayer.explorerView": [ ... ], // a panel in the Explorer sidebar
"commandLayer.activityBarView": [ ... ] // its own Activity Bar icon
}
VS Code also lets you drag either view to the secondary sidebar or anywhere else.
A node is one of four things:
"commandLayer.explorerView": [
"workbench.action.reloadWindow", // leaf
{ "title": "Deploy", "command": "workbench.action.tasks.runTask", "args": ["deploy"] },
{ "title": "Project", "children": [ // group — nests
{ "title": "Build", "children": ["workbench.action.tasks.build"] }
]},
{ "title": "Tasks", "from": "commandLayer.fetchVSCodeTasks" }, // group — filled
{ "title": "Git", "children": [ // two sources, one flat list
{ "from": "commandLayer.fetchVSCodeCommands", "filter": "git" },
{ "from": "commandLayer.fetchGlobalActions", "filter": "git" }
]},
{ "from": "commandLayer.fetchFrequentActions", "limit": 5 } // fragment — splices in
]
A title makes a level. A node with from and no title is a
fragment: its entries splice into the parent rather than nesting. Same
source, same filter — the title alone decides.
filter (substring of the visible label, case-insensitive), limit and
when apply to any node. A node uses from or children, never
both.
List providers
from is a command id returning a list of actions. These ship with
Command Layer:
commandLayer.fetchGlobalActions |
your global actions |
commandLayer.fetchFileActions |
your file actions |
commandLayer.fetchSelectionActions |
your selection actions |
commandLayer.fetchTextActions |
actions from your text entries |
commandLayer.fetchTerminalActions |
actions from your terminal entries |
commandLayer.fetchVSCodeCommands |
every command registered in VS Code |
commandLayer.fetchVSCodeTasks |
your tasks.json tasks |
commandLayer.fetchFrequentActions |
what you run most, in this workspace |
Any command returning an array of actions works — a bare command id
string, or { title, command, args }. So another extension, or a few
lines in utils.js, can supply a list without changing anything here.
fetchVSCodeCommands answers the hardest problem: you can't bind a
command you've never heard of. Point a filtered group at an extension's
prefix — git., python. — to see what it offers, click one to try it,
then right-click to Copy Command Id or Copy Action Stub and
paste it into your settings.
Built-in commands
Two, for things VS Code exposes only as an API and not as a command:
| Command | Does |
|---|---|
commandLayer.openExternal |
Hands a URI to the OS's own handler — registered protocols (obsidian://, slack://) and a file's default desktop app |
commandLayer.copyToClipboard |
Copies a string. VS Code's clipboard commands act on the selection; none takes a value |
Opening things
Three jobs, easy to confuse:
| Goal | Command |
|---|---|
| Show a file or folder in Explorer / Finder | revealFileInOS |
| Open a file in VS Code, or a URL in the browser | vscode.open |
| Open a file in its default desktop app, or launch a tool by protocol | commandLayer.openExternal |
Running shell commands
Command Layer doesn't run processes. VS Code has two native ways, both reachable as ordinary commands.
Tasks, for anything repeatable — you get cwd, environment, problem
matchers, and dependsOn for ordering:
// tasks.json
{ "label": "deploy", "dependsOrder": "sequence", "dependsOn": ["build", "test"],
"type": "shell", "command": "npm run deploy" }
// settings.json
{ "command": "workbench.action.tasks.runTask", "args": ["deploy"] }
sendSequence, for one-offs built from captures:
{ "command": "workbench.action.terminal.sendSequence",
"args": [["object", { "text": "python plot.py \"$1\"\u000D" }]] }
\u000D is the carriage return that submits the line. It goes to the
active terminal, so open one first. Ordering belongs in the shell:
{ "command": "workbench.action.terminal.sendSequence",
"args": [["object", { "text": "python plot.py && code plot.png\u000D" }]] }
&& guarantees the ordering; code opens the result in the running
window. Note && differs across cmd, PowerShell and POSIX shells.
Optional utilities
utils.js ships alongside the extension but isn't part of it. It loads
only when enabled, and registers nothing otherwise:
{ "commandLayer.utils.enabled": true } // requires a reload
| Command | Does |
|---|---|
commandLayer.util.searchSelection |
Search the workspace for the selection, ignoring spacing |
commandLayer.util.searchSelectionExact |
Same, matching literally |
commandLayer.util.writeFile |
[path, content] |
Conveniences that would otherwise mean installing a separate extension
for a one-liner. What belongs there: pure functions, or thin wrappers
over a vscode.* API. Not: anything running a shell command, and
nothing an existing extension already does properly.
From outside VS Code
A URI handler lets another program — a launcher, a script, open on
macOS, a link in a Markdown file — run any VS Code command, with arguments,
in the window you used last. It is taken from
Command Executor
by Elio Struyf: the same URI shape and the same reading of arguments, so
links written for one read the same to the other apart from the id. What is
added is that a value can take the typed forms of an action's args.
vscode://saemeon.command-layer?command=<id>
vscode://saemeon.command-layer?command=<id>&args=<value>
vscode://saemeon.command-layer?command=<id>&args0=<value>&args1=<value>
Arguments. args is one argument, and args0, args1, … are the
arguments in order; an index left out is skipped (undefined), and indexes
above 63 are ignored. Each value is read as JSON when it parses and is the
text otherwise, so args0=42 passes the number 42, args0=true a boolean,
args0={"query":"x"} an object and args0=hello the string hello. Quote a
string that would parse to keep it one: args0="42". The typed forms of an
action's args are JSON too (["uri", "/path"], ["object", {...}],
["regex", ...], ["number", ...], ["boolean", ...]), which is how a
command taking a Uri is given one. Nothing in a value is substituted: $1
and ${file} arrive as literal text, and ${command:...} runs nothing. VS
Code decodes a URI's query once before the handler sees it, so a value
containing &, + or a percent sign has to be percent-encoded twice;
anything else needs it once, as in any URL.
Your own global actions are commands too: an entry with an id in
commandLayer.globalActions is commandLayer.action.<id>, so
?command=commandLayer.action.openNotes runs it. A task is
?command=workbench.action.tasks.runTask&args0=build, the label as Run
Task shows it.
Nothing is checked. There is no allowlist: any command runs, with any
arguments, once VS Code's prompt for an extension opening a URI has been
accepted. That prompt is VS Code's only guard, it is once per extension, and
its dialog has a checkbox to stop asking. Any local application and any web
page that can open a URL can reach this handler, and a command taking no
argument can still run code: workbench.action.tasks.build runs the default
build task, workbench.action.debug.start a launch configuration,
workbench.action.terminal.runActiveFile a file. Install this extension
only if you accept that, and keep the prompt on.
A request with no command, an argument that is not valid in its typed form
(["uri", ""]), or a command that throws shows an error message with Show
Details, logged to the Command Layer output channel along with every
URI that ran.
The macOS launcher's rows
While the extension is installed, the launcher in this repository
(extensions/vscodebridge.lua) offers four rows, each opening one of these
URIs:
| Row | Sends |
|---|---|
| VS Code: Find in files for "…" | ?command=workbench.action.findInFiles&args0={"query":"…","triggerSearch":true} |
| VS Code: Go to file "…" | ?command=workbench.action.quickOpen&args0=… |
| VS Code: Run task… | ?command=workbench.action.tasks.runTask&args0=<label> |
| VS Code: Run command… | ?command=<id> |
Examples
Open a folder in a new window. The arguments are ["uri", "/Users/me/my project"] and {"forceNewWindow": true}, each JSON encoded once:
open 'vscode://saemeon.command-layer?command=vscode.openFolder&args0=%5B%22uri%22%2C%22%2fUsers%2fme%2fmy%20project%22%5D&args1=%7B%22forceNewWindow%22%3Atrue%7D'
Open Quick Open with README typed in:
open 'vscode://saemeon.command-layer?command=workbench.action.quickOpen&args0=README'
Run the build task:
open 'vscode://saemeon.command-layer?command=workbench.action.tasks.runTask&args0=build'
As a link in a Markdown file:
[Build](vscode://saemeon.command-layer?command=workbench.action.tasks.runTask&args0=build)
Validation
Configuration is checked on startup and whenever settings change. An
invalid regex, an action with no command, a textActions entry with no
match, a duplicate id, a from naming a command that doesn't exist —
each produces one consolidated warning with a Show Details button
opening the Command Layer output channel. Invalid entries are
skipped; the rest keep working. Nothing is shown when the configuration
is clean.
Errors from an action surface as a notification with a Show Details button; nothing fails silently.
Cookbook
Open a Windows folder path in Explorer
"commandLayer.textActions": [
{ "match": "folderpath:\\s*([^\\r\\n]+)",
"actions": [{ "title": "Reveal", "command": "revealFileInOS", "args": [["uri", "$1"]] }] }
]
Spaces work — the match runs to the end of the line.
Make stack-trace paths clickable in terminal output
"commandLayer.terminalActions": [
{ "match": "([\\w./-]+\\.(?:ts|js|py|md)):(\\d+)",
"actions": [{ "title": "Open", "command": "vscode.open", "args": [["uri", "$1"]] }] }
]
Plot a CSV
"commandLayer.fileActions": [
{ "title": "Plot", "match": "\\.csv$",
"command": "workbench.action.terminal.sendSequence",
"args": [["object", { "text": "python -c \"import pandas as pd, matplotlib.pyplot as plt; pd.read_csv(r'${file}').plot(); plt.show()\"\u000D" }]] }
]
Right-click any .csv in the Explorer. The entry doesn't appear on
other files.
Send a selection to the Python Interactive Window
"commandLayer.selectionActions": [
{ "title": "Plot selected key",
"command": "jupyter.execSelectionInteractive",
"args": ["SELECTION = \"${selectedText}\"\nimport my_plotter; my_plotter.plot(SELECTION)"] }
]
Pull on opening a workspace
"commandLayer.eventActions": [
{ "on": "workspaceOpen", "title": "Pull", "command": "git.pull", "confirm": true }
]
confirm asks first — worth keeping on for anything touching a repo.
A view that gathers everything
"commandLayer.explorerView": [
{ "from": "commandLayer.fetchFrequentActions", "limit": 5 },
{ "title": "Tasks", "from": "commandLayer.fetchVSCodeTasks" },
{ "title": "My actions", "from": "commandLayer.fetchGlobalActions" },
{ "title": "Browse", "children": [
{ "title": "Git", "from": "commandLayer.fetchVSCodeCommands", "filter": "git" },
{ "title": "Python", "from": "commandLayer.fetchVSCodeCommands", "filter": "python" }
]}
]