@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,1765 @@
1
+ /**
2
+ * Phase 3: Content Phase
3
+ *
4
+ * Render all nodes to a terminal buffer.
5
+ *
6
+ * This module orchestrates the rendering process by traversing the node tree
7
+ * and delegating to specialized rendering functions for boxes and text.
8
+ *
9
+ * Layout (top-down):
10
+ * contentPhase → renderNodeToBuffer → renderScrollContainerChildren
11
+ * → renderNormalChildren
12
+ * Helpers: clearDirtyFlags, hasChildPositionChanged, computeChildClipBounds
13
+ * Region clearing: findInheritedBg, clearNodeRegion, clippedFill
14
+ */
15
+
16
+ import type { Color } from "../buffer"
17
+ import { TerminalBuffer } from "../buffer"
18
+ import type { BoxProps, TeaNode, TextProps } from "@silvery/tea/types"
19
+ import { getBorderSize, getPadding } from "./helpers"
20
+ import { renderBox, renderOutline, renderScrollIndicators } from "./render-box"
21
+ import { parseColor } from "./render-helpers"
22
+ import { clearBgConflictWarnings, renderText, setBgConflictMode } from "./render-text"
23
+ import { pushContextTheme, popContextTheme } from "@silvery/theme/state"
24
+ import type { Theme } from "@silvery/theme/types"
25
+ import type { ClipBounds, ContentPhaseStats, NodeRenderState, NodeTraceEntry, PipelineContext } from "./types"
26
+
27
+ /**
28
+ * Render all nodes to a terminal buffer.
29
+ *
30
+ * @param root The root SilveryNode
31
+ * @param prevBuffer Previous buffer for incremental rendering (optional)
32
+ * @returns A TerminalBuffer with the rendered content
33
+ */
34
+ export function contentPhase(root: TeaNode, prevBuffer?: TerminalBuffer | null, ctx?: PipelineContext): TerminalBuffer {
35
+ const layout = root.contentRect
36
+ if (!layout) {
37
+ throw new Error("contentPhase called before layout phase")
38
+ }
39
+
40
+ // Resolve instrumentation from ctx (if provided) or module-level globals
41
+ const instrumentEnabled = ctx?.instrumentEnabled ?? _instrumentEnabled
42
+ const stats = ctx?.stats ?? _contentPhaseStats
43
+ const nodeTrace = ctx?.nodeTrace ?? _nodeTrace
44
+ const nodeTraceEnabled = ctx?.nodeTraceEnabled ?? _nodeTraceEnabled
45
+
46
+ // Clone prevBuffer if same dimensions, else create fresh
47
+ const hasPrevBuffer = prevBuffer && prevBuffer.width === layout.width && prevBuffer.height === layout.height
48
+
49
+ if (instrumentEnabled) {
50
+ _contentPhaseCallCount++
51
+ stats._prevBufferNull = prevBuffer == null ? 1 : 0
52
+ stats._prevBufferDimMismatch = prevBuffer && !hasPrevBuffer ? 1 : 0
53
+ stats._hasPrevBuffer = hasPrevBuffer ? 1 : 0
54
+ stats._layoutW = layout.width
55
+ stats._layoutH = layout.height
56
+ stats._prevW = prevBuffer?.width ?? 0
57
+ stats._prevH = prevBuffer?.height ?? 0
58
+ stats._callCount = _contentPhaseCallCount
59
+ }
60
+
61
+ const t0 = instrumentEnabled ? performance.now() : 0
62
+ const buffer = hasPrevBuffer ? prevBuffer.clone() : new TerminalBuffer(layout.width, layout.height)
63
+ const tClone = instrumentEnabled ? performance.now() - t0 : 0
64
+
65
+ const t1 = instrumentEnabled ? performance.now() : 0
66
+ renderNodeToBuffer(
67
+ root,
68
+ buffer,
69
+ {
70
+ scrollOffset: 0,
71
+ clipBounds: undefined,
72
+ hasPrevBuffer: !!hasPrevBuffer,
73
+ ancestorCleared: false,
74
+ bufferIsCloned: !!hasPrevBuffer,
75
+ ancestorLayoutChanged: false,
76
+ },
77
+ ctx,
78
+ )
79
+ const tRender = instrumentEnabled ? performance.now() - t1 : 0
80
+
81
+ if (instrumentEnabled) {
82
+ // Expose sub-phase timing for profiling
83
+ const snap = {
84
+ clone: tClone,
85
+ render: tRender,
86
+ ...structuredClone(stats),
87
+ }
88
+ ;(globalThis as any).__silvery_content_detail = snap
89
+ const arr = ((globalThis as any).__silvery_content_all ??= [] as (typeof snap)[])
90
+ arr.push(snap)
91
+ for (const key of Object.keys(stats) as (keyof ContentPhaseStats)[]) {
92
+ ;(stats as any)[key] = 0
93
+ }
94
+ stats.cascadeMinDepth = 999
95
+ stats.cascadeNodes = ""
96
+ stats.scrollClearReason = ""
97
+ stats.normalRepaintReason = ""
98
+ }
99
+
100
+ // Export node trace for SILVERY_STRICT diagnosis
101
+ if (nodeTraceEnabled && nodeTrace.length > 0) {
102
+ const traceArr = ((globalThis as any).__silvery_node_trace ??= [] as NodeTraceEntry[][])
103
+ traceArr.push([...nodeTrace])
104
+ nodeTrace.length = 0
105
+ }
106
+
107
+ // Sync prevLayout after content phase to prevent staleness on subsequent frames.
108
+ // Without this, prevLayout stays at the old value from propagateLayout, causing
109
+ // hasChildPositionChanged and clearExcessArea to use stale coordinates.
110
+ syncPrevLayout(root)
111
+
112
+ return buffer
113
+ }
114
+
115
+ /**
116
+ * Sync prevLayout to contentRect for all nodes in the tree.
117
+ *
118
+ * Called at the end of each contentPhase pass. This prevents:
119
+ * 1. The O(N) staleness bug where prevLayout drifts from contentRect
120
+ * causing !rectEqual to always be true on subsequent frames.
121
+ * 2. Stale old-bounds references in clearExcessArea on doRender iteration 2+.
122
+ * 3. Asymmetry between incremental and fresh renders — doFreshRender's layout
123
+ * phase syncs prevLayout before content, so without this, the real render
124
+ * has null/stale prevLayout while fresh has synced prevLayout, causing
125
+ * different cascade behavior (layoutChanged true vs false).
126
+ */
127
+ function syncPrevLayout(node: TeaNode): void {
128
+ node.prevLayout = node.contentRect
129
+ for (const child of node.children) {
130
+ syncPrevLayout(child)
131
+ }
132
+ }
133
+
134
+ /** Instrumentation enabled when SILVERY_STRICT, SILVERY_CHECK_INCREMENTAL, or SILVERY_INSTRUMENT is set */
135
+ const _instrumentEnabled =
136
+ typeof process !== "undefined" &&
137
+ !!(process.env?.SILVERY_STRICT || process.env?.SILVERY_CHECK_INCREMENTAL || process.env?.SILVERY_INSTRUMENT)
138
+
139
+ /** Mutable stats counters — reset after each contentPhase call */
140
+ const _contentPhaseStats: ContentPhaseStats = {
141
+ nodesVisited: 0,
142
+ nodesRendered: 0,
143
+ nodesSkipped: 0,
144
+ textNodes: 0,
145
+ boxNodes: 0,
146
+ clearOps: 0,
147
+ noPrevBuffer: 0,
148
+ flagContentDirty: 0,
149
+ flagPaintDirty: 0,
150
+ flagLayoutChanged: 0,
151
+ flagSubtreeDirty: 0,
152
+ flagChildrenDirty: 0,
153
+ flagChildPositionChanged: 0,
154
+ flagAncestorLayoutChanged: 0,
155
+ scrollContainerCount: 0,
156
+ scrollViewportCleared: 0,
157
+ scrollClearReason: "",
158
+ normalChildrenRepaint: 0,
159
+ normalRepaintReason: "",
160
+ cascadeMinDepth: 999,
161
+ cascadeNodes: "",
162
+ _prevBufferNull: 0,
163
+ _prevBufferDimMismatch: 0,
164
+ _hasPrevBuffer: 0,
165
+ _layoutW: 0,
166
+ _layoutH: 0,
167
+ _prevW: 0,
168
+ _prevH: 0,
169
+ _callCount: 0,
170
+ }
171
+
172
+ let _contentPhaseCallCount = 0
173
+
174
+ /** Module-level node trace (fallback when ctx.nodeTrace is not provided) */
175
+ const _nodeTrace: NodeTraceEntry[] = []
176
+ const _nodeTraceEnabled =
177
+ typeof process !== "undefined" && !!(process.env?.SILVERY_STRICT || process.env?.SILVERY_CHECK_INCREMENTAL)
178
+
179
+ /** DIAG: compute node depth in tree */
180
+ function _getNodeDepth(node: TeaNode): number {
181
+ let depth = 0
182
+ let n: TeaNode | null = node.parent
183
+ while (n) {
184
+ depth++
185
+ n = n.parent
186
+ }
187
+ return depth
188
+ }
189
+
190
+ // Re-export for consumers who need to clear bg conflict warnings
191
+ export { clearBgConflictWarnings, setBgConflictMode }
192
+
193
+ // ============================================================================
194
+ // Core Rendering
195
+ // ============================================================================
196
+
197
+ /**
198
+ * Render a single node to the buffer.
199
+ */
200
+ function renderNodeToBuffer(
201
+ node: TeaNode,
202
+ buffer: TerminalBuffer,
203
+ nodeState: NodeRenderState,
204
+ ctx?: PipelineContext,
205
+ ): void {
206
+ const { scrollOffset, clipBounds, hasPrevBuffer, ancestorCleared, bufferIsCloned, ancestorLayoutChanged } = nodeState
207
+ // Resolve instrumentation from ctx or module globals
208
+ const instrumentEnabled = ctx?.instrumentEnabled ?? _instrumentEnabled
209
+ const stats = ctx?.stats ?? _contentPhaseStats
210
+ const nodeTrace = ctx?.nodeTrace ?? _nodeTrace
211
+ const nodeTraceEnabled = ctx?.nodeTraceEnabled ?? _nodeTraceEnabled
212
+ if (instrumentEnabled) stats.nodesVisited++
213
+ const layout = node.contentRect
214
+ if (!layout) return
215
+
216
+ // Skip nodes without Yoga (raw text and virtual text nodes)
217
+ // Their content is rendered by their parent silvery-text via collectTextContent()
218
+ if (!node.layoutNode) {
219
+ // Clear dirty flags so markSubtreeDirty() can propagate future updates.
220
+ // Without this, virtual text children keep stale subtreeDirty=true from
221
+ // creation, causing markSubtreeDirty to stop early and never reach the
222
+ // layout ancestor — producing 0-byte diffs on text content changes.
223
+ clearVirtualTextFlags(node)
224
+ return
225
+ }
226
+
227
+ // Skip hidden nodes (Suspense support)
228
+ // When a Suspense boundary shows a fallback, the hidden subtree is not rendered
229
+ if (node.hidden) {
230
+ clearDirtyFlags(node)
231
+ return
232
+ }
233
+
234
+ const props = node.props as BoxProps & TextProps
235
+
236
+ // Skip display="none" nodes - they have 0x0 dimensions and shouldn't render
237
+ // Also skip their children since the entire subtree is hidden
238
+ if (props.display === "none") {
239
+ clearDirtyFlags(node)
240
+ return
241
+ }
242
+
243
+ // Skip nodes entirely off-screen (viewport clipping).
244
+ // The scroll container's VirtualList already handles most culling, but this
245
+ // catches any remaining nodes rendered below/above the visible area.
246
+ //
247
+ // IMPORTANT: Don't clear dirty flags on nodes that were never rendered.
248
+ // Just skip them and leave their flags intact so they render correctly
249
+ // when scrolled into view.
250
+ //
251
+ // Why this matters: clearDirtyFlags on off-screen nodes prevents them from
252
+ // rendering when they later become visible:
253
+ // 1. Node off-screen → clearDirtyFlags → all flags false
254
+ // 2. Scroll brings node on-screen with hasPrevBuffer=true
255
+ // 3. skipFastPath = true (all flags clean) → node SKIPPED
256
+ // 4. Buffer has stale/blank pixels → blank content visible
257
+ //
258
+ // By preserving dirty flags, the node forces rendering when it enters
259
+ // the visible area. The subtreeDirty flag on ancestors is maintained
260
+ // because we don't clear it — markSubtreeDirty() already set it during
261
+ // reconciliation/layout, and not clearing here preserves that signal.
262
+ const screenY = layout.y - scrollOffset
263
+ if (screenY >= buffer.height || screenY + layout.height <= 0) {
264
+ return
265
+ }
266
+
267
+ // FAST PATH: Skip entire subtree if unchanged and we have a previous buffer
268
+ // The buffer was cloned from prevBuffer, so skipped nodes keep their rendered output
269
+ //
270
+ // layoutChanged: did this node's layout position/size change?
271
+ // Uses layoutChangedThisFrame (set by propagateLayout in layout phase) instead of
272
+ // the stale !rectEqual(prevLayout, contentRect). The rect comparison is asymmetric
273
+ // between incremental and fresh renders: doFreshRender's layout phase syncs
274
+ // prevLayout=contentRect before content, making layoutChanged=false, while the
275
+ // real render may have prevLayout=null (new nodes), making layoutChanged=true.
276
+ // This asymmetry causes contentAreaAffected→clearNodeRegion to fire in incremental
277
+ // but not fresh, wiping sibling content. layoutChangedThisFrame is symmetric.
278
+ const layoutChanged = node.layoutChangedThisFrame
279
+
280
+ // Check if any child shifted position (sibling shift from size changes).
281
+ // Gap space between children belongs to this container, so must re-render.
282
+ const childPositionChanged = hasPrevBuffer && !layoutChanged && hasChildPositionChanged(node)
283
+
284
+ // FAST PATH: Skip unchanged subtrees when we have a valid previous buffer.
285
+ // The cloned buffer already has correct pixels for clean nodes.
286
+ // SILVERY_STRICT=1 verifies this by comparing incremental vs fresh renders.
287
+ //
288
+ // ancestorLayoutChanged: an ancestor's layout position/size changed this frame.
289
+ // Even if this node's own flags are clean, its pixels in the cloned buffer are
290
+ // at coordinates relative to the old ancestor layout. The node must re-render
291
+ // at its new absolute position. This is a safety net — normally the parent's
292
+ // parentRegionChanged cascade sets childHasPrev=false which prevents skipping,
293
+ // but ancestorLayoutChanged catches cases where the cascade doesn't propagate
294
+ // (e.g., ancestor with backgroundColor that breaks the ancestorCleared chain).
295
+ const skipFastPath =
296
+ hasPrevBuffer &&
297
+ !node.contentDirty &&
298
+ !node.paintDirty &&
299
+ !layoutChanged &&
300
+ !node.subtreeDirty &&
301
+ !node.childrenDirty &&
302
+ !childPositionChanged &&
303
+ !ancestorLayoutChanged
304
+
305
+ // Node ID for tracing (only trace named nodes to keep compact)
306
+ const _nodeId = instrumentEnabled ? ((props.id as string | undefined) ?? "") : ""
307
+ const _traceThis = instrumentEnabled && nodeTraceEnabled && _nodeId
308
+
309
+ // Cell debug: log nodes that cover the target cell
310
+ const _cellDbg = (globalThis as any).__silvery_cell_debug as { x: number; y: number; log: string[] } | undefined
311
+ const _coversCellNow =
312
+ _cellDbg &&
313
+ layout.x <= _cellDbg.x &&
314
+ layout.x + layout.width > _cellDbg.x &&
315
+ screenY <= _cellDbg.y &&
316
+ screenY + layout.height > _cellDbg.y
317
+ const _coversCellPrev =
318
+ _cellDbg &&
319
+ node.prevLayout &&
320
+ node.prevLayout.x <= _cellDbg.x &&
321
+ node.prevLayout.x + node.prevLayout.width > _cellDbg.x &&
322
+ node.prevLayout.y - scrollOffset <= _cellDbg.y &&
323
+ node.prevLayout.y - scrollOffset + node.prevLayout.height > _cellDbg.y
324
+
325
+ if (skipFastPath) {
326
+ if (_cellDbg && (_coversCellNow || _coversCellPrev)) {
327
+ const id = (props.id as string) ?? node.type
328
+ const depth = _getNodeDepth(node)
329
+ const prev = node.prevLayout
330
+ _cellDbg.log.push(
331
+ `SKIP ${id}@${depth} rect=${layout.x},${screenY} ${layout.width}x${layout.height}` +
332
+ ` prev=${prev ? `${prev.x},${prev.y - scrollOffset} ${prev.width}x${prev.height}` : "null"}` +
333
+ ` coversNow=${_coversCellNow} coversPrev=${_coversCellPrev}`,
334
+ )
335
+ }
336
+ if (instrumentEnabled) {
337
+ stats.nodesSkipped++
338
+ if (_traceThis) {
339
+ nodeTrace.push({
340
+ id: _nodeId,
341
+ type: node.type,
342
+ depth: _getNodeDepth(node),
343
+ rect: `${layout.x},${layout.y} ${layout.width}x${layout.height}`,
344
+ prevLayout: node.prevLayout
345
+ ? `${node.prevLayout.x},${node.prevLayout.y} ${node.prevLayout.width}x${node.prevLayout.height}`
346
+ : "null",
347
+ hasPrev: hasPrevBuffer,
348
+ ancestorCleared,
349
+ flags: "",
350
+ decision: "SKIPPED",
351
+ layoutChanged,
352
+ })
353
+ }
354
+ }
355
+ clearDirtyFlags(node)
356
+ return
357
+ }
358
+ if (instrumentEnabled) {
359
+ stats.nodesRendered++
360
+ if (!hasPrevBuffer) stats.noPrevBuffer++
361
+ if (node.contentDirty) stats.flagContentDirty++
362
+ if (node.paintDirty) stats.flagPaintDirty++
363
+ if (layoutChanged) stats.flagLayoutChanged++
364
+ if (node.subtreeDirty) stats.flagSubtreeDirty++
365
+ if (node.childrenDirty) stats.flagChildrenDirty++
366
+ if (childPositionChanged) stats.flagChildPositionChanged++
367
+ if (ancestorLayoutChanged) stats.flagAncestorLayoutChanged++
368
+ }
369
+
370
+ // Push per-subtree theme override (if this Box has a theme prop).
371
+ // Placed after all early returns and fast-path skip — only active during
372
+ // actual rendering. Popped at the end of this function after all child passes.
373
+ const nodeTheme = (props as BoxProps).theme as Theme | undefined
374
+ if (nodeTheme) pushContextTheme(nodeTheme)
375
+ try {
376
+ // Check if this is a scrollable container
377
+ const isScrollContainer = props.overflow === "scroll" && node.scrollState
378
+
379
+ // Does this node's OWN visual state need re-rendering?
380
+ // True when content/style changed, children restructured, or layout shifted.
381
+ // (Not true for subtreeDirty alone — that only means descendants changed.)
382
+ //
383
+ // Why paintDirty: measure phase may clear contentDirty for its text-collection
384
+ // cache, so paintDirty acts as a surviving witness that style props changed.
385
+ // Why this matters: when backgroundColor changes from "cyan" to undefined,
386
+ // paintDirty ensures we clear stale pixels from the cloned buffer.
387
+ // needsOwnRepaint = node.contentDirty || node.paintDirty || node.childrenDirty || layoutChanged || childPositionChanged
388
+
389
+ // contentAreaAffected: did this node's CONTENT AREA change (not just border)?
390
+ // Excludes border-only paint changes for BOX nodes: renderBox only draws border
391
+ // chars at edges, content area pixels are untouched. This avoids cascading ~200
392
+ // node re-renders per Card on cursor move (borderColor changes yellow↔blackBright
393
+ // but content area is unchanged).
394
+ //
395
+ // For TEXT nodes, paintDirty IS included because text nodes have no borders —
396
+ // any paint change (color, bold, inverse, or text content change) affects the
397
+ // content area. The measure phase clears contentDirty for its text-collection
398
+ // cache, so paintDirty acts as the surviving witness that the text node's
399
+ // content area changed and needs region clearing. Without this, stale pixels
400
+ // (e.g., cursor inverse attribute) persist when text content changes but
401
+ // layout dimensions stay the same.
402
+ //
403
+ // Uses bgDirty (set by reconciler when backgroundColor specifically changes) rather
404
+ // than checking current props.backgroundColor — catches bg removal (cyan → undefined)
405
+ // where current value is falsy but stale pixels must still be cleared.
406
+ const textPaintDirty = node.type === "silvery-text" && node.paintDirty
407
+
408
+ // absoluteChildMutated: an absolute child had its children added/removed/reordered,
409
+ // or its layout changed. In the two-pass rendering model (normal-flow first, absolute
410
+ // second), the cloned buffer contains BOTH first-pass content AND stale overlay pixels
411
+ // from the previous frame. When an absolute child's content structure changes (e.g.,
412
+ // a dialog unmounts), its old pixels persist at positions not covered by any current
413
+ // child. By including this in contentAreaAffected, the parent clears its region
414
+ // (removing stale overlay pixels in gap areas) and forces normal-flow children to
415
+ // re-render on the cleared background — matching fresh render behavior.
416
+ //
417
+ // Only checked when hasPrevBuffer (incremental mode) and subtreeDirty (a descendant
418
+ // changed somewhere). The scan is cheap: only direct children are checked.
419
+ const absoluteChildMutated =
420
+ hasPrevBuffer &&
421
+ node.subtreeDirty &&
422
+ node.children !== undefined &&
423
+ node.children.some((child) => {
424
+ const cp = child.props as BoxProps
425
+ return (
426
+ cp.position === "absolute" &&
427
+ (child.childrenDirty || child.layoutChangedThisFrame || hasChildPositionChanged(child))
428
+ )
429
+ })
430
+
431
+ // descendantOverflowChanged: a descendant (child, grandchild, etc.) was overflowing
432
+ // THIS node's rect in the previous frame and its layout changed (shrank/moved).
433
+ // clearExcessArea on the descendant clips to its immediate parent's content area,
434
+ // leaving stale pixels in THIS node's border/padding area and beyond THIS node's rect.
435
+ // By including this in contentAreaAffected, the node clears its region (removing stale
436
+ // overflow pixels, restoring borders) and forces children to re-render. The overflow
437
+ // area beyond the node's rect is handled by clearDescendantOverflowRegions.
438
+ //
439
+ // Recursive detection is critical: a grandchild overflowing a child AND this node
440
+ // must be caught at THIS level so the border is properly restored (depth 5 detects
441
+ // depth 7's overflow rather than depth 6 clearing into depth 5's border area).
442
+ const descendantOverflowChanged =
443
+ hasPrevBuffer && node.subtreeDirty && node.children !== undefined && hasDescendantOverflowChanged(node)
444
+
445
+ const contentAreaAffected =
446
+ node.contentDirty ||
447
+ layoutChanged ||
448
+ childPositionChanged ||
449
+ node.childrenDirty ||
450
+ node.bgDirty ||
451
+ textPaintDirty ||
452
+ absoluteChildMutated ||
453
+ descendantOverflowChanged
454
+
455
+ // subtreeDirtyWithBg: a descendant changed inside a Box with backgroundColor.
456
+ // When a child Text shrinks, trailing chars from the old longer text survive in
457
+ // the cloned buffer. The parent's bg fill must re-run to clear them, and children
458
+ // must re-render on top of the fresh fill. This is NOT added to contentAreaAffected
459
+ // because non-bg boxes don't need region clearing for subtreeDirty — only bg-bearing
460
+ // boxes need their fill to overwrite stale child pixels.
461
+ const subtreeDirtyWithBg = hasPrevBuffer && !contentAreaAffected && node.subtreeDirty && !!props.backgroundColor
462
+
463
+ // Clear this node's region when its content area changed but has no backgroundColor.
464
+ // Without bg, renderBox won't fill, so stale pixels from the cloned buffer
465
+ // remain visible. We must explicitly clear with inherited bg.
466
+ //
467
+ // Gated on (hasPrevBuffer || ancestorCleared) because:
468
+ // - hasPrevBuffer=true: buffer is a clone with stale pixels
469
+ // - ancestorCleared=true: buffer is a clone but hasPrevBuffer=false was passed
470
+ // (ancestor cleared its region, but this node may need to clear its sub-region)
471
+ // On a truly fresh buffer (first render), both are false — no wasteful clear.
472
+ const parentRegionCleared = (hasPrevBuffer || ancestorCleared) && contentAreaAffected && !props.backgroundColor
473
+
474
+ // skipBgFill: in incremental mode, skip the bg fill when the cloned buffer
475
+ // already has the correct bg at this node's position. That's ONLY when:
476
+ // - hasPrevBuffer=true (buffer is a clone with previous frame's pixels)
477
+ // - ancestorCleared=false (no ancestor erased our region)
478
+ // - contentAreaAffected=false (no content-area changes)
479
+ // - subtreeDirtyWithBg=false (no descendant change requiring bg refresh)
480
+ //
481
+ // Uses contentAreaAffected (not needsOwnRepaint) because border-only changes
482
+ // (paintDirty without bgDirty) don't change the bg fill — the cloned buffer
483
+ // already has the correct bg. Using needsOwnRepaint here caused bg fill to
484
+ // wipe child content on borderColor changes, while parentRegionChanged=false
485
+ // (from contentAreaAffected) prevented children from re-rendering to restore it.
486
+ //
487
+ // When ancestorCleared=true, the buffer at our position was erased to the
488
+ // inherited bg, NOT our bg — so we must re-fill.
489
+ // When hasPrevBuffer=false AND ancestorCleared=false, it's a fresh render.
490
+ const skipBgFill = hasPrevBuffer && !ancestorCleared && !contentAreaAffected && !subtreeDirtyWithBg
491
+
492
+ // parentRegionChanged: this node's content area was modified on a cloned buffer.
493
+ // Children must re-render (childHasPrev=false) because their pixels may be stale.
494
+ const parentRegionChanged = (hasPrevBuffer || ancestorCleared) && (contentAreaAffected || subtreeDirtyWithBg)
495
+
496
+ // DIAG: Per-node trace and cascade tracking (gated on instrumentation)
497
+ if (instrumentEnabled) {
498
+ if (_traceThis) {
499
+ const flagStr = [
500
+ node.contentDirty && "C",
501
+ node.paintDirty && "P",
502
+ node.bgDirty && "B",
503
+ node.subtreeDirty && "S",
504
+ node.childrenDirty && "Ch",
505
+ childPositionChanged && "CP",
506
+ ]
507
+ .filter(Boolean)
508
+ .join(",")
509
+ const childrenNeedRepaint_ = node.childrenDirty || childPositionChanged || parentRegionChanged
510
+ const childHasPrev_ = childrenNeedRepaint_ ? false : hasPrevBuffer
511
+ const childAncestorCleared_ = parentRegionCleared || (ancestorCleared && !props.backgroundColor)
512
+ nodeTrace.push({
513
+ id: _nodeId,
514
+ type: node.type,
515
+ depth: _getNodeDepth(node),
516
+ rect: `${layout.x},${layout.y} ${layout.width}x${layout.height}`,
517
+ prevLayout: node.prevLayout
518
+ ? `${node.prevLayout.x},${node.prevLayout.y} ${node.prevLayout.width}x${node.prevLayout.height}`
519
+ : "null",
520
+ hasPrev: hasPrevBuffer,
521
+ ancestorCleared,
522
+ flags: flagStr,
523
+ decision: "RENDER",
524
+ layoutChanged,
525
+ contentAreaAffected,
526
+ parentRegionCleared,
527
+ parentRegionChanged,
528
+ childHasPrev: childHasPrev_,
529
+ childAncestorCleared: childAncestorCleared_,
530
+ skipBgFill,
531
+ bgColor: props.backgroundColor as string | undefined,
532
+ })
533
+ }
534
+ if (parentRegionChanged && node.children.length > 0) {
535
+ const depth = _getNodeDepth(node)
536
+ if (depth < stats.cascadeMinDepth) {
537
+ stats.cascadeMinDepth = depth
538
+ }
539
+ const id = (node.props as Record<string, unknown>).id ?? node.type
540
+ const flags = [
541
+ node.contentDirty && "C",
542
+ node.paintDirty && "P",
543
+ node.childrenDirty && "Ch",
544
+ layoutChanged && "L",
545
+ childPositionChanged && "CP",
546
+ ]
547
+ .filter(Boolean)
548
+ .join("")
549
+ const entry = `${id}@${depth}[${flags}:${node.children.length}ch]`
550
+ stats.cascadeNodes += (stats.cascadeNodes ? " " : "") + entry
551
+ }
552
+ }
553
+
554
+ // Cell debug: log render decision for nodes covering target cell
555
+ if (_cellDbg && (_coversCellNow || _coversCellPrev)) {
556
+ const id = (props.id as string) ?? node.type
557
+ const depth = _getNodeDepth(node)
558
+ const prev = node.prevLayout
559
+ const flags = [
560
+ node.contentDirty && "C",
561
+ node.paintDirty && "P",
562
+ layoutChanged && "L",
563
+ node.subtreeDirty && "S",
564
+ node.childrenDirty && "Ch",
565
+ childPositionChanged && "CP",
566
+ node.bgDirty && "B",
567
+ ]
568
+ .filter(Boolean)
569
+ .join(",")
570
+ _cellDbg.log.push(
571
+ `RENDER ${id}@${depth} rect=${layout.x},${screenY} ${layout.width}x${layout.height}` +
572
+ ` prev=${prev ? `${prev.x},${prev.y - scrollOffset} ${prev.width}x${prev.height}` : "null"}` +
573
+ ` flags=[${flags}] hasPrev=${hasPrevBuffer} ancClr=${ancestorCleared}` +
574
+ ` caa=${contentAreaAffected} prc=${parentRegionCleared} prm=${parentRegionChanged}` +
575
+ ` coversNow=${_coversCellNow} coversPrev=${_coversCellPrev}` +
576
+ ` bg=${props.backgroundColor ?? "none"}`,
577
+ )
578
+ }
579
+
580
+ if (parentRegionCleared) {
581
+ if (instrumentEnabled) stats.clearOps++
582
+ clearNodeRegion(node, buffer, layout, scrollOffset, clipBounds, layoutChanged)
583
+ } else if (bufferIsCloned && layoutChanged && node.prevLayout) {
584
+ // Even when parentRegionCleared is false, a shrinking node needs its excess
585
+ // area cleared. Key scenario: absolute-positioned overlays (e.g., search dialog)
586
+ // that shrink while normal-flow siblings are dirty — forceRepaint sets
587
+ // hasPrevBuffer=false + ancestorCleared=false, making parentRegionCleared=false,
588
+ // but the cloned buffer still has stale pixels from the old larger layout.
589
+ // Also applies to nodes WITH backgroundColor: renderBox fills only the NEW
590
+ // (smaller) region, leaving stale pixels in the excess area.
591
+ //
592
+ // Gated on bufferIsCloned: on a fresh buffer (e.g., multi-pass resize where
593
+ // dimensions changed between passes), there are no stale pixels to clear.
594
+ // Without this guard, clearExcessArea writes inherited bg into cells that
595
+ // doFreshRender leaves as default, causing STRICT mismatches.
596
+ clearExcessArea(node, buffer, layout, scrollOffset, clipBounds, layoutChanged)
597
+ }
598
+
599
+ // Clear descendant overflow regions: areas where descendants' previous layouts
600
+ // extended beyond THIS node's rect. clearNodeRegion covers the node's interior,
601
+ // but overflow content is beyond it. This is separate from parentRegionCleared
602
+ // because overflow is OUTSIDE the rect — it needs clearing even for nodes with
603
+ // backgroundColor (whose interior is handled by renderBox's bg fill).
604
+ // Runs BEFORE renderBox so borders drawn by renderBox aren't overwritten.
605
+ if (descendantOverflowChanged) {
606
+ clearDescendantOverflowRegions(node, buffer, layout, scrollOffset, clipBounds)
607
+ }
608
+
609
+ // Compute inherited bg once for boxes — used by border and outline rendering
610
+ // to preserve parent backgrounds on border cells (prevents transparent holes).
611
+ const boxInheritedBg =
612
+ node.type === "silvery-box" && !props.backgroundColor ? findInheritedBg(node).color : undefined
613
+
614
+ // Render based on node type
615
+ if (node.type === "silvery-box") {
616
+ if (instrumentEnabled) stats.boxNodes++
617
+ renderBox(node, buffer, layout, props, nodeState, skipBgFill, boxInheritedBg)
618
+ } else if (node.type === "silvery-text") {
619
+ if (instrumentEnabled) stats.textNodes++
620
+ // Pass inherited bg/fg from nearest ancestor with backgroundColor/color.
621
+ // inheritedBg decouples text rendering from buffer state, which is critical
622
+ // for incremental rendering: the cloned buffer may have stale bg at positions
623
+ // outside the parent's bg-filled region (e.g., overflow text, moved nodes).
624
+ // Foreground inheritance matches CSS semantics: Box color cascades to Text children.
625
+ const textInheritedBg = findInheritedBg(node).color
626
+ const textInheritedFg = findInheritedFg(node)
627
+ renderText(node, buffer, layout, props, nodeState, textInheritedBg, textInheritedFg, ctx)
628
+ }
629
+
630
+ // Render children
631
+ if (isScrollContainer) {
632
+ renderScrollContainerChildren(node, buffer, props, nodeState, parentRegionCleared, parentRegionChanged, ctx)
633
+
634
+ // Render overflow indicators AFTER children so they survive viewport clear.
635
+ // renderScrollContainerChildren may clear the viewport (Tier 2) which would
636
+ // overwrite indicators drawn before children.
637
+ renderScrollIndicators(node, buffer, layout, props, node.scrollState!, ctx)
638
+ } else {
639
+ renderNormalChildren(
640
+ node,
641
+ buffer,
642
+ props,
643
+ nodeState,
644
+ childPositionChanged,
645
+ parentRegionCleared,
646
+ parentRegionChanged,
647
+ ctx,
648
+ )
649
+ }
650
+
651
+ // Render outline AFTER children — outline overlaps content at edges
652
+ if (node.type === "silvery-box" && props.outlineStyle) {
653
+ const { x, width, height } = layout
654
+ const y = layout.y - scrollOffset
655
+ renderOutline(buffer, x, y, width, height, props, clipBounds, boxInheritedBg)
656
+ }
657
+
658
+ // Clear dirty flags (current node only — children clear their own when rendered)
659
+ clearNodeDirtyFlags(node)
660
+ } finally {
661
+ // Pop per-subtree theme override (after ALL child passes including absolute/sticky)
662
+ if (nodeTheme) popContextTheme()
663
+ }
664
+ }
665
+
666
+ /**
667
+ * Render children of a scroll container with proper clipping and offset.
668
+ */
669
+ function renderScrollContainerChildren(
670
+ node: TeaNode,
671
+ buffer: TerminalBuffer,
672
+ props: BoxProps,
673
+ nodeState: NodeRenderState,
674
+ parentRegionCleared = false,
675
+ parentRegionChanged = false,
676
+ ctx?: PipelineContext,
677
+ ): void {
678
+ const { clipBounds, hasPrevBuffer, ancestorCleared, bufferIsCloned, ancestorLayoutChanged } = nodeState
679
+ // Resolve instrumentation from ctx or module globals
680
+ const instrumentEnabled = ctx?.instrumentEnabled ?? _instrumentEnabled
681
+ const stats = ctx?.stats ?? _contentPhaseStats
682
+ const layout = node.contentRect
683
+ const ss = node.scrollState
684
+ if (!layout || !ss) return
685
+
686
+ const border = props.borderStyle ? getBorderSize(props) : { top: 0, bottom: 0, left: 0, right: 0 }
687
+ const padding = getPadding(props)
688
+ // Scroll containers clip vertically (for scrolling) but NOT horizontally.
689
+ // Horizontal clipping is only for overflow="hidden" containers (e.g., HVL).
690
+ const childClipBounds = computeChildClipBounds(
691
+ layout,
692
+ props,
693
+ clipBounds,
694
+ 0,
695
+ /* horizontal */ false,
696
+ /* vertical */ true,
697
+ )
698
+
699
+ // Determine if scroll offset changed since last render.
700
+ const scrollOffsetChanged = ss.offset !== ss.prevOffset
701
+
702
+ // Three-tier strategy for scroll container updates:
703
+ //
704
+ // 1. Buffer shift (scrollOnly): scroll offset changed but nothing else.
705
+ // Shift buffer contents by scroll delta, then re-render only newly
706
+ // visible children. Previously visible children keep their shifted pixels.
707
+ // This avoids re-rendering the entire viewport on every scroll.
708
+ //
709
+ // 2. Full viewport clear: children restructured or parent region changed.
710
+ // Must clear viewport and re-render all visible children.
711
+ // NOTE: subtreeDirty alone does NOT require viewport clear — dirty
712
+ // descendants handle their own region clearing. Clearing for subtreeDirty
713
+ // caused a 12ms regression (re-rendering ~50 children vs 2 dirty ones).
714
+ //
715
+ // 3. No clear needed: only subtreeDirty (some descendants changed).
716
+ // Children use hasPrevBuffer=true and skip via fast-path if clean.
717
+ //
718
+ // IMPORTANT: Buffer shift is unsafe when sticky children exist. Sticky
719
+ // children render in a second pass that overwrites first-pass content.
720
+ // After a shift, these overwritten pixels corrupt items at new positions
721
+ // that skip rendering (hasPrevBuffer=true, no dirty flags). Fall back
722
+ // to full viewport clear (tier 2) when sticky children are present.
723
+ const hasStickyChildren = !!(ss.stickyChildren && ss.stickyChildren.length > 0)
724
+ // Detect when visible range changed (items became hidden or newly visible).
725
+ // When lastVisibleChild decreases, stale pixels from now-hidden items remain
726
+ // in the cloned buffer and must be cleared.
727
+ const visibleRangeChanged =
728
+ ss.firstVisibleChild !== ss.prevFirstVisibleChild || ss.lastVisibleChild !== ss.prevLastVisibleChild
729
+ const scrollOnly =
730
+ hasPrevBuffer &&
731
+ scrollOffsetChanged &&
732
+ !node.childrenDirty &&
733
+ !parentRegionChanged &&
734
+ !hasStickyChildren &&
735
+ !visibleRangeChanged
736
+ const needsViewportClear =
737
+ hasPrevBuffer &&
738
+ !scrollOnly &&
739
+ (scrollOffsetChanged || node.childrenDirty || parentRegionChanged || visibleRangeChanged)
740
+
741
+ if (instrumentEnabled) {
742
+ stats.scrollContainerCount++
743
+ if (needsViewportClear || scrollOnly) {
744
+ stats.scrollViewportCleared++
745
+ const reasons: string[] = []
746
+ if (scrollOnly) reasons.push("SHIFT")
747
+ if (scrollOffsetChanged) reasons.push(`scrollOffset(${ss.prevOffset}->${ss.offset})`)
748
+ if (node.childrenDirty) reasons.push("childrenDirty")
749
+ if (parentRegionChanged) reasons.push("parentRegionChanged")
750
+ reasons.push(
751
+ `vp=${ss.viewportHeight} content=${ss.contentHeight} vis=${ss.firstVisibleChild}-${ss.lastVisibleChild}`,
752
+ )
753
+ stats.scrollClearReason = reasons.join("+")
754
+ }
755
+ }
756
+
757
+ // STRICT invariant: Scroll Tier 1 (buffer shift) must never be used when sticky
758
+ // children exist. Sticky children render in a second pass that overwrites first-pass
759
+ // content — after a buffer shift, those overwritten pixels corrupt items at new
760
+ // positions. If this fires, the scrollOnly guard above has a logic error.
761
+ if (process.env.SILVERY_STRICT && scrollOnly && hasStickyChildren) {
762
+ throw new Error(
763
+ `[SILVERY_STRICT] Scroll Tier 1 (buffer shift) activated with sticky children ` +
764
+ `(node: ${(props.id as string | undefined) ?? node.type}, ` +
765
+ `stickyCount: ${ss.stickyChildren?.length ?? 0})`,
766
+ )
767
+ }
768
+
769
+ // Compute viewport geometry (shared by both paths)
770
+ const clearY = childClipBounds.top
771
+ const clearHeight = childClipBounds.bottom - childClipBounds.top
772
+ const contentX = layout.x + border.left + padding.left
773
+ const contentWidth = layout.width - border.left - border.right - padding.left - padding.right
774
+ const scrollBg =
775
+ needsViewportClear || scrollOnly
776
+ ? props.backgroundColor
777
+ ? parseColor(props.backgroundColor)
778
+ : findInheritedBg(node).color
779
+ : null
780
+
781
+ // Buffer shift: shift viewport contents instead of full clear.
782
+ // After shift, previously-visible children's pixels are at correct positions.
783
+ // Exposed rows (top/bottom edge) are filled with scrollBg (null = no bg).
784
+ const scrollDelta = ss.offset - (ss.prevOffset ?? ss.offset)
785
+ if (scrollOnly && clearHeight > 0) {
786
+ // Clear scroll indicator rows before shifting. Borderless scroll indicators
787
+ // (overflowIndicator) paint a full-width bar (fg=15/bg=8) on the first/last
788
+ // content rows. Children may be narrower than the indicator bar, so after a
789
+ // shift, stale indicator pixels at the edges (columns not covered by children)
790
+ // persist and cause incremental vs fresh render mismatches.
791
+ // Clearing these rows to scrollBg before the shift ensures the shift carries
792
+ // correct bg. The indicators are re-rendered after children by
793
+ // renderScrollIndicators.
794
+ const showBorderless = props.overflowIndicator === true
795
+ if (showBorderless && !border.top && !border.bottom) {
796
+ const topIndicatorY = clearY
797
+ const bottomIndicatorY = clearY + clearHeight - 1
798
+ if (ss.prevOffset != null && ss.prevOffset > 0) {
799
+ // Previous frame had items hidden above → top indicator was showing
800
+ buffer.fill(contentX, topIndicatorY, contentWidth, 1, { char: " ", bg: scrollBg })
801
+ }
802
+ // Previous frame had items hidden below → bottom indicator was showing
803
+ // (safe to always clear bottom row since it will be re-rendered)
804
+ buffer.fill(contentX, bottomIndicatorY, contentWidth, 1, { char: " ", bg: scrollBg })
805
+ }
806
+ buffer.scrollRegion(contentX, clearY, contentWidth, clearHeight, scrollDelta, {
807
+ char: " ",
808
+ bg: scrollBg,
809
+ })
810
+ }
811
+
812
+ // Full viewport clear (tier 2)
813
+ if (needsViewportClear && clearHeight > 0) {
814
+ buffer.fill(contentX, clearY, contentWidth, clearHeight, {
815
+ char: " ",
816
+ bg: scrollBg,
817
+ })
818
+ }
819
+
820
+ // Determine per-child hasPrev and ancestorCleared.
821
+ // - scrollOnly: per-child based on previous visibility
822
+ // - needsViewportClear: all false (full re-render)
823
+ // - otherwise: preserve parent's hasPrevBuffer
824
+ const defaultChildHasPrev = needsViewportClear ? false : hasPrevBuffer
825
+ const defaultChildAncestorCleared = needsViewportClear ? true : ancestorCleared || parentRegionCleared
826
+
827
+ // Propagate ancestor layout change to scroll container children.
828
+ const childAncestorLayoutChanged = node.layoutChangedThisFrame || !!ancestorLayoutChanged
829
+
830
+ // For buffer shift: children that were fully visible in BOTH the previous
831
+ // and current frames have correct pixels after the shift (childHasPrev=true).
832
+ // Newly visible children need full rendering (childHasPrev=false).
833
+ const prevVisTop = ss.prevOffset ?? ss.offset
834
+ const prevVisBottom = prevVisTop + ss.viewportHeight
835
+
836
+ // When sticky children exist and we're in tier 3 (subtreeDirty only, no
837
+ // viewport clear), force ALL first-pass items to re-render. This is needed
838
+ // because sticky headers render in a second pass that overwrites first-pass
839
+ // content. On a fresh render, the buffer has correct first-pass content at
840
+ // all positions. On incremental renders, the cloned buffer may have stale
841
+ // content from PREVIOUS frames' sticky headers at various positions — both
842
+ // current AND former sticky positions. Forcing all items to re-render
843
+ // ensures the buffer matches fresh render state before the sticky pass.
844
+ //
845
+ // Performance: this re-renders all visible items (~20-50) instead of just
846
+ // dirty ones (~2-3). Only applies to scroll containers with sticky children
847
+ // in tier 3 (not tier 1/2 which already handle this via shift/viewport clear).
848
+ const stickyForceRefresh = hasStickyChildren && hasPrevBuffer && !needsViewportClear
849
+
850
+ // Full viewport clear for sticky containers: clear to blank (bg=null) to
851
+ // match fresh buffer state. The cloned buffer has stale sticky header content
852
+ // from previous frames at positions that may have moved. Pre-clearing only
853
+ // current sticky positions is insufficient because stale bg from old sticky
854
+ // positions leaks through to Text nodes via inheritedBg (getCellBg is legacy fallback only).
855
+ // Clearing the entire viewport ensures a clean slate matching fresh render
856
+ // behavior (null bg everywhere before any content renders).
857
+ //
858
+ // Uses bg=null (not scrollBg/inherited bg) because fresh render starts with
859
+ // a blank buffer — the viewport has null bg before any content renders.
860
+ if (stickyForceRefresh && clearHeight > 0) {
861
+ buffer.fill(contentX, clearY, contentWidth, clearHeight, { char: " ", bg: null })
862
+ }
863
+
864
+ // First pass: render non-sticky visible children with scroll offset
865
+ for (let i = 0; i < node.children.length; i++) {
866
+ const child = node.children[i]
867
+ if (!child) continue
868
+ const childProps = child.props as BoxProps
869
+
870
+ // Skip sticky children - they're rendered in second pass
871
+ if (childProps.position === "sticky") {
872
+ continue
873
+ }
874
+
875
+ // Skip children that are completely outside the visible range
876
+ if (i < ss.firstVisibleChild || i > ss.lastVisibleChild) {
877
+ continue
878
+ }
879
+
880
+ // Determine per-child hasPrev for buffer shift mode
881
+ let thisChildHasPrev = defaultChildHasPrev
882
+ let thisChildAncestorCleared = defaultChildAncestorCleared
883
+ if (scrollOnly) {
884
+ // Check if child was fully visible in the previous frame
885
+ const childRect = child.contentRect
886
+ if (childRect) {
887
+ const childTop = childRect.y - layout.y - border.top - padding.top
888
+ const childBottom = childTop + childRect.height
889
+ const wasFullyVisible = childTop >= prevVisTop && childBottom <= prevVisBottom
890
+ thisChildHasPrev = wasFullyVisible
891
+ // Shifted children: their pixels are intact (not cleared)
892
+ // Newly visible: exposed region was filled by scrollRegion
893
+ thisChildAncestorCleared = wasFullyVisible ? ancestorCleared || parentRegionCleared : true
894
+ }
895
+ }
896
+
897
+ // Force fresh rendering when sticky children exist (see stickyForceRefresh).
898
+ if (stickyForceRefresh && thisChildHasPrev) {
899
+ thisChildHasPrev = false
900
+ thisChildAncestorCleared = false
901
+ }
902
+
903
+ // Render visible children with scroll offset applied.
904
+ renderNodeToBuffer(
905
+ child,
906
+ buffer,
907
+ {
908
+ scrollOffset: ss.offset,
909
+ clipBounds: childClipBounds,
910
+ hasPrevBuffer: thisChildHasPrev,
911
+ ancestorCleared: thisChildAncestorCleared,
912
+ bufferIsCloned,
913
+ ancestorLayoutChanged: childAncestorLayoutChanged,
914
+ },
915
+ ctx,
916
+ )
917
+ }
918
+
919
+ // Second pass: render sticky children at their computed positions
920
+ // Rendered last so they appear on top of other content
921
+ if (ss.stickyChildren) {
922
+ for (const sticky of ss.stickyChildren) {
923
+ const child = node.children[sticky.index]
924
+ if (!child?.contentRect) continue
925
+
926
+ // Calculate the scroll offset that would place the child at its sticky position
927
+ // stickyOffset = naturalTop - renderOffset
928
+ // This makes the child render at renderOffset instead of its natural position
929
+ const stickyScrollOffset = sticky.naturalTop - sticky.renderOffset
930
+
931
+ // Sticky children always re-render (hasPrevBuffer=false) since their
932
+ // effective scroll offset may change even when the container's doesn't.
933
+ //
934
+ // ancestorCleared=false matches fresh render semantics: on a fresh render,
935
+ // the buffer at sticky positions has first-pass content (not "cleared").
936
+ // Using ancestorCleared=true would cause transparent spacer Boxes to clear
937
+ // their region (via layoutChanged=true from prevLayout=null → cascading
938
+ // parentRegionCleared), wiping overlapping sticky headers rendered earlier
939
+ // in this pass.
940
+ //
941
+ // Stale bg from previous frames is handled by the stickyForceRefresh
942
+ // pre-clear above, which ensures correct bg is in the buffer before sticky
943
+ // children render on top of first-pass content.
944
+ renderNodeToBuffer(
945
+ child,
946
+ buffer,
947
+ {
948
+ scrollOffset: stickyScrollOffset,
949
+ clipBounds: childClipBounds,
950
+ hasPrevBuffer: false,
951
+ ancestorCleared: false,
952
+ bufferIsCloned,
953
+ ancestorLayoutChanged: childAncestorLayoutChanged,
954
+ },
955
+ ctx,
956
+ )
957
+ }
958
+ }
959
+ }
960
+
961
+ /**
962
+ * Render children of a normal (non-scroll) container.
963
+ */
964
+ function renderNormalChildren(
965
+ node: TeaNode,
966
+ buffer: TerminalBuffer,
967
+ props: BoxProps,
968
+ nodeState: NodeRenderState,
969
+ childPositionChanged = false,
970
+ parentRegionCleared = false,
971
+ parentRegionChanged = false,
972
+ ctx?: PipelineContext,
973
+ ): void {
974
+ const { scrollOffset, clipBounds, hasPrevBuffer, ancestorCleared, bufferIsCloned, ancestorLayoutChanged } = nodeState
975
+ // Resolve instrumentation from ctx or module globals
976
+ const instrumentEnabled = ctx?.instrumentEnabled ?? _instrumentEnabled
977
+ const stats = ctx?.stats ?? _contentPhaseStats
978
+ const layout = node.contentRect
979
+ if (!layout) return
980
+
981
+ // For overflow='hidden' containers, clip children to content area.
982
+ // Supports per-axis clipping: overflowX/overflowY override the shorthand overflow prop.
983
+ const clipX = (props.overflowX ?? props.overflow) === "hidden"
984
+ const clipY = (props.overflowY ?? props.overflow) === "hidden"
985
+ const effectiveClipBounds =
986
+ clipX || clipY ? computeChildClipBounds(layout, props, clipBounds, scrollOffset, clipX, clipY) : clipBounds
987
+
988
+ // Non-scroll sticky children support. When the layout phase computes
989
+ // node.stickyChildren, we use the same two-pass pattern as scroll containers:
990
+ // first pass renders non-sticky children, second pass renders sticky children
991
+ // at their computed renderOffset positions.
992
+ const hasStickyChildren = !!(node.stickyChildren && node.stickyChildren.length > 0)
993
+
994
+ // When sticky children exist and hasPrevBuffer is true, force all first-pass
995
+ // children to re-render. The cloned buffer may have stale pixels from previous
996
+ // frames' sticky positions. This matches the stickyForceRefresh pattern from
997
+ // scroll containers (Tier 3).
998
+ const stickyForceRefresh = hasStickyChildren && hasPrevBuffer
999
+
1000
+ // Pre-clear the content area to bg=null when stickyForceRefresh is true.
1001
+ // Fresh renders start with a blank buffer (null bg everywhere). The cloned
1002
+ // buffer has stale content from old sticky positions that would leak through
1003
+ // on incremental renders. Clearing to null matches fresh render state before
1004
+ // any content renders.
1005
+ if (stickyForceRefresh) {
1006
+ const border = props.borderStyle ? getBorderSize(props) : { top: 0, bottom: 0, left: 0, right: 0 }
1007
+ const padding = getPadding(props)
1008
+ let clearX = layout.x + border.left + padding.left
1009
+ let clearY = layout.y - scrollOffset + border.top + padding.top
1010
+ let clearW = layout.width - border.left - border.right - padding.left - padding.right
1011
+ let clearH = layout.height - border.top - border.bottom - padding.top - padding.bottom
1012
+ // Clip to clipBounds (same discipline as scroll container clear)
1013
+ if (clipBounds) {
1014
+ const clipTop = clipBounds.top
1015
+ const clipBottom = clipBounds.bottom
1016
+ if (clearY < clipTop) {
1017
+ clearH -= clipTop - clearY
1018
+ clearY = clipTop
1019
+ }
1020
+ if (clearY + clearH > clipBottom) {
1021
+ clearH = clipBottom - clearY
1022
+ }
1023
+ if (clipBounds.left !== undefined && clearX < clipBounds.left) {
1024
+ clearW -= clipBounds.left - clearX
1025
+ clearX = clipBounds.left
1026
+ }
1027
+ if (clipBounds.right !== undefined && clearX + clearW > clipBounds.right) {
1028
+ clearW = clipBounds.right - clearX
1029
+ }
1030
+ }
1031
+ if (clearW > 0 && clearH > 0) {
1032
+ buffer.fill(clearX, clearY, clearW, clearH, { char: " ", bg: null })
1033
+ }
1034
+ }
1035
+
1036
+ // Force children to re-render when parent's region was modified on a clone,
1037
+ // children were restructured, or sibling positions shifted.
1038
+ const childrenNeedRepaint = node.childrenDirty || childPositionChanged || parentRegionChanged
1039
+ if (instrumentEnabled && childrenNeedRepaint && hasPrevBuffer) {
1040
+ stats.normalChildrenRepaint++
1041
+ const reasons: string[] = []
1042
+ if (node.childrenDirty) reasons.push("childrenDirty")
1043
+ if (childPositionChanged) reasons.push("childPositionChanged")
1044
+ if (parentRegionChanged) reasons.push("parentRegionChanged")
1045
+ stats.normalRepaintReason = reasons.join("+")
1046
+ }
1047
+ let childHasPrev = childrenNeedRepaint ? false : hasPrevBuffer
1048
+ // childAncestorCleared: tells descendants that STALE pixels exist in the buffer.
1049
+ // Only parentRegionCleared (no bg fill → stale pixels remain) propagates this.
1050
+ // parentRegionChanged WITHOUT parentRegionCleared means the parent filled its bg,
1051
+ // so children's positions have correct bg — NOT stale. Setting ancestorCleared
1052
+ // there would cause children to re-fill, overwriting border cells at boundaries.
1053
+ // When this node has backgroundColor, its renderBox fill covers any stale
1054
+ // pixels from ancestor clears — so children don't need ancestorCleared.
1055
+ let childAncestorCleared = parentRegionCleared || (ancestorCleared && !props.backgroundColor)
1056
+
1057
+ // Propagate ancestor layout change to children: if this node or any ancestor
1058
+ // had layoutChangedThisFrame, children must not be skipped even if their own
1059
+ // flags are clean — their pixels in the cloned buffer are at wrong positions.
1060
+ const childAncestorLayoutChanged = node.layoutChangedThisFrame || !!ancestorLayoutChanged
1061
+
1062
+ // Override child flags when sticky force refresh is active — all first-pass
1063
+ // children must re-render fresh (matching the scroll container pattern).
1064
+ if (stickyForceRefresh) {
1065
+ childHasPrev = false
1066
+ childAncestorCleared = false
1067
+ }
1068
+
1069
+ // Multi-pass rendering to match CSS paint order:
1070
+ // 1. Normal-flow children (skip sticky and absolute)
1071
+ // 2. Sticky children at computed positions (on top of normal-flow)
1072
+ // 3. Absolute children on top of everything
1073
+ //
1074
+ // This ensures absolute children's pixels (bg fills, text) are never
1075
+ // overwritten by normal-flow siblings' clearNodeRegion/render.
1076
+ //
1077
+ // Pre-scan: detect if any non-absolute, non-sticky sibling is dirty. When
1078
+ // true, absolute children in the third pass must force-repaint because the
1079
+ // first pass may have overwritten their pixels in the cloned buffer.
1080
+ let hasAbsoluteChildren = false
1081
+
1082
+ // First pass: render normal-flow children (skip sticky + absolute), track dirty state
1083
+ for (const child of node.children) {
1084
+ const childProps = child.props as BoxProps
1085
+ if (childProps.position === "absolute") {
1086
+ hasAbsoluteChildren = true
1087
+ continue // Skip — rendered in third pass
1088
+ }
1089
+ if (hasStickyChildren && childProps.position === "sticky") {
1090
+ continue // Skip — rendered in second pass
1091
+ }
1092
+
1093
+ renderNodeToBuffer(
1094
+ child,
1095
+ buffer,
1096
+ {
1097
+ scrollOffset,
1098
+ clipBounds: effectiveClipBounds,
1099
+ hasPrevBuffer: childHasPrev,
1100
+ ancestorCleared: childAncestorCleared,
1101
+ bufferIsCloned,
1102
+ ancestorLayoutChanged: childAncestorLayoutChanged,
1103
+ },
1104
+ ctx,
1105
+ )
1106
+ }
1107
+
1108
+ // Second pass: render sticky children at their computed positions.
1109
+ // Rendered after normal-flow so they appear on top of other content.
1110
+ if (node.stickyChildren) {
1111
+ for (const sticky of node.stickyChildren) {
1112
+ const child = node.children[sticky.index]
1113
+ if (!child?.contentRect) continue
1114
+
1115
+ // Calculate the scroll offset that would place the child at its sticky position.
1116
+ // stickyScrollOffset = naturalTop - renderOffset
1117
+ // This makes the child render at renderOffset instead of its natural position.
1118
+ const stickyScrollOffset = sticky.naturalTop - sticky.renderOffset
1119
+
1120
+ // Sticky children always re-render (hasPrevBuffer=false) since their
1121
+ // effective position may change between frames.
1122
+ //
1123
+ // ancestorCleared=false matches fresh render semantics: on a fresh render,
1124
+ // the buffer at sticky positions has first-pass content (not "cleared").
1125
+ // Using ancestorCleared=true would cause transparent spacer Boxes to clear
1126
+ // their region, wiping overlapping sticky headers rendered earlier in this pass.
1127
+ //
1128
+ // ancestorLayoutChanged propagated so descendants know to re-render.
1129
+ renderNodeToBuffer(
1130
+ child,
1131
+ buffer,
1132
+ {
1133
+ scrollOffset: stickyScrollOffset,
1134
+ clipBounds: effectiveClipBounds,
1135
+ hasPrevBuffer: false,
1136
+ ancestorCleared: false,
1137
+ bufferIsCloned,
1138
+ ancestorLayoutChanged: childAncestorLayoutChanged,
1139
+ },
1140
+ ctx,
1141
+ )
1142
+ }
1143
+ }
1144
+
1145
+ // Third pass: render absolute children on top (CSS paint order)
1146
+ if (hasAbsoluteChildren) {
1147
+ for (const child of node.children) {
1148
+ const childProps = child.props as BoxProps
1149
+ if (childProps.position !== "absolute") continue
1150
+
1151
+ // Both hasPrevBuffer and ancestorCleared must be false for absolute children
1152
+ // in the second pass. The buffer at the absolute child's position contains
1153
+ // first-pass content (normal-flow siblings), not "previous frame" content.
1154
+ // This is conceptually a fresh render at the absolute child's position:
1155
+ //
1156
+ // - hasPrevBuffer=false: prevents parentRegionCleared from firing.
1157
+ // Without this, a transparent overlay (no backgroundColor) that changes
1158
+ // (contentAreaAffected=true) would clear its entire region, wiping the
1159
+ // normal-flow content painted in the first pass. On a fresh render,
1160
+ // hasPrevBuffer=false prevents clearing, so this matches.
1161
+ //
1162
+ // - ancestorCleared=false: prevents transparent descendants from clearing
1163
+ // their regions, which would also wipe first-pass content.
1164
+ renderNodeToBuffer(
1165
+ child,
1166
+ buffer,
1167
+ {
1168
+ scrollOffset,
1169
+ clipBounds: effectiveClipBounds,
1170
+ hasPrevBuffer: false,
1171
+ ancestorCleared: false,
1172
+ bufferIsCloned,
1173
+ ancestorLayoutChanged: childAncestorLayoutChanged,
1174
+ },
1175
+ ctx,
1176
+ )
1177
+ }
1178
+ }
1179
+ }
1180
+
1181
+ // ============================================================================
1182
+ // Helpers
1183
+ // ============================================================================
1184
+
1185
+ /**
1186
+ * Clear dirty flags on the current node only (no recursion).
1187
+ * Used after rendering a node to reset its flags.
1188
+ */
1189
+ function clearNodeDirtyFlags(node: TeaNode): void {
1190
+ node.contentDirty = false
1191
+ node.paintDirty = false
1192
+ node.bgDirty = false
1193
+ node.subtreeDirty = false
1194
+ node.childrenDirty = false
1195
+ node.layoutChangedThisFrame = false
1196
+ }
1197
+
1198
+ /**
1199
+ * Clear dirty flags on a subtree that was skipped during incremental rendering.
1200
+ */
1201
+ function clearDirtyFlags(node: TeaNode): void {
1202
+ clearNodeDirtyFlags(node)
1203
+ for (const child of node.children) {
1204
+ if (child.layoutNode) {
1205
+ clearDirtyFlags(child)
1206
+ } else {
1207
+ // Virtual text children also need flags cleared — they're rendered by
1208
+ // their parent's collectTextContent(), not by renderNodeToBuffer().
1209
+ clearVirtualTextFlags(child)
1210
+ }
1211
+ }
1212
+ }
1213
+
1214
+ /**
1215
+ * Clear dirty flags on a virtual text node and its descendants.
1216
+ * Virtual text nodes (no layoutNode) are rendered by their parent layout
1217
+ * ancestor via collectTextContent(). Their dirty flags must be cleared
1218
+ * after the parent renders, otherwise stale subtreeDirty blocks
1219
+ * markSubtreeDirty() propagation on future updates.
1220
+ */
1221
+ function clearVirtualTextFlags(node: TeaNode): void {
1222
+ clearNodeDirtyFlags(node)
1223
+ for (const child of node.children) {
1224
+ clearVirtualTextFlags(child)
1225
+ }
1226
+ }
1227
+
1228
+ /**
1229
+ * Check if any child's position changed since last render (sibling shift).
1230
+ * Checked even when subtreeDirty=true because subtreeDirty only means
1231
+ * descendants are dirty, not that this container's gap regions need clearing.
1232
+ */
1233
+ function hasChildPositionChanged(node: TeaNode): boolean {
1234
+ for (const child of node.children) {
1235
+ if (child.contentRect && child.prevLayout) {
1236
+ if (child.contentRect.x !== child.prevLayout.x || child.contentRect.y !== child.prevLayout.y) {
1237
+ return true
1238
+ }
1239
+ }
1240
+ }
1241
+ return false
1242
+ }
1243
+
1244
+ /**
1245
+ * Check if any descendant was overflowing THIS node's rect and had its layout change.
1246
+ * Recursive: a grandchild overflowing a child AND this node is detected here.
1247
+ *
1248
+ * When a descendant overflows (prevLayout extends beyond this node's rect) and then
1249
+ * shrinks, clearExcessArea on the descendant clips to its immediate parent's content
1250
+ * area, leaving stale pixels in this node's border/padding area and beyond this node's
1251
+ * rect. By detecting at THIS level, the node clears its region (restoring borders)
1252
+ * and handles overflow beyond its rect via clearDescendantOverflowRegions.
1253
+ *
1254
+ * Only follows subtreeDirty paths for efficiency — layoutChangedThisFrame on a
1255
+ * descendant implies subtreeDirty on all its ancestors.
1256
+ */
1257
+ function hasDescendantOverflowChanged(node: TeaNode): boolean {
1258
+ const rect = node.contentRect!
1259
+ return _checkDescendantOverflow(node.children, rect.x, rect.y, rect.x + rect.width, rect.y + rect.height)
1260
+ }
1261
+
1262
+ function _checkDescendantOverflow(
1263
+ children: readonly TeaNode[],
1264
+ nodeLeft: number,
1265
+ nodeTop: number,
1266
+ nodeRight: number,
1267
+ nodeBottom: number,
1268
+ ): boolean {
1269
+ for (const child of children) {
1270
+ // Check this child's previous layout against the ancestor's rect
1271
+ if (child.prevLayout && child.layoutChangedThisFrame) {
1272
+ const prev = child.prevLayout
1273
+ if (
1274
+ prev.x + prev.width > nodeRight ||
1275
+ prev.y + prev.height > nodeBottom ||
1276
+ prev.x < nodeLeft ||
1277
+ prev.y < nodeTop
1278
+ ) {
1279
+ return true
1280
+ }
1281
+ }
1282
+ // Recurse into subtree-dirty children to find deeper overflows
1283
+ if (child.subtreeDirty && child.children !== undefined) {
1284
+ if (_checkDescendantOverflow(child.children, nodeLeft, nodeTop, nodeRight, nodeBottom)) {
1285
+ return true
1286
+ }
1287
+ }
1288
+ }
1289
+ return false
1290
+ }
1291
+
1292
+ /**
1293
+ * Compute clip bounds for a container's children by insetting for border+padding,
1294
+ * then intersecting with parent clip bounds.
1295
+ */
1296
+ function computeChildClipBounds(
1297
+ layout: NonNullable<TeaNode["contentRect"]>,
1298
+ props: BoxProps,
1299
+ parentClip: ClipBounds | undefined,
1300
+ scrollOffset = 0,
1301
+ /** Compute left/right clip bounds for horizontal overflow clipping. */
1302
+ horizontal = true,
1303
+ /** Compute top/bottom clip bounds for vertical overflow clipping.
1304
+ * Defaults to true — scroll containers pass vertical=true, horizontal=false. */
1305
+ vertical = true,
1306
+ ): ClipBounds {
1307
+ const border = props.borderStyle ? getBorderSize(props) : { top: 0, bottom: 0, left: 0, right: 0 }
1308
+ const padding = getPadding(props)
1309
+ const adjustedY = layout.y - scrollOffset
1310
+ const nodeClip: ClipBounds = vertical
1311
+ ? {
1312
+ top: adjustedY + border.top + padding.top,
1313
+ bottom: adjustedY + layout.height - border.bottom - padding.bottom,
1314
+ }
1315
+ : { top: -Infinity, bottom: Infinity }
1316
+ if (horizontal) {
1317
+ nodeClip.left = layout.x + border.left + padding.left
1318
+ nodeClip.right = layout.x + layout.width - border.right - padding.right
1319
+ }
1320
+ if (!parentClip) return nodeClip
1321
+ const result: ClipBounds = {
1322
+ top: vertical ? Math.max(parentClip.top, nodeClip.top) : parentClip.top,
1323
+ bottom: vertical ? Math.min(parentClip.bottom, nodeClip.bottom) : parentClip.bottom,
1324
+ }
1325
+ if (horizontal && nodeClip.left !== undefined && nodeClip.right !== undefined) {
1326
+ result.left = Math.max(parentClip.left ?? 0, nodeClip.left)
1327
+ result.right = Math.min(parentClip.right ?? Infinity, nodeClip.right)
1328
+ } else if (parentClip.left !== undefined && parentClip.right !== undefined) {
1329
+ // Pass through parent's horizontal clip bounds without adding own
1330
+ result.left = parentClip.left
1331
+ result.right = parentClip.right
1332
+ }
1333
+ return result
1334
+ }
1335
+
1336
+ // ============================================================================
1337
+ // Region Clearing
1338
+ // ============================================================================
1339
+
1340
+ /**
1341
+ * Result of finding inherited background - includes both color and ancestor bounds.
1342
+ */
1343
+ interface InheritedBgResult {
1344
+ color: Color
1345
+ /** The rect of the ancestor that has the background color (for clipping) */
1346
+ ancestorRect: { x: number; y: number; width: number; height: number } | null
1347
+ }
1348
+
1349
+ /**
1350
+ * Find the nearest ancestor with a backgroundColor and return the parsed color
1351
+ * along with the ancestor's rect for proper clipping.
1352
+ *
1353
+ * When clearing excess area after a node shrinks, we need to clip to the colored
1354
+ * ancestor's bounds - not just the immediate parent. Otherwise the inherited
1355
+ * color can bleed into sibling areas that should have different backgrounds.
1356
+ */
1357
+ function findInheritedBg(node: TeaNode): InheritedBgResult {
1358
+ let current = node.parent
1359
+ while (current) {
1360
+ const bg = (current.props as BoxProps).backgroundColor
1361
+ if (bg) {
1362
+ return {
1363
+ color: parseColor(bg),
1364
+ ancestorRect: current.contentRect,
1365
+ }
1366
+ }
1367
+ current = current.parent
1368
+ }
1369
+ return { color: null, ancestorRect: null }
1370
+ }
1371
+
1372
+ /**
1373
+ * Find the nearest ancestor Box with a `color` prop and return the parsed color.
1374
+ * Implements CSS-style foreground color inheritance: Text children without an
1375
+ * explicit `color` prop inherit from the nearest Box ancestor that sets one.
1376
+ */
1377
+ function findInheritedFg(node: TeaNode): Color {
1378
+ let current = node.parent
1379
+ while (current) {
1380
+ const fg = (current.props as BoxProps).color
1381
+ if (fg) return parseColor(fg)
1382
+ current = current.parent
1383
+ }
1384
+ return null
1385
+ }
1386
+
1387
+ /**
1388
+ * Clear overflow regions: areas where children's prevLayouts extended beyond
1389
+ * this node's rect. Called when childOverflowChanged detected stale overflow.
1390
+ *
1391
+ * clearNodeRegion handles the node's own rect. This function handles the
1392
+ * overflow area — pixels that a child rendered OUTSIDE the parent's rect
1393
+ * in a previous frame (via overflow:visible behavior). When the child shrinks,
1394
+ * those pixels become stale in the cloned buffer.
1395
+ *
1396
+ * Clears each child's overflow extent, clipped to buffer bounds.
1397
+ */
1398
+ /**
1399
+ * Clear areas where descendants' previous layouts overflowed beyond THIS node's rect.
1400
+ * Only clears OUTSIDE the node's rect — interior clearing is handled by clearNodeRegion
1401
+ * and renderBox. Recursive: follows subtreeDirty paths to find all overflowing descendants.
1402
+ */
1403
+ function clearDescendantOverflowRegions(
1404
+ node: TeaNode,
1405
+ buffer: TerminalBuffer,
1406
+ layout: NonNullable<TeaNode["contentRect"]>,
1407
+ scrollOffset: number,
1408
+ clipBounds: ClipBounds | undefined,
1409
+ ): void {
1410
+ const inherited = findInheritedBg(node)
1411
+ const clearBg = inherited.color
1412
+ const nodeRight = layout.x + layout.width
1413
+ const nodeBottom = layout.y - scrollOffset + layout.height
1414
+ const nodeLeft = layout.x
1415
+ const nodeTop = layout.y - scrollOffset
1416
+
1417
+ _clearDescendantOverflow(
1418
+ node.children,
1419
+ buffer,
1420
+ nodeLeft,
1421
+ nodeTop,
1422
+ nodeRight,
1423
+ nodeBottom,
1424
+ scrollOffset,
1425
+ clipBounds,
1426
+ clearBg,
1427
+ )
1428
+ }
1429
+
1430
+ function _clearDescendantOverflow(
1431
+ children: readonly TeaNode[],
1432
+ buffer: TerminalBuffer,
1433
+ nodeLeft: number,
1434
+ nodeTop: number,
1435
+ nodeRight: number,
1436
+ nodeBottom: number,
1437
+ scrollOffset: number,
1438
+ clipBounds: ClipBounds | undefined,
1439
+ clearBg: Color,
1440
+ ): void {
1441
+ for (const child of children) {
1442
+ if (child.prevLayout && child.layoutChangedThisFrame) {
1443
+ const prev = child.prevLayout
1444
+ const prevRight = prev.x + prev.width
1445
+ const prevBottom = prev.y - scrollOffset + prev.height
1446
+ const prevTop = prev.y - scrollOffset
1447
+
1448
+ // Clear overflow to the right of the ancestor
1449
+ if (prevRight > nodeRight) {
1450
+ const overflowX = nodeRight
1451
+ const overflowWidth = Math.min(prevRight, buffer.width) - overflowX
1452
+ const overflowTop = Math.max(prevTop, clipBounds?.top ?? 0)
1453
+ const overflowBottom = Math.min(prevBottom, clipBounds?.bottom ?? buffer.height)
1454
+ if (overflowWidth > 0 && overflowBottom > overflowTop) {
1455
+ buffer.fill(overflowX, overflowTop, overflowWidth, overflowBottom - overflowTop, { char: " ", bg: clearBg })
1456
+ }
1457
+ }
1458
+ // Clear overflow below the ancestor
1459
+ if (prevBottom > nodeBottom) {
1460
+ const overflowTop = Math.max(nodeBottom, clipBounds?.top ?? 0)
1461
+ const overflowBottom = Math.min(prevBottom, clipBounds?.bottom ?? buffer.height)
1462
+ const overflowX = Math.max(prev.x, clipBounds?.left ?? 0)
1463
+ const overflowWidth = Math.min(prevRight, clipBounds?.right ?? buffer.width) - overflowX
1464
+ if (overflowWidth > 0 && overflowBottom > overflowTop) {
1465
+ buffer.fill(overflowX, overflowTop, overflowWidth, overflowBottom - overflowTop, { char: " ", bg: clearBg })
1466
+ }
1467
+ }
1468
+ // Clear overflow to the left of the ancestor
1469
+ if (prev.x < nodeLeft) {
1470
+ const overflowX = Math.max(prev.x, 0)
1471
+ const overflowWidth = Math.min(nodeLeft, buffer.width) - overflowX
1472
+ const overflowTop = Math.max(prevTop, clipBounds?.top ?? 0)
1473
+ const overflowBottom = Math.min(prevBottom, clipBounds?.bottom ?? buffer.height)
1474
+ if (overflowWidth > 0 && overflowBottom > overflowTop) {
1475
+ buffer.fill(overflowX, overflowTop, overflowWidth, overflowBottom - overflowTop, { char: " ", bg: clearBg })
1476
+ }
1477
+ }
1478
+ // Clear overflow above the ancestor
1479
+ if (prevTop < nodeTop) {
1480
+ const overflowTop = Math.max(prevTop, clipBounds?.top ?? 0)
1481
+ const overflowBottom = Math.min(nodeTop, clipBounds?.bottom ?? buffer.height)
1482
+ const overflowX = Math.max(prev.x, clipBounds?.left ?? 0)
1483
+ const overflowWidth = Math.min(prevRight, clipBounds?.right ?? buffer.width) - overflowX
1484
+ if (overflowWidth > 0 && overflowBottom > overflowTop) {
1485
+ buffer.fill(overflowX, overflowTop, overflowWidth, overflowBottom - overflowTop, { char: " ", bg: clearBg })
1486
+ }
1487
+ }
1488
+ }
1489
+ // Recurse into subtree-dirty children to find deeper overflows
1490
+ if (child.subtreeDirty && child.children !== undefined) {
1491
+ _clearDescendantOverflow(
1492
+ child.children,
1493
+ buffer,
1494
+ nodeLeft,
1495
+ nodeTop,
1496
+ nodeRight,
1497
+ nodeBottom,
1498
+ scrollOffset,
1499
+ clipBounds,
1500
+ clearBg,
1501
+ )
1502
+ }
1503
+ }
1504
+ }
1505
+
1506
+ /**
1507
+ * Clear a node's region with inherited bg when it has no backgroundColor.
1508
+ * Also clears excess area when the node shrank (previous layout was larger).
1509
+ *
1510
+ * Clipping: clips to parent's contentRect (prevents overflow) and to the
1511
+ * colored ancestor's bounds (prevents bg color bleeding into siblings).
1512
+ */
1513
+ function clearNodeRegion(
1514
+ node: TeaNode,
1515
+ buffer: TerminalBuffer,
1516
+ layout: NonNullable<TeaNode["contentRect"]>,
1517
+ scrollOffset: number,
1518
+ clipBounds: ClipBounds | undefined,
1519
+ layoutChanged: boolean,
1520
+ ): void {
1521
+ const inherited = findInheritedBg(node)
1522
+ const clearBg = inherited.color
1523
+ const screenY = layout.y - scrollOffset
1524
+
1525
+ // Clip to parent's contentRect to prevent oversized children from clearing
1526
+ // beyond their parent's bounds and bleeding inherited bg into sibling regions.
1527
+ const parentRect = node.parent?.contentRect
1528
+ const parentBottom = parentRect ? parentRect.y - scrollOffset + parentRect.height : undefined
1529
+
1530
+ const clearY = clipBounds ? Math.max(screenY, clipBounds.top) : screenY
1531
+ let clearBottom = clipBounds ? Math.min(screenY + layout.height, clipBounds.bottom) : screenY + layout.height
1532
+ if (parentBottom !== undefined) {
1533
+ clearBottom = Math.min(clearBottom, parentBottom)
1534
+ }
1535
+
1536
+ // Clip horizontally to clipBounds (overflow:hidden containers) and to the
1537
+ // colored ancestor's bounds (prevents inherited bg bleeding into siblings).
1538
+ let clearX = layout.x
1539
+ let clearWidth = layout.width
1540
+ if (clipBounds?.left !== undefined && clipBounds.right !== undefined) {
1541
+ if (clearX < clipBounds.left) {
1542
+ clearWidth -= clipBounds.left - clearX
1543
+ clearX = clipBounds.left
1544
+ }
1545
+ if (clearX + clearWidth > clipBounds.right) {
1546
+ clearWidth = Math.max(0, clipBounds.right - clearX)
1547
+ }
1548
+ }
1549
+ if (inherited.ancestorRect) {
1550
+ const ancestorRight = inherited.ancestorRect.x + inherited.ancestorRect.width
1551
+ const ancestorLeft = inherited.ancestorRect.x
1552
+ if (clearX < ancestorLeft) {
1553
+ clearWidth -= ancestorLeft - clearX
1554
+ clearX = ancestorLeft
1555
+ }
1556
+ if (clearX + clearWidth > ancestorRight) {
1557
+ clearWidth = Math.max(0, ancestorRight - clearX)
1558
+ }
1559
+ }
1560
+
1561
+ const clearHeight = clearBottom - clearY
1562
+ if (clearHeight > 0 && clearWidth > 0) {
1563
+ // Cell debug: log clearNodeRegion coverage
1564
+ const _cellDbg2 = (globalThis as any).__silvery_cell_debug as { x: number; y: number; log: string[] } | undefined
1565
+ if (_cellDbg2) {
1566
+ const covers =
1567
+ clearX <= _cellDbg2.x &&
1568
+ clearX + clearWidth > _cellDbg2.x &&
1569
+ clearY <= _cellDbg2.y &&
1570
+ clearY + clearHeight > _cellDbg2.y
1571
+ if (covers) {
1572
+ const id = ((node.props as Record<string, unknown>).id as string) ?? node.type
1573
+ _cellDbg2.log.push(
1574
+ `CLEAR_REGION ${id} fill=${clearX},${clearY} ${clearWidth}x${clearHeight} bg=${String(clearBg)} COVERS TARGET`,
1575
+ )
1576
+ }
1577
+ }
1578
+ buffer.fill(clearX, clearY, clearWidth, clearHeight, {
1579
+ char: " ",
1580
+ bg: clearBg,
1581
+ })
1582
+ }
1583
+
1584
+ // Delegate excess area clearing to shared helper
1585
+ clearExcessArea(node, buffer, layout, scrollOffset, clipBounds, layoutChanged, inherited)
1586
+ }
1587
+
1588
+ /**
1589
+ * Clear the excess area when a node shrinks (old bounds were larger than new).
1590
+ *
1591
+ * This is separated from clearNodeRegion because excess area clearing must happen
1592
+ * even when parentRegionCleared is false. Key scenario: absolute-positioned overlays
1593
+ * (e.g., search dialog) that shrink while normal-flow siblings are dirty. The
1594
+ * forceRepaint path sets hasPrevBuffer=false + ancestorCleared=false, making
1595
+ * parentRegionCleared=false — but the cloned buffer still has stale pixels from
1596
+ * the old larger layout that must be cleared.
1597
+ *
1598
+ * Clips to the COLORED ANCESTOR's content area (not immediate parent's full rect)
1599
+ * to prevent inherited color from bleeding into sibling areas with different bg.
1600
+ *
1601
+ * IMPORTANT: Uses content area (inside border/padding), not full contentRect.
1602
+ * Without this, excess clearing of a child that previously filled the parent's
1603
+ * content area will extend into the parent's border row, overwriting border chars.
1604
+ */
1605
+ function clearExcessArea(
1606
+ node: TeaNode,
1607
+ buffer: TerminalBuffer,
1608
+ layout: NonNullable<TeaNode["contentRect"]>,
1609
+ scrollOffset: number,
1610
+ clipBounds: ClipBounds | undefined,
1611
+ layoutChanged: boolean,
1612
+ inherited?: InheritedBgResult,
1613
+ ): void {
1614
+ if (!layoutChanged || !node.prevLayout) return
1615
+ const prev = node.prevLayout
1616
+
1617
+ // Cell debug: log clearExcessArea decisions
1618
+ const _cellDbg3 = (globalThis as any).__silvery_cell_debug as { x: number; y: number; log: string[] } | undefined
1619
+ const _prevCoversCell3 =
1620
+ _cellDbg3 &&
1621
+ prev.x <= _cellDbg3.x &&
1622
+ prev.x + prev.width > _cellDbg3.x &&
1623
+ prev.y - scrollOffset <= _cellDbg3.y &&
1624
+ prev.y - scrollOffset + prev.height > _cellDbg3.y
1625
+
1626
+ // Only clear if the node actually shrank in at least one dimension
1627
+ if (prev.width <= layout.width && prev.height <= layout.height) {
1628
+ if (_cellDbg3 && _prevCoversCell3) {
1629
+ const id = ((node.props as Record<string, unknown>).id as string) ?? node.type
1630
+ _cellDbg3.log.push(
1631
+ `EXCESS_SKIP_NO_SHRINK ${id} prev=${prev.x},${prev.y - scrollOffset} ${prev.width}x${prev.height}` +
1632
+ ` now=${layout.x},${layout.y - scrollOffset} ${layout.width}x${layout.height}`,
1633
+ )
1634
+ }
1635
+ return
1636
+ }
1637
+
1638
+ // Skip excess clearing when the node MOVED (changed x or y position).
1639
+ // The right/bottom excess formulas use new-x + old-y coordinates, which
1640
+ // creates a phantom rectangle at wrong positions when the node moved.
1641
+ // Example: text at old=(30,7,23,1) → new=(22,8,14,2) computes excess at
1642
+ // (36,7) which overwrites a sibling's border character.
1643
+ //
1644
+ // When the node moved, the parent handles old-pixel cleanup:
1645
+ // - Parent's clearNodeRegion covers old pixels within parent's current rect
1646
+ // - Parent's clearExcessArea covers old pixels outside parent's rect
1647
+ if (prev.x !== layout.x || prev.y !== layout.y) {
1648
+ if (_cellDbg3 && _prevCoversCell3) {
1649
+ const id = ((node.props as Record<string, unknown>).id as string) ?? node.type
1650
+ _cellDbg3.log.push(
1651
+ `EXCESS_SKIP_MOVED ${id} prev=${prev.x},${prev.y - scrollOffset} ${prev.width}x${prev.height}` +
1652
+ ` now=${layout.x},${layout.y - scrollOffset} ${layout.width}x${layout.height}` +
1653
+ ` (dx=${layout.x - prev.x} dy=${layout.y - prev.y})`,
1654
+ )
1655
+ }
1656
+ return
1657
+ }
1658
+
1659
+ if (!inherited) inherited = findInheritedBg(node)
1660
+ const clearBg = inherited.color
1661
+ const screenY = layout.y - scrollOffset
1662
+ const prevScreenY = prev.y - scrollOffset
1663
+
1664
+ // Clip to prevent excess clearing from bleeding outside valid bounds.
1665
+ // Start with the colored ancestor's rect (prevents bg color bleed),
1666
+ // then further restrict to the immediate parent's content area (prevents
1667
+ // overwriting parent's border characters).
1668
+ const clipRect = inherited.ancestorRect ?? node.parent?.contentRect
1669
+ if (!clipRect) return
1670
+
1671
+ const clipRectScreenY = clipRect.y - scrollOffset
1672
+ let clipRectBottom = clipRectScreenY + clipRect.height
1673
+ let clipRectRight = clipRect.x + clipRect.width
1674
+
1675
+ // Always inset by the immediate parent's border/padding.
1676
+ // Without this, a child's excess clearing extends into the parent's
1677
+ // border row, overwriting border characters with spaces.
1678
+ // (The old code skipped inset when clip rect came from a colored ancestor,
1679
+ // assuming "its bg fill covers its border area" — but bg fill only covers
1680
+ // the inside, while renderBorder draws characters on the border row.)
1681
+ const parent = node.parent
1682
+ if (parent?.contentRect) {
1683
+ const parentProps = parent.props as BoxProps
1684
+ const border = getBorderSize(parentProps)
1685
+ const padding = getPadding(parentProps)
1686
+ const parentRight = parent.contentRect.x + parent.contentRect.width - border.right - padding.right
1687
+ const parentBottom =
1688
+ parent.contentRect.y - scrollOffset + parent.contentRect.height - border.bottom - padding.bottom
1689
+ clipRectRight = Math.min(clipRectRight, parentRight)
1690
+ clipRectBottom = Math.min(clipRectBottom, parentBottom)
1691
+ }
1692
+
1693
+ // Clear right margin (old was wider than new)
1694
+ if (prev.width > layout.width) {
1695
+ let excessX = layout.x + layout.width
1696
+ let excessWidth = prev.width - layout.width
1697
+ // Clip horizontally to parent's content area (inside border/padding).
1698
+ // Without this, excess clearing of a child that previously filled a wider
1699
+ // layout extends into the parent's right border, overwriting border chars.
1700
+ if (excessX + excessWidth > clipRectRight) {
1701
+ excessWidth = Math.max(0, clipRectRight - excessX)
1702
+ }
1703
+ if (excessWidth > 0) {
1704
+ clippedFill(
1705
+ buffer,
1706
+ excessX,
1707
+ excessWidth,
1708
+ prevScreenY,
1709
+ prevScreenY + prev.height,
1710
+ clipBounds,
1711
+ clipRectBottom,
1712
+ clearBg,
1713
+ )
1714
+ }
1715
+ }
1716
+
1717
+ // Clear bottom margin (old was taller than new)
1718
+ if (prev.height > layout.height) {
1719
+ let bottomWidth = prev.width
1720
+ // Clip horizontally to parent's content area
1721
+ if (layout.x + bottomWidth > clipRectRight) {
1722
+ bottomWidth = Math.max(0, clipRectRight - layout.x)
1723
+ }
1724
+ clippedFill(
1725
+ buffer,
1726
+ layout.x,
1727
+ bottomWidth,
1728
+ screenY + layout.height,
1729
+ prevScreenY + prev.height,
1730
+ clipBounds,
1731
+ clipRectBottom,
1732
+ clearBg,
1733
+ )
1734
+ }
1735
+ }
1736
+
1737
+ /** Fill a rectangular region, clipping to clipBounds and an outer bottom limit. */
1738
+ function clippedFill(
1739
+ buffer: TerminalBuffer,
1740
+ x: number,
1741
+ width: number,
1742
+ top: number,
1743
+ bottom: number,
1744
+ clipBounds: ClipBounds | undefined,
1745
+ outerBottom: number,
1746
+ bg: Color,
1747
+ ): void {
1748
+ const clippedTop = clipBounds ? Math.max(top, clipBounds.top) : top
1749
+ const clippedBottom = Math.min(clipBounds ? Math.min(bottom, clipBounds.bottom) : bottom, outerBottom)
1750
+ let clippedX = x
1751
+ let clippedWidth = width
1752
+ if (clipBounds?.left !== undefined && clipBounds.right !== undefined) {
1753
+ if (clippedX < clipBounds.left) {
1754
+ clippedWidth -= clipBounds.left - clippedX
1755
+ clippedX = clipBounds.left
1756
+ }
1757
+ if (clippedX + clippedWidth > clipBounds.right) {
1758
+ clippedWidth = Math.max(0, clipBounds.right - clippedX)
1759
+ }
1760
+ }
1761
+ const height = clippedBottom - clippedTop
1762
+ if (height > 0 && clippedWidth > 0) {
1763
+ buffer.fill(clippedX, clippedTop, clippedWidth, height, { char: " ", bg })
1764
+ }
1765
+ }