Files
pi-config/extensions/pi-interactive-shell/pty-session.ts

615 lines
18 KiB
TypeScript

import { stripVTControlCharacters } from "node:util";
import { spawn, type IPty } from "zigpty";
import type { IBufferCell, Terminal as XtermTerminal } from "@xterm/headless";
import xterm from "@xterm/headless";
import { SerializeAddon } from "@xterm/addon-serialize";
import { sliceLogOutput, trimRawOutput } from "./pty-log.js";
import { splitAroundDsr, buildCursorPositionResponse } from "./pty-protocol.js";
const Terminal = xterm.Terminal;
// Regex patterns for sanitizing terminal output (used by sanitizeLine for viewport rendering)
const OSC_REGEX = /\x1b\][^\x07]*(?:\x07|\x1b\\)/g;
const APC_REGEX = /\x1b_[^\x07\x1b]*(?:\x07|\x1b\\)/g;
const DCS_REGEX = /\x1bP[^\x07\x1b]*(?:\x07|\x1b\\)/g;
const CSI_REGEX = /\x1b\[[0-9;?]*[A-Za-z]/g;
const ESC_SINGLE_REGEX = /\x1b[@-_]/g;
const CONTROL_REGEX = /[\x00-\x08\x0B\x0C\x0E-\x1A\x1C-\x1F\x7F]/g;
function sanitizeLine(line: string): string {
let out = line;
if (out.includes("\u001b")) {
out = out.replace(OSC_REGEX, "");
out = out.replace(APC_REGEX, "");
out = out.replace(DCS_REGEX, "");
out = out.replace(CSI_REGEX, (match) => (match.endsWith("m") ? match : ""));
out = out.replace(ESC_SINGLE_REGEX, "");
}
if (out.includes("\t")) {
out = out.replace(/\t/g, " ");
}
if (out.includes("\r")) {
out = out.replace(/\r/g, "");
}
out = out.replace(CONTROL_REGEX, "");
return out;
}
type CellStyle = {
bold: boolean;
dim: boolean;
italic: boolean;
underline: boolean;
inverse: boolean;
invisible: boolean;
strikethrough: boolean;
fgMode: "default" | "palette" | "rgb";
fg: number;
bgMode: "default" | "palette" | "rgb";
bg: number;
};
function styleKey(style: CellStyle): string {
return [
style.bold ? "b" : "-",
style.dim ? "d" : "-",
style.italic ? "i" : "-",
style.underline ? "u" : "-",
style.inverse ? "v" : "-",
style.invisible ? "x" : "-",
style.strikethrough ? "s" : "-",
`fg:${style.fgMode}:${style.fg}`,
`bg:${style.bgMode}:${style.bg}`,
].join("");
}
function rgbToSgr(isFg: boolean, hex: number): string {
const r = (hex >> 16) & 0xff;
const g = (hex >> 8) & 0xff;
const b = hex & 0xff;
return isFg ? `38;2;${r};${g};${b}` : `48;2;${r};${g};${b}`;
}
function paletteToSgr(isFg: boolean, idx: number): string {
return isFg ? `38;5;${idx}` : `48;5;${idx}`;
}
function sgrForStyle(style: CellStyle): string {
const parts: string[] = ["0"];
if (style.bold) parts.push("1");
if (style.dim) parts.push("2");
if (style.italic) parts.push("3");
if (style.underline) parts.push("4");
if (style.inverse) parts.push("7");
if (style.invisible) parts.push("8");
if (style.strikethrough) parts.push("9");
if (style.fgMode === "rgb") parts.push(rgbToSgr(true, style.fg));
else if (style.fgMode === "palette") parts.push(paletteToSgr(true, style.fg));
if (style.bgMode === "rgb") parts.push(rgbToSgr(false, style.bg));
else if (style.bgMode === "palette") parts.push(paletteToSgr(false, style.bg));
return `\u001b[${parts.join(";")}m`;
}
function normalizePaletteColor(mode: "default" | "palette" | "rgb", value: number): { mode: "default" | "palette" | "rgb"; value: number } {
if (mode !== "palette") return { mode, value };
// xterm uses special palette values (>= 256) to represent defaults/specials; do not emit invalid 38;5;N codes.
if (value < 0 || value > 255) {
return { mode: "default", value: 0 };
}
return { mode: "palette", value };
}
export interface PtySessionOptions {
command: string;
shell?: string;
cwd?: string;
env?: Record<string, string | undefined>;
cols?: number;
rows?: number;
scrollback?: number;
ansiReemit?: boolean;
}
export interface PtySessionEvents {
onData?: (data: string) => void;
onExit?: (exitCode: number, signal?: number) => void;
}
// Simple write queue to ensure ordered writes to terminal
class WriteQueue {
private queue = Promise.resolve();
enqueue(fn: () => Promise<void> | void): void {
this.queue = this.queue.then(() => fn()).catch((err) => {
console.error("WriteQueue error:", err);
});
}
async drain(): Promise<void> {
await this.queue;
}
}
export class PtyTerminalSession {
private ptyProcess: IPty;
private xterm: XtermTerminal;
private serializer: SerializeAddon | null = null;
private _exited = false;
private _exitCode: number | null = null;
private _signal: number | undefined;
private scrollOffset = 0;
private followBottom = true; // Auto-scroll to bottom when new data arrives
// Raw output buffer for incremental streaming
private rawOutput = "";
private lastStreamPosition = 0;
// Write queue for ordered terminal writes
private writeQueue = new WriteQueue();
private dataHandler: ((data: string) => void) | undefined;
private exitHandler: ((exitCode: number, signal?: number) => void) | undefined;
private additionalDataListeners: Array<(data: string) => void> = [];
private additionalExitListeners: Array<(exitCode: number, signal?: number) => void> = [];
// Trim raw output buffer if it exceeds max size
private trimRawOutputIfNeeded(): void {
const trimmed = trimRawOutput(this.rawOutput, this.lastStreamPosition);
this.rawOutput = trimmed.rawOutput;
this.lastStreamPosition = trimmed.lastStreamPosition;
}
constructor(options: PtySessionOptions, events: PtySessionEvents = {}) {
const {
command,
cwd = process.cwd(),
env,
cols = 80,
rows = 24,
scrollback = 5000,
ansiReemit = true,
} = options;
this.dataHandler = events.onData;
this.exitHandler = events.onExit;
this.xterm = new Terminal({ cols, rows, scrollback, allowProposedApi: true, convertEol: true });
if (ansiReemit) {
this.serializer = new SerializeAddon();
this.xterm.loadAddon(this.serializer);
}
const shell =
options.shell ??
(process.platform === "win32"
? process.env.COMSPEC || "cmd.exe"
: process.env.SHELL || "/bin/sh");
const shellArgs = process.platform === "win32" ? ["/c", command] : ["-c", command];
const mergedEnvRaw = env ? { ...process.env, ...env } : { ...process.env };
if (!mergedEnvRaw.TERM) mergedEnvRaw.TERM = "xterm-256color";
const mergedEnv: Record<string, string> = {};
for (const [key, value] of Object.entries(mergedEnvRaw)) {
if (value !== undefined) mergedEnv[key] = value;
}
this.ptyProcess = spawn(shell, shellArgs, {
name: "xterm-256color",
cols,
rows,
cwd,
env: mergedEnv,
});
this.ptyProcess.onData((data) => {
const chunk = typeof data === "string" ? data : data.toString("utf8");
// Handle DSR (Device Status Report) cursor position queries
// TUI apps send ESC[6n or ESC[?6n expecting ESC[row;colR response
// We must process in order: write text to xterm, THEN respond to DSR
const { segments, hasDsr } = splitAroundDsr(chunk);
if (!hasDsr) {
// Fast path: no DSR in data
this.writeQueue.enqueue(async () => {
this.rawOutput += chunk;
this.trimRawOutputIfNeeded();
await new Promise<void>((resolve) => {
this.xterm.write(chunk, () => resolve());
});
this.notifyDataListeners(chunk);
});
} else {
// Process each segment in order, responding to DSR after writing preceding text
for (const segment of segments) {
this.writeQueue.enqueue(async () => {
if (segment.text) {
this.rawOutput += segment.text;
this.trimRawOutputIfNeeded();
await new Promise<void>((resolve) => {
this.xterm.write(segment.text, () => resolve());
});
this.notifyDataListeners(segment.text);
}
// If there was a DSR after this segment, respond with current cursor position
if (segment.dsrAfter) {
const buffer = this.xterm.buffer.active;
const response = buildCursorPositionResponse(buffer.cursorY + 1, buffer.cursorX + 1);
this.ptyProcess.write(response);
}
});
}
}
});
this.ptyProcess.onExit(({ exitCode, signal }) => {
this._exited = true;
this._exitCode = exitCode;
this._signal = signal;
// Append exit message to terminal buffer, then notify handler after queue drains
const exitMsg = `\n[Process exited with code ${exitCode}${signal ? ` (signal: ${signal})` : ""}]\n`;
this.writeQueue.enqueue(async () => {
this.rawOutput += exitMsg;
await new Promise<void>((resolve) => {
this.xterm.write(exitMsg, () => resolve());
});
});
// Wait for writeQueue to drain before calling exit listeners
// This ensures exit message is in rawOutput and xterm buffer
this.writeQueue.drain().then(() => {
this.notifyExitListeners(exitCode, signal);
});
});
}
setEventHandlers(events: PtySessionEvents): void {
this.dataHandler = events.onData;
this.exitHandler = events.onExit;
}
addDataListener(cb: (data: string) => void): () => void {
this.additionalDataListeners.push(cb);
return () => {
const idx = this.additionalDataListeners.indexOf(cb);
if (idx >= 0) this.additionalDataListeners.splice(idx, 1);
};
}
addExitListener(cb: (exitCode: number, signal?: number) => void): () => void {
this.additionalExitListeners.push(cb);
return () => {
const idx = this.additionalExitListeners.indexOf(cb);
if (idx >= 0) this.additionalExitListeners.splice(idx, 1);
};
}
private notifyDataListeners(data: string): void {
this.dataHandler?.(data);
// Copy array to avoid issues if a listener unsubscribes during iteration
for (const listener of [...this.additionalDataListeners]) {
listener(data);
}
}
private notifyExitListeners(exitCode: number, signal?: number): void {
this.exitHandler?.(exitCode, signal);
// Copy array to avoid issues if a listener unsubscribes during iteration
for (const listener of [...this.additionalExitListeners]) {
listener(exitCode, signal);
}
}
get exited(): boolean {
return this._exited;
}
get exitCode(): number | null {
return this._exitCode;
}
get signal(): number | undefined {
return this._signal;
}
get pid(): number {
return this.ptyProcess.pid;
}
get cols(): number {
return this.xterm.cols;
}
get rows(): number {
return this.xterm.rows;
}
write(data: string): void {
if (!this._exited) {
this.ptyProcess.write(data);
}
}
resize(cols: number, rows: number): void {
if (cols === this.xterm.cols && rows === this.xterm.rows) return;
if (cols < 1 || rows < 1) return;
this.xterm.resize(cols, rows);
if (!this._exited) {
this.ptyProcess.resize(cols, rows);
}
}
private renderLineFromCells(lineIndex: number, cols: number): string {
const buffer = this.xterm.buffer.active;
const line = buffer.getLine(lineIndex);
let currentStyle: CellStyle = {
bold: false,
dim: false,
italic: false,
underline: false,
inverse: false,
invisible: false,
strikethrough: false,
fgMode: "default",
fg: 0,
bgMode: "default",
bg: 0,
};
let currentKey = styleKey(currentStyle);
let out = sgrForStyle(currentStyle);
for (let x = 0; x < cols; x++) {
const cell: IBufferCell | undefined = line?.getCell(x);
const width = cell?.getWidth() ?? 1;
if (width === 0) continue;
const chars = cell?.getChars() ?? " ";
const cellChars = chars.length === 0 ? " " : chars;
const rawFgMode: CellStyle["fgMode"] = cell?.isFgDefault()
? "default"
: cell?.isFgRGB()
? "rgb"
: cell?.isFgPalette()
? "palette"
: "default";
const rawBgMode: CellStyle["bgMode"] = cell?.isBgDefault()
? "default"
: cell?.isBgRGB()
? "rgb"
: cell?.isBgPalette()
? "palette"
: "default";
const fg = normalizePaletteColor(rawFgMode, cell?.getFgColor() ?? 0);
const bg = normalizePaletteColor(rawBgMode, cell?.getBgColor() ?? 0);
const nextStyle: CellStyle = {
bold: !!cell?.isBold(),
dim: !!cell?.isDim(),
italic: !!cell?.isItalic(),
underline: !!cell?.isUnderline(),
inverse: !!cell?.isInverse(),
invisible: !!cell?.isInvisible(),
strikethrough: !!cell?.isStrikethrough(),
fgMode: fg.mode,
fg: fg.value,
bgMode: bg.mode,
bg: bg.value,
};
const nextKey = styleKey(nextStyle);
if (nextKey !== currentKey) {
currentStyle = nextStyle;
currentKey = nextKey;
out += sgrForStyle(currentStyle);
}
out += cellChars;
}
return out + "\u001b[0m";
}
getViewportLines(options: { ansi?: boolean } = {}): string[] {
const buffer = this.xterm.buffer.active;
const lines: string[] = [];
const totalLines = buffer.length;
// If following bottom, reset scroll offset at render time (not on each data event)
// This prevents flickering from scroll position racing with buffer updates
if (this.followBottom) {
this.scrollOffset = 0;
}
const viewportStart = Math.max(0, totalLines - this.xterm.rows - this.scrollOffset);
const useAnsi = !!options.ansi;
if (useAnsi) {
for (let i = 0; i < this.xterm.rows; i++) {
const lineIndex = viewportStart + i;
const rendered = this.renderLineFromCells(lineIndex, this.xterm.cols);
// Safety fallback: if our cell->SGR renderer produces no visible non-space content
// but the buffer line contains text, fall back to plain translation. This prevents
// “blank screen” regressions on terminals that use special color encodings.
const plain = buffer.getLine(lineIndex)?.translateToString(true) ?? "";
const renderedPlain = rendered
.replace(/\x1b\[[0-9;]*m/g, "")
.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "");
if (plain.trim().length > 0 && renderedPlain.trim().length === 0) {
lines.push(sanitizeLine(plain) + "\u001b[0m");
} else {
lines.push(rendered);
}
}
return lines;
}
for (let i = 0; i < this.xterm.rows; i++) {
const lineIndex = viewportStart + i;
if (lineIndex < totalLines) {
const line = buffer.getLine(lineIndex);
lines.push(sanitizeLine(line?.translateToString(true) ?? ""));
} else {
lines.push("");
}
}
return lines;
}
getTailLines(options: { lines: number; ansi?: boolean; maxChars?: number }): {
lines: string[];
totalLinesInBuffer: number;
truncatedByChars: boolean;
} {
const requested = Math.max(0, Math.trunc(options.lines));
const maxChars = options.maxChars !== undefined ? Math.max(0, Math.trunc(options.maxChars)) : undefined;
const buffer = this.xterm.buffer.active;
const totalLinesInBuffer = buffer.length;
if (requested === 0) {
return { lines: [], totalLinesInBuffer, truncatedByChars: false };
}
const start = Math.max(0, totalLinesInBuffer - requested);
const out: string[] = [];
let remainingChars = maxChars;
let truncatedByChars = false;
const useAnsi = options.ansi && this.serializer;
if (useAnsi) {
const serialized = this.serializer!.serialize();
const serializedLines = serialized.split(/\r?\n/);
if (serializedLines.length >= totalLinesInBuffer) {
for (let i = start; i < totalLinesInBuffer; i++) {
const raw = serializedLines[i] ?? "";
const line = sanitizeLine(raw) + "\u001b[0m";
if (remainingChars !== undefined) {
if (remainingChars <= 0) {
truncatedByChars = true;
break;
}
remainingChars -= line.length;
}
out.push(line);
}
return { lines: out, totalLinesInBuffer, truncatedByChars };
}
}
for (let i = start; i < totalLinesInBuffer; i++) {
const lineObj = buffer.getLine(i);
const line = sanitizeLine(lineObj?.translateToString(true) ?? "");
if (remainingChars !== undefined) {
if (remainingChars <= 0) {
truncatedByChars = true;
break;
}
remainingChars -= line.length;
}
out.push(line);
}
return { lines: out, totalLinesInBuffer, truncatedByChars };
}
/**
* Get raw output stream with optional incremental reading.
* @param options.sinceLast - If true, only return output since last call
* @param options.stripAnsi - If true, strip ANSI escape codes (default: true)
*/
getRawStream(options: { sinceLast?: boolean; stripAnsi?: boolean } = {}): string {
let output: string;
if (options.sinceLast) {
output = this.rawOutput.substring(this.lastStreamPosition);
this.lastStreamPosition = this.rawOutput.length;
} else {
output = this.rawOutput;
}
// Strip ANSI codes and control characters by default using Node.js built-in
if (options.stripAnsi !== false && output) {
output = stripVTControlCharacters(output);
}
return output;
}
/**
* Get a slice of log output with offset/limit pagination.
* Similar to Clawdbot's sliceLogLines - enables reading specific ranges of output.
* @param options.offset - Line number to start from (0-indexed). If omitted with limit, returns tail.
* @param options.limit - Max number of lines to return
* @param options.stripAnsi - If true, strip ANSI escape codes (default: true)
*/
getLogSlice(options: { offset?: number; limit?: number; stripAnsi?: boolean } = {}): {
slice: string;
totalLines: number;
totalChars: number;
sliceLineCount: number;
} {
return sliceLogOutput(this.rawOutput, options);
}
scrollUp(lines: number): void {
const buffer = this.xterm.buffer.active;
const maxScroll = Math.max(0, buffer.length - this.xterm.rows);
this.scrollOffset = Math.min(this.scrollOffset + lines, maxScroll);
this.followBottom = false; // User scrolled up, stop auto-following
}
scrollDown(lines: number): void {
this.scrollOffset = Math.max(0, this.scrollOffset - lines);
// If scrolled to bottom, resume auto-following
if (this.scrollOffset === 0) {
this.followBottom = true;
}
}
scrollToBottom(): void {
this.scrollOffset = 0;
this.followBottom = true;
}
isScrolledUp(): boolean {
return this.scrollOffset > 0;
}
kill(signal: string = "SIGTERM"): void {
if (this._exited) return;
const pid = this.ptyProcess.pid;
// Try to kill the entire process tree (prevents orphan child processes)
if (process.platform !== "win32" && pid) {
try {
// Kill process group (negative PID)
process.kill(-pid, signal as NodeJS.Signals);
return;
} catch {
// Fall through to direct kill
}
}
// Direct kill as fallback
try {
this.ptyProcess.kill(signal);
} catch {
// Process may already be dead
}
}
dispose(): void {
this.kill();
try {
this.ptyProcess.close();
} catch {
// Ignore close errors during teardown.
}
this.xterm.dispose();
}
}