feat(vscode): add full chat interface to OpenClaude extension (#608)

Add a Claude Code-like chat experience to the VS Code extension with:
- Streaming chat panel (sidebar + editor tab) with markdown rendering
- Tool use visualization with inline diffs (replace/with display)
- Session history browser with JSONL transcript parsing
- Thinking block indicator with elapsed time and token count
- Clickable file paths that open in the editor
- Permission mode setting (acceptEdits default)
- Multi-turn conversation support via NDJSON stream-json protocol
- Status bar with live activity indicators
- Ctrl+Shift+L keybinding to open chat panel

Made-with: Cursor

Co-authored-by: henriquepasquini2 <henriquepasquini2@users.noreply.github.com>
This commit is contained in:
henriquepasquini2
2026-04-15 18:04:31 -03:00
committed by GitHub
parent 77083d769b
commit fbcd928f7f
10 changed files with 3148 additions and 6 deletions

View File

@@ -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": [

View File

@@ -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');

View File

@@ -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,
};

File diff suppressed because it is too large Load Diff

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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<string,string>} [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 };

View File

@@ -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,
};

View File

@@ -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/<sanitized-cwd>/<sessionId>.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 };

View File

@@ -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,
};