silvery 0.19.1 → 0.21.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 (218) hide show
  1. package/README.md +9 -4
  2. package/dist/Text-Lq0dmj8-.mjs +239 -0
  3. package/dist/Text-Lq0dmj8-.mjs.map +1 -0
  4. package/dist/UPNG-Bo33r8rA.mjs +3 -0
  5. package/dist/UPNG-DosRPdF4.mjs +5075 -0
  6. package/dist/UPNG-DosRPdF4.mjs.map +1 -0
  7. package/dist/__vite-browser-external-2447137e-D_JM6skp.mjs +6 -0
  8. package/dist/__vite-browser-external-2447137e-D_JM6skp.mjs.map +1 -0
  9. package/dist/{animation-Cn64yepo.mjs → animation-ZMN2_XKv.mjs} +2 -2
  10. package/dist/animation-ZMN2_XKv.mjs.map +1 -0
  11. package/dist/{ansi-Cc33mW54.d.mts → ansi-2Xn0yatP.d.mts} +1 -1
  12. package/dist/{ansi-Cc33mW54.d.mts.map → ansi-2Xn0yatP.d.mts.map} +1 -1
  13. package/dist/{ansi-CLOitHKx.mjs → ansi-D1KQMAbf.mjs} +1 -1
  14. package/dist/{ansi-CLOitHKx.mjs.map → ansi-D1KQMAbf.mjs.map} +1 -1
  15. package/dist/ansi-yC4RyBNY.mjs +22441 -0
  16. package/dist/ansi-yC4RyBNY.mjs.map +1 -0
  17. package/dist/apng-CR08rIaH.mjs +58 -0
  18. package/dist/apng-CR08rIaH.mjs.map +1 -0
  19. package/dist/apng-DaHfVaVI.mjs +3 -0
  20. package/dist/assets/resvgjs.darwin-arm64-BtufyGW1.node +0 -0
  21. package/dist/assets/skia.darwin-arm64-DQs5sT6N.node +0 -0
  22. package/dist/backend-B-WYLUib.mjs +13396 -0
  23. package/dist/backend-B-WYLUib.mjs.map +1 -0
  24. package/dist/backends-CUtan80W.mjs +3 -0
  25. package/dist/backends-DIVYzKqd.mjs +1083 -0
  26. package/dist/backends-DIVYzKqd.mjs.map +1 -0
  27. package/dist/bound-term-0sPrrzH1.d.mts +4640 -0
  28. package/dist/bound-term-0sPrrzH1.d.mts.map +1 -0
  29. package/dist/canvas-1v7dPT-_.mjs +3 -0
  30. package/dist/canvas-CSuPOMNt.mjs +1442 -0
  31. package/dist/canvas-CSuPOMNt.mjs.map +1 -0
  32. package/dist/{chunk-Vs_PY4HZ.mjs → chunk-BSw8zbkd.mjs} +1 -1
  33. package/dist/cli-dvo0r2fs.mjs +4 -0
  34. package/dist/compare-CQodSH4G.mjs +376 -0
  35. package/dist/compare-CQodSH4G.mjs.map +1 -0
  36. package/dist/compare-DHlcxEYA.mjs +3 -0
  37. package/dist/context-BU5LkkIy.mjs.map +1 -1
  38. package/dist/devtools-CJdt5H0X.mjs +2 -0
  39. package/dist/{devtools-DxkSLXDA.mjs → devtools-DcQjgyjL.mjs} +5 -4
  40. package/dist/{devtools-DxkSLXDA.mjs.map → devtools-DcQjgyjL.mjs.map} +1 -1
  41. package/dist/easing-BI-ASGMO.d.mts +24 -0
  42. package/dist/easing-BI-ASGMO.d.mts.map +1 -0
  43. package/dist/{eta-Bb3RH3wh.mjs → eta-CJlGH06n.mjs} +1 -1
  44. package/dist/{eta-Bb3RH3wh.mjs.map → eta-CJlGH06n.mjs.map} +1 -1
  45. package/dist/flexily-zero-adapter-C3Vj0fPt.mjs +306 -0
  46. package/dist/flexily-zero-adapter-C3Vj0fPt.mjs.map +1 -0
  47. package/dist/{flexily-zero-adapter-CMxXhdOL.mjs → flexily-zero-adapter-C4lW_Ov5.mjs} +1 -1
  48. package/dist/fonts-BFmhXDv7.mjs +88 -0
  49. package/dist/fonts-BFmhXDv7.mjs.map +1 -0
  50. package/dist/gif-C_AjaT9d.mjs +188 -0
  51. package/dist/gif-C_AjaT9d.mjs.map +1 -0
  52. package/dist/gif-DaC4XrxA.mjs +3 -0
  53. package/dist/gifenc-BOUT-KFB.mjs +730 -0
  54. package/dist/gifenc-BOUT-KFB.mjs.map +1 -0
  55. package/dist/image-C2Birh2x.mjs +1252 -0
  56. package/dist/image-C2Birh2x.mjs.map +1 -0
  57. package/dist/index-BUMxS65f.d.mts +453 -0
  58. package/dist/index-BUMxS65f.d.mts.map +1 -0
  59. package/dist/{index-D3saHouR.d.mts → index-CSQf13CI.d.mts} +1057 -1133
  60. package/dist/index-CSQf13CI.d.mts.map +1 -0
  61. package/dist/{index-BXslOebb.d.mts → index-Cl9KKjQ_.d.mts} +4919 -3921
  62. package/dist/index-Cl9KKjQ_.d.mts.map +1 -0
  63. package/dist/index-XbNrPhWl.d.mts +336 -0
  64. package/dist/index-XbNrPhWl.d.mts.map +1 -0
  65. package/dist/index.d.mts +8 -5
  66. package/dist/index.d.mts.map +1 -1
  67. package/dist/index.mjs +14 -12
  68. package/dist/index.mjs.map +1 -1
  69. package/dist/key-mapping-CS-YD_cD.mjs +132 -0
  70. package/dist/key-mapping-CS-YD_cD.mjs.map +1 -0
  71. package/dist/key-mapping-Yn-Jgrij.mjs +3 -0
  72. package/dist/{layout-engine-B6Cdz1yZ.mjs → layout-engine-C07LEXWT.mjs} +1 -1
  73. package/dist/layout-engine-C2px0RJE.mjs +67 -0
  74. package/dist/layout-engine-C2px0RJE.mjs.map +1 -0
  75. package/dist/layout-signals-Cnw6xk8Q.mjs +988 -0
  76. package/dist/layout-signals-Cnw6xk8Q.mjs.map +1 -0
  77. package/dist/mouse-events-Dki3ISIp.mjs +1044 -0
  78. package/dist/mouse-events-Dki3ISIp.mjs.map +1 -0
  79. package/dist/{multi-progress-Bq9Oi_WI.mjs → multi-progress-CIRjrzma.mjs} +3 -3
  80. package/dist/{multi-progress-Bq9Oi_WI.mjs.map → multi-progress-CIRjrzma.mjs.map} +1 -1
  81. package/dist/{multi-progress-DAQC7eap.d.mts → multi-progress-DHZ2xUT2.d.mts} +2 -2
  82. package/dist/{multi-progress-DAQC7eap.d.mts.map → multi-progress-DHZ2xUT2.d.mts.map} +1 -1
  83. package/dist/{node-BeWlnCPY.mjs → node-CjM5Rt-M.mjs} +4 -4
  84. package/dist/node-CjM5Rt-M.mjs.map +1 -0
  85. package/dist/playwright-D5YiZcNS.mjs +76397 -0
  86. package/dist/playwright-D5YiZcNS.mjs.map +1 -0
  87. package/dist/png-codec-Dp84742B.mjs +36 -0
  88. package/dist/png-codec-Dp84742B.mjs.map +1 -0
  89. package/dist/png-codec-QwOtJ8Zs.mjs +3 -0
  90. package/dist/progress-DB_Xo071.mjs +675 -0
  91. package/dist/progress-DB_Xo071.mjs.map +1 -0
  92. package/dist/{progress-bar-CXE5Qfkd.mjs → progress-bar-oJwq22CR.mjs} +4 -4
  93. package/dist/{progress-bar-CXE5Qfkd.mjs.map → progress-bar-oJwq22CR.mjs.map} +1 -1
  94. package/dist/rasterizer-BRXrDdWx.mjs +3 -0
  95. package/dist/rasterizer-CpEhJvdR.mjs +296 -0
  96. package/dist/rasterizer-CpEhJvdR.mjs.map +1 -0
  97. package/dist/reconciler-DldIJB93.mjs +2083 -0
  98. package/dist/reconciler-DldIJB93.mjs.map +1 -0
  99. package/dist/{render-string-0mN37DLf.mjs → render-string-BcoCpjCB.mjs} +1 -1
  100. package/dist/{render-string-X-CxpTdZ.mjs → render-string-DkQacASz.mjs} +2728 -555
  101. package/dist/render-string-DkQacASz.mjs.map +1 -0
  102. package/dist/resvg-js-DkOndZI3.mjs +203 -0
  103. package/dist/resvg-js-DkOndZI3.mjs.map +1 -0
  104. package/dist/runtime.d.mts +3 -2
  105. package/dist/runtime.mjs +3 -3
  106. package/dist/schemes-JjNp4aSl.mjs +2611 -0
  107. package/dist/schemes-JjNp4aSl.mjs.map +1 -0
  108. package/dist/{spinner-CGo34vyR.d.mts → spinner-CZINHpkV.d.mts} +2 -2
  109. package/dist/{spinner-CGo34vyR.d.mts.map → spinner-CZINHpkV.d.mts.map} +1 -1
  110. package/dist/{spinner-CeOmcuw_.mjs → spinner-D9lrHr8s.mjs} +7 -7
  111. package/dist/spinner-D9lrHr8s.mjs.map +1 -0
  112. package/dist/src-5w9QR6_8.mjs +1071 -0
  113. package/dist/src-5w9QR6_8.mjs.map +1 -0
  114. package/dist/src-BNTToU7l.mjs +4387 -0
  115. package/dist/src-BNTToU7l.mjs.map +1 -0
  116. package/dist/{src-Bd7ezSgG.mjs → src-BR4xNwdG.mjs} +10436 -2622
  117. package/dist/src-BR4xNwdG.mjs.map +1 -0
  118. package/dist/{types-Bk2yw9Qj.mjs → src-DKp-_OFG.mjs} +34 -94
  119. package/dist/src-DKp-_OFG.mjs.map +1 -0
  120. package/dist/src-bt8wSrfJ.mjs +258 -0
  121. package/dist/src-bt8wSrfJ.mjs.map +1 -0
  122. package/dist/src-e33Y6kNJ.mjs +3 -0
  123. package/dist/src-iDwu25UD.mjs +1814 -0
  124. package/dist/src-iDwu25UD.mjs.map +1 -0
  125. package/dist/steps-Bp2uNqnn.d.mts +202 -0
  126. package/dist/steps-Bp2uNqnn.d.mts.map +1 -0
  127. package/dist/svg-15lZZzxq.mjs +486 -0
  128. package/dist/svg-15lZZzxq.mjs.map +1 -0
  129. package/dist/svg-Cz0UXcDj.mjs +255 -0
  130. package/dist/svg-Cz0UXcDj.mjs.map +1 -0
  131. package/dist/svg-DY72a4HK.mjs +3 -0
  132. package/dist/svg-g1D6ErwR.d.mts +82 -0
  133. package/dist/svg-g1D6ErwR.d.mts.map +1 -0
  134. package/dist/term.d.mts +3 -0
  135. package/dist/term.mjs +9 -0
  136. package/dist/term.mjs.map +1 -0
  137. package/dist/theme.d.mts +95 -2
  138. package/dist/theme.d.mts.map +1 -0
  139. package/dist/theme.mjs +9 -3
  140. package/dist/theme.mjs.map +1 -0
  141. package/dist/{types-BH_v3iMT.d.mts → types-kt_fKR37.d.mts} +2 -15
  142. package/dist/types-kt_fKR37.d.mts.map +1 -0
  143. package/dist/ui/animation.d.mts +2 -1
  144. package/dist/ui/animation.mjs +1 -1
  145. package/dist/ui/ansi.d.mts +1 -1
  146. package/dist/ui/ansi.mjs +1 -1
  147. package/dist/ui/cli.d.mts +3 -3
  148. package/dist/ui/cli.mjs +5 -5
  149. package/dist/ui/display.d.mts +1 -1
  150. package/dist/ui/image.d.mts +2 -2
  151. package/dist/ui/image.mjs +2 -2
  152. package/dist/ui/input.d.mts +1 -1
  153. package/dist/ui/input.mjs +4 -2
  154. package/dist/ui/input.mjs.map +1 -1
  155. package/dist/ui/progress.d.mts +5 -249
  156. package/dist/ui/progress.mjs +5 -858
  157. package/dist/ui/react.d.mts +1 -1
  158. package/dist/ui/react.mjs +2 -2
  159. package/dist/ui/recording-chrome-react.d.mts +21 -0
  160. package/dist/ui/recording-chrome-react.d.mts.map +1 -0
  161. package/dist/ui/recording-chrome-react.mjs +105 -0
  162. package/dist/ui/recording-chrome-react.mjs.map +1 -0
  163. package/dist/ui/recording-chrome.d.mts +2 -0
  164. package/dist/ui/recording-chrome.mjs +2 -0
  165. package/dist/ui/utils.mjs +1 -1
  166. package/dist/ui/wrappers.d.mts +3 -3
  167. package/dist/ui/wrappers.mjs +2 -2
  168. package/dist/ui.d.mts +7 -6
  169. package/dist/ui.mjs +8 -7
  170. package/dist/{useLatest-Bg2x4bfP.d.mts → useLatest-DRDDVwjh.d.mts} +5 -25
  171. package/dist/useLatest-DRDDVwjh.d.mts.map +1 -0
  172. package/dist/{with-text-input-CRfoiFFG.d.mts → with-text-input-YeohVLeo.d.mts} +4 -55
  173. package/dist/with-text-input-YeohVLeo.d.mts.map +1 -0
  174. package/dist/wrapper-C70ATkVv.mjs +3527 -0
  175. package/dist/wrapper-C70ATkVv.mjs.map +1 -0
  176. package/dist/{wrappers-UTADQkSY.mjs → wrappers-BCUYITrY.mjs} +5 -157
  177. package/dist/wrappers-BCUYITrY.mjs.map +1 -0
  178. package/dist/{yoga-adapter-8oRGRw8V.mjs → yoga-adapter-BnZX1PAY.mjs} +28 -2
  179. package/dist/yoga-adapter-BnZX1PAY.mjs.map +1 -0
  180. package/dist/yoga-adapter-DxgsQ_gg.mjs +2 -0
  181. package/dist/zipBundle-3nqeDRtm.mjs +3 -0
  182. package/dist/zipBundle-VNAYFmqJ.mjs +2003 -0
  183. package/dist/zipBundle-VNAYFmqJ.mjs.map +1 -0
  184. package/package.json +20 -9
  185. package/dist/animation-Cn64yepo.mjs.map +0 -1
  186. package/dist/cli-BKp0YtBD.mjs +0 -4
  187. package/dist/devtools-9QY4teqI.mjs +0 -2
  188. package/dist/flexily-zero-adapter-BlQa46nr.mjs +0 -3385
  189. package/dist/flexily-zero-adapter-BlQa46nr.mjs.map +0 -1
  190. package/dist/image-CTII5QWI.mjs +0 -477
  191. package/dist/image-CTII5QWI.mjs.map +0 -1
  192. package/dist/index-BXslOebb.d.mts.map +0 -1
  193. package/dist/index-BnA7mNpo.d.mts +0 -175
  194. package/dist/index-BnA7mNpo.d.mts.map +0 -1
  195. package/dist/index-D3saHouR.d.mts.map +0 -1
  196. package/dist/layout-engine-ClUgv6jB.mjs +0 -50
  197. package/dist/layout-engine-ClUgv6jB.mjs.map +0 -1
  198. package/dist/node-BeWlnCPY.mjs.map +0 -1
  199. package/dist/reconciler-Cwgm8hRR.mjs +0 -8459
  200. package/dist/reconciler-Cwgm8hRR.mjs.map +0 -1
  201. package/dist/render-string-X-CxpTdZ.mjs.map +0 -1
  202. package/dist/spinner-CeOmcuw_.mjs.map +0 -1
  203. package/dist/src-B5GjfG7g.mjs +0 -4305
  204. package/dist/src-B5GjfG7g.mjs.map +0 -1
  205. package/dist/src-Bd7ezSgG.mjs.map +0 -1
  206. package/dist/src-CChwjk0Z.mjs +0 -738
  207. package/dist/src-CChwjk0Z.mjs.map +0 -1
  208. package/dist/src-NCKb8kE5.mjs +0 -2660
  209. package/dist/src-NCKb8kE5.mjs.map +0 -1
  210. package/dist/types-BH_v3iMT.d.mts.map +0 -1
  211. package/dist/types-Bk2yw9Qj.mjs.map +0 -1
  212. package/dist/ui/progress.d.mts.map +0 -1
  213. package/dist/ui/progress.mjs.map +0 -1
  214. package/dist/useLatest-Bg2x4bfP.d.mts.map +0 -1
  215. package/dist/with-text-input-CRfoiFFG.d.mts.map +0 -1
  216. package/dist/wrappers-UTADQkSY.mjs.map +0 -1
  217. package/dist/yoga-adapter-8oRGRw8V.mjs.map +0 -1
  218. package/dist/yoga-adapter-D_CcxSt5.mjs +0 -2
@@ -1,16 +1,18 @@
1
- import { c as signal, i as syncRectSignals, o as computed, t as rectEqual } from "./types-Bk2yw9Qj.mjs";
1
+ import { i as __require } from "./chunk-BSw8zbkd.mjs";
2
+ import { i as signal, n as computed } from "./src-DKp-_OFG.mjs";
2
3
  import { c as StdoutContext, l as TermContext, s as StderrContext } from "./context-BU5LkkIy.mjs";
3
- import { At as bufferToText, Dt as TerminalBuffer, Et as DEFAULT_BG, Ft as createTextFrame, H as ensureEmojiPresentation, I as canBreakAnywhere, It as init_buffer, K as graphemeWidth, Lt as isDefaultBg, Ot as ansi256ToRgb, Pt as createMutableCell, U as getActiveLineHeight, V as displayWidthAnsi, Z as isLikelyEmoji, _t as wrapText, a as hostConfig, at as parseAnsiText, b as createTerm, c as clearDirtyTracking, d as collectPlainText, et as isWordBoundary, f as advanceRenderEpoch, ft as splitGraphemes, g as isDirty, h as isCurrentEpoch, kt as bufferToStyledText, l as hasScrollDirty, lt as sliceByWidth, m as isAnyDirty, ot as runWithMeasurer, p as getRenderEpoch, pt as splitGraphemesAnsiAware, q as hasAnsi, r as getContainerRoot, t as createContainer, u as measureStats, ut as sliceByWidthFromEnd } from "./reconciler-Cwgm8hRR.mjs";
4
- import { A as backdropPlacementId, F as kittyUploadScrimImage, M as cupTo, N as kittyDeleteAllScrimPlacements, P as kittyPlaceAt, j as buildScrimPixels, k as resolveThemeColor, t as init_src, w as monoAttrsForColorString } from "./src-NCKb8kE5.mjs";
5
- import { i as isLayoutEngineInitialized, r as getLayoutEngine } from "./layout-engine-ClUgv6jB.mjs";
6
- import { x as ansi16DarkTheme } from "./src-B5GjfG7g.mjs";
4
+ import { a as hasObservedLayoutSignal, c as syncDecorationRects, i as hasLayoutSignals, m as rectEqual, u as syncRectSignals } from "./layout-signals-Cnw6xk8Q.mjs";
5
+ import { _ as isAnyDirty, b as clearDirtyTracking, g as getRenderEpoch, h as advanceRenderEpoch, l as measureStats, o as hostConfig, r as getContainerRoot, t as createContainer, u as collectPlainText, v as isCurrentEpoch, x as hasScrollDirty, y as isDirty } from "./reconciler-DldIJB93.mjs";
6
+ import { _ as cupTo, b as kittyUploadScrimImage, d as monoAttrsForColorString, g as buildScrimPixels, h as backdropPlacementId, m as resolveThemeColor, t as init_src, v as kittyDeleteAllScrimPlacements, y as kittyPlaceAt } from "./src-BNTToU7l.mjs";
7
+ import { t as ansi16DarkTheme } from "./schemes-JjNp4aSl.mjs";
8
+ import { $ as splitGraphemesAnsiAware, A as getActiveLineHeight, Ct as init_buffer, H as isWordBoundary, J as runWithMeasurer, N as graphemeWidth, O as displayWidthAnsi, P as hasAnsi, Q as splitGraphemes, R as isLikelyEmoji, St as createTextFrame, X as sliceByWidthFromEnd, Y as sliceByWidth, _t as bufferToText, gt as bufferToStyledText, ht as ansi256ToRgb, k as ensureEmojiPresentation, mt as TerminalBuffer, n as createTerm, pt as DEFAULT_BG, q as parseAnsiText, rt as wrapText, w as canBreakAnywhere, wt as isDefaultBg, xt as createMutableCell, z as isSoftBreakPoint } from "./ansi-yC4RyBNY.mjs";
9
+ import { t as init_src$1 } from "./src-5w9QR6_8.mjs";
10
+ import { i as isLayoutEngineInitialized, r as getLayoutEngine } from "./layout-engine-C2px0RJE.mjs";
7
11
  import React, { act } from "react";
12
+ import { hexToOklch, mixSrgb, oklchToHex, relativeLuminance } from "@silvery/color";
8
13
  import { createLogger } from "loggily";
9
- import * as Upstream from "@silvery/color";
10
- import { relativeLuminance } from "@silvery/color";
11
14
  import Reconciler from "react-reconciler";
12
15
  //#region packages/ag-term/src/pipeline/prepared-text.ts
13
- init_buffer();
14
16
  const MAX_FORMAT_ENTRIES = 4;
15
17
  /** Content-affecting flags that invalidate plain text. */
16
18
  const PLAIN_TEXT_DIRTY = 9;
@@ -217,6 +219,11 @@ function buildTextAnalysis(text, gWidthFn = graphemeWidth) {
217
219
  breakIndices.push(i);
218
220
  maxWordWidth = Math.max(maxWordWidth, currentWordWidth);
219
221
  currentWordWidth = w;
222
+ } else if (isSoftBreakPoint(g) && i + 1 < len) {
223
+ currentWordWidth += w;
224
+ breakIndices.push(i + 1);
225
+ maxWordWidth = Math.max(maxWordWidth, currentWordWidth);
226
+ currentWordWidth = 0;
220
227
  } else if (w > 0) currentWordWidth += w;
221
228
  }
222
229
  maxWordWidth = Math.max(maxWordWidth, currentWordWidth);
