feat(vscode): refactor OpenCode configuration management and API proxying
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -56,6 +56,10 @@
|
||||
{
|
||||
"command": "openchamber.restartApi",
|
||||
"title": "OpenChamber: Restart API Connection"
|
||||
},
|
||||
{
|
||||
"command": "openchamber.showOpenCodeStatus",
|
||||
"title": "OpenChamber: Show OpenCode Status"
|
||||
}
|
||||
],
|
||||
"configuration": {
|
||||
|
||||
@@ -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<string, AbortController>();
|
||||
|
||||
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<string, string>): Record<string, string> {
|
||||
return {
|
||||
Accept: 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
...(extra || {}),
|
||||
};
|
||||
}
|
||||
|
||||
private _collectHeaders(headers: Headers): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
result[key] = value;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private async _startSseProxy(message: BridgeRequest): Promise<BridgeResponse> {
|
||||
const { id, type, payload } = message;
|
||||
const apiBaseUrl = this._openCodeManager?.getApiUrl();
|
||||
|
||||
const { path, headers } = (payload || {}) as { path?: string; headers?: Record<string, string> };
|
||||
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<BridgeResponse> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, string>;
|
||||
bodyBase64?: string;
|
||||
};
|
||||
|
||||
type ApiProxyResponsePayload = {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
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<Record<string, unknown>>(SETTINGS_KEY) || {};
|
||||
@@ -230,11 +245,86 @@ const fetchModelsMetadata = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const base64EncodeUtf8 = (text: string) => Buffer.from(text, 'utf8').toString('base64');
|
||||
|
||||
const collectHeaders = (headers: Headers): Record<string, string> => {
|
||||
const result: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
result[key] = value;
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeContext): Promise<BridgeResponse> {
|
||||
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<string, string> = { ...(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<string, unknown> };
|
||||
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<string, unknown>);
|
||||
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<string, unknown>);
|
||||
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<string, unknown> };
|
||||
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<string, unknown>);
|
||||
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<string, unknown>);
|
||||
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) {
|
||||
|
||||
@@ -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<string>('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<Record<string, unknown>>(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;
|
||||
}
|
||||
|
||||
+196
-29
@@ -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<void>;
|
||||
stop(): Promise<void>;
|
||||
@@ -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<boolean> {
|
||||
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<string> => {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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
|
||||
|
||||
@@ -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<string, unknown> => {
|
||||
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<string, unknown>;
|
||||
};
|
||||
|
||||
const writeConfig = (config: Record<string, unknown>) => {
|
||||
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<string, unknown>; 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<string, unknown>, body: (match[2] || '').trim() };
|
||||
};
|
||||
|
||||
const writeMdFile = (filePath: string, frontmatter: Record<string, unknown>, 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<string, unknown> | undefined)?.[agentName] as Record<string, unknown> | 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<string, unknown>) => {
|
||||
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<string, unknown> | undefined;
|
||||
if (agentMap?.[agentName]) throw new Error(`Agent ${agentName} already exists in opencode.json`);
|
||||
|
||||
const { prompt, ...frontmatter } = config as Record<string, unknown> & { prompt?: unknown };
|
||||
writeMdFile(mdPath, frontmatter, typeof prompt === 'string' ? prompt : '');
|
||||
};
|
||||
|
||||
export const updateAgent = (agentName: string, updates: Record<string, unknown>) => {
|
||||
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<string, unknown> | undefined) ?? {};
|
||||
const jsonSection = agentMap[agentName] as Record<string, unknown> | 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<string, unknown>)[agentName] as Record<string, unknown> | undefined;
|
||||
(config.agent as Record<string, unknown>)[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<string, unknown>)[agentName] as Record<string, unknown> | undefined) ?? {};
|
||||
(config.agent as Record<string, unknown>)[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<string, unknown> | 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<string, unknown> | undefined)?.[commandName] as Record<string, unknown> | 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<string, unknown>) => {
|
||||
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<string, unknown> | undefined;
|
||||
if (commandMap?.[commandName]) throw new Error(`Command ${commandName} already exists in opencode.json`);
|
||||
|
||||
const { template, ...frontmatter } = config as Record<string, unknown> & { template?: unknown };
|
||||
writeMdFile(mdPath, frontmatter, typeof template === 'string' ? template : '');
|
||||
};
|
||||
|
||||
export const updateCommand = (commandName: string, updates: Record<string, unknown>) => {
|
||||
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<string, unknown> | undefined) ?? {};
|
||||
const jsonSection = commandMap[commandName] as Record<string, unknown> | 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<string, unknown>)[commandName] as Record<string, unknown> | undefined;
|
||||
(config.command as Record<string, unknown>)[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<string, unknown>)[commandName] as Record<string, unknown> | undefined) ?? {};
|
||||
(config.command as Record<string, unknown>)[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<string, unknown> | undefined) ?? {};
|
||||
if (commandMap[commandName] !== undefined) {
|
||||
delete commandMap[commandName];
|
||||
config.command = commandMap;
|
||||
writeConfig(config);
|
||||
deleted = true;
|
||||
}
|
||||
|
||||
if (!deleted) {
|
||||
throw new Error(`Command "${commandName}" not found`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -59,6 +59,14 @@ window.addEventListener('message', (event: MessageEvent<BridgeResponse>) => {
|
||||
});
|
||||
|
||||
export function sendBridgeMessage<T = unknown>(type: string, payload?: unknown): Promise<T> {
|
||||
return sendBridgeMessageWithOptions<T>(type, payload);
|
||||
}
|
||||
|
||||
export function sendBridgeMessageWithOptions<T = unknown>(
|
||||
type: string,
|
||||
payload?: unknown,
|
||||
options?: { timeoutMs?: number }
|
||||
): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = `req_${++requestIdCounter}_${Date.now()}`;
|
||||
const request: BridgeRequest = { id, type, payload };
|
||||
@@ -68,17 +76,55 @@ export function sendBridgeMessage<T = unknown>(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<string, string>;
|
||||
bodyBase64: string;
|
||||
};
|
||||
|
||||
export async function proxyApiRequest(options: {
|
||||
method: string;
|
||||
path: string;
|
||||
headers?: Record<string, string>;
|
||||
bodyBase64?: string;
|
||||
}): Promise<ProxiedApiResponse> {
|
||||
// Do not impose a bridge-level timeout. Let the original fetch's AbortSignal
|
||||
// (or OpenCode server response timing) control the lifecycle.
|
||||
return sendBridgeMessageWithOptions<ProxiedApiResponse>('api:proxy', options, { timeoutMs: 0 });
|
||||
}
|
||||
|
||||
export type ProxiedSseStartResponse = {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
streamId: string | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export async function startSseProxy(options: {
|
||||
path: string;
|
||||
headers?: Record<string, string>;
|
||||
}): Promise<ProxiedSseStartResponse> {
|
||||
return sendBridgeMessage<ProxiedSseStartResponse>('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<string, CommandHandler>();
|
||||
|
||||
|
||||
@@ -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<boolean> => {
|
||||
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<string>((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<string> => {
|
||||
if (apiBaseUrl) return apiBaseUrl;
|
||||
|
||||
const timeout = new Promise<string>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout waiting for API URL')), timeoutMs)
|
||||
);
|
||||
|
||||
return Promise.race([apiUrlPromise, timeout]);
|
||||
const headersToRecord = (headers: HeadersInit | undefined): Record<string, string> => {
|
||||
if (!headers) return {};
|
||||
const normalized = headers instanceof Headers ? headers : new Headers(headers);
|
||||
const result: Record<string, string> = {};
|
||||
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<string | undefined> => {
|
||||
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<Uint8Array>({
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user