425 lines
10 KiB
TypeScript
425 lines
10 KiB
TypeScript
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";
|
|
|
|
const { Terminal } = xterm;
|
|
const require = createRequire(import.meta.url);
|
|
let nodePtySpawnHelperChecked = false;
|
|
const STATE_BROADCAST_INTERVAL_MS = 33;
|
|
|
|
export interface Cell {
|
|
char: string;
|
|
fg: number | undefined;
|
|
bg: number | undefined;
|
|
fgMode?: number; // 0=default, 1=16 ANSI, 2=256, 3=RGB
|
|
bgMode?: number;
|
|
bold?: boolean;
|
|
italic?: boolean;
|
|
underline?: boolean;
|
|
}
|
|
|
|
export interface Pos {
|
|
row: number;
|
|
col: number;
|
|
}
|
|
|
|
export interface TerminalState {
|
|
rows: number;
|
|
cols: number;
|
|
grid: Cell[][];
|
|
scrollback: Cell[][];
|
|
cursor: Pos;
|
|
}
|
|
|
|
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;
|
|
getState(): TerminalState;
|
|
kill(): void;
|
|
}
|
|
|
|
export interface CreateTerminalOptions {
|
|
cwd: string;
|
|
shell?: string;
|
|
env?: Record<string, string>;
|
|
rows?: number;
|
|
cols?: number;
|
|
name?: string;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
function extractCell(terminal: TerminalType, row: number, col: number): Cell {
|
|
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,
|
|
};
|
|
}
|
|
|
|
function extractGrid(terminal: TerminalType): Cell[][] {
|
|
const grid: Cell[][] = [];
|
|
const buffer = terminal.buffer.active;
|
|
// Visible viewport starts at baseY
|
|
const baseY = buffer.baseY;
|
|
|
|
for (let row = 0; row < terminal.rows; row++) {
|
|
const rowCells: Cell[] = [];
|
|
for (let col = 0; col < terminal.cols; col++) {
|
|
rowCells.push(extractCell(terminal, baseY + row, col));
|
|
}
|
|
grid.push(rowCells);
|
|
}
|
|
|
|
return grid;
|
|
}
|
|
|
|
function extractScrollback(terminal: TerminalType): Cell[][] {
|
|
const scrollback: Cell[][] = [];
|
|
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: Cell[] = [];
|
|
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,
|
|
});
|
|
} else {
|
|
rowCells.push({ char: " ", fg: undefined, bg: undefined });
|
|
}
|
|
} else {
|
|
rowCells.push({ char: " ", fg: undefined, bg: undefined });
|
|
}
|
|
}
|
|
scrollback.push(rowCells);
|
|
}
|
|
|
|
return scrollback;
|
|
}
|
|
|
|
export async function createTerminal(options: CreateTerminalOptions): Promise<TerminalSession> {
|
|
const {
|
|
cwd,
|
|
shell = process.env.SHELL || "/bin/sh",
|
|
env = {},
|
|
rows = 24,
|
|
cols = 80,
|
|
name = "Terminal",
|
|
} = options;
|
|
|
|
const id = randomUUID();
|
|
const listeners = new Set<(msg: ServerMessage) => void>();
|
|
const exitListeners = new Set<() => void>();
|
|
let killed = false;
|
|
let disposed = false;
|
|
let exitEmitted = false;
|
|
let stateBroadcastScheduled = false;
|
|
let stateBroadcastTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
// Create xterm.js headless terminal
|
|
const terminal = new Terminal({
|
|
rows,
|
|
cols,
|
|
scrollback: 1000,
|
|
allowProposedApi: true,
|
|
});
|
|
|
|
ensureNodePtySpawnHelperExecutableForCurrentPlatform();
|
|
|
|
// Create PTY
|
|
const ptyProcess = pty.spawn(shell, [], {
|
|
name: "xterm-256color",
|
|
cols,
|
|
rows,
|
|
cwd,
|
|
env: {
|
|
...process.env,
|
|
...env,
|
|
TERM: "xterm-256color",
|
|
},
|
|
});
|
|
|
|
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;
|
|
if (stateBroadcastTimer) {
|
|
clearTimeout(stateBroadcastTimer);
|
|
stateBroadcastTimer = null;
|
|
}
|
|
stateBroadcastScheduled = false;
|
|
terminal.dispose();
|
|
listeners.clear();
|
|
exitListeners.clear();
|
|
}
|
|
|
|
function emitStateToListeners(): void {
|
|
stateBroadcastScheduled = false;
|
|
if (disposed || killed || listeners.size === 0) {
|
|
return;
|
|
}
|
|
const state = getState();
|
|
for (const listener of listeners) {
|
|
listener({ type: "snapshot", state });
|
|
}
|
|
}
|
|
|
|
function scheduleStateBroadcast(): void {
|
|
if (disposed || killed || listeners.size === 0 || stateBroadcastScheduled) {
|
|
return;
|
|
}
|
|
stateBroadcastScheduled = true;
|
|
stateBroadcastTimer = setTimeout(() => {
|
|
stateBroadcastTimer = null;
|
|
emitStateToListeners();
|
|
}, STATE_BROADCAST_INTERVAL_MS);
|
|
}
|
|
|
|
// Pipe PTY output to terminal emulator
|
|
ptyProcess.onData((data) => {
|
|
if (killed) return;
|
|
for (const listener of listeners) {
|
|
listener({ type: "output", data });
|
|
}
|
|
terminal.write(data, () => {
|
|
scheduleStateBroadcast();
|
|
});
|
|
});
|
|
|
|
ptyProcess.onExit(() => {
|
|
killed = true;
|
|
emitExit();
|
|
disposeResources();
|
|
});
|
|
|
|
function getState(): TerminalState {
|
|
return {
|
|
rows: terminal.rows,
|
|
cols: terminal.cols,
|
|
grid: extractGrid(terminal),
|
|
scrollback: extractScrollback(terminal),
|
|
cursor: {
|
|
row: terminal.buffer.active.cursorY,
|
|
col: terminal.buffer.active.cursorX,
|
|
},
|
|
};
|
|
}
|
|
|
|
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);
|
|
|
|
queueMicrotask(() => {
|
|
if (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,
|
|
getState,
|
|
kill,
|
|
};
|
|
}
|