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
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 };
|
||||
Reference in New Issue
Block a user