feat: vscode extension (#59)
* feat: add initial VS Code extension plan and implementation tasks * feat(vscode): added initial version of an Openchamber VSCode extension * feat(vscode): enhance VS Code extension with theme integration and session management * feat(vscode): implement connection status handling and overlay in VSCode layout * feat: move extension to secondary sidebar * chore: upgrade @opencode-ai/sdk to 1.0.150 * vscode: editor bridge, file picker, click-to-open in tool parts * vscode: layout session lifecycle, theme sync, typography overrides * ui: compact mode for vscode, model search, autocomplete width fixes * perf: scroll force flag, raf placeholder, git polling backoff * ui: tool output styling, markdown code block fix, gitignore * refactor: update typography handling for VSCode runtime, remove unused styles * docs: update README with VS Code extension details and add extension image * docs: update changelog with new features and performance improvements
This commit is contained in:
committed by
GitHub
parent
610ccf4c62
commit
bb72c0fb0c
@@ -0,0 +1,120 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { handleBridgeMessage, type BridgeRequest } from './bridge';
|
||||
import { getThemeKindName } from './theme';
|
||||
import type { OpenCodeManager, ConnectionStatus } from './opencode';
|
||||
|
||||
export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
public static readonly viewType = 'openchamber.chatView';
|
||||
|
||||
private _view?: vscode.WebviewView;
|
||||
private _isVisible = false;
|
||||
|
||||
constructor(
|
||||
private readonly _context: vscode.ExtensionContext,
|
||||
private readonly _extensionUri: vscode.Uri,
|
||||
private readonly _openCodeManager?: OpenCodeManager
|
||||
) {}
|
||||
|
||||
public resolveWebviewView(
|
||||
webviewView: vscode.WebviewView
|
||||
) {
|
||||
this._view = webviewView;
|
||||
this._isVisible = webviewView.visible;
|
||||
|
||||
webviewView.onDidChangeVisibility(() => {
|
||||
this._isVisible = webviewView.visible;
|
||||
});
|
||||
|
||||
const distUri = vscode.Uri.joinPath(this._extensionUri, 'dist');
|
||||
|
||||
webviewView.webview.options = {
|
||||
enableScripts: true,
|
||||
localResourceRoots: [this._extensionUri, distUri],
|
||||
};
|
||||
|
||||
webviewView.webview.html = this._getHtmlForWebview(webviewView.webview);
|
||||
|
||||
webviewView.webview.onDidReceiveMessage(async (message: BridgeRequest) => {
|
||||
if (message.type === 'restartApi') {
|
||||
await this._openCodeManager?.restart();
|
||||
return;
|
||||
}
|
||||
const response = await handleBridgeMessage(message, {
|
||||
manager: this._openCodeManager,
|
||||
context: this._context,
|
||||
});
|
||||
webviewView.webview.postMessage(response);
|
||||
});
|
||||
}
|
||||
|
||||
public newSession() {
|
||||
if (this._view) {
|
||||
this._view.webview.postMessage({ type: 'command', command: 'newSession' });
|
||||
}
|
||||
}
|
||||
|
||||
public updateTheme(kind: vscode.ColorThemeKind) {
|
||||
if (this._view) {
|
||||
const themeKind = getThemeKindName(kind);
|
||||
this._view.webview.postMessage({
|
||||
type: 'themeChange',
|
||||
theme: { kind: themeKind },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public updateConnectionStatus(status: ConnectionStatus, error?: string) {
|
||||
if (this._view) {
|
||||
this._view.webview.postMessage({
|
||||
type: 'connectionStatus',
|
||||
status,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public isVisible(): boolean {
|
||||
return this._isVisible;
|
||||
}
|
||||
|
||||
private _getHtmlForWebview(webview: vscode.Webview) {
|
||||
const scriptPath = vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview', 'assets', 'index.js');
|
||||
const scriptUri = webview.asWebviewUri(scriptPath);
|
||||
|
||||
const config = vscode.workspace.getConfiguration('openchamber');
|
||||
const apiUrl = this._openCodeManager?.getApiUrl() || config.get<string>('apiUrl') || 'http://localhost:47339';
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
||||
const themeKind = getThemeKindName(vscode.window.activeColorTheme.kind);
|
||||
const initialStatus = this._openCodeManager?.getStatus() || 'disconnected';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline'; script-src ${webview.cspSource} 'unsafe-inline' 'unsafe-eval'; connect-src * ws: wss: http: https:; img-src ${webview.cspSource} data: https:; font-src ${webview.cspSource} data:;">
|
||||
<style>
|
||||
html, body, #root { height: 100%; width: 100%; }
|
||||
body { margin: 0; padding: 0; overflow: hidden; background: transparent; }
|
||||
</style>
|
||||
<title>OpenChamber</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script>
|
||||
// Polyfill process for Node.js modules running in browser
|
||||
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}"
|
||||
};
|
||||
window.__OPENCHAMBER_HOME__ = "${workspaceFolder.replace(/\\/g, '\\\\')}";
|
||||
</script>
|
||||
<script type="module" src="${scriptUri}"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import type { OpenCodeManager } from './opencode';
|
||||
|
||||
export interface BridgeRequest {
|
||||
id: string;
|
||||
type: string;
|
||||
payload?: unknown;
|
||||
}
|
||||
|
||||
export interface BridgeResponse {
|
||||
id: string;
|
||||
type: string;
|
||||
success: boolean;
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface FileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
}
|
||||
|
||||
interface FileSearchResult {
|
||||
path: string;
|
||||
score?: number;
|
||||
}
|
||||
|
||||
export interface BridgeContext {
|
||||
manager?: OpenCodeManager;
|
||||
context?: vscode.ExtensionContext;
|
||||
}
|
||||
|
||||
const SETTINGS_KEY = 'openchamber.settings';
|
||||
|
||||
const readSettings = (ctx?: BridgeContext) => {
|
||||
const stored = ctx?.context?.globalState.get<Record<string, unknown>>(SETTINGS_KEY) || {};
|
||||
const restStored = { ...stored };
|
||||
delete (restStored as Record<string, unknown>).lastDirectory;
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
||||
const themeVariant =
|
||||
vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Light ||
|
||||
vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.HighContrastLight
|
||||
? 'light'
|
||||
: 'dark';
|
||||
|
||||
return {
|
||||
themeVariant,
|
||||
lastDirectory: workspaceFolder,
|
||||
...restStored,
|
||||
};
|
||||
};
|
||||
|
||||
const persistSettings = async (changes: Record<string, unknown>, ctx?: BridgeContext) => {
|
||||
const current = readSettings(ctx);
|
||||
const restChanges = { ...(changes || {}) };
|
||||
delete restChanges.lastDirectory;
|
||||
const merged = { ...current, ...restChanges, lastDirectory: current.lastDirectory };
|
||||
await ctx?.context?.globalState.update(SETTINGS_KEY, merged);
|
||||
return merged;
|
||||
};
|
||||
|
||||
const normalizeFsPath = (value: string) => value.replace(/\\/g, '/');
|
||||
|
||||
const listDirectoryEntries = async (dirPath: string) => {
|
||||
const uri = vscode.Uri.file(dirPath);
|
||||
const entries = await vscode.workspace.fs.readDirectory(uri);
|
||||
return entries.map(([name, fileType]) => ({
|
||||
name,
|
||||
path: normalizeFsPath(vscode.Uri.joinPath(uri, name).fsPath),
|
||||
isDirectory: fileType === vscode.FileType.Directory,
|
||||
}));
|
||||
};
|
||||
|
||||
const searchDirectory = async (directory: string, query: string, limit = 60) => {
|
||||
const rootPath = directory || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
||||
if (!rootPath) return [];
|
||||
|
||||
const sanitizedQuery = query?.trim() || '';
|
||||
const pattern = sanitizedQuery ? `**/*${sanitizedQuery}*` : '**/*';
|
||||
const exclude = '**/{node_modules,.git,dist,build,.next,.turbo,.cache,coverage,tmp,logs}/**';
|
||||
const results = await vscode.workspace.findFiles(
|
||||
new vscode.RelativePattern(vscode.Uri.file(rootPath), pattern),
|
||||
exclude,
|
||||
limit,
|
||||
);
|
||||
|
||||
return results.map((file) => {
|
||||
const absolute = normalizeFsPath(file.fsPath);
|
||||
const relative = normalizeFsPath(path.relative(rootPath, absolute));
|
||||
const name = path.basename(absolute);
|
||||
return {
|
||||
name,
|
||||
path: absolute,
|
||||
relativePath: relative || name,
|
||||
extension: name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const fetchModelsMetadata = async () => {
|
||||
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}`);
|
||||
}
|
||||
return await response.json();
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeContext): Promise<BridgeResponse> {
|
||||
const { id, type, payload } = message;
|
||||
|
||||
try {
|
||||
switch (type) {
|
||||
case 'files:list': {
|
||||
const { path: dirPath } = payload as { path: string };
|
||||
const uri = vscode.Uri.file(dirPath);
|
||||
const entries = await vscode.workspace.fs.readDirectory(uri);
|
||||
const result: FileEntry[] = entries.map(([name, fileType]) => ({
|
||||
name,
|
||||
path: vscode.Uri.joinPath(uri, name).fsPath,
|
||||
isDirectory: fileType === vscode.FileType.Directory,
|
||||
}));
|
||||
return { id, type, success: true, data: { directory: dirPath, entries: result } };
|
||||
}
|
||||
|
||||
case 'files:search': {
|
||||
const { query, maxResults = 50 } = payload as { query: string; maxResults?: number };
|
||||
const pattern = `**/*${query}*`;
|
||||
const files = await vscode.workspace.findFiles(pattern, '**/node_modules/**', maxResults);
|
||||
const results: FileSearchResult[] = files.map((file) => ({
|
||||
path: file.fsPath,
|
||||
}));
|
||||
return { id, type, success: true, data: results };
|
||||
}
|
||||
|
||||
case 'workspace:folder': {
|
||||
const folder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
||||
return { id, type, success: true, data: { folder } };
|
||||
}
|
||||
|
||||
case 'config:get': {
|
||||
const { key } = payload as { key: string };
|
||||
const config = vscode.workspace.getConfiguration('openchamber');
|
||||
const value = config.get(key);
|
||||
return { id, type, success: true, data: { value } };
|
||||
}
|
||||
|
||||
case 'api:fs:list': {
|
||||
const target = (payload as { path?: string })?.path || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||
const entries = await listDirectoryEntries(target);
|
||||
return { id, type, success: true, data: { entries, directory: target } };
|
||||
}
|
||||
|
||||
case 'api:fs:search': {
|
||||
const { directory = '', query = '', limit } = (payload || {}) as { directory?: string; query?: string; limit?: number };
|
||||
const files = await searchDirectory(directory, query, limit);
|
||||
return { id, type, success: true, data: { files } };
|
||||
}
|
||||
|
||||
case 'api:fs:mkdir': {
|
||||
const target = (payload as { path: string })?.path;
|
||||
if (!target) {
|
||||
return { id, type, success: false, error: 'Path is required' };
|
||||
}
|
||||
await vscode.workspace.fs.createDirectory(vscode.Uri.file(target));
|
||||
return { id, type, success: true, data: { success: true, path: normalizeFsPath(target) } };
|
||||
}
|
||||
|
||||
case 'api:fs/home': {
|
||||
const workspaceHome = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
const home = workspaceHome || os.homedir();
|
||||
return { id, type, success: true, data: { home: normalizeFsPath(home) } };
|
||||
}
|
||||
|
||||
case 'api:files/pick': {
|
||||
const MAX_SIZE = 10 * 1024 * 1024;
|
||||
const allowMany = (payload as { allowMany?: boolean })?.allowMany !== false;
|
||||
const defaultUri = vscode.workspace.workspaceFolders?.[0]?.uri;
|
||||
|
||||
const picks = await vscode.window.showOpenDialog({
|
||||
canSelectFiles: true,
|
||||
canSelectFolders: false,
|
||||
canSelectMany: allowMany,
|
||||
defaultUri,
|
||||
openLabel: 'Attach',
|
||||
});
|
||||
|
||||
if (!picks || picks.length === 0) {
|
||||
return { id, type, success: true, data: { files: [], skipped: [] } };
|
||||
}
|
||||
|
||||
const files: Array<{ name: string; mimeType: string; size: number; dataUrl: string }> = [];
|
||||
const skipped: Array<{ name: string; reason: string }> = [];
|
||||
|
||||
const guessMime = (ext: string) => {
|
||||
switch (ext) {
|
||||
case '.png':
|
||||
case '.jpg':
|
||||
case '.jpeg':
|
||||
case '.gif':
|
||||
case '.bmp':
|
||||
case '.webp':
|
||||
return `image/${ext.replace('.', '')}`;
|
||||
case '.pdf':
|
||||
return 'application/pdf';
|
||||
case '.txt':
|
||||
case '.log':
|
||||
return 'text/plain';
|
||||
case '.json':
|
||||
return 'application/json';
|
||||
case '.md':
|
||||
case '.markdown':
|
||||
return 'text/markdown';
|
||||
default:
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
};
|
||||
|
||||
for (const uri of picks) {
|
||||
try {
|
||||
const stat = await vscode.workspace.fs.stat(uri);
|
||||
const size = stat.size ?? 0;
|
||||
const name = path.basename(uri.fsPath);
|
||||
|
||||
if (size > MAX_SIZE) {
|
||||
skipped.push({ name, reason: 'File exceeds 10MB limit' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const bytes = await vscode.workspace.fs.readFile(uri);
|
||||
const ext = path.extname(name).toLowerCase();
|
||||
const mimeType = guessMime(ext);
|
||||
const base64 = Buffer.from(bytes).toString('base64');
|
||||
const dataUrl = `data:${mimeType};base64,${base64}`;
|
||||
files.push({ name, mimeType, size, dataUrl });
|
||||
} catch (error) {
|
||||
const name = path.basename(uri.fsPath);
|
||||
skipped.push({ name, reason: error instanceof Error ? error.message : 'Failed to read file' });
|
||||
}
|
||||
}
|
||||
|
||||
return { id, type, success: true, data: { files, skipped } };
|
||||
}
|
||||
|
||||
case 'api:config/settings:get': {
|
||||
const settings = readSettings(ctx);
|
||||
return { id, type, success: true, data: settings };
|
||||
}
|
||||
|
||||
case 'api:config/settings:save': {
|
||||
const changes = (payload as Record<string, unknown>) || {};
|
||||
const updated = await persistSettings(changes, ctx);
|
||||
return { id, type, success: true, data: updated };
|
||||
}
|
||||
|
||||
case 'api:config/reload': {
|
||||
await ctx?.manager?.restart();
|
||||
return { id, type, success: true, data: { restarted: true } };
|
||||
}
|
||||
|
||||
case 'api:opencode/directory': {
|
||||
const target = (payload as { path?: string })?.path;
|
||||
if (!target) {
|
||||
return { id, type, success: false, error: 'Path is required' };
|
||||
}
|
||||
const result = await ctx?.manager?.setWorkingDirectory(target);
|
||||
if (!result) {
|
||||
return { id, type, success: false, error: 'OpenCode manager unavailable' };
|
||||
}
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:models/metadata': {
|
||||
try {
|
||||
const data = await fetchModelsMetadata();
|
||||
return { id, type, success: true, data };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return { id, type, success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
case 'editor:openFile': {
|
||||
const { path: filePath, line, column } = payload as { path: string; line?: number; column?: number };
|
||||
try {
|
||||
const doc = await vscode.workspace.openTextDocument(filePath);
|
||||
const options: vscode.TextDocumentShowOptions = {};
|
||||
if (typeof line === 'number') {
|
||||
const pos = new vscode.Position(Math.max(0, line - 1), column || 0);
|
||||
options.selection = new vscode.Range(pos, pos);
|
||||
}
|
||||
await vscode.window.showTextDocument(doc, options);
|
||||
return { id, type, success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return { id, type, success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
case 'editor:openDiff': {
|
||||
const { original, modified, label } = payload as { original: string; modified: string; label?: string };
|
||||
try {
|
||||
// If the paths are just content, we need to create virtual documents or temp files.
|
||||
// However, 'editor:openDiff' usually implies comparing two URIs.
|
||||
// If the payload contains file paths:
|
||||
const originalUri = vscode.Uri.file(original);
|
||||
const modifiedUri = vscode.Uri.file(modified);
|
||||
const title = label || `${path.basename(original)} ↔ ${path.basename(modified)}`;
|
||||
|
||||
await vscode.commands.executeCommand('vscode.diff', originalUri, modifiedUri, title);
|
||||
return { id, type, success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return { id, type, success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return { id, type, success: false, error: `Unknown message type: ${type}` };
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
return { id, type, success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { ChatViewProvider } from './ChatViewProvider';
|
||||
import { createOpenCodeManager, type OpenCodeManager } from './opencode';
|
||||
|
||||
let chatViewProvider: ChatViewProvider | undefined;
|
||||
let openCodeManager: OpenCodeManager | undefined;
|
||||
|
||||
export function activate(context: vscode.ExtensionContext) {
|
||||
// Create OpenCode manager first
|
||||
openCodeManager = createOpenCodeManager(context);
|
||||
|
||||
// Create chat view provider with manager reference
|
||||
chatViewProvider = new ChatViewProvider(context, context.extensionUri, openCodeManager);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.window.registerWebviewViewProvider(
|
||||
ChatViewProvider.viewType,
|
||||
chatViewProvider,
|
||||
{ webviewOptions: { retainContextWhenHidden: true } }
|
||||
)
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('openchamber.newSession', () => {
|
||||
chatViewProvider?.newSession();
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('openchamber.focusChat', () => {
|
||||
vscode.commands.executeCommand('openchamber.chatView.focus');
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('openchamber.restartApi', async () => {
|
||||
await openCodeManager?.restart();
|
||||
})
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('openchamber.showInSecondarySidebar', async () => {
|
||||
const viewId = ChatViewProvider.viewType;
|
||||
const isVisible = chatViewProvider?.isVisible() === true;
|
||||
|
||||
if (isVisible) {
|
||||
await vscode.commands.executeCommand('workbench.action.toggleAuxiliaryBar');
|
||||
return;
|
||||
}
|
||||
|
||||
await vscode.commands.executeCommand('workbench.action.focusAuxiliaryBar');
|
||||
await vscode.commands.executeCommand(`${viewId}.focus`);
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.window.onDidChangeActiveColorTheme((theme) => {
|
||||
chatViewProvider?.updateTheme(theme.kind);
|
||||
})
|
||||
);
|
||||
|
||||
// Subscribe to status changes
|
||||
context.subscriptions.push(
|
||||
openCodeManager.onStatusChange((status, error) => {
|
||||
chatViewProvider?.updateConnectionStatus(status, error);
|
||||
})
|
||||
);
|
||||
|
||||
// Auto-start OpenCode API
|
||||
openCodeManager.start();
|
||||
}
|
||||
|
||||
export function deactivate() {
|
||||
openCodeManager?.stop();
|
||||
openCodeManager = undefined;
|
||||
chatViewProvider = undefined;
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { spawn, ChildProcess, spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import * as net from 'net';
|
||||
|
||||
const DEFAULT_PORT = 47339;
|
||||
const HEALTH_CHECK_INTERVAL = 5000;
|
||||
const STARTUP_TIMEOUT = 10000;
|
||||
const SHUTDOWN_TIMEOUT = 3000;
|
||||
|
||||
const BIN_CANDIDATES = [
|
||||
process.env.OPENCHAMBER_OPENCODE_PATH,
|
||||
process.env.OPENCHAMBER_OPENCODE_BIN,
|
||||
process.env.OPENCODE_PATH,
|
||||
process.env.OPENCODE_BINARY,
|
||||
'/opt/homebrew/bin/opencode',
|
||||
'/usr/local/bin/opencode',
|
||||
'/usr/bin/opencode',
|
||||
path.join(os.homedir(), '.local/bin/opencode'),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
|
||||
export interface OpenCodeManager {
|
||||
start(workdir?: string): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
restart(): Promise<void>;
|
||||
setWorkingDirectory(path: string): Promise<{ success: boolean; restarted: boolean; path: string }>;
|
||||
getStatus(): ConnectionStatus;
|
||||
getApiUrl(): string;
|
||||
getWorkingDirectory(): string;
|
||||
onStatusChange(callback: (status: ConnectionStatus, error?: string) => void): vscode.Disposable;
|
||||
}
|
||||
|
||||
function isExecutable(filePath: string): boolean {
|
||||
try {
|
||||
fs.accessSync(filePath, fs.constants.X_OK);
|
||||
return fs.statSync(filePath).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCliPath(): string | null {
|
||||
for (const candidate of BIN_CANDIDATES) {
|
||||
if (candidate && isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
const envPath = process.env.PATH || '';
|
||||
for (const segment of envPath.split(path.delimiter)) {
|
||||
const candidate = path.join(segment, 'opencode');
|
||||
if (isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
const shellCandidates = [
|
||||
process.env.SHELL,
|
||||
'/bin/bash',
|
||||
'/bin/zsh',
|
||||
'/bin/sh',
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
for (const shellPath of shellCandidates) {
|
||||
if (!isExecutable(shellPath)) continue;
|
||||
try {
|
||||
const result = spawnSync(shellPath, ['-lic', 'command -v opencode'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
if (result.status === 0) {
|
||||
const candidate = result.stdout.trim().split(/\s+/).pop();
|
||||
if (candidate && isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function checkHealth(apiUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
const candidates = [`${apiUrl}/health`, `${apiUrl}/api/health`];
|
||||
|
||||
for (const target of candidates) {
|
||||
try {
|
||||
const response = await fetch(target, { signal: controller.signal });
|
||||
if (response.ok) {
|
||||
clearTimeout(timeout);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hashWorkspaceIdentifier(identifier: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < identifier.length; i++) {
|
||||
hash = (hash * 31 + identifier.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
async function findAvailablePort(startPort: number, maxAttempts = 20): Promise<number> {
|
||||
let port = startPort;
|
||||
for (let i = 0; i < maxAttempts; i += 1) {
|
||||
const available = await new Promise<boolean>((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', () => {
|
||||
server.close();
|
||||
resolve(false);
|
||||
});
|
||||
server.listen(port, () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
});
|
||||
if (available) {
|
||||
return port;
|
||||
}
|
||||
port += 1;
|
||||
}
|
||||
return startPort;
|
||||
}
|
||||
|
||||
export function createOpenCodeManager(context: vscode.ExtensionContext): OpenCodeManager {
|
||||
let childProcess: ChildProcess | null = null;
|
||||
let status: ConnectionStatus = 'disconnected';
|
||||
let healthCheckInterval: NodeJS.Timeout | null = null;
|
||||
let lastError: string | undefined;
|
||||
const listeners = new Set<(status: ConnectionStatus, error?: string) => void>();
|
||||
let workingDirectory: string = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||
const workspaceKey = `openchamber.api.port.${hashWorkspaceIdentifier(workspaceFolder)}`;
|
||||
|
||||
const config = vscode.workspace.getConfiguration('openchamber');
|
||||
const configuredApiUrl = config.get<string>('apiUrl') || '';
|
||||
|
||||
const storedPort = context.workspaceState.get<number>(workspaceKey);
|
||||
let apiUrl: string = storedPort && Number.isFinite(storedPort)
|
||||
? `http://localhost:${storedPort}`
|
||||
: `http://localhost:${DEFAULT_PORT}`;
|
||||
let desiredPort: number = storedPort && Number.isFinite(storedPort) ? storedPort : DEFAULT_PORT;
|
||||
|
||||
const parseApiUrl = (candidate: string): { url: string; port: number } | null => {
|
||||
try {
|
||||
const parsed = new URL(candidate);
|
||||
const origin = parsed.origin;
|
||||
const pathname = parsed.pathname && parsed.pathname !== '/' ? parsed.pathname.replace(/\/+$/, '') : '';
|
||||
const normalized = `${origin}${pathname}`;
|
||||
const port = parsed.port ? parseInt(parsed.port, 10) : DEFAULT_PORT;
|
||||
return {
|
||||
url: normalized,
|
||||
port: Number.isFinite(port) && port > 0 ? port : DEFAULT_PORT,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveApi = async () => {
|
||||
// If user explicitly set a non-default URL, honor it (shared across workspaces).
|
||||
const parsed = configuredApiUrl ? parseApiUrl(configuredApiUrl) : null;
|
||||
const isDefault = !parsed || parsed.port === DEFAULT_PORT;
|
||||
|
||||
if (!isDefault && parsed) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// Workspace-isolated port selection
|
||||
const storedPort = context.workspaceState.get<number>(workspaceKey);
|
||||
const basePort = storedPort && Number.isFinite(storedPort) ? storedPort : DEFAULT_PORT + (hashWorkspaceIdentifier(workspaceFolder) % 1000);
|
||||
const port = await findAvailablePort(basePort);
|
||||
void context.workspaceState.update(workspaceKey, port);
|
||||
return { url: `http://localhost:${port}`, port };
|
||||
};
|
||||
|
||||
const apiConfigPromise = resolveApi().then((result) => {
|
||||
apiUrl = result.url;
|
||||
desiredPort = result.port;
|
||||
return result;
|
||||
}).catch(() => null);
|
||||
|
||||
function setStatus(newStatus: ConnectionStatus, error?: string) {
|
||||
if (status !== newStatus || lastError !== error) {
|
||||
status = newStatus;
|
||||
lastError = error;
|
||||
listeners.forEach(cb => cb(status, error));
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForHealthy(timeoutMs: number): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (await checkHealth(apiUrl)) {
|
||||
return true;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function startHealthCheck() {
|
||||
stopHealthCheck();
|
||||
healthCheckInterval = setInterval(async () => {
|
||||
const healthy = await checkHealth(apiUrl);
|
||||
if (healthy && status !== 'connected') {
|
||||
setStatus('connected');
|
||||
} else if (!healthy && status === 'connected') {
|
||||
setStatus('disconnected');
|
||||
}
|
||||
}, HEALTH_CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
function stopHealthCheck() {
|
||||
if (healthCheckInterval) {
|
||||
clearInterval(healthCheckInterval);
|
||||
healthCheckInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function start(workdir?: string) {
|
||||
await apiConfigPromise;
|
||||
|
||||
if (typeof workdir === 'string' && workdir.trim().length > 0) {
|
||||
workingDirectory = workdir.trim();
|
||||
}
|
||||
|
||||
// First check if API is already running
|
||||
if (await checkHealth(apiUrl)) {
|
||||
setStatus('connected');
|
||||
startHealthCheck();
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('connecting');
|
||||
|
||||
const cliPath = resolveCliPath();
|
||||
if (!cliPath) {
|
||||
setStatus('error', 'OpenCode CLI not found. Install it or set OPENCODE_BINARY env var.');
|
||||
vscode.window.showErrorMessage(
|
||||
'OpenCode CLI not found. Please install it or set the OPENCODE_BINARY environment variable.',
|
||||
'More Info'
|
||||
).then(selection => {
|
||||
if (selection === 'More Info') {
|
||||
vscode.env.openExternal(vscode.Uri.parse('https://github.com/opencode-ai/opencode'));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const spawnCwd = workingDirectory || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||
|
||||
try {
|
||||
childProcess = spawn(cliPath, ['serve', '--port', desiredPort.toString()], {
|
||||
cwd: spawnCwd,
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCODE_PORT: desiredPort.toString(),
|
||||
},
|
||||
detached: false,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
childProcess.stdout?.on('data', (data) => {
|
||||
console.log('[OpenCode]', data.toString());
|
||||
});
|
||||
|
||||
childProcess.stderr?.on('data', (data) => {
|
||||
console.error('[OpenCode]', data.toString());
|
||||
});
|
||||
|
||||
childProcess.on('error', (err) => {
|
||||
setStatus('error', `Failed to start OpenCode: ${err.message}`);
|
||||
childProcess = null;
|
||||
});
|
||||
|
||||
childProcess.on('exit', () => {
|
||||
if (status !== 'disconnected') {
|
||||
setStatus('disconnected');
|
||||
}
|
||||
childProcess = null;
|
||||
});
|
||||
|
||||
// Wait for API to become healthy
|
||||
const healthy = await waitForHealthy(STARTUP_TIMEOUT);
|
||||
if (healthy) {
|
||||
setStatus('connected');
|
||||
startHealthCheck();
|
||||
} else {
|
||||
setStatus('error', 'OpenCode API did not start in time');
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setStatus('error', `Failed to start OpenCode: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
stopHealthCheck();
|
||||
|
||||
if (childProcess) {
|
||||
try {
|
||||
childProcess.kill('SIGTERM');
|
||||
// Wait a bit for graceful shutdown
|
||||
await new Promise(r => setTimeout(r, SHUTDOWN_TIMEOUT));
|
||||
if (childProcess && !childProcess.killed && childProcess.exitCode === null) {
|
||||
childProcess.kill('SIGKILL');
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
childProcess = null;
|
||||
}
|
||||
|
||||
setStatus('disconnected');
|
||||
}
|
||||
|
||||
async function restart() {
|
||||
await stop();
|
||||
await start();
|
||||
}
|
||||
|
||||
async function setWorkingDirectory(path: string) {
|
||||
const target = typeof path === 'string' && path.trim().length > 0 ? path.trim() : workingDirectory;
|
||||
workingDirectory = target;
|
||||
await restart();
|
||||
return { success: true, restarted: true, path: target };
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
restart,
|
||||
setWorkingDirectory,
|
||||
getStatus: () => status,
|
||||
getApiUrl: () => apiUrl,
|
||||
getWorkingDirectory: () => workingDirectory,
|
||||
onStatusChange(callback) {
|
||||
listeners.add(callback);
|
||||
// Immediately call with current status
|
||||
callback(status, lastError);
|
||||
return new vscode.Disposable(() => listeners.delete(callback));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
export type ThemeKindName = 'light' | 'dark';
|
||||
|
||||
export function getThemeKindName(kind: vscode.ColorThemeKind): ThemeKindName {
|
||||
switch (kind) {
|
||||
case vscode.ColorThemeKind.Light:
|
||||
case vscode.ColorThemeKind.HighContrastLight:
|
||||
return 'light';
|
||||
case vscode.ColorThemeKind.Dark:
|
||||
case vscode.ColorThemeKind.HighContrast:
|
||||
default:
|
||||
return 'dark';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user