1/** Server/client folds must agree: only the last LIVE_LINES are cursor-addressable, with no wrapping.
2 * SGR resets at line boundaries for safe tail cuts; unfinished escapes carry through pending. */
3export interface TerminalOutputState {
4 /** Fixed lines followed by the live window; contains only SGR escapes. */
5 readonly text: string;
6 readonly truncated: boolean;
7 /** An unfinished escape sequence awaiting the next chunk. */
8 readonly pending: string;
9 readonly screen: TerminalScreen;
10}
11
12/** Canonical SGR parameters; empty means default style. */
13interface Run {
14 readonly pen: string;
15 readonly text: string;
16}
17type Line = ReadonlyArray<Run>;
18
19interface TerminalScreen {
20 /** Scrolled-out lines, each ending in newline, as head + tail. Only the tail grows, so a cut
21 * usually slices the head instead of copying all scrollback on every chunk. */
22 readonly settled: readonly [head: string, tail: string];
23 /** Never empty. */
24 readonly lines: ReadonlyArray<Line>;
25 readonly row: number;
26 readonly col: number;
27 readonly pen: string;
28 readonly saved: { readonly row: number; readonly col: number } | null;
29}
30
31export const EMPTY_TERMINAL_OUTPUT: TerminalOutputState = {
32 text: "",
33 truncated: false,
34 pending: "",
35 screen: { settled: ["", ""], lines: [[]], row: 0, col: 0, pen: "", saved: null },
36};
37
38/** Default command tail, in UTF-16 code units. */
39export const TERMINAL_OUTPUT_TAIL_CHARS = 64 * 1024;
40
41const LIVE_LINES = 64;
42
43const MAX_PENDING_ESCAPE = 64;
44
45const ESC = "\u001b";
46
47function sgr(pen: string): string {
48 return pen === "" ? `${ESC}[0m` : `${ESC}[0;${pen}m`;
49}
50
51// Lines stay immutable after a feed, so their measured lengths can be cached.
52const lineLengths = new WeakMap<Line, number>();
53
54function lineLength(line: Line): number {
55 const cached = lineLengths.get(line);
56 if (cached !== undefined) return cached;
57 let length = 0;
58 for (const run of line) length += run.text.length;
59 lineLengths.set(line, length);
60 return length;
61}
62
63// Strip oldest styles to bound work on color-per-character output.
64const MAX_LINE_RUNS = 2_048;
65
66function sliceLine(line: Line, start: number, end: number): Array<Run> {
67 const out: Array<Run> = [];
68 let offset = 0;
69 for (const run of line) {
70 const runEnd = offset + run.text.length;
71 if (runEnd > start && offset < end) {
72 out.push({
73 pen: run.pen,
74 text: run.text.slice(Math.max(0, start - offset), Math.min(run.text.length, end - offset)),
75 });
76 }
77 offset = runEnd;
78 if (offset >= end) break;
79 }
80 return out;
81}
82
83function pushRun(runs: Array<Run>, run: Run): void {
84 if (run.text.length === 0) return;
85 const last = runs.at(-1);
86 if (last !== undefined && last.pen === run.pen) {
87 runs[runs.length - 1] = { pen: last.pen, text: last.text + run.text };
88 } else {
89 runs.push(run);
90 }
91}
92
93/** Mutates only lines owned by this feed. */
94function trimLineStart(runs: Array<Run>, count: number): void {
95 let dropped = 0;
96 let remaining = count;
97 while (remaining > 0 && dropped < runs.length) {
98 const run = runs[dropped]!;
99 if (run.text.length <= remaining) {
100 remaining -= run.text.length;
101 dropped += 1;
102 } else {
103 runs[dropped] = { pen: run.pen, text: run.text.slice(remaining) };
104 remaining = 0;
105 }
106 }
107 if (dropped > 0) runs.splice(0, dropped);
108}
109
110function writeLine(line: Line, col: number, text: string, pen: string): Line {
111 const length = lineLength(line);
112 if (col === length) {
113 const runs = line.slice();
114 pushRun(runs, { pen, text });
115 return runs;
116 }
117 const runs: Array<Run> = [];
118 for (const run of sliceLine(line, 0, Math.min(col, length))) pushRun(runs, run);
119 if (col > length) pushRun(runs, { pen: "", text: " ".repeat(col - length) });
120 pushRun(runs, { pen, text });
121 for (const run of sliceLine(line, col + text.length, length)) pushRun(runs, run);
122 return runs;
123}
124
125/** Erase in line: 0 cursor to end, 1 start to cursor, 2 whole line. */
126function eraseLine(line: Line, col: number, mode: number): Line {
127 const length = lineLength(line);
128 if (mode === 0) return sliceLine(line, 0, col);
129 if (mode === 1) {
130 const runs: Array<Run> = [];
131 pushRun(runs, { pen: "", text: " ".repeat(Math.min(col + 1, length)) });
132 for (const run of sliceLine(line, col + 1, length)) pushRun(runs, run);
133 return runs;
134 }
135 return mode === 2 ? [] : line;
136}
137
138function serializeLine(line: Line): string {
139 let out = "";
140 let current = "";
141 for (const run of line) {
142 if (run.pen !== current) {
143 out += sgr(run.pen);
144 current = run.pen;
145 }
146 out += run.text;
147 }
148 return current === "" ? out : out + sgr("");
149}
150
151interface Attributes {
152 bold: boolean;
153 dim: boolean;
154 italic: boolean;
155 underline: boolean;
156 strike: boolean;
157 inverse: boolean;
158 fg: string;
159 bg: string;
160}
161
162function parsePen(pen: string): Attributes {
163 const attributes: Attributes = {
164 bold: false,
165 dim: false,
166 italic: false,
167 underline: false,
168 strike: false,
169 inverse: false,
170 fg: "",
171 bg: "",
172 };
173 if (pen !== "") applySgr(attributes, pen.split(";").map(Number));
174 return attributes;
175}
176
177function formatPen(attributes: Attributes): string {
178 const parts: Array<string> = [];
179 if (attributes.bold) parts.push("1");
180 if (attributes.dim) parts.push("2");
181 if (attributes.italic) parts.push("3");
182 if (attributes.underline) parts.push("4");
183 if (attributes.inverse) parts.push("7");
184 if (attributes.strike) parts.push("9");
185 if (attributes.fg !== "") parts.push(attributes.fg);
186 if (attributes.bg !== "") parts.push(attributes.bg);
187 return parts.join(";");
188}
189
190/** Reads 5;n or 2;r;g;b colors; returns the code and next index. */
191function extendedColor(
192 code: number,
193 params: ReadonlyArray<number>,
194 index: number,
195): [string | undefined, number] {
196 const mode = params[index + 1];
197 if (mode === 5) {
198 const value = params[index + 2];
199 return value === undefined || value > 255
200 ? [undefined, index + 3]
201 : [`${code};5;${value}`, index + 3];
202 }
203 if (mode === 2) {
204 const [r, g, b] = [params[index + 2], params[index + 3], params[index + 4]];
205 if (r === undefined || g === undefined || b === undefined) return [undefined, index + 5];
206 return [`${code};2;${r & 255};${g & 255};${b & 255}`, index + 5];
207 }
208 return [undefined, index + 2];
209}
210
211function applySgr(attributes: Attributes, params: ReadonlyArray<number>): void {
212 if (params.length === 0) params = [0];
213 for (let index = 0; index < params.length;) {
214 const code = params[index]!;
215 if (code === 0 || Number.isNaN(code)) {
216 Object.assign(attributes, parsePen(""));
217 } else if (code === 1) attributes.bold = true;
218 else if (code === 2) attributes.dim = true;
219 else if (code === 3) attributes.italic = true;
220 else if (code === 4) attributes.underline = true;
221 else if (code === 7) attributes.inverse = true;
222 else if (code === 9) attributes.strike = true;
223 else if (code === 22) attributes.bold = attributes.dim = false;
224 else if (code === 23) attributes.italic = false;
225 else if (code === 24) attributes.underline = false;
226 else if (code === 27) attributes.inverse = false;
227 else if (code === 29) attributes.strike = false;
228 else if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) attributes.fg = String(code);
229 else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107))
230 attributes.bg = String(code);
231 else if (code === 39) attributes.fg = "";
232 else if (code === 49) attributes.bg = "";
233 else if (code === 38 || code === 48 || code === 58) {
234 // 58 (underline colour) is consumed but not kept: nothing renders it.
235 const [color, next] = extendedColor(code, params, index);
236 if (color !== undefined && code !== 58) attributes[code === 38 ? "fg" : "bg"] = color;
237 index = next;
238 continue;
239 }
240 index += 1;
241 }
242}
243
244const STRING_SEQUENCE_INTRODUCERS = new Set(["]", "P", "X", "_", "^"]);
245
246/** Length of a complete escape sequence starting at `index` (an ESC), or -1 if unfinished. */
247function escapeLength(input: string, index: number): number {
248 const next = input[index + 1];
249 if (next === undefined) return -1;
250 // A second ESC restarts the sequence.
251 if (next === ESC) return 1;
252 if (next === "[") {
253 for (let cursor = index + 2; cursor < input.length; cursor += 1) {
254 const code = input.charCodeAt(cursor);
255 if (code >= 0x40 && code <= 0x7e) return cursor - index + 1;
256 }
257 return -1;
258 }
259 if (STRING_SEQUENCE_INTRODUCERS.has(next)) {
260 // BEL/ST ends string sequences; also stop at newlines so stray binary introducers cannot hide later output.
261 for (let cursor = index + 2; cursor < input.length; cursor += 1) {
262 if (input[cursor] === "\u0007") return cursor - index + 1;
263 if (input[cursor] === "\n") return cursor - index;
264 // Like xterm.js, an ESC that does not start ST ends the string and begins the next sequence.
265 if (input[cursor] === ESC) {
266 if (cursor + 1 >= input.length) return -1;
267 return input[cursor + 1] === "\\" ? cursor - index + 2 : cursor - index;
268 }
269 }
270 return -1;
271 }
272 // ECMA-35: any intermediate bytes (0x20-0x2F, e.g. charset designations, DECALN) precede one final byte.
273 let cursor = index + 1;
274 while (
275 cursor < input.length &&
276 input.charCodeAt(cursor) >= 0x20 &&
277 input.charCodeAt(cursor) <= 0x2f
278 ) {
279 cursor += 1;
280 }
281 if (cursor >= input.length) return -1;
282 // After intermediates, a control or ESC is not a final byte; end before it so it is still processed.
283 const final = input.charCodeAt(cursor);
284 if (cursor > index + 1 && (final < 0x30 || final > 0x7e)) return cursor - index;
285 return cursor - index + 1;
286}
287
288const penTransitions = new Map<string, string>();
289
290// Colon color sub-parameters carry an optional color-space id absent from the semicolon form.
291function csiParams(params: string): Array<number> {
292 const numbers: Array<number> = [];
293 // After a semicolon-form 38/48/58 the next part is its mode, then that many
294 // colour values; tracking them keeps a value from being read as a code.
295 let pending: "code" | "mode" | number = "code";
296 for (const part of params.split(";")) {
297 const sub = part.split(":").map(Number);
298 const head = sub[0]!;
299 if (typeof pending === "number") {
300 numbers.push(head);
301 pending = pending > 1 ? pending - 1 : "code";
302 continue;
303 }
304 if (pending === "mode") {
305 pending = "code";
306 if (sub.length === 1) {
307 numbers.push(head);
308 if (head === 5 || head === 2) pending = head === 5 ? 1 : 3;
309 continue;
310 }
311 // xterm also takes the mode's fields after colons (`38;2:255:0:0`).
312 if (head === 2 && sub.length >= 5) sub.splice(1, 1);
313 numbers.push(...sub.slice(0, head === 5 ? 2 : head === 2 ? 4 : 1));
314 continue;
315 }
316 if (sub.length === 1) {
317 numbers.push(head);
318 if (head === 38 || head === 48 || head === 58) pending = "mode";
319 continue;
320 }
321 if (head !== 38 && head !== 48 && head !== 58) {
322 // Other sub-parameters pick a style variant (`4:3` curly underline); keep
323 // only the head, except that `4:0` turns underline off.
324 numbers.push(head === 4 && sub[1] === 0 ? 24 : head);
325 continue;
326 }
327 if (sub[1] === 2 && sub.length >= 6) sub.splice(2, 1);
328 // The group is one parameter, so fields past its mode's length must not
329 // leak out as SGR codes of their own. Indexed colour has no colour-space id.
330 numbers.push(...sub.slice(0, sub[1] === 5 ? 3 : sub[1] === 2 ? 5 : 2));
331 }
332 return numbers;
333}
334
335class Terminal {
336 settledHead: string;
337 settledTail: string;
338 lines: Array<Line>;
339 row: number;
340 col: number;
341 pen: string;
342 saved: { row: number; col: number } | null;
343 /** Output was discarded on purpose (scrollback cleared or window over budget). */
344 dropped = false;
345 readonly maxChars: number;
346 /** Lines created during this feed, which it may extend in place. */
347 private readonly owned = new WeakSet<Line>();
348
349 constructor(screen: TerminalScreen, maxChars: number) {
350 this.maxChars = maxChars;
351 [this.settledHead, this.settledTail] = screen.settled;
352 this.lines = screen.lines.slice();
353 this.row = screen.row;
354 this.col = screen.col;
355 this.pen = screen.pen;
356 this.saved = screen.saved;
357 }
358
359 write(text: string): void {
360 let line = this.lines[this.row]!;
361 const length = lineLength(line);
362 if (this.col === length && this.owned.has(line)) {
363 pushRun(line as Array<Run>, { pen: this.pen, text });
364 lineLengths.set(line, length + text.length);
365 } else {
366 line = writeLine(line, this.col, text, this.pen);
367 this.owned.add(line);
368 }
369 this.col += text.length;
370 if (line.length > MAX_LINE_RUNS) {
371 // Flatten old runs so later trimming stays cheap.
372 const keep = line.length - MAX_LINE_RUNS / 8;
373 const plain = line
374 .slice(0, keep)
375 .map((run) => run.text)
376 .join("");
377 line = [{ pen: "", text: plain }, ...line.slice(keep)];
378 this.owned.add(line);
379 }
380 // Trim each write to maxChars columns so results do not depend on chunk boundaries.
381 let current = lineLength(line);
382 if (current > this.maxChars) {
383 let removed = current - this.maxChars;
384 if (!this.owned.has(line)) {
385 line = line.slice();
386 this.owned.add(line);
387 }
388 trimLineStart(line as Array<Run>, removed);
389 // Never start on a lone low surrogate.
390 const first = line[0]?.text.charCodeAt(0) ?? 0;
391 if (first >= 0xdc00 && first <= 0xdfff) {
392 trimLineStart(line as Array<Run>, 1);
393 removed += 1;
394 }
395 current -= removed;
396 this.dropped = true;
397 this.col = Math.max(0, this.col - removed);
398 if (this.saved !== null && this.saved.row === this.row) {
399 this.saved = { ...this.saved, col: Math.max(0, this.saved.col - removed) };
400 }
401 }
402 lineLengths.set(line, current);
403 this.lines[this.row] = line;
404 }
405
406 newline(): void {
407 this.row += 1;
408 this.col = 0;
409 if (this.row === this.lines.length) this.lines.push([]);
410 while (this.lines.length > LIVE_LINES) this.scrollOut();
411 }
412
413 scrollOut(): number {
414 const line = this.lines.shift()!;
415 this.settle(line);
416 this.row -= 1;
417 if (this.saved !== null) this.saved = { ...this.saved, row: Math.max(0, this.saved.row - 1) };
418 return lineLength(line);
419 }
420
421 settle(line: Line): void {
422 this.settledTail += `${serializeLine(line)}\n`;
423 if (this.settledHead.length + this.settledTail.length > 2 * this.maxChars) {
424 [this.settledHead, this.settledTail = ""] = terminalOutputTailParts(
425 [this.settledHead, this.settledTail],
426 this.maxChars,
427 ).parts;
428 this.dropped = true;
429 }
430 }
431
432 moveTo(row: number, col: number): void {
433 this.row = Math.max(0, Math.min(this.lines.length - 1, row));
434 // A huge column (ESC[999999999C) would otherwise pad a line past any string limit.
435 this.col = Math.max(0, Math.min(TERMINAL_OUTPUT_TAIL_CHARS, col));
436 }
437
438 csi(params: string, final: string): void {
439 if (!/^[\d;:]*$/u.test(params) || (final !== "m" && params.includes(":"))) return;
440 if (final === "m") {
441 const key = `${this.pen}|${params}`;
442 let pen = penTransitions.get(key);
443 if (pen === undefined) {
444 const attributes = parsePen(this.pen);
445 applySgr(attributes, params === "" ? [] : csiParams(params));
446 pen = formatPen(attributes);
447 if (penTransitions.size >= 512) penTransitions.clear();
448 penTransitions.set(key, pen);
449 }
450 this.pen = pen;
451 return;
452 }
453 const numbers = params === "" ? [] : csiParams(params);
454 const count = Math.max(1, numbers[0] || 1);
455 switch (final) {
456 case "A":
457 return this.moveTo(this.row - count, this.col);
458 case "B":
459 return this.moveTo(this.row + count, this.col);
460 case "C":
461 return this.moveTo(this.row, this.col + count);
462 case "D":
463 return this.moveTo(this.row, this.col - count);
464 case "E":
465 return this.moveTo(this.row + count, 0);
466 case "F":
467 return this.moveTo(this.row - count, 0);
468 case "G":
469 return this.moveTo(this.row, count - 1);
470 case "H":
471 case "f":
472 // Rows count from the top of the window: there is no fixed screen height.
473 return this.moveTo(count - 1, Math.max(1, numbers[1] || 1) - 1);
474 case "K":
475 this.lines[this.row] = eraseLine(this.lines[this.row]!, this.col, numbers[0] ?? 0);
476 return;
477 case "J": {
478 const mode = numbers[0] ?? 0;
479 if (mode === 0) {
480 this.lines[this.row] = eraseLine(this.lines[this.row]!, this.col, 0);
481 this.lines.length = this.row + 1;
482 } else if (mode === 1) {
483 for (let row = 0; row < this.row; row += 1) this.lines[row] = [];
484 this.lines[this.row] = eraseLine(this.lines[this.row]!, this.col, 1);
485 } else if (mode === 3) {
486 if (this.settledHead !== "" || this.settledTail !== "") this.dropped = true;
487 this.settledHead = "";
488 this.settledTail = "";
489 } else if (mode === 2) {
490 this.lines = [[]];
491 this.row = 0;
492 this.col = 0;
493 }
494 return;
495 }
496 case "s":
497 this.saved = { row: this.row, col: this.col };
498 return;
499 case "u":
500 if (this.saved !== null) this.moveTo(this.saved.row, this.saved.col);
501 return;
502 default:
503 }
504 }
505
506 escape(sequence: string): void {
507 if (sequence[1] === "[") {
508 this.csi(sequence.slice(2, -1), sequence.at(-1)!);
509 } else if (sequence === `${ESC}7`) {
510 this.saved = { row: this.row, col: this.col };
511 } else if (sequence === `${ESC}8`) {
512 if (this.saved !== null) this.moveTo(this.saved.row, this.saved.col);
513 } else if (sequence === `${ESC}M`) {
514 this.moveTo(this.row - 1, this.col);
515 }
516 }
517
518 /** Applies `input`; returns the unfinished escape left at its end. */
519 feed(input: string): string {
520 let printable = 0;
521 const flush = (end: number) => {
522 if (end > printable) this.write(input.slice(printable, end));
523 };
524 for (let index = 0; index < input.length; index += 1) {
525 const code = input.charCodeAt(index);
526 if (code === 0x09 || (code >= 0x20 && code !== 0x7f && (code < 0x80 || code > 0x9f))) {
527 continue;
528 }
529 flush(index);
530 printable = index + 1;
531 if (code === 0x0a) this.newline();
532 else if (code === 0x0d) this.col = 0;
533 else if (code === 0x08) this.col = Math.max(0, this.col - 1);
534 else if (code === 0x1b) {
535 const length = escapeLength(input, index);
536 if (length === -1) {
537 const fragment = input.slice(index);
538 if (fragment.length <= MAX_PENDING_ESCAPE) return fragment;
539 // Retain the introducer and possible ST prefix so long string payloads stay hidden until termination.
540 if (STRING_SEQUENCE_INTRODUCERS.has(fragment[1]!)) {
541 return fragment.slice(0, 2) + (fragment.endsWith(ESC) ? ESC : "");
542 }
543 return "";
544 }
545 this.escape(input.slice(index, index + length));
546 index += length - 1;
547 printable = index + 1;
548 }
549 }
550 flush(input.length);
551 return "";
552 }
553}
554
555/** The active style at `index` of `text` and where a cut there may safely start. */
556function styleAt(text: string, index: number): { readonly start: number; readonly prefix: string } {
557 const lineStart = text.lastIndexOf("\n", index - 1) + 1;
558 let prefix = "";
559 let cursor = lineStart;
560 while (cursor < index) {
561 const found = text.indexOf(`${ESC}[`, cursor);
562 if (found === -1 || found >= index) break;
563 const end = text.indexOf("m", found);
564 if (end === -1) break;
565 if (end >= index) return { start: end + 1, prefix: text.slice(found, end + 1) };
566 prefix = text.slice(found, end + 1);
567 cursor = end + 1;
568 }
569 return { start: index, prefix: prefix === sgr("") ? "" : prefix };
570}
571
572/** Keeps the last `maxChars`, preferring to start on a fresh line. */
573export function terminalOutputTail(
574 text: string,
575 maxChars: number,
576): { readonly text: string; readonly truncated: boolean } {
577 if (text.length <= maxChars) return { text, truncated: false };
578 let start = text.length - maxChars;
579 const newline = text.indexOf("\n", start);
580 // Drop the partial first line unless that would discard most of the tail.
581 if (newline !== -1 && newline - start < maxChars / 4) {
582 return { text: text.slice(newline + 1), truncated: true };
583 }
584 // Cutting inside a line: never split an escape, and keep the colour in force.
585 const style = styleAt(text, start);
586 start = style.start;
587 // Never start on a lone low surrogate.
588 const first = text.charCodeAt(start);
589 if (first >= 0xdc00 && first <= 0xdfff) start += 1;
590 return { text: style.prefix + text.slice(start), truncated: true };
591}
592
593/**
594 * terminalOutputTail of the parts' concatenation, kept as parts. V8 copies a concatenated string
595 * the first time it is searched or sliced, so a newline cut slices only the part it lands in.
596 */
597function terminalOutputTailParts(
598 parts: readonly [string, ...Array<string>],
599 maxChars: number,
600): { readonly parts: readonly [string, ...Array<string>]; readonly truncated: boolean } {
601 let total = 0;
602 for (const part of parts) total += part.length;
603 if (total <= maxChars) return { parts, truncated: false };
604 const start = total - maxChars;
605 let offset = 0;
606 for (let index = 0; index < parts.length; index += 1) {
607 const part = parts[index]!;
608 if (offset + part.length > start) {
609 const newline = part.indexOf("\n", Math.max(0, start - offset));
610 if (newline !== -1) {
611 if (offset + newline - start >= maxChars / 4) break;
612 return { parts: [part.slice(newline + 1), ...parts.slice(index + 1)], truncated: true };
613 }
614 }
615 offset += part.length;
616 }
617 return { parts: [terminalOutputTail(parts.join(""), maxChars).text], truncated: true };
618}
619
620export function appendTerminalOutput(
621 state: TerminalOutputState,
622 chunk: string,
623 maxChars: number = TERMINAL_OUTPUT_TAIL_CHARS,
624): TerminalOutputState {
625 const input = state.pending + chunk;
626 if (input.length === 0) return state;
627 const terminal = new Terminal(state.screen, maxChars);
628 const pending = terminal.feed(input);
629 let truncated = state.truncated;
630 let windowChars = terminal.lines.reduce((total, line) => total + lineLength(line), 0);
631 while (windowChars > maxChars && terminal.row > 0) windowChars -= terminal.scrollOut();
632 // Lines below a cursor that moved up can't scroll out; they keep only their ends.
633 if (windowChars > maxChars) {
634 let budget = maxChars;
635 for (let row = 0; row <= terminal.row; row += 1) budget -= lineLength(terminal.lines[row]!);
636 for (let row = terminal.row + 1; row < terminal.lines.length; row += 1) {
637 const line = terminal.lines[row]!;
638 const length = lineLength(line);
639 const keep = Math.max(0, Math.min(length, budget));
640 if (keep < length) {
641 truncated = true;
642 terminal.lines[row] = sliceLine(line, length - keep, length);
643 if (terminal.saved !== null && terminal.saved.row === row) {
644 terminal.saved = {
645 ...terminal.saved,
646 col: Math.max(0, terminal.saved.col - (length - keep)),
647 };
648 }
649 }
650 budget -= keep;
651 }
652 }
653 if (terminal.dropped) truncated = true;
654 const settled = terminalOutputTailParts([terminal.settledHead, terminal.settledTail], maxChars);
655 const window = terminal.lines.map(serializeLine).join("\n");
656 const tail = terminalOutputTailParts([...settled.parts, window], maxChars);
657 return {
658 text: tail.parts.reduce((text, part) => text + part, ""),
659 truncated: truncated || settled.truncated || tail.truncated,
660 pending,
661 screen: {
662 settled: [settled.parts[0], settled.parts[1] ?? ""],
663 lines: terminal.lines,
664 row: terminal.row,
665 col: terminal.col,
666 pen: terminal.pen,
667 saved: terminal.saved,
668 },
669 };
670}
671
672/** Rebuilds state from empty, including cursor/style/pending escapes, so later raw chunks fold identically. */
673export function terminalOutputResumeText(state: TerminalOutputState): string {
674 const { screen } = state;
675 const lastRow = screen.lines.length - 1;
676 // Moves are relative to the last line, and a viewer only holds the lines in `text`.
677 let reachable = 0;
678 let cursor = 0;
679 while (reachable < LIVE_LINES - 1) {
680 const newline = state.text.indexOf("\n", cursor);
681 if (newline === -1) break;
682 reachable += 1;
683 cursor = newline + 1;
684 }
685 const up = (row: number) => Math.min(lastRow - row, reachable);
686 const goTo = (row: number, col: number) =>
687 (up(row) > 0 ? `${ESC}[${up(row)}A` : "") + `${ESC}[${col + 1}G`;
688 let suffix = "";
689 if (screen.saved !== null) {
690 suffix += `${goTo(screen.saved.row, screen.saved.col)}${ESC}7`;
691 if (up(screen.saved.row) > 0) suffix += `${ESC}[${up(screen.saved.row)}B`;
692 }
693 if (
694 screen.saved !== null ||
695 screen.row < lastRow ||
696 screen.col !== lineLength(screen.lines[screen.row]!)
697 ) {
698 suffix += goTo(screen.row, screen.col);
699 }
700 if (screen.pen !== "") suffix += sgr(screen.pen);
701 return state.text + suffix + state.pending;
702}
703
704export function normalizeTerminalOutput(
705 text: string,
706 maxChars: number = TERMINAL_OUTPUT_TAIL_CHARS,
707): { readonly text: string; readonly truncated: boolean } {
708 const state = appendTerminalOutput(EMPTY_TERMINAL_OUTPUT, text, maxChars);
709 return { text: state.text, truncated: state.truncated };
710}
711
712export interface TerminalSpan {
713 readonly text: string;
714 readonly style: TerminalSpanStyle | null;
715}
716
717export interface TerminalSpanStyle {
718 /** CSS colours, already resolved against the palette. */
719 readonly color?: string;
720 readonly backgroundColor?: string;
721 readonly fontWeight?: "600";
722 readonly opacity?: number;
723 readonly fontStyle?: "italic";
724 readonly textDecorationLine?: "underline" | "line-through" | "underline line-through";
725}
726
727export const TERMINAL_PALETTES = {
728 dark: [
729 "#3b4048",
730 "#f47067",
731 "#57ab5a",
732 "#c69026",
733 "#539bf5",
734 "#b083f0",
735 "#39c5cf",
736 "#adbac7",
737 "#768390",
738 "#ff938a",
739 "#6bc46d",
740 "#daaa3f",
741 "#6cb6ff",
742 "#dcbdfb",
743 "#56d4dd",
744 "#cdd9e5",
745 ],
746 light: [
747 "#24292f",
748 "#cf222e",
749 "#116329",
750 "#7d4e00",
751 "#0550ae",
752 "#8250df",
753 "#1b7c83",
754 "#6e7781",
755 "#57606a",
756 "#a40e26",
757 "#1a7f37",
758 "#633c01",
759 "#0969da",
760 "#6639ba",
761 "#3192aa",
762 "#8c959f",
763 ],
764} as const;
765
766function xtermColor(index: number, palette: ReadonlyArray<string>): string {
767 if (index < 16) return palette[index]!;
768 if (index >= 232) {
769 const level = 8 + (index - 232) * 10;
770 return `rgb(${level},${level},${level})`;
771 }
772 const cube = index - 16;
773 const channel = (value: number) => (value === 0 ? 0 : 55 + value * 40);
774 return `rgb(${channel(Math.floor(cube / 36))},${channel(Math.floor(cube / 6) % 6)},${channel(cube % 6)})`;
775}
776
777function resolveColor(
778 code: string,
779 base: number,
780 palette: ReadonlyArray<string>,
781): string | undefined {
782 const parts = code.split(";").map(Number);
783 const head = parts[0]!;
784 if (head === base + 8) {
785 if (parts[1] === 5) return xtermColor(parts[2]!, palette);
786 return `rgb(${parts[2]},${parts[3]},${parts[4]})`;
787 }
788 if (head >= base && head <= base + 7) return palette[head - base];
789 if (head >= base + 60 && head <= base + 67) return palette[head - base - 52];
790 return undefined;
791}
792
793type Rgb = readonly [number, number, number];
794
795function parseCssColor(color: string): Rgb | undefined {
796 if (color.startsWith("#") && color.length === 7) {
797 return [1, 3, 5].map((offset) =>
798 Number.parseInt(color.slice(offset, offset + 2), 16),
799 ) as unknown as Rgb;
800 }
801 const match = /^rgb\((\d+),(\d+),(\d+)\)$/u.exec(color);
802 return match === null ? undefined : [Number(match[1]), Number(match[2]), Number(match[3])];
803}
804
805function luminance([r, g, b]: Rgb): number {
806 const channel = (value: number) => {
807 const scaled = value / 255;
808 return scaled <= 0.03928 ? scaled / 12.92 : ((scaled + 0.055) / 1.055) ** 2.4;
809 };
810 return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
811}
812
813function contrastRatio(a: Rgb, b: Rgb): number {
814 const [first, second] = [luminance(a), luminance(b)];
815 return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05);
816}
817
818function spanStyle(pen: string, palette: ReadonlyArray<string>): TerminalSpanStyle | null {
819 if (pen === "") return null;
820 const attributes = parsePen(pen);
821 let color = attributes.fg === "" ? undefined : resolveColor(attributes.fg, 30, palette);
822 let backgroundColor = attributes.bg === "" ? undefined : resolveColor(attributes.bg, 40, palette);
823 if (attributes.inverse) {
824 [color, backgroundColor] = [backgroundColor, color ?? palette[7]];
825 }
826 // Badges like "PASS" on green assume a terminal's own palette; keep their text readable.
827 if (backgroundColor !== undefined) {
828 const background = parseCssColor(backgroundColor);
829 const foreground = color === undefined ? undefined : parseCssColor(color);
830 if (
831 background !== undefined &&
832 (foreground === undefined || contrastRatio(foreground, background) < 3)
833 ) {
834 color = luminance(background) > 0.35 ? "#1f2328" : "#ffffff";
835 }
836 }
837 const textDecorationLine = attributes.underline
838 ? attributes.strike
839 ? "underline line-through"
840 : "underline"
841 : attributes.strike
842 ? "line-through"
843 : undefined;
844 return {
845 ...(color === undefined ? {} : { color }),
846 ...(backgroundColor === undefined ? {} : { backgroundColor }),
847 ...(attributes.bold ? { fontWeight: "600" } : {}),
848 ...(attributes.dim ? { opacity: 0.65 } : {}),
849 ...(attributes.italic ? { fontStyle: "italic" } : {}),
850 ...(textDecorationLine === undefined ? {} : { textDecorationLine }),
851 };
852}
853
854/** Caps styled spans at maxSpans, then emits one plain remainder to bound DOM/native nodes. */
855export function terminalOutputSpans(
856 text: string,
857 palette: ReadonlyArray<string>,
858 maxSpans = 2_000,
859): ReadonlyArray<TerminalSpan> {
860 const spans: Array<TerminalSpan> = [];
861 const styles = new Map<string, TerminalSpanStyle | null>();
862 let pen = "";
863 let cursor = 0;
864 const push = (end: number) => {
865 if (end <= cursor) return;
866 let style = styles.get(pen);
867 if (style === undefined) {
868 style = spanStyle(pen, palette);
869 styles.set(pen, style);
870 }
871 spans.push({ text: text.slice(cursor, end), style });
872 };
873 while (cursor < text.length) {
874 const escape = text.indexOf(`${ESC}[`, cursor);
875 if (escape === -1 || spans.length >= maxSpans) break;
876 push(escape);
877 const end = text.indexOf("m", escape);
878 if (end === -1) break;
879 const params = text.slice(escape + 2, end);
880 pen = params === "0" ? "" : params.replace(/^0;/u, "");
881 cursor = end + 1;
882 }
883 if (cursor < text.length) {
884 if (spans.length >= maxSpans) {
885 spans.push({ text: terminalOutputPlainText(text.slice(cursor)), style: null });
886 } else {
887 push(text.length);
888 }
889 }
890 return spans;
891}
892
893const SGR_PATTERN = new RegExp(`${ESC}\\[[0-9;]*m`, "gu");
894
895export function terminalOutputPlainText(text: string): string {
896 return text.replace(SGR_PATTERN, "");
897}