From 359cfd45b138bf01a02a74d970c22323659c9f99 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 24 Dec 2025 22:50:46 +0200 Subject: [PATCH] feat(vscode): refactor OpenCode configuration management and API proxying --- packages/vscode/README.md | 3 +- packages/vscode/package.json | 4 + packages/vscode/src/ChatViewProvider.ts | 188 +++++++++- packages/vscode/src/bridge.ts | 228 ++++++++++++ packages/vscode/src/extension.ts | 175 ++++++++- packages/vscode/src/opencode.ts | 225 ++++++++++-- packages/vscode/src/opencodeConfig.ts | 338 +++++++++++++++++ packages/vscode/webview/api/bridge.ts | 58 ++- packages/vscode/webview/main.tsx | 384 +++++++++++++++----- packages/vscode/webview/stores/chatStore.ts | 2 +- 10 files changed, 1457 insertions(+), 148 deletions(-) create mode 100644 packages/vscode/src/opencodeConfig.ts diff --git a/packages/vscode/README.md b/packages/vscode/README.md index 438d188d..6a5e200a 100644 --- a/packages/vscode/README.md +++ b/packages/vscode/README.md @@ -34,12 +34,13 @@ OpenChamber inside VS Code: embeds the OpenChamber chat UI in the activity bar a |---------|-------------| | `OpenChamber: Focus on Chat View` | Focus chat panel | | `OpenChamber: Restart API Connection` | Restart OpenCode API process | +| `OpenChamber: Show OpenCode Status` | Provide debug info useful for development or bug report | ## Configuration | Setting | Default | Description | |---------|---------|-------------| -| `openchamber.apiUrl` | `http://localhost:47339` | OpenCode API server URL | +| `openchamber.apiUrl` | `http://localhost:47339` | OpenCode API server URL. Not required by default. Spawns its own process when not set. | ## Requirements diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 2b21e823..a271982d 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -56,6 +56,10 @@ { "command": "openchamber.restartApi", "title": "OpenChamber: Restart API Connection" + }, + { + "command": "openchamber.showOpenCodeStatus", + "title": "OpenChamber: Show OpenCode Status" } ], "configuration": { diff --git a/packages/vscode/src/ChatViewProvider.ts b/packages/vscode/src/ChatViewProvider.ts index cb7f2172..965d73b4 100644 --- a/packages/vscode/src/ChatViewProvider.ts +++ b/packages/vscode/src/ChatViewProvider.ts @@ -1,5 +1,5 @@ import * as vscode from 'vscode'; -import { handleBridgeMessage, type BridgeRequest } from './bridge'; +import { handleBridgeMessage, type BridgeRequest, type BridgeResponse } from './bridge'; import { getThemeKindName } from './theme'; import type { OpenCodeManager, ConnectionStatus } from './opencode'; import { getWebviewShikiThemes } from './shikiThemes'; @@ -12,7 +12,8 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { // Cache latest status/URL for when webview is resolved after connection is ready private _cachedStatus: ConnectionStatus = 'connecting'; private _cachedError?: string; - private _cachedApiUrl?: string; + private _sseCounter = 0; + private _sseStreams = new Map(); constructor( private readonly _context: vscode.ExtensionContext, @@ -44,6 +45,19 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { await this._openCodeManager?.restart(); return; } + + if (message.type === 'api:sse:start') { + const response = await this._startSseProxy(message); + webviewView.webview.postMessage(response); + return; + } + + if (message.type === 'api:sse:stop') { + const response = await this._stopSseProxy(message); + webviewView.webview.postMessage(response); + return; + } + const response = await handleBridgeMessage(message, { manager: this._openCodeManager, context: this._context, @@ -68,7 +82,6 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { // Cache the latest state this._cachedStatus = status; this._cachedError = error; - this._cachedApiUrl = this._openCodeManager?.getApiUrl() || undefined; // Send to webview if it exists this._sendCachedState(); @@ -84,14 +97,159 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { status: this._cachedStatus, error: this._cachedError, }); - - // Send API URL update if we have one - if (this._cachedApiUrl) { - this._view.webview.postMessage({ - type: 'apiUrlUpdate', - url: this._cachedApiUrl, - }); + } + + private _buildSseHeaders(extra?: Record): Record { + return { + Accept: 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + ...(extra || {}), + }; + } + + private _collectHeaders(headers: Headers): Record { + const result: Record = {}; + headers.forEach((value, key) => { + result[key] = value; + }); + return result; + } + + private async _startSseProxy(message: BridgeRequest): Promise { + const { id, type, payload } = message; + const apiBaseUrl = this._openCodeManager?.getApiUrl(); + + const { path, headers } = (payload || {}) as { path?: string; headers?: Record }; + const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : '/event'; + + if (!apiBaseUrl) { + return { + id, + type, + success: true, + data: { status: 503, headers: { 'content-type': 'application/json' }, streamId: null }, + }; } + + const streamId = `sse_${++this._sseCounter}_${Date.now()}`; + const controller = new AbortController(); + + const base = `${apiBaseUrl.replace(/\/+$/, '')}/`; + const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString(); + + let response: Response; + try { + response = await fetch(targetUrl, { + method: 'GET', + headers: this._buildSseHeaders(headers || {}), + signal: controller.signal, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + id, + type, + success: true, + data: { status: 502, headers: { 'content-type': 'application/json' }, streamId: null, error: message }, + }; + } + + const responseHeaders = this._collectHeaders(response.headers); + const responseBody = response.body; + if (!response.ok || !responseBody) { + return { + id, + type, + success: true, + data: { + status: response.status, + headers: responseHeaders, + streamId: null, + error: `SSE failed: ${response.status}`, + }, + }; + } + + this._sseStreams.set(streamId, controller); + + (async () => { + try { + const reader = responseBody.getReader(); + const decoder = new TextDecoder(); + let sseBuffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (controller.signal.aborted) break; + if (value && value.length > 0) { + const chunk = decoder.decode(value, { stream: true }); + if (!chunk) continue; + + // Reduce webview message pressure by forwarding complete SSE blocks. + // The SDK SSE parser is block-based (\n\n delimited) and can consume + // partial chunks, but VS Code's postMessage channel can be a bottleneck. + sseBuffer += chunk; + const blocks = sseBuffer.split('\n\n'); + sseBuffer = blocks.pop() ?? ''; + if (blocks.length > 0) { + const joined = blocks.map((block) => `${block}\n\n`).join(''); + this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined }); + } + } + } + + const tail = decoder.decode(); + if (tail) { + sseBuffer += tail; + } + if (sseBuffer) { + this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: sseBuffer }); + } + } finally { + try { + reader.releaseLock(); + } catch { + // ignore + } + } + + this._view?.webview.postMessage({ type: 'api:sse:end', streamId }); + } catch (error) { + if (!controller.signal.aborted) { + const message = error instanceof Error ? error.message : String(error); + this._view?.webview.postMessage({ type: 'api:sse:end', streamId, error: message }); + } + } finally { + this._sseStreams.delete(streamId); + } + })(); + + return { + id, + type, + success: true, + data: { + status: response.status, + headers: responseHeaders, + streamId, + }, + }; + } + + private async _stopSseProxy(message: BridgeRequest): Promise { + const { id, type, payload } = message; + const { streamId } = (payload || {}) as { streamId?: string }; + if (typeof streamId === 'string' && streamId.length > 0) { + const controller = this._sseStreams.get(streamId); + if (controller) { + controller.abort(); + this._sseStreams.delete(streamId); + } + } + return { id, type, success: true, data: { stopped: true } }; } private _getHtmlForWebview(webview: vscode.Webview) { @@ -102,7 +260,6 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { const themeKind = getThemeKindName(vscode.window.activeColorTheme.kind); // Use cached values which are updated by onStatusChange callback const initialStatus = this._cachedStatus; - const apiUrl = this._cachedApiUrl || ''; const cliAvailable = this._openCodeManager?.isCliAvailable() ?? false; // Use VS Code CSS variables for proper theme integration @@ -211,7 +368,6 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { window.process = window.process || { env: { NODE_ENV: 'production' }, platform: '', version: '', browser: true }; window.__VSCODE_CONFIG__ = { - apiUrl: "${apiUrl}", workspaceFolder: "${workspaceFolder.replace(/\\/g, '\\\\')}", theme: "${themeKind}", connectionStatus: "${initialStatus}", @@ -224,21 +380,19 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { var msg = event.data; if (msg && msg.type === 'connectionStatus') { var statusEl = document.getElementById('loading-status'); - var loadingEl = document.getElementById('initial-loading'); if (statusEl) { if (msg.status === 'connecting') { statusEl.textContent = 'Starting OpenCode API…'; + statusEl.classList.remove('error-text'); } else if (msg.status === 'connected') { statusEl.textContent = 'Connected!'; - // Fade out loading screen once connected and UI is ready - setTimeout(function() { - if (loadingEl) loadingEl.classList.add('fade-out'); - }, 300); + statusEl.classList.remove('error-text'); } else if (msg.status === 'error') { statusEl.textContent = msg.error || 'Connection error'; statusEl.classList.add('error-text'); } else { statusEl.textContent = 'Reconnecting…'; + statusEl.classList.remove('error-text'); } } } diff --git a/packages/vscode/src/bridge.ts b/packages/vscode/src/bridge.ts index b1fd7a66..efb01a3b 100644 --- a/packages/vscode/src/bridge.ts +++ b/packages/vscode/src/bridge.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import * as os from 'os'; import * as path from 'path'; import type { OpenCodeManager } from './opencode'; +import { createAgent, createCommand, deleteAgent, deleteCommand, getAgentSources, getCommandSources, updateAgent, updateCommand } from './opencodeConfig'; export interface BridgeRequest { id: string; @@ -17,6 +18,19 @@ export interface BridgeResponse { error?: string; } +type ApiProxyRequestPayload = { + method?: string; + path?: string; + headers?: Record; + bodyBase64?: string; +}; + +type ApiProxyResponsePayload = { + status: number; + headers: Record; + bodyBase64: string; +}; + interface FileEntry { name: string; path: string; @@ -34,6 +48,7 @@ export interface BridgeContext { } const SETTINGS_KEY = 'openchamber.settings'; +const CLIENT_RELOAD_DELAY_MS = 800; const readSettings = (ctx?: BridgeContext) => { const stored = ctx?.context?.globalState.get>(SETTINGS_KEY) || {}; @@ -230,11 +245,86 @@ const fetchModelsMetadata = async () => { } }; +const base64EncodeUtf8 = (text: string) => Buffer.from(text, 'utf8').toString('base64'); + +const collectHeaders = (headers: Headers): Record => { + const result: Record = {}; + headers.forEach((value, key) => { + result[key] = value; + }); + return result; +}; + export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeContext): Promise { const { id, type, payload } = message; try { switch (type) { + case 'api:proxy': { + const apiUrl = ctx?.manager?.getApiUrl(); + if (!apiUrl) { + const body = JSON.stringify({ error: 'OpenCode API unavailable' }); + const data: ApiProxyResponsePayload = { + status: 503, + headers: { 'content-type': 'application/json' }, + bodyBase64: base64EncodeUtf8(body), + }; + return { id, type, success: true, data }; + } + + const { method, path: requestPath, headers, bodyBase64 } = (payload || {}) as ApiProxyRequestPayload; + const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; + const normalizedPath = + typeof requestPath === 'string' && requestPath.trim().length > 0 + ? requestPath.trim().startsWith('/') + ? requestPath.trim() + : `/${requestPath.trim()}` + : '/'; + + const base = `${apiUrl.replace(/\/+$/, '')}/`; + const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString(); + const requestHeaders: Record = { ...(headers || {}) }; + + // Ensure SSE requests are negotiated correctly. + if (normalizedPath === '/event' || normalizedPath === '/global/event') { + if (!requestHeaders.Accept) { + requestHeaders.Accept = 'text/event-stream'; + } + requestHeaders['Cache-Control'] = requestHeaders['Cache-Control'] || 'no-cache'; + requestHeaders.Connection = requestHeaders.Connection || 'keep-alive'; + } + + try { + const response = await fetch(targetUrl, { + method: normalizedMethod, + headers: requestHeaders, + body: + typeof bodyBase64 === 'string' && bodyBase64.length > 0 && normalizedMethod !== 'GET' && normalizedMethod !== 'HEAD' + ? Buffer.from(bodyBase64, 'base64') + : undefined, + }); + + const arrayBuffer = await response.arrayBuffer(); + const data: ApiProxyResponsePayload = { + status: response.status, + headers: collectHeaders(response.headers), + bodyBase64: Buffer.from(arrayBuffer).toString('base64'), + }; + + return { id, type, success: true, data }; + } catch (error) { + const body = JSON.stringify({ + error: error instanceof Error ? error.message : 'Failed to reach OpenCode API', + }); + const data: ApiProxyResponsePayload = { + status: 502, + headers: { 'content-type': 'application/json' }, + bodyBase64: base64EncodeUtf8(body), + }; + return { id, type, success: true, data }; + } + } + case 'files:list': { const { path: dirPath } = payload as { path: string }; const uri = vscode.Uri.file(dirPath); @@ -382,6 +472,144 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo return { id, type, success: true, data: { restarted: true } }; } + case 'api:config/agents': { + const { method, name, body } = (payload || {}) as { method?: string; name?: string; body?: Record }; + const agentName = typeof name === 'string' ? name.trim() : ''; + if (!agentName) { + return { id, type, success: false, error: 'Agent name is required' }; + } + + const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; + if (normalizedMethod === 'GET') { + const sources = getAgentSources(agentName); + return { + id, + type, + success: true, + data: { name: agentName, sources, isBuiltIn: !sources.md.exists && !sources.json.exists }, + }; + } + + if (normalizedMethod === 'POST') { + createAgent(agentName, (body || {}) as Record); + await ctx?.manager?.restart(); + return { + id, + type, + success: true, + data: { + success: true, + requiresReload: true, + message: `Agent ${agentName} created successfully. Reloading interface…`, + reloadDelayMs: CLIENT_RELOAD_DELAY_MS, + }, + }; + } + + if (normalizedMethod === 'PATCH') { + updateAgent(agentName, (body || {}) as Record); + await ctx?.manager?.restart(); + return { + id, + type, + success: true, + data: { + success: true, + requiresReload: true, + message: `Agent ${agentName} updated successfully. Reloading interface…`, + reloadDelayMs: CLIENT_RELOAD_DELAY_MS, + }, + }; + } + + if (normalizedMethod === 'DELETE') { + deleteAgent(agentName); + await ctx?.manager?.restart(); + return { + id, + type, + success: true, + data: { + success: true, + requiresReload: true, + message: `Agent ${agentName} deleted successfully. Reloading interface…`, + reloadDelayMs: CLIENT_RELOAD_DELAY_MS, + }, + }; + } + + return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; + } + + case 'api:config/commands': { + const { method, name, body } = (payload || {}) as { method?: string; name?: string; body?: Record }; + const commandName = typeof name === 'string' ? name.trim() : ''; + if (!commandName) { + return { id, type, success: false, error: 'Command name is required' }; + } + + const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; + if (normalizedMethod === 'GET') { + const sources = getCommandSources(commandName); + return { + id, + type, + success: true, + data: { name: commandName, sources, isBuiltIn: !sources.md.exists && !sources.json.exists }, + }; + } + + if (normalizedMethod === 'POST') { + createCommand(commandName, (body || {}) as Record); + await ctx?.manager?.restart(); + return { + id, + type, + success: true, + data: { + success: true, + requiresReload: true, + message: `Command ${commandName} created successfully. Reloading interface…`, + reloadDelayMs: CLIENT_RELOAD_DELAY_MS, + }, + }; + } + + if (normalizedMethod === 'PATCH') { + updateCommand(commandName, (body || {}) as Record); + await ctx?.manager?.restart(); + return { + id, + type, + success: true, + data: { + success: true, + requiresReload: true, + message: `Command ${commandName} updated successfully. Reloading interface…`, + reloadDelayMs: CLIENT_RELOAD_DELAY_MS, + }, + }; + } + + if (normalizedMethod === 'DELETE') { + deleteCommand(commandName); + await ctx?.manager?.restart(); + return { + id, + type, + success: true, + data: { + success: true, + requiresReload: true, + message: `Command ${commandName} deleted successfully. Reloading interface…`, + reloadDelayMs: CLIENT_RELOAD_DELAY_MS, + }, + }; + } + + return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; + } + case 'api:opencode/directory': { const target = (payload as { path?: string })?.path; if (!target) { diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 16bad6f6..21c0004e 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -4,8 +4,28 @@ import { createOpenCodeManager, type OpenCodeManager } from './opencode'; let chatViewProvider: ChatViewProvider | undefined; let openCodeManager: OpenCodeManager | undefined; +let outputChannel: vscode.OutputChannel | undefined; + +const SETTINGS_KEY = 'openchamber.settings'; + +const formatIso = (value: number | null | undefined) => { + if (typeof value !== 'number' || !Number.isFinite(value)) return '(none)'; + try { + return new Date(value).toISOString(); + } catch { + return String(value); + } +}; + +const formatDurationMs = (value: number | null | undefined) => { + if (typeof value !== 'number' || !Number.isFinite(value)) return '(none)'; + const seconds = Math.round(value / 100) / 10; + return `${seconds}s`; +}; export async function activate(context: vscode.ExtensionContext) { + outputChannel = vscode.window.createOutputChannel('OpenChamber'); + // Create OpenCode manager first openCodeManager = createOpenCodeManager(context); @@ -32,6 +52,153 @@ export async function activate(context: vscode.ExtensionContext) { }) ); + context.subscriptions.push( + vscode.commands.registerCommand('openchamber.showOpenCodeStatus', async () => { + const config = vscode.workspace.getConfiguration('openchamber'); + const configuredApiUrl = (config.get('apiUrl') || '').trim(); + + const extensionVersion = String(context.extension?.packageJSON?.version || ''); + const workspaceFolders = (vscode.workspace.workspaceFolders || []).map((folder) => folder.uri.fsPath); + + const debug = openCodeManager?.getDebugInfo(); + const resolvedApiUrl = openCodeManager?.getApiUrl(); + const workingDirectory = openCodeManager?.getWorkingDirectory() ?? ''; + + const safeFetch = async (input: string, timeoutMs = 2500) => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + const startedAt = Date.now(); + try { + const resp = await fetch(input, { + method: 'GET', + headers: { Accept: 'application/json' }, + signal: controller.signal, + }); + const elapsedMs = Date.now() - startedAt; + const contentType = resp.headers.get('content-type') || ''; + + let summary = ''; + if (contentType.includes('application/json')) { + const json = await resp.json().catch(() => null); + if (Array.isArray(json)) { + summary = `json[array] len=${json.length}`; + } else if (json && typeof json === 'object') { + const keys = Object.keys(json).slice(0, 8); + summary = `json[object] keys=${keys.join(',')}${Object.keys(json).length > keys.length ? ',…' : ''}`; + } else { + summary = `json[${typeof json}]`; + } + } else { + summary = contentType ? `content-type=${contentType}` : 'no content-type'; + } + + return { ok: resp.ok, status: resp.status, elapsedMs, summary }; + } catch (error) { + const elapsedMs = Date.now() - startedAt; + const isAbort = + controller.signal.aborted || + (error instanceof Error && (error.name === 'AbortError' || error.message.toLowerCase().includes('aborted'))); + const message = isAbort + ? `timeout after ${timeoutMs}ms` + : error instanceof Error + ? error.message + : String(error); + return { ok: false, status: 0, elapsedMs, summary: `error=${message}` }; + } finally { + clearTimeout(timeout); + } + }; + + const buildProbeUrl = (pathname: string, includeDirectory = true) => { + if (!resolvedApiUrl) return null; + const base = `${resolvedApiUrl.replace(/\/+$/, '')}/`; + const url = new URL(pathname.replace(/^\/+/, ''), base); + if (includeDirectory && workingDirectory) { + url.searchParams.set('directory', workingDirectory); + } + return url.toString(); + }; + + const probeTargets: Array<{ label: string; path: string; includeDirectory?: boolean; timeoutMs?: number }> = [ + { label: 'config', path: '/config', includeDirectory: true }, + { label: 'providers', path: '/config/providers', includeDirectory: true }, + // Can be slower on large configs; keep the probe from producing false negatives. + { label: 'agents', path: '/agent', includeDirectory: true, timeoutMs: 8000 }, + { label: 'commands', path: '/command', includeDirectory: true }, + { label: 'project', path: '/project/current', includeDirectory: true }, + { label: 'path', path: '/path', includeDirectory: true }, + { label: 'sessionStatus', path: '/session/status', includeDirectory: true }, + ]; + + const probes = resolvedApiUrl + ? await Promise.all( + probeTargets.map(async (entry) => { + const url = buildProbeUrl(entry.path, entry.includeDirectory !== false); + if (!url) { + return { label: entry.label, url: '(none)', result: null as null }; + } + const result = await safeFetch(url, typeof entry.timeoutMs === 'number' ? entry.timeoutMs : undefined); + return { label: entry.label, url, result }; + }) + ) + : []; + + const storedSettings = context.globalState.get>(SETTINGS_KEY) || {}; + const settingsKeys = Object.keys(storedSettings).filter((key) => key !== 'lastDirectory'); + + const lines = [ + `Time: ${new Date().toISOString()}`, + `OpenChamber version: ${extensionVersion || '(unknown)'}`, + `VS Code version: ${vscode.version}`, + `Platform: ${process.platform} ${process.arch}`, + `Workspace folders: ${workspaceFolders.length}${workspaceFolders.length ? ` (${workspaceFolders.join(', ')})` : ''}`, + `Status: ${openCodeManager?.getStatus() ?? 'unknown'}`, + `CLI available: ${openCodeManager?.isCliAvailable() ?? false}`, + `Working directory: ${openCodeManager?.getWorkingDirectory() ?? ''}`, + `API URL (configured): ${configuredApiUrl || '(none)'}`, + `API URL (resolved): ${openCodeManager?.getApiUrl() ?? '(none)'}`, + debug + ? `OpenCode mode: ${debug.mode} (starts=${debug.startCount}, restarts=${debug.restartCount})` + : `OpenCode mode: (unknown)`, + debug + ? `OpenCode CLI path: ${debug.cliPath || '(not found)'}` + : `OpenCode CLI path: (unknown)`, + debug + ? `OpenCode detected port: ${debug.detectedPort ?? '(none)'}` + : `OpenCode detected port: (unknown)`, + debug + ? `OpenCode API prefix: ${debug.apiPrefixDetected ? (debug.apiPrefix || '(root)') : '(unknown)'}` + : `OpenCode API prefix: (unknown)`, + debug + ? `Last start: ${formatIso(debug.lastStartAt)}` + : `Last start: (unknown)`, + debug + ? `Last connected: ${formatIso(debug.lastConnectedAt)}` + : `Last connected: (unknown)`, + debug && debug.lastConnectedAt ? `Connected for: ${formatDurationMs(Date.now() - debug.lastConnectedAt)}` : `Connected for: (n/a)`, + debug && debug.lastExitCode !== null ? `Last exit code: ${debug.lastExitCode}` : `Last exit code: (none)`, + debug?.lastError ? `Last error: ${debug.lastError}` : `Last error: (none)`, + `Settings keys (stored): ${settingsKeys.length ? settingsKeys.join(', ') : '(none)'}`, + probes.length ? '' : '', + ...(probes.length + ? [ + 'OpenCode API probes:', + ...probes.map((probe) => { + if (!probe.result) return `- ${probe.label}: (no url)`; + const { ok, status, elapsedMs, summary } = probe.result; + const suffix = ok ? '' : ` url=${probe.url}`; + return `- ${probe.label}: ${ok ? 'ok' : 'fail'} status=${status} time=${elapsedMs}ms ${summary}${suffix}`; + }), + ] + : []), + '', + ]; + + outputChannel?.appendLine(lines.join('\n')); + outputChannel?.show(true); + }) + ); + context.subscriptions.push( vscode.window.onDidChangeActiveColorTheme((theme) => { chatViewProvider?.updateTheme(theme.kind); @@ -60,13 +227,15 @@ export async function activate(context: vscode.ExtensionContext) { }) ); - // Start OpenCode API and wait for it to be ready - // The webview will show loading state during this time - await openCodeManager.start(); + // Start OpenCode API without blocking activation. + // Blocking here delays webview resolution and causes a blank panel until startup completes. + void openCodeManager.start(); } export async function deactivate() { await openCodeManager?.stop(); openCodeManager = undefined; chatViewProvider = undefined; + outputChannel?.dispose(); + outputChannel = undefined; } diff --git a/packages/vscode/src/opencode.ts b/packages/vscode/src/opencode.ts index aa0ee538..ff903593 100644 --- a/packages/vscode/src/opencode.ts +++ b/packages/vscode/src/opencode.ts @@ -12,9 +12,11 @@ const HEALTH_CHECK_INTERVAL_MS = 5000; const SHUTDOWN_TIMEOUT_MS = 3000; // Regex to detect port from CLI output (matches desktop pattern) -const URL_REGEX = /https?:\/\/[^:\s]+:(\d+)(?:\/[^\s"']*)?/gi; +const URL_REGEX = /https?:\/\/[^:\s]+:(\d+)(\/[^\s"']*)?/gi; const FALLBACK_PORT_REGEX = /(?:^|\s)(?:127\.0\.0\.1|localhost):(\d+)/i; +const API_PREFIX_CANDIDATES = ['', '/api'] as const; + const BIN_CANDIDATES = [ process.env.OPENCHAMBER_OPENCODE_PATH, process.env.OPENCHAMBER_OPENCODE_BIN, @@ -29,6 +31,25 @@ const BIN_CANDIDATES = [ export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'; +export type OpenCodeDebugInfo = { + mode: 'managed' | 'external'; + status: ConnectionStatus; + lastError?: string; + workingDirectory: string; + cliAvailable: boolean; + cliPath: string | null; + configuredApiUrl: string | null; + configuredPort: number | null; + detectedPort: number | null; + apiPrefix: string; + apiPrefixDetected: boolean; + startCount: number; + restartCount: number; + lastStartAt: number | null; + lastConnectedAt: number | null; + lastExitCode: number | null; +}; + export interface OpenCodeManager { start(workdir?: string): Promise; stop(): Promise; @@ -38,6 +59,7 @@ export interface OpenCodeManager { getApiUrl(): string | null; getWorkingDirectory(): string; isCliAvailable(): boolean; + getDebugInfo(): OpenCodeDebugInfo; onStatusChange(callback: (status: ConnectionStatus, error?: string) => void): vscode.Disposable; } @@ -147,36 +169,30 @@ async function checkHealth(apiUrl: string, quick = false): Promise { const timeoutMs = quick ? 1500 : 3000; const timeout = setTimeout(() => controller.abort(), timeoutMs); - // For quick checks during startup, just check /health - if (quick) { - try { - const response = await fetch(`${apiUrl}/health`, { signal: controller.signal }); - clearTimeout(timeout); - return response.ok; - } catch { - clearTimeout(timeout); - return false; - } + const normalized = apiUrl.replace(/\/+$/, ''); + const candidates: string[] = [`${normalized}/config`]; + + // Some deployments expose a /health endpoint (not guaranteed for OpenCode). + if (!quick) { + const healthUrl = normalized.endsWith('/api') ? `${normalized.slice(0, -4)}/health` : `${normalized}/health`; + candidates.push(healthUrl); } - - // Full health check: verify multiple endpoints - const candidates = [`${apiUrl}/health`, `${apiUrl}/config`]; - let successCount = 0; for (const target of candidates) { try { - const response = await fetch(target, { signal: controller.signal }); + const response = await fetch(target, { + signal: controller.signal, + headers: { Accept: 'application/json' }, + }); if (response.ok) { - successCount++; - if (successCount >= 2) { - clearTimeout(timeout); - return true; - } + clearTimeout(timeout); + return true; } } catch { // try next } } + clearTimeout(timeout); } catch { // ignore @@ -192,10 +208,19 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo let lastError: string | undefined; const listeners = new Set<(status: ConnectionStatus, error?: string) => void>(); let workingDirectory: string = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); + let startCount = 0; + let restartCount = 0; + let lastStartAt: number | null = null; + let lastConnectedAt: number | null = null; + let lastExitCode: number | null = null; // Port detection state (like desktop) let detectedPort: number | null = null; let portWaiters: Array<(port: number) => void> = []; + + // OpenCode API prefix detection (some versions serve under /api) + let apiPrefix: string = ''; + let apiPrefixDetected = false; // Check if user configured a specific API URL const config = vscode.workspace.getConfiguration('openchamber'); @@ -218,10 +243,110 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo const cliPath = resolveCliPath(); const cliAvailable = cliPath !== null; + const normalizeApiPrefix = (prefix: string): string => { + const trimmed = (prefix || '').trim(); + if (!trimmed || trimmed === '/') return ''; + const withLeading = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; + return withLeading.endsWith('/') ? withLeading.slice(0, -1) : withLeading; + }; + + const inferPrefixFromLogPath = (candidatePath: string | null | undefined): string | null => { + if (!candidatePath) return null; + const normalized = normalizeApiPrefix(candidatePath); + if (normalized === '/api' || normalized.startsWith('/api/')) { + return '/api'; + } + return null; + }; + + const setDetectedApiPrefix = (prefix: string) => { + const normalized = normalizeApiPrefix(prefix); + if (!apiPrefixDetected || apiPrefix !== normalized) { + apiPrefix = normalized; + apiPrefixDetected = true; + console.log(`[OpenCode] Detected API prefix: ${apiPrefix || '(root)'}`); + } + }; + + const buildApiBaseUrlFromPort = (port: number, prefixOverride?: string): string => { + const prefix = normalizeApiPrefix(prefixOverride !== undefined ? prefixOverride : apiPrefixDetected ? apiPrefix : ''); + return `http://localhost:${port}${prefix}`; + }; + + const detectApiPrefixFromOutput = (text: string) => { + if (!text) return; + URL_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = URL_REGEX.exec(text)) !== null) { + const port = parseInt(match[1], 10); + if (!Number.isFinite(port) || port <= 0) continue; + if (detectedPort !== null && port !== detectedPort) continue; + + const inferred = inferPrefixFromLogPath(match[2] || ''); + if (inferred !== null) { + setDetectedApiPrefix(inferred); + return; + } + } + }; + + const extractPrefixFromOpenApiDoc = (content: string): string | null => { + const match = content.match(/__OPENCODE_API_BASE__\s*=\s*['"]([^'"]+)['"]/); + if (!match?.[1]) return null; + try { + const parsed = new URL(match[1], 'http://localhost'); + return normalizeApiPrefix(parsed.pathname || ''); + } catch { + return normalizeApiPrefix(match[1]); + } + }; + + const detectApiPrefix = async (port: number): Promise => { + if (apiPrefixDetected) return apiPrefix; + + const origin = `http://localhost:${port}`; + + // Try /doc for explicit base hints first (best signal when available). + for (const candidate of API_PREFIX_CANDIDATES) { + const prefix = normalizeApiPrefix(candidate); + try { + const response = await fetch(`${origin}${prefix}/doc`, { method: 'GET', headers: { Accept: '*/*' } }); + if (!response.ok) continue; + const text = await response.text(); + const extracted = extractPrefixFromOpenApiDoc(text); + if (extracted !== null) { + setDetectedApiPrefix(extracted); + return apiPrefix; + } + } catch { + // ignore + } + } + + // Fallback: probe a stable endpoint under root vs /api. + for (const candidate of API_PREFIX_CANDIDATES) { + try { + const base = buildApiBaseUrlFromPort(port, candidate); + const response = await fetch(`${base}/config`, { method: 'GET', headers: { Accept: 'application/json' } }); + if (!response.ok) continue; + await response.json().catch(() => null); + setDetectedApiPrefix(candidate); + return apiPrefix; + } catch { + // ignore + } + } + + return apiPrefix; + }; + function setStatus(newStatus: ConnectionStatus, error?: string) { if (status !== newStatus || lastError !== error) { status = newStatus; lastError = error; + if (newStatus === 'connected') { + lastConnectedAt = Date.now(); + } listeners.forEach(cb => cb(status, error)); } } @@ -252,6 +377,10 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo const port = parseInt(match[1], 10); if (Number.isFinite(port) && port > 0) { setDetectedPort(port); + const inferred = inferPrefixFromLogPath(match[2] || ''); + if (inferred !== null) { + setDetectedApiPrefix(inferred); + } return; } } @@ -305,7 +434,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo return configuredApiUrl.replace(/\/+$/, ''); } if (detectedPort !== null) { - return `http://localhost:${detectedPort}`; + return buildApiBaseUrlFromPort(detectedPort); } return null; } @@ -338,11 +467,14 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo } async function start(workdir?: string): Promise { + startCount += 1; + lastStartAt = Date.now(); + if (typeof workdir === 'string' && workdir.trim().length > 0) { workingDirectory = workdir.trim(); } - // If user configured an external API URL, just check if it's healthy + // If user configured an external API URL, do NOT start a local CLI instance. if (useConfiguredUrl && configuredApiUrl) { setStatus('connecting'); const healthy = await checkHealth(configuredApiUrl); @@ -351,12 +483,8 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo startHealthCheck(); return; } - // If configured URL isn't responding and no CLI, show error - if (!cliAvailable) { - setStatus('error', `OpenCode API at ${configuredApiUrl} is not responding and CLI is not available.`); - return; - } - // Fall through to start CLI with configured port if possible + setStatus('error', `OpenCode API at ${configuredApiUrl} is not responding.`); + return; } // Check for existing running instance (only if port is known) @@ -384,6 +512,9 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo // Reset port detection for fresh start detectedPort = null; + apiPrefix = ''; + apiPrefixDetected = false; + lastExitCode = null; const spawnCwd = workingDirectory || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); @@ -407,12 +538,14 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo const text = data.toString(); console.log('[OpenCode]', text.trim()); detectPortFromOutput(text); + detectApiPrefixFromOutput(text); }); childProcess.stderr?.on('data', (data) => { const text = data.toString(); console.error('[OpenCode]', text.trim()); detectPortFromOutput(text); + detectApiPrefixFromOutput(text); }); childProcess.on('error', (err) => { @@ -426,6 +559,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo } childProcess = null; detectedPort = null; + lastExitCode = typeof code === 'number' ? code : null; }); // Wait for port detection (port comes from stdout/stderr) @@ -438,6 +572,11 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo } // Now wait for API to be ready + const detected = detectedPort; + if (detected !== null && !apiPrefixDetected) { + await detectApiPrefix(detected); + } + const apiUrl = getApiUrl(); if (!apiUrl) { setStatus('error', 'Failed to determine OpenCode API URL'); @@ -480,6 +619,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo } async function restart(): Promise { + restartCount += 1; await stop(); // Brief delay to let OS release resources await new Promise(r => setTimeout(r, 250)); @@ -488,7 +628,16 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo async function setWorkingDirectory(newPath: string): Promise<{ success: boolean; restarted: boolean; path: string }> { const target = typeof newPath === 'string' && newPath.trim().length > 0 ? newPath.trim() : workingDirectory; + if (target === workingDirectory) { + return { success: true, restarted: false, path: target }; + } workingDirectory = target; + + // When pointing at an external API URL, avoid restarting a local CLI process. + if (useConfiguredUrl && configuredApiUrl) { + return { success: true, restarted: false, path: target }; + } + await restart(); return { success: true, restarted: true, path: target }; } @@ -502,6 +651,24 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo getApiUrl, getWorkingDirectory: () => workingDirectory, isCliAvailable: () => cliAvailable, + getDebugInfo: () => ({ + mode: useConfiguredUrl && configuredApiUrl ? 'external' : 'managed', + status, + lastError, + workingDirectory, + cliAvailable, + cliPath, + configuredApiUrl: useConfiguredUrl && configuredApiUrl ? configuredApiUrl.replace(/\/+$/, '') : null, + configuredPort, + detectedPort, + apiPrefix, + apiPrefixDetected, + startCount, + restartCount, + lastStartAt, + lastConnectedAt, + lastExitCode, + }), onStatusChange(callback) { listeners.add(callback); // Immediately call with current status diff --git a/packages/vscode/src/opencodeConfig.ts b/packages/vscode/src/opencodeConfig.ts new file mode 100644 index 00000000..1019f57d --- /dev/null +++ b/packages/vscode/src/opencodeConfig.ts @@ -0,0 +1,338 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import yaml from 'yaml'; +import stripJsonComments from 'strip-json-comments'; + +const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode'); +const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agent'); +const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'command'); +const CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'opencode.json'); +const PROMPT_FILE_PATTERN = /^\{file:(.+)\}$/i; + +export type ConfigSources = { + md: { exists: boolean; path: string | null; fields: string[] }; + json: { exists: boolean; path: string; fields: string[] }; +}; + +const ensureDirs = () => { + if (!fs.existsSync(OPENCODE_CONFIG_DIR)) fs.mkdirSync(OPENCODE_CONFIG_DIR, { recursive: true }); + if (!fs.existsSync(AGENT_DIR)) fs.mkdirSync(AGENT_DIR, { recursive: true }); + if (!fs.existsSync(COMMAND_DIR)) fs.mkdirSync(COMMAND_DIR, { recursive: true }); +}; + +const isPromptFileReference = (value: unknown): value is string => { + return typeof value === 'string' && PROMPT_FILE_PATTERN.test(value.trim()); +}; + +const resolvePromptFilePath = (reference: string): string | null => { + const match = reference.trim().match(PROMPT_FILE_PATTERN); + if (!match?.[1]) return null; + let target = match[1].trim(); + if (!target) return null; + + if (target.startsWith('./')) { + target = path.join(OPENCODE_CONFIG_DIR, target.slice(2)); + } else if (!path.isAbsolute(target)) { + target = path.join(OPENCODE_CONFIG_DIR, target); + } + + return target; +}; + +const writePromptFile = (filePath: string, content: string) => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, 'utf8'); +}; + +const readConfig = (): Record => { + if (!fs.existsSync(CONFIG_FILE)) return {}; + const content = fs.readFileSync(CONFIG_FILE, 'utf8'); + const normalized = stripJsonComments(content).trim(); + if (!normalized) return {}; + return JSON.parse(normalized) as Record; +}; + +const writeConfig = (config: Record) => { + if (fs.existsSync(CONFIG_FILE)) { + const backupFile = `${CONFIG_FILE}.openchamber.backup`; + try { + fs.copyFileSync(CONFIG_FILE, backupFile); + } catch { + // ignore backup failures + } + } + fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8'); +}; + +const parseMdFile = (filePath: string): { frontmatter: Record; body: string } => { + const content = fs.readFileSync(filePath, 'utf8'); + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/); + if (!match) return { frontmatter: {}, body: content.trim() }; + return { frontmatter: (yaml.parse(match[1]) || {}) as Record, body: (match[2] || '').trim() }; +}; + +const writeMdFile = (filePath: string, frontmatter: Record, body: string) => { + const yamlStr = yaml.stringify(frontmatter ?? {}); + const content = `---\n${yamlStr}---\n\n${body ?? ''}`.trimEnd(); + fs.writeFileSync(filePath, content, 'utf8'); +}; + +export const getAgentSources = (agentName: string): ConfigSources => { + const mdPath = path.join(AGENT_DIR, `${agentName}.md`); + const mdExists = fs.existsSync(mdPath); + + const config = readConfig(); + const agentSection = (config.agent as Record | undefined)?.[agentName] as Record | undefined; + + const sources: ConfigSources = { + md: { exists: mdExists, path: mdExists ? mdPath : null, fields: [] }, + json: { exists: Boolean(agentSection), path: CONFIG_FILE, fields: [] }, + }; + + if (mdExists) { + const { frontmatter, body } = parseMdFile(mdPath); + sources.md.fields = Object.keys(frontmatter); + if (body) sources.md.fields.push('prompt'); + } + + if (agentSection) { + sources.json.fields = Object.keys(agentSection); + } + + return sources; +}; + +export const createAgent = (agentName: string, config: Record) => { + ensureDirs(); + + const mdPath = path.join(AGENT_DIR, `${agentName}.md`); + if (fs.existsSync(mdPath)) throw new Error(`Agent ${agentName} already exists as .md file`); + + const existingConfig = readConfig(); + const agentMap = existingConfig.agent as Record | undefined; + if (agentMap?.[agentName]) throw new Error(`Agent ${agentName} already exists in opencode.json`); + + const { prompt, ...frontmatter } = config as Record & { prompt?: unknown }; + writeMdFile(mdPath, frontmatter, typeof prompt === 'string' ? prompt : ''); +}; + +export const updateAgent = (agentName: string, updates: Record) => { + ensureDirs(); + + const mdPath = path.join(AGENT_DIR, `${agentName}.md`); + const mdExists = fs.existsSync(mdPath); + + const mdData = mdExists ? parseMdFile(mdPath) : null; + const config = readConfig(); + const agentMap = (config.agent as Record | undefined) ?? {}; + const jsonSection = agentMap[agentName] as Record | undefined; + + let mdModified = false; + let jsonModified = false; + + for (const [field, value] of Object.entries(updates || {})) { + if (field === 'prompt') { + const normalizedValue = typeof value === 'string' ? value : value == null ? '' : String(value); + + if (mdExists && mdData) { + mdData.body = normalizedValue; + mdModified = true; + continue; + } + + if (isPromptFileReference(jsonSection?.prompt)) { + const promptFilePath = resolvePromptFilePath(jsonSection.prompt); + if (!promptFilePath) throw new Error(`Invalid prompt file reference for agent ${agentName}`); + writePromptFile(promptFilePath, normalizedValue); + continue; + } + + if (!config.agent) config.agent = {}; + const target = (config.agent as Record)[agentName] as Record | undefined; + (config.agent as Record)[agentName] = { ...(target || {}), prompt: normalizedValue }; + jsonModified = true; + continue; + } + + const hasMdField = Boolean(mdData?.frontmatter?.[field] !== undefined); + const hasJsonField = Boolean(jsonSection?.[field] !== undefined); + + if (hasMdField && mdData) { + mdData.frontmatter[field] = value; + mdModified = true; + continue; + } + + if (!config.agent) config.agent = {}; + const current = ((config.agent as Record)[agentName] as Record | undefined) ?? {}; + (config.agent as Record)[agentName] = { ...current, [field]: value }; + jsonModified = true; + + if (hasJsonField) { + continue; + } + } + + if (mdModified && mdData) { + writeMdFile(mdPath, mdData.frontmatter, mdData.body); + } + + if (jsonModified) { + writeConfig(config); + } +}; + +export const deleteAgent = (agentName: string) => { + const mdPath = path.join(AGENT_DIR, `${agentName}.md`); + let deleted = false; + + if (fs.existsSync(mdPath)) { + fs.unlinkSync(mdPath); + deleted = true; + } + + const config = readConfig(); + const agentMap = (config.agent as Record | undefined) ?? {}; + if (agentMap[agentName] !== undefined) { + delete agentMap[agentName]; + config.agent = agentMap; + writeConfig(config); + deleted = true; + } + + if (!deleted) { + config.agent = agentMap; + agentMap[agentName] = { disable: true }; + writeConfig(config); + } +}; + +export const getCommandSources = (commandName: string): ConfigSources => { + const mdPath = path.join(COMMAND_DIR, `${commandName}.md`); + const mdExists = fs.existsSync(mdPath); + + const config = readConfig(); + const commandSection = (config.command as Record | undefined)?.[commandName] as Record | undefined; + + const sources: ConfigSources = { + md: { exists: mdExists, path: mdExists ? mdPath : null, fields: [] }, + json: { exists: Boolean(commandSection), path: CONFIG_FILE, fields: [] }, + }; + + if (mdExists) { + const { frontmatter, body } = parseMdFile(mdPath); + sources.md.fields = Object.keys(frontmatter); + if (body) sources.md.fields.push('template'); + } + + if (commandSection) { + sources.json.fields = Object.keys(commandSection); + } + + return sources; +}; + +export const createCommand = (commandName: string, config: Record) => { + ensureDirs(); + + const mdPath = path.join(COMMAND_DIR, `${commandName}.md`); + if (fs.existsSync(mdPath)) throw new Error(`Command ${commandName} already exists as .md file`); + + const existingConfig = readConfig(); + const commandMap = existingConfig.command as Record | undefined; + if (commandMap?.[commandName]) throw new Error(`Command ${commandName} already exists in opencode.json`); + + const { template, ...frontmatter } = config as Record & { template?: unknown }; + writeMdFile(mdPath, frontmatter, typeof template === 'string' ? template : ''); +}; + +export const updateCommand = (commandName: string, updates: Record) => { + ensureDirs(); + + const mdPath = path.join(COMMAND_DIR, `${commandName}.md`); + const mdExists = fs.existsSync(mdPath); + + const mdData = mdExists ? parseMdFile(mdPath) : null; + const config = readConfig(); + const commandMap = (config.command as Record | undefined) ?? {}; + const jsonSection = commandMap[commandName] as Record | undefined; + + let mdModified = false; + let jsonModified = false; + + for (const [field, value] of Object.entries(updates || {})) { + if (field === 'template') { + const normalizedValue = typeof value === 'string' ? value : value == null ? '' : String(value); + + if (mdExists && mdData) { + mdData.body = normalizedValue; + mdModified = true; + continue; + } + + if (isPromptFileReference(jsonSection?.template)) { + const templateFilePath = resolvePromptFilePath(jsonSection.template); + if (!templateFilePath) throw new Error(`Invalid template file reference for command ${commandName}`); + writePromptFile(templateFilePath, normalizedValue); + continue; + } + + if (!config.command) config.command = {}; + const target = (config.command as Record)[commandName] as Record | undefined; + (config.command as Record)[commandName] = { ...(target || {}), template: normalizedValue }; + jsonModified = true; + continue; + } + + const hasMdField = Boolean(mdData?.frontmatter?.[field] !== undefined); + const hasJsonField = Boolean(jsonSection?.[field] !== undefined); + + if (hasMdField && mdData) { + mdData.frontmatter[field] = value; + mdModified = true; + continue; + } + + if (!config.command) config.command = {}; + const current = ((config.command as Record)[commandName] as Record | undefined) ?? {}; + (config.command as Record)[commandName] = { ...current, [field]: value }; + jsonModified = true; + + if (hasJsonField) { + continue; + } + } + + if (mdModified && mdData) { + writeMdFile(mdPath, mdData.frontmatter, mdData.body); + } + + if (jsonModified) { + writeConfig(config); + } +}; + +export const deleteCommand = (commandName: string) => { + const mdPath = path.join(COMMAND_DIR, `${commandName}.md`); + let deleted = false; + + if (fs.existsSync(mdPath)) { + fs.unlinkSync(mdPath); + deleted = true; + } + + const config = readConfig(); + const commandMap = (config.command as Record | undefined) ?? {}; + if (commandMap[commandName] !== undefined) { + delete commandMap[commandName]; + config.command = commandMap; + writeConfig(config); + deleted = true; + } + + if (!deleted) { + throw new Error(`Command "${commandName}" not found`); + } +}; + diff --git a/packages/vscode/webview/api/bridge.ts b/packages/vscode/webview/api/bridge.ts index 872b8833..8c0ee84c 100644 --- a/packages/vscode/webview/api/bridge.ts +++ b/packages/vscode/webview/api/bridge.ts @@ -59,6 +59,14 @@ window.addEventListener('message', (event: MessageEvent) => { }); export function sendBridgeMessage(type: string, payload?: unknown): Promise { + return sendBridgeMessageWithOptions(type, payload); +} + +export function sendBridgeMessageWithOptions( + type: string, + payload?: unknown, + options?: { timeoutMs?: number } +): Promise { return new Promise((resolve, reject) => { const id = `req_${++requestIdCounter}_${Date.now()}`; const request: BridgeRequest = { id, type, payload }; @@ -68,17 +76,55 @@ export function sendBridgeMessage(type: string, payload?: unknown): reject, }); - setTimeout(() => { - if (pendingRequests.has(id)) { - pendingRequests.delete(id); - reject(new Error(`Request ${type} timed out`)); - } - }, 30000); + const timeoutMs = typeof options?.timeoutMs === 'number' ? options.timeoutMs : 30000; + if (Number.isFinite(timeoutMs) && timeoutMs > 0) { + setTimeout(() => { + if (pendingRequests.has(id)) { + pendingRequests.delete(id); + reject(new Error(`Request ${type} timed out`)); + } + }, timeoutMs); + } getVSCodeAPI().postMessage(request); }); } +export type ProxiedApiResponse = { + status: number; + headers: Record; + bodyBase64: string; +}; + +export async function proxyApiRequest(options: { + method: string; + path: string; + headers?: Record; + bodyBase64?: string; +}): Promise { + // Do not impose a bridge-level timeout. Let the original fetch's AbortSignal + // (or OpenCode server response timing) control the lifecycle. + return sendBridgeMessageWithOptions('api:proxy', options, { timeoutMs: 0 }); +} + +export type ProxiedSseStartResponse = { + status: number; + headers: Record; + streamId: string | null; + error?: string; +}; + +export async function startSseProxy(options: { + path: string; + headers?: Record; +}): Promise { + return sendBridgeMessage('api:sse:start', options); +} + +export async function stopSseProxy(options: { streamId: string }): Promise<{ stopped: boolean }> { + return sendBridgeMessage<{ stopped: boolean }>('api:sse:stop', options); +} + type CommandHandler = (payload: unknown) => void; const commandHandlers = new Map(); diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index 67549cb4..6d7136f1 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -1,5 +1,5 @@ import { createVSCodeAPIs } from './api'; -import { onThemeChange, sendBridgeMessage } from './api/bridge'; +import { onThemeChange, proxyApiRequest, sendBridgeMessage, startSseProxy, stopSseProxy } from './api/bridge'; import type { RuntimeAPIs } from '../../ui/src/lib/api/types'; import { buildVSCodeThemeFromPalette, @@ -14,7 +14,7 @@ declare global { interface Window { __OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs; __VSCODE_CONFIG__?: { - apiUrl: string; + apiUrl?: string; workspaceFolder: string; theme: string; connectionStatus: string; @@ -48,19 +48,124 @@ const handleConnectionMessage = (event: MessageEvent) => { const prevCliAvailable = window.__OPENCHAMBER_CONNECTION__?.cliAvailable ?? true; window.__OPENCHAMBER_CONNECTION__ = { status: payload, error, cliAvailable: prevCliAvailable }; window.dispatchEvent(new CustomEvent('openchamber:connection-status', { detail: { status: payload, error } })); - - // Hide loading screen when connected - if (payload === 'connected') { - const loadingEl = document.getElementById('initial-loading'); - if (loadingEl) { - loadingEl.classList.add('fade-out'); - setTimeout(() => loadingEl.remove(), 300); - } - } } }; window.addEventListener('message', handleConnectionMessage); +window.addEventListener('openchamber:connection-status', () => { + maybeHideLoadingOverlay(); +}); + +const fadeOutLoadingScreen = () => { + const loadingEl = document.getElementById('initial-loading'); + if (!loadingEl) return; + loadingEl.classList.add('fade-out'); + setTimeout(() => { + try { + loadingEl.remove(); + } catch { + // ignore + } + }, 300); +}; + +const setLoadingStatusText = (text: string, variant: 'normal' | 'error' = 'normal') => { + const statusEl = document.getElementById('loading-status'); + if (!statusEl) return; + statusEl.textContent = text; + if (variant === 'error') { + statusEl.classList.add('error-text'); + } else { + statusEl.classList.remove('error-text'); + } +}; + +const waitForUiMount = (timeoutMs = 8000): Promise => { + if (typeof document === 'undefined') return Promise.resolve(false); + const root = document.getElementById('root'); + if (!root) return Promise.resolve(false); + + const hasContent = () => root.childNodes.length > 0; + if (hasContent()) return Promise.resolve(true); + + return new Promise((resolve) => { + const observer = new MutationObserver(() => { + if (hasContent()) { + observer.disconnect(); + clearTimeout(timeout); + resolve(true); + } + }); + + observer.observe(root, { childList: true, subtree: true }); + + const timeout = setTimeout(() => { + observer.disconnect(); + resolve(false); + }, timeoutMs); + }); +}; + +let uiMounted = false; +let bootstrapProvidersReady = false; +let bootstrapAgentsReady = false; +let bootstrapFailed = false; + +const recordBootstrapFetch = (pathname: string, ok: boolean) => { + if (!pathname.startsWith('/api/')) return; + + if (pathname.startsWith('/api/config/providers')) { + if (ok) bootstrapProvidersReady = true; + else bootstrapFailed = true; + return; + } + + if (pathname === '/api/agent' || pathname.startsWith('/api/agent?')) { + if (ok) bootstrapAgentsReady = true; + else bootstrapFailed = true; + } +}; + +const maybeHideLoadingOverlay = () => { + const connectionStatus = window.__OPENCHAMBER_CONNECTION__?.status ?? 'connecting'; + + if (!uiMounted) { + return; + } + + if (connectionStatus === 'connected') { + if (bootstrapFailed) { + setLoadingStatusText('OpenCode connected, but initial data load failed.', 'error'); + fadeOutLoadingScreen(); + return; + } + + if (bootstrapProvidersReady && bootstrapAgentsReady) { + fadeOutLoadingScreen(); + return; + } + + const providersText = bootstrapProvidersReady ? '✓ Providers' : '… Providers'; + const agentsText = bootstrapAgentsReady ? '✓ Agents' : '… Agents'; + setLoadingStatusText(`Loading data (${providersText}, ${agentsText})…`); + return; + } + + if (connectionStatus === 'error') { + const error = window.__OPENCHAMBER_CONNECTION__?.error; + setLoadingStatusText(error || 'Connection error', 'error'); + fadeOutLoadingScreen(); + return; + } + + if (connectionStatus === 'disconnected') { + setLoadingStatusText('Disconnected', 'error'); + fadeOutLoadingScreen(); + return; + } + + setLoadingStatusText('Starting OpenCode API…'); +}; const applyInitialTheme = (theme: { metadata?: { variant?: string }; colors?: { surface?: { background?: string; foreground?: string } } }) => { if (typeof document === 'undefined' || !theme) return; @@ -133,9 +238,6 @@ if (workspaceFolder) { } catch (error) { console.warn('Failed to persist workspace folder', error); } - sendBridgeMessage('api:opencode/directory', { path: workspaceFolder }).catch((error) => { - console.warn('Failed to set OpenCode working directory from VS Code workspace', error); - }); } const normalizeUrl = (input: string | URL) => { @@ -146,42 +248,67 @@ const normalizeUrl = (input: string | URL) => { } }; -// API URL may be empty during initial load while port is being detected -// The extension will broadcast the URL once it's known -let apiBaseUrl = window.__VSCODE_CONFIG__?.apiUrl?.replace(/\/+$/, '') || ''; - -// Promise that resolves when API URL is available -let apiUrlResolver: ((url: string) => void) | null = null; -const apiUrlPromise = apiBaseUrl - ? Promise.resolve(apiBaseUrl) - : new Promise((resolve) => { apiUrlResolver = resolve; }); - -// Listen for API URL updates from extension -window.addEventListener('message', (event: MessageEvent) => { - const msg = event.data; - if (msg?.type === 'apiUrlUpdate' && typeof msg.url === 'string') { - const newUrl = msg.url.replace(/\/+$/, ''); - apiBaseUrl = newUrl; - console.log('[OpenChamber] API URL updated:', apiBaseUrl); - // Resolve the promise if waiting - if (apiUrlResolver) { - apiUrlResolver(newUrl); - apiUrlResolver = null; - } - } -}); - -// Helper to wait for API URL with timeout -const waitForApiUrl = async (timeoutMs = 15000): Promise => { - if (apiBaseUrl) return apiBaseUrl; - - const timeout = new Promise((_, reject) => - setTimeout(() => reject(new Error('Timeout waiting for API URL')), timeoutMs) - ); - - return Promise.race([apiUrlPromise, timeout]); +const headersToRecord = (headers: HeadersInit | undefined): Record => { + if (!headers) return {}; + const normalized = headers instanceof Headers ? headers : new Headers(headers); + const result: Record = {}; + normalized.forEach((value, key) => { + result[key] = value; + }); + return result; }; +const decodeBase64 = (value: string): Uint8Array => { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +}; + +const encodeBase64 = (bytes: Uint8Array): string => { + const CHUNK = 0x8000; + let binary = ''; + for (let i = 0; i < bytes.length; i += CHUNK) { + binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK)); + } + return btoa(binary); +}; + +const extractBodyBase64 = async (input: RequestInfo | URL, init: RequestInit | undefined, method: string): Promise => { + if (method === 'GET' || method === 'HEAD') return undefined; + + if (input instanceof Request) { + const cloned = input.clone(); + const buffer = await cloned.arrayBuffer(); + const bytes = new Uint8Array(buffer); + return bytes.length > 0 ? encodeBase64(bytes) : undefined; + } + + const body = init?.body; + if (!body) return undefined; + + if (typeof body === 'string') { + return encodeBase64(new TextEncoder().encode(body)); + } + + if (body instanceof URLSearchParams) { + return encodeBase64(new TextEncoder().encode(body.toString())); + } + + if (body instanceof Blob) { + const buffer = await body.arrayBuffer(); + const bytes = new Uint8Array(buffer); + return bytes.length > 0 ? encodeBase64(bytes) : undefined; + } + + console.warn('[OpenChamber] Unsupported request body type for proxy request:', body); + return undefined; +}; + +const isSseApiPath = (pathname: string) => pathname === '/api/event' || pathname === '/api/global/event'; + const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { const pathname = url.pathname; @@ -200,33 +327,6 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { }); } - if (pathname.startsWith('/api/openchamber/models-metadata')) { - const controller = typeof AbortController !== 'undefined' ? new AbortController() : undefined; - const timeout = controller ? setTimeout(() => controller.abort(), 8000) : undefined; - try { - const response = await fetch('https://models.dev/api.json', { - signal: controller?.signal, - headers: { Accept: 'application/json' }, - }); - if (!response.ok) { - throw new Error(`models.dev responded with ${response.status}`); - } - const data = await response.json(); - return new Response(JSON.stringify(data), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - } catch (error) { - console.warn('[OpenChamber] Failed to fetch models metadata, returning empty set:', error); - return new Response(JSON.stringify({}), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - } finally { - if (timeout) clearTimeout(timeout); - } - } - if (pathname.startsWith('/api/fs/list')) { const targetPath = url.searchParams.get('path') || ''; const data = await sendBridgeMessage('api:fs:list', { path: targetPath }); @@ -259,6 +359,34 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } + if (pathname.startsWith('/api/config/agents/')) { + const encodedName = pathname.slice('/api/config/agents/'.length); + const name = decodeURIComponent(encodedName); + const verb = ((init?.method || 'GET') as string).toUpperCase(); + const body = init?.body ? JSON.parse(init.body as string) : {}; + try { + const data = await sendBridgeMessage('api:config/agents', { method: verb, name, body }); + return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } }); + } + } + + if (pathname.startsWith('/api/config/commands/')) { + const encodedName = pathname.slice('/api/config/commands/'.length); + const name = decodeURIComponent(encodedName); + const verb = ((init?.method || 'GET') as string).toUpperCase(); + const body = init?.body ? JSON.parse(init.body as string) : {}; + try { + const data = await sendBridgeMessage('api:config/commands', { method: verb, name, body }); + return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } }); + } + } + if (pathname.startsWith('/api/config/settings')) { if ((init?.method || 'GET').toUpperCase() === 'GET') { const settings = await sendBridgeMessage('api:config/settings:get'); @@ -327,27 +455,89 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { if (targetUrl && targetUrl.pathname.startsWith('/api/')) { const localResponse = await handleLocalApiRequest(targetUrl, init); if (localResponse) { + recordBootstrapFetch(targetUrl.pathname, localResponse.ok); + maybeHideLoadingOverlay(); return localResponse; } - // Wait for API URL to be available before making requests - const baseUrl = await waitForApiUrl(); - - const rewritten = new URL(targetUrl.href); - rewritten.pathname = targetUrl.pathname.replace(/^\/api/, ''); - const fetchTarget = `${baseUrl}${rewritten.pathname}${rewritten.search}`; + const suffixPath = `${targetUrl.pathname.replace(/^\/api/, '')}${targetUrl.search}`; - if (input instanceof Request) { - const cloned = input.clone(); - const requestInit: RequestInit = { - method: method, - headers: cloned.headers, - body: method === 'GET' || method === 'HEAD' ? undefined : await cloned.blob(), - }; - return originalFetch(fetchTarget, requestInit); + const headersFromRequest = input instanceof Request ? headersToRecord(input.headers) : {}; + const headersFromInit = headersToRecord(init?.headers); + const headers = { ...headersFromRequest, ...headersFromInit }; + + if (isSseApiPath(targetUrl.pathname)) { + const start = await startSseProxy({ path: suffixPath, headers }); + if (!start.streamId) { + return new Response(null, { status: start.status || 503, headers: start.headers || {} }); + } + + const streamId = start.streamId; + const signal = (input instanceof Request ? input.signal : init?.signal) as AbortSignal | undefined; + const encoder = new TextEncoder(); + let unsubscribe: (() => void) | null = null; + + const stream = new ReadableStream({ + start(controller) { + const onMessage = (event: MessageEvent) => { + const msg = event.data as { type?: string; streamId?: string; chunk?: string; error?: string }; + if (!msg || msg.streamId !== streamId) return; + + if (msg.type === 'api:sse:chunk' && typeof msg.chunk === 'string') { + controller.enqueue(encoder.encode(msg.chunk)); + return; + } + + if (msg.type === 'api:sse:end') { + unsubscribe?.(); + unsubscribe = null; + if (typeof msg.error === 'string' && msg.error.length > 0) { + controller.error(new Error(msg.error)); + } else { + controller.close(); + } + void stopSseProxy({ streamId }).catch(() => {}); + } + }; + + window.addEventListener('message', onMessage); + unsubscribe = () => window.removeEventListener('message', onMessage); + + if (signal) { + const onAbort = () => { + unsubscribe?.(); + unsubscribe = null; + try { + controller.error(new DOMException('Aborted', 'AbortError')); + } catch { + controller.close(); + } + void stopSseProxy({ streamId }).catch(() => {}); + }; + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener('abort', onAbort, { once: true }); + } + }, + cancel() { + unsubscribe?.(); + unsubscribe = null; + void stopSseProxy({ streamId }).catch(() => {}); + }, + }); + + return new Response(stream, { status: start.status || 200, headers: start.headers || { 'content-type': 'text/event-stream' } }); } - return originalFetch(fetchTarget, init); + const bodyBase64 = await extractBodyBase64(input, init, method); + const proxied = await proxyApiRequest({ method, path: suffixPath, headers, bodyBase64 }); + const body = proxied.bodyBase64 ? decodeBase64(proxied.bodyBase64) : new Uint8Array(); + const response = new Response(body, { status: proxied.status, headers: proxied.headers }); + recordBootstrapFetch(targetUrl.pathname, response.ok); + maybeHideLoadingOverlay(); + return response; } if (targetUrl && targetUrl.hostname.includes('models.dev')) { @@ -362,4 +552,16 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { return originalFetch(input as RequestInfo, init); }; -import('../../ui/src/main'); + +import('../../ui/src/main') + .then(async () => { + await waitForUiMount(); + uiMounted = true; + maybeHideLoadingOverlay(); + }) + .catch((error) => { + console.error('[OpenChamber] Failed to bootstrap UI:', error); + // If the UI bundle fails to load, remove the overlay so the user at least sees errors in the root. + uiMounted = true; + fadeOutLoadingScreen(); + }); diff --git a/packages/vscode/webview/stores/chatStore.ts b/packages/vscode/webview/stores/chatStore.ts index 8d154865..d19c2098 100644 --- a/packages/vscode/webview/stores/chatStore.ts +++ b/packages/vscode/webview/stores/chatStore.ts @@ -2,7 +2,7 @@ import { create } from 'zustand'; import { createOpencodeClient, type OpencodeClient } from '@opencode-ai/sdk'; import type { Session, Message, Part } from '@opencode-ai/sdk'; -const getApiUrl = () => window.__VSCODE_CONFIG__?.apiUrl || 'http://localhost:47339'; +const getApiUrl = () => '/api'; const getWorkspaceFolder = () => window.__VSCODE_CONFIG__?.workspaceFolder || ''; const AUTO_DELETE_STORAGE_KEY = 'oc.vscode.autoDeleteLastRunAt';