opencodeとcode-serverのサイドバーで使える拡張機能

code-serverのサイドバーでopencodeを利用する拡張機能を少しだけ更新しました。
ここではLXDコンテナで利用する方法で。

code-severをインストール

まずはLXDコンテナにcode-serverをインストールします。

opencodeをインストール

opencodeのインストール自体は簡単です。次のコードを入力するだけです。
code-serverと同じLXDコンテナにインストールします。

curl -fsSL https://opencode.ai/install | bash

ターミナルから呼び出して使用

インストールが完了したら、新たなターミナルを開いてopencodeと入力すれば利用出来るようになります。

opencode

Webブラウザから利用

Webブラウザから利用したい場合も、手軽なのは下記コマンドで実行するだけです。

opencode web --hostname 0.0.0.0 --port 4096

code-serveの拡張機能に追加

code-serveのサイドバーでopencodeを利用するために拡張機能を作ります。まずは下記スクリプトを貼り付けて実行し、拡張機能を作成します。

apt install -y zip
mkdir -p /opt/lxd-data/script
cd /opt/lxd-data/script
nano build-opencode-dev.sh
bash build-opencode-dev.sh
#!/bin/bash
# ============================================================
# build-opencode-dev.sh
# opencode-dev VSIX拡張機能作成スクリプト
# 使い方: bash build-opencode-dev.sh
# ============================================================
set -e

VERSION="3.0.0"
OUT="/opt/lxd-data/opencode/opencode-dev-${VERSION}.vsix"
WORKDIR="$(mktemp -d)"
trap "rm -rf $WORKDIR" EXIT

mkdir -p /opt/lxd-data/opencode/log
echo "==> ビルドディレクトリ: $WORKDIR"
mkdir -p "$WORKDIR/extension"

# ── extension/package.json ──────────────────────────────────
cat > "$WORKDIR/extension/package.json" << 'EOF'
{
	"name": "opencode-dev",
	"displayName": "OpenCode Dev",
	"description": "Programming assistant with live progress from OpenCode",
	"version": "3.0.0",
	"publisher": "opencode",
	"engines": { "vscode": "^1.90.0" },
	"categories": ["AI"],
	"contributes": {
		"viewsContainers": {
			"activitybar": [
				{
					"id": "opencodeDev",
					"title": "OpenCode Dev",
					"icon": "$(tools)"
				}
			]
		},
		"views": {
			"opencodeDev": [
				{
					"type": "webview",
					"id": "opencodeDev.view",
					"name": "OpenCode Dev"
				}
			]
		},
		"configuration": {
			"title": "OpenCode Dev",
			"properties": {
				"opencodeDev.executablePath": {
					"type": "string",
					"default": "",
					"description": "opencode実行ファイルのフルパス(空の場合は ~/.opencode/bin/opencode を使用)"
				}
			}
		}
	},
	"activationEvents": ["onStartupFinished"],
	"main": "./extension.js"
}
EOF

# ── extension/extension.js ──────────────────────────────────
cat > "$WORKDIR/extension/extension.js" << 'EOF'
const vscode = require('vscode');
const { spawn } = require('child_process');
const path = require('path');
const os = require('os');
const fs = require('fs');

const LOG_DIR = '/opt/lxd-data/opencode/log';

function activate(extensionContext) {
	function findOpencodePath() {
		const config = vscode.workspace.getConfiguration('opencodeDev');
		const customPath = config.get('executablePath');
		if (customPath && customPath.trim()) return customPath.trim();
		return path.join(os.homedir(), '.opencode', 'bin', 'opencode');
	}

	const provider = new OpencodeDevProvider(extensionContext, findOpencodePath);
	extensionContext.subscriptions.push(
		vscode.window.registerWebviewViewProvider('opencodeDev.view', provider)
	);
}

class OpencodeDevProvider {
	constructor(context, getExecPath) {
		this._context = context;
		this._getExecPath = getExecPath;
		this._view = null;
		this._currentProc = null;
		this._currentSessionLog = [];
	}

