| 1 | export interface TerminalOutputChunk { |
| 2 | /** UTF-16 string offset within this generation and reset. */ |
| 3 | readonly startOffset: number; |
| 4 | readonly data: string; |
| 5 | readonly byteLength: number; |
| 6 | } |
| 7 | |
| 8 | export interface TerminalOutputState { |
| 9 | readonly generation: number; |
| 10 | readonly chunks: ReadonlyArray<TerminalOutputChunk>; |
| 11 | readonly retainedBytes: number; |
| 12 | readonly resetVersion: number; |
| 13 | readonly nextOffset: number; |
| 14 | } |
| 15 | |
| 16 | export interface TerminalOutputCursor { |
| 17 | readonly generation: number; |
| 18 | readonly resetVersion: number; |
| 19 | readonly offset: number; |
| 20 | } |
| 21 | |
| 22 | /** Forces the first `readTerminalOutputUpdate` to resynchronize from a reset. */ |
| 23 | export const INITIAL_TERMINAL_OUTPUT_CURSOR = Object.freeze<TerminalOutputCursor>({ |
| 24 | generation: -1, |
| 25 | resetVersion: -1, |
| 26 | offset: 0, |
| 27 | }); |
| 28 | |
| 29 | export type TerminalOutputUpdate = |
| 30 | | { |
| 31 | readonly type: "none"; |
| 32 | readonly cursor: TerminalOutputCursor; |
| 33 | } |
| 34 | | { |
| 35 | readonly type: "reset"; |
| 36 | readonly data: string; |
| 37 | readonly cursor: TerminalOutputCursor; |
| 38 | } |
| 39 | | { |
| 40 | readonly type: "append"; |
| 41 | readonly cursor: TerminalOutputCursor; |
| 42 | readonly data: string; |
| 43 | }; |
| 44 | |
| 45 | export const DEFAULT_MAX_TERMINAL_BUFFER_BYTES = 512 * 1024; |
| 46 | const DEFAULT_TERMINAL_CHUNK_BYTES = 16 * 1024; |
| 47 | const MAX_TERMINAL_OUTPUT_CHUNKS = 1_024; |
| 48 | const textEncoder = new TextEncoder(); |
| 49 | // A BOM at a retained chunk boundary is terminal data, not an encoding marker. |
| 50 | const textDecoder = new TextDecoder("utf-8", { ignoreBOM: true }); |
| 51 | |
| 52 | export const EMPTY_TERMINAL_OUTPUT_STATE = Object.freeze<TerminalOutputState>({ |
| 53 | generation: 0, |
| 54 | chunks: Object.freeze([]), |
| 55 | retainedBytes: 0, |
| 56 | resetVersion: 0, |
| 57 | nextOffset: 0, |
| 58 | }); |
| 59 | |
| 60 | interface Utf8Chunk { |
| 61 | readonly data: string; |
| 62 | readonly byteLength: number; |
| 63 | } |
| 64 | |
| 65 | /** |
| 66 | * Split a string into chunks of at most `maxBytes` UTF-8 bytes without cutting |
| 67 | * a code point in half. The retained-output budget always supplies a positive |
| 68 | * size. Only new output is encoded on live updates. |
| 69 | * |
| 70 | * A chunk that fits whole is returned as the original string, so the common |
| 71 | * small-write path pays one encode and no decode. |
| 72 | */ |
| 73 | function splitStringByUtf8Bytes(data: string, maxBytes: number): ReadonlyArray<Utf8Chunk> { |
| 74 | if (data.length === 0) return []; |
| 75 | |
| 76 | const encoded = textEncoder.encode(data); |
| 77 | if (encoded.byteLength <= maxBytes) { |
| 78 | return [{ data, byteLength: encoded.byteLength }]; |
| 79 | } |
| 80 | |
| 81 | const chunks: Utf8Chunk[] = []; |
| 82 | let offset = 0; |
| 83 | while (offset < encoded.byteLength) { |
| 84 | let end = Math.min(offset + maxBytes, encoded.byteLength); |
| 85 | while (end < encoded.byteLength && ((encoded[end] ?? 0) & 0xc0) === 0x80) { |
| 86 | end -= 1; |
| 87 | } |
| 88 | // A degenerate budget smaller than one code point still has to advance: |
| 89 | // include the whole code point rather than looping forever. |
| 90 | if (end === offset) { |
| 91 | end = Math.min(offset + maxBytes, encoded.byteLength); |
| 92 | while (end < encoded.byteLength && ((encoded[end] ?? 0) & 0xc0) === 0x80) { |
| 93 | end += 1; |
| 94 | } |
| 95 | } |
| 96 | const bytes = encoded.subarray(offset, end); |
| 97 | chunks.push({ data: textDecoder.decode(bytes), byteLength: bytes.byteLength }); |
| 98 | offset = end; |
| 99 | } |
| 100 | |
| 101 | return chunks; |
| 102 | } |
| 103 | |
| 104 | function trimBufferToBytes(buffer: string, maxBufferBytes: number): string { |
| 105 | if (maxBufferBytes <= 0) { |
| 106 | return ""; |
| 107 | } |
| 108 | |
| 109 | const encoded = textEncoder.encode(buffer); |
| 110 | if (encoded.byteLength <= maxBufferBytes) { |
| 111 | return buffer; |
| 112 | } |
| 113 | |
| 114 | let start = encoded.byteLength - maxBufferBytes; |
| 115 | while (start < encoded.length) { |
| 116 | const byte = encoded[start]; |
| 117 | if (byte === undefined || (byte & 0b1100_0000) !== 0b1000_0000) { |
| 118 | break; |
| 119 | } |
| 120 | start += 1; |
| 121 | } |
| 122 | |
| 123 | return textDecoder.decode(encoded.subarray(start)); |
| 124 | } |
| 125 | |
| 126 | function splitOutputChunks( |
| 127 | data: string, |
| 128 | firstOffset: number, |
| 129 | maxChunkBytes = DEFAULT_TERMINAL_CHUNK_BYTES, |
| 130 | ): { |
| 131 | readonly chunks: ReadonlyArray<TerminalOutputChunk>; |
| 132 | readonly nextOffset: number; |
| 133 | readonly byteLength: number; |
| 134 | } { |
| 135 | const split = splitStringByUtf8Bytes(data, maxChunkBytes); |
| 136 | let byteLength = 0; |
| 137 | let nextOffset = firstOffset; |
| 138 | const chunks = split.map((chunk) => { |
| 139 | byteLength += chunk.byteLength; |
| 140 | const startOffset = nextOffset; |
| 141 | nextOffset += chunk.data.length; |
| 142 | return { |
| 143 | startOffset, |
| 144 | data: chunk.data, |
| 145 | byteLength: chunk.byteLength, |
| 146 | }; |
| 147 | }); |
| 148 | |
| 149 | return { |
| 150 | chunks, |
| 151 | nextOffset, |
| 152 | byteLength, |
| 153 | }; |
| 154 | } |
| 155 | |
| 156 | /** |
| 157 | * Merge adjacent chunks without changing their string positions. A reader can |
| 158 | * still append the unread suffix when its cursor falls inside a merged chunk. |
| 159 | */ |
| 160 | function compactRetainedChunks(chunks: ReadonlyArray<TerminalOutputChunk>) { |
| 161 | const compacted: TerminalOutputChunk[] = []; |
| 162 | for (const chunk of chunks) { |
| 163 | const previous = compacted.at(-1); |
| 164 | if ( |
| 165 | previous !== undefined && |
| 166 | previous.startOffset + previous.data.length === chunk.startOffset && |
| 167 | previous.byteLength + chunk.byteLength <= DEFAULT_TERMINAL_CHUNK_BYTES |
| 168 | ) { |
| 169 | compacted[compacted.length - 1] = { |
| 170 | startOffset: previous.startOffset, |
| 171 | data: `${previous.data}${chunk.data}`, |
| 172 | byteLength: previous.byteLength + chunk.byteLength, |
| 173 | }; |
| 174 | } else { |
| 175 | compacted.push(chunk); |
| 176 | } |
| 177 | } |
| 178 | return compacted; |
| 179 | } |
| 180 | |
| 181 | // Scan only the removed prefix instead of encoding retained output again. |
| 182 | function trimOutputChunkStart( |
| 183 | chunk: TerminalOutputChunk, |
| 184 | bytesToDrop: number, |
| 185 | ): TerminalOutputChunk { |
| 186 | let offset = 0; |
| 187 | let droppedBytes = 0; |
| 188 | while (droppedBytes < bytesToDrop && offset < chunk.data.length) { |
| 189 | const codepoint = chunk.data.codePointAt(offset)!; |
| 190 | droppedBytes += codepoint <= 0x7f ? 1 : codepoint <= 0x7ff ? 2 : codepoint <= 0xffff ? 3 : 4; |
| 191 | offset += codepoint <= 0xffff ? 1 : 2; |
| 192 | } |
| 193 | return { |
| 194 | ...chunk, |
| 195 | startOffset: chunk.startOffset + offset, |
| 196 | data: chunk.data.slice(offset), |
| 197 | byteLength: chunk.byteLength - droppedBytes, |
| 198 | }; |
| 199 | } |
| 200 | |
| 201 | function appendOutput( |
| 202 | current: TerminalOutputState, |
| 203 | data: string, |
| 204 | maxBufferBytes: number, |
| 205 | ): TerminalOutputState { |
| 206 | if (data.length === 0) return current; |
| 207 | if (maxBufferBytes <= 0) { |
| 208 | return { |
| 209 | generation: current.generation, |
| 210 | chunks: [], |
| 211 | retainedBytes: 0, |
| 212 | resetVersion: current.resetVersion + 1, |
| 213 | nextOffset: current.nextOffset + data.length, |
| 214 | }; |
| 215 | } |
| 216 | const appended = splitOutputChunks( |
| 217 | data, |
| 218 | current.nextOffset, |
| 219 | Math.min(DEFAULT_TERMINAL_CHUNK_BYTES, Math.max(1, maxBufferBytes)), |
| 220 | ); |
| 221 | |
| 222 | const chunks = [...current.chunks, ...appended.chunks]; |
| 223 | let retainedBytes = current.retainedBytes + appended.byteLength; |
| 224 | let firstRetainedIndex = 0; |
| 225 | while (retainedBytes > maxBufferBytes && firstRetainedIndex < chunks.length) { |
| 226 | const first = chunks[firstRetainedIndex]!; |
| 227 | const bytesToDrop = retainedBytes - maxBufferBytes; |
| 228 | if (bytesToDrop < first.byteLength) { |
| 229 | const trimmed = trimOutputChunkStart(first, bytesToDrop); |
| 230 | retainedBytes -= first.byteLength - trimmed.byteLength; |
| 231 | if (trimmed.byteLength > 0) { |
| 232 | chunks[firstRetainedIndex] = trimmed; |
| 233 | } else { |
| 234 | firstRetainedIndex += 1; |
| 235 | } |
| 236 | break; |
| 237 | } |
| 238 | retainedBytes -= first.byteLength; |
| 239 | firstRetainedIndex += 1; |
| 240 | } |
| 241 | |
| 242 | let retainedChunks = firstRetainedIndex === 0 ? chunks : chunks.slice(firstRetainedIndex); |
| 243 | if (retainedChunks.length > MAX_TERMINAL_OUTPUT_CHUNKS) { |
| 244 | retainedChunks = compactRetainedChunks(retainedChunks); |
| 245 | const excessChunks = retainedChunks.length - MAX_TERMINAL_OUTPUT_CHUNKS; |
| 246 | if (excessChunks > 0) { |
| 247 | for (const chunk of retainedChunks.slice(0, excessChunks)) { |
| 248 | retainedBytes -= chunk.byteLength; |
| 249 | } |
| 250 | retainedChunks = retainedChunks.slice(excessChunks); |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | return { |
| 255 | generation: current.generation, |
| 256 | chunks: retainedChunks, |
| 257 | retainedBytes, |
| 258 | resetVersion: current.resetVersion, |
| 259 | nextOffset: appended.nextOffset, |
| 260 | }; |
| 261 | } |
| 262 | |
| 263 | function resetOutput( |
| 264 | current: TerminalOutputState, |
| 265 | data: string, |
| 266 | maxBufferBytes: number, |
| 267 | ): TerminalOutputState { |
| 268 | const retained = trimBufferToBytes(data, maxBufferBytes); |
| 269 | const reset = splitOutputChunks( |
| 270 | retained, |
| 271 | 0, |
| 272 | Math.min(DEFAULT_TERMINAL_CHUNK_BYTES, Math.max(1, maxBufferBytes)), |
| 273 | ); |
| 274 | return { |
| 275 | generation: current.generation, |
| 276 | chunks: reset.chunks, |
| 277 | retainedBytes: reset.byteLength, |
| 278 | resetVersion: current.resetVersion + 1, |
| 279 | nextOffset: reset.nextOffset, |
| 280 | }; |
| 281 | } |
| 282 | |
| 283 | export function terminalOutputText(output: TerminalOutputState): string { |
| 284 | return output.chunks.map((chunk) => chunk.data).join(""); |
| 285 | } |
| 286 | |
| 287 | export function readTerminalOutputUpdate( |
| 288 | output: TerminalOutputState, |
| 289 | cursor: TerminalOutputCursor, |
| 290 | ): TerminalOutputUpdate { |
| 291 | const nextCursor = { |
| 292 | generation: output.generation, |
| 293 | resetVersion: output.resetVersion, |
| 294 | offset: output.nextOffset, |
| 295 | }; |
| 296 | const firstChunk = output.chunks[0]; |
| 297 | if ( |
| 298 | cursor.generation !== output.generation || |
| 299 | cursor.resetVersion !== output.resetVersion || |
| 300 | cursor.offset < (firstChunk?.startOffset ?? output.nextOffset) |
| 301 | ) { |
| 302 | return { type: "reset", data: terminalOutputText(output), cursor: nextCursor }; |
| 303 | } |
| 304 | |
| 305 | const appended = output.chunks.filter( |
| 306 | (chunk) => chunk.startOffset + chunk.data.length > cursor.offset, |
| 307 | ); |
| 308 | if (appended.length === 0) { |
| 309 | return { type: "none", cursor: nextCursor }; |
| 310 | } |
| 311 | return { |
| 312 | type: "append", |
| 313 | data: appended |
| 314 | .map((chunk) => chunk.data.slice(Math.max(0, cursor.offset - chunk.startOffset))) |
| 315 | .join(""), |
| 316 | cursor: nextCursor, |
| 317 | }; |
| 318 | } |
| 319 | |
| 320 | export { appendOutput, resetOutput }; |