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