import EventEmitter from 'events'; import pty from 'node-pty'; class TerminalService extends EventEmitter { constructor() { super(); this.lines = []; this.partial = ''; this.ptyProcess = null; this.started = false; this.maxLines = 1000; } start() { if (this.started) return; const shell = process.platform === 'win32' ? 'powershell.exe' : 'bash'; const cols = process.stdout && process.stdout.columns ? process.stdout.columns : 120; const rows = process.stdout && process.stdout.rows ? process.stdout.rows : 30; const isWindows = process.platform === 'win32'; const userShell = process.env.SHELL && !isWindows ? process.env.SHELL : null; const shellPath = userShell || (isWindows ? 'powershell.exe' : '/bin/bash'); const args = ['--rcfile','rc']; this.ptyProcess = pty.spawn(shellPath, args, { name: 'xterm-256color', cols, rows, cwd: process.cwd(), env: { ...process.env, TERM: 'xterm-256color', PS1: 'bash> ' }, }); this.ptyProcess.onData((data) => { const str = String(data); // Normalize CRLF to LF to avoid double-handling \r and \n const normalized = str.replace(/\r\n/g, '\n'); for (let i = 0; i < normalized.length; i += 1) { const ch = normalized[i]; if (ch === '\n') { // Line feed completes the current line this.lines.push(this.partial); this.partial = ''; } else if (ch === '\r') { // Standalone carriage return: simulate return to start of line (overwrite) this.partial = ''; } else if (ch === '\b' || ch === '\x7f') { // Backspace or DEL: remove last char if present if (this.partial.length > 0) { this.partial = this.partial.slice(0, -1); } } else { this.partial += ch; } } // Enforce max lines buffer if (this.lines.length > this.maxLines) { this.lines.splice(0, this.lines.length - this.maxLines); } // Emit lines including current partial to ensure prompts (no trailing newline) are visible const display = this.partial ? [...this.lines, this.partial] : this.lines.slice(); this.emit('update', display); }); // Resize with the host TTY const onResize = () => { try { const newCols = process.stdout.columns || cols; const newRows = process.stdout.rows || rows; this.ptyProcess.resize(newCols, newRows); } catch { // ignore } }; if (process.stdout && process.stdout.on) { process.stdout.on('resize', onResize); } this.ptyProcess.onExit(({ exitCode, signal }) => { this.emit('exit', { exitCode, signal }); }); this.started = true; } getLines() { return this.lines.slice(); } write(input) { if (!this.ptyProcess) return; this.ptyProcess.write(input); } resize(columns, rows) { if (!this.ptyProcess) return; try { const cols = Math.max(1, Number(columns) || 1); const r = rows ? Math.max(1, Number(rows) || 1) : undefined; if (r !== undefined) { this.ptyProcess.resize(cols, r); } else { this.ptyProcess.resize(cols, this.ptyProcess.rows || 24); } } catch { // ignore } } dispose() { try { if (this.ptyProcess) { this.ptyProcess.kill(); this.ptyProcess = null; } this.started = false; } catch { // ignore } } restart() { try { this.dispose(); } catch {} try { this.start(); } catch {} } } const terminalService = new TerminalService(); export default terminalService;