Pular para o conteúdo
← Voltar para o blog
VS Code e tooling de IA

Como construir uma extensão para VS Code: li a do OpenCode e criei a minha

12 min de leitura

Escrito 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.

Imagem de prévia OpenGraph deste artigo. Como construir uma extensão para VS Code: li a do OpenCode e criei a minha

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:

  1. 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.
  2. O atalho Cmd+Alt+K depende 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.
  • activationEvents vazio 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_PORT só existe se o terminal foi criado pela extensão oficial via Cmd+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+S e procure pelo atalho — pode ter outro comando roubando.
  • fetch está disponível sem import. O Node.js 18+ (que o VS Code usa) tem fetch global. Não precisa de node-fetch nem axios para 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