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:
committed by
GitHub
parent
77083d769b
commit
fbcd928f7f
@@ -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": [
|
||||
|
||||
17
vscode-extension/openclaude-vscode/scripts/lint.js
Normal file
17
vscode-extension/openclaude-vscode/scripts/lint.js
Normal 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');
|
||||
676
vscode-extension/openclaude-vscode/src/chat/chatProvider.js
Normal file
676
vscode-extension/openclaude-vscode/src/chat/chatProvider.js
Normal 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,
|
||||
};
|
||||
1354
vscode-extension/openclaude-vscode/src/chat/chatRenderer.js
Normal file
1354
vscode-extension/openclaude-vscode/src/chat/chatRenderer.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
};
|
||||
177
vscode-extension/openclaude-vscode/src/chat/messageParser.js
Normal file
177
vscode-extension/openclaude-vscode/src/chat/messageParser.js
Normal 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,
|
||||
};
|
||||
194
vscode-extension/openclaude-vscode/src/chat/processManager.js
Normal file
194
vscode-extension/openclaude-vscode/src/chat/processManager.js
Normal 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 };
|
||||
186
vscode-extension/openclaude-vscode/src/chat/protocol.js
Normal file
186
vscode-extension/openclaude-vscode/src/chat/protocol.js
Normal 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,
|
||||
};
|
||||
282
vscode-extension/openclaude-vscode/src/chat/sessionManager.js
Normal file
282
vscode-extension/openclaude-vscode/src/chat/sessionManager.js
Normal 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 };
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user