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,11 @@
|
||||
.vscode/**
|
||||
node_modules/**
|
||||
src/**
|
||||
webview/**
|
||||
.gitignore
|
||||
tsconfig.json
|
||||
tsconfig.webview.json
|
||||
vite.config.ts
|
||||
*.map
|
||||
**/*.ts
|
||||
!dist/**
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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 |
@@ -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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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]',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
@@ -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' }),
|
||||
}));
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
@@ -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 });
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user