@@ -277,11 +284,23 @@ function shrinkwrapWidth(analysis, maxWidth) {
277
284
  * around forced breaks. Falls back to greedy wrapping for paragraphs
278
285
  * where the DP finds no feasible solution (overlong words).
279
286
  *
280
- * O(breakpoints²) per paragraph, typically much less with pruning.
287
+ * `width` accepts either a constant number (uniform width — fast path)
288
+ * or a `WidthFn(lineIndex)` for per-line widths. Per-line uses a
289
+ * forward DP that tracks line index in state.
290
+ *
291
+ * Pass `opts.maxLines` to cap the wrap to ≤ N lines (a 2D DP over
292
+ * candidates × lines-used). When infeasible the function returns `[]`
293
+ * and the caller (`optimalWrap`) is responsible for the truncation
294
+ * fallback.
295
+ *
296
+ * O(breakpoints²) for the constant-width unbounded case;
297
+ * O(breakpoints² × maxLines) for per-line or when `maxLines` is set.
298
+ * All variants are negligible for terminal-scale text.
281
299
  */
282
- function knuthPlassBreaks(analysis, width) {
283
- if (width <= 0) return [];
284
- if (analysis.totalWidth <= width && analysis.newlineIndices.length === 0) return [];
300
+ function knuthPlassBreaks(analysis, width, opts) {
301
+ const w0 = typeof width === "function" ? width(0) : width;
302
+ if (w0 <= 0) return [];
303
+ if (analysis.totalWidth <= w0 && analysis.newlineIndices.length === 0) return [];
285
304
  const { newlineIndices, graphemes } = analysis;
286
305
  const allBreaks = [];
287
306
  const paragraphStarts = [0];
@@ -290,14 +309,84 @@ function knuthPlassBreaks(analysis, width) {
290
309
  const pStart = paragraphStarts[p];
291
310
  const pEnd = p + 1 < paragraphStarts.length ? paragraphStarts[p + 1] - 1 : graphemes.length;
292
311
  if (pStart >= pEnd) continue;
293
- const breaks = knuthPlassForParagraph(analysis, pStart, pEnd, width);
312
+ const breaks = typeof width === "function" ? knuthPlassForParagraphPerLine(analysis, pStart, pEnd, width, opts) : knuthPlassForParagraph(analysis, pStart, pEnd, width, opts);
294
313
  allBreaks.push(...breaks);
295
314
  if (p < paragraphStarts.length - 1 && pEnd < graphemes.length) allBreaks.push(pEnd + 1);
296
315
  }
297
316
  return allBreaks;
298
317
  }
299
- /** DP for a single paragraph (no newlines). */
300
- function knuthPlassForParagraph(analysis, pStart, pEnd, width) {
318
+ /**
319
+ * Minimum visible-character count for a non-final line before the
320
+ * orphan penalty kicks in. A "token" here is loosely "the visible width
321
+ * of the line"; the penalty is tuned for terminal card widths (≈20-40
322
+ * cols), where any line under 8 cols is visually a stranded fragment.
323
+ */
324
+ const ORPHAN_MIN_WIDTH = 8;
325
+ /** Penalty (added to leftover²) for a non-final line below ORPHAN_MIN_WIDTH. */
326
+ const ORPHAN_PENALTY = 500;
327
+ /**
328
+ * Threshold for a hyphenated compound to count as "short" on either side.
329
+ * Breaking inside `cmd-hover` (3-5 split) or `auto-` (4 chars left, 9
330
+ * chars right of the hyphen) reads worse than letting the compound
331
+ * stay intact. Penalty is gentler than the orphan penalty because
332
+ * breaking after a hyphen IS sometimes the right call (long compounds,
333
+ * narrow widths).
334
+ */
335
+ const HYPHEN_COMPOUND_MIN_SIDE = 5;
336
+ /** Penalty (added to leftover²) for a break inside a short hyphenated compound. */
337
+ const HYPHEN_COMPOUND_PENALTY = 250;
338
+ /**
339
+ * Score the *quality* of a non-final wrapped line.
340
+ *
341
+ * Knuth-Plass's classic cost (leftover²) gives the line layout a global
342
+ * raggedness optimum but doesn't know about typographical taboos —
343
+ * single-word "hover" lines, breaks inside short hyphenated compounds
344
+ * like `cmd-hover`, etc. The penalty is added to the squared-leftover
345
+ * cost and tuned so it dominates *only* when the alternative is visually
346
+ * better; if the alternative has worse raggedness, the leftover²
347
+ * differential will still pick the better wrap.
348
+ *
349
+ * Parameters:
350
+ * - `lineStart` — grapheme index where the line begins (a break candidate)
351
+ * - `lineEnd` — grapheme index where the next line begins (a break candidate)
352
+ * - `trimEnd` — `lineEnd` after trailing whitespace/ANSI trim (= visible end)
353
+ */
354
+ function wrapQualityPenalty(analysis, lineStart, lineEnd, trimEnd) {
355
+ let penalty = 0;
356
+ const { cumWidths, graphemes } = analysis;
357
+ if (cumWidths[trimEnd] - cumWidths[lineStart] < ORPHAN_MIN_WIDTH) penalty += ORPHAN_PENALTY;
358
+ if (lineEnd > 0 && graphemes[lineEnd - 1] === "-") {
359
+ let leftLen = 0;
360
+ for (let k = lineEnd - 2; k >= 0; k--) {
361
+ const g = graphemes[k];
362
+ if (g === " " || g === " " || g === "-" || g === "\n") break;
363
+ leftLen++;
364
+ if (leftLen >= HYPHEN_COMPOUND_MIN_SIDE) break;
365
+ }
366
+ let rightLen = 0;
367
+ for (let k = lineEnd; k < graphemes.length; k++) {
368
+ const g = graphemes[k];
369
+ if (g === " " || g === " " || g === "-" || g === "\n") break;
370
+ rightLen++;
371
+ if (rightLen >= HYPHEN_COMPOUND_MIN_SIDE) break;
372
+ }
373
+ if (leftLen < HYPHEN_COMPOUND_MIN_SIDE || rightLen < HYPHEN_COMPOUND_MIN_SIDE) penalty += HYPHEN_COMPOUND_PENALTY;
374
+ }
375
+ return penalty;
376
+ }
377
+ /**
378
+ * DP for a single paragraph (no newlines). Constant-width.
379
+ *
380
+ * Without `opts.maxLines`: backward 1D DP, `cost[i]` = min cost to reach
381
+ * the end starting at candidate `i`. Final answer = `cost[0]`.
382
+ *
383
+ * With `opts.maxLines`: forward 2D DP, `f[i][k]` = min cost to reach
384
+ * candidate `i` after consuming exactly `k` lines. See
385
+ * `knuthPlassForParagraph2D` below — same shape as the per-line variant
386
+ * but with a constant `width` for every line.
387
+ */
388
+ function knuthPlassForParagraph(analysis, pStart, pEnd, width, opts) {
389
+ if (opts?.maxLines !== void 0) return knuthPlassForParagraph2D(analysis, pStart, pEnd, () => width, opts.maxLines);
301
390
  const { cumWidths, breakIndices, widths, graphemes } = analysis;
302
391
  const candidates = [pStart];
303
392
  for (const bp of breakIndices) if (bp > pStart && bp <= pEnd) candidates.push(bp);
@@ -311,7 +400,8 @@ function knuthPlassForParagraph(analysis, pStart, pEnd, width) {
311
400
  const lineStart = candidates[i];
312
401
  const lineStartCum = cumWidths[lineStart];
313
402
  for (let j = i + 1; j < n; j++) {
314
- let trimEnd = candidates[j];
403
+ const lineEnd = candidates[j];
404
+ let trimEnd = lineEnd;
315
405
  while (trimEnd > lineStart) {
316
406
  const prevG = graphemes[trimEnd - 1];
317
407
  if (widths[trimEnd - 1] === 0) {
@@ -327,7 +417,10 @@ function knuthPlassForParagraph(analysis, pStart, pEnd, width) {
327
417
  const lineWidth = cumWidths[trimEnd] - lineStartCum;
328
418
  if (lineWidth > width) break;
329
419
  const leftover = width - lineWidth;
330
- const totalCost = (j === n - 1 ? 0 : leftover * leftover) + cost[j];
420
+ const isLastLine = j === n - 1;
421
+ let lineCost = isLastLine ? 0 : leftover * leftover;
422
+ if (!isLastLine) lineCost += wrapQualityPenalty(analysis, lineStart, lineEnd, trimEnd);
423
+ const totalCost = lineCost + cost[j];
331
424
  if (totalCost < cost[i]) {
332
425
  cost[i] = totalCost;
333
426
  next[i] = j;
@@ -344,23 +437,216 @@ function knuthPlassForParagraph(analysis, pStart, pEnd, width) {
344
437
  return breaks;
345
438
  }
346
439
  /**
440
+ * DP for a single paragraph (no newlines), per-line width.
441
+ *
442
+ * Forward DP — `f[i]` is the min cost to reach candidate i with `line[i]`
443
+ * tracking the line index of position i in the optimal path. Each segment
444
+ * (j → i) is on line `line[j]`, so its allowed width is `widthFn(line[j])`.
445
+ *
446
+ * O(n² × m) where m is max meaningful line index. For titles n is small
447
+ * (~30 candidates, ~5 lines) so this is trivial. The constant-width path
448
+ * above stays as the fast path for callers that don't need per-line.
449
+ *
450
+ * Dispatches to the 2D variant when `opts.maxLines` is set — needed because
451
+ * the optimal path through candidate `i` for line-count `k` may differ from
452
+ * the path for `k+1`, so the single-state `line[]` shortcut is unsafe under
453
+ * a hard line cap.
454
+ */
455
+ function knuthPlassForParagraphPerLine(analysis, pStart, pEnd, widthFn, opts) {
456
+ if (opts?.maxLines !== void 0) return knuthPlassForParagraph2D(analysis, pStart, pEnd, widthFn, opts.maxLines);
457
+ const { cumWidths, breakIndices, widths, graphemes } = analysis;
458
+ const candidates = [pStart];
459
+ for (const bp of breakIndices) if (bp > pStart && bp <= pEnd) candidates.push(bp);
460
+ candidates.push(pEnd);
461
+ const n = candidates.length;
462
+ if (n <= 2) return [];
463
+ const f = new Array(n).fill(Infinity);
464
+ const from = new Array(n).fill(-1);
465
+ const line = new Array(n).fill(0);
466
+ f[0] = 0;
467
+ for (let i = 1; i < n; i++) {
468
+ const lineEnd = candidates[i];
469
+ let trimEnd = lineEnd;
470
+ while (trimEnd > 0) {
471
+ const prevG = graphemes[trimEnd - 1];
472
+ if (widths[trimEnd - 1] === 0) {
473
+ trimEnd--;
474
+ continue;
475
+ }
476
+ if (prevG === " " || prevG === " ") {
477
+ trimEnd--;
478
+ continue;
479
+ }
480
+ break;
481
+ }
482
+ const trimEndCum = cumWidths[trimEnd];
483
+ for (let j = 0; j < i; j++) {
484
+ if (f[j] === Infinity) continue;
485
+ const lineStart = candidates[j];
486
+ const lineWidth = trimEndCum - cumWidths[lineStart];
487
+ const segLineIndex = line[j];
488
+ const allowedWidth = widthFn(segLineIndex);
489
+ if (lineWidth > allowedWidth) continue;
490
+ const leftover = allowedWidth - lineWidth;
491
+ const isLastLine = i === n - 1;
492
+ let lineCost = isLastLine ? 0 : leftover * leftover;
493
+ if (!isLastLine) lineCost += wrapQualityPenalty(analysis, lineStart, lineEnd, trimEnd);
494
+ const totalCost = f[j] + lineCost;
495
+ if (totalCost < f[i]) {
496
+ f[i] = totalCost;
497
+ from[i] = j;
498
+ line[i] = segLineIndex + 1;
499
+ }
500
+ }
501
+ }
502
+ if (f[n - 1] === Infinity) return [];
503
+ const breaks = [];
504
+ let idx = n - 1;
505
+ while (from[idx] > 0) {
506
+ idx = from[idx];
507
+ breaks.push(candidates[idx]);
508
+ }
509
+ breaks.reverse();
510
+ return breaks;
511
+ }
512
+ /**
513
+ * DP for a single paragraph with a hard line-budget cap.
514
+ *
515
+ * Forward 2D DP — `f[i][k]` = min cost to reach candidate `i` using
516
+ * exactly `k` lines. Transition:
517
+ *
518
+ * f[i][k+1] = min over j<i of (f[j][k] + lineCost(j → i, line k))
519
+ *
520
+ * The line index `k` is consumed by the (j → i) segment, so its allowed
521
+ * width is `widthFn(k)`. Final answer = `argmin_{k <= maxLines} f[n-1][k]`,
522
+ * with `cost = 0` on the last line (no raggedness penalty on the final
523
+ * line, matching the 1D contracts above).
524
+ *
525
+ * Returns `[]` when no path fits within `maxLines` lines (infeasibility
526
+ * signal — `optimalWrap` falls back to truncation).
527
+ *
528
+ * Complexity: O(n² × maxLines). For terminal titles n ≈ 30 candidates and
529
+ * maxLines ≈ 4-6, so ≤ 5400 ops per paragraph — negligible. Memory: one
530
+ * 2D Float64Array sized `n × (maxLines + 1)`.
531
+ *
532
+ * Tracking: @km/silvery/15130-pretext-maxlines-aware.
533
+ */
534
+ function knuthPlassForParagraph2D(analysis, pStart, pEnd, widthFn, maxLines) {
535
+ if (maxLines <= 0) return [];
536
+ const { cumWidths, breakIndices, widths, graphemes } = analysis;
537
+ const candidates = [pStart];
538
+ for (const bp of breakIndices) if (bp > pStart && bp <= pEnd) candidates.push(bp);
539
+ candidates.push(pEnd);
540
+ const n = candidates.length;
541
+ if (n <= 2) return [];
542
+ const K = maxLines + 1;
543
+ const f = new Float64Array(n * K).fill(Infinity);
544
+ const from = new Int32Array(n * K).fill(-1);
545
+ f[0] = 0;
546
+ const trimEnds = new Array(n);
547
+ for (let i = 0; i < n; i++) {
548
+ let trimEnd = candidates[i];
549
+ while (trimEnd > 0) {
550
+ const prevG = graphemes[trimEnd - 1];
551
+ if (widths[trimEnd - 1] === 0) {
552
+ trimEnd--;
553
+ continue;
554
+ }
555
+ if (prevG === " " || prevG === " ") {
556
+ trimEnd--;
557
+ continue;
558
+ }
559
+ break;
560
+ }
561
+ trimEnds[i] = trimEnd;
562
+ }
563
+ for (let j = 0; j < n - 1; j++) {
564
+ const lineStart = candidates[j];
565
+ const lineStartCum = cumWidths[lineStart];
566
+ for (let k = 0; k < maxLines; k++) {
567
+ const fjk = f[j * K + k];
568
+ if (fjk === Infinity) continue;
569
+ const allowedWidth = widthFn(k);
570
+ if (allowedWidth <= 0) continue;
571
+ for (let i = j + 1; i < n; i++) {
572
+ const lineEnd = candidates[i];
573
+ const trimEnd = trimEnds[i];
574
+ const lineWidth = cumWidths[trimEnd] - lineStartCum;
575
+ if (lineWidth > allowedWidth) continue;
576
+ const leftover = allowedWidth - lineWidth;
577
+ const isLastLine = i === n - 1;
578
+ let lineCost = isLastLine ? 0 : leftover * leftover;
579
+ if (!isLastLine) lineCost += wrapQualityPenalty(analysis, lineStart, lineEnd, trimEnd);
580
+ const total = fjk + lineCost;
581
+ const target = i * K + (k + 1);
582
+ if (total < f[target]) {
583
+ f[target] = total;
584
+ from[target] = j;
585
+ }
586
+ }
587
+ }
588
+ }
589
+ let bestK = -1;
590
+ let bestCost = Infinity;
591
+ for (let k = 1; k <= maxLines; k++) {
592
+ const c = f[(n - 1) * K + k];
593
+ if (c < bestCost) {
594
+ bestCost = c;
595
+ bestK = k;
596
+ }
597
+ }
598
+ if (bestK === -1 || bestCost === Infinity) return [];
599
+ const breaks = [];
600
+ let curI = n - 1;
601
+ let curK = bestK;
602
+ while (curK > 0) {
603
+ const prev = from[curI * K + curK];
604
+ if (prev < 0) break;
605
+ if (prev > 0) breaks.push(candidates[prev]);
606
+ curI = prev;
607
+ curK = curK - 1;
608
+ }
609
+ breaks.reverse();
610
+ return breaks;
611
+ }
612
+ /**
347
613
  * Wrap text using Knuth-Plass optimal breaks.
348
614
  * Returns line strings — drop-in replacement for greedy wrap.
349
615
  * Falls back to greedy wrapText when DP finds no feasible solution.
616
+ *
617
+ * `width` accepts a constant `number` (uniform width) OR a `WidthFn(lineIndex)`
618
+ * for per-line widths (e.g. line 0 narrowed by a top-right pill, lines 1+
619
+ * full width — CSS-float-equivalent layouts).
620
+ *
621
+ * `opts.maxLines` caps the wrap to ≤ N lines. When the DP can't fit the
622
+ * text in that budget, falls back to a greedy wrap clipped to `maxLines`
623
+ * with the final visible line truncated (head trimmed at a grapheme
624
+ * boundary so `head + truncationSuffix` fits within the budget). Default
625
+ * suffix is `"…"`.
626
+ *
627
+ * Tracking: @km/silvery/15130-pretext-maxlines-aware.
350
628
  */
351
- function optimalWrap(text, analysis, width) {
352
- const breaks = knuthPlassBreaks(analysis, width);
629
+ function optimalWrap(text, analysis, width, opts) {
630
+ const maxLines = opts?.maxLines;
631
+ const truncationSuffix = opts?.truncationSuffix ?? "…";
632
+ const breaks = knuthPlassBreaks(analysis, width, maxLines !== void 0 ? { maxLines } : void 0);
633
+ const fallbackWidth = typeof width === "function" ? width(0) : width;
353
634
  if (breaks.length === 0) {
354
- if (analysis.totalWidth <= width && analysis.newlineIndices.length === 0) return [text];
355
- return wrapText(text, width, true, true);
635
+ if (analysis.totalWidth <= fallbackWidth && analysis.newlineIndices.length === 0) return [text];
636
+ const greedy = wrapText(text, fallbackWidth, true, true);
637
+ if (maxLines !== void 0 && greedy.length > maxLines) return clampToMaxLines(greedy, maxLines, fallbackWidth, truncationSuffix);
638
+ return greedy;
356
639
  }
357
640
  const { graphemes, widths } = analysis;
358
641
  const lines = [];
359
642
  let lineStart = 0;
643
+ let pendingAnsiPrefix = "";
360
644
  for (const bp of breaks) {
361
645
  let lineEnd = bp;
646
+ let trailingAnsi = "";
362
647
  while (lineEnd > lineStart) {
363
648
  if (widths[lineEnd - 1] === 0) {
649
+ trailingAnsi = graphemes[lineEnd - 1] + trailingAnsi;
364
650
  lineEnd--;
365
651
  continue;
366
652
  }
@@ -371,20 +657,82 @@ function optimalWrap(text, analysis, width) {
371
657
  }
372
658
  break;
373
659
  }
374
- lines.push(graphemes.slice(lineStart, lineEnd).join(""));
660
+ lines.push(pendingAnsiPrefix + graphemes.slice(lineStart, lineEnd).join("") + trailingAnsi);
661
+ pendingAnsiPrefix = "";
375
662
  lineStart = bp;
376
663
  while (lineStart < graphemes.length) {
377
664
  const g = graphemes[lineStart];
665
+ const w = widths[lineStart];
378
666
  if (g === " " || g === " ") {
379
667
  lineStart++;
380
668
  continue;
381
669
  }
670
+ if (w === 0) {
671
+ pendingAnsiPrefix += g;
672
+ lineStart++;
673
+ continue;
674
+ }
382
675
  break;
383
676
  }
384
677
  }
385
- if (lineStart < graphemes.length) lines.push(graphemes.slice(lineStart).join(""));
678
+ if (lineStart < graphemes.length) lines.push(pendingAnsiPrefix + graphemes.slice(lineStart).join(""));
679
+ else if (pendingAnsiPrefix !== "") {
680
+ if (lines.length > 0) lines[lines.length - 1] = lines[lines.length - 1] + pendingAnsiPrefix;
681
+ }
682
+ if (maxLines !== void 0 && lines.length > maxLines) return clampToMaxLines(lines, maxLines, fallbackWidth, truncationSuffix);
386
683
  return lines;
387
684
  }
685
+ /**
686
+ * Clamp a wrapped output to `maxLines` rows. The final visible line is
687
+ * truncated so that `head + suffix` fits within `width`; the head is cut
688
+ * at a grapheme-aware boundary (using `splitGraphemesAnsiAware` to keep
689
+ * ANSI tokens intact).
690
+ *
691
+ * Used by `optimalWrap` when the DP cannot satisfy `opts.maxLines` and
692
+ * falls back to greedy + truncate, AND as a defensive net at the end of
693
+ * `optimalWrap` (the DP already enforces the cap by construction).
694
+ *
695
+ * Tracking: @km/silvery/15130-pretext-maxlines-aware.
696
+ */
697
+ function clampToMaxLines(lines, maxLines, width, suffix) {
698
+ if (lines.length <= maxLines) return lines;
699
+ if (maxLines <= 0) return [];
700
+ const kept = lines.slice(0, maxLines);
701
+ const lastIdx = maxLines - 1;
702
+ const last = kept[lastIdx];
703
+ kept[lastIdx] = appendTruncationSuffix(last, width, suffix);
704
+ return kept;
705
+ }
706
+ /**
707
+ * Append `suffix` to `line`, trimming graphemes off the visible end if
708
+ * needed so that `displayLength(line + suffix) <= width`. ANSI tokens
709
+ * (zero-width) are preserved across the truncation boundary so styling
710
+ * state isn't dropped.
711
+ *
712
+ * If `suffix` itself is wider than `width`, returns just the suffix
713
+ * (truncated to width via the same grapheme walk). Callers using a
714
+ * pathological width should size-check on their side.
715
+ */
716
+ function appendTruncationSuffix(line, width, suffix) {
717
+ if (width <= 0) return "";
718
+ const suffixWidth = splitGraphemesAnsiAware(suffix).reduce((acc, g) => acc + graphemeWidth(g), 0);
719
+ const lineGraphemes = splitGraphemesAnsiAware(line);
720
+ if (lineGraphemes.reduce((acc, g) => acc + graphemeWidth(g), 0) + suffixWidth <= width) return line + suffix;
721
+ const budget = Math.max(0, width - suffixWidth);
722
+ let acc = 0;
723
+ let cutoff = 0;
724
+ for (let i = 0; i < lineGraphemes.length; i++) {
725
+ const g = lineGraphemes[i];
726
+ const w = graphemeWidth(g);
727
+ if (acc + w > budget) break;
728
+ acc += w;
729
+ cutoff = i + 1;
730
+ }
731
+ let tail = "";
732
+ for (let i = cutoff; i < lineGraphemes.length; i++) if (graphemeWidth(lineGraphemes[i]) === 0) tail += lineGraphemes[i];
733
+ else break;
734
+ return lineGraphemes.slice(0, cutoff).join("") + tail + suffix;
735
+ }
388
736
  //#endregion
389
737
  //#region packages/ag-term/src/pipeline/helpers.ts
390
738
  /**
@@ -418,6 +766,363 @@ function getBorderSize(props) {
418
766
  };
419
767
  }
420
768
  //#endregion
769
+ //#region packages/ag-term/src/runtime/pass-cause.ts
770
+ /**
771
+ * pass-cause.ts — Renderer feedback-edge instrumentation, loggily-native.
772
+ *
773
+ * Captures the cause of each render/layout pass beyond pass 1. The convergence
774
+ * loop in `renderer.ts` (singlePassLayout / classic) and `create-app.tsx`
775
+ * (processEventBatch flush loop) iterate until `hadReactCommit` is false. Each
776
+ * extra pass is a "feedback edge": some computation in pass N caused React
777
+ * state to change, which forces pass N+1.
778
+ *
779
+ * This module attributes each extra pass to a category (PassCause) and an
780
+ * originating identity (node id, signal name, etc.) so we can answer "what
781
+ * keeps the loop alive?". The data feeds C3b (bounded-convergence) — once we
782
+ * know which feedback edges dominate, we can replace MAX_SINGLE_PASS_ITERATIONS
783
+ * with attributed bounds.
784
+ *
785
+ * ## Design (post dual-pro review, 2026-04-26)
786
+ *
787
+ * Pass-causes are CATEGORICAL events, not duration spans. Both GPT-5.4 Pro
788
+ * and Kimi K2.6 converged on:
789
+ *
790
+ * - Use `log.debug?.("pass", { cause, edge, nodeId, producerPhase })` —
791
+ * not `log.span()`. Spans imply duration; a pass-cause is instantaneous
792
+ * and would log spurious 0ms entries that pollute traces.
793
+ * - Aggregate via a custom pipeline `Stage` that captures `LogEvent.props`.
794
+ * The `Stage` type is part of loggily's public API (`loggily/index.ts`
795
+ * re-exports it from `./pipeline.js`) — it's the framework's intended
796
+ * extension point for non-duration aggregation.
797
+ * - Do NOT use `loggily/metrics`. `MetricsCollector` is intentionally
798
+ * duration-only: `recordSpan(data)` only sees `{name, durationMs}`,
799
+ * props are dropped at the collector boundary, and `summary()` returns
800
+ * `"name: N spans, mean=Xms, p50=Yms"`. For pass-causes the durations
801
+ * would all be ~0ms, and we would lose the edge/node breakdown that
802
+ * C3b consumes.
803
+ * - Emit on a parent + child sub-namespace (`silvery:passes` and
804
+ * `silvery:passes:<cause>`) so `DEBUG=silvery:passes:layout-invalidate`
805
+ * filters by cause without losing the canonical histogram (which reads
806
+ * from the parent-namespace stage).
807
+ * - Default-off via the silvery-side `SILVERY_INSTRUMENT=1` constant-fold
808
+ * gate (zero overhead when unset). Setting it also sets the loggily
809
+ * `LOG_LEVEL=debug` for `silvery:passes` (see logger setup below).
810
+ * - For tests: the aggregator stage is composable; tests can call
811
+ * `createPassCauseAggregator()` for a fresh aggregator and pass
812
+ * `.stage` into a test-local `createLogger("silvery:passes", ...)`.
813
+ *
814
+ * Tracking: km-silvery.renderer-feedback-trace, km-silvery.feedback-trace-loggily
815
+ */
816
+ /**
817
+ * Module-level instrumentation gate. Constant so JS engines can fold the
818
+ * `if (INSTRUMENT) { ... }` blocks at every emit site out of the hot path.
819
+ *
820
+ * Mapping to loggily:
821
+ * - SILVERY_INSTRUMENT=1 implies LOG_LEVEL=debug + DEBUG=silvery:passes
822
+ * for the silvery:passes namespace, so the loggily pipeline accepts
823
+ * debug-level events (default level is info, which would drop them).
824
+ *
825
+ * The two gates compose:
826
+ * - INSTRUMENT controls the silvery side (emit-call evaluation, prop
827
+ * allocation).
828
+ * - DEBUG/LOG_LEVEL controls the loggily side (which sinks/stages see
829
+ * the event).
830
+ */
831
+ const INSTRUMENT = process.env.SILVERY_INSTRUMENT === "1";
832
+ const instrumentEnabled = INSTRUMENT;
833
+ function createPassCauseAggregator() {
834
+ let state = {
835
+ records: [],
836
+ perPass: []
837
+ };
838
+ let recordsAtPassStart = 0;
839
+ const stage = (event) => {
840
+ if (event.kind !== "log") return event;
841
+ if (event.namespace !== "silvery:passes" && !event.namespace.startsWith("silvery:passes:")) return event;
842
+ if (event.message !== "pass") return event;
843
+ const props = event.props;
844
+ if (!props?.cause) return event;
845
+ state.records.push({
846
+ cause: props.cause,
847
+ nodeId: props.nodeId,
848
+ edge: props.edge,
849
+ producerPhase: props.producerPhase,
850
+ detail: props.detail
851
+ });
852
+ return event;
853
+ };
854
+ function getHistogram() {
855
+ const byCauseMap = /* @__PURE__ */ new Map();
856
+ for (const r of state.records) {
857
+ let entry = byCauseMap.get(r.cause);
858
+ if (!entry) {
859
+ entry = {
860
+ count: 0,
861
+ edges: /* @__PURE__ */ new Map(),
862
+ nodes: /* @__PURE__ */ new Map()
863
+ };
864
+ byCauseMap.set(r.cause, entry);
865
+ }
866
+ entry.count += 1;
867
+ if (r.edge) entry.edges.set(r.edge, (entry.edges.get(r.edge) ?? 0) + 1);
868
+ if (r.nodeId !== void 0) entry.nodes.set(r.nodeId, (entry.nodes.get(r.nodeId) ?? 0) + 1);
869
+ }
870
+ const byCause = [];
871
+ for (const [cause, entry] of byCauseMap) {
872
+ const topEdges = [...entry.edges.entries()].map(([edge, count]) => ({
873
+ edge,
874
+ count
875
+ })).sort((a, b) => b.count - a.count).slice(0, 8);
876
+ const topNodes = [...entry.nodes.entries()].map(([nodeId, count]) => ({
877
+ nodeId,
878
+ count
879
+ })).sort((a, b) => b.count - a.count).slice(0, 8);
880
+ byCause.push({
881
+ cause,
882
+ count: entry.count,
883
+ topEdges,
884
+ topNodes
885
+ });
886
+ }
887
+ byCause.sort((a, b) => b.count - a.count);
888
+ return {
889
+ totalRecords: state.records.length,
890
+ perPass: state.perPass.slice(),
891
+ byCause
892
+ };
893
+ }
894
+ function formatSummary() {
895
+ const h = getHistogram();
896
+ if (h.totalRecords === 0) return "pass-cause histogram: no extra passes recorded";
897
+ const lines = [];
898
+ lines.push(`pass-cause histogram: ${h.totalRecords} extra-pass causes`);
899
+ if (h.perPass.length > 0) {
900
+ const perPassLine = h.perPass.map((c, i) => c > 0 ? `pass${i}=${c}` : null).filter((s) => s !== null).join(" ");
901
+ if (perPassLine) lines.push(` per-pass commits: ${perPassLine}`);
902
+ }
903
+ for (const entry of h.byCause) {
904
+ const pct = (entry.count / h.totalRecords * 100).toFixed(1);
905
+ lines.push(` ${entry.cause}: ${entry.count} (${pct}%)`);
906
+ if (entry.topEdges.length > 0) lines.push(" edges: " + entry.topEdges.map((e) => `${e.edge}×${e.count}`).join(", "));
907
+ if (entry.topNodes.length > 0) lines.push(" nodes: " + entry.topNodes.map((n) => `${n.nodeId}×${n.count}`).join(", "));
908
+ }
909
+ return lines.join("\n");
910
+ }
911
+ function reset() {
912
+ state = {
913
+ records: [],
914
+ perPass: []
915
+ };
916
+ recordsAtPassStart = 0;
917
+ }
918
+ function notePassCommit(passIndex) {
919
+ while (state.perPass.length <= passIndex) state.perPass.push(0);
920
+ state.perPass[passIndex] = (state.perPass[passIndex] ?? 0) + 1;
921
+ if (state.records.length === recordsAtPassStart) state.records.push({
922
+ cause: "unknown",
923
+ detail: `pass-${passIndex}-uncategorized-commit`
924
+ });
925
+ }
926
+ function beginPass(_passIndex) {
927
+ recordsAtPassStart = state.records.length;
928
+ }
929
+ function beginConvergenceLoop() {
930
+ recordsAtPassStart = state.records.length;
931
+ }
932
+ function appendJson(file) {
933
+ const h = getHistogram();
934
+ if (h.totalRecords === 0) return;
935
+ __require("node:fs").appendFileSync(file, JSON.stringify(h) + "\n");
936
+ }
937
+ return {
938
+ stage,
939
+ getHistogram,
940
+ formatSummary,
941
+ reset,
942
+ notePassCommit,
943
+ beginPass,
944
+ beginConvergenceLoop,
945
+ appendJson
946
+ };
947
+ }
948
+ const passAggregator = createPassCauseAggregator();
949
+ /**
950
+ * Pass-cause loggers. Parent namespace is `silvery:passes`. Each cause gets
951
+ * a child namespace `silvery:passes:<cause>` so users can filter at any
952
+ * granularity:
953
+ * DEBUG=silvery:passes → all causes
954
+ * DEBUG=silvery:passes:layout-invalidate → only layout-invalidate
955
+ * DEBUG=silvery:passes:scrollto-settle → only scrollto-settle
956
+ *
957
+ * Both the parent and every child share the same pipeline (level + the
958
+ * aggregator stage). Children are cached on first use to avoid logger
959
+ * construction in the hot path.
960
+ *
961
+ * The aggregator stage is always attached — it's a no-op when no events
962
+ * arrive, and the INSTRUMENT gate at emit sites prevents events when the
963
+ * env var is unset.
964
+ */
965
+ const passLogConfig = [{ level: instrumentEnabled ? "debug" : "info" }, passAggregator.stage];
966
+ createLogger("silvery:passes", [...passLogConfig]);
967
+ const childLoggers = /* @__PURE__ */ new Map();
968
+ function loggerForCause(cause) {
969
+ let child = childLoggers.get(cause);
970
+ if (!child) {
971
+ child = createLogger(`silvery:passes:${cause}`, [...passLogConfig]);
972
+ childLoggers.set(cause, child);
973
+ }
974
+ return child;
975
+ }
976
+ /**
977
+ * Reset the per-loop pass-index tracker. Called at the top of each
978
+ * convergence loop in renderer.ts / runtime/create-app.tsx.
979
+ */
980
+ function beginConvergenceLoop() {
981
+ if (!instrumentEnabled) return;
982
+ passAggregator.beginConvergenceLoop();
983
+ }
984
+ /** Mark the start of pass N (0-based). */
985
+ function beginPass(passIndex) {
986
+ if (!instrumentEnabled) return;
987
+ passAggregator.beginPass(passIndex);
988
+ }
989
+ /**
990
+ * Record that a feedback edge fired during the current pass.
991
+ *
992
+ * Emits on the per-cause child namespace so that
993
+ * `DEBUG=silvery:passes:<cause>` filters by cause; the aggregator stage
994
+ * captures both parent and child events into the canonical histogram.
995
+ */
996
+ function logPass(record) {
997
+ if (!instrumentEnabled) return;
998
+ loggerForCause(record.cause).debug?.("pass", record);
999
+ }
1000
+ /**
1001
+ * Mark that pass N committed React work that will require pass N+1.
1002
+ * Bumps the per-pass-index counter and synthesizes an "unknown" record
1003
+ * if no specific cause was emitted during pass N.
1004
+ */
1005
+ function notePassCommit(passIndex) {
1006
+ if (!instrumentEnabled) return;
1007
+ passAggregator.notePassCommit(passIndex);
1008
+ }
1009
+ /** Snapshot the current pass-cause histogram. */
1010
+ function getPassHistogram() {
1011
+ if (!instrumentEnabled) return {
1012
+ totalRecords: 0,
1013
+ perPass: [],
1014
+ byCause: []
1015
+ };
1016
+ return passAggregator.getHistogram();
1017
+ }
1018
+ /** Clear all captured records. */
1019
+ function resetPassHistogram() {
1020
+ passAggregator.reset();
1021
+ }
1022
+ /**
1023
+ * Print histogram to stderr (test runner / app teardown). No-op if
1024
+ * instrumentation is disabled or no records exist.
1025
+ *
1026
+ * In test environments where stdout/stderr are intercepted, write to
1027
+ * SILVERY_INSTRUMENT_FILE instead.
1028
+ */
1029
+ function printPassHistogram() {
1030
+ if (!instrumentEnabled) return;
1031
+ if (passAggregator.getHistogram().totalRecords === 0) return;
1032
+ const formatted = passAggregator.formatSummary();
1033
+ const file = process.env.SILVERY_INSTRUMENT_FILE;
1034
+ if (file) {
1035
+ __require("node:fs").appendFileSync(file, "\n" + formatted + "\n");
1036
+ return;
1037
+ }
1038
+ process.stderr.write("\n" + formatted + "\n");
1039
+ }
1040
+ /** Append a JSON snapshot to a file (vitest exit hook). */
1041
+ function appendHistogramJson(file) {
1042
+ if (!instrumentEnabled) return;
1043
+ passAggregator.appendJson(file);
1044
+ }
1045
+ /**
1046
+ * Per-cause convergence bound: maximum extra passes (beyond the initial pass)
1047
+ * each PassCause category can structurally trigger before settling. Each
1048
+ * bound is an invariant of how the cause works in the pipeline — not a
1049
+ * histogram observation — and the math is documented in
1050
+ * `hub/silvery/design/convergence-bounds.md`.
1051
+ *
1052
+ * Empirical baseline (C3a v3 corpus, 105 termless app teardowns,
1053
+ * 11 538 records): no test reached pass 1+. The structural ceiling matches:
1054
+ * sum(PASS_CAUSE_BOUNDS) = 0, plus 1 initial pass + 1 settle pass = MAX=2.
1055
+ *
1056
+ * Why every cause's bound is 0:
1057
+ * - layout-invalidate: when a subscriber re-renders, the new layout produces
1058
+ * the SAME rects (idempotent given same input) so the second
1059
+ * notifyLayoutSubscribers reports no change. The settle pass drains the
1060
+ * subscribers' commits; it doesn't seed new feedback.
1061
+ * - intrinsic-shrinkwrap: measurement is content-deterministic; same tree
1062
+ * produces same shrunkWidth / fitContentHeight on the next pass.
1063
+ * - scrollto-settle: prevScrollTo === scrollTo guard prevents the same intent
1064
+ * from re-firing; the recovery edge is a one-shot "target moved offscreen".
1065
+ * - sticky-resettle: stickyChildren is recomputed deterministically from
1066
+ * layout positions — once layout is stable, sticky offsets are stable.
1067
+ * - viewport-resize: dim-change is a depth-0 root trigger, not a feedback
1068
+ * edge. Once the new dims are observed, no further resize fires.
1069
+ * - unknown: any non-zero unknown count is a regression to surface, not
1070
+ * budget to consume.
1071
+ *
1072
+ * The total convergence ceiling = 1 (initial) + 1 (settle) + sum of per-cause
1073
+ * bounds. Per the v3 corpus and the audit above, the per-cause bounds are
1074
+ * ALL 0 — each cause is satisfied within the single settle pass. Total = 2.
1075
+ */
1076
+ const PASS_CAUSE_BOUNDS = {
1077
+ "layout-invalidate": 0,
1078
+ "intrinsic-shrinkwrap": 0,
1079
+ "scrollto-settle": 0,
1080
+ "sticky-resettle": 0,
1081
+ "viewport-resize": 0,
1082
+ unknown: 0
1083
+ };
1084
+ /**
1085
+ * Total subscriber-feedback bound: 1 (initial) + 1 (one settle pass) + sum
1086
+ * of PASS_CAUSE_BOUNDS = 2. This applies to loops where pipeline output and
1087
+ * React effects flush in **separate phases** — singlePassLayout (one
1088
+ * runPipeline + a separate effect-flush loop), the effect-flush loop
1089
+ * itself, and the production processEventBatch flush.
1090
+ *
1091
+ * The `classic` loop in renderer.ts is structurally different: it runs
1092
+ * runPipeline + flushSyncWork **interleaved** within the same loop, so a
1093
+ * single iteration may emit a rect-signal change AND drain a virtualizer
1094
+ * re-render that mounts new items, which then need ANOTHER iteration to
1095
+ * lay out. That's not subscriber feedback — it's layout-vs-React
1096
+ * stabilisation, and the historical empirical bound for it is 5. Tests
1097
+ * that exercise virtualizer + scroll convergence (e.g.
1098
+ * list-view-refinements with 100 items + indexed virtualization) actually
1099
+ * use 3-4 of those iterations to settle the rendered window.
1100
+ *
1101
+ * Both bounds replace the four prior magic constants
1102
+ * (MAX_SINGLE_PASS_ITERATIONS=15, MAX_LAYOUT_ITERATIONS=5,
1103
+ * MAX_EFFECT_FLUSHES=5, maxFlushes=5) — the 15-pass cap is gone, and the
1104
+ * 5 is now an explicit per-loop documented value, not a guess.
1105
+ */
1106
+ const MAX_CONVERGENCE_PASSES = 2 + PASS_CAUSE_BOUNDS["layout-invalidate"] + PASS_CAUSE_BOUNDS["intrinsic-shrinkwrap"] + PASS_CAUSE_BOUNDS["scrollto-settle"] + PASS_CAUSE_BOUNDS["sticky-resettle"] + PASS_CAUSE_BOUNDS["viewport-resize"] + PASS_CAUSE_BOUNDS.unknown;
1107
+ /**
1108
+ * Assert the convergence loop did not exceed its cap. Called from the
1109
+ * renderer's loops + create-app's processEventBatch flush loop. No-op
1110
+ * outside SILVERY_STRICT — the loop bound itself is the production
1111
+ * safety net.
1112
+ *
1113
+ * STRICT=2: throws with a per-cause breakdown so a regression names the
1114
+ * responsible edge instead of just "exhausted N iterations".
1115
+ * STRICT=1: emits a stderr warning with the same breakdown.
1116
+ */
1117
+ function assertBoundedConvergence(passCount, loopName, cap) {
1118
+ if (passCount <= cap) return;
1119
+ const strict = process?.env?.SILVERY_STRICT;
1120
+ if (!strict) return;
1121
+ const msg = `convergence bound exceeded in ${loopName}: ${passCount} passes ran but cap=${cap} (default MAX_CONVERGENCE_PASSES=${MAX_CONVERGENCE_PASSES}). Per-cause breakdown: ${getPassHistogram().byCause.map((e) => `${e.cause}=${e.count}(bound=${PASS_CAUSE_BOUNDS[e.cause]})`).join(", ") || "(no records — INSTRUMENT off)"}. Either a feedback edge broke its per-cause invariant, or a new edge needs a PassCause category in pass-cause.ts.`;
1122
+ if (strict === "2") throw new Error(msg);
1123
+ process.stderr.write(`[silvery] ${msg}\n`);
1124
+ }
1125
+ //#endregion
421
1126
  //#region packages/ag-term/src/pipeline/measure-phase.ts
422
1127
  /**
423
1128
  * Handle fit-content nodes by measuring their intrinsic content size.
@@ -446,11 +1151,25 @@ function measurePhase(root, ctx) {
446
1151
  }
447
1152
  if (isSnugContent) {
448
1153
  const shrunkWidth = computeSnugContentWidth(node, measureIntrinsicSize(node, ctx, availableWidth).width, ctx);
1154
+ const prevWidth = node.boxRect?.width;
449
1155
  node.layoutNode.setMaxWidth(shrunkWidth);
1156
+ if (INSTRUMENT && prevWidth !== void 0 && prevWidth !== shrunkWidth) logPass({
1157
+ cause: "intrinsic-shrinkwrap",
1158
+ edge: "snug-content:width",
1159
+ producerPhase: "measure",
1160
+ detail: `${prevWidth}→${shrunkWidth}`
1161
+ });
450
1162
  }
451
1163
  if (isHeightFitContent) {
452
1164
  const intrinsicSize = measureIntrinsicSize(node, ctx, availableWidth);
1165
+ const prevHeight = node.boxRect?.height;
453
1166
  node.layoutNode.setHeight(intrinsicSize.height);
1167
+ if (INSTRUMENT && prevHeight !== void 0 && prevHeight !== intrinsicSize.height) logPass({
1168
+ cause: "intrinsic-shrinkwrap",
1169
+ edge: "fit-content:height",
1170
+ producerPhase: "measure",
1171
+ detail: `${prevHeight}→${intrinsicSize.height}`
1172
+ });
454
1173
  }
455
1174
  }
456
1175
  });
@@ -482,8 +1201,10 @@ function measureIntrinsicSize(node, ctx, availableWidth) {
482
1201
  }
483
1202
  const transform = textProps.internal_transform;
484
1203
  let lines;
485
- if (availableWidth !== void 0 && availableWidth > 0 && isWrapEnabled(textProps.wrap)) lines = ctx ? ctx.measurer.wrapText(text, availableWidth, true, true) : wrapText(text, availableWidth, true, true);
486
- else lines = text.split("\n");
1204
+ if (availableWidth !== void 0 && availableWidth > 0 && isWrapEnabled(textProps.wrap)) {
1205
+ const truncateAtomic = textProps.wrap === "wrap-truncate";
1206
+ lines = ctx ? ctx.measurer.wrapText(text, availableWidth, true, true, truncateAtomic) : wrapText(text, availableWidth, true, true, truncateAtomic);
1207
+ } else lines = text.split("\n");
487
1208
  if (transform) lines = lines.map((line, index) => transform(line, index));
488
1209
  return {
489
1210
  width: Math.max(...lines.map((line) => getTextWidth$1(line, ctx))),
@@ -528,7 +1249,7 @@ function measureIntrinsicSize(node, ctx, availableWidth) {
528
1249
  * Check if text wrapping is enabled for a text node.
529
1250
  */
530
1251
  function isWrapEnabled(wrap) {
531
- return wrap === "wrap" || wrap === "hard" || wrap === "even" || wrap === true || wrap === void 0;
1252
+ return wrap === "wrap" || wrap === "wrap-truncate" || wrap === "hard" || wrap === "even" || wrap === true || wrap === void 0;
532
1253
  }
533
1254
  /**
534
1255
  * Compute snug-content width for a node.
@@ -612,6 +1333,49 @@ function getTextWidth$1(text, ctx) {
612
1333
  */
613
1334
  const log$2 = createLogger("silvery:layout");
614
1335
  /**
1336
+ * Stable-ish identity string for an AgNode used by pass-cause records.
1337
+ * Prefers explicit identity props (testid / id / name / nodeId) and falls
1338
+ * back to type. Keeps the histogram readable without paying a Map lookup
1339
+ * per node when SILVERY_INSTRUMENT is unset (call site is gated by the
1340
+ * inert no-op in `logPass`).
1341
+ */
1342
+ function nodeIdent(node) {
1343
+ const props = node.props;
1344
+ const ident = props?.["testid"] ?? props?.["id"] ?? props?.["name"] ?? props?.["nodeId"];
1345
+ return ident ? `${node.type}#${ident}` : node.type;
1346
+ }
1347
+ function nodePath(node) {
1348
+ const path = [];
1349
+ let current = node;
1350
+ while (current) {
1351
+ path.push(nodeIdent(current));
1352
+ current = current.parent;
1353
+ }
1354
+ return path.reverse().join(" > ");
1355
+ }
1356
+ function layoutPropsForLog(node) {
1357
+ const props = node.props ?? {};
1358
+ return {
1359
+ width: props.width,
1360
+ maxWidth: props.maxWidth,
1361
+ minWidth: props.minWidth,
1362
+ flexGrow: props.flexGrow,
1363
+ flexShrink: props.flexShrink,
1364
+ flexBasis: props.flexBasis,
1365
+ alignSelf: props.alignSelf,
1366
+ position: props.position,
1367
+ overflow: props.overflow,
1368
+ borderStyle: props.borderStyle,
1369
+ padding: props.padding,
1370
+ paddingLeft: props.paddingLeft,
1371
+ paddingRight: props.paddingRight
1372
+ };
1373
+ }
1374
+ function normalizeScrollOffset(offset) {
1375
+ if (!Number.isFinite(offset)) return 0;
1376
+ return Math.round(offset);
1377
+ }
1378
+ /**
615
1379
  * Run Yoga layout calculation and propagate dimensions to all nodes.
616
1380
  *
617
1381
  * @param root The root SilveryNode
@@ -765,8 +1529,72 @@ function _hasChildPositionChanged(node) {
765
1529
  return false;
766
1530
  }
767
1531
  /**
768
- * Check if any descendant was overflowing THIS node's rect and had its layout change.
769
- * Recursive: follows subtreeDirty paths for efficiency.
1532
+ * Lightweight `getEffectiveBg` for the layout phase. Mirrors the
1533
+ * production helper in `render-box.ts` (Sterling-aware: `bg-surface-default`
1534
+ * → legacy `bg`). Inlined here to avoid a layout→render-box import cycle.
1535
+ *
1536
+ * The render phase still uses its own `getEffectiveBg` for the cascade
1537
+ * formulas; this duplicate exists ONLY so the layout phase can flag
1538
+ * "bg-bearing descendant moved/shrank" without dragging render-box into
1539
+ * the layout module graph.
1540
+ */
1541
+ function _layoutGetEffectiveBg(props) {
1542
+ if (props.backgroundColor) return props.backgroundColor;
1543
+ const theme = props.theme;
1544
+ if (theme) {
1545
+ const sterlingBg = theme["bg-surface-default"];
1546
+ if (typeof sterlingBg === "string") return sterlingBg;
1547
+ const legacyBg = theme["bg"];
1548
+ if (typeof legacyBg === "string") return legacyBg;
1549
+ }
1550
+ }
1551
+ /**
1552
+ * True iff `prev` has any cells NOT covered by `cur` (shrink, move, or any
1553
+ * combination thereof). This is the "bg residue" predicate: cells the
1554
+ * painter occupied before but doesn't occupy now. Equivalent to
1555
+ * `prev \ cur ≠ ∅`.
1556
+ */
1557
+ function _prevHasResidueOutside(prev, cur) {
1558
+ return prev.x < cur.x || prev.y < cur.y || prev.x + prev.width > cur.x + cur.width || prev.y + prev.height > cur.y + cur.height;
1559
+ }
1560
+ /**
1561
+ * Check if any descendant was overflowing THIS node's rect and had its layout
1562
+ * change OR if any bg-bearing descendant shrank/moved within this node's rect
1563
+ * (leaving stale bg residue inside).
1564
+ *
1565
+ * Two related cases:
1566
+ *
1567
+ * 1. **Outside-overflow** (original): descendant's prev rect extended
1568
+ * BEYOND `node`'s current rect. The descendant either grew past
1569
+ * `node`'s edge before and shrank back inside, or moved out and back —
1570
+ * either way `node`'s border/padding cells outside the descendant's
1571
+ * new rect carry stale pixels.
1572
+ *
1573
+ * 2. **Inside-bg-residue** (added 2026-05-07, bead
1574
+ * @km/silvery/incremental-bg-residue-shrink-move): descendant has
1575
+ * `effectiveBg` AND its prev rect has cells NOT covered by its
1576
+ * current rect (any shrink/move). The painter's prev paint covered
1577
+ * those cells with its bg; on this frame nothing in the painter's
1578
+ * subtree paints them. If those cells are within `node`'s rect,
1579
+ * `node` must clear them via its own `clearNodeRegion` (when
1580
+ * transparent) or `renderBox` fill (when bg-bearing). Without this
1581
+ * flag, `node`'s contentAreaAffected may be FALSE — the painter's
1582
+ * `clearExcessArea` SKIPS due to the position-change guard, and
1583
+ * `node`'s fast-path-with-subtreeDirty doesn't trigger any cleanup.
1584
+ * The cell carries forward bg from the prev frame indefinitely.
1585
+ *
1586
+ * Why on `node` and not just the immediate parent: the residue
1587
+ * cells must be covered by SOME ancestor that clears or fills.
1588
+ * The closest transparent ancestor that contains the residue cells
1589
+ * must clear them; if every ancestor up to a bg-bearing one is
1590
+ * transparent, that bg-bearing ancestor's renderBox fill suffices.
1591
+ * Setting the flag at every ancestor whose rect contains residue
1592
+ * cells guarantees coverage regardless of where the bg-break is.
1593
+ *
1594
+ * Recursive: follows subtreeDirty paths for efficiency. Returns early on
1595
+ * first match. Performance: only runs when subtreeDirty (matches the
1596
+ * `_hasAbsoluteChildMutated` guard); for typical UI updates the search
1597
+ * touches < 10 nodes before terminating.
770
1598
  */
771
1599
  function _hasDescendantOverflowChanged(node, rect) {
772
1600
  return _checkDescendantOverflow(node.children, rect.x, rect.y, rect.x + rect.width, rect.y + rect.height);
@@ -776,6 +1604,16 @@ function _checkDescendantOverflow(children, nodeLeft, nodeTop, nodeRight, nodeBo
776
1604
  if (child.prevLayout && isCurrentEpoch(child.layoutChangedThisFrame)) {
777
1605
  const prev = child.prevLayout;
778
1606
  if (prev.x + prev.width > nodeRight || prev.y + prev.height > nodeBottom || prev.x < nodeLeft || prev.y < nodeTop) return true;
1607
+ if (child.boxRect) {
1608
+ const props = child.props;
1609
+ if (_layoutGetEffectiveBg(props) !== void 0 && _prevHasResidueOutside(prev, child.boxRect)) {
1610
+ const ix1 = Math.max(prev.x, nodeLeft);
1611
+ const iy1 = Math.max(prev.y, nodeTop);
1612
+ const ix2 = Math.min(prev.x + prev.width, nodeRight);
1613
+ const iy2 = Math.min(prev.y + prev.height, nodeBottom);
1614
+ if (ix2 > ix1 && iy2 > iy1) return true;
1615
+ }
1616
+ }
779
1617
  }
780
1618
  if (isDirty(child.dirtyBits, child.dirtyEpoch, 16) && child.children !== void 0) {
781
1619
  if (_checkDescendantOverflow(child.children, nodeLeft, nodeTop, nodeRight, nodeBottom)) return true;
@@ -796,11 +1634,44 @@ function _checkDescendantOverflow(children, nodeLeft, nodeTop, nodeRight, nodeBo
796
1634
  * offset changes even when scrollRect stays the same.
797
1635
  */
798
1636
  function notifyLayoutSubscribers(node) {
799
- rectEqual(node.prevLayout, node.boxRect);
800
- rectEqual(node.prevScrollRect, node.scrollRect);
801
- rectEqual(node.prevScreenRect, node.screenRect);
1637
+ const contentChanged = !rectEqual(node.prevLayout, node.boxRect);
1638
+ const sizeChanged = (node.prevLayout?.width ?? 0) !== (node.boxRect?.width ?? 0) || (node.prevLayout?.height ?? 0) !== (node.boxRect?.height ?? 0);
1639
+ const screenChanged = !rectEqual(node.prevScrollRect, node.scrollRect);
1640
+ const renderChanged = !rectEqual(node.prevScreenRect, node.screenRect);
1641
+ if (INSTRUMENT) {
1642
+ if (contentChanged || screenChanged || renderChanged) {
1643
+ if (hasLayoutSignals(node)) {
1644
+ const ident = nodeIdent(node);
1645
+ if (contentChanged && hasObservedLayoutSignal(node, "boxRect")) logPass({
1646
+ cause: "layout-invalidate",
1647
+ edge: "boxRect",
1648
+ nodeId: ident,
1649
+ producerPhase: "layout"
1650
+ });
1651
+ if (sizeChanged && hasObservedLayoutSignal(node, "boxSize")) logPass({
1652
+ cause: "layout-invalidate",
1653
+ edge: "boxSize",
1654
+ nodeId: ident,
1655
+ producerPhase: "layout"
1656
+ });
1657
+ if (screenChanged && hasObservedLayoutSignal(node, "scrollRect")) logPass({
1658
+ cause: "layout-invalidate",
1659
+ edge: "scrollRect",
1660
+ nodeId: ident,
1661
+ producerPhase: "layout"
1662
+ });
1663
+ if (renderChanged && hasObservedLayoutSignal(node, "screenRect")) logPass({
1664
+ cause: "layout-invalidate",
1665
+ edge: "screenRect",
1666
+ nodeId: ident,
1667
+ producerPhase: "layout"
1668
+ });
1669
+ }
1670
+ }
1671
+ }
802
1672
  syncRectSignals(node);
803
1673
  for (const child of node.children) notifyLayoutSubscribers(child);
1674
+ if (node.parent === null) syncDecorationRects(node);
804
1675
  }
805
1676
  /**
806
1677
  * Verify that no child's boxRect.width exceeds its parent's inner content width.
@@ -843,9 +1714,24 @@ function strictLayoutOverflowCheck(root) {
843
1714
  if (child.boxRect.width > parentInnerWidth) {
844
1715
  const childId = childProps.id ?? child.type;
845
1716
  const parentId = parentProps.id ?? node.type;
846
- const msg = `[SILVERY_STRICT] Layout overflow: child "${childId}" width ${child.boxRect.width} exceeds parent "${parentId}" inner width ${parentInnerWidth} (parent box: ${node.boxRect.width}, border: ${border.left}+${border.right}, padding: ${padding.left}+${padding.right})`;
1717
+ const detail = {
1718
+ child: nodeIdent(child),
1719
+ parent: nodeIdent(node),
1720
+ childPath: nodePath(child),
1721
+ childBox: child.boxRect,
1722
+ parentBox: node.boxRect,
1723
+ parentInnerWidth,
1724
+ border,
1725
+ padding,
1726
+ childProps: layoutPropsForLog(child),
1727
+ parentProps: layoutPropsForLog(node)
1728
+ };
1729
+ const msg = `[SILVERY_STRICT] Layout overflow: child "${childId}" width ${child.boxRect.width} exceeds parent "${parentId}" inner width ${parentInnerWidth} (parent box: ${node.boxRect.width}, border: ${border.left}+${border.right}, padding: ${padding.left}+${padding.right}, path: ${detail.childPath})`;
847
1730
  if (shouldThrow) throw new Error(msg);
848
- else console.warn(msg);
1731
+ else {
1732
+ log$2.debug?.("layout overflow", detail);
1733
+ console.warn(msg);
1734
+ }
849
1735
  }
850
1736
  }
851
1737
  walk(child);
@@ -941,18 +1827,38 @@ function calculateScrollState(node, props, skipStateUpdates) {
941
1827
  const viewportHeight = rawViewportHeight;
942
1828
  const indicatorReserve = props.overflowIndicator === true && !props.borderStyle && contentHeight > rawViewportHeight ? 1 : 0;
943
1829
  const prevOffset = node.scrollState?.offset;
1830
+ const prevScrollTo = node.scrollState?.prevScrollTo;
944
1831
  let scrollOffset = props.scrollOffset ?? prevOffset ?? 0;
945
1832
  const scrollTo = props.scrollTo;
946
- if (scrollTo !== void 0 && scrollTo >= 0 && scrollTo < childPositions.length) {
1833
+ const scrollToChanged = prevScrollTo !== scrollTo;
1834
+ let targetCompletelyOffscreen = false;
1835
+ if (!scrollToChanged && scrollTo !== void 0 && scrollTo >= 0 && scrollTo < childPositions.length) {
1836
+ const target = childPositions.find((c) => c.index === scrollTo);
1837
+ if (target && target.top !== target.bottom) {
1838
+ const visTop = scrollOffset;
1839
+ const visBottom = scrollOffset + viewportHeight;
1840
+ if (!(target.bottom > visTop && target.top < visBottom)) targetCompletelyOffscreen = true;
1841
+ }
1842
+ }
1843
+ if (scrollTo !== void 0 && scrollTo >= 0 && scrollTo < childPositions.length && (scrollToChanged || targetCompletelyOffscreen)) {
947
1844
  const target = childPositions.find((c) => c.index === scrollTo);
948
1845
  if (target) {
1846
+ if (INSTRUMENT) logPass({
1847
+ cause: "scrollto-settle",
1848
+ edge: targetCompletelyOffscreen ? "scrollTo:recovery" : "scrollTo:newIntent",
1849
+ nodeId: nodeIdent(node),
1850
+ producerPhase: "scroll",
1851
+ detail: `target=${scrollTo}`
1852
+ });
949
1853
  const effectiveHeight = viewportHeight - indicatorReserve;
950
1854
  const visibleTop = scrollOffset;
951
1855
  const visibleBottom = scrollOffset + effectiveHeight;
952
- if (target.top < visibleTop) scrollOffset = target.top > 0 ? target.top - 1 : 0;
1856
+ if (target.bottom - target.top > effectiveHeight) scrollOffset = target.top > 0 ? target.top - 1 : 0;
1857
+ else if (target.top < visibleTop) scrollOffset = target.top > 0 ? target.top - 1 : 0;
953
1858
  else if (target.bottom > visibleBottom) scrollOffset = snapOffsetToChildTop(target.bottom - effectiveHeight, childPositions, target);
954
1859
  }
955
1860
  }
1861
+ scrollOffset = normalizeScrollOffset(scrollOffset);
956
1862
  scrollOffset = Math.max(0, scrollOffset);
957
1863
  scrollOffset = Math.min(scrollOffset, Math.max(0, contentHeight - viewportHeight));
958
1864
  const visibleTop = scrollOffset;
@@ -961,6 +1867,10 @@ function calculateScrollState(node, props, skipStateUpdates) {
961
1867
  let lastVisible = -1;
962
1868
  let hiddenAbove = 0;
963
1869
  let hiddenBelow = 0;
1870
+ const logicalCount = (cp) => {
1871
+ const r = cp.child.props.representsItems;
1872
+ return r !== void 0 && r >= 0 ? r : 1;
1873
+ };
964
1874
  for (const cp of childPositions) {
965
1875
  if (cp.isSticky) {
966
1876
  if (firstVisible === -1) firstVisible = cp.index;
@@ -968,15 +1878,20 @@ function calculateScrollState(node, props, skipStateUpdates) {
968
1878
  continue;
969
1879
  }
970
1880
  if (cp.top === cp.bottom) continue;
971
- if (cp.bottom <= visibleTop) hiddenAbove++;
972
- else if (cp.top >= visibleBottom) hiddenBelow++;
1881
+ if (cp.bottom <= visibleTop) hiddenAbove += logicalCount(cp);
1882
+ else if (cp.top >= visibleBottom) hiddenBelow += logicalCount(cp);
973
1883
  else if (cp.top < visibleTop) {
974
1884
  if (firstVisible === -1) firstVisible = cp.index;
975
1885
  lastVisible = Math.max(lastVisible, cp.index);
976
1886
  } else if (cp.bottom > visibleBottom) {
977
1887
  if (firstVisible === -1) firstVisible = cp.index;
978
1888
  lastVisible = cp.index;
979
- if (indicatorReserve > 0) hiddenBelow++;
1889
+ const isLastChild = cp.index === childPositions[childPositions.length - 1]?.index;
1890
+ const rawViewportBottom = scrollOffset + viewportHeight;
1891
+ const childHeight = cp.bottom - cp.top;
1892
+ const isPhantomReserveCut = isLastChild && cp.bottom <= rawViewportBottom;
1893
+ const isOversizedLastAtEnd = isLastChild && childHeight > viewportHeight && scrollTo === cp.index;
1894
+ if (indicatorReserve > 0 && !isPhantomReserveCut && !isOversizedLastAtEnd) hiddenBelow += logicalCount(cp);
980
1895
  } else {
981
1896
  if (firstVisible === -1) firstVisible = cp.index;
982
1897
  lastVisible = cp.index;
@@ -1006,6 +1921,7 @@ function calculateScrollState(node, props, skipStateUpdates) {
1006
1921
  height: childHeight
1007
1922
  });
1008
1923
  }
1924
+ strictScrollInvariants(node, props, scrollOffset, contentHeight, viewportHeight, indicatorReserve, childPositions, firstVisible, lastVisible, stickyChildren);
1009
1925
  if (skipStateUpdates) return;
1010
1926
  const prevFirstVisible = node.scrollState?.firstVisibleChild ?? firstVisible;
1011
1927
  const prevLastVisible = node.scrollState?.lastVisibleChild ?? lastVisible;
@@ -1019,6 +1935,7 @@ function calculateScrollState(node, props, skipStateUpdates) {
1019
1935
  node.scrollState = {
1020
1936
  offset: scrollOffset,
1021
1937
  prevOffset: prevOffset ?? scrollOffset,
1938
+ prevScrollTo: scrollTo,
1022
1939
  contentHeight,
1023
1940
  viewportHeight,
1024
1941
  firstVisibleChild: firstVisible,
@@ -1031,6 +1948,69 @@ function calculateScrollState(node, props, skipStateUpdates) {
1031
1948
  };
1032
1949
  }
1033
1950
  /**
1951
+ * Runtime invariants for scroll state. Gated on SILVERY_STRICT.
1952
+ *
1953
+ * Invariant set (all violations use `[SILVERY_STRICT]` prefix, L1 warn / L2 throw):
1954
+ * 1. scrollOffset is clamped: 0 ≤ scrollOffset ≤ max(0, contentHeight - viewportHeight).
1955
+ * 2. If scrollTo is a valid index, the target child intersects the effective
1956
+ * viewport (viewport minus indicatorReserve) after the offset calc.
1957
+ * 3. firstVisibleChild / lastVisibleChild correspond to children that actually
1958
+ * intersect the effective viewport (not spacer indices outside).
1959
+ * 4. Sticky child renderOffset stays within legal viewport bounds — specifically
1960
+ * `renderOffset + height > 0 && renderOffset < viewportHeight` (sticky row
1961
+ * not clipped entirely out, which `calculateScrollState` already filters,
1962
+ * but we also verify the sticky row's bottom doesn't sit ABOVE 0 for
1963
+ * isSticking=true entries since clamping should have prevented that).
1964
+ *
1965
+ * These are GENERIC scroll invariants — no virtualizer knowledge. Any divergence
1966
+ * points to a bug in the scroll/sticky math itself.
1967
+ */
1968
+ function strictScrollInvariants(node, props, scrollOffset, contentHeight, viewportHeight, indicatorReserve, childPositions, firstVisible, lastVisible, stickyChildren) {
1969
+ const strict = process?.env?.SILVERY_STRICT;
1970
+ if (!strict) return;
1971
+ const shouldThrow = strict === "2";
1972
+ const nodeId = props.id ?? node.type;
1973
+ const report = (msg) => {
1974
+ const full = `[SILVERY_STRICT] ${msg} (node: ${nodeId})`;
1975
+ if (shouldThrow) throw new Error(full);
1976
+ else console.warn(full);
1977
+ };
1978
+ const maxOffset = Math.max(0, contentHeight - viewportHeight);
1979
+ if (scrollOffset < 0) report(`scrollOffset ${scrollOffset} < 0`);
1980
+ else if (scrollOffset > maxOffset) report(`scrollOffset ${scrollOffset} exceeds max ${maxOffset} (contentHeight=${contentHeight}, viewportHeight=${viewportHeight})`);
1981
+ const scrollTo = props.scrollTo;
1982
+ if (scrollTo !== void 0 && scrollTo >= 0 && scrollTo < childPositions.length) {
1983
+ const target = childPositions.find((c) => c.index === scrollTo);
1984
+ if (target && target.top !== target.bottom) {
1985
+ const visibleTop = scrollOffset;
1986
+ const visibleBottom = scrollOffset + viewportHeight;
1987
+ if (!(target.bottom > visibleTop && target.top < visibleBottom)) report(`scrollTo target index=${scrollTo} does not intersect viewport (target [${target.top},${target.bottom}), visible [${visibleTop},${visibleBottom}), indicatorReserve=${indicatorReserve})`);
1988
+ }
1989
+ }
1990
+ const visibleTop = scrollOffset;
1991
+ const visibleBottom = scrollOffset + viewportHeight - indicatorReserve;
1992
+ const checkVisible = (label, idx) => {
1993
+ if (idx < 0) return;
1994
+ const cp = childPositions.find((c) => c.index === idx);
1995
+ if (!cp) {
1996
+ report(`${label}=${idx} but no child at that index`);
1997
+ return;
1998
+ }
1999
+ if (cp.isSticky) return;
2000
+ if (cp.top === cp.bottom) {
2001
+ report(`${label}=${idx} references zero-height child`);
2002
+ return;
2003
+ }
2004
+ if (!(cp.bottom > visibleTop && cp.top < visibleBottom)) report(`${label}=${idx} does not intersect effective viewport (child [${cp.top},${cp.bottom}), visible [${visibleTop},${visibleBottom}))`);
2005
+ };
2006
+ checkVisible("firstVisibleChild", firstVisible);
2007
+ checkVisible("lastVisibleChild", lastVisible);
2008
+ for (const sc of stickyChildren) {
2009
+ const topRow = sc.renderOffset;
2010
+ if (sc.renderOffset + sc.height <= 0 || topRow >= viewportHeight) report(`sticky child index=${sc.index} renderOffset=${topRow} height=${sc.height} outside viewport [0,${viewportHeight})`);
2011
+ }
2012
+ }
2013
+ /**
1034
2014
  * Compute sticky offsets for non-scroll containers that have sticky children.
1035
2015
  *
1036
2016
  * Scroll containers handle their own sticky logic in calculateScrollState().
@@ -1102,6 +2082,13 @@ function stickyPhase(root) {
1102
2082
  node.dirtyBits = 16;
1103
2083
  node.dirtyEpoch = epoch;
1104
2084
  } else node.dirtyBits |= 16;
2085
+ if (INSTRUMENT) logPass({
2086
+ cause: "sticky-resettle",
2087
+ edge: "stickyChildren",
2088
+ nodeId: nodeIdent(node),
2089
+ producerPhase: "sticky",
2090
+ detail: `${prev?.length ?? 0}→${next?.length ?? 0}`
2091
+ });
1105
2092
  }
1106
2093
  });
1107
2094
  }
@@ -1265,6 +2252,7 @@ function detectPipelineFeatures(root) {
1265
2252
  }
1266
2253
  //#endregion
1267
2254
  //#region packages/ag-term/src/pipeline/state.ts
2255
+ init_src$1();
1268
2256
  /**
1269
2257
  * Safe fallback theme. Never mutated — the theme flows via the AgNode tree
1270
2258
  * (Box theme= prop + pushContextTheme/popContextTheme in render-phase.ts).
@@ -1312,18 +2300,7 @@ function popContextTheme() {
1312
2300
  }
1313
2301
  //#endregion
1314
2302
  //#region packages/ag-term/src/pipeline/render-helpers.ts
1315
- /**
1316
- * Render Helpers - Pure utility functions for content rendering.
1317
- *
1318
- * Contains:
1319
- * - Color parsing (parseColor)
1320
- * - Border character definitions (getBorderChars)
1321
- * - Style extraction (getTextStyle)
1322
- * - Text width utilities (getTextWidth)
1323
- *
1324
- * Re-exports layout helpers from helpers.ts:
1325
- * - getPadding, getBorderSize
1326
- */
2303
+ init_buffer();
1327
2304
  init_src();
1328
2305
  const namedColors = {
1329
2306
  black: 0,
@@ -1388,7 +2365,7 @@ function parseColor(color) {
1388
2365
  }
1389
2366
  }
1390
2367
  if (color.startsWith("$")) {
1391
- if (getActiveColorLevel() === "none") return null;
2368
+ if (getActiveColorLevel() === "mono") return null;
1392
2369
  const resolved = resolveThemeColor(color, getActiveTheme());
1393
2370
  if (resolved && resolved !== color) return parseColor(resolved);
1394
2371
  return null;
@@ -1523,14 +2500,16 @@ function collectMonoAttrs(color, into) {
1523
2500
  function getTextStyle(props) {
1524
2501
  let underlineStyle;
1525
2502
  if (props.underlineStyle !== void 0) underlineStyle = props.underlineStyle;
1526
- else if (props.underline) underlineStyle = "single";
2503
+ else if (typeof props.underline === "string") underlineStyle = props.underline;
2504
+ else if (props.underline === true) underlineStyle = "single";
1527
2505
  let bold = props.bold;
1528
- let dim = props.dim || props.dimColor;
2506
+ let dim;
1529
2507
  let italic = props.italic;
1530
- let underline = props.underline || !!underlineStyle;
2508
+ let underline = !!props.underline || !!underlineStyle;
1531
2509
  let strikethrough = props.strikethrough;
1532
2510
  let inverse = props.inverse;
1533
- if (getActiveColorLevel() === "none") {
2511
+ const overline = !!props.overline;
2512
+ if (getActiveColorLevel() === "mono") {
1534
2513
  const monoAttrs = /* @__PURE__ */ new Set();
1535
2514
  collectMonoAttrs(props.color, monoAttrs);
1536
2515
  collectMonoAttrs(props.backgroundColor, monoAttrs);
@@ -1554,6 +2533,7 @@ function getTextStyle(props) {
1554
2533
  italic,
1555
2534
  underline,
1556
2535
  underlineStyle,
2536
+ overline,
1557
2537
  strikethrough,
1558
2538
  inverse
1559
2539
  }
@@ -1572,6 +2552,580 @@ function getTextWidth(text, ctx) {
1572
2552
  return displayWidthAnsi(text);
1573
2553
  }
1574
2554
  //#endregion
2555
+ //#region packages/ag-term/src/strict-mode.ts
2556
+ let cachedRaw = void 0;
2557
+ let cachedParsed = null;
2558
+ function parseStrict(raw) {
2559
+ if (!raw || raw === "0") return {
2560
+ enabledTiers: /* @__PURE__ */ new Set(),
2561
+ enabledSlugs: /* @__PURE__ */ new Set(),
2562
+ disabledSlugs: /* @__PURE__ */ new Set(),
2563
+ raw
2564
+ };
2565
+ const tiers = /* @__PURE__ */ new Set();
2566
+ const slugs = /* @__PURE__ */ new Set();
2567
+ const disabled = /* @__PURE__ */ new Set();
2568
+ for (const part of raw.split(",")) {
2569
+ const trimmed = part.trim();
2570
+ if (!trimmed) continue;
2571
+ if (trimmed.startsWith("!")) {
2572
+ disabled.add(trimmed.slice(1));
2573
+ continue;
2574
+ }
2575
+ const n = Number(trimmed);
2576
+ if (Number.isFinite(n) && n >= 1) {
2577
+ tiers.add(n);
2578
+ continue;
2579
+ }
2580
+ slugs.add(trimmed);
2581
+ }
2582
+ return {
2583
+ enabledTiers: tiers,
2584
+ enabledSlugs: slugs,
2585
+ disabledSlugs: disabled,
2586
+ raw
2587
+ };
2588
+ }
2589
+ function getParsed() {
2590
+ const raw = process.env.SILVERY_STRICT;
2591
+ if (raw !== cachedRaw || cachedParsed === null) {
2592
+ cachedRaw = raw;
2593
+ cachedParsed = parseStrict(raw);
2594
+ }
2595
+ return cachedParsed;
2596
+ }
2597
+ /**
2598
+ * Returns true if `slug` should fire under the current `SILVERY_STRICT`.
2599
+ *
2600
+ * - `!slug` always wins (per-check skip)
2601
+ * - explicit slug match wins
2602
+ * - any tier >= minTier (default 1) implies the check
2603
+ */
2604
+ function isStrictEnabled(slug, minTier = 1) {
2605
+ const p = getParsed();
2606
+ if (p.disabledSlugs.has(slug)) return false;
2607
+ if (p.enabledSlugs.has(slug)) return true;
2608
+ for (const tier of p.enabledTiers) if (tier >= minTier) return true;
2609
+ return false;
2610
+ }
2611
+ //#endregion
2612
+ //#region packages/ag-term/src/strict-bordered-rect.ts
2613
+ /** SILVERY_STRICT slug for the bordered-rect clip check. Tier 2 by design. */
2614
+ const BORDERED_RECT_CLIP_SLUG = "bordered-rect-clip";
2615
+ /** Returns true when the bordered-rect clip check should fire. */
2616
+ function isBorderedRectClipEnabled() {
2617
+ return isStrictEnabled(BORDERED_RECT_CLIP_SLUG, 2);
2618
+ }
2619
+ /**
2620
+ * Error thrown when a cell is painted outside the nearest bordered
2621
+ * ancestor's inner content rect under SILVERY_STRICT=bordered-rect-clip.
2622
+ *
2623
+ * Like `IncrementalRenderMismatchError`, this should NOT be caught by
2624
+ * general error handlers — it indicates a real layout/clip bug that
2625
+ * needs a structural fix (`overflow="hidden"` on the bordered Box, or
2626
+ * `wrap="truncate"` on the inner Text).
2627
+ */
2628
+ var BorderedRectClipError = class extends Error {
2629
+ constructor(message) {
2630
+ super(message);
2631
+ this.name = "BorderedRectClipError";
2632
+ }
2633
+ };
2634
+ const renderingStack = [];
2635
+ /** Push the node currently being painted onto the rendering stack. */
2636
+ function pushRenderingNode(node) {
2637
+ renderingStack.push(node);
2638
+ }
2639
+ /** Pop the most recently pushed rendering node. */
2640
+ function popRenderingNode() {
2641
+ renderingStack.pop();
2642
+ }
2643
+ /** Peek the topmost rendering node, or null if the stack is empty. */
2644
+ function peekRenderingNode() {
2645
+ return renderingStack.length === 0 ? null : renderingStack[renderingStack.length - 1] ?? null;
2646
+ }
2647
+ /**
2648
+ * Walk `node.parent` until we find a node with `borderStyle != null`.
2649
+ * Returns null when no ancestor is bordered (the outer canvas itself
2650
+ * has no border to honor).
2651
+ *
2652
+ * Excludes the starting node — a bordered Box's own border cells sit on
2653
+ * its perimeter (outside its OWN inner rect). The relevant constraint is
2654
+ * "stay inside the parent that borders me", not "stay inside myself".
2655
+ */
2656
+ function findBorderedAncestor(node) {
2657
+ let current = node.parent;
2658
+ while (current) {
2659
+ const props = current.props;
2660
+ if (props && props.borderStyle != null) return current;
2661
+ current = current.parent;
2662
+ }
2663
+ return null;
2664
+ }
2665
+ /**
2666
+ * Bordered-ancestor lookup result, cached against the topmost rendering
2667
+ * node identity. The renderer dispatches one `renderText` / `renderBox`
2668
+ * call per AgNode, and inside that call every emitSetCell shares the
2669
+ * same currently-rendering node. So a single-slot cache amortizes the
2670
+ * O(depth) walk to once per dispatch, not per cell.
2671
+ *
2672
+ * `cachedFor === null` means "no entry"; `cachedAncestor === null`
2673
+ * means "no bordered ancestor exists" (legitimate — outer-canvas paint).
2674
+ */
2675
+ let cachedFor = null;
2676
+ let cachedAncestor = null;
2677
+ let cachedSkip = false;
2678
+ function getBorderedAncestorCached(node) {
2679
+ if (cachedFor === node) return {
2680
+ ancestor: cachedAncestor,
2681
+ skip: cachedSkip
2682
+ };
2683
+ cachedFor = node;
2684
+ cachedAncestor = findBorderedAncestor(node);
2685
+ cachedSkip = cachedAncestor != null && hasOverflowHidden(cachedAncestor);
2686
+ return {
2687
+ ancestor: cachedAncestor,
2688
+ skip: cachedSkip
2689
+ };
2690
+ }
2691
+ function hasOverflowHidden(node) {
2692
+ const p = node.props;
2693
+ if (!p) return false;
2694
+ if (p.overflow === "hidden") return true;
2695
+ if (p.overflowX === "hidden" && p.overflowY === "hidden") return true;
2696
+ return false;
2697
+ }
2698
+ /** Reset the per-render cache. Called at the boundary of each rendering
2699
+ * push/pop so a stale identity from a previous frame never leaks. */
2700
+ function resetBorderedRectClipCache() {
2701
+ cachedFor = null;
2702
+ cachedAncestor = null;
2703
+ cachedSkip = false;
2704
+ }
2705
+ function computeInnerRect(box, props) {
2706
+ const top = props.borderTop !== false ? 1 : 0;
2707
+ const bottom = props.borderBottom !== false ? 1 : 0;
2708
+ const left = props.borderLeft !== false ? 1 : 0;
2709
+ const right = props.borderRight !== false ? 1 : 0;
2710
+ return {
2711
+ left: box.x + left,
2712
+ top: box.y + top,
2713
+ right: box.x + box.width - right,
2714
+ bottom: box.y + box.height - bottom
2715
+ };
2716
+ }
2717
+ /**
2718
+ * Assert that cell (x, y) lies inside the nearest bordered ancestor's
2719
+ * inner content rect. No-op when:
2720
+ * - the strict slug is not enabled
2721
+ * - no rendering node is on the stack (paint outside a renderer
2722
+ * dispatch — e.g. early init clear)
2723
+ * - no bordered ancestor exists
2724
+ * - the bordered ancestor has overflow="hidden" (clip enforced
2725
+ * elsewhere by computeChildClipBounds)
2726
+ *
2727
+ * Throws `BorderedRectClipError` on violation.
2728
+ */
2729
+ function assertCellInsideBorderedRect(x, y, opts = {}) {
2730
+ if (!isBorderedRectClipEnabled()) return;
2731
+ const node = peekRenderingNode();
2732
+ if (!node) return;
2733
+ const { ancestor, skip } = getBorderedAncestorCached(node);
2734
+ if (!ancestor || skip) return;
2735
+ const box = ancestor.screenRect ?? ancestor.scrollRect ?? ancestor.boxRect;
2736
+ if (!box) return;
2737
+ const props = ancestor.props;
2738
+ const inner = computeInnerRect(box, props);
2739
+ if (x >= inner.left && x < inner.right && y >= inner.top && y < inner.bottom) return;
2740
+ const ancestorId = describeNode(ancestor);
2741
+ const nodeId = describeNode(node);
2742
+ const path = describeDepthPath(node);
2743
+ throw new BorderedRectClipError(`STRICT bordered-rect-clip: cell painted outside ancestor's bordered rect\n cell: (x=${x}, y=${y}) char=${opts.char ? `'${opts.char}'` : "(unknown)"} bg=${formatColor(opts.bg)} fg=${formatColor(opts.fg)}\n parent box: id='${ancestorId}' rect=(${box.x},${box.y},${box.width},${box.height}) borderStyle='${props.borderStyle}'\n inner rect: (left=${inner.left}, top=${inner.top}, right=${inner.right}, bottom=${inner.bottom}) [exclusive]\n painter: '${nodeId}'\n depth path: ${path}\n\n Suggested fix: set \`overflow="hidden"\` on the bordered Box,\n or use \`wrap="wrap"\` / \`wrap="truncate"\` on the inner Text.\n\n Slug: SILVERY_STRICT=${BORDERED_RECT_CLIP_SLUG} (tier 2+).\n Per-test opt-out: SILVERY_STRICT=2,!${BORDERED_RECT_CLIP_SLUG}.`);
2744
+ }
2745
+ function describeNode(node) {
2746
+ const key = node.props?.key;
2747
+ if (key !== void 0) return `${node.type}[key=${String(key)}]`;
2748
+ if (node.type === "silvery-text" && typeof node.textContent === "string") {
2749
+ const t = node.textContent.length > 20 ? `${node.textContent.slice(0, 20)}…` : node.textContent;
2750
+ return `${node.type}[${JSON.stringify(t)}]`;
2751
+ }
2752
+ return `${node.type}`;
2753
+ }
2754
+ function describeDepthPath(node) {
2755
+ const parts = [];
2756
+ let cur = node;
2757
+ let depth = 0;
2758
+ while (cur && depth < 8) {
2759
+ const props = cur.props;
2760
+ let label = cur.type;
2761
+ if (props?.borderStyle) label += `[border=${props.borderStyle}]`;
2762
+ parts.unshift(label);
2763
+ cur = cur.parent;
2764
+ depth++;
2765
+ }
2766
+ if (cur) parts.unshift("…");
2767
+ return parts.join(" > ");
2768
+ }
2769
+ function formatColor(c) {
2770
+ if (c === null || c === void 0) return "default";
2771
+ if (typeof c === "number") return `${c}`;
2772
+ if (typeof c === "object") {
2773
+ const o = c;
2774
+ if (typeof o.r === "number") return `#${(o.r << 16 | o.g << 8 | o.b).toString(16).padStart(6, "0")}`;
2775
+ }
2776
+ return String(c);
2777
+ }
2778
+ //#endregion
2779
+ //#region packages/ag-term/src/pipeline/render-sink.ts
2780
+ function cloneCellPatch(cell) {
2781
+ const out = { ...cell };
2782
+ if (cell.attrs) out.attrs = { ...cell.attrs };
2783
+ return out;
2784
+ }
2785
+ function cloneCell(cell) {
2786
+ return {
2787
+ ...cell,
2788
+ attrs: { ...cell.attrs }
2789
+ };
2790
+ }
2791
+ function cloneStyle(style) {
2792
+ return {
2793
+ ...style,
2794
+ attrs: { ...style.attrs }
2795
+ };
2796
+ }
2797
+ /**
2798
+ * `RenderSink` implementation that mutates a `TerminalBuffer` directly.
2799
+ * Behavior-equivalent to the legacy direct-mutation path. Used during
2800
+ * the Phase 2 transition: renderers route through the sink, but with
2801
+ * `BufferSink` the actual buffer mutation is unchanged.
2802
+ *
2803
+ * Once every renderer call site routes through a sink, swapping
2804
+ * `BufferSink` for `PlanSink` at one entry point flips the renderer
2805
+ * onto the plan/commit substrate.
2806
+ */
2807
+ var BufferSink = class {
2808
+ constructor(buffer) {
2809
+ this.buffer = buffer;
2810
+ }
2811
+ selectableCell(cell, selectable) {
2812
+ return selectable === void 0 ? cell : {
2813
+ ...cell,
2814
+ selectable
2815
+ };
2816
+ }
2817
+ get width() {
2818
+ return this.buffer.width;
2819
+ }
2820
+ get height() {
2821
+ return this.buffer.height;
2822
+ }
2823
+ emitScrollRegion(x, y, width, height, delta, clearCell = {}, selectable) {
2824
+ this.buffer.scrollRegion(x, y, width, height, delta, this.selectableCell(clearCell, selectable));
2825
+ }
2826
+ emitClearRect(x, y, width, height, bg, selectable) {
2827
+ this.buffer.fill(x, y, width, height, {
2828
+ char: " ",
2829
+ bg,
2830
+ selectable
2831
+ });
2832
+ }
2833
+ emitClearCells(x, y, width, height, cell, selectable) {
2834
+ this.buffer.fill(x, y, width, height, this.selectableCell(cell, selectable));
2835
+ }
2836
+ emitSetCell(x, y, cell, selectable) {
2837
+ assertCellInsideBorderedRect(x, y, {
2838
+ char: cell.char,
2839
+ fg: cell.fg,
2840
+ bg: cell.bg
2841
+ });
2842
+ this.buffer.setCell(x, y, this.selectableCell(cell, selectable));
2843
+ }
2844
+ emitPaintFill(x, y, width, height, cell, selectable) {
2845
+ this.buffer.fill(x, y, width, height, this.selectableCell(cell, selectable));
2846
+ }
2847
+ emitFillBg(x, y, width, height, bg) {
2848
+ this.buffer.fillBg(x, y, width, height, bg);
2849
+ }
2850
+ emitRestyleRegion(x, y, width, height, style) {
2851
+ this.buffer.restyleRegion(x, y, width, height, style);
2852
+ }
2853
+ emitMergeAttrs(x, y, width, height, attrs, underlineColor) {
2854
+ this.buffer.mergeAttrsInRect(x, y, width, height, attrs, underlineColor);
2855
+ }
2856
+ setRowMeta(row, meta) {
2857
+ this.buffer.setRowMeta(row, meta);
2858
+ }
2859
+ setOutlineSnapshots(_snapshots) {}
2860
+ };
2861
+ /**
2862
+ * `RenderSink` that fans every emission out to a PRIMARY sink and a
2863
+ * SECONDARY sink. Used by the SILVERY_RENDER_PLAN production wiring:
2864
+ *
2865
+ * - primary = `BufferSink(buffer)`: keeps direct buffer mutation so
2866
+ * intra-frame buffer reads (`getCellBg`, dirty rows, snapshot reads)
2867
+ * return correct values during the render walk. Read-elimination is
2868
+ * the Phase 2 Step 6 follow-up.
2869
+ * - secondary = `PlanSink`: captures the same emissions into a
2870
+ * `SectionedRenderPlan` for parity verification or for committing
2871
+ * onto a fresh clone in plan-mode.
2872
+ *
2873
+ * The Tee fans every method to both sinks unconditionally. Since both
2874
+ * are in lock-step, the plan captures exactly the ops that were applied
2875
+ * to the buffer.
2876
+ */
2877
+ var TeeSink = class {
2878
+ constructor(primary, secondary) {
2879
+ this.primary = primary;
2880
+ this.secondary = secondary;
2881
+ }
2882
+ get width() {
2883
+ return this.primary.width;
2884
+ }
2885
+ get height() {
2886
+ return this.primary.height;
2887
+ }
2888
+ emitScrollRegion(x, y, width, height, delta, clearCell, selectable) {
2889
+ this.primary.emitScrollRegion(x, y, width, height, delta, clearCell, selectable);
2890
+ this.secondary.emitScrollRegion(x, y, width, height, delta, clearCell, selectable);
2891
+ }
2892
+ emitClearRect(x, y, width, height, bg, selectable) {
2893
+ this.primary.emitClearRect(x, y, width, height, bg, selectable);
2894
+ this.secondary.emitClearRect(x, y, width, height, bg, selectable);
2895
+ }
2896
+ emitClearCells(x, y, width, height, cell, selectable) {
2897
+ this.primary.emitClearCells(x, y, width, height, cell, selectable);
2898
+ this.secondary.emitClearCells(x, y, width, height, cell, selectable);
2899
+ }
2900
+ emitSetCell(x, y, cell, selectable) {
2901
+ this.primary.emitSetCell(x, y, cell, selectable);
2902
+ this.secondary.emitSetCell(x, y, cell, selectable);
2903
+ }
2904
+ emitPaintFill(x, y, width, height, cell, selectable) {
2905
+ this.primary.emitPaintFill(x, y, width, height, cell, selectable);
2906
+ this.secondary.emitPaintFill(x, y, width, height, cell, selectable);
2907
+ }
2908
+ emitFillBg(x, y, width, height, bg) {
2909
+ this.primary.emitFillBg(x, y, width, height, bg);
2910
+ this.secondary.emitFillBg(x, y, width, height, bg);
2911
+ }
2912
+ emitRestyleRegion(x, y, width, height, style) {
2913
+ this.primary.emitRestyleRegion(x, y, width, height, style);
2914
+ this.secondary.emitRestyleRegion(x, y, width, height, style);
2915
+ }
2916
+ emitMergeAttrs(x, y, width, height, attrs, underlineColor) {
2917
+ this.primary.emitMergeAttrs(x, y, width, height, attrs, underlineColor);
2918
+ this.secondary.emitMergeAttrs(x, y, width, height, attrs, underlineColor);
2919
+ }
2920
+ setRowMeta(row, meta) {
2921
+ this.primary.setRowMeta(row, meta);
2922
+ this.secondary.setRowMeta(row, meta);
2923
+ }
2924
+ setOutlineSnapshots(snapshots) {
2925
+ this.primary.setOutlineSnapshots(snapshots);
2926
+ this.secondary.setOutlineSnapshots(snapshots);
2927
+ }
2928
+ };
2929
+ /**
2930
+ * The PlanSink that the current frame's BufferSink instances should also
2931
+ * record to (when SILVERY_RENDER_PLAN is enabled). Set by `withPlanCapture`
2932
+ * around a render-phase call; null at all other times.
2933
+ *
2934
+ * Module-level state because every renderer in render-phase / render-box /
2935
+ * render-text / decoration-phase constructs its own local sink. The
2936
+ * factory below reads this so all of them emit to the same plan.
2937
+ */
2938
+ let _frameCapturePlanSink = null;
2939
+ /**
2940
+ * Run `fn` with frame-level plan capture enabled. Returns the plan
2941
+ * captured during `fn` along with whatever `fn` returned.
2942
+ *
2943
+ * Production wire path for SILVERY_RENDER_PLAN: ag.ts wraps the
2944
+ * `renderPhase()` call in `withPlanCapture(...)` and gets back both the
2945
+ * rendered buffer (already mutated by BufferSink-on-real-buffer) and the
2946
+ * matching `SectionedRenderPlan`. The plan can then be replayed onto a
2947
+ * fresh clone for parity verification, or used directly when buffer-read
2948
+ * elimination (Step 6) lets PlanSink stand alone.
2949
+ */
2950
+ function withPlanCapture(width, height, fn) {
2951
+ const captured = new PlanSink(width, height);
2952
+ const prev = _frameCapturePlanSink;
2953
+ _frameCapturePlanSink = captured;
2954
+ try {
2955
+ return {
2956
+ result: fn(),
2957
+ plan: captured.toPlan()
2958
+ };
2959
+ } finally {
2960
+ _frameCapturePlanSink = prev;
2961
+ }
2962
+ }
2963
+ /**
2964
+ * Construct the right sink for a renderer call site. By default
2965
+ * (`SILVERY_RENDER_PLAN` not set), returns a plain `BufferSink(buffer)`.
2966
+ *
2967
+ * When inside a `withPlanCapture` scope, returns a `TeeSink` that fans
2968
+ * every emission to BOTH the BufferSink AND the frame-shared PlanSink.
2969
+ * This is how every local sink construction in render-phase /
2970
+ * render-box / render-text / decoration-phase contributes to a single
2971
+ * SectionedRenderPlan for the frame.
2972
+ */
2973
+ function createFrameSink(buffer) {
2974
+ const direct = new BufferSink(buffer);
2975
+ if (_frameCapturePlanSink === null) return direct;
2976
+ return new TeeSink(direct, _frameCapturePlanSink);
2977
+ }
2978
+ /**
2979
+ * `RenderSink` implementation that builds a `SectionedRenderPlan` directly
2980
+ * — no heuristics, no backing buffer for reads (yet — see file header
2981
+ * REMAINING). Each emit method routes the op into the corresponding
2982
+ * section.
2983
+ *
2984
+ * This is the L4 substrate: ops land in their section by API call, not
2985
+ * by classification of post-hoc mutations. Wrong-order paint becomes
2986
+ * unrepresentable because a clear API call cannot land in `paintOps` —
2987
+ * it goes through `emitClearRect` which writes to `cleanupOps`.
2988
+ *
2989
+ * Limitation: the sink has no buffer to back reads. Renderers that
2990
+ * currently read from the buffer mid-frame (`getCellBg`, dirty row
2991
+ * checks, outline snapshot reads) will not work with `PlanSink`
2992
+ * directly. Phase 2 Step 4 audits and eliminates those reads. Until
2993
+ * then, `PlanSink` is for synthetic plans + tests; production routing
2994
+ * waits on Step 4.
2995
+ */
2996
+ var PlanSink = class {
2997
+ transferOps = [];
2998
+ cleanupOps = [];
2999
+ paintOps = [];
3000
+ overlayOps = [];
3001
+ postStateOps = [];
3002
+ constructor(width, height) {
3003
+ this.width = width;
3004
+ this.height = height;
3005
+ }
3006
+ emitScrollRegion(x, y, width, height, delta, clearCell, selectable) {
3007
+ this.transferOps.push({
3008
+ kind: "scrollRegion",
3009
+ x,
3010
+ y,
3011
+ width,
3012
+ height,
3013
+ delta,
3014
+ clearCell: clearCell ? this.selectableCell(clearCell, selectable) : void 0,
3015
+ selectable
3016
+ });
3017
+ }
3018
+ emitClearRect(x, y, width, height, bg, selectable) {
3019
+ this.cleanupOps.push({
3020
+ kind: "clearRect",
3021
+ x,
3022
+ y,
3023
+ width,
3024
+ height,
3025
+ bg,
3026
+ selectable
3027
+ });
3028
+ }
3029
+ emitClearCells(x, y, width, height, cell, selectable) {
3030
+ this.cleanupOps.push({
3031
+ kind: "clearCells",
3032
+ x,
3033
+ y,
3034
+ width,
3035
+ height,
3036
+ cell: this.selectableCell(cell, selectable)
3037
+ });
3038
+ }
3039
+ emitSetCell(x, y, cell, selectable) {
3040
+ this.paintOps.push({
3041
+ kind: "setCell",
3042
+ x,
3043
+ y,
3044
+ cell: this.selectableCell(cell, selectable)
3045
+ });
3046
+ }
3047
+ emitPaintFill(x, y, width, height, cell, selectable) {
3048
+ this.paintOps.push({
3049
+ kind: "paintFill",
3050
+ x,
3051
+ y,
3052
+ width,
3053
+ height,
3054
+ cell: this.selectableCell(cell, selectable)
3055
+ });
3056
+ }
3057
+ emitFillBg(x, y, width, height, bg) {
3058
+ this.paintOps.push({
3059
+ kind: "fillBg",
3060
+ x,
3061
+ y,
3062
+ width,
3063
+ height,
3064
+ bg
3065
+ });
3066
+ }
3067
+ emitRestyleRegion(x, y, width, height, style) {
3068
+ this.paintOps.push({
3069
+ kind: "restyleRegion",
3070
+ x,
3071
+ y,
3072
+ width,
3073
+ height,
3074
+ style: cloneStyle(style)
3075
+ });
3076
+ }
3077
+ emitMergeAttrs(x, y, width, height, attrs, underlineColor) {
3078
+ this.overlayOps.push({
3079
+ kind: "mergeAttrsInRect",
3080
+ x,
3081
+ y,
3082
+ width,
3083
+ height,
3084
+ attrs: { ...attrs },
3085
+ underlineColor
3086
+ });
3087
+ }
3088
+ setRowMeta(row, meta) {
3089
+ this.postStateOps.push({
3090
+ kind: "setRowMeta",
3091
+ row,
3092
+ ...meta
3093
+ });
3094
+ }
3095
+ setOutlineSnapshots(snapshots) {
3096
+ this.postStateOps.push({
3097
+ kind: "setOutlineSnapshots",
3098
+ snapshots: snapshots.map((snapshot) => ({
3099
+ ...snapshot,
3100
+ cell: cloneCell(snapshot.cell)
3101
+ }))
3102
+ });
3103
+ }
3104
+ selectableCell(cell, selectable) {
3105
+ const cloned = cloneCellPatch(cell);
3106
+ return selectable === void 0 ? cloned : {
3107
+ ...cloned,
3108
+ selectable
3109
+ };
3110
+ }
3111
+ /**
3112
+ * Snapshot the accumulated emissions as an immutable
3113
+ * `SectionedRenderPlan`. After calling this, further emissions on the
3114
+ * sink do not affect the returned plan.
3115
+ */
3116
+ toPlan() {
3117
+ return {
3118
+ width: this.width,
3119
+ height: this.height,
3120
+ transferOps: this.transferOps.slice(),
3121
+ cleanupOps: this.cleanupOps.slice(),
3122
+ paintOps: this.paintOps.slice(),
3123
+ overlayOps: this.overlayOps.slice(),
3124
+ postStateOps: this.postStateOps.slice()
3125
+ };
3126
+ }
3127
+ };
3128
+ //#endregion
1575
3129
  //#region packages/ag-term/src/pipeline/render-text.ts
1576
3130
  init_buffer();
1577
3131
  const log$1 = createLogger("silvery:content");
@@ -1663,6 +3217,7 @@ function styleToAnsi(style) {
1663
3217
  }
1664
3218
  if (style.inverse) parts.push("7");
1665
3219
  if (style.strikethrough) parts.push("9");
3220
+ if (style.overline) parts.push("53");
1666
3221
  if (parts.length === 0) return "";
1667
3222
  return `\x1b[${parts.join(";")}m`;
1668
3223
  }
@@ -1671,15 +3226,31 @@ function styleToAnsi(style) {
1671
3226
  * Child values override parent values when specified.
1672
3227
  */
1673
3228
  function mergeStyleContext(parent, childProps) {
3229
+ const effectiveChildColor = childProps.color === "inherit" || childProps.color === "currentColor" ? parent.color : childProps.color;
3230
+ const childUl = childProps.underline;
3231
+ const childUlStyle = childProps.underlineStyle;
3232
+ let childUnderline;
3233
+ let childUnderlineStyle;
3234
+ if (childUlStyle !== void 0) {
3235
+ childUnderline = childUlStyle === false ? false : true;
3236
+ childUnderlineStyle = childUlStyle;
3237
+ } else if (typeof childUl === "string") {
3238
+ childUnderline = true;
3239
+ childUnderlineStyle = childUl;
3240
+ } else if (childUl !== void 0) {
3241
+ childUnderline = childUl;
3242
+ childUnderlineStyle = void 0;
3243
+ }
1674
3244
  return {
1675
- color: (childProps.color === "inherit" || childProps.color === "currentColor" ? parent.color : childProps.color) ?? parent.color,
3245
+ color: effectiveChildColor ?? parent.color,
1676
3246
  backgroundColor: childProps.backgroundColor ?? parent.backgroundColor,
1677
3247
  bold: childProps.bold ?? parent.bold,
1678
- dim: childProps.dim ?? childProps.dimColor ?? parent.dim,
3248
+ dim: parent.dim,
1679
3249
  italic: childProps.italic ?? parent.italic,
1680
- underline: childProps.underline ?? childProps.underlineStyle ? true : parent.underline,
1681
- underlineStyle: childProps.underlineStyle ?? parent.underlineStyle,
3250
+ underline: childUnderline ?? parent.underline,
3251
+ underlineStyle: childUnderlineStyle ?? parent.underlineStyle,
1682
3252
  underlineColor: childProps.underlineColor ?? parent.underlineColor,
3253
+ overline: childProps.overline ?? parent.overline,
1683
3254
  inverse: childProps.inverse ?? parent.inverse,
1684
3255
  strikethrough: childProps.strikethrough ?? parent.strikethrough
1685
3256
  };
@@ -1800,9 +3371,12 @@ function collectTextWithBg(node, parentContext = {}, offset = 0, maxDisplayWidth
1800
3371
  * @param lineCharEnd - Character offset in original text where this line ends
1801
3372
  * @param bgSegments - Background color segments to apply
1802
3373
  */
1803
- function applyBgSegmentsToLine(buffer, x, y, lineText, lineCharStart, lineCharEnd, bgSegments, ctx) {
3374
+ function applyBgSegmentsToLine(buffer, x, y, lineText, lineCharStart, lineCharEnd, bgSegments, ctx, maxCol, minCol, selectable = false) {
1804
3375
  if (bgSegments.length === 0) return;
1805
- if (y < 0 || y >= buffer.height) return;
3376
+ const sink = createFrameSink(buffer);
3377
+ if (y < 0 || y >= sink.height) return;
3378
+ const rightClip = maxCol !== void 0 ? Math.min(maxCol, sink.width) : sink.width;
3379
+ const leftClip = minCol !== void 0 ? Math.max(minCol, 0) : 0;
1806
3380
  const bgCell = createMutableCell();
1807
3381
  const gWidthFn = ctx ? ctx.measurer.graphemeWidth : graphemeWidth;
1808
3382
  for (const seg of bgSegments) {
@@ -1818,17 +3392,20 @@ function applyBgSegmentsToLine(buffer, x, y, lineText, lineCharStart, lineCharEn
1818
3392
  if (gWidth === 0) continue;
1819
3393
  const displayOffset = col - x;
1820
3394
  if (displayOffset >= relStart && displayOffset < relEnd) {
1821
- buffer.readCellInto(col, y, bgCell);
1822
- bgCell.bg = seg.bg;
1823
- buffer.setCell(col, y, bgCell);
1824
- if (gWidth === 2 && col + 1 < buffer.width) {
1825
- buffer.readCellInto(col + 1, y, bgCell);
3395
+ if (col < rightClip && col >= leftClip) {
3396
+ buffer.readCellInto(col, y, bgCell);
1826
3397
  bgCell.bg = seg.bg;
1827
- buffer.setCell(col + 1, y, bgCell);
3398
+ sink.emitSetCell(col, y, bgCell, selectable);
3399
+ if (gWidth === 2 && col + 1 < sink.width && col + 1 < rightClip) {
3400
+ buffer.readCellInto(col + 1, y, bgCell);
3401
+ bgCell.bg = seg.bg;
3402
+ sink.emitSetCell(col + 1, y, bgCell, selectable);
3403
+ }
1828
3404
  }
1829
3405
  }
1830
3406
  col += gWidth;
1831
3407
  if (col - x >= relEnd) break;
3408
+ if (col >= rightClip) break;
1832
3409
  }
1833
3410
  }
1834
3411
  }
@@ -1949,6 +3526,10 @@ function formatTextLines(text, width, wrap, ctx, trim = true) {
1949
3526
  if (wrap === "truncate-start") return lines.map((line) => truncateText(line, width, "start", ctx));
1950
3527
  if (wrap === "truncate-middle") return lines.map((line) => truncateText(line, width, "middle", ctx));
1951
3528
  if (wrap === "even") return optimalWrap(normalizedText, buildTextAnalysis(normalizedText, ctx?.measurer?.graphemeWidth?.bind(ctx.measurer) ?? graphemeWidth), width);
3529
+ if (wrap === "wrap-truncate") {
3530
+ if (ctx) return ctx.measurer.wrapText(normalizedText, width, true, false, true);
3531
+ return wrapText(normalizedText, width, true, true, true);
3532
+ }
1952
3533
  if (ctx) return ctx.measurer.wrapText(normalizedText, width, true, trim);
1953
3534
  return wrapText(normalizedText, width, true, trim);
1954
3535
  }
@@ -1979,20 +3560,20 @@ function truncateText(text, width, mode, ctx) {
1979
3560
  * become stale during incremental rendering (the owning container's dirty
1980
3561
  * tracking doesn't cover cells outside its layout bounds).
1981
3562
  */
1982
- function renderTextLine(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx) {
3563
+ function renderTextLine(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx, selectable = false) {
1983
3564
  if (hasAnsi(text)) {
1984
- renderAnsiTextLine(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx);
3565
+ renderAnsiTextLine(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx, selectable);
1985
3566
  return;
1986
3567
  }
1987
- renderGraphemes(buffer, splitGraphemes(text), x, y, baseStyle, maxCol, inheritedBg, ctx);
3568
+ renderGraphemes(buffer, splitGraphemes(text), x, y, baseStyle, maxCol, inheritedBg, ctx, void 0, selectable);
1988
3569
  }
1989
3570
  /**
1990
3571
  * Like renderTextLine but returns the column position after the last rendered character.
1991
3572
  * Used by renderText to know where to clear remaining cells.
1992
3573
  */
1993
- function renderTextLineReturn(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx, minCol) {
1994
- if (hasAnsi(text)) return renderAnsiTextLineReturn(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx, minCol);
1995
- return renderGraphemes(buffer, splitGraphemes(text), x, y, baseStyle, maxCol, inheritedBg, ctx, minCol);
3574
+ function renderTextLineReturn(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx, minCol, selectable = false, bgConflictOverride) {
3575
+ if (hasAnsi(text)) return renderAnsiTextLineReturn(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx, minCol, selectable, bgConflictOverride);
3576
+ return renderGraphemes(buffer, splitGraphemes(text), x, y, baseStyle, maxCol, inheritedBg, ctx, minCol, selectable);
1996
3577
  }
1997
3578
  /**
1998
3579
  * Render graphemes to buffer cells with proper Unicode handling.
@@ -2011,9 +3592,10 @@ function renderTextLineReturn(buffer, x, y, text, baseStyle, maxCol, inheritedBg
2011
3592
  *
2012
3593
  * Returns the column position after the last rendered grapheme.
2013
3594
  */
2014
- function renderGraphemes(buffer, graphemes, startCol, y, style, maxCol, inheritedBg, ctx, minCol) {
3595
+ function renderGraphemes(buffer, graphemes, startCol, y, style, maxCol, inheritedBg, ctx, minCol, selectable = false) {
3596
+ const sink = createFrameSink(buffer);
2015
3597
  let col = startCol;
2016
- const rightEdge = maxCol !== void 0 ? Math.min(maxCol, buffer.width) : buffer.width;
3598
+ const rightEdge = maxCol !== void 0 ? Math.min(maxCol, sink.width) : sink.width;
2017
3599
  const leftEdge = minCol !== void 0 ? Math.max(minCol, 0) : 0;
2018
3600
  const gWidthFn = ctx ? ctx.measurer.graphemeWidth : graphemeWidth;
2019
3601
  for (const grapheme of graphemes) {
@@ -2028,9 +3610,9 @@ function renderGraphemes(buffer, graphemes, startCol, y, style, maxCol, inherite
2028
3610
  col = leftEdge;
2029
3611
  continue;
2030
3612
  }
2031
- const existingBg = style.bg !== null ? style.bg : inheritedBg !== void 0 ? inheritedBg : buffer.getCellBg(col, y);
3613
+ const existingBg = style.bg !== null ? style.bg : inheritedBg !== void 0 ? inheritedBg : null;
2032
3614
  if (width === 2 && col + 1 >= rightEdge) {
2033
- buffer.setCell(col, y, {
3615
+ sink.emitSetCell(col, y, {
2034
3616
  char: " ",
2035
3617
  fg: style.fg,
2036
3618
  bg: existingBg,
@@ -2039,12 +3621,12 @@ function renderGraphemes(buffer, graphemes, startCol, y, style, maxCol, inherite
2039
3621
  wide: false,
2040
3622
  continuation: false,
2041
3623
  hyperlink: style.hyperlink
2042
- });
3624
+ }, false);
2043
3625
  col += 1;
2044
3626
  continue;
2045
3627
  }
2046
3628
  const outputChar = width === 2 ? ensureEmojiPresentation(grapheme) : grapheme;
2047
- buffer.setCell(col, y, {
3629
+ sink.emitSetCell(col, y, {
2048
3630
  char: outputChar,
2049
3631
  fg: style.fg,
2050
3632
  bg: existingBg,
@@ -2053,10 +3635,10 @@ function renderGraphemes(buffer, graphemes, startCol, y, style, maxCol, inherite
2053
3635
  wide: width === 2,
2054
3636
  continuation: false,
2055
3637
  hyperlink: style.hyperlink
2056
- });
2057
- if (width === 2 && col + 1 < buffer.width) {
2058
- const existingBg2 = style.bg !== null ? style.bg : inheritedBg !== void 0 ? inheritedBg : buffer.getCellBg(col + 1, y);
2059
- buffer.setCell(col + 1, y, {
3638
+ }, selectable);
3639
+ if (width === 2 && col + 1 < sink.width) {
3640
+ const existingBg2 = style.bg !== null ? style.bg : inheritedBg !== void 0 ? inheritedBg : null;
3641
+ sink.emitSetCell(col + 1, y, {
2060
3642
  char: "",
2061
3643
  fg: style.fg,
2062
3644
  bg: existingBg2,
@@ -2065,7 +3647,7 @@ function renderGraphemes(buffer, graphemes, startCol, y, style, maxCol, inherite
2065
3647
  wide: false,
2066
3648
  continuation: true,
2067
3649
  hyperlink: style.hyperlink
2068
- });
3650
+ }, selectable);
2069
3651
  col += 2;
2070
3652
  } else col += width;
2071
3653
  }
@@ -2075,20 +3657,21 @@ function renderGraphemes(buffer, graphemes, startCol, y, style, maxCol, inherite
2075
3657
  * Render text line with ANSI escape sequences.
2076
3658
  * Parses ANSI codes and applies styles to individual segments.
2077
3659
  */
2078
- function renderAnsiTextLine(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx) {
2079
- renderAnsiTextLineReturn(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx);
3660
+ function renderAnsiTextLine(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx, selectable = false) {
3661
+ renderAnsiTextLineReturn(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx, void 0, selectable);
2080
3662
  }
2081
3663
  /**
2082
3664
  * Like renderAnsiTextLine but returns the column position after the last rendered character.
2083
3665
  */
2084
- function renderAnsiTextLineReturn(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx, minCol) {
3666
+ function renderAnsiTextLineReturn(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx, minCol, selectable = false, bgConflictOverride) {
3667
+ createFrameSink(buffer);
2085
3668
  const segments = parseAnsiText(text);
2086
3669
  let col = x;
2087
3670
  for (const segment of segments) {
2088
3671
  const style = mergeAnsiStyle(baseStyle, segment);
2089
- const effectiveBgConflictMode = ctx?.bgConflictMode ?? getBgConflictMode();
3672
+ const effectiveBgConflictMode = bgConflictOverride ?? ctx?.bgConflictMode ?? getBgConflictMode();
2090
3673
  if (effectiveBgConflictMode !== "ignore" && !segment.bgOverride && segment.bg !== void 0 && segment.bg !== null) {
2091
- const existingBufBg = col < buffer.width ? buffer.getCellBg(col, y) : null;
3674
+ const existingBufBg = inheritedBg !== void 0 ? inheritedBg : null;
2092
3675
  if (baseStyle.bg !== null || existingBufBg !== null) {
2093
3676
  const preview = segment.text.slice(0, 30);
2094
3677
  const chalkBg = formatBgConflictColor(segment.bg);
@@ -2104,7 +3687,7 @@ function renderAnsiTextLineReturn(buffer, x, y, text, baseStyle, maxCol, inherit
2104
3687
  }
2105
3688
  }
2106
3689
  }
2107
- col = renderGraphemes(buffer, splitGraphemes(segment.text), col, y, style, maxCol, inheritedBg, ctx, minCol);
3690
+ col = renderGraphemes(buffer, splitGraphemes(segment.text), col, y, style, maxCol, inheritedBg, ctx, minCol, selectable);
2108
3691
  }
2109
3692
  return col;
2110
3693
  }
@@ -2135,10 +3718,12 @@ function mergeStyles(base, overlay, options = {}) {
2135
3718
  attrs.underlineStyle = overlayAttrs.underlineStyle ?? baseAttrs.underlineStyle ?? "single";
2136
3719
  }
2137
3720
  attrs.strikethrough = overlayAttrs.strikethrough || baseAttrs.strikethrough;
3721
+ attrs.overline = overlayAttrs.overline || baseAttrs.overline;
2138
3722
  } else {
2139
3723
  attrs.underline = overlayAttrs.underline ?? baseAttrs.underline;
2140
3724
  attrs.underlineStyle = overlayAttrs.underlineStyle ?? baseAttrs.underlineStyle;
2141
3725
  attrs.strikethrough = overlayAttrs.strikethrough ?? baseAttrs.strikethrough;
3726
+ attrs.overline = overlayAttrs.overline ?? baseAttrs.overline;
2142
3727
  }
2143
3728
  if (preserveEmphasis) {
2144
3729
  attrs.bold = overlayAttrs.bold || baseAttrs.bold;
@@ -2178,6 +3763,8 @@ function mergeAnsiStyle(base, segment, options = {}) {
2178
3763
  if (segment.underline !== void 0) overlayAttrs.underline = segment.underline;
2179
3764
  if (segment.underlineStyle !== void 0) overlayAttrs.underlineStyle = segment.underlineStyle;
2180
3765
  if (segment.inverse !== void 0) overlayAttrs.inverse = segment.inverse;
3766
+ if (segment.strikethrough !== void 0) overlayAttrs.strikethrough = segment.strikethrough;
3767
+ if (segment.overline !== void 0) overlayAttrs.overline = segment.overline;
2181
3768
  const merged = mergeStyles(base, {
2182
3769
  fg,
2183
3770
  bg,
@@ -2215,6 +3802,7 @@ function ansiColorToColor(code) {
2215
3802
  * See km-silvery.bg-bleed for details.
2216
3803
  */
2217
3804
  function renderText(node, buffer, layout, props, nodeState, inheritedBg, inheritedFg, ctx) {
3805
+ const sink = createFrameSink(buffer);
2218
3806
  const { scrollOffset, clipBounds } = nodeState;
2219
3807
  const { x, width, height } = layout;
2220
3808
  let { y } = layout;
@@ -2236,20 +3824,29 @@ function renderText(node, buffer, layout, props, nodeState, inheritedBg, inherit
2236
3824
  lineCount = (plainText.match(/\n/g)?.length ?? 0) + 1;
2237
3825
  setCachedPlainText(node, plainText, lineCount);
2238
3826
  }
2239
- maxDisplayWidth = (width + 1) * lineCount;
3827
+ maxDisplayWidth = (width + 2) * lineCount;
2240
3828
  }
2241
3829
  let text;
2242
3830
  let bgSegments;
2243
3831
  let childSpans;
3832
+ let rootUnderline;
3833
+ let rootUnderlineStyle;
3834
+ if (props.underlineStyle !== void 0) {
3835
+ rootUnderline = props.underlineStyle !== false;
3836
+ rootUnderlineStyle = props.underlineStyle;
3837
+ } else if (typeof props.underline === "string") {
3838
+ rootUnderline = true;
3839
+ rootUnderlineStyle = props.underline;
3840
+ } else if (props.underline !== void 0) rootUnderline = props.underline;
2244
3841
  const rootContext = {
2245
3842
  color: props.color,
2246
3843
  backgroundColor: props.backgroundColor,
2247
3844
  bold: props.bold,
2248
- dim: props.dim || props.dimColor,
2249
3845
  italic: props.italic,
2250
- underline: !!(props.underline || props.underlineStyle),
2251
- underlineStyle: props.underlineStyle,
3846
+ underline: rootUnderline,
3847
+ underlineStyle: rootUnderlineStyle,
2252
3848
  underlineColor: props.underlineColor,
3849
+ overline: props.overline,
2253
3850
  inverse: props.inverse,
2254
3851
  strikethrough: props.strikethrough
2255
3852
  };
@@ -2288,14 +3885,16 @@ function renderText(node, buffer, layout, props, nodeState, inheritedBg, inherit
2288
3885
  const lineY = y + lineIdx;
2289
3886
  if (clipBounds && (lineY < clipBounds.top || lineY >= clipBounds.bottom)) continue;
2290
3887
  const line = lines[lineIdx];
2291
- const layoutRight = internalTransform ? buffer.width : x + width;
3888
+ const layoutRight = internalTransform ? sink.width : x + width;
2292
3889
  const maxCol = clipBounds && "right" in clipBounds && clipBounds.right !== void 0 ? Math.min(layoutRight, clipBounds.right) : layoutRight;
2293
3890
  const minCol = clipBounds && "left" in clipBounds && clipBounds.left !== void 0 ? clipBounds.left : void 0;
2294
- const endCol = renderTextLineReturn(buffer, x, lineY, line, style, maxCol, inheritedBg, ctx, minCol);
3891
+ const lineTextForSelection = hasAnsi(line) ? stripAnsiForBg(line) : line;
3892
+ const lineSelectable = nodeState.selectableMode && /\S/.test(lineTextForSelection);
3893
+ const endCol = renderTextLineReturn(buffer, x, lineY, line, style, maxCol, inheritedBg, ctx, minCol, lineSelectable, props.bgConflict);
2295
3894
  const clearStart = minCol !== void 0 ? Math.max(endCol, minCol) : endCol;
2296
3895
  if (clearStart < maxCol) {
2297
3896
  const clearBg = inheritedBg ?? null;
2298
- for (let cx = clearStart; cx < maxCol && cx < buffer.width; cx++) buffer.setCell(cx, lineY, {
3897
+ const clearCell = {
2299
3898
  char: " ",
2300
3899
  fg: style.fg,
2301
3900
  bg: clearBg,
@@ -2305,6 +3904,7 @@ function renderText(node, buffer, layout, props, nodeState, inheritedBg, inherit
2305
3904
  dim: false,
2306
3905
  italic: false,
2307
3906
  underline: false,
3907
+ overline: false,
2308
3908
  inverse: false,
2309
3909
  strikethrough: false,
2310
3910
  blink: false,
@@ -2312,11 +3912,12 @@ function renderText(node, buffer, layout, props, nodeState, inheritedBg, inherit
2312
3912
  },
2313
3913
  wide: false,
2314
3914
  continuation: false
2315
- });
3915
+ };
3916
+ for (let cx = clearStart; cx < maxCol && cx < sink.width; cx++) sink.emitClearCells(cx, lineY, 1, 1, clearCell);
2316
3917
  }
2317
3918
  if (bgSegments.length > 0 && lineIdx < lineOffsets.length) {
2318
3919
  const { start, end } = lineOffsets[lineIdx];
2319
- applyBgSegmentsToLine(buffer, x, lineY, line, start, end, bgSegments, ctx);
3920
+ applyBgSegmentsToLine(buffer, x, lineY, line, start, end, bgSegments, ctx, maxCol, minCol, lineSelectable);
2320
3921
  }
2321
3922
  }
2322
3923
  if (childSpans.length > 0 && lineOffsets.length > 0) computeInlineRects(childSpans, lineOffsets, x, y, lines.length, height);
@@ -2378,6 +3979,7 @@ function getEffectiveBg(props) {
2378
3979
  * Render a Box node.
2379
3980
  */
2380
3981
  function renderBox(_node, buffer, layout, props, nodeState, skipBgFill = false, inheritedBg, bgOnlyChange = false, inheritedFg) {
3982
+ const sink = createFrameSink(buffer);
2381
3983
  const { scrollOffset, clipBounds } = nodeState;
2382
3984
  const { x, width, height } = layout;
2383
3985
  const y = layout.y - scrollOffset;
@@ -2399,17 +4001,17 @@ function renderBox(_node, buffer, layout, props, nodeState, skipBgFill = false,
2399
4001
  clippedX = Math.max(x, clipBounds.left);
2400
4002
  clippedWidth = Math.min(x + width, clipBounds.right) - clippedX;
2401
4003
  }
2402
- if (clippedHeight > 0 && clippedWidth > 0) if (bgOnlyChange) buffer.fillBg(clippedX, clippedY, clippedWidth, clippedHeight, bg);
2403
- else buffer.fill(clippedX, clippedY, clippedWidth, clippedHeight, { bg });
2404
- } else if (bgOnlyChange) buffer.fillBg(x, y, width, height, bg);
2405
- else buffer.fill(x, y, width, height, { bg });
4004
+ if (clippedHeight > 0 && clippedWidth > 0) if (bgOnlyChange) sink.emitFillBg(clippedX, clippedY, clippedWidth, clippedHeight, bg);
4005
+ else sink.emitPaintFill(clippedX, clippedY, clippedWidth, clippedHeight, { bg });
4006
+ } else if (bgOnlyChange) sink.emitFillBg(x, y, width, height, bg);
4007
+ else sink.emitPaintFill(x, y, width, height, { bg });
2406
4008
  }
2407
- if (props.borderStyle) renderBorder(buffer, x, y, width, height, props, clipBounds, inheritedBg, inheritedFg);
4009
+ if (props.borderStyle) renderBorder(buffer, sink, x, y, width, height, props, clipBounds, inheritedBg, inheritedFg);
2408
4010
  }
2409
4011
  /**
2410
4012
  * Render a border around a box.
2411
4013
  */
2412
- function renderBorder(buffer, x, y, width, height, props, clipBounds, inheritedBg, inheritedFg) {
4014
+ function renderBorder(buffer, sink, x, y, width, height, props, clipBounds, inheritedBg, inheritedFg) {
2413
4015
  const chars = getBorderChars(props.borderStyle ?? "single");
2414
4016
  let color;
2415
4017
  if (props.borderColor === "currentColor" || props.borderColor === "inherit") color = props.color ? parseColor(props.color) : inheritedFg ?? null;
@@ -2430,27 +4032,27 @@ function renderBorder(buffer, x, y, width, height, props, clipBounds, inheritedB
2430
4032
  const showLeft = props.borderLeft !== false;
2431
4033
  const showRight = props.borderRight !== false;
2432
4034
  const isRowVisible = (row) => {
2433
- if (!clipBounds) return row >= 0 && row < buffer.height;
2434
- return row >= clipBounds.top && row < clipBounds.bottom && row < buffer.height;
4035
+ if (!clipBounds) return row >= 0 && row < sink.height;
4036
+ return row >= clipBounds.top && row < clipBounds.bottom && row < sink.height;
2435
4037
  };
2436
4038
  const isColVisible = (col) => {
2437
- if (clipBounds?.left === void 0 || clipBounds.right === void 0) return col >= 0 && col < buffer.width;
2438
- return col >= clipBounds.left && col < clipBounds.right && col < buffer.width;
4039
+ if (clipBounds?.left === void 0 || clipBounds.right === void 0) return col >= 0 && col < sink.width;
4040
+ return col >= clipBounds.left && col < clipBounds.right && col < sink.width;
2439
4041
  };
2440
4042
  if (showTop && isRowVisible(y)) {
2441
- if (showLeft && isColVisible(x)) buffer.setCell(x, y, {
4043
+ if (showLeft && isColVisible(x)) sink.emitSetCell(x, y, {
2442
4044
  char: chars.topLeft,
2443
4045
  fg: color,
2444
4046
  bg: topBg
2445
4047
  });
2446
4048
  const hStart = showLeft ? x + 1 : x;
2447
4049
  const hEnd = showRight ? x + width - 1 : x + width;
2448
- for (let col = hStart; col < hEnd && col < buffer.width; col++) if (isColVisible(col)) buffer.setCell(col, y, {
4050
+ for (let col = hStart; col < hEnd && col < sink.width; col++) if (isColVisible(col)) sink.emitSetCell(col, y, {
2449
4051
  char: chars.horizontal,
2450
4052
  fg: color,
2451
4053
  bg: topBg
2452
4054
  });
2453
- if (showRight && x + width - 1 < buffer.width && isColVisible(x + width - 1)) buffer.setCell(x + width - 1, y, {
4055
+ if (showRight && x + width - 1 < sink.width && isColVisible(x + width - 1)) sink.emitSetCell(x + width - 1, y, {
2454
4056
  char: chars.topRight,
2455
4057
  fg: color,
2456
4058
  bg: topBg
@@ -2461,12 +4063,12 @@ function renderBorder(buffer, x, y, width, height, props, clipBounds, inheritedB
2461
4063
  const sideEnd = showBottom ? y + height - 1 : y + height;
2462
4064
  for (let row = sideStart; row < sideEnd; row++) {
2463
4065
  if (!isRowVisible(row)) continue;
2464
- if (showLeft && isColVisible(x)) buffer.setCell(x, row, {
4066
+ if (showLeft && isColVisible(x)) sink.emitSetCell(x, row, {
2465
4067
  char: chars.vertical,
2466
4068
  fg: color,
2467
4069
  bg: leftBg
2468
4070
  });
2469
- if (showRight && x + width - 1 < buffer.width && isColVisible(x + width - 1)) buffer.setCell(x + width - 1, row, {
4071
+ if (showRight && x + width - 1 < sink.width && isColVisible(x + width - 1)) sink.emitSetCell(x + width - 1, row, {
2470
4072
  char: rightVertical,
2471
4073
  fg: color,
2472
4074
  bg: rightBg
@@ -2475,19 +4077,19 @@ function renderBorder(buffer, x, y, width, height, props, clipBounds, inheritedB
2475
4077
  const bottomHorizontal = chars.bottomHorizontal ?? chars.horizontal;
2476
4078
  const bottomY = y + height - 1;
2477
4079
  if (showBottom && isRowVisible(bottomY)) {
2478
- if (showLeft && isColVisible(x)) buffer.setCell(x, bottomY, {
4080
+ if (showLeft && isColVisible(x)) sink.emitSetCell(x, bottomY, {
2479
4081
  char: chars.bottomLeft,
2480
4082
  fg: color,
2481
4083
  bg: bottomBg
2482
4084
  });
2483
4085
  const bStart = showLeft ? x + 1 : x;
2484
4086
  const bEnd = showRight ? x + width - 1 : x + width;
2485
- for (let col = bStart; col < bEnd && col < buffer.width; col++) if (isColVisible(col)) buffer.setCell(col, bottomY, {
4087
+ for (let col = bStart; col < bEnd && col < sink.width; col++) if (isColVisible(col)) sink.emitSetCell(col, bottomY, {
2486
4088
  char: bottomHorizontal,
2487
4089
  fg: color,
2488
4090
  bg: bottomBg
2489
4091
  });
2490
- if (showRight && x + width - 1 < buffer.width && isColVisible(x + width - 1)) buffer.setCell(x + width - 1, bottomY, {
4092
+ if (showRight && x + width - 1 < sink.width && isColVisible(x + width - 1)) sink.emitSetCell(x + width - 1, bottomY, {
2491
4093
  char: chars.bottomRight,
2492
4094
  fg: color,
2493
4095
  bg: bottomBg
@@ -2504,7 +4106,7 @@ function renderBorder(buffer, x, y, width, height, props, clipBounds, inheritedB
2504
4106
  * The outline occupies cells at (x-1, y-1) through (x+width, y+height) —
2505
4107
  * entirely outside the box's own rect. Content is never overlapped.
2506
4108
  */
2507
- function renderOutline(buffer, x, y, width, height, props, clipBounds, inheritedBg) {
4109
+ function renderOutline(buffer, sink, x, y, width, height, props, clipBounds, inheritedBg) {
2508
4110
  const chars = getBorderChars(props.outlineStyle ?? "single");
2509
4111
  const color = props.outlineColor ? parseColor(props.outlineColor) : null;
2510
4112
  const bg = props.backgroundColor ? parseColor(props.backgroundColor) : inheritedBg ?? null;
@@ -2514,31 +4116,31 @@ function renderOutline(buffer, x, y, width, height, props, clipBounds, inherited
2514
4116
  const ow = width + 2;
2515
4117
  const oh = height + 2;
2516
4118
  const isRowVisible = (row) => {
2517
- if (!clipBounds) return row >= 0 && row < buffer.height;
2518
- return row >= clipBounds.top && row < clipBounds.bottom && row < buffer.height;
4119
+ if (!clipBounds) return row >= 0 && row < sink.height;
4120
+ return row >= clipBounds.top && row < clipBounds.bottom && row < sink.height;
2519
4121
  };
2520
4122
  const isColVisible = (col) => {
2521
- if (clipBounds?.left === void 0 || clipBounds.right === void 0) return col >= 0 && col < buffer.width;
2522
- return col >= clipBounds.left && col < clipBounds.right && col < buffer.width;
4123
+ if (clipBounds?.left === void 0 || clipBounds.right === void 0) return col >= 0 && col < sink.width;
4124
+ return col >= clipBounds.left && col < clipBounds.right && col < sink.width;
2523
4125
  };
2524
4126
  const showTop = props.outlineTop !== false;
2525
4127
  const showBottom = props.outlineBottom !== false;
2526
4128
  const showLeft = props.outlineLeft !== false;
2527
4129
  const showRight = props.outlineRight !== false;
2528
4130
  if (showTop && isRowVisible(oy)) {
2529
- if (showLeft && isColVisible(ox)) buffer.setCell(ox, oy, {
4131
+ if (showLeft && isColVisible(ox)) sink.emitSetCell(ox, oy, {
2530
4132
  char: chars.topLeft,
2531
4133
  fg: color,
2532
4134
  bg,
2533
4135
  attrs
2534
4136
  });
2535
- for (let col = ox + 1; col < ox + ow - 1 && col < buffer.width; col++) if (isColVisible(col)) buffer.setCell(col, oy, {
4137
+ for (let col = ox + 1; col < ox + ow - 1 && col < sink.width; col++) if (isColVisible(col)) sink.emitSetCell(col, oy, {
2536
4138
  char: chars.horizontal,
2537
4139
  fg: color,
2538
4140
  bg,
2539
4141
  attrs
2540
4142
  });
2541
- if (showRight && ox + ow - 1 < buffer.width && isColVisible(ox + ow - 1)) buffer.setCell(ox + ow - 1, oy, {
4143
+ if (showRight && ox + ow - 1 < sink.width && isColVisible(ox + ow - 1)) sink.emitSetCell(ox + ow - 1, oy, {
2542
4144
  char: chars.topRight,
2543
4145
  fg: color,
2544
4146
  bg,
@@ -2550,13 +4152,13 @@ function renderOutline(buffer, x, y, width, height, props, clipBounds, inherited
2550
4152
  const sideEnd = showBottom ? oy + oh - 1 : oy + oh;
2551
4153
  for (let row = sideStart; row < sideEnd; row++) {
2552
4154
  if (!isRowVisible(row)) continue;
2553
- if (showLeft && isColVisible(ox)) buffer.setCell(ox, row, {
4155
+ if (showLeft && isColVisible(ox)) sink.emitSetCell(ox, row, {
2554
4156
  char: chars.vertical,
2555
4157
  fg: color,
2556
4158
  bg,
2557
4159
  attrs
2558
4160
  });
2559
- if (showRight && ox + ow - 1 < buffer.width && isColVisible(ox + ow - 1)) buffer.setCell(ox + ow - 1, row, {
4161
+ if (showRight && ox + ow - 1 < sink.width && isColVisible(ox + ow - 1)) sink.emitSetCell(ox + ow - 1, row, {
2560
4162
  char: outlineRightVertical,
2561
4163
  fg: color,
2562
4164
  bg,
@@ -2566,19 +4168,19 @@ function renderOutline(buffer, x, y, width, height, props, clipBounds, inherited
2566
4168
  const outlineBottomHorizontal = chars.bottomHorizontal ?? chars.horizontal;
2567
4169
  const bottomY = oy + oh - 1;
2568
4170
  if (showBottom && isRowVisible(bottomY)) {
2569
- if (showLeft && isColVisible(ox)) buffer.setCell(ox, bottomY, {
4171
+ if (showLeft && isColVisible(ox)) sink.emitSetCell(ox, bottomY, {
2570
4172
  char: chars.bottomLeft,
2571
4173
  fg: color,
2572
4174
  bg,
2573
4175
  attrs
2574
4176
  });
2575
- for (let col = ox + 1; col < ox + ow - 1 && col < buffer.width; col++) if (isColVisible(col)) buffer.setCell(col, bottomY, {
4177
+ for (let col = ox + 1; col < ox + ow - 1 && col < sink.width; col++) if (isColVisible(col)) sink.emitSetCell(col, bottomY, {
2576
4178
  char: outlineBottomHorizontal,
2577
4179
  fg: color,
2578
4180
  bg,
2579
4181
  attrs
2580
4182
  });
2581
- if (showRight && ox + ow - 1 < buffer.width && isColVisible(ox + ow - 1)) buffer.setCell(ox + ow - 1, bottomY, {
4183
+ if (showRight && ox + ow - 1 < sink.width && isColVisible(ox + ow - 1)) sink.emitSetCell(ox + ow - 1, bottomY, {
2582
4184
  char: chars.bottomRight,
2583
4185
  fg: color,
2584
4186
  bg,
@@ -2613,43 +4215,277 @@ function renderScrollIndicators(_node, buffer, layout, props, ss, ctx) {
2613
4215
  const indicator = `\u25b2${ss.hiddenAbove}`;
2614
4216
  if (border.top > 0) {
2615
4217
  const contentWidth = layout.width - border.left - border.right;
2616
- const bar = padCenter(indicator, contentWidth);
2617
4218
  const x = layout.x + border.left;
2618
4219
  const y = layout.y;
2619
- renderTextLine(buffer, x, y, bar, indicatorStyle, x + contentWidth, void 0, ctx);
4220
+ renderCenteredIndicator(buffer, x, y, indicator, indicatorStyle, contentWidth, x + contentWidth, ctx);
2620
4221
  } else if (showBorderless) {
2621
4222
  const padding = getPadding(props);
2622
4223
  const contentWidth = layout.width - padding.left - padding.right;
2623
- const bar = padCenter(indicator, contentWidth);
2624
4224
  const x = layout.x + padding.left;
2625
- renderTextLine(buffer, x, layout.y + padding.top, bar, indicatorStyle, x + contentWidth, void 0, ctx);
4225
+ renderCenteredIndicator(buffer, x, layout.y + padding.top, indicator, indicatorStyle, contentWidth, x + contentWidth, ctx);
2626
4226
  }
2627
4227
  }
2628
4228
  if (ss.hiddenBelow > 0) {
2629
4229
  const indicator = `\u25bc${ss.hiddenBelow}`;
2630
4230
  if (border.bottom > 0) {
2631
4231
  const contentWidth = layout.width - border.left - border.right;
2632
- const bar = padCenter(indicator, contentWidth);
2633
4232
  const x = layout.x + border.left;
2634
- renderTextLine(buffer, x, layout.y + layout.height - 1, bar, indicatorStyle, x + contentWidth, void 0, ctx);
4233
+ renderCenteredIndicator(buffer, x, layout.y + layout.height - 1, indicator, indicatorStyle, contentWidth, x + contentWidth, ctx);
2635
4234
  } else if (showBorderless) {
2636
4235
  const padding = getPadding(props);
2637
4236
  const contentWidth = layout.width - padding.left - padding.right;
2638
- const bar = padCenter(indicator, contentWidth);
2639
4237
  const x = layout.x + padding.left;
2640
- renderTextLine(buffer, x, layout.y + layout.height - padding.bottom - 1, bar, indicatorStyle, x + contentWidth, void 0, ctx);
4238
+ renderCenteredIndicator(buffer, x, layout.y + layout.height - padding.bottom - 1, indicator, indicatorStyle, contentWidth, x + contentWidth, ctx);
2641
4239
  }
2642
4240
  }
2643
4241
  }
2644
- /** Center text within a fixed width, padding with spaces on both sides.
2645
- * Truncates from the right if text exceeds available width. */
2646
- function padCenter(text, width) {
2647
- if (width <= 0) return "";
2648
- if (text.length > width) return text.slice(0, width);
2649
- if (text.length === width) return text;
2650
- const leftPad = Math.floor((width - text.length) / 2);
2651
- const rightPad = width - text.length - leftPad;
2652
- return " ".repeat(leftPad) + text + " ".repeat(rightPad);
4242
+ function renderCenteredIndicator(buffer, x, y, indicator, style, width, maxCol, ctx) {
4243
+ if (width <= 0) return;
4244
+ const text = indicator.length > width ? indicator.slice(0, width) : indicator;
4245
+ const indicatorX = x + Math.max(0, Math.floor((width - text.length) / 2));
4246
+ renderTextLine(buffer, x, y, " ".repeat(width), {
4247
+ fg: null,
4248
+ bg: null,
4249
+ attrs: {}
4250
+ }, maxCol, void 0, ctx);
4251
+ renderTextLine(buffer, indicatorX, y, text, style, Math.min(maxCol, indicatorX + text.length), void 0, ctx);
4252
+ }
4253
+ //#endregion
4254
+ //#region packages/ag-term/src/strict-island.ts
4255
+ /**
4256
+ * Runtime checks for `<Island>` strict slugs.
4257
+ *
4258
+ * The Island contract lives in `@silvery/ag`, but SILVERY_STRICT is a
4259
+ * terminal-target runtime gate. Keep the instrumentation here so core remains
4260
+ * target-agnostic while the terminal renderer can still enforce the contract.
4261
+ */
4262
+ /** Tier-2 paint budget: one 256x256 island frame. */
4263
+ const ISLAND_PAINT_BUDGET_CELLS = 65536;
4264
+ const instrumentedHandles = /* @__PURE__ */ new WeakSet();
4265
+ /**
4266
+ * Attach strict wrappers to a live island handle once.
4267
+ *
4268
+ * This is called from the render phase when the terminal target first sees a
4269
+ * handle. Wrapping at the target avoids pulling SILVERY_STRICT into `@silvery/ag`.
4270
+ */
4271
+ function ensureIslandStrictInstrumentation(node) {
4272
+ if (!needsIslandStrictInstrumentation()) return;
4273
+ const state = node.islandState;
4274
+ const handle = state?.handle;
4275
+ if (!state || !handle || instrumentedHandles.has(handle)) return;
4276
+ instrumentedHandles.add(handle);
4277
+ wrapWriteCells(handle);
4278
+ wrapSubscription(handle.output, state, "output");
4279
+ wrapSubscription(handle.size, state, "size");
4280
+ if (handle.modes) wrapSubscription(handle.modes, state, "modes");
4281
+ if (handle.input) wrapMouseInput(handle.input, handle);
4282
+ }
4283
+ function needsIslandStrictInstrumentation() {
4284
+ return isStrictEnabled("island-paint-oob", 2) || isStrictEnabled("island-dispose-leak", 2) || isStrictEnabled("island-boundary-limits", 2);
4285
+ }
4286
+ /** Render-phase checks that do not depend on guest mutation APIs. */
4287
+ function assertIslandRenderInvariants(node, layout) {
4288
+ const state = node.islandState;
4289
+ const handle = state?.handle;
4290
+ if (!state || !handle) return;
4291
+ const source = handle.output.buffer;
4292
+ assertResizeRace(handle, source);
4293
+ assertPaintBudget(source, layout);
4294
+ assertGraphemeWidth(source);
4295
+ assertCursorBoundary(handle);
4296
+ assertNoDisposedRender(state);
4297
+ }
4298
+ function wrapWriteCells(handle) {
4299
+ const original = handle.output.writeCells.bind(handle.output);
4300
+ handle.output.writeCells = (dirtyRects, source) => {
4301
+ assertDirtyRectsInsideIsland(handle, dirtyRects, source);
4302
+ return original(dirtyRects, source);
4303
+ };
4304
+ }
4305
+ function wrapSubscription(owner, state, label) {
4306
+ const original = owner.subscribe.bind(owner);
4307
+ owner.subscribe = ((listener) => {
4308
+ return original((...args) => {
4309
+ assertNoPostDisposeCallback(state, label);
4310
+ listener(...args);
4311
+ });
4312
+ });
4313
+ }
4314
+ function wrapMouseInput(input, handle) {
4315
+ if (!input.onMouse) return;
4316
+ const original = input.onMouse.bind(input);
4317
+ input.onMouse = (handler) => {
4318
+ return original((event) => {
4319
+ assertMouseInsideIsland(handle, event);
4320
+ handler(event);
4321
+ });
4322
+ };
4323
+ }
4324
+ function assertDirtyRectsInsideIsland(handle, dirtyRects, source) {
4325
+ if (!isStrictEnabled("island-paint-oob", 2)) return;
4326
+ const cols = Math.min(handle.size.cols, source.cols);
4327
+ const rows = Math.min(handle.size.rows, source.rows);
4328
+ for (const rect of dirtyRects) {
4329
+ const c1 = rect.col + rect.width;
4330
+ const r1 = rect.row + rect.height;
4331
+ if (!Number.isInteger(rect.col) || !Number.isInteger(rect.row) || !Number.isInteger(rect.width) || !Number.isInteger(rect.height) || rect.col < 0 || rect.row < 0 || rect.width < 0 || rect.height < 0 || c1 > cols || r1 > rows) throw new Error(`[SILVERY_STRICT=island-paint-oob] guest dirty rect ${formatRect(rect)} escapes island ${cols}x${rows}`);
4332
+ }
4333
+ }
4334
+ function assertResizeRace(handle, source) {
4335
+ if (!isStrictEnabled("island-resize-race", 2)) return;
4336
+ if (source.cols <= handle.size.cols && source.rows <= handle.size.rows) return;
4337
+ throw new Error(`[SILVERY_STRICT=island-resize-race] guest output ${source.cols}x${source.rows} exceeds size-owner acknowledgement ${handle.size.cols}x${handle.size.rows}`);
4338
+ }
4339
+ function assertPaintBudget(source, layout) {
4340
+ if (!isStrictEnabled("island-paint-budget", 2)) return;
4341
+ const cells = Math.max(0, Math.min(layout.width, source.cols)) * Math.max(0, Math.min(layout.height, source.rows));
4342
+ if (cells <= 65536) return;
4343
+ throw new Error(`[SILVERY_STRICT=island-paint-budget] island paints ${cells} cells in one frame (budget ${ISLAND_PAINT_BUDGET_CELLS})`);
4344
+ }
4345
+ function assertGraphemeWidth(source) {
4346
+ if (!isStrictEnabled("island-grapheme-width", 2)) return;
4347
+ for (let row = 0; row < source.rows; row++) for (let col = 0; col < source.cols; col++) {
4348
+ const cell = source.getCell(col, row);
4349
+ if (cell.continuation) {
4350
+ if (col === 0 || !source.getCell(col - 1, row).wide) throw new Error(`[SILVERY_STRICT=island-grapheme-width] continuation cell at ${col},${row} has no leading wide cell`);
4351
+ continue;
4352
+ }
4353
+ const width = graphemeWidth(cell.char);
4354
+ if (width === 2) {
4355
+ const next = col + 1 < source.cols ? source.getCell(col + 1, row) : null;
4356
+ if (!cell.wide || !next?.continuation) throw new Error(`[SILVERY_STRICT=island-grapheme-width] wide grapheme ${JSON.stringify(cell.char)} at ${col},${row} is missing wide+continuation metadata`);
4357
+ continue;
4358
+ }
4359
+ if (cell.wide || width > 2) throw new Error(`[SILVERY_STRICT=island-grapheme-width] cell ${JSON.stringify(cell.char)} at ${col},${row} declares wide=${String(cell.wide)} but measures width ${width}`);
4360
+ }
4361
+ }
4362
+ function assertCursorBoundary(handle) {
4363
+ if (!isStrictEnabled("island-boundary-limits", 2)) return;
4364
+ const cursor = handle.output.cursor;
4365
+ if (!handle.output.cursorVisible || !cursor) return;
4366
+ if (cursor.row >= 0 && cursor.row < handle.size.rows && cursor.col >= 0 && cursor.col < handle.size.cols) return;
4367
+ throw new Error(`[SILVERY_STRICT=island-boundary-limits] guest cursor ${cursor.col},${cursor.row} escapes island ${handle.size.cols}x${handle.size.rows}`);
4368
+ }
4369
+ function assertMouseInsideIsland(handle, event) {
4370
+ if (!isStrictEnabled("island-boundary-limits", 2)) return;
4371
+ if (event.row >= 0 && event.row < handle.size.rows && event.col >= 0 && event.col < handle.size.cols) return;
4372
+ throw new Error(`[SILVERY_STRICT=island-boundary-limits] mouse event ${event.col},${event.row} escapes island ${handle.size.cols}x${handle.size.rows}`);
4373
+ }
4374
+ function assertNoPostDisposeCallback(state, label) {
4375
+ if (!isStrictEnabled("island-dispose-leak", 2)) return;
4376
+ if (state.lifecycle !== "disposed" && !state.abortController.signal.aborted) return;
4377
+ throw new Error(`[SILVERY_STRICT=island-dispose-leak] ${label} callback fired after island dispose`);
4378
+ }
4379
+ function assertNoDisposedRender(state) {
4380
+ if (!isStrictEnabled("island-dispose-leak", 2)) return;
4381
+ if (state.lifecycle !== "disposed") return;
4382
+ throw new Error("[SILVERY_STRICT=island-dispose-leak] disposed island reached render phase");
4383
+ }
4384
+ function formatRect(rect) {
4385
+ return `{col:${rect.col},row:${rect.row},width:${rect.width},height:${rect.height}}`;
4386
+ }
4387
+ //#endregion
4388
+ //#region packages/ag-term/src/pipeline/render-viewport.ts
4389
+ /**
4390
+ * Blit the foreign cell buffer at `node.viewportState.buffer` into `buffer`
4391
+ * (via `sink.emitSetCell`) at `layout` (the viewport's content rect in
4392
+ * absolute parent-buffer coordinates). Cells outside `buffer`'s bounds are
4393
+ * silently clipped — the Viewport rect's right/bottom may extend off-screen
4394
+ * and that's fine.
4395
+ */
4396
+ function renderViewport(node, buffer, sink, layout, scrollOffset) {
4397
+ const state = node.viewportState;
4398
+ if (!state) return;
4399
+ const src = state.buffer;
4400
+ const baseX = layout.x;
4401
+ const baseY = layout.y - scrollOffset;
4402
+ const drawW = Math.min(layout.width, src.cols);
4403
+ const drawH = Math.min(layout.height, src.rows);
4404
+ for (let r = 0; r < drawH; r++) {
4405
+ const dstY = baseY + r;
4406
+ if (dstY < 0 || dstY >= buffer.height) continue;
4407
+ for (let c = 0; c < drawW; c++) {
4408
+ const dstX = baseX + c;
4409
+ if (dstX < 0 || dstX >= buffer.width) continue;
4410
+ const cell = src.getCell(c, r);
4411
+ sink.emitSetCell(dstX, dstY, viewportCellToPatch(cell));
4412
+ }
4413
+ }
4414
+ }
4415
+ /**
4416
+ * Convert a viewport {@link Cell} (string-colored, framework-agnostic shape
4417
+ * from `@silvery/ag/types`) to a {@link CellPatch} the parent
4418
+ * {@link TerminalBuffer} accepts (Color = `number | RGB | null`). String
4419
+ * colors are parsed once per cell — the upcoming xterm adapter writes
4420
+ * pre-resolved RGB strings, so parseColor's fast path runs.
4421
+ *
4422
+ * Reused by {@link renderIsland} — both viewport and island share the same
4423
+ * Cell shape from `@silvery/ag/types`. Islands pass an inherited background
4424
+ * so snapshot guests can leave cell.bg null and still sit on host chrome.
4425
+ */
4426
+ function viewportCellToPatch(cell, inheritedBg = null) {
4427
+ return {
4428
+ char: cell.char,
4429
+ fg: cell.fg === null ? null : parseColor(cell.fg),
4430
+ bg: cell.bg === null ? inheritedBg : parseColor(cell.bg),
4431
+ attrs: cell.attrs,
4432
+ wide: cell.wide,
4433
+ continuation: cell.continuation
4434
+ };
4435
+ }
4436
+ /**
4437
+ * Blit a `silvery-island` node's guest cell buffer into the parent buffer.
4438
+ *
4439
+ * Sibling of {@link renderViewport}: reads from
4440
+ * `node.islandState.handle.output.buffer` (the guest's read-only output
4441
+ * surface from {@link IslandOutputOwner}) instead of
4442
+ * `node.viewportState.buffer`. Same routing — through {@link RenderSink}'s
4443
+ * `emitSetCell`, not direct buffer writes — so the cells survive
4444
+ * `commitSectionedPlan` under `SILVERY_RENDER_PLAN`.
4445
+ *
4446
+ * Bails when the host node has no `islandState` (factory still mounting) or
4447
+ * the guest's `init()` hasn't resolved yet (`handle === null`, lifecycle
4448
+ * `"pending"` / `"errored"` / `"disposed"`). In that case the parent's
4449
+ * `clearNodeRegion` (or inherited bg fill) has already painted blanks at
4450
+ * the island's rect, so we paint nothing and the host chrome shows through.
4451
+ *
4452
+ * Cursor handling: `IslandOutputOwner.cursor` is the guest's internal
4453
+ * cursor descriptor. v1 (Phase 1) does NOT render the guest cursor into the
4454
+ * host frame — the host cursor sits OUTSIDE the island, and the
4455
+ * `IslandModesOwner` contract un-applies the host cursor on focus blur to
4456
+ * the island. Phase 3 of `@km/silvery/15646-islands` wires the guest cursor
4457
+ * into the host's cursor signal (separate epic unit); until then, the
4458
+ * cursor field is read by the focus aggregator, not the blit.
4459
+ *
4460
+ * Clipping: same as viewport — out-of-bounds cells are silently dropped.
4461
+ * Both axes (right + bottom) clip; an island whose `cols×rows` overshoots
4462
+ * the parent buffer paints only its in-bounds intersection.
4463
+ *
4464
+ * See {@link island-types.ts} in `@silvery/ag` and bead
4465
+ * `@km/silvery/15646-islands`.
4466
+ */
4467
+ function renderIsland(node, buffer, sink, layout, scrollOffset, inheritedBg = null) {
4468
+ const state = node.islandState;
4469
+ if (!state) return;
4470
+ const handle = state.handle;
4471
+ if (!handle) return;
4472
+ ensureIslandStrictInstrumentation(node);
4473
+ assertIslandRenderInvariants(node, layout);
4474
+ const src = handle.output.buffer;
4475
+ const baseX = layout.x;
4476
+ const baseY = layout.y - scrollOffset;
4477
+ const drawW = Math.min(layout.width, src.cols);
4478
+ const drawH = Math.min(layout.height, src.rows);
4479
+ for (let r = 0; r < drawH; r++) {
4480
+ const dstY = baseY + r;
4481
+ if (dstY < 0 || dstY >= buffer.height) continue;
4482
+ for (let c = 0; c < drawW; c++) {
4483
+ const dstX = baseX + c;
4484
+ if (dstX < 0 || dstX >= buffer.width) continue;
4485
+ const cell = src.getCell(c, r);
4486
+ sink.emitSetCell(dstX, dstY, viewportCellToPatch(cell, inheritedBg));
4487
+ }
4488
+ }
2653
4489
  }
2654
4490
  //#endregion
2655
4491
  //#region packages/ag-term/src/pipeline/decoration-phase.ts
@@ -2658,12 +4494,20 @@ function padCenter(text, width) {
2658
4494
  * state. Called at the start of each incremental render, before the content
2659
4495
  * phase, on the cloned buffer. No-op when there are no previous snapshots
2660
4496
  * (fresh render or no outlines last frame).
4497
+ *
4498
+ * Phase 2 Step 5 (paint-clear-invariant L5): snapshots are read from the
4499
+ * `RenderPostState` carrier owned by `createAg`, NOT from the buffer. The
4500
+ * sink still receives a `setOutlineSnapshots([])` op so the plan-shape
4501
+ * captures the cleanup intent for parity tests; the authoritative state
4502
+ * lives in `postState.outlineSnapshots`.
2661
4503
  */
2662
- function clearPreviousOutlines(buffer) {
2663
- const snapshots = buffer.outlineSnapshots;
2664
- if (!snapshots || snapshots.length === 0) return;
2665
- for (const snap of snapshots) buffer.setCell(snap.x, snap.y, snap.cell);
2666
- buffer.outlineSnapshots = [];
4504
+ function clearPreviousOutlines(buffer, postState) {
4505
+ const snapshots = postState.outlineSnapshots;
4506
+ if (snapshots.length === 0) return;
4507
+ const sink = createFrameSink(buffer);
4508
+ for (const snap of snapshots) sink.emitSetCell(snap.x, snap.y, snap.cell);
4509
+ postState.outlineSnapshots = [];
4510
+ sink.setOutlineSnapshots([]);
2667
4511
  }
2668
4512
  /**
2669
4513
  * Walk the node tree, drawing outlines for every node with `outlineStyle`.
@@ -2673,18 +4517,25 @@ function clearPreviousOutlines(buffer) {
2673
4517
  * incremental). Mirrors `renderNodeToBuffer`'s state threading for scroll
2674
4518
  * offsets, clip bounds, and inherited background — but does nothing except
2675
4519
  * visit the tree and draw outlines.
4520
+ *
4521
+ * Phase 2 Step 5 (paint-clear-invariant L5): snapshots are written to the
4522
+ * `RenderPostState` carrier; the sink op is recorded in the plan for parity
4523
+ * tests only. After this call, `postState.outlineSnapshots` holds exactly
4524
+ * the cells that need restoring on the next frame.
2676
4525
  */
2677
- function renderDecorationPass(buffer, root) {
4526
+ function renderDecorationPass(buffer, root, postState) {
2678
4527
  const snapshots = [];
2679
- walk(root, buffer, 0, void 0, { color: null }, snapshots);
2680
- buffer.outlineSnapshots = snapshots;
4528
+ const sink = createFrameSink(buffer);
4529
+ walk(root, buffer, sink, 0, void 0, { color: null }, snapshots);
4530
+ postState.outlineSnapshots = snapshots;
4531
+ sink.setOutlineSnapshots(snapshots);
2681
4532
  }
2682
4533
  /**
2683
4534
  * Recursive tree walk. Each invocation corresponds to the state at a single
2684
4535
  * node — scroll offset, clip bounds, inherited background — matching what
2685
4536
  * `renderNodeToBuffer` would have threaded through its `NodeRenderState`.
2686
4537
  */
2687
- function walk(node, buffer, scrollOffset, clipBounds, inheritedBg, snapshots) {
4538
+ function walk(node, buffer, sink, scrollOffset, clipBounds, inheritedBg, snapshots) {
2688
4539
  if (!node.layoutNode) return;
2689
4540
  const layout = node.boxRect;
2690
4541
  if (!layout) return;
@@ -2692,20 +4543,23 @@ function walk(node, buffer, scrollOffset, clipBounds, inheritedBg, snapshots) {
2692
4543
  const props = node.props;
2693
4544
  if (props.display === "none") return;
2694
4545
  const y = layout.y - scrollOffset;
2695
- if (y >= buffer.height || y + layout.height <= 0) return;
4546
+ if (y >= sink.height || y + layout.height <= 0) return;
2696
4547
  const effectiveBg = getEffectiveBg(props);
2697
4548
  const theme = props.theme;
2698
4549
  const themeBg = theme && typeof theme["bg-surface-default"] === "string" ? theme["bg-surface-default"] : theme && typeof theme["bg"] === "string" ? theme["bg"] : void 0;
2699
4550
  const childInheritedBg = effectiveBg ? { color: parseColor(effectiveBg) } : themeBg !== void 0 ? { color: parseColor(themeBg) } : inheritedBg;
2700
4551
  if (node.type === "silvery-box" && props.outlineStyle) {
2701
4552
  const boxInheritedBg = effectiveBg ? void 0 : inheritedBg.color;
2702
- const positions = collectOutlineCells(layout.x, y, layout.width, layout.height, props, clipBounds, buffer);
4553
+ const positions = collectOutlineCells(layout.x, y, layout.width, layout.height, props, clipBounds, sink.width, sink.height);
2703
4554
  for (const pos of positions) snapshots.push({
2704
4555
  x: pos.x,
2705
4556
  y: pos.y,
2706
- cell: buffer.getCell(pos.x, pos.y)
4557
+ cell: {
4558
+ ...buffer.getCell(pos.x, pos.y),
4559
+ selectable: buffer.isCellSelectable(pos.x, pos.y)
4560
+ }
2707
4561
  });
2708
- renderOutline(buffer, layout.x, y, layout.width, layout.height, props, clipBounds, boxInheritedBg);
4562
+ renderOutline(buffer, sink, layout.x, y, layout.width, layout.height, props, clipBounds, boxInheritedBg);
2709
4563
  }
2710
4564
  if (node.children.length === 0) return;
2711
4565
  const isScrollContainer = props.overflow === "scroll" && node.scrollState;
@@ -2719,16 +4573,16 @@ function walk(node, buffer, scrollOffset, clipBounds, inheritedBg, snapshots) {
2719
4573
  if (!child) continue;
2720
4574
  if (child.props.position === "sticky") continue;
2721
4575
  if (i < ss.firstVisibleChild || i > ss.lastVisibleChild) continue;
2722
- walk(child, buffer, ss.offset, childClip, childInheritedBg, snapshots);
4576
+ walk(child, buffer, sink, ss.offset, childClip, childInheritedBg, snapshots);
2723
4577
  }
2724
4578
  if (ss.stickyChildren) for (const sticky of ss.stickyChildren) {
2725
4579
  const child = node.children[sticky.index];
2726
4580
  if (!child) continue;
2727
- walk(child, buffer, sticky.naturalTop - sticky.renderOffset, childClip, childInheritedBg, snapshots);
4581
+ walk(child, buffer, sink, sticky.naturalTop - sticky.renderOffset, childClip, childInheritedBg, snapshots);
2728
4582
  }
2729
4583
  } else {
2730
4584
  const childClip = clipX || clipY ? computeChildClip(layout, props, clipBounds, scrollOffset, clipX, clipY) : clipBounds;
2731
- for (const child of node.children) walk(child, buffer, scrollOffset, childClip, childInheritedBg, snapshots);
4585
+ for (const child of node.children) walk(child, buffer, sink, scrollOffset, childClip, childInheritedBg, snapshots);
2732
4586
  }
2733
4587
  }
2734
4588
  /**
@@ -2741,19 +4595,19 @@ function walk(node, buffer, scrollOffset, clipBounds, inheritedBg, snapshots) {
2741
4595
  * Uses the SAME visibility checks as `renderOutline` so the snapshot set is
2742
4596
  * an exact match for the cell set the renderer will overwrite.
2743
4597
  */
2744
- function collectOutlineCells(x, y, width, height, props, clipBounds, buffer) {
4598
+ function collectOutlineCells(x, y, width, height, props, clipBounds, bufferWidth, bufferHeight) {
2745
4599
  const out = [];
2746
4600
  const ox = x - 1;
2747
4601
  const oy = y - 1;
2748
4602
  const ow = width + 2;
2749
4603
  const oh = height + 2;
2750
4604
  const isRowVisible = (row) => {
2751
- if (!clipBounds) return row >= 0 && row < buffer.height;
2752
- return row >= clipBounds.top && row < clipBounds.bottom && row < buffer.height;
4605
+ if (!clipBounds) return row >= 0 && row < bufferHeight;
4606
+ return row >= clipBounds.top && row < clipBounds.bottom && row < bufferHeight;
2753
4607
  };
2754
4608
  const isColVisible = (col) => {
2755
- if (clipBounds?.left === void 0 || clipBounds.right === void 0) return col >= 0 && col < buffer.width;
2756
- return col >= clipBounds.left && col < clipBounds.right && col < buffer.width;
4609
+ if (clipBounds?.left === void 0 || clipBounds.right === void 0) return col >= 0 && col < bufferWidth;
4610
+ return col >= clipBounds.left && col < clipBounds.right && col < bufferWidth;
2757
4611
  };
2758
4612
  const showTop = props.outlineTop !== false;
2759
4613
  const showBottom = props.outlineBottom !== false;
@@ -2764,11 +4618,11 @@ function collectOutlineCells(x, y, width, height, props, clipBounds, buffer) {
2764
4618
  x: ox,
2765
4619
  y: oy
2766
4620
  });
2767
- for (let col = ox + 1; col < ox + ow - 1 && col < buffer.width; col++) if (isColVisible(col)) out.push({
4621
+ for (let col = ox + 1; col < ox + ow - 1 && col < bufferWidth; col++) if (isColVisible(col)) out.push({
2768
4622
  x: col,
2769
4623
  y: oy
2770
4624
  });
2771
- if (showRight && ox + ow - 1 < buffer.width && isColVisible(ox + ow - 1)) out.push({
4625
+ if (showRight && ox + ow - 1 < bufferWidth && isColVisible(ox + ow - 1)) out.push({
2772
4626
  x: ox + ow - 1,
2773
4627
  y: oy
2774
4628
  });
@@ -2781,7 +4635,7 @@ function collectOutlineCells(x, y, width, height, props, clipBounds, buffer) {
2781
4635
  x: ox,
2782
4636
  y: row
2783
4637
  });
2784
- if (showRight && ox + ow - 1 < buffer.width && isColVisible(ox + ow - 1)) out.push({
4638
+ if (showRight && ox + ow - 1 < bufferWidth && isColVisible(ox + ow - 1)) out.push({
2785
4639
  x: ox + ow - 1,
2786
4640
  y: row
2787
4641
  });
@@ -2792,11 +4646,11 @@ function collectOutlineCells(x, y, width, height, props, clipBounds, buffer) {
2792
4646
  x: ox,
2793
4647
  y: bottomY
2794
4648
  });
2795
- for (let col = ox + 1; col < ox + ow - 1 && col < buffer.width; col++) if (isColVisible(col)) out.push({
4649
+ for (let col = ox + 1; col < ox + ow - 1 && col < bufferWidth; col++) if (isColVisible(col)) out.push({
2796
4650
  x: col,
2797
4651
  y: bottomY
2798
4652
  });
2799
- if (showRight && ox + ow - 1 < buffer.width && isColVisible(ox + ow - 1)) out.push({
4653
+ if (showRight && ox + ow - 1 < bufferWidth && isColVisible(ox + ow - 1)) out.push({
2800
4654
  x: ox + ow - 1,
2801
4655
  y: bottomY
2802
4656
  });
@@ -2843,6 +4697,15 @@ function computeChildClip(layout, props, parentClip, scrollOffset, horizontal, v
2843
4697
  return result;
2844
4698
  }
2845
4699
  //#endregion
4700
+ //#region packages/ag-term/src/pipeline/render-post-state.ts
4701
+ /**
4702
+ * Construct an empty `RenderPostState`. Called once per `createAg` lifetime
4703
+ * (and on `resetBuffer` to mirror prevBuffer reset semantics).
4704
+ */
4705
+ function createRenderPostState() {
4706
+ return { outlineSnapshots: [] };
4707
+ }
4708
+ //#endregion
2846
4709
  //#region packages/ag-term/src/pipeline/cascade-predicates.ts
2847
4710
  /**
2848
4711
  * Compute all cascade predicate values from boolean inputs.
@@ -3049,9 +4912,17 @@ const cellLog = createLogger("silvery:content:cell");
3049
4912
  *
3050
4913
  * @param root The root SilveryNode
3051
4914
  * @param prevBuffer Previous buffer for incremental rendering (optional)
4915
+ * @param ctx Pipeline context (instrumentation, measurer)
4916
+ * @param postState Cross-frame post-state carrier (outline snapshots, etc.).
4917
+ * Owned by `createAg`; passed in here rather than read from the buffer so
4918
+ * the decoration phase can run against either a BufferSink-mutated buffer
4919
+ * or a PlanSink-committed buffer (Phase 2 Step 5 of paint-clear-invariant L5).
4920
+ * When omitted (legacy callers), a fresh empty post-state is used per call —
4921
+ * correct for fresh renders, but means outline cleanup is lost across frames
4922
+ * if the caller doesn't hold the post-state externally.
3052
4923
  * @returns A TerminalBuffer with the rendered content
3053
4924
  */
3054
- function renderPhase(root, prevBuffer, ctx) {
4925
+ function renderPhase(root, prevBuffer, ctx, postState = createRenderPostState()) {
3055
4926
  const layout = root.boxRect;
3056
4927
  if (!layout) throw new Error("renderPhase called before layout phase");
3057
4928
  const instr = resolveInstrumentation(ctx);
@@ -3073,10 +4944,10 @@ function renderPhase(root, prevBuffer, ctx) {
3073
4944
  const t0 = instr.enabled ? performance.now() : 0;
3074
4945
  const buffer = hasPrevBuffer ? prevBuffer.clone() : new TerminalBuffer(layout.width, layout.height);
3075
4946
  const tClone = instr.enabled ? performance.now() - t0 : 0;
3076
- buffer.setSelectableMode(true);
3077
- clearPreviousOutlines(buffer);
4947
+ const frameSink = createFrameSink(buffer);
4948
+ clearPreviousOutlines(buffer, postState);
3078
4949
  const t1 = instr.enabled ? performance.now() : 0;
3079
- renderNodeToBuffer(root, buffer, {
4950
+ renderNodeToBuffer(root, buffer, frameSink, {
3080
4951
  scrollOffset: 0,
3081
4952
  clipBounds: void 0,
3082
4953
  hasPrevBuffer: !!hasPrevBuffer,
@@ -3087,10 +4958,11 @@ function renderPhase(root, prevBuffer, ctx) {
3087
4958
  color: null,
3088
4959
  ancestorRect: null
3089
4960
  },
3090
- inheritedFg: null
4961
+ inheritedFg: null,
4962
+ selectableMode: true
3091
4963
  }, ctx);
3092
4964
  const tRender = instr.enabled ? performance.now() - t1 : 0;
3093
- renderDecorationPass(buffer, root);
4965
+ renderDecorationPass(buffer, root, postState);
3094
4966
  if (instr.enabled) emitRenderPhaseStats(instr.stats, instr.nodeTrace, instr.nodeTraceEnabled, tClone, tRender);
3095
4967
  syncPrevLayout(root, isCurrentEpoch(root.layoutChangedThisFrame) || isDirty(root.dirtyBits, root.dirtyEpoch, 16) || !hasPrevBuffer);
3096
4968
  advanceRenderEpoch();
@@ -3156,6 +5028,7 @@ const _renderPhaseStats = {
3156
5028
  scrollClearReason: "",
3157
5029
  normalChildrenRepaint: 0,
3158
5030
  normalRepaintReason: "",
5031
+ siblingOverlapForced: 0,
3159
5032
  cascadeMinDepth: 999,
3160
5033
  cascadeNodes: "",
3161
5034
  _noopSkip: 0,
@@ -3234,8 +5107,14 @@ function emitRenderPhaseStats(stats, nodeTrace, nodeTraceEnabled, tClone, tRende
3234
5107
  }
3235
5108
  /**
3236
5109
  * Render a single node to the buffer.
5110
+ *
5111
+ * `sink` is the frame-shared RenderSink threaded from `renderPhase`. All
5112
+ * post-state writes (selectableMode), cleanup ops (executeRegionClearing),
5113
+ * paint ops (renderOwnContent inner sinks), and overlay ops
5114
+ * (applyBoxAttrOverlay) route through this single sink. See Smell #2 from
5115
+ * the 2026-04-27 dual-pro review for the rationale.
3237
5116
  */
3238
- function renderNodeToBuffer(node, buffer, nodeState, ctx) {
5117
+ function renderNodeToBuffer(node, buffer, sink, nodeState, ctx) {
3239
5118
  const { scrollOffset, clipBounds, hasPrevBuffer, ancestorCleared, bufferIsCloned, ancestorLayoutChanged = false } = nodeState;
3240
5119
  const instr = resolveInstrumentation(ctx);
3241
5120
  if (instr.enabled) instr.stats.nodesVisited++;
@@ -3250,20 +5129,12 @@ function renderNodeToBuffer(node, buffer, nodeState, ctx) {
3250
5129
  return;
3251
5130
  }
3252
5131
  const props = node.props;
3253
- const prevSelectableMode = buffer.getSelectableMode();
3254
- const userSelect = props.userSelect;
3255
- if (userSelect === "none") buffer.setSelectableMode(false);
3256
- else if (userSelect === "text" || userSelect === "contain") buffer.setSelectableMode(true);
3257
5132
  if (props.display === "none") {
3258
5133
  clearDirtyFlags(node);
3259
- buffer.setSelectableMode(prevSelectableMode);
3260
5134
  return;
3261
5135
  }
3262
5136
  const screenY = layout.y - scrollOffset;
3263
- if (screenY >= buffer.height || screenY + layout.height <= 0) {
3264
- buffer.setSelectableMode(prevSelectableMode);
3265
- return;
3266
- }
5137
+ if (screenY >= buffer.height || screenY + layout.height <= 0) return;
3267
5138
  const layoutChanged = isCurrentEpoch(node.layoutChangedThisFrame);
3268
5139
  const childPositionChanged = !!(hasPrevBuffer && !layoutChanged && hasChildPositionChanged(node));
3269
5140
  const scrollOffsetChanged = !!(node.scrollState && node.scrollState.offset !== node.scrollState.prevOffset);
@@ -3298,7 +5169,6 @@ function renderNodeToBuffer(node, buffer, nodeState, ctx) {
3298
5169
  });
3299
5170
  }
3300
5171
  clearDirtyFlags(node);
3301
- buffer.setSelectableMode(prevSelectableMode);
3302
5172
  return;
3303
5173
  }
3304
5174
  if (instr.enabled) {
@@ -3312,6 +5182,15 @@ function renderNodeToBuffer(node, buffer, nodeState, ctx) {
3312
5182
  if (childPositionChanged) instr.stats.flagChildPositionChanged++;
3313
5183
  if (ancestorLayoutChanged) instr.stats.flagAncestorLayoutChanged++;
3314
5184
  }
5185
+ const prevSelectableMode = nodeState.selectableMode;
5186
+ const userSelect = props.userSelect;
5187
+ let currentSelectableMode = prevSelectableMode;
5188
+ if (userSelect === "none") currentSelectableMode = false;
5189
+ else if (userSelect === "text" || userSelect === "contain") currentSelectableMode = true;
5190
+ const currentNodeState = currentSelectableMode === prevSelectableMode ? nodeState : {
5191
+ ...nodeState,
5192
+ selectableMode: currentSelectableMode
5193
+ };
3315
5194
  const nodeTheme = props.theme;
3316
5195
  if (nodeTheme) pushContextTheme(nodeTheme);
3317
5196
  try {
@@ -3357,13 +5236,31 @@ function renderNodeToBuffer(node, buffer, nodeState, ctx) {
3357
5236
  childrenNeedFreshRender
3358
5237
  };
3359
5238
  }
5239
+ if (node.type === "silvery-box" && isDirty(node.dirtyBits, node.dirtyEpoch, 2) && (mayHaveBoxAttrOverlay(props) || node.hadBoxAttrOverlay)) {
5240
+ const contentRegionCleared = (hasPrevBuffer || ancestorCleared) && !getEffectiveBg(props);
5241
+ const childrenNeedFreshRender = hasPrevBuffer || ancestorCleared;
5242
+ cascade = {
5243
+ ...cascade,
5244
+ contentAreaAffected: true,
5245
+ bgOnlyChange: false,
5246
+ contentRegionCleared,
5247
+ childrenNeedFreshRender
5248
+ };
5249
+ }
3360
5250
  const { contentRegionCleared, skipBgFill, childrenNeedFreshRender } = cascade;
3361
5251
  if (instr.enabled || _cellDbg && (_coversCellNow || _coversCellPrev)) traceRenderDecision(node, props, layout, screenY, scrollOffset, hasPrevBuffer, ancestorCleared, layoutChanged, childPositionChanged, cascade, _nodeId, _traceThis, _cellDbg, _coversCellNow, _coversCellPrev, instr.enabled, instr.stats, instr.nodeTrace);
3362
5252
  const useTextStyleFastPath = false;
3363
- executeRegionClearing(node, buffer, layout, scrollOffset, clipBounds, bufferIsCloned, layoutChanged, contentRegionCleared, descendantOverflowChanged, instr.enabled, instr.stats, nodeState.inheritedBg);
3364
- const needsOwnRepaint = !hasPrevBuffer || ancestorCleared || ancestorLayoutChanged || cascade.contentAreaAffected || isDirty(node.dirtyBits, node.dirtyEpoch, 2) || cascade.bgRefillNeeded;
3365
- node.type === "silvery-box" && !getEffectiveBg(props) && nodeState.inheritedBg.color;
3366
- if (needsOwnRepaint) renderOwnContent(node, buffer, layout, props, nodeState, skipBgFill, instr.enabled, instr.stats, ctx, cascade.bgOnlyChange, useTextStyleFastPath);
5253
+ executeRegionClearing(node, buffer, sink, layout, scrollOffset, clipBounds, bufferIsCloned, layoutChanged, contentRegionCleared, descendantOverflowChanged, instr.enabled, instr.stats, nodeState.inheritedBg, hasPrevBuffer);
5254
+ if (!hasPrevBuffer || ancestorCleared || ancestorLayoutChanged || cascade.contentAreaAffected || isDirty(node.dirtyBits, node.dirtyEpoch, 2) || cascade.bgRefillNeeded) {
5255
+ pushRenderingNode(node);
5256
+ resetBorderedRectClipCache();
5257
+ try {
5258
+ renderOwnContent(node, buffer, sink, layout, props, currentNodeState, skipBgFill, instr.enabled, instr.stats, ctx, cascade.bgOnlyChange, useTextStyleFastPath);
5259
+ } finally {
5260
+ popRenderingNode();
5261
+ resetBorderedRectClipCache();
5262
+ }
5263
+ }
3367
5264
  const effectiveBg = getEffectiveBg(props);
3368
5265
  const childInheritedBg = effectiveBg ? {
3369
5266
  color: parseColor(effectiveBg),
@@ -3374,18 +5271,19 @@ function renderNodeToBuffer(node, buffer, nodeState, ctx) {
3374
5271
  } : nodeState.inheritedBg;
3375
5272
  const childInheritedFg = props.color === "inherit" || props.color === "currentColor" ? nodeState.inheritedFg : props.color ? parseColor(props.color) : nodeTheme ? parseColor(nodeTheme.fg) : nodeState.inheritedFg;
3376
5273
  const childState = {
3377
- ...nodeState,
5274
+ ...currentNodeState,
3378
5275
  inheritedBg: childInheritedBg,
3379
- inheritedFg: childInheritedFg
5276
+ inheritedFg: childInheritedFg,
5277
+ selectableMode: currentSelectableMode
3380
5278
  };
3381
5279
  if (isScrollContainer) {
3382
- renderScrollContainerChildren(node, buffer, props, childState, contentRegionCleared, childrenNeedFreshRender, ctx);
5280
+ renderScrollContainerChildren(node, buffer, sink, props, childState, contentRegionCleared, childrenNeedFreshRender, ctx);
3383
5281
  renderScrollIndicators(node, buffer, layout, props, node.scrollState, ctx);
3384
- } else renderNormalChildren(node, buffer, props, childState, childPositionChanged, contentRegionCleared, childrenNeedFreshRender, ctx);
5282
+ } else renderNormalChildren(node, buffer, sink, props, childState, childPositionChanged, contentRegionCleared, childrenNeedFreshRender, ctx);
5283
+ if (node.type === "silvery-box") node.hadBoxAttrOverlay = applyBoxAttrOverlay(buffer, sink, layout, props, scrollOffset, clipBounds);
3385
5284
  clearNodeDirtyFlags(node);
3386
5285
  } finally {
3387
5286
  if (nodeTheme) popContextTheme();
3388
- buffer.setSelectableMode(prevSelectableMode);
3389
5287
  }
3390
5288
  }
3391
5289
  /**
@@ -3486,12 +5384,14 @@ function traceRenderDecision(node, props, layout, screenY, scrollOffset, hasPrev
3486
5384
  *
3487
5385
  * All clearing runs BEFORE renderBox/renderText so borders drawn later are not overwritten.
3488
5386
  */
3489
- function executeRegionClearing(node, buffer, layout, scrollOffset, clipBounds, bufferIsCloned, layoutChanged, contentRegionCleared, descendantOverflowChanged, instrumentEnabled, stats, threadedInheritedBg) {
5387
+ function executeRegionClearing(node, buffer, sink, layout, scrollOffset, clipBounds, bufferIsCloned, layoutChanged, contentRegionCleared, descendantOverflowChanged, instrumentEnabled, stats, threadedInheritedBg, hasPrevBuffer) {
3490
5388
  if (contentRegionCleared) {
3491
5389
  if (instrumentEnabled) stats.clearOps++;
3492
- clearNodeRegion(node, buffer, layout, scrollOffset, clipBounds, layoutChanged, threadedInheritedBg);
3493
- } else if (bufferIsCloned && layoutChanged && node.prevLayout) clearExcessArea(node, buffer, layout, scrollOffset, clipBounds, layoutChanged, threadedInheritedBg);
3494
- if (descendantOverflowChanged) clearDescendantOverflowRegions(node, buffer, layout, scrollOffset, clipBounds, threadedInheritedBg);
5390
+ clearNodeRegion(node, sink, layout, scrollOffset, clipBounds, threadedInheritedBg);
5391
+ }
5392
+ const excessGate = requireExcessClearGate(bufferIsCloned, layoutChanged, node.prevLayout, hasPrevBuffer);
5393
+ if (excessGate) clearExcessArea(excessGate, node, buffer, sink, layout, scrollOffset, clipBounds, threadedInheritedBg);
5394
+ if (descendantOverflowChanged) clearDescendantOverflowRegions(node, buffer, sink, layout, scrollOffset, clipBounds, threadedInheritedBg);
3495
5395
  }
3496
5396
  /**
3497
5397
  * Render this node's own content (box background/border or text).
@@ -3501,12 +5401,14 @@ function executeRegionClearing(node, buffer, layout, scrollOffset, clipBounds, b
3501
5401
  *
3502
5402
  * @returns The boxInheritedBg color (needed by outline rendering after children).
3503
5403
  */
3504
- function renderOwnContent(node, buffer, layout, props, nodeState, skipBgFill, instrumentEnabled, stats, ctx, bgOnlyChange = false, useTextStyleFastPath = false) {
5404
+ function renderOwnContent(node, buffer, sink, layout, props, nodeState, skipBgFill, instrumentEnabled, stats, ctx, bgOnlyChange = false, useTextStyleFastPath = false) {
3505
5405
  const boxInheritedBg = node.type === "silvery-box" && !getEffectiveBg(props) ? nodeState.inheritedBg.color : void 0;
3506
5406
  if (node.type === "silvery-box") {
3507
5407
  if (instrumentEnabled) stats.boxNodes++;
3508
5408
  renderBox(node, buffer, layout, props, nodeState, skipBgFill, boxInheritedBg, bgOnlyChange, nodeState.inheritedFg);
3509
- } else if (node.type === "silvery-text") {
5409
+ } else if (node.type === "silvery-viewport") renderViewport(node, buffer, sink, layout, nodeState.scrollOffset);
5410
+ else if (node.type === "silvery-island") renderIsland(node, buffer, sink, layout, nodeState.scrollOffset, nodeState.inheritedBg.color);
5411
+ else if (node.type === "silvery-text") {
3510
5412
  if (instrumentEnabled) stats.textNodes++;
3511
5413
  const textInheritedBg = nodeState.inheritedBg.color;
3512
5414
  const textInheritedFg = nodeState.inheritedFg;
@@ -3516,7 +5418,7 @@ function renderOwnContent(node, buffer, layout, props, nodeState, skipBgFill, in
3516
5418
  const effectiveBg = style.bg !== null ? style.bg : textInheritedBg ?? null;
3517
5419
  const { x, width, height } = layout;
3518
5420
  const y = layout.y - nodeState.scrollOffset;
3519
- buffer.restyleRegion(x, y, width, height, {
5421
+ sink.emitRestyleRegion(x, y, width, height, {
3520
5422
  fg: style.fg,
3521
5423
  bg: effectiveBg,
3522
5424
  underlineColor: style.underlineColor ?? null,
@@ -3527,6 +5429,125 @@ function renderOwnContent(node, buffer, layout, props, nodeState, skipBgFill, in
3527
5429
  return boxInheritedBg;
3528
5430
  }
3529
5431
  /**
5432
+ * Cheap check: is ANY attr-overlay prop present on this Box right now?
5433
+ *
5434
+ * Used by the cascade to decide whether `stylePropsDirty` on a silvery-box
5435
+ * should escalate to `contentAreaAffected`. We can't reliably inspect the
5436
+ * PREVIOUS frame's props from here, so we conservatively escalate whenever
5437
+ * EITHER the current frame OR a neighbor frame might have had overlay.
5438
+ * The single-call common case (Box with no attr overlay ever) stays on the
5439
+ * fast path because this returns false both frames and the stylePropsDirty
5440
+ * gate doesn't fire (stylePropsDirty requires a prop-change reconciler event,
5441
+ * which won't happen for a Box that never had these props).
5442
+ */
5443
+ function mayHaveBoxAttrOverlay(props) {
5444
+ return !!(props.underline || props.underlineStyle || props.underlineColor || props.overline || props.bold || props.italic || props.inverse || props.strikethrough);
5445
+ }
5446
+ /**
5447
+ * Detect which attr props are active on a Box. Only returns the overlay
5448
+ * subset — attrs that benefit from merge-over-children semantics.
5449
+ *
5450
+ * bold / dim / italic / inverse / strikethrough / underline / underlineColor
5451
+ * all qualify. Text color, bg color, and border-related props are handled
5452
+ * elsewhere and are NOT attr-overlay props.
5453
+ *
5454
+ * Returns null when no overlay attrs are present (fast path — most Boxes).
5455
+ */
5456
+ function computeBoxAttrOverlay(props) {
5457
+ let underlineStyle;
5458
+ if (props.underlineStyle !== void 0 && props.underlineStyle !== false) underlineStyle = props.underlineStyle;
5459
+ else if (typeof props.underline === "string") underlineStyle = props.underline;
5460
+ else if (props.underline === true) underlineStyle = "single";
5461
+ const hasUnderline = !!underlineStyle;
5462
+ const bold = !!props.bold;
5463
+ const italic = !!props.italic;
5464
+ const inverse = !!props.inverse;
5465
+ const strikethrough = !!props.strikethrough;
5466
+ const overline = !!props.overline;
5467
+ if (!hasUnderline && !bold && !italic && !inverse && !strikethrough && !overline) return null;
5468
+ return {
5469
+ attrs: {
5470
+ bold: bold || void 0,
5471
+ italic: italic || void 0,
5472
+ inverse: inverse || void 0,
5473
+ strikethrough: strikethrough || void 0,
5474
+ overline: overline || void 0,
5475
+ underline: hasUnderline || void 0,
5476
+ underlineStyle: hasUnderline ? underlineStyle : void 0
5477
+ },
5478
+ underlineColor: (props.underlineColor ? parseColor(props.underlineColor) : void 0) ?? void 0
5479
+ };
5480
+ }
5481
+ /**
5482
+ * Apply a Box's attr overlay to its rect, respecting scroll offset and clip
5483
+ * bounds. Runs AFTER children render so attrs layer on top of content without
5484
+ * overwriting glyphs/fg/bg.
5485
+ *
5486
+ * @returns true when an overlay was actually applied — used by the caller to
5487
+ * set `node.hadBoxAttrOverlay`, which next frame's cascade reads to detect
5488
+ * overlay removal.
5489
+ */
5490
+ function applyBoxAttrOverlay(buffer, sink, layout, props, scrollOffset, clipBounds) {
5491
+ const overlay = computeBoxAttrOverlay(props);
5492
+ if (!overlay) return false;
5493
+ const x = layout.x;
5494
+ const y = layout.y - scrollOffset;
5495
+ let rx = x;
5496
+ let rw = layout.width;
5497
+ let ry = y;
5498
+ let rh = layout.height;
5499
+ if (clipBounds) {
5500
+ const topClipped = Math.max(ry, clipBounds.top);
5501
+ const bottomClipped = Math.min(ry + rh, clipBounds.bottom);
5502
+ if (topClipped >= bottomClipped) return false;
5503
+ ry = topClipped;
5504
+ rh = bottomClipped - topClipped;
5505
+ if (clipBounds.left !== void 0 && clipBounds.right !== void 0) {
5506
+ const leftClipped = Math.max(rx, clipBounds.left);
5507
+ const rightClipped = Math.min(rx + rw, clipBounds.right);
5508
+ if (leftClipped >= rightClipped) return false;
5509
+ rx = leftClipped;
5510
+ rw = rightClipped - leftClipped;
5511
+ }
5512
+ }
5513
+ sink.emitMergeAttrs(rx, ry, rw, rh, overlay.attrs, overlay.underlineColor);
5514
+ return true;
5515
+ }
5516
+ /**
5517
+ * Check whether any transparent sibling of this scroll container is an
5518
+ * absolute-positioned Box whose rect overlaps with the scroll container's rect.
5519
+ *
5520
+ * This matters for Tier 1 (buffer shift) safety: when a transparent sibling
5521
+ * absolute child paints into our rect (e.g., an overlay glyph at col=width-1),
5522
+ * the shift would smear those pixels along with the content. Opaque absolute
5523
+ * siblings are safe because the normal third pass always re-renders absolute
5524
+ * children after the scroll container and repaints their whole rect.
5525
+ *
5526
+ * O(siblings) — typically 1-3 siblings under common parents (ListView outer
5527
+ * Box has scroll container + scrollbar + edge-bump indicator). Returns false
5528
+ * for the common case (no parent or no absolute siblings).
5529
+ */
5530
+ function checkOverlappingAbsoluteSibling(node, scrollRect) {
5531
+ const parent = node.parent;
5532
+ if (!parent) return false;
5533
+ const siblings = parent.children;
5534
+ if (!siblings) return false;
5535
+ const sLeft = scrollRect.x;
5536
+ const sTop = scrollRect.y;
5537
+ const sRight = scrollRect.x + scrollRect.width;
5538
+ const sBottom = scrollRect.y + scrollRect.height;
5539
+ for (const sib of siblings) {
5540
+ if (sib === node) continue;
5541
+ const sp = sib.props;
5542
+ if (sp?.position !== "absolute") continue;
5543
+ if (getEffectiveBg(sp)) continue;
5544
+ const r = sib.boxRect;
5545
+ if (!r) continue;
5546
+ if (r.x < sRight && r.x + r.width > sLeft && r.y < sBottom && r.y + r.height > sTop) return true;
5547
+ }
5548
+ return false;
5549
+ }
5550
+ /**
3530
5551
  * Determine the scroll tier strategy for this frame.
3531
5552
  *
3532
5553
  * Pure function -- no side effects, no node access beyond the inputs.
@@ -3534,15 +5555,15 @@ function renderOwnContent(node, buffer, layout, props, nodeState, skipBgFill, in
3534
5555
  * Three-tier strategy:
3535
5556
  * 1. **shift**: Only scroll offset changed, no sticky children. Buffer contents
3536
5557
  * shifted by scroll delta; only newly visible edges re-render.
3537
- * 2. **clear**: Children restructured, visible range changed with scroll, or
3538
- * parent region changed. Entire viewport cleared and all children re-render.
5558
+ * 2. **clear**: Children restructured, sticky/overlaid scroll, or parent region
5559
+ * changed. Entire viewport cleared and all children re-render.
3539
5560
  * 3. **subtree-only**: Only some descendants changed. Children use hasPrevBuffer=true
3540
5561
  * and skip via fast-path if clean. With sticky children, forces all first-pass
3541
5562
  * items to re-render (stickyForceRefresh).
3542
5563
  */
3543
5564
  function planScrollRender(inputs) {
3544
- const { scrollOffsetChanged, visibleRangeChanged, hasStickyChildren, childrenNeedFreshRender, childrenDirty, hasPrevBuffer, ancestorCleared, contentRegionCleared, scrollBg } = inputs;
3545
- const scrollOnly = hasPrevBuffer && scrollOffsetChanged && !childrenDirty && !childrenNeedFreshRender && !hasStickyChildren && !visibleRangeChanged;
5565
+ const { scrollOffsetChanged, visibleRangeChanged, hasStickyChildren, childrenNeedFreshRender, childrenDirty, hasPrevBuffer, ancestorCleared, contentRegionCleared, scrollBg, hasOverlappingAbsoluteSibling = false } = inputs;
5566
+ const scrollOnly = hasPrevBuffer && scrollOffsetChanged && !childrenDirty && !childrenNeedFreshRender && !hasStickyChildren && !hasOverlappingAbsoluteSibling;
3546
5567
  const needsViewportClear = hasPrevBuffer && !scrollOnly && (scrollOffsetChanged || childrenDirty || childrenNeedFreshRender || visibleRangeChanged);
3547
5568
  const stickyForceRefresh = hasStickyChildren && hasPrevBuffer && !needsViewportClear;
3548
5569
  const reasons = [];
@@ -3552,6 +5573,7 @@ function planScrollRender(inputs) {
3552
5573
  if (childrenDirty) reasons.push("childrenDirty");
3553
5574
  if (childrenNeedFreshRender) reasons.push("childrenNeedFreshRender");
3554
5575
  if (visibleRangeChanged) reasons.push("visibleRangeChanged");
5576
+ if (hasOverlappingAbsoluteSibling) reasons.push("overlappingAbsoluteSibling");
3555
5577
  }
3556
5578
  if (stickyForceRefresh) reasons.push("stickyForceRefresh");
3557
5579
  return {
@@ -3566,8 +5588,8 @@ function planScrollRender(inputs) {
3566
5588
  /**
3567
5589
  * Render children of a scroll container with proper clipping and offset.
3568
5590
  */
3569
- function renderScrollContainerChildren(node, buffer, props, nodeState, contentRegionCleared = false, childrenNeedFreshRender = false, ctx) {
3570
- const { clipBounds, hasPrevBuffer, ancestorCleared, bufferIsCloned, ancestorLayoutChanged, inheritedBg, inheritedFg } = nodeState;
5591
+ function renderScrollContainerChildren(node, buffer, sink, props, nodeState, contentRegionCleared = false, childrenNeedFreshRender = false, ctx) {
5592
+ const { clipBounds, hasPrevBuffer, ancestorCleared, bufferIsCloned, ancestorLayoutChanged, inheritedBg, inheritedFg, selectableMode } = nodeState;
3571
5593
  const instr = resolveInstrumentation(ctx);
3572
5594
  const layout = node.boxRect;
3573
5595
  const ss = node.scrollState;
@@ -3593,6 +5615,7 @@ function renderScrollContainerChildren(node, buffer, props, nodeState, contentRe
3593
5615
  const contentX = layout.x + border.left + padding.left;
3594
5616
  const contentWidth = layout.width - border.left - border.right - padding.left - padding.right;
3595
5617
  const scrollBg = scrollOffsetChanged || isDirty(node.dirtyBits, node.dirtyEpoch, 8) || childrenNeedFreshRender || visibleRangeChanged ? getEffectiveBg(props) ? parseColor(getEffectiveBg(props)) : inheritedBg.color : null;
5618
+ const hasOverlappingAbsoluteSibling = checkOverlappingAbsoluteSibling(node, layout);
3596
5619
  const plan = planScrollRender({
3597
5620
  scrollOffsetChanged,
3598
5621
  visibleRangeChanged,
@@ -3602,7 +5625,8 @@ function renderScrollContainerChildren(node, buffer, props, nodeState, contentRe
3602
5625
  hasPrevBuffer,
3603
5626
  ancestorCleared,
3604
5627
  contentRegionCleared,
3605
- scrollBg
5628
+ scrollBg,
5629
+ hasOverlappingAbsoluteSibling
3606
5630
  });
3607
5631
  const { tier, stickyForceRefresh } = plan;
3608
5632
  const defaultChildHasPrev = plan.childHasPrev;
@@ -3623,28 +5647,16 @@ function renderScrollContainerChildren(node, buffer, props, nodeState, contentRe
3623
5647
  if (props.overflowIndicator === true && !border.top && !border.bottom) {
3624
5648
  const topIndicatorY = clearY;
3625
5649
  const bottomIndicatorY = clearY + clearHeight - 1;
3626
- if (ss.prevOffset != null && ss.prevOffset > 0) buffer.fill(contentX, topIndicatorY, contentWidth, 1, {
3627
- char: " ",
3628
- bg: plan.clearBg
3629
- });
3630
- buffer.fill(contentX, bottomIndicatorY, contentWidth, 1, {
3631
- char: " ",
3632
- bg: plan.clearBg
3633
- });
5650
+ if (ss.prevOffset != null && ss.prevOffset > 0) sink.emitClearRect(contentX, topIndicatorY, contentWidth, 1, plan.clearBg);
5651
+ sink.emitClearRect(contentX, bottomIndicatorY, contentWidth, 1, plan.clearBg);
3634
5652
  }
3635
- buffer.scrollRegion(contentX, clearY, contentWidth, clearHeight, scrollDelta, {
5653
+ sink.emitScrollRegion(contentX, clearY, contentWidth, clearHeight, scrollDelta, {
3636
5654
  char: " ",
3637
5655
  bg: plan.clearBg
3638
5656
  });
3639
5657
  }
3640
- if (tier === "clear" && clearHeight > 0) buffer.fill(contentX, clearY, contentWidth, clearHeight, {
3641
- char: " ",
3642
- bg: plan.clearBg
3643
- });
3644
- if (stickyForceRefresh && clearHeight > 0) buffer.fill(contentX, clearY, contentWidth, clearHeight, {
3645
- char: " ",
3646
- bg: null
3647
- });
5658
+ if (tier === "clear" && clearHeight > 0) sink.emitClearRect(contentX, clearY, contentWidth, clearHeight, plan.clearBg);
5659
+ if (stickyForceRefresh && clearHeight > 0) sink.emitClearRect(contentX, clearY, contentWidth, clearHeight, null);
3648
5660
  const childAncestorLayoutChanged = isCurrentEpoch(node.layoutChangedThisFrame) || !!ancestorLayoutChanged;
3649
5661
  const prevVisTop = ss.prevOffset ?? ss.offset;
3650
5662
  const prevVisBottom = prevVisTop + ss.viewportHeight;
@@ -3663,6 +5675,7 @@ function renderScrollContainerChildren(node, buffer, props, nodeState, contentRe
3663
5675
  const wasFullyVisible = childTop >= prevVisTop && childBottom <= prevVisBottom;
3664
5676
  thisChildHasPrev = wasFullyVisible;
3665
5677
  thisChildAncestorCleared = wasFullyVisible ? ancestorCleared || contentRegionCleared : true;
5678
+ if (!wasFullyVisible) clearShiftedNewChildRegion(childRect, ss.offset, childClipBounds, sink, plan.clearBg);
3666
5679
  }
3667
5680
  }
3668
5681
  if (stickyForceRefresh && thisChildHasPrev) {
@@ -3670,7 +5683,7 @@ function renderScrollContainerChildren(node, buffer, props, nodeState, contentRe
3670
5683
  thisChildAncestorCleared = false;
3671
5684
  }
3672
5685
  if (canSkipChildSubtree(child, thisChildHasPrev, childAncestorLayoutChanged)) continue;
3673
- renderNodeToBuffer(child, buffer, {
5686
+ renderNodeToBuffer(child, buffer, sink, {
3674
5687
  scrollOffset: ss.offset,
3675
5688
  clipBounds: childClipBounds,
3676
5689
  hasPrevBuffer: thisChildHasPrev,
@@ -3678,13 +5691,14 @@ function renderScrollContainerChildren(node, buffer, props, nodeState, contentRe
3678
5691
  bufferIsCloned,
3679
5692
  ancestorLayoutChanged: childAncestorLayoutChanged,
3680
5693
  inheritedBg,
3681
- inheritedFg
5694
+ inheritedFg,
5695
+ selectableMode
3682
5696
  }, ctx);
3683
5697
  }
3684
5698
  if (ss.stickyChildren) for (const sticky of ss.stickyChildren) {
3685
5699
  const child = node.children[sticky.index];
3686
5700
  if (!child?.boxRect) continue;
3687
- renderNodeToBuffer(child, buffer, {
5701
+ renderNodeToBuffer(child, buffer, sink, {
3688
5702
  scrollOffset: sticky.naturalTop - sticky.renderOffset,
3689
5703
  clipBounds: childClipBounds,
3690
5704
  hasPrevBuffer: false,
@@ -3692,15 +5706,42 @@ function renderScrollContainerChildren(node, buffer, props, nodeState, contentRe
3692
5706
  bufferIsCloned,
3693
5707
  ancestorLayoutChanged: childAncestorLayoutChanged,
3694
5708
  inheritedBg,
3695
- inheritedFg
5709
+ inheritedFg,
5710
+ selectableMode
3696
5711
  }, ctx);
3697
5712
  }
3698
5713
  }
3699
5714
  /**
5715
+ * Tier-1 scroll shifting reuses previous-frame pixels. A child that was not fully
5716
+ * visible in the previous viewport is rendered like a fresh subtree, but the
5717
+ * shifted buffer under its transparent cells may still contain unrelated pixels
5718
+ * from a previous row. Clear the child's currently visible rectangle first so
5719
+ * transparent boxes/text render onto the same blank base as a fresh render.
5720
+ */
5721
+ function clearShiftedNewChildRegion(childRect, scrollOffset, clipBounds, sink, bg) {
5722
+ let x = childRect.x;
5723
+ let y = childRect.y - scrollOffset;
5724
+ let width = childRect.width;
5725
+ let height = childRect.height;
5726
+ if (clipBounds) {
5727
+ if (clipBounds.left !== void 0 && x < clipBounds.left) {
5728
+ width -= clipBounds.left - x;
5729
+ x = clipBounds.left;
5730
+ }
5731
+ if (clipBounds.right !== void 0 && x + width > clipBounds.right) width = clipBounds.right - x;
5732
+ if (y < clipBounds.top) {
5733
+ height -= clipBounds.top - y;
5734
+ y = clipBounds.top;
5735
+ }
5736
+ if (y + height > clipBounds.bottom) height = clipBounds.bottom - y;
5737
+ }
5738
+ if (width > 0 && height > 0) sink.emitClearRect(x, y, width, height, bg);
5739
+ }
5740
+ /**
3700
5741
  * Render children of a normal (non-scroll) container.
3701
5742
  */
3702
- function renderNormalChildren(node, buffer, props, nodeState, childPositionChanged = false, contentRegionCleared = false, childrenNeedFreshRender = false, ctx) {
3703
- const { scrollOffset, clipBounds, hasPrevBuffer, ancestorCleared, bufferIsCloned, ancestorLayoutChanged, inheritedBg, inheritedFg } = nodeState;
5743
+ function renderNormalChildren(node, buffer, sink, props, nodeState, childPositionChanged = false, contentRegionCleared = false, childrenNeedFreshRender = false, ctx) {
5744
+ const { scrollOffset, clipBounds, hasPrevBuffer, ancestorCleared, bufferIsCloned, ancestorLayoutChanged, inheritedBg, inheritedFg, selectableMode } = nodeState;
3704
5745
  const instr = resolveInstrumentation(ctx);
3705
5746
  const layout = node.boxRect;
3706
5747
  if (!layout) return;
@@ -3735,10 +5776,7 @@ function renderNormalChildren(node, buffer, props, nodeState, childPositionChang
3735
5776
  }
3736
5777
  if (clipBounds.right !== void 0 && clearX + clearW > clipBounds.right) clearW = clipBounds.right - clearX;
3737
5778
  }
3738
- if (clearW > 0 && clearH > 0) buffer.fill(clearX, clearY, clearW, clearH, {
3739
- char: " ",
3740
- bg: null
3741
- });
5779
+ if (clearW > 0 && clearH > 0) sink.emitClearRect(clearX, clearY, clearW, clearH, null);
3742
5780
  }
3743
5781
  const childrenNeedRepaint = isDirty(node.dirtyBits, node.dirtyEpoch, 8) || childPositionChanged || childrenNeedFreshRender;
3744
5782
  if (instr.enabled && childrenNeedRepaint && hasPrevBuffer) {
@@ -3757,6 +5795,7 @@ function renderNormalChildren(node, buffer, props, nodeState, childPositionChang
3757
5795
  childAncestorCleared = false;
3758
5796
  }
3759
5797
  let hasAbsoluteChildren = false;
5798
+ const firstPassChildren = [];
3760
5799
  for (const child of node.children) {
3761
5800
  const childProps = child.props;
3762
5801
  if (childProps.position === "absolute") {
@@ -3764,22 +5803,46 @@ function renderNormalChildren(node, buffer, props, nodeState, childPositionChang
3764
5803
  continue;
3765
5804
  }
3766
5805
  if (hasStickyChildren && childProps.position === "sticky") continue;
3767
- if (canSkipChildSubtree(child, childHasPrev, childAncestorLayoutChanged)) continue;
3768
- renderNodeToBuffer(child, buffer, {
5806
+ firstPassChildren.push(child);
5807
+ }
5808
+ const willRender = firstPassChildren.map((child) => !canSkipChildSubtree(child, childHasPrev, childAncestorLayoutChanged));
5809
+ if (childHasPrev) for (let i = 0; i < firstPassChildren.length; i++) {
5810
+ if (!willRender[i]) continue;
5811
+ const childI = firstPassChildren[i];
5812
+ const ei = computeSubtreePaintExtent(childI);
5813
+ if (!ei || ei.width <= 0 || ei.height <= 0) continue;
5814
+ for (let j = i + 1; j < firstPassChildren.length; j++) {
5815
+ if (willRender[j]) continue;
5816
+ const rj = firstPassChildren[j].boxRect;
5817
+ if (!rj || rj.width <= 0 || rj.height <= 0) continue;
5818
+ const overlapX = ei.x < rj.x + rj.width && rj.x < ei.x + ei.width;
5819
+ const overlapY = ei.y < rj.y + rj.height && rj.y < ei.y + ei.height;
5820
+ if (overlapX && overlapY) {
5821
+ willRender[j] = true;
5822
+ if (instr.enabled) instr.stats.siblingOverlapForced++;
5823
+ }
5824
+ }
5825
+ }
5826
+ for (let i = 0; i < firstPassChildren.length; i++) {
5827
+ if (!willRender[i]) continue;
5828
+ const child = firstPassChildren[i];
5829
+ const overlapForced = !!canSkipChildSubtree(child, childHasPrev, childAncestorLayoutChanged);
5830
+ renderNodeToBuffer(child, buffer, sink, {
3769
5831
  scrollOffset,
3770
5832
  clipBounds: effectiveClipBounds,
3771
- hasPrevBuffer: childHasPrev,
3772
- ancestorCleared: childAncestorCleared,
5833
+ hasPrevBuffer: overlapForced ? false : childHasPrev,
5834
+ ancestorCleared: overlapForced ? false : childAncestorCleared,
3773
5835
  bufferIsCloned,
3774
5836
  ancestorLayoutChanged: childAncestorLayoutChanged,
3775
5837
  inheritedBg,
3776
- inheritedFg
5838
+ inheritedFg,
5839
+ selectableMode
3777
5840
  }, ctx);
3778
5841
  }
3779
5842
  if (node.stickyChildren) for (const sticky of node.stickyChildren) {
3780
5843
  const child = node.children[sticky.index];
3781
5844
  if (!child?.boxRect) continue;
3782
- renderNodeToBuffer(child, buffer, {
5845
+ renderNodeToBuffer(child, buffer, sink, {
3783
5846
  scrollOffset: sticky.naturalTop - sticky.renderOffset,
3784
5847
  clipBounds: effectiveClipBounds,
3785
5848
  hasPrevBuffer: false,
@@ -3787,12 +5850,13 @@ function renderNormalChildren(node, buffer, props, nodeState, childPositionChang
3787
5850
  bufferIsCloned,
3788
5851
  ancestorLayoutChanged: childAncestorLayoutChanged,
3789
5852
  inheritedBg,
3790
- inheritedFg
5853
+ inheritedFg,
5854
+ selectableMode
3791
5855
  }, ctx);
3792
5856
  }
3793
5857
  if (hasAbsoluteChildren) for (const child of node.children) {
3794
5858
  if (child.props.position !== "absolute") continue;
3795
- renderNodeToBuffer(child, buffer, {
5859
+ renderNodeToBuffer(child, buffer, sink, {
3796
5860
  scrollOffset,
3797
5861
  clipBounds: effectiveClipBounds,
3798
5862
  hasPrevBuffer: false,
@@ -3800,11 +5864,51 @@ function renderNormalChildren(node, buffer, props, nodeState, childPositionChang
3800
5864
  bufferIsCloned,
3801
5865
  ancestorLayoutChanged: childAncestorLayoutChanged,
3802
5866
  inheritedBg,
3803
- inheritedFg
5867
+ inheritedFg,
5868
+ selectableMode
3804
5869
  }, ctx);
3805
5870
  }
3806
5871
  }
3807
5872
  /**
5873
+ * Compute the screen-space rectangle that bounds every paint within this
5874
+ * subtree. Used by sibling-overlap detection in renderNormalChildren so
5875
+ * that a descendant overflowing its ancestor still triggers force-repaint
5876
+ * of any later sibling at the painted row.
5877
+ *
5878
+ * Returns the union of `node.boxRect` with the recursive paint extent of
5879
+ * each child whose `boxRect` is set. Returns null if `node.boxRect` is
5880
+ * unset (e.g. abandoned subtree). Skipping descendants without a boxRect
5881
+ * is safe because they never paint.
5882
+ *
5883
+ * Cost: O(N) per call where N is the subtree's node count. Only called
5884
+ * for first-pass children that will render AND only when their parent has
5885
+ * a prev buffer (i.e. real incremental render frames). Skipped children
5886
+ * never trigger the walk.
5887
+ */
5888
+ function computeSubtreePaintExtent(node) {
5889
+ const own = node.boxRect;
5890
+ if (!own) return null;
5891
+ let minX = own.x;
5892
+ let minY = own.y;
5893
+ let maxX = own.x + own.width;
5894
+ let maxY = own.y + own.height;
5895
+ const children = node.children;
5896
+ if (children) for (const child of children) {
5897
+ const sub = computeSubtreePaintExtent(child);
5898
+ if (!sub) continue;
5899
+ if (sub.x < minX) minX = sub.x;
5900
+ if (sub.y < minY) minY = sub.y;
5901
+ if (sub.x + sub.width > maxX) maxX = sub.x + sub.width;
5902
+ if (sub.y + sub.height > maxY) maxY = sub.y + sub.height;
5903
+ }
5904
+ return {
5905
+ x: minX,
5906
+ y: minY,
5907
+ width: maxX - minX,
5908
+ height: maxY - minY
5909
+ };
5910
+ }
5911
+ /**
3808
5912
  * O(1) pre-check: can we skip calling renderNodeToBuffer() on this child entirely?
3809
5913
  *
3810
5914
  * This is the Phase 4 "dirty set rendering" optimization. Instead of calling
@@ -3950,15 +6054,15 @@ function computeChildClipBounds(layout, props, parentClip, scrollOffset = 0, hor
3950
6054
  * Only clears OUTSIDE the node's rect — interior clearing is handled by clearNodeRegion
3951
6055
  * and renderBox. Recursive: follows subtreeDirty paths to find all overflowing descendants.
3952
6056
  */
3953
- function clearDescendantOverflowRegions(node, buffer, layout, scrollOffset, clipBounds, threadedInheritedBg) {
6057
+ function clearDescendantOverflowRegions(node, buffer, sink, layout, scrollOffset, clipBounds, threadedInheritedBg) {
3954
6058
  const clearBg = threadedInheritedBg.color;
3955
6059
  const nodeRight = layout.x + layout.width;
3956
6060
  const nodeBottom = layout.y - scrollOffset + layout.height;
3957
6061
  const nodeLeft = layout.x;
3958
6062
  const nodeTop = layout.y - scrollOffset;
3959
- _clearDescendantOverflow(node.children, buffer, nodeLeft, nodeTop, nodeRight, nodeBottom, scrollOffset, clipBounds, clearBg);
6063
+ _clearDescendantOverflow(node.children, buffer, sink, nodeLeft, nodeTop, nodeRight, nodeBottom, scrollOffset, clipBounds, clearBg);
3960
6064
  }
3961
- function _clearDescendantOverflow(children, buffer, nodeLeft, nodeTop, nodeRight, nodeBottom, scrollOffset, clipBounds, clearBg) {
6065
+ function _clearDescendantOverflow(children, buffer, sink, nodeLeft, nodeTop, nodeRight, nodeBottom, scrollOffset, clipBounds, clearBg) {
3962
6066
  for (const child of children) {
3963
6067
  if (child.prevLayout && isCurrentEpoch(child.layoutChangedThisFrame)) {
3964
6068
  const prev = child.prevLayout;
@@ -3970,53 +6074,51 @@ function _clearDescendantOverflow(children, buffer, nodeLeft, nodeTop, nodeRight
3970
6074
  const overflowWidth = Math.min(prevRight, buffer.width) - overflowX;
3971
6075
  const overflowTop = Math.max(prevTop, clipBounds?.top ?? 0);
3972
6076
  const overflowBottom = Math.min(prevBottom, clipBounds?.bottom ?? buffer.height);
3973
- if (overflowWidth > 0 && overflowBottom > overflowTop) buffer.fill(overflowX, overflowTop, overflowWidth, overflowBottom - overflowTop, {
3974
- char: " ",
3975
- bg: clearBg
3976
- });
6077
+ if (overflowWidth > 0 && overflowBottom > overflowTop) sink.emitClearRect(overflowX, overflowTop, overflowWidth, overflowBottom - overflowTop, clearBg);
3977
6078
  }
3978
6079
  if (prevBottom > nodeBottom) {
3979
6080
  const overflowTop = Math.max(nodeBottom, clipBounds?.top ?? 0);
3980
6081
  const overflowBottom = Math.min(prevBottom, clipBounds?.bottom ?? buffer.height);
3981
6082
  const overflowX = Math.max(prev.x, clipBounds?.left ?? 0);
3982
6083
  const overflowWidth = Math.min(prevRight, clipBounds?.right ?? buffer.width) - overflowX;
3983
- if (overflowWidth > 0 && overflowBottom > overflowTop) buffer.fill(overflowX, overflowTop, overflowWidth, overflowBottom - overflowTop, {
3984
- char: " ",
3985
- bg: clearBg
3986
- });
6084
+ if (overflowWidth > 0 && overflowBottom > overflowTop) sink.emitClearRect(overflowX, overflowTop, overflowWidth, overflowBottom - overflowTop, clearBg);
3987
6085
  }
3988
6086
  if (prev.x < nodeLeft) {
3989
6087
  const overflowX = Math.max(prev.x, 0);
3990
6088
  const overflowWidth = Math.min(nodeLeft, buffer.width) - overflowX;
3991
6089
  const overflowTop = Math.max(prevTop, clipBounds?.top ?? 0);
3992
6090
  const overflowBottom = Math.min(prevBottom, clipBounds?.bottom ?? buffer.height);
3993
- if (overflowWidth > 0 && overflowBottom > overflowTop) buffer.fill(overflowX, overflowTop, overflowWidth, overflowBottom - overflowTop, {
3994
- char: " ",
3995
- bg: clearBg
3996
- });
6091
+ if (overflowWidth > 0 && overflowBottom > overflowTop) sink.emitClearRect(overflowX, overflowTop, overflowWidth, overflowBottom - overflowTop, clearBg);
3997
6092
  }
3998
6093
  if (prevTop < nodeTop) {
3999
6094
  const overflowTop = Math.max(prevTop, clipBounds?.top ?? 0);
4000
6095
  const overflowBottom = Math.min(nodeTop, clipBounds?.bottom ?? buffer.height);
4001
6096
  const overflowX = Math.max(prev.x, clipBounds?.left ?? 0);
4002
6097
  const overflowWidth = Math.min(prevRight, clipBounds?.right ?? buffer.width) - overflowX;
4003
- if (overflowWidth > 0 && overflowBottom > overflowTop) buffer.fill(overflowX, overflowTop, overflowWidth, overflowBottom - overflowTop, {
4004
- char: " ",
4005
- bg: clearBg
4006
- });
6098
+ if (overflowWidth > 0 && overflowBottom > overflowTop) sink.emitClearRect(overflowX, overflowTop, overflowWidth, overflowBottom - overflowTop, clearBg);
4007
6099
  }
4008
6100
  }
4009
- if (isDirty(child.dirtyBits, child.dirtyEpoch, 16) && child.children !== void 0) _clearDescendantOverflow(child.children, buffer, nodeLeft, nodeTop, nodeRight, nodeBottom, scrollOffset, clipBounds, clearBg);
6101
+ if (isDirty(child.dirtyBits, child.dirtyEpoch, 16) && child.children !== void 0) _clearDescendantOverflow(child.children, buffer, sink, nodeLeft, nodeTop, nodeRight, nodeBottom, scrollOffset, clipBounds, clearBg);
4010
6102
  }
4011
6103
  }
4012
6104
  /**
4013
- * Clear a node's region with inherited bg when it has no backgroundColor.
4014
- * Also clears excess area when the node shrank (previous layout was larger).
6105
+ * Clear a node's CURRENT region with inherited bg when it has no backgroundColor.
6106
+ *
6107
+ * Single responsibility: fill the node's `layout` rect with `clearBg`,
6108
+ * clipped to the parent's bounds and to the colored ancestor's bounds.
6109
+ *
6110
+ * Smell #3 (paint-clear-step2-followups) — pre-decouple, this function
6111
+ * tail-called `clearExcessArea` unconditionally. That meant excess
6112
+ * clearing could fire on a fresh buffer when contentRegionCleared=true
6113
+ * but bufferIsCloned/hasPrevBuffer were false. Post-decouple,
6114
+ * `executeRegionClearing` is the SOLE coordinator: it calls
6115
+ * `clearNodeRegion` AND `clearExcessArea` independently when their
6116
+ * respective gates are true.
4015
6117
  *
4016
6118
  * Clipping: clips to parent's boxRect (prevents overflow) and to the
4017
6119
  * colored ancestor's bounds (prevents bg color bleeding into siblings).
4018
6120
  */
4019
- function clearNodeRegion(node, buffer, layout, scrollOffset, clipBounds, layoutChanged, threadedInheritedBg) {
6121
+ function clearNodeRegion(node, sink, layout, scrollOffset, clipBounds, threadedInheritedBg) {
4020
6122
  const inherited = threadedInheritedBg;
4021
6123
  const clearBg = inherited.color;
4022
6124
  const screenY = layout.y - scrollOffset;
@@ -4051,33 +6153,51 @@ function clearNodeRegion(node, buffer, layout, scrollOffset, clipBounds, layoutC
4051
6153
  _cellDbg2.log.push(msg);
4052
6154
  cellLog.debug?.(msg);
4053
6155
  }
4054
- buffer.fill(clearX, clearY, clearWidth, clearHeight, {
4055
- char: " ",
4056
- bg: clearBg
4057
- });
6156
+ sink.emitClearRect(clearX, clearY, clearWidth, clearHeight, clearBg);
4058
6157
  }
4059
- clearExcessArea(node, buffer, layout, scrollOffset, clipBounds, layoutChanged, inherited);
6158
+ }
6159
+ /**
6160
+ * Construct an `ExcessClearGate` if and only if all four preconditions hold.
6161
+ * Returns null otherwise. This is the SOLE constructor for the brand.
6162
+ *
6163
+ * Preconditions (each enforces one structural invariant):
6164
+ * - bufferIsCloned: there are stale pixels to clear (false for fresh buffers).
6165
+ * - layoutChanged: the node's rect changed this frame (no shrink → no excess).
6166
+ * - prevLayout != null: we have an old rect to compute the excess from.
6167
+ * - hasPrevBuffer: the buffer at this node's position contains the *previous
6168
+ * frame's* pixels (not first-pass-painted current-frame siblings — the
6169
+ * latter is what second-pass absolute/sticky/overlap-forced dispatch sites
6170
+ * pass with hasPrevBuffer=false). Excess-clearing on top of fresh sibling
6171
+ * paint corrupts the in-progress frame; see km-silvery.ai-chat-incremental-mismatch.
6172
+ */
6173
+ function requireExcessClearGate(bufferIsCloned, layoutChanged, prevLayout, hasPrevBuffer) {
6174
+ if (!bufferIsCloned) return null;
6175
+ if (!layoutChanged) return null;
6176
+ if (prevLayout == null) return null;
6177
+ if (!hasPrevBuffer) return null;
6178
+ return { prevLayout };
4060
6179
  }
4061
6180
  /**
4062
6181
  * Clear the excess area when a node shrinks (old bounds were larger than new).
4063
6182
  *
4064
- * This is separated from clearNodeRegion because excess area clearing must happen
4065
- * even when contentRegionCleared is false. Key scenario: absolute-positioned overlays
4066
- * (e.g., search dialog) that shrink while normal-flow siblings are dirty. The
4067
- * forceRepaint path sets hasPrevBuffer=false + ancestorCleared=false, making
4068
- * contentRegionCleared=false but the cloned buffer still has stale pixels from
4069
- * the old larger layout that must be cleared.
6183
+ * Requires an `ExcessClearGate` as the first parameter only obtainable via
6184
+ * `requireExcessClearGate`, which checks the four preconditions. This makes
6185
+ * the wrong-order second-pass call site (the bug that motivated silvery
6186
+ * 168b4989) structurally unrepresentable: a caller cannot write
6187
+ * `clearExcessArea(...)` without first flowing through the factory, which
6188
+ * returns null when hasPrevBuffer=false (the second-pass case).
4070
6189
  *
4071
- * Clips to the COLORED ANCESTOR's content area (not immediate parent's full rect)
4072
- * to prevent inherited color from bleeding into sibling areas with different bg.
6190
+ * Clips to the COLORED ANCESTOR's content area (not immediate parent's full
6191
+ * rect) to prevent inherited color from bleeding into sibling areas with
6192
+ * different bg.
4073
6193
  *
4074
6194
  * IMPORTANT: Uses content area (inside border/padding), not full boxRect.
4075
6195
  * Without this, excess clearing of a child that previously filled the parent's
4076
- * content area will extend into the parent's border row, overwriting border chars.
6196
+ * content area will extend into the parent's border row, overwriting border
6197
+ * chars.
4077
6198
  */
4078
- function clearExcessArea(node, buffer, layout, scrollOffset, clipBounds, layoutChanged, inherited) {
4079
- if (!layoutChanged || !node.prevLayout) return;
4080
- const prev = node.prevLayout;
6199
+ function clearExcessArea(gate, node, buffer, sink, layout, scrollOffset, clipBounds, inherited) {
6200
+ const prev = gate.prevLayout;
4081
6201
  const _cellDbg3 = getCellDebug();
4082
6202
  const _prevCoversCell3 = _cellDbg3 && cellCoversPoint(_cellDbg3, prev.x, prev.y - scrollOffset, prev.width, prev.height);
4083
6203
  if (prev.width <= layout.width && prev.height <= layout.height) {
@@ -4117,16 +6237,16 @@ function clearExcessArea(node, buffer, layout, scrollOffset, clipBounds, layoutC
4117
6237
  const excessX = layout.x + layout.width;
4118
6238
  let excessWidth = prev.width - layout.width;
4119
6239
  if (excessX + excessWidth > clipRectRight) excessWidth = Math.max(0, clipRectRight - excessX);
4120
- if (excessWidth > 0) clippedFill(buffer, excessX, excessWidth, prevScreenY, prevScreenY + prev.height, clipBounds, clipRectBottom, clearBg);
6240
+ if (excessWidth > 0) clippedFill(sink, excessX, excessWidth, prevScreenY, prevScreenY + prev.height, clipBounds, clipRectBottom, clearBg);
4121
6241
  }
4122
6242
  if (prev.height > layout.height) {
4123
6243
  let bottomWidth = prev.width;
4124
6244
  if (layout.x + bottomWidth > clipRectRight) bottomWidth = Math.max(0, clipRectRight - layout.x);
4125
- clippedFill(buffer, layout.x, bottomWidth, screenY + layout.height, prevScreenY + prev.height, clipBounds, clipRectBottom, clearBg);
6245
+ clippedFill(sink, layout.x, bottomWidth, screenY + layout.height, prevScreenY + prev.height, clipBounds, clipRectBottom, clearBg);
4126
6246
  }
4127
6247
  }
4128
6248
  /** Fill a rectangular region, clipping to clipBounds and an outer bottom limit. */
4129
- function clippedFill(buffer, x, width, top, bottom, clipBounds, outerBottom, bg) {
6249
+ function clippedFill(sink, x, width, top, bottom, clipBounds, outerBottom, bg) {
4130
6250
  const clippedTop = clipBounds ? Math.max(top, clipBounds.top) : top;
4131
6251
  const clippedBottom = Math.min(clipBounds ? Math.min(bottom, clipBounds.bottom) : bottom, outerBottom);
4132
6252
  let clippedX = x;
@@ -4139,10 +6259,98 @@ function clippedFill(buffer, x, width, top, bottom, clipBounds, outerBottom, bg)
4139
6259
  if (clippedX + clippedWidth > clipBounds.right) clippedWidth = Math.max(0, clipBounds.right - clippedX);
4140
6260
  }
4141
6261
  const height = clippedBottom - clippedTop;
4142
- if (height > 0 && clippedWidth > 0) buffer.fill(clippedX, clippedTop, clippedWidth, height, {
6262
+ if (height > 0 && clippedWidth > 0) sink.emitClearRect(clippedX, clippedTop, clippedWidth, height, bg);
6263
+ }
6264
+ //#endregion
6265
+ //#region packages/ag-term/src/pipeline/render-plan.ts
6266
+ init_buffer();
6267
+ /**
6268
+ * Read the SILVERY_RENDER_PLAN env var. Returns true (default) UNLESS
6269
+ * the user explicitly opts out with `SILVERY_RENDER_PLAN=0` /
6270
+ * `SILVERY_RENDER_PLAN=false`.
6271
+ *
6272
+ * Phase 1: opt-in only (default off).
6273
+ * Phase 2: opt-in via flag for testing.
6274
+ * Phase 3 (current): default ON, opt-out for one release. The plan
6275
+ * capture + sectioned commit is now load-bearing — it runs every
6276
+ * frame, the captured plan is committed onto a fresh clone for parity
6277
+ * (verified by tests/features/render-plan-fuzz.test.tsx +
6278
+ * tests/features/render-plan-production.test.tsx). The opt-out exists
6279
+ * solely to roll back if a regression slips through; the next release
6280
+ * removes the env var entirely.
6281
+ */
6282
+ function isRenderPlanEnabled() {
6283
+ const v = (typeof process !== "undefined" ? process.env : void 0)?.SILVERY_RENDER_PLAN;
6284
+ if (v === void 0) return true;
6285
+ const lower = v.toLowerCase();
6286
+ return lower !== "0" && lower !== "false" && lower !== "" && lower !== "off" && lower !== "no";
6287
+ }
6288
+ /**
6289
+ * Apply a sectioned plan to a target buffer in structural order:
6290
+ * transfer → cleanup → paint → overlay → postState. Within each section,
6291
+ * ops apply in plan-emission order (which preserves CSS paint order for
6292
+ * paintOps: later sibling bg can legitimately cover earlier sibling
6293
+ * overflow text).
6294
+ *
6295
+ * This is the L4 commit path: wrong-order sibling-stomp is unrepresentable
6296
+ * by construction because a `ClearOp` cannot end up in `paintOps` — the
6297
+ * type system rejects it.
6298
+ */
6299
+ function commitSectionedPlan(target, plan) {
6300
+ if (target.width !== plan.width || target.height !== plan.height) throw new Error(`commitSectionedPlan: buffer dimensions ${target.width}x${target.height} do not match plan ${plan.width}x${plan.height}`);
6301
+ for (const op of plan.transferOps) applyTransfer(target, op);
6302
+ for (const op of plan.cleanupOps) applyClear(target, op);
6303
+ for (const op of plan.paintOps) applyPaint(target, op);
6304
+ for (const op of plan.overlayOps) applyOverlay(target, op);
6305
+ for (const op of plan.postStateOps) applyPostState(target, op);
6306
+ }
6307
+ function applyTransfer(buffer, op) {
6308
+ buffer.scrollRegion(op.x, op.y, op.width, op.height, op.delta, selectablePatch(op.clearCell ?? {}, op.selectable));
6309
+ }
6310
+ function applyClear(buffer, op) {
6311
+ if (op.kind === "clearRect") buffer.fill(op.x, op.y, op.width, op.height, {
4143
6312
  char: " ",
4144
- bg
6313
+ bg: op.bg,
6314
+ selectable: op.selectable
4145
6315
  });
6316
+ else buffer.fill(op.x, op.y, op.width, op.height, op.cell);
6317
+ }
6318
+ function applyPaint(buffer, op) {
6319
+ switch (op.kind) {
6320
+ case "setCell":
6321
+ buffer.setCell(op.x, op.y, op.cell);
6322
+ return;
6323
+ case "fillBg":
6324
+ buffer.fillBg(op.x, op.y, op.width, op.height, op.bg);
6325
+ return;
6326
+ case "paintFill":
6327
+ buffer.fill(op.x, op.y, op.width, op.height, op.cell);
6328
+ return;
6329
+ case "restyleRegion":
6330
+ buffer.restyleRegion(op.x, op.y, op.width, op.height, op.style);
6331
+ return;
6332
+ }
6333
+ }
6334
+ function applyOverlay(buffer, op) {
6335
+ buffer.mergeAttrsInRect(op.x, op.y, op.width, op.height, op.attrs, op.underlineColor);
6336
+ }
6337
+ function applyPostState(buffer, op) {
6338
+ switch (op.kind) {
6339
+ case "setRowMeta": {
6340
+ const meta = {};
6341
+ if (op.softWrapped !== void 0) meta.softWrapped = op.softWrapped;
6342
+ if (op.lastContentCol !== void 0) meta.lastContentCol = op.lastContentCol;
6343
+ buffer.setRowMeta(op.row, meta);
6344
+ return;
6345
+ }
6346
+ case "setOutlineSnapshots": return;
6347
+ }
6348
+ }
6349
+ function selectablePatch(cell, selectable) {
6350
+ return selectable === void 0 ? cell : {
6351
+ ...cell,
6352
+ selectable
6353
+ };
4146
6354
  }
4147
6355
  //#endregion
4148
6356
  //#region packages/ag-term/src/pipeline/backdrop/color.ts
@@ -4207,12 +6415,15 @@ function normalizeHex(hex) {
4207
6415
  /**
4208
6416
  * Backdrop fade — stage 1: build the immutable `Plan`.
4209
6417
  *
4210
- * `buildPlan(root, options)` is a PURE, capability-independent pass that
4211
- * walks the tree, collects `data-backdrop-fade` / `data-backdrop-fade-excluded`
4212
- * markers, enforces the single-amount invariant, and resolves the scrim +
4213
- * default colors. The realizers (`./realize-buffer.ts`, `./realize-kitty.ts`)
4214
- * trust the plan: they do NOT re-walk the tree, re-resolve the scrim, or
4215
- * re-validate amounts. This module is the single source of truth.
6418
+ * `buildPlan(root, options)` is a PURE pass that walks the tree, collects
6419
+ * `data-backdrop-fade` / `data-backdrop-fade-excluded` markers, enforces
6420
+ * the single-amount invariant, and resolves the scrim + default colors.
6421
+ * It returns a `TerminalPlan` (`CorePlan` + `kittyEnabled`); call
6422
+ * `buildCorePlan` for a framework-agnostic plan that strips terminal
6423
+ * capability flags. The realizers (`./realize-buffer.ts`,
6424
+ * `./realize-kitty.ts`) trust the plan: they do NOT re-walk the tree,
6425
+ * re-resolve the scrim, or re-validate amounts. This module is the
6426
+ * single source of truth.
4216
6427
  *
4217
6428
  * ## The model: per-channel alpha scrim with perceptually-aware fg
4218
6429
  *
@@ -4232,7 +6443,7 @@ function normalizeHex(hex) {
4232
6443
  * Fg uses OKLCH deemphasize with explicit polarity so colored text
4233
6444
  * deemphasizes toward the correct theme neutral — toward black on dark
4234
6445
  * themes (same formula we've always used), toward white on light themes
4235
- * (new — see `./color-compat.ts` for the math). The quadratic chroma
6446
+ * (new — see `./color-shim.ts` for the math). The quadratic chroma
4236
6447
  * falloff compensates for the human-vision nonlinearity that reads chroma
4237
6448
  * relative to luminance. Bg uses sRGB source-over because the Kitty
4238
6449
  * graphics scrim overlay composites in sRGB at alpha at the hardware
@@ -4303,10 +6514,15 @@ const INACTIVE_PLAN = Object.freeze({
4303
6514
  kittyEnabled: false
4304
6515
  });
4305
6516
  /**
4306
- * Quick check: does the tree contain any backdrop markers? Used as a gate so
4307
- * we don't clone the buffer every frame when no fade is active. Walks the
4308
- * full tree once (O(N)) the alternative (tracking dirty markers in the
4309
- * reconciler) is more complex and the walk is cheap compared to the pass.
6517
+ * Syntactic check: does the tree contain any `data-backdrop-fade*`
6518
+ * attribute? Returns true even when the marker amounts are 0 (which
6519
+ * yields an inactive plan). For "is the plan active this frame?" use
6520
+ * `buildPlan(...).active`.
6521
+ *
6522
+ * Used as a gate so we don't clone the buffer every frame when no
6523
+ * backdrop markers are mounted. Walks the full tree once (O(N)) — the
6524
+ * alternative (tracking dirty markers in the reconciler) is more complex
6525
+ * and the walk is cheap compared to the pass.
4310
6526
  */
4311
6527
  function hasBackdropMarkers(root) {
4312
6528
  const props = root.props;
@@ -4314,20 +6530,28 @@ function hasBackdropMarkers(root) {
4314
6530
  for (const child of root.children) if (hasBackdropMarkers(child)) return true;
4315
6531
  return false;
4316
6532
  }
6533
+ Object.freeze({
6534
+ active: false,
6535
+ amount: 0,
6536
+ scrim: null,
6537
+ defaultBg: null,
6538
+ defaultFg: null,
6539
+ includes: Object.freeze([]),
6540
+ excludes: Object.freeze([]),
6541
+ mixedAmounts: false,
6542
+ scrimTowardLight: false
6543
+ });
4317
6544
  /**
4318
- * Stage 1 — build the immutable `Plan`.
4319
- *
4320
- * Pure function of `(tree markers, options)`. No buffer access, no Kitty
4321
- * capability knowledge, no console I/O. The realizers read from the plan
4322
- * exclusively; the orchestrator (`./index.ts`) handles dev-mode diagnostics
4323
- * derived from `plan.mixedAmounts`.
6545
+ * Stage 1 (terminal) — build the immutable `TerminalPlan`.
4324
6546
  *
4325
- * Returns `INACTIVE_PLAN` when:
4326
- * - `colorLevel === "none"` (monochrome terminal pass is a no-op).
4327
- * - The tree has no backdrop markers, OR all markers have `amount <= 0`.
6547
+ * Same as `buildCorePlan` plus the derived `kittyEnabled` capability flag.
6548
+ * `kittyEnabled = options.kittyGraphics === true && scrim !== null` the
6549
+ * overlay needs a resolvable tint to composite, so a null-scrim plan
6550
+ * cannot use the Kitty path even when the caller has the capability
6551
+ * enabled.
4328
6552
  */
4329
6553
  function buildPlan(root, options) {
4330
- if ((options?.colorLevel ?? "truecolor") === "none") return INACTIVE_PLAN;
6554
+ if ((options?.colorLevel ?? "truecolor") === "mono") return INACTIVE_PLAN;
4331
6555
  const includes = [];
4332
6556
  const excludes = [];
4333
6557
  const includeAmounts = [];
@@ -4340,7 +6564,10 @@ function buildPlan(root, options) {
4340
6564
  const scrimTowardLight = isLightScrim(scrim);
4341
6565
  const defaultFg = normalizeHex(options?.defaultFg) ?? (scrim === null ? null : scrimTowardLight ? "#000000" : "#ffffff");
4342
6566
  const { amount, hasMixedAmounts } = assertSingleAmount(includeAmounts, excludeAmounts);
4343
- return {
6567
+ const kittyEnabled = options?.kittyGraphics === true && scrim !== null;
6568
+ Object.freeze(includes);
6569
+ Object.freeze(excludes);
6570
+ return Object.freeze({
4344
6571
  active: true,
4345
6572
  amount,
4346
6573
  scrim,
@@ -4350,8 +6577,8 @@ function buildPlan(root, options) {
4350
6577
  excludes,
4351
6578
  mixedAmounts: hasMixedAmounts,
4352
6579
  scrimTowardLight,
4353
- kittyEnabled: options?.kittyGraphics === true && scrim !== null
4354
- };
6580
+ kittyEnabled
6581
+ });
4355
6582
  }
4356
6583
  /**
4357
6584
  * Detect the single-amount invariant across all markers. Returns the
@@ -4411,16 +6638,22 @@ function collectBackdropMarkers(node, includes, excludes, includeAmounts, exclud
4411
6638
  const includeRaw = props[BACKDROP_FADE_ATTR];
4412
6639
  const excludeRaw = props[BACKDROP_FADE_EXCLUDE_ATTR];
4413
6640
  if (includeRaw !== void 0 || excludeRaw !== void 0) {
4414
- const rect = node.screenRect ?? node.scrollRect ?? node.boxRect;
4415
- if (rect && rect.width > 0 && rect.height > 0) {
6641
+ const sourceRect = node.screenRect ?? node.scrollRect ?? node.boxRect;
6642
+ if (sourceRect && sourceRect.width > 0 && sourceRect.height > 0) {
6643
+ const rect = Object.freeze({
6644
+ x: sourceRect.x,
6645
+ y: sourceRect.y,
6646
+ width: sourceRect.width,
6647
+ height: sourceRect.height
6648
+ });
4416
6649
  const inc = parseFade(includeRaw);
4417
6650
  if (inc !== null) {
4418
- includes.push({ rect });
6651
+ includes.push(Object.freeze({ rect }));
4419
6652
  includeAmounts.push(inc);
4420
6653
  }
4421
6654
  const exc = parseFade(excludeRaw);
4422
6655
  if (exc !== null) {
4423
- excludes.push({ rect });
6656
+ excludes.push(Object.freeze({ rect }));
4424
6657
  excludeAmounts.push(exc);
4425
6658
  }
4426
6659
  }
@@ -4459,48 +6692,23 @@ function parseFade(raw) {
4459
6692
  return n > 1 ? 1 : n;
4460
6693
  }
4461
6694
  //#endregion
4462
- //#region packages/ag-term/src/pipeline/backdrop/color-compat.ts
6695
+ //#region packages/ag-term/src/pipeline/backdrop/color-shim.ts
4463
6696
  /**
4464
- * Backdrop fade — `@silvery/color` compatibility shim.
6697
+ * Backdrop fade — `@silvery/color` polarity-aware deemphasize shim.
4465
6698
  *
4466
- * Temporary shim while `@silvery/color` lags behind on publish cycles.
4467
- * `@silvery/color` does export `mixSrgb` and `deemphasize` from source; this
4468
- * module prefers the upstream versions at runtime and falls back to a
4469
- * local implementation when an upstream export is missing — e.g., when a
4470
- * new helper is introduced in the same release cycle as the silvery
4471
- * package that imports it (the published `@silvery/color` dist doesn't
4472
- * ship the new name until its next publish, breaking CI verify).
6699
+ * `@silvery/color` exports `deemphasize` (dark-only, drifts L toward 0) and
6700
+ * `mixSrgb` directly. Use those whenever possible. The light-theme-aware
6701
+ * variant `deemphasizeOklchToward` (drifts L toward 0 OR 1 by polarity) is
6702
+ * NOT yet upstream this file ships it locally.
4473
6703
  *
4474
- * The fallback implementations are byte-identical to the upstream ones.
4475
- * Once all downstream consumers of silvery are on a published version of
4476
- * `@silvery/color` that exports every name we reference, delete the
4477
- * `local*` fallbacks and collapse each export to a direct re-export.
4478
- *
4479
- * Light-theme-aware deemphasize (`deemphasizeOklchToward`) is NOT in
4480
- * upstream yet — it's only shipped by this module. When it lands in
4481
- * `@silvery/color`, replace the local implementation with an upstream
4482
- * re-export behind the same shim.
6704
+ * **Deletion condition**: when `@silvery/color` exports
6705
+ * `deemphasizeOklchToward`, delete this file entirely and update
6706
+ * `./realize-buffer.ts` to import directly from `@silvery/color`. Audit
6707
+ * with `rg "from .*color-shim"` should drop to zero.
4483
6708
  *
4484
6709
  * @see ./color.ts for hex↔rgb adapter helpers and `HexColor` type.
4485
6710
  */
4486
6711
  /**
4487
- * sRGB source-over alpha mix. `out = a * (1 - t) + b * t`.
4488
- *
4489
- * Prefers `@silvery/color`'s published export; falls back to the local copy
4490
- * when upstream doesn't ship the name yet. The local implementation matches
4491
- * `@silvery/color/src/color.ts` byte-for-byte and is safe to use while the
4492
- * publish train catches up.
4493
- */
4494
- function localMixSrgb(a, b, t) {
4495
- const ra = hexToRgb$1(a);
4496
- const rb = hexToRgb$1(b);
4497
- if (!ra || !rb) return a;
4498
- const u = Math.max(0, Math.min(1, t));
4499
- return rgbToHex(ra.r * (1 - u) + rb.r * u, ra.g * (1 - u) + rb.g * u, ra.b * (1 - u) + rb.b * u);
4500
- }
4501
- /** sRGB source-over mix. Prefers upstream `@silvery/color`; falls back to the local copy. */
4502
- const mixSrgb = Upstream.mixSrgb ?? localMixSrgb;
4503
- /**
4504
6712
  * OKLCH-native deemphasize that drifts toward EITHER black (dark themes)
4505
6713
  * OR white (light themes). `towardLight` controls the lightness target;
4506
6714
  * the chroma falloff is identical in both directions.
@@ -4517,16 +6725,7 @@ const mixSrgb = Upstream.mixSrgb ?? localMixSrgb;
4517
6725
  * the human visual system reads chroma RELATIVE to luminance, so a modest
4518
6726
  * OKLCH C at extreme L *appears* distinctly more chromatic than the same C
4519
6727
  * near mid-L. Proportional L+C scaling (`C *= 1-α`, preserving C/L) feels
4520
- * "more saturated when darkened" to viewers — the exact complaint that
4521
- * prompted the quadratic version.
4522
- *
4523
- * Using `(1-α)²` for chroma reduces saturation faster than lightness on
4524
- * both polarities:
4525
- *
4526
- * α=0.25 → C *= 0.563 (C/L drops to 75% of original)
4527
- * α=0.40 → C *= 0.360 (C/L drops to 60%)
4528
- * α=0.50 → C *= 0.250 (C/L drops to 50%)
4529
- * α=1.00 → C *= 0 (fully faded to the target luminance).
6728
+ * "more saturated when darkened" to viewers.
4530
6729
  *
4531
6730
  * Light-theme case (towardLight=true): a bright colored text on a light
4532
6731
  * backdrop is made paler by raising L toward 1 and dropping C — the
@@ -4535,21 +6734,18 @@ const mixSrgb = Upstream.mixSrgb ?? localMixSrgb;
4535
6734
  * would darken colored text on a light bg, which reads as "text popping"
4536
6735
  * against the faded scrim rather than receding.
4537
6736
  */
4538
- function localDeemphasizeOklchToward(hex, amount, towardLight) {
4539
- const o = upstreamHexToOklch(hex);
6737
+ function deemphasizeOklchToward(hex, amount, towardLight) {
6738
+ const o = hexToOklch(hex);
4540
6739
  if (!o) return hex;
4541
6740
  const a = Math.max(0, Math.min(1, amount));
4542
6741
  const chromaFactor = (1 - a) * (1 - a);
4543
6742
  const L = towardLight ? o.L + (1 - o.L) * a : o.L * (1 - a);
4544
- return upstreamOklchToHex({
6743
+ return oklchToHex({
4545
6744
  L: Math.max(0, Math.min(1, L)),
4546
6745
  C: Math.max(0, o.C * chromaFactor),
4547
6746
  H: o.H
4548
6747
  });
4549
6748
  }
4550
- const upstreamHexToOklch = Upstream.hexToOklch;
4551
- const upstreamOklchToHex = Upstream.oklchToHex;
4552
- const deemphasizeOklchToward = Upstream.deemphasizeOklchToward ?? localDeemphasizeOklchToward;
4553
6749
  //#endregion
4554
6750
  //#region packages/ag-term/src/pipeline/backdrop/region.ts
4555
6751
  /**
@@ -4568,7 +6764,7 @@ const deemphasizeOklchToward = Upstream.deemphasizeOklchToward ?? localDeemphasi
4568
6764
  * Returns the count of unique cells visited. Useful for short-circuiting
4569
6765
  * the "was any cell modified?" signal in realizers.
4570
6766
  */
4571
- function forEachFadeRegionCell(bufferWidth, bufferHeight, includes, excludes, visit) {
6767
+ function forEachBackdropCell(bufferWidth, bufferHeight, includes, excludes, visit) {
4572
6768
  if (bufferWidth <= 0 || bufferHeight <= 0) return 0;
4573
6769
  if (includes.length === 0 && excludes.length === 0) return 0;
4574
6770
  const seen = new Uint8Array(bufferWidth * bufferHeight);
@@ -4617,9 +6813,47 @@ function forEachFadeRegionCell(bufferWidth, bufferHeight, includes, excludes, vi
4617
6813
  //#endregion
4618
6814
  //#region packages/ag-term/src/pipeline/backdrop/realize-buffer.ts
4619
6815
  /**
6816
+ * Backdrop fade — stage 2a: apply the plan's cell-level transform to the
6817
+ * terminal buffer.
6818
+ *
6819
+ * Uses the shared `forEachBackdropCell` walker (`./region.ts`) to visit
6820
+ * every cell covered by the plan's include + exclude rects exactly once.
6821
+ * Trusts the plan: no marker re-collection, no scrim/default resolution,
6822
+ * no amount validation, no capability re-derivation (`plan.kittyEnabled`
6823
+ * is the sole source of truth for the emoji branch).
6824
+ *
6825
+ * Wide ≠ emoji. CJK / Hangul / Japanese fullwidth text occupies two columns
6826
+ * but responds to `fg` color normally — it goes through the standard mix
6827
+ * path. Only EMOJI (bitmap glyphs that ignore `fg`) need special handling,
6828
+ * detected via `isLikelyEmoji(cell.char)`.
6829
+ *
6830
+ * For emoji cells, two paths, mutually exclusive:
6831
+ *
6832
+ * 1. **Kitty graphics available** (`plan.kittyEnabled === true`): emoji cells
6833
+ * are SKIPPED entirely here. `./realize-kitty.ts` emits a translucent
6834
+ * scrim image at alpha=amount above each emoji cell, and the terminal
6835
+ * composites the overlay on top of the unmixed cell, landing at
6836
+ * `cell_bg * (1 - amount) + scrim * amount` — the same luminance as
6837
+ * surrounding text cells. This avoids the double-fade that would make
6838
+ * emoji bg visibly blacker.
6839
+ *
6840
+ * 2. **Kitty graphics unavailable** (`plan.kittyEnabled === false`): the
6841
+ * per-cell mix runs on emoji cells too and stamps `attrs.dim` (SGR 2)
6842
+ * on lead + continuation. Terminals honoring SGR 2 on emoji fade the
6843
+ * glyph; others see the glyph at full brightness but the cell bg
6844
+ * matches surroundings.
6845
+ *
6846
+ * @see ./plan.ts for the color model and scrim derivation.
6847
+ * @see ./color.ts for the hex↔rgb adapter helpers.
6848
+ * @see @silvery/color for `mixSrgb`.
6849
+ * @see ./color-shim.ts for `deemphasizeOklchToward` (polarity-aware,
6850
+ * not yet upstream).
6851
+ * @see ./region.ts for the shared include/exclude region walker.
6852
+ */
6853
+ /**
4620
6854
  * Stage 2a — apply the plan's cell-level transform to the buffer.
4621
6855
  *
4622
- * Walks every include + exclude cell once via `forEachFadeRegionCell` and
6856
+ * Walks every include + exclude cell once via `forEachBackdropCell` and
4623
6857
  * applies `fadeCell` with the plan's single `amount`. The buffer is mutated
4624
6858
  * in place.
4625
6859
  *
@@ -4635,7 +6869,7 @@ function realizeToBuffer(plan, buffer) {
4635
6869
  if (!plan.active) return false;
4636
6870
  if (plan.amount <= 0) return false;
4637
6871
  let modified = false;
4638
- forEachFadeRegionCell(buffer.width, buffer.height, plan.includes, plan.excludes, (x, y) => {
6872
+ forEachBackdropCell(buffer.width, buffer.height, plan.includes, plan.excludes, (x, y) => {
4639
6873
  if (fadeCell(buffer, x, y, plan)) modified = true;
4640
6874
  });
4641
6875
  return modified;
@@ -4751,6 +6985,19 @@ function fadeCell(buffer, x, y, plan) {
4751
6985
  if (fgHex && bgHex) {
4752
6986
  const mixedRgb = hexToRgb$1(mixSrgb(fgHex, bgHex, amount));
4753
6987
  if (!mixedRgb) return false;
6988
+ if (isEmojiGlyph) {
6989
+ const newAttrs = cell.attrs.dim ? cell.attrs : {
6990
+ ...cell.attrs,
6991
+ dim: true
6992
+ };
6993
+ buffer.setCell(x, y, {
6994
+ ...cell,
6995
+ fg: mixedRgb,
6996
+ attrs: newAttrs
6997
+ });
6998
+ propagateToContinuation(buffer, cell, x, y, { dim: true });
6999
+ return true;
7000
+ }
4754
7001
  buffer.setCell(x, y, {
4755
7002
  ...cell,
4756
7003
  fg: mixedRgb
@@ -4818,17 +7065,19 @@ init_src();
4818
7065
  * Stage 2b — emit the Kitty graphics overlay for the plan.
4819
7066
  *
4820
7067
  * The output always begins with `CURSOR_SAVE + kittyDeleteAllScrimPlacements()
4821
- * + CURSOR_RESTORE` when `plan.active` is true — even when zero emoji cells
7068
+ * + CURSOR_RESTORE` when the plan is realized — even when zero emoji cells
4822
7069
  * fall inside the faded region this frame. The unconditional clear is what
4823
7070
  * erases stale placements from a previous frame.
4824
7071
  *
4825
- * Returns `""` when `plan.active` is false. Callers that also need to
4826
- * suppress the overlay because Kitty graphics are not available should NOT
4827
- * call this function at all the orchestrator (`./index.ts`) guards the
4828
- * call site with `plan.kittyEnabled`.
7072
+ * Returns `""` when the plan can't be realized as a Kitty overlay. Public
7073
+ * API safety callers (tests, future consumers) should be able to invoke
7074
+ * this directly without re-deriving the `kittyEnabled` / `scrim` /
7075
+ * `amount > 0` gate. The orchestrator (`./index.ts`) also gates with
7076
+ * `plan.kittyEnabled` for clarity, but the early-out below is the
7077
+ * authoritative contract.
4829
7078
  */
4830
7079
  function realizeToKitty(plan, buffer) {
4831
- if (!plan.active) return "";
7080
+ if (!plan.active || !plan.kittyEnabled || plan.amount <= 0 || plan.scrim === null) return "";
4832
7081
  const cells = collectEmojiCellsInFadeRegion(buffer, plan);
4833
7082
  const tint = hexToRgb$1(plan.scrim ?? plan.defaultBg ?? "#000000") ?? {
4834
7083
  r: 0,
@@ -4866,13 +7115,13 @@ function realizeToKitty(plan, buffer) {
4866
7115
  * overlay because their rendering ignores the fg color.
4867
7116
  *
4868
7117
  * The iteration order is deterministic (delegated to
4869
- * `forEachFadeRegionCell`), matching the buffer realizer's order. STRICT
7118
+ * `forEachBackdropCell`), matching the buffer realizer's order. STRICT
4870
7119
  * mode compares the overlay string across fresh and incremental paths —
4871
7120
  * any drift in this order would fail the comparison.
4872
7121
  */
4873
7122
  function collectEmojiCellsInFadeRegion(buffer, plan) {
4874
7123
  const out = [];
4875
- forEachFadeRegionCell(buffer.width, buffer.height, plan.includes, plan.excludes, (x, y) => {
7124
+ forEachBackdropCell(buffer.width, buffer.height, plan.includes, plan.excludes, (x, y) => {
4876
7125
  if (x + 1 >= buffer.width) return;
4877
7126
  if (!buffer.isCellWide(x, y)) return;
4878
7127
  if (buffer.isCellContinuation(x, y)) return;
@@ -4917,7 +7166,7 @@ const EMPTY_RESULT = Object.freeze({
4917
7166
  */
4918
7167
  function applyBackdrop(root, buffer, options) {
4919
7168
  const plan = buildPlan(root, options);
4920
- if (plan.mixedAmounts && process.env.NODE_ENV !== "production") console.warn(`[silvery:backdrop] multiple fade amounts in one frame (using ${plan.amount}); Kitty overlay will use the first observed amount. See plan.ts / assertSingleAmount.`);
7169
+ if (plan.mixedAmounts && process.env.NODE_ENV !== "production") console.warn(`[silvery:backdrop] multiple fade amounts in one frame; plan.amount collapsed to the first observed (${plan.amount}). Visuals may differ from intent. See plan.ts / assertSingleAmount.`);
4921
7170
  if (!plan.active) return EMPTY_RESULT;
4922
7171
  return {
4923
7172
  modified: realizeToBuffer(plan, buffer),
@@ -4925,62 +7174,6 @@ function applyBackdrop(root, buffer, options) {
4925
7174
  };
4926
7175
  }
4927
7176
  //#endregion
4928
- //#region \0@oxc-project+runtime@0.126.0/helpers/usingCtx.js
4929
- function _usingCtx() {
4930
- var r = "function" == typeof SuppressedError ? SuppressedError : function(r, e) {
4931
- var n = Error();
4932
- return n.name = "SuppressedError", n.error = r, n.suppressed = e, n;
4933
- }, e = {}, n = [];
4934
- function using(r, e) {
4935
- if (null != e) {
4936
- if (Object(e) !== e) throw new TypeError("using declarations can only be used with objects, functions, null, or undefined.");
4937
- if (r) var o = e[Symbol.asyncDispose || Symbol["for"]("Symbol.asyncDispose")];
4938
- if (void 0 === o && (o = e[Symbol.dispose || Symbol["for"]("Symbol.dispose")], r)) var t = o;
4939
- if ("function" != typeof o) throw new TypeError("Object is not disposable.");
4940
- t && (o = function o() {
4941
- try {
4942
- t.call(e);
4943
- } catch (r) {
4944
- return Promise.reject(r);
4945
- }
4946
- }), n.push({
4947
- v: e,
4948
- d: o,
4949
- a: r
4950
- });
4951
- } else r && n.push({
4952
- d: e,
4953
- a: r
4954
- });
4955
- return e;
4956
- }
4957
- return {
4958
- e,
4959
- u: using.bind(null, !1),
4960
- a: using.bind(null, !0),
4961
- d: function d() {
4962
- var o, t = this.e, s = 0;
4963
- function next() {
4964
- for (; o = n.pop();) try {
4965
- if (!o.a && 1 === s) return s = 0, n.push(o), Promise.resolve().then(next);
4966
- if (o.d) {
4967
- var r = o.d.call(o.v);
4968
- if (o.a) return s |= 2, Promise.resolve(r).then(next, err);
4969
- } else s |= 1;
4970
- } catch (r) {
4971
- return err(r);
4972
- }
4973
- if (1 === s) return t !== e ? Promise.reject(t) : Promise.resolve();
4974
- if (t !== e) throw t;
4975
- }
4976
- function err(n) {
4977
- return t = t !== e ? new r(n, t) : n, next();
4978
- }
4979
- return next();
4980
- }
4981
- };
4982
- }
4983
- //#endregion
4984
7177
  //#region packages/ag-term/src/ag.ts
4985
7178
  /**
4986
7179
  * Ag — tree + layout engine + renderer.
@@ -5071,129 +7264,106 @@ function createAg(root, options) {
5071
7264
  const kittyGraphics = options?.kittyGraphics !== void 0 ? options.kittyGraphics : isKittyGraphicsEnabledFromEnv();
5072
7265
  const ctx = measurer ? { measurer } : void 0;
5073
7266
  let _prevBuffer = null;
7267
+ let _postState = createRenderPostState();
5074
7268
  let _kittyActive = false;
5075
7269
  let hasScroll = false;
5076
7270
  let hasSticky = false;
5077
7271
  function doLayout(cols, rows, opts) {
5078
- try {
5079
- var _usingCtx$1 = _usingCtx();
5080
- const prevRootLayout = root.boxRect;
5081
- if (!(prevRootLayout && (prevRootLayout.width !== cols || prevRootLayout.height !== rows)) && !root.layoutNode?.isDirty() && !hasScrollDirty()) {
5082
- log.debug?.("layout: skipped (Flexily clean, no scrollDirty, dimensions unchanged)");
5083
- layoutPhase(root, cols, rows);
5084
- return {
5085
- tMeasure: 0,
5086
- tLayout: 0,
5087
- tScroll: 0,
5088
- tScrollRect: 0,
5089
- tNotify: 0
5090
- };
5091
- }
5092
- const render = _usingCtx$1.u(baseLog.span("pipeline", {
5093
- width: cols,
5094
- height: rows
5095
- }));
5096
- let tMeasure;
5097
- try {
5098
- var _usingCtx3 = _usingCtx();
5099
- _usingCtx3.u(render.span("measure"));
5100
- const t = performance.now();
5101
- measurePhase(root, ctx);
5102
- tMeasure = performance.now() - t;
5103
- log.debug?.(`measure: ${tMeasure.toFixed(2)}ms`);
5104
- } catch (_) {
5105
- _usingCtx3.e = _;
5106
- } finally {
5107
- _usingCtx3.d();
5108
- }
5109
- let tLayout;
5110
- try {
5111
- var _usingCtx4 = _usingCtx();
5112
- _usingCtx4.u(render.span("layout"));
5113
- const t = performance.now();
5114
- layoutPhase(root, cols, rows);
5115
- tLayout = performance.now() - t;
5116
- log.debug?.(`layout: ${tLayout.toFixed(2)}ms`);
5117
- } catch (_) {
5118
- _usingCtx4.e = _;
5119
- } finally {
5120
- _usingCtx4.d();
5121
- }
5122
- strictLayoutOverflowCheck(root);
5123
- if (!hasScroll || !hasSticky) {
5124
- const features = detectPipelineFeatures(root);
5125
- if (features.hasScroll) hasScroll = true;
5126
- if (features.hasSticky) hasSticky = true;
5127
- }
5128
- let tScroll;
5129
- if (hasScroll) try {
5130
- var _usingCtx5 = _usingCtx();
5131
- _usingCtx5.u(render.span("scroll"));
5132
- const t = performance.now();
5133
- scrollPhase(root, { skipStateUpdates: opts?.skipScrollStateUpdates });
5134
- tScroll = performance.now() - t;
5135
- } catch (_) {
5136
- _usingCtx5.e = _;
5137
- } finally {
5138
- _usingCtx5.d();
5139
- }
5140
- else tScroll = 0;
5141
- if (hasSticky) stickyPhase(root);
5142
- let tScrollRect;
5143
- try {
5144
- var _usingCtx6 = _usingCtx();
5145
- _usingCtx6.u(render.span("scrollRect"));
5146
- const t = performance.now();
5147
- if (hasScroll || hasSticky) scrollrectPhase(root);
5148
- else scrollrectPhaseSimple(root);
5149
- tScrollRect = performance.now() - t;
5150
- } catch (_) {
5151
- _usingCtx6.e = _;
5152
- } finally {
5153
- _usingCtx6.d();
5154
- }
5155
- let tNotify = 0;
5156
- if (!opts?.skipLayoutNotifications) try {
5157
- var _usingCtx7 = _usingCtx();
5158
- _usingCtx7.u(render.span("notify"));
5159
- const t = performance.now();
5160
- notifyLayoutSubscribers(root);
5161
- tNotify = performance.now() - t;
5162
- } catch (_) {
5163
- _usingCtx7.e = _;
5164
- } finally {
5165
- _usingCtx7.d();
5166
- }
5167
- const acc = globalThis.__silvery_bench_phases;
5168
- if (acc) {
5169
- acc.measure += tMeasure;
5170
- acc.layout += tLayout;
5171
- acc.scroll += tScroll;
5172
- acc.scrollRect += tScrollRect;
5173
- acc.notify += tNotify;
5174
- acc.layoutTotal += tMeasure + tLayout + tScroll + tScrollRect + tNotify;
5175
- }
7272
+ const prevRootLayout = root.boxRect;
7273
+ if (!(prevRootLayout && (prevRootLayout.width !== cols || prevRootLayout.height !== rows)) && !root.layoutNode?.isDirty() && !hasScrollDirty()) {
7274
+ log.debug?.("layout: skipped (Flexily clean, no scrollDirty, dimensions unchanged)");
7275
+ layoutPhase(root, cols, rows);
5176
7276
  return {
5177
- tMeasure,
5178
- tLayout,
5179
- tScroll,
5180
- tScrollRect,
5181
- tNotify
7277
+ tMeasure: 0,
7278
+ tLayout: 0,
7279
+ tScroll: 0,
7280
+ tScrollRect: 0,
7281
+ tNotify: 0
5182
7282
  };
5183
- } catch (_) {
5184
- _usingCtx$1.e = _;
5185
- } finally {
5186
- _usingCtx$1.d();
5187
7283
  }
7284
+ using render = baseLog.span?.("pipeline", {
7285
+ width: cols,
7286
+ height: rows
7287
+ });
7288
+ let tMeasure;
7289
+ {
7290
+ using _m = render?.span("measure");
7291
+ const t = performance.now();
7292
+ measurePhase(root, ctx);
7293
+ tMeasure = performance.now() - t;
7294
+ log.debug?.(`measure: ${tMeasure.toFixed(2)}ms`);
7295
+ }
7296
+ let tLayout;
7297
+ {
7298
+ using _l = render?.span("layout");
7299
+ const t = performance.now();
7300
+ layoutPhase(root, cols, rows);
7301
+ tLayout = performance.now() - t;
7302
+ log.debug?.(`layout: ${tLayout.toFixed(2)}ms`);
7303
+ }
7304
+ strictLayoutOverflowCheck(root);
7305
+ if (!hasScroll || !hasSticky) {
7306
+ const features = detectPipelineFeatures(root);
7307
+ if (features.hasScroll) hasScroll = true;
7308
+ if (features.hasSticky) hasSticky = true;
7309
+ }
7310
+ let tScroll;
7311
+ if (hasScroll) {
7312
+ using _s = render?.span("scroll");
7313
+ const t = performance.now();
7314
+ scrollPhase(root, { skipStateUpdates: opts?.skipScrollStateUpdates });
7315
+ tScroll = performance.now() - t;
7316
+ } else tScroll = 0;
7317
+ if (hasSticky) stickyPhase(root);
7318
+ let tScrollRect;
7319
+ {
7320
+ using _r = render?.span("scrollRect");
7321
+ const t = performance.now();
7322
+ if (hasScroll || hasSticky) scrollrectPhase(root);
7323
+ else scrollrectPhaseSimple(root);
7324
+ tScrollRect = performance.now() - t;
7325
+ }
7326
+ let tNotify = 0;
7327
+ if (!opts?.skipLayoutNotifications) {
7328
+ using _n = render?.span("notify");
7329
+ const t = performance.now();
7330
+ notifyLayoutSubscribers(root);
7331
+ tNotify = performance.now() - t;
7332
+ }
7333
+ const acc = globalThis.__silvery_bench_phases;
7334
+ if (acc) {
7335
+ acc.measure += tMeasure;
7336
+ acc.layout += tLayout;
7337
+ acc.scroll += tScroll;
7338
+ acc.scrollRect += tScrollRect;
7339
+ acc.notify += tNotify;
7340
+ acc.layoutTotal += tMeasure + tLayout + tScroll + tScrollRect + tNotify;
7341
+ }
7342
+ return {
7343
+ tMeasure,
7344
+ tLayout,
7345
+ tScroll,
7346
+ tScrollRect,
7347
+ tNotify
7348
+ };
5188
7349
  }
5189
7350
  function doRender(opts) {
5190
7351
  clearBgConflictWarnings();
5191
7352
  const prevBuffer = opts?.fresh ? null : opts?.prevBuffer !== void 0 ? opts.prevBuffer : _prevBuffer;
7353
+ const postState = opts?.fresh ? createRenderPostState() : opts?.postState ?? _postState;
5192
7354
  let tContent;
5193
7355
  let buffer;
5194
7356
  {
5195
7357
  const t = performance.now();
5196
- buffer = renderPhase(root, prevBuffer, ctx);
7358
+ if (isRenderPlanEnabled()) {
7359
+ const layout = root.boxRect;
7360
+ if (!layout) throw new Error("doRender: SILVERY_RENDER_PLAN enabled but root has no boxRect");
7361
+ const captured = withPlanCapture(layout.width, layout.height, () => renderPhase(root, prevBuffer, ctx, postState));
7362
+ captured.result;
7363
+ const replay = prevBuffer && prevBuffer.width === layout.width && prevBuffer.height === layout.height ? prevBuffer.clone() : new TerminalBuffer(layout.width, layout.height);
7364
+ commitSectionedPlan(replay, captured.plan);
7365
+ buffer = replay;
7366
+ } else buffer = renderPhase(root, prevBuffer, ctx, postState);
5197
7367
  tContent = performance.now() - t;
5198
7368
  log.debug?.(`content: ${tContent.toFixed(2)}ms`);
5199
7369
  }
@@ -5228,7 +7398,8 @@ function createAg(root, options) {
5228
7398
  carryForwardBuffer,
5229
7399
  prevBuffer,
5230
7400
  tContent,
5231
- overlay
7401
+ overlay,
7402
+ postState
5232
7403
  };
5233
7404
  }
5234
7405
  function agCreateNode(type, props) {
@@ -5281,11 +7452,13 @@ function createAg(root, options) {
5281
7452
  buffer: result.buffer,
5282
7453
  carryForwardBuffer: result.carryForwardBuffer,
5283
7454
  prevBuffer: result.prevBuffer,
5284
- overlay: result.overlay
7455
+ overlay: result.overlay,
7456
+ postState: result.postState
5285
7457
  };
5286
7458
  },
5287
7459
  resetBuffer() {
5288
7460
  _prevBuffer = null;
7461
+ _postState = createRenderPostState();
5289
7462
  },
5290
7463
  createNode: agCreateNode,
5291
7464
  insertChild: agInsertChild,
@@ -5362,7 +7535,7 @@ init_buffer();
5362
7535
  let engineInitialized = false;
5363
7536
  async function ensureLayoutEngine() {
5364
7537
  if (engineInitialized || isLayoutEngineInitialized()) return;
5365
- const { ensureDefaultLayoutEngine } = await import("./layout-engine-B6Cdz1yZ.mjs");
7538
+ const { ensureDefaultLayoutEngine } = await import("./layout-engine-C07LEXWT.mjs");
5366
7539
  await ensureDefaultLayoutEngine();
5367
7540
  engineInitialized = true;
5368
7541
  }
@@ -5426,7 +7599,7 @@ function renderStringSync(element, options = {}) {
5426
7599
  removeListener: () => mockStdout,
5427
7600
  addListener: () => mockStdout
5428
7601
  };
5429
- const mockTerm = createTerm({ color: plain ? null : "truecolor" });
7602
+ const mockTerm = createTerm({ colorLevel: plain ? null : "truecolor" });
5430
7603
  const wrapped = React.createElement(TermContext.Provider, { value: mockTerm }, React.createElement(StdoutContext.Provider, { value: {
5431
7604
  stdout: mockStdout,
5432
7605
  write: () => {}
@@ -5509,6 +7682,6 @@ function withActEnvironment(fn) {
5509
7682
  }
5510
7683
  }
5511
7684
  //#endregion
5512
- export { setActiveColorLevel as a, _usingCtx as i, renderStringSync as n, createAg as r, renderString as t };
7685
+ export { printPassHistogram as _, createRenderPostState as a, setActiveColorLevel as c, appendHistogramJson as d, assertBoundedConvergence as f, notePassCommit as g, logPass as h, hexToRgb$1 as i, INSTRUMENT as l, beginPass as m, renderStringSync as n, isStrictEnabled as o, beginConvergenceLoop as p, createAg as r, parseColor as s, renderString as t, MAX_CONVERGENCE_PASSES as u, resetPassHistogram as v };
5513
7686
 
5514
- //# sourceMappingURL=render-string-X-CxpTdZ.mjs.map
7687
+ //# sourceMappingURL=render-string-DkQacASz.mjs.map