import * as pty from "node-pty"; import xterm, { type Terminal as TerminalType } from "@xterm/headless"; import { randomUUID } from "crypto"; import { chmodSync, existsSync, statSync } from "node:fs"; import { dirname, join } from "node:path"; import { createRequire } from "node:module"; import stripAnsi from "strip-ansi"; import type { TerminalCell, TerminalState } from "../shared/messages.js"; const { Terminal } = xterm; const require = createRequire(import.meta.url); let nodePtySpawnHelperChecked = false; export type ClientMessage = | { type: "input"; data: string } | { type: "resize"; rows: number; cols: number } | { type: "mouse"; row: number; col: number; button: number; action: "down" | "up" | "move" }; export type ServerMessage = | { type: "output"; data: string } | { type: "snapshot"; state: TerminalState }; export interface TerminalSession { id: string; name: string; cwd: string; send(msg: ClientMessage): void; subscribe(listener: (msg: ServerMessage) => void): () => void; onExit(listener: () => void): () => void; getSize(): { rows: number; cols: number }; getState(): TerminalState; kill(): void; } export interface CreateTerminalOptions { cwd: string; shell?: string; env?: Record; rows?: number; cols?: number; name?: string; } export interface CaptureTerminalLinesOptions { start?: number; end?: number; stripAnsi?: boolean; } export interface CaptureTerminalLinesResult { lines: string[]; totalLines: number; } type EnsureNodePtySpawnHelperExecutableOptions = { packageRoot?: string; platform?: NodeJS.Platform; arch?: string; force?: boolean; }; function resolveNodePtyPackageRoot(): string | null { try { const packageJsonPath = require.resolve("node-pty/package.json"); return dirname(packageJsonPath); } catch { return null; } } function ensureExecutableBit(path: string): void { if (!existsSync(path)) { return; } const stat = statSync(path); if (!stat.isFile()) { return; } // node-pty 1.1.0 shipped darwin prebuild spawn-helper without execute bit. if ((stat.mode & 0o111) === 0o111) { return; } chmodSync(path, stat.mode | 0o111); } export function ensureNodePtySpawnHelperExecutableForCurrentPlatform( options: EnsureNodePtySpawnHelperExecutableOptions = {}, ): void { const platform = options.platform ?? process.platform; if (platform !== "darwin") { return; } if (nodePtySpawnHelperChecked && !options.force) { return; } const packageRoot = options.packageRoot ?? resolveNodePtyPackageRoot(); if (!packageRoot) { return; } const arch = options.arch ?? process.arch; const candidates = [ join(packageRoot, "build", "Release", "spawn-helper"), join(packageRoot, "build", "Debug", "spawn-helper"), join(packageRoot, "prebuilds", `darwin-${arch}`, "spawn-helper"), ]; for (const candidate of candidates) { try { ensureExecutableBit(candidate); } catch { // best-effort hardening only } } if (!options.force) { nodePtySpawnHelperChecked = true; } } export function resolveDefaultTerminalShell( options: { platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; } = {}, ): string { const platform = options.platform ?? process.platform; const env = options.env ?? process.env; if (platform === "win32") { return env.ComSpec || env.COMSPEC || "C:\\Windows\\System32\\cmd.exe"; } return env.SHELL || "/bin/sh"; } function extractCell(terminal: TerminalType, row: number, col: number): TerminalCell { const buffer = terminal.buffer.active; const line = buffer.getLine(row); if (!line) { return { char: " ", fg: undefined, bg: undefined }; } const cell = line.getCell(col); if (!cell) { return { char: " ", fg: undefined, bg: undefined }; } // Color modes from xterm.js: 0=DEFAULT, 1=16 colors (ANSI), 2=256 colors, 3=RGB // getFgColorMode() returns packed value with mode in upper byte (e.g. 0x01000000 for mode 1) const fgModeRaw = cell.getFgColorMode(); const bgModeRaw = cell.getBgColorMode(); const fgMode = fgModeRaw >> 24; const bgMode = bgModeRaw >> 24; // Only return color if not default (mode 0) const fg = fgMode !== 0 ? cell.getFgColor() : undefined; const bg = bgMode !== 0 ? cell.getBgColor() : undefined; return { char: cell.getChars() || " ", fg, bg, fgMode: fgMode !== 0 ? fgMode : undefined, bgMode: bgMode !== 0 ? bgMode : undefined, bold: cell.isBold() !== 0, italic: cell.isItalic() !== 0, underline: cell.isUnderline() !== 0, dim: cell.isDim() !== 0, inverse: cell.isInverse() !== 0, strikethrough: cell.isStrikethrough() !== 0, }; } function extractGrid(terminal: TerminalType): TerminalCell[][] { const grid: TerminalCell[][] = []; const buffer = terminal.buffer.active; // Visible viewport starts at baseY const baseY = buffer.baseY; for (let row = 0; row < terminal.rows; row++) { const rowCells: TerminalCell[] = []; for (let col = 0; col < terminal.cols; col++) { rowCells.push(extractCell(terminal, baseY + row, col)); } grid.push(rowCells); } return grid; } function extractScrollback(terminal: TerminalType): TerminalCell[][] { const scrollback: TerminalCell[][] = []; const buffer = terminal.buffer.active; // baseY is the first row of the visible viewport (0-indexed) // Lines 0 to baseY-1 are in scrollback, lines baseY onwards are visible const scrollbackLines = buffer.baseY; for (let row = 0; row < scrollbackLines; row++) { const rowCells: TerminalCell[] = []; const line = buffer.getLine(row); for (let col = 0; col < terminal.cols; col++) { if (line) { const cell = line.getCell(col); if (cell) { const fgModeRaw = cell.getFgColorMode(); const bgModeRaw = cell.getBgColorMode(); const fgMode = fgModeRaw >> 24; const bgMode = bgModeRaw >> 24; const fg = fgMode !== 0 ? cell.getFgColor() : undefined; const bg = bgMode !== 0 ? cell.getBgColor() : undefined; rowCells.push({ char: cell.getChars() || " ", fg, bg, fgMode: fgMode !== 0 ? fgMode : undefined, bgMode: bgMode !== 0 ? bgMode : undefined, bold: cell.isBold() !== 0, italic: cell.isItalic() !== 0, underline: cell.isUnderline() !== 0, dim: cell.isDim() !== 0, inverse: cell.isInverse() !== 0, strikethrough: cell.isStrikethrough() !== 0, }); } else { rowCells.push({ char: " ", fg: undefined, bg: undefined }); } } else { rowCells.push({ char: " ", fg: undefined, bg: undefined }); } } scrollback.push(rowCells); } return scrollback; } function extractCursorState(terminal: TerminalType): TerminalState["cursor"] { const coreService = (terminal as any)._core?.coreService; const cursorStyle = coreService?.decPrivateModes?.cursorStyle; const normalizedCursorStyle = cursorStyle === "block" || cursorStyle === "underline" || cursorStyle === "bar" ? cursorStyle : undefined; const cursorBlink = typeof coreService?.decPrivateModes?.cursorBlink === "boolean" ? coreService.decPrivateModes.cursorBlink : undefined; const hidden = Boolean(coreService?.isCursorHidden); return { row: terminal.buffer.active.cursorY, col: terminal.buffer.active.cursorX, ...(hidden ? { hidden: true } : {}), ...(normalizedCursorStyle ? { style: normalizedCursorStyle } : {}), ...(typeof cursorBlink === "boolean" ? { blink: cursorBlink } : {}), }; } function cellsToPlainText(cells: TerminalCell[], options: { stripAnsi: boolean }): string { const text = cells.map((cell) => cell.char).join("").trimEnd(); return options.stripAnsi ? stripAnsi(text) : text; } function resolveCaptureLineIndex( lineNumber: number | undefined, totalLines: number, fallback: "start" | "end", ): number { if (totalLines === 0) { return fallback === "start" ? 0 : -1; } const defaultIndex = fallback === "start" ? 0 : totalLines - 1; if (typeof lineNumber !== "number") { return defaultIndex; } const resolvedIndex = lineNumber < 0 ? totalLines + lineNumber : lineNumber; if (resolvedIndex < 0) { return 0; } if (resolvedIndex >= totalLines) { return totalLines - 1; } return resolvedIndex; } export function captureTerminalLines( terminal: TerminalSession, options: CaptureTerminalLinesOptions = {}, ): CaptureTerminalLinesResult { const state = terminal.getState(); const allLines = [...state.scrollback, ...state.grid].map((cells) => cellsToPlainText(cells, { stripAnsi: options.stripAnsi ?? true }), ); const totalLines = allLines.length; const startIndex = resolveCaptureLineIndex(options.start, totalLines, "start"); const endIndex = resolveCaptureLineIndex(options.end, totalLines, "end"); if (totalLines === 0 || startIndex > endIndex) { return { lines: [], totalLines, }; } return { lines: allLines.slice(startIndex, endIndex + 1), totalLines, }; } export async function createTerminal(options: CreateTerminalOptions): Promise { const { cwd, shell, env = {}, rows = 24, cols = 80, name = "Terminal", } = options; const resolvedShell = shell ?? resolveDefaultTerminalShell(); const id = randomUUID(); const listeners = new Set<(msg: ServerMessage) => void>(); const exitListeners = new Set<() => void>(); let killed = false; let disposed = false; let exitEmitted = false; // Create xterm.js headless terminal const terminal = new Terminal({ rows, cols, scrollback: 1000, allowProposedApi: true, }); ensureNodePtySpawnHelperExecutableForCurrentPlatform(); // Create PTY const ptyProcess = pty.spawn(resolvedShell, [], { name: "xterm-256color", cols, rows, cwd, env: { ...process.env, ...env, TERM: "xterm-256color", }, }); // Respond to DA1 queries (CSI c or CSI 0 c) — apps like nvim query terminal capabilities terminal.parser.registerCsiHandler({ final: "c" }, (params) => { if (params.length === 0 || (params.length === 1 && params[0] === 0)) { ptyProcess.write("\x1b[?62;4;22c"); return true; } return false; }); function emitExit(): void { if (exitEmitted) { return; } exitEmitted = true; for (const listener of Array.from(exitListeners)) { try { listener(); } catch { // no-op } } exitListeners.clear(); } function disposeResources(): void { if (disposed) { return; } disposed = true; terminal.dispose(); listeners.clear(); exitListeners.clear(); } // Pipe PTY output to terminal emulator ptyProcess.onData((data) => { if (killed) return; terminal.write(data, () => { if (disposed || killed) { return; } for (const listener of listeners) { listener({ type: "output", data }); } }); }); ptyProcess.onExit(() => { killed = true; emitExit(); disposeResources(); }); function getState(): TerminalState { return { rows: terminal.rows, cols: terminal.cols, grid: extractGrid(terminal), scrollback: extractScrollback(terminal), cursor: extractCursorState(terminal), }; } function getSize(): { rows: number; cols: number } { return { rows: terminal.rows, cols: terminal.cols, }; } function send(msg: ClientMessage): void { if (killed) return; switch (msg.type) { case "input": ptyProcess.write(msg.data); break; case "resize": terminal.resize(msg.cols, msg.rows); ptyProcess.resize(msg.cols, msg.rows); break; case "mouse": // Mouse events can be sent as escape sequences if terminal supports it // For now, we'll just ignore them - can be implemented later break; } } function subscribe(listener: (msg: ServerMessage) => void): () => void { listeners.add(listener); terminal.write("", () => { if (!disposed && listeners.has(listener)) { listener({ type: "snapshot", state: getState() }); } }); return () => { listeners.delete(listener); }; } function onExit(listener: () => void): () => void { if (killed) { queueMicrotask(() => { try { listener(); } catch { // no-op } }); return () => {}; } exitListeners.add(listener); return () => { exitListeners.delete(listener); }; } function kill(): void { if (!killed) { killed = true; ptyProcess.kill(); emitExit(); } disposeResources(); } // Small delay to let shell initialize await new Promise((resolve) => setTimeout(resolve, 50)); return { id, name, cwd, send, subscribe, onExit, getSize, getState, kill, }; }