	resolveWebviewView(webviewView) {
		this._view = webviewView;
		webviewView.webview.options = { enableScripts: true };
		webviewView.webview.html = this._getHtml();

		webviewView.webview.onDidReceiveMessage(async (msg) => {
			if (msg.command === 'send') {
				await this._runOpencode(msg.text);
			} else if (msg.command === 'cancel') {
				if (this._currentProc) {
					this._currentProc.kill();
					this._currentProc = null;
					this._post({ command: 'cancelled' });
				}
			} else if (msg.command === 'newSession') {
				await this._saveCurrentSession();
				this._currentSessionLog = [];
				this._post({ command: 'sessionCleared' });
			}
		});
	}

	_post(msg) {
		if (this._view) this._view.webview.postMessage(msg);
	}

	async _saveCurrentSession() {
		if (this._currentSessionLog.length === 0) return;

		try {
			fs.mkdirSync(LOG_DIR, { recursive: true });
		} catch (e) {
			vscode.window.showErrorMessage(`ログディレクトリの作成に失敗: ${e.message}`);
			return;
		}

		const now = new Date();
		const pad = (n) => String(n).padStart(2, '0');
		const filename = `session_${now.getFullYear()}${pad(now.getMonth()+1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}.txt`;
		const filePath = path.join(LOG_DIR, filename);

		let content = `OpenCode Dev - 会話ログ\n`;
		content += `日時: ${now.toLocaleString('ja-JP')}\n`;
		content += `${'='.repeat(60)}\n\n`;

		for (const entry of this._currentSessionLog) {
			if (entry.role === 'user') {
				content += `【ユーザー】\n${entry.text}\n\n`;
			} else if (entry.role === 'assistant') {
				content += `【アシスタント】\n`;
				for (const block of entry.blocks) {
					if (block.type === 'text') {
						content += `${block.text}\n`;
					} else if (block.type === 'tool') {
						content += `\n[ツール: ${block.toolName}] ${block.title}\n`;
						if (block.inputSummary) content += `  入力: ${block.inputSummary}\n`;
						if (block.output != null) {
							const out = typeof block.output === 'string'
								? block.output
								: JSON.stringify(block.output, null, 2);
							content += `  出力: ${out.substring(0, 500)}${out.length > 500 ? '...(省略)' : ''}\n`;
						}
					} else if (block.type === 'error') {
						content += `[エラー] ${block.text}\n`;
					} else if (block.type === 'meta') {
						content += `${block.text}\n`;
					}
				}
				content += `\n`;
			}
		}

		content += `${'='.repeat(60)}\n`;
		content += `(保存: ${new Date().toLocaleString('ja-JP')})\n`;

		try {
			fs.writeFileSync(filePath, content, 'utf8');
			vscode.window.showInformationMessage(`会話を保存しました: ${filePath}`);
		} catch (e) {
			vscode.window.showErrorMessage(`ログ保存に失敗: ${e.message}`);
		}
	}

