export interface TerminalOutputChunk { /** UTF-16 string offset within this generation and reset. */ readonly startOffset: number; readonly data: string; readonly byteLength: number; } export interface TerminalOutputState { readonly generation: number; readonly chunks: ReadonlyArray; readonly retainedBytes: number; readonly resetVersion: number; readonly nextOffset: number; } export interface TerminalOutputCursor { readonly generation: number; readonly resetVersion: number; readonly offset: number; } /** Forces the first `readTerminalOutputUpdate` to resynchronize from a reset. */ export const INITIAL_TERMINAL_OUTPUT_CURSOR = Object.freeze({ generation: -1, resetVersion: -1, offset: 0, }); export type TerminalOutputUpdate = | { readonly type: "none"; readonly cursor: TerminalOutputCursor; } | { readonly type: "reset"; readonly data: string; readonly cursor: TerminalOutputCursor; } | { readonly type: "append"; readonly cursor: TerminalOutputCursor; readonly data: string; }; export const DEFAULT_MAX_TERMINAL_BUFFER_BYTES = 512 * 1024; const DEFAULT_TERMINAL_CHUNK_BYTES = 16 * 1024; const MAX_TERMINAL_OUTPUT_CHUNKS = 1_024; const textEncoder = new TextEncoder(); // A BOM at a retained chunk boundary is terminal data, not an encoding marker. const textDecoder = new TextDecoder("utf-8", { ignoreBOM: true }); export const EMPTY_TERMINAL_OUTPUT_STATE = Object.freeze({ generation: 0, chunks: Object.freeze([]), retainedBytes: 0, resetVersion: 0, nextOffset: 0, }); interface Utf8Chunk { readonly data: string; readonly byteLength: number; } /** * Split a string into chunks of at most `maxBytes` UTF-8 bytes without cutting * a code point in half. The retained-output budget always supplies a positive * size. Only new output is encoded on live updates. * * A chunk that fits whole is returned as the original string, so the common * small-write path pays one encode and no decode. */ function splitStringByUtf8Bytes(data: string, maxBytes: number): ReadonlyArray { if (data.length === 0) return []; const encoded = textEncoder.encode(data); if (encoded.byteLength <= maxBytes) { return [{ data, byteLength: encoded.byteLength }]; } const chunks: Utf8Chunk[] = []; let offset = 0; while (offset < encoded.byteLength) { let end = Math.min(offset + maxBytes, encoded.byteLength); while (end < encoded.byteLength && ((encoded[end] ?? 0) & 0xc0) === 0x80) { end -= 1; } // A degenerate budget smaller than one code point still has to advance: // include the whole code point rather than looping forever. if (end === offset) { end = Math.min(offset + maxBytes, encoded.byteLength); while (end < encoded.byteLength && ((encoded[end] ?? 0) & 0xc0) === 0x80) { end += 1; } } const bytes = encoded.subarray(offset, end); chunks.push({ data: textDecoder.decode(bytes), byteLength: bytes.byteLength }); offset = end; } return chunks; } function trimBufferToBytes(buffer: string, maxBufferBytes: number): string { if (maxBufferBytes <= 0) { return ""; } const encoded = textEncoder.encode(buffer); if (encoded.byteLength <= maxBufferBytes) { return buffer; } let start = encoded.byteLength - maxBufferBytes; while (start < encoded.length) { const byte = encoded[start]; if (byte === undefined || (byte & 0b1100_0000) !== 0b1000_0000) { break; } start += 1; } return textDecoder.decode(encoded.subarray(start)); } function splitOutputChunks( data: string, firstOffset: number, maxChunkBytes = DEFAULT_TERMINAL_CHUNK_BYTES, ): { readonly chunks: ReadonlyArray; readonly nextOffset: number; readonly byteLength: number; } { const split = splitStringByUtf8Bytes(data, maxChunkBytes); let byteLength = 0; let nextOffset = firstOffset; const chunks = split.map((chunk) => { byteLength += chunk.byteLength; const startOffset = nextOffset; nextOffset += chunk.data.length; return { startOffset, data: chunk.data, byteLength: chunk.byteLength, }; }); return { chunks, nextOffset, byteLength, }; } /** * Merge adjacent chunks without changing their string positions. A reader can * still append the unread suffix when its cursor falls inside a merged chunk. */ function compactRetainedChunks(chunks: ReadonlyArray) { const compacted: TerminalOutputChunk[] = []; for (const chunk of chunks) { const previous = compacted.at(-1); if ( previous !== undefined && previous.startOffset + previous.data.length === chunk.startOffset && previous.byteLength + chunk.byteLength <= DEFAULT_TERMINAL_CHUNK_BYTES ) { compacted[compacted.length - 1] = { startOffset: previous.startOffset, data: `${previous.data}${chunk.data}`, byteLength: previous.byteLength + chunk.byteLength, }; } else { compacted.push(chunk); } } return compacted; } // Scan only the removed prefix instead of encoding retained output again. function trimOutputChunkStart( chunk: TerminalOutputChunk, bytesToDrop: number, ): TerminalOutputChunk { let offset = 0; let droppedBytes = 0; while (droppedBytes < bytesToDrop && offset < chunk.data.length) { const codepoint = chunk.data.codePointAt(offset)!; droppedBytes += codepoint <= 0x7f ? 1 : codepoint <= 0x7ff ? 2 : codepoint <= 0xffff ? 3 : 4; offset += codepoint <= 0xffff ? 1 : 2; } return { ...chunk, startOffset: chunk.startOffset + offset, data: chunk.data.slice(offset), byteLength: chunk.byteLength - droppedBytes, }; } function appendOutput( current: TerminalOutputState, data: string, maxBufferBytes: number, ): TerminalOutputState { if (data.length === 0) return current; if (maxBufferBytes <= 0) { return { generation: current.generation, chunks: [], retainedBytes: 0, resetVersion: current.resetVersion + 1, nextOffset: current.nextOffset + data.length, }; } const appended = splitOutputChunks( data, current.nextOffset, Math.min(DEFAULT_TERMINAL_CHUNK_BYTES, Math.max(1, maxBufferBytes)), ); const chunks = [...current.chunks, ...appended.chunks]; let retainedBytes = current.retainedBytes + appended.byteLength; let firstRetainedIndex = 0; while (retainedBytes > maxBufferBytes && firstRetainedIndex < chunks.length) { const first = chunks[firstRetainedIndex]!; const bytesToDrop = retainedBytes - maxBufferBytes; if (bytesToDrop < first.byteLength) { const trimmed = trimOutputChunkStart(first, bytesToDrop); retainedBytes -= first.byteLength - trimmed.byteLength; if (trimmed.byteLength > 0) { chunks[firstRetainedIndex] = trimmed; } else { firstRetainedIndex += 1; } break; } retainedBytes -= first.byteLength; firstRetainedIndex += 1; } let retainedChunks = firstRetainedIndex === 0 ? chunks : chunks.slice(firstRetainedIndex); if (retainedChunks.length > MAX_TERMINAL_OUTPUT_CHUNKS) { retainedChunks = compactRetainedChunks(retainedChunks); const excessChunks = retainedChunks.length - MAX_TERMINAL_OUTPUT_CHUNKS; if (excessChunks > 0) { for (const chunk of retainedChunks.slice(0, excessChunks)) { retainedBytes -= chunk.byteLength; } retainedChunks = retainedChunks.slice(excessChunks); } } return { generation: current.generation, chunks: retainedChunks, retainedBytes, resetVersion: current.resetVersion, nextOffset: appended.nextOffset, }; } function resetOutput( current: TerminalOutputState, data: string, maxBufferBytes: number, ): TerminalOutputState { const retained = trimBufferToBytes(data, maxBufferBytes); const reset = splitOutputChunks( retained, 0, Math.min(DEFAULT_TERMINAL_CHUNK_BYTES, Math.max(1, maxBufferBytes)), ); return { generation: current.generation, chunks: reset.chunks, retainedBytes: reset.byteLength, resetVersion: current.resetVersion + 1, nextOffset: reset.nextOffset, }; } export function terminalOutputText(output: TerminalOutputState): string { return output.chunks.map((chunk) => chunk.data).join(""); } export function readTerminalOutputUpdate( output: TerminalOutputState, cursor: TerminalOutputCursor, ): TerminalOutputUpdate { const nextCursor = { generation: output.generation, resetVersion: output.resetVersion, offset: output.nextOffset, }; const firstChunk = output.chunks[0]; if ( cursor.generation !== output.generation || cursor.resetVersion !== output.resetVersion || cursor.offset < (firstChunk?.startOffset ?? output.nextOffset) ) { return { type: "reset", data: terminalOutputText(output), cursor: nextCursor }; } const appended = output.chunks.filter( (chunk) => chunk.startOffset + chunk.data.length > cursor.offset, ); if (appended.length === 0) { return { type: "none", cursor: nextCursor }; } return { type: "append", data: appended .map((chunk) => chunk.data.slice(Math.max(0, cursor.offset - chunk.startOffset))) .join(""), cursor: nextCursor, }; } export { appendOutput, resetOutput };