@silvery/term 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/package.json +54 -0
  2. package/src/adapters/canvas-adapter.ts +356 -0
  3. package/src/adapters/dom-adapter.ts +452 -0
  4. package/src/adapters/flexily-zero-adapter.ts +368 -0
  5. package/src/adapters/terminal-adapter.ts +305 -0
  6. package/src/adapters/yoga-adapter.ts +370 -0
  7. package/src/ansi/ansi.ts +251 -0
  8. package/src/ansi/constants.ts +76 -0
  9. package/src/ansi/detection.ts +441 -0
  10. package/src/ansi/hyperlink.ts +38 -0
  11. package/src/ansi/index.ts +201 -0
  12. package/src/ansi/patch-console.ts +159 -0
  13. package/src/ansi/sgr-codes.ts +34 -0
  14. package/src/ansi/storybook.ts +209 -0
  15. package/src/ansi/term.ts +724 -0
  16. package/src/ansi/types.ts +202 -0
  17. package/src/ansi/underline.ts +156 -0
  18. package/src/ansi/utils.ts +65 -0
  19. package/src/ansi-sanitize.ts +509 -0
  20. package/src/app.ts +571 -0
  21. package/src/bound-term.ts +94 -0
  22. package/src/bracketed-paste.ts +75 -0
  23. package/src/browser-renderer.ts +174 -0
  24. package/src/buffer.ts +1984 -0
  25. package/src/clipboard.ts +74 -0
  26. package/src/cursor-query.ts +85 -0
  27. package/src/device-attrs.ts +228 -0
  28. package/src/devtools.ts +123 -0
  29. package/src/dom/index.ts +194 -0
  30. package/src/errors.ts +39 -0
  31. package/src/focus-reporting.ts +48 -0
  32. package/src/hit-registry-core.ts +228 -0
  33. package/src/hit-registry.ts +176 -0
  34. package/src/index.ts +458 -0
  35. package/src/input.ts +119 -0
  36. package/src/inspector.ts +155 -0
  37. package/src/kitty-detect.ts +95 -0
  38. package/src/kitty-manager.ts +160 -0
  39. package/src/layout-engine.ts +296 -0
  40. package/src/layout.ts +26 -0
  41. package/src/measurer.ts +74 -0
  42. package/src/mode-query.ts +106 -0
  43. package/src/mouse-events.ts +419 -0
  44. package/src/mouse.ts +83 -0
  45. package/src/non-tty.ts +223 -0
  46. package/src/osc-markers.ts +32 -0
  47. package/src/osc-palette.ts +169 -0
  48. package/src/output.ts +406 -0
  49. package/src/pane-manager.ts +248 -0
  50. package/src/pipeline/CLAUDE.md +587 -0
  51. package/src/pipeline/content-phase-adapter.ts +976 -0
  52. package/src/pipeline/content-phase.ts +1765 -0
  53. package/src/pipeline/helpers.ts +42 -0
  54. package/src/pipeline/index.ts +416 -0
  55. package/src/pipeline/layout-phase.ts +686 -0
  56. package/src/pipeline/measure-phase.ts +198 -0
  57. package/src/pipeline/measure-stats.ts +21 -0
  58. package/src/pipeline/output-phase.ts +2593 -0
  59. package/src/pipeline/render-box.ts +343 -0
  60. package/src/pipeline/render-helpers.ts +243 -0
  61. package/src/pipeline/render-text.ts +1255 -0
  62. package/src/pipeline/types.ts +161 -0
  63. package/src/pipeline.ts +29 -0
  64. package/src/pixel-size.ts +119 -0
  65. package/src/render-adapter.ts +179 -0
  66. package/src/renderer.ts +1330 -0
  67. package/src/runtime/create-app.tsx +1845 -0
  68. package/src/runtime/create-buffer.ts +18 -0
  69. package/src/runtime/create-runtime.ts +325 -0
  70. package/src/runtime/diff.ts +56 -0
  71. package/src/runtime/event-handlers.ts +254 -0
  72. package/src/runtime/index.ts +119 -0
  73. package/src/runtime/keys.ts +8 -0
  74. package/src/runtime/layout.ts +164 -0
  75. package/src/runtime/run.tsx +318 -0
  76. package/src/runtime/term-provider.ts +399 -0
  77. package/src/runtime/terminal-lifecycle.ts +246 -0
  78. package/src/runtime/tick.ts +219 -0
  79. package/src/runtime/types.ts +210 -0
  80. package/src/scheduler.ts +723 -0
  81. package/src/screenshot.ts +57 -0
  82. package/src/scroll-region.ts +69 -0
  83. package/src/scroll-utils.ts +97 -0
  84. package/src/term-def.ts +267 -0
  85. package/src/terminal-caps.ts +5 -0
  86. package/src/terminal-colors.ts +216 -0
  87. package/src/termtest.ts +224 -0
  88. package/src/text-sizing.ts +109 -0
  89. package/src/toolbelt/index.ts +72 -0
  90. package/src/unicode.ts +1763 -0
  91. package/src/xterm/index.ts +491 -0
  92. package/src/xterm/xterm-provider.ts +204 -0
