2025-12-13 16:34:17 +02:00
|
|
|
import * as vscode from 'vscode';
|
2025-12-24 22:50:46 +02:00
|
|
|
import { handleBridgeMessage, type BridgeRequest, type BridgeResponse } from './bridge';
|
2025-12-13 16:34:17 +02:00
|
|
|
import { getThemeKindName } from './theme';
|
|
|
|
|
import type { OpenCodeManager, ConnectionStatus } from './opencode';
|
2025-12-15 02:29:39 +02:00
|
|
|
import { getWebviewShikiThemes } from './shikiThemes';
|
2026-01-03 22:11:16 +01:00
|
|
|
import { getWebviewHtml } from './webviewHtml';
|
2025-12-13 16:34:17 +02:00
|
|
|
|
|
|
|
|
export class ChatViewProvider implements vscode.WebviewViewProvider {
|
|
|
|
|
public static readonly viewType = 'openchamber.chatView';
|
|
|
|
|
|
|
|
|
|
private _view?: vscode.WebviewView;
|
2025-12-31 00:06:59 +02:00
|
|
|
|
|
|
|
|
public isVisible() {
|
|
|
|
|
return this._view?.visible ?? false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 03:31:07 +02:00
|
|
|
// Cache latest status/URL for when webview is resolved after connection is ready
|
|
|
|
|
private _cachedStatus: ConnectionStatus = 'connecting';
|
|
|
|
|
private _cachedError?: string;
|
2025-12-24 22:50:46 +02:00
|
|
|
private _sseCounter = 0;
|
|
|
|
|
private _sseStreams = new Map<string, AbortController>();
|
2025-12-13 16:34:17 +02:00
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
private readonly _context: vscode.ExtensionContext,
|
|
|
|
|
private readonly _extensionUri: vscode.Uri,
|
|
|
|
|
private readonly _openCodeManager?: OpenCodeManager
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
public resolveWebviewView(
|
|
|
|
|
webviewView: vscode.WebviewView
|
|
|
|
|
) {
|
|
|
|
|
this._view = webviewView;
|
|
|
|
|
|
|
|
|
|
const distUri = vscode.Uri.joinPath(this._extensionUri, 'dist');
|
|
|
|
|
|
|
|
|
|
webviewView.webview.options = {
|
|
|
|
|
enableScripts: true,
|
|
|
|
|
localResourceRoots: [this._extensionUri, distUri],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
webviewView.webview.html = this._getHtmlForWebview(webviewView.webview);
|
2025-12-15 02:29:39 +02:00
|
|
|
// Send theme payload (including optional Shiki theme JSON) after the webview is set up.
|
|
|
|
|
void this.updateTheme(vscode.window.activeColorTheme.kind);
|
2025-12-24 03:31:07 +02:00
|
|
|
|
|
|
|
|
// Send cached connection status and API URL (may have been set before webview was resolved)
|
|
|
|
|
this._sendCachedState();
|
2025-12-13 16:34:17 +02:00
|
|
|
|
|
|
|
|
webviewView.webview.onDidReceiveMessage(async (message: BridgeRequest) => {
|
|
|
|
|
if (message.type === 'restartApi') {
|
|
|
|
|
await this._openCodeManager?.restart();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-12-24 22:50:46 +02:00
|
|
|
|
|
|
|
|
if (message.type === 'api:sse:start') {
|
|
|
|
|
const response = await this._startSseProxy(message);
|
|
|
|
|
webviewView.webview.postMessage(response);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (message.type === 'api:sse:stop') {
|
|
|
|
|
const response = await this._stopSseProxy(message);
|
|
|
|
|
webviewView.webview.postMessage(response);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-13 16:34:17 +02:00
|
|
|
const response = await handleBridgeMessage(message, {
|
|
|
|
|
manager: this._openCodeManager,
|
|
|
|
|
context: this._context,
|
|
|
|
|
});
|
|
|
|
|
webviewView.webview.postMessage(response);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public updateTheme(kind: vscode.ColorThemeKind) {
|
|
|
|
|
if (this._view) {
|
|
|
|
|
const themeKind = getThemeKindName(kind);
|
2025-12-15 02:29:39 +02:00
|
|
|
void getWebviewShikiThemes().then((shikiThemes) => {
|
|
|
|
|
this._view?.webview.postMessage({
|
|
|
|
|
type: 'themeChange',
|
|
|
|
|
theme: { kind: themeKind, shikiThemes },
|
|
|
|
|
});
|
2025-12-13 16:34:17 +02:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public updateConnectionStatus(status: ConnectionStatus, error?: string) {
|
2025-12-24 03:31:07 +02:00
|
|
|
// Cache the latest state
|
|
|
|
|
this._cachedStatus = status;
|
|
|
|
|
this._cachedError = error;
|
|
|
|
|
|
|
|
|
|
// Send to webview if it exists
|
|
|
|
|
this._sendCachedState();
|
|
|
|
|
}
|
2025-12-30 16:32:27 +01:00
|
|
|
|
|
|
|
|
public addTextToInput(text: string) {
|
|
|
|
|
if (this._view) {
|
|
|
|
|
// Reveal the webview panel
|
|
|
|
|
this._view.show(true);
|
|
|
|
|
|
|
|
|
|
this._view.webview.postMessage({
|
|
|
|
|
type: 'command',
|
|
|
|
|
command: 'addToContext',
|
|
|
|
|
payload: { text }
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public createNewSessionWithPrompt(prompt: string) {
|
|
|
|
|
if (this._view) {
|
|
|
|
|
// Reveal the webview panel
|
|
|
|
|
this._view.show(true);
|
|
|
|
|
|
|
|
|
|
this._view.webview.postMessage({
|
|
|
|
|
type: 'command',
|
|
|
|
|
command: 'createSessionWithPrompt',
|
|
|
|
|
payload: { prompt }
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-31 16:16:03 +01:00
|
|
|
|
|
|
|
|
public createNewSession() {
|
|
|
|
|
if (this._view) {
|
|
|
|
|
// Reveal the webview panel
|
|
|
|
|
this._view.show(true);
|
|
|
|
|
|
|
|
|
|
this._view.webview.postMessage({
|
|
|
|
|
type: 'command',
|
|
|
|
|
command: 'newSession'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public showSettings() {
|
|
|
|
|
if (this._view) {
|
|
|
|
|
// Reveal the webview panel
|
|
|
|
|
this._view.show(true);
|
|
|
|
|
|
|
|
|
|
this._view.webview.postMessage({
|
|
|
|
|
type: 'command',
|
|
|
|
|
command: 'showSettings'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-24 03:31:07 +02:00
|
|
|
|
|
|
|
|
private _sendCachedState() {
|
|
|
|
|
if (!this._view) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this._view.webview.postMessage({
|
|
|
|
|
type: 'connectionStatus',
|
|
|
|
|
status: this._cachedStatus,
|
|
|
|
|
error: this._cachedError,
|
|
|
|
|
});
|
2025-12-24 22:50:46 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private _buildSseHeaders(extra?: Record<string, string>): Record<string, string> {
|
|
|
|
|
return {
|
|
|
|
|
Accept: 'text/event-stream',
|
|
|
|
|
'Cache-Control': 'no-cache',
|
|
|
|
|
Connection: 'keep-alive',
|
|
|
|
|
...(extra || {}),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private _collectHeaders(headers: Headers): Record<string, string> {
|
|
|
|
|
const result: Record<string, string> = {};
|
|
|
|
|
headers.forEach((value, key) => {
|
|
|
|
|
result[key] = value;
|
|
|
|
|
});
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async _startSseProxy(message: BridgeRequest): Promise<BridgeResponse> {
|
|
|
|
|
const { id, type, payload } = message;
|
|
|
|
|
const apiBaseUrl = this._openCodeManager?.getApiUrl();
|
|
|
|
|
|
|
|
|
|
const { path, headers } = (payload || {}) as { path?: string; headers?: Record<string, string> };
|
|
|
|
|
const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : '/event';
|
|
|
|
|
|
|
|
|
|
if (!apiBaseUrl) {
|
|
|
|
|
return {
|
|
|
|
|
id,
|
|
|
|
|
type,
|
|
|
|
|
success: true,
|
|
|
|
|
data: { status: 503, headers: { 'content-type': 'application/json' }, streamId: null },
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const streamId = `sse_${++this._sseCounter}_${Date.now()}`;
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
|
|
|
|
|
const base = `${apiBaseUrl.replace(/\/+$/, '')}/`;
|
|
|
|
|
const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString();
|
|
|
|
|
|
|
|
|
|
let response: Response;
|
|
|
|
|
try {
|
|
|
|
|
response = await fetch(targetUrl, {
|
|
|
|
|
method: 'GET',
|
|
|
|
|
headers: this._buildSseHeaders(headers || {}),
|
|
|
|
|
signal: controller.signal,
|
2025-12-13 16:34:17 +02:00
|
|
|
});
|
2025-12-24 22:50:46 +02:00
|
|
|
} catch (error) {
|
|
|
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
|
|
|
return {
|
|
|
|
|
id,
|
|
|
|
|
type,
|
|
|
|
|
success: true,
|
|
|
|
|
data: { status: 502, headers: { 'content-type': 'application/json' }, streamId: null, error: message },
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const responseHeaders = this._collectHeaders(response.headers);
|
|
|
|
|
const responseBody = response.body;
|
|
|
|
|
if (!response.ok || !responseBody) {
|
|
|
|
|
return {
|
|
|
|
|
id,
|
|
|
|
|
type,
|
|
|
|
|
success: true,
|
|
|
|
|
data: {
|
|
|
|
|
status: response.status,
|
|
|
|
|
headers: responseHeaders,
|
|
|
|
|
streamId: null,
|
|
|
|
|
error: `SSE failed: ${response.status}`,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this._sseStreams.set(streamId, controller);
|
|
|
|
|
|
|
|
|
|
(async () => {
|
|
|
|
|
try {
|
|
|
|
|
const reader = responseBody.getReader();
|
|
|
|
|
const decoder = new TextDecoder();
|
|
|
|
|
let sseBuffer = '';
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
while (true) {
|
|
|
|
|
const { done, value } = await reader.read();
|
|
|
|
|
if (done) break;
|
|
|
|
|
if (controller.signal.aborted) break;
|
|
|
|
|
if (value && value.length > 0) {
|
|
|
|
|
const chunk = decoder.decode(value, { stream: true });
|
|
|
|
|
if (!chunk) continue;
|
|
|
|
|
|
|
|
|
|
// Reduce webview message pressure by forwarding complete SSE blocks.
|
|
|
|
|
// The SDK SSE parser is block-based (\n\n delimited) and can consume
|
|
|
|
|
// partial chunks, but VS Code's postMessage channel can be a bottleneck.
|
|
|
|
|
sseBuffer += chunk;
|
|
|
|
|
const blocks = sseBuffer.split('\n\n');
|
|
|
|
|
sseBuffer = blocks.pop() ?? '';
|
|
|
|
|
if (blocks.length > 0) {
|
|
|
|
|
const joined = blocks.map((block) => `${block}\n\n`).join('');
|
|
|
|
|
this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const tail = decoder.decode();
|
|
|
|
|
if (tail) {
|
|
|
|
|
sseBuffer += tail;
|
|
|
|
|
}
|
|
|
|
|
if (sseBuffer) {
|
|
|
|
|
this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: sseBuffer });
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
try {
|
|
|
|
|
reader.releaseLock();
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this._view?.webview.postMessage({ type: 'api:sse:end', streamId });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (!controller.signal.aborted) {
|
|
|
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
|
|
|
this._view?.webview.postMessage({ type: 'api:sse:end', streamId, error: message });
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
this._sseStreams.delete(streamId);
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id,
|
|
|
|
|
type,
|
|
|
|
|
success: true,
|
|
|
|
|
data: {
|
|
|
|
|
status: response.status,
|
|
|
|
|
headers: responseHeaders,
|
|
|
|
|
streamId,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async _stopSseProxy(message: BridgeRequest): Promise<BridgeResponse> {
|
|
|
|
|
const { id, type, payload } = message;
|
|
|
|
|
const { streamId } = (payload || {}) as { streamId?: string };
|
|
|
|
|
if (typeof streamId === 'string' && streamId.length > 0) {
|
|
|
|
|
const controller = this._sseStreams.get(streamId);
|
|
|
|
|
if (controller) {
|
|
|
|
|
controller.abort();
|
|
|
|
|
this._sseStreams.delete(streamId);
|
|
|
|
|
}
|
2025-12-13 16:34:17 +02:00
|
|
|
}
|
2025-12-24 22:50:46 +02:00
|
|
|
return { id, type, success: true, data: { stopped: true } };
|
2025-12-13 16:34:17 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private _getHtmlForWebview(webview: vscode.Webview) {
|
|
|
|
|
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
2025-12-24 03:31:07 +02:00
|
|
|
// Use cached values which are updated by onStatusChange callback
|
|
|
|
|
const initialStatus = this._cachedStatus;
|
|
|
|
|
const cliAvailable = this._openCodeManager?.isCliAvailable() ?? false;
|
2025-12-13 16:34:17 +02:00
|
|
|
|
2026-01-03 22:11:16 +01:00
|
|
|
return getWebviewHtml({
|
|
|
|
|
webview,
|
|
|
|
|
extensionUri: this._extensionUri,
|
|
|
|
|
workspaceFolder,
|
|
|
|
|
initialStatus,
|
|
|
|
|
cliAvailable,
|
2025-12-24 03:31:07 +02:00
|
|
|
});
|
2025-12-13 16:34:17 +02:00
|
|
|
}
|
|
|
|
|
}
|