Files
orcs-code/vscode-extension/openclaude-vscode/src/chat/processManager.js
henriquepasquini2 fbcd928f7f 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>
2026-04-16 05:04:31 +08:00

195 lines
5.5 KiB
JavaScript

/**
* 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 };