Como construir uma extensão para VS Code: li a do OpenCode e criei a minha
12 min de leituraEscrito por: Jonathan Reis em
A extensão oficial do OpenCode no VS Code tem 2KB e só envia referências de arquivo para o terminal. Eu precisava enviar o texto selecionado, de qualquer arquivo, salvo ou não. Em vez de desistir, li o código minificado, encontrei o endpoint HTTP interno e construí uma extensão VS Code própria — um tutorial prático de como criar extensões para VS Code.

A extensão oficial do OpenCode para VS Code tem 2.298 bytes. Sem bundler, sem dependências, sem framework. Isso é uma pista: extensões VS Code são mais simples do que parecem, e dá pra aprender muito lendo uma que funciona.
Eu queria mandar texto selecionado de qualquer arquivo aberto no VS Code direto pro prompt do OpenCode, sem copiar e colar. A extensão oficial só envia referências de arquivo (@path#Lstart-end) e só para arquivos dentro do workspace. Arquivos temporários, não salvos, abertos de fora do projeto — nada.
Em vez de desistir, eu li o código dela.
O problema real
O OpenCode é um agente de IA que roda no terminal. A extensão oficial do VS Code abre ele num terminal split e registra atalhos: Cmd+Esc abre o terminal, Cmd+Alt+K insere referência do arquivo atual. O “context awareness” — a capacidade de compartilhar o que você está selecionando no editor com o agente — é o que conecta o editor ao terminal.
A inspiração é o Cursor. No Cursor você seleciona código, aperta Cmd+L, e o texto vira contexto do chat — sem copiar, sem colar, sem descrever onde está. Eu queria esse comportamento no VS Code com OpenCode.
A extensão oficial tem duas limitações que me incomodavam:
- Só envia
@path#Lstart-end, nunca o texto cru. Se o arquivo não está no workspace, não tem caminho relativo, não tem referência. - O atalho
Cmd+Alt+Kdepende do terminal ativo se chamar “opencode”. Se você abriu o OpenCode manualmente num terminal normal, o atalho não faz nada.
O que eu queria era o comportamento do Cursor: selecionar texto em qualquer arquivo (salvo, temporário, fora do workspace) e mandar pro prompt. Arquivos do workspace viram referência compacta (@path#Lstart-end), igual ao Cursor. Arquivos fora do workspace viram texto sanitizado, sem quebrar o TUI.
Lendo a extensão oficial
A extensão instalada está em ~/.vscode/extensions/sst-dev.opencode-0.0.13/. O package.json mostra três keybindings:
"keybindings": [
{ "command": "opencode.openTerminal", "key": "cmd+escape" },
{ "command": "opencode.openNewTerminal", "key": "cmd+shift+escape" },
{ "command": "opencode.addFilepathToTerminal", "key": "cmd+alt+k" }
]
O activationEvents é [] — a extensão não declara quando ativar. O VS Code carrega ela lazy, só quando um comando dela é chamado pela primeira vez. Por isso os atalhos pareciam mortos até eu abrir o OpenCode via Cmd+Esc.
O extension.js é minificado, mas pequeno o suficiente pra ler de uma vez. A função que monta a referência é curta:
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;
}
A guarda que me bloqueava está na segunda linha: if (!o.workspace.getWorkspaceFolder(n.uri)) return;. Se o arquivo não pertence a uma pasta do workspace, a função retorna undefined e o comando aborta silenciosamente.
O endpoint escondido
A parte que me surpreendeu está na função que envia o texto. Em vez de digitar no terminal via sendText, a extensão faz um POST HTTP:
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 }),
});
}
O t é uma porta aleatória entre 16384 e 65536, gerada quando o terminal é criado. A extensão passa essa porta como env var _EXTENSION_OPENCODE_PORT nas creationOptions do terminal. O OpenCode, ao rodar com opencode --port <porta>, sobe um servidor HTTP local que escuta nesse endpoint.
Isso é o que torna a extensão oficial possível: ela não digita no terminal, ela fala com o servidor HTTP que o OpenCode sobe. O TUI recebe o texto via API, não via keystroke. Se o endpoint append-prompt aceita qualquer texto, eu posso mandar o que quiser — não só referências de arquivo.
A anatomia de uma extensão VS Code
Antes de mostrar a minha extensão, vale destrinchar o que compõe uma extensão VS Code mínima. São três peças:
1. package.json
Declara a extensão para o VS Code. Define o nome, publisher, versão, quando ativar, qual arquivo é o ponto de entrada e quais comandos e atalhos a extensão contribui.
{
"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" }
]
}
}
O campo activationEvents diz ao VS Code quando carregar a extensão. onCommand:opencodeSelection.send significa: carregue a extensão quando alguém chamar esse comando pela primeira vez. Antes disso, a extensão não ocupa memória.
O contributes é onde a extensão declara o que ela adiciona ao VS Code: comandos que aparecem na paleta, atalhos de teclado, configurações, views. Para uma extensão que só registra um atalho, isso é tudo.
2. extension.js
O ponto de entrada. Exporta duas funções: activate e deactivate. A activate roda quando o VS Code decide carregar a extensão (baseado nos activationEvents).
function activate(context) {
const disposable = vscode.commands.registerCommand("opencodeSelection.send", async () => {
// o que acontece quando você aperta Cmd+L
});
context.subscriptions.push(disposable);
}
function deactivate() {}
module.exports = { activate, deactivate };
O context.subscriptions.push(disposable) é o padrão do VS Code para limpeza: tudo que você registra (comandos, listeners, providers) vai na lista de subscriptions, e o VS Code descarta tudo quando a extensão é desativada.
3. A pasta
O VS Code espera extensões em ~/.vscode/extensions/ com a pasta nomeada como <publisher>.<name>-<version>. Se a pasta não tiver a versão no nome, a extensão não carrega. Isso me custou 20 minutos de debug — a pasta sunstoneapps.opencode-selection não funcionava até eu renomear para sunstoneapps.opencode-selection-0.1.0.
O que a minha extensão faz diferente
A lógica se divide em dois arquivos: src/logic.js com funções puras (sem dependência do VS Code API, testáveis) e extension.js que faz a ponte com o editor. Cada função faz uma coisa.
buildReference — referência ou nada
A versão em src/logic.js recebe os dados prontos, sem acoplar à API do VS Code:
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;
}
No extension.js, um wrapper fina faz a ponte com a API do VS Code:
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);
}
Mesma lógica da extensão oficial, com uma diferença: se o arquivo não está no workspace, retorno null em vez de abortar. Quem decide o que fazer com null é a função seguinte.
sanitizeText — texto que não quebra o TUI
A parte que me deu mais trabalho. O OpenCode processa keystrokes char a char. Se o texto enviado contém \n, o TUI interpreta como Enter (submit do prompt). Se contém \t, troca de agente. Backticks, chars de controle, tudo pode ativar algo.
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 vira quatro espaços. Newline vira espaço. Chars de controle (\x00-\x1F, \x7F) são removidos. Se o texto original era multiline, envolvo em backticks inline para o LLM saber que é código contínuo. Backticks dentro do texto são escapados com ```.
sendToTerminal — HTTP ou 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();
}
Se a porta não está nas env vars do terminal (porque alguém abriu o OpenCode manualmente), faz fallback pra terminal.sendText — que digita o texto no terminal como keystrokes. Não é ideal, mas é melhor que falhar silenciosamente.
A armadilha do newline
A primeira versão funcionava para texto de uma linha. Para seleções multiline, o texto ia pro prompt e o OpenCode interpretava o \n como Enter — submetia o prompt antes que eu terminasse de digitar.
A solução de envolver em backticks parecia funcionar até eu testar com código que tinha tab. O TUI trocou de agente no meio do envio. Foi quando entendi que o problema não era só newline — era qualquer char de controle.
A regex [\x00-\x08\x0B\x0C\x0E-\x1F\x7F] cobre tudo que é control char no ASCII, exceto \t (tratado antes, virando espaço) e \n (também antes, virando espaço). O \x7F (DEL) também entra. Não é paranoia — é o que acontece quando você injeta texto num TUI que processa keystrokes.
Por que isso funciona
O OpenCode, quando rodado com --port, sobe um servidor HTTP local. A extensão oficial passa essa porta como env var do terminal. Qualquer extensão que consiga ler essa env var consegue falar com o OpenCode via HTTP.
O endpoint /tui/append-prompt não é documentado. Não há contrato público de que ele vai continuar existindo. Isso é o risco de depender de API interna — pode quebrar a qualquer release. Para uma extensão que resolve um problema real e está publicada com código aberto, o risco é aceitável: se quebrar, o leitor pode corrigir. Para um produto comercial fechado, não seria.
O que checar antes de construir uma extensão VS Code
- A pasta precisa da versão no nome.
<publisher>.<name>-<version>. Sem a versão, o VS Code ignora. activationEventsvazio significa lazy loading. A extensão só carrega quando um comando dela é chamado. Se os atalhos parecem mortos, pode ser que a extensão nunca ativou naquela janela.terminal.creationOptions.envé só leitura. A env var_EXTENSION_OPENCODE_PORTsó existe se o terminal foi criado pela extensão oficial viaCmd+Esc. Um terminal aberto manualmente não tem.- Conflito de keybinding é silencioso. O VS Code não avisa quando dois comandos competem pelo mesmo atalho. O que vence é o primeiro da lista em ordem de precedência. Se o seu atalho não funciona, abra
Cmd+K Cmd+Se procure pelo atalho — pode ter outro comando roubando. fetchestá disponível sem import. O Node.js 18+ (que o VS Code usa) temfetchglobal. Não precisa denode-fetchnemaxiospara um POST simples.
O que ficou de fora
A extensão tem testes para sanitizeText e buildReference — 11 asserções cobrindo tab, newline, control chars, DEL, backticks, file ref, single-line e multi-line. São funções puras em src/logic.js, fáceis de testar sem mockar a API do VS Code.
O que ainda não tem:
- Configuração. Permitir customizar o atalho, escolher entre referência e texto, desativar o fallback de
sendText. - Descoberta do terminal mais robusta. Em vez de filtrar por nome “opencode”, rastrear o PID ou usar a env var como única fonte de verdade.
- Tratamento de versão da API. Checar se o endpoint responde antes de mandar, e dar mensagem útil se mudou.
Instalar
A extensão está publicada no VS Code Marketplace. Você pode instalar direto pelo VS Code: abra a paleta de comandos (Cmd+Shift+P), digite “Install Extensions” e procure por “OpenCode Selection”. Ou pelo terminal:
code --install-extension sunstoneapps.opencode-selection
O código-fonte está no GitHub.
Se você chegou aqui porque também queria enviar seleção de texto pro OpenCode, a extensão está pronta. Se queria entender como uma extensão VS Code funciona por dentro, a leitura da oficial é o melhor ponto de partida que eu conheço.
Post relacionadoQuando astro check diz que falta dependência, mas o bug real é um override do pnpm8 min readEscrito por: Jonathan Reis em