@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
@@ -0,0 +1,2593 @@
1
+ /**
2
+ * Phase 4: Output Phase
3
+ *
4
+ * Diff two buffers and produce minimal ANSI output.
5
+ *
6
+ * Debug: Set SILVERY_DEBUG_OUTPUT=1 to log diff changes and ANSI sequences.
7
+ */
8
+
9
+ import {
10
+ type Style,
11
+ type TerminalBuffer,
12
+ type UnderlineStyle,
13
+ VISIBLE_SPACE_ATTR_MASK,
14
+ colorEquals,
15
+ createMutableCell,
16
+ hasActiveAttrs,
17
+ isDefaultBg,
18
+ styleEquals,
19
+ } from "../buffer"
20
+ import { fgColorCode, bgColorCode } from "../ansi/sgr-codes"
21
+ import type { CursorState } from "@silvery/react/hooks/useCursor"
22
+ import { IncrementalRenderMismatchError } from "../errors"
23
+ import { textSized } from "../text-sizing"
24
+ import { graphemeWidth, isTextSizingEnabled } from "../unicode"
25
+ import type { CellChange } from "./types"
26
+
27
+ const DEBUG_OUTPUT = !!process.env.SILVERY_DEBUG_OUTPUT
28
+ const FULL_RENDER = !!process.env.SILVERY_FULL_RENDER
29
+ const DEBUG_CAPTURE = !!process.env.SILVERY_DEBUG_CAPTURE
30
+ const CAPTURE_RAW = !!process.env.SILVERY_CAPTURE_RAW
31
+ let _debugFrameCount = 0
32
+ let _captureRawFrameCount = 0
33
+
34
+ // ============================================================================
35
+ // Terminal Capability Flags (suppress unsupported SGR codes)
36
+ // ============================================================================
37
+
38
+ import type { TerminalCaps } from "../terminal-caps"
39
+
40
+ /**
41
+ * @deprecated Use createOutputPhase(caps) instead. This is a no-op.
42
+ */
43
+ export function setOutputCaps(
44
+ _caps: Partial<Pick<TerminalCaps, "underlineStyles" | "underlineColor" | "colorLevel">>,
45
+ ): void {
46
+ // No-op: use createOutputPhase(caps) instead
47
+ }
48
+
49
+ // ============================================================================
50
+ // Output Phase Factory (per-term instance, no globals)
51
+ // ============================================================================
52
+
53
+ /** Output-phase capabilities type. */
54
+ export type OutputCaps = Pick<TerminalCaps, "underlineStyles" | "underlineColor" | "colorLevel">
55
+
56
+ // ============================================================================
57
+ // Output Context (per-instance state, replaces module-level globals)
58
+ // ============================================================================
59
+
60
+ /**
61
+ * Per-instance output context containing terminal capabilities, measurer,
62
+ * and caches. Threaded through internal functions to eliminate module-level
63
+ * mutable state. Caches are per-context because SGR output depends on caps.
64
+ */
65
+ interface OutputContext {
66
+ readonly caps: OutputCaps
67
+ readonly measurer: OutputMeasurer | null
68
+ readonly sgrCache: Map<string, string>
69
+ readonly transitionCache: Map<string, string>
70
+ }
71
+
72
+ /** Default context used by bare outputPhase() calls (full capability support, no measurer). */
73
+ const defaultContext: OutputContext = {
74
+ caps: {
75
+ underlineStyles: true,
76
+ underlineColor: true,
77
+ colorLevel: "truecolor",
78
+ },
79
+ measurer: null,
80
+ sgrCache: new Map(),
81
+ transitionCache: new Map(),
82
+ }
83
+
84
+ /** Output phase function signature. */
85
+ export interface OutputPhaseFn {
86
+ (
87
+ prev: TerminalBuffer | null,
88
+ next: TerminalBuffer,
89
+ mode?: "fullscreen" | "inline",
90
+ scrollbackOffset?: number,
91
+ termRows?: number,
92
+ cursorPos?: CursorState | null,
93
+ ): string
94
+ /** Reset inline cursor state. Used by useScrollback to clear cursor tracking on resize. */
95
+ resetInlineState?: () => void
96
+ /** Get the current inline cursor row (relative to render region start). -1 if unknown. */
97
+ getInlineCursorRow?: () => number
98
+ /** Promote frozen content to scrollback. Called by useScrollback to queue
99
+ * frozen content for the next render — the output phase writes frozen + live
100
+ * content in a single target.write() to avoid flicker. */
101
+ promoteScrollback?: (frozenContent: string, frozenLineCount: number) => void
102
+ }
103
+
104
+ // ============================================================================
105
+ // Output Phase Measurer (module-local, avoids dual-module-loading issues)
106
+ // ============================================================================
107
+ // bun can load the same .ts file via symlink + real path as separate module
108
+ // instances. This means `_scopedMeasurer` set by `runWithMeasurer()` in
109
+ // pipeline/index.ts's instance of unicode.ts is invisible to output-phase.ts's
110
+ // instance. We avoid this by closing over the measurer in createOutputPhase()
111
+ // and setting a module-local variable that all output-phase functions read.
112
+
113
+ interface OutputMeasurer {
114
+ graphemeWidth(grapheme: string): number
115
+ readonly textSizingEnabled: boolean
116
+ }
117
+
118
+ /** Get grapheme width using the output context measurer (falls back to unicode.ts import). */
119
+ function outputGraphemeWidth(g: string, ctx: OutputContext): number {
120
+ return ctx.measurer ? ctx.measurer.graphemeWidth(g) : graphemeWidth(g)
121
+ }
122
+
123
+ /** Check if text sizing is enabled using the output context measurer. */
124
+ function outputTextSizingEnabled(ctx: OutputContext): boolean {
125
+ return ctx.measurer ? ctx.measurer.textSizingEnabled : isTextSizingEnabled()
126
+ }
127
+
128
+ /**
129
+ * Create a scoped output phase that uses specific terminal capabilities.
130
+ *
131
+ * @param caps - Terminal capabilities for SGR code generation
132
+ * @param measurer - Width measurer for graphemeWidth/textSizingEnabled (avoids dual-module-loading issues)
133
+ */
134
+ export function createOutputPhase(caps: Partial<OutputCaps>, measurer?: OutputMeasurer): OutputPhaseFn {
135
+ // Instance-scoped context — caps, measurer, and caches are all per-instance.
136
+ // No module-level globals are read or modified.
137
+ const ctx: OutputContext = {
138
+ caps: {
139
+ underlineStyles: caps.underlineStyles ?? true,
140
+ underlineColor: caps.underlineColor ?? true,
141
+ colorLevel: caps.colorLevel ?? "truecolor",
142
+ },
143
+ measurer: measurer ?? null,
144
+ sgrCache: new Map(),
145
+ transitionCache: new Map(),
146
+ }
147
+ // Instance-scoped inline cursor state — persists across frames for incremental rendering.
148
+ // Each createOutputPhase() call gets its own state, eliminating module-level globals.
149
+ const inlineState = createInlineCursorState()
150
+
151
+ // Instance-scoped accumulated ANSI state for SILVERY_STRICT_ACCUMULATE verification.
152
+ // Tracks per-instance verification state rather than using module-level globals.
153
+ const accState = {
154
+ accumulatedAnsi: "",
155
+ accumulateWidth: 0,
156
+ accumulateHeight: 0,
157
+ accumulateFrameCount: 0,
158
+ }
159
+
160
+ // Pending scrollback promotion — queued by useScrollback, consumed by the next render.
161
+ let pendingPromotion: { frozenContent: string; frozenLineCount: number } | null = null
162
+
163
+ const fn: OutputPhaseFn = function scopedOutputPhase(
164
+ prev: TerminalBuffer | null,
165
+ next: TerminalBuffer,
166
+ mode: "fullscreen" | "inline" = "fullscreen",
167
+ scrollbackOffset = 0,
168
+ termRows?: number,
169
+ cursorPos?: CursorState | null,
170
+ ): string {
171
+ // Handle scrollback promotion: write frozen content + live content in one pass.
172
+ if (pendingPromotion && mode === "inline") {
173
+ const promo = pendingPromotion
174
+ pendingPromotion = null
175
+ return handleScrollbackPromotion(
176
+ inlineState,
177
+ promo.frozenContent,
178
+ promo.frozenLineCount,
179
+ next,
180
+ termRows,
181
+ cursorPos,
182
+ ctx,
183
+ )
184
+ }
185
+ return outputPhase(prev, next, mode, scrollbackOffset, termRows, cursorPos, inlineState, ctx, accState)
186
+ }
187
+
188
+ fn.resetInlineState = () => {
189
+ Object.assign(inlineState, createInlineCursorState())
190
+ inlineState.forceFirstRender = true
191
+ // Clear any queued promotion — the resize handler re-emits all frozen items
192
+ // directly, so any pending promotion from the freeze effect is redundant.
193
+ // Without this, freeze+resize in the same frame causes duplicate frozen content.
194
+ pendingPromotion = null
195
+ }
196
+
197
+ fn.getInlineCursorRow = () => inlineState.prevCursorRow
198
+
199
+ fn.promoteScrollback = (frozenContent: string, frozenLineCount: number) => {
200
+ if (pendingPromotion) {
201
+ pendingPromotion.frozenContent += frozenContent
202
+ pendingPromotion.frozenLineCount += frozenLineCount
203
+ } else {
204
+ pendingPromotion = { frozenContent, frozenLineCount }
205
+ }
206
+ }
207
+
208
+ return fn
209
+ }
210
+
211
+ /**
212
+ * Handle scrollback promotion: write frozen content + live content in a single output string.
213
+ *
214
+ * Instead of useScrollback writing directly to stdout (which blanks the screen and
215
+ * causes flicker), this function builds one output string that:
216
+ * 1. Moves cursor to the render region start (no clearing)
217
+ * 2. Writes frozen content (each line overwrites in-place via \x1b[K])
218
+ * 3. Writes live content via bufferToAnsi (also with per-line \x1b[K])
219
+ * 4. Erases any leftover lines from the previous frame
220
+ * 5. Positions the hardware cursor
221
+ *
222
+ * Result: a single target.write() with no blanking — no flicker.
223
+ */
224
+ function handleScrollbackPromotion(
225
+ state: InlineCursorState,
226
+ frozenContent: string,
227
+ frozenLineCount: number,
228
+ next: TerminalBuffer,
229
+ termRows: number | undefined,
230
+ cursorPos: CursorState | null | undefined,
231
+ ctx: OutputContext,
232
+ ): string {
233
+ // 1. Move cursor to render region start
234
+ let output = ""
235
+ if (state.prevCursorRow > 0) {
236
+ output += `\x1b[${state.prevCursorRow}A`
237
+ }
238
+ output += "\r" // column 0, NO \x1b[J clear
239
+
240
+ // 2. Write frozen content (overwrites old content in-place, OSC markers included)
241
+ output += frozenContent
242
+
243
+ // 3. Write live content via bufferToAnsi (each line has \x1b[K — no blanking)
244
+ const nextContentLines = findLastContentLine(next) + 1
245
+ const maxOutputLines = termRows != null ? Math.min(nextContentLines, termRows) : nextContentLines
246
+ output += bufferToAnsi(next, "inline", ctx, maxOutputLines)
247
+
248
+ // Total lines on-screen: frozen + live. The terminal may scroll if this exceeds
249
+ // termRows, naturally pushing frozen lines into scrollback. No padding needed —
250
+ // we track ALL on-screen lines so the next render can overwrite them cleanly.
251
+ const totalOnScreen = frozenLineCount + maxOutputLines
252
+
253
+ // 4. Erase leftover lines at bottom (if content shrank)
254
+ const oldTotalLines = state.prevOutputLines
255
+ const nextLastLine = totalOnScreen - 1
256
+ const terminalScroll = termRows != null ? Math.max(0, totalOnScreen - termRows) : 0
257
+ const lastOccupied = Math.max(oldTotalLines - 1 - terminalScroll, 0)
258
+ if (lastOccupied > nextLastLine) {
259
+ for (let y = nextLastLine + 1; y <= lastOccupied; y++) {
260
+ output += "\n\r\x1b[K"
261
+ }
262
+ const up = lastOccupied - nextLastLine
263
+ if (up > 0) output += `\x1b[${up}A`
264
+ }
265
+
266
+ // 5. Cursor suffix (hardware cursor positioning)
267
+ // Cursor is at the end of live content (row totalOnScreen - 1 relative to
268
+ // render region start). inlineCursorSuffix moves it to the useCursor position
269
+ // within the live content area.
270
+ output += inlineCursorSuffix(cursorPos ?? null, next, termRows)
271
+
272
+ // 6. Update tracking for subsequent incremental renders.
273
+ // Track cursor position and output lines relative to the LIVE content only.
274
+ // Frozen content has been written as raw ANSI above the live content and is
275
+ // now "owned" by the terminal — it stays on screen until natural scrolling
276
+ // pushes it into terminal scrollback. The next render only needs to cursor-up
277
+ // to the start of the live content area, not past the frozen content.
278
+ //
279
+ // This is critical for real terminals where pre-existing content (shell prompt,
280
+ // direnv output) sits above the app. If prevCursorRow included frozenLineCount,
281
+ // the cursor-up would overshoot into the shell prompt area, clearing it.
282
+ let startLine = 0
283
+ if (termRows != null && nextContentLines > termRows) startLine = nextContentLines - termRows
284
+ state.prevBuffer = next
285
+
286
+ // Cursor row within the LIVE content area only (not including frozen lines).
287
+ // inlineCursorSuffix already positioned the cursor within the live content.
288
+ if (cursorPos?.visible) {
289
+ const visibleRow = cursorPos.y - startLine
290
+ state.prevCursorRow = visibleRow >= 0 && visibleRow < maxOutputLines ? visibleRow : maxOutputLines - 1
291
+ } else {
292
+ state.prevCursorRow = maxOutputLines - 1
293
+ }
294
+ state.prevOutputLines = maxOutputLines
295
+
296
+ return output
297
+ }
298
+
299
+ // These use getters so they can be set after module load (e.g., in test files).
300
+ // SILVERY_STRICT enables buffer + output checks (per-frame).
301
+ // SILVERY_STRICT_OUTPUT=0 explicitly disables output checking even when SILVERY_STRICT is set.
302
+ // SILVERY_STRICT_ACCUMULATE is separate — it replays ALL frames (O(N²)) and is opt-in only.
303
+ function isStrictOutput(): boolean {
304
+ const outputEnv = process.env.SILVERY_STRICT_OUTPUT
305
+ if (outputEnv === "0" || outputEnv === "false") return false
306
+ return !!outputEnv || !!process.env.SILVERY_STRICT
307
+ }
308
+ function isStrictAccumulate(): boolean {
309
+ return !!process.env.SILVERY_STRICT_ACCUMULATE
310
+ }
311
+
312
+ /** Per-instance state for SILVERY_STRICT_ACCUMULATE verification. */
313
+ interface AccumulateState {
314
+ accumulatedAnsi: string
315
+ accumulateWidth: number
316
+ accumulateHeight: number
317
+ accumulateFrameCount: number
318
+ }
319
+
320
+ /** Default accumulate state used by bare outputPhase() calls. */
321
+ const defaultAccState: AccumulateState = {
322
+ accumulatedAnsi: "",
323
+ accumulateWidth: 0,
324
+ accumulateHeight: 0,
325
+ accumulateFrameCount: 0,
326
+ }
327
+
328
+ // ============================================================================
329
+ // Inline Mode: Inter-frame Cursor Tracking (instance-scoped)
330
+ // ============================================================================
331
+
332
+ /**
333
+ * Mutable state for inline mode inter-frame cursor tracking.
334
+ * Captured in the createOutputPhase() closure — no module-level globals.
335
+ */
336
+ interface InlineCursorState {
337
+ /** Row within render region after last inline frame's cursor suffix. -1 = unknown. */
338
+ prevCursorRow: number
339
+ /** Total output lines rendered in last frame. Used to clear old content on resize. */
340
+ prevOutputLines: number
341
+ /** Previous frame's buffer — used for incremental rendering when runtime invalidates (resize). */
342
+ prevBuffer: TerminalBuffer | null
343
+ /** When true, the next inline render treats prev as null (first-render path).
344
+ * Set by resetInlineState() after useScrollback clears and re-emits frozen items. */
345
+ forceFirstRender: boolean
346
+ }
347
+
348
+ /** Create fresh inline cursor state (unknown position → first call falls back to full render). */
349
+ function createInlineCursorState(): InlineCursorState {
350
+ return {
351
+ prevCursorRow: -1,
352
+ prevOutputLines: 0,
353
+ prevBuffer: null,
354
+ forceFirstRender: false,
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Update cursor tracking after an inline render frame.
360
+ * Records where the terminal cursor ends up after inlineCursorSuffix().
361
+ */
362
+ function updateInlineCursorRow(
363
+ state: InlineCursorState,
364
+ cursorPos: CursorState | null | undefined,
365
+ maxOutputLines: number,
366
+ startLine: number,
367
+ ): void {
368
+ if (cursorPos?.visible) {
369
+ const visibleRow = cursorPos.y - startLine
370
+ state.prevCursorRow = visibleRow >= 0 && visibleRow < maxOutputLines ? visibleRow : maxOutputLines - 1
371
+ } else {
372
+ // Cursor hidden: cursor stays at end of last content line
373
+ state.prevCursorRow = maxOutputLines - 1
374
+ }
375
+ state.prevOutputLines = maxOutputLines
376
+ }
377
+
378
+ /**
379
+ * Wrap a cell character in OSC 66 if text sizing is enabled and the cell is
380
+ * wide. OSC 66 tells the terminal to render the character in exactly `width`
381
+ * cells, matching the layout engine's measurement.
382
+ *
383
+ * Previously this only wrapped specific categories (PUA, text-presentation
384
+ * emoji, flag emoji) — a whack-a-mole approach that missed new categories
385
+ * as Unicode evolved. Now wraps ALL wide chars unconditionally: if the buffer
386
+ * says width 2, the terminal is told width 2. CJK chars don't strictly need
387
+ * it (terminals agree on their width), but the ~8-byte overhead per wide char
388
+ * is negligible and eliminates any future width disagreement.
389
+ */
390
+ function wrapTextSizing(char: string, wide: boolean, ctx: OutputContext): string {
391
+ if (!wide || !outputTextSizingEnabled(ctx)) return char
392
+ return textSized(char, 2)
393
+ }
394
+
395
+ // ============================================================================
396
+ // Style Interning + SGR Cache
397
+ // ============================================================================
398
+
399
+ // SGR caches are now per-OutputContext (see OutputContext interface).
400
+ // This is correct because SGR output depends on caps (underlineStyles,
401
+ // underlineColor), so caches populated under one caps configuration
402
+ // would produce wrong results under a different one.
403
+
404
+ /**
405
+ * Serialize a Style into a cache key string.
406
+ * Fast path: most styles are simple (256-color or null fg/bg, no true color).
407
+ */
408
+ function styleToKey(style: Style): string {
409
+ const fg = style.fg
410
+ const bg = style.bg
411
+ const attrs = style.attrs
412
+
413
+ // Fast path: common case of simple colors + few attrs
414
+ let key = ""
415
+
416
+ // fg
417
+ if (fg === null) {
418
+ key = "n"
419
+ } else if (typeof fg === "number") {
420
+ key = `${fg}`
421
+ } else {
422
+ key = `r${fg.r},${fg.g},${fg.b}`
423
+ }
424
+
425
+ key += "|"
426
+
427
+ // bg
428
+ if (bg === null) {
429
+ key += "n"
430
+ } else if (typeof bg === "number") {
431
+ key += `${bg}`
432
+ } else {
433
+ key += `r${bg.r},${bg.g},${bg.b}`
434
+ }
435
+
436
+ // attrs packed as bitmask for speed
437
+ let attrBits = 0
438
+ if (attrs.bold) attrBits |= 1
439
+ if (attrs.dim) attrBits |= 2
440
+ if (attrs.italic) attrBits |= 4
441
+ if (attrs.underline) attrBits |= 8
442
+ if (attrs.inverse) attrBits |= 16
443
+ if (attrs.strikethrough) attrBits |= 32
444
+ if (attrs.blink) attrBits |= 64
445
+ if (attrs.hidden) attrBits |= 128
446
+
447
+ key += `|${attrBits}`
448
+
449
+ // Underline style (rare)
450
+ if (attrs.underlineStyle) {
451
+ key += `|u${attrs.underlineStyle}`
452
+ }
453
+
454
+ // Underline color (rare)
455
+ const ul = style.underlineColor
456
+ if (ul !== null && ul !== undefined) {
457
+ if (typeof ul === "number") {
458
+ key += `|l${ul}`
459
+ } else {
460
+ key += `|lr${ul.r},${ul.g},${ul.b}`
461
+ }
462
+ }
463
+
464
+ // Hyperlink URL (rare)
465
+ if (style.hyperlink) {
466
+ key += `|h${style.hyperlink}`
467
+ }
468
+
469
+ return key
470
+ }
471
+
472
+ /**
473
+ * Get the SGR escape string for a style, using the intern cache.
474
+ * Cache hit: O(1) Map lookup + key serialization.
475
+ * Cache miss: builds the SGR string and caches it.
476
+ */
477
+ function cachedStyleToAnsi(style: Style, ctx: OutputContext): string {
478
+ const key = styleToKey(style)
479
+ let sgr = ctx.sgrCache.get(key)
480
+ if (sgr !== undefined) return sgr
481
+ sgr = styleToAnsi(style, ctx)
482
+ ctx.sgrCache.set(key, sgr)
483
+ if (ctx.sgrCache.size > 1000) ctx.sgrCache.clear()
484
+ return sgr
485
+ }
486
+
487
+ /**
488
+ * Compute the minimal SGR transition between two styles.
489
+ *
490
+ * When oldStyle is null (first cell or after reset), falls through to
491
+ * full SGR generation via cachedStyleToAnsi. Otherwise, diffs attribute
492
+ * by attribute and emits only changed SGR codes. Caches the result for
493
+ * each (oldKey, newKey) pair.
494
+ */
495
+ function styleTransition(oldStyle: Style | null, newStyle: Style, ctx: OutputContext): string {
496
+ // First cell or after reset — full generation
497
+ if (!oldStyle) return cachedStyleToAnsi(newStyle, ctx)
498
+
499
+ // Same style — nothing to emit
500
+ if (styleEquals(oldStyle, newStyle)) return ""
501
+
502
+ // Check transition cache
503
+ const oldKey = styleToKey(oldStyle)
504
+ const newKey = styleToKey(newStyle)
505
+ const cacheKey = `${oldKey}\x00${newKey}`
506
+ const cached = ctx.transitionCache.get(cacheKey)
507
+ if (cached !== undefined) return cached
508
+
509
+ // Build minimal diff
510
+ const codes: string[] = []
511
+
512
+ // Check attributes that can only be "turned off" via reset or specific off-codes.
513
+ // If an attribute was on and is now off, we need either the off-code or a full reset.
514
+ const oa = oldStyle.attrs
515
+ const na = newStyle.attrs
516
+
517
+ // Bold and dim share SGR 22 as their off-code, so handle them together
518
+ // to avoid emitting duplicate codes.
519
+ const boldChanged = Boolean(oa.bold) !== Boolean(na.bold)
520
+ const dimChanged = Boolean(oa.dim) !== Boolean(na.dim)
521
+ if (boldChanged || dimChanged) {
522
+ const boldOff = boldChanged && !na.bold
523
+ const dimOff = dimChanged && !na.dim
524
+ if (boldOff || dimOff) {
525
+ // SGR 22 resets both bold and dim
526
+ codes.push("22")
527
+ // Re-enable whichever should stay on
528
+ if (na.bold) codes.push("1")
529
+ if (na.dim) codes.push("2")
530
+ } else {
531
+ // Only turning attributes on
532
+ if (boldChanged && na.bold) codes.push("1")
533
+ if (dimChanged && na.dim) codes.push("2")
534
+ }
535
+ }
536
+ if (Boolean(oa.italic) !== Boolean(na.italic)) {
537
+ codes.push(na.italic ? "3" : "23")
538
+ }
539
+
540
+ // Underline: compare both underline flag and underlineStyle
541
+ const oldUl = Boolean(oa.underline)
542
+ const newUl = Boolean(na.underline)
543
+ const oldUlStyle = oa.underlineStyle ?? false
544
+ const newUlStyle = na.underlineStyle ?? false
545
+ if (oldUl !== newUl || oldUlStyle !== newUlStyle) {
546
+ if (!ctx.caps.underlineStyles) {
547
+ // Terminal doesn't support SGR 4:x — fall back to simple SGR 4/24
548
+ codes.push(newUl || na.underlineStyle ? "4" : "24")
549
+ } else {
550
+ const sgrSub = underlineStyleToSgr(na.underlineStyle)
551
+ if (sgrSub !== null && sgrSub !== 0) {
552
+ codes.push(`4:${sgrSub}`)
553
+ } else if (newUl) {
554
+ codes.push("4")
555
+ } else {
556
+ codes.push("24")
557
+ }
558
+ }
559
+ }
560
+
561
+ if (Boolean(oa.inverse) !== Boolean(na.inverse)) {
562
+ codes.push(na.inverse ? "7" : "27")
563
+ }
564
+ if (Boolean(oa.hidden) !== Boolean(na.hidden)) {
565
+ codes.push(na.hidden ? "8" : "28")
566
+ }
567
+ if (Boolean(oa.strikethrough) !== Boolean(na.strikethrough)) {
568
+ codes.push(na.strikethrough ? "9" : "29")
569
+ }
570
+ if (Boolean(oa.blink) !== Boolean(na.blink)) {
571
+ codes.push(na.blink ? "5" : "25")
572
+ }
573
+
574
+ // Foreground color
575
+ if (!colorEquals(oldStyle.fg, newStyle.fg)) {
576
+ if (newStyle.fg === null) {
577
+ codes.push("39")
578
+ } else {
579
+ codes.push(fgColorCode(newStyle.fg))
580
+ }
581
+ }
582
+
583
+ // Background color
584
+ if (!colorEquals(oldStyle.bg, newStyle.bg)) {
585
+ if (newStyle.bg === null) {
586
+ codes.push("49")
587
+ } else {
588
+ codes.push(bgColorCode(newStyle.bg))
589
+ }
590
+ }
591
+
592
+ // Underline color (SGR 58/59) — skip for terminals that don't support it
593
+ if (ctx.caps.underlineColor && !colorEquals(oldStyle.underlineColor, newStyle.underlineColor)) {
594
+ if (newStyle.underlineColor === null || newStyle.underlineColor === undefined) {
595
+ // SGR 59 resets underline color
596
+ codes.push("59")
597
+ } else if (typeof newStyle.underlineColor === "number") {
598
+ codes.push(`58;5;${newStyle.underlineColor}`)
599
+ } else {
600
+ codes.push(`58;2;${newStyle.underlineColor.r};${newStyle.underlineColor.g};${newStyle.underlineColor.b}`)
601
+ }
602
+ }
603
+
604
+ // Hyperlink (OSC 8) is handled separately in the render loops, not here.
605
+
606
+ let result: string
607
+ if (codes.length === 0) {
608
+ // Styles differ but no SGR codes emitted (e.g., hyperlink-only change).
609
+ // Fall back to full generation to be safe.
610
+ result = cachedStyleToAnsi(newStyle, ctx)
611
+ } else {
612
+ result = `\x1b[${codes.join(";")}m`
613
+ }
614
+
615
+ ctx.transitionCache.set(cacheKey, result)
616
+ if (ctx.transitionCache.size > 1000) ctx.transitionCache.clear()
617
+ return result
618
+ }
619
+
620
+ /**
621
+ * Map underline style to SGR 4:x subparameter.
622
+ */
623
+ function underlineStyleToSgr(style: UnderlineStyle | undefined): number | null {
624
+ switch (style) {
625
+ case false:
626
+ return 0 // SGR 4:0 = no underline
627
+ case "single":
628
+ return 1 // SGR 4:1 = single underline
629
+ case "double":
630
+ return 2 // SGR 4:2 = double underline
631
+ case "curly":
632
+ return 3 // SGR 4:3 = curly underline
633
+ case "dotted":
634
+ return 4 // SGR 4:4 = dotted underline
635
+ case "dashed":
636
+ return 5 // SGR 4:5 = dashed underline
637
+ default:
638
+ return null // Use simple SGR 4 or no underline
639
+ }
640
+ }
641
+
642
+ /**
643
+ * Diff two buffers and produce minimal ANSI output.
644
+ *
645
+ * @param prev Previous buffer (null on first render)
646
+ * @param next Current buffer
647
+ * @param mode Render mode: fullscreen or inline
648
+ * @param scrollbackOffset Lines written to stdout between renders (inline mode)
649
+ * @param termRows Terminal height in rows (inline mode) — caps output to prevent
650
+ * scrollback corruption when content exceeds terminal height
651
+ * @returns ANSI escape sequence string
652
+ */
653
+ export function outputPhase(
654
+ prev: TerminalBuffer | null,
655
+ next: TerminalBuffer,
656
+ mode: "fullscreen" | "inline" = "fullscreen",
657
+ scrollbackOffset = 0,
658
+ termRows?: number,
659
+ cursorPos?: CursorState | null,
660
+ _inlineState?: InlineCursorState,
661
+ _ctx?: OutputContext,
662
+ _accState?: AccumulateState,
663
+ ): string {
664
+ // Bare outputPhase() calls use a fresh cursor state each time.
665
+ // prevCursorRow = -1 means incremental rendering always falls back to full render.
666
+ // Instance-scoped state (via createOutputPhase) enables incremental across frames.
667
+ const inlineState = _inlineState ?? createInlineCursorState()
668
+ const ctx = _ctx ?? defaultContext
669
+ const accState = _accState ?? defaultAccState
670
+
671
+ // After resetInlineState (e.g., useScrollback cleared and re-emitted frozen items),
672
+ // treat the next render as a first render. The cursor is at a known position
673
+ // (right after the re-emitted frozen items) and prev is stale.
674
+ if (mode === "inline" && inlineState.forceFirstRender) {
675
+ inlineState.forceFirstRender = false
676
+ prev = null // may already be null (runtime.invalidate on resize), consume flag regardless
677
+ }
678
+
679
+ // First render: output entire buffer
680
+ if (!prev) {
681
+ // Inline mode resize optimization: if the runtime invalidated prevBuffer (resize)
682
+ // but we have a stored buffer with matching dimensions, use incremental rendering
683
+ // instead of clear+full render. This avoids wiping content when the buffer is unchanged
684
+ // (e.g., content narrower than both old and new terminal widths).
685
+ if (mode === "inline" && inlineState.prevBuffer && inlineState.prevCursorRow >= 0) {
686
+ const stored = inlineState.prevBuffer
687
+ if (stored.width === next.width && stored.height === next.height) {
688
+ // Dimensions match — use incremental rendering (skip clear entirely)
689
+ inlineState.prevBuffer = next
690
+ return inlineIncrementalRender(inlineState, stored, next, scrollbackOffset, termRows, cursorPos, ctx)
691
+ }
692
+ }
693
+
694
+ // Cap output to terminal height to prevent scroll desync.
695
+ // In inline mode: prevents scrollback corruption (cursor-up clamped at row 0).
696
+ // In fullscreen mode: prevents terminal scroll that desynchronizes prevBuffer
697
+ // from actual terminal state, causing ghost pixels on subsequent incremental renders.
698
+ const firstOutput = bufferToAnsi(next, mode, ctx, termRows)
699
+ // For inline first render, append cursor positioning and initialize tracking
700
+ if (mode === "inline") {
701
+ const firstContentLines = findLastContentLine(next) + 1
702
+ const firstMaxOutput = termRows != null ? Math.min(firstContentLines, termRows) : firstContentLines
703
+ let firstStartLine = 0
704
+ if (termRows != null && firstContentLines > termRows) firstStartLine = firstContentLines - termRows
705
+
706
+ // Resize: clear the entire visible screen and re-render.
707
+ // Terminal reflow is unpredictable — lines wrap differently based on content,
708
+ // unicode, wrap points. Rather than guessing cursor-up distances, overshoot:
709
+ // ESC[nA is clamped at row 0 of the visible screen (can't touch scrollback),
710
+ // so using termRows is safe. Frozen scrollback content above is preserved.
711
+ let prefix = ""
712
+ if (inlineState.prevCursorRow >= 0) {
713
+ const clearDistance = termRows ?? Math.max(inlineState.prevCursorRow, inlineState.prevOutputLines - 1)
714
+ if (clearDistance > 0) {
715
+ prefix += `\x1b[${clearDistance}A`
716
+ }
717
+ prefix += "\r\x1b[J" // column 0, clear from cursor to end of screen
718
+ }
719
+
720
+ inlineState.prevBuffer = next
721
+ updateInlineCursorRow(inlineState, cursorPos, firstMaxOutput, firstStartLine)
722
+ return prefix + firstOutput + inlineCursorSuffix(cursorPos ?? null, next, termRows)
723
+ }
724
+ if (isStrictAccumulate()) {
725
+ accState.accumulatedAnsi = firstOutput
726
+ accState.accumulateWidth = next.width
727
+ accState.accumulateHeight = next.height
728
+ accState.accumulateFrameCount = 0
729
+ }
730
+ if (CAPTURE_RAW) {
731
+ try {
732
+ const fs = require("fs")
733
+ _captureRawFrameCount = 0
734
+ // Write initial render with frame separator
735
+ fs.writeFileSync("/tmp/silvery-raw.ansi", firstOutput)
736
+ fs.writeFileSync(
737
+ "/tmp/silvery-raw-frames.jsonl",
738
+ JSON.stringify({
739
+ frame: 0,
740
+ type: "full",
741
+ bytes: firstOutput.length,
742
+ width: next.width,
743
+ height: next.height,
744
+ }) + "\n",
745
+ )
746
+ } catch {}
747
+ }
748
+ return firstOutput
749
+ }
750
+
751
+ // Inline mode: use incremental rendering when safe, fall back to full render.
752
+ if (mode === "inline") {
753
+ inlineState.prevBuffer = next
754
+ return inlineIncrementalRender(inlineState, prev, next, scrollbackOffset, termRows, cursorPos, ctx)
755
+ }
756
+
757
+ // SILVERY_FULL_RENDER: bypass incremental diff, always render full buffer.
758
+ // Use to diagnose garbled rendering — if FULL_RENDER fixes it, the bug
759
+ // is in changesToAnsi (diff → ANSI serialization).
760
+ if (FULL_RENDER) {
761
+ return bufferToAnsi(next, mode, ctx, termRows)
762
+ }
763
+
764
+ // Fullscreen mode: diff and emit only changes
765
+ const { pool, count: rawCount } = diffBuffers(prev, next)
766
+
767
+ // Filter out changes beyond terminal height to prevent CUP targeting rows
768
+ // past the terminal, which causes scrolling and prevBuffer desync.
769
+ let count = rawCount
770
+ if (termRows != null) {
771
+ let writeIdx = 0
772
+ for (let i = 0; i < rawCount; i++) {
773
+ if (pool[i]!.y < termRows) {
774
+ pool[writeIdx++] = pool[i]!
775
+ }
776
+ }
777
+ count = writeIdx
778
+ }
779
+
780
+ if (DEBUG_OUTPUT) {
781
+ // eslint-disable-next-line no-console
782
+ console.error(`[SILVERY_DEBUG_OUTPUT] diffBuffers: ${count} changes${rawCount !== count ? ` (${rawCount - count} clamped beyond termRows)` : ""}`)
783
+ const debugLimit = Math.min(count, 10)
784
+ for (let i = 0; i < debugLimit; i++) {
785
+ const change = pool[i]!
786
+ // eslint-disable-next-line no-console
787
+ console.error(` (${change.x},${change.y}): "${change.cell.char}"`)
788
+ }
789
+ if (count > 10) {
790
+ // eslint-disable-next-line no-console
791
+ console.error(` ... and ${count - 10} more`)
792
+ }
793
+ }
794
+
795
+ if (count === 0) {
796
+ return "" // No changes
797
+ }
798
+
799
+ // Wide characters are handled atomically in changesToAnsi():
800
+ // - Wide char main cells emit the character and advance cursor by 2
801
+ // - Continuation cells are skipped (handled with their main cell)
802
+ // - Orphaned continuation cells (main cell unchanged) trigger a
803
+ // re-emit of the main cell from the buffer
804
+ const { output: incrOutput } = changesToAnsi(pool, count, mode, ctx, next)
805
+
806
+ // Log output sizes when debug or strict-accumulate is enabled
807
+ if (DEBUG_OUTPUT || isStrictAccumulate()) {
808
+ const bytes = Buffer.byteLength(incrOutput)
809
+ try {
810
+ const fs = require("fs")
811
+ fs.appendFileSync("/tmp/silvery-sizes.log", `changesToAnsi: ${count} changes, ${bytes} bytes\n`)
812
+ } catch {}
813
+ }
814
+
815
+ // Debug capture: write both incremental and fresh ANSI to files for comparison.
816
+ // Usage: SILVERY_DEBUG_CAPTURE=1 bun km view --repo imports/asana launch-academy
817
+ if (DEBUG_CAPTURE) {
818
+ _debugFrameCount++
819
+ try {
820
+ const fs = require("fs")
821
+ const freshOutput = bufferToAnsi(next, mode, ctx)
822
+ const freshPrev = prev ? bufferToAnsi(prev, mode, ctx) : ""
823
+ // Replay incremental on top of fresh prev
824
+ const w = Math.max(prev?.width ?? next.width, next.width)
825
+ const h = Math.max(prev?.height ?? next.height, next.height)
826
+ const screenIncr = replayAnsiWithStyles(w, h, freshPrev + incrOutput, ctx)
827
+ const screenFresh = replayAnsiWithStyles(w, h, freshOutput, ctx)
828
+ // Find first mismatch
829
+ let mismatchInfo = ""
830
+ for (let y = 0; y < h && !mismatchInfo; y++) {
831
+ for (let x = 0; x < w && !mismatchInfo; x++) {
832
+ const ic = screenIncr[y]?.[x]
833
+ const fc = screenFresh[y]?.[x]
834
+ if (ic && fc && (ic.char !== fc.char || !sgrColorEquals(ic.fg, fc.fg) || !sgrColorEquals(ic.bg, fc.bg))) {
835
+ mismatchInfo = `MISMATCH at (${x},${y}): incr='${ic.char}' fresh='${fc.char}' incrFg=${formatColor(ic.fg)} freshFg=${formatColor(fc.fg)} incrBg=${formatColor(ic.bg)} freshBg=${formatColor(fc.bg)}`
836
+ // Show row context
837
+ const incrRow = screenIncr[y]!.map((c) => c.char).join("")
838
+ const freshRow = screenFresh[y]!.map((c) => c.char).join("")
839
+ mismatchInfo += `\n incr row ${y}: ${incrRow.slice(Math.max(0, x - 20), x + 40)}\n fresh row ${y}: ${freshRow.slice(Math.max(0, x - 20), x + 40)}`
840
+ }
841
+ }
842
+ }
843
+ const status = mismatchInfo || "MATCH"
844
+ fs.appendFileSync("/tmp/silvery-capture.log", `Frame ${_debugFrameCount}: ${count} changes, ${status}\n`)
845
+ if (mismatchInfo) {
846
+ fs.writeFileSync(`/tmp/silvery-incr-${_debugFrameCount}.ansi`, freshPrev + incrOutput)
847
+ fs.writeFileSync(`/tmp/silvery-fresh-${_debugFrameCount}.ansi`, freshOutput)
848
+ fs.appendFileSync(
849
+ "/tmp/silvery-capture.log",
850
+ ` Saved ANSI files: /tmp/silvery-incr-${_debugFrameCount}.ansi and /tmp/silvery-fresh-${_debugFrameCount}.ansi\n`,
851
+ )
852
+ }
853
+ } catch (e) {
854
+ try {
855
+ require("fs").appendFileSync("/tmp/silvery-capture.log", `Frame ${_debugFrameCount}: ERROR ${e}\n`)
856
+ } catch {}
857
+ }
858
+ }
859
+
860
+ // SILVERY_STRICT_OUTPUT: verify that the incremental ANSI output produces the
861
+ // same visible terminal state as a fresh render. Catches bugs in changesToAnsi
862
+ // that SILVERY_STRICT (buffer-level check) cannot detect.
863
+ if (isStrictOutput()) {
864
+ verifyOutputEquivalence(prev, next, incrOutput, mode, ctx)
865
+ }
866
+
867
+ // SILVERY_STRICT_ACCUMULATE: verify that the accumulated output from ALL frames
868
+ // produces the same terminal state as a fresh render of the current buffer.
869
+ // Catches compounding errors that per-frame verification misses.
870
+ if (isStrictAccumulate()) {
871
+ accState.accumulatedAnsi += incrOutput
872
+ accState.accumulateFrameCount++
873
+ verifyAccumulatedOutput(next, mode, ctx, accState)
874
+ }
875
+
876
+ if (CAPTURE_RAW) {
877
+ try {
878
+ const fs = require("fs")
879
+ _captureRawFrameCount++
880
+ // Append incremental output to cumulative ANSI file
881
+ fs.appendFileSync("/tmp/silvery-raw.ansi", incrOutput)
882
+ // Also save the fresh render of this frame for comparison
883
+ const freshOutput = bufferToAnsi(next, mode, ctx)
884
+ fs.writeFileSync(`/tmp/silvery-raw-fresh-${_captureRawFrameCount}.ansi`, freshOutput)
885
+ fs.appendFileSync(
886
+ "/tmp/silvery-raw-frames.jsonl",
887
+ JSON.stringify({
888
+ frame: _captureRawFrameCount,
889
+ type: "incremental",
890
+ changes: count,
891
+ bytes: incrOutput.length,
892
+ freshBytes: freshOutput.length,
893
+ width: next.width,
894
+ height: next.height,
895
+ }) + "\n",
896
+ )
897
+ } catch {}
898
+ }
899
+
900
+ return incrOutput
901
+ }
902
+
903
+ /**
904
+ * Check if a line has any non-space content or styling.
905
+ * A row with only spaces but with background color or other styling
906
+ * (bold, inverse, underline, etc.) is visually meaningful.
907
+ */
908
+ function lineHasContent(buffer: TerminalBuffer, y: number): boolean {
909
+ for (let x = 0; x < buffer.width; x++) {
910
+ const ch = buffer.getCellChar(x, y)
911
+ if (ch !== " " && ch !== "") return true
912
+ // Styled blank cells are visually meaningful:
913
+ // - background color (colored spacer rows)
914
+ // - inverse (visible block of fg color)
915
+ // - underline (visible line under space)
916
+ // - strikethrough (visible line through space)
917
+ const bg = buffer.getCellBg(x, y)
918
+ if (bg !== null) return true
919
+ if (buffer.getCellAttrs(x, y) & VISIBLE_SPACE_ATTR_MASK) return true
920
+ }
921
+ return false
922
+ }
923
+
924
+ /**
925
+ * Find the last line with content in the buffer.
926
+ */
927
+ function findLastContentLine(buffer: TerminalBuffer): number {
928
+ for (let y = buffer.height - 1; y >= 0; y--) {
929
+ if (lineHasContent(buffer, y)) {
930
+ return y
931
+ }
932
+ }
933
+ return 0 // At least render first line
934
+ }
935
+
936
+ /**
937
+ * Compute the ANSI suffix that positions the real terminal cursor for inline mode.
938
+ *
939
+ * After inline rendering, the terminal cursor sits at the end of the last
940
+ * content line. If a component used useCursor(), we move the cursor to
941
+ * that position (relative to the rendered output). Otherwise we just show
942
+ * the cursor at its current position.
943
+ *
944
+ * @param cursorPos The cursor state from useCursor() (or null if none)
945
+ * @param buffer The rendered buffer
946
+ * @param termRows Terminal height cap (may limit visible rows)
947
+ */
948
+ function inlineCursorSuffix(
949
+ cursorPos: CursorState | null | undefined,
950
+ buffer: TerminalBuffer,
951
+ termRows?: number,
952
+ ): string {
953
+ if (!cursorPos?.visible) {
954
+ // No active cursor — hide it
955
+ return "\x1b[?25l"
956
+ }
957
+
958
+ // Determine the visible row range (same logic as bufferToAnsi for inline)
959
+ const lastContentLine = findLastContentLine(buffer)
960
+ const maxLine = lastContentLine
961
+ let startLine = 0
962
+ const maxOutputLines = termRows != null ? Math.min(lastContentLine + 1, termRows) : lastContentLine + 1
963
+ if (termRows != null && maxLine >= termRows) {
964
+ startLine = maxLine - termRows + 1
965
+ }
966
+
967
+ // Convert absolute buffer cursor position to visible row index
968
+ const visibleRow = cursorPos.y - startLine
969
+ if (visibleRow < 0 || visibleRow >= maxOutputLines) {
970
+ // Cursor is outside the visible area (scrolled off) — hide it
971
+ return "\x1b[?25l"
972
+ }
973
+
974
+ // After rendering, the terminal cursor is at the end of the last output line.
975
+ // The last output line is at visible row (maxOutputLines - 1).
976
+ const currentRow = maxOutputLines - 1
977
+ const rowDelta = currentRow - visibleRow
978
+
979
+ let suffix = ""
980
+ // Move up to the correct row
981
+ if (rowDelta > 0) {
982
+ suffix += `\x1b[${rowDelta}A`
983
+ }
984
+ // Move to column 0, then right to the correct column
985
+ suffix += "\r"
986
+ if (cursorPos.x > 0) {
987
+ suffix += `\x1b[${cursorPos.x}C`
988
+ }
989
+ // Show cursor
990
+ suffix += "\x1b[?25h"
991
+ return suffix
992
+ }
993
+
994
+ /**
995
+ * Incremental rendering for inline mode.
996
+ *
997
+ * When conditions are safe (no external writes, dimensions unchanged),
998
+ * diffs prev/next buffers and emits only changed cells using relative
999
+ * cursor positioning. Falls back to full render otherwise.
1000
+ *
1001
+ * This reduces output from ~5,848 bytes (full re-render at 50 items)
1002
+ * to ~50-100 bytes per keystroke, matching fullscreen efficiency.
1003
+ */
1004
+ function inlineIncrementalRender(
1005
+ state: InlineCursorState,
1006
+ prev: TerminalBuffer,
1007
+ next: TerminalBuffer,
1008
+ scrollbackOffset: number,
1009
+ termRows?: number,
1010
+ cursorPos?: CursorState | null,
1011
+ ctx: OutputContext = defaultContext,
1012
+ ): string {
1013
+ // Guard: fall back to full render for complex cases
1014
+ if (scrollbackOffset > 0 || prev.width !== next.width || prev.height !== next.height || state.prevCursorRow < 0) {
1015
+ return inlineFullRender(state, prev, next, scrollbackOffset, termRows, cursorPos, ctx)
1016
+ }
1017
+
1018
+ const nextContentLines = findLastContentLine(next) + 1
1019
+ const prevContentLines = findLastContentLine(prev) + 1
1020
+
1021
+ // Compute visible ranges for both prev and next content
1022
+ const prevMaxOutputLines = termRows != null ? Math.min(prevContentLines, termRows) : prevContentLines
1023
+ const maxOutputLines = termRows != null ? Math.min(nextContentLines, termRows) : nextContentLines
1024
+ let prevStartLine = 0
1025
+ if (termRows != null && prevContentLines > termRows) {
1026
+ prevStartLine = prevContentLines - termRows
1027
+ }
1028
+ let startLine = 0
1029
+ if (termRows != null && nextContentLines > termRows) {
1030
+ startLine = nextContentLines - termRows
1031
+ }
1032
+
1033
+ // When the visible window shifts (content exceeds termRows and startLine changes),
1034
+ // the entire visible region is different — fall back to full render.
1035
+ if (startLine !== prevStartLine) {
1036
+ return inlineFullRender(state, prev, next, scrollbackOffset, termRows, cursorPos, ctx)
1037
+ }
1038
+
1039
+ // Diff buffers
1040
+ const { pool, count } = diffBuffers(prev, next)
1041
+ if (count === 0 && nextContentLines === prevContentLines) {
1042
+ // No buffer changes, but cursor position may have changed.
1043
+ // Emit cursor suffix to update the terminal cursor.
1044
+ const suffix = inlineCursorSuffix(cursorPos ?? null, next, termRows)
1045
+ updateInlineCursorRow(state, cursorPos, maxOutputLines, startLine)
1046
+ return suffix
1047
+ }
1048
+
1049
+ // Move cursor from tracked row to row 0 of render region
1050
+ let output = ""
1051
+ if (state.prevCursorRow > 0) {
1052
+ output += `\x1b[${state.prevCursorRow}A`
1053
+ }
1054
+ output += "\r"
1055
+ output += "\x1b[?25l" // hide cursor during update
1056
+
1057
+ // Emit changes with relative positioning.
1058
+ // Use the larger of prev/next output lines so changesToAnsi processes all
1059
+ // visible cells including rows that shrank (need clearing) or grew (need writing).
1060
+ const effectiveOutputLines = Math.max(prevMaxOutputLines, maxOutputLines)
1061
+ const changes = changesToAnsi(pool, count, "inline", ctx, next, startLine, effectiveOutputLines)
1062
+ output += changes.output
1063
+
1064
+ // After changesToAnsi, cursor is at changes.finalY (render-relative).
1065
+ // We need to position cursor at the effective bottom row, then handle
1066
+ // growth/shrinkage, and end at (maxOutputLines - 1) for inlineCursorSuffix.
1067
+ const finalY = changes.finalY
1068
+ const prevBottomRow = prevMaxOutputLines - 1
1069
+ const bottomRow = maxOutputLines - 1
1070
+
1071
+ if (maxOutputLines > prevMaxOutputLines) {
1072
+ // Content grew: rows beyond the previous bottom don't exist on the terminal.
1073
+ // CUD (\x1b[nB) is clamped at the terminal edge and won't create new lines.
1074
+ // Use \r\n for each new row to ensure the terminal extends naturally.
1075
+ //
1076
+ // changesToAnsi may have already moved past prevBottomRow using \r\n
1077
+ // (creating new terminal lines in the process). Only add \r\n for
1078
+ // rows beyond what changesToAnsi already reached.
1079
+ const fromRow = finalY >= 0 ? finalY : 0
1080
+ if (fromRow >= bottomRow) {
1081
+ // changesToAnsi already reached or passed the new bottom — nothing to do
1082
+ } else if (fromRow >= prevBottomRow) {
1083
+ // Cursor already past old bottom (changesToAnsi extended the terminal).
1084
+ // Only need \r\n for remaining rows.
1085
+ const remainingRows = bottomRow - fromRow
1086
+ for (let i = 0; i < remainingRows; i++) {
1087
+ output += "\r\n"
1088
+ }
1089
+ } else {
1090
+ // Cursor is still within the old content area.
1091
+ // First, move to the old bottom row using CUD (safe, rows exist on terminal)
1092
+ if (fromRow < prevBottomRow) {
1093
+ const dy = prevBottomRow - fromRow
1094
+ output += dy === 1 ? "\r\n" : `\r\x1b[${dy}B`
1095
+ }
1096
+ // Then extend to new bottom with \r\n for each new row
1097
+ const newRows = bottomRow - prevBottomRow
1098
+ for (let i = 0; i < newRows; i++) {
1099
+ output += "\r\n"
1100
+ }
1101
+ }
1102
+ } else if (maxOutputLines < prevMaxOutputLines) {
1103
+ // Content shrank: erase orphan lines below the new content.
1104
+ // changesToAnsi already wrote empty cells to clear the old content,
1105
+ // but we need \x1b[K to clear any residual characters.
1106
+ // The cursor is at finalY after changesToAnsi — move to the new bottom first.
1107
+ const fromRow = finalY >= 0 ? finalY : 0
1108
+ if (fromRow < bottomRow) {
1109
+ const dy = bottomRow - fromRow
1110
+ output += dy === 1 ? "\r\n" : `\r\x1b[${dy}B`
1111
+ } else if (fromRow > bottomRow) {
1112
+ // Cursor is past the new bottom (was erasing orphan rows) — move back up
1113
+ output += `\x1b[${fromRow - bottomRow}A`
1114
+ }
1115
+ // Now at new bottom row (bottomRow). Erase orphan lines below by moving
1116
+ // down one line at a time, erasing each. This leaves cursor at the last
1117
+ // orphan row (prevMaxOutputLines - 1).
1118
+ const orphanCount = prevMaxOutputLines - maxOutputLines
1119
+ for (let y = 0; y < orphanCount; y++) {
1120
+ output += "\n\r\x1b[K"
1121
+ }
1122
+ // Move back up from last orphan row to new bottom row
1123
+ if (orphanCount > 0) output += `\x1b[${orphanCount}A`
1124
+ } else {
1125
+ // Same height: move to bottom row if not already there
1126
+ if (finalY >= 0 && finalY < bottomRow) {
1127
+ const dy = bottomRow - finalY
1128
+ output += dy === 1 ? "\r\n" : `\r\x1b[${dy}B`
1129
+ }
1130
+ }
1131
+
1132
+ output += inlineCursorSuffix(cursorPos ?? null, next, termRows)
1133
+
1134
+ // Update tracking
1135
+ updateInlineCursorRow(state, cursorPos, maxOutputLines, startLine)
1136
+
1137
+ return output
1138
+ }
1139
+
1140
+ /**
1141
+ * Full re-render for inline mode.
1142
+ *
1143
+ * Moves cursor to the start of the render region, writes the entire
1144
+ * buffer fresh, and erases any leftover lines from the previous render.
1145
+ *
1146
+ * When content exceeds terminal height, output is capped to termRows lines.
1147
+ * Lines beyond the terminal can't be managed (cursor-up is clamped at row 0),
1148
+ * so we truncate to prevent scrollback corruption.
1149
+ */
1150
+ function inlineFullRender(
1151
+ state: InlineCursorState,
1152
+ prev: TerminalBuffer,
1153
+ next: TerminalBuffer,
1154
+ scrollbackOffset: number,
1155
+ termRows?: number,
1156
+ cursorPos?: CursorState | null,
1157
+ ctx: OutputContext = defaultContext,
1158
+ ): string {
1159
+ const nextContentLines = findLastContentLine(next) + 1
1160
+
1161
+ // Use tracked state from the previous frame for cursor position and output height.
1162
+ // state.prevCursorRow tracks where the terminal cursor actually is (row within render
1163
+ // region), and state.prevOutputLines tracks how many lines the previous frame rendered.
1164
+ // Re-deriving from the prev buffer can disagree (e.g., when cursor was positioned at a
1165
+ // visible row via useCursor, not at the bottom), causing the cursor-up to overshoot
1166
+ // and leaving orphan lines below the content ("inline-bleed").
1167
+ // Fall back to prev-buffer derivation only when cursor tracking is uninitialized.
1168
+ let prevOutputLines: number
1169
+ let cursorRowInRegion: number
1170
+ if (state.prevCursorRow >= 0) {
1171
+ prevOutputLines = state.prevOutputLines
1172
+ cursorRowInRegion = state.prevCursorRow
1173
+ } else {
1174
+ const prevContentLines = findLastContentLine(prev) + 1
1175
+ prevOutputLines = termRows != null ? Math.min(prevContentLines, termRows) : prevContentLines
1176
+ cursorRowInRegion = prevOutputLines - 1
1177
+ }
1178
+
1179
+ // How far the cursor is below the start of the render region:
1180
+ // tracked cursor row + any lines written to stdout between renders.
1181
+ // Cap to termRows-1: terminal clamps cursor-up at row 0.
1182
+ const rawCursorOffset = cursorRowInRegion + scrollbackOffset
1183
+ const cursorOffset = termRows != null && !isStrictOutput() ? Math.min(rawCursorOffset, termRows - 1) : rawCursorOffset
1184
+
1185
+ // Cap output at terminal height to prevent scrollback corruption.
1186
+ // Content taller than the terminal pushes lines into scrollback where
1187
+ // they can never be overwritten (cursor-up is clamped at terminal row 0).
1188
+ const maxOutputLines = termRows != null ? Math.min(nextContentLines, termRows) : nextContentLines
1189
+
1190
+ // Quick check: if nothing changed and no scrollback displacement, skip
1191
+ if (scrollbackOffset === 0) {
1192
+ const { count } = diffBuffers(prev, next)
1193
+ if (count === 0) return ""
1194
+ }
1195
+
1196
+ // Move cursor up to the start of the render region
1197
+ let prefix = ""
1198
+ if (cursorOffset > 0) {
1199
+ prefix = `\x1b[${cursorOffset}A\r`
1200
+ }
1201
+ // bufferToAnsi handles: hide cursor, render content lines with
1202
+ // \x1b[K (clear to EOL) on each line, and reset style at end.
1203
+ let output = prefix + bufferToAnsi(next, "inline", ctx, maxOutputLines)
1204
+
1205
+ // Erase leftover lines if visible area shrank.
1206
+ // Account for terminal scroll: when useScrollback writes frozen items and the
1207
+ // cursor overflows the terminal, the terminal scrolls up. The scroll amount
1208
+ // equals how far rawCursorOffset exceeds termRows-1 (the terminal's last row).
1209
+ // That many old render lines were pushed into scrollback and no longer need
1210
+ // erasing. The remaining visible old render lines end at lastOccupiedLine.
1211
+ // Lines beyond that contain frozen items — those must NOT be erased.
1212
+ // Note: computed from terminal geometry, independent of strict-output cursor bypass.
1213
+ const terminalScroll = termRows != null ? Math.max(0, rawCursorOffset - (termRows - 1)) : 0
1214
+ const lastOccupiedLine = Math.max(prevOutputLines - 1 - terminalScroll, 0)
1215
+ const nextLastLine = maxOutputLines - 1
1216
+ if (lastOccupiedLine > nextLastLine) {
1217
+ for (let y = nextLastLine + 1; y <= lastOccupiedLine; y++) {
1218
+ output += "\n\r\x1b[K"
1219
+ }
1220
+ const up = lastOccupiedLine - nextLastLine
1221
+ if (up > 0) output += `\x1b[${up}A`
1222
+ }
1223
+
1224
+ // Position the real terminal cursor and show it.
1225
+ // If a component called useCursor(), place the cursor there.
1226
+ // Otherwise, just show it at the current position (end of content).
1227
+ output += inlineCursorSuffix(cursorPos ?? null, next, termRows)
1228
+
1229
+ // Update cursor tracking for incremental rendering on next frame
1230
+ let startLine = 0
1231
+ if (termRows != null && nextContentLines > termRows) startLine = nextContentLines - termRows
1232
+ updateInlineCursorRow(state, cursorPos, maxOutputLines, startLine)
1233
+
1234
+ return output
1235
+ }
1236
+
1237
+ /**
1238
+ * Convert entire buffer to ANSI string.
1239
+ *
1240
+ * @param maxRows Optional cap on number of rows to output (inline mode).
1241
+ * When content exceeds terminal height, this prevents scrollback corruption.
1242
+ */
1243
+ function bufferToAnsi(
1244
+ buffer: TerminalBuffer,
1245
+ mode: "fullscreen" | "inline" = "fullscreen",
1246
+ ctx: OutputContext = defaultContext,
1247
+ maxRows?: number,
1248
+ ): string {
1249
+ let output = ""
1250
+ let currentStyle: Style | null = null
1251
+ let currentHyperlink: string | undefined
1252
+
1253
+ // Cap output to prevent rendering beyond terminal height.
1254
+ // Inline mode: render up to last content line; if taller than terminal, show bottom
1255
+ // (footer and latest content stay visible in scrollback).
1256
+ // Fullscreen mode: always start from top; cap at terminal height to prevent scroll
1257
+ // that desynchronizes prevBuffer from actual terminal state.
1258
+ let maxLine = mode === "inline" ? findLastContentLine(buffer) : buffer.height - 1
1259
+ let startLine = 0
1260
+ if (maxRows != null && maxLine >= maxRows) {
1261
+ if (mode === "fullscreen") {
1262
+ maxLine = maxRows - 1 // cap at terminal height, always from top
1263
+ } else {
1264
+ startLine = maxLine - maxRows + 1 // show bottom of content
1265
+ }
1266
+ }
1267
+
1268
+ // Move cursor to start position based on mode
1269
+ if (mode === "fullscreen") {
1270
+ // Fullscreen: Move cursor to home position (top-left)
1271
+ output += "\x1b[H"
1272
+ } else {
1273
+ // Inline: Hide cursor, start from current position
1274
+ output += "\x1b[?25l"
1275
+ }
1276
+
1277
+ // Reusable objects to avoid per-cell allocation in the inner loop
1278
+ const cell = createMutableCell()
1279
+ const cellStyle: Style = {
1280
+ fg: null,
1281
+ bg: null,
1282
+ underlineColor: null,
1283
+ attrs: {},
1284
+ }
1285
+
1286
+ for (let y = startLine; y <= maxLine; y++) {
1287
+ // Move to start of line
1288
+ if (y > startLine || mode === "inline") {
1289
+ output += "\r"
1290
+ }
1291
+
1292
+ // Render the line content
1293
+ for (let x = 0; x < buffer.width; x++) {
1294
+ buffer.readCellInto(x, y, cell)
1295
+
1296
+ // No continuation skip here. Valid continuation cells are never reached
1297
+ // because `if (cell.wide) x++` below jumps past them. Orphaned
1298
+ // continuation cells (wide char overwritten by region clear) must NOT
1299
+ // be skipped — they write a space to keep the VT cursor in sync.
1300
+
1301
+ // Handle OSC 8 hyperlink transitions (separate from SGR style)
1302
+ const cellHyperlink = cell.hyperlink
1303
+ if (cellHyperlink !== currentHyperlink) {
1304
+ if (currentHyperlink) {
1305
+ output += "\x1b]8;;\x1b\\" // Close previous hyperlink
1306
+ }
1307
+ if (cellHyperlink) {
1308
+ output += `\x1b]8;;${cellHyperlink}\x1b\\` // Open new hyperlink
1309
+ }
1310
+ currentHyperlink = cellHyperlink
1311
+ }
1312
+
1313
+ // Build style from cell and check if changed.
1314
+ // readCellInto mutates cell.attrs in place, so we must snapshot attrs
1315
+ // only when the style actually changes (which is rare -- most adjacent
1316
+ // cells share the same style). This avoids per-cell object allocation.
1317
+ cellStyle.fg = cell.fg
1318
+ cellStyle.bg = cell.bg
1319
+ cellStyle.underlineColor = cell.underlineColor
1320
+ cellStyle.attrs = cell.attrs
1321
+ if (!styleEquals(currentStyle, cellStyle)) {
1322
+ // Snapshot: copy attrs so currentStyle isn't invalidated by next readCellInto
1323
+ const saved: Style = {
1324
+ fg: cell.fg,
1325
+ bg: cell.bg,
1326
+ underlineColor: cell.underlineColor,
1327
+ attrs: { ...cell.attrs },
1328
+ }
1329
+ output += styleTransition(currentStyle, saved, ctx)
1330
+ currentStyle = saved
1331
+ }
1332
+
1333
+ // Write character — empty-string chars are treated as space to ensure
1334
+ // the terminal cursor advances (an empty string writes nothing, causing
1335
+ // all subsequent characters on the row to shift left by one column).
1336
+ const char = cell.char || " "
1337
+ output += wrapTextSizing(char, cell.wide, ctx)
1338
+
1339
+ // Wide characters occupy 2 columns in the terminal. Skip the next cell
1340
+ // position since the terminal cursor already advanced by 2. We skip
1341
+ // unconditionally (not relying on the next cell's continuation flag)
1342
+ // because the buffer may have a corrupted continuation cell — e.g., when
1343
+ // an adjacent container's region clear overwrites the continuation.
1344
+ // Without this, the non-continuation cell at x+1 would also be written,
1345
+ // causing every subsequent character on the row to shift right by 1.
1346
+ if (cell.wide) {
1347
+ x++
1348
+ // Cursor re-sync: some terminals treat multi-codepoint wide chars
1349
+ // (flag emoji like 🇨🇦) as two width-1 chars instead of one width-2
1350
+ // char. This causes the terminal cursor to be 1 column behind where
1351
+ // we expect. Emit an explicit cursor position to re-sync, mirroring
1352
+ // the re-sync in changesToAnsi. Cost: ~8 bytes per wide char; wide
1353
+ // chars are rare so overhead is negligible.
1354
+ // After x++, x points to the continuation cell. The next character
1355
+ // to write is at x+1 (after the loop increment), so position the
1356
+ // cursor at 0-indexed column x+1 = 1-indexed column x+2.
1357
+ if (mode === "fullscreen") {
1358
+ output += `\x1b[${y - startLine + 1};${x + 2}H`
1359
+ } else {
1360
+ // Inline: \r resets to column 0, CUF moves to expected position.
1361
+ // Reset bg first to prevent bleed across traversed cells.
1362
+ if (currentStyle && (currentStyle.bg !== null || hasActiveAttrs(currentStyle.attrs))) {
1363
+ output += "\x1b[0m"
1364
+ currentStyle = null
1365
+ }
1366
+ const nextCol = x + 1
1367
+ output += "\r"
1368
+ if (nextCol > 0) output += nextCol === 1 ? "\x1b[C" : `\x1b[${nextCol}C`
1369
+ }
1370
+ }
1371
+ }
1372
+
1373
+ // Close any open hyperlink at end of row
1374
+ if (currentHyperlink) {
1375
+ output += "\x1b]8;;\x1b\\"
1376
+ currentHyperlink = undefined
1377
+ }
1378
+
1379
+ // Reset style before clear-to-end and newline to prevent background
1380
+ // color from filling the right margin or bleeding into the next line.
1381
+ // \x1b[K] uses current SGR attributes for the erased area.
1382
+ if (currentStyle && (currentStyle.bg !== null || hasActiveAttrs(currentStyle.attrs))) {
1383
+ output += "\x1b[0m"
1384
+ currentStyle = null
1385
+ }
1386
+ // Clear to end of line (removes any leftover content from previous render)
1387
+ output += "\x1b[K"
1388
+
1389
+ // Move to next line (except for last line)
1390
+ if (y < maxLine) {
1391
+ // In inline mode, use \r\n instead of bare \n to cancel DECAWM
1392
+ // pending-wrap state. When the line fills exactly `cols` characters,
1393
+ // the cursor enters pending-wrap at position cols. A bare \n in that
1394
+ // state causes a double line advance in some terminals (Ghostty, iTerm2).
1395
+ // The \r first moves to column 0 (canceling pending-wrap), then \n
1396
+ // advances one row cleanly.
1397
+ output += mode === "inline" ? "\r\n" : "\n"
1398
+ }
1399
+ }
1400
+
1401
+ // Close any open hyperlink at end
1402
+ if (currentHyperlink) {
1403
+ output += "\x1b]8;;\x1b\\"
1404
+ }
1405
+
1406
+ // Reset style at end
1407
+ output += "\x1b[0m"
1408
+
1409
+ return output
1410
+ }
1411
+
1412
+ // ============================================================================
1413
+ // Pre-allocated diff pool
1414
+ // ============================================================================
1415
+
1416
+ /**
1417
+ * Create a fresh CellChange with empty cell data.
1418
+ * Used to populate the pre-allocated pool.
1419
+ */
1420
+ function createEmptyCellChange(): CellChange {
1421
+ return {
1422
+ x: 0,
1423
+ y: 0,
1424
+ cell: {
1425
+ char: " ",
1426
+ fg: null,
1427
+ bg: null,
1428
+ underlineColor: null,
1429
+ attrs: {},
1430
+ wide: false,
1431
+ continuation: false,
1432
+ },
1433
+ }
1434
+ }
1435
+
1436
+ /** Pre-allocated pool of CellChange objects, reused across frames. */
1437
+ const diffPool: CellChange[] = []
1438
+
1439
+ /** Current pool capacity. */
1440
+ let diffPoolCapacity = 0
1441
+
1442
+ /**
1443
+ * Ensure the diff pool has at least `capacity` entries.
1444
+ * Grows the pool if needed; never shrinks.
1445
+ */
1446
+ function ensureDiffPoolCapacity(capacity: number): void {
1447
+ if (capacity <= diffPoolCapacity) return
1448
+ for (let i = diffPoolCapacity; i < capacity; i++) {
1449
+ diffPool.push(createEmptyCellChange())
1450
+ }
1451
+ diffPoolCapacity = capacity
1452
+ }
1453
+
1454
+ /**
1455
+ * Write cell data from a buffer into a pre-allocated CellChange entry.
1456
+ * Uses readCellInto for zero-allocation reads.
1457
+ */
1458
+ function writeCellChange(change: CellChange, x: number, y: number, buffer: TerminalBuffer): void {
1459
+ change.x = x
1460
+ change.y = y
1461
+ buffer.readCellInto(x, y, change.cell)
1462
+ }
1463
+
1464
+ /**
1465
+ * Write empty cell data into a pre-allocated CellChange entry.
1466
+ * Used for shrink regions where cells need to be cleared.
1467
+ */
1468
+ function writeEmptyCellChange(change: CellChange, x: number, y: number): void {
1469
+ change.x = x
1470
+ change.y = y
1471
+ const cell = change.cell
1472
+ cell.char = " "
1473
+ cell.fg = null
1474
+ cell.bg = null
1475
+ cell.underlineColor = null
1476
+ // Reset attrs fields
1477
+ const attrs = cell.attrs
1478
+ attrs.bold = undefined
1479
+ attrs.dim = undefined
1480
+ attrs.italic = undefined
1481
+ attrs.underline = undefined
1482
+ attrs.underlineStyle = undefined
1483
+ attrs.blink = undefined
1484
+ attrs.inverse = undefined
1485
+ attrs.hidden = undefined
1486
+ attrs.strikethrough = undefined
1487
+ cell.wide = false
1488
+ cell.continuation = false
1489
+ }
1490
+
1491
+ /**
1492
+ * Diff result: pool reference + count (avoids per-frame array allocation).
1493
+ */
1494
+ interface DiffResult {
1495
+ pool: CellChange[]
1496
+ count: number
1497
+ }
1498
+
1499
+ /** Reusable diff result object (avoids allocating a new one per frame). */
1500
+ const diffResult: DiffResult = { pool: diffPool, count: 0 }
1501
+
1502
+ /**
1503
+ * Diff two buffers and return changes via pre-allocated pool.
1504
+ *
1505
+ * Optimization: Uses a pre-allocated pool of CellChange objects to avoid
1506
+ * allocating new objects per changed cell. Uses readCellInto for
1507
+ * zero-allocation cell reads. The pool grows as needed but is reused
1508
+ * between frames. Returns a pool+count pair instead of slicing the array.
1509
+ */
1510
+ function diffBuffers(prev: TerminalBuffer, next: TerminalBuffer): DiffResult {
1511
+ // Ensure pool is large enough for worst case (all cells changed).
1512
+ // Wide→narrow transitions emit an extra change for the continuation cell,
1513
+ // so worst case is 1.5x (every other cell could be a wide→narrow transition).
1514
+ const cells = Math.max(prev.width, next.width) * Math.max(prev.height, next.height)
1515
+ const maxChanges = cells + (cells >> 1) // 1.5x
1516
+ ensureDiffPoolCapacity(maxChanges)
1517
+
1518
+ let changeCount = 0
1519
+
1520
+ // Dimension mismatch means we need to re-render everything visible
1521
+ const height = Math.min(prev.height, next.height)
1522
+ const width = Math.min(prev.width, next.width)
1523
+
1524
+ // Use dirty row bounding box to narrow the scan range.
1525
+ // If no rows are dirty, minDirtyRow is -1 and the loop body is skipped.
1526
+ const startRow = next.minDirtyRow === -1 ? 0 : next.minDirtyRow
1527
+ const endRow = next.maxDirtyRow === -1 ? -1 : Math.min(next.maxDirtyRow, height - 1)
1528
+
1529
+ for (let y = startRow; y <= endRow; y++) {
1530
+ // Skip individual clean rows within the bounding box
1531
+ if (!next.isRowDirty(y)) continue
1532
+
1533
+ // Fast row-level pre-check: if all packed metadata, chars, AND Map-based
1534
+ // extras (true colors, underline colors, hyperlinks) match, skip per-cell
1535
+ // comparison entirely. This catches rows marked dirty by fill() or
1536
+ // scrollRegion() that didn't actually change content.
1537
+ // NOTE: rowExtrasEquals is essential — rowMetadataEquals only checks packed
1538
+ // flags (e.g., "has true color fg"), not the actual RGB values in the Maps.
1539
+ if (next.rowMetadataEquals(y, prev) && next.rowCharsEquals(y, prev) && next.rowExtrasEquals(y, prev)) continue
1540
+
1541
+ for (let x = 0; x < width; x++) {
1542
+ // Use buffer's optimized cellEquals which compares packed metadata first
1543
+ if (!next.cellEquals(x, y, prev)) {
1544
+ writeCellChange(diffPool[changeCount]!, x, y, next)
1545
+ changeCount++
1546
+
1547
+ // Wide char transition: when prev had a wide char and next doesn't,
1548
+ // we must also emit the continuation position (x+1) as a change.
1549
+ // The terminal's state at x+1 contains the second half of the wide
1550
+ // char, but the buffer may show x+1 as "unchanged" (both prev and
1551
+ // next are ' '). Without this explicit change, changesToAnsi skips
1552
+ // x+1 and the terminal retains the wide char remnant, causing
1553
+ // cursor drift.
1554
+ if (x + 1 < width && prev.isCellWide(x, y) && !next.isCellWide(x, y)) {
1555
+ writeCellChange(diffPool[changeCount]!, x + 1, y, next)
1556
+ changeCount++
1557
+ }
1558
+ }
1559
+ }
1560
+ }
1561
+
1562
+ // Handle size growth: add all cells in new areas.
1563
+ // Width growth covers the right strip (x >= prev.width) for ALL rows.
1564
+ // Height growth covers the bottom strip (y >= prev.height) but only up to
1565
+ // prev.width to avoid double-counting the corner with width growth.
1566
+ const widthGrew = next.width > prev.width
1567
+ if (widthGrew) {
1568
+ for (let y = 0; y < next.height; y++) {
1569
+ for (let x = prev.width; x < next.width; x++) {
1570
+ writeCellChange(diffPool[changeCount]!, x, y, next)
1571
+ changeCount++
1572
+ }
1573
+ }
1574
+ }
1575
+ if (next.height > prev.height) {
1576
+ // When width also grew, only iterate x=0..prev.width (the rest was
1577
+ // already covered by width growth above). Otherwise iterate full width.
1578
+ const xEnd = widthGrew ? prev.width : next.width
1579
+ for (let y = prev.height; y < next.height; y++) {
1580
+ for (let x = 0; x < xEnd; x++) {
1581
+ writeCellChange(diffPool[changeCount]!, x, y, next)
1582
+ changeCount++
1583
+ }
1584
+ }
1585
+ }
1586
+
1587
+ // Handle size shrink: clear cells in old-but-not-new areas.
1588
+ // Width shrink covers x >= next.width for the shared height.
1589
+ // Height shrink covers y >= next.height but only up to next.width when
1590
+ // width also shrank, to avoid double-counting the corner.
1591
+ const widthShrank = prev.width > next.width
1592
+ if (widthShrank) {
1593
+ for (let y = 0; y < height; y++) {
1594
+ for (let x = next.width; x < prev.width; x++) {
1595
+ writeEmptyCellChange(diffPool[changeCount]!, x, y)
1596
+ changeCount++
1597
+ }
1598
+ }
1599
+ }
1600
+ if (prev.height > next.height) {
1601
+ // When width also shrank, the corner (x >= next.width, y >= next.height)
1602
+ // was NOT covered by width shrink (which only iterates y < height =
1603
+ // min(prev.height, next.height) = next.height). So iterate full prev.width.
1604
+ for (let y = next.height; y < prev.height; y++) {
1605
+ for (let x = 0; x < prev.width; x++) {
1606
+ writeEmptyCellChange(diffPool[changeCount]!, x, y)
1607
+ changeCount++
1608
+ }
1609
+ }
1610
+ }
1611
+
1612
+ if (changeCount > maxChanges) {
1613
+ throw new Error(
1614
+ `diffBuffers: changeCount ${changeCount} exceeds pool capacity ${maxChanges} ` +
1615
+ `(prev ${prev.width}x${prev.height}, next ${next.width}x${next.height})`,
1616
+ )
1617
+ }
1618
+
1619
+ diffResult.pool = diffPool
1620
+ diffResult.count = changeCount
1621
+ return diffResult
1622
+ }
1623
+
1624
+ /** Result from changesToAnsi: ANSI output string and final cursor position. */
1625
+ interface ChangesResult {
1626
+ output: string
1627
+ /** Final render-relative cursor Y after emitting changes (-1 if no changes emitted). */
1628
+ finalY: number
1629
+ }
1630
+
1631
+ /** Pre-allocated style object reused across changesToAnsi calls. */
1632
+ const reusableCellStyle: Style = {
1633
+ fg: null,
1634
+ bg: null,
1635
+ underlineColor: null,
1636
+ attrs: {},
1637
+ }
1638
+
1639
+ /**
1640
+ * Pre-allocated cell for looking up wide char main cells from the buffer
1641
+ * when an orphaned continuation cell is encountered in changesToAnsi.
1642
+ */
1643
+ const wideCharLookupCell = createMutableCell()
1644
+
1645
+ /**
1646
+ * Sort a sub-range of the pool by position for optimal cursor movement.
1647
+ * Uses a simple in-place sort on pool[0..count).
1648
+ */
1649
+ function sortPoolByPosition(pool: CellChange[], count: number): void {
1650
+ // Insertion sort is efficient for the typical case (mostly sorted or small count)
1651
+ for (let i = 1; i < count; i++) {
1652
+ const item = pool[i]!
1653
+ const iy = item.y
1654
+ const ix = item.x
1655
+ let j = i - 1
1656
+ while (j >= 0 && (pool[j]!.y > iy || (pool[j]!.y === iy && pool[j]!.x > ix))) {
1657
+ pool[j + 1] = pool[j]!
1658
+ j--
1659
+ }
1660
+ pool[j + 1] = item
1661
+ }
1662
+ }
1663
+
1664
+ /**
1665
+ * Convert cell changes to optimized ANSI output.
1666
+ *
1667
+ * Wide characters are handled atomically: the main cell (wide:true) and its
1668
+ * continuation cell are treated as a single unit. When the main cell is in
1669
+ * the pool, it's emitted and the cursor advances by 2. When only the
1670
+ * continuation cell changed (e.g., bg color), the main cell is read from
1671
+ * the buffer and emitted to cover both columns.
1672
+ *
1673
+ * @param pool Pre-allocated pool of CellChange objects
1674
+ * @param count Number of valid entries in the pool
1675
+ * @param mode Render mode: "fullscreen" uses absolute positioning,
1676
+ * "inline" uses relative cursor movement
1677
+ * @param buffer The current buffer, used to look up main cells for orphaned
1678
+ * continuation cells (optional for backward compatibility)
1679
+ * @param startLine For inline mode: first visible buffer row (for termRows capping)
1680
+ * @param maxOutputLines For inline mode: number of visible rows
1681
+ */
1682
+ function changesToAnsi(
1683
+ pool: CellChange[],
1684
+ count: number,
1685
+ mode: "fullscreen" | "inline" = "fullscreen",
1686
+ ctx: OutputContext = defaultContext,
1687
+ buffer?: TerminalBuffer,
1688
+ startLine = 0,
1689
+ maxOutputLines = Infinity,
1690
+ ): ChangesResult {
1691
+ if (count === 0) return { output: "", finalY: -1 }
1692
+
1693
+ // Sort by position for optimal cursor movement (in-place, no allocation)
1694
+ sortPoolByPosition(pool, count)
1695
+
1696
+ let output = ""
1697
+ let currentStyle: Style | null = null
1698
+ let currentHyperlink: string | undefined
1699
+ const isInline = mode === "inline"
1700
+ const endLine = startLine + maxOutputLines // exclusive upper bound for inline filtering
1701
+ let finalY = -1
1702
+ let cursorX = -1
1703
+ let cursorY = -1
1704
+ let prevY = -1
1705
+ // Track the last emitted cell position to detect when a continuation
1706
+ // cell's main cell was already emitted in this pass.
1707
+ let lastEmittedX = -1
1708
+ let lastEmittedY = -1
1709
+
1710
+ for (let i = 0; i < count; i++) {
1711
+ const change = pool[i]!
1712
+ let x = change.x
1713
+ const y = change.y
1714
+ let cell = change.cell
1715
+
1716
+ // In inline mode, skip changes outside the visible range
1717
+ if (isInline && (y < startLine || y >= endLine)) continue
1718
+
1719
+ // Handle continuation cells: these are the second column of a wide
1720
+ // character. If their main cell (x-1) was already emitted in this
1721
+ // pass, skip. Otherwise, look up and emit the main cell from the
1722
+ // buffer so the wide char covers both columns.
1723
+ if (cell.continuation) {
1724
+ // Main cell was already emitted — skip
1725
+ if (lastEmittedX === x - 1 && lastEmittedY === y) continue
1726
+
1727
+ // Orphaned continuation cell: main cell didn't change but this
1728
+ // cell's style did. Read the main cell from the buffer and emit it.
1729
+ if (buffer && x > 0) {
1730
+ x = x - 1
1731
+ buffer.readCellInto(x, y, wideCharLookupCell)
1732
+ cell = wideCharLookupCell
1733
+ // If the looked-up cell is itself a continuation (shouldn't happen
1734
+ // with valid buffers) or not wide, fall back to skipping
1735
+ if (cell.continuation || !cell.wide) continue
1736
+ } else {
1737
+ continue
1738
+ }
1739
+ }
1740
+
1741
+ // For inline mode, use render-region-relative row indices
1742
+ const renderY = isInline ? y - startLine : y
1743
+
1744
+ // Close hyperlink on row change (hyperlinks must not span across rows)
1745
+ if (y !== prevY && currentHyperlink) {
1746
+ output += "\x1b]8;;\x1b\\"
1747
+ currentHyperlink = undefined
1748
+ }
1749
+ prevY = y
1750
+
1751
+ // Move cursor if needed (cursor must be exactly at target position)
1752
+ if (renderY !== cursorY || x !== cursorX) {
1753
+ // Use \r\n optimization only if cursor is initialized AND we're moving
1754
+ // to the next line at column 0. Don't use it when cursorY is -1
1755
+ // (uninitialized) because that would incorrectly emit a newline at start.
1756
+ // Bug km-x7ih: This was causing the first row to appear at the bottom.
1757
+ if (cursorY >= 0 && renderY === cursorY + 1 && x === 0) {
1758
+ // Next line at column 0, use newline (more efficient)
1759
+ // Reset style before newline to prevent background color bleeding
1760
+ if (currentStyle && (currentStyle.bg !== null || hasActiveAttrs(currentStyle.attrs))) {
1761
+ output += "\x1b[0m"
1762
+ currentStyle = null
1763
+ }
1764
+ output += "\r\n"
1765
+ } else if (cursorY >= 0 && renderY === cursorY && x > cursorX) {
1766
+ // Same row, forward: use CUF (Cursor Forward) for small jumps.
1767
+ // Reset bg before CUF to prevent background color bleeding into
1768
+ // skipped cells. Some terminals (e.g., Ghostty) may apply the
1769
+ // current bg to cells traversed by CUF, causing visual artifacts
1770
+ // when bg transitions from undefined→color (km-tui.col-header-dup).
1771
+ if (currentStyle && currentStyle.bg !== null) {
1772
+ output += "\x1b[0m"
1773
+ currentStyle = null
1774
+ }
1775
+ const dx = x - cursorX
1776
+ output += dx === 1 ? "\x1b[C" : `\x1b[${dx}C`
1777
+ } else if (cursorY >= 0 && renderY > cursorY && x === 0) {
1778
+ // Same column (0), down N rows: use \r + CUD
1779
+ const dy = renderY - cursorY
1780
+ if (currentStyle && (currentStyle.bg !== null || hasActiveAttrs(currentStyle.attrs))) {
1781
+ output += "\x1b[0m"
1782
+ currentStyle = null
1783
+ }
1784
+ output += dy === 1 ? "\r\n" : `\r\x1b[${dy}B`
1785
+ } else if (isInline) {
1786
+ // Inline mode: relative positioning (no absolute row numbers)
1787
+ if (currentStyle && (currentStyle.bg !== null || hasActiveAttrs(currentStyle.attrs))) {
1788
+ output += "\x1b[0m"
1789
+ currentStyle = null
1790
+ }
1791
+ // When cursorY === -1 (first change in incremental render),
1792
+ // the cursor is at row 0 (set by inlineIncrementalRender prefix).
1793
+ const fromRow = cursorY >= 0 ? cursorY : 0
1794
+ if (renderY > fromRow) {
1795
+ output += `\x1b[${renderY - fromRow}B\r`
1796
+ } else if (renderY < fromRow) {
1797
+ output += `\x1b[${fromRow - renderY}A\r`
1798
+ } else {
1799
+ output += "\r"
1800
+ }
1801
+ if (x > 0) output += x === 1 ? "\x1b[C" : `\x1b[${x}C`
1802
+ } else {
1803
+ // Fullscreen: absolute position (1-indexed)
1804
+ output += `\x1b[${renderY + 1};${x + 1}H`
1805
+ }
1806
+ }
1807
+
1808
+ // Handle OSC 8 hyperlink transitions (separate from SGR style)
1809
+ const cellHyperlink = cell.hyperlink
1810
+ if (cellHyperlink !== currentHyperlink) {
1811
+ if (currentHyperlink) {
1812
+ output += "\x1b]8;;\x1b\\" // Close previous hyperlink
1813
+ }
1814
+ if (cellHyperlink) {
1815
+ output += `\x1b]8;;${cellHyperlink}\x1b\\` // Open new hyperlink
1816
+ }
1817
+ currentHyperlink = cellHyperlink
1818
+ }
1819
+
1820
+ // Update style if changed (reuse pre-allocated style object)
1821
+ reusableCellStyle.fg = cell.fg
1822
+ reusableCellStyle.bg = cell.bg
1823
+ reusableCellStyle.underlineColor = cell.underlineColor
1824
+ reusableCellStyle.attrs = cell.attrs
1825
+ if (!styleEquals(currentStyle, reusableCellStyle)) {
1826
+ // Snapshot: copy attrs so currentStyle isn't invalidated by next iteration
1827
+ const prevStyle = currentStyle
1828
+ currentStyle = {
1829
+ fg: cell.fg,
1830
+ bg: cell.bg,
1831
+ underlineColor: cell.underlineColor,
1832
+ attrs: { ...cell.attrs },
1833
+ }
1834
+ output += styleTransition(prevStyle, currentStyle, ctx)
1835
+ }
1836
+
1837
+ // Write character — empty-string chars are treated as space to ensure
1838
+ // the terminal cursor advances and cursorX tracking stays correct.
1839
+ const char = cell.char || " "
1840
+ output += wrapTextSizing(char, cell.wide, ctx)
1841
+ cursorX = x + (cell.wide ? 2 : 1)
1842
+ cursorY = renderY
1843
+ lastEmittedX = x
1844
+ lastEmittedY = y
1845
+
1846
+ // Wide char cursor re-sync: terminals may advance the cursor by 1
1847
+ // instead of 2 for certain emoji (flag sequences, text-presentation
1848
+ // emoji without OSC 66 support). In bufferToAnsi (full render) this
1849
+ // only causes a consistent per-row shift since every cell is written
1850
+ // sequentially. But in changesToAnsi, contiguous runs rely on cursor
1851
+ // auto-advance, so a width mismatch causes progressive drift —
1852
+ // characters appear at wrong positions, mixing old and new content.
1853
+ // Fix: emit an explicit cursor position after each wide char to
1854
+ // re-sync the terminal cursor with our tracking. Cost: ~8 bytes per
1855
+ // wide char (CUP in fullscreen, \r+CUF in inline). Wide chars are
1856
+ // rare so the overhead is negligible.
1857
+ if (cell.wide) {
1858
+ if (isInline) {
1859
+ // Inline: \r resets to column 0, CUF moves to expected position.
1860
+ // Reset bg first to prevent bleed across traversed cells.
1861
+ if (currentStyle && currentStyle.bg !== null) {
1862
+ output += "\x1b[0m"
1863
+ currentStyle = null
1864
+ }
1865
+ output += "\r"
1866
+ if (cursorX > 0) output += cursorX === 1 ? "\x1b[C" : `\x1b[${cursorX}C`
1867
+ } else {
1868
+ // Fullscreen: CUP (absolute position) — no style reset needed.
1869
+ output += `\x1b[${cursorY + 1};${cursorX + 1}H`
1870
+ }
1871
+ }
1872
+ }
1873
+
1874
+ finalY = cursorY
1875
+
1876
+ // Close any open hyperlink
1877
+ if (currentHyperlink) {
1878
+ output += "\x1b]8;;\x1b\\"
1879
+ }
1880
+
1881
+ // Reset style at end
1882
+ if (currentStyle) {
1883
+ output += "\x1b[0m"
1884
+ }
1885
+
1886
+ return { output, finalY }
1887
+ }
1888
+
1889
+ // =============================================================================
1890
+ // Color code helpers (imported from ansi/sgr-codes.ts)
1891
+ // =============================================================================
1892
+
1893
+ /**
1894
+ * Convert style to ANSI escape sequence (chalk-compatible format).
1895
+ *
1896
+ * Emits only non-default attributes with no reset prefix. Called when there
1897
+ * is no previous style context (first cell or after all attributes are off),
1898
+ * so the terminal is already in reset state.
1899
+ *
1900
+ * Uses native 4-bit codes for basic colors (0-7), 256-color for extended,
1901
+ * and true-color for RGB. Each attribute gets its own \x1b[Xm sequence to
1902
+ * match chalk's output format.
1903
+ *
1904
+ * Emits SGR codes including:
1905
+ * - Basic colors (30-37, 40-47)
1906
+ * - 256-color (38;5;N, 48;5;N)
1907
+ * - True color (38;2;r;g;b, 48;2;r;g;b)
1908
+ * - Underline styles (4:x where x = 0-5)
1909
+ * - Underline color (58;5;N or 58;2;r;g;b)
1910
+ * - Inverse uses SGR 7 so terminals swap fg/bg correctly (including default colors)
1911
+ */
1912
+ function styleToAnsi(style: Style, ctx: OutputContext = defaultContext): string {
1913
+ const fg = style.fg
1914
+ const bg = style.bg
1915
+
1916
+ // Build individual escape sequences (chalk-compatible: one \x1b[Xm per attribute)
1917
+ let result = ""
1918
+
1919
+ // Foreground color
1920
+ if (fg !== null) {
1921
+ result += `\x1b[${fgColorCode(fg)}m`
1922
+ }
1923
+
1924
+ // Background color (DEFAULT_BG sentinel = terminal default, skip)
1925
+ if (bg !== null && !isDefaultBg(bg)) {
1926
+ result += `\x1b[${bgColorCode(bg)}m`
1927
+ }
1928
+
1929
+ // Attributes
1930
+ if (style.attrs.bold) result += "\x1b[1m"
1931
+ if (style.attrs.dim) result += "\x1b[2m"
1932
+ if (style.attrs.italic) result += "\x1b[3m"
1933
+
1934
+ // Underline: use SGR 4:x if style specified, otherwise simple SGR 4
1935
+ if (!ctx.caps.underlineStyles) {
1936
+ // Terminal doesn't support SGR 4:x — use simple SGR 4
1937
+ if (style.attrs.underline || style.attrs.underlineStyle) result += "\x1b[4m"
1938
+ } else {
1939
+ const underlineStyle = style.attrs.underlineStyle
1940
+ const sgrSubparam = underlineStyleToSgr(underlineStyle)
1941
+ if (sgrSubparam !== null && sgrSubparam !== 0) {
1942
+ result += `\x1b[4:${sgrSubparam}m`
1943
+ } else if (style.attrs.underline) {
1944
+ result += "\x1b[4m"
1945
+ }
1946
+ }
1947
+
1948
+ // Use SGR 7 for inverse — lets the terminal correctly swap fg/bg
1949
+ // (including default terminal colors that have no explicit ANSI code)
1950
+ if (style.attrs.blink) result += "\x1b[5m"
1951
+ if (style.attrs.inverse) result += "\x1b[7m"
1952
+ if (style.attrs.hidden) result += "\x1b[8m"
1953
+ if (style.attrs.strikethrough) result += "\x1b[9m"
1954
+
1955
+ // Append underline color if specified (SGR 58) — skip for limited terminals
1956
+ if (ctx.caps.underlineColor && style.underlineColor !== null && style.underlineColor !== undefined) {
1957
+ if (typeof style.underlineColor === "number") {
1958
+ result += `\x1b[58;5;${style.underlineColor}m`
1959
+ } else {
1960
+ result += `\x1b[58;2;${style.underlineColor.r};${style.underlineColor.g};${style.underlineColor.b}m`
1961
+ }
1962
+ }
1963
+
1964
+ return result
1965
+ }
1966
+
1967
+ // =============================================================================
1968
+ // SILVERY_STRICT_OUTPUT: ANSI output verification via virtual terminal replay
1969
+ // =============================================================================
1970
+
1971
+ // ============================================================================
1972
+ // Style-Aware ANSI Replay
1973
+ // ============================================================================
1974
+
1975
+ /** SGR state tracked during ANSI replay. */
1976
+ interface SgrState {
1977
+ fg: number | { r: number; g: number; b: number } | null
1978
+ bg: number | { r: number; g: number; b: number } | null
1979
+ bold: boolean
1980
+ dim: boolean
1981
+ italic: boolean
1982
+ underline: boolean
1983
+ blink: boolean
1984
+ inverse: boolean
1985
+ hidden: boolean
1986
+ strikethrough: boolean
1987
+ }
1988
+
1989
+ /** A cell in the style-aware virtual terminal. */
1990
+ interface StyledCell {
1991
+ char: string
1992
+ fg: number | { r: number; g: number; b: number } | null
1993
+ bg: number | { r: number; g: number; b: number } | null
1994
+ bold: boolean
1995
+ dim: boolean
1996
+ italic: boolean
1997
+ underline: boolean
1998
+ blink: boolean
1999
+ inverse: boolean
2000
+ hidden: boolean
2001
+ strikethrough: boolean
2002
+ }
2003
+
2004
+ function createDefaultSgr(): SgrState {
2005
+ return {
2006
+ fg: null,
2007
+ bg: null,
2008
+ bold: false,
2009
+ dim: false,
2010
+ italic: false,
2011
+ underline: false,
2012
+ blink: false,
2013
+ inverse: false,
2014
+ hidden: false,
2015
+ strikethrough: false,
2016
+ }
2017
+ }
2018
+
2019
+ function createDefaultStyledCell(): StyledCell {
2020
+ return {
2021
+ char: " ",
2022
+ fg: null,
2023
+ bg: null,
2024
+ bold: false,
2025
+ dim: false,
2026
+ italic: false,
2027
+ underline: false,
2028
+ blink: false,
2029
+ inverse: false,
2030
+ hidden: false,
2031
+ strikethrough: false,
2032
+ }
2033
+ }
2034
+
2035
+ /**
2036
+ * Apply SGR parameters to the current state.
2037
+ * Handles all SGR codes used by styleTransition().
2038
+ */
2039
+ function applySgrParams(params: string, sgr: SgrState): void {
2040
+ if (params === "" || params === "0") {
2041
+ // Reset
2042
+ sgr.fg = null
2043
+ sgr.bg = null
2044
+ sgr.bold = false
2045
+ sgr.dim = false
2046
+ sgr.italic = false
2047
+ sgr.underline = false
2048
+ sgr.blink = false
2049
+ sgr.inverse = false
2050
+ sgr.hidden = false
2051
+ sgr.strikethrough = false
2052
+ return
2053
+ }
2054
+
2055
+ const parts = params.split(";")
2056
+ let i = 0
2057
+ while (i < parts.length) {
2058
+ const code = parts[i]!
2059
+ // Handle subparameters (e.g., "4:3" for curly underline)
2060
+ const colonIdx = code.indexOf(":")
2061
+ if (colonIdx >= 0) {
2062
+ const mainCode = parseInt(code.substring(0, colonIdx))
2063
+ if (mainCode === 4) {
2064
+ // Underline style subparameter
2065
+ const sub = parseInt(code.substring(colonIdx + 1))
2066
+ sgr.underline = sub > 0
2067
+ }
2068
+ i++
2069
+ continue
2070
+ }
2071
+
2072
+ const n = parseInt(code)
2073
+ if (n === 0) {
2074
+ sgr.fg = null
2075
+ sgr.bg = null
2076
+ sgr.bold = false
2077
+ sgr.dim = false
2078
+ sgr.italic = false
2079
+ sgr.underline = false
2080
+ sgr.blink = false
2081
+ sgr.inverse = false
2082
+ sgr.hidden = false
2083
+ sgr.strikethrough = false
2084
+ } else if (n === 1) {
2085
+ sgr.bold = true
2086
+ } else if (n === 2) {
2087
+ sgr.dim = true
2088
+ } else if (n === 3) {
2089
+ sgr.italic = true
2090
+ } else if (n === 4) {
2091
+ sgr.underline = true
2092
+ } else if (n === 5 || n === 6) {
2093
+ sgr.blink = true
2094
+ } else if (n === 7) {
2095
+ sgr.inverse = true
2096
+ } else if (n === 8) {
2097
+ sgr.hidden = true
2098
+ } else if (n === 9) {
2099
+ sgr.strikethrough = true
2100
+ } else if (n === 22) {
2101
+ sgr.bold = false
2102
+ sgr.dim = false
2103
+ } else if (n === 23) {
2104
+ sgr.italic = false
2105
+ } else if (n === 24) {
2106
+ sgr.underline = false
2107
+ } else if (n === 25) {
2108
+ sgr.blink = false
2109
+ } else if (n === 27) {
2110
+ sgr.inverse = false
2111
+ } else if (n === 28) {
2112
+ sgr.hidden = false
2113
+ } else if (n === 29) {
2114
+ sgr.strikethrough = false
2115
+ } else if (n >= 30 && n <= 37) {
2116
+ sgr.fg = n - 30
2117
+ } else if (n === 38) {
2118
+ // Extended fg color
2119
+ if (i + 1 < parts.length && parts[i + 1] === "5" && i + 2 < parts.length) {
2120
+ sgr.fg = parseInt(parts[i + 2]!)
2121
+ i += 2
2122
+ } else if (i + 1 < parts.length && parts[i + 1] === "2" && i + 4 < parts.length) {
2123
+ sgr.fg = {
2124
+ r: parseInt(parts[i + 2]!),
2125
+ g: parseInt(parts[i + 3]!),
2126
+ b: parseInt(parts[i + 4]!),
2127
+ }
2128
+ i += 4
2129
+ }
2130
+ } else if (n === 39) {
2131
+ sgr.fg = null
2132
+ } else if (n >= 40 && n <= 47) {
2133
+ sgr.bg = n - 40
2134
+ } else if (n === 48) {
2135
+ // Extended bg color
2136
+ if (i + 1 < parts.length && parts[i + 1] === "5" && i + 2 < parts.length) {
2137
+ sgr.bg = parseInt(parts[i + 2]!)
2138
+ i += 2
2139
+ } else if (i + 1 < parts.length && parts[i + 1] === "2" && i + 4 < parts.length) {
2140
+ sgr.bg = {
2141
+ r: parseInt(parts[i + 2]!),
2142
+ g: parseInt(parts[i + 3]!),
2143
+ b: parseInt(parts[i + 4]!),
2144
+ }
2145
+ i += 4
2146
+ }
2147
+ } else if (n === 49) {
2148
+ sgr.bg = null
2149
+ } else if (n >= 90 && n <= 97) {
2150
+ sgr.fg = n - 90 + 8 // bright colors: 8-15
2151
+ } else if (n >= 100 && n <= 107) {
2152
+ sgr.bg = n - 100 + 8
2153
+ }
2154
+ // 58/59 (underline color) not tracked in cell comparison for now
2155
+ i++
2156
+ }
2157
+ }
2158
+
2159
+ /**
2160
+ * Replay ANSI output tracking both characters AND SGR styles.
2161
+ * Returns a 2D grid of StyledCell objects.
2162
+ */
2163
+ export function replayAnsiWithStyles(
2164
+ width: number,
2165
+ height: number,
2166
+ ansi: string,
2167
+ ctx: OutputContext = defaultContext,
2168
+ ): StyledCell[][] {
2169
+ const screen: StyledCell[][] = Array.from({ length: height }, () =>
2170
+ Array.from({ length: width }, () => createDefaultStyledCell()),
2171
+ )
2172
+ let cx = 0
2173
+ let cy = 0
2174
+ const sgr = createDefaultSgr()
2175
+ let i = 0
2176
+
2177
+ while (i < ansi.length) {
2178
+ if (ansi[i] === "\x1b") {
2179
+ if (ansi[i + 1] === "[") {
2180
+ i += 2
2181
+ let params = ""
2182
+ while (
2183
+ i < ansi.length &&
2184
+ ((ansi[i]! >= "0" && ansi[i]! <= "9") || ansi[i] === ";" || ansi[i] === "?" || ansi[i] === ":")
2185
+ ) {
2186
+ params += ansi[i]
2187
+ i++
2188
+ }
2189
+ const cmd = ansi[i]
2190
+ i++
2191
+ if (cmd === "H") {
2192
+ if (params === "") {
2193
+ cx = 0
2194
+ cy = 0
2195
+ } else {
2196
+ const cmdParts = params.split(";")
2197
+ cy = Math.max(0, (parseInt(cmdParts[0]!) || 1) - 1)
2198
+ cx = Math.max(0, (parseInt(cmdParts[1]!) || 1) - 1)
2199
+ }
2200
+ } else if (cmd === "K") {
2201
+ // Erase to end of line — fills with current bg (or default)
2202
+ for (let x = cx; x < width; x++) {
2203
+ const cell = screen[cy]![x]!
2204
+ cell.char = " "
2205
+ cell.fg = null
2206
+ cell.bg = sgr.bg
2207
+ cell.bold = false
2208
+ cell.dim = false
2209
+ cell.italic = false
2210
+ cell.underline = false
2211
+ cell.blink = false
2212
+ cell.inverse = false
2213
+ cell.hidden = false
2214
+ cell.strikethrough = false
2215
+ }
2216
+ } else if (cmd === "A") {
2217
+ cy = Math.max(0, cy - (parseInt(params) || 1))
2218
+ } else if (cmd === "B") {
2219
+ cy = Math.min(height - 1, cy + (parseInt(params) || 1))
2220
+ } else if (cmd === "C") {
2221
+ cx = Math.min(width - 1, cx + (parseInt(params) || 1))
2222
+ } else if (cmd === "D") {
2223
+ cx = Math.max(0, cx - (parseInt(params) || 1))
2224
+ } else if (cmd === "G") {
2225
+ cx = Math.max(0, (parseInt(params) || 1) - 1)
2226
+ } else if (cmd === "J") {
2227
+ if (params === "2") {
2228
+ for (let y = 0; y < height; y++)
2229
+ for (let x = 0; x < width; x++) {
2230
+ screen[y]![x] = createDefaultStyledCell()
2231
+ }
2232
+ }
2233
+ } else if (cmd === "m") {
2234
+ // SGR — apply to current state
2235
+ applySgrParams(params, sgr)
2236
+ }
2237
+ // Skip DEC modes (h/l), etc.
2238
+ } else if (ansi[i + 1] === "]") {
2239
+ // OSC: extract payload and check for OSC 66 (text sizing)
2240
+ i += 2
2241
+ let oscPayload = ""
2242
+ while (i < ansi.length) {
2243
+ if (ansi[i] === "\x1b" && ansi[i + 1] === "\\") {
2244
+ i += 2
2245
+ break
2246
+ }
2247
+ if (ansi[i] === "\x07") {
2248
+ i++
2249
+ break
2250
+ }
2251
+ oscPayload += ansi[i]
2252
+ i++
2253
+ }
2254
+ // OSC 66: text sizing — format is "66;w=N;TEXT"
2255
+ // Extract TEXT and process it as a character with the declared width
2256
+ if (oscPayload.startsWith("66;")) {
2257
+ const semiIdx = oscPayload.indexOf(";", 3)
2258
+ if (semiIdx !== -1) {
2259
+ const text = oscPayload.slice(semiIdx + 1)
2260
+ const widthParam = oscPayload.slice(3, semiIdx)
2261
+ const declaredWidth = widthParam.startsWith("w=") ? parseInt(widthParam.slice(2)) || 1 : 1
2262
+ if (cy < height && cx < width) {
2263
+ const cell = screen[cy]![cx]!
2264
+ cell.char = text
2265
+ cell.fg = sgr.fg
2266
+ cell.bg = sgr.bg
2267
+ cell.bold = sgr.bold
2268
+ cell.dim = sgr.dim
2269
+ cell.italic = sgr.italic
2270
+ cell.underline = sgr.underline
2271
+ cell.blink = sgr.blink
2272
+ cell.inverse = sgr.inverse
2273
+ cell.hidden = sgr.hidden
2274
+ cell.strikethrough = sgr.strikethrough
2275
+ if (declaredWidth > 1 && cx + 1 < width) {
2276
+ const cont = screen[cy]![cx + 1]!
2277
+ cont.char = " "
2278
+ cont.fg = null
2279
+ cont.bg = sgr.bg
2280
+ cont.bold = false
2281
+ cont.dim = false
2282
+ cont.italic = false
2283
+ cont.underline = false
2284
+ cont.blink = false
2285
+ cont.inverse = false
2286
+ cont.hidden = false
2287
+ cont.strikethrough = false
2288
+ }
2289
+ cx += declaredWidth
2290
+ }
2291
+ }
2292
+ }
2293
+ // Other OSC sequences (8=hyperlinks, etc.) are skipped
2294
+ } else if (ansi[i + 1] === ">") {
2295
+ i += 2
2296
+ while (i < ansi.length && ansi[i] !== "\x1b") i++
2297
+ } else {
2298
+ i += 2
2299
+ }
2300
+ } else if (ansi[i] === "\r") {
2301
+ cx = 0
2302
+ i++
2303
+ } else if (ansi[i] === "\n") {
2304
+ cy = Math.min(height - 1, cy + 1)
2305
+ i++
2306
+ } else {
2307
+ // Extract a full grapheme cluster (handles surrogate pairs and multi-codepoint sequences
2308
+ // like flag emoji 🇺🇸 which are 2 regional indicator codepoints = 4 UTF-16 code units)
2309
+ const cp = ansi.codePointAt(i)!
2310
+ // Advance past this codepoint (2 code units if surrogate pair, 1 otherwise)
2311
+ const cpLen = cp > 0xffff ? 2 : 1
2312
+ // Collect combining marks and joiners that follow (ZWJ sequences, variation selectors, etc.)
2313
+ let grapheme = String.fromCodePoint(cp)
2314
+ let j = i + cpLen
2315
+ let prevWasZwj = false
2316
+ while (j < ansi.length) {
2317
+ const nextCp = ansi.codePointAt(j)!
2318
+ // Combining marks (U+0300-U+036F, U+20D0-U+20FF, U+FE00-U+FE0F variation selectors),
2319
+ // ZWJ (U+200D), regional indicators following another regional indicator.
2320
+ // After ZWJ, the next codepoint is always consumed (it's the joinee — e.g.,
2321
+ // 🏃‍♂️ = runner + ZWJ + male sign + VS16: male sign is NOT a combining mark
2322
+ // but must be part of this grapheme cluster).
2323
+ const isCombining =
2324
+ prevWasZwj || // Joinee after ZWJ
2325
+ (nextCp >= 0x0300 && nextCp <= 0x036f) || // Combining Diacritical Marks
2326
+ (nextCp >= 0x20d0 && nextCp <= 0x20ff) || // Combining Diacritical Marks for Symbols
2327
+ (nextCp >= 0xfe00 && nextCp <= 0xfe0f) || // Variation Selectors
2328
+ nextCp === 0xfe0e ||
2329
+ nextCp === 0xfe0f || // Text/Emoji presentation
2330
+ nextCp === 0x200d || // ZWJ
2331
+ (nextCp >= 0xe0100 && nextCp <= 0xe01ef) || // Variation Selectors Supplement
2332
+ // Skin tone modifiers (Fitzpatrick scale)
2333
+ (nextCp >= 0x1f3fb && nextCp <= 0x1f3ff) ||
2334
+ // Regional indicator following a regional indicator (flag sequences)
2335
+ (cp >= 0x1f1e6 && cp <= 0x1f1ff && nextCp >= 0x1f1e6 && nextCp <= 0x1f1ff)
2336
+ if (!isCombining) break
2337
+ prevWasZwj = nextCp === 0x200d
2338
+ const nextLen = nextCp > 0xffff ? 2 : 1
2339
+ grapheme += String.fromCodePoint(nextCp)
2340
+ j += nextLen
2341
+ }
2342
+ if (cy < height && cx < width) {
2343
+ const gw = outputGraphemeWidth(grapheme, ctx)
2344
+ const charWidth = gw || 1
2345
+
2346
+ const cell = screen[cy]![cx]!
2347
+ cell.char = grapheme
2348
+ cell.fg = sgr.fg
2349
+ cell.bg = sgr.bg
2350
+ cell.bold = sgr.bold
2351
+ cell.dim = sgr.dim
2352
+ cell.italic = sgr.italic
2353
+ cell.underline = sgr.underline
2354
+ cell.blink = sgr.blink
2355
+ cell.inverse = sgr.inverse
2356
+ cell.hidden = sgr.hidden
2357
+ cell.strikethrough = sgr.strikethrough
2358
+
2359
+ // Wide character overwrites the next cell (continuation cell)
2360
+ // Real terminals do this automatically — the wide char occupies 2 columns
2361
+ if (charWidth > 1 && cx + 1 < width) {
2362
+ const cont = screen[cy]![cx + 1]!
2363
+ cont.char = " "
2364
+ cont.fg = null
2365
+ cont.bg = sgr.bg
2366
+ cont.bold = false
2367
+ cont.dim = false
2368
+ cont.italic = false
2369
+ cont.underline = false
2370
+ cont.blink = false
2371
+ cont.inverse = false
2372
+ cont.hidden = false
2373
+ cont.strikethrough = false
2374
+ }
2375
+ cx += charWidth
2376
+ }
2377
+ i = j
2378
+ }
2379
+ }
2380
+ return screen
2381
+ }
2382
+
2383
+ /** Format a color value for display. */
2384
+ function formatColor(c: number | { r: number; g: number; b: number } | null): string {
2385
+ if (c === null) return "default"
2386
+ if (typeof c === "number") return `${c}`
2387
+ return `rgb(${c.r},${c.g},${c.b})`
2388
+ }
2389
+
2390
+ /**
2391
+ * Verify that applying changesToAnsi output to a previous terminal state
2392
+ * produces the same visible characters AND styles as a fresh render of the
2393
+ * next buffer. Throws on mismatch.
2394
+ *
2395
+ * This catches SGR style bugs that character-only verification misses.
2396
+ */
2397
+ function verifyOutputEquivalence(
2398
+ prev: TerminalBuffer,
2399
+ next: TerminalBuffer,
2400
+ incrOutput: string,
2401
+ mode: "fullscreen" | "inline",
2402
+ ctx: OutputContext = defaultContext,
2403
+ ): void {
2404
+ const w = Math.max(prev.width, next.width)
2405
+ // VT height must accommodate the larger buffer to prevent scrolling artifacts
2406
+ // when prev is taller than next (e.g., items removed from a scrollback list).
2407
+ // We only compare up to next.height rows — excess rows should be cleared.
2408
+ const vtHeight = Math.max(prev.height, next.height)
2409
+ const compareHeight = next.height
2410
+ // DEBUG: log buffer dimensions
2411
+ if (process.env.SILVERY_DEBUG_OUTPUT) {
2412
+ // eslint-disable-next-line no-console
2413
+ console.error(
2414
+ `[VERIFY] prev=${prev.width}x${prev.height} next=${next.width}x${next.height} vtSize=${w}x${vtHeight}`,
2415
+ )
2416
+ }
2417
+ // Replay: fresh prev render + incremental diff applied on top
2418
+ const freshPrev = bufferToAnsi(prev, mode, ctx)
2419
+ if (process.env.SILVERY_DEBUG_OUTPUT) {
2420
+ // eslint-disable-next-line no-console
2421
+ console.error(`[VERIFY] freshPrev len=${freshPrev.length} incrOutput len=${incrOutput.length}`)
2422
+ // Show incrOutput as escaped string
2423
+ const escaped = incrOutput.replace(/\x1b/g, "\\e").replace(/\r/g, "\\r").replace(/\n/g, "\\n")
2424
+ // eslint-disable-next-line no-console
2425
+ console.error(`[VERIFY] incrOutput: ${escaped.slice(0, 500)}`)
2426
+ }
2427
+ const screenIncr = replayAnsiWithStyles(w, vtHeight, freshPrev + incrOutput, ctx)
2428
+ // Replay: fresh render of next buffer
2429
+ const freshNext = bufferToAnsi(next, mode, ctx)
2430
+ const screenFresh = replayAnsiWithStyles(w, vtHeight, freshNext, ctx)
2431
+
2432
+ const _dumpRowWideCells = (buf: TerminalBuffer, row: number): string => {
2433
+ const parts: string[] = []
2434
+ for (let cx = 0; cx < buf.width; cx++) {
2435
+ const c = buf.getCell(cx, row)
2436
+ const cp = c.char
2437
+ ? [...c.char].map((ch) => "U+" + (ch.codePointAt(0) ?? 0).toString(16).toUpperCase().padStart(4, "0")).join(",")
2438
+ : "empty"
2439
+ if (c.wide) parts.push(`W@${cx}:${cp}(gw=${outputGraphemeWidth(c.char, ctx)})`)
2440
+ if (c.continuation) parts.push(`C@${cx}`)
2441
+ // Flag cells where written char width differs from buffer expectation
2442
+ const charToWrite = c.char || " "
2443
+ const vtWidth = outputGraphemeWidth(charToWrite, ctx)
2444
+ const bufWidth = c.wide ? 2 : 1
2445
+ if (!c.continuation && vtWidth !== bufWidth) {
2446
+ parts.push(`MISMATCH@${cx}:${cp}(vtW=${vtWidth},bufW=${bufWidth},tse=${outputTextSizingEnabled(ctx)})`)
2447
+ }
2448
+ }
2449
+ return parts.join(" ")
2450
+ }
2451
+
2452
+ // Compare character by character AND style by style.
2453
+ // Use vtHeight (not compareHeight) to catch stale rows after height shrink.
2454
+ // When prev.height > next.height, stale rows beyond next.height must be
2455
+ // verified as cleared — otherwise incremental output silently diverges.
2456
+ for (let y = 0; y < vtHeight; y++) {
2457
+ for (let x = 0; x < w; x++) {
2458
+ const incr = screenIncr[y]![x]!
2459
+ const fresh = screenFresh[y]![x]!
2460
+
2461
+ // Check character
2462
+ if (incr.char !== fresh.char) {
2463
+ // Build context: show the row from both renders
2464
+ const incrRow = screenIncr[y]!.map((c) => c.char).join("")
2465
+ const freshRow = screenFresh[y]!.map((c) => c.char).join("")
2466
+ // Also show the prev buffer row for diagnosis
2467
+ const prevRow = screenIncr[y]!.map((_, cx) => {
2468
+ const prevCell = prev.getCell(cx, y)
2469
+ return prevCell.char
2470
+ }).join("")
2471
+ // Show what changesToAnsi tried to write at this position
2472
+ const nextCell = next.getCell(x, y)
2473
+ const prevCell = prev.getCell(x, y)
2474
+ // Show detailed column-by-column comparison around the mismatch
2475
+ const contextStart = Math.max(0, x - 5)
2476
+ const contextEnd = Math.min(w, x + 10)
2477
+ const colDetails: string[] = []
2478
+ for (let cx = contextStart; cx < contextEnd; cx++) {
2479
+ const ic = screenIncr[y]![cx]!
2480
+ const fc = screenFresh[y]![cx]!
2481
+ const pc = prev.getCell(cx, y)
2482
+ const nc = next.getCell(cx, y)
2483
+ const marker = cx === x ? " <<<" : ic.char !== fc.char ? " !!!" : ""
2484
+ colDetails.push(
2485
+ ` col ${cx}: prev='${pc.char}'(w=${pc.wide},c=${pc.continuation}) next='${nc.char}' incr='${ic.char}' fresh='${fc.char}' wide=${nc.wide} cont=${nc.continuation}${marker}`,
2486
+ )
2487
+ }
2488
+ const msg =
2489
+ `SILVERY_STRICT_OUTPUT char mismatch at (${x},${y}): ` +
2490
+ `incremental='${incr.char}' fresh='${fresh.char}'\n` +
2491
+ ` prev buffer cell: char='${prevCell.char}' bg=${prevCell.bg} wide=${prevCell.wide} cont=${prevCell.continuation}\n` +
2492
+ ` next buffer cell: char='${nextCell.char}' bg=${nextCell.bg} wide=${nextCell.wide} cont=${nextCell.continuation}\n` +
2493
+ ` incr row: ${incrRow}\n` +
2494
+ ` fresh row: ${freshRow}\n` +
2495
+ ` prev row: ${prevRow}\n` +
2496
+ `Wide/cont cells on row ${y} (next buffer): ${_dumpRowWideCells(next, y)}\n` +
2497
+ `Wide/cont cells on row ${y} (prev buffer): ${_dumpRowWideCells(prev, y)}\n` +
2498
+ `Column detail around mismatch:\n${colDetails.join("\n")}`
2499
+ // eslint-disable-next-line no-console
2500
+ console.error(msg)
2501
+ throw new IncrementalRenderMismatchError(msg)
2502
+ }
2503
+
2504
+ // Check styles
2505
+ const diffs: string[] = []
2506
+ if (!sgrColorEquals(incr.fg, fresh.fg)) diffs.push(`fg: ${formatColor(incr.fg)} vs ${formatColor(fresh.fg)}`)
2507
+ if (!sgrColorEquals(incr.bg, fresh.bg)) diffs.push(`bg: ${formatColor(incr.bg)} vs ${formatColor(fresh.bg)}`)
2508
+ if (incr.bold !== fresh.bold) diffs.push(`bold: ${incr.bold} vs ${fresh.bold}`)
2509
+ if (incr.dim !== fresh.dim) diffs.push(`dim: ${incr.dim} vs ${fresh.dim}`)
2510
+ if (incr.italic !== fresh.italic) diffs.push(`italic: ${incr.italic} vs ${fresh.italic}`)
2511
+ if (incr.underline !== fresh.underline) diffs.push(`underline: ${incr.underline} vs ${fresh.underline}`)
2512
+ if (incr.inverse !== fresh.inverse) diffs.push(`inverse: ${incr.inverse} vs ${fresh.inverse}`)
2513
+ if (incr.strikethrough !== fresh.strikethrough)
2514
+ diffs.push(`strikethrough: ${incr.strikethrough} vs ${fresh.strikethrough}`)
2515
+
2516
+ if (diffs.length > 0) {
2517
+ const msg =
2518
+ `SILVERY_STRICT_OUTPUT style mismatch at (${x},${y}) char='${incr.char}': ` +
2519
+ diffs.join(", ") +
2520
+ `\n incremental: fg=${formatColor(incr.fg)} bg=${formatColor(incr.bg)} bold=${incr.bold} dim=${incr.dim}` +
2521
+ `\n fresh: fg=${formatColor(fresh.fg)} bg=${formatColor(fresh.bg)} bold=${fresh.bold} dim=${fresh.dim}`
2522
+ throw new IncrementalRenderMismatchError(msg)
2523
+ }
2524
+ }
2525
+ }
2526
+ }
2527
+
2528
+ /**
2529
+ * Verify that the accumulated output from all frames produces the same
2530
+ * terminal state as a fresh render of the current buffer.
2531
+ * Catches compounding errors across multiple render frames.
2532
+ */
2533
+ function verifyAccumulatedOutput(
2534
+ currentBuffer: TerminalBuffer,
2535
+ mode: "fullscreen" | "inline",
2536
+ ctx: OutputContext = defaultContext,
2537
+ accState: AccumulateState = defaultAccState,
2538
+ ): void {
2539
+ const w = accState.accumulateWidth
2540
+ const h = accState.accumulateHeight
2541
+ // Replay all accumulated output (first render + all incremental updates)
2542
+ const screenAccumulated = replayAnsiWithStyles(w, h, accState.accumulatedAnsi, ctx)
2543
+ // Replay fresh render of current buffer
2544
+ const freshOutput = bufferToAnsi(currentBuffer, mode, ctx)
2545
+ const screenFresh = replayAnsiWithStyles(w, h, freshOutput, ctx)
2546
+
2547
+ for (let y = 0; y < h; y++) {
2548
+ for (let x = 0; x < w; x++) {
2549
+ const accum = screenAccumulated[y]![x]!
2550
+ const fresh = screenFresh[y]![x]!
2551
+
2552
+ if (accum.char !== fresh.char) {
2553
+ const msg =
2554
+ `SILVERY_STRICT_ACCUMULATE char mismatch at (${x},${y}) after ${accState.accumulateFrameCount} frames: ` +
2555
+ `accumulated='${accum.char}' fresh='${fresh.char}'`
2556
+ // eslint-disable-next-line no-console
2557
+ console.error(msg)
2558
+ throw new IncrementalRenderMismatchError(msg)
2559
+ }
2560
+
2561
+ const diffs: string[] = []
2562
+ if (!sgrColorEquals(accum.fg, fresh.fg)) diffs.push(`fg: ${formatColor(accum.fg)} vs ${formatColor(fresh.fg)}`)
2563
+ if (!sgrColorEquals(accum.bg, fresh.bg)) diffs.push(`bg: ${formatColor(accum.bg)} vs ${formatColor(fresh.bg)}`)
2564
+ if (accum.bold !== fresh.bold) diffs.push(`bold: ${accum.bold} vs ${fresh.bold}`)
2565
+ if (accum.dim !== fresh.dim) diffs.push(`dim: ${accum.dim} vs ${fresh.dim}`)
2566
+ if (accum.italic !== fresh.italic) diffs.push(`italic: ${accum.italic} vs ${fresh.italic}`)
2567
+ if (accum.underline !== fresh.underline) diffs.push(`underline: ${accum.underline} vs ${fresh.underline}`)
2568
+ if (accum.inverse !== fresh.inverse) diffs.push(`inverse: ${accum.inverse} vs ${fresh.inverse}`)
2569
+ if (accum.strikethrough !== fresh.strikethrough)
2570
+ diffs.push(`strikethrough: ${accum.strikethrough} vs ${fresh.strikethrough}`)
2571
+
2572
+ if (diffs.length > 0) {
2573
+ const msg =
2574
+ `SILVERY_STRICT_ACCUMULATE style mismatch at (${x},${y}) char='${accum.char}' after ${accState.accumulateFrameCount} frames: ` +
2575
+ diffs.join(", ")
2576
+ // eslint-disable-next-line no-console
2577
+ console.error(msg)
2578
+ throw new IncrementalRenderMismatchError(msg)
2579
+ }
2580
+ }
2581
+ }
2582
+ }
2583
+
2584
+ /** Compare two SGR color values. */
2585
+ function sgrColorEquals(
2586
+ a: number | { r: number; g: number; b: number } | null,
2587
+ b: number | { r: number; g: number; b: number } | null,
2588
+ ): boolean {
2589
+ if (a === b) return true
2590
+ if (a === null || b === null) return false
2591
+ if (typeof a === "number" || typeof b === "number") return a === b
2592
+ return a.r === b.r && a.g === b.g && a.b === b.b
2593
+ }