code-severのサイドバーにopencodeを呼び出す拡張機能

code-severからopencodeを使用するには、ターミナルで「opencode」と入力して呼び出す必要があり少し使い勝手が良くありません。VS Codeなら拡張機能が使えるようですが、code-severからだとうまく機能しないようです。そこで、code-severのサイドバーからopencodeを利用する拡張機能を作成してみました。

opencode連携拡張機能を作成するスクリプト

LXDコンテナ内で下記を実行し、その下のスクリプトを貼り付けます。

apt install 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="1.0.1"
OUT="opencode-dev-${VERSION}.vsix"
WORKDIR="$(mktemp -d)"
trap "rm -rf $WORKDIR" EXIT

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": "1.0.1",
	"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');

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;
	}

	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' });
				}
			}
		});
	}

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

	async _runOpencode(prompt) {
		const execPath = this._getExecPath();
		this._post({ command: 'start' });

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

			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);
					} catch { /* JSON以外は無視 */ }
				}
			});

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

			const finish = (code) => {
				if (settled) return;
				settled = true;
				this._currentProc = null;
				if (code !== 0 && code !== null) {
					this._post({ command: 'error', text: `opencode が失敗しました (code=${code})` });
				}
				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;
				this._post({ command: 'error', text: err.message });
				this._post({ command: 'done' });
				resolve();
			});

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

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

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

			case 'text':
				if (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;

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

			case 'step_finish':
				this._post({
					command: 'step_finish',
					reason: part.reason || 'stop',
					tokens: part.tokens || null,
					cost:   part.cost   ?? null,
				});
				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;
  }
  #output {
    flex: 1; overflow-y: auto; padding: 8px;
    display: flex; flex-direction: column; gap: 6px;
  }
  .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);
  }
  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="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');

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';
}
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' }));
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 '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 "$OLDPWD/$OUT" extension.vsixmanifest extension/package.json extension/extension.js)

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

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

作成されたVSIXは拡張機能メニューからインストールしても良いですし、ファイルを右クリックしてインストールしても構いません。

すると一番左のメニューにサイドバーを呼び出す項目が表示されるので、それを押せばサイドバーが表示されます。そこで依頼すれば、そのまま実行してくれるはずです。表示された部分をクリックすれば、実行した内容が表示されます。

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