feat: vscode extension (#59)

* feat: add initial VS Code extension plan and implementation tasks

* feat(vscode): added initial version of an Openchamber VSCode extension

* feat(vscode): enhance VS Code extension with theme integration and session management

* feat(vscode): implement connection status handling and overlay in VSCode layout

* feat: move extension to secondary sidebar

* chore: upgrade @opencode-ai/sdk to 1.0.150

* vscode: editor bridge, file picker, click-to-open in tool parts

* vscode: layout session lifecycle, theme sync, typography overrides

* ui: compact mode for vscode, model search, autocomplete width fixes

* perf: scroll force flag, raf placeholder, git polling backoff

* ui: tool output styling, markdown code block fix, gitignore

* refactor: update typography handling for VSCode runtime, remove unused styles

* docs: update README with VS Code extension details and add extension image

* docs: update changelog with new features and performance improvements
This commit is contained in:
Bohdan Triapitsyn
2025-12-13 16:34:17 +02:00
committed by GitHub
parent 610ccf4c62
commit bb72c0fb0c
76 changed files with 6097 additions and 296 deletions
+114
View File
@@ -0,0 +1,114 @@
declare const acquireVsCodeApi: () => {
postMessage: (message: unknown) => void;
getState: () => unknown;
setState: (state: unknown) => void;
};
interface VSCodeAPI {
postMessage: (message: unknown) => void;
}
let vscodeApi: VSCodeAPI | null = null;
function getVSCodeAPI(): VSCodeAPI {
if (!vscodeApi) {
vscodeApi = acquireVsCodeApi();
}
return vscodeApi;
}
// Export vscode API for direct use
export const vscode = {
postMessage: (message: unknown) => getVSCodeAPI().postMessage(message),
};
interface BridgeRequest {
id: string;
type: string;
payload?: unknown;
}
interface BridgeResponse {
id: string;
type: string;
success: boolean;
data?: unknown;
error?: string;
}
const pendingRequests = new Map<string, {
resolve: (value: unknown) => void;
reject: (reason: Error) => void;
}>();
let requestIdCounter = 0;
window.addEventListener('message', (event: MessageEvent<BridgeResponse>) => {
const response = event.data;
if (!response || typeof response.id !== 'string') return;
const pending = pendingRequests.get(response.id);
if (pending) {
pendingRequests.delete(response.id);
if (response.success) {
pending.resolve(response.data);
} else {
pending.reject(new Error(response.error || 'Unknown error'));
}
}
});
export function sendBridgeMessage<T = unknown>(type: string, payload?: unknown): Promise<T> {
return new Promise((resolve, reject) => {
const id = `req_${++requestIdCounter}_${Date.now()}`;
const request: BridgeRequest = { id, type, payload };
pendingRequests.set(id, {
resolve: resolve as (value: unknown) => void,
reject,
});
setTimeout(() => {
if (pendingRequests.has(id)) {
pendingRequests.delete(id);
reject(new Error(`Request ${type} timed out`));
}
}, 30000);
getVSCodeAPI().postMessage(request);
});
}
type CommandHandler = (payload: unknown) => void;
const commandHandlers = new Map<string, CommandHandler>();
export function onCommand(command: string, handler: CommandHandler): () => void {
commandHandlers.set(command, handler);
return () => commandHandlers.delete(command);
}
window.addEventListener('message', (event: MessageEvent) => {
const message = event.data;
if (message?.type === 'command' && message.command) {
const handler = commandHandlers.get(message.command);
if (handler) {
handler(message.payload);
}
}
});
type ThemeChangePayload = 'light' | 'dark' | { kind?: 'light' | 'dark' | 'high-contrast' };
type ThemeChangeHandler = (theme: ThemeChangePayload) => void;
let themeChangeHandler: ThemeChangeHandler | null = null;
export function onThemeChange(handler: ThemeChangeHandler): () => void {
themeChangeHandler = handler;
return () => { themeChangeHandler = null; };
}
window.addEventListener('message', (event: MessageEvent) => {
const message = event.data;
if (message?.type === 'themeChange' && themeChangeHandler) {
themeChangeHandler(message.theme);
}
});
+12
View File
@@ -0,0 +1,12 @@
import { sendBridgeMessage } from './bridge';
import type { EditorAPI } from '@openchamber/ui/lib/api/types';
export const createVSCodeEditorAPI = (): EditorAPI => ({
openFile: async (path: string, line?: number, column?: number) => {
await sendBridgeMessage('editor:openFile', { path, line, column });
},
openDiff: async (original: string, modified: string, label?: string) => {
await sendBridgeMessage('editor:openDiff', { original, modified, label });
},
});
+71
View File
@@ -0,0 +1,71 @@
import type { DirectoryListResult, FileSearchQuery, FileSearchResult, FilesAPI } from '@openchamber/ui/lib/api/types';
// Use same endpoints as web - fetch interceptor handles URL rewriting
const normalizePath = (path: string): string => path.replace(/\\/g, '/');
export const createVSCodeFilesAPI = (): FilesAPI => ({
async listDirectory(path: string): Promise<DirectoryListResult> {
const target = normalizePath(path);
const response = await fetch('/api/fs/list', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: target }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to list directory');
}
return response.json();
},
async search(payload: FileSearchQuery): Promise<FileSearchResult[]> {
const response = await fetch('/api/fs/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
directory: normalizePath(payload.directory),
query: payload.query,
maxResults: payload.maxResults,
}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to search files');
}
const results = (await response.json()) as unknown;
if (!Array.isArray(results)) {
return [];
}
return results
.filter((item): item is FileSearchResult => !!item && typeof item === 'object' && typeof (item as { path?: string }).path === 'string')
.map((item) => ({
path: normalizePath((item as FileSearchResult).path),
score: (item as FileSearchResult).score,
preview: (item as FileSearchResult).preview,
}));
},
async createDirectory(path: string): Promise<{ success: boolean; path: string }> {
const target = normalizePath(path);
const response = await fetch('/api/fs/mkdir', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: target }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to create directory');
}
const result = await response.json();
return {
success: Boolean(result?.success),
path: typeof result?.path === 'string' ? normalizePath(result.path) : target,
};
},
});
+63
View File
@@ -0,0 +1,63 @@
import type { RuntimeAPIs, TerminalAPI, GitAPI, NotificationsAPI } from '@openchamber/ui/lib/api/types';
import { createVSCodeFilesAPI } from './files';
import { createVSCodeSettingsAPI } from './settings';
import { createVSCodePermissionsAPI } from './permissions';
import { createVSCodeToolsAPI } from './tools';
import { createVSCodeEditorAPI } from './editor';
// Stub APIs return sensible defaults instead of throwing
const createStubTerminalAPI = (): TerminalAPI => ({
createSession: async () => ({ sessionId: '', cols: 80, rows: 24 }),
connect: () => ({ close: () => {} }),
sendInput: async () => {},
resize: async () => {},
close: async () => {},
});
const createStubGitAPI = (): GitAPI => ({
checkIsGitRepository: async () => false,
getGitStatus: async () => ({ current: '', tracking: null, ahead: 0, behind: 0, files: [], isClean: true }),
getGitDiff: async () => ({ diff: '' }),
getGitFileDiff: async () => ({ original: '', modified: '', path: '' }),
revertGitFile: async () => {},
isLinkedWorktree: async () => false,
getGitBranches: async () => ({ all: [], current: '', branches: {} }),
deleteGitBranch: async () => ({ success: false }),
deleteRemoteBranch: async () => ({ success: false }),
generateCommitMessage: async () => ({ message: { subject: '', highlights: [] } }),
listGitWorktrees: async () => [],
addGitWorktree: async () => ({ success: false, path: '', branch: '' }),
removeGitWorktree: async () => ({ success: false }),
ensureOpenChamberIgnored: async () => {},
createGitCommit: async () => ({ success: false, commit: '', branch: '', summary: { changes: 0, insertions: 0, deletions: 0 } }),
gitPush: async () => ({ success: false, pushed: [], repo: '', ref: null }),
gitPull: async () => ({ success: false, summary: { changes: 0, insertions: 0, deletions: 0 }, files: [], insertions: 0, deletions: 0 }),
gitFetch: async () => ({ success: false }),
checkoutBranch: async () => ({ success: false, branch: '' }),
createBranch: async () => ({ success: false, branch: '' }),
getGitLog: async () => ({ all: [], latest: null, total: 0 }),
getCommitFiles: async () => ({ files: [] }),
getCurrentGitIdentity: async () => null,
setGitIdentity: async () => ({ success: false, profile: { id: '', name: '', userName: '', userEmail: '' } }),
getGitIdentities: async () => [],
createGitIdentity: async (p) => p,
updateGitIdentity: async (_, p) => p,
deleteGitIdentity: async () => {},
});
const createStubNotificationsAPI = (): NotificationsAPI => ({
notifyAgentCompletion: async () => true,
canNotify: () => true,
});
export const createVSCodeAPIs = (): RuntimeAPIs => ({
runtime: { platform: 'vscode', isDesktop: false, isVSCode: true, label: 'VS Code Extension' },
terminal: createStubTerminalAPI(),
git: createStubGitAPI(),
files: createVSCodeFilesAPI(),
settings: createVSCodeSettingsAPI(),
permissions: createVSCodePermissionsAPI(),
notifications: createStubNotificationsAPI(),
tools: createVSCodeToolsAPI(),
editor: createVSCodeEditorAPI(),
});
@@ -0,0 +1,16 @@
import type { DirectoryPermissionRequest, PermissionsAPI, StartAccessingResult } from '@openchamber/ui/lib/api/types';
export const createVSCodePermissionsAPI = (): PermissionsAPI => ({
async requestDirectoryAccess(request: DirectoryPermissionRequest) {
// VS Code handles permissions via workspace
return { success: true, path: request.path };
},
async startAccessingDirectory(path: string): Promise<StartAccessingResult> {
void path;
return { success: true };
},
async stopAccessingDirectory(path: string): Promise<StartAccessingResult> {
void path;
return { success: true };
},
});
+70
View File
@@ -0,0 +1,70 @@
import type { SettingsAPI, SettingsLoadResult, SettingsPayload } from '@openchamber/ui/lib/api/types';
// Use same endpoints as web - fetch interceptor handles URL rewriting
const SETTINGS_ENDPOINT = '/api/config/settings';
const RELOAD_ENDPOINT = '/api/config/reload';
const sanitizePayload = (data: unknown): SettingsPayload => {
if (!data || typeof data !== 'object') {
return {};
}
return data as SettingsPayload;
};
export const createVSCodeSettingsAPI = (): SettingsAPI => ({
async load(): Promise<SettingsLoadResult> {
const response = await fetch(SETTINGS_ENDPOINT, {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
// Fallback to VS Code config
return {
settings: {
themeVariant: window.__VSCODE_CONFIG__?.theme === 'light' ? 'light' : 'dark',
lastDirectory: window.__VSCODE_CONFIG__?.workspaceFolder || '',
},
source: 'web',
};
}
const payload = sanitizePayload(await response.json().catch(() => ({})));
return {
settings: {
...payload,
// Override with VS Code settings
lastDirectory: window.__VSCODE_CONFIG__?.workspaceFolder || payload.lastDirectory || '',
},
source: 'web',
};
},
async save(changes: Partial<SettingsPayload>): Promise<SettingsPayload> {
const response = await fetch(SETTINGS_ENDPOINT, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(changes),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to save settings');
}
const payload = sanitizePayload(await response.json().catch(() => ({})));
return payload;
},
async restartOpenCode(): Promise<{ restarted: boolean }> {
const response = await fetch(RELOAD_ENDPOINT, { method: 'POST' });
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to restart OpenCode');
}
return { restarted: true };
},
});
+22
View File
@@ -0,0 +1,22 @@
import type { ToolsAPI } from '@openchamber/ui/lib/api/types';
// Use same endpoint as web - fetch interceptor handles URL rewriting
export const createVSCodeToolsAPI = (): ToolsAPI => ({
async getAvailableTools(): Promise<string[]> {
const response = await fetch('/api/experimental/tool/ids');
if (!response.ok) {
throw new Error(`Tools API returned ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (!Array.isArray(data)) {
throw new Error('Tools API returned invalid data format');
}
return data
.filter((tool: unknown): tool is string => typeof tool === 'string' && tool !== 'invalid')
.sort();
},
});