import type { Terminal as XTerm } from "@xterm/xterm"; import type { SerializeAddon } from "@xterm/addon-serialize"; import { capHibernateBuffer, capHibernateBufferByLines, TERMINAL_HIBERNATE_SNAPSHOT_MAX_LINES, type TerminalHibernateWakePayload, } from "../../domain/terminalHibernate"; import type { XTermRuntime } from "./runtime/createXTermRuntime"; import { readActiveTerminalBufferTextRange } from "./terminalContextBuffer"; import { serializeTerminalBuffer } from "./terminalSerialize"; import { writeTerminalPayloadChunked, writeTerminalReplaySequence, type TerminalReplayOptions, } from "./terminalReplay"; export function isTerminalAlternateScreenActive(term: XTerm): boolean { return (term.buffer.active as { type?: string }).type === "alternate"; } export function resolveHibernateSerializeOptions(term: XTerm): { excludeAltBuffer: boolean; excludeModes: boolean; alternateScreen: boolean; } { const alternateScreen = isTerminalAlternateScreenActive(term); return { excludeAltBuffer: !alternateScreen, excludeModes: !alternateScreen, alternateScreen, }; } export type TerminalHibernateSnapshot = { snapshot: string; viewportSnapshot: string; scrollbackSnapshot: string; contextSnapshot?: string; contextViewportSnapshot?: string; contextScrollbackSnapshot?: string; alternateScreen: boolean; }; type MutableValue = { current: T }; export function applyAuthoritativeHibernateSnapshot( refs: { snapshot: MutableValue; viewportSnapshot: MutableValue; scrollbackSnapshot: MutableValue; contextSnapshot: MutableValue; contextViewportSnapshot: MutableValue; contextScrollbackSnapshot: MutableValue; pendingBuffer: MutableValue; alternateScreen: MutableValue; }, snapshot: string, context: TerminalHibernateContextSnapshot, ): void { refs.snapshot.current = snapshot; refs.viewportSnapshot.current = snapshot; refs.scrollbackSnapshot.current = ""; refs.contextSnapshot.current = context.contextSnapshot; refs.contextViewportSnapshot.current = context.contextViewportSnapshot; refs.contextScrollbackSnapshot.current = context.contextScrollbackSnapshot; refs.pendingBuffer.current = ""; refs.alternateScreen.current = context.alternateScreen; } export type TerminalHibernateContextSnapshot = Required>; export function resolveTerminalSnapshotCapture( serialized: unknown, context: TerminalHibernateContextSnapshot, ): { snapshot: string; context: TerminalHibernateContextSnapshot } { if (typeof serialized === "string") return { snapshot: serialized, context }; const plainText = context.alternateScreen ? context.contextViewportSnapshot : context.contextSnapshot; const replayText = plainText.replace(/\r?\n/g, "\r\n"); return { snapshot: context.alternateScreen ? `\x1b[?1049h\x1b[H${replayText}` : replayText, context, }; } function isEmptyTerminalContext(text: string): boolean { return text.split("\n").every((line) => line.length === 0); } export function readTerminalHibernateContext( term: XTerm, ): TerminalHibernateContextSnapshot { const rows = Math.max(1, term.rows); const bufferLength = resolveActiveBufferLength(term); if (isTerminalAlternateScreenActive(term)) { const contextViewportSnapshot = readActiveTerminalBufferTextRange(term, { startLine: 0, endLine: Math.max(0, rows - 1), }); const empty = isEmptyTerminalContext(contextViewportSnapshot); return { contextSnapshot: empty ? "" : contextViewportSnapshot, contextViewportSnapshot: empty ? "" : contextViewportSnapshot, contextScrollbackSnapshot: "", alternateScreen: true, }; } const activeBuffer = term.buffer.active as typeof term.buffer.active & { viewportY?: number }; const bottomViewportStart = Math.max(0, bufferLength - rows); const viewportStart = Math.min( bottomViewportStart, Math.max(0, activeBuffer.viewportY ?? bottomViewportStart), ); const viewportEnd = bufferLength > 0 ? Math.min(bufferLength - 1, viewportStart + rows - 1) : -1; const contextViewportSnapshot = readActiveTerminalBufferTextRange(term, { startLine: viewportStart, endLine: viewportEnd, }); const contextStart = Math.min( viewportStart, Math.max(0, bufferLength - TERMINAL_HIBERNATE_SNAPSHOT_MAX_LINES), ); const contextEnd = bufferLength > 0 ? Math.min( bufferLength - 1, Math.max(viewportEnd, contextStart + TERMINAL_HIBERNATE_SNAPSHOT_MAX_LINES - 1), ) : -1; const contextScrollbackSnapshot = viewportStart > 0 ? readActiveTerminalBufferTextRange(term, { startLine: contextStart, endLine: viewportStart - 1, }) : ""; const contextSnapshot = readActiveTerminalBufferTextRange(term, { startLine: contextStart, endLine: contextEnd, }); if (isEmptyTerminalContext(contextSnapshot)) { return { contextSnapshot: "", contextViewportSnapshot: "", contextScrollbackSnapshot: "", alternateScreen: false, }; } return { contextSnapshot, contextViewportSnapshot, contextScrollbackSnapshot, alternateScreen: false, }; } function resolveActiveBufferLength(term: XTerm): number { return term.buffer.active.length; } async function serializeWithOptions( term: XTerm, serializeAddon: SerializeAddon, options: Record, preferWasm: boolean, ): Promise { try { return await serializeTerminalBuffer({ term, serializeAddon, options, preferWasm, }); } catch { return ""; } } export async function serializeTerminalForHibernate( term: XTerm, serializeAddon: SerializeAddon, options: { preferWasm?: boolean; prepare?: () => Promise } = {}, ): Promise { await options.prepare?.(); const { excludeAltBuffer, excludeModes, alternateScreen } = resolveHibernateSerializeOptions(term); const preferWasm = options.preferWasm === true; const rows = Math.max(1, term.rows); const bufferLength = resolveActiveBufferLength(term); let context: TerminalHibernateContextSnapshot = { contextSnapshot: "", contextViewportSnapshot: "", contextScrollbackSnapshot: "", alternateScreen, }; try { context = readTerminalHibernateContext(term); } catch { // A transient buffer read failure must not prevent visual snapshot capture. } try { if (alternateScreen) { const endRow = Math.max(0, rows - 1); const viewportSnapshot = capHibernateBufferByLines( await serializeWithOptions(term, serializeAddon, { excludeAltBuffer: false, excludeModes: false, range: { start: 0, end: endRow }, }, preferWasm), rows, ); return { snapshot: viewportSnapshot, viewportSnapshot, scrollbackSnapshot: "", ...context, alternateScreen: true, }; } const viewportStart = Math.max(0, bufferLength - rows); const viewportEnd = Math.max(0, bufferLength - 1); const viewportSnapshot = await serializeWithOptions(term, serializeAddon, { excludeAltBuffer, excludeModes, range: { start: viewportStart, end: viewportEnd }, }, preferWasm); let scrollbackSnapshot = ""; if (viewportStart > 0) { const scrollbackStart = Math.max(0, viewportStart - TERMINAL_HIBERNATE_SNAPSHOT_MAX_LINES); scrollbackSnapshot = capHibernateBufferByLines( await serializeWithOptions(term, serializeAddon, { excludeAltBuffer, excludeModes, range: { start: scrollbackStart, end: viewportStart - 1 }, }, preferWasm), TERMINAL_HIBERNATE_SNAPSHOT_MAX_LINES, ); } const snapshot = capHibernateBufferByLines( await serializeWithOptions(term, serializeAddon, { excludeAltBuffer, excludeModes, }, preferWasm), TERMINAL_HIBERNATE_SNAPSHOT_MAX_LINES, ); return { snapshot, viewportSnapshot, scrollbackSnapshot, ...context, alternateScreen: false, }; } catch { return { snapshot: "", viewportSnapshot: "", scrollbackSnapshot: "", alternateScreen: isTerminalAlternateScreenActive(term), }; } } export function appendHibernatePendingBuffer(current: string, chunk: string): string { return capHibernateBuffer(current + chunk); } export function refreshTerminalViewport(term: XTerm): void { const endRow = term.rows - 1; if (endRow < 0) return; term.refresh(0, endRow); } export async function appendTerminalReplayData( term: XTerm, data: string, replayOptions?: TerminalReplayOptions, ): Promise { return writeTerminalPayloadChunked(term, data, replayOptions); } export type ApplyHibernateWakeOptions = { replayOptions?: TerminalReplayOptions; deferWebgl?: boolean; }; /** * Resolve the pre-pending history bytes to replay on hibernate wake. * Prefer the coherent full snapshot: SerializeAddon range slices omit a * trailing newline at the boundary, so concatenating scrollback+viewport can * merge the seam lines. Fall back to scrollback -> viewport when snapshot is * empty (still never viewport-first -- that evicts newest rows under a finite * scrollback cap; #2762). */ export function resolveHibernateWakeHistory(payload: TerminalHibernateWakePayload): string { if (payload.snapshot) return payload.snapshot; const viewport = payload.viewportSnapshot ?? ""; const scrollback = payload.scrollbackSnapshot ?? ""; if (!scrollback) return viewport; if (!viewport) return scrollback; if (/\r?\n$/.test(scrollback)) return `${scrollback}${viewport}`; return `${scrollback}\r\n${viewport}`; } export async function applyHibernateWakeToTerminal( term: XTerm, runtime: XTermRuntime, payload: TerminalHibernateWakePayload, options: ApplyHibernateWakeOptions = {}, ): Promise { const replayOptions = options.replayOptions; const history = resolveHibernateWakeHistory(payload); // Chunked writes already yield to the event loop for large buffers. Do not // idle-append older scrollback after the viewport: under a finite xterm // scrollback cap that trims the just-restored newest rows (#2762). await writeTerminalReplaySequence( term, [history, payload.pendingBuffer], replayOptions, ); if (!options.deferWebgl) { runtime.ensureWebglRenderer(); runtime.clearTextureAtlas(); } if (payload.alternateScreen) { refreshTerminalViewport(term); } } export function nudgeAlternateScreenRedraw(term: XTerm): void { // A same-size resize does not notify the PTY, but xterm still synchronously // drains its private parser buffer before returning. During a large TUI frame // that can interrupt the normal write callback and strand renderer flow. // Public refresh is sufficient to repaint the already-parsed viewport. refreshTerminalViewport(term); } export function buildHibernateWakePayload( snapshot: TerminalHibernateSnapshot, pendingBuffer: string, ): TerminalHibernateWakePayload { return { snapshot: snapshot.snapshot, viewportSnapshot: snapshot.viewportSnapshot, scrollbackSnapshot: snapshot.scrollbackSnapshot, pendingBuffer, alternateScreen: snapshot.alternateScreen, }; }