[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
364
components/terminal/terminalHibernateRuntime.ts
Normal file
364
components/terminal/terminalHibernateRuntime.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
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<T> = { current: T };
|
||||
|
||||
export function applyAuthoritativeHibernateSnapshot(
|
||||
refs: {
|
||||
snapshot: MutableValue<string>;
|
||||
viewportSnapshot: MutableValue<string>;
|
||||
scrollbackSnapshot: MutableValue<string>;
|
||||
contextSnapshot: MutableValue<string>;
|
||||
contextViewportSnapshot: MutableValue<string>;
|
||||
contextScrollbackSnapshot: MutableValue<string>;
|
||||
pendingBuffer: MutableValue<string>;
|
||||
alternateScreen: MutableValue<boolean>;
|
||||
},
|
||||
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<Pick<
|
||||
TerminalHibernateSnapshot,
|
||||
"contextSnapshot" | "contextViewportSnapshot" | "contextScrollbackSnapshot" | "alternateScreen"
|
||||
>>;
|
||||
|
||||
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<string, unknown>,
|
||||
preferWasm: boolean,
|
||||
): Promise<string> {
|
||||
try {
|
||||
return await serializeTerminalBuffer({
|
||||
term,
|
||||
serializeAddon,
|
||||
options,
|
||||
preferWasm,
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export async function serializeTerminalForHibernate(
|
||||
term: XTerm,
|
||||
serializeAddon: SerializeAddon,
|
||||
options: { preferWasm?: boolean; prepare?: () => Promise<void> } = {},
|
||||
): Promise<TerminalHibernateSnapshot> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user