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:
Bohdan Triapitsyn
2025-12-13 16:34:17 +02:00
committed by GitHub
parent 610ccf4c62
commit bb72c0fb0c
76 changed files with 6097 additions and 296 deletions
+11
View File
@@ -0,0 +1,11 @@
.vscode/**
node_modules/**
src/**
webview/**
.gitignore
tsconfig.json
tsconfig.webview.json
vite.config.ts
*.map
**/*.ts
!dist/**
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 OpenChamber contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+47
View File
@@ -0,0 +1,47 @@
# OpenChamber VS Code Extension
AI coding assistant for VS Code powered by the OpenCode API. Embeds the OpenChamber chat interface in VS Code's secondary sidebar.
![VS Code Extension](../../docs/references/vscode_extension.png)
## Features
- Chat UI in secondary sidebar
- Session management with history
- File attachments via native VS Code file picker (10MB limit)
- Auto-start `opencode serve` if not running
- Workspace-isolated opencode instances (different workspaces get unique opencode instances)
- Adapts to VS Code's light/dark/high-contrast themes
## Commands
| Command | Description |
|---------|-------------|
| `OpenChamber: New Chat Session` | Create new chat session |
| `OpenChamber: Focus Chat` | Focus chat panel in secondary sidebar |
| `OpenChamber: Restart API Connection` | Restart OpenCode API process |
| `OpenChamber: Show in Secondary Side Bar` | Toggle chat panel visibility |
## Configuration
| Setting | Default | Description |
|---------|---------|-------------|
| `openchamber.apiUrl` | `http://localhost:47339` | OpenCode API server URL |
## Requirements
- OpenCode CLI installed and available in PATH (or set via `OPENCODE_BINARY` env var)
- VS Code 1.85.0+
## Development
```bash
pnpm install
pnpm -C packages/vscode run build # build extension + webview
pnpm -C packages/vscode exec vsce package --no-dependencies
```
## Local Install
- After packaging: `code --install-extension packages/vscode/openchamber-*.vsix`
- Or in VS Code: Extensions panel → "Install from VSIX…" and select the file
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<!-- Dark rounded background -->
<rect x="0" y="0" width="1024" height="1024" rx="150" ry="150" fill="#1a1616"/>
<!-- Glyph centered -->
<g transform="translate(512, 512) scale(10) translate(-35, -36.5)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 13H35V58H0V13ZM26.25 22.1957H8.75V48.701H26.25V22.1957Z" fill="#F8F7F3"/>
<rect x="8.75" y="30" width="17.5" height="18.5" fill="#4B4646"/>
<path d="M43.75 13H70V22.1957H52.5V48.701H70V57.8967H43.75V13Z" fill="#F8F7F3"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 644 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+9
View File
@@ -0,0 +1,9 @@
<svg width="24" height="24" viewBox="0 0 70 70" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(35, 35) scale(0.95) translate(-35, -35)">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M0 13H35V58H0V13ZM26.25 22.1957H8.75V48.701H26.25V22.1957Z"
fill="#808080"/>
<path d="M43.75 13H70V22.1957H52.5V48.701H70V57.8967H43.75V13Z"
fill="#808080"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 411 B

+121
View File
@@ -0,0 +1,121 @@
{
"name": "openchamber",
"displayName": "OpenChamber",
"description": "AI coding assistant powered by OpenCode",
"version": "1.0.9",
"publisher": "fedaykindev",
"private": true,
"repository": {
"type": "git",
"url": "https://github.com/btriapitsyn/openchamber.git"
},
"license": "MIT",
"engines": {
"vscode": "^1.85.0"
},
"categories": [
"Programming Languages",
"Machine Learning",
"Other"
],
"keywords": [
"ai",
"claude",
"gpt",
"agent",
"coding",
"chatgpt",
"groq",
"assistant",
"opencode",
"openchamber"
],
"icon": "assets/app-icon.png",
"main": "./dist/extension.js",
"activationEvents": [],
"contributes": {
"viewsContainers": {
"secondarySidebar": [
{
"id": "openchamber",
"title": "OpenChamber",
"icon": "assets/icon.svg"
}
]
},
"views": {
"openchamber": [
{
"type": "webview",
"id": "openchamber.chatView",
"name": "Chat"
}
]
},
"commands": [
{
"command": "openchamber.newSession",
"title": "OpenChamber: New Chat Session"
},
{
"command": "openchamber.focusChat",
"title": "OpenChamber: Focus Chat"
},
{
"command": "openchamber.restartApi",
"title": "OpenChamber: Restart API Connection"
},
{
"command": "openchamber.showInSecondarySidebar",
"title": "OpenChamber: Show in Secondary Side Bar",
"icon": "assets/icon.svg"
}
],
"menus": {
"editor/title": [
{
"command": "openchamber.showInSecondarySidebar",
"when": "editorIsOpen",
"group": "navigation@100"
}
]
},
"configuration": {
"title": "OpenChamber",
"properties": {
"openchamber.apiUrl": {
"type": "string",
"default": "http://localhost:47339",
"description": "URL of the OpenCode API server"
}
}
}
},
"scripts": {
"vscode:prepublish": "pnpm run build",
"build": "pnpm run build:extension && pnpm run build:webview",
"build:extension": "esbuild src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node --minify",
"build:webview": "VITE_OPENCODE_URL=/api vite build",
"dev": "concurrently -n \"ext,web\" -c \"cyan,magenta\" \"pnpm run watch:extension\" \"pnpm run watch:webview\"",
"watch:extension": "esbuild src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node --watch --sourcemap",
"watch:webview": "vite build --watch",
"type-check": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.webview.json",
"lint": "pnpm dlx eslint --ext .ts,.tsx src webview",
"package": "vsce package --no-dependencies"
},
"devDependencies": {
"@types/vscode": "^1.85.0",
"@vitejs/plugin-react": "^5.0.0",
"concurrently": "^9.2.1",
"esbuild": "^0.24.2",
"typescript": "~5.8.3",
"vite": "^7.1.2",
"@vscode/vsce": "^3.2.1"
},
"dependencies": {
"@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "^1.0.133",
"react": "^19.1.1",
"react-dom": "^19.1.1"
}
}
+120
View File
@@ -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>`;
}
}
+338
View File
@@ -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 };
}
}
+76
View File
@@ -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;
}
+365
View File
@@ -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));
},
};
}
+15
View File
@@ -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';
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "bundler",
"moduleDetection": "force",
"verbatimModuleSyntax": true,
"resolveJsonModule": true,
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"esModuleInterop": true,
"outDir": "dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "webview"]
}
+30
View File
@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"moduleDetection": "force",
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"baseUrl": ".",
"types": ["vite/client"],
"paths": {
"@/*": ["../ui/src/*"],
"@vscode/*": ["./webview/*"],
"@openchamber/ui/*": ["../ui/src/*"]
}
},
"include": ["webview/**/*", "../ui/src/**/*"],
"exclude": [
"webview/components/**/*",
"webview/stores/**/*",
"webview/hooks/**/*",
"webview/App.tsx"
]
}
+41
View File
@@ -0,0 +1,41 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
root: path.resolve(__dirname, 'webview'),
base: './', // Use relative paths for VS Code webview
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, '../ui/src'),
'@vscode': path.resolve(__dirname, './webview'),
'@openchamber/ui': path.resolve(__dirname, '../ui/src'),
'@opencode-ai/sdk': path.resolve(__dirname, '../../node_modules/@opencode-ai/sdk/dist/client.js'),
},
},
define: {
'process.env.NODE_ENV': JSON.stringify('production'),
'global': 'globalThis',
},
envPrefix: ['VITE_'],
optimizeDeps: {
include: ['@opencode-ai/sdk'],
},
build: {
outDir: path.resolve(__dirname, 'dist/webview'),
emptyOutDir: true,
rollupOptions: {
input: path.resolve(__dirname, 'webview/index.html'),
external: ['node:child_process', 'node:fs', 'node:path', 'node:url'],
output: {
entryFileNames: 'assets/[name].js',
chunkFileNames: 'assets/[name].js',
assetFileNames: 'assets/[name].[ext]',
},
},
},
});
+272
View File
@@ -0,0 +1,272 @@
import React from 'react';
import { useChatStore } from './stores/chatStore';
import { useNavigation } from './hooks/useNavigation';
type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
function ConnectionStatusBanner({ status, error, onRetry }: {
status: ConnectionStatus;
error?: string;
onRetry: () => void;
}) {
if (status === 'connected') return null;
const messages: Record<ConnectionStatus, string> = {
disconnected: 'Not connected to OpenCode API',
connecting: 'Connecting...',
connected: '',
error: error || 'Connection error',
};
return (
<div className={`flex items-center justify-center gap-2 px-4 py-2 text-sm border-b ${
status === 'error' ? 'bg-destructive/10 text-destructive' : 'bg-muted text-muted-foreground'
}`}>
{status === 'connecting' && (
<span className="inline-block w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
)}
<span>{messages[status]}</span>
{(status === 'disconnected' || status === 'error') && (
<button onClick={onRetry} className="px-2 py-0.5 text-xs rounded bg-primary text-primary-foreground hover:bg-primary/90">
Retry
</button>
)}
</div>
);
}
function SessionsList() {
const { sessions, currentSessionId, selectSession, createSession, isLoadingSessions } = useChatStore();
const { goToChat } = useNavigation();
const [isCreating, setIsCreating] = React.useState(false);
const handleSelectSession = async (sessionId: string) => {
await selectSession(sessionId);
goToChat();
};
const handleNewSession = async () => {
setIsCreating(true);
const sessionId = await createSession();
setIsCreating(false);
if (sessionId) {
goToChat();
}
};
const formatTime = (timestamp?: number) => {
if (!timestamp) return '';
const date = new Date(timestamp);
const now = new Date();
const diffDays = Math.floor((now.getTime() - date.getTime()) / 86400000);
if (diffDays === 0) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (diffDays === 1) return 'Yesterday';
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
};
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
<h1 className="text-sm font-medium">Sessions</h1>
<button
onClick={handleNewSession}
disabled={isCreating}
className="p-1.5 rounded hover:bg-muted disabled:opacity-50"
>
{isCreating ? (
<span className="inline-block w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : (
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" /></svg>
)}
</button>
</div>
<div className="flex-1 overflow-y-auto">
{isLoadingSessions ? (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">Loading...</div>
) : sessions.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full p-4 text-center">
<div className="text-muted-foreground text-sm mb-4">No sessions yet</div>
<button
onClick={handleNewSession}
disabled={isCreating}
className="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
>
{isCreating ? 'Creating...' : 'Start New Chat'}
</button>
</div>
) : (
<div className="divide-y divide-border">
{sessions.map((session) => (
<button
key={session.id}
onClick={() => handleSelectSession(session.id)}
className={`w-full text-left px-3 py-2.5 hover:bg-muted/50 transition-colors ${
session.id === currentSessionId ? 'bg-primary/10 border-l-2 border-primary' : ''
}`}
>
<div className="text-sm font-medium truncate">{session.title || 'New Session'}</div>
<div className="text-xs text-muted-foreground">{formatTime(session.time?.created)}</div>
</button>
))}
</div>
)}
</div>
</div>
);
}
function ChatPanel() {
const { currentSessionId, sessions, messages, sendMessage, abortMessage, isSending, streamingSessionId } = useChatStore();
const { goToSessions } = useNavigation();
const [input, setInput] = React.useState('');
const messagesEndRef = React.useRef<HTMLDivElement>(null);
const currentSession = sessions.find((s) => s.id === currentSessionId);
const sessionMessages = currentSessionId ? messages.get(currentSessionId) || [] : [];
const isStreaming = streamingSessionId === currentSessionId;
React.useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [sessionMessages.length]);
const handleSend = async () => {
if (!input.trim() || isSending) return;
const text = input.trim();
setInput('');
await sendMessage(text);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border">
<button onClick={goToSessions} className="p-1 rounded hover:bg-muted">
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M11 2L5 8l6 6" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>
</button>
<h1 className="text-sm font-medium truncate flex-1">{currentSession?.title || 'New Chat'}</h1>
</div>
<div className="flex-1 overflow-y-auto px-3 py-2">
{sessionMessages.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Start a conversation
</div>
) : (
<div className="space-y-3">
{sessionMessages.map((msg, idx) => (
<MessageBubble key={msg.info.id || idx} message={msg} />
))}
{isStreaming && (
<div className="flex justify-start">
<div className="bg-muted rounded-lg px-3 py-2 text-sm">
<span className="inline-block w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
)}
</div>
<div className="border-t border-border p-3">
<div className="flex gap-2">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
rows={1}
disabled={isSending}
className="flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
style={{ minHeight: 40, maxHeight: 120 }}
/>
{isStreaming ? (
<button onClick={abortMessage} className="px-3 py-2 rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90">
<svg width="16" height="16" viewBox="0 0 16 16"><rect x="3" y="3" width="10" height="10" rx="1" fill="currentColor"/></svg>
</button>
) : (
<button onClick={handleSend} disabled={!input.trim() || isSending} className="px-3 py-2 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50">
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M2 8l12-6-3.5 6 3.5 6L2 8z" fill="currentColor"/></svg>
</button>
)}
</div>
</div>
</div>
);
}
function MessageBubble({ message }: { message: { info: { role: string }; parts: Array<{ type: string; text?: string }> } }) {
const isUser = message.info.role === 'user';
const text = message.parts.filter((p) => p.type === 'text').map((p) => p.text).join('\n');
return (
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${
isUser ? 'bg-primary text-primary-foreground' : 'bg-muted'
}`}>
<div className="whitespace-pre-wrap break-words">{text || '...'}</div>
</div>
</div>
);
}
export function VSCodeApp() {
const { initialize, isConnected } = useChatStore();
const { currentView } = useNavigation();
const [status, setStatus] = React.useState<ConnectionStatus>('connecting');
const [error, setError] = React.useState<string>();
const connect = React.useCallback(async () => {
setStatus('connecting');
setError(undefined);
try {
await initialize();
setStatus('connected');
} catch (err) {
setStatus('error');
setError(err instanceof Error ? err.message : 'Failed to connect');
}
}, [initialize]);
React.useEffect(() => {
connect();
}, [connect]);
React.useEffect(() => {
if (isConnected) setStatus('connected');
}, [isConnected]);
// Listen for extension messages
React.useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const msg = event.data;
if (msg.type === 'connectionStatus') {
if (msg.status === 'connected') setStatus('connected');
else if (msg.status === 'error') {
setStatus('error');
setError(msg.error);
}
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, []);
return (
<div className="flex flex-col h-full bg-background text-foreground">
<ConnectionStatusBanner status={status} error={error} onRetry={connect} />
<div className="flex-1 min-h-0">
{currentView === 'sessions' ? <SessionsList /> : <ChatPanel />}
</div>
</div>
);
}
export default VSCodeApp;
+114
View File
@@ -0,0 +1,114 @@
declare const acquireVsCodeApi: () => {
postMessage: (message: unknown) => void;
getState: () => unknown;
setState: (state: unknown) => void;
};
interface VSCodeAPI {
postMessage: (message: unknown) => void;
}
let vscodeApi: VSCodeAPI | null = null;
function getVSCodeAPI(): VSCodeAPI {
if (!vscodeApi) {
vscodeApi = acquireVsCodeApi();
}
return vscodeApi;
}
// Export vscode API for direct use
export const vscode = {
postMessage: (message: unknown) => getVSCodeAPI().postMessage(message),
};
interface BridgeRequest {
id: string;
type: string;
payload?: unknown;
}
interface BridgeResponse {
id: string;
type: string;
success: boolean;
data?: unknown;
error?: string;
}
const pendingRequests = new Map<string, {
resolve: (value: unknown) => void;
reject: (reason: Error) => void;
}>();
let requestIdCounter = 0;
window.addEventListener('message', (event: MessageEvent<BridgeResponse>) => {
const response = event.data;
if (!response || typeof response.id !== 'string') return;
const pending = pendingRequests.get(response.id);
if (pending) {
pendingRequests.delete(response.id);
if (response.success) {
pending.resolve(response.data);
} else {
pending.reject(new Error(response.error || 'Unknown error'));
}
}
});
export function sendBridgeMessage<T = unknown>(type: string, payload?: unknown): Promise<T> {
return new Promise((resolve, reject) => {
const id = `req_${++requestIdCounter}_${Date.now()}`;
const request: BridgeRequest = { id, type, payload };
pendingRequests.set(id, {
resolve: resolve as (value: unknown) => void,
reject,
});
setTimeout(() => {
if (pendingRequests.has(id)) {
pendingRequests.delete(id);
reject(new Error(`Request ${type} timed out`));
}
}, 30000);
getVSCodeAPI().postMessage(request);
});
}
type CommandHandler = (payload: unknown) => void;
const commandHandlers = new Map<string, CommandHandler>();
export function onCommand(command: string, handler: CommandHandler): () => void {
commandHandlers.set(command, handler);
return () => commandHandlers.delete(command);
}
window.addEventListener('message', (event: MessageEvent) => {
const message = event.data;
if (message?.type === 'command' && message.command) {
const handler = commandHandlers.get(message.command);
if (handler) {
handler(message.payload);
}
}
});
type ThemeChangePayload = 'light' | 'dark' | { kind?: 'light' | 'dark' | 'high-contrast' };
type ThemeChangeHandler = (theme: ThemeChangePayload) => void;
let themeChangeHandler: ThemeChangeHandler | null = null;
export function onThemeChange(handler: ThemeChangeHandler): () => void {
themeChangeHandler = handler;
return () => { themeChangeHandler = null; };
}
window.addEventListener('message', (event: MessageEvent) => {
const message = event.data;
if (message?.type === 'themeChange' && themeChangeHandler) {
themeChangeHandler(message.theme);
}
});
+12
View File
@@ -0,0 +1,12 @@
import { sendBridgeMessage } from './bridge';
import type { EditorAPI } from '@openchamber/ui/lib/api/types';
export const createVSCodeEditorAPI = (): EditorAPI => ({
openFile: async (path: string, line?: number, column?: number) => {
await sendBridgeMessage('editor:openFile', { path, line, column });
},
openDiff: async (original: string, modified: string, label?: string) => {
await sendBridgeMessage('editor:openDiff', { original, modified, label });
},
});
+71
View File
@@ -0,0 +1,71 @@
import type { DirectoryListResult, FileSearchQuery, FileSearchResult, FilesAPI } from '@openchamber/ui/lib/api/types';
// Use same endpoints as web - fetch interceptor handles URL rewriting
const normalizePath = (path: string): string => path.replace(/\\/g, '/');
export const createVSCodeFilesAPI = (): FilesAPI => ({
async listDirectory(path: string): Promise<DirectoryListResult> {
const target = normalizePath(path);
const response = await fetch('/api/fs/list', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: target }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to list directory');
}
return response.json();
},
async search(payload: FileSearchQuery): Promise<FileSearchResult[]> {
const response = await fetch('/api/fs/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
directory: normalizePath(payload.directory),
query: payload.query,
maxResults: payload.maxResults,
}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to search files');
}
const results = (await response.json()) as unknown;
if (!Array.isArray(results)) {
return [];
}
return results
.filter((item): item is FileSearchResult => !!item && typeof item === 'object' && typeof (item as { path?: string }).path === 'string')
.map((item) => ({
path: normalizePath((item as FileSearchResult).path),
score: (item as FileSearchResult).score,
preview: (item as FileSearchResult).preview,
}));
},
async createDirectory(path: string): Promise<{ success: boolean; path: string }> {
const target = normalizePath(path);
const response = await fetch('/api/fs/mkdir', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: target }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to create directory');
}
const result = await response.json();
return {
success: Boolean(result?.success),
path: typeof result?.path === 'string' ? normalizePath(result.path) : target,
};
},
});
+63
View File
@@ -0,0 +1,63 @@
import type { RuntimeAPIs, TerminalAPI, GitAPI, NotificationsAPI } from '@openchamber/ui/lib/api/types';
import { createVSCodeFilesAPI } from './files';
import { createVSCodeSettingsAPI } from './settings';
import { createVSCodePermissionsAPI } from './permissions';
import { createVSCodeToolsAPI } from './tools';
import { createVSCodeEditorAPI } from './editor';
// Stub APIs return sensible defaults instead of throwing
const createStubTerminalAPI = (): TerminalAPI => ({
createSession: async () => ({ sessionId: '', cols: 80, rows: 24 }),
connect: () => ({ close: () => {} }),
sendInput: async () => {},
resize: async () => {},
close: async () => {},
});
const createStubGitAPI = (): GitAPI => ({
checkIsGitRepository: async () => false,
getGitStatus: async () => ({ current: '', tracking: null, ahead: 0, behind: 0, files: [], isClean: true }),
getGitDiff: async () => ({ diff: '' }),
getGitFileDiff: async () => ({ original: '', modified: '', path: '' }),
revertGitFile: async () => {},
isLinkedWorktree: async () => false,
getGitBranches: async () => ({ all: [], current: '', branches: {} }),
deleteGitBranch: async () => ({ success: false }),
deleteRemoteBranch: async () => ({ success: false }),
generateCommitMessage: async () => ({ message: { subject: '', highlights: [] } }),
listGitWorktrees: async () => [],
addGitWorktree: async () => ({ success: false, path: '', branch: '' }),
removeGitWorktree: async () => ({ success: false }),
ensureOpenChamberIgnored: async () => {},
createGitCommit: async () => ({ success: false, commit: '', branch: '', summary: { changes: 0, insertions: 0, deletions: 0 } }),
gitPush: async () => ({ success: false, pushed: [], repo: '', ref: null }),
gitPull: async () => ({ success: false, summary: { changes: 0, insertions: 0, deletions: 0 }, files: [], insertions: 0, deletions: 0 }),
gitFetch: async () => ({ success: false }),
checkoutBranch: async () => ({ success: false, branch: '' }),
createBranch: async () => ({ success: false, branch: '' }),
getGitLog: async () => ({ all: [], latest: null, total: 0 }),
getCommitFiles: async () => ({ files: [] }),
getCurrentGitIdentity: async () => null,
setGitIdentity: async () => ({ success: false, profile: { id: '', name: '', userName: '', userEmail: '' } }),
getGitIdentities: async () => [],
createGitIdentity: async (p) => p,
updateGitIdentity: async (_, p) => p,
deleteGitIdentity: async () => {},
});
const createStubNotificationsAPI = (): NotificationsAPI => ({
notifyAgentCompletion: async () => true,
canNotify: () => true,
});
export const createVSCodeAPIs = (): RuntimeAPIs => ({
runtime: { platform: 'vscode', isDesktop: false, isVSCode: true, label: 'VS Code Extension' },
terminal: createStubTerminalAPI(),
git: createStubGitAPI(),
files: createVSCodeFilesAPI(),
settings: createVSCodeSettingsAPI(),
permissions: createVSCodePermissionsAPI(),
notifications: createStubNotificationsAPI(),
tools: createVSCodeToolsAPI(),
editor: createVSCodeEditorAPI(),
});
@@ -0,0 +1,16 @@
import type { DirectoryPermissionRequest, PermissionsAPI, StartAccessingResult } from '@openchamber/ui/lib/api/types';
export const createVSCodePermissionsAPI = (): PermissionsAPI => ({
async requestDirectoryAccess(request: DirectoryPermissionRequest) {
// VS Code handles permissions via workspace
return { success: true, path: request.path };
},
async startAccessingDirectory(path: string): Promise<StartAccessingResult> {
void path;
return { success: true };
},
async stopAccessingDirectory(path: string): Promise<StartAccessingResult> {
void path;
return { success: true };
},
});
+70
View File
@@ -0,0 +1,70 @@
import type { SettingsAPI, SettingsLoadResult, SettingsPayload } from '@openchamber/ui/lib/api/types';
// Use same endpoints as web - fetch interceptor handles URL rewriting
const SETTINGS_ENDPOINT = '/api/config/settings';
const RELOAD_ENDPOINT = '/api/config/reload';
const sanitizePayload = (data: unknown): SettingsPayload => {
if (!data || typeof data !== 'object') {
return {};
}
return data as SettingsPayload;
};
export const createVSCodeSettingsAPI = (): SettingsAPI => ({
async load(): Promise<SettingsLoadResult> {
const response = await fetch(SETTINGS_ENDPOINT, {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
// Fallback to VS Code config
return {
settings: {
themeVariant: window.__VSCODE_CONFIG__?.theme === 'light' ? 'light' : 'dark',
lastDirectory: window.__VSCODE_CONFIG__?.workspaceFolder || '',
},
source: 'web',
};
}
const payload = sanitizePayload(await response.json().catch(() => ({})));
return {
settings: {
...payload,
// Override with VS Code settings
lastDirectory: window.__VSCODE_CONFIG__?.workspaceFolder || payload.lastDirectory || '',
},
source: 'web',
};
},
async save(changes: Partial<SettingsPayload>): Promise<SettingsPayload> {
const response = await fetch(SETTINGS_ENDPOINT, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(changes),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to save settings');
}
const payload = sanitizePayload(await response.json().catch(() => ({})));
return payload;
},
async restartOpenCode(): Promise<{ restarted: boolean }> {
const response = await fetch(RELOAD_ENDPOINT, { method: 'POST' });
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to restart OpenCode');
}
return { restarted: true };
},
});
+22
View File
@@ -0,0 +1,22 @@
import type { ToolsAPI } from '@openchamber/ui/lib/api/types';
// Use same endpoint as web - fetch interceptor handles URL rewriting
export const createVSCodeToolsAPI = (): ToolsAPI => ({
async getAvailableTools(): Promise<string[]> {
const response = await fetch('/api/experimental/tool/ids');
if (!response.ok) {
throw new Error(`Tools API returned ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (!Array.isArray(data)) {
throw new Error('Tools API returned invalid data format');
}
return data
.filter((tool: unknown): tool is string => typeof tool === 'string' && tool !== 'invalid')
.sort();
},
});
@@ -0,0 +1,131 @@
import React from 'react';
import { useSessionStore } from '@openchamber/ui/stores/useSessionStore';
import { useNavigation } from '../hooks/useNavigation';
import { VSCodeHeader } from './VSCodeHeader';
import { SimpleMessageRenderer } from './SimpleMessageRenderer';
export function ChatPanel() {
const { goToSessions } = useNavigation();
const messagesEndRef = React.useRef<HTMLDivElement>(null);
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
const currentSessionId = useSessionStore((s) => s.currentSessionId);
const messages = useSessionStore((s) => s.messages);
const sessions = useSessionStore((s) => s.sessions);
const sendMessage = useSessionStore((s) => s.sendMessage);
const abortCurrentOperation = useSessionStore((s) => s.abortCurrentOperation);
const streamingMessageIds = useSessionStore((s) => s.streamingMessageIds);
const [inputValue, setInputValue] = React.useState('');
const [isSending, setIsSending] = React.useState(false);
const currentSession = sessions.find((s) => s.id === currentSessionId);
const sessionTitle = currentSession?.title || 'New Chat';
const sessionMessages = currentSessionId ? messages.get(currentSessionId) || [] : [];
const isStreaming = currentSessionId ? streamingMessageIds.has(currentSessionId) : false;
// Auto-scroll to bottom when new messages arrive
React.useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [sessionMessages.length]);
const handleSend = async () => {
if (!inputValue.trim() || !currentSessionId || isSending) return;
const messageText = inputValue.trim();
setInputValue('');
setIsSending(true);
try {
await sendMessage(messageText);
} catch (error) {
console.error('Failed to send message:', error);
setInputValue(messageText); // Restore input on error
} finally {
setIsSending(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleAbort = () => {
if (currentSessionId) {
abortCurrentOperation(currentSessionId);
}
};
return (
<div className="flex flex-col h-full">
{/* Header */}
<VSCodeHeader
title={sessionTitle}
showBack
onBack={goToSessions}
/>
{/* Messages */}
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto px-3 py-2"
>
{sessionMessages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center">
<div className="text-muted-foreground text-sm">
Start a conversation
</div>
</div>
) : (
<div className="space-y-3">
{sessionMessages.map((msg) => (
<SimpleMessageRenderer key={msg.info.id} message={msg} />
))}
<div ref={messagesEndRef} />
</div>
)}
</div>
{/* Input */}
<div className="border-t border-border p-3 bg-background">
<div className="flex gap-2">
<textarea
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
disabled={isSending}
rows={1}
className="flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
style={{ minHeight: '40px', maxHeight: '120px' }}
/>
{isStreaming ? (
<button
onClick={handleAbort}
className="px-3 py-2 rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 transition-colors"
aria-label="Stop"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<rect x="3" y="3" width="10" height="10" rx="1" />
</svg>
</button>
) : (
<button
onClick={handleSend}
disabled={!inputValue.trim() || isSending}
className="px-3 py-2 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
aria-label="Send"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M1 8l14-7-4 7 4 7L1 8z" />
</svg>
</button>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,71 @@
import React from 'react';
import type { Session } from '@opencode-ai/sdk';
interface SessionItemProps {
session: Session;
isActive: boolean;
isStreaming?: boolean;
onClick: () => void;
}
const formatRelativeTime = (timestamp: number | undefined): string => {
if (!timestamp) return '';
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
};
export function SessionItem({ session, isActive, isStreaming, onClick }: SessionItemProps) {
const title = session.title || 'New Session';
const time = formatRelativeTime(session.time?.created);
return (
<button
onClick={onClick}
className={`w-full text-left px-3 py-2.5 flex items-start gap-2 transition-colors ${
isActive
? 'bg-primary/10 border-l-2 border-primary'
: 'hover:bg-muted/50 border-l-2 border-transparent'
}`}
>
{/* Activity indicator */}
<div className="mt-1.5 flex-shrink-0">
{isStreaming ? (
<span className="block w-2 h-2 rounded-full bg-green-500 animate-pulse" />
) : (
<span className={`block w-2 h-2 rounded-full ${isActive ? 'bg-primary' : 'bg-muted-foreground/30'}`} />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{title}</div>
<div className="text-xs text-muted-foreground flex items-center gap-2">
<span>{time}</span>
{session.summary && (
<span className="text-[10px]">
{session.summary.additions !== undefined && (
<span className="text-green-600">+{session.summary.additions}</span>
)}
{session.summary.deletions !== undefined && (
<span className="text-red-500 ml-1">-{session.summary.deletions}</span>
)}
</span>
)}
</div>
</div>
</button>
);
}
@@ -0,0 +1,86 @@
import React from 'react';
import { useSessionStore } from '@openchamber/ui/stores/useSessionStore';
import { useNavigation } from '../hooks/useNavigation';
import { VSCodeHeader } from './VSCodeHeader';
import { SessionItem } from './SessionItem';
export function SessionsListView() {
const { sessions, currentSessionId, setCurrentSession, createSession, streamingMessageIds } = useSessionStore();
const { goToChat } = useNavigation();
const [isCreating, setIsCreating] = React.useState(false);
const sortedSessions = React.useMemo(() => {
return [...sessions].sort((a, b) => (b.time?.created || 0) - (a.time?.created || 0));
}, [sessions]);
const handleSelectSession = async (sessionId: string) => {
await setCurrentSession(sessionId);
goToChat();
};
const handleNewSession = async () => {
if (isCreating) return;
setIsCreating(true);
try {
await createSession();
goToChat();
} catch (error) {
console.error('Failed to create session:', error);
} finally {
setIsCreating(false);
}
};
const newButton = (
<button
onClick={handleNewSession}
disabled={isCreating}
className="p-1.5 rounded hover:bg-muted transition-colors disabled:opacity-50"
aria-label="New session"
>
{isCreating ? (
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" />
</svg>
)}
</button>
);
return (
<div className="flex flex-col h-full">
<VSCodeHeader title="Sessions" actions={newButton} />
<div className="flex-1 overflow-y-auto">
{sortedSessions.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full p-4 text-center">
<div className="text-muted-foreground text-sm mb-4">No sessions yet</div>
<button
onClick={handleNewSession}
disabled={isCreating}
className="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{isCreating ? 'Creating...' : 'Start New Chat'}
</button>
</div>
) : (
<div className="divide-y divide-border">
{sortedSessions.map((session) => (
<SessionItem
key={session.id}
session={session}
isActive={session.id === currentSessionId}
isStreaming={streamingMessageIds.has(session.id)}
onClick={() => handleSelectSession(session.id)}
/>
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,88 @@
import React from 'react';
import type { Message, Part } from '@opencode-ai/sdk';
interface SimpleMessageRendererProps {
message: { info: Message; parts: Part[] };
}
export function SimpleMessageRenderer({ message }: SimpleMessageRendererProps) {
const { info, parts } = message;
const isUser = info.role === 'user';
// Extract text content from parts
const textContent = parts
.filter((part): part is Part & { type: 'text' } => part.type === 'text')
.map((part) => part.text)
.join('\n');
// Check for tool calls
const toolParts = parts.filter((part) => part.type === 'tool-invocation' || part.type === 'tool-result');
return (
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${
isUser
? 'bg-primary text-primary-foreground'
: 'bg-muted text-foreground'
}`}
>
{/* Role indicator for assistant */}
{!isUser && (
<div className="text-xs font-medium text-muted-foreground mb-1">
Assistant
</div>
)}
{/* Text content */}
{textContent && (
<div className="whitespace-pre-wrap break-words">{textContent}</div>
)}
{/* Tool activity indicator */}
{toolParts.length > 0 && (
<div className="mt-2 pt-2 border-t border-border/50">
{toolParts.map((part, idx) => (
<ToolPartRenderer key={idx} part={part} />
))}
</div>
)}
{/* Empty message placeholder */}
{!textContent && toolParts.length === 0 && (
<div className="text-muted-foreground italic">...</div>
)}
</div>
</div>
);
}
function ToolPartRenderer({ part }: { part: Part }) {
if (part.type === 'tool-invocation') {
const toolName = part.toolInvocation?.toolName || 'tool';
const state = part.toolInvocation?.state || 'pending';
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{state === 'pending' || state === 'streaming' ? (
<span className="inline-block w-3 h-3 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : state === 'result' ? (
<span className="text-green-500"></span>
) : (
<span className="text-red-500"></span>
)}
<span className="font-mono">{toolName}</span>
</div>
);
}
if (part.type === 'tool-result') {
return (
<div className="text-xs text-muted-foreground font-mono truncate">
Result: {typeof part.result === 'string' ? part.result.slice(0, 50) : '...'}
</div>
);
}
return null;
}
@@ -0,0 +1,28 @@
import React from 'react';
interface VSCodeHeaderProps {
title: string;
showBack?: boolean;
onBack?: () => void;
actions?: React.ReactNode;
}
export function VSCodeHeader({ title, showBack, onBack, actions }: VSCodeHeaderProps) {
return (
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-background/80 backdrop-blur-sm sticky top-0 z-10">
{showBack && (
<button
onClick={onBack}
className="p-1 -ml-1 rounded hover:bg-muted transition-colors"
aria-label="Go back"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M11 2L5 8l6 6" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
)}
<h1 className="flex-1 text-sm font-medium truncate">{title}</h1>
{actions && <div className="flex items-center gap-1">{actions}</div>}
</div>
);
}
@@ -0,0 +1,14 @@
import React from 'react';
import { useNavigation } from '../hooks/useNavigation';
import { SessionsListView } from './SessionsListView';
import { ChatPanel } from './ChatPanel';
export function VSCodeLayout() {
const { currentView } = useNavigation();
return (
<div className="h-full w-full bg-background text-foreground">
{currentView === 'sessions' ? <SessionsListView /> : <ChatPanel />}
</div>
);
}
@@ -0,0 +1,17 @@
import { create } from 'zustand';
export type ViewType = 'sessions' | 'chat';
interface NavigationState {
currentView: ViewType;
navigateTo: (view: ViewType) => void;
goToChat: () => void;
goToSessions: () => void;
}
export const useNavigation = create<NavigationState>((set) => ({
currentView: 'sessions',
navigateTo: (view) => set({ currentView: view }),
goToChat: () => set({ currentView: 'chat' }),
goToSessions: () => set({ currentView: 'sessions' }),
}));
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OpenChamber</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
+281
View File
@@ -0,0 +1,281 @@
import { createVSCodeAPIs } from './api';
import { onThemeChange, sendBridgeMessage } from './api/bridge';
import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types';
import {
buildVSCodeThemeFromPalette,
readVSCodeThemePalette,
type VSCodeThemeKind,
type VSCodeThemePayload,
} from '@openchamber/ui/lib/theme/vscode/adapter';
type ConnectionStatus = 'connecting' | 'connected' | 'error' | 'disconnected';
declare global {
interface Window {
__OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs;
__VSCODE_CONFIG__?: {
apiUrl: string;
workspaceFolder: string;
theme: string;
connectionStatus: string;
};
__OPENCHAMBER_VSCODE_THEME__?: VSCodeThemePayload['theme'];
__OPENCHAMBER_CONNECTION__?: { status: ConnectionStatus; error?: string };
}
}
console.log('[OpenChamber] VS Code webview starting...');
console.log('[OpenChamber] Config:', window.__VSCODE_CONFIG__);
window.__OPENCHAMBER_RUNTIME_APIS__ = createVSCodeAPIs();
const bootstrapConnectionStatus = () => {
const initialStatus = (window.__VSCODE_CONFIG__?.connectionStatus as ConnectionStatus | undefined) || 'connecting';
window.__OPENCHAMBER_CONNECTION__ = { status: initialStatus };
};
bootstrapConnectionStatus();
const handleConnectionMessage = (event: MessageEvent) => {
const msg = event.data;
if (msg?.type === 'connectionStatus') {
const payload: ConnectionStatus = msg.status;
const error: string | undefined = msg.error;
window.__OPENCHAMBER_CONNECTION__ = { status: payload, error };
window.dispatchEvent(new CustomEvent('openchamber:connection-status', { detail: { status: payload, error } }));
}
};
window.addEventListener('message', handleConnectionMessage);
const applyInitialTheme = (theme: { metadata?: { variant?: string }; colors?: { surface?: { background?: string; foreground?: string } } }) => {
if (typeof document === 'undefined' || !theme) return;
const variant = theme.metadata?.variant === 'dark' ? 'dark' : 'light';
const root = document.documentElement;
root.classList.remove('light', 'dark');
root.classList.add(variant);
const background = theme.colors?.surface?.background;
if (background) {
document.body.style.backgroundColor = background;
let meta = document.querySelector('meta[name="theme-color"]') as HTMLMetaElement | null;
if (!meta) {
meta = document.createElement('meta');
meta.setAttribute('name', 'theme-color');
document.head.appendChild(meta);
}
meta.setAttribute('content', background);
}
};
const emitVSCodeTheme = (preferredKind?: VSCodeThemeKind) => {
const palette = readVSCodeThemePalette(preferredKind);
if (!palette) {
return;
}
const theme = buildVSCodeThemeFromPalette(palette);
window.__OPENCHAMBER_VSCODE_THEME__ = theme;
applyInitialTheme(theme);
window.dispatchEvent(new CustomEvent<VSCodeThemePayload>('openchamber:vscode-theme', {
detail: { theme, palette },
}));
};
emitVSCodeTheme(window.__VSCODE_CONFIG__?.theme as VSCodeThemeKind | undefined);
onThemeChange((payload) => {
const kind = (typeof payload === 'string'
? payload
: typeof payload === 'object' && payload
? payload.kind
: undefined) as VSCodeThemeKind | undefined;
emitVSCodeTheme(kind);
});
const workspaceFolder = window.__VSCODE_CONFIG__?.workspaceFolder;
if (workspaceFolder) {
window.__OPENCHAMBER_HOME__ = workspaceFolder;
try {
window.localStorage.setItem('lastDirectory', 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) => {
try {
return typeof input === 'string' ? new URL(input, window.location.origin) : new URL(input.toString());
} catch {
return null;
}
};
const apiBaseUrl = window.__VSCODE_CONFIG__?.apiUrl?.replace(/\/+$/, '') || 'http://localhost:47339';
const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
const pathname = url.pathname;
// Health endpoints: always return OK to avoid blocking VS Code UX
if (pathname === '/health' || pathname === '/api/health') {
return new Response(JSON.stringify({ status: 'ok', isOpenCodeReady: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
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 });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname.startsWith('/api/fs/search')) {
const directory = url.searchParams.get('directory') || '';
const query = url.searchParams.get('q') || '';
const limitParam = url.searchParams.get('limit');
const limit = limitParam ? Number(limitParam) : undefined;
const resolvedLimit = Number.isFinite(limit) ? limit : undefined;
const data = await sendBridgeMessage('api:fs:search', { directory, query, limit: resolvedLimit });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname.startsWith('/api/fs/mkdir')) {
const body = init?.body ? JSON.parse(init.body as string) : {};
const data = await sendBridgeMessage('api:fs:mkdir', { path: body.path });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname.startsWith('/api/fs/home')) {
const data = await sendBridgeMessage('api:fs/home');
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname.startsWith('/api/vscode/pick-files')) {
const data = await sendBridgeMessage('api:files/pick');
return new Response(JSON.stringify(data), { status: 200, 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');
return new Response(JSON.stringify(settings), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
const body = init?.body ? JSON.parse(init.body as string) : {};
const updated = await sendBridgeMessage('api:config/settings:save', body);
return new Response(JSON.stringify(updated), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname.startsWith('/api/config/reload')) {
await sendBridgeMessage('api:config/reload');
return new Response(JSON.stringify({ restarted: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname.startsWith('/api/openchamber/models-metadata')) {
try {
const data = await sendBridgeMessage('api:models/metadata');
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (error) {
console.warn('[OpenChamber] Failed to fetch models metadata via bridge, returning empty set:', error);
return new Response(JSON.stringify({}), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
}
if (pathname === '/auth/session') {
// VS Code host is trusted; mirror web server shape to keep UI logic happy
const body = {
authenticated: true,
requireSetup: false,
authenticatedAt: Date.now(),
};
return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname.startsWith('/api/opencode/directory')) {
const body = init?.body ? JSON.parse(init.body as string) : {};
const result = await sendBridgeMessage('api:opencode/directory', { path: body.path });
return new Response(JSON.stringify(result), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
return null;
};
const originalFetch = window.fetch.bind(window);
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const targetUrl = typeof input === 'string' || input instanceof URL ? normalizeUrl(input) : normalizeUrl((input as Request).url);
const method = (init?.method || (input instanceof Request ? input.method : 'GET')).toUpperCase();
const pathname = targetUrl?.pathname || '';
const normalizedPathname = pathname.replace(/\/+/, '/');
if (targetUrl && normalizedPathname === '/health') {
return new Response(JSON.stringify({ status: 'ok', isOpenCodeReady: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (targetUrl && targetUrl.pathname.startsWith('/api/')) {
const localResponse = await handleLocalApiRequest(targetUrl, init);
if (localResponse) {
return localResponse;
}
const rewritten = new URL(targetUrl.href);
rewritten.pathname = targetUrl.pathname.replace(/^\/api/, '');
const fetchTarget = `${apiBaseUrl}${rewritten.pathname}${rewritten.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);
}
return originalFetch(fetchTarget, init);
}
if (targetUrl && targetUrl.hostname.includes('models.dev')) {
try {
const data = await sendBridgeMessage('api:models/metadata');
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (error) {
console.warn('[OpenChamber] models.dev request failed via bridge, returning empty metadata:', error);
return new Response(JSON.stringify({}), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
}
return originalFetch(input as RequestInfo, init);
};
import('@openchamber/ui/main');
+183
View File
@@ -0,0 +1,183 @@
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 getWorkspaceFolder = () => window.__VSCODE_CONFIG__?.workspaceFolder || '';
interface MessageRecord {
info: Message;
parts: Part[];
}
interface ChatState {
// Client
client: OpencodeClient | null;
isConnected: boolean;
// Sessions
sessions: Session[];
currentSessionId: string | null;
isLoadingSessions: boolean;
// Messages
messages: Map<string, MessageRecord[]>;
isLoadingMessages: boolean;
isSending: boolean;
streamingSessionId: string | null;
// Actions
initialize: () => Promise<void>;
loadSessions: () => Promise<void>;
createSession: () => Promise<string | null>;
selectSession: (sessionId: string) => Promise<void>;
loadMessages: (sessionId: string) => Promise<void>;
sendMessage: (content: string) => Promise<void>;
abortMessage: () => Promise<void>;
}
export const useChatStore = create<ChatState>((set, get) => ({
client: null,
isConnected: false,
sessions: [],
currentSessionId: null,
isLoadingSessions: false,
messages: new Map(),
isLoadingMessages: false,
isSending: false,
streamingSessionId: null,
initialize: async () => {
const apiUrl = getApiUrl();
const client = createOpencodeClient({ baseUrl: apiUrl });
// Test connection
try {
await client.session.list({ query: { directory: getWorkspaceFolder() } });
set({ client, isConnected: true });
await get().loadSessions();
} catch (error) {
console.error('Failed to connect to OpenCode API:', error);
set({ client, isConnected: false });
}
},
loadSessions: async () => {
const { client } = get();
if (!client) return;
set({ isLoadingSessions: true });
try {
const response = await client.session.list({ query: { directory: getWorkspaceFolder() } });
const sessionsArray = Array.isArray(response.data) ? response.data : [];
const sessions = sessionsArray.sort(
(a, b) => (b.time?.created || 0) - (a.time?.created || 0)
);
set({ sessions, isLoadingSessions: false });
} catch (error) {
console.error('Failed to load sessions:', error);
set({ isLoadingSessions: false });
}
},
createSession: async () => {
const { client } = get();
if (!client) return null;
try {
const response = await client.session.create({ query: { directory: getWorkspaceFolder() }, body: {} });
const session = response.data;
if (!session) throw new Error('No session returned');
await get().loadSessions();
set({ currentSessionId: session.id });
return session.id;
} catch (error) {
console.error('Failed to create session:', error);
return null;
}
},
selectSession: async (sessionId: string) => {
set({ currentSessionId: sessionId });
await get().loadMessages(sessionId);
},
loadMessages: async (sessionId: string) => {
const { client, messages } = get();
if (!client) return;
set({ isLoadingMessages: true });
try {
const response = await client.session.messages({
path: { id: sessionId },
query: { directory: getWorkspaceFolder() }
});
const messageRecords: MessageRecord[] = (response.data || []).map((msg) => ({
info: msg.info,
parts: msg.parts || [],
}));
const newMessages = new Map(messages);
newMessages.set(sessionId, messageRecords);
set({ messages: newMessages, isLoadingMessages: false });
} catch (error) {
console.error('Failed to load messages:', error);
set({ isLoadingMessages: false });
}
},
sendMessage: async (content: string) => {
const { client, currentSessionId, messages } = get();
if (!client || !currentSessionId) return;
set({ isSending: true, streamingSessionId: currentSessionId });
try {
// Add user message optimistically
const userMessage: MessageRecord = {
info: {
id: `temp-${Date.now()}`,
sessionId: currentSessionId,
role: 'user',
parts: [{ type: 'text', text: content }],
time: { created: Date.now() },
} as Message,
parts: [{ type: 'text', text: content }],
};
const currentMessages = messages.get(currentSessionId) || [];
const newMessages = new Map(messages);
newMessages.set(currentSessionId, [...currentMessages, userMessage]);
set({ messages: newMessages });
// Send message via session.prompt
await client.session.prompt({
path: { id: currentSessionId },
query: { directory: getWorkspaceFolder() },
body: {
parts: [{ type: 'text', text: content }],
},
});
// Reload messages to get the actual response
await get().loadMessages(currentSessionId);
await get().loadSessions(); // Update session title if changed
} catch (error) {
console.error('Failed to send message:', error);
} finally {
set({ isSending: false, streamingSessionId: null });
}
},
abortMessage: async () => {
const { client, currentSessionId } = get();
if (!client || !currentSessionId) return;
try {
await client.session.abort({ path: { id: currentSessionId } });
} catch (error) {
console.error('Failed to abort:', error);
}
set({ isSending: false, streamingSessionId: null });
},
}));