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 ` + + + + + + + + +
+
OpenClaude
+ + + +
+
+ + Ready + +
+ +
+
+
OpenClaude
+
Ask a question, request a code change, or start a new task.
+
Press ${escapeHtml(modKey)}+L to focus input
+
+
+ +
+
+
+ Thinking... +
+
+
+ +
+
+
+
+
+ +
+ + +
+ + +
+
+

Session History

+ +
+ +
+
No sessions found
+
+
+ + + +`; +} + +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, };