import * as plugins from './plugins.js'; // Type-only: the emulator and the addon are constructed through plugins, but their own named // types are not worth restating structurally. import type { IDisposable, Terminal } from '@xterm/headless'; import type { SerializeAddon } from '@xterm/addon-serialize'; /** * How much reconstructed history a snapshot carries. The mirror emulates the terminal at the * PTY's own grid, so this is rows of scrollback, not bytes: a reopened terminal gets its last * screen plus this much history instead of every byte the root ever wrote. */ const mirrorScrollbackRows = 1_000; /** * How much history the payload is allowed to cost. A pathological screen — every cell carrying its * own colour — serializes to roughly twenty bytes per cell, so the row budget alone does not bound * it. Above this the snapshot drops its scrollback so the last screen stays exact; it is a bound on * reconstructed history, not a cap on the payload, because a large grid's own screen can exceed it * and a screen that is not serialized whole would be the wrong screen. */ const maxSnapshotBytes = 512 * 1024; /** * PTY flow control. The emulator parses in time slices, so a flooding root outruns it; pausing * the PTY above the high watermark makes the root block on write exactly as it would behind a * slow physical terminal, and resuming below the low watermark keeps the pause short. Both stay * far below the emulator's own 50 MB discard limit, which is what keeps that limit unreachable. */ const highBacklogWatermarkBytes = 256 * 1024; const lowBacklogWatermarkBytes = 32 * 1024; /** DECSCUSR parameter 0 is "whatever the terminal defaults to", so it is never re-emitted. */ const defaultCursorStyle = 0; /** * Mouse coordinate encodings the emulators implement; `default` is X10 coordinates. `?1005` and * `?1015` are deliberately absent: both emulators log them as unsupported and stay on whichever * encoding was active, so tracking them would replace a correct `?1006h` with a sequence the * restored terminal ignores. */ type TMouseEncoding = 'default' | 'sgr' | 'sgrPixels'; const mouseEncodingModes: Record, number> = { sgr: 1006, sgrPixels: 1016, }; type TBufferKind = 'normal' | 'alternate'; /** One-based inclusive DECSTBM margins. */ interface IScrollRegion { top: number; bottom: number; } /** An action that must take effect at an exact position in the output stream. */ interface IStreamBarrier { at: number; run: () => void; /** Runs instead of `run` when the mirror stops before the offset is parsed. */ cancel: () => void; } export interface IControllerTerminalMirrorOptions { cols: number; rows: number; /** * Applies PTY flow control. Called only on a change, and possibly before the PTY exists — the * caller reconciles `ptyPaused` once it does. */ setPtyPaused: (pausedArg: boolean) => void; } export interface IControllerTerminalSnapshotRequest { /** * Serializes the current screen alone and drops the reconstructed scrollback. Used when a peer * has to be handed the state again: the smaller payload is what lets it reach the live stream * instead of losing the delivery window once more while a larger one is acknowledged. */ screenOnly?: boolean; } export interface IControllerTerminalMirrorSnapshot { /** Absolute stream offset the reconstructed state is exact at. */ offset: number; /** Escape sequences that reproduce the state when written into a `cols`x`rows` grid. */ data: Buffer; cols: number; rows: number; } /** CSI parameters arrive as numbers, or as a sub-parameter group whose head carries the value. */ const numberParameter = (paramsArg: (number | number[])[], indexArg: number): number => { const value = paramsArg[indexArg]; if (typeof value === 'number') return value; if (Array.isArray(value) && typeof value[0] === 'number') return value[0]; return 0; }; /** * The controller's own picture of what a terminal currently shows. * * Every byte the PTY produces is parsed by a headless emulator, so the last state of a terminal is * always available as a bounded serialization instead of a raw byte replay: an attaching peer * receives the reconstructed screen at an exact stream offset and then streams live output from * that offset, and the raw output ring only has to cover the in-flight delivery window. * * The emulator is fed in stream order and never talks back: replies it would produce as a real * terminal (device attributes, cursor reports) are dropped, because the attached browser is the * terminal that answers those. */ export class ControllerTerminalMirror { private readonly emulator: Terminal; private readonly serializer: SerializeAddon; private readonly parserHandlers: IDisposable[] = []; private readonly barriers: IStreamBarrier[] = []; /** * State the serialize addon does not reproduce, tracked as the stream is parsed and re-emitted * around the snapshot: DECSTBM margins per buffer, DECTCEM cursor visibility, the DECSCUSR * cursor style, and the SGR or SGR-pixel mouse coordinate encoding a full-screen application * needs for its clicks to decode. Title, hyperlinks and underline style or colour are * deliberately not tracked: the addon drops them and they do not change how the terminal * behaves. */ private readonly scrollRegions = new Map(); /** * The last serialization, reused by every peer that asks for the same state. Peers attaching in * the same tick all resolve at one stream offset, and the tracked state can only change while * bytes are parsed — which moves the offset — or on a resize, which clears the cache, so an * equal offset and history choice is an equal screen. */ private cachedSnapshot?: { screenOnly: boolean; snapshot: IControllerTerminalMirrorSnapshot; }; private cursorVisible = true; private cursorStyle = defaultCursorStyle; private mouseEncoding: TMouseEncoding = 'default'; private writtenBytes = 0; private parsedBytes = 0; private paused = false; private flowControlReleased = false; private disposedState = false; private releasedState = false; private failureState?: Error; /** * `allowProposedApi` is required: the buffer, parser and mode accessors this class and the * serialize addon read are proposed API. */ constructor(private readonly options: IControllerTerminalMirrorOptions) { this.emulator = new plugins.xtermHeadless.Terminal({ cols: options.cols, rows: options.rows, scrollback: mirrorScrollbackRows, allowProposedApi: true, }); this.serializer = new plugins.xtermSerialize.SerializeAddon(); this.emulator.loadAddon(this.serializer); this.registerModeTracking(); } /** Absolute stream offset one past the last byte the mirror accepted. */ public get streamEnd(): number { return this.writtenBytes; } /** True while the PTY is held back because the emulator has not caught up. */ public get ptyPaused(): boolean { return this.paused; } public get disposed(): boolean { return this.disposedState; } /** * Why the mirror stopped reflecting its terminal. The emulator only rejects writes above its own * discard limit, which flow control keeps unreachable; if it ever happens the mirror fails * closed and attaching says so, instead of serving a state that is quietly wrong. */ public get failure(): Error | undefined { return this.failureState; } public write(chunkArg: Buffer): void { if (this.disposedState || this.failureState || chunkArg.byteLength === 0) return; // The offset is committed before the write, because a write whose buffer was empty parses // synchronously and would otherwise run its barriers against a stale stream end. this.writtenBytes += chunkArg.byteLength; const parsedEnd = this.writtenBytes; try { this.emulator.write(chunkArg, () => this.confirmParsed(parsedEnd)); } catch (errorArg) { this.fail(errorArg instanceof Error ? errorArg : new Error(String(errorArg))); return; } this.applyBacklogPressure(); } /** * Resizes the emulator at the current stream position, so output the root produced at the old * grid is still parsed at that grid. The emulator drops its scroll margins on resize, so the * tracked ones go with them. */ public resize(colsArg: number, rowsArg: number): void { if (this.disposedState || this.failureState) return; this.scheduleAtStreamEnd(() => { // The emulator ignores a resize to its current grid and keeps its margins with it, so a // reconciliation that lands on the same size must not drop the tracked ones either. if (this.emulator.cols === colsArg && this.emulator.rows === rowsArg) return; this.emulator.resize(colsArg, rowsArg); this.scrollRegions.clear(); this.cachedSnapshot = undefined; }); } /** * The reconstructed state at the mirror's current stream end. Resolves once the emulator has * parsed every byte accepted up to that offset, which is what makes the snapshot exact there: * the serialization runs inside the write callback of the chunk ending at the offset, so no * later byte can have been applied yet. */ public async requestSnapshot( requestArg: IControllerTerminalSnapshotRequest = {}, ): Promise { if (this.failureState) throw this.failureState; if (this.disposedState) throw new Error('The terminal state mirror is no longer available.'); const screenOnly = requestArg.screenOnly === true; const offset = this.writtenBytes; if (this.parsedBytes >= offset) return this.captureSnapshot(offset, screenOnly); return new Promise((resolve, reject) => { this.barriers.push({ at: offset, run: () => resolve(this.captureSnapshot(offset, screenOnly)), cancel: () => reject( this.failureState ?? new Error('The terminal state mirror is no longer available.'), ), }); }); } /** * Hands the PTY back for good while the emulator keeps parsing. Called once a terminal's root is * being stopped: node-pty closes a PTY shortly after its child exits, output still unread then is * lost, and the exit is only reported after that close — so output held back by a pause that is * live when the root dies can never be recovered. From the moment a stop is decided, everything * the root writes is its final flush and must reach the stream rather than be held back, which is * also why flow control does not re-engage afterwards. The backlog is then bounded by the stop * deadline alone; a root that floods past the emulator's own discard limit anyway makes the * mirror fail closed, exactly as any other refused write does. */ public releaseFlowControl(): void { if (this.flowControlReleased) return; this.flowControlReleased = true; if (!this.paused) return; this.paused = false; this.options.setPtyPaused(false); } public dispose(): void { if (this.disposedState) return; this.disposedState = true; this.release(); } private fail(errorArg: Error): void { if (this.failureState) return; this.failureState = errorArg; this.release(); } private release(): void { for (const barrier of this.barriers.splice(0, this.barriers.length)) barrier.cancel(); if (this.releasedState) return; this.releasedState = true; for (const handler of this.parserHandlers.splice(0, this.parserHandlers.length)) { handler.dispose(); } this.serializer.dispose(); this.emulator.dispose(); this.cachedSnapshot = undefined; if (this.paused) { this.paused = false; this.options.setPtyPaused(false); } } private confirmParsed(parsedEndArg: number): void { // The emulator keeps draining its pending writes after it was disposed, and those callbacks // must not reach flow control: the PTY was handed back in release() and re-pausing it there // would leave it paused behind a mirror that no longer reads. if (this.releasedState) return; this.parsedBytes = parsedEndArg; while (this.barriers.length > 0 && this.barriers[0].at <= this.parsedBytes) { this.barriers.shift()!.run(); } this.applyBacklogPressure(); } private scheduleAtStreamEnd(runArg: () => void): void { if (this.parsedBytes >= this.writtenBytes) { runArg(); return; } this.barriers.push({ at: this.writtenBytes, run: runArg, cancel: () => undefined }); } private applyBacklogPressure(): void { const backlog = this.writtenBytes - this.parsedBytes; if (!this.paused && !this.flowControlReleased && backlog >= highBacklogWatermarkBytes) { this.paused = true; this.options.setPtyPaused(true); return; } if (this.paused && backlog <= lowBacklogWatermarkBytes) { this.paused = false; this.options.setPtyPaused(false); } } private captureSnapshot( offsetArg: number, screenOnlyArg: boolean, ): IControllerTerminalMirrorSnapshot { const cached = this.cachedSnapshot; if (cached && cached.screenOnly === screenOnlyArg && cached.snapshot.offset === offsetArg) { return cached.snapshot; } const body = this.serializeBoundedBody(screenOnlyArg); const snapshot: IControllerTerminalMirrorSnapshot = { offset: offsetArg, data: Buffer.from(`${body}${this.untrackedStateSuffix()}`, 'utf8'), cols: this.emulator.cols, rows: this.emulator.rows, }; this.cachedSnapshot = { screenOnly: screenOnlyArg, snapshot }; return snapshot; } private serializeBoundedBody(screenOnlyArg: boolean): string { if (screenOnlyArg) return this.serializer.serialize({ scrollback: 0 }); const full = this.serializer.serialize(); if (Buffer.byteLength(full, 'utf8') <= maxSnapshotBytes) return full; return this.serializer.serialize({ scrollback: 0 }); } /** * Re-emits the tracked state the serialization leaves out. It goes after the body, which ends * with the addon's own mode block, so nothing here can influence how the body is painted. * DECSTBM is the one that needs care: it homes the cursor, so the cursor the body restored has * to be re-applied after it. */ private untrackedStateSuffix(): string { const parts: string[] = []; const region = this.scrollRegions.get(this.activeBufferKind()); if (region) { parts.push(`\x1b[${region.top};${region.bottom}r`, this.cursorPositionSequence(region)); } if (!this.cursorVisible) parts.push('\x1b[?25l'); if (this.mouseEncoding !== 'default') { parts.push(`\x1b[?${mouseEncodingModes[this.mouseEncoding]}h`); } if (this.cursorStyle !== defaultCursorStyle) parts.push(`\x1b[${this.cursorStyle} q`); return parts.join(''); } private cursorPositionSequence(regionArg: IScrollRegion): string { const buffer = this.emulator.buffer.active; const column = buffer.cursorX + 1; // Origin mode makes CUP relative to the scroll region, so the row is expressed in whichever // frame of reference the restored terminal will be in. const row = this.emulator.modes.originMode ? Math.max(1, buffer.cursorY + 1 - (regionArg.top - 1)) : buffer.cursorY + 1; return `\x1b[${row};${column}H`; } private activeBufferKind(): TBufferKind { return this.emulator.buffer.active.type === 'alternate' ? 'alternate' : 'normal'; } /** * Every handler returns false, which lets the emulator's own handler run afterwards: the mirror * observes a sequence, it never replaces its effect. */ private registerModeTracking(): void { const parser = this.emulator.parser; this.parserHandlers.push( parser.registerCsiHandler({ prefix: '?', final: 'h' }, (paramsArg) => { this.applyPrivateModes(paramsArg, true); return false; }), parser.registerCsiHandler({ prefix: '?', final: 'l' }, (paramsArg) => { this.applyPrivateModes(paramsArg, false); return false; }), parser.registerCsiHandler({ final: 'r' }, (paramsArg) => { this.applyScrollRegion(paramsArg); return false; }), parser.registerCsiHandler({ intermediates: ' ', final: 'q' }, (paramsArg) => { this.cursorStyle = paramsArg.length === 0 ? 1 : numberParameter(paramsArg, 0); return false; }), parser.registerCsiHandler({ intermediates: '!', final: 'p' }, () => { // DECSTR: the emulator shows the cursor, drops the active buffer's margins and resets the // cursor style, and leaves mouse reporting alone. this.cursorVisible = true; this.cursorStyle = defaultCursorStyle; this.scrollRegions.delete(this.activeBufferKind()); return false; }), parser.registerEscHandler({ final: 'c' }, () => { // RIS: the emulator clears the cursor style, the margins and the mouse encoding, but not // DECTCEM — a cursor hidden before the reset stays hidden — so neither does the tracker. this.cursorStyle = defaultCursorStyle; this.mouseEncoding = 'default'; this.scrollRegions.clear(); return false; }), ); } private applyPrivateModes(paramsArg: (number | number[])[], setArg: boolean): void { for (let index = 0; index < paramsArg.length; index += 1) { switch (numberParameter(paramsArg, index)) { case 25: this.cursorVisible = setArg; break; case 1006: // Either encoding's reset returns the emulator to X10 coordinates regardless of which // one was active — both modes share one reset case there — so the tracker does too. this.mouseEncoding = setArg ? 'sgr' : 'default'; break; case 1016: this.mouseEncoding = setArg ? 'sgrPixels' : 'default'; break; default: // Every other private mode is either serialized by the addon or irrelevant to the // restored screen. break; } } } /** Mirrors the emulator's own DECSTBM acceptance rules, so a rejected region is not tracked. */ private applyScrollRegion(paramsArg: (number | number[])[]): void { const rows = this.emulator.rows; const top = numberParameter(paramsArg, 0) || 1; const requestedBottom = numberParameter(paramsArg, 1); const bottom = paramsArg.length < 2 || requestedBottom === 0 || requestedBottom > rows ? rows : requestedBottom; if (bottom <= top) return; const kind = this.activeBufferKind(); if (top === 1 && bottom === rows) this.scrollRegions.delete(kind); else this.scrollRegions.set(kind, { top, bottom }); } }