diff --git a/vscode-extension/openclaude-vscode/package.json b/vscode-extension/openclaude-vscode/package.json
index e66a82c4..9022fa17 100644
--- a/vscode-extension/openclaude-vscode/package.json
+++ b/vscode-extension/openclaude-vscode/package.json
@@ -2,7 +2,7 @@
"name": "openclaude-vscode",
"displayName": "OpenClaude",
"description": "Practical VS Code companion for OpenClaude with project-aware launch behavior and a real Control Center.",
- "version": "0.1.1",
+ "version": "0.2.0",
"publisher": "devnull-bootloader",
"engines": {
"vscode": "^1.95.0"
@@ -19,7 +19,12 @@
"onCommand:openclaude.openSetupDocs",
"onCommand:openclaude.openWorkspaceProfile",
"onCommand:openclaude.openControlCenter",
- "onView:openclaude.controlCenter"
+ "onCommand:openclaude.newChat",
+ "onCommand:openclaude.openChat",
+ "onCommand:openclaude.resumeSession",
+ "onCommand:openclaude.abortChat",
+ "onView:openclaude.controlCenter",
+ "onView:openclaude.chat"
],
"main": "./src/extension.js",
"files": [
@@ -28,6 +33,7 @@
"src/extension.js",
"src/presentation.js",
"src/state.js",
+ "src/chat/**",
"themes/**"
],
"contributes": {
@@ -61,6 +67,26 @@
"command": "openclaude.openControlCenter",
"title": "OpenClaude: Open Control Center",
"category": "OpenClaude"
+ },
+ {
+ "command": "openclaude.newChat",
+ "title": "OpenClaude: New Chat",
+ "category": "OpenClaude"
+ },
+ {
+ "command": "openclaude.openChat",
+ "title": "OpenClaude: Open Chat Panel",
+ "category": "OpenClaude"
+ },
+ {
+ "command": "openclaude.resumeSession",
+ "title": "OpenClaude: Resume Session",
+ "category": "OpenClaude"
+ },
+ {
+ "command": "openclaude.abortChat",
+ "title": "OpenClaude: Abort Generation",
+ "category": "OpenClaude"
}
],
"viewsContainers": {
@@ -74,6 +100,11 @@
},
"views": {
"openclaude": [
+ {
+ "id": "openclaude.chat",
+ "name": "Chat",
+ "type": "webview"
+ },
{
"id": "openclaude.controlCenter",
"name": "Control Center",
@@ -81,6 +112,13 @@
}
]
},
+ "keybindings": [
+ {
+ "command": "openclaude.openChat",
+ "key": "ctrl+shift+l",
+ "mac": "cmd+shift+l"
+ }
+ ],
"configuration": {
"title": "OpenClaude",
"properties": {
@@ -98,6 +136,18 @@
"type": "boolean",
"default": false,
"description": "Optionally set CLAUDE_CODE_USE_OPENAI=1 in launched OpenClaude terminals."
+ },
+ "openclaude.permissionMode": {
+ "type": "string",
+ "default": "acceptEdits",
+ "enum": ["default", "acceptEdits", "bypassPermissions", "plan"],
+ "enumDescriptions": [
+ "Prompt for permission on each tool use (requires manual approval)",
+ "Auto-approve file edits, prompt for other operations (recommended)",
+ "Auto-approve all operations without prompting",
+ "Read-only mode — no file modifications allowed"
+ ],
+ "description": "Permission mode for chat sessions. Controls which tool operations are auto-approved."
}
}
},
@@ -111,7 +161,7 @@
},
"scripts": {
"test": "node --test ./src/*.test.js",
- "lint": "node -e \"for (const file of require('node:fs').readdirSync('./src')) { if (file.endsWith('.js')) { require('node:child_process').execFileSync(process.execPath, ['--check', require('node:path').join('src', file)], { stdio: 'inherit' }); } }\"",
+ "lint": "node scripts/lint.js",
"package": "npx @vscode/vsce package --no-dependencies"
},
"keywords": [
diff --git a/vscode-extension/openclaude-vscode/scripts/lint.js b/vscode-extension/openclaude-vscode/scripts/lint.js
new file mode 100644
index 00000000..cc1fba9c
--- /dev/null
+++ b/vscode-extension/openclaude-vscode/scripts/lint.js
@@ -0,0 +1,17 @@
+const { readdirSync } = require('node:fs');
+const { execFileSync } = require('node:child_process');
+const { join } = require('node:path');
+
+function check(dir) {
+ for (const f of readdirSync(dir, { withFileTypes: true })) {
+ if (f.isDirectory()) {
+ check(join(dir, f.name));
+ } else if (f.name.endsWith('.js') && !f.name.endsWith('.test.js')) {
+ execFileSync(process.execPath, ['--check', join(dir, f.name)], {
+ stdio: 'inherit',
+ });
+ }
+ }
+}
+
+check('./src');
diff --git a/vscode-extension/openclaude-vscode/src/chat/chatProvider.js b/vscode-extension/openclaude-vscode/src/chat/chatProvider.js
new file mode 100644
index 00000000..5d71aabd
--- /dev/null
+++ b/vscode-extension/openclaude-vscode/src/chat/chatProvider.js
@@ -0,0 +1,676 @@
+/**
+ * chatProvider — WebviewViewProvider (sidebar) and WebviewPanel manager
+ * (editor tab) that wire ProcessManager events to the chat UI.
+ */
+
+const vscode = require('vscode');
+const crypto = require('crypto');
+const { ProcessManager } = require('./processManager');
+const { toViewModel } = require('./messageParser');
+const { renderChatHtml } = require('./chatRenderer');
+const { isAssistantMessage, isPartialMessage, isStreamEvent,
+ isContentBlockDelta, isContentBlockStart, isMessageStart,
+ isResultMessage, isControlRequest, isToolProgressMessage,
+ isStatusMessage, isRateLimitEvent, getTextContent,
+ getToolUseBlocks } = require('./protocol');
+
+async function openFileInEditor(filePath) {
+ try {
+ const uri = vscode.Uri.file(filePath);
+ const doc = await vscode.workspace.openTextDocument(uri);
+ await vscode.window.showTextDocument(doc, { preview: false });
+ } catch {
+ vscode.window.showWarningMessage(`Could not open file: ${filePath}`);
+ }
+}
+
+function getLaunchConfig() {
+ const cfg = vscode.workspace.getConfiguration('openclaude');
+ const command = cfg.get('launchCommand', 'openclaude');
+ const shimEnabled = cfg.get('useOpenAIShim', false);
+ const permissionMode = cfg.get('permissionMode', 'acceptEdits');
+ const env = {};
+ if (shimEnabled) env.CLAUDE_CODE_USE_OPENAI = '1';
+ const folders = vscode.workspace.workspaceFolders;
+ const cwd = folders && folders.length > 0 ? folders[0].uri.fsPath : undefined;
+ return { command, cwd, env, permissionMode };
+}
+
+class ChatController {
+ constructor(sessionManager) {
+ this._sessionManager = sessionManager;
+ this._process = null;
+ this._webviews = new Set();
+ this._accumulatedText = '';
+ this._toolUses = [];
+ this._messages = [];
+ this._currentSessionId = null;
+ this._streaming = false;
+ this._lastResult = null;
+ this._thinkingTokens = 0;
+ this._thinkingStartTime = null;
+ this._currentBlockType = null;
+
+ this._onDidChangeState = new vscode.EventEmitter();
+ this.onDidChangeState = this._onDidChangeState.event;
+ }
+
+ get sessionId() { return this._currentSessionId; }
+ get isStreaming() { return this._process && this._process.running; }
+ get sessionManager() { return this._sessionManager; }
+
+ registerWebview(webview) {
+ this._webviews.add(webview);
+ return { dispose: () => this._webviews.delete(webview) };
+ }
+
+ broadcast(msg) {
+ for (const wv of this._webviews) {
+ try { wv.postMessage(msg); } catch { /* webview might be disposed */ }
+ }
+ }
+
+ _broadcast(msg) {
+ this.broadcast(msg);
+ }
+
+ async startSession(opts = {}) {
+ this.stopSession();
+ this._accumulatedText = '';
+ this._toolUses = [];
+ // Only clear messages if this is a brand new session (not continuing)
+ if (!opts.continueSession && !opts.sessionId) {
+ this._messages = [];
+ }
+ this._currentSessionId = opts.sessionId || this._currentSessionId || null;
+
+ const { command, cwd, env, permissionMode } = getLaunchConfig();
+
+ this._process = new ProcessManager({
+ command,
+ cwd,
+ env,
+ sessionId: opts.sessionId,
+ continueSession: opts.continueSession || false,
+ model: opts.model,
+ permissionMode,
+ extraArgs: opts.extraArgs || [],
+ });
+
+ this._readyResolve = null;
+ this._readyPromise = new Promise(resolve => { this._readyResolve = resolve; });
+
+ this._process.onMessage((msg) => {
+ if (msg.type === 'system' && this._readyResolve) {
+ this._readyResolve();
+ this._readyResolve = null;
+ }
+ this._handleMessage(msg);
+ });
+ this._process.onError((err) => {
+ this._broadcast({ type: 'error', message: err.message || String(err) });
+ });
+ this._process.onExit(({ code }) => {
+ // Flush any remaining streamed text
+ if (this._streaming && this._accumulatedText) {
+ this._broadcast({ type: 'stream_end', text: this._accumulatedText, usage: null, final: true });
+ } else if (this._streaming) {
+ this._broadcast({ type: 'stream_end', text: '', usage: (this._lastResult || {}).usage || null, final: true });
+ }
+ this._streaming = false;
+ this._accumulatedText = '';
+ this._toolUses = [];
+ this._lastResult = null;
+ this._broadcast({
+ type: 'connected',
+ message: code === 0 ? 'Ready' : `Process exited (code ${code})`,
+ });
+ this._onDidChangeState.fire('idle');
+ });
+
+ try {
+ this._process.start();
+ this._broadcast({ type: 'connected', message: 'Connected' });
+ this._onDidChangeState.fire('connected');
+ } catch (err) {
+ this._broadcast({ type: 'error', message: `Failed to start: ${err.message}` });
+ }
+ }
+
+ stopSession() {
+ if (this._process) {
+ this._process.dispose();
+ this._process = null;
+ }
+ }
+
+ async sendMessage(text) {
+ // Keep the process alive for multi-turn — just send directly.
+ // The CLI maintains full session state (tools, history) across turns.
+ // Only start a new process if none exists or it died.
+ if (!this._process || !this._process.running) {
+ await this.startSession({
+ sessionId: this._currentSessionId || undefined,
+ });
+ }
+ await this._doSend(text);
+ }
+
+ async _doSend(text) {
+ if (!this._process) return;
+ // On first message after process start, wait for CLI to be ready.
+ // On subsequent messages, the process is already running and accepting input.
+ if (this._readyPromise) {
+ const grace = new Promise(resolve => setTimeout(resolve, 8000));
+ await Promise.race([this._readyPromise, grace]);
+ this._readyPromise = null;
+ }
+ this._accumulatedText = '';
+ this._toolUses = [];
+ try {
+ this._process.sendUserMessage(text);
+ this._messages.push({ role: 'user', text });
+ } catch (err) {
+ this._broadcast({ type: 'error', message: err.message });
+ }
+ }
+
+ abort() {
+ if (this._process) {
+ this._process.abort();
+ this._broadcast({ type: 'stream_end', text: this._accumulatedText, usage: null });
+ this._onDidChangeState.fire('idle');
+ }
+ }
+
+ sendPermissionResponse(requestId, action, toolUseId) {
+ if (!this._process) return;
+ if (action === 'deny') {
+ try {
+ this._process.write({
+ type: 'control_response',
+ response: {
+ subtype: 'error',
+ request_id: requestId,
+ error: 'User denied permission',
+ },
+ });
+ } catch (err) {
+ this._broadcast({ type: 'error', message: err.message });
+ }
+ return;
+ }
+ try {
+ this._process.sendControlResponse(requestId, {
+ toolUseID: toolUseId || undefined,
+ ...(action === 'allow-session' ? { remember: true } : {}),
+ });
+ } catch (err) {
+ this._broadcast({ type: 'error', message: err.message });
+ }
+ }
+
+ getMessages() { return this._messages; }
+
+ _handleMessage(msg) {
+ if (msg.session_id && !this._currentSessionId) {
+ this._currentSessionId = msg.session_id;
+ }
+
+ // System message — extract model and session info
+ if (msg.type === 'system') {
+ this._broadcast({
+ type: 'system_info',
+ model: msg.model || null,
+ sessionId: msg.session_id || msg.sessionId || null,
+ });
+ return;
+ }
+
+ // Control request (permission prompt) — check EARLY before other handlers
+ if (msg.type === 'control_request' || isControlRequest(msg)) {
+ const req = msg.request || {};
+ const { toolDisplayName, parseToolInput } = require('./messageParser');
+ this._broadcast({
+ type: 'permission_request',
+ requestId: msg.request_id,
+ toolName: req.tool_name || 'Unknown',
+ displayName: req.display_name || req.title || toolDisplayName(req.tool_name),
+ description: req.description || '',
+ inputPreview: parseToolInput(req.input),
+ toolUseId: req.tool_use_id || null,
+ });
+ return;
+ }
+
+ // Control cancel request
+ if (msg.type === 'control_cancel_request') {
+ return;
+ }
+
+ // Handle Anthropic raw stream events (the primary streaming mechanism)
+ if (isStreamEvent(msg)) {
+ this._handleStreamEvent(msg);
+ return;
+ }
+
+ // Assistant message — always mid-turn; true completion comes from 'result'
+ if (isAssistantMessage(msg)) {
+ const inner = msg.message || msg;
+ const text = getTextContent(inner);
+ const toolBlocks = getToolUseBlocks(inner);
+ const { toolDisplayName, toolIcon } = require('./messageParser');
+ const toolUseVms = toolBlocks.map(tu => ({
+ id: tu.id,
+ name: tu.name,
+ displayName: toolDisplayName(tu.name),
+ icon: toolIcon(tu.name),
+ inputPreview: typeof tu.input === 'string' ? tu.input : JSON.stringify(tu.input || ''),
+ input: tu.input,
+ status: 'running',
+ }));
+ this._messages.push({ role: 'assistant', text, toolUses: toolUseVms });
+ const usage = inner.usage || msg.usage || null;
+
+ // Finalize current text bubble but stay streaming — true completion
+ // is signaled by the 'result' message, not by the assistant message.
+ this._broadcast({ type: 'stream_end', text, usage, final: false });
+ this._accumulatedText = '';
+
+ if (toolBlocks.length > 0) {
+ for (const tu of toolBlocks) {
+ this._broadcast({
+ type: 'tool_input_ready',
+ toolUseId: tu.id,
+ input: tu.input,
+ name: tu.name,
+ });
+ }
+ this._broadcast({ type: 'status', content: 'Using tools...' });
+ }
+ return;
+ }
+
+ // User message with tool_use_result — this is the tool output
+ if (msg.type === 'user' && msg.message) {
+ const content = msg.message.content;
+ if (Array.isArray(content)) {
+ for (const block of content) {
+ if (block.type === 'tool_result' && block.tool_use_id) {
+ const resultText = typeof block.content === 'string'
+ ? block.content
+ : Array.isArray(block.content)
+ ? block.content.map(b => b.text || '').join('')
+ : '';
+ this._broadcast({
+ type: 'tool_result',
+ toolUseId: block.tool_use_id,
+ content: resultText.slice(0, 2000) || '(done)',
+ isError: block.is_error || false,
+ });
+ }
+ }
+ }
+ this._broadcast({ type: 'status', content: 'Thinking...' });
+ return;
+ }
+
+ // Session result — turn is complete. Go idle. The process stays alive
+ // in stream-json mode for multi-turn conversation.
+ if (msg.type === 'result' && msg.subtype) {
+ this._lastResult = msg;
+ // Only use result text if nothing was shown via streaming yet
+ const text = this._accumulatedText || '';
+ this._broadcast({ type: 'stream_end', text, usage: msg.usage || null, final: true });
+ // Show turn info: if the model stopped without using tools (num_turns=1),
+ // the user knows the model chose not to edit
+ if (msg.num_turns !== undefined) {
+ const reason = msg.stop_reason || 'done';
+ this._broadcast({
+ type: 'status',
+ content: msg.num_turns > 1
+ ? 'Completed (' + msg.num_turns + ' turns)'
+ : 'Ready',
+ });
+ }
+ this._accumulatedText = '';
+ this._toolUses = [];
+ this._streaming = false;
+ this._onDidChangeState.fire('idle');
+ return;
+ }
+
+ if (isToolProgressMessage(msg)) {
+ const vm = toViewModel(msg)[0];
+ this._broadcast({
+ type: 'tool_progress',
+ toolUseId: vm.toolUseId,
+ content: vm.content,
+ });
+ return;
+ }
+
+ if (isStatusMessage(msg)) {
+ const vm = toViewModel(msg)[0];
+ this._broadcast({ type: 'status', content: vm.content });
+ return;
+ }
+
+ if (isRateLimitEvent(msg)) {
+ const vm = toViewModel(msg)[0];
+ this._broadcast({ type: 'rate_limit', message: vm.message });
+ return;
+ }
+
+ // Log unhandled message types for debugging
+ if (msg.type && msg.type !== 'stream_event') {
+ this._broadcast({ type: 'status', content: '[debug] unhandled: ' + msg.type });
+ }
+ }
+
+ _handleStreamEvent(msg) {
+ const event = msg.event;
+ if (!event) return;
+
+ switch (event.type) {
+ case 'message_start':
+ this._accumulatedText = '';
+ this._thinkingTokens = 0;
+ this._currentBlockType = null;
+ if (!this._streaming) {
+ this._streaming = true;
+ this._toolUses = [];
+ this._onDidChangeState.fire('streaming');
+ }
+ this._broadcast({ type: 'stream_start' });
+ break;
+
+ case 'content_block_start':
+ if (event.content_block) {
+ this._currentBlockType = event.content_block.type;
+ if (event.content_block.type === 'tool_use') {
+ const tu = event.content_block;
+ this._toolUses.push({ id: tu.id, name: tu.name, input: '' });
+ const { toolDisplayName, toolIcon } = require('./messageParser');
+ this._broadcast({
+ type: 'tool_use',
+ toolUse: {
+ id: tu.id,
+ name: tu.name,
+ displayName: toolDisplayName(tu.name),
+ icon: toolIcon(tu.name),
+ inputPreview: '',
+ input: tu.input || null,
+ status: 'running',
+ },
+ });
+ } else if (event.content_block.type === 'thinking') {
+ this._thinkingTokens = 0;
+ this._thinkingStartTime = Date.now();
+ this._broadcast({ type: 'thinking_start' });
+ }
+ }
+ break;
+
+ case 'content_block_delta':
+ if (event.delta) {
+ if (event.delta.type === 'text_delta' && event.delta.text) {
+ this._accumulatedText += event.delta.text;
+ this._broadcast({ type: 'stream_delta', text: this._accumulatedText });
+ } else if (event.delta.type === 'thinking_delta') {
+ this._thinkingTokens += (event.delta.thinking || '').length;
+ const elapsed = Math.round((Date.now() - (this._thinkingStartTime || Date.now())) / 1000);
+ this._broadcast({
+ type: 'thinking_delta',
+ tokens: this._thinkingTokens,
+ elapsed,
+ });
+ } else if (event.delta.type === 'input_json_delta' && event.delta.partial_json) {
+ const lastTool = this._toolUses[this._toolUses.length - 1];
+ if (lastTool) {
+ lastTool.input = (lastTool.input || '') + event.delta.partial_json;
+ }
+ }
+ }
+ break;
+
+ case 'content_block_stop':
+ if (this._currentBlockType === 'thinking') {
+ this._broadcast({ type: 'thinking_end' });
+ }
+ this._currentBlockType = null;
+ break;
+
+ case 'message_delta':
+ break;
+
+ case 'message_stop':
+ break;
+
+ default:
+ break;
+ }
+ }
+
+ dispose() {
+ this.stopSession();
+ this._onDidChangeState.dispose();
+ }
+}
+
+class OpenClaudeChatViewProvider {
+ constructor(chatController) {
+ this._chatController = chatController;
+ this._webviewView = null;
+ }
+
+ resolveWebviewView(webviewView, _context, _token) {
+ this._webviewView = webviewView;
+ const webview = webviewView.webview;
+ webview.options = { enableScripts: true };
+
+ const registration = this._chatController.registerWebview(webview);
+ webviewView.onDidDispose(() => {
+ registration.dispose();
+ if (this._webviewView === webviewView) this._webviewView = null;
+ });
+
+ webview.html = this._getHtml(webview);
+ this._attachMessageHandler(webview);
+ }
+
+ _getHtml() {
+ const nonce = crypto.randomBytes(16).toString('hex');
+ return renderChatHtml({ nonce, platform: process.platform });
+ }
+
+ _attachMessageHandler(webview) {
+ webview.onDidReceiveMessage(async (msg) => {
+ switch (msg.type) {
+ case 'send_message':
+ this._chatController.sendMessage(msg.text);
+ break;
+ case 'abort':
+ this._chatController.abort();
+ break;
+ case 'new_session':
+ this._chatController.stopSession();
+ webview.postMessage({ type: 'session_cleared' });
+ break;
+ case 'resume_session':
+ this._chatController.stopSession();
+ webview.postMessage({ type: 'session_cleared' });
+ await this._loadAndDisplaySession(webview, msg.sessionId);
+ await this._chatController.startSession({ sessionId: msg.sessionId });
+ break;
+ case 'permission_response':
+ this._chatController.sendPermissionResponse(msg.requestId, msg.action, msg.toolUseId);
+ break;
+ case 'copy_code':
+ if (msg.text) await vscode.env.clipboard.writeText(msg.text);
+ break;
+ case 'open_file':
+ if (msg.path) await openFileInEditor(msg.path);
+ break;
+ case 'request_sessions':
+ await this._sendSessionList(webview);
+ break;
+ case 'restore_request':
+ this._restoreMessages(webview);
+ break;
+ case 'webview_ready':
+ break;
+ }
+ });
+ }
+
+ async _sendSessionList(webview) {
+ if (!this._chatController.sessionManager) return;
+ try {
+ const sessions = await this._chatController.sessionManager.listSessions();
+ webview.postMessage({ type: 'session_list', sessions });
+ } catch {
+ webview.postMessage({ type: 'session_list', sessions: [] });
+ }
+ }
+
+ _restoreMessages(webview) {
+ const messages = this._chatController.getMessages();
+ if (messages.length > 0) {
+ webview.postMessage({ type: 'restore_messages', messages });
+ }
+ }
+
+ async _loadAndDisplaySession(webview, sessionId) {
+ if (!this._chatController.sessionManager) return;
+ try {
+ const messages = await this._chatController.sessionManager.loadSession(sessionId);
+ if (messages && messages.length > 0) {
+ this._chatController._messages = messages;
+ webview.postMessage({ type: 'restore_messages', messages });
+ }
+ } catch { /* session may not be loadable */ }
+ }
+}
+
+class OpenClaudeChatPanelManager {
+ constructor(chatController) {
+ this._chatController = chatController;
+ this._panel = null;
+ }
+
+ openPanel() {
+ if (this._panel) {
+ this._panel.reveal();
+ return;
+ }
+
+ this._panel = vscode.window.createWebviewPanel(
+ 'openclaude.chatPanel',
+ 'OpenClaude Chat',
+ vscode.ViewColumn.Beside,
+ {
+ enableScripts: true,
+ retainContextWhenHidden: true,
+ },
+ );
+
+ const webview = this._panel.webview;
+ const registration = this._chatController.registerWebview(webview);
+
+ this._panel.onDidDispose(() => {
+ registration.dispose();
+ this._panel = null;
+ });
+
+ const nonce = crypto.randomBytes(16).toString('hex');
+ webview.html = renderChatHtml({ nonce, platform: process.platform });
+ this._attachMessageHandler(webview);
+
+ const messages = this._chatController.getMessages();
+ if (messages.length > 0) {
+ webview.postMessage({ type: 'restore_messages', messages });
+ }
+ }
+
+ _attachMessageHandler(webview) {
+ webview.onDidReceiveMessage(async (msg) => {
+ switch (msg.type) {
+ case 'send_message':
+ this._chatController.sendMessage(msg.text);
+ break;
+ case 'abort':
+ this._chatController.abort();
+ break;
+ case 'new_session':
+ this._chatController.stopSession();
+ webview.postMessage({ type: 'session_cleared' });
+ break;
+ case 'resume_session':
+ this._chatController.stopSession();
+ webview.postMessage({ type: 'session_cleared' });
+ await this._loadAndDisplaySession(webview, msg.sessionId);
+ await this._chatController.startSession({ sessionId: msg.sessionId });
+ break;
+ case 'permission_response':
+ this._chatController.sendPermissionResponse(msg.requestId, msg.action, msg.toolUseId);
+ break;
+ case 'copy_code':
+ if (msg.text) await vscode.env.clipboard.writeText(msg.text);
+ break;
+ case 'open_file':
+ if (msg.path) await openFileInEditor(msg.path);
+ break;
+ case 'request_sessions':
+ await this._sendSessionList(webview);
+ break;
+ case 'restore_request':
+ this._restoreMessages(webview);
+ break;
+ case 'webview_ready':
+ break;
+ }
+ });
+ }
+
+ async _sendSessionList(webview) {
+ if (!this._chatController.sessionManager) return;
+ try {
+ const sessions = await this._chatController.sessionManager.listSessions();
+ webview.postMessage({ type: 'session_list', sessions });
+ } catch {
+ webview.postMessage({ type: 'session_list', sessions: [] });
+ }
+ }
+
+ _restoreMessages(webview) {
+ const messages = this._chatController.getMessages();
+ if (messages.length > 0) {
+ webview.postMessage({ type: 'restore_messages', messages });
+ }
+ }
+
+ async _loadAndDisplaySession(webview, sessionId) {
+ if (!this._chatController.sessionManager) return;
+ try {
+ const messages = await this._chatController.sessionManager.loadSession(sessionId);
+ if (messages && messages.length > 0) {
+ this._chatController._messages = messages;
+ webview.postMessage({ type: 'restore_messages', messages });
+ }
+ } catch { /* session may not be loadable */ }
+ }
+
+ dispose() {
+ if (this._panel) {
+ this._panel.dispose();
+ this._panel = null;
+ }
+ }
+}
+
+module.exports = {
+ ChatController,
+ OpenClaudeChatViewProvider,
+ OpenClaudeChatPanelManager,
+};
diff --git a/vscode-extension/openclaude-vscode/src/chat/chatRenderer.js b/vscode-extension/openclaude-vscode/src/chat/chatRenderer.js
new file mode 100644
index 00000000..df9c023a
--- /dev/null
+++ b/vscode-extension/openclaude-vscode/src/chat/chatRenderer.js
@@ -0,0 +1,1354 @@
+/**
+ * chatRenderer — produces the full self-contained HTML document for the chat
+ * webview. All CSS and JS are inlined (no external bundles).
+ *
+ * The webview JS communicates with the extension host via postMessage.
+ * Incoming messages update the DOM incrementally so streaming feels fluid.
+ */
+
+function escapeHtml(value) {
+ return String(value)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+function renderChatHtml({ nonce, platform }) {
+ const modKey = platform === 'darwin' ? 'Cmd' : 'Ctrl';
+
+ return `
+
+
+
+
+
+
+
+
+
+
+
+ Ready
+
+
+
+
+
+
OpenClaude
+
Ask a question, request a code change, or start a new task.
+
Press ${escapeHtml(modKey)}+L to focus input
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+}
+
+module.exports = { renderChatHtml };
diff --git a/vscode-extension/openclaude-vscode/src/chat/diffController.js b/vscode-extension/openclaude-vscode/src/chat/diffController.js
new file mode 100644
index 00000000..b5bcc34b
--- /dev/null
+++ b/vscode-extension/openclaude-vscode/src/chat/diffController.js
@@ -0,0 +1,90 @@
+/**
+ * diffController — provides a TextDocumentContentProvider for virtual
+ * diff documents and helpers to open VS Code's native diff editor when
+ * tool use involves file edits.
+ */
+
+const vscode = require('vscode');
+
+const SCHEME = 'openclaude-diff';
+let contentStore = new Map();
+
+class DiffContentProvider {
+ constructor() {
+ this._onDidChange = new vscode.EventEmitter();
+ this.onDidChange = this._onDidChange.event;
+ }
+
+ provideTextDocumentContent(uri) {
+ return contentStore.get(uri.toString()) || '';
+ }
+
+ update(uri) {
+ this._onDidChange.fire(uri);
+ }
+
+ dispose() {
+ this._onDidChange.dispose();
+ }
+}
+
+function storeContent(id, content) {
+ const uri = vscode.Uri.parse(`${SCHEME}:/${id}`);
+ contentStore.set(uri.toString(), content);
+ return uri;
+}
+
+function clearContent(id) {
+ const uri = vscode.Uri.parse(`${SCHEME}:/${id}`);
+ contentStore.delete(uri.toString());
+}
+
+function clearAll() {
+ contentStore.clear();
+}
+
+/**
+ * Opens a diff view between original and modified content.
+ * @param {object} opts
+ * @param {string} opts.filePath - Display path (for the title)
+ * @param {string} opts.original - Original file content
+ * @param {string} opts.modified - Modified file content
+ * @param {string} [opts.toolUseId] - Unique ID for this diff
+ */
+async function openDiff({ filePath, original, modified, toolUseId }) {
+ const id = toolUseId || Math.random().toString(36).slice(2, 10);
+ const originalUri = storeContent(`original-${id}`, original || '');
+ const modifiedUri = storeContent(`modified-${id}`, modified || '');
+ const shortName = filePath ? filePath.split(/[\\/]/).pop() : 'file';
+ const title = `${shortName} (OpenClaude Diff)`;
+
+ await vscode.commands.executeCommand('vscode.diff', originalUri, modifiedUri, title);
+}
+
+/**
+ * Opens a diff between a real file on disk and modified content from
+ * a tool use result.
+ * @param {object} opts
+ * @param {string} opts.filePath - Absolute path to the real file
+ * @param {string} opts.modified - Modified content
+ * @param {string} [opts.toolUseId]
+ */
+async function openFileDiff({ filePath, modified, toolUseId }) {
+ const id = toolUseId || Math.random().toString(36).slice(2, 10);
+ const fileUri = vscode.Uri.file(filePath);
+ const modifiedUri = storeContent(`modified-${id}`, modified || '');
+ const shortName = filePath.split(/[\\/]/).pop() || 'file';
+ const title = `${shortName} (OpenClaude Edit)`;
+
+ await vscode.commands.executeCommand('vscode.diff', fileUri, modifiedUri, title);
+}
+
+module.exports = {
+ DiffContentProvider,
+ SCHEME,
+ openDiff,
+ openFileDiff,
+ storeContent,
+ clearContent,
+ clearAll,
+};
diff --git a/vscode-extension/openclaude-vscode/src/chat/messageParser.js b/vscode-extension/openclaude-vscode/src/chat/messageParser.js
new file mode 100644
index 00000000..c70b5c66
--- /dev/null
+++ b/vscode-extension/openclaude-vscode/src/chat/messageParser.js
@@ -0,0 +1,177 @@
+/**
+ * messageParser — transforms raw SDK messages from the CLI into view-model
+ * objects that the chat renderer can display.
+ */
+
+const {
+ isAssistantMessage,
+ isPartialMessage,
+ isResultMessage,
+ isControlRequest,
+ isStatusMessage,
+ isToolProgressMessage,
+ isSessionStateChanged,
+ isRateLimitEvent,
+ getTextContent,
+ getToolUseBlocks,
+} = require('./protocol');
+
+function parseToolInput(input) {
+ if (!input || typeof input !== 'object') return String(input ?? '');
+ if (input.command) return input.command;
+ if (input.file_path || input.path) return input.file_path || input.path;
+ if (input.query) return input.query;
+ try { return JSON.stringify(input, null, 2); } catch { return String(input); }
+}
+
+function toolDisplayName(name) {
+ const map = {
+ Bash: 'Terminal',
+ Read: 'Read File',
+ Write: 'Write File',
+ Edit: 'Edit File',
+ MultiEdit: 'Multi Edit',
+ Glob: 'Find Files',
+ Grep: 'Search',
+ LS: 'List Directory',
+ WebFetch: 'Web Fetch',
+ WebSearch: 'Web Search',
+ TodoRead: 'Read Todos',
+ TodoWrite: 'Write Todos',
+ Task: 'Sub-agent',
+ };
+ return map[name] || name || 'Tool';
+}
+
+function toolIcon(name) {
+ const map = {
+ Bash: '\u{1F4BB}',
+ Read: '\u{1F4C4}',
+ Write: '\u{270F}\uFE0F',
+ Edit: '\u{270F}\uFE0F',
+ MultiEdit: '\u{270F}\uFE0F',
+ Glob: '\u{1F50D}',
+ Grep: '\u{1F50E}',
+ LS: '\u{1F4C2}',
+ WebFetch: '\u{1F310}',
+ WebSearch: '\u{1F310}',
+ Task: '\u{1F916}',
+ };
+ return map[name] || '\u{1F527}';
+}
+
+/**
+ * Converts an SDK message into one or more view-model entries for the chat UI.
+ * Returns an array so partial messages can update in-place while final messages
+ * produce a finalized entry.
+ */
+function toViewModel(msg) {
+ if (isAssistantMessage(msg)) {
+ return [{
+ kind: 'assistant',
+ id: msg.id || msg.message?.id || null,
+ text: getTextContent(msg.message || msg),
+ toolUses: getToolUseBlocks(msg.message || msg).map(tu => ({
+ id: tu.id,
+ name: tu.name,
+ displayName: toolDisplayName(tu.name),
+ icon: toolIcon(tu.name),
+ inputPreview: parseToolInput(tu.input),
+ input: tu.input,
+ status: 'complete',
+ })),
+ model: msg.model || null,
+ stopReason: msg.stop_reason || null,
+ usage: msg.usage || null,
+ final: true,
+ }];
+ }
+
+ if (isPartialMessage(msg)) {
+ const inner = msg.message || msg;
+ return [{
+ kind: 'assistant_partial',
+ id: inner.id || null,
+ text: getTextContent(inner),
+ toolUses: getToolUseBlocks(inner).map(tu => ({
+ id: tu.id,
+ name: tu.name,
+ displayName: toolDisplayName(tu.name),
+ icon: toolIcon(tu.name),
+ inputPreview: parseToolInput(tu.input),
+ input: tu.input,
+ status: 'running',
+ })),
+ final: false,
+ }];
+ }
+
+ if (isResultMessage(msg)) {
+ return [{
+ kind: 'tool_result',
+ toolUseId: msg.tool_use_id,
+ content: typeof msg.content === 'string'
+ ? msg.content
+ : Array.isArray(msg.content)
+ ? msg.content.map(b => b.text || '').join('')
+ : '',
+ isError: msg.is_error || false,
+ }];
+ }
+
+ if (isControlRequest(msg)) {
+ return [{
+ kind: 'permission_request',
+ requestId: msg.request_id || msg.id,
+ toolName: msg.tool_name || msg.tool?.name || 'Unknown',
+ displayName: toolDisplayName(msg.tool_name || msg.tool?.name),
+ description: msg.description || msg.tool?.description || '',
+ input: msg.tool_input || msg.input || null,
+ inputPreview: parseToolInput(msg.tool_input || msg.input),
+ }];
+ }
+
+ if (isToolProgressMessage(msg)) {
+ return [{
+ kind: 'tool_progress',
+ toolUseId: msg.tool_use_id,
+ content: msg.content || msg.progress || '',
+ }];
+ }
+
+ if (isStatusMessage(msg)) {
+ return [{
+ kind: 'status',
+ content: msg.content || msg.message || '',
+ }];
+ }
+
+ if (isSessionStateChanged(msg)) {
+ return [{
+ kind: 'session_state',
+ sessionId: msg.session_id || null,
+ state: msg.state || null,
+ }];
+ }
+
+ if (isRateLimitEvent(msg)) {
+ return [{
+ kind: 'rate_limit',
+ retryAfter: msg.retry_after || null,
+ message: msg.message || 'Rate limited',
+ }];
+ }
+
+ return [{
+ kind: 'unknown',
+ type: msg.type,
+ raw: msg,
+ }];
+}
+
+module.exports = {
+ toViewModel,
+ toolDisplayName,
+ toolIcon,
+ parseToolInput,
+};
diff --git a/vscode-extension/openclaude-vscode/src/chat/processManager.js b/vscode-extension/openclaude-vscode/src/chat/processManager.js
new file mode 100644
index 00000000..f74fc617
--- /dev/null
+++ b/vscode-extension/openclaude-vscode/src/chat/processManager.js
@@ -0,0 +1,194 @@
+/**
+ * ProcessManager — spawns OpenClaude in print/SDK mode and manages the
+ * NDJSON stdin/stdout lifecycle.
+ *
+ * Usage:
+ * const pm = new ProcessManager({ command, cwd, env });
+ * pm.onMessage(msg => { ... });
+ * pm.onError(err => { ... });
+ * pm.onExit(code => { ... });
+ * await pm.start();
+ * pm.sendUserMessage('Hello');
+ * pm.abort(); // SIGINT (graceful)
+ * pm.kill(); // SIGTERM (hard)
+ * pm.dispose();
+ */
+
+const { spawn } = require('child_process');
+const vscode = require('vscode');
+const { parseStdoutLine, serializeStdinMessage, buildUserMessage, buildControlResponse } = require('./protocol');
+
+class ProcessManager {
+ /**
+ * @param {object} opts
+ * @param {string} opts.command - The openclaude binary (e.g. 'openclaude')
+ * @param {string} [opts.cwd] - Working directory
+ * @param {Record} [opts.env] - Extra env vars
+ * @param {string} [opts.sessionId] - Session to resume
+ * @param {boolean} [opts.continueSession] - Use --continue instead of --resume
+ * @param {string} [opts.model] - Model override
+ * @param {string[]} [opts.extraArgs] - Additional CLI flags
+ */
+ constructor(opts) {
+ this._command = opts.command || 'openclaude';
+ this._cwd = opts.cwd || undefined;
+ this._env = opts.env || {};
+ this._sessionId = opts.sessionId || null;
+ this._continueSession = opts.continueSession || false;
+ this._model = opts.model || null;
+ this._permissionMode = opts.permissionMode || 'acceptEdits';
+ this._extraArgs = opts.extraArgs || [];
+ this._process = null;
+ this._buffer = '';
+ this._disposed = false;
+
+ this._onMessageEmitter = new vscode.EventEmitter();
+ this._onErrorEmitter = new vscode.EventEmitter();
+ this._onExitEmitter = new vscode.EventEmitter();
+ this.onMessage = this._onMessageEmitter.event;
+ this.onError = this._onErrorEmitter.event;
+ this.onExit = this._onExitEmitter.event;
+ }
+
+ get running() {
+ return this._process !== null && !this._process.killed;
+ }
+
+ get sessionId() {
+ return this._sessionId;
+ }
+
+ start() {
+ if (this._disposed) throw new Error('ProcessManager is disposed');
+ if (this._process) throw new Error('Process already started');
+
+ const args = [
+ '--print',
+ '--verbose',
+ '--input-format=stream-json',
+ '--output-format=stream-json',
+ '--include-partial-messages',
+ '--permission-mode', this._permissionMode || 'acceptEdits',
+ ];
+
+ if (this._sessionId) {
+ args.push('--resume', this._sessionId);
+ } else if (this._continueSession) {
+ args.push('--continue');
+ }
+
+ if (this._model) {
+ args.push('--model', this._model);
+ }
+
+ args.push(...this._extraArgs);
+
+ const spawnEnv = { ...process.env, ...this._env };
+ const isWin = process.platform === 'win32';
+
+ if (isWin) {
+ // On Windows, npm global installs create .cmd shims that spawn()
+ // cannot find without a shell. Build one command string so the
+ // deprecation warning about unsanitised args does not fire.
+ const cmdLine = [this._command, ...args].join(' ');
+ this._process = spawn(cmdLine, [], {
+ cwd: this._cwd,
+ env: spawnEnv,
+ stdio: ['pipe', 'pipe', 'pipe'],
+ shell: true,
+ windowsHide: true,
+ });
+ } else {
+ this._process = spawn(this._command, args, {
+ cwd: this._cwd,
+ env: spawnEnv,
+ stdio: ['pipe', 'pipe', 'pipe'],
+ windowsHide: true,
+ });
+ }
+
+ this._process.stdout.setEncoding('utf8');
+ this._process.stderr.setEncoding('utf8');
+
+ this._process.stdout.on('data', (chunk) => this._onData(chunk));
+ this._process.stderr.on('data', (chunk) => this._onStderr(chunk));
+ this._process.on('error', (err) => this._onErrorEmitter.fire(err));
+ this._process.on('close', (code, signal) => {
+ this._process = null;
+ this._onExitEmitter.fire({ code, signal });
+ });
+ }
+
+ _onData(chunk) {
+ this._buffer += chunk;
+ const lines = this._buffer.split('\n');
+ this._buffer = lines.pop() || '';
+
+ for (const line of lines) {
+ const msg = parseStdoutLine(line);
+ if (msg) {
+ this._extractSessionId(msg);
+ this._onMessageEmitter.fire(msg);
+ }
+ }
+ }
+
+ _extractSessionId(msg) {
+ if (msg.session_id && !this._sessionId) {
+ this._sessionId = msg.session_id;
+ }
+ }
+
+ _onStderr(chunk) {
+ const trimmed = chunk.trim();
+ if (!trimmed) return;
+ // Suppress common non-error noise from the CLI (deprecation warnings, etc.)
+ if (/^\(node:\d+\)|^DeprecationWarning|^ExperimentalWarning/i.test(trimmed)) return;
+ this._onErrorEmitter.fire(new Error(trimmed));
+ }
+
+ sendUserMessage(text) {
+ this._write(buildUserMessage(text));
+ }
+
+ sendControlResponse(requestId, result) {
+ this._write(buildControlResponse(requestId, result));
+ }
+
+ write(msg) {
+ if (!this._process || !this._process.stdin.writable) {
+ throw new Error('Process is not running');
+ }
+ this._process.stdin.write(serializeStdinMessage(msg));
+ }
+
+ _write(msg) {
+ this.write(msg);
+ }
+
+ abort() {
+ if (this._process && !this._process.killed) {
+ if (process.platform === 'win32') {
+ this._process.kill('SIGINT');
+ } else {
+ this._process.kill('SIGINT');
+ }
+ }
+ }
+
+ kill() {
+ if (this._process && !this._process.killed) {
+ this._process.kill('SIGTERM');
+ }
+ }
+
+ dispose() {
+ this._disposed = true;
+ this.kill();
+ this._onMessageEmitter.dispose();
+ this._onErrorEmitter.dispose();
+ this._onExitEmitter.dispose();
+ }
+}
+
+module.exports = { ProcessManager };
diff --git a/vscode-extension/openclaude-vscode/src/chat/protocol.js b/vscode-extension/openclaude-vscode/src/chat/protocol.js
new file mode 100644
index 00000000..6b065a59
--- /dev/null
+++ b/vscode-extension/openclaude-vscode/src/chat/protocol.js
@@ -0,0 +1,186 @@
+/**
+ * NDJSON protocol helpers and message type constants for the OpenClaude
+ * stream-json SDK wire format.
+ *
+ * The extension spawns `openclaude --print --input-format=stream-json
+ * --output-format=stream-json` and speaks NDJSON over stdin/stdout.
+ * This module provides lightweight parsing, serialization, and type guards
+ * so the rest of the extension never touches raw JSON strings.
+ */
+
+const MESSAGE_TYPES = {
+ ASSISTANT: 'assistant',
+ USER: 'user',
+ USER_REPLAY: 'user_replay',
+ RESULT: 'result',
+ SYSTEM: 'system',
+ STREAM_EVENT: 'stream_event',
+ PARTIAL: 'partial',
+ COMPACT_BOUNDARY: 'compact_boundary',
+ STATUS: 'status',
+ API_RETRY: 'api_retry',
+ LOCAL_COMMAND_OUTPUT: 'local_command_output',
+ HOOK_STARTED: 'hook_started',
+ HOOK_PROGRESS: 'hook_progress',
+ HOOK_RESPONSE: 'hook_response',
+ TOOL_PROGRESS: 'tool_progress',
+ AUTH_STATUS: 'auth_status',
+ TASK_NOTIFICATION: 'task_notification',
+ TASK_STARTED: 'task_started',
+ TASK_PROGRESS: 'task_progress',
+ SESSION_STATE_CHANGED: 'session_state_changed',
+ FILES_PERSISTED: 'files_persisted',
+ TOOL_USE_SUMMARY: 'tool_use_summary',
+ RATE_LIMIT: 'rate_limit',
+ ELICITATION_COMPLETE: 'elicitation_complete',
+ PROMPT_SUGGESTION: 'prompt_suggestion',
+ STREAMLINED_TEXT: 'streamlined_text',
+ STREAMLINED_TOOL_USE_SUMMARY: 'streamlined_tool_use_summary',
+ POST_TURN_SUMMARY: 'post_turn_summary',
+ CONTROL_RESPONSE: 'control_response',
+ CONTROL_REQUEST: 'control_request',
+};
+
+function parseStdoutLine(line) {
+ const trimmed = (line || '').trim();
+ if (!trimmed) return null;
+ try {
+ return JSON.parse(trimmed);
+ } catch {
+ return null;
+ }
+}
+
+function serializeStdinMessage(msg) {
+ return JSON.stringify(msg) + '\n';
+}
+
+function buildUserMessage(text) {
+ return {
+ type: 'user',
+ message: {
+ role: 'user',
+ content: text,
+ },
+ parent_tool_use_id: null,
+ };
+}
+
+function buildControlResponse(requestId, result) {
+ return {
+ type: 'control_response',
+ response: {
+ subtype: 'success',
+ request_id: requestId,
+ response: result || {},
+ },
+ };
+}
+
+function isAssistantMessage(msg) {
+ return msg && msg.type === MESSAGE_TYPES.ASSISTANT;
+}
+
+function isPartialMessage(msg) {
+ return msg && msg.type === MESSAGE_TYPES.PARTIAL;
+}
+
+function isStreamEvent(msg) {
+ return msg && msg.type === MESSAGE_TYPES.STREAM_EVENT && msg.event;
+}
+
+function isContentBlockDelta(msg) {
+ return isStreamEvent(msg) && msg.event.type === 'content_block_delta';
+}
+
+function isContentBlockStart(msg) {
+ return isStreamEvent(msg) && msg.event.type === 'content_block_start';
+}
+
+function isMessageStart(msg) {
+ return isStreamEvent(msg) && msg.event.type === 'message_start';
+}
+
+function isMessageStop(msg) {
+ return isStreamEvent(msg) && msg.event.type === 'message_stop';
+}
+
+function isMessageDelta(msg) {
+ return isStreamEvent(msg) && msg.event.type === 'message_delta';
+}
+
+function isResultMessage(msg) {
+ return msg && msg.type === MESSAGE_TYPES.RESULT;
+}
+
+function isToolUse(block) {
+ return block && block.type === 'tool_use';
+}
+
+function isTextBlock(block) {
+ return block && block.type === 'text';
+}
+
+function isThinkingBlock(block) {
+ return block && block.type === 'thinking';
+}
+
+function isControlRequest(msg) {
+ return msg && msg.type === MESSAGE_TYPES.CONTROL_REQUEST;
+}
+
+function isStatusMessage(msg) {
+ return msg && msg.type === MESSAGE_TYPES.STATUS;
+}
+
+function isToolProgressMessage(msg) {
+ return msg && msg.type === MESSAGE_TYPES.TOOL_PROGRESS;
+}
+
+function isSessionStateChanged(msg) {
+ return msg && msg.type === MESSAGE_TYPES.SESSION_STATE_CHANGED;
+}
+
+function isRateLimitEvent(msg) {
+ return msg && msg.type === MESSAGE_TYPES.RATE_LIMIT;
+}
+
+function getTextContent(message) {
+ if (!message || !Array.isArray(message.content)) return '';
+ return message.content
+ .filter(b => b.type === 'text')
+ .map(b => b.text || '')
+ .join('');
+}
+
+function getToolUseBlocks(message) {
+ if (!message || !Array.isArray(message.content)) return [];
+ return message.content.filter(b => b.type === 'tool_use');
+}
+
+module.exports = {
+ MESSAGE_TYPES,
+ parseStdoutLine,
+ serializeStdinMessage,
+ buildUserMessage,
+ buildControlResponse,
+ isAssistantMessage,
+ isPartialMessage,
+ isStreamEvent,
+ isContentBlockDelta,
+ isContentBlockStart,
+ isMessageStart,
+ isMessageStop,
+ isMessageDelta,
+ isResultMessage,
+ isToolUse,
+ isTextBlock,
+ isThinkingBlock,
+ isControlRequest,
+ isStatusMessage,
+ isToolProgressMessage,
+ isSessionStateChanged,
+ isRateLimitEvent,
+ getTextContent,
+ getToolUseBlocks,
+};
diff --git a/vscode-extension/openclaude-vscode/src/chat/sessionManager.js b/vscode-extension/openclaude-vscode/src/chat/sessionManager.js
new file mode 100644
index 00000000..c5530c99
--- /dev/null
+++ b/vscode-extension/openclaude-vscode/src/chat/sessionManager.js
@@ -0,0 +1,282 @@
+/**
+ * sessionManager — reads JSONL session history from disk, lists sessions,
+ * and provides metadata for the session list UI.
+ *
+ * Session files live under:
+ * ~/.openclaude/projects//.jsonl
+ *
+ * Falls back to ~/.claude/projects/ for legacy installs.
+ */
+
+const fs = require('fs');
+const fsp = require('fs/promises');
+const path = require('path');
+const os = require('os');
+const crypto = require('crypto');
+
+const MAX_SANITIZED_LENGTH = 80;
+
+function sanitizePath(name) {
+ const sanitized = name.replace(/[^a-zA-Z0-9]/g, '-');
+ if (sanitized.length <= MAX_SANITIZED_LENGTH) return sanitized;
+ const hash = simpleHash(name);
+ return sanitized.slice(0, MAX_SANITIZED_LENGTH) + '-' + hash;
+}
+
+function simpleHash(str) {
+ let h = 0;
+ for (let i = 0; i < str.length; i++) {
+ h = ((h << 5) - h + str.charCodeAt(i)) | 0;
+ }
+ return Math.abs(h).toString(36);
+}
+
+function resolveConfigDir() {
+ const envDir = process.env.CLAUDE_CONFIG_DIR;
+ if (envDir) return envDir;
+ const home = os.homedir();
+ const openClaudeDir = path.join(home, '.openclaude');
+ const legacyDir = path.join(home, '.claude');
+ if (!fs.existsSync(openClaudeDir) && fs.existsSync(legacyDir)) {
+ return legacyDir;
+ }
+ return openClaudeDir;
+}
+
+function getProjectsDir() {
+ return path.join(resolveConfigDir(), 'projects');
+}
+
+function getProjectDir(cwd) {
+ return path.join(getProjectsDir(), sanitizePath(cwd));
+}
+
+class SessionManager {
+ constructor() {
+ this._cwd = null;
+ }
+
+ setCwd(cwd) {
+ this._cwd = cwd;
+ }
+
+ async listSessions() {
+ const projectDir = this._cwd
+ ? getProjectDir(this._cwd)
+ : null;
+
+ const dirs = projectDir
+ ? [projectDir]
+ : await this._allProjectDirs();
+
+ const sessions = [];
+ for (const dir of dirs) {
+ const items = await this._readSessionDir(dir);
+ sessions.push(...items);
+ }
+
+ sessions.sort((a, b) => b.timestamp - a.timestamp);
+ return sessions;
+ }
+
+ async _allProjectDirs() {
+ const base = getProjectsDir();
+ if (!fs.existsSync(base)) return [];
+ try {
+ const entries = await fsp.readdir(base, { withFileTypes: true });
+ return entries
+ .filter(e => e.isDirectory())
+ .map(e => path.join(base, e.name));
+ } catch {
+ return [];
+ }
+ }
+
+ async _readSessionDir(dir) {
+ if (!fs.existsSync(dir)) return [];
+ try {
+ const files = await fsp.readdir(dir);
+ const jsonlFiles = files.filter(f => f.endsWith('.jsonl'));
+ const results = [];
+
+ for (const file of jsonlFiles) {
+ const filePath = path.join(dir, file);
+ try {
+ const meta = await this._extractSessionMeta(filePath);
+ if (meta) results.push(meta);
+ } catch { /* skip unreadable */ }
+ }
+
+ return results;
+ } catch {
+ return [];
+ }
+ }
+
+ async _extractSessionMeta(filePath) {
+ const sessionId = path.basename(filePath, '.jsonl');
+ const stat = await fsp.stat(filePath);
+ // Read a larger head because JSONL files often start with system/snapshot
+ // entries before the first user message.
+ const head = await this._readHead(filePath, 65536);
+ const lines = head.split('\n').filter(Boolean);
+
+ let title = null;
+ let preview = '';
+ let timestamp = stat.mtimeMs;
+ let firstTimestamp = null;
+
+ for (const line of lines) {
+ try {
+ const entry = JSON.parse(line);
+
+ if (!preview && entry.type === 'user' && entry.message) {
+ const content = entry.message.content;
+ if (typeof content === 'string') {
+ preview = content.slice(0, 120);
+ } else if (Array.isArray(content)) {
+ const textBlock = content.find(b => b.type === 'text');
+ preview = textBlock ? (textBlock.text || '').slice(0, 120) : '';
+ }
+ }
+
+ if (entry.type === 'custom-title' || entry.type === 'session-title') {
+ title = entry.title || entry.name || null;
+ }
+
+ if (entry.type === 'summary' && entry.summary && !title) {
+ title = entry.summary;
+ }
+
+ if (entry.timestamp && !firstTimestamp) {
+ const t = typeof entry.timestamp === 'number'
+ ? entry.timestamp
+ : new Date(entry.timestamp).getTime();
+ if (t && !isNaN(t)) firstTimestamp = t;
+ }
+ } catch { /* skip bad line */ }
+ }
+
+ if (firstTimestamp) timestamp = firstTimestamp;
+ const timeLabel = formatRelativeTime(timestamp);
+
+ return {
+ id: sessionId,
+ title: title || preview.slice(0, 60) || 'Untitled session',
+ preview: preview || '',
+ timestamp,
+ timeLabel,
+ filePath,
+ };
+ }
+
+ async loadSession(sessionId) {
+ const projectDir = this._cwd ? getProjectDir(this._cwd) : null;
+ const dirs = projectDir ? [projectDir] : await this._allProjectDirs();
+
+ for (const dir of dirs) {
+ const filePath = path.join(dir, `${sessionId}.jsonl`);
+ if (fs.existsSync(filePath)) {
+ return this._parseSessionFile(filePath);
+ }
+ }
+ return null;
+ }
+
+ async _parseSessionFile(filePath) {
+ const content = await fsp.readFile(filePath, 'utf8');
+ const lines = content.split('\n').filter(Boolean);
+ const messages = [];
+ const toolResults = new Map();
+
+ // First pass: collect tool results from user messages
+ for (const line of lines) {
+ try {
+ const entry = JSON.parse(line);
+ if (entry.type === 'user' && entry.message && Array.isArray(entry.message.content)) {
+ for (const block of entry.message.content) {
+ if (block.type === 'tool_result' && block.tool_use_id) {
+ const resultText = typeof block.content === 'string'
+ ? block.content
+ : Array.isArray(block.content)
+ ? block.content.map(b => b.text || '').join('')
+ : '';
+ toolResults.set(String(block.tool_use_id), {
+ content: resultText.slice(0, 2000),
+ isError: block.is_error || false,
+ });
+ }
+ }
+ }
+ } catch { /* skip */ }
+ }
+
+ // Second pass: build messages with tool use details
+ for (const line of lines) {
+ try {
+ const entry = JSON.parse(line);
+ if (entry.type === 'user' && entry.message) {
+ const c = entry.message.content;
+ // Skip tool result messages (they're user messages with tool_result blocks)
+ if (Array.isArray(c) && c.length > 0 && c[0].type === 'tool_result') continue;
+ const text = typeof c === 'string'
+ ? c
+ : Array.isArray(c)
+ ? c.filter(b => b.type === 'text').map(b => b.text).join('')
+ : '';
+ if (text) messages.push({ role: 'user', text });
+ } else if (entry.type === 'assistant' && entry.message) {
+ const c = entry.message.content;
+ const text = typeof c === 'string'
+ ? c
+ : Array.isArray(c)
+ ? c.filter(b => b.type === 'text').map(b => b.text).join('')
+ : '';
+ const toolUses = Array.isArray(c)
+ ? c.filter(b => b.type === 'tool_use').map(tu => {
+ const result = toolResults.get(String(tu.id));
+ return {
+ id: tu.id,
+ name: tu.name,
+ input: tu.input || null,
+ status: result ? (result.isError ? 'error' : 'complete') : 'complete',
+ result: result ? result.content : null,
+ isError: result ? result.isError : false,
+ };
+ })
+ : [];
+ messages.push({ role: 'assistant', text, toolUses });
+ }
+ } catch { /* skip */ }
+ }
+
+ return messages;
+ }
+
+ async _readHead(filePath, bytes) {
+ const fd = await fsp.open(filePath, 'r');
+ try {
+ const buf = Buffer.alloc(bytes);
+ const { bytesRead } = await fd.read(buf, 0, bytes, 0);
+ return buf.slice(0, bytesRead).toString('utf8');
+ } finally {
+ await fd.close();
+ }
+ }
+}
+
+function formatRelativeTime(ts) {
+ const now = Date.now();
+ const diff = now - ts;
+ const mins = Math.floor(diff / 60000);
+ if (mins < 1) return 'Just now';
+ if (mins < 60) return `${mins}m ago`;
+ const hours = Math.floor(mins / 60);
+ if (hours < 24) return `${hours}h ago`;
+ const days = Math.floor(hours / 24);
+ if (days < 7) return `${days}d ago`;
+ const date = new Date(ts);
+ return date.toLocaleDateString();
+}
+
+module.exports = { SessionManager };
diff --git a/vscode-extension/openclaude-vscode/src/extension.js b/vscode-extension/openclaude-vscode/src/extension.js
index b24873d4..1470abe6 100644
--- a/vscode-extension/openclaude-vscode/src/extension.js
+++ b/vscode-extension/openclaude-vscode/src/extension.js
@@ -12,6 +12,9 @@ const {
resolveCommandCheckPath,
} = require('./state');
const { buildControlCenterViewModel } = require('./presentation');
+const { ChatController, OpenClaudeChatViewProvider, OpenClaudeChatPanelManager } = require('./chat/chatProvider');
+const { SessionManager } = require('./chat/sessionManager');
+const { DiffContentProvider, SCHEME: DIFF_SCHEME } = require('./chat/diffController');
const OPENCLAUDE_REPO_URL = 'https://github.com/Gitlawb/openclaude';
const OPENCLAUDE_SETUP_URL = 'https://github.com/Gitlawb/openclaude/blob/main/README.md#quick-start';
@@ -1041,11 +1044,58 @@ class OpenClaudeControlCenterProvider {
* @param {vscode.ExtensionContext} context
*/
function activate(context) {
+ // ── Control Center (existing) ──
const provider = new OpenClaudeControlCenterProvider();
const refreshProvider = () => {
void provider.refresh();
};
+ // ── Chat system ──
+ const sessionManager = new SessionManager();
+ const folders = vscode.workspace.workspaceFolders;
+ if (folders && folders.length > 0) {
+ sessionManager.setCwd(folders[0].uri.fsPath);
+ }
+
+ const chatController = new ChatController(sessionManager);
+ const chatViewProvider = new OpenClaudeChatViewProvider(chatController);
+ const chatPanelManager = new OpenClaudeChatPanelManager(chatController);
+
+ // ── Diff content provider ──
+ const diffProvider = new DiffContentProvider();
+ const diffProviderReg = vscode.workspace.registerTextDocumentContentProvider(
+ DIFF_SCHEME,
+ diffProvider,
+ );
+
+ // ── Status bar ──
+ const statusBarItem = vscode.window.createStatusBarItem(
+ vscode.StatusBarAlignment.Right,
+ 100,
+ );
+ statusBarItem.text = '$(comment-discussion) OpenClaude';
+ statusBarItem.tooltip = 'Open OpenClaude Chat';
+ statusBarItem.command = 'openclaude.openChat';
+ statusBarItem.show();
+
+ chatController.onDidChangeState((state) => {
+ switch (state) {
+ case 'streaming':
+ statusBarItem.text = '$(sync~spin) OpenClaude';
+ statusBarItem.tooltip = 'OpenClaude is generating...';
+ break;
+ case 'connected':
+ statusBarItem.text = '$(comment-discussion) OpenClaude';
+ statusBarItem.tooltip = 'OpenClaude connected';
+ break;
+ default:
+ statusBarItem.text = '$(comment-discussion) OpenClaude';
+ statusBarItem.tooltip = 'Open OpenClaude Chat';
+ break;
+ }
+ });
+
+ // ── Existing commands ──
const startCommand = vscode.commands.registerCommand('openclaude.start', async () => {
await launchOpenClaude();
});
@@ -1079,32 +1129,95 @@ function activate(context) {
await vscode.commands.executeCommand('workbench.view.extension.openclaude');
});
- const providerDisposable = vscode.window.registerWebviewViewProvider(
+ // ── New chat commands ──
+ const newChatCommand = vscode.commands.registerCommand('openclaude.newChat', () => {
+ chatController.stopSession();
+ chatController.broadcast({ type: 'session_cleared' });
+ });
+
+ const openChatCommand = vscode.commands.registerCommand('openclaude.openChat', () => {
+ chatPanelManager.openPanel();
+ });
+
+ const resumeSessionCommand = vscode.commands.registerCommand('openclaude.resumeSession', async () => {
+ const sessions = await sessionManager.listSessions();
+ if (sessions.length === 0) {
+ await vscode.window.showInformationMessage('No sessions found to resume.');
+ return;
+ }
+ const items = sessions.slice(0, 30).map(s => ({
+ label: s.title || s.id,
+ description: s.timeLabel,
+ detail: s.preview,
+ sessionId: s.id,
+ }));
+ const picked = await vscode.window.showQuickPick(items, {
+ placeHolder: 'Select a session to resume',
+ });
+ if (picked) {
+ chatController.stopSession();
+ chatController.broadcast({ type: 'session_cleared' });
+ await chatController.startSession({ sessionId: picked.sessionId });
+ }
+ });
+
+ const abortChatCommand = vscode.commands.registerCommand('openclaude.abortChat', () => {
+ chatController.abort();
+ });
+
+ // ── Register providers ──
+ const controlCenterProviderReg = vscode.window.registerWebviewViewProvider(
'openclaude.controlCenter',
provider,
);
+ const chatViewProviderReg = vscode.window.registerWebviewViewProvider(
+ 'openclaude.chat',
+ chatViewProvider,
+ { webviewOptions: { retainContextWhenHidden: true } },
+ );
+
const profileWatcher = vscode.workspace.createFileSystemWatcher(`**/${PROFILE_FILE_NAME}`);
context.subscriptions.push(
+ // existing
startCommand,
startInWorkspaceRootCommand,
openDocsCommand,
openSetupDocsCommand,
openWorkspaceProfileCommand,
openUiCommand,
- providerDisposable,
+ controlCenterProviderReg,
+ // new chat
+ newChatCommand,
+ openChatCommand,
+ resumeSessionCommand,
+ abortChatCommand,
+ chatViewProviderReg,
+ diffProviderReg,
+ statusBarItem,
+ // watchers
profileWatcher,
vscode.workspace.onDidChangeConfiguration(event => {
if (event.affectsConfiguration('openclaude')) {
refreshProvider();
}
}),
- vscode.workspace.onDidChangeWorkspaceFolders(refreshProvider),
+ vscode.workspace.onDidChangeWorkspaceFolders((e) => {
+ refreshProvider();
+ const folders = vscode.workspace.workspaceFolders;
+ if (folders && folders.length > 0) {
+ sessionManager.setCwd(folders[0].uri.fsPath);
+ }
+ }),
vscode.window.onDidChangeActiveTextEditor(refreshProvider),
profileWatcher.onDidCreate(refreshProvider),
profileWatcher.onDidChange(refreshProvider),
profileWatcher.onDidDelete(refreshProvider),
+ // disposables
+ { dispose: () => chatController.dispose() },
+ { dispose: () => chatPanelManager.dispose() },
+ { dispose: () => diffProvider.dispose() },
);
}
@@ -1116,4 +1229,7 @@ module.exports = {
OpenClaudeControlCenterProvider,
renderControlCenterHtml,
resolveLaunchTargets,
+ ChatController,
+ OpenClaudeChatViewProvider,
+ OpenClaudeChatPanelManager,
};