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,'&').replace(/</g,'<').replace(/>/g,'>');
}
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は拡張機能メニューからインストールしても良いですし、ファイルを右クリックしてインストールしても構いません。

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


