1export 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
8export 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
16export 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. */
23export const INITIAL_TERMINAL_OUTPUT_CURSOR = Object.freeze<TerminalOutputCursor>({
24 generation: -1,
25 resetVersion: -1,
26 offset: 0,
27});
28
29export 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
45export const DEFAULT_MAX_TERMINAL_BUFFER_BYTES = 512 * 1024;
46const DEFAULT_TERMINAL_CHUNK_BYTES = 16 * 1024;
47const MAX_TERMINAL_OUTPUT_CHUNKS = 1_024;
48const textEncoder = new TextEncoder();
49// A BOM at a retained chunk boundary is terminal data, not an encoding marker.
50const textDecoder = new TextDecoder("utf-8", { ignoreBOM: true });
51
52export 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
60interface 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 */
73function 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
104function 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
126function 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 */
160function 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.
182function 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
201function 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
263function 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
283export function terminalOutputText(output: TerminalOutputState): string {
284 return output.chunks.map((chunk) => chunk.data).join("");
285}
286
287export 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
320export { appendOutput, resetOutput };