How to build a VS Code extension: I read the OpenCode one and wrote my own
10 min readWritten by: Jonathan Reis on
The official OpenCode VS Code extension is 2KB and only sends file references to the terminal. I needed to send selected text from any file, saved or not. Instead of giving up, I read the minified source, found the internal HTTP endpoint, and built my own VS Code extension — a practical tutorial on how to build VS Code extensions.

The official OpenCode extension for VS Code is 2,298 bytes. No bundler, no dependencies, no framework. That is a clue: VS Code extensions are simpler than they look, and you can learn a lot by reading one that works.
I wanted to send selected text from any file open in VS Code directly to the OpenCode prompt, without copy-paste. The official extension only sends file references (@path#Lstart-end) and only for files inside the workspace. Temporary files, unsaved buffers, files opened from outside the project — nothing.
Instead of giving up, I read its code.
The real problem
OpenCode is an AI agent that runs in the terminal. The official VS Code extension opens it in a split terminal and registers shortcuts: Cmd+Esc opens the terminal, Cmd+Alt+K inserts a reference to the current file. “Context awareness” — the ability to share what you are selecting in the editor with the agent — is what connects the editor to the terminal.
The inspiration is Cursor. In Cursor you select code, press Cmd+L, and the text becomes context for the chat — no copy, no paste, no describing where you are. I wanted that behavior in VS Code with OpenCode.
The official extension had two limitations that bothered me:
- It only sends
@path#Lstart-end, never raw text. If the file is not in the workspace, there is no relative path, no reference. - The
Cmd+Alt+Kshortcut depends on the active terminal being named “opencode”. If you opened OpenCode manually in a normal terminal, the shortcut does nothing.
What I wanted was Cursor’s behavior: select text in any file (saved, temporary, outside the workspace) and send it to the prompt. Workspace files become compact references (@path#Lstart-end), like Cursor does. Files outside the workspace become sanitized text, without breaking the TUI.
Reading the official extension
The installed extension lives at ~/.vscode/extensions/sst-dev.opencode-0.0.13/. The package.json shows three keybindings:
"keybindings": [
{ "command": "opencode.openTerminal", "key": "cmd+escape" },
{ "command": "opencode.openNewTerminal", "key": "cmd+shift+escape" },
{ "command": "opencode.addFilepathToTerminal", "key": "cmd+alt+k" }
]
The activationEvents is [] — the extension does not declare when to activate. VS Code loads it lazily, only when one of its commands is called for the first time. That is why the shortcuts seemed dead until I opened OpenCode via Cmd+Esc.
The extension.js is minified, but small enough to read in one pass. The function that builds the reference is short:
function u() {
let t = o.window.activeTextEditor;
if (!t) return;
let n = t.document;
if (!o.workspace.getWorkspaceFolder(n.uri)) return;
let c = `@${o.workspace.asRelativePath(n.uri)}`;
let d = t.selection;
if (!d.isEmpty) {
let f = d.start.line + 1;
let w = d.end.line + 1;
f === w ? (c += `#L${f}`) : (c += `#L${f}-${w}`);
}
return c;
}
The guard that blocked me is on the second line: if (!o.workspace.getWorkspaceFolder(n.uri)) return;. If the file does not belong to a workspace folder, the function returns undefined and the command aborts silently.
The hidden endpoint
The part that surprised me was the function that sends the text. Instead of typing into the terminal via sendText, the extension makes an HTTP POST:
async function h(t, n) {
await fetch(`http://localhost:${t}/tui/append-prompt`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: n }),
});
}
The t is a random port between 16384 and 65536, generated when the terminal is created. The extension passes this port as the env var _EXTENSION_OPENCODE_PORT in the terminal’s creationOptions. OpenCode, when run with opencode --port <port>, starts a local HTTP server that listens on that endpoint.
That is what makes the official extension possible: it does not type into the terminal, it talks to the HTTP server that OpenCode starts. The TUI receives the text via API, not via keystroke. If the append-prompt endpoint accepts any text, I can send whatever I want — not just file references.
The anatomy of a VS Code extension
Before showing my extension, it is worth breaking down what makes a minimal VS Code extension. There are three pieces:
1. package.json
Declares the extension to VS Code. Defines the name, publisher, version, when to activate, which file is the entry point, and which commands and shortcuts the extension contributes.
{
"name": "opencode-selection",
"publisher": "sunstoneapps",
"version": "0.1.0",
"main": "./extension.js",
"activationEvents": ["onCommand:opencodeSelection.send"],
"contributes": {
"commands": [
{ "command": "opencodeSelection.send", "title": "OpenCode Selection: Send to OpenCode" }
],
"keybindings": [
{ "command": "opencodeSelection.send", "mac": "cmd+l", "win": "ctrl+l", "linux": "ctrl+l" }
]
}
}
The activationEvents field tells VS Code when to load the extension. onCommand:opencodeSelection.send means: load the extension when someone calls that command for the first time. Before that, the extension consumes no memory.
The contributes section is where the extension declares what it adds to VS Code: commands that appear in the palette, keyboard shortcuts, settings, views. For an extension that only registers a shortcut, that is all you need.
2. extension.js
The entry point. Exports two functions: activate and deactivate. activate runs when VS Code decides to load the extension (based on activationEvents).
function activate(context) {
const disposable = vscode.commands.registerCommand("opencodeSelection.send", async () => {
// what happens when you press Cmd+L
});
context.subscriptions.push(disposable);
}
function deactivate() {}
module.exports = { activate, deactivate };
The context.subscriptions.push(disposable) is the VS Code cleanup pattern: everything you register (commands, listeners, providers) goes into the subscriptions list, and VS Code disposes all of it when the extension is deactivated.
3. The folder
VS Code expects extensions in ~/.vscode/extensions/ with the folder named as <publisher>.<name>-<version>. If the folder does not have the version in the name, the extension does not load. That cost me 20 minutes of debugging — the folder sunstoneapps.opencode-selection did not work until I renamed it to sunstoneapps.opencode-selection-0.1.0.
What my extension does differently
The logic splits into two files: src/logic.js with pure functions (no VS Code API dependency, testable) and extension.js that bridges to the editor. Each function does one thing.
buildReference — reference or nothing
The version in src/logic.js receives data ready, without coupling to the VS Code API:
function buildReference(doc, workspaceFolder, asRelativePath, selection) {
if (!workspaceFolder) return null;
let ref = "@" + asRelativePath;
if (!selection.isEmpty) {
const startLine = selection.start.line + 1;
const endLine = selection.end.line + 1;
ref += startLine === endLine ? `#L${startLine}` : `#L${startLine}-${endLine}`;
}
return ref;
}
In extension.js, a thin wrapper bridges to the VS Code API:
function buildReference(editor) {
const doc = editor.document;
const wsFolder = vscode.workspace.getWorkspaceFolder(doc.uri);
const relPath = vscode.workspace.asRelativePath(doc.uri, false);
return buildRef(doc, wsFolder, relPath, editor.selection);
}
Same logic as the official extension, with one difference: if the file is not in the workspace, I return null instead of aborting. The function that calls this one decides what to do with null.
sanitizeText — text that does not break the TUI
The part that took the most work. OpenCode processes keystrokes char by char. If the sent text contains \n, the TUI interprets it as Enter (submitting the prompt). If it contains \t, it switches agents. Backticks, control chars, anything can trigger something.
function sanitizeText(text) {
const isMultiline = text.includes("\n");
let safe = text
.replace(/\r\n/g, "\n")
.replace(/\t/g, " ")
.replace(/\n/g, " ")
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
.trim();
if (isMultiline) {
safe = "`" + safe.replace(/`/g, "\\`") + "`";
}
return safe;
}
Tab becomes four spaces. Newline becomes space. Control chars (\x00-\x1F, \x7F) are removed. If the original text was multiline, I wrap it in inline backticks so the LLM knows it is continuous code. Backticks inside the text are escaped with ```.
sendToTerminal — HTTP or fallback
async function sendToTerminal(terminal, payload) {
const env = terminal.creationOptions && terminal.creationOptions.env;
const port = env ? env._EXTENSION_OPENCODE_PORT : undefined;
if (!port) {
terminal.sendText(payload, false);
terminal.show();
return;
}
const url = `http://localhost:${port}/tui/append-prompt`;
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: payload }),
});
if (!res.ok) {
throw new Error(`OpenCode responded ${res.status}: ${await res.text()}`);
}
terminal.show();
}
If the port is not in the terminal’s env vars (because someone opened OpenCode manually), it falls back to terminal.sendText — which types the text into the terminal as keystrokes. Not ideal, but better than failing silently.
The newline trap
The first version worked for single-line text. For multiline selections, the text went to the prompt and OpenCode interpreted the \n as Enter — submitting the prompt before I finished typing.
Wrapping in backticks seemed to work until I tested with code that had a tab. The TUI switched agents in the middle of the send. That is when I understood the problem was not just newline — it was any control character.
The regex [\x00-\x08\x0B\x0C\x0E-\x1F\x7F] covers every ASCII control char except \t (handled before, converted to spaces) and \n (also before, converted to spaces). \x7F (DEL) is included. It is not paranoia — it is what happens when you inject text into a TUI that processes keystrokes.
Why this works
OpenCode, when run with --port, starts a local HTTP server. The official extension passes that port as a terminal env var. Any extension that can read that env var can talk to OpenCode via HTTP.
The /tui/append-prompt endpoint is not documented. There is no public contract that it will continue to exist. That is the risk of depending on an internal API — it can break on any release. For an open-source extension that solves a real problem, the risk is acceptable: if it breaks, the reader can fix it. For a closed commercial product, it would not be.
What to check before building a VS Code extension
- The folder needs the version in its name.
<publisher>.<name>-<version>. Without the version, VS Code ignores it. - Empty
activationEventsmeans lazy loading. The extension only loads when one of its commands is called. If your shortcuts seem dead, the extension may have never activated in that window. terminal.creationOptions.envis read-only. The_EXTENSION_OPENCODE_PORTenv var only exists if the terminal was created by the official extension viaCmd+Esc. A manually opened terminal does not have it.- Keybinding conflicts are silent. VS Code does not warn when two commands compete for the same shortcut. The winner is the first in precedence order. If your shortcut does not work, open
Cmd+K Cmd+Sand search for it — another command may be stealing it. fetchis available without import. Node.js 18+ (which VS Code uses) has globalfetch. No need fornode-fetchoraxiosfor a simple POST.
What is missing
The extension has tests for sanitizeText and buildReference — 11 assertions covering tab, newline, control chars, DEL, backticks, file ref, single-line and multi-line. They are pure functions in src/logic.js, easy to test without mocking the VS Code API.
What it still does not have:
- Configuration. Allow customizing the shortcut, choosing between reference and text, disabling the
sendTextfallback. - More robust terminal discovery. Instead of filtering by terminal name “opencode”, track the PID or use the env var as the single source of truth.
- API version handling. Check if the endpoint responds before sending, and give a useful message if it changed.
Install
The extension is published on the VS Code Marketplace. You can install it directly from VS Code: open the command palette (Cmd+Shift+P), type “Install Extensions” and search for “OpenCode Selection”. Or from the terminal:
code --install-extension sunstoneapps.opencode-selection
The source code is on GitHub.
If you arrived here because you also wanted to send selected text to OpenCode, the extension is ready. If you wanted to understand how a VS Code extension works inside, reading the official one is the best starting point I know.
Related postWhen astro check says a dependency is missing but the real bug is a pnpm override8 min readWritten by: Jonathan Reis on