mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
189 lines
6.4 KiB
TypeScript
189 lines
6.4 KiB
TypeScript
import { createRequire } from "node:module";
|
|
import type { TerminalRetention } from "../../domain/process/processCaptureCoverage.js";
|
|
import type { BoundedProcessTextNormalization } from "./ProcessNormalization.js";
|
|
|
|
import type {
|
|
RecordProcessCaptureEvent,
|
|
RenderedTerminalFrame,
|
|
} from "../../domain/process/processCapture.js";
|
|
|
|
const require = createRequire(import.meta.url);
|
|
// SAFETY: both pinned xterm packages publish CommonJS at runtime and matching declarations.
|
|
const HeadlessPackage =
|
|
require("@xterm/headless") as typeof import("@xterm/headless");
|
|
// SAFETY: the addon package is pinned with the compatible headless xterm release.
|
|
const SerializePackage =
|
|
require("@xterm/addon-serialize") as typeof import("@xterm/addon-serialize");
|
|
|
|
const TRAILING_SPACES = / +$/u;
|
|
|
|
interface TerminalRendererOptions {
|
|
readonly columns: number;
|
|
readonly rows: number;
|
|
readonly scrollback: number;
|
|
readonly maxBytes: number;
|
|
readonly normalize: (
|
|
value: string,
|
|
maxBytes: number,
|
|
) => BoundedProcessTextNormalization;
|
|
readonly recordEvent?: RecordProcessCaptureEvent;
|
|
}
|
|
|
|
/**
|
|
* Owns one headless terminal and serializes writes into deterministic frames.
|
|
* Reconstructs bounded terminal states while raw PTY chunks remain authoritative.
|
|
*
|
|
* Rendered frames answer what an operator saw after control-sequence handling;
|
|
* raw frames preserve byte/chunk differences that can render identically.
|
|
* Comparisons retain both because neither representation subsumes the other.
|
|
*/
|
|
export class TerminalRenderer {
|
|
readonly #terminal: InstanceType<typeof HeadlessPackage.Terminal>;
|
|
readonly #serializeAddon = new SerializePackage.SerializeAddon();
|
|
readonly #frames: RenderedTerminalFrame[] = [];
|
|
#pending: Promise<void> = Promise.resolve();
|
|
#capturedBytes = 0;
|
|
#observedBytes = 0;
|
|
#observedFrames = 0;
|
|
#truncated = false;
|
|
#unknownSizeFrames = 0;
|
|
|
|
constructor(private readonly options: TerminalRendererOptions) {
|
|
this.#terminal = new HeadlessPackage.Terminal({
|
|
allowProposedApi: true,
|
|
cols: options.columns,
|
|
rows: options.rows,
|
|
scrollback: options.scrollback,
|
|
});
|
|
this.#terminal.loadAddon(this.#serializeAddon);
|
|
}
|
|
|
|
/** Queue one PTY chunk and capture state only after xterm has parsed it. */
|
|
write(data: string, atMs: number): void {
|
|
this.#queue(
|
|
() =>
|
|
new Promise<void>((resolveWrite, rejectWrite) => {
|
|
this.#terminal.write(data, () => {
|
|
try {
|
|
this.#capture(atMs);
|
|
resolveWrite();
|
|
} catch (error) {
|
|
rejectWrite(error);
|
|
}
|
|
});
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** Queue a terminal resize after every preceding write. */
|
|
resize(columns: number, rows: number, atMs: number): void {
|
|
this.#queue(() => {
|
|
this.#terminal.resize(columns, rows);
|
|
this.#capture(atMs);
|
|
});
|
|
}
|
|
|
|
/** Await all queued parsing and return immutable rendered observations. */
|
|
async frames(): Promise<readonly RenderedTerminalFrame[]> {
|
|
await this.#pending;
|
|
return structuredClone(this.#frames);
|
|
}
|
|
|
|
/** Whether a rendered observation exceeded its independent capture budget. */
|
|
truncated(): boolean {
|
|
return this.#truncated;
|
|
}
|
|
|
|
/** Budget accounting after awaiting queued writes with frames(). */
|
|
retention(): TerminalRetention {
|
|
return {
|
|
budget_bytes: this.options.maxBytes,
|
|
observed_bytes: this.#observedBytes,
|
|
retained_bytes: this.#capturedBytes,
|
|
observed_frames: this.#observedFrames,
|
|
retained_frames: this.#frames.length,
|
|
...(this.#unknownSizeFrames === 0
|
|
? {}
|
|
: { unknown_size_frames: this.#unknownSizeFrames }),
|
|
};
|
|
}
|
|
|
|
/** Release addon and terminal resources after all writes settle. */
|
|
async dispose(): Promise<void> {
|
|
// frames() owns observation failures; cleanup reports only disposal failures.
|
|
await this.#pending.catch(() => undefined);
|
|
// xterm owns and disposes every addon loaded through loadAddon().
|
|
this.#terminal.dispose();
|
|
}
|
|
|
|
#queue(operation: () => void | Promise<void>): void {
|
|
this.#pending = this.#pending.then(operation);
|
|
// PTY callbacks can fail before capture completion awaits frames(). Keep
|
|
// the original rejection observable there without an unhandled rejection.
|
|
void this.#pending.catch(() => undefined);
|
|
}
|
|
|
|
#capture(atMs: number): void {
|
|
const buffer = this.#terminal.buffer.active;
|
|
const lines: string[] = [];
|
|
this.#observedFrames += 1;
|
|
let remainingBytes = this.options.maxBytes - this.#capturedBytes;
|
|
let bytes = 0;
|
|
for (let row = 0; row < this.#terminal.rows; row += 1) {
|
|
const line = buffer.getLine(buffer.viewportY + row);
|
|
const renderedLine =
|
|
line?.translateToString(false, 0, this.#terminal.cols) ??
|
|
" ".repeat(this.#terminal.cols);
|
|
const trailingSpaceBytes = Buffer.byteLength(
|
|
renderedLine.match(TRAILING_SPACES)?.[0] ?? "",
|
|
);
|
|
const normalized = this.options.normalize(
|
|
renderedLine,
|
|
Math.min(Number.MAX_SAFE_INTEGER, remainingBytes + trailingSpaceBytes),
|
|
);
|
|
if (normalized.state === "over_budget") {
|
|
this.#unknownSizeFrames += 1;
|
|
this.#truncated = true;
|
|
return;
|
|
}
|
|
const normalizedLine = normalized.value.replace(TRAILING_SPACES, "");
|
|
const lineBytes = Buffer.byteLength(normalizedLine);
|
|
if (lineBytes > remainingBytes) {
|
|
this.#unknownSizeFrames += 1;
|
|
this.#truncated = true;
|
|
return;
|
|
}
|
|
remainingBytes -= lineBytes;
|
|
bytes += lineBytes;
|
|
// Trailing blank cells carry no information beyond `columns`; dropping
|
|
// them keeps sparse screens from costing a full row per blank line.
|
|
lines.push(normalizedLine);
|
|
}
|
|
const normalizedState = this.options.normalize(
|
|
this.#serializeAddon.serialize(),
|
|
remainingBytes,
|
|
);
|
|
if (normalizedState.state === "over_budget") {
|
|
this.#unknownSizeFrames += 1;
|
|
this.#truncated = true;
|
|
return;
|
|
}
|
|
bytes += normalizedState.utf8Bytes;
|
|
this.#observedBytes += bytes;
|
|
this.#capturedBytes += bytes;
|
|
const sequence = this.#frames.length;
|
|
this.#frames.push({
|
|
sequence,
|
|
at_ms: atMs,
|
|
columns: this.#terminal.cols,
|
|
rows: this.#terminal.rows,
|
|
cursor_x: buffer.cursorX,
|
|
cursor_y: buffer.cursorY,
|
|
active_buffer: buffer.type,
|
|
lines,
|
|
serialized_state: normalizedState.value,
|
|
});
|
|
this.options.recordEvent?.("rendered_frames", sequence);
|
|
}
|
|
}
|