	async _runOpencode(prompt) {
		const execPath = this._getExecPath();

		this._currentSessionLog.push({ role: 'user', text: prompt });
		const assistantEntry = { role: 'assistant', blocks: [] };
		this._currentSessionLog.push(assistantEntry);

		this._post({ command: 'start' });

		await new Promise((resolve) => {
			let settled = false;
			let lineBuf = '';
			let stderrBuf = '';

			const escaped = prompt.replace(/'/g, `'\\''`);
			const cmd = `${execPath} run --format json '${escaped}'`;

			const proc = spawn('script', ['-q', '-c', cmd, '/dev/null'], {
				env: { ...process.env },
			});
			this._currentProc = proc;

			proc.stdout.on('data', (data) => {
				const clean = data.toString()
					.replace(/\r\n/g, '\n')
					.replace(/\r/g, '\n')
					.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');

				lineBuf += clean;
				const lines = lineBuf.split('\n');
				lineBuf = lines.pop();

				for (const line of lines) {
					const trimmed = line.trim();
					if (!trimmed) continue;
					try {
						const event = JSON.parse(trimmed);
						this._handleEvent(event, assistantEntry);
					} catch { /* JSON以外は無視 */ }
				}
			});

			proc.stderr.on('data', (data) => {
				stderrBuf += data.toString();
			});

			const finish = (code) => {
				if (settled) return;
				settled = true;
				this._currentProc = null;
				if (code !== 0 && code !== null) {
					const detail = stderrBuf.trim() ? `\n${stderrBuf.trim().substring(0, 300)}` : '';
					const errText = `opencode が失敗しました (code=${code})${detail}`;
					assistantEntry.blocks.push({ type: 'error', text: errText });
					this._post({ command: 'error', text: errText });
				}
				this._post({ command: 'done' });
				resolve();
			};

			proc.on('close', finish);
			proc.on('exit', finish);
			proc.on('error', (err) => {
				if (settled) return;
				settled = true;
				this._currentProc = null;
				assistantEntry.blocks.push({ type: 'error', text: err.message });
				this._post({ command: 'error', text: err.message });
				this._post({ command: 'done' });
				resolve();
			});

			setTimeout(() => {
				if (settled) return;
				settled = true;
				proc.kill();
				this._currentProc = null;
				const errText = 'タイムアウト (3分)';
				assistantEntry.blocks.push({ type: 'error', text: errText });
				this._post({ command: 'error', text: errText });
				this._post({ command: 'done' });
				resolve();
			}, 180000);
		});
	}

	_handleEvent(event, assistantEntry) {
		const type = event.type;
		const part = event.part || {};

		switch (type) {
			case 'step_start':
				this._post({ command: 'step_start' });
				break;

			case 'text':
				if (part.text) {
					const blocks = assistantEntry.blocks;
					if (blocks.length > 0 && blocks[blocks.length-1].type === 'text') {
						blocks[blocks.length-1].text += part.text;
					} else {
						blocks.push({ type: 'text', text: part.text });
					}
					this._post({ command: 'text', text: part.text });
				}
				break;

			case 'tool_use': {
				const state = part.state || {};
				const input = state.input || {};
				const toolName = part.tool || 'tool';
				const title    = part.title || input.description || input.command || toolName;

				let inputSummary = null;
				if (input.command)      inputSummary = input.command;
				else if (input.path)    inputSummary = input.path;
				else if (input.pattern) inputSummary = input.pattern;
				else if (Object.keys(input).length) inputSummary = JSON.stringify(input, null, 2);

				const output  = state.output ?? state.result ?? null;
				const isError = (state.exit != null && state.exit !== 0) || !!state.error;

				assistantEntry.blocks.push({ type: 'tool', toolName, title, inputSummary, output, isError });

				this._post({
					command: 'tool_use',
					toolName, title, inputSummary, output, isError,
					status: state.status || 'completed',
				});
				break;
			}

			case 'step_finish': {
				const tokens = part.tokens || null;
				const cost   = part.cost ?? null;
				if (tokens || cost) {
					const parts = [];
					if (tokens) {
						parts.push(`入力:${tokens.input ?? '-'}tok`);
						parts.push(`出力:${tokens.output ?? '-'}tok`);
						if (cost != null && cost > 0) parts.push(`$${Number(cost).toFixed(4)}`);
					}
					assistantEntry.blocks.push({ type: 'meta', text: parts.join(' / ') });
				}
				this._post({ command: 'step_finish', reason: part.reason || 'stop', tokens, cost });
				break;
			}

			default:
				break;
		}
	}

	_getHtml() {
		return `<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
  * { box-sizing: border-box; margin: 0; padding: 0; }
  body {
    font-family: var(--vscode-font-family);
    font-size: var(--vscode-font-size);
    color: var(--vscode-foreground);
    background: var(--vscode-sideBar-background);
    display: flex; flex-direction: column; height: 100vh; overflow: hidden;
  }
  #toolbar {
    display: flex; align-items: center; gap: 4px;
    padding: 5px 8px;
    border-bottom: 1px solid var(--vscode-panel-border);
    background: var(--vscode-editorGroupHeader-tabsBackground, #252526);
    flex-shrink: 0;
  }
  #toolbar-title {
    font-size: 0.78em;
    color: var(--vscode-descriptionForeground);
    font-weight: 600;
    letter-spacing: 0.05em;
    text-transform: uppercase;
    flex: 1;
  }
  .tb-btn {
    background: none;
    border: 1px solid transparent;
    border-radius: 3px;
    color: var(--vscode-foreground);
    cursor: pointer;
    font-size: 0.8em;
    padding: 3px 8px;
    display: flex; align-items: center; gap: 4px;
    white-space: nowrap;
  }
  .tb-btn:hover {
    background: var(--vscode-toolbar-hoverBackground, rgba(255,255,255,0.07));
    border-color: var(--vscode-panel-border);
  }
  #output {
    flex: 1; overflow-y: auto; padding: 8px;
    display: flex; flex-direction: column; gap: 6px;
  }
  .session-divider {
    display: flex; align-items: center; gap: 8px;
    color: var(--vscode-descriptionForeground);
    font-size: 0.75em; margin: 4px 0;
  }
  .session-divider::before, .session-divider::after {
    content: ''; flex: 1; height: 1px;
    background: var(--vscode-panel-border);
  }
  .turn-user {
    background: var(--vscode-inputOption-activeBackground);
    color: var(--vscode-inputOption-activeForeground, var(--vscode-foreground));
    border-radius: 4px; padding: 8px 10px;
    align-self: flex-end; max-width: 85%;
    white-space: pre-wrap; word-break: break-word; line-height: 1.5;
  }
  .turn-assistant { display: flex; flex-direction: column; gap: 4px; }
  .thinking {
    color: var(--vscode-descriptionForeground);
    font-style: italic; font-size: 0.9em; padding: 2px 0;
    display: flex; align-items: center; gap: 6px;
  }
  .thinking .spinner {
    display: inline-block; width: 12px; height: 12px;
    border: 2px solid var(--vscode-descriptionForeground);
    border-top-color: transparent; border-radius: 50%;
    animation: spin 0.8s linear infinite;
  }
  @keyframes spin { to { transform: rotate(360deg); } }
  .block-text {
    background: var(--vscode-editor-background);
    border: 1px solid var(--vscode-panel-border);
    border-radius: 4px; padding: 8px 10px;
    white-space: pre-wrap; word-break: break-word; line-height: 1.6;
    font-family: var(--vscode-editor-font-family);
    font-size: var(--vscode-editor-font-size);
  }
  .block-tool {
    border: 1px solid var(--vscode-panel-border);
    border-radius: 4px; overflow: hidden; font-size: 0.88em;
  }
  .tool-header {
    background: var(--vscode-editorGroupHeader-tabsBackground, #252526);
    padding: 5px 8px;
    display: flex; align-items: center; gap: 6px;
    cursor: pointer; user-select: none;
  }
  .tool-icon { font-size: 1em; flex-shrink: 0; }
  .tool-title { flex: 1; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .tool-badge { font-size: 0.8em; padding: 1px 6px; border-radius: 10px; flex-shrink: 0; }
  .tool-badge.running { background: var(--vscode-statusBarItem-warningBackground, #664d00); color: #ffcc00; }
  .tool-badge.ok  { background: var(--vscode-terminal-ansiGreen, #16825d); color: #fff; }
  .tool-badge.err { background: var(--vscode-inputValidation-errorBackground, #5a1d1d); color: var(--vscode-errorForeground, #f48771); }
  .chevron { font-size: 0.75em; transition: transform 0.15s; flex-shrink: 0; }
  .collapsed .chevron { transform: rotate(-90deg); }
  .tool-body {
    background: var(--vscode-editor-background);
    padding: 6px 8px;
    font-family: var(--vscode-editor-font-family, monospace);
    white-space: pre-wrap; word-break: break-all;
    max-height: 280px; overflow-y: auto;
    border-top: 1px solid var(--vscode-panel-border);
  }
  .tool-body.hidden { display: none; }
  .tool-section-label {
    color: var(--vscode-descriptionForeground);
    font-size: 0.82em; margin-bottom: 2px; margin-top: 6px;
    font-family: var(--vscode-font-family);
  }
  .tool-section-label:first-child { margin-top: 0; }
  .tool-input-text  { color: var(--vscode-editor-foreground); }
  .tool-output-ok   { color: var(--vscode-terminal-ansiGreen, #4ec9b0); }
  .tool-output-err  { color: var(--vscode-errorForeground, #f48771); }
  .block-meta {
    color: var(--vscode-descriptionForeground);
    font-size: 0.78em; padding: 1px 4px;
    display: flex; gap: 8px; flex-wrap: wrap;
  }
  .block-error {
    background: var(--vscode-inputValidation-errorBackground);
    border: 1px solid var(--vscode-inputValidation-errorBorder);
    border-radius: 4px; padding: 6px 10px;
    white-space: pre-wrap; word-break: break-word;
  }
  #input-area {
    padding: 8px; border-top: 1px solid var(--vscode-panel-border);
    display: flex; flex-direction: column; gap: 4px;
    background: var(--vscode-sideBar-background); flex-shrink: 0;
  }
  textarea {
    width: 100%; min-height: 60px; max-height: 150px;
    background: var(--vscode-input-background);
    color: var(--vscode-input-foreground);
    border: 1px solid var(--vscode-input-border, transparent);
    border-radius: 4px; padding: 6px 8px;
    font-family: var(--vscode-font-family); font-size: var(--vscode-font-size);
    resize: none; outline: none; overflow-y: auto;
  }
  textarea:focus { border-color: var(--vscode-focusBorder); }
  #btn-row { display: flex; gap: 6px; justify-content: flex-end; }
  button { padding: 4px 14px; border: none; border-radius: 3px; cursor: pointer; font-size: var(--vscode-font-size); }
  #send-btn { background: var(--vscode-button-background); color: var(--vscode-button-foreground); }
  #send-btn:hover { background: var(--vscode-button-hoverBackground); }
  #send-btn:disabled { opacity: 0.5; cursor: default; }
  #cancel-btn { background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground); display: none; }
  #cancel-btn:hover { background: var(--vscode-button-secondaryHoverBackground); }
  #hint { font-size: 0.8em; color: var(--vscode-descriptionForeground); }
</style>
</head>
<body>
<div id="toolbar">
  <span id="toolbar-title">OpenCode Dev</span>
  <button class="tb-btn" id="new-session-btn" title="新規会話(現会話をログ保存)">+ 新規会話</button>
</div>
<div id="output"></div>
<div id="input-area">
  <textarea id="prompt" placeholder="タスクを入力... (Ctrl+Enter で実行)" rows="3"></textarea>
  <div id="btn-row">
    <button id="cancel-btn">⛔ 中断</button>
    <button id="send-btn">▶ 実行</button>
  </div>
  <div id="hint">Ctrl+Enter で実行</div>
</div>
<script>
const vscode = acquireVsCodeApi();
const outputEl      = document.getElementById('output');
const promptEl      = document.getElementById('prompt');
const sendBtn       = document.getElementById('send-btn');
const cancelBtn     = document.getElementById('cancel-btn');
const newSessionBtn = document.getElementById('new-session-btn');

let assistantBlock = null;
let textEl         = null;
let thinkingEl     = null;

function scroll() { outputEl.scrollTop = outputEl.scrollHeight; }
function setRunning(v) {
  sendBtn.disabled = v;
  cancelBtn.style.display = v ? 'block' : 'none';
  newSessionBtn.disabled = v;
}
function addUserMsg(text) {
  const el = document.createElement('div');
  el.className = 'turn-user';
  el.textContent = text;
  outputEl.appendChild(el);
  scroll();
}
function startAssistantBlock() {
  assistantBlock = document.createElement('div');
  assistantBlock.className = 'turn-assistant';
  outputEl.appendChild(assistantBlock);
  thinkingEl = document.createElement('div');
  thinkingEl.className = 'thinking';
  thinkingEl.innerHTML = '<span class="spinner"></span>opencode が処理中...';
  assistantBlock.appendChild(thinkingEl);
  scroll();
}
function removeThinking() {
  if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; }
}
function appendText(text) {
  removeThinking();
  if (!textEl) {
    textEl = document.createElement('div');
    textEl.className = 'block-text';
    assistantBlock.appendChild(textEl);
  }
  textEl.textContent += text;
  scroll();
}
function toolIcon(name) {
  const n = (name || '').toLowerCase();
  if (n === 'bash' || n.includes('exec') || n.includes('run')) return '⚙️';
  if (n.includes('write') || n.includes('edit') || n.includes('patch')) return '✏️';
  if (n.includes('read') || n.includes('file')) return '📄';
  if (n.includes('search') || n.includes('grep') || n.includes('find')) return '🔍';
  if (n.includes('list') || n.includes('ls')) return '📁';
  if (n.includes('web') || n.includes('http')) return '🌐';
  return '🔧';
}
function escHtml(s) {
  return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function addToolBlock(msg) {
  removeThinking();
  textEl = null;
  const wrap = document.createElement('div');
  wrap.className = 'block-tool';
  const header = document.createElement('div');
  header.className = 'tool-header';
  const badge = msg.status === 'running'
    ? '<span class="tool-badge running">実行中</span>'
    : msg.isError
      ? '<span class="tool-badge err">❌ エラー</span>'
      : '<span class="tool-badge ok">✅ 完了</span>';
  header.innerHTML =
    \`<span class="tool-icon">\${toolIcon(msg.toolName)}</span>\` +
    \`<span class="tool-title">\${escHtml(msg.title || msg.toolName)}</span>\` +
    badge + \`<span class="chevron">▾</span>\`;
  const body = document.createElement('div');
  body.className = 'tool-body';
  if (msg.inputSummary) {
    const lbl = document.createElement('div');
    lbl.className = 'tool-section-label';
    lbl.textContent = '入力';
    const cnt = document.createElement('div');
    cnt.className = 'tool-input-text';
    cnt.textContent = msg.inputSummary;
    body.appendChild(lbl); body.appendChild(cnt);
  }
  if (msg.output != null) {
    const lbl = document.createElement('div');
    lbl.className = 'tool-section-label';
    lbl.textContent = '出力';
    const cnt = document.createElement('div');
    cnt.className = msg.isError ? 'tool-output-err' : 'tool-output-ok';
    cnt.textContent = typeof msg.output === 'string' ? msg.output : JSON.stringify(msg.output, null, 2);
    body.appendChild(lbl); body.appendChild(cnt);
  }
  if (msg.status !== 'running') {
    body.classList.add('hidden');
    header.classList.add('collapsed');
  }
  header.addEventListener('click', () => {
    const c = body.classList.toggle('hidden');
    header.classList.toggle('collapsed', c);
    scroll();
  });
  wrap.appendChild(header); wrap.appendChild(body);
  assistantBlock.appendChild(wrap);
  scroll();
}
function addMeta(tokens, cost) {
  const parts = [];
  if (tokens) {
    parts.push(\`📥 \${tokens.input ?? '-'}\`);
    parts.push(\`📤 \${tokens.output ?? '-'}\`);
    parts.push(\`🔢 \${tokens.total ?? '-'}\`);
    if (tokens.cache?.read) parts.push(\`💾 \${tokens.cache.read}\`);
  }
  if (cost != null && cost > 0) parts.push(\`💰 $\${Number(cost).toFixed(4)}\`);
  if (!parts.length) return;
  const el = document.createElement('div');
  el.className = 'block-meta';
  el.textContent = parts.join('  ');
  assistantBlock.appendChild(el);
  scroll();
}
function send() {
  const text = promptEl.value.trim();
  if (!text || sendBtn.disabled) return;
  addUserMsg(text);
  promptEl.value = '';
  setRunning(true);
  assistantBlock = null; textEl = null; thinkingEl = null;
  vscode.postMessage({ command: 'send', text });
}
sendBtn.addEventListener('click', send);
cancelBtn.addEventListener('click', () => vscode.postMessage({ command: 'cancel' }));
newSessionBtn.addEventListener('click', () => vscode.postMessage({ command: 'newSession' }));
promptEl.addEventListener('keydown', e => {
  if (e.key === 'Enter' && e.ctrlKey) { e.preventDefault(); send(); }
});
window.addEventListener('message', e => {
  const msg = e.data;
  switch (msg.command) {
    case 'sessionCleared': {
      const div = document.createElement('div');
      div.className = 'session-divider';
      div.textContent = '新しい会話';
      outputEl.appendChild(div);
      assistantBlock = null; textEl = null; thinkingEl = null;
      scroll(); promptEl.focus();
      break;
    }
    case 'start':
      startAssistantBlock();
      break;
    case 'step_start':
      if (assistantBlock && !thinkingEl) {
        thinkingEl = document.createElement('div');
        thinkingEl.className = 'thinking';
        thinkingEl.innerHTML = '<span class="spinner"></span>処理中...';
        assistantBlock.appendChild(thinkingEl);
        scroll();
      }
      break;
    case 'text':
      appendText(msg.text);
      break;
    case 'tool_use':
      addToolBlock(msg);
      break;
    case 'step_finish':
      removeThinking();
      addMeta(msg.tokens, msg.cost);
      break;
    case 'error':
      removeThinking();
      if (!assistantBlock) startAssistantBlock();
      { const el = document.createElement('div');
        el.className = 'block-error';
        el.textContent = 'エラー: ' + msg.text;
        assistantBlock.appendChild(el); scroll(); }
      break;
    case 'cancelled':
      removeThinking();
      if (assistantBlock) {
        const el = document.createElement('div');
        el.className = 'block-meta';
        el.textContent = '⛔ 中断されました';
        assistantBlock.appendChild(el); scroll();
      }
      setRunning(false); textEl = null; promptEl.focus();
      break;
    case 'done':
      removeThinking();
      setRunning(false); textEl = null; promptEl.focus();
      break;
  }
});
promptEl.focus();
</script>
</body>
</html>`;
	}
}

exports.activate = activate;
EOF

# ── extension.vsixmanifest ──────────────────────────────────
cat > "$WORKDIR/extension.vsixmanifest" << EOF
<?xml version="1.0" encoding="utf-8"?>
<PackageManifest Version="2.0.0" xmlns="http://schemas.microsoft.com/developer/vsx-syndication-schema/2011">
  <Metadata>
    <Identity Id="opencode-dev" Version="${VERSION}" Publisher="opencode" />
    <DisplayName>OpenCode Dev</DisplayName>
    <Description>Programming assistant with live progress from OpenCode</Description>
    <Tags>AI,Dev</Tags>
  </Metadata>
  <Installation />
  <Dependencies />
  <Assets>
    <Asset Type="Microsoft.VSCode.Extension" Path="." />
  </Assets>
</PackageManifest>
EOF

# ── パッケージング ──────────────────────────────────────────
echo "==> パッケージング中..."
(cd "$WORKDIR" && zip -q "$OUT" extension.vsixmanifest extension/package.json extension/extension.js)

echo ""
echo "✅ 完成: $OUT"
echo ""
echo "インストール方法:"
echo "  code-server --install-extension $OUT"
echo "  または code-server の拡張機能メニューから VSIX をインストール"

作成した拡張機能をインストール

スクリプトを実行すると/opt/lxd-data/opencodeに拡張機能のVSIXが作成されるので、右クリックしてインストールします。

作業を指示

ウィンドウはチャット位置に表示したほうが使いやすいでしょう。ここで指示して作業します。
試しにオセロゲームを作ってもらいました。

タイトルとURLをコピーしました