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