package/src/buffer.ts ADDED
@@ -0,0 +1,1984 @@
1
+ /**
2
+ * Terminal buffer implementation for Silvery.
3
+ *
4
+ * Uses packed Uint32Array for efficient cell metadata storage,
5
+ * with separate string array for character storage (needed for
6
+ * multi-byte Unicode graphemes and combining characters).
7
+ */
8
+
9
+ import { fgColorCode, bgColorCode } from "./ansi/sgr-codes"
10
+
11
+ // ============================================================================
12
+ // Types
13
+ // ============================================================================
14
+
15
+ /**
16
+ * Underline style variants (SGR 4:x codes).
17
+ * - false: no underline
18
+ * - 'single': standard underline (SGR 4 or 4:1)
19
+ * - 'double': double underline (SGR 4:2)
20
+ * - 'curly': curly/wavy underline (SGR 4:3)
21
+ * - 'dotted': dotted underline (SGR 4:4)
22
+ * - 'dashed': dashed underline (SGR 4:5)
23
+ */
24
+ export type UnderlineStyle = false | "single" | "double" | "curly" | "dotted" | "dashed"
25
+
26
+ /**
27
+ * Text attributes that can be applied to a cell.
28
+ */
29
+ export interface CellAttrs {
30
+ bold?: boolean
31
+ dim?: boolean
32
+ italic?: boolean
33
+ /** Simple underline flag (for backwards compatibility) */
34
+ underline?: boolean
35
+ /**
36
+ * Underline style: 'single' | 'double' | 'curly' | 'dotted' | 'dashed'.
37
+ * When set, takes precedence over the underline boolean.
38
+ */
39
+ underlineStyle?: UnderlineStyle
40
+ blink?: boolean
41
+ inverse?: boolean
42
+ hidden?: boolean
43
+ strikethrough?: boolean
44
+ }
45
+
46
+ /**
47
+ * Color representation.
48
+ * - number: 256-color index (0-255)
49
+ * - RGB object: true color
50
+ * - null: default/inherit
51
+ * - DEFAULT_BG: terminal's default background (SGR 49), opaque but uses terminal's own bg color
52
+ */
53
+ export type Color = number | { r: number; g: number; b: number } | null
54
+
55
+ /**
56
+ * Sentinel color representing the terminal's default background (SGR 49).
57
+ * Unlike `null` (transparent/inherit), this actively fills cells with the
58
+ * terminal's configured background, making the element opaque.
59
+ */
60
+ export const DEFAULT_BG: Color = Object.freeze({ r: -1, g: -1, b: -1 })
61
+
62
+ /** Check if a color is the default bg sentinel. */
63
+ export function isDefaultBg(color: Color): boolean {
64
+ return color !== null && typeof color === "object" && color.r === -1
65
+ }
66
+
67
+ /**
68
+ * A single cell in the terminal buffer.
69
+ */
70
+ export interface Cell {
71
+ /** The character/grapheme in this cell */
72
+ char: string
73
+ /** Foreground color */
74
+ fg: Color
75
+ /** Background color */
76
+ bg: Color
77
+ /**
78
+ * Underline color (independent of fg).
79
+ * Uses SGR 58. If null, underline uses fg color.
80
+ */
81
+ underlineColor: Color
82
+ /** Text attributes */
83
+ attrs: CellAttrs
84
+ /** True if this is a wide character (CJK, emoji, etc.) */
85
+ wide: boolean
86
+ /** True if this is the continuation cell after a wide character */
87
+ continuation: boolean
88
+ /**
89
+ * OSC 8 hyperlink URL.
90
+ * When set, the cell is part of a clickable hyperlink in supporting terminals.
91
+ */
92
+ hyperlink?: string
93
+ }
94
+
95
+ /**
96
+ * Style information for a cell (excludes char and position flags).
97
+ */
98
+ export interface Style {
99
+ fg: Color
100
+ bg: Color
101
+ /**
102
+ * Underline color (independent of fg).
103
+ * Uses SGR 58. If null, underline uses fg color.
104
+ */
105
+ underlineColor?: Color
106
+ attrs: CellAttrs
107
+ /**
108
+ * OSC 8 hyperlink URL.
109
+ * When set, the cell is part of a clickable hyperlink in supporting terminals.
110
+ */
111
+ hyperlink?: string
112
+ }
113
+
114
+ // ============================================================================
115
+ // Constants
116
+ // ============================================================================
117
+
118
+ // Bit packing layout for cell metadata in Uint32Array:
119
+ // [0-7]: foreground color index (8 bits)
120
+ // [8-15]: background color index (8 bits)
121
+ // [16-23]: attributes (8 bits): bold, dim, italic, blink, inverse, hidden, strikethrough + 1 spare
122
+ // [24-26]: underline style (3 bits): 0=none, 1=single, 2=double, 3=curly, 4=dotted, 5=dashed
123
+ // [27-31]: flags (5 bits): wide, continuation, true_color_fg, true_color_bg + 1 spare
124
+
125
+ // Attribute bit positions (within bits 16-23)
126
+ const ATTR_BOLD = 1 << 16
127
+ const ATTR_DIM = 1 << 17
128
+ const ATTR_ITALIC = 1 << 18
129
+ const ATTR_BLINK = 1 << 19
130
+ const ATTR_INVERSE = 1 << 20
131
+ const ATTR_HIDDEN = 1 << 21
132
+ const ATTR_STRIKETHROUGH = 1 << 22
133
+ // bit 23 spare
134
+
135
+ // Underline style (3 bits in positions 24-26)
136
+ // 0 = no underline, 1 = single, 2 = double, 3 = curly, 4 = dotted, 5 = dashed
137
+ const UNDERLINE_STYLE_SHIFT = 24
138
+ const UNDERLINE_STYLE_MASK = 0x7 << UNDERLINE_STYLE_SHIFT // 3 bits
139
+
140
+ // Flag bit positions (in bits 27-31)
141
+ const WIDE_FLAG = 1 << 27
142
+ const CONTINUATION_FLAG = 1 << 28
143
+ const TRUE_COLOR_FG_FLAG = 1 << 29
144
+ const TRUE_COLOR_BG_FLAG = 1 << 30
145
+ // bit 31 spare
146
+
147
+ /**
148
+ * Packed attribute bits that make a space character visually meaningful.
149
+ * Inverse makes spaces visible (block of color), underline draws a line under spaces,
150
+ * strikethrough draws a line through spaces. Other attrs (bold, dim, italic) don't
151
+ * visually affect space characters.
152
+ */
153
+ export const VISIBLE_SPACE_ATTR_MASK = ATTR_INVERSE | ATTR_STRIKETHROUGH | UNDERLINE_STYLE_MASK
154
+
155
+ // Default empty cell
156
+ const EMPTY_CELL: Cell = {
157
+ char: " ",
158
+ fg: null,
159
+ bg: null,
160
+ underlineColor: null,
161
+ attrs: {},
162
+ wide: false,
163
+ continuation: false,
164
+ hyperlink: undefined,
165
+ }
166
+
167
+ /** Frozen empty attrs object, shared across zero-allocation reads for OOB cells */
168
+ const EMPTY_ATTRS: CellAttrs = Object.freeze({})
169
+
170
+ // ============================================================================
171
+ // Packing/Unpacking Helpers
172
+ // ============================================================================
173
+
174
+ /**
175
+ * Map UnderlineStyle to numeric value for bit packing.
176
+ */
177
+ function underlineStyleToNumber(style: UnderlineStyle | undefined): number {
178
+ switch (style) {
179
+ case false:
180
+ return 0
181
+ case "single":
182
+ return 1
183
+ case "double":
184
+ return 2
185
+ case "curly":
186
+ return 3
187
+ case "dotted":
188
+ return 4
189
+ case "dashed":
190
+ return 5
191
+ default:
192
+ return 0 // undefined or unknown = no underline
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Map numeric value back to UnderlineStyle.
198
+ */
199
+ function numberToUnderlineStyle(n: number): UnderlineStyle | undefined {
200
+ switch (n) {
201
+ case 0:
202
+ return undefined // No underline
203
+ case 1:
204
+ return "single"
205
+ case 2:
206
+ return "double"
207
+ case 3:
208
+ return "curly"
209
+ case 4:
210
+ return "dotted"
211
+ case 5:
212
+ return "dashed"
213
+ default:
214
+ return undefined
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Convert CellAttrs to bits for packing (used internally by packCell).
220
+ * Note: This packs into the full 32-bit word, not just the attrs byte.
221
+ */
222
+ export function attrsToNumber(attrs: CellAttrs): number {
223
+ let n = 0
224
+ if (attrs.bold) n |= ATTR_BOLD
225
+ if (attrs.dim) n |= ATTR_DIM
226
+ if (attrs.italic) n |= ATTR_ITALIC
227
+ if (attrs.blink) n |= ATTR_BLINK
228
+ if (attrs.inverse) n |= ATTR_INVERSE
229
+ if (attrs.hidden) n |= ATTR_HIDDEN
230
+ if (attrs.strikethrough) n |= ATTR_STRIKETHROUGH
231
+
232
+ // Pack underline style (3 bits)
233
+ // If underlineStyle is set, use it. Otherwise, check underline boolean.
234
+ const ulStyle = attrs.underlineStyle ?? (attrs.underline ? "single" : undefined)
235
+ n |= underlineStyleToNumber(ulStyle) << UNDERLINE_STYLE_SHIFT
236
+
237
+ return n
238
+ }
239
+
240
+ /**
241
+ * Convert a number back to CellAttrs.
242
+ */
243
+ export function numberToAttrs(n: number): CellAttrs {
244
+ const attrs: CellAttrs = {}
245
+ if (n & ATTR_BOLD) attrs.bold = true
246
+ if (n & ATTR_DIM) attrs.dim = true
247
+ if (n & ATTR_ITALIC) attrs.italic = true
248
+ if (n & ATTR_BLINK) attrs.blink = true
249
+ if (n & ATTR_INVERSE) attrs.inverse = true
250
+ if (n & ATTR_HIDDEN) attrs.hidden = true
251
+ if (n & ATTR_STRIKETHROUGH) attrs.strikethrough = true
252
+
253
+ // Unpack underline style
254
+ const ulStyleNum = (n & UNDERLINE_STYLE_MASK) >> UNDERLINE_STYLE_SHIFT
255
+ const ulStyle = numberToUnderlineStyle(ulStyleNum)
256
+ if (ulStyle) {
257
+ attrs.underlineStyle = ulStyle
258
+ attrs.underline = true
259
+ }
260
+
261
+ return attrs
262
+ }
263
+
264
+ /**
265
+ * Convert a color to an index value for packing.
266
+ * Returns 0 for null (default), or (index + 1) for 256-color.
267
+ * This +1 offset allows distinguishing null from black (color index 0).
268
+ * True color is handled separately via flags and auxiliary storage.
269
+ */
270
+ function colorToIndex(color: Color): number {
271
+ if (color === null) return 0
272
+ if (typeof color === "number") return (color & 0xff) + 1 // +1 to distinguish from null
273
+ // True color - return 0, handle via flag
274
+ return 0
275
+ }
276
+
277
+ /**
278
+ * Check if a color is true color (RGB).
279
+ */
280
+ function isTrueColor(color: Color): color is { r: number; g: number; b: number } {
281
+ return color !== null && typeof color === "object"
282
+ }
283
+
284
+ /**
285
+ * Pack cell metadata into a 32-bit number.
286
+ */
287
+ export function packCell(cell: Cell): number {
288
+ let packed = 0
289
+
290
+ // Foreground color index (bits 0-7)
291
+ packed |= colorToIndex(cell.fg) & 0xff
292
+
293
+ // Background color index (bits 8-15)
294
+ packed |= (colorToIndex(cell.bg) & 0xff) << 8
295
+
296
+ // Attributes (bits 16-22) and underline style (bits 24-26)
297
+ // attrsToNumber returns bits already in their final positions
298
+ packed |= attrsToNumber(cell.attrs)
299
+
300
+ // Flags (bits 27-30)
301
+ if (cell.wide) packed |= WIDE_FLAG
302
+ if (cell.continuation) packed |= CONTINUATION_FLAG
303
+ if (isTrueColor(cell.fg)) packed |= TRUE_COLOR_FG_FLAG
304
+ if (isTrueColor(cell.bg)) packed |= TRUE_COLOR_BG_FLAG
305
+
306
+ return packed
307
+ }
308
+
309
+ /**
310
+ * Unpack foreground color index from packed value.
311
+ */
312
+ function unpackFgIndex(packed: number): number {
313
+ return packed & 0xff
314
+ }
315
+
316
+ /**
317
+ * Unpack background color index from packed value.
318
+ */
319
+ function unpackBgIndex(packed: number): number {
320
+ return (packed >> 8) & 0xff
321
+ }
322
+
323
+ /**
324
+ * Unpack attributes from packed value.
325
+ * Extracts both the boolean attrs (bits 16-22) and underline style (bits 24-26).
326
+ */
327
+ function unpackAttrs(packed: number): CellAttrs {
328
+ // numberToAttrs expects the full packed value with attrs in bits 16-22
329
+ // and underline style in bits 24-26
330
+ return numberToAttrs(packed)
331
+ }
332
+
333
+ /**
334
+ * Check if wide flag is set.
335
+ */
336
+ function unpackWide(packed: number): boolean {
337
+ return (packed & WIDE_FLAG) !== 0
338
+ }
339
+
340
+ /**
341
+ * Check if continuation flag is set.
342
+ */
343
+ function unpackContinuation(packed: number): boolean {
344
+ return (packed & CONTINUATION_FLAG) !== 0
345
+ }
346
+
347
+ /**
348
+ * Check if true color foreground flag is set.
349
+ */
350
+ function unpackTrueColorFg(packed: number): boolean {
351
+ return (packed & TRUE_COLOR_FG_FLAG) !== 0
352
+ }
353
+
354
+ /**
355
+ * Check if true color background flag is set.
356
+ */
357
+ function unpackTrueColorBg(packed: number): boolean {
358
+ return (packed & TRUE_COLOR_BG_FLAG) !== 0
359
+ }
360
+
361
+ // ============================================================================
362
+ // TerminalBuffer Class
363
+ // ============================================================================
364
+
365
+ /**
366
+ * Efficient terminal cell buffer.
367
+ *
368
+ * Uses packed Uint32Array for cell metadata and separate string array
369
+ * for characters. This allows efficient diffing while supporting
370
+ * full Unicode grapheme clusters.
371
+ */
372
+ export class TerminalBuffer {
373
+ /** Packed cell metadata */
374
+ private cells: Uint32Array
375
+ /** Character storage (one per cell, may be multi-byte grapheme) */
376
+ private chars: string[]
377
+ /** True color foreground storage (only for cells with true color fg) */
378
+ private fgColors: Map<number, { r: number; g: number; b: number }>
379
+ /** True color background storage (only for cells with true color bg) */
380
+ private bgColors: Map<number, { r: number; g: number; b: number }>
381
+ /** Underline color storage (independent of fg, for SGR 58) */
382
+ private underlineColors: Map<number, Color>
383
+ /** OSC 8 hyperlink URL storage (only for cells that are part of a hyperlink) */
384
+ private hyperlinks: Map<number, string>
385
+ /**
386
+ * Per-row dirty tracking for diff optimization.
387
+ * When set, diffBuffers() can skip clean rows entirely.
388
+ * 0 = clean (unchanged since last resetDirtyRows), 1 = dirty (modified).
389
+ */
390
+ private _dirtyRows: Uint8Array
391
+ /** Bounding box: first dirty row (inclusive). -1 when no rows are dirty. */
392
+ private _minDirtyRow: number
393
+ /** Bounding box: last dirty row (inclusive). -1 when no rows are dirty. */
394
+ private _maxDirtyRow: number
395
+
396
+ readonly width: number
397
+ readonly height: number
398
+
399
+ constructor(width: number, height: number) {
400
+ this.width = width
401
+ this.height = height
402
+ const size = width * height
403
+ this.cells = new Uint32Array(size)
404
+ this.chars = new Array<string>(size).fill(" ")
405
+ this.fgColors = new Map()
406
+ this.bgColors = new Map()
407
+ this.underlineColors = new Map()
408
+ this.hyperlinks = new Map()
409
+ // All rows start dirty (fresh buffer needs full diff on first comparison)
410
+ this._dirtyRows = new Uint8Array(height).fill(1)
411
+ this._minDirtyRow = 0
412
+ this._maxDirtyRow = height - 1
413
+ }
414
+
415
+ /**
416
+ * Get the index for a cell position.
417
+ */
418
+ private index(x: number, y: number): number {
419
+ return y * this.width + x
420
+ }
421
+
422
+ /**
423
+ * Check if coordinates are within bounds.
424
+ */
425
+ inBounds(x: number, y: number): boolean {
426
+ return x >= 0 && x < this.width && y >= 0 && y < this.height
427
+ }
428
+
429
+ /**
430
+ * Get a cell at the given position.
431
+ */
432
+ getCell(x: number, y: number): Cell {
433
+ if (!this.inBounds(x, y)) {
434
+ return { ...EMPTY_CELL }
435
+ }
436
+
437
+ const idx = this.index(x, y)
438
+ const packed = this.cells[idx]
439
+ const char = this.chars[idx]
440
+
441
+ // Determine foreground color
442
+ // Color indices are stored with +1 offset (0=null, 1=black, 2=red, etc.)
443
+ let fg: Color = null
444
+ if (unpackTrueColorFg(packed!)) {
445
+ fg = this.fgColors.get(idx) ?? null
446
+ } else {
447
+ const fgIndex = unpackFgIndex(packed!)
448
+ fg = fgIndex > 0 ? fgIndex - 1 : null // -1 to restore actual color index
449
+ }
450
+
451
+ // Determine background color
452
+ let bg: Color = null
453
+ if (unpackTrueColorBg(packed!)) {
454
+ bg = this.bgColors.get(idx) ?? null
455
+ } else {
456
+ const bgIndex = unpackBgIndex(packed!)
457
+ bg = bgIndex > 0 ? bgIndex - 1 : null // -1 to restore actual color index
458
+ }
459
+
460
+ const hyperlink = this.hyperlinks.get(idx)
461
+ return {
462
+ char: char!,
463
+ fg,
464
+ bg,
465
+ underlineColor: this.underlineColors.get(idx) ?? null,
466
+ attrs: unpackAttrs(packed!),
467
+ wide: unpackWide(packed!),
468
+ continuation: unpackContinuation(packed!),
469
+ ...(hyperlink !== undefined ? { hyperlink } : {}),
470
+ }
471
+ }
472
+
473
+ // --------------------------------------------------------------------------
474
+ // Zero-allocation cell accessors for hot paths
475
+ // --------------------------------------------------------------------------
476
+
477
+ /**
478
+ * Get just the character at a cell position (no object allocation).
479
+ * Returns " " for out-of-bounds positions.
480
+ */
481
+ getCellChar(x: number, y: number): string {
482
+ if (!this.inBounds(x, y)) return " "
483
+ return this.chars[this.index(x, y)]!
484
+ }
485
+
486
+ /**
487
+ * Get just the background color at a cell position (no object allocation).
488
+ * Returns null for out-of-bounds positions.
489
+ */
490
+ getCellBg(x: number, y: number): Color {
491
+ if (!this.inBounds(x, y)) return null
492
+ const idx = this.index(x, y)
493
+ const packed = this.cells[idx]!
494
+ if (unpackTrueColorBg(packed)) {
495
+ return this.bgColors.get(idx) ?? null
496
+ }
497
+ const bgIndex = unpackBgIndex(packed)
498
+ return bgIndex > 0 ? bgIndex - 1 : null
499
+ }
500
+
501
+ /**
502
+ * Get just the foreground color at a cell position (no object allocation).
503
+ * Returns null for out-of-bounds positions.
504
+ */
505
+ getCellFg(x: number, y: number): Color {
506
+ if (!this.inBounds(x, y)) return null
507
+ const idx = this.index(x, y)
508
+ const packed = this.cells[idx]!
509
+ if (unpackTrueColorFg(packed)) {
510
+ return this.fgColors.get(idx) ?? null
511
+ }
512
+ const fgIndex = unpackFgIndex(packed)
513
+ return fgIndex > 0 ? fgIndex - 1 : null
514
+ }
515
+
516
+ /**
517
+ * Get the raw packed metadata at a cell position (no unpackAttrs allocation).
518
+ * Returns 0 for out-of-bounds positions. The packed value contains color
519
+ * indices, attr bits, underline style, and flags in a single Uint32.
520
+ */
521
+ getCellAttrs(x: number, y: number): number {
522
+ if (!this.inBounds(x, y)) return 0
523
+ return this.cells[this.index(x, y)]!
524
+ }
525
+
526
+ /**
527
+ * Check if a cell is a wide character (no object allocation).
528
+ * Returns false for out-of-bounds positions.
529
+ */
530
+ isCellWide(x: number, y: number): boolean {
531
+ if (!this.inBounds(x, y)) return false
532
+ return unpackWide(this.cells[this.index(x, y)]!)
533
+ }
534
+
535
+ /**
536
+ * Check if a cell is a continuation of a wide character (no object allocation).
537
+ * Returns false for out-of-bounds positions.
538
+ */
539
+ isCellContinuation(x: number, y: number): boolean {
540
+ if (!this.inBounds(x, y)) return false
541
+ return unpackContinuation(this.cells[this.index(x, y)]!)
542
+ }
543
+
544
+ /**
545
+ * Read cell data into a caller-provided Cell object (zero-allocation).
546
+ * For hot loops that need the full Cell, reuse a single object:
547
+ *
548
+ * const cell = createMutableCell()
549
+ * for (...) { buffer.readCellInto(x, y, cell) }
550
+ *
551
+ * Returns the same `out` object for chaining convenience.
552
+ */
553
+ readCellInto(x: number, y: number, out: Cell): Cell {
554
+ if (!this.inBounds(x, y)) {
555
+ out.char = " "
556
+ out.fg = null
557
+ out.bg = null
558
+ out.underlineColor = null
559
+ out.attrs = EMPTY_ATTRS
560
+ out.wide = false
561
+ out.continuation = false
562
+ out.hyperlink = undefined
563
+ return out
564
+ }
565
+
566
+ const idx = this.index(x, y)
567
+ const packed = this.cells[idx]!
568
+
569
+ out.char = this.chars[idx]!
570
+
571
+ // Foreground color
572
+ if (unpackTrueColorFg(packed)) {
573
+ out.fg = this.fgColors.get(idx) ?? null
574
+ } else {
575
+ const fgIndex = unpackFgIndex(packed)
576
+ out.fg = fgIndex > 0 ? fgIndex - 1 : null
577
+ }
578
+
579
+ // Background color
580
+ if (unpackTrueColorBg(packed)) {
581
+ out.bg = this.bgColors.get(idx) ?? null
582
+ } else {
583
+ const bgIndex = unpackBgIndex(packed)
584
+ out.bg = bgIndex > 0 ? bgIndex - 1 : null
585
+ }
586
+
587
+ out.underlineColor = this.underlineColors.get(idx) ?? null
588
+
589
+ // Unpack attrs inline to avoid allocating a new CellAttrs object.
590
+ // We reuse the existing out.attrs object when possible.
591
+ const attrs = out.attrs === EMPTY_ATTRS ? ((out.attrs = {}), out.attrs) : out.attrs
592
+ attrs.bold = (packed & ATTR_BOLD) !== 0 ? true : undefined
593
+ attrs.dim = (packed & ATTR_DIM) !== 0 ? true : undefined
594
+ attrs.italic = (packed & ATTR_ITALIC) !== 0 ? true : undefined
595
+ attrs.blink = (packed & ATTR_BLINK) !== 0 ? true : undefined
596
+ attrs.inverse = (packed & ATTR_INVERSE) !== 0 ? true : undefined
597
+ attrs.hidden = (packed & ATTR_HIDDEN) !== 0 ? true : undefined
598
+ attrs.strikethrough = (packed & ATTR_STRIKETHROUGH) !== 0 ? true : undefined
599
+
600
+ const ulStyleNum = (packed & UNDERLINE_STYLE_MASK) >> UNDERLINE_STYLE_SHIFT
601
+ const ulStyle = numberToUnderlineStyle(ulStyleNum)
602
+ if (ulStyle) {
603
+ attrs.underlineStyle = ulStyle
604
+ attrs.underline = true
605
+ } else {
606
+ attrs.underlineStyle = undefined
607
+ attrs.underline = undefined
608
+ }
609
+
610
+ out.wide = (packed & WIDE_FLAG) !== 0
611
+ out.continuation = (packed & CONTINUATION_FLAG) !== 0
612
+ out.hyperlink = this.hyperlinks.get(idx)
613
+
614
+ return out
615
+ }
616
+
617
+ /**
618
+ * Set a cell at the given position.
619
+ *
620
+ * Optimized: resolves defaults and packs metadata inline to avoid
621
+ * allocating an intermediate Cell object.
622
+ */
623
+ setCell(x: number, y: number, cell: Partial<Cell>): void {
624
+ if (!this.inBounds(x, y)) {
625
+ return
626
+ }
627
+
628
+ // Write trap for SILVERY_STRICT mismatch diagnosis
629
+ const trap = (globalThis as any).__silvery_write_trap
630
+ if (trap && x === trap.x && y === trap.y) {
631
+ const char = cell.char ?? " "
632
+ const stack = new Error().stack?.split("\n").slice(1, 6).join("\n") ?? ""
633
+ trap.log.push(
634
+ ` char="${char}" fg=${cell.fg ?? "null"} bg=${cell.bg ?? "null"} dim=${cell.attrs?.dim} ul=${cell.attrs?.underline}\n${stack}`,
635
+ )
636
+ }
637
+
638
+ this._dirtyRows[y] = 1
639
+ if (this._minDirtyRow === -1 || y < this._minDirtyRow) this._minDirtyRow = y
640
+ if (y > this._maxDirtyRow) this._maxDirtyRow = y
641
+
642
+ const idx = this.index(x, y)
643
+
644
+ // Resolve properties with defaults (no intermediate object)
645
+ const char = cell.char ?? " "
646
+ const fg = cell.fg ?? null
647
+ const bg = cell.bg ?? null
648
+ const underlineColor = cell.underlineColor ?? null
649
+ const attrs = cell.attrs ?? EMPTY_ATTRS
650
+ const wide = cell.wide ?? false
651
+ const continuation = cell.continuation ?? false
652
+
653
+ // Store character
654
+ this.chars[idx] = char
655
+
656
+ // Handle true color storage
657
+ if (isTrueColor(fg)) {
658
+ this.fgColors.set(idx, fg)
659
+ } else {
660
+ this.fgColors.delete(idx)
661
+ }
662
+
663
+ if (isTrueColor(bg)) {
664
+ this.bgColors.set(idx, bg)
665
+ } else {
666
+ this.bgColors.delete(idx)
667
+ }
668
+
669
+ // Handle underline color storage
670
+ if (underlineColor !== null) {
671
+ this.underlineColors.set(idx, underlineColor)
672
+ } else {
673
+ this.underlineColors.delete(idx)
674
+ }
675
+
676
+ // Handle hyperlink storage
677
+ const hyperlink = cell.hyperlink
678
+ if (hyperlink !== undefined && hyperlink !== "") {
679
+ this.hyperlinks.set(idx, hyperlink)
680
+ } else {
681
+ this.hyperlinks.delete(idx)
682
+ }
683
+
684
+ // Pack metadata inline (avoids packCell's fullCell parameter overhead)
685
+ let packed = 0
686
+ packed |= colorToIndex(fg) & 0xff
687
+ packed |= (colorToIndex(bg) & 0xff) << 8
688
+ packed |= attrsToNumber(attrs)
689
+ if (wide) packed |= WIDE_FLAG
690
+ if (continuation) packed |= CONTINUATION_FLAG
691
+ if (isTrueColor(fg)) packed |= TRUE_COLOR_FG_FLAG
692
+ if (isTrueColor(bg)) packed |= TRUE_COLOR_BG_FLAG
693
+ this.cells[idx] = packed
694
+ }
695
+
696
+ /**
697
+ * Fill a region with a cell.
698
+ *
699
+ * Optimized: packs cell metadata once and assigns directly to arrays,
700
+ * avoiding O(width*height) intermediate object allocations from setCell().
701
+ */
702
+ fill(x: number, y: number, width: number, height: number, cell: Partial<Cell>): void {
703
+ const endX = Math.min(x + width, this.width)
704
+ const endY = Math.min(y + height, this.height)
705
+ const startX = Math.max(0, x)
706
+ const startY = Math.max(0, y)
707
+
708
+ if (startX >= endX || startY >= endY) return
709
+
710
+ // Resolve cell properties once (instead of per-cell in setCell)
711
+ const char = cell.char ?? " "
712
+ const fg = cell.fg ?? null
713
+ const bg = cell.bg ?? null
714
+ const underlineColor = cell.underlineColor ?? null
715
+ const attrs = cell.attrs ?? {}
716
+ const wide = cell.wide ?? false
717
+ const continuation = cell.continuation ?? false
718
+
719
+ // Pack metadata once for the entire fill region
720
+ const fullCell: Cell = {
721
+ char,
722
+ fg,
723
+ bg,
724
+ underlineColor,
725
+ attrs,
726
+ wide,
727
+ continuation,
728
+ }
729
+ const packed = packCell(fullCell)
730
+
731
+ // Determine true color values once
732
+ const hasTrueColorFg = isTrueColor(fg)
733
+ const hasTrueColorBg = isTrueColor(bg)
734
+ const trueColorFg = hasTrueColorFg ? (fg as { r: number; g: number; b: number }) : null
735
+ const trueColorBg = hasTrueColorBg ? (bg as { r: number; g: number; b: number }) : null
736
+ const hasUnderlineColor = underlineColor !== null
737
+ const hyperlink = cell.hyperlink
738
+ const hasHyperlink = hyperlink !== undefined && hyperlink !== ""
739
+
740
+ // Mark affected rows dirty + update bounding box
741
+ for (let cy = startY; cy < endY; cy++) {
742
+ this._dirtyRows[cy] = 1
743
+ }
744
+ if (startY < endY) {
745
+ if (this._minDirtyRow === -1 || startY < this._minDirtyRow) this._minDirtyRow = startY
746
+ if (endY - 1 > this._maxDirtyRow) this._maxDirtyRow = endY - 1
747
+ }
748
+
749
+ // Determine which Map operations are actually needed.
750
+ // Skip delete() calls when the map is already empty (common case: no true colors).
751
+ const needFgDelete = !hasTrueColorFg && this.fgColors.size > 0
752
+ const needBgDelete = !hasTrueColorBg && this.bgColors.size > 0
753
+ const needUlDelete = !hasUnderlineColor && this.underlineColors.size > 0
754
+ const needHlDelete = !hasHyperlink && this.hyperlinks.size > 0
755
+
756
+ for (let cy = startY; cy < endY; cy++) {
757
+ const rowBase = cy * this.width
758
+ for (let cx = startX; cx < endX; cx++) {
759
+ const idx = rowBase + cx
760
+
761
+ // Direct array assignment (no setCell overhead)
762
+ this.cells[idx] = packed
763
+ this.chars[idx] = char
764
+
765
+ // Handle true color maps — skip delete when map is empty
766
+ if (hasTrueColorFg) {
767
+ this.fgColors.set(idx, trueColorFg!)
768
+ } else if (needFgDelete) {
769
+ this.fgColors.delete(idx)
770
+ }
771
+
772
+ if (hasTrueColorBg) {
773
+ this.bgColors.set(idx, trueColorBg!)
774
+ } else if (needBgDelete) {
775
+ this.bgColors.delete(idx)
776
+ }
777
+
778
+ if (hasUnderlineColor) {
779
+ this.underlineColors.set(idx, underlineColor)
780
+ } else if (needUlDelete) {
781
+ this.underlineColors.delete(idx)
782
+ }
783
+
784
+ if (hasHyperlink) {
785
+ this.hyperlinks.set(idx, hyperlink!)
786
+ } else if (needHlDelete) {
787
+ this.hyperlinks.delete(idx)
788
+ }
789
+ }
790
+ }
791
+ }
792
+
793
+ /**
794
+ * Clear the buffer (fill with empty cells).
795
+ */
796
+ clear(): void {
797
+ this.cells.fill(0)
798
+ this.chars.fill(" ")
799
+ this.fgColors.clear()
800
+ this.bgColors.clear()
801
+ this.underlineColors.clear()
802
+ this.hyperlinks.clear()
803
+ this._dirtyRows.fill(1)
804
+ this._minDirtyRow = 0
805
+ this._maxDirtyRow = this.height - 1
806
+ }
807
+
808
+ /**
809
+ * Copy a region from another buffer.
810
+ */
811
+ copyFrom(
812
+ source: TerminalBuffer,
813
+ srcX: number,
814
+ srcY: number,
815
+ destX: number,
816
+ destY: number,
817
+ width: number,
818
+ height: number,
819
+ ): void {
820
+ const cell = createMutableCell()
821
+ for (let dy = 0; dy < height; dy++) {
822
+ const dstY = destY + dy
823
+ if (dstY >= 0 && dstY < this.height) {
824
+ this._dirtyRows[dstY] = 1
825
+ if (this._minDirtyRow === -1 || dstY < this._minDirtyRow) this._minDirtyRow = dstY
826
+ if (dstY > this._maxDirtyRow) this._maxDirtyRow = dstY
827
+ }
828
+ for (let dx = 0; dx < width; dx++) {
829
+ const sx = srcX + dx
830
+ const sy = srcY + dy
831
+ const dX = destX + dx
832
+
833
+ if (source.inBounds(sx, sy) && this.inBounds(dX, dstY)) {
834
+ source.readCellInto(sx, sy, cell)
835
+ this.setCell(dX, dstY, cell)
836
+ }
837
+ }
838
+ }
839
+ }
840
+
841
+ /**
842
+ * Shift content within a rectangular region vertically by `delta` rows.
843
+ * Positive delta = shift content UP (scroll down), negative = shift DOWN (scroll up).
844
+ * Exposed rows (at the bottom for positive delta, top for negative) are filled
845
+ * with the given background cell.
846
+ *
847
+ * Uses Uint32Array.copyWithin for the packed cells (native memcpy) and
848
+ * Array splice for the character array.
849
+ */
850
+ scrollRegion(
851
+ x: number,
852
+ y: number,
853
+ regionWidth: number,
854
+ regionHeight: number,
855
+ delta: number,
856
+ clearCell: Partial<Cell> = {},
857
+ ): void {
858
+ if (delta === 0 || regionHeight <= 0 || regionWidth <= 0) return
859
+
860
+ const startX = Math.max(0, x)
861
+ const endX = Math.min(x + regionWidth, this.width)
862
+ const startY = Math.max(0, y)
863
+ const endY = Math.min(y + regionHeight, this.height)
864
+ const clampedWidth = endX - startX
865
+ const clampedHeight = endY - startY
866
+
867
+ if (clampedWidth <= 0 || clampedHeight <= 0) return
868
+
869
+ // Mark all rows in the scroll region dirty + update bounding box
870
+ for (let r = startY; r < endY; r++) {
871
+ this._dirtyRows[r] = 1
872
+ }
873
+ if (this._minDirtyRow === -1 || startY < this._minDirtyRow) this._minDirtyRow = startY
874
+ if (endY - 1 > this._maxDirtyRow) this._maxDirtyRow = endY - 1
875
+
876
+ if (Math.abs(delta) >= clampedHeight) {
877
+ // Scroll amount exceeds region — just clear everything
878
+ this.fill(startX, startY, clampedWidth, clampedHeight, {
879
+ char: clearCell.char ?? " ",
880
+ bg: clearCell.bg ?? null,
881
+ })
882
+ return
883
+ }
884
+
885
+ const absDelta = Math.abs(delta)
886
+ const w = this.width
887
+
888
+ if (delta > 0) {
889
+ // Shift content UP: copy rows [startY + delta .. endY) to [startY .. endY - delta)
890
+ for (let row = startY; row < endY - absDelta; row++) {
891
+ const dstBase = row * w
892
+ const srcBase = (row + absDelta) * w
893
+ // Copy cells and chars for the region columns
894
+ this.cells.copyWithin(dstBase + startX, srcBase + startX, srcBase + endX)
895
+ for (let cx = startX; cx < endX; cx++) {
896
+ this.chars[dstBase + cx] = this.chars[srcBase + cx]!
897
+ // Move true color maps
898
+ const srcIdx = srcBase + cx
899
+ const dstIdx = dstBase + cx
900
+ const fgc = this.fgColors.get(srcIdx)
901
+ if (fgc) {
902
+ this.fgColors.set(dstIdx, fgc)
903
+ this.fgColors.delete(srcIdx)
904
+ } else {
905
+ this.fgColors.delete(dstIdx)
906
+ }
907
+ const bgc = this.bgColors.get(srcIdx)
908
+ if (bgc) {
909
+ this.bgColors.set(dstIdx, bgc)
910
+ this.bgColors.delete(srcIdx)
911
+ } else {
912
+ this.bgColors.delete(dstIdx)
913
+ }
914
+ const ulc = this.underlineColors.get(srcIdx)
915
+ if (ulc) {
916
+ this.underlineColors.set(dstIdx, ulc)
917
+ this.underlineColors.delete(srcIdx)
918
+ } else {
919
+ this.underlineColors.delete(dstIdx)
920
+ }
921
+ const hl = this.hyperlinks.get(srcIdx)
922
+ if (hl) {
923
+ this.hyperlinks.set(dstIdx, hl)
924
+ this.hyperlinks.delete(srcIdx)
925
+ } else {
926
+ this.hyperlinks.delete(dstIdx)
927
+ }
928
+ }
929
+ }
930
+ // Clear exposed rows at bottom
931
+ this.fill(startX, endY - absDelta, clampedWidth, absDelta, {
932
+ char: clearCell.char ?? " ",
933
+ bg: clearCell.bg ?? null,
934
+ })
935
+ } else {
936
+ // Shift content DOWN: copy rows [startY .. endY - absDelta) to [startY + absDelta .. endY)
937
+ for (let row = endY - 1; row >= startY + absDelta; row--) {
938
+ const dstBase = row * w
939
+ const srcBase = (row - absDelta) * w
940
+ this.cells.copyWithin(dstBase + startX, srcBase + startX, srcBase + endX)
941
+ for (let cx = startX; cx < endX; cx++) {
942
+ this.chars[dstBase + cx] = this.chars[srcBase + cx]!
943
+ const srcIdx = srcBase + cx
944
+ const dstIdx = dstBase + cx
945
+ const fgc = this.fgColors.get(srcIdx)
946
+ if (fgc) {
947
+ this.fgColors.set(dstIdx, fgc)
948
+ this.fgColors.delete(srcIdx)
949
+ } else {
950
+ this.fgColors.delete(dstIdx)
951
+ }
952
+ const bgc = this.bgColors.get(srcIdx)
953
+ if (bgc) {
954
+ this.bgColors.set(dstIdx, bgc)
955
+ this.bgColors.delete(srcIdx)
956
+ } else {
957
+ this.bgColors.delete(dstIdx)
958
+ }
959
+ const ulc = this.underlineColors.get(srcIdx)
960
+ if (ulc) {
961
+ this.underlineColors.set(dstIdx, ulc)
962
+ this.underlineColors.delete(srcIdx)
963
+ } else {
964
+ this.underlineColors.delete(dstIdx)
965
+ }
966
+ const hl = this.hyperlinks.get(srcIdx)
967
+ if (hl) {
968
+ this.hyperlinks.set(dstIdx, hl)
969
+ this.hyperlinks.delete(srcIdx)
970
+ } else {
971
+ this.hyperlinks.delete(dstIdx)
972
+ }
973
+ }
974
+ }
975
+ // Clear exposed rows at top
976
+ this.fill(startX, startY, clampedWidth, absDelta, {
977
+ char: clearCell.char ?? " ",
978
+ bg: clearCell.bg ?? null,
979
+ })
980
+ }
981
+ }
982
+
983
+ /**
984
+ * Clone this buffer.
985
+ */
986
+ clone(): TerminalBuffer {
987
+ const copy = new TerminalBuffer(this.width, this.height)
988
+ copy.cells.set(this.cells)
989
+ copy.chars = [...this.chars]
990
+ copy.fgColors = new Map(this.fgColors)
991
+ copy.bgColors = new Map(this.bgColors)
992
+ copy.underlineColors = new Map(this.underlineColors)
993
+ copy.hyperlinks = new Map(this.hyperlinks)
994
+ // Clone starts with all rows CLEAN. The content phase will mark only
995
+ // the rows it modifies as dirty. diffBuffers() then skips clean rows,
996
+ // which are guaranteed identical to the prev buffer (since this is a clone).
997
+ copy._dirtyRows.fill(0)
998
+ copy._minDirtyRow = -1
999
+ copy._maxDirtyRow = -1
1000
+ return copy
1001
+ }
1002
+
1003
+ /**
1004
+ * Check if a row has been modified since the last resetDirtyRows() call.
1005
+ * Used by diffBuffers() to skip unchanged rows.
1006
+ */
1007
+ isRowDirty(y: number): boolean {
1008
+ if (y < 0 || y >= this.height) return false
1009
+ return this._dirtyRows[y] !== 0
1010
+ }
1011
+
1012
+ /** First dirty row (inclusive), or -1 if no rows are dirty. */
1013
+ get minDirtyRow(): number {
1014
+ return this._minDirtyRow
1015
+ }
1016
+
1017
+ /** Last dirty row (inclusive), or -1 if no rows are dirty. */
1018
+ get maxDirtyRow(): number {
1019
+ return this._maxDirtyRow
1020
+ }
1021
+
1022
+ /**
1023
+ * Reset all dirty row flags to clean.
1024
+ * Call after diffing to prepare for the next frame's modifications.
1025
+ */
1026
+ resetDirtyRows(): void {
1027
+ this._dirtyRows.fill(0)
1028
+ this._minDirtyRow = -1
1029
+ this._maxDirtyRow = -1
1030
+ }
1031
+
1032
+ /**
1033
+ * Mark all rows as dirty.
1034
+ * Used when the buffer's dirty rows may not cover all changes relative
1035
+ * to a different prev buffer (e.g., after multiple doRender calls where
1036
+ * the runtime's prevBuffer skipped intermediate buffers).
1037
+ */
1038
+ markAllRowsDirty(): void {
1039
+ this._dirtyRows.fill(1)
1040
+ this._minDirtyRow = 0
1041
+ this._maxDirtyRow = this.height - 1
1042
+ }
1043
+
1044
+ /**
1045
+ * Check if two cells at given positions are equal.
1046
+ * Used for diffing.
1047
+ */
1048
+ cellEquals(x: number, y: number, other: TerminalBuffer): boolean {
1049
+ if (!this.inBounds(x, y) || !other.inBounds(x, y)) {
1050
+ return false
1051
+ }
1052
+
1053
+ const idx = this.index(x, y)
1054
+ const otherIdx = other.index(x, y)
1055
+
1056
+ // Quick check: packed metadata must match
1057
+ if (this.cells[idx] !== other.cells[otherIdx]) {
1058
+ return false
1059
+ }
1060
+
1061
+ // Character must match
1062
+ if (this.chars[idx] !== other.chars[otherIdx]) {
1063
+ return false
1064
+ }
1065
+
1066
+ // If true color flags are set, check the color values
1067
+ const packed = this.cells[idx]!
1068
+ if (unpackTrueColorFg(packed)) {
1069
+ const a = this.fgColors.get(idx)
1070
+ const b = other.fgColors.get(otherIdx)
1071
+ if (!colorEquals(a, b)) return false
1072
+ }
1073
+ if (unpackTrueColorBg(packed!)) {
1074
+ const a = this.bgColors.get(idx)
1075
+ const b = other.bgColors.get(otherIdx)
1076
+ if (!colorEquals(a, b)) return false
1077
+ }
1078
+
1079
+ // Check underline colors
1080
+ const ulA = this.underlineColors.get(idx) ?? null
1081
+ const ulB = other.underlineColors.get(otherIdx) ?? null
1082
+ if (!colorEquals(ulA, ulB)) return false
1083
+
1084
+ // Check hyperlinks
1085
+ const hlA = this.hyperlinks.get(idx)
1086
+ const hlB = other.hyperlinks.get(otherIdx)
1087
+ if (hlA !== hlB) return false
1088
+
1089
+ return true
1090
+ }
1091
+
1092
+ /**
1093
+ * Fast check: are all packed metadata values identical for a row?
1094
+ * This is a bulk pre-check before per-cell comparison. If metadata differs,
1095
+ * we still need per-cell diffing. If metadata matches, we only need to
1096
+ * check chars, true color maps, underline colors, and hyperlinks.
1097
+ * Returns true if all packed 32-bit values in the row are identical.
1098
+ */
1099
+ rowMetadataEquals(y: number, other: TerminalBuffer): boolean {
1100
+ if (y < 0 || y >= this.height || y >= other.height) return false
1101
+ const start = y * this.width
1102
+ const otherStart = y * other.width
1103
+ const w = Math.min(this.width, other.width)
1104
+ for (let i = 0; i < w; i++) {
1105
+ if (this.cells[start + i] !== other.cells[otherStart + i]) return false
1106
+ }
1107
+ return true
1108
+ }
1109
+
1110
+ /**
1111
+ * Fast check: are all characters identical for a row?
1112
+ * Companion to rowMetadataEquals for a two-phase row comparison.
1113
+ */
1114
+ rowCharsEquals(y: number, other: TerminalBuffer): boolean {
1115
+ if (y < 0 || y >= this.height || y >= other.height) return false
1116
+ const start = y * this.width
1117
+ const otherStart = y * other.width
1118
+ const w = Math.min(this.width, other.width)
1119
+ for (let i = 0; i < w; i++) {
1120
+ if (this.chars[start + i] !== other.chars[otherStart + i]) return false
1121
+ }
1122
+ return true
1123
+ }
1124
+
1125
+ /**
1126
+ * Check Map-based extras for a row: true color fg/bg, underline colors, hyperlinks.
1127
+ * Must be called AFTER rowMetadataEquals confirms packed metadata matches.
1128
+ * Only checks cells that have true color flags set (the Maps are only populated
1129
+ * for those cells). Also checks underline colors and hyperlinks for all cells.
1130
+ */
1131
+ rowExtrasEquals(y: number, other: TerminalBuffer): boolean {
1132
+ if (y < 0 || y >= this.height || y >= other.height) return false
1133
+ const start = y * this.width
1134
+ const w = Math.min(this.width, other.width)
1135
+ const otherStart = y * other.width
1136
+ for (let i = 0; i < w; i++) {
1137
+ const idx = start + i
1138
+ const otherIdx = otherStart + i
1139
+ const packed = this.cells[idx]!
1140
+
1141
+ // Check true color fg values
1142
+ if ((packed & TRUE_COLOR_FG_FLAG) !== 0) {
1143
+ const a = this.fgColors.get(idx)
1144
+ const b = other.fgColors.get(otherIdx)
1145
+ if (!colorEquals(a, b)) return false
1146
+ }
1147
+
1148
+ // Check true color bg values
1149
+ if ((packed & TRUE_COLOR_BG_FLAG) !== 0) {
1150
+ const a = this.bgColors.get(idx)
1151
+ const b = other.bgColors.get(otherIdx)
1152
+ if (!colorEquals(a, b)) return false
1153
+ }
1154
+
1155
+ // Check underline colors
1156
+ const ulA = this.underlineColors.get(idx) ?? null
1157
+ const ulB = other.underlineColors.get(otherIdx) ?? null
1158
+ if (!colorEquals(ulA, ulB)) return false
1159
+
1160
+ // Check hyperlinks
1161
+ const hlA = this.hyperlinks.get(idx)
1162
+ const hlB = other.hyperlinks.get(otherIdx)
1163
+ if (hlA !== hlB) return false
1164
+ }
1165
+ return true
1166
+ }
1167
+ }
1168
+
1169
+ // ============================================================================
1170
+ // Utility Functions
1171
+ // ============================================================================
1172
+
1173
+ /**
1174
+ * Compare two colors for equality.
1175
+ */
1176
+ export function colorEquals(a: Color | undefined, b: Color | undefined): boolean {
1177
+ if (a === b) return true
1178
+ if (a === null || a === undefined) return b === null || b === undefined
1179
+ if (b === null || b === undefined) return false
1180
+ if (typeof a === "number") return a === b
1181
+ if (typeof b === "number") return false
1182
+ return a.r === b.r && a.g === b.g && a.b === b.b
1183
+ }
1184
+
1185
+ /**
1186
+ * Compare two cells for equality.
1187
+ */
1188
+ export function cellEquals(a: Cell, b: Cell): boolean {
1189
+ return (
1190
+ a.char === b.char &&
1191
+ colorEquals(a.fg, b.fg) &&
1192
+ colorEquals(a.bg, b.bg) &&
1193
+ colorEquals(a.underlineColor, b.underlineColor) &&
1194
+ a.wide === b.wide &&
1195
+ a.continuation === b.continuation &&
1196
+ attrsEquals(a.attrs, b.attrs) &&
1197
+ (a.hyperlink ?? undefined) === (b.hyperlink ?? undefined)
1198
+ )
1199
+ }
1200
+
1201
+ /**
1202
+ * Compare two CellAttrs for equality.
1203
+ */
1204
+ export function attrsEquals(a: CellAttrs, b: CellAttrs): boolean {
1205
+ return (
1206
+ Boolean(a.bold) === Boolean(b.bold) &&
1207
+ Boolean(a.dim) === Boolean(b.dim) &&
1208
+ Boolean(a.italic) === Boolean(b.italic) &&
1209
+ Boolean(a.underline) === Boolean(b.underline) &&
1210
+ (a.underlineStyle ?? false) === (b.underlineStyle ?? false) &&
1211
+ Boolean(a.blink) === Boolean(b.blink) &&
1212
+ Boolean(a.inverse) === Boolean(b.inverse) &&
1213
+ Boolean(a.hidden) === Boolean(b.hidden) &&
1214
+ Boolean(a.strikethrough) === Boolean(b.strikethrough)
1215
+ )
1216
+ }
1217
+
1218
+ /**
1219
+ * Compare two styles for equality.
1220
+ */
1221
+ export function styleEquals(a: Style | null, b: Style | null): boolean {
1222
+ if (a === b) return true
1223
+ if (!a || !b) return false
1224
+ return (
1225
+ colorEquals(a.fg, b.fg) &&
1226
+ colorEquals(a.bg, b.bg) &&
1227
+ colorEquals(a.underlineColor, b.underlineColor) &&
1228
+ attrsEquals(a.attrs, b.attrs) &&
1229
+ (a.hyperlink ?? undefined) === (b.hyperlink ?? undefined)
1230
+ )
1231
+ }
1232
+
1233
+ /**
1234
+ * Create a mutable Cell object for use with readCellInto().
1235
+ * The returned object is reusable -- readCellInto() overwrites all fields.
1236
+ */
1237
+ export function createMutableCell(): Cell {
1238
+ return {
1239
+ char: " ",
1240
+ fg: null,
1241
+ bg: null,
1242
+ underlineColor: null,
1243
+ attrs: {},
1244
+ wide: false,
1245
+ continuation: false,
1246
+ hyperlink: undefined,
1247
+ }
1248
+ }
1249
+
1250
+ /**
1251
+ * Create a buffer initialized with a specific character.
1252
+ */
1253
+ export function createBuffer(width: number, height: number, char = " "): TerminalBuffer {
1254
+ const buffer = new TerminalBuffer(width, height)
1255
+ if (char !== " ") {
1256
+ buffer.fill(0, 0, width, height, { char })
1257
+ }
1258
+ return buffer
1259
+ }
1260
+
1261
+ // ============================================================================
1262
+ // Buffer Conversion Utilities
1263
+ // ============================================================================
1264
+
1265
+ /**
1266
+ * Convert a terminal buffer to plain text (no ANSI codes).
1267
+ * Useful for snapshot testing and text-based assertions.
1268
+ *
1269
+ * @param buffer The buffer to convert
1270
+ * @param options.trimTrailingWhitespace Remove trailing spaces from each line (default: true)
1271
+ * @param options.trimEmptyLines Remove trailing empty lines (default: true)
1272
+ * @returns Plain text representation of the buffer
1273
+ */
1274
+ export function bufferToText(
1275
+ buffer: TerminalBuffer,
1276
+ options: {
1277
+ trimTrailingWhitespace?: boolean
1278
+ trimEmptyLines?: boolean
1279
+ } = {},
1280
+ ): string {
1281
+ const { trimTrailingWhitespace = true, trimEmptyLines = true } = options
1282
+
1283
+ const lines: string[] = []
1284
+
1285
+ for (let y = 0; y < buffer.height; y++) {
1286
+ let line = ""
1287
+ // Track string offset for each column (needed because continuation cells
1288
+ // are skipped, making string length != column count for wide chars)
1289
+ let strOffset = 0
1290
+ let contentEdgeStrOffset = 0
1291
+ const contentEdge = trimTrailingWhitespace ? getContentEdge(buffer, y) : 0
1292
+ for (let x = 0; x < buffer.width; x++) {
1293
+ // Use zero-allocation accessors instead of getCell()
1294
+ if (buffer.isCellContinuation(x, y)) continue
1295
+ line += buffer.getCellChar(x, y)
1296
+ strOffset++
1297
+ // Track the string offset corresponding to the content edge column
1298
+ if (x < contentEdge) {
1299
+ contentEdgeStrOffset = strOffset
1300
+ }
1301
+ }
1302
+ if (trimTrailingWhitespace) {
1303
+ // Smart trim: use content edge to preserve styled trailing spaces
1304
+ // while removing unstyled buffer padding.
1305
+ const trimmed = line.trimEnd()
1306
+ line = trimmed.length >= contentEdgeStrOffset ? trimmed : line.substring(0, contentEdgeStrOffset)
1307
+ }
1308
+ lines.push(line)
1309
+ }
1310
+
1311
+ let result = lines.join("\n")
1312
+ if (trimEmptyLines) {
1313
+ // Remove trailing empty lines without stripping spaces from last content line
1314
+ while (lines.length > 0 && lines[lines.length - 1]!.length === 0) {
1315
+ lines.pop()
1316
+ }
1317
+ result = lines.join("\n")
1318
+ }
1319
+ return result
1320
+ }
1321
+
1322
+ /**
1323
+ * Find the rightmost column with non-default cell content on a row.
1324
+ * A default cell has packed metadata === 0 (no fg, bg, attrs) AND char === ' '.
1325
+ * Returns the column count (1-indexed), so the content edge for trimming.
1326
+ */
1327
+ function getContentEdge(buffer: TerminalBuffer, y: number): number {
1328
+ // Mask out structural flags (wide, continuation) that don't indicate actual content.
1329
+ // True-color flags DO indicate styled content (they mean fg/bg is set in Maps).
1330
+ const FLAG_MASK = ~(WIDE_FLAG | CONTINUATION_FLAG)
1331
+ for (let x = buffer.width - 1; x >= 0; x--) {
1332
+ // Skip continuation cells (trailing half of wide chars) — the main cell covers them
1333
+ if (buffer.isCellContinuation(x, y)) continue
1334
+ // Check if cell has any actual styling (fg, bg, text attrs) after masking structural flags
1335
+ const attrs = buffer.getCellAttrs(x, y) & FLAG_MASK
1336
+ if (attrs !== 0) return x + 1
1337
+ // Check if cell has a non-space character
1338
+ if (buffer.getCellChar(x, y) !== " ") return x + 1
1339
+ }
1340
+ return 0
1341
+ }
1342
+
1343
+ /**
1344
+ * Convert a terminal buffer to styled ANSI text.
1345
+ * Unlike bufferToAnsi, this doesn't include cursor control sequences,
1346
+ * making it suitable for displaying in terminals or saving to files.
1347
+ *
1348
+ * @param buffer The buffer to convert
1349
+ * @param options.trimTrailingWhitespace Remove trailing spaces from each line (default: true)
1350
+ * @param options.trimEmptyLines Remove trailing empty lines (default: true)
1351
+ * @returns ANSI-styled text (no cursor control)
1352
+ */
1353
+ export function bufferToStyledText(
1354
+ buffer: TerminalBuffer,
1355
+ options: {
1356
+ trimTrailingWhitespace?: boolean
1357
+ trimEmptyLines?: boolean
1358
+ } = {},
1359
+ ): string {
1360
+ const { trimTrailingWhitespace = true, trimEmptyLines = true } = options
1361
+
1362
+ const lines: string[] = []
1363
+ let currentStyle: Style | null = null
1364
+ let currentHyperlink: string | undefined
1365
+
1366
+ for (let y = 0; y < buffer.height; y++) {
1367
+ let line = ""
1368
+
1369
+ for (let x = 0; x < buffer.width; x++) {
1370
+ // getCell allocates a fresh object each call, which is fine here since
1371
+ // bufferToStyledText is a utility function, not a hot render path.
1372
+ const cell = buffer.getCell(x, y)
1373
+ // Skip continuation cells (part of wide character)
1374
+ if (cell.continuation) continue
1375
+
1376
+ // Check if hyperlink changed (OSC 8 is separate from SGR)
1377
+ const cellHyperlink = cell.hyperlink
1378
+ if (cellHyperlink !== currentHyperlink) {
1379
+ if (currentHyperlink) {
1380
+ // Close previous hyperlink using the same format as the open
1381
+ line += emitHyperlinkClose(currentHyperlink)
1382
+ }
1383
+ if (cellHyperlink) {
1384
+ line += emitHyperlinkOpen(cellHyperlink)
1385
+ }
1386
+ currentHyperlink = cellHyperlink
1387
+ }
1388
+
1389
+ // Check if style changed — emit minimal transition (chalk-compatible)
1390
+ const cellStyle: Style = {
1391
+ fg: cell.fg,
1392
+ bg: cell.bg,
1393
+ underlineColor: cell.underlineColor,
1394
+ attrs: cell.attrs,
1395
+ }
1396
+ if (!styleEquals(currentStyle, cellStyle)) {
1397
+ line += styleTransitionCodes(currentStyle, cellStyle)
1398
+ currentStyle = cellStyle
1399
+ }
1400
+
1401
+ line += cell.char
1402
+ }
1403
+
1404
+ // Close any open hyperlink at end of line
1405
+ if (currentHyperlink) {
1406
+ line += emitHyperlinkClose(currentHyperlink)
1407
+ currentHyperlink = undefined
1408
+ }
1409
+
1410
+ // Reset style at end of line using per-attribute resets (chalk-compatible)
1411
+ if (currentStyle && (currentStyle.bg !== null || hasActiveAttrs(currentStyle.attrs))) {
1412
+ line += styleResetCodes(currentStyle)
1413
+ currentStyle = null
1414
+ }
1415
+
1416
+ if (trimTrailingWhitespace) {
1417
+ // Need to be careful not to strip ANSI codes
1418
+ // Only trim actual whitespace at the end
1419
+ line = trimTrailingWhitespacePreservingAnsi(line)
1420
+ }
1421
+ lines.push(line)
1422
+ }
1423
+
1424
+ // Final per-attribute reset (chalk-compatible)
1425
+ let result = lines.join("\n")
1426
+ if (currentStyle && (currentStyle.bg !== null || hasActiveAttrs(currentStyle.attrs))) {
1427
+ result += styleResetCodes(currentStyle)
1428
+ }
1429
+
1430
+ if (trimEmptyLines) {
1431
+ // Remove empty lines at the end (but preserve ANSI resets)
1432
+ result = result.replace(/\n+$/, "")
1433
+ }
1434
+
1435
+ return result
1436
+ }
1437
+
1438
+ // ============================================================================
1439
+ // Hyperlink Format Helpers
1440
+ // ============================================================================
1441
+
1442
+ /**
1443
+ * Decode hyperlink format metadata from URL prefix.
1444
+ * parseAnsiText encodes the original OSC format (C1 vs ESC, BEL vs ST)
1445
+ * as a prefix: \x01<tag>\x02<url>
1446
+ *
1447
+ * Tags:
1448
+ * c1b = C1 OSC (\x9d) + BEL (\x07) terminator
1449
+ * c1s = C1 OSC (\x9d) + ST (\x1b\\) terminator
1450
+ * e7b = ESC OSC (\x1b]) + BEL (\x07) terminator
1451
+ * (no prefix) = ESC OSC + ST (default)
1452
+ */
1453
+ function decodeHyperlinkFormat(encoded: string): {
1454
+ url: string
1455
+ oscIntro: string
1456
+ oscClose: string
1457
+ closeIntro: string
1458
+ closeTerminator: string
1459
+ } {
1460
+ if (encoded.charCodeAt(0) === 1) {
1461
+ const sepIdx = encoded.indexOf("\x02")
1462
+ if (sepIdx > 0) {
1463
+ const tag = encoded.slice(1, sepIdx)
1464
+ const url = encoded.slice(sepIdx + 1)
1465
+ if (tag === "c1b") {
1466
+ return {
1467
+ url,
1468
+ oscIntro: "\x9d",
1469
+ oscClose: "\x9d",
1470
+ closeIntro: "\x9d",
1471
+ closeTerminator: "\x07",
1472
+ }
1473
+ }
1474
+ if (tag === "c1s") {
1475
+ return {
1476
+ url,
1477
+ oscIntro: "\x9d",
1478
+ oscClose: "\x9d",
1479
+ closeIntro: "\x9d",
1480
+ closeTerminator: "\x1b\\",
1481
+ }
1482
+ }
1483
+ if (tag === "e7b") {
1484
+ return {
1485
+ url,
1486
+ oscIntro: "\x1b]",
1487
+ oscClose: "\x1b]",
1488
+ closeIntro: "\x1b]",
1489
+ closeTerminator: "\x07",
1490
+ }
1491
+ }
1492
+ }
1493
+ }
1494
+ // Default: ESC OSC + ST
1495
+ return {
1496
+ url: encoded,
1497
+ oscIntro: "\x1b]",
1498
+ oscClose: "\x1b]",
1499
+ closeIntro: "\x1b]",
1500
+ closeTerminator: "\x1b\\",
1501
+ }
1502
+ }
1503
+
1504
+ /** Emit OSC 8 hyperlink open sequence, respecting format metadata in URL. */
1505
+ function emitHyperlinkOpen(encoded: string): string {
1506
+ const fmt = decodeHyperlinkFormat(encoded)
1507
+ return `${fmt.oscIntro}8;;${fmt.url}${fmt.closeTerminator}`
1508
+ }
1509
+
1510
+ /** Emit OSC 8 hyperlink close sequence, respecting format metadata in URL. */
1511
+ function emitHyperlinkClose(encoded: string): string {
1512
+ const fmt = decodeHyperlinkFormat(encoded)
1513
+ return `${fmt.closeIntro}8;;${fmt.closeTerminator}`
1514
+ }
1515
+
1516
+ // ============================================================================
1517
+ // xterm-256 Color Palette
1518
+ // ============================================================================
1519
+
1520
+ /** Standard xterm-256 color palette as hex strings. */
1521
+ const XTERM_256_PALETTE: string[] = (() => {
1522
+ const palette: string[] = new Array(256)
1523
+
1524
+ // Colors 0-7: standard colors
1525
+ const standard = ["#000000", "#cd0000", "#00cd00", "#cdcd00", "#0000ee", "#cd00cd", "#00cdcd", "#e5e5e5"]
1526
+ // Colors 8-15: bright colors
1527
+ const bright = ["#7f7f7f", "#ff0000", "#00ff00", "#ffff00", "#5c5cff", "#ff00ff", "#00ffff", "#ffffff"]
1528
+ for (let i = 0; i < 8; i++) {
1529
+ palette[i] = standard[i]!
1530
+ palette[i + 8] = bright[i]!
1531
+ }
1532
+
1533
+ // Colors 16-231: 6x6x6 RGB cube
1534
+ const cubeValues = [0, 95, 135, 175, 215, 255]
1535
+ for (let i = 0; i < 216; i++) {
1536
+ const r = cubeValues[Math.floor(i / 36)]!
1537
+ const g = cubeValues[Math.floor((i % 36) / 6)]!
1538
+ const b = cubeValues[i % 6]!
1539
+ palette[16 + i] =
1540
+ "#" + r.toString(16).padStart(2, "0") + g.toString(16).padStart(2, "0") + b.toString(16).padStart(2, "0")
1541
+ }
1542
+
1543
+ // Colors 232-255: grayscale ramp
1544
+ for (let i = 0; i < 24; i++) {
1545
+ const v = 8 + i * 10
1546
+ const hex = v.toString(16).padStart(2, "0")
1547
+ palette[232 + i] = "#" + hex + hex + hex
1548
+ }
1549
+
1550
+ return palette
1551
+ })()
1552
+
1553
+ /**
1554
+ * Convert a Color value to a CSS color string.
1555
+ * Returns null for default/inherit colors.
1556
+ */
1557
+ function colorToCSS(color: Color): string | null {
1558
+ if (color === null) return null
1559
+ if (typeof color === "number") {
1560
+ return XTERM_256_PALETTE[color] ?? null
1561
+ }
1562
+ // DEFAULT_BG sentinel → no CSS color (use inherited/default)
1563
+ if (color.r === -1) return null
1564
+ return `rgb(${color.r},${color.g},${color.b})`
1565
+ }
1566
+
1567
+ // ============================================================================
1568
+ // Buffer to HTML Conversion
1569
+ // ============================================================================
1570
+
1571
+ /**
1572
+ * Convert a terminal buffer to a full HTML document.
1573
+ * Suitable for rendering as a screenshot via headless browser.
1574
+ *
1575
+ * @param buffer The buffer to convert
1576
+ * @param options.fontFamily CSS font-family (default: 'JetBrains Mono, Menlo, monospace')
1577
+ * @param options.fontSize CSS font-size in px (default: 14)
1578
+ * @param options.theme Color scheme (default: 'dark')
1579
+ * @returns Complete HTML document string
1580
+ */
1581
+ export function bufferToHTML(
1582
+ buffer: TerminalBuffer,
1583
+ options: {
1584
+ fontFamily?: string
1585
+ fontSize?: number
1586
+ theme?: "dark" | "light"
1587
+ } = {},
1588
+ ): string {
1589
+ const { fontFamily = "JetBrains Mono, Menlo, monospace", fontSize = 14, theme = "dark" } = options
1590
+
1591
+ const defaultFg = theme === "dark" ? "#d4d4d4" : "#1e1e1e"
1592
+ const defaultBg = theme === "dark" ? "#1e1e1e" : "#ffffff"
1593
+
1594
+ const htmlLines: string[] = []
1595
+
1596
+ for (let y = 0; y < buffer.height; y++) {
1597
+ let lineHTML = ""
1598
+ let currentStyle: Style | null = null
1599
+ let spanOpen = false
1600
+ let linkOpen = false
1601
+ let currentHyperlink: string | undefined
1602
+
1603
+ for (let x = 0; x < buffer.width; x++) {
1604
+ const cell = buffer.getCell(x, y)
1605
+ if (cell.continuation) continue
1606
+
1607
+ // Handle hyperlink transitions
1608
+ const cellHyperlink = cell.hyperlink
1609
+ if (cellHyperlink !== currentHyperlink) {
1610
+ if (linkOpen) {
1611
+ if (spanOpen) {
1612
+ lineHTML += "</span>"
1613
+ spanOpen = false
1614
+ }
1615
+ lineHTML += "</a>"
1616
+ linkOpen = false
1617
+ }
1618
+ if (cellHyperlink) {
1619
+ lineHTML += `<a href="${escapeHTML(cellHyperlink)}">`
1620
+ linkOpen = true
1621
+ }
1622
+ currentHyperlink = cellHyperlink
1623
+ }
1624
+
1625
+ const cellStyle: Style = {
1626
+ fg: cell.fg,
1627
+ bg: cell.bg,
1628
+ underlineColor: cell.underlineColor,
1629
+ attrs: cell.attrs,
1630
+ }
1631
+
1632
+ if (!styleEquals(currentStyle, cellStyle)) {
1633
+ if (spanOpen) {
1634
+ lineHTML += "</span>"
1635
+ spanOpen = false
1636
+ }
1637
+ const css = styleToCSSProperties(cellStyle, defaultFg, defaultBg)
1638
+ if (css) {
1639
+ lineHTML += `<span style="${css}">`
1640
+ spanOpen = true
1641
+ }
1642
+ currentStyle = cellStyle
1643
+ }
1644
+
1645
+ lineHTML += escapeHTML(cell.char)
1646
+ }
1647
+
1648
+ if (spanOpen) {
1649
+ lineHTML += "</span>"
1650
+ }
1651
+ if (linkOpen) {
1652
+ lineHTML += "</a>"
1653
+ currentHyperlink = undefined
1654
+ }
1655
+
1656
+ htmlLines.push(`<div>${lineHTML}</div>`)
1657
+ }
1658
+
1659
+ return `<!DOCTYPE html>
1660
+ <html>
1661
+ <head><meta charset="utf-8"></head>
1662
+ <body style="margin:0;padding:0;background:${defaultBg};color:${defaultFg};font-family:${fontFamily};font-size:${fontSize}px;line-height:1.2;white-space:pre">
1663
+ ${htmlLines.join("\n")}
1664
+ </body>
1665
+ </html>`
1666
+ }
1667
+
1668
+ /**
1669
+ * Convert a Style to CSS inline style properties.
1670
+ * Returns null if the style is entirely default.
1671
+ */
1672
+ function styleToCSSProperties(style: Style, defaultFg: string, defaultBg: string): string | null {
1673
+ const parts: string[] = []
1674
+
1675
+ // Handle inverse: swap fg/bg
1676
+ let fgColor: string | null
1677
+ let bgColor: string | null
1678
+ if (style.attrs.inverse) {
1679
+ fgColor = colorToCSS(style.bg) ?? defaultBg
1680
+ bgColor = colorToCSS(style.fg) ?? defaultFg
1681
+ } else {
1682
+ fgColor = colorToCSS(style.fg)
1683
+ bgColor = colorToCSS(style.bg)
1684
+ }
1685
+
1686
+ if (fgColor) parts.push(`color:${fgColor}`)
1687
+ if (bgColor) parts.push(`background:${bgColor}`)
1688
+ if (style.attrs.bold) parts.push("font-weight:bold")
1689
+ if (style.attrs.dim) parts.push("opacity:0.5")
1690
+ if (style.attrs.italic) parts.push("font-style:italic")
1691
+ if (style.attrs.hidden) parts.push("visibility:hidden")
1692
+
1693
+ // Text decoration: underline and/or strikethrough
1694
+ const decorations: string[] = []
1695
+ const underlineStyle = style.attrs.underlineStyle
1696
+ if (typeof underlineStyle === "string") {
1697
+ const cssStyleMap: Record<string, string> = {
1698
+ single: "solid",
1699
+ double: "double",
1700
+ curly: "wavy",
1701
+ dotted: "dotted",
1702
+ dashed: "dashed",
1703
+ }
1704
+ decorations.push("underline")
1705
+ const cssStyle = cssStyleMap[underlineStyle]
1706
+ if (cssStyle) parts.push(`text-decoration-style:${cssStyle}`)
1707
+ const ulColor = colorToCSS(style.underlineColor ?? null)
1708
+ if (ulColor) parts.push(`text-decoration-color:${ulColor}`)
1709
+ } else if (style.attrs.underline) {
1710
+ decorations.push("underline")
1711
+ }
1712
+ if (style.attrs.strikethrough) decorations.push("line-through")
1713
+ if (decorations.length > 0) parts.push(`text-decoration:${decorations.join(" ")}`)
1714
+
1715
+ return parts.length > 0 ? parts.join(";") : null
1716
+ }
1717
+
1718
+ /** Escape special HTML characters. */
1719
+ function escapeHTML(str: string): string {
1720
+ if (str === " " || str.length === 0) return str
1721
+ return str.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;")
1722
+ }
1723
+
1724
+ /**
1725
+ * Check if any text attributes are active.
1726
+ */
1727
+ export function hasActiveAttrs(attrs: CellAttrs): boolean {
1728
+ return !!(
1729
+ attrs.bold ||
1730
+ attrs.dim ||
1731
+ attrs.italic ||
1732
+ attrs.underline ||
1733
+ attrs.underlineStyle ||
1734
+ attrs.blink ||
1735
+ attrs.inverse ||
1736
+ attrs.hidden ||
1737
+ attrs.strikethrough
1738
+ )
1739
+ }
1740
+
1741
+ /**
1742
+ * Convert style to ANSI escape sequence.
1743
+ *
1744
+ * Uses SGR 7 for inverse so terminals correctly swap fg/bg
1745
+ * (including default terminal colors that have no explicit ANSI code).
1746
+ */
1747
+ // =============================================================================
1748
+ // Color code helpers (imported from ansi/sgr-codes.ts)
1749
+ // =============================================================================
1750
+
1751
+ /**
1752
+ * Convert style to ANSI escape sequence (chalk-compatible format).
1753
+ *
1754
+ * Emits only non-default attributes with no reset prefix. Called when there
1755
+ * is no previous style context (first cell), so the terminal is already in
1756
+ * reset state. Each attribute gets its own \x1b[Xm sequence.
1757
+ */
1758
+ function styleToAnsiCodes(style: Style): string {
1759
+ const fg = style.fg
1760
+ const bg = style.bg
1761
+
1762
+ let result = ""
1763
+
1764
+ // Foreground color
1765
+ if (fg !== null) {
1766
+ result += `\x1b[${fgColorCode(fg)}m`
1767
+ }
1768
+
1769
+ // Background color (DEFAULT_BG sentinel = terminal default, skip)
1770
+ if (bg !== null && !isDefaultBg(bg)) {
1771
+ result += `\x1b[${bgColorCode(bg)}m`
1772
+ }
1773
+
1774
+ // Attributes
1775
+ if (style.attrs.bold) result += "\x1b[1m"
1776
+ if (style.attrs.dim) result += "\x1b[2m"
1777
+ if (style.attrs.italic) result += "\x1b[3m"
1778
+
1779
+ // Underline: use SGR 4:x if style specified, otherwise simple SGR 4
1780
+ const underlineStyle = style.attrs.underlineStyle
1781
+ if (typeof underlineStyle === "string") {
1782
+ const styleMap: Record<string, number> = {
1783
+ single: 1,
1784
+ double: 2,
1785
+ curly: 3,
1786
+ dotted: 4,
1787
+ dashed: 5,
1788
+ }
1789
+ const subparam = styleMap[underlineStyle]
1790
+ if (subparam !== undefined && subparam !== 0) {
1791
+ result += `\x1b[4:${subparam}m`
1792
+ }
1793
+ } else if (style.attrs.underline) {
1794
+ result += "\x1b[4m" // Simple underline
1795
+ }
1796
+
1797
+ // Use SGR 7 for inverse — lets the terminal correctly swap fg/bg
1798
+ if (style.attrs.inverse) result += "\x1b[7m"
1799
+ if (style.attrs.strikethrough) result += "\x1b[9m"
1800
+
1801
+ // Underline color (SGR 58)
1802
+ if (style.underlineColor !== null && style.underlineColor !== undefined) {
1803
+ if (typeof style.underlineColor === "number") {
1804
+ result += `\x1b[58;5;${style.underlineColor}m`
1805
+ } else {
1806
+ result += `\x1b[58;2;${style.underlineColor.r};${style.underlineColor.g};${style.underlineColor.b}m`
1807
+ }
1808
+ }
1809
+
1810
+ return result
1811
+ }
1812
+
1813
+ /**
1814
+ * Compute the minimal SGR transition between two styles (chalk-compatible).
1815
+ *
1816
+ * When oldStyle is null (first cell or after reset), falls through to
1817
+ * full generation via styleToAnsiCodes. Otherwise, diffs attribute
1818
+ * by attribute and emits only changed SGR codes as individual \x1b[Xm sequences.
1819
+ */
1820
+ function styleTransitionCodes(oldStyle: Style | null, newStyle: Style): string {
1821
+ // First cell or after reset — full generation
1822
+ if (!oldStyle) return styleToAnsiCodes(newStyle)
1823
+
1824
+ // Same style — nothing to emit
1825
+ if (styleEquals(oldStyle, newStyle)) return ""
1826
+
1827
+ let result = ""
1828
+ const oa = oldStyle.attrs
1829
+ const na = newStyle.attrs
1830
+
1831
+ // Bold and dim share SGR 22 as their off-code
1832
+ const boldChanged = Boolean(oa.bold) !== Boolean(na.bold)
1833
+ const dimChanged = Boolean(oa.dim) !== Boolean(na.dim)
1834
+ if (boldChanged || dimChanged) {
1835
+ const boldOff = boldChanged && !na.bold
1836
+ const dimOff = dimChanged && !na.dim
1837
+ if (boldOff || dimOff) {
1838
+ result += "\x1b[22m"
1839
+ if (na.bold) result += "\x1b[1m"
1840
+ if (na.dim) result += "\x1b[2m"
1841
+ } else {
1842
+ if (boldChanged && na.bold) result += "\x1b[1m"
1843
+ if (dimChanged && na.dim) result += "\x1b[2m"
1844
+ }
1845
+ }
1846
+ if (Boolean(oa.italic) !== Boolean(na.italic)) {
1847
+ result += na.italic ? "\x1b[3m" : "\x1b[23m"
1848
+ }
1849
+
1850
+ // Underline
1851
+ const oldUl = Boolean(oa.underline)
1852
+ const newUl = Boolean(na.underline)
1853
+ const oldUlStyle = oa.underlineStyle ?? false
1854
+ const newUlStyle = na.underlineStyle ?? false
1855
+ if (oldUl !== newUl || oldUlStyle !== newUlStyle) {
1856
+ if (typeof na.underlineStyle === "string") {
1857
+ const styleMap: Record<string, number> = {
1858
+ single: 1,
1859
+ double: 2,
1860
+ curly: 3,
1861
+ dotted: 4,
1862
+ dashed: 5,
1863
+ }
1864
+ const sub = styleMap[na.underlineStyle]
1865
+ if (sub !== undefined && sub !== 0) {
1866
+ result += `\x1b[4:${sub}m`
1867
+ } else if (newUl) {
1868
+ result += "\x1b[4m"
1869
+ } else {
1870
+ result += "\x1b[24m"
1871
+ }
1872
+ } else if (newUl) {
1873
+ result += "\x1b[4m"
1874
+ } else {
1875
+ result += "\x1b[24m"
1876
+ }
1877
+ }
1878
+
1879
+ if (Boolean(oa.inverse) !== Boolean(na.inverse)) {
1880
+ result += na.inverse ? "\x1b[7m" : "\x1b[27m"
1881
+ }
1882
+ if (Boolean(oa.strikethrough) !== Boolean(na.strikethrough)) {
1883
+ result += na.strikethrough ? "\x1b[9m" : "\x1b[29m"
1884
+ }
1885
+
1886
+ // Foreground color
1887
+ if (!colorEquals(oldStyle.fg, newStyle.fg)) {
1888
+ if (newStyle.fg === null) {
1889
+ result += "\x1b[39m"
1890
+ } else {
1891
+ result += `\x1b[${fgColorCode(newStyle.fg)}m`
1892
+ }
1893
+ }
1894
+
1895
+ // Background color
1896
+ if (!colorEquals(oldStyle.bg, newStyle.bg)) {
1897
+ if (newStyle.bg === null) {
1898
+ result += "\x1b[49m"
1899
+ } else {
1900
+ result += `\x1b[${bgColorCode(newStyle.bg)}m`
1901
+ }
1902
+ }
1903
+
1904
+ // Underline color (SGR 58/59)
1905
+ if (!colorEquals(oldStyle.underlineColor, newStyle.underlineColor)) {
1906
+ if (newStyle.underlineColor === null || newStyle.underlineColor === undefined) {
1907
+ result += "\x1b[59m"
1908
+ } else if (typeof newStyle.underlineColor === "number") {
1909
+ result += `\x1b[58;5;${newStyle.underlineColor}m`
1910
+ } else {
1911
+ result += `\x1b[58;2;${newStyle.underlineColor.r};${newStyle.underlineColor.g};${newStyle.underlineColor.b}m`
1912
+ }
1913
+ }
1914
+
1915
+ return result
1916
+ }
1917
+
1918
+ /**
1919
+ * Emit per-attribute reset codes for all active attributes in a style.
1920
+ * Returns empty string if no attributes are active.
1921
+ * Uses individual \x1b[Xm sequences to match chalk's format.
1922
+ */
1923
+ function styleResetCodes(style: Style): string {
1924
+ let result = ""
1925
+ // Attributes (order: underline, bold/dim, italic, strikethrough, inverse — matches chalk close order)
1926
+ if (style.attrs.underline || style.attrs.underlineStyle) result += "\x1b[24m"
1927
+ if (style.attrs.bold || style.attrs.dim) result += "\x1b[22m"
1928
+ if (style.attrs.italic) result += "\x1b[23m"
1929
+ if (style.attrs.strikethrough) result += "\x1b[29m"
1930
+ if (style.attrs.inverse) result += "\x1b[27m"
1931
+ // Colors
1932
+ if (style.bg !== null && !isDefaultBg(style.bg)) result += "\x1b[49m"
1933
+ if (style.fg !== null) result += "\x1b[39m"
1934
+ // Underline color
1935
+ if (style.underlineColor !== null && style.underlineColor !== undefined) result += "\x1b[59m"
1936
+ return result
1937
+ }
1938
+
1939
+ /**
1940
+ * Trim trailing whitespace from a string while preserving ANSI codes.
1941
+ */
1942
+ function trimTrailingWhitespacePreservingAnsi(str: string): string {
1943
+ // Find the last non-whitespace character or ANSI escape
1944
+ let lastContentIndex = -1
1945
+ let i = 0
1946
+
1947
+ while (i < str.length) {
1948
+ if (str[i] === "\x1b") {
1949
+ // Check for OSC sequence (ESC ] ... ST or BEL)
1950
+ if (str[i + 1] === "]") {
1951
+ // Find the terminator: ST (\x1b\\) or BEL (\x07)
1952
+ let end = -1
1953
+ for (let j = i + 2; j < str.length; j++) {
1954
+ if (str[j] === "\x07") {
1955
+ end = j
1956
+ break
1957
+ }
1958
+ if (str[j] === "\x1b" && str[j + 1] === "\\") {
1959
+ end = j + 1
1960
+ break
1961
+ }
1962
+ }
1963
+ if (end !== -1) {
1964
+ lastContentIndex = end
1965
+ i = end + 1
1966
+ continue
1967
+ }
1968
+ }
1969
+ // Found SGR escape - skip the entire sequence
1970
+ const end = str.indexOf("m", i)
1971
+ if (end !== -1) {
1972
+ lastContentIndex = end
1973
+ i = end + 1
1974
+ continue
1975
+ }
1976
+ }
1977
+ if (str[i] !== " " && str[i] !== "\t") {
1978
+ lastContentIndex = i
1979
+ }
1980
+ i++
1981
+ }
1982
+
1983
+ return str.slice(0, lastContentIndex + 1)
1984
+ }