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",
|
"name": "openclaude-vscode",
|
||||||
"displayName": "OpenClaude",
|
"displayName": "OpenClaude",
|
||||||
"description": "Practical VS Code companion for OpenClaude with project-aware launch behavior and a real Control Center.",
|
"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",
|
"publisher": "devnull-bootloader",
|
||||||
"engines": {
|
"engines": {
|
||||||
"vscode": "^1.95.0"
|
"vscode": "^1.95.0"
|
||||||
@@ -19,7 +19,12 @@
|
|||||||
"onCommand:openclaude.openSetupDocs",
|
"onCommand:openclaude.openSetupDocs",
|
||||||
"onCommand:openclaude.openWorkspaceProfile",
|
"onCommand:openclaude.openWorkspaceProfile",
|
||||||
"onCommand:openclaude.openControlCenter",
|
"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",
|
"main": "./src/extension.js",
|
||||||
"files": [
|
"files": [
|
||||||
@@ -28,6 +33,7 @@
|
|||||||
"src/extension.js",
|
"src/extension.js",
|
||||||
"src/presentation.js",
|
"src/presentation.js",
|
||||||
"src/state.js",
|
"src/state.js",
|
||||||
|
"src/chat/**",
|
||||||
"themes/**"
|
"themes/**"
|
||||||
],
|
],
|
||||||
"contributes": {
|
"contributes": {
|
||||||
@@ -61,6 +67,26 @@
|
|||||||
"command": "openclaude.openControlCenter",
|
"command": "openclaude.openControlCenter",
|
||||||
"title": "OpenClaude: Open Control Center",
|
"title": "OpenClaude: Open Control Center",
|
||||||
"category": "OpenClaude"
|
"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": {
|
"viewsContainers": {
|
||||||
@@ -74,6 +100,11 @@
|
|||||||
},
|
},
|
||||||
"views": {
|
"views": {
|
||||||
"openclaude": [
|
"openclaude": [
|
||||||
|
{
|
||||||
|
"id": "openclaude.chat",
|
||||||
|
"name": "Chat",
|
||||||
|
"type": "webview"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "openclaude.controlCenter",
|
"id": "openclaude.controlCenter",
|
||||||
"name": "Control Center",
|
"name": "Control Center",
|
||||||
@@ -81,6 +112,13 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"keybindings": [
|
||||||
|
{
|
||||||
|
"command": "openclaude.openChat",
|
||||||
|
"key": "ctrl+shift+l",
|
||||||
|
"mac": "cmd+shift+l"
|
||||||
|
}
|
||||||
|
],
|
||||||
"configuration": {
|
"configuration": {
|
||||||
"title": "OpenClaude",
|
"title": "OpenClaude",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -98,6 +136,18 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": false,
|
"default": false,
|
||||||
"description": "Optionally set CLAUDE_CODE_USE_OPENAI=1 in launched OpenClaude terminals."
|
"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": {
|
"scripts": {
|
||||||
"test": "node --test ./src/*.test.js",
|
"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"
|
"package": "npx @vscode/vsce package --no-dependencies"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"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,
|
resolveCommandCheckPath,
|
||||||
} = require('./state');
|
} = require('./state');
|
||||||
const { buildControlCenterViewModel } = require('./presentation');
|
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_REPO_URL = 'https://github.com/Gitlawb/openclaude';
|
||||||
const OPENCLAUDE_SETUP_URL = 'https://github.com/Gitlawb/openclaude/blob/main/README.md#quick-start';
|
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
|
* @param {vscode.ExtensionContext} context
|
||||||
*/
|
*/
|
||||||
function activate(context) {
|
function activate(context) {
|
||||||
|
// ── Control Center (existing) ──
|
||||||
const provider = new OpenClaudeControlCenterProvider();
|
const provider = new OpenClaudeControlCenterProvider();
|
||||||
const refreshProvider = () => {
|
const refreshProvider = () => {
|
||||||
void provider.refresh();
|
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 () => {
|
const startCommand = vscode.commands.registerCommand('openclaude.start', async () => {
|
||||||
await launchOpenClaude();
|
await launchOpenClaude();
|
||||||
});
|
});
|
||||||
@@ -1079,32 +1129,95 @@ function activate(context) {
|
|||||||
await vscode.commands.executeCommand('workbench.view.extension.openclaude');
|
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',
|
'openclaude.controlCenter',
|
||||||
provider,
|
provider,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const chatViewProviderReg = vscode.window.registerWebviewViewProvider(
|
||||||
|
'openclaude.chat',
|
||||||
|
chatViewProvider,
|
||||||
|
{ webviewOptions: { retainContextWhenHidden: true } },
|
||||||
|
);
|
||||||
|
|
||||||
const profileWatcher = vscode.workspace.createFileSystemWatcher(`**/${PROFILE_FILE_NAME}`);
|
const profileWatcher = vscode.workspace.createFileSystemWatcher(`**/${PROFILE_FILE_NAME}`);
|
||||||
|
|
||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
|
// existing
|
||||||
startCommand,
|
startCommand,
|
||||||
startInWorkspaceRootCommand,
|
startInWorkspaceRootCommand,
|
||||||
openDocsCommand,
|
openDocsCommand,
|
||||||
openSetupDocsCommand,
|
openSetupDocsCommand,
|
||||||
openWorkspaceProfileCommand,
|
openWorkspaceProfileCommand,
|
||||||
openUiCommand,
|
openUiCommand,
|
||||||
providerDisposable,
|
controlCenterProviderReg,
|
||||||
|
// new chat
|
||||||
|
newChatCommand,
|
||||||
|
openChatCommand,
|
||||||
|
resumeSessionCommand,
|
||||||
|
abortChatCommand,
|
||||||
|
chatViewProviderReg,
|
||||||
|
diffProviderReg,
|
||||||
|
statusBarItem,
|
||||||
|
// watchers
|
||||||
profileWatcher,
|
profileWatcher,
|
||||||
vscode.workspace.onDidChangeConfiguration(event => {
|
vscode.workspace.onDidChangeConfiguration(event => {
|
||||||
if (event.affectsConfiguration('openclaude')) {
|
if (event.affectsConfiguration('openclaude')) {
|
||||||
refreshProvider();
|
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),
|
vscode.window.onDidChangeActiveTextEditor(refreshProvider),
|
||||||
profileWatcher.onDidCreate(refreshProvider),
|
profileWatcher.onDidCreate(refreshProvider),
|
||||||
profileWatcher.onDidChange(refreshProvider),
|
profileWatcher.onDidChange(refreshProvider),
|
||||||
profileWatcher.onDidDelete(refreshProvider),
|
profileWatcher.onDidDelete(refreshProvider),
|
||||||
|
// disposables
|
||||||
|
{ dispose: () => chatController.dispose() },
|
||||||
|
{ dispose: () => chatPanelManager.dispose() },
|
||||||
|
{ dispose: () => diffProvider.dispose() },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1116,4 +1229,7 @@ module.exports = {
|
|||||||
OpenClaudeControlCenterProvider,
|
OpenClaudeControlCenterProvider,
|
||||||
renderControlCenterHtml,
|
renderControlCenterHtml,
|
||||||
resolveLaunchTargets,
|
resolveLaunchTargets,
|
||||||
|
ChatController,
|
||||||
|
OpenClaudeChatViewProvider,
|
||||||
|
OpenClaudeChatPanelManager,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user