All skills
aktsmm avatar

/vscode-extension-guide

@7ae9078
by yamapanaktsmm/agent-skills26 stars
4

Guide for creating VS Code extensions and plugins from scratch through Marketplace publication. Use when developing a VS Code extension/plugin, adding commands or keybindings, building TreeView or Webview UI, publishing to Marketplace, or troubleshooting activation and packaging issues.

Use this Skill: https://skilld.dev/gh/aktsmm/agent-skills/vscode-extension-guide

This session only. Nothing lands on disk.

referenceswebview.md

≈6.1k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Webview Implementation

Create rich HTML-based UI panels in VS Code.

Basic Webview Panel

import * as vscode from "vscode";

export function createWebviewPanel(context: vscode.ExtensionContext) {
  const panel = vscode.window.createWebviewPanel(
    "myWebview", // Identifier
    "My Webview", // Title
    vscode.ViewColumn.One, // Editor column
    {
      enableScripts: true, // Enable JavaScript
      retainContextWhenHidden: true, // Keep state when hidden
      localResourceRoots: [
        // Allowed local resources
        vscode.Uri.joinPath(context.extensionUri, "media"),
      ],
    },
  );

  panel.webview.html = getWebviewContent(panel.webview, context.extensionUri);

  return panel;
}

HTML Content

function getWebviewContent(
  webview: vscode.Webview,
  extensionUri: vscode.Uri,
): string {
  // Get URI for local resources
  const styleUri = webview.asWebviewUri(
    vscode.Uri.joinPath(extensionUri, "media", "style.css"),
  );
  const scriptUri = webview.asWebviewUri(
    vscode.Uri.joinPath(extensionUri, "media", "main.js"),
  );

  // CSP nonce for security
  const nonce = getNonce();

  return `<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta http-equiv="Content-Security-Policy" 
        content="default-src 'none'; style-src ${webview.cspSource}; script-src 'nonce-${nonce}';">
  <link href="${styleUri}" rel="stylesheet">
</head>
<body>
  <h1>Hello Webview!</h1>
  <button id="btn">Click Me</button>
  <script nonce="${nonce}" src="${scriptUri}"></script>
</body>
</html>`;
}

function getNonce(): string {
  let text = "";
  const chars =
    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
  for (let i = 0; i < 32; i++) {
    text += chars.charAt(Math.floor(Math.random() * chars.length));
  }
  return text;
}

Embed initial data safely (no document.write)

<script id="initial-data" type="application/json">
  ${serializeForWebview(initialData)}
</script>
<script nonce="${nonce}">
  (function () {
    var vscode = acquireVsCodeApi();
    var initialData = {};
    try {
      var el = document.getElementById("initial-data");
      if (el && el.textContent) initialData = JSON.parse(el.textContent) || {};
    } catch (e) {
      initialData = {};
    }
    // ... use initialData ...
  })();
</script>
  • Avoid Base64 + document.write; inject JSON as text and parse.
  • Escape <, U+2028/2029 before embedding to keep the script tag valid.
  • Keep the CSP nonce on the executable script only.

Message Passing

Extension → Webview

// In extension
panel.webview.postMessage({ command: "update", data: { count: 42 } });
// In webview (media/main.js)
window.addEventListener("message", (event) => {
  const message = event.data;
  if (message.command === "update") {
    console.log("Count:", message.data.count);
  }
});

Webview → Extension

// In webview
const vscode = acquireVsCodeApi();

document.getElementById("btn").addEventListener("click", () => {
  vscode.postMessage({ command: "buttonClicked", text: "Hello!" });
});
// In extension
panel.webview.onDidReceiveMessage(
  (message) => {
    switch (message.command) {
      case "buttonClicked":
        vscode.window.showInformationMessage(message.text);
        return;
    }
  },
  undefined,
  context.subscriptions,
);

State Persistence

// In webview - save state
const vscode = acquireVsCodeApi();
vscode.setState({ count: 5 });

// Restore state
const state = vscode.getState();
if (state) {
  console.log("Restored count:", state.count);
}

When an edit form shows values derived from current settings defaults, capture a normalized baseline when edit starts and diff against that frozen baseline on submit. Do not recompute the original side of the diff from current defaults, or unchanged fields can become false updates if settings change mid-edit.

For persisted edits, send that baseline to the host and compare it with current data before confirmation and again inside the storage transaction. A browser save lock cannot protect other windows. Reject stale edits without restoring revoked consent; recheck cancellation and trust at commit. Compare settings only when unrelated history updates should not conflict; revision-based callers may intentionally use a stricter whole-store check. Reset must load a fresh baseline and disclose that unsaved input is discarded.

let editingTaskSnapshot = null;
let editingTaskNormalizedSnapshot = null;

function normalizeTaskForDiff(task, currentDefaults) {
  const source = task || {};
  return {
    jitterSeconds:
      source.jitterSeconds != null
        ? Number(source.jitterSeconds)
        : currentDefaults.jitterSeconds,
    autoMode: source.autoMode === true,
    chatSession:
      source.chatSession === "new" || source.chatSession === "continue"
        ? source.chatSession
        : "default",
  };
}

function beginEdit(task, currentDefaults) {
  editingTaskSnapshot = { ...task };
  editingTaskNormalizedSnapshot = normalizeTaskForDiff(task, currentDefaults);
}

function buildUpdateData(formData, currentDefaults) {
  const current = normalizeTaskForDiff(formData, currentDefaults);
  const original =
    editingTaskNormalizedSnapshot ||
    normalizeTaskForDiff(editingTaskSnapshot, currentDefaults);
  const diff = {};

  for (const key of Object.keys(current)) {
    if (current[key] !== original[key]) {
      diff[key] = formData[key];
    }
  }

  return diff;
}

This matters when you correctly keep create-form defaults reactive but avoid overwriting active edit forms during updateDefaults / configuration-change events.

Keep persistence and presentation outcomes separate. After a confirmed save, a failed refresh or notification must retain the saved ID/success and return a sanitized warning, not an apparent save failure that invites duplicate retries. Actual write failures and standalone refresh failures remain errors. Persist a new folder approval with the successful task save, not as an earlier side effect. Test revoked consent during a queued save, changes during confirmation, stale form/reset, unrelated updates and presenter failure against real fixture storage.

VS Code Theme Integration

Use CSS variables for consistent theming:

/* media/style.css */
body {
  font-family: var(--vscode-font-family);
  font-size: var(--vscode-font-size);
  color: var(--vscode-foreground);
  background-color: var(--vscode-editor-background);
}

button {
  background-color: var(--vscode-button-background);
  color: var(--vscode-button-foreground);
  border: none;
  padding: 8px 16px;
  cursor: pointer;
}

button:hover {
  background-color: var(--vscode-button-hoverBackground);
}

Sidebar Webview (WebviewViewProvider)

For webviews in the sidebar instead of editor panels:

class MyWebviewProvider implements vscode.WebviewViewProvider {
  resolveWebviewView(webviewView: vscode.WebviewView) {
    webviewView.webview.options = { enableScripts: true };
    webviewView.webview.html = getWebviewContent();
  }
}

// Register in extension.ts
vscode.window.registerWebviewViewProvider(
  "myExtSidebarView",
  new MyWebviewProvider(),
);
"contributes": {
  "views": {
    "explorer": [{
      "type": "webview",
      "id": "myExtSidebarView",
      "name": "My Webview"
    }]
  }
}

Fallback Patterns

Keyboard Tabs

Use one tab stop: the selected tab has tabindex="0", others -1, with matching aria-selected and panel associations in both initial HTML and every switch. Left/Right wrap; Home/End select endpoints. Reuse the click switch path and retain native Enter/Space activation to avoid double handling. Ignore modifiers, composition and non-tab fields; reject missing targets before hiding the current panel. Move focus off hidden panels or deactivated tab buttons, but do not steal unrelated focus.

Test actual handlers and registration with real key presses, active element, selected state and visible panel assertions. An extracted production-code browser fixture can verify those interactions; label it as partial evidence, not full VS Code host or screen-reader verification. Remove its owned server and temporary files afterward.

Promise-based Callback Fallback

When using Promise-based callbacks (e.g., resolveCreate), always provide a fallback mechanism:

// ❌ Bad: Single callback dependency
case "createTask": {
  if (!resolveCreate) {
    return; // Silent failure if callback not set
  }
  resolveCreate(data);
  break;
}

// ✅ Good: Fallback to alternative handler
case "createTask": {
  const result = buildResult(data);
  if (resolveCreate) {
    resolveCreate(result);
    resolveCreate = undefined;
  } else if (onAction) {
    // Fallback to action handler
    onAction({ action: "create", data: result });
  }
  break;
}

VS Code Internal API Fallback

When using internal/unstable APIs (vscode.lm, vscode.chat), always implement fallback:

// ✅ Good: API availability check + fallback
static async getAvailableModels(): Promise<Model[]> {
  const models: Model[] = [{ id: "", name: "Default" }];

  try {
    if (typeof vscode.lm !== "undefined" && "selectChatModels" in vscode.lm) {
      const available = await (vscode.lm as any).selectChatModels({});

      // Null check for API result
      if (available && Array.isArray(available)) {
        for (const model of available) {
          models.push({
            id: model.id || model.family,
            name: model.name || model.family || model.id,
          });
        }
      }
    }
  } catch (error) {
    console.log("API not available, using fallback", error);
  }

  // Return fallback if API returned nothing useful
  if (models.length <= 1) {
    return getFallbackModels();
  }

  return models;
}

Path Consistency

When handling both local and global paths, use consistent format:

// ❌ Bad: Mixed path formats
templates.push({
  source: "local",
  path: relativePath, // Relative
});
templates.push({
  source: "global",
  path: file.fsPath, // Absolute - inconsistent!
});

// ✅ Good: Consistent relative paths
templates.push({
  source: "local",
  path: path.relative(workspaceRoot, file.fsPath).replace(/\\/g, "/"),
});
templates.push({
  source: "global",
  path: path.relative(globalRoot, file.fsPath).replace(/\\/g, "/"),
});

Reliable Webview Communication Pattern

Recommended Pattern (Simple & Reliable)

Wrap the entire webview script in an IIFE and send webviewReady at the end:

` ypescript function getWebviewContent(): string { return <!DOCTYPE html>

<html> <head> <meta charset="UTF-8"> <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-${nonce}';"> </head> <body> <div id="app"></div> <script nonce="${nonce}"> (function() { const vscode = acquireVsCodeApi(); // Initialize UI // ... DOM setup ... // Handle messages from extension window.addEventListener('message', event => { const message = event.data; switch (message.type) { case 'updateData': renderData(message.data); break; } }); // Initial render renderUI(); // Notify extension that webview is ready (LAST!) vscode.postMessage({ type: 'webviewReady' }); })(); </script> </body> </html>; } `

Extension Side Handler

ypescript panel.webview.onDidReceiveMessage(message => { switch (message.type) { case 'webviewReady': console.log('[Extension] Webview reported ready'); webviewReady = true; // Send initial data AFTER webview is ready panel.webview.postMessage({ type: 'updateAgents', agents: cachedAgents, }); panel.webview.postMessage({ type: 'updateModels', models: cachedModels, }); break; } });

❌ Anti-Pattern: Complex Handshakes

Avoid adding complexity like ping/ACK/retry/fallback mechanisms:

` ypescript // ❌ Bad: Overly complex handshake let webviewReadyAcked = false; let webviewReadyRetryTimer = null; let webviewReadyAttempts = 0;

function startWebviewReadyHandshake() { sendWebviewReady(); webviewReadyRetryTimer = setInterval(() => { if (webviewReadyAcked || webviewReadyAttempts >= 12) { clearInterval(webviewReadyRetryTimer); return; } sendWebviewReady(); // Retry }, 500); }

// Host pings webview, webview responds, host ACKs... // This adds complexity and often fails in unexpected ways `

Why it fails:

  • More moving parts = more failure modes
  • Race conditions between ping/ACK/retry timers
  • Fallback mechanisms mask the real problem

Simple pattern is best: One webviewReady message at script end, host waits for it.

Debugging Tips

  1. Host logs vs Webview logs are separate

    • Extension host: console.log() appears in Debug Console
    • Webview: console.log() appears in Webview Developer Tools
    • Use Developer: Open Webview Developer Tools command
  2. Verify script execution via message javascript // First line after acquireVsCodeApi vscode.postMessage({ type: 'scriptStarted' }); If host receives this, script is running. If not, check CSP/nonce.

  3. Check CSP errors in Webview DevTools

    • Open Webview Developer Tools
    • Look for CSP violation errors in Console

Webview JavaScript Anti-Patterns

Webview内のJavaScriptは通常のブラウザ環境と異なる動作をする場合があります。以下のパターンを避けてください。

1. アロー関数 vs 従来関数

Webview環境では従来のfunction構文がより安全です:

// ❌ Bad: Arrow function
btn.addEventListener("click", (e) => {
  handleClick(e);
});

// ✅ Good: Traditional function
btn.addEventListener("click", function (e) {
  handleClick(e);
});

2. nullチェック必須

getElementById は null を返す可能性があります。常にnullチェックを行ってください:

// ❌ Bad: No null check
document.getElementById("my-input").value = "xxx";

// ✅ Good: With null check
var element = document.getElementById("my-input");
if (element) element.value = "xxx";

3. イベント委譲パターン推奨

NodeListへの直接イベント登録は失敗する可能性があります。イベント委譲を使用してください:

// ❌ Bad: Direct event registration on NodeList
document.querySelectorAll(".btn").forEach(function (btn) {
  btn.addEventListener("click", handleClick);
});

// ✅ Good: Event delegation
document.addEventListener("click", function (e) {
  var target = e.target;
  if (target && target.classList && target.classList.contains("btn")) {
    e.preventDefault();
    handleClick(target);
  }
});

4. var を使用

互換性のため、const / let より var を推奨:

// ❌ Bad: const/let
const items = [];
let count = 0;

// ✅ Good: var
var items = [];
var count = 0;

5. デフォルト引数の回避

ES6のデフォルト引数構文は避けてください:

// ❌ Bad: Default parameters
function updateOptions(source, selectedPath = "") {
  // ...
}

// ✅ Good: Manual default
function updateOptions(source, selectedPath) {
  selectedPath = selectedPath || "";
  // ...
}

6. 初期データの埋め込み

"Loading..."をハードコードせず、初期データがある場合は直接埋め込んでください:

// ❌ Bad: Hardcoded loading state
return `<select id="agent-select">
  <option value="">Loading...</option>
</select>`;

// ✅ Good: Embed initial data if available
const options =
  agents.length > 0
    ? agents.map((a) => `<option value="${a.id}">${a.name}</option>`).join("")
    : '<option value="">Loading...</option>';
return `<select id="agent-select">${options}</select>`;

7. 非同期処理のフォールバック

API呼び出しには常にtry/catchとフォールバックデータを用意してください:

// ❌ Bad: No fallback
async function getModels(): Promise<Model[]> {
  return await vscode.lm.selectChatModels({});
}

// ✅ Good: With fallback
async function getModels(): Promise<Model[]> {
  try {
    const models = await vscode.lm.selectChatModels({});
    if (models && models.length > 0) {
      return models;
    }
  } catch {
    // API may not be available
  }
  return getFallbackModels();
}

8. data-action + 委譲でアクションを束ねる

// ✅ Good: render attributes, delegate once
function renderTasks(tasks) {
  return tasks
    .map(function (task) {
      var id = escapeAttr(task.id || "");
      return '<button data-action="run" data-id="' + id + '">Run</button>';
    })
    .join("");
}

document.addEventListener("click", function (e) {
  var target = e.target;
  var host =
    target && typeof target.closest === "function"
      ? target.closest("[data-action]")
      : null;
  if (!host) return;
  var action = host.getAttribute("data-action");
  var id = host.getAttribute("data-id");
  if (!action || !id) return;
  if (action === "run") window.runTask(id);
  if (action === "edit") window.editTask(id);
  // ... other actions ...
});
  • ❌ script-src 'nonce-...' は onclick="..." などのインラインイベント属性を許可しない。nonce 付き script 内で addEventListener を登録し、実機クリックと Webview Developer Tools の CSP エラーを確認する。ソース検査だけで動作確認済みとしない。
  • ✅ 未信頼の Markdown リンクは Webview からの URL を信用せず、Extension Host 側でスキームを許可リスト照合し、相対パスを信頼できる配布元 URL に解決してから vscode.env.openExternal で開く。
  • ❌ Avoid TypeScript キャスト文字列(as HTMLElement がそのままHTMLに出てSyntaxError)。
  • ✅ 属性は必ず escape し、委譲で処理する。

9. ビルド後 HTML の健全性チェック

  • ビルド時に debug-webview.html を出力し、実ファイルをブラウザ/VS Codeで開いて SyntaxError を確認する。
  • Webview Developer Tools の Console を確認し、CSP/quote崩れ/document.write などのエラーを検知する。
  • タブ切り替え・プルダウンなど主要動作を1回ずつ手動で触り、ログにエラーが出ないか見る。

正規表現リテラルの二重エスケープ

テンプレートリテラル内で正規表現を記述する際、バックスラッシュが消える問題があります:

// ❌ Bad: Backslash gets stripped in template literal
const html = `<script>var everyN = /^\*\/(\d+)$/.exec(minute);</script>`;
// Result in browser: /^*/(\d+)$/ → SyntaxError: Nothing to repeat

// ✅ Good: Double-escape backslashes
const html = `<script>var everyN = /^\\*\\/(\\d+)$/.exec(minute);</script>`;
// Result in browser: /^\*\/(\d+)$/ → Works correctly

影響を受けるパターン:

  • \d → \\d
  • \s → \\s
  • \* → \\*
  • \/ → \\/

デバッグ方法:

  1. Webview Developer Toolsを開く
  2. Consoleで Invalid regular expression: /^*/: Nothing to repeat を探す
  3. ビルド出力 (out/extension.js) で該当の正規表現を確認

設定変更の即時反映

言語設定などを変更した際、Webviewを即座に再レンダリングするパターン:

// extension.ts
const configWatcher = vscode.workspace.onDidChangeConfiguration((e) => {
  if (e.affectsConfiguration("myExtension.language")) {
    // Webviewを新しい言語で再レンダリング
    MyWebview.refreshLanguage(getCurrentData());
  }

  if (
    e.affectsConfiguration("myExtension.globalPromptsPath") ||
    e.affectsConfiguration("myExtension.globalAgentsPath")
  ) {
    // キャッシュをクリアして再取得
    void refreshCachedData(true);
  }
});

context.subscriptions.push(configWatcher);
// webview.ts
static refreshLanguage(data: any[]): void {
  if (this.panel) {
    // パネルを閉じて再作成(言語変更を反映)
    this.panel.dispose();
    this.panel = undefined;
    void this.show(this.extensionUri, data, this.onAction);
  }
}

Moving Inline JS to an External File (Recommended)

Large inline <script> blocks inside TypeScript template literals are hard to edit, cause merge conflicts, and slow down the webview parse step. Prefer an external file.

Anti-pattern (inline)

// BAD – hundreds of lines of JS buried in a TS template literal
return `<html>...
  <script nonce="${nonce}">
    // 1000 lines of JS here
  </script>
</html>`;

Preferred pattern (external file)

my-extension/
├── media/
│   └── webview.js     ← all webview logic lives here
└── src/
    └── myWebview.ts   ← only HTML skeleton + initial-data injection
// myWebview.ts – only the skeleton remains in TypeScript
const scriptUri = webview.asWebviewUri(
  vscode.Uri.joinPath(extensionUri, "media", "webview.js"),
);

return `<!DOCTYPE html>
<html>
<head>
  <meta http-equiv="Content-Security-Policy"
        content="default-src 'none';
                 style-src ${webview.cspSource} 'unsafe-inline';
                 script-src 'nonce-${nonce}';
                 img-src ${webview.cspSource};
                 font-src ${webview.cspSource};">
</head>
<body>
  <script nonce="${nonce}" id="initial-data"
          type="application/json">${serializeForWebview(data)}</script>
  <script nonce="${nonce}" src="${scriptUri}"></script>
</body>
</html>`;
// Tighten localResourceRoots to only what is needed
this.panel = vscode.window.createWebviewPanel(
  "myWebview",
  "My View",
  vscode.ViewColumn.One,
  {
    enableScripts: true,
    retainContextWhenHidden: true,
    localResourceRoots: [
      vscode.Uri.joinPath(extensionUri, "media"), // JS / CSS
      vscode.Uri.joinPath(extensionUri, "images"), // icons
      // ❌ Don't pass extensionUri directly – too broad
    ],
  },
);

Serialising initial data safely

function serializeForWebview(value: unknown): string {
  const json = JSON.stringify(value ?? null) ?? "null";
  return json
    .replace(/</g, "\\u003c")
    .replace(/\u2028/g, "\\u2028")
    .replace(/\u2029/g, "\\u2029");
}
// media/webview.js – read the injected data
(function () {
  var vscode = acquireVsCodeApi();
  var initialData = {};
  try {
    var el = document.getElementById("initial-data");
    if (el) initialData = JSON.parse(el.textContent || "{}");
  } catch (e) {
    /* ignore */
  }
  // … use initialData …
})();

Prompting Reload After Extension Update

Because activationEvents: ["onStartupFinished"] fires only once per VS Code startup, users who update the extension without restarting VS Code will keep running stale code. Show a "Reload Now" notification when the version changes.

// extension.ts
const LAST_VERSION_KEY = "lastKnownVersion";

export function activate(context: vscode.ExtensionContext): void {
  const currentVersion =
    (context.extension.packageJSON as { version?: string }).version ?? "0.0.0";
  const lastVersion = context.globalState.get<string>(LAST_VERSION_KEY);

  if (lastVersion && lastVersion !== currentVersion) {
    void vscode.window
      .showInformationMessage(
        `Extension updated to v${currentVersion}. Reload to activate.`,
        "Reload Now",
      )
      .then((choice) => {
        if (choice === "Reload Now") {
          void vscode.commands.executeCommand("workbench.action.reloadWindow");
        }
      });
  }
  void context.globalState.update(LAST_VERSION_KEY, currentVersion);
}

Why not vscode.env.reload()? It reloads immediately without user consent. The pattern above lets users finish their current work first.

Source: SKILL.md on GitHub

1 warning8d5 checks · Risk SAFE
  • Gen Agent Trust Hub8d

    The skill provides a comprehensive and secure guide for developing and publishing VS Code extensions. It follows security best practices, particularly concerning secret management and webview implementation.

  • Socket8d

    No alerts

  • Snyk8d

    Risk: LOW · No issues

  • Runlayer7mo

    9/9 files flagged

  • ZeroLeaks5mo

    Score: 93/100 · 2 sections analyzed

Signed by skilld at 7ae9078. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 18 hours ago.

Activeupdated last week
argument-hint
作りたい拡張機能、追加したい機能、困っている点
user-invocable
true
metadata
{
  "author": "yamapan (https://github.com/aktsmm)"
}

README badge

README badge for aktsmm/agent-skills/vscode-extension-guide