Make mobile terminals load faster (#1147)

* Speed up terminal restores on mobile

* Preserve terminal output bytes in the app
This commit is contained in:
Mohamed Boudra
2026-05-23 18:41:10 +08:00
committed by GitHub
parent 84f1dfc8cb
commit 846c9b9da3
32 changed files with 1304 additions and 357 deletions
@@ -1,4 +1,9 @@
import { createTerminal, type TerminalSession, type TerminalStateSnapshot } from "./terminal.js";
import {
createTerminal,
type TerminalSession,
type TerminalStateSnapshot,
type TerminalStateSnapshotOptions,
} from "./terminal.js";
import { captureTerminalLines, type CaptureTerminalLinesResult } from "./terminal-capture.js";
import { resolve, sep, win32, posix } from "node:path";
@@ -29,7 +34,10 @@ export interface TerminalManager {
}): Promise<TerminalSession>;
registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void;
getTerminal(id: string): TerminalSession | undefined;
getTerminalState(id: string): Promise<TerminalStateSnapshot | null>;
getTerminalState(
id: string,
options?: TerminalStateSnapshotOptions,
): Promise<TerminalStateSnapshot | null>;
setTerminalTitle(id: string, title: string): boolean;
killTerminal(id: string): void;
killTerminalAndWait(
@@ -208,8 +216,11 @@ export function createTerminalManager(): TerminalManager {
return terminalsById.get(id);
},
async getTerminalState(id: string): Promise<TerminalStateSnapshot | null> {
return terminalsById.get(id)?.getStateSnapshot() ?? null;
async getTerminalState(
id: string,
options?: TerminalStateSnapshotOptions,
): Promise<TerminalStateSnapshot | null> {
return terminalsById.get(id)?.getStateSnapshot(options) ?? null;
},
setTerminalTitle(id: string, title: string): boolean {
@@ -0,0 +1,72 @@
import { describe, expect, test } from "vitest";
import { TerminalStreamOpcode, decodeTerminalStreamFrame } from "../shared/binary-frames/index.js";
import type { TerminalCell, TerminalState } from "../shared/messages.js";
import {
encodeTerminalRestoreFrame,
resolveRestoreAfterOutputOverflow,
resolveTerminalRestoreSnapshotOptions,
resolveTerminalSubscriptionSnapshotMode,
} from "./terminal-restore.js";
function terminalRow(text: string, cols = 80): TerminalCell[] {
return Array.from({ length: cols }, (_, index) => ({
char: text[index] ?? " ",
}));
}
function terminalState(text: string): TerminalState {
return {
rows: 1,
cols: 80,
grid: [terminalRow(text)],
scrollback: [],
cursor: { row: 0, col: text.length },
};
}
describe("terminal restore policy", () => {
test("uses ready snapshots only for restore-aware subscriptions", () => {
expect(resolveTerminalSubscriptionSnapshotMode(undefined)).toBe("state");
expect(resolveTerminalSubscriptionSnapshotMode({ mode: "live" })).toBe("ready");
});
test("resolves bounded restore snapshot options", () => {
expect(resolveTerminalRestoreSnapshotOptions({ mode: "live" })).toBeNull();
expect(resolveTerminalRestoreSnapshotOptions({ mode: "full-snapshot" })).toBeUndefined();
expect(resolveTerminalRestoreSnapshotOptions({ mode: "visible-snapshot" })).toEqual({
scrollbackLines: 200,
});
expect(
resolveTerminalRestoreSnapshotOptions({
mode: "visible-snapshot",
scrollbackLines: 999,
}),
).toEqual({ scrollbackLines: 500 });
});
test("promotes live restore to visible restore after output overflow", () => {
expect(resolveRestoreAfterOutputOverflow({ mode: "live" })).toEqual({
mode: "visible-snapshot",
});
expect(resolveRestoreAfterOutputOverflow({ mode: "full-snapshot" })).toEqual({
mode: "full-snapshot",
});
});
test("encodes restore snapshots as restore frames", () => {
const frame = decodeTerminalStreamFrame(
encodeTerminalRestoreFrame({
slot: 4,
snapshot: {
state: terminalState("restored"),
revision: 1,
},
}),
);
expect(frame?.opcode).toBe(TerminalStreamOpcode.Restore);
expect(frame?.slot).toBe(4);
expect(new TextDecoder().decode(frame?.payload ?? new Uint8Array())).toContain("restored");
});
});
@@ -0,0 +1,75 @@
import type { SubscribeTerminalRequest } from "../server/messages.js";
import {
TerminalStreamOpcode,
encodeTerminalSnapshotPayload,
encodeTerminalStreamFrame,
} from "../shared/binary-frames/index.js";
import { renderTerminalSnapshotToAnsi } from "../shared/terminal-snapshot.js";
import type { TerminalStateSnapshot, TerminalStateSnapshotOptions } from "./terminal.js";
export const MAX_TERMINAL_OUTPUT_FRAME_BYTES = 256 * 1024;
const DEFAULT_VISIBLE_RESTORE_SCROLLBACK_LINES = 200;
const MAX_VISIBLE_RESTORE_SCROLLBACK_LINES = 500;
export type TerminalRestoreOptions = NonNullable<SubscribeTerminalRequest["restore"]>;
export type TerminalSubscriptionSnapshotMode = "state" | "ready";
export function resolveTerminalSubscriptionSnapshotMode(
restore: TerminalRestoreOptions | undefined,
): TerminalSubscriptionSnapshotMode {
return restore ? "ready" : "state";
}
export function resolveRestoreAfterOutputOverflow(
restore: TerminalRestoreOptions | undefined,
): TerminalRestoreOptions | undefined {
if (restore?.mode === "live") {
return { mode: "visible-snapshot" };
}
return restore;
}
export function resolveTerminalRestoreSnapshotOptions(
restore: TerminalRestoreOptions,
): TerminalStateSnapshotOptions | null | undefined {
if (restore.mode === "live") {
return null;
}
if (restore.mode === "visible-snapshot") {
return {
scrollbackLines: resolveVisibleRestoreScrollbackLines(restore.scrollbackLines),
};
}
return undefined;
}
export function encodeLegacyTerminalSnapshotFrame(input: {
slot: number;
snapshot: TerminalStateSnapshot;
}): Uint8Array {
return encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Snapshot,
slot: input.slot,
payload: encodeTerminalSnapshotPayload(input.snapshot.state),
});
}
export function encodeTerminalRestoreFrame(input: {
slot: number;
snapshot: TerminalStateSnapshot;
}): Uint8Array {
return encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Restore,
slot: input.slot,
payload: renderTerminalSnapshotToAnsi(input.snapshot.state),
});
}
function resolveVisibleRestoreScrollbackLines(value: number | undefined): number {
if (typeof value !== "number") {
return DEFAULT_VISIBLE_RESTORE_SCROLLBACK_LINES;
}
return Math.min(Math.max(0, value), MAX_VISIBLE_RESTORE_SCROLLBACK_LINES);
}
@@ -0,0 +1,142 @@
import { describe, expect, test, vi } from "vitest";
import type pino from "pino";
import type { SessionOutboundMessage } from "../server/messages.js";
import {
TerminalStreamOpcode,
decodeTerminalStreamFrame,
type TerminalStreamFrame,
} from "../shared/binary-frames/index.js";
import type { TerminalCell, TerminalState } from "../shared/messages.js";
import type { ServerMessage, TerminalSession, TerminalStateSnapshot } from "./terminal.js";
import { TerminalSessionController } from "./terminal-session-controller.js";
import type { TerminalManager } from "./terminal-manager.js";
function deferred<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
} {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
function terminalRow(text: string, cols = 80): TerminalCell[] {
return Array.from({ length: cols }, (_, index) => ({
char: text[index] ?? " ",
}));
}
function terminalState(text: string): TerminalState {
return {
rows: 1,
cols: 80,
grid: [terminalRow(text)],
scrollback: [],
cursor: { row: 0, col: text.length },
};
}
function createLogger(): pino.Logger {
return {
warn: vi.fn(),
error: vi.fn(),
} as unknown as pino.Logger;
}
describe("terminal-session-controller restore", () => {
test("delivers output produced while restore is in flight after the restore frame", async () => {
let terminalListener: ((message: ServerMessage) => void) | null = null;
const snapshot = deferred<TerminalStateSnapshot | null>();
const binaryFrames: TerminalStreamFrame[] = [];
const outboundMessages: SessionOutboundMessage[] = [];
const terminal: TerminalSession = {
id: "term-1",
name: "Terminal",
cwd: "/tmp",
send: vi.fn(),
subscribe: (listener) => {
terminalListener = listener;
queueMicrotask(() => listener({ type: "snapshotReady", revision: 1 }));
return vi.fn();
},
onExit: () => vi.fn(),
onCommandFinished: () => vi.fn(),
onTitleChange: () => vi.fn(),
getSize: () => ({ rows: 1, cols: 80 }),
getState: () => terminalState("restore-before"),
getStateSnapshot: () => ({ state: terminalState("restore-before"), revision: 1 }),
getReplayPreamble: () => "",
getTitle: () => undefined,
setTitle: vi.fn(),
getExitInfo: () => null,
kill: vi.fn(),
killAndWait: vi.fn(),
};
const terminalManager: TerminalManager = {
getTerminals: vi.fn(),
createTerminal: vi.fn(),
registerCwdEnv: vi.fn(),
getTerminal: vi.fn(() => terminal),
getTerminalState: vi.fn(() => snapshot.promise),
setTerminalTitle: vi.fn(),
killTerminal: vi.fn(),
killTerminalAndWait: vi.fn(),
captureTerminal: vi.fn(),
listDirectories: vi.fn(() => []),
killAll: vi.fn(),
subscribeTerminalsChanged: vi.fn(() => vi.fn()),
};
const controller = new TerminalSessionController({
terminalManager,
emit: (message) => outboundMessages.push(message),
emitBinary: (bytes) => {
const frame = decodeTerminalStreamFrame(bytes);
if (frame) {
binaryFrames.push(frame);
}
},
hasBinaryChannel: () => true,
isPathWithinRoot: () => false,
sessionLogger: createLogger(),
});
await controller.dispatch({
type: "subscribe_terminal_request",
terminalId: "term-1",
requestId: "req-1",
restore: {
mode: "visible-snapshot",
scrollbackLines: 200,
},
});
await Promise.resolve();
expect(terminalManager.getTerminalState).toHaveBeenCalledTimes(1);
terminalListener?.({ type: "output", data: "restore-after\n", revision: 2 });
snapshot.resolve({ state: terminalState("restore-before"), revision: 1 });
await snapshot.promise;
await new Promise((resolve) => setTimeout(resolve, 20));
expect(outboundMessages).toContainEqual({
type: "subscribe_terminal_response",
payload: {
terminalId: "term-1",
slot: 0,
error: null,
requestId: "req-1",
},
});
expect(binaryFrames.map((frame) => frame.opcode)).toEqual([
TerminalStreamOpcode.Restore,
TerminalStreamOpcode.Output,
]);
expect(new TextDecoder().decode(binaryFrames[0]?.payload)).toContain("restore-before");
expect(new TextDecoder().decode(binaryFrames[1]?.payload)).toBe("restore-after\n");
});
});
@@ -17,11 +17,19 @@ import { killTerminalsUnderPath as killWorktreeTerminalsUnderPath } from "../ser
import {
TerminalStreamOpcode,
decodeTerminalResizePayload,
encodeTerminalSnapshotPayload,
encodeTerminalStreamFrame,
type TerminalStreamFrame,
} from "../shared/binary-frames/index.js";
import { TerminalOutputCoalescer } from "./terminal-output-coalescer.js";
import {
MAX_TERMINAL_OUTPUT_FRAME_BYTES,
encodeLegacyTerminalSnapshotFrame,
encodeTerminalRestoreFrame,
resolveRestoreAfterOutputOverflow,
resolveTerminalRestoreSnapshotOptions,
resolveTerminalSubscriptionSnapshotMode,
type TerminalRestoreOptions,
} from "./terminal-restore.js";
import type { TerminalSession } from "./terminal.js";
import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js";
@@ -38,10 +46,18 @@ interface ActiveTerminalStream {
unsubscribe: () => void;
needsSnapshot: boolean;
snapshotInFlight: boolean;
readyRevision?: number;
restore?: TerminalRestoreOptions;
bufferedOutputs: BufferedTerminalOutput[];
outputBytesSinceSnapshot: number;
outputCoalescer: TerminalOutputCoalescer;
}
interface SnapshotSendResult {
shouldContinue: boolean;
replayRevision?: number;
}
export interface TerminalSessionControllerOptions {
terminalManager: TerminalManager | null;
emit: (msg: SessionOutboundMessage) => void;
@@ -488,7 +504,21 @@ export class TerminalSessionController {
}
this.ensureExitSubscription(session);
const slot = this.bindActiveStream(session);
if (msg.restore?.size) {
const currentSize = session.getSize();
if (
currentSize.rows !== msg.restore.size.rows ||
currentSize.cols !== msg.restore.size.cols
) {
session.send({
type: "resize",
rows: msg.restore.size.rows,
cols: msg.restore.size.cols,
});
}
}
const slot = this.bindActiveStream(session, { restore: msg.restore });
if (slot === null) {
this.sessionLogger.warn(
{
@@ -628,7 +658,10 @@ export class TerminalSessionController {
}
}
private bindActiveStream(terminal: TerminalSession): number | null {
private bindActiveStream(
terminal: TerminalSession,
options?: { restore?: TerminalRestoreOptions },
): number | null {
if (!this.hasBinaryChannel()) {
return null;
}
@@ -638,6 +671,7 @@ export class TerminalSessionController {
const existingStream = this.activeStreams.get(existingSlot);
if (existingStream) {
existingStream.needsSnapshot = true;
existingStream.restore = options?.restore;
return existingSlot;
}
this.idToSlot.delete(terminal.id);
@@ -654,13 +688,23 @@ export class TerminalSessionController {
unsubscribe: () => {},
needsSnapshot: true,
snapshotInFlight: false,
readyRevision: undefined,
restore: options?.restore,
bufferedOutputs: [],
outputBytesSinceSnapshot: 0,
outputCoalescer: new TerminalOutputCoalescer({
timers: { setTimeout, clearTimeout },
onFlush: ({ payload }) => {
if (this.activeStreams.get(slot) !== activeStream) {
return;
}
activeStream.outputBytesSinceSnapshot += payload.byteLength;
if (activeStream.outputBytesSinceSnapshot > MAX_TERMINAL_OUTPUT_FRAME_BYTES) {
activeStream.restore = resolveRestoreAfterOutputOverflow(activeStream.restore);
activeStream.needsSnapshot = true;
void this.trySendSnapshot(activeStream);
return;
}
this.emitBinary(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
@@ -675,31 +719,35 @@ export class TerminalSessionController {
this.activeStreams.set(slot, activeStream);
this.idToSlot.set(terminal.id, slot);
activeStream.unsubscribe = terminal.subscribe((message) => {
if (this.activeStreams.get(slot) !== activeStream) {
return;
}
if (message.type === "snapshot") {
activeStream.outputCoalescer.flush();
activeStream.needsSnapshot = true;
void this.trySendSnapshot(activeStream);
return;
}
if (message.type === "titleChange") {
return;
}
if (message.data.length === 0) {
return;
}
if (activeStream.needsSnapshot || activeStream.snapshotInFlight) {
activeStream.bufferedOutputs.push({
data: message.data,
revision: message.revision,
});
return;
}
activeStream.outputCoalescer.handle(message.data);
});
activeStream.unsubscribe = terminal.subscribe(
(message) => {
if (this.activeStreams.get(slot) !== activeStream) {
return;
}
if (message.type === "snapshot" || message.type === "snapshotReady") {
activeStream.readyRevision = message.revision;
activeStream.outputCoalescer.flush();
activeStream.needsSnapshot = true;
void this.trySendSnapshot(activeStream);
return;
}
if (message.type === "titleChange") {
return;
}
if (message.data.length === 0) {
return;
}
if (activeStream.needsSnapshot || activeStream.snapshotInFlight) {
activeStream.bufferedOutputs.push({
data: message.data,
revision: message.revision,
});
return;
}
activeStream.outputCoalescer.handle(message.data);
},
{ initialSnapshot: resolveTerminalSubscriptionSnapshotMode(options?.restore) },
);
return slot;
}
@@ -722,43 +770,23 @@ export class TerminalSessionController {
this.detachStream(activeStream.terminalId, { emitExit: true });
return;
}
if (activeStream.restore && activeStream.readyRevision === undefined) {
return;
}
activeStream.outputCoalescer.flush();
activeStream.snapshotInFlight = true;
try {
const snapshot = await terminalManager.getTerminalState(activeStream.terminalId);
if (this.activeStreams.get(activeStream.slot) !== activeStream) {
const restore = activeStream.restore;
const snapshotResult = restore
? await this.emitRestoreSnapshot(activeStream, terminalManager, restore)
: await this.emitLegacySnapshot(activeStream, terminalManager);
if (!snapshotResult.shouldContinue) {
return;
}
if (!snapshot) {
this.detachStream(activeStream.terminalId, { emitExit: true });
return;
}
this.emitBinary(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Snapshot,
slot: activeStream.slot,
payload: encodeTerminalSnapshotPayload(snapshot.state),
}),
);
const replayPreamble = terminal.getReplayPreamble();
if (replayPreamble.length > 0) {
activeStream.outputCoalescer.handle(replayPreamble);
}
const bufferedOutputs = activeStream.bufferedOutputs.splice(
0,
activeStream.bufferedOutputs.length,
);
for (const output of bufferedOutputs) {
if (output.revision !== undefined && output.revision <= snapshot.revision) {
continue;
}
activeStream.outputCoalescer.handle(output.data);
}
this.replayTerminalOutputAfterSnapshot(activeStream, terminal, snapshotResult.replayRevision);
activeStream.needsSnapshot = false;
activeStream.outputBytesSinceSnapshot = 0;
} catch (error) {
this.sessionLogger.warn(
{ err: error, terminalId: activeStream.terminalId },
@@ -770,6 +798,85 @@ export class TerminalSessionController {
}
}
private async emitLegacySnapshot(
activeStream: ActiveTerminalStream,
terminalManager: TerminalManager,
): Promise<SnapshotSendResult> {
const snapshot = await terminalManager.getTerminalState(activeStream.terminalId);
if (this.activeStreams.get(activeStream.slot) !== activeStream) {
return { shouldContinue: false };
}
if (!snapshot) {
this.detachStream(activeStream.terminalId, { emitExit: true });
return { shouldContinue: false };
}
this.emitBinary(
encodeLegacyTerminalSnapshotFrame({
slot: activeStream.slot,
snapshot,
}),
);
return { shouldContinue: true, replayRevision: snapshot.revision };
}
private async emitRestoreSnapshot(
activeStream: ActiveTerminalStream,
terminalManager: TerminalManager,
restore: TerminalRestoreOptions,
): Promise<SnapshotSendResult> {
const snapshotOptions = resolveTerminalRestoreSnapshotOptions(restore);
if (snapshotOptions === null) {
return { shouldContinue: true };
}
const snapshot = await terminalManager.getTerminalState(
activeStream.terminalId,
snapshotOptions,
);
if (this.activeStreams.get(activeStream.slot) !== activeStream) {
return { shouldContinue: false };
}
if (!snapshot) {
this.detachStream(activeStream.terminalId, { emitExit: true });
return { shouldContinue: false };
}
this.emitBinary(
encodeTerminalRestoreFrame({
slot: activeStream.slot,
snapshot,
}),
);
return { shouldContinue: true, replayRevision: snapshot.revision };
}
private replayTerminalOutputAfterSnapshot(
activeStream: ActiveTerminalStream,
terminal: TerminalSession,
replayRevision: number | undefined,
): void {
const replayPreamble = terminal.getReplayPreamble();
if (replayPreamble.length > 0) {
activeStream.outputCoalescer.handle(replayPreamble);
}
const bufferedOutputs = activeStream.bufferedOutputs.splice(
0,
activeStream.bufferedOutputs.length,
);
for (const output of bufferedOutputs) {
if (
replayRevision !== undefined &&
output.revision !== undefined &&
output.revision <= replayRevision
) {
continue;
}
activeStream.outputCoalescer.handle(output.data);
}
}
private allocateSlot(): number | null {
for (let attempt = 0; attempt < MAX_TERMINAL_STREAM_SLOTS; attempt += 1) {
const slot = (this.nextSlot + attempt) % MAX_TERMINAL_STREAM_SLOTS;
@@ -176,7 +176,7 @@ async function handleRequest(message: TerminalWorkerRequest): Promise<void> {
type: "response",
requestId: message.requestId,
ok: true,
result: session?.getStateSnapshot() ?? null,
result: session?.getStateSnapshot(message.options) ?? null,
});
return;
}
@@ -3,6 +3,7 @@ import type {
ServerMessage,
ClientMessage,
TerminalStateSnapshot,
TerminalStateSnapshotOptions,
} from "./terminal.js";
import type { TerminalState } from "../shared/messages.js";
import type { CaptureTerminalLinesResult } from "./terminal-capture.js";
@@ -61,6 +62,7 @@ export type TerminalWorkerRequest =
type: "getTerminalState";
requestId: string;
terminalId: string;
options?: TerminalStateSnapshotOptions;
}
| {
type: "captureTerminal";
+36 -10
View File
@@ -33,6 +33,14 @@ export interface TerminalStateSnapshot {
revision: number;
}
export interface TerminalStateSnapshotOptions {
scrollbackLines?: number;
}
export interface TerminalSubscribeOptions {
initialSnapshot?: "state" | "ready";
}
export type ClientMessage =
| { type: "input"; data: string }
| { type: "resize"; rows: number; cols: number }
@@ -41,6 +49,7 @@ export type ClientMessage =
export type ServerMessage =
| { type: "output"; data: string; revision?: number }
| { type: "snapshot"; state: TerminalState; revision?: number }
| { type: "snapshotReady"; revision?: number }
| { type: "titleChange"; title?: string };
export interface TerminalSession {
@@ -48,13 +57,13 @@ export interface TerminalSession {
name: string;
cwd: string;
send(msg: ClientMessage): void;
subscribe(listener: (msg: ServerMessage) => void): () => void;
subscribe(listener: (msg: ServerMessage) => void, options?: TerminalSubscribeOptions): () => void;
onExit(listener: (info: TerminalExitInfo) => void): () => void;
onCommandFinished(listener: (info: TerminalCommandFinishedInfo) => void): () => void;
onTitleChange(listener: (title?: string) => void): () => void;
getSize(): { rows: number; cols: number };
getState(): TerminalState;
getStateSnapshot(): TerminalStateSnapshot;
getStateSnapshot(options?: TerminalStateSnapshotOptions): TerminalStateSnapshot;
getReplayPreamble(): string;
getTitle(): string | undefined;
setTitle(title: string): void;
@@ -292,14 +301,21 @@ function extractGrid(terminal: TerminalType): TerminalCell[][] {
return grid;
}
function extractScrollback(terminal: TerminalType): TerminalCell[][] {
function extractScrollback(
terminal: TerminalType,
options?: { scrollbackLines?: number },
): TerminalCell[][] {
const scrollback: TerminalCell[][] = [];
const buffer = terminal.buffer.active;
// baseY is the first row of the visible viewport (0-indexed)
// Lines 0 to baseY-1 are in scrollback, lines baseY onwards are visible
const scrollbackLines = buffer.baseY;
const startRow =
typeof options?.scrollbackLines === "number"
? Math.max(0, scrollbackLines - options.scrollbackLines)
: 0;
for (let row = 0; row < scrollbackLines; row++) {
for (let row = startRow; row < scrollbackLines; row++) {
const rowCells: TerminalCell[] = [];
const line = buffer.getLine(row);
for (let col = 0; col < terminal.cols; col++) {
@@ -813,20 +829,22 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
}
}
function getState(): TerminalState {
function getState(snapshotOptions?: TerminalStateSnapshotOptions): TerminalState {
return {
rows: terminal.rows,
cols: terminal.cols,
grid: extractGrid(terminal),
scrollback: extractScrollback(terminal),
scrollback: extractScrollback(terminal, {
scrollbackLines: snapshotOptions?.scrollbackLines,
}),
cursor: extractCursorState(terminal),
...(title ? { title } : {}),
};
}
function getStateSnapshot(): TerminalStateSnapshot {
function getStateSnapshot(snapshotOptions?: TerminalStateSnapshotOptions): TerminalStateSnapshot {
return {
state: getState(),
state: getState(snapshotOptions),
revision: stateRevision,
};
}
@@ -891,10 +909,14 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
}
}
function subscribe(listener: (msg: ServerMessage) => void): () => void {
function subscribe(
listener: (msg: ServerMessage) => void,
subscribeOptions?: TerminalSubscribeOptions,
): () => void {
let active = true;
let snapshotDelivered = false;
const queuedMessages: ServerMessage[] = [];
const initialSnapshot = subscribeOptions?.initialSnapshot ?? "state";
const subscriptionListener = (msg: ServerMessage): void => {
if (!active) {
return;
@@ -911,7 +933,11 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
terminal.write("", () => {
if (!disposed && active && listeners.has(subscriptionListener)) {
snapshotDelivered = true;
listener({ type: "snapshot", ...getStateSnapshot() });
if (initialSnapshot === "ready") {
listener({ type: "snapshotReady", revision: stateRevision });
} else {
listener({ type: "snapshot", ...getStateSnapshot() });
}
for (const message of queuedMessages.splice(0)) {
listener(message);
}
@@ -200,8 +200,18 @@ export function createWorkerTerminalManager(
}
sendBestEffortRequest({ type: "send", terminalId: record.info.id, message });
},
subscribe(listener: (msg: ServerMessage) => void): () => void {
subscribe(
listener: (msg: ServerMessage) => void,
options?: { initialSnapshot?: "state" | "ready" },
): () => void {
record.messageListeners.add(listener);
if (options?.initialSnapshot === "ready") {
queueMicrotask(() => {
if (record.messageListeners.has(listener)) {
listener({ type: "snapshotReady", revision: 0 });
}
});
}
return () => {
record.messageListeners.delete(listener);
};
@@ -244,9 +254,14 @@ export function createWorkerTerminalManager(
getState(): TerminalState {
return record.state;
},
getStateSnapshot(): TerminalStateSnapshot {
getStateSnapshot(options?: { scrollbackLines?: number }): TerminalStateSnapshot {
const scrollbackLines = options?.scrollbackLines;
const scrollback =
typeof scrollbackLines === "number"
? record.state.scrollback.slice(-scrollbackLines)
: record.state.scrollback;
return {
state: record.state,
state: { ...record.state, scrollback },
revision: 0,
};
},
@@ -539,10 +554,14 @@ export function createWorkerTerminalManager(
return recordsById.get(id)?.session;
},
async getTerminalState(id: string): Promise<TerminalStateSnapshot | null> {
async getTerminalState(
id: string,
options?: { scrollbackLines?: number },
): Promise<TerminalStateSnapshot | null> {
return (await sendRequest({
type: "getTerminalState",
terminalId: id,
...(options ? { options } : {}),
})) as TerminalWorkerStateResult;
},