silvery 0.21.1 → 0.23.1

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 (165) hide show
  1. package/dist/{Text-BRf-59j2.mjs → Text-4tWKabKH.mjs} +46 -46
  2. package/dist/Text-4tWKabKH.mjs.map +1 -0
  3. package/dist/{ag-BeFC4S2N.mjs → ag-B-7XAHL8.mjs} +42 -19
  4. package/dist/ag-B-7XAHL8.mjs.map +1 -0
  5. package/dist/{animation-N8MFybTk.mjs → animation-CZ4x2R7C.mjs} +1 -1
  6. package/dist/{animation-N8MFybTk.mjs.map → animation-CZ4x2R7C.mjs.map} +1 -1
  7. package/dist/{ansi-C6Qs1Wn2.mjs → ansi-CLmIMnop.mjs} +1 -1
  8. package/dist/{ansi-C6Qs1Wn2.mjs.map → ansi-CLmIMnop.mjs.map} +1 -1
  9. package/dist/{ansi-CBkam1ty.d.mts → ansi-qYRTDp5m.d.mts} +1 -1
  10. package/dist/{ansi-CBkam1ty.d.mts.map → ansi-qYRTDp5m.d.mts.map} +1 -1
  11. package/dist/chain-bridge-B7LNJwVF.mjs +4149 -0
  12. package/dist/chain-bridge-B7LNJwVF.mjs.map +1 -0
  13. package/dist/cli-CuNV0hTE.mjs +4 -0
  14. package/dist/{context-BU5LkkIy.mjs → context-CEhuD1R_.mjs} +1 -1
  15. package/dist/{context-BU5LkkIy.mjs.map → context-CEhuD1R_.mjs.map} +1 -1
  16. package/dist/devtools-BuTq07Zb.mjs +2 -0
  17. package/dist/{devtools-9zfhpFyG.mjs → devtools-akTjVxxX.mjs} +3 -3
  18. package/dist/{devtools-9zfhpFyG.mjs.map → devtools-akTjVxxX.mjs.map} +1 -1
  19. package/dist/{easing-B0oZKDki.d.mts → easing-8OlNopWv.d.mts} +1 -1
  20. package/dist/{easing-B0oZKDki.d.mts.map → easing-8OlNopWv.d.mts.map} +1 -1
  21. package/dist/{eta-DGOuC8yU.mjs → eta-CPZ45g_P.mjs} +1 -1
  22. package/dist/{eta-DGOuC8yU.mjs.map → eta-CPZ45g_P.mjs.map} +1 -1
  23. package/dist/{flexily-zero-adapter-D6hcFgrH.mjs → flexily-zero-adapter-DktX3HsX.mjs} +1 -1
  24. package/dist/{flexily-zero-adapter-CEJOcbNp.mjs → flexily-zero-adapter-VoBGHG14.mjs} +1 -1
  25. package/dist/{flexily-zero-adapter-CEJOcbNp.mjs.map → flexily-zero-adapter-VoBGHG14.mjs.map} +1 -1
  26. package/dist/hit-registry-DRV2ig-Y.d.mts +71 -0
  27. package/dist/hit-registry-DRV2ig-Y.d.mts.map +1 -0
  28. package/dist/{image-1nRyKa60.mjs → image-o2WpbrD1.mjs} +7 -7
  29. package/dist/{image-1nRyKa60.mjs.map → image-o2WpbrD1.mjs.map} +1 -1
  30. package/dist/{index-2E1jYgak.d.mts → index-3vAxA_EQ.d.mts} +35 -12
  31. package/dist/index-3vAxA_EQ.d.mts.map +1 -0
  32. package/dist/{index-DnuadDNL.d.mts → index-BKujGENR.d.mts} +8590 -11173
  33. package/dist/index-BKujGENR.d.mts.map +1 -0
  34. package/dist/{index-Bi4Jdz5g.d.mts → index-C6oOd9eE.d.mts} +2 -2
  35. package/dist/{index-Bi4Jdz5g.d.mts.map → index-C6oOd9eE.d.mts.map} +1 -1
  36. package/dist/{index-Dg1YaeJb.d.mts → index-DhYF3m7V.d.mts} +2 -2
  37. package/dist/{index-Dg1YaeJb.d.mts.map → index-DhYF3m7V.d.mts.map} +1 -1
  38. package/dist/index.d.mts +12 -8
  39. package/dist/index.d.mts.map +1 -1
  40. package/dist/index.mjs +18 -17
  41. package/dist/index.mjs.map +1 -1
  42. package/dist/{layout-engine-Ca_nbtfL.mjs → layout-engine-BYp49XPD.mjs} +3 -3
  43. package/dist/{layout-engine-Ca_nbtfL.mjs.map → layout-engine-BYp49XPD.mjs.map} +1 -1
  44. package/dist/{layout-engine-CgsoBRIn.mjs → layout-engine-D0dXw5fr.mjs} +1 -1
  45. package/dist/{layout-signals-Dch2EiCy.mjs → layout-signals-uQadGDmO.mjs} +2 -2
  46. package/dist/layout-signals-uQadGDmO.mjs.map +1 -0
  47. package/dist/{mouse-events-hnbJZRwK.mjs → mouse-events-Pg-XKR3T.mjs} +35 -6
  48. package/dist/mouse-events-Pg-XKR3T.mjs.map +1 -0
  49. package/dist/{bound-term-BumfuXXW.d.mts → mouse-events-UoUWNIdS.d.mts} +559 -401
  50. package/dist/mouse-events-UoUWNIdS.d.mts.map +1 -0
  51. package/dist/{multi-progress-CaXTuL9G.mjs → multi-progress-Bvd1BYED.mjs} +3 -3
  52. package/dist/{multi-progress-CaXTuL9G.mjs.map → multi-progress-Bvd1BYED.mjs.map} +1 -1
  53. package/dist/{multi-progress-Bg4ngK80.d.mts → multi-progress-DPOCjomS.d.mts} +2 -2
  54. package/dist/{multi-progress-Bg4ngK80.d.mts.map → multi-progress-DPOCjomS.d.mts.map} +1 -1
  55. package/dist/{node-BiFu8I9Y.mjs → node-Cl-kzQcl.mjs} +1 -1
  56. package/dist/{node-BiFu8I9Y.mjs.map → node-Cl-kzQcl.mjs.map} +1 -1
  57. package/dist/{progress-B_UPy6zk.mjs → progress-1r6M_ZAP.mjs} +4 -4
  58. package/dist/{progress-B_UPy6zk.mjs.map → progress-1r6M_ZAP.mjs.map} +1 -1
  59. package/dist/{progress-bar-DmIMPdL0.mjs → progress-bar-DzW1DzNj.mjs} +4 -4
  60. package/dist/{progress-bar-DmIMPdL0.mjs.map → progress-bar-DzW1DzNj.mjs.map} +1 -1
  61. package/dist/{reconciler-NBDSEm8k.mjs → reconciler-lsTL1y8i.mjs} +111 -110
  62. package/dist/reconciler-lsTL1y8i.mjs.map +1 -0
  63. package/dist/{render-string-B4h4SmK7.mjs → render-string-2ZcV8n3B.mjs} +15 -11
  64. package/dist/render-string-2ZcV8n3B.mjs.map +1 -0
  65. package/dist/{render-string-BntLj7Xq.mjs → render-string-BJZqo5Rr.mjs} +1 -1
  66. package/dist/runtime.d.mts +3 -2
  67. package/dist/runtime.mjs +3 -2
  68. package/dist/{schemes-DYt2ushj.mjs → schemes-C4SUgVea.mjs} +2 -2
  69. package/dist/{schemes-DYt2ushj.mjs.map → schemes-C4SUgVea.mjs.map} +1 -1
  70. package/dist/{spinner-Py8_-hn9.mjs → spinner-Ck3SdCpW.mjs} +3 -3
  71. package/dist/{spinner-Py8_-hn9.mjs.map → spinner-Ck3SdCpW.mjs.map} +1 -1
  72. package/dist/{spinner-CLgzJ_QF.d.mts → spinner-CpcGtQ9L.d.mts} +2 -2
  73. package/dist/{spinner-CLgzJ_QF.d.mts.map → spinner-CpcGtQ9L.d.mts.map} +1 -1
  74. package/dist/{src-WeA_J4BV.mjs → src-B6MuVlOK.mjs} +88 -45
  75. package/dist/src-B6MuVlOK.mjs.map +1 -0
  76. package/dist/{src-CbhWmUnF.mjs → src-BZOfzNeo.mjs} +1484 -1488
  77. package/dist/src-BZOfzNeo.mjs.map +1 -0
  78. package/dist/{src-B9S_woYc.mjs → src-ChuXo--x.mjs} +20290 -23261
  79. package/dist/src-ChuXo--x.mjs.map +1 -0
  80. package/dist/{src-Dvq-s8iD.mjs → src-DdGaRDi9.mjs} +3 -3
  81. package/dist/{src-Dvq-s8iD.mjs.map → src-DdGaRDi9.mjs.map} +1 -1
  82. package/dist/{src-Oe6x5PrS.mjs → src-DorsnS70.mjs} +190 -76
  83. package/dist/src-DorsnS70.mjs.map +1 -0
  84. package/dist/{steps-DYrzCUCK.d.mts → steps-DV34gm_Z.d.mts} +1 -1
  85. package/dist/{steps-DYrzCUCK.d.mts.map → steps-DV34gm_Z.d.mts.map} +1 -1
  86. package/dist/{svg-DhxQkz-O.mjs → svg-NmV9dgB9.mjs} +1 -1
  87. package/dist/{svg-DhxQkz-O.mjs.map → svg-NmV9dgB9.mjs.map} +1 -1
  88. package/dist/{svg-Hk7lIl4F.d.mts → svg-wvIEu-RS.d.mts} +1 -1
  89. package/dist/{svg-Hk7lIl4F.d.mts.map → svg-wvIEu-RS.d.mts.map} +1 -1
  90. package/dist/term.d.mts +3 -3
  91. package/dist/term.mjs +4 -4
  92. package/dist/test.d.mts +673 -0
  93. package/dist/test.d.mts.map +1 -0
  94. package/dist/test.mjs +2829 -0
  95. package/dist/test.mjs.map +1 -0
  96. package/dist/theme.d.mts +2 -2
  97. package/dist/theme.d.mts.map +1 -1
  98. package/dist/theme.mjs +3 -3
  99. package/dist/{types-Bx-XZNbE.d.mts → types-VyreAPAB.d.mts} +1 -1
  100. package/dist/{types-Bx-XZNbE.d.mts.map → types-VyreAPAB.d.mts.map} +1 -1
  101. package/dist/ui/animation.d.mts +2 -2
  102. package/dist/ui/animation.mjs +1 -1
  103. package/dist/ui/ansi.d.mts +1 -1
  104. package/dist/ui/ansi.mjs +1 -1
  105. package/dist/ui/cli.d.mts +3 -3
  106. package/dist/ui/cli.mjs +5 -5
  107. package/dist/ui/image.d.mts +1 -1
  108. package/dist/ui/image.mjs +1 -1
  109. package/dist/ui/progress.d.mts +4 -4
  110. package/dist/ui/progress.mjs +4 -4
  111. package/dist/ui/react.d.mts +1 -1
  112. package/dist/ui/react.mjs +2 -2
  113. package/dist/ui/recording-chrome-react.d.mts +1 -1
  114. package/dist/ui/recording-chrome-react.mjs +2 -2
  115. package/dist/ui/recording-chrome.d.mts +1 -1
  116. package/dist/ui/recording-chrome.mjs +1 -1
  117. package/dist/ui/utils.mjs +1 -1
  118. package/dist/ui/wrappers.d.mts +2 -2
  119. package/dist/ui/wrappers.mjs +1 -1
  120. package/dist/ui.d.mts +6 -6
  121. package/dist/ui.mjs +7 -7
  122. package/dist/{ansi-OYRLxxZQ.mjs → unicode-X43-fdGF.mjs} +7317 -6909
  123. package/dist/unicode-X43-fdGF.mjs.map +1 -0
  124. package/dist/{useLatest-DC8i7guK.d.mts → useLatest-D9p0ZDAC.d.mts} +2 -2
  125. package/dist/{useLatest-DC8i7guK.d.mts.map → useLatest-D9p0ZDAC.d.mts.map} +1 -1
  126. package/dist/{useLayout-BKsQl2Or.mjs → useLayout-Cx01rHVI.mjs} +4 -4
  127. package/dist/{useLayout-BKsQl2Or.mjs.map → useLayout-Cx01rHVI.mjs.map} +1 -1
  128. package/dist/{with-text-input-DG4f7JII.d.mts → with-text-input-CW8MR9Th.d.mts} +3 -3
  129. package/dist/{with-text-input-DG4f7JII.d.mts.map → with-text-input-CW8MR9Th.d.mts.map} +1 -1
  130. package/dist/wrap-measurer-registration-DNBmtFNM.d.mts +3106 -0
  131. package/dist/wrap-measurer-registration-DNBmtFNM.d.mts.map +1 -0
  132. package/dist/{wrapper-D7gNSsgf.mjs → wrapper-DpMauwdI.mjs} +15 -17
  133. package/dist/wrapper-DpMauwdI.mjs.map +1 -0
  134. package/dist/{wrappers-CypAzrMO.mjs → wrappers-DHMzvbRo.mjs} +6 -6
  135. package/dist/{wrappers-CypAzrMO.mjs.map → wrappers-DHMzvbRo.mjs.map} +1 -1
  136. package/dist/yoga-adapter-B-DCP4Bx.mjs +2 -0
  137. package/dist/{yoga-adapter-SsEIqMc1.mjs → yoga-adapter-CB_7pLyl.mjs} +2 -2
  138. package/dist/{yoga-adapter-SsEIqMc1.mjs.map → yoga-adapter-CB_7pLyl.mjs.map} +1 -1
  139. package/package.json +21 -16
  140. package/dist/Text-BRf-59j2.mjs.map +0 -1
  141. package/dist/ag-BeFC4S2N.mjs.map +0 -1
  142. package/dist/ansi-OYRLxxZQ.mjs.map +0 -1
  143. package/dist/bound-term-BumfuXXW.d.mts.map +0 -1
  144. package/dist/cli-DG6zsfsS.mjs +0 -4
  145. package/dist/devtools-nmPUmYU_.mjs +0 -2
  146. package/dist/index-2E1jYgak.d.mts.map +0 -1
  147. package/dist/index-DnuadDNL.d.mts.map +0 -1
  148. package/dist/layout-signals-Dch2EiCy.mjs.map +0 -1
  149. package/dist/mouse-events-hnbJZRwK.mjs.map +0 -1
  150. package/dist/reconciler-NBDSEm8k.mjs.map +0 -1
  151. package/dist/render-string-B4h4SmK7.mjs.map +0 -1
  152. package/dist/src-B9S_woYc.mjs.map +0 -1
  153. package/dist/src-CbhWmUnF.mjs.map +0 -1
  154. package/dist/src-Oe6x5PrS.mjs.map +0 -1
  155. package/dist/src-WeA_J4BV.mjs.map +0 -1
  156. package/dist/ui/display.d.mts +0 -35
  157. package/dist/ui/display.d.mts.map +0 -1
  158. package/dist/ui/display.mjs +0 -123
  159. package/dist/ui/display.mjs.map +0 -1
  160. package/dist/ui/input.d.mts +0 -184
  161. package/dist/ui/input.d.mts.map +0 -1
  162. package/dist/ui/input.mjs +0 -283
  163. package/dist/ui/input.mjs.map +0 -1
  164. package/dist/wrapper-D7gNSsgf.mjs.map +0 -1
  165. package/dist/yoga-adapter-1ex8r0ws.mjs +0 -2
@@ -0,0 +1,4149 @@
1
+ import { y as getAncestorPath } from "./mouse-events-Pg-XKR3T.mjs";
2
+ import { _ as rectEqual, a as findActiveParkRect, g as setWrapMeasurer, h as getWrapMeasurer, i as findActiveCursorRectWithProvenance, n as computeContentRect, r as findActiveCursorRect, t as commitLayoutSnapshot } from "./layout-signals-uQadGDmO.mjs";
3
+ import { B as resolveNonTTYMode, L as countLines, R as createOutputTransformer, _t as findActiveCursorNode, k as createPipeline } from "./src-BZOfzNeo.mjs";
4
+ import { $t as bufferToText, Ct as isStrictEnabled, D as runWithMeasurer, L as wrapTextWithOffsets, N as stripAnsi, bt as recordOutputCursorDiagnostics, en as cellEquals, gt as outputPhase, ht as getLastOutputPhaseDiagnostics, it as createOsc52Backend, kt as ANSI, mn as parseHotkey, nn as colorEquals, pt as clearLastOutputPhaseDiagnostics, vt as IncrementalRenderMismatchError } from "./unicode-X43-fdGF.mjs";
5
+ import { C as isDirty, x as isAnyDirty } from "./reconciler-lsTL1y8i.mjs";
6
+ import { n as createRenderPostState, o as FRESH_LAYOUT_STRICT_SLUG, s as markLayoutTreeDirty, t as createAg } from "./ag-B-7XAHL8.mjs";
7
+ import { createLogger } from "loggily";
8
+ import { appendFileSync, existsSync, mkdirSync } from "node:fs";
9
+ import { createContext } from "react";
10
+ import { mkdir } from "node:fs/promises";
11
+ import { join } from "node:path";
12
+ import { Fragment, jsx } from "react/jsx-runtime";
13
+ //#region packages/ag/src/focus-events.ts
14
+ /**
15
+ * Create a synthetic keyboard event.
16
+ */
17
+ function createKeyEvent(input, key, target) {
18
+ let propagationStopped = false;
19
+ let defaultPrevented = false;
20
+ return {
21
+ key: input,
22
+ input,
23
+ ctrl: key.ctrl,
24
+ meta: key.meta,
25
+ shift: key.shift,
26
+ super: key.super,
27
+ hyper: key.hyper,
28
+ eventType: key.eventType,
29
+ target,
30
+ currentTarget: target,
31
+ nativeEvent: {
32
+ input,
33
+ key
34
+ },
35
+ get propagationStopped() {
36
+ return propagationStopped;
37
+ },
38
+ get defaultPrevented() {
39
+ return defaultPrevented;
40
+ },
41
+ stopPropagation() {
42
+ propagationStopped = true;
43
+ },
44
+ preventDefault() {
45
+ defaultPrevented = true;
46
+ }
47
+ };
48
+ }
49
+ /**
50
+ * Create a synthetic focus event.
51
+ */
52
+ function createFocusEvent(type, target, relatedTarget) {
53
+ let propagationStopped = false;
54
+ return {
55
+ type,
56
+ target,
57
+ relatedTarget,
58
+ currentTarget: target,
59
+ get propagationStopped() {
60
+ return propagationStopped;
61
+ },
62
+ stopPropagation() {
63
+ propagationStopped = true;
64
+ }
65
+ };
66
+ }
67
+ /**
68
+ * Dispatch a keyboard event through the render tree with DOM-style
69
+ * capture/target/bubble phases.
70
+ *
71
+ * For press/repeat events:
72
+ * 1. Capture phase: root → target (onKeyDownCapture props)
73
+ * 2. Target phase: target's onKeyDown
74
+ * 3. Bubble phase: target parent → root (onKeyDown props)
75
+ *
76
+ * For release events:
77
+ * 1. Target phase: target's onKeyUp
78
+ * 2. Bubble phase: target parent → root (onKeyUp props)
79
+ * (No capture phase for keyUp — deliberate simplification; React DOM has onKeyUpCapture)
80
+ *
81
+ * stopPropagation() halts traversal at any phase.
82
+ */
83
+ function dispatchKeyEvent(event, dispatch) {
84
+ const path = getAncestorPath(event.target);
85
+ const mutableEvent = event;
86
+ const isRelease = event.eventType === "release";
87
+ const handlerProp = isRelease ? "onKeyUp" : "onKeyDown";
88
+ if (!isRelease) for (let i = path.length - 1; i > 0; i--) {
89
+ if (event.propagationStopped) return;
90
+ const node = path[i];
91
+ const handler = node.props.onKeyDownCapture;
92
+ if (handler) {
93
+ mutableEvent.currentTarget = node;
94
+ handler(event);
95
+ }
96
+ }
97
+ if (!event.propagationStopped) {
98
+ const target = path[0];
99
+ mutableEvent.currentTarget = target;
100
+ const handler = target.props[handlerProp];
101
+ if (handler) handler(event, dispatch);
102
+ }
103
+ for (let i = 1; i < path.length; i++) {
104
+ if (event.propagationStopped) return;
105
+ const node = path[i];
106
+ const handler = node.props[handlerProp];
107
+ if (handler) {
108
+ mutableEvent.currentTarget = node;
109
+ handler(event, dispatch);
110
+ }
111
+ }
112
+ }
113
+ /**
114
+ * Dispatch a focus event through the render tree.
115
+ *
116
+ * Fires onFocus/onBlur on the target, then bubbles to ancestors.
117
+ */
118
+ function dispatchFocusEvent(event) {
119
+ const handlerProp = event.type === "focus" ? "onFocus" : "onBlur";
120
+ const path = getAncestorPath(event.target);
121
+ const mutableEvent = event;
122
+ for (const node of path) {
123
+ if (event.propagationStopped) break;
124
+ const handler = node.props[handlerProp];
125
+ if (handler) {
126
+ mutableEvent.currentTarget = node;
127
+ handler(event);
128
+ }
129
+ }
130
+ }
131
+ //#endregion
132
+ //#region packages/create/src/pipe.ts
133
+ function pipe(base, ...plugins) {
134
+ let result = base;
135
+ for (const plugin of plugins) result = plugin(result);
136
+ return result;
137
+ }
138
+ //#endregion
139
+ //#region packages/create/src/internal/capability-registry.ts
140
+ /** Create a new capability registry (one per app instance). */
141
+ function createCapabilityRegistry() {
142
+ const capabilities = /* @__PURE__ */ new Map();
143
+ return {
144
+ register(key, capability) {
145
+ capabilities.set(key, capability);
146
+ },
147
+ get(key) {
148
+ return capabilities.get(key);
149
+ }
150
+ };
151
+ }
152
+ //#endregion
153
+ //#region packages/create/src/internal/capabilities.ts
154
+ /**
155
+ * Capability symbols — well-known keys for the CapabilityRegistry.
156
+ *
157
+ * Features register themselves under these symbols so other parts
158
+ * of the composition chain can discover and interact with them.
159
+ *
160
+ * @internal Not exported from the public barrel.
161
+ */
162
+ /** Selection feature: text selection state + mouse handling. */
163
+ const SELECTION_CAPABILITY = Symbol.for("silvery.selection");
164
+ /** Drag feature: drag-and-drop state + mouse handling. */
165
+ const DRAG_CAPABILITY = Symbol.for("silvery.drag");
166
+ //#endregion
167
+ //#region packages/ag-term/src/features/selection.ts
168
+ /**
169
+ * Create a bridge SelectionFeature that delegates to an external state owner.
170
+ *
171
+ * Used by create-app to expose its inline selection state to React hooks
172
+ * (useSelection) and copy-mode (which calls setRange/clear) without
173
+ * duplicating the state machine. Mouse handlers are no-ops — the external
174
+ * owner (create-app's event loop) handles mouse events directly.
175
+ */
176
+ function createSelectionBridge(options) {
177
+ return {
178
+ get state() {
179
+ return options.getState();
180
+ },
181
+ subscribe(listener) {
182
+ return options.subscribe(listener);
183
+ },
184
+ handleMouseDown(_col, _row, _altKey) {},
185
+ handleMouseMove(_col, _row) {},
186
+ handleMouseUp(_col, _row) {},
187
+ setRange(range) {
188
+ options.setRange(range);
189
+ },
190
+ clear() {
191
+ options.clear();
192
+ },
193
+ copySelection() {
194
+ options.copy?.();
195
+ },
196
+ dispose() {}
197
+ };
198
+ }
199
+ //#endregion
200
+ //#region packages/create/src/runtime/with-terminal-chain.ts
201
+ /**
202
+ * Install the terminal observer + lifecycle plugin.
203
+ *
204
+ * The returned app exposes the terminal slice at `app.terminal`.
205
+ * Plugins upstream (last in `pipe()`) see ops first; this plugin
206
+ * should go near the BASE of the chain so focused/useInput plugins
207
+ * handle actual key content before the terminal observer peeks at
208
+ * modifier state.
209
+ */
210
+ function withTerminalChain(options = {}) {
211
+ return (app) => {
212
+ const store = {
213
+ cols: options.cols ?? 80,
214
+ rows: options.rows ?? 24,
215
+ focused: options.focused ?? true,
216
+ modifiers: {
217
+ ctrl: false,
218
+ shift: false,
219
+ alt: false,
220
+ meta: false,
221
+ super: false,
222
+ hyper: false
223
+ }
224
+ };
225
+ const prev = app.apply;
226
+ app.apply = (op) => {
227
+ if (op.type === "input:key") {
228
+ const key = op.key;
229
+ if (key) store.modifiers = {
230
+ ctrl: !!key.ctrl,
231
+ shift: !!key.shift,
232
+ alt: !!key.alt || !!key.meta,
233
+ meta: !!key.meta,
234
+ super: !!key.super,
235
+ hyper: !!key.hyper
236
+ };
237
+ return prev(op);
238
+ }
239
+ if (op.type === "term:resize") {
240
+ const cols = op.cols;
241
+ const rows = op.rows;
242
+ if (typeof cols === "number") store.cols = cols;
243
+ if (typeof rows === "number") store.rows = rows;
244
+ const effects = [{ type: "render" }];
245
+ const downstream = prev(op);
246
+ if (downstream !== false) effects.push(...downstream);
247
+ return effects;
248
+ }
249
+ if (op.type === "term:focus") {
250
+ const focused = !!op.focused;
251
+ store.focused = focused;
252
+ if (!focused) store.modifiers = {
253
+ ctrl: false,
254
+ shift: false,
255
+ alt: false,
256
+ meta: false,
257
+ super: false,
258
+ hyper: false
259
+ };
260
+ return [];
261
+ }
262
+ return prev(op);
263
+ };
264
+ return Object.assign(app, { terminal: store });
265
+ };
266
+ }
267
+ //#endregion
268
+ //#region packages/create/src/runtime/with-focus-chain.ts
269
+ function isModifierOnly$1(_input, key) {
270
+ return key?.isModifierOnly === true;
271
+ }
272
+ /**
273
+ * Install the focus-dispatch plugin.
274
+ *
275
+ * Place this plugin OUTERMOST in the input-handling chain so focused
276
+ * targets see the key before the `useInput` fallback store.
277
+ */
278
+ function withFocusChain(options) {
279
+ return (app) => {
280
+ const store = { lastConsumed: false };
281
+ const prev = app.apply;
282
+ app.apply = (op) => {
283
+ if (op.type !== "input:key") return prev(op);
284
+ const input = op.input ?? "";
285
+ const key = op.key;
286
+ const isRelease = key?.eventType === "release";
287
+ const modOnly = isModifierOnly$1(input, key);
288
+ if ((isRelease || modOnly) && !options.dispatchReleaseAndModifierOnly) return prev(op);
289
+ let consumed = false;
290
+ try {
291
+ consumed = !!options.dispatchKey(input, key ?? {});
292
+ } catch (err) {
293
+ console.error("[withFocusChain] dispatchKey threw", err);
294
+ }
295
+ store.lastConsumed = consumed;
296
+ if (consumed) return [{ type: "render" }];
297
+ return prev(op);
298
+ };
299
+ return Object.assign(app, { focusChain: store });
300
+ };
301
+ }
302
+ //#endregion
303
+ //#region packages/commands/src/with-commands.ts
304
+ /**
305
+ * Find command by short name or full id.
306
+ *
307
+ * Supports both:
308
+ * - Exact id match: `cmd['cursor_down']`
309
+ * - Short name match: `cmd.down` (matches 'cursor_down', 'navigation_down', etc.)
310
+ */
311
+ function findCommand(registry, key) {
312
+ const byId = registry.get(key);
313
+ if (byId) return byId;
314
+ return registry.getAll().find((c) => {
315
+ return c.id.split(/[._]/).pop() === key;
316
+ });
317
+ }
318
+ /**
319
+ * Get keys bound to a command.
320
+ */
321
+ function getKeysForCommand(commandId, keybindings) {
322
+ if (!keybindings) return [];
323
+ return keybindings.filter((kb) => kb.commandId === commandId).map((kb) => {
324
+ const parts = [];
325
+ if (kb.cmd) parts.push("Cmd");
326
+ if (kb.ctrl) parts.push("Ctrl");
327
+ if (kb.alt || kb.opt) parts.push("Alt");
328
+ if (kb.shift) parts.push("Shift");
329
+ parts.push(kb.key);
330
+ return parts.join("+");
331
+ });
332
+ }
333
+ /**
334
+ * Format help text for all commands.
335
+ */
336
+ function formatHelp(registry, keybindings) {
337
+ return registry.getAll().map((cmd) => {
338
+ const keys = getKeysForCommand(cmd.id, keybindings);
339
+ const keyStr = keys.length > 0 ? ` [${keys.join(", ")}]` : "";
340
+ return `${cmd.id}${keyStr}: ${cmd.description}`;
341
+ }).join("\n");
342
+ }
343
+ function withCommands(appOrOptions, maybeOptions) {
344
+ if (maybeOptions === void 0) {
345
+ const options = appOrOptions;
346
+ return (app) => applyCommands(app, options);
347
+ }
348
+ return applyCommands(appOrOptions, maybeOptions);
349
+ }
350
+ function applyCommands(app, options) {
351
+ const { registry, getContext, handleAction, getKeybindings } = options;
352
+ const cmd = new Proxy({}, {
353
+ get(_, prop) {
354
+ if (typeof prop === "symbol") return void 0;
355
+ if (prop === "all") return () => {
356
+ const commands = registry.getAll();
357
+ const keybindings = getKeybindings?.();
358
+ return commands.map((c) => ({
359
+ id: c.id,
360
+ name: c.name,
361
+ description: c.description,
362
+ keys: getKeysForCommand(c.id, keybindings)
363
+ }));
364
+ };
365
+ if (prop === "describe") return () => formatHelp(registry, getKeybindings?.());
366
+ const def = findCommand(registry, prop);
367
+ if (!def) return void 0;
368
+ const fn = async () => {
369
+ const ctx = getContext();
370
+ const result = def.execute(ctx);
371
+ if (result) {
372
+ const actions = Array.isArray(result) ? result : [result];
373
+ for (const action of actions) handleAction(action);
374
+ }
375
+ await Promise.resolve();
376
+ };
377
+ const keybindings = getKeybindings?.();
378
+ Object.defineProperties(fn, {
379
+ id: {
380
+ value: def.id,
381
+ enumerable: true
382
+ },
383
+ name: {
384
+ value: def.name,
385
+ enumerable: true
386
+ },
387
+ help: {
388
+ value: def.description,
389
+ enumerable: true
390
+ },
391
+ keys: {
392
+ value: getKeysForCommand(def.id, keybindings),
393
+ enumerable: true
394
+ }
395
+ });
396
+ return fn;
397
+ },
398
+ has(_, prop) {
399
+ if (typeof prop === "symbol") return false;
400
+ if (prop === "all" || prop === "describe") return true;
401
+ return !!findCommand(registry, prop);
402
+ }
403
+ });
404
+ const getState = () => {
405
+ const commands = registry.getAll();
406
+ const keybindings = getKeybindings?.();
407
+ return {
408
+ screen: app.text,
409
+ commands: commands.map((c) => ({
410
+ id: c.id,
411
+ name: c.name,
412
+ description: c.description,
413
+ keys: getKeysForCommand(c.id, keybindings)
414
+ })),
415
+ focus: void 0
416
+ };
417
+ };
418
+ return Object.assign(app, {
419
+ cmd,
420
+ getState
421
+ });
422
+ }
423
+ //#endregion
424
+ //#region packages/commands/src/with-keybindings.ts
425
+ /**
426
+ * Resolve a key press to a command ID using keybinding lookup.
427
+ */
428
+ function resolveKeybinding(key, modifiers, bindings, ctx) {
429
+ for (const binding of bindings) {
430
+ if (binding.key !== key) continue;
431
+ if (!!binding.ctrl !== !!modifiers.ctrl) continue;
432
+ if (!!binding.opt !== !!modifiers.meta) continue;
433
+ if (!!binding.cmd !== !!modifiers.super) continue;
434
+ if (!(key.length === 1 && key >= "A" && key <= "Z" && !binding.shift) && !!binding.shift !== !!modifiers.shift) continue;
435
+ if (binding.modes && binding.modes.length > 0) {
436
+ if (!binding.modes.includes(ctx.mode)) continue;
437
+ }
438
+ if (binding.when && !binding.when(ctx)) continue;
439
+ return binding.commandId;
440
+ }
441
+ return null;
442
+ }
443
+ function withKeybindings(appOrOptions, maybeOptions) {
444
+ if (maybeOptions === void 0) {
445
+ const options = appOrOptions;
446
+ return (app) => applyKeybindings(app, options);
447
+ }
448
+ return applyKeybindings(appOrOptions, maybeOptions);
449
+ }
450
+ function applyKeybindings(app, options) {
451
+ const { bindings, getKeyContext } = options;
452
+ const originalPress = app.press.bind(app);
453
+ return new Proxy(app, { get(target, prop, receiver) {
454
+ if (prop === "press") return async function interceptedPress(keyStr) {
455
+ const { key, ...modifiers } = parseHotkey(keyStr);
456
+ const commandId = resolveKeybinding(key, modifiers, bindings, getKeyContext());
457
+ if (commandId) {
458
+ const cmd = target.cmd[commandId];
459
+ if (cmd) {
460
+ await cmd();
461
+ return receiver;
462
+ }
463
+ }
464
+ await originalPress(keyStr);
465
+ return receiver;
466
+ };
467
+ return Reflect.get(target, prop, receiver);
468
+ } });
469
+ }
470
+ //#endregion
471
+ //#region packages/test/src/compare-buffers.ts
472
+ /**
473
+ * Buffer comparison utility for differential rendering tests.
474
+ *
475
+ * Compares two terminal buffers cell-by-cell, returning the first
476
+ * mismatch found (or null if buffers are identical).
477
+ *
478
+ * ## Documented-distinct from the termless comparator family
479
+ *
480
+ * The terminal-flow vertical slice (§7 "Comparator family") converges the
481
+ * ecosystem's differs — termless `diffBuffers`, `terminalStateDigest` /
482
+ * `diffTerminalStates`, and the restore-equivalence oracle's state digest — into
483
+ * ONE compare family in termless. `compareBuffers` here is deliberately **NOT** a
484
+ * member of that family, and NOT a duplicate of `diffBuffers`. It is a
485
+ * **render-level** check, kept distinct by design:
486
+ *
487
+ * - **Different input.** The termless family compares terminal *state* read
488
+ * through the `TerminalReadable` contract ("are these two terminals in the same
489
+ * observable state?"). `compareBuffers` compares two silvery `TerminalBuffer`s —
490
+ * silvery's internal packed render target, which is NOT a `TerminalReadable` and
491
+ * cannot be one: it carries render-only semantics (the `SELECTABLE_FLAG`, the
492
+ * `DEFAULT_BG` sentinel, inheritedBg-resolved cells, `continuation` occupancy)
493
+ * that exist only inside the render pipeline, before any emulator sees output.
494
+ * - **Different question.** Every caller (`with-diagnostics`'s `SILVERY_STRICT`
495
+ * `incremental` check, the incremental-rendering fuzz, `render-plan-parity`)
496
+ * asks "did silvery's incremental / replayed render path produce the identical
497
+ * render buffer as a fresh / recorded render?" — the incremental≡fresh
498
+ * invariant. That has no analog in the termless state-equivalence family.
499
+ * - **Non-temporal A/B framing.** `cellA`/`cellB` are two renders of the SAME
500
+ * frame (incremental vs fresh, replayed vs recorded) — deliberately NOT the
501
+ * `oldCell`/`newCell` temporal framing of `diffBuffers`, and it returns the
502
+ * FIRST mismatch (fail-fast for an assertion) rather than every changed cell
503
+ * (a differential corpus).
504
+ *
505
+ * Structural correspondence, for readers crossing between the two: this file's
506
+ * `{ x, y }` is `diffBuffers`'s `{ col, row }` (x=col, y=row); `cellA`/`cellB`
507
+ * carry silvery buffer `Cell`s (fg/bg = `number | { r, g, b, index? } | null`),
508
+ * whose color shape is index-preserving and thus structurally compatible with the
509
+ * termless `Cell`'s `Color = { r, g, b, index? }` — but the two types are never
510
+ * imported across the silvery↔termless boundary (compatible shapes, not shared
511
+ * imports; slice §9 rule 7).
512
+ */
513
+ /**
514
+ * Compare two terminal buffers cell-by-cell.
515
+ *
516
+ * @returns The first mismatch found, or null if buffers are identical.
517
+ */
518
+ function compareBuffers(a, b) {
519
+ const width = Math.max(a.width, b.width);
520
+ const height = Math.max(a.height, b.height);
521
+ for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
522
+ const cellA = a.inBounds(x, y) ? a.getCell(x, y) : {
523
+ char: " ",
524
+ fg: null,
525
+ bg: null,
526
+ underlineColor: null,
527
+ attrs: {},
528
+ wide: false,
529
+ continuation: false
530
+ };
531
+ const cellB = b.inBounds(x, y) ? b.getCell(x, y) : {
532
+ char: " ",
533
+ fg: null,
534
+ bg: null,
535
+ underlineColor: null,
536
+ attrs: {},
537
+ wide: false,
538
+ continuation: false
539
+ };
540
+ if (!cellEquals(cellA, cellB)) return {
541
+ x,
542
+ y,
543
+ cellA,
544
+ cellB
545
+ };
546
+ }
547
+ return null;
548
+ }
549
+ /**
550
+ * Format a buffer mismatch for human-readable error output.
551
+ */
552
+ function formatMismatch(mismatch, context) {
553
+ const { x, y, cellA, cellB } = mismatch;
554
+ const lines = [
555
+ `Buffer mismatch at (${x}, ${y})`,
556
+ ` incremental: char=${JSON.stringify(cellA.char)} fg=${JSON.stringify(cellA.fg)} bg=${JSON.stringify(cellA.bg)} attrs=${JSON.stringify(cellA.attrs)}`,
557
+ ` fresh: char=${JSON.stringify(cellB.char)} fg=${JSON.stringify(cellB.fg)} bg=${JSON.stringify(cellB.bg)} attrs=${JSON.stringify(cellB.attrs)}`
558
+ ];
559
+ if (context?.seed !== void 0) lines.push(` seed: ${context.seed}`);
560
+ if (context?.iteration !== void 0) lines.push(` iteration: ${context.iteration}`);
561
+ if (context?.key) lines.push(` key: ${JSON.stringify(context.key)}`);
562
+ if (context?.incrementalText) lines.push("", "--- incremental ---", context.incrementalText);
563
+ if (context?.freshText) lines.push("", "--- fresh ---", context.freshText);
564
+ return lines.join("\n");
565
+ }
566
+ //#endregion
567
+ //#region packages/ag-term/src/plugins/with-diagnostics.ts
568
+ /**
569
+ * withDiagnostics - Plugin for buffer and rendering diagnostic checks
570
+ *
571
+ * Wraps the `cmd` object to check invariants after command execution:
572
+ * - All commands: Check incremental vs fresh render
573
+ * - Cursor moves: Also check buffer content stability
574
+ * - Optional: ANSI replay verification (characters AND SGR styles)
575
+ *
576
+ * ## Design Note: Why wrap `cmd` instead of `sendInput`?
577
+ *
578
+ * The two approaches are complementary:
579
+ *
580
+ * 1. **`sendInput()` level** (in renderer.ts) — Already has `SILVERY_STRICT`
581
+ * which catches ALL inputs regardless of how they arrive (raw key presses,
582
+ * type(), press(), etc.). This is the right place for incremental render checks.
583
+ *
584
+ * 2. **`cmd` level** (this plugin) — Command-aware, can selectively check stability
585
+ * for cursor moves only. Raw sendInput doesn't know which inputs are cursor
586
+ * commands that should preserve content. Another option would be `withInput()`
587
+ * which could wrap sendInput with awareness of what input was sent.
588
+ *
589
+ * This plugin focuses on the command-aware checks. For comprehensive incremental
590
+ * render checking, use `SILVERY_STRICT=1` environment variable which enables all checks.
591
+ *
592
+ * @example
593
+ * ```typescript
594
+ * import { withDiagnostics } from '@silvery/ag-term/toolbelt';
595
+ *
596
+ * // All checks enabled by default when you call withDiagnostics()
597
+ * const driver = withDiagnostics(createBoardDriver(repo, rootId));
598
+ *
599
+ * // Or disable specific checks
600
+ * const driver = withDiagnostics(createBoardDriver(repo, rootId), {
601
+ * checkReplay: false // skip ANSI replay check
602
+ * });
603
+ *
604
+ * // Commands now run invariant checks automatically
605
+ * await driver.cmd.down(); // Checks incremental + stability + replay
606
+ * await driver.cmd.search(); // Checks incremental + replay
607
+ * ```
608
+ */
609
+ /**
610
+ * Virtual terminal simulator for testing ANSI replay equivalence.
611
+ *
612
+ * Parses ANSI sequences and applies them to a 2D grid tracking both
613
+ * character content and SGR style attributes (fg, bg, bold, italic, etc.).
614
+ * Used to verify the Replay Equivalence invariant: applying the ANSI
615
+ * diff to the previous buffer state should produce the target buffer.
616
+ *
617
+ * Handles:
618
+ * - Cursor positioning (H, G, A, B, C, D)
619
+ * - Line clear (K)
620
+ * - Wide characters (emojis, CJK)
621
+ * - CR/LF
622
+ * - SGR style sequences (m) — fg/bg colors, text attributes
623
+ */
624
+ var VirtualTerminal = class {
625
+ grid;
626
+ wideMarker;
627
+ styles;
628
+ sgr;
629
+ cursorX = 0;
630
+ cursorY = 0;
631
+ constructor(width, height) {
632
+ this.width = width;
633
+ this.height = height;
634
+ this.grid = Array.from({ length: height }, () => Array(width).fill(" "));
635
+ this.wideMarker = Array.from({ length: height }, () => Array(width).fill(false));
636
+ this.styles = Array.from({ length: height }, () => Array.from({ length: width }, () => createDefaultVTermStyle()));
637
+ this.sgr = createDefaultVTermStyle();
638
+ }
639
+ /**
640
+ * Initialize grid from a TerminalBuffer (for incremental replay).
641
+ * Loads both character content and style attributes.
642
+ */
643
+ loadFromBuffer(buffer) {
644
+ for (let y = 0; y < Math.min(this.height, buffer.height); y++) for (let x = 0; x < Math.min(this.width, buffer.width); x++) if (buffer.isCellContinuation(x, y)) {
645
+ this.wideMarker[y][x] = true;
646
+ this.grid[y][x] = "";
647
+ this.styles[y][x] = createDefaultVTermStyle();
648
+ } else {
649
+ this.grid[y][x] = buffer.getCellChar(x, y);
650
+ this.wideMarker[y][x] = false;
651
+ const cell = buffer.getCell(x, y);
652
+ this.styles[y][x] = {
653
+ fg: cell.fg,
654
+ bg: cell.bg,
655
+ bold: !!cell.attrs.bold,
656
+ dim: !!cell.attrs.dim,
657
+ italic: !!cell.attrs.italic,
658
+ underline: !!cell.attrs.underline || !!cell.attrs.underlineStyle,
659
+ blink: !!cell.attrs.blink,
660
+ inverse: !!cell.attrs.inverse,
661
+ hidden: !!cell.attrs.hidden,
662
+ strikethrough: !!cell.attrs.strikethrough
663
+ };
664
+ }
665
+ }
666
+ /**
667
+ * Apply ANSI escape sequence string to the virtual terminal.
668
+ */
669
+ applyAnsi(ansi) {
670
+ let i = 0;
671
+ while (i < ansi.length) {
672
+ if (ansi[i] === "\x1B" && ansi[i + 1] === "[") {
673
+ const match = ansi.slice(i).match(/^\x1b\[([0-9;:?]*)([A-Za-z])/);
674
+ if (match) {
675
+ this.handleCsi(match[1] || "", match[2]);
676
+ i += match[0].length;
677
+ continue;
678
+ }
679
+ }
680
+ if (ansi[i] === "\x1B" && ansi[i + 1] === "]") {
681
+ let j = i + 2;
682
+ while (j < ansi.length) {
683
+ if (ansi[j] === "\x07") {
684
+ j++;
685
+ break;
686
+ }
687
+ if (ansi[j] === "\x1B" && ansi[j + 1] === "\\") {
688
+ j += 2;
689
+ break;
690
+ }
691
+ j++;
692
+ }
693
+ i = j;
694
+ continue;
695
+ }
696
+ if (ansi[i] === "\r") {
697
+ this.cursorX = 0;
698
+ i++;
699
+ continue;
700
+ }
701
+ if (ansi[i] === "\n") {
702
+ this.cursorY = Math.min(this.cursorY + 1, this.height - 1);
703
+ i++;
704
+ continue;
705
+ }
706
+ const char = this.extractChar(ansi, i);
707
+ if (this.cursorX < this.width && this.cursorY < this.height) {
708
+ this.grid[this.cursorY][this.cursorX] = char;
709
+ this.wideMarker[this.cursorY][this.cursorX] = false;
710
+ this.styles[this.cursorY][this.cursorX] = { ...this.sgr };
711
+ this.cursorX++;
712
+ if (this.isWideChar(char) && this.cursorX < this.width) {
713
+ this.grid[this.cursorY][this.cursorX] = "";
714
+ this.wideMarker[this.cursorY][this.cursorX] = true;
715
+ this.styles[this.cursorY][this.cursorX] = createDefaultVTermStyle();
716
+ this.cursorX++;
717
+ }
718
+ }
719
+ i += char.length;
720
+ }
721
+ }
722
+ /**
723
+ * Check if a character is wide (emoji, CJK, etc).
724
+ */
725
+ isWideChar(char) {
726
+ if (char.length === 0) return false;
727
+ if (char.includes("️")) return true;
728
+ const code = char.codePointAt(0) || 0;
729
+ if (code >= 127744 && code <= 129535) return true;
730
+ if (code >= 9728 && code <= 9983) return true;
731
+ if (code >= 9984 && code <= 10175) return true;
732
+ if (code >= 19968 && code <= 40959) return true;
733
+ if (code >= 12288 && code <= 12351) return true;
734
+ if (code >= 65280 && code <= 65519) return true;
735
+ return false;
736
+ }
737
+ /**
738
+ * Extract a single Unicode character (which may be multiple bytes).
739
+ * Includes VS16 (U+FE0F) if it follows, since VS16 is a presentation selector
740
+ * that modifies the preceding character's rendering width.
741
+ */
742
+ extractChar(str, start) {
743
+ const code = str.codePointAt(start);
744
+ if (code === void 0) return str[start] || "";
745
+ let char;
746
+ if (code > 65535) char = String.fromCodePoint(code);
747
+ else char = str[start] || "";
748
+ const nextIdx = start + char.length;
749
+ if (nextIdx < str.length && str.codePointAt(nextIdx) === 65039) char += "️";
750
+ return char;
751
+ }
752
+ handleCsi(params, cmd) {
753
+ switch (cmd) {
754
+ case "H": {
755
+ const parts = params.split(";");
756
+ this.cursorY = Math.max(0, (Number.parseInt(parts[0] || "1", 10) || 1) - 1);
757
+ this.cursorX = Math.max(0, (Number.parseInt(parts[1] || "1", 10) || 1) - 1);
758
+ break;
759
+ }
760
+ case "G":
761
+ this.cursorX = Math.max(0, (Number.parseInt(params || "1", 10) || 1) - 1);
762
+ break;
763
+ case "A": {
764
+ const n = Number.parseInt(params || "1", 10) || 1;
765
+ this.cursorY = Math.max(0, this.cursorY - n);
766
+ break;
767
+ }
768
+ case "B": {
769
+ const n = Number.parseInt(params || "1", 10) || 1;
770
+ this.cursorY = Math.min(this.height - 1, this.cursorY + n);
771
+ break;
772
+ }
773
+ case "C": {
774
+ const n = Number.parseInt(params || "1", 10) || 1;
775
+ this.cursorX = Math.min(this.width - 1, this.cursorX + n);
776
+ break;
777
+ }
778
+ case "D": {
779
+ const n = Number.parseInt(params || "1", 10) || 1;
780
+ this.cursorX = Math.max(0, this.cursorX - n);
781
+ break;
782
+ }
783
+ case "K": {
784
+ const mode = Number.parseInt(params || "0", 10);
785
+ if (mode === 0) for (let x = this.cursorX; x < this.width; x++) {
786
+ this.grid[this.cursorY][x] = " ";
787
+ this.wideMarker[this.cursorY][x] = false;
788
+ this.styles[this.cursorY][x] = {
789
+ ...createDefaultVTermStyle(),
790
+ bg: this.sgr.bg
791
+ };
792
+ }
793
+ else if (mode === 1) for (let x = 0; x <= this.cursorX; x++) {
794
+ this.grid[this.cursorY][x] = " ";
795
+ this.wideMarker[this.cursorY][x] = false;
796
+ this.styles[this.cursorY][x] = {
797
+ ...createDefaultVTermStyle(),
798
+ bg: this.sgr.bg
799
+ };
800
+ }
801
+ else if (mode === 2) for (let x = 0; x < this.width; x++) {
802
+ this.grid[this.cursorY][x] = " ";
803
+ this.wideMarker[this.cursorY][x] = false;
804
+ this.styles[this.cursorY][x] = {
805
+ ...createDefaultVTermStyle(),
806
+ bg: this.sgr.bg
807
+ };
808
+ }
809
+ break;
810
+ }
811
+ case "m":
812
+ this.applySgr(params);
813
+ break;
814
+ case "l":
815
+ case "h": break;
816
+ }
817
+ }
818
+ /**
819
+ * Apply SGR parameters to the current style state.
820
+ * Parses the semicolon-separated parameter string and updates this.sgr.
821
+ */
822
+ applySgr(params) {
823
+ if (params === "" || params === "0") {
824
+ Object.assign(this.sgr, createDefaultVTermStyle());
825
+ return;
826
+ }
827
+ const parts = params.split(";");
828
+ let i = 0;
829
+ while (i < parts.length) {
830
+ const code = parts[i];
831
+ const colonIdx = code.indexOf(":");
832
+ if (colonIdx >= 0) {
833
+ if (Number.parseInt(code.substring(0, colonIdx), 10) === 4) {
834
+ const sub = Number.parseInt(code.substring(colonIdx + 1), 10);
835
+ this.sgr.underline = sub > 0;
836
+ }
837
+ i++;
838
+ continue;
839
+ }
840
+ const n = Number.parseInt(code, 10);
841
+ if (n === 0) Object.assign(this.sgr, createDefaultVTermStyle());
842
+ else if (n === 1) this.sgr.bold = true;
843
+ else if (n === 2) this.sgr.dim = true;
844
+ else if (n === 3) this.sgr.italic = true;
845
+ else if (n === 4) this.sgr.underline = true;
846
+ else if (n === 5 || n === 6) this.sgr.blink = true;
847
+ else if (n === 7) this.sgr.inverse = true;
848
+ else if (n === 8) this.sgr.hidden = true;
849
+ else if (n === 9) this.sgr.strikethrough = true;
850
+ else if (n === 22) {
851
+ this.sgr.bold = false;
852
+ this.sgr.dim = false;
853
+ } else if (n === 23) this.sgr.italic = false;
854
+ else if (n === 24) this.sgr.underline = false;
855
+ else if (n === 25) this.sgr.blink = false;
856
+ else if (n === 27) this.sgr.inverse = false;
857
+ else if (n === 28) this.sgr.hidden = false;
858
+ else if (n === 29) this.sgr.strikethrough = false;
859
+ else if (n >= 30 && n <= 37) this.sgr.fg = n - 30;
860
+ else if (n === 38) {
861
+ if (i + 1 < parts.length && parts[i + 1] === "5" && i + 2 < parts.length) {
862
+ this.sgr.fg = Number.parseInt(parts[i + 2], 10);
863
+ i += 2;
864
+ } else if (i + 1 < parts.length && parts[i + 1] === "2" && i + 4 < parts.length) {
865
+ this.sgr.fg = {
866
+ r: Number.parseInt(parts[i + 2], 10),
867
+ g: Number.parseInt(parts[i + 3], 10),
868
+ b: Number.parseInt(parts[i + 4], 10)
869
+ };
870
+ i += 4;
871
+ }
872
+ } else if (n === 39) this.sgr.fg = null;
873
+ else if (n >= 40 && n <= 47) this.sgr.bg = n - 40;
874
+ else if (n === 48) {
875
+ if (i + 1 < parts.length && parts[i + 1] === "5" && i + 2 < parts.length) {
876
+ this.sgr.bg = Number.parseInt(parts[i + 2], 10);
877
+ i += 2;
878
+ } else if (i + 1 < parts.length && parts[i + 1] === "2" && i + 4 < parts.length) {
879
+ this.sgr.bg = {
880
+ r: Number.parseInt(parts[i + 2], 10),
881
+ g: Number.parseInt(parts[i + 3], 10),
882
+ b: Number.parseInt(parts[i + 4], 10)
883
+ };
884
+ i += 4;
885
+ }
886
+ } else if (n === 49) this.sgr.bg = null;
887
+ else if (n >= 90 && n <= 97) this.sgr.fg = n - 90 + 8;
888
+ else if (n >= 100 && n <= 107) this.sgr.bg = n - 100 + 8;
889
+ i++;
890
+ }
891
+ }
892
+ /**
893
+ * Get the character at a position.
894
+ */
895
+ getChar(x, y) {
896
+ if (this.wideMarker[y]?.[x]) return "";
897
+ return this.grid[y]?.[x] ?? " ";
898
+ }
899
+ /**
900
+ * Get the style at a position.
901
+ */
902
+ getStyle(x, y) {
903
+ return this.styles[y]?.[x] ?? createDefaultVTermStyle();
904
+ }
905
+ /**
906
+ * Compare with a TerminalBuffer and return character mismatches.
907
+ */
908
+ compareToBuffer(buffer) {
909
+ const mismatches = [];
910
+ for (let y = 0; y < Math.min(this.height, buffer.height); y++) for (let x = 0; x < Math.min(this.width, buffer.width); x++) {
911
+ if (buffer.isCellContinuation(x, y)) continue;
912
+ const expected = buffer.getCellChar(x, y);
913
+ const actual = this.getChar(x, y);
914
+ if (expected !== actual) mismatches.push({
915
+ x,
916
+ y,
917
+ expected,
918
+ actual
919
+ });
920
+ }
921
+ return mismatches;
922
+ }
923
+ /**
924
+ * Compare styles with a TerminalBuffer and return style mismatches.
925
+ * Checks fg, bg, bold, dim, italic, underline, blink, inverse, hidden, strikethrough.
926
+ */
927
+ compareStylesToBuffer(buffer) {
928
+ const mismatches = [];
929
+ for (let y = 0; y < Math.min(this.height, buffer.height); y++) for (let x = 0; x < Math.min(this.width, buffer.width); x++) {
930
+ if (buffer.isCellContinuation(x, y)) continue;
931
+ const cell = buffer.getCell(x, y);
932
+ const actual = this.getStyle(x, y);
933
+ const diffs = [];
934
+ if (!colorEquals(actual.fg, cell.fg)) diffs.push(`fg: ${formatVTermColor(actual.fg)} vs ${formatVTermColor(cell.fg)}`);
935
+ if (!colorEquals(actual.bg, cell.bg)) diffs.push(`bg: ${formatVTermColor(actual.bg)} vs ${formatVTermColor(cell.bg)}`);
936
+ if (actual.bold !== !!cell.attrs.bold) diffs.push(`bold: ${actual.bold} vs ${!!cell.attrs.bold}`);
937
+ if (actual.dim !== !!cell.attrs.dim) diffs.push(`dim: ${actual.dim} vs ${!!cell.attrs.dim}`);
938
+ if (actual.italic !== !!cell.attrs.italic) diffs.push(`italic: ${actual.italic} vs ${!!cell.attrs.italic}`);
939
+ const expectedUnderline = !!cell.attrs.underline || !!cell.attrs.underlineStyle;
940
+ if (actual.underline !== expectedUnderline) diffs.push(`underline: ${actual.underline} vs ${expectedUnderline}`);
941
+ if (actual.blink !== !!cell.attrs.blink) diffs.push(`blink: ${actual.blink} vs ${!!cell.attrs.blink}`);
942
+ if (actual.inverse !== !!cell.attrs.inverse) diffs.push(`inverse: ${actual.inverse} vs ${!!cell.attrs.inverse}`);
943
+ if (actual.hidden !== !!cell.attrs.hidden) diffs.push(`hidden: ${actual.hidden} vs ${!!cell.attrs.hidden}`);
944
+ if (actual.strikethrough !== !!cell.attrs.strikethrough) diffs.push(`strikethrough: ${actual.strikethrough} vs ${!!cell.attrs.strikethrough}`);
945
+ if (diffs.length > 0) mismatches.push({
946
+ x,
947
+ y,
948
+ char: cell.char,
949
+ diffs
950
+ });
951
+ }
952
+ return mismatches;
953
+ }
954
+ };
955
+ /**
956
+ * Create a default VTermStyle with all attributes reset to defaults.
957
+ */
958
+ function createDefaultVTermStyle() {
959
+ return {
960
+ fg: null,
961
+ bg: null,
962
+ bold: false,
963
+ dim: false,
964
+ italic: false,
965
+ underline: false,
966
+ blink: false,
967
+ inverse: false,
968
+ hidden: false,
969
+ strikethrough: false
970
+ };
971
+ }
972
+ /**
973
+ * Format a color value for diagnostic display.
974
+ */
975
+ function formatVTermColor(c) {
976
+ if (c === null) return "default";
977
+ if (typeof c === "number") return `${c}`;
978
+ return `rgb(${c.r},${c.g},${c.b})`;
979
+ }
980
+ /** Commands that should preserve buffer content (only cursor position changes) */
981
+ const CURSOR_COMMANDS = new Set([
982
+ "cursor_up",
983
+ "cursor_down",
984
+ "cursor_left",
985
+ "cursor_right",
986
+ "up",
987
+ "down",
988
+ "left",
989
+ "right"
990
+ ]);
991
+ /**
992
+ * Parse SILVERY_STABILITY_SKIP_LINES environment variable.
993
+ * Format: comma-separated integers, e.g., "0,-1"
994
+ */
995
+ function parseSkipLines(env) {
996
+ if (!env) return [];
997
+ return env.split(",").map((s) => Number.parseInt(s.trim(), 10)).filter((n) => !isNaN(n));
998
+ }
999
+ /**
1000
+ * Compare text content before and after a command.
1001
+ * Returns the first mismatch found, or null if content matches.
1002
+ *
1003
+ * @param before - Text before command execution
1004
+ * @param after - Text after command execution
1005
+ * @param skipLines - Line indices to skip (supports negative indices from end)
1006
+ */
1007
+ function compareText(before, after, skipLines) {
1008
+ const beforeLines = before.split("\n");
1009
+ const afterLines = after.split("\n");
1010
+ const maxLines = Math.max(beforeLines.length, afterLines.length);
1011
+ const skipSet = /* @__PURE__ */ new Set();
1012
+ for (const line of skipLines) if (line >= 0) skipSet.add(line);
1013
+ else skipSet.add(maxLines + line);
1014
+ for (let i = 0; i < maxLines; i++) {
1015
+ if (skipSet.has(i)) continue;
1016
+ const b = beforeLines[i] ?? "";
1017
+ const a = afterLines[i] ?? "";
1018
+ if (b !== a) return {
1019
+ line: i,
1020
+ before: b,
1021
+ after: a
1022
+ };
1023
+ }
1024
+ return null;
1025
+ }
1026
+ /**
1027
+ * Check layout tree integrity. Returns violation messages, or empty array if valid.
1028
+ *
1029
+ * Checks:
1030
+ * - All rect dimensions are finite and non-negative (width >= 0, height >= 0)
1031
+ * - All positions are finite (x, y are valid numbers)
1032
+ * - No NaN values in computed layout
1033
+ * - Children don't overflow parent bounds (1px tolerance for rounding)
1034
+ * - Skips overflow check for nodes with overflow:hidden/scroll (they intentionally clip)
1035
+ */
1036
+ function checkLayoutInvariants(node) {
1037
+ const violations = [];
1038
+ walkLayout(node, null, violations);
1039
+ return violations;
1040
+ }
1041
+ function walkLayout(node, parentRect, violations) {
1042
+ const rect = node.boxRect;
1043
+ if (!rect) return;
1044
+ const id = node.props.id ?? node.type;
1045
+ if (!Number.isFinite(rect.width) || rect.width < 0) violations.push(`${id}: invalid width ${rect.width}`);
1046
+ if (!Number.isFinite(rect.height) || rect.height < 0) violations.push(`${id}: invalid height ${rect.height}`);
1047
+ if (!Number.isFinite(rect.x)) violations.push(`${id}: invalid x ${rect.x}`);
1048
+ if (!Number.isFinite(rect.y)) violations.push(`${id}: invalid y ${rect.y}`);
1049
+ if (parentRect && !parentRect.clipped) {
1050
+ const TOLERANCE = 1;
1051
+ if (rect.x + rect.width > parentRect.x + parentRect.width + TOLERANCE) violations.push(`${id}: overflows parent right (${rect.x + rect.width} > ${parentRect.x + parentRect.width})`);
1052
+ if (rect.y + rect.height > parentRect.y + parentRect.height + TOLERANCE) violations.push(`${id}: overflows parent bottom (${rect.y + rect.height} > ${parentRect.y + parentRect.height})`);
1053
+ if (rect.x < parentRect.x - TOLERANCE) violations.push(`${id}: overflows parent left (${rect.x} < ${parentRect.x})`);
1054
+ if (rect.y < parentRect.y - TOLERANCE) violations.push(`${id}: overflows parent top (${rect.y} < ${parentRect.y})`);
1055
+ }
1056
+ const overflow = node.props.overflow;
1057
+ const clipped = overflow === "hidden" || overflow === "scroll";
1058
+ const childParentRect = {
1059
+ x: rect.x,
1060
+ y: rect.y,
1061
+ width: rect.width,
1062
+ height: rect.height,
1063
+ clipped
1064
+ };
1065
+ for (const child of node.children) walkLayout(child, childParentRect, violations);
1066
+ }
1067
+ /**
1068
+ * Add diagnostic checking to an app with commands.
1069
+ *
1070
+ * Wraps the `cmd` proxy to intercept all command executions and run checks:
1071
+ * - **All commands**: Check that incremental render matches fresh render
1072
+ * - **Cursor commands**: Also check that buffer content didn't change
1073
+ *
1074
+ * **All checks are enabled by default** when you call this function.
1075
+ * The principle: if you wrapped with withDiagnostics(), you want diagnostics.
1076
+ *
1077
+ * @param app - App with command system (from withCommands)
1078
+ * @param options - Diagnostic check configuration (all enabled by default)
1079
+ * @returns App with wrapped cmd that runs diagnostic checks
1080
+ */
1081
+ function withDiagnostics(app, options = {}) {
1082
+ const { checkIncremental = true, checkStability = true, checkReplay = true, checkLayout = true, skipLines = parseSkipLines(process.env.SILVERY_STABILITY_SKIP_LINES), captureOnFailure = false, screenshotDir = "/tmp/silvery-diagnostics" } = options;
1083
+ if (!checkIncremental && !checkStability && !checkReplay && !checkLayout) return app;
1084
+ /** Capture screenshot on diagnostic failure (best-effort, never masks original error) */
1085
+ async function captureFailureScreenshot(commandId, checkType) {
1086
+ if (!captureOnFailure) return null;
1087
+ try {
1088
+ await mkdir(screenshotDir, { recursive: true });
1089
+ const filepath = join(screenshotDir, `fail-${commandId}-${checkType}.png`);
1090
+ await app.screenshot(filepath);
1091
+ return filepath;
1092
+ } catch {
1093
+ return null;
1094
+ }
1095
+ }
1096
+ const wrappedCmd = new Proxy(app.cmd, {
1097
+ get(target, prop) {
1098
+ if (typeof prop === "symbol") return Reflect.get(target, prop);
1099
+ const original = Reflect.get(target, prop);
1100
+ if (typeof original !== "function") return original;
1101
+ if (prop === "all" || prop === "describe") return original;
1102
+ const command = original;
1103
+ const wrapped = async () => {
1104
+ const beforeText = app.text;
1105
+ const beforeBuffer = checkReplay ? app.lastBuffer() : null;
1106
+ await command();
1107
+ if (checkIncremental) {
1108
+ const incremental = app.lastBuffer();
1109
+ try {
1110
+ const fresh = app.freshRender();
1111
+ if (incremental && fresh) {
1112
+ const mismatch = compareBuffers(incremental, fresh);
1113
+ if (mismatch) {
1114
+ const incrementalText = app.text;
1115
+ const freshText = fresh ? Array.from({ length: fresh.height }, (_, y) => Array.from({ length: fresh.width }, (_, x) => fresh.getCellChar(x, y)).join("")).join("\n") : "(no fresh buffer)";
1116
+ const screenshotPath = await captureFailureScreenshot(command.id, "incremental");
1117
+ throw new Error(`SILVERY_DIAGNOSTIC: Incremental/fresh mismatch after ${command.id}\n` + formatMismatch(mismatch, {
1118
+ key: command.id,
1119
+ incrementalText,
1120
+ freshText
1121
+ }) + (screenshotPath ? `\n Screenshot saved: ${screenshotPath}` : ""));
1122
+ }
1123
+ }
1124
+ } catch (e) {
1125
+ if (!(e instanceof Error) || !e.message.includes("only available in test renderer")) throw e;
1126
+ }
1127
+ }
1128
+ if (checkStability && CURSOR_COMMANDS.has(command.id)) {
1129
+ const afterText = app.text;
1130
+ const mismatch = compareText(beforeText, afterText, skipLines);
1131
+ if (mismatch) {
1132
+ const screenshotPath = await captureFailureScreenshot(command.id, "stability");
1133
+ throw new Error(`SILVERY_DIAGNOSTIC: Content changed after cursor move ${command.id}\n Line ${mismatch.line}: "${mismatch.before}" → "${mismatch.after}"` + (screenshotPath ? `\n Screenshot saved: ${screenshotPath}` : ""));
1134
+ }
1135
+ }
1136
+ if (checkReplay && beforeBuffer) {
1137
+ const afterBuffer = app.lastBuffer();
1138
+ if (afterBuffer) {
1139
+ const ansiDiff = outputPhase(beforeBuffer, afterBuffer);
1140
+ const vterm = new VirtualTerminal(afterBuffer.width, afterBuffer.height);
1141
+ vterm.loadFromBuffer(beforeBuffer);
1142
+ vterm.applyAnsi(ansiDiff);
1143
+ const mismatches = vterm.compareToBuffer(afterBuffer);
1144
+ if (mismatches.length > 0) {
1145
+ const details = mismatches.slice(0, 5).map((m) => ` (${m.x},${m.y}): expected="${m.expected}" actual="${m.actual}"`).join("\n");
1146
+ const screenshotPath = await captureFailureScreenshot(command.id, "replay");
1147
+ throw new Error(`SILVERY_DIAGNOSTIC: ANSI replay mismatch after ${command.id}\n ${mismatches.length} cells differ:\n${details}` + (mismatches.length > 5 ? `\n ... and ${mismatches.length - 5} more` : "") + (screenshotPath ? `\n Screenshot saved: ${screenshotPath}` : ""));
1148
+ }
1149
+ const styleMismatches = vterm.compareStylesToBuffer(afterBuffer);
1150
+ if (styleMismatches.length > 0) {
1151
+ const details = styleMismatches.slice(0, 5).map((m) => ` (${m.x},${m.y}) char="${m.char}": ${m.diffs.join(", ")}`).join("\n");
1152
+ const screenshotPath = await captureFailureScreenshot(command.id, "replay-style");
1153
+ throw new Error(`SILVERY_DIAGNOSTIC: ANSI replay style mismatch after ${command.id}\n ${styleMismatches.length} cells have style differences:\n${details}` + (styleMismatches.length > 5 ? `\n ... and ${styleMismatches.length - 5} more` : "") + (screenshotPath ? `\n Screenshot saved: ${screenshotPath}` : ""));
1154
+ }
1155
+ }
1156
+ }
1157
+ if (checkLayout) {
1158
+ const violations = checkLayoutInvariants(app.getContainer());
1159
+ if (violations.length > 0) {
1160
+ const details = violations.slice(0, 10).map((v) => ` ${v}`).join("\n");
1161
+ const screenshotPath = await captureFailureScreenshot(command.id, "layout");
1162
+ throw new Error(`SILVERY_DIAGNOSTIC: Layout invariant violation after ${command.id}\n ${violations.length} violation(s):\n${details}` + (violations.length > 10 ? `\n ... and ${violations.length - 10} more` : "") + (screenshotPath ? `\n Screenshot saved: ${screenshotPath}` : ""));
1163
+ }
1164
+ }
1165
+ };
1166
+ Object.defineProperties(wrapped, {
1167
+ id: {
1168
+ value: command.id,
1169
+ enumerable: true
1170
+ },
1171
+ name: {
1172
+ value: command.name,
1173
+ enumerable: true
1174
+ },
1175
+ help: {
1176
+ value: command.help,
1177
+ enumerable: true
1178
+ },
1179
+ keys: {
1180
+ value: command.keys,
1181
+ enumerable: true
1182
+ }
1183
+ });
1184
+ return wrapped;
1185
+ },
1186
+ has(target, prop) {
1187
+ return Reflect.has(target, prop);
1188
+ },
1189
+ ownKeys(target) {
1190
+ return Reflect.ownKeys(target);
1191
+ },
1192
+ getOwnPropertyDescriptor(target, prop) {
1193
+ return Reflect.getOwnPropertyDescriptor(target, prop);
1194
+ }
1195
+ });
1196
+ return Object.assign(app, { cmd: wrappedCmd });
1197
+ }
1198
+ //#endregion
1199
+ //#region packages/ag-term/src/plugins/with-render.ts
1200
+ /**
1201
+ * Extend a Term with render pipeline capabilities.
1202
+ *
1203
+ * Creates a pipeline config (width measurer + output phase) from the term's caps,
1204
+ * and adds render() and renderStatic() methods plus measurer methods.
1205
+ *
1206
+ * @param term - A Term instance (from createTerm)
1207
+ * @returns Extended term with render and measurement capabilities
1208
+ */
1209
+ function withRender(term) {
1210
+ const pipelineConfig = createPipeline({ caps: term.caps });
1211
+ const { measurer } = pipelineConfig;
1212
+ function renderPipeline(root, width, height, prevBuffer, options) {
1213
+ const { mode = "fullscreen", skipLayoutNotifications = false, skipScrollStateUpdates = false, scrollbackOffset = 0, termRows, cursorPos } = typeof options === "string" ? { mode: options } : options ?? {};
1214
+ const doRender = () => {
1215
+ const ag = createAg(root, { measurer });
1216
+ ag.layout({
1217
+ cols: width,
1218
+ rows: height
1219
+ }, {
1220
+ skipLayoutNotifications,
1221
+ skipScrollStateUpdates
1222
+ });
1223
+ const { buffer, overlay } = ag.render({ prevBuffer });
1224
+ let output = (pipelineConfig.outputPhaseFn ?? outputPhase)(prevBuffer, buffer, mode, scrollbackOffset, termRows, cursorPos);
1225
+ if (overlay) output += overlay;
1226
+ return {
1227
+ output,
1228
+ buffer
1229
+ };
1230
+ };
1231
+ return measurer ? runWithMeasurer(measurer, doRender) : doRender();
1232
+ }
1233
+ async function renderStaticFn(element, options) {
1234
+ const { renderString } = await import("./render-string-BJZqo5Rr.mjs");
1235
+ return renderString(element, {
1236
+ ...options,
1237
+ pipelineConfig
1238
+ });
1239
+ }
1240
+ return Object.create(term, {
1241
+ maybeWideEmojis: {
1242
+ get: () => measurer.maybeWideEmojis,
1243
+ enumerable: true
1244
+ },
1245
+ textSizing: {
1246
+ get: () => measurer.textSizing,
1247
+ enumerable: true
1248
+ },
1249
+ displayWidth: {
1250
+ value: measurer.displayWidth.bind(measurer),
1251
+ enumerable: true
1252
+ },
1253
+ displayWidthAnsi: {
1254
+ value: measurer.displayWidthAnsi.bind(measurer),
1255
+ enumerable: true
1256
+ },
1257
+ graphemeWidth: {
1258
+ value: measurer.graphemeWidth.bind(measurer),
1259
+ enumerable: true
1260
+ },
1261
+ wrapText: {
1262
+ value: measurer.wrapText.bind(measurer),
1263
+ enumerable: true
1264
+ },
1265
+ sliceByWidth: {
1266
+ value: measurer.sliceByWidth.bind(measurer),
1267
+ enumerable: true
1268
+ },
1269
+ sliceByWidthFromEnd: {
1270
+ value: measurer.sliceByWidthFromEnd.bind(measurer),
1271
+ enumerable: true
1272
+ },
1273
+ pipelineConfig: {
1274
+ value: pipelineConfig,
1275
+ enumerable: true
1276
+ },
1277
+ render: {
1278
+ value: renderPipeline,
1279
+ enumerable: true
1280
+ },
1281
+ renderStatic: {
1282
+ value: renderStaticFn,
1283
+ enumerable: true
1284
+ }
1285
+ });
1286
+ }
1287
+ //#endregion
1288
+ //#region packages/create/src/runtime/base-app.ts
1289
+ /**
1290
+ * Create a fresh {@link BaseApp}. The base handles nothing — all
1291
+ * behavior comes from plugins.
1292
+ *
1293
+ * Call order inside `dispatch(op)`:
1294
+ * 1. `apply(op)` (runs the plugin chain, inside reentry guard)
1295
+ * 2. Queue the returned effects (if handled)
1296
+ * 3. Drain loop — dispatch-type effects re-enter, others bubble up to
1297
+ * the runner via {@link BaseApp.drainEffects}
1298
+ */
1299
+ function createBaseApp() {
1300
+ let dispatching = false;
1301
+ let draining = false;
1302
+ /** Effects currently queued from the active dispatch() + its drain. */
1303
+ const effectQueue = [];
1304
+ /** Effects the runner should interpret (everything not consumed by
1305
+ * the internal dispatch-drain, e.g. render/exit). */
1306
+ const pendingRunnerEffects = [];
1307
+ const app = {
1308
+ dispatch(op) {
1309
+ if (dispatching) throw new Error(`Reentrant dispatch: ${op.type}`);
1310
+ dispatching = true;
1311
+ try {
1312
+ const result = app.apply(op);
1313
+ if (result !== false) effectQueue.push(...result);
1314
+ } finally {
1315
+ dispatching = false;
1316
+ }
1317
+ if (draining) return;
1318
+ draining = true;
1319
+ try {
1320
+ while (effectQueue.length > 0) {
1321
+ const batch = effectQueue.splice(0);
1322
+ for (const eff of batch) if (eff.type === "dispatch") {
1323
+ const nested = eff.op;
1324
+ if (!nested || typeof nested.type !== "string") continue;
1325
+ dispatching = true;
1326
+ try {
1327
+ const nestedResult = app.apply(nested);
1328
+ if (nestedResult !== false) effectQueue.push(...nestedResult);
1329
+ } finally {
1330
+ dispatching = false;
1331
+ }
1332
+ } else pendingRunnerEffects.push(eff);
1333
+ }
1334
+ } finally {
1335
+ draining = false;
1336
+ }
1337
+ },
1338
+ apply() {
1339
+ return false;
1340
+ },
1341
+ drainEffects() {
1342
+ if (pendingRunnerEffects.length === 0) return [];
1343
+ return pendingRunnerEffects.splice(0);
1344
+ }
1345
+ };
1346
+ return app;
1347
+ }
1348
+ createContext(null);
1349
+ createContext({
1350
+ activeId: void 0,
1351
+ isFocusEnabled: true,
1352
+ add() {},
1353
+ remove() {},
1354
+ activate() {},
1355
+ deactivate() {},
1356
+ enableFocus() {},
1357
+ disableFocus() {},
1358
+ focusNext() {},
1359
+ focusPrevious() {},
1360
+ focus() {},
1361
+ blur() {}
1362
+ });
1363
+ //#endregion
1364
+ //#region packages/ag-term/src/bytes-out-monitor.ts
1365
+ /**
1366
+ * Render-output throughput monitor — backs the `SILVERY_STRICT=bytes_out`
1367
+ * slug. Watches the bytes silvery's render pipeline writes to stdout and
1368
+ * trips at fixed thresholds.
1369
+ *
1370
+ * Why this exists: silvery emits ANSI bytes to stdout; those bytes leave the
1371
+ * process and land in the pty parent's buffer (terminal multiplexer
1372
+ * scrollback — cmux, tmux, etc.). Internal heap profilers
1373
+ * (`process.memoryUsage`, `Bun.heapStats`, `--inspect`) cannot see this
1374
+ * firehose because the bytes are already gone. The 2026-05-13 cmux/silvercode
1375
+ * incident attributed multi-GB of memory to cmux, not silvercode; silvercode's
1376
+ * own heap looked healthy throughout. See
1377
+ * `docs/lessons/cmux-pty-buffer-firehose.md` (in km root) for the full
1378
+ * incident write-up.
1379
+ *
1380
+ * Behavior (fixed thresholds — no env knobs):
1381
+ * - WARN at sustained ≥1 MB/s over a 10s window. Emits a `silvery:bytes_out`
1382
+ * log + writes frame summaries to `/tmp/silvery-bytes-out-warn-<pid>-<ts>.txt`.
1383
+ * Cooldown 10s before re-trip.
1384
+ * - PANIC at burst ≥100 MB/s over a 2s window. Emits frame summaries AND a
1385
+ * v8 heap snapshot. Cooldown 60s before re-trip.
1386
+ *
1387
+ * Activation: tier 1 default (inherited from `SILVERY_STRICT=1`). Opt out per
1388
+ * test/session with `SILVERY_STRICT=1,!bytes_out`. The options below are
1389
+ * TEST overrides only — production reads no env knobs (per the user's
1390
+ * "diagnostics that require remembering ad-hoc env vars don't get used" rule).
1391
+ */
1392
+ const NS$2 = "silvery:bytes_out";
1393
+ const FRAME_HISTORY$1 = 100;
1394
+ const WARN_WINDOW_MS = 1e4;
1395
+ const PANIC_WINDOW_MS = 2e3;
1396
+ const WARN_COOLDOWN_MS = 1e4;
1397
+ const PANIC_COOLDOWN_MS$1 = 6e4;
1398
+ const EVICT_OLDER_THAN_MS$1 = 6e4;
1399
+ const DEFAULT_WARN_MB_PER_SEC = 1;
1400
+ const DEFAULT_PANIC_MB_PER_SEC = 100;
1401
+ function createBytesOutMonitor(options = {}) {
1402
+ const warnMbPerSec = options.warnMbPerSec ?? DEFAULT_WARN_MB_PER_SEC;
1403
+ const panicMbPerSec = options.panicMbPerSec ?? DEFAULT_PANIC_MB_PER_SEC;
1404
+ const skipHeapSnapshot = options.skipHeapSnapshot ?? false;
1405
+ const snapshotDir = options.snapshotDir ?? "/tmp";
1406
+ const log = options.logger ?? createLogger(NS$2);
1407
+ const now = options.now ?? Date.now;
1408
+ const writeHeapSnapshot = options.writeHeapSnapshot ?? defaultWriteHeapSnapshot;
1409
+ const writeFile = options.writeFile ?? defaultWriteFile$1;
1410
+ const pid = options.pid ?? process.pid;
1411
+ const warnBytesInWindow = warnMbPerSec * 1e6 * (WARN_WINDOW_MS / 1e3);
1412
+ const panicBytesInWindow = panicMbPerSec * 1e6 * (PANIC_WINDOW_MS / 1e3);
1413
+ const frames = [];
1414
+ let lastWarnAt = 0;
1415
+ let lastPanicAt = 0;
1416
+ let lastTrip = null;
1417
+ let disposed = false;
1418
+ function evictOld(cutoff) {
1419
+ while (frames.length > 0 && frames[0].ts < cutoff) frames.shift();
1420
+ }
1421
+ function sumBytesInWindow(windowMs, t) {
1422
+ const horizon = t - windowMs;
1423
+ let sum = 0;
1424
+ for (let i = frames.length - 1; i >= 0; i--) {
1425
+ const f = frames[i];
1426
+ if (f.ts < horizon) break;
1427
+ sum += f.bytes;
1428
+ }
1429
+ return sum;
1430
+ }
1431
+ function dumpFrameSummaries(filePath) {
1432
+ try {
1433
+ writeFile(filePath, "# frameNum ts bytes source owner phase artifactKind reason mode width height prevWidth prevHeight changedCells rawChangedCells dirtyRows outputChars cursorChars syncWrapped\n" + frames.slice(-FRAME_HISTORY$1).map((f) => {
1434
+ const d = f.diagnostics;
1435
+ return [
1436
+ f.frameNum,
1437
+ new Date(f.ts).toISOString(),
1438
+ f.bytes,
1439
+ d?.source,
1440
+ d?.owner,
1441
+ d?.phase,
1442
+ d?.artifactKind,
1443
+ d?.reason,
1444
+ d?.mode,
1445
+ d?.width,
1446
+ d?.height,
1447
+ d?.prevWidth,
1448
+ d?.prevHeight,
1449
+ d?.changedCells,
1450
+ d?.rawChangedCells,
1451
+ d?.dirtyRows,
1452
+ d?.outputChars,
1453
+ d?.cursorChars,
1454
+ d?.syncWrapped
1455
+ ].map(formatSummaryField).join(" ");
1456
+ }).join("\n") + "\n");
1457
+ return true;
1458
+ } catch (e) {
1459
+ log.error?.(`failed to write frame summary: ${e.message}`);
1460
+ return false;
1461
+ }
1462
+ }
1463
+ function maybePanic(t) {
1464
+ if (t - lastPanicAt < PANIC_COOLDOWN_MS$1) return false;
1465
+ const burstBytes = sumBytesInWindow(PANIC_WINDOW_MS, t);
1466
+ if (burstBytes < panicBytesInWindow) return false;
1467
+ const summaryPath = `${snapshotDir}/silvery-bytes-out-panic-${pid}-${t}.summary.txt`;
1468
+ dumpFrameSummaries(summaryPath);
1469
+ let snapshotPath;
1470
+ if (!skipHeapSnapshot) {
1471
+ const candidate = `${snapshotDir}/silvery-firehose-${pid}-${t}.heapsnapshot`;
1472
+ try {
1473
+ const written = writeHeapSnapshot(candidate);
1474
+ snapshotPath = typeof written === "string" ? written : candidate;
1475
+ } catch (e) {
1476
+ log.error?.(`failed to write heap snapshot: ${e.message}`);
1477
+ }
1478
+ }
1479
+ const mbPerSec = burstBytes / 1e6 / (PANIC_WINDOW_MS / 1e3);
1480
+ log.error?.(`PANIC render-out burst ${mbPerSec.toFixed(1)} MB/s exceeds ${panicMbPerSec} MB/s for ${PANIC_WINDOW_MS}ms`, {
1481
+ summary: summaryPath,
1482
+ heapSnapshot: snapshotPath
1483
+ });
1484
+ lastPanicAt = t;
1485
+ lastTrip = {
1486
+ kind: "panic",
1487
+ ts: t,
1488
+ mbPerSec,
1489
+ summaryPath,
1490
+ snapshotPath
1491
+ };
1492
+ return true;
1493
+ }
1494
+ function maybeWarn(t) {
1495
+ if (t - lastWarnAt < WARN_COOLDOWN_MS) return false;
1496
+ const sustainedBytes = sumBytesInWindow(WARN_WINDOW_MS, t);
1497
+ if (sustainedBytes < warnBytesInWindow) return false;
1498
+ const summaryPath = `${snapshotDir}/silvery-bytes-out-warn-${pid}-${t}.summary.txt`;
1499
+ dumpFrameSummaries(summaryPath);
1500
+ const mbPerSec = sustainedBytes / 1e6 / (WARN_WINDOW_MS / 1e3);
1501
+ log.warn?.(`WARN render-out sustained ${mbPerSec.toFixed(1)} MB/s exceeds ${warnMbPerSec} MB/s over ${WARN_WINDOW_MS}ms`, { summary: summaryPath });
1502
+ lastWarnAt = t;
1503
+ lastTrip = {
1504
+ kind: "warn",
1505
+ ts: t,
1506
+ mbPerSec,
1507
+ summaryPath
1508
+ };
1509
+ return true;
1510
+ }
1511
+ function recordWrite(frameNum, bytes, diagnostics) {
1512
+ if (disposed) return;
1513
+ const t = now();
1514
+ evictOld(t - EVICT_OLDER_THAN_MS$1);
1515
+ frames.push({
1516
+ frameNum,
1517
+ ts: t,
1518
+ bytes,
1519
+ diagnostics
1520
+ });
1521
+ if (maybePanic(t)) return;
1522
+ maybeWarn(t);
1523
+ }
1524
+ function snapshotFrames() {
1525
+ return frames.slice();
1526
+ }
1527
+ function dispose() {
1528
+ disposed = true;
1529
+ frames.length = 0;
1530
+ }
1531
+ return {
1532
+ recordWrite,
1533
+ get lastTrip() {
1534
+ return lastTrip;
1535
+ },
1536
+ snapshotFrames,
1537
+ dispose
1538
+ };
1539
+ }
1540
+ function formatSummaryField(value) {
1541
+ if (value === void 0 || value === null) return "";
1542
+ return String(value);
1543
+ }
1544
+ function defaultWriteHeapSnapshot(path) {
1545
+ return loadV8().writeHeapSnapshot(path);
1546
+ }
1547
+ function defaultWriteFile$1(path, contents) {
1548
+ process.getBuiltinModule("node:fs").writeFileSync(path, contents);
1549
+ }
1550
+ function loadV8() {
1551
+ return process.getBuiltinModule("node:v8");
1552
+ }
1553
+ //#endregion
1554
+ //#region packages/ag-term/src/flicker-monitor.ts
1555
+ /**
1556
+ * Repaint-storm (flicker) monitor — backs the `SILVERY_STRICT=flicker` slug
1557
+ * (alias intent: "cls_storm"). Watches the ANSI byte stream silvery's render
1558
+ * pipeline writes to the terminal and PANICs when destructive full-screen
1559
+ * clears / scroll-region resets repeat fast enough to constitute visible
1560
+ * flicker.
1561
+ *
1562
+ * Why this exists: flicker is the single hardest render bug to catch — it is
1563
+ * invisible to text-frame assertions (the FINAL frame is correct; the damage
1564
+ * is the transient blank between frames) and historically took eyeball +
1565
+ * `#undead` rounds to pin down. The `@ag/code/20297-pane-flicker-on-resize`
1566
+ * incident was a bare `\x1b[2J` emitted OUTSIDE the DEC-2026 sync region on
1567
+ * every resize: each `2J` blanked the pane for one composited frame before the
1568
+ * repaint landed. Repeated rapidly (resize-drag, a layout limit-cycle, or a
1569
+ * stale process re-emitting frames) that is a strobing blank-flash. This
1570
+ * monitor makes that class fail LOUD + NAMED at the source.
1571
+ *
1572
+ * What counts as a storm signal (per frame written to the terminal):
1573
+ * - `\x1b[2J` / `\x1b[3J` — erase-in-display (the 20297 culprit). `\x1b[J` and
1574
+ * `\x1b[0J` (erase-to-end) and `\x1b[1J` (erase-to-start) are NOT counted:
1575
+ * they are partial, non-blanking, and used legitimately by inline/static
1576
+ * output. Only the full-screen erasers blank the viewport.
1577
+ * - `\x1b[r` / `\x1b[<top>;<bottom>r` — DECSTBM scroll-region reset/set. A
1578
+ * scroll-region thrash (set/reset churn) is the other shape of repaint
1579
+ * storm (a "scroll storm"); a single frame carrying one is benign,
1580
+ * repetition in the window is not.
1581
+ *
1582
+ * What does NOT count (CRITICAL — the 20297 *fix* must never false-trip):
1583
+ * - A homed full-viewport repaint (`\x1b[H` followed by a full `bufferToAnsi`
1584
+ * rewrite of every cell) is the LEGITIMATE post-20297 resize/focus frame. It
1585
+ * carries NO `2J`/`3J` at all, so it registers ZERO storm signals — it can
1586
+ * never trip this monitor regardless of size. The monitor targets
1587
+ * destructive-clear REPETITION within a window, not full repaints, and not a
1588
+ * single isolated clear.
1589
+ *
1590
+ * Behavior (fixed thresholds — no env knobs):
1591
+ * - PANIC when ≥ `PANIC_CLEARS` destructive clears land within `WINDOW_MS`
1592
+ * with no intervening settle (a settle = a frame in the window that carried
1593
+ * zero storm signals, i.e. the storm actually paused). The diagnostic NAMES
1594
+ * the offending sequence + the count + the window, plus a culprit hint
1595
+ * (bare `2J` outside the sync region — the @ag/code/20297 signature).
1596
+ * Cooldown `PANIC_COOLDOWN_MS` before a re-trip so a genuine sustained storm
1597
+ * logs once, not every frame.
1598
+ *
1599
+ * Activation: tier 1 default (inherited from `SILVERY_STRICT=1`, the same tier
1600
+ * as `bytes_out`/`mem`). Opt out per test/session with
1601
+ * `SILVERY_STRICT=1,!flicker`. The options below are TEST overrides only —
1602
+ * production reads no env knobs (per the user's "diagnostics that require
1603
+ * remembering ad-hoc env vars don't get used" rule).
1604
+ */
1605
+ const NS$1 = "silvery:flicker";
1606
+ /** Rolling window over which clears are counted. */
1607
+ const WINDOW_MS = 1e3;
1608
+ /**
1609
+ * Number of destructive full-screen clears within the window (with no
1610
+ * intervening settle frame) that constitutes a repaint storm.
1611
+ *
1612
+ * Three is deliberately above any LEGITIMATE clear cadence:
1613
+ * - A normal session emits at most ONE full clear per discrete event (startup
1614
+ * alt-screen entry, an explicit `invalidate({ clearScreen })` on focus-in).
1615
+ * Post-20297 a resize/focus repaint emits ZERO `2J` (the homed full repaint
1616
+ * IS the clear), so even rapid resize-drag produces no clears at all.
1617
+ * - The 20297 BUG emitted a `2J` on EVERY resize frame; a resize-drag fires
1618
+ * tens of frames per second → ≫3 clears in a 1 s window → trips.
1619
+ */
1620
+ const PANIC_CLEARS = 3;
1621
+ /** Cooldown before a sustained storm is allowed to re-log. */
1622
+ const PANIC_COOLDOWN_MS = 5e3;
1623
+ /** Drop frame records older than this to keep memory bounded. */
1624
+ const EVICT_OLDER_THAN_MS = 1e4;
1625
+ /** Last-N frames retained for the diagnostic dump. */
1626
+ const FRAME_HISTORY = 50;
1627
+ /**
1628
+ * Destructive full-screen erase-in-display: `\x1b[2J` (whole viewport) and
1629
+ * `\x1b[3J` (viewport + scrollback). Deliberately does NOT match `\x1b[J`,
1630
+ * `\x1b[0J`, or `\x1b[1J` — those are partial, non-blanking erases.
1631
+ */
1632
+ const FULL_CLEAR_RE = /\x1b\[[23]J/g;
1633
+ /**
1634
+ * DECSTBM scroll-region reset/set: `\x1b[r` (reset to full screen) or
1635
+ * `\x1b[<top>;<bottom>r`. A private-mode variant (`\x1b[?...r`) is matched too
1636
+ * so DEC private scroll-region churn is caught.
1637
+ */
1638
+ const SCROLL_REGION_RE = /\x1b\[\??(?:\d+(?:;\d+)*)?r/g;
1639
+ function createFlickerMonitor(options = {}) {
1640
+ const panicClears = options.panicClears ?? PANIC_CLEARS;
1641
+ const windowMs = options.windowMs ?? WINDOW_MS;
1642
+ const noThrow = options.noThrow ?? false;
1643
+ const log = options.logger ?? createLogger(NS$1);
1644
+ const now = options.now ?? Date.now;
1645
+ const writeFile = options.writeFile ?? defaultWriteFile;
1646
+ const snapshotDir = options.snapshotDir ?? "/tmp";
1647
+ const pid = options.pid ?? process.pid;
1648
+ const frames = [];
1649
+ let lastPanicAt = 0;
1650
+ let lastTrip = null;
1651
+ let disposed = false;
1652
+ function evictOld(cutoff) {
1653
+ while (frames.length > 0 && frames[0].ts < cutoff) frames.shift();
1654
+ }
1655
+ /**
1656
+ * Count storm signals across the in-window tail. Returns the totals AND
1657
+ * whether the storm settled (a window frame carried zero signals) — a settle
1658
+ * resets the "fast repetition" judgment so a slow trickle of clears spread
1659
+ * across calm frames never trips.
1660
+ */
1661
+ function tallyWindow(t) {
1662
+ const horizon = t - windowMs;
1663
+ let fullClears = 0;
1664
+ let scrollRegionResets = 0;
1665
+ let settled = false;
1666
+ for (let i = frames.length - 1; i >= 0; i--) {
1667
+ const f = frames[i];
1668
+ if (f.ts < horizon) break;
1669
+ const s = f.signals;
1670
+ if (s.fullClears + s.scrollRegionResets === 0) {
1671
+ settled = true;
1672
+ break;
1673
+ }
1674
+ fullClears += s.fullClears;
1675
+ scrollRegionResets += s.scrollRegionResets;
1676
+ }
1677
+ return {
1678
+ fullClears,
1679
+ scrollRegionResets,
1680
+ settled
1681
+ };
1682
+ }
1683
+ function dumpFrameSummaries(filePath) {
1684
+ try {
1685
+ writeFile(filePath, "# frameNum ts bytes fullClears scrollRegionResets\n" + frames.slice(-FRAME_HISTORY).map((f) => [
1686
+ f.frameNum,
1687
+ new Date(f.ts).toISOString(),
1688
+ f.bytes,
1689
+ f.signals.fullClears,
1690
+ f.signals.scrollRegionResets
1691
+ ].join(" ")).join("\n") + "\n");
1692
+ } catch (e) {
1693
+ log.error?.(`failed to write flicker frame summary: ${e.message}`);
1694
+ }
1695
+ }
1696
+ function maybePanic(t) {
1697
+ if (t - lastPanicAt < PANIC_COOLDOWN_MS) return;
1698
+ const { fullClears, scrollRegionResets, settled } = tallyWindow(t);
1699
+ if (settled) return;
1700
+ if (fullClears < panicClears) return;
1701
+ const summaryPath = `${snapshotDir}/silvery-flicker-panic-${pid}-${t}.summary.txt`;
1702
+ dumpFrameSummaries(summaryPath);
1703
+ const clearSeq = "\\x1b[2J";
1704
+ const sequence = scrollRegionResets > 0 ? "\\x1b[2J/\\x1b[3J full-screen clear + \\x1b[r scroll-region reset" : "\\x1b[2J/\\x1b[3J full-screen clear";
1705
+ const message = `PANIC repaint storm: ${fullClears} full-screen clears` + (scrollRegionResets > 0 ? ` + ${scrollRegionResets} scroll-region resets` : "") + ` in ${windowMs}ms (threshold ${panicClears}). Offending sequence: ${sequence}. Likely culprit: a destructive ${clearSeq} emitted per-frame outside the DEC-2026 sync region (the @ag/code/20297-pane-flicker-on-resize signature) — a full repaint should home (\\x1b[H) and overwrite cells instead. Frame summary: ${summaryPath}`;
1706
+ log.error?.(message, {
1707
+ clears: fullClears,
1708
+ scrollRegionResets,
1709
+ windowMs,
1710
+ summary: summaryPath
1711
+ });
1712
+ lastPanicAt = t;
1713
+ lastTrip = {
1714
+ kind: "panic",
1715
+ ts: t,
1716
+ clears: fullClears,
1717
+ scrollRegionResets,
1718
+ windowMs,
1719
+ sequence,
1720
+ message,
1721
+ summaryPath
1722
+ };
1723
+ if (!noThrow) throw new Error(message);
1724
+ }
1725
+ function recordFrame(frameNum, output) {
1726
+ if (disposed) return;
1727
+ const t = now();
1728
+ evictOld(t - EVICT_OLDER_THAN_MS);
1729
+ const signals = {
1730
+ fullClears: countMatches(output, FULL_CLEAR_RE),
1731
+ scrollRegionResets: countMatches(output, SCROLL_REGION_RE)
1732
+ };
1733
+ frames.push({
1734
+ frameNum,
1735
+ ts: t,
1736
+ bytes: output.length,
1737
+ signals
1738
+ });
1739
+ maybePanic(t);
1740
+ }
1741
+ function snapshotFrames() {
1742
+ return frames.slice();
1743
+ }
1744
+ function dispose() {
1745
+ disposed = true;
1746
+ frames.length = 0;
1747
+ }
1748
+ return {
1749
+ recordFrame,
1750
+ get lastTrip() {
1751
+ return lastTrip;
1752
+ },
1753
+ snapshotFrames,
1754
+ dispose
1755
+ };
1756
+ }
1757
+ function countMatches(haystack, re) {
1758
+ re.lastIndex = 0;
1759
+ let count = 0;
1760
+ while (re.exec(haystack) !== null) count++;
1761
+ re.lastIndex = 0;
1762
+ return count;
1763
+ }
1764
+ function defaultWriteFile(path, contents) {
1765
+ process.getBuiltinModule("node:fs").writeFileSync(path, contents);
1766
+ }
1767
+ //#endregion
1768
+ //#region packages/ag-term/src/mem-monitor.ts
1769
+ /**
1770
+ * In-process heap poll monitor — backs the `SILVERY_STRICT=mem` slug
1771
+ * (Layer 2 of the memory-observability stack).
1772
+ *
1773
+ * Samples `process.memoryUsage()` every 30 s on a fixed interval, logs the
1774
+ * full breakdown via the `silvery:mem` loggily namespace, and trips a
1775
+ * one-shot WARN line if RSS or `external + arrayBuffers` doubles within a
1776
+ * 60 s window.
1777
+ *
1778
+ * Where this fits: L1 (`bytes_out`) watches data leaving the process via the
1779
+ * render pipeline. L2 (`mem`, this file) watches data still inside the
1780
+ * process. L4 (`bun memwatch <pid>`) is the external view. Together they
1781
+ * cover the bytes-out / bytes-in / RSS-outside triad for a pty-attached
1782
+ * TUI. See `docs/lessons/cmux-pty-buffer-firehose.md` (in km root) for the
1783
+ * motivating incident.
1784
+ *
1785
+ * Convention: tier-1 default, opt-out via `SILVERY_STRICT=1,!mem`. No env
1786
+ * knobs — fixed sample cadence + threshold in code. Per the user-direction
1787
+ * documented in `vendor/silvery/docs/guide/debugging.md`:
1788
+ * "diagnostics that require remembering ad-hoc env vars don't get used."
1789
+ */
1790
+ const NS = "silvery:mem";
1791
+ const SAMPLE_INTERVAL_MS = 3e4;
1792
+ const DOUBLE_WINDOW_MS = 6e4;
1793
+ const SAMPLE_HISTORY = 8;
1794
+ function createMemMonitor(options = {}) {
1795
+ const log = options.logger ?? createLogger(NS);
1796
+ const now = options.now ?? Date.now;
1797
+ const memoryUsage = options.memoryUsage ?? process.memoryUsage;
1798
+ const manual = options.manual ?? false;
1799
+ const samples = [];
1800
+ let lastWarn = null;
1801
+ let lastWarnReason = null;
1802
+ let disposed = false;
1803
+ let interval = null;
1804
+ function evictOld(cutoff) {
1805
+ while (samples.length > 0 && samples[0].ts < cutoff) samples.shift();
1806
+ while (samples.length > SAMPLE_HISTORY) samples.shift();
1807
+ }
1808
+ function snapshotMin(field, windowMs, t) {
1809
+ const horizon = t - windowMs;
1810
+ let min = Number.POSITIVE_INFINITY;
1811
+ for (const s of samples) {
1812
+ if (s.ts < horizon) continue;
1813
+ const v = s[field];
1814
+ if (v < min) min = v;
1815
+ }
1816
+ return min;
1817
+ }
1818
+ function tick() {
1819
+ if (disposed) return;
1820
+ const t = now();
1821
+ const m = memoryUsage();
1822
+ const sample = {
1823
+ ts: t,
1824
+ rss: m.rss,
1825
+ heapUsed: m.heapUsed,
1826
+ heapTotal: m.heapTotal,
1827
+ external: m.external,
1828
+ arrayBuffers: m.arrayBuffers
1829
+ };
1830
+ samples.push(sample);
1831
+ evictOld(t - DOUBLE_WINDOW_MS * 2);
1832
+ log.info?.("mem sample", {
1833
+ rss_mb: Math.round(sample.rss / 1e6),
1834
+ heap_used_mb: Math.round(sample.heapUsed / 1e6),
1835
+ heap_total_mb: Math.round(sample.heapTotal / 1e6),
1836
+ external_mb: Math.round(sample.external / 1e6),
1837
+ array_buffers_mb: Math.round(sample.arrayBuffers / 1e6)
1838
+ });
1839
+ const rssMin = snapshotMin("rss", DOUBLE_WINDOW_MS, t);
1840
+ if (Number.isFinite(rssMin) && rssMin > 0 && sample.rss >= rssMin * 2) {
1841
+ const reason = `RSS doubled in ${DOUBLE_WINDOW_MS / 1e3}s: ${Math.round(rssMin / 1e6)} → ${Math.round(sample.rss / 1e6)} MB`;
1842
+ if (reason !== lastWarnReason) {
1843
+ log.warn?.(`WARN ${reason}`);
1844
+ lastWarn = {
1845
+ ts: t,
1846
+ reason
1847
+ };
1848
+ lastWarnReason = reason;
1849
+ }
1850
+ return;
1851
+ }
1852
+ const offHeapNow = sample.external + sample.arrayBuffers;
1853
+ const horizon = t - DOUBLE_WINDOW_MS;
1854
+ let offHeapMin = Number.POSITIVE_INFINITY;
1855
+ for (const s of samples) {
1856
+ if (s.ts < horizon) continue;
1857
+ const sum = s.external + s.arrayBuffers;
1858
+ if (sum < offHeapMin) offHeapMin = sum;
1859
+ }
1860
+ if (Number.isFinite(offHeapMin) && offHeapMin > 0 && offHeapNow >= offHeapMin * 2) {
1861
+ const reason = `external+arrayBuffers doubled in ${DOUBLE_WINDOW_MS / 1e3}s: ${Math.round(offHeapMin / 1e6)} → ${Math.round(offHeapNow / 1e6)} MB`;
1862
+ if (reason !== lastWarnReason) {
1863
+ log.warn?.(`WARN ${reason}`);
1864
+ lastWarn = {
1865
+ ts: t,
1866
+ reason
1867
+ };
1868
+ lastWarnReason = reason;
1869
+ }
1870
+ }
1871
+ }
1872
+ function dispose() {
1873
+ if (disposed) return;
1874
+ disposed = true;
1875
+ if (interval !== null) {
1876
+ clearInterval(interval);
1877
+ interval = null;
1878
+ }
1879
+ samples.length = 0;
1880
+ }
1881
+ if (!manual) {
1882
+ interval = setInterval(tick, SAMPLE_INTERVAL_MS);
1883
+ const maybeUnref = interval;
1884
+ if (typeof maybeUnref.unref === "function") maybeUnref.unref();
1885
+ }
1886
+ return {
1887
+ tick,
1888
+ get lastSample() {
1889
+ return samples.length > 0 ? samples[samples.length - 1] : null;
1890
+ },
1891
+ get lastWarn() {
1892
+ return lastWarn;
1893
+ },
1894
+ dispose
1895
+ };
1896
+ }
1897
+ //#endregion
1898
+ //#region packages/test/src/debug-mismatch.ts
1899
+ /**
1900
+ * Find the innermost node at a screen position.
1901
+ */
1902
+ function findNodeAtPosition(root, x, y) {
1903
+ let result = null;
1904
+ function visit(node) {
1905
+ const rect = node.scrollRect;
1906
+ if (!rect) return;
1907
+ if (x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height) {
1908
+ result = node;
1909
+ for (const child of node.children) visit(child);
1910
+ }
1911
+ }
1912
+ visit(root);
1913
+ return result;
1914
+ }
1915
+ /**
1916
+ * Find all nodes whose scrollRect contains the given position.
1917
+ * Returns nodes from root to innermost (outermost first).
1918
+ */
1919
+ function findAllContainingNodes(root, x, y) {
1920
+ const result = [];
1921
+ function visit(node) {
1922
+ const rect = node.scrollRect;
1923
+ if (!rect) return;
1924
+ if (x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height) {
1925
+ result.push(node);
1926
+ for (const child of node.children) visit(child);
1927
+ }
1928
+ }
1929
+ visit(root);
1930
+ return result;
1931
+ }
1932
+ /**
1933
+ * Get the path from root to a node (for identification).
1934
+ */
1935
+ function getNodePath(node) {
1936
+ const parts = [];
1937
+ let current = node;
1938
+ while (current) {
1939
+ const props = current.props;
1940
+ if (props.id) parts.unshift(`#${props.id}`);
1941
+ else if (current.parent) {
1942
+ const idx = current.parent.children.indexOf(current);
1943
+ parts.unshift(`[${idx}]`);
1944
+ } else parts.unshift("root");
1945
+ current = current.parent;
1946
+ }
1947
+ return parts.join(" > ");
1948
+ }
1949
+ /**
1950
+ * Check if a rect changed (position or size).
1951
+ */
1952
+ function rectChanged(a, b) {
1953
+ if (a === b) return false;
1954
+ if (!a || !b) return true;
1955
+ return a.x !== b.x || a.y !== b.y || a.width !== b.width || a.height !== b.height;
1956
+ }
1957
+ /**
1958
+ * Extract debug info from a node.
1959
+ */
1960
+ function getNodeDebugInfo(node) {
1961
+ const props = node.props;
1962
+ let childIndex = null;
1963
+ if (node.parent) childIndex = node.parent.children.indexOf(node);
1964
+ return {
1965
+ id: props.id,
1966
+ type: node.type,
1967
+ path: getNodePath(node),
1968
+ childIndex,
1969
+ dirtyFlags: {
1970
+ contentDirty: isDirty(node.dirtyBits, node.dirtyEpoch, 1),
1971
+ stylePropsDirty: isDirty(node.dirtyBits, node.dirtyEpoch, 2),
1972
+ subtreeDirty: isDirty(node.dirtyBits, node.dirtyEpoch, 16),
1973
+ childrenDirty: isDirty(node.dirtyBits, node.dirtyEpoch, 8)
1974
+ },
1975
+ layout: {
1976
+ prevLayout: node.prevLayout,
1977
+ boxRect: node.boxRect,
1978
+ scrollRect: node.scrollRect,
1979
+ layoutChanged: rectChanged(node.prevLayout, node.boxRect)
1980
+ },
1981
+ scroll: node.scrollState ? {
1982
+ offset: node.scrollState.offset,
1983
+ prevOffset: node.scrollState.prevOffset,
1984
+ offsetChanged: node.scrollState.offset !== node.scrollState.prevOffset,
1985
+ contentHeight: node.scrollState.contentHeight,
1986
+ viewportHeight: node.scrollState.viewportHeight,
1987
+ hiddenAbove: node.scrollState.hiddenAbove,
1988
+ hiddenBelow: node.scrollState.hiddenBelow,
1989
+ firstVisibleChild: node.scrollState.firstVisibleChild,
1990
+ lastVisibleChild: node.scrollState.lastVisibleChild
1991
+ } : void 0,
1992
+ backgroundColor: props.backgroundColor,
1993
+ childCount: node.children.length,
1994
+ hidden: node.hidden ?? false
1995
+ };
1996
+ }
1997
+ /**
1998
+ * Find scroll container ancestors for a node.
1999
+ */
2000
+ function findScrollAncestors(node) {
2001
+ const result = [];
2002
+ let current = node.parent;
2003
+ while (current) {
2004
+ if (current.scrollState) result.push(current);
2005
+ current = current.parent;
2006
+ }
2007
+ return result;
2008
+ }
2009
+ /**
2010
+ * Analyze why a node might have been incorrectly skipped by fast-path.
2011
+ */
2012
+ function analyzeFastPath(node, scrollAncestors) {
2013
+ const analysis = [];
2014
+ if (!node) {
2015
+ analysis.push("⚠ No node found at mismatch position - possible virtualization issue");
2016
+ return analysis;
2017
+ }
2018
+ const flags = node;
2019
+ if (!isAnyDirty(flags.dirtyBits, flags.dirtyEpoch)) analysis.push("⚠ ALL DIRTY FLAGS FALSE - fast-path likely skipped this node");
2020
+ const scrollParent = scrollAncestors[0];
2021
+ if (scrollParent?.scrollState) {
2022
+ const ss = scrollParent.scrollState;
2023
+ const childIndex = node.parent ? node.parent.children.indexOf(node) : -1;
2024
+ const inVisibleRange = childIndex >= ss.firstVisibleChild && childIndex <= ss.lastVisibleChild;
2025
+ if (!inVisibleRange && childIndex >= 0) {
2026
+ analysis.push(`⚠ Node index ${childIndex} is OUTSIDE visible range [${ss.firstVisibleChild}..${ss.lastVisibleChild}]`);
2027
+ analysis.push(" → Node should have been skipped, but mismatch suggests it should render");
2028
+ } else if (inVisibleRange) analysis.push(`✓ Node index ${childIndex} is in visible range [${ss.firstVisibleChild}..${ss.lastVisibleChild}]`);
2029
+ if (ss.offset === ss.prevOffset) analysis.push("✓ Scroll offset unchanged (fast-path enabled for children)");
2030
+ else analysis.push(`⚠ Scroll offset CHANGED: ${ss.prevOffset} → ${ss.offset}`);
2031
+ if (ss.firstVisibleChild !== 0 || ss.lastVisibleChild !== scrollParent.children.length - 1) {
2032
+ analysis.push(` Visible range is partial: [${ss.firstVisibleChild}..${ss.lastVisibleChild}] of ${scrollParent.children.length} children`);
2033
+ analysis.push(" → If visible range changed, newly visible children need rendering");
2034
+ }
2035
+ }
2036
+ if (!rectChanged(node.prevLayout, node.boxRect) && node.prevLayout) analysis.push("✓ Layout unchanged (prevLayout matches boxRect)");
2037
+ else if (!node.prevLayout) analysis.push("⚠ prevLayout is NULL - node may never have been rendered before");
2038
+ else analysis.push("⚠ Layout CHANGED but node still skipped - dirty flag not set?");
2039
+ if (node.parent && node.parent.children.length > 1) {
2040
+ let siblingMoved = false;
2041
+ for (const sibling of node.parent.children) if (sibling !== node && sibling.boxRect && sibling.prevLayout) {
2042
+ if (sibling.boxRect.x !== sibling.prevLayout.x || sibling.boxRect.y !== sibling.prevLayout.y) {
2043
+ siblingMoved = true;
2044
+ break;
2045
+ }
2046
+ }
2047
+ if (siblingMoved) analysis.push("⚠ SIBLING POSITION CHANGED - parent should have detected this");
2048
+ }
2049
+ if (node.hidden) analysis.push("⚠ Node is HIDDEN (Suspense) - should not be rendered");
2050
+ return analysis;
2051
+ }
2052
+ /**
2053
+ * Build full mismatch debug context.
2054
+ */
2055
+ function buildMismatchContext(root, x, y, incrementalCell, freshCell, renderNum) {
2056
+ const innermost = findNodeAtPosition(root, x, y);
2057
+ const containing = findAllContainingNodes(root, x, y);
2058
+ const scrollAncestorNodes = innermost ? findScrollAncestors(innermost) : [];
2059
+ return {
2060
+ position: {
2061
+ x,
2062
+ y
2063
+ },
2064
+ cells: {
2065
+ incremental: incrementalCell,
2066
+ fresh: freshCell
2067
+ },
2068
+ renderNum,
2069
+ node: innermost ? getNodeDebugInfo(innermost) : null,
2070
+ scrollAncestors: scrollAncestorNodes.map(getNodeDebugInfo),
2071
+ containingNodes: containing.map(getNodeDebugInfo),
2072
+ fastPathAnalysis: analyzeFastPath(innermost, scrollAncestorNodes)
2073
+ };
2074
+ }
2075
+ /**
2076
+ * Format mismatch context as a human-readable string.
2077
+ *
2078
+ * @param ctx - The mismatch debug context (node attribution, dirty flags, scroll, fast-path)
2079
+ * @param renderPhaseStats - Optional render-phase instrumentation snapshot (auto-included by SILVERY_STRICT)
2080
+ */
2081
+ function formatMismatchContext(ctx, renderPhaseStats) {
2082
+ const lines = [];
2083
+ lines.push(`SILVERY_STRICT: MISMATCH at (${ctx.position.x}, ${ctx.position.y}) on render #${ctx.renderNum}`);
2084
+ lines.push("");
2085
+ const { incremental, fresh } = ctx.cells;
2086
+ lines.push("CELL VALUES:");
2087
+ lines.push(` incremental: char=${JSON.stringify(incremental.char)} fg=${JSON.stringify(incremental.fg)} bg=${JSON.stringify(incremental.bg)} attrs=${JSON.stringify(incremental.attrs)}`);
2088
+ lines.push(` fresh: char=${JSON.stringify(fresh.char)} fg=${JSON.stringify(fresh.fg)} bg=${JSON.stringify(fresh.bg)} attrs=${JSON.stringify(fresh.attrs)}`);
2089
+ lines.push("");
2090
+ if (ctx.node) {
2091
+ lines.push("INNERMOST NODE:");
2092
+ lines.push(` path: ${ctx.node.path}`);
2093
+ lines.push(` type: ${ctx.node.type}`);
2094
+ if (ctx.node.backgroundColor) lines.push(` backgroundColor: ${ctx.node.backgroundColor}`);
2095
+ lines.push("");
2096
+ const flags = ctx.node.dirtyFlags;
2097
+ const activeFlags = Object.entries(flags).filter(([, v]) => v).map(([k]) => k);
2098
+ lines.push("DIRTY FLAGS:");
2099
+ if (activeFlags.length > 0) lines.push(` active: ${activeFlags.join(", ")}`);
2100
+ else lines.push(" active: (none - node was clean)");
2101
+ lines.push(` all: contentDirty=${flags.contentDirty} stylePropsDirty=${flags.stylePropsDirty} subtreeDirty=${flags.subtreeDirty} childrenDirty=${flags.childrenDirty}`);
2102
+ lines.push("");
2103
+ const { layout } = ctx.node;
2104
+ lines.push("LAYOUT:");
2105
+ if (layout.layoutChanged) {
2106
+ lines.push(" ⚠ LAYOUT CHANGED:");
2107
+ lines.push(` prevLayout: ${formatRect(layout.prevLayout)}`);
2108
+ lines.push(` boxRect: ${formatRect(layout.boxRect)}`);
2109
+ } else lines.push(` boxRect: ${formatRect(layout.boxRect)}`);
2110
+ lines.push(` scrollRect: ${formatRect(layout.scrollRect)}`);
2111
+ lines.push("");
2112
+ if (ctx.node.scroll) {
2113
+ lines.push("SCROLL STATE (this node):");
2114
+ formatScrollState(lines, ctx.node.scroll);
2115
+ lines.push("");
2116
+ }
2117
+ } else {
2118
+ lines.push("INNERMOST NODE: (none found at this position)");
2119
+ lines.push("");
2120
+ }
2121
+ if (ctx.scrollAncestors.length > 0) {
2122
+ lines.push("SCROLL ANCESTORS:");
2123
+ for (const ancestor of ctx.scrollAncestors) {
2124
+ lines.push(` ${ancestor.path}:`);
2125
+ if (ancestor.scroll) formatScrollState(lines, ancestor.scroll, " ");
2126
+ }
2127
+ lines.push("");
2128
+ }
2129
+ if (ctx.containingNodes.length > 1) {
2130
+ lines.push("ALL CONTAINING NODES (outermost to innermost):");
2131
+ for (const node of ctx.containingNodes) {
2132
+ const flags = Object.entries(node.dirtyFlags).filter(([, v]) => v).map(([k]) => k.replace("Dirty", "")).join(",");
2133
+ const flagStr = flags ? ` [${flags}]` : " [clean]";
2134
+ const bgStr = node.backgroundColor ? ` bg=${node.backgroundColor}` : "";
2135
+ const childStr = node.childIndex !== null ? ` child[${node.childIndex}]` : "";
2136
+ lines.push(` ${node.path}${flagStr}${bgStr}${childStr}`);
2137
+ }
2138
+ lines.push("");
2139
+ }
2140
+ if (ctx.fastPathAnalysis.length > 0) {
2141
+ lines.push("FAST-PATH ANALYSIS:");
2142
+ for (const line of ctx.fastPathAnalysis) lines.push(` ${line}`);
2143
+ lines.push("");
2144
+ }
2145
+ if (renderPhaseStats) {
2146
+ const s = renderPhaseStats;
2147
+ lines.push("RENDER PHASE STATS:");
2148
+ lines.push(` nodesVisited: ${s.nodesVisited} nodesRendered: ${s.nodesRendered} nodesSkipped: ${s.nodesSkipped}`);
2149
+ lines.push(` textNodes: ${s.textNodes} boxNodes: ${s.boxNodes} clearOps: ${s.clearOps}`);
2150
+ const flagLines = [];
2151
+ if (s.noPrevBuffer) flagLines.push(`noPrevBuffer=${s.noPrevBuffer}`);
2152
+ if (s.flagContentDirty) flagLines.push(`contentDirty=${s.flagContentDirty}`);
2153
+ if (s.flagStylePropsDirty) flagLines.push(`stylePropsDirty=${s.flagStylePropsDirty}`);
2154
+ if (s.flagLayoutChanged) flagLines.push(`layoutChanged=${s.flagLayoutChanged}`);
2155
+ if (s.flagSubtreeDirty) flagLines.push(`subtreeDirty=${s.flagSubtreeDirty}`);
2156
+ if (s.flagChildrenDirty) flagLines.push(`childrenDirty=${s.flagChildrenDirty}`);
2157
+ if (s.flagChildPositionChanged) flagLines.push(`childPositionChanged=${s.flagChildPositionChanged}`);
2158
+ if (flagLines.length > 0) lines.push(` render reasons: ${flagLines.join(", ")}`);
2159
+ if (s.scrollContainerCount > 0) {
2160
+ lines.push(` scrollContainers: ${s.scrollContainerCount} viewportCleared: ${s.scrollViewportCleared}`);
2161
+ if (s.scrollClearReason) lines.push(` scrollClearReason: ${s.scrollClearReason}`);
2162
+ }
2163
+ if (s.normalChildrenRepaint > 0) {
2164
+ lines.push(` normalChildrenRepaint: ${s.normalChildrenRepaint}`);
2165
+ if (s.normalRepaintReason) lines.push(` normalRepaintReason: ${s.normalRepaintReason}`);
2166
+ }
2167
+ if (s.cascadeMinDepth < 999) {
2168
+ lines.push(` cascadeMinDepth: ${s.cascadeMinDepth}`);
2169
+ if (s.cascadeNodes) lines.push(` cascadeNodes: ${s.cascadeNodes}`);
2170
+ }
2171
+ lines.push("");
2172
+ }
2173
+ return lines.join("\n");
2174
+ }
2175
+ function formatRect(rect) {
2176
+ if (!rect) return "(null)";
2177
+ return `{x:${rect.x}, y:${rect.y}, w:${rect.width}, h:${rect.height}}`;
2178
+ }
2179
+ function formatScrollState(lines, scroll, indent = " ") {
2180
+ if (scroll.offsetChanged) lines.push(`${indent}⚠ SCROLL CHANGED: offset ${scroll.prevOffset} → ${scroll.offset}`);
2181
+ else lines.push(`${indent}offset: ${scroll.offset}`);
2182
+ lines.push(`${indent}viewport: ${scroll.viewportHeight}/${scroll.contentHeight} (hidden: ▲${scroll.hiddenAbove} ▼${scroll.hiddenBelow})`);
2183
+ lines.push(`${indent}visibleRange: [${scroll.firstVisibleChild}..${scroll.lastVisibleChild}]`);
2184
+ }
2185
+ //#endregion
2186
+ //#region packages/ag-term/src/managed-caret.ts
2187
+ /**
2188
+ * Resolve the composited caret SHAPE from the cursor's declared shape. Only ever
2189
+ * called when a caret is shown at all — i.e. the window is FOCUSED (an unfocused
2190
+ * window composites no caret; see `composeManagedCaret`).
2191
+ *
2192
+ * - A declared `shape: "underline"` wins — an underline-shaped caret is
2193
+ * semantically distinct and stays underline.
2194
+ * - Otherwise the default filled `"block"`.
2195
+ */
2196
+ function caretStyle(cursor) {
2197
+ return cursor.shape === "underline" ? "underline" : "block";
2198
+ }
2199
+ /** Map a composited-caret style to the cell attributes that render it. */
2200
+ function caretCellAttrs(style) {
2201
+ switch (style) {
2202
+ case "underline": return { underline: true };
2203
+ default: return { inverse: true };
2204
+ }
2205
+ }
2206
+ /**
2207
+ * Paint a semantic caret into a presentation buffer for managed terminal frames.
2208
+ *
2209
+ * The source buffer stays untouched. Runtime diff state stores the presentation
2210
+ * buffer so moving/hiding the caret clears the previous frame's overlay like any
2211
+ * other cell update.
2212
+ *
2213
+ * @param windowFocused - whether the terminal WINDOW is focused. An UNFOCUSED
2214
+ * window composites NO caret (@km/code/v0.2/19702, reframed 2026-06-18: hide
2215
+ * the cursor COMPLETELY, replacing 20082's hollow box). Defaults to `true` —
2216
+ * a fail-safe so a single user with a focused terminal (or any caller that
2217
+ * hasn't wired standard `?1004` focus reporting) still sees the filled block,
2218
+ * never a vanished caret on unknown focus. For a composited caret the hardware
2219
+ * cursor is parked-and-hidden regardless of focus (see `managedCursorSuffix`),
2220
+ * so an unfocused pane shows nothing at all — and a dropped `?25l` cannot
2221
+ * strand a hardware cursor. The one exception is a `cursorActive` island
2222
+ * host-caret, shown as the real hardware cursor when the window is focused
2223
+ * (see `computeManagedFrame` / @hab 20398).
2224
+ */
2225
+ function composeManagedCaret(buffer, cursor, windowFocused = true) {
2226
+ if (!windowFocused || !cursor?.visible || !buffer.inBounds(cursor.x, cursor.y)) return {
2227
+ buffer,
2228
+ compositorCaret: null
2229
+ };
2230
+ const next = buffer.clone();
2231
+ next.markAllRowsDirty();
2232
+ const style = caretStyle(cursor);
2233
+ next.mergeAttrsInRect(cursor.x, cursor.y, 1, 1, caretCellAttrs(style));
2234
+ return {
2235
+ buffer: next,
2236
+ compositorCaret: {
2237
+ x: cursor.x,
2238
+ y: cursor.y,
2239
+ visible: true,
2240
+ style
2241
+ }
2242
+ };
2243
+ }
2244
+ /** SILVERY_STRICT slug for the managed-caret overlay-residue check. */
2245
+ const CARET_OVERLAY_STRICT_SLUG = "cursor";
2246
+ function sameCaretCell(a, b) {
2247
+ if (a === null || b === null) return a === b;
2248
+ return a.x === b.x && a.y === b.y;
2249
+ }
2250
+ /**
2251
+ * Clear a prior frame's composited-caret overlay from a STATIC row.
2252
+ *
2253
+ * `composeManagedCaret`'s two paths are asymmetric about dirty rows: the PAINT
2254
+ * path calls `markAllRowsDirty()` (so a freshly-painted caret is always
2255
+ * diffed), but the NO-CARET early-return hands back the source buffer untouched.
2256
+ * When a caret that was painted at cell C in frame N is suppressed or moves to a
2257
+ * different cell in frame N+1, and C's ROW is otherwise static (clean in the
2258
+ * incremental buffer — the reconciler only marks rows whose content changed),
2259
+ * `diffBuffers` skips that row (`if (!next.isRowDirty(y)) continue`). The frame-N
2260
+ * `inverse` cell is then never compared against the cleared frame-N+1 buffer, so
2261
+ * the reverse-video block strands on screen with NO `?25h`/CUP — the
2262
+ * @km/code/v0.2/19702 cursor-above-composer signature.
2263
+ *
2264
+ * Fix: mark EXACTLY the prior caret's row dirty in the presentation buffer the
2265
+ * caller will diff+store, so `diffBuffers` re-scans it and emits the cleared
2266
+ * cell. We never call `markAllRowsDirty` (that would re-diff every static
2267
+ * transcript row each frame — the incremental-perf regression the dirty-row gate
2268
+ * exists to prevent). We touch one row, only when the caret actually left it.
2269
+ *
2270
+ * Buffer ownership: when the current frame composited NO caret (or composited
2271
+ * one on a DIFFERENT buffer), `presentationBuffer` may be the caller's untouched
2272
+ * source buffer (the documented `composeManagedCaret` no-clone contract). To
2273
+ * honour "the source buffer stays untouched", we clone before marking in that
2274
+ * case and return the clone. When the PAINT path already cloned, we mutate that
2275
+ * clone in place (it is already the caller's diff baseline).
2276
+ */
2277
+ function clearPriorCaretOverlay(presentationBuffer, sourceBuffer, currentCaret, prevCaret) {
2278
+ if (prevCaret === null) return presentationBuffer;
2279
+ if (sameCaretCell(prevCaret, currentCaret)) return presentationBuffer;
2280
+ if (prevCaret.y < 0 || prevCaret.y >= presentationBuffer.height) return presentationBuffer;
2281
+ if (presentationBuffer.isRowDirty(prevCaret.y)) return presentationBuffer;
2282
+ const buf = presentationBuffer === sourceBuffer ? presentationBuffer.clone() : presentationBuffer;
2283
+ buf.markRowDirty(prevCaret.y);
2284
+ return buf;
2285
+ }
2286
+ /**
2287
+ * STRICT (tier 2, slug `cursor`) — managed-caret overlay-residue invariant.
2288
+ *
2289
+ * After a frame whose composited caret left cell C (suppressed or moved), C's
2290
+ * row in the presentation buffer the caller diffs MUST be dirty — otherwise
2291
+ * `diffBuffers` skips it and the prior `inverse` overlay strands (the
2292
+ * @km/code/v0.2/19702 signature). This is the overlay-layer analogue of
2293
+ * `strict-residue.ts` (which runs BELOW the caret overlay, in the render phase,
2294
+ * and so cannot see this). Fires only under `SILVERY_STRICT=cursor` / tier ≥ 2.
2295
+ */
2296
+ function verifyNoCaretOverlayResidue(presentationBuffer, currentCaret, prevCaret) {
2297
+ if (!isStrictEnabled("cursor", 2)) return;
2298
+ if (prevCaret === null) return;
2299
+ if (sameCaretCell(prevCaret, currentCaret)) return;
2300
+ if (prevCaret.y < 0 || prevCaret.y >= presentationBuffer.height) return;
2301
+ if (presentationBuffer.isRowDirty(prevCaret.y)) return;
2302
+ throw new IncrementalRenderMismatchError(`STRICT caret-overlay residue: prior composited caret at (${prevCaret.x},${prevCaret.y}) left the row, but row ${prevCaret.y} is CLEAN in the presentation buffer.\n diffBuffers skips clean rows, so the prior frame's reverse-video (inverse)\n overlay cell at (${prevCaret.x},${prevCaret.y}) will NOT be cleared — it strands\n on screen with no ?25h/CUP. This is the @km/code/v0.2/19702\n cursor-above-composer mechanism.\n current caret: ${currentCaret ? `(${currentCaret.x},${currentCaret.y})` : "none (suppressed)"}\n Fix: clearPriorCaretOverlay must mark the prior caret's row dirty.\n Slug: SILVERY_STRICT=${CARET_OVERLAY_STRICT_SLUG} (tier 2+).`);
2303
+ }
2304
+ /**
2305
+ * STRICT (tier 2, slug `cursor`) — no-fallback park invariant.
2306
+ *
2307
+ * When an editable declares a `parkOffset` (resolved to `parkRect`) and no
2308
+ * visible caret owns the frame, the managed hardware cursor MUST park on that
2309
+ * declared cell — never `home(0,0)` or a box-origin fallback. This pins that the
2310
+ * @km/code/v0.2/19702 fix stays wired: a regression that re-introduces a
2311
+ * fallback overriding the declared park target throws HERE instead of silently
2312
+ * stranding the cursor above the prompt (the recurring #undead signature, where
2313
+ * a dropped `?25l` surfaces the mis-parked cursor). The undeclared-editable case
2314
+ * (an editable that declares NO park) has no generic runtime signal — it is
2315
+ * pinned by the composer park-target contract + the parkOffset mutation test.
2316
+ * Fires only under `SILVERY_STRICT=cursor` / tier ≥ 2.
2317
+ *
2318
+ * Exported for direct mutation-proof unit testing (the fault path can't arise
2319
+ * from the correct `managedCursorSuffix`, so the test simulates a fallback park).
2320
+ */
2321
+ function verifyParkHonorsDeclaredTarget(cursor, parkRect, parkTarget) {
2322
+ if (!isStrictEnabled("cursor", 2)) return;
2323
+ if (cursor !== null || parkRect === null) return;
2324
+ if (parkTarget !== null && parkTarget.x === parkRect.x && parkTarget.y === parkRect.y) return;
2325
+ throw new IncrementalRenderMismatchError(`STRICT no-fallback park: parkOffset declared at (${parkRect.x},${parkRect.y}) and no caret owns the frame, but the hardware cursor parked at ${parkTarget ? `(${parkTarget.x},${parkTarget.y})` : "none"} instead.\n A managed frame MUST park on the editable's declared input cell — never a\n box-origin / home(0,0) fallback. A dropped ?25l would then strand the cursor off\n the prompt — the @km/code/v0.2/19702 cursor-above-composer mechanism.\n Fix: managedCursorSuffix must honor findActiveParkRect's result.\n Slug: SILVERY_STRICT=${CARET_OVERLAY_STRICT_SLUG} (tier 2+).`);
2326
+ }
2327
+ function cursorOwnerBounds(activeNode, cursor) {
2328
+ return {
2329
+ promptBounds: cursor ? {
2330
+ x: cursor.x,
2331
+ y: cursor.y,
2332
+ width: 1,
2333
+ height: 1
2334
+ } : null,
2335
+ composerBounds: activeNode ? computeContentRect(activeNode) : null
2336
+ };
2337
+ }
2338
+ /**
2339
+ * Compute the trailing cursor-control bytes for a managed fullscreen frame.
2340
+ *
2341
+ * The visible caret is painted into the presentation buffer by
2342
+ * `composeManagedCaret`, so the hardware cursor's only job is to stay OUT of
2343
+ * the way: every managed frame moves it to a deterministic safe cell and hides
2344
+ * it. Parking-before-hide is load-bearing because a multiplexer/terminal can
2345
+ * drop or override `?25l` (cmux re-shows the focused pane's cursor; an
2346
+ * unfocused pane renders a hollow cursor). If we hide WITHOUT moving, the
2347
+ * hardware cursor stays wherever the diff's last content write landed — the
2348
+ * bottom-most painted row — and a dropped hide leaves a visible cursor
2349
+ * stranded in transcript/activity/chrome content (the recurring
2350
+ * @km/code/v0.2/19702 signature, e.g. a block on the `Codex Done …` status
2351
+ * row). Parking pins that worst case to a benign, predictable cell instead.
2352
+ *
2353
+ * Park priority:
2354
+ * 1. The active caret cell — when a caret exists, the hardware cursor and the
2355
+ * composited caret coincide, so a dropped hide just overlaps the caret.
2356
+ * 2. The editable's DECLARED input-cell park target (`parkOffset`, resolved by
2357
+ * `findActiveParkRect`) — present even when the editable is unfocused/idle,
2358
+ * so a dropped hide surfaces the cursor ON the prompt input cell.
2359
+ * 3. Home (0,0) — last resort, ONLY for a frame with no editable/park declarer
2360
+ * at all (no input locus to be "above"). Never a dynamic transcript/chrome
2361
+ * row.
2362
+ *
2363
+ * The earlier composer-ORIGIN fallback (the editable's box top/border row — one
2364
+ * cell ABOVE the `>` prompt) is DELETED: it was the @km/code/v0.2/19702
2365
+ * bug-masking fallback (a multiplexer dropping `?25l` surfaced the parked cursor
2366
+ * one row above the prompt). Per Fail-Loud / no-defensive-fallbacks, a present
2367
+ * editable now declares its input cell via `parkOffset` instead of relying on a
2368
+ * silent box-origin degrade.
2369
+ */
2370
+ function managedCursorSuffix(cursor, parkRect, opts) {
2371
+ const park = cursor !== null ? {
2372
+ x: cursor.x,
2373
+ y: cursor.y
2374
+ } : parkRect !== null ? {
2375
+ x: parkRect.x,
2376
+ y: parkRect.y
2377
+ } : {
2378
+ x: 0,
2379
+ y: 0
2380
+ };
2381
+ const tail = opts?.visible === true ? ANSI.CURSOR_SHOW : ANSI.CURSOR_HIDE;
2382
+ return {
2383
+ suffix: ANSI.moveCursor(park.x, park.y) + tail,
2384
+ parkTarget: park
2385
+ };
2386
+ }
2387
+ /**
2388
+ * Transport-safe managed cursor delivery.
2389
+ *
2390
+ * The semantic suffix is still "park then hide" (`managedCursorSuffix` above).
2391
+ * Output paths append this idempotent SGR reset after it so the managed cursor
2392
+ * controls are not the literal trailing run of cursor-control bytes. That keeps
2393
+ * multiplexers that drop a trailing cursor-control tail from peeling the park
2394
+ * move away from the content frame that required it.
2395
+ */
2396
+ function protectManagedCursorSuffix(cursorSuffix) {
2397
+ return cursorSuffix.length === 0 ? "" : cursorSuffix + ANSI.RESET;
2398
+ }
2399
+ function computeManagedFrame(sourceBuffer, root, mode, opts) {
2400
+ if (mode !== "fullscreen") return {
2401
+ presentationBuffer: sourceBuffer,
2402
+ cursorSuffix: "",
2403
+ compositorCaret: null,
2404
+ cursorTarget: null,
2405
+ expectedTerminal: null,
2406
+ promptBounds: null,
2407
+ composerBounds: null
2408
+ };
2409
+ const active = findActiveCursorRectWithProvenance(root);
2410
+ const treeCursor = active?.rect ?? null;
2411
+ const legacyCursor = treeCursor ? null : opts?.legacyCursor ?? null;
2412
+ const cursor = treeCursor ?? legacyCursor;
2413
+ const bounds = cursorOwnerBounds(findActiveCursorNode(root), cursor);
2414
+ const suppressDeclarativeFallback = active?.provenance === "declarative-fallback";
2415
+ const windowFocused = opts?.windowFocused ?? true;
2416
+ const islandHardwareCursor = active?.provenance === "island" && windowFocused && treeCursor?.visible === true;
2417
+ const managed = composeManagedCaret(sourceBuffer, legacyCursor !== null ? legacyCursor : islandHardwareCursor || suppressDeclarativeFallback ? null : treeCursor, windowFocused);
2418
+ const prevCaret = opts?.prevCaret ?? null;
2419
+ const presentationBuffer = clearPriorCaretOverlay(managed.buffer, sourceBuffer, managed.compositorCaret, prevCaret);
2420
+ verifyNoCaretOverlayResidue(presentationBuffer, managed.compositorCaret, prevCaret);
2421
+ const parkRect = findActiveParkRect(root);
2422
+ const managedCursor = managedCursorSuffix(cursor, parkRect, { visible: islandHardwareCursor });
2423
+ verifyParkHonorsDeclaredTarget(cursor, parkRect, managedCursor.parkTarget);
2424
+ let cursorTarget = null;
2425
+ let expectedTerminal = null;
2426
+ if (cursor) {
2427
+ cursorTarget = {
2428
+ x: cursor.x,
2429
+ y: cursor.y,
2430
+ visible: cursor.visible,
2431
+ shape: cursor.shape
2432
+ };
2433
+ expectedTerminal = {
2434
+ ...cursorTarget,
2435
+ visible: islandHardwareCursor
2436
+ };
2437
+ } else if (managedCursor.parkTarget) expectedTerminal = {
2438
+ ...managedCursor.parkTarget,
2439
+ visible: false
2440
+ };
2441
+ return {
2442
+ presentationBuffer,
2443
+ cursorSuffix: managedCursor.suffix,
2444
+ compositorCaret: managed.compositorCaret,
2445
+ cursorTarget,
2446
+ expectedTerminal,
2447
+ promptBounds: bounds.promptBounds,
2448
+ composerBounds: bounds.composerBounds
2449
+ };
2450
+ }
2451
+ //#endregion
2452
+ //#region packages/ag-term/src/runtime/render-trace.ts
2453
+ /**
2454
+ * render-trace.ts — Render-boundary instrumentation for the Visual Eyes epic.
2455
+ *
2456
+ * Phase 4 of the Visual Eyes epic. Each silvery render-pass becomes an
2457
+ * observable `RENDER_DISPATCHED` event annotated with WHY it rendered:
2458
+ *
2459
+ * { ts, reason, dirtyRegions, signalDelta, fiberHash }
2460
+ *
2461
+ * A frame-trace consumer (e.g. termless's `createFrameTracer`) joins these
2462
+ * events onto its numbered frames by timestamp, so a trace frame's metadata
2463
+ * answers "what made this render happen" instead of just "the buffer changed".
2464
+ *
2465
+ * ## Design — additive instrumentation only
2466
+ *
2467
+ * This module emits an event AFTER the existing reconciler-flush + render
2468
+ * pipeline boundary in `renderer.ts`'s `doRender()`. It changes NO rendering
2469
+ * behavior, NO dirty-flag logic, NO pipeline ordering. It only reads state
2470
+ * the renderer already computed and writes it to an opt-in sink.
2471
+ *
2472
+ * ## Zero-cost when disabled
2473
+ *
2474
+ * The whole module is gated by the `SILVERY_TRACE_FRAMES` env var. When
2475
+ * unset, `isRenderTraceEnabled()` is `false`, `emitRenderDispatched()`
2476
+ * returns immediately, and the renderer's call site is a single boolean
2477
+ * check — production rendering pays nothing.
2478
+ *
2479
+ * ## Channel choice — sidecar JSONL + in-process bus
2480
+ *
2481
+ * `SILVERY_TRACE_FRAMES` names a directory (the same dir a termless
2482
+ * frame-trace writes its `index.jsonl` into). silvery appends render
2483
+ * events to `<dir>/render-events.jsonl`, one JSON object per line. termless
2484
+ * reads + joins on `ts`. This is the simplest robust channel for
2485
+ * `bun km view <vault>` — silvery and termless run in one process but are
2486
+ * separate packages with no shared module; a filesystem sidecar needs no
2487
+ * cross-package wiring and survives process boundaries too. Terminal-output
2488
+ * events are written to `<dir>/render-output-events.jsonl` with bytes,
2489
+ * changed-cell counts, dirty-row counts, and output-phase reason.
2490
+ *
2491
+ * A process-global in-memory ring buffer (`globalThis.__silvery_render_events`)
2492
+ * is also maintained so a same-process consumer can read events without
2493
+ * touching the filesystem.
2494
+ */
2495
+ const renderLog = createLogger("silvery:render");
2496
+ const outputLog = createLogger("silvery:render:output");
2497
+ let sidecarFile = null;
2498
+ let sidecarReady = false;
2499
+ let outputSidecarFile = null;
2500
+ let outputSidecarReady = false;
2501
+ /** The configured trace directory, or `null` when `SILVERY_TRACE_FRAMES` is unset. */
2502
+ function renderTraceDir() {
2503
+ return process.env.SILVERY_TRACE_FRAMES?.trim() || null;
2504
+ }
2505
+ /** True when sidecar or loggily render tracing is active. */
2506
+ function isRenderTraceEnabled() {
2507
+ return renderTraceDir() !== null || renderLog.debug !== void 0 || outputLog.debug !== void 0;
2508
+ }
2509
+ const MAX_BUFFERED_EVENTS = 4096;
2510
+ function bus() {
2511
+ const g = globalThis;
2512
+ if (!g.__silvery_render_events) g.__silvery_render_events = {
2513
+ events: [],
2514
+ outputEvents: []
2515
+ };
2516
+ else if (!g.__silvery_render_events.outputEvents) g.__silvery_render_events.outputEvents = [];
2517
+ return g.__silvery_render_events;
2518
+ }
2519
+ /**
2520
+ * Recent render events from the in-process bus. Consumers running in the
2521
+ * same process as silvery (e.g. a km-tui-wired termless tracer) can poll
2522
+ * this instead of reading the sidecar file.
2523
+ */
2524
+ function recentRenderEvents() {
2525
+ return bus().events;
2526
+ }
2527
+ /**
2528
+ * Recent output-frame events from the in-process bus. Same-process consumers
2529
+ * can join these with `recentRenderEvents()` by timestamp and frame order.
2530
+ */
2531
+ function recentRenderOutputEvents() {
2532
+ return bus().outputEvents;
2533
+ }
2534
+ /** Convert the buffer's per-row dirty predicate into contiguous ranges. */
2535
+ function dirtyRegionsFromBuffer(height, isRowDirty) {
2536
+ const regions = [];
2537
+ let runStart = -1;
2538
+ for (let y = 0; y < height; y++) if (isRowDirty(y)) {
2539
+ if (runStart === -1) runStart = y;
2540
+ } else if (runStart !== -1) {
2541
+ regions.push({
2542
+ row: runStart,
2543
+ height: y - runStart
2544
+ });
2545
+ runStart = -1;
2546
+ }
2547
+ if (runStart !== -1) regions.push({
2548
+ row: runStart,
2549
+ height: height - runStart
2550
+ });
2551
+ return regions;
2552
+ }
2553
+ /**
2554
+ * Emit a `RENDER_DISPATCHED` event for the just-completed render pass.
2555
+ *
2556
+ * No-op when `SILVERY_TRACE_FRAMES` is unset. Called from `renderer.ts`'s
2557
+ * `doRender()` at the flush boundary — AFTER reconciliation + the render
2558
+ * pipeline, just before the buffer is returned. Purely additive: reads
2559
+ * already-computed state, writes to the opt-in sink, never throws into the
2560
+ * render path.
2561
+ */
2562
+ function emitRenderDispatched(input) {
2563
+ const traceDir = renderTraceDir();
2564
+ const logEnabled = renderLog.debug !== void 0;
2565
+ if (traceDir === null && !logEnabled) return;
2566
+ try {
2567
+ const reasonParts = [...input.dirtyReasons];
2568
+ if (input.dimsChanged) reasonParts.push("dims-changed");
2569
+ const event = {
2570
+ type: "RENDER_DISPATCHED",
2571
+ ts: Date.now(),
2572
+ renderCount: input.renderCount,
2573
+ reason: reasonParts.length > 0 ? reasonParts.join(",") : "initial",
2574
+ dirtyRegions: dirtyRegionsFromBuffer(input.bufferHeight, input.isRowDirty),
2575
+ signalDelta: input.signalDelta,
2576
+ fiberHash: `${input.rootNodeCount}:${input.rootDirtyEpoch}`
2577
+ };
2578
+ const b = bus();
2579
+ b.events.push(event);
2580
+ if (b.events.length > MAX_BUFFERED_EVENTS) b.events.splice(0, b.events.length - MAX_BUFFERED_EVENTS);
2581
+ renderLog.debug?.("render dispatched", { ...event });
2582
+ if (traceDir !== null && !sidecarReady) {
2583
+ if (!existsSync(traceDir)) mkdirSync(traceDir, { recursive: true });
2584
+ sidecarFile = join(traceDir, "render-events.jsonl");
2585
+ appendFileSync(sidecarFile, "", { flag: "w" });
2586
+ sidecarReady = true;
2587
+ }
2588
+ if (traceDir !== null && sidecarFile) appendFileSync(sidecarFile, JSON.stringify(event) + "\n");
2589
+ } catch {}
2590
+ }
2591
+ /**
2592
+ * Emit a `RENDER_OUTPUT` event for bytes actually written to the terminal.
2593
+ *
2594
+ * No-op when `SILVERY_TRACE_FRAMES` is unset. This is intentionally separate
2595
+ * from `emitRenderDispatched`: the render pipeline produces dirty regions,
2596
+ * while the output phase later decides which cells/bytes were emitted.
2597
+ */
2598
+ function emitRenderOutputFrame(input) {
2599
+ const traceDir = renderTraceDir();
2600
+ const logEnabled = renderLog.debug !== void 0 || outputLog.debug !== void 0;
2601
+ if (traceDir === null && !logEnabled) return;
2602
+ try {
2603
+ const event = {
2604
+ type: "RENDER_OUTPUT",
2605
+ ts: Date.now(),
2606
+ renderCount: input.renderCount,
2607
+ outputFrame: input.outputFrame,
2608
+ bytes: input.bytes,
2609
+ diagnostics: input.diagnostics ? { ...input.diagnostics } : {}
2610
+ };
2611
+ const b = bus();
2612
+ b.outputEvents.push(event);
2613
+ if (b.outputEvents.length > MAX_BUFFERED_EVENTS) b.outputEvents.splice(0, b.outputEvents.length - MAX_BUFFERED_EVENTS);
2614
+ outputLog.debug?.("render output", { ...event });
2615
+ if (traceDir !== null && !outputSidecarReady) {
2616
+ if (!existsSync(traceDir)) mkdirSync(traceDir, { recursive: true });
2617
+ outputSidecarFile = join(traceDir, "render-output-events.jsonl");
2618
+ appendFileSync(outputSidecarFile, "", { flag: "w" });
2619
+ outputSidecarReady = true;
2620
+ }
2621
+ if (traceDir !== null && outputSidecarFile) appendFileSync(outputSidecarFile, JSON.stringify(event) + "\n");
2622
+ } catch {}
2623
+ }
2624
+ //#endregion
2625
+ //#region packages/ag-term/src/scheduler.ts
2626
+ /**
2627
+ * Silvery Render Scheduler
2628
+ *
2629
+ * Batches rapid state updates to prevent flicker and improve performance.
2630
+ * Uses queueMicrotask for coalescing multiple synchronous state changes
2631
+ * into a single render pass.
2632
+ *
2633
+ * Features:
2634
+ * - Microtask-based batching (coalesces synchronous updates)
2635
+ * - Frame batching to prevent flicker
2636
+ * - Resize handling with debounce
2637
+ * - Clean shutdown
2638
+ */
2639
+ const log$1 = createLogger("silvery:scheduler");
2640
+ /**
2641
+ * Whether synchronized update mode (DEC 2026) is enabled.
2642
+ *
2643
+ * Disabled by default due to a Ghostty rendering bug where incremental
2644
+ * cursor-positioned updates inside a sync region cause progressive visual
2645
+ * corruption. Works correctly in Kitty. Full renders (bufferToAnsi) work
2646
+ * fine with sync — only incremental diff output (changesToAnsi) triggers it.
2647
+ *
2648
+ * Set SILVERY_SYNC_UPDATE=1 to force-enable (e.g., for testing in Kitty).
2649
+ * TODO: Re-enable by default once the Ghostty bug is fixed.
2650
+ * See: https://github.com/ghostty-org/ghostty/discussions/11002
2651
+ */
2652
+ const SYNC_UPDATE_ENABLED = process.env.SILVERY_SYNC_UPDATE === "1" || process.env.SILVERY_SYNC_UPDATE === "true";
2653
+ /**
2654
+ * Schedules and batches render operations.
2655
+ *
2656
+ * Usage:
2657
+ * ```ts
2658
+ * const scheduler = new RenderScheduler({
2659
+ * stdout: process.stdout,
2660
+ * root: rootNode,
2661
+ * });
2662
+ *
2663
+ * // Schedule renders (automatically batched)
2664
+ * scheduler.scheduleRender();
2665
+ * scheduler.scheduleRender(); // This won't cause duplicate render
2666
+ *
2667
+ * // Force immediate render
2668
+ * scheduler.forceRender();
2669
+ *
2670
+ * // Clean shutdown
2671
+ * scheduler.dispose();
2672
+ * ```
2673
+ */
2674
+ var RenderScheduler = class {
2675
+ stdout;
2676
+ size;
2677
+ root;
2678
+ debugMode;
2679
+ minFrameTime;
2680
+ slowFrameThreshold;
2681
+ mode;
2682
+ pipelineConfig;
2683
+ getCursorState;
2684
+ /**
2685
+ * Read the terminal WINDOW focus state (@km/code/v0.2/19702). Threaded into
2686
+ * `computeManagedFrame` each frame: focused → filled block, unfocused → NO
2687
+ * caret (hidden). Defaults to `() => true` (the fail-safe focused default)
2688
+ * when no reader is supplied.
2689
+ */
2690
+ getWindowFocused;
2691
+ nonTTYMode;
2692
+ outputTransformer;
2693
+ writeOutput;
2694
+ /**
2695
+ * Render-output throughput monitor — backs `SILVERY_STRICT=bytes_out`.
2696
+ * Constructed lazily, only when the slug is active, so the no-op path
2697
+ * has zero overhead. See `bytes-out-monitor.ts` +
2698
+ * `docs/lessons/cmux-pty-buffer-firehose.md`.
2699
+ */
2700
+ bytesOutMonitor = null;
2701
+ /**
2702
+ * Repaint-storm monitor — backs `SILVERY_STRICT=flicker`. Sibling of
2703
+ * `bytesOutMonitor`; scans the SAME terminal-output bytes for destructive
2704
+ * full-screen clears / scroll-region resets and PANICs when they repeat fast
2705
+ * enough to constitute flicker (the `@ag/code/20297-pane-flicker-on-resize`
2706
+ * class). Lazily constructed only when the slug is active. See
2707
+ * `flicker-monitor.ts`.
2708
+ */
2709
+ flickerMonitor = null;
2710
+ /**
2711
+ * In-process heap-poll monitor — backs `SILVERY_STRICT=mem`. Sibling of
2712
+ * `bytesOutMonitor`; samples `process.memoryUsage()` on its own 30s
2713
+ * interval. See `mem-monitor.ts`.
2714
+ */
2715
+ memMonitor = null;
2716
+ log;
2717
+ /** Previous buffer for diffing */
2718
+ prevBuffer = null;
2719
+ /**
2720
+ * Previous presentation buffer for terminal output diffing, bundled with the
2721
+ * composited caret painted into it.
2722
+ *
2723
+ * The caret is threaded back into `computeManagedFrame` so a caret that moves
2724
+ * off / is suppressed on a static row gets that row marked dirty and the stale
2725
+ * `inverse` overlay cleared — the @km/code/v0.2/19702 overlay-residue fix.
2726
+ * Buffer and caret live in one struct so they cannot desync: every write sets
2727
+ * both in a single assignment, so the buffer baseline can never advance
2728
+ * without the caret it was composited with (see `PrevPresentation`).
2729
+ */
2730
+ prevPresentation = null;
2731
+ /**
2732
+ * Cross-frame post-state carrier (outline snapshots, etc.).
2733
+ *
2734
+ * Held at the scheduler level — not on `Ag` — because each `doRender()`
2735
+ * creates a fresh `Ag` per frame, so an Ag-internal `_postState` would be
2736
+ * empty every time and `clearPreviousOutlines` would never find prior
2737
+ * snapshots to restore. Mirrors the `prevBuffer` lifecycle: invalidated
2738
+ * together on resize / pause / clear so the snapshot coordinates always
2739
+ * match the buffer they reference.
2740
+ */
2741
+ postState = createRenderPostState();
2742
+ /** Line count of previous render (for non-TTY modes) */
2743
+ prevLineCount = 0;
2744
+ /** Monotonic terminal-output frame counter for render traces. */
2745
+ outputFrameCount = 0;
2746
+ /** Cached trace gate so disabled output frames do not read env per render. */
2747
+ renderTraceEnabled = false;
2748
+ /** Accumulated output for static mode */
2749
+ staticOutput = "";
2750
+ /** Is a render currently scheduled? */
2751
+ renderScheduled = false;
2752
+ /** Last render timestamp */
2753
+ lastRenderTime = 0;
2754
+ /** Pending frame timeout (for frame rate limiting) */
2755
+ frameTimeout = null;
2756
+ /** Resize listener cleanup */
2757
+ resizeCleanup = null;
2758
+ /**
2759
+ * Cursor-state listener cleanup.
2760
+ *
2761
+ * The scheduler subscribes to cursor-state changes so a re-render is
2762
+ * triggered whenever `useCursor` (mounts / unmounts / moves) changes the
2763
+ * store. Without this subscription, `setCursorState` calls that happen
2764
+ * AFTER a render completes (e.g. on the second commit of a parent Box,
2765
+ * when `NodeContext` finally propagates a non-null AgNode) leave the
2766
+ * scheduler holding a stale (`null`) cursor state — the previous
2767
+ * render emitted `CURSOR_HIDE` and no further render is triggered to
2768
+ * emit `moveCursor + CURSOR_SHOW`. Result: the hardware cursor stays
2769
+ * parked at the prior frame's last buffer write. See bead
2770
+ * `km-silvercode.cursor-startup-position`.
2771
+ */
2772
+ cursorCleanup = null;
2773
+ /** Render statistics */
2774
+ stats = {
2775
+ renderCount: 0,
2776
+ skippedCount: 0,
2777
+ lastRenderTime: 0,
2778
+ avgRenderTime: 0
2779
+ };
2780
+ /** Is the scheduler disposed? */
2781
+ disposed = false;
2782
+ /** Is the scheduler paused? When paused, renders are deferred until resume. */
2783
+ paused = false;
2784
+ /** Was a render requested while paused? */
2785
+ pendingWhilePaused = false;
2786
+ /**
2787
+ * Lines written to stdout between renders (inline mode only).
2788
+ * When useScrollback or other code writes to stdout, those lines
2789
+ * displace the terminal cursor. This offset is consumed on the next render.
2790
+ */
2791
+ scrollbackOffset = 0;
2792
+ constructor(options) {
2793
+ this.stdout = options.stdout;
2794
+ this.size = options.size;
2795
+ this.root = options.root;
2796
+ this.debugMode = options.debug ?? false;
2797
+ this.minFrameTime = options.minFrameTime ?? 16;
2798
+ this.slowFrameThreshold = options.slowFrameThreshold ?? 50;
2799
+ this.mode = options.mode ?? "fullscreen";
2800
+ this.pipelineConfig = options.pipelineConfig;
2801
+ this.getCursorState = options.cursorAccessors?.getCursorState ?? (() => null);
2802
+ this.getWindowFocused = options.windowFocused ?? (() => true);
2803
+ if (options.cursorAccessors) this.cursorCleanup = options.cursorAccessors.subscribeCursor(() => {
2804
+ this.scheduleRender();
2805
+ });
2806
+ this.writeOutput = options.writeOutput ?? ((data) => options.stdout.write(data));
2807
+ if (isStrictEnabled("bytes_out", 1)) this.bytesOutMonitor = createBytesOutMonitor();
2808
+ if (isStrictEnabled("flicker", 1)) this.flickerMonitor = createFlickerMonitor();
2809
+ if (isStrictEnabled("mem", 1)) this.memMonitor = createMemMonitor();
2810
+ this.renderTraceEnabled = isRenderTraceEnabled();
2811
+ this.log = createLogger("silvery:scheduler");
2812
+ this.nonTTYMode = resolveNonTTYMode({
2813
+ mode: options.nonTTYMode,
2814
+ stdout: this.stdout
2815
+ });
2816
+ this.outputTransformer = createOutputTransformer(this.nonTTYMode);
2817
+ log$1.debug?.(`non-TTY mode resolved to: ${this.nonTTYMode}`);
2818
+ if (this.nonTTYMode === "tty") this.setupResizeListener();
2819
+ }
2820
+ /**
2821
+ * Get the resolved non-TTY mode.
2822
+ */
2823
+ getNonTTYMode() {
2824
+ return this.nonTTYMode;
2825
+ }
2826
+ /**
2827
+ * Schedule a render on the next microtask.
2828
+ *
2829
+ * Multiple calls within the same synchronous execution will be
2830
+ * coalesced into a single render.
2831
+ */
2832
+ scheduleRender() {
2833
+ if (this.disposed) return;
2834
+ if (this.paused) {
2835
+ this.pendingWhilePaused = true;
2836
+ return;
2837
+ }
2838
+ if (this.renderScheduled) {
2839
+ this.stats.skippedCount++;
2840
+ log$1.debug?.(`render skipped (batched), total: ${this.stats.skippedCount}`);
2841
+ return;
2842
+ }
2843
+ this.renderScheduled = true;
2844
+ log$1.debug?.("render scheduled");
2845
+ queueMicrotask(() => {
2846
+ this.renderScheduled = false;
2847
+ if (this.disposed) return;
2848
+ const timeSinceLastRender = Date.now() - this.lastRenderTime;
2849
+ if (timeSinceLastRender < this.minFrameTime) {
2850
+ log$1.debug?.(`frame limited, delay: ${this.minFrameTime - timeSinceLastRender}ms`);
2851
+ this.scheduleNextFrame(this.minFrameTime - timeSinceLastRender);
2852
+ } else this.doRender();
2853
+ });
2854
+ }
2855
+ /**
2856
+ * Force an immediate render, bypassing batching.
2857
+ */
2858
+ forceRender() {
2859
+ if (this.disposed) return;
2860
+ if (this.paused) {
2861
+ this.pendingWhilePaused = true;
2862
+ return;
2863
+ }
2864
+ this.renderScheduled = false;
2865
+ if (this.frameTimeout) {
2866
+ clearTimeout(this.frameTimeout);
2867
+ this.frameTimeout = null;
2868
+ }
2869
+ this.doRender();
2870
+ }
2871
+ /**
2872
+ * Get render statistics.
2873
+ */
2874
+ getStats() {
2875
+ return { ...this.stats };
2876
+ }
2877
+ /**
2878
+ * Report lines written to stdout between renders (inline mode only).
2879
+ * This adjusts cursor position tracking so the next render accounts
2880
+ * for the extra lines. Used by useScrollback to notify the scheduler
2881
+ * when it writes frozen items to stdout.
2882
+ */
2883
+ addScrollbackLines(lines) {
2884
+ if (this.mode !== "inline" || lines <= 0) return;
2885
+ this.scrollbackOffset += lines;
2886
+ }
2887
+ /**
2888
+ * Copy text to the system clipboard via OSC 52.
2889
+ * Works across SSH sessions in terminals that support it.
2890
+ */
2891
+ copyToClipboard(text) {
2892
+ if (this.disposed) return;
2893
+ createOsc52Backend({ write: (data) => this.writeOutput(data) }).write({ text });
2894
+ }
2895
+ /**
2896
+ * Pause rendering. While paused, scheduled and forced renders are deferred.
2897
+ * Input handling continues normally. Call resume() to unpause and force a
2898
+ * full redraw. Used for screen-switching (alt screen ↔ normal screen).
2899
+ */
2900
+ pause() {
2901
+ if (this.disposed || this.paused) return;
2902
+ this.paused = true;
2903
+ this.pendingWhilePaused = false;
2904
+ log$1.debug?.("scheduler paused");
2905
+ }
2906
+ /**
2907
+ * Resume rendering after pause. Resets the previous buffer so the next
2908
+ * render outputs everything (full redraw), then forces an immediate render.
2909
+ */
2910
+ resume() {
2911
+ if (this.disposed || !this.paused) return;
2912
+ this.paused = false;
2913
+ log$1.debug?.("scheduler resumed");
2914
+ this.prevBuffer = null;
2915
+ this.prevPresentation = null;
2916
+ this.postState = createRenderPostState();
2917
+ if (this.pendingWhilePaused) {
2918
+ this.pendingWhilePaused = false;
2919
+ this.doRender();
2920
+ }
2921
+ }
2922
+ /**
2923
+ * Whether the scheduler is currently paused.
2924
+ */
2925
+ isPaused() {
2926
+ return this.paused;
2927
+ }
2928
+ /**
2929
+ * Clear the terminal and reset buffer.
2930
+ */
2931
+ clear() {
2932
+ if (this.disposed) return;
2933
+ this.writeOutput("\x1B[2J\x1B[H\x1B[?25l");
2934
+ this.prevBuffer = null;
2935
+ this.prevPresentation = null;
2936
+ this.postState = createRenderPostState();
2937
+ }
2938
+ /**
2939
+ * Dispose the scheduler and clean up resources.
2940
+ */
2941
+ [Symbol.dispose]() {
2942
+ this.dispose();
2943
+ }
2944
+ dispose() {
2945
+ if (this.disposed) return;
2946
+ log$1.debug?.(`dispose: renders=${this.stats.renderCount}, skipped=${this.stats.skippedCount}, avg=${Math.round(this.stats.avgRenderTime)}ms`);
2947
+ this.disposed = true;
2948
+ this.renderScheduled = false;
2949
+ if (this.frameTimeout) {
2950
+ clearTimeout(this.frameTimeout);
2951
+ this.frameTimeout = null;
2952
+ }
2953
+ if (this.resizeCleanup) {
2954
+ this.resizeCleanup();
2955
+ this.resizeCleanup = null;
2956
+ }
2957
+ if (this.cursorCleanup) {
2958
+ this.cursorCleanup();
2959
+ this.cursorCleanup = null;
2960
+ }
2961
+ if (this.bytesOutMonitor) {
2962
+ this.bytesOutMonitor.dispose();
2963
+ this.bytesOutMonitor = null;
2964
+ }
2965
+ if (this.flickerMonitor) {
2966
+ this.flickerMonitor.dispose();
2967
+ this.flickerMonitor = null;
2968
+ }
2969
+ if (this.memMonitor) {
2970
+ this.memMonitor.dispose();
2971
+ this.memMonitor = null;
2972
+ }
2973
+ if (this.nonTTYMode === "static" && this.staticOutput) {
2974
+ this.writeOutput(this.staticOutput);
2975
+ this.writeOutput("\n");
2976
+ }
2977
+ }
2978
+ /**
2979
+ * Get the last rendered output (for static mode).
2980
+ * Returns the plain text output that would be written on dispose.
2981
+ */
2982
+ getStaticOutput() {
2983
+ return this.staticOutput;
2984
+ }
2985
+ /**
2986
+ * Schedule render for next frame (frame rate limiting).
2987
+ */
2988
+ scheduleNextFrame(delay) {
2989
+ if (this.frameTimeout) return;
2990
+ this.frameTimeout = setTimeout(() => {
2991
+ this.frameTimeout = null;
2992
+ if (!this.disposed) this.doRender();
2993
+ }, delay);
2994
+ }
2995
+ /**
2996
+ * Resolve the active cursor for this frame.
2997
+ *
2998
+ * Priority:
2999
+ * 1. Layout-output cursor (BoxProps.cursorOffset → LayoutSignals.cursorRect).
3000
+ * Set synchronously by the layout phase, so available on the very first
3001
+ * frame after mount — no React-effect-chain stale read.
3002
+ * 2. Cursor store (legacy useCursor() / Ink-compat setCursorPosition()).
3003
+ * Falls back here when no node declares a layout-output cursor.
3004
+ *
3005
+ * Returns null when no active cursor in either path.
3006
+ *
3007
+ * The shape is identical between paths so callers (cursor suffix emission,
3008
+ * inline-mode pipeline cursor) treat them uniformly.
3009
+ */
3010
+ resolveActiveCursor() {
3011
+ const layoutCursor = findActiveCursorRect(this.root);
3012
+ if (layoutCursor) return layoutCursor;
3013
+ return this.getStoreCursor();
3014
+ }
3015
+ /**
3016
+ * The legacy/store cursor (useCursor() / Ink-compat setCursorPosition()),
3017
+ * projected into CursorRect shape, or null. This is the cursor source that
3018
+ * has NO owning AgNode — `computeManagedFrame` composites it unconditionally
3019
+ * (it is an explicit imperative request, not a focus-gated declarative
3020
+ * fallback). Layout-output cursors are resolved by `findActiveCursorRect`
3021
+ * inside `computeManagedFrame`, so this helper intentionally does NOT consult
3022
+ * the tree.
3023
+ */
3024
+ getStoreCursor() {
3025
+ const storeCursor = this.getCursorState();
3026
+ if (!storeCursor) return null;
3027
+ return {
3028
+ x: storeCursor.x,
3029
+ y: storeCursor.y,
3030
+ visible: storeCursor.visible !== false,
3031
+ shape: storeCursor.shape
3032
+ };
3033
+ }
3034
+ /**
3035
+ * Execute the actual render.
3036
+ */
3037
+ doRender() {
3038
+ using render = this.log.span?.("render");
3039
+ const startTime = Date.now();
3040
+ try {
3041
+ const width = this.size?.cols() ?? this.stdout.columns ?? 80;
3042
+ const height = this.mode === "inline" ? NaN : this.size?.rows() ?? this.stdout.rows ?? 24;
3043
+ log$1.debug?.(`render #${this.stats.renderCount + 1}: ${width}x${height}, nonTTYMode=${this.nonTTYMode}`);
3044
+ const scrollbackOffset = this.scrollbackOffset;
3045
+ this.scrollbackOffset = 0;
3046
+ const measurer = this.pipelineConfig?.measurer;
3047
+ let inlineCursor;
3048
+ const doRender = () => {
3049
+ const ag = createAg(this.root, { measurer });
3050
+ ag.layout({
3051
+ cols: width,
3052
+ rows: height
3053
+ });
3054
+ if (this.mode === "inline") inlineCursor = this.resolveActiveCursor();
3055
+ const { buffer, overlay } = ag.render({
3056
+ prevBuffer: this.prevBuffer,
3057
+ postState: this.postState
3058
+ });
3059
+ const managed = computeManagedFrame(buffer, this.root, this.mode, {
3060
+ legacyCursor: this.mode === "fullscreen" ? this.getStoreCursor() : null,
3061
+ prevCaret: this.prevPresentation?.caret ?? null,
3062
+ windowFocused: this.getWindowFocused()
3063
+ });
3064
+ const fullscreenCursor = managed.cursorTarget;
3065
+ const outputBuffer = managed.presentationBuffer;
3066
+ const start = performance.now();
3067
+ const outputFn = this.pipelineConfig?.outputPhaseFn ?? outputPhase;
3068
+ let ansiOutput;
3069
+ try {
3070
+ clearLastOutputPhaseDiagnostics();
3071
+ ansiOutput = outputFn(this.prevPresentation?.buffer ?? null, outputBuffer, this.mode, scrollbackOffset, this.mode === "inline" ? this.size?.rows() ?? this.stdout.rows ?? 24 : void 0, inlineCursor ?? void 0);
3072
+ } catch (e) {
3073
+ if (e instanceof Error) e.__silvery_buffer = buffer;
3074
+ throw e;
3075
+ }
3076
+ if (overlay) ansiOutput += overlay;
3077
+ const tOutput = performance.now() - start;
3078
+ const acc = globalThis.__silvery_bench_phases;
3079
+ if (acc) {
3080
+ acc.output += tOutput;
3081
+ acc.pipelineCalls += 1;
3082
+ }
3083
+ return {
3084
+ output: ansiOutput,
3085
+ buffer,
3086
+ outputBuffer,
3087
+ overlay,
3088
+ cursor: fullscreenCursor,
3089
+ compositorCaret: managed.compositorCaret,
3090
+ managed
3091
+ };
3092
+ };
3093
+ const { output, buffer, outputBuffer, overlay: incrementalOverlay, compositorCaret, managed: managedFrame } = measurer ? runWithMeasurer(measurer, doRender) : doRender();
3094
+ let transformedOutput;
3095
+ if (this.nonTTYMode === "tty") transformedOutput = output;
3096
+ else if (this.nonTTYMode === "static") {
3097
+ this.staticOutput = stripAnsi(output);
3098
+ transformedOutput = "";
3099
+ } else {
3100
+ transformedOutput = this.outputTransformer(output, this.prevLineCount);
3101
+ this.prevLineCount = countLines(output);
3102
+ }
3103
+ let cursorSuffix = "";
3104
+ let cursorTarget = null;
3105
+ let expectedTerminal = null;
3106
+ let promptBounds = null;
3107
+ let composerBounds = null;
3108
+ if (this.nonTTYMode === "tty") {
3109
+ cursorSuffix = managedFrame.cursorSuffix;
3110
+ cursorTarget = managedFrame.cursorTarget;
3111
+ expectedTerminal = managedFrame.expectedTerminal;
3112
+ promptBounds = managedFrame.promptBounds;
3113
+ composerBounds = managedFrame.composerBounds;
3114
+ }
3115
+ const cursorDelivery = protectManagedCursorSuffix(cursorSuffix);
3116
+ if (transformedOutput.length > 0 || cursorDelivery.length > 0) {
3117
+ const cursorDeliveryNeedsSync = this.nonTTYMode === "tty" && cursorSuffix.length > 0 && expectedTerminal !== null && (expectedTerminal.x !== 0 || expectedTerminal.y !== 0);
3118
+ const fullOutput = this.nonTTYMode === "tty" && (SYNC_UPDATE_ENABLED || cursorDeliveryNeedsSync) ? `${ANSI.SYNC_BEGIN}${transformedOutput}${cursorDelivery}${ANSI.SYNC_END}` : transformedOutput + cursorDelivery;
3119
+ if (log$1.debug) {
3120
+ const bytes = Buffer.byteLength(fullOutput);
3121
+ log$1.debug?.(`stdout.write: ${bytes} bytes (${transformedOutput.length} chars output + ${cursorSuffix.length} chars cursor)`);
3122
+ if (bytes > 16384) log$1.debug?.(`large output: ${bytes} bytes may exceed pipe buffer (16KB on macOS), risk of mid-sequence split`);
3123
+ }
3124
+ const captureFile = process.env.SILVERY_CAPTURE_OUTPUT;
3125
+ if (captureFile) {
3126
+ const fs = process.getBuiltinModule("node:fs");
3127
+ fs.appendFileSync(captureFile, `--- FRAME ${this.stats.renderCount + 1} (${Buffer.byteLength(fullOutput)} bytes) ---\n`);
3128
+ fs.appendFileSync(captureFile, fullOutput);
3129
+ fs.appendFileSync(captureFile, "\n");
3130
+ }
3131
+ recordOutputCursorDiagnostics({
3132
+ reason: "scheduler-render",
3133
+ mode: "fullscreen",
3134
+ width,
3135
+ height,
3136
+ output: fullOutput,
3137
+ target: cursorTarget,
3138
+ expectedTerminal,
3139
+ compositorCaret,
3140
+ promptBounds,
3141
+ composerBounds
3142
+ });
3143
+ this.writeOutput(fullOutput);
3144
+ if (this.flickerMonitor) {
3145
+ const renderNumber = this.stats.renderCount + 1;
3146
+ this.flickerMonitor.recordFrame(renderNumber, fullOutput);
3147
+ }
3148
+ const traceOutputEnabled = this.renderTraceEnabled || isRenderTraceEnabled();
3149
+ if (this.bytesOutMonitor || traceOutputEnabled) {
3150
+ const outputDiagnostics = getLastOutputPhaseDiagnostics();
3151
+ const bytes = Buffer.byteLength(fullOutput);
3152
+ const diagnostics = {
3153
+ ...outputDiagnostics,
3154
+ outputChars: transformedOutput.length,
3155
+ cursorChars: cursorDelivery.length,
3156
+ syncWrapped: this.nonTTYMode === "tty" && (SYNC_UPDATE_ENABLED || cursorDeliveryNeedsSync)
3157
+ };
3158
+ const renderNumber = this.stats.renderCount + 1;
3159
+ this.bytesOutMonitor?.recordWrite(renderNumber, bytes, diagnostics);
3160
+ if (traceOutputEnabled) {
3161
+ this.outputFrameCount += 1;
3162
+ emitRenderOutputFrame({
3163
+ renderCount: renderNumber,
3164
+ outputFrame: this.outputFrameCount,
3165
+ bytes,
3166
+ diagnostics
3167
+ });
3168
+ }
3169
+ }
3170
+ }
3171
+ this.prevBuffer = buffer;
3172
+ this.prevPresentation = {
3173
+ buffer: outputBuffer,
3174
+ caret: compositorCaret
3175
+ };
3176
+ commitLayoutSnapshot(this.root);
3177
+ const freshLayout = isStrictEnabled(FRESH_LAYOUT_STRICT_SLUG, 2);
3178
+ if ((isStrictEnabled("incremental", 1) || freshLayout) && this.stats.renderCount > 0) {
3179
+ const renderNum = this.stats.renderCount + 1;
3180
+ const doFreshRender = () => {
3181
+ const freshAg = createAg(this.root, { measurer });
3182
+ if (freshLayout) markLayoutTreeDirty(this.root);
3183
+ freshAg.layout({
3184
+ cols: width,
3185
+ rows: height
3186
+ }, {
3187
+ skipLayoutNotifications: true,
3188
+ forceFullPropagate: freshLayout
3189
+ });
3190
+ return freshAg.render({ fresh: true });
3191
+ };
3192
+ const { buffer: freshBuffer, overlay: freshOverlay } = measurer ? runWithMeasurer(measurer, doFreshRender) : doFreshRender();
3193
+ if (incrementalOverlay !== freshOverlay) {
3194
+ const msg = formatOverlayMismatch(incrementalOverlay, freshOverlay, renderNum);
3195
+ if (process.env.DEBUG_LOG) appendFileSync(process.env.DEBUG_LOG, msg + "\n");
3196
+ log$1.error?.(msg);
3197
+ throw new IncrementalRenderMismatchError(msg);
3198
+ }
3199
+ let found = false;
3200
+ for (let y = 0; y < buffer.height && !found; y++) for (let x = 0; x < buffer.width && !found; x++) {
3201
+ const a = buffer.getCell(x, y);
3202
+ const b = freshBuffer.getCell(x, y);
3203
+ if (!cellEquals(a, b)) {
3204
+ found = true;
3205
+ const ctx = buildMismatchContext(this.root, x, y, a, b, renderNum);
3206
+ const renderPhaseStats = globalThis.__silvery_content_detail ? structuredClone(globalThis.__silvery_content_detail) : void 0;
3207
+ const msg = formatMismatchContext(ctx, renderPhaseStats) + `--- incremental ---\n${bufferToText(buffer)}\n--- fresh ---\n${bufferToText(freshBuffer)}`;
3208
+ if (process.env.DEBUG_LOG) appendFileSync(process.env.DEBUG_LOG, msg + "\n");
3209
+ log$1.error?.(msg);
3210
+ throw new IncrementalRenderMismatchError(msg, {
3211
+ renderPhaseStats,
3212
+ mismatchContext: ctx
3213
+ });
3214
+ }
3215
+ }
3216
+ if (!found && process.env.DEBUG_LOG) appendFileSync(process.env.DEBUG_LOG, `SILVERY_STRICT: render #${renderNum} OK\n`);
3217
+ }
3218
+ const renderTime = Date.now() - startTime;
3219
+ this.stats.renderCount++;
3220
+ this.stats.lastRenderTime = renderTime;
3221
+ this.stats.avgRenderTime = (this.stats.avgRenderTime * (this.stats.renderCount - 1) + renderTime) / this.stats.renderCount;
3222
+ this.lastRenderTime = Date.now();
3223
+ if (render) {
3224
+ render.spanData.renderCount = this.stats.renderCount;
3225
+ render.spanData.renderTime = renderTime;
3226
+ render.spanData.bytes = transformedOutput.length;
3227
+ }
3228
+ log$1.debug?.(`render #${this.stats.renderCount} complete: ${renderTime}ms, output: ${transformedOutput.length} bytes`);
3229
+ const threshold = this.stats.renderCount <= 1 ? this.slowFrameThreshold * 5 : this.slowFrameThreshold;
3230
+ if (threshold > 0 && renderTime > threshold) log$1.debug?.(`slow frame: render #${this.stats.renderCount} took ${renderTime}ms (threshold: ${this.slowFrameThreshold}ms, bytes: ${transformedOutput.length})`);
3231
+ if (this.debugMode) this.logDebug(`Render #${this.stats.renderCount} took ${renderTime}ms`);
3232
+ } catch (error) {
3233
+ log$1.error?.(`render error: ${error}`);
3234
+ this.logError("Render error:", error);
3235
+ throw error;
3236
+ }
3237
+ }
3238
+ /**
3239
+ * Set up terminal resize listener.
3240
+ */
3241
+ setupResizeListener() {
3242
+ let resizeTimeout = null;
3243
+ const handleResize = () => {
3244
+ if (resizeTimeout) clearTimeout(resizeTimeout);
3245
+ resizeTimeout = setTimeout(() => {
3246
+ resizeTimeout = null;
3247
+ this.prevBuffer = null;
3248
+ this.prevPresentation = null;
3249
+ this.postState = createRenderPostState();
3250
+ this.scheduleRender();
3251
+ }, 50);
3252
+ };
3253
+ this.stdout.on("resize", handleResize);
3254
+ this.resizeCleanup = () => {
3255
+ this.stdout.off("resize", handleResize);
3256
+ if (resizeTimeout) clearTimeout(resizeTimeout);
3257
+ };
3258
+ }
3259
+ /**
3260
+ * Log debug message (via loggily, not stderr — stderr corrupts alt screen).
3261
+ */
3262
+ logDebug(message) {
3263
+ log$1.debug?.(message);
3264
+ }
3265
+ /**
3266
+ * Log error message (via loggily, not stderr — stderr corrupts alt screen).
3267
+ */
3268
+ logError(message, error) {
3269
+ if (error instanceof Error) log$1.error?.(`${message} ${error.stack ?? error.message}`);
3270
+ else log$1.error?.(`${message} ${String(error)}`);
3271
+ }
3272
+ };
3273
+ /**
3274
+ * Format a human-readable diff when the incremental and fresh Kitty overlays
3275
+ * disagree. The overlays are Kitty graphics protocol escape sequences that
3276
+ * place translucent "scrim" images over emoji cells inside faded regions
3277
+ * (see `pipeline/backdrop/`). They MUST be byte-identical across fresh and
3278
+ * incremental paths — any drift is a determinism bug in:
3279
+ *
3280
+ * - `collectBackdropMarkers` (tree walk order)
3281
+ * - `collectEmojiCellsInFadeRegion` (rect iteration)
3282
+ * - `backdropPlacementId` (per-cell ID derivation)
3283
+ * - `assertSingleAmount` (amount selection when markers disagree)
3284
+ *
3285
+ * The diff surfaces the placement IDs that differ — the Kitty protocol uses
3286
+ * `p=<id>` in each placement command, so grep-friendly IDs are the
3287
+ * quickest path from "test failed" to "which cell drifted".
3288
+ */
3289
+ function formatOverlayMismatch(incremental, fresh, renderNum) {
3290
+ const incPlacements = extractPlacementIds(incremental);
3291
+ const freshPlacements = extractPlacementIds(fresh);
3292
+ const incSet = new Set(incPlacements);
3293
+ const freshSet = new Set(freshPlacements);
3294
+ const onlyIncremental = incPlacements.filter((id) => !freshSet.has(id));
3295
+ const onlyFresh = freshPlacements.filter((id) => !incSet.has(id));
3296
+ const lines = [
3297
+ `[SILVERY_STRICT] Kitty overlay mismatch at render #${renderNum}`,
3298
+ ` incremental length: ${incremental.length} bytes, placements: ${incPlacements.length}`,
3299
+ ` fresh length: ${fresh.length} bytes, placements: ${freshPlacements.length}`
3300
+ ];
3301
+ if (onlyIncremental.length > 0) lines.push(` only in incremental (moved/appeared): ${onlyIncremental.join(", ")}`);
3302
+ if (onlyFresh.length > 0) lines.push(` only in fresh (missing/disappeared): ${onlyFresh.join(", ")}`);
3303
+ if (onlyIncremental.length === 0 && onlyFresh.length === 0) lines.push(` placements match — drift is in scrim-image payload or ordering`);
3304
+ const cap = 400;
3305
+ lines.push(` incremental[0..${cap}]: ${JSON.stringify(incremental.slice(0, cap))}`, ` fresh [0..${cap}]: ${JSON.stringify(fresh.slice(0, cap))}`);
3306
+ return lines.join("\n");
3307
+ }
3308
+ /**
3309
+ * Extract the list of `p=<id>` placement IDs from a Kitty overlay string, in
3310
+ * emission order. Used by the STRICT diagnostic to show which placements
3311
+ * moved/appeared/disappeared.
3312
+ */
3313
+ function extractPlacementIds(overlay) {
3314
+ const ids = [];
3315
+ const re = /p=(\d+)/g;
3316
+ let m;
3317
+ while ((m = re.exec(overlay)) !== null) ids.push(m[1]);
3318
+ return ids;
3319
+ }
3320
+ //#endregion
3321
+ //#region packages/create/src/runtime/with-paste-chain.ts
3322
+ /**
3323
+ * Install the paste router plugin.
3324
+ *
3325
+ * On `term:paste` ops:
3326
+ * - Call `routeToFocused(text)` first (if provided). If it returns
3327
+ * true, we short-circuit: handled by focus tree, no fallback.
3328
+ * - Otherwise invoke every registered global handler in order.
3329
+ * - Always emit `[{type:"render"}]` when handled, so the runner
3330
+ * knows to paint the post-paste state.
3331
+ */
3332
+ function withPasteChain(options = {}) {
3333
+ return (app) => {
3334
+ const handlers = [];
3335
+ const store = {
3336
+ handlers,
3337
+ register(handler) {
3338
+ handlers.push(handler);
3339
+ return () => {
3340
+ const i = handlers.indexOf(handler);
3341
+ if (i >= 0) handlers.splice(i, 1);
3342
+ };
3343
+ },
3344
+ unregister(handler) {
3345
+ for (let i = handlers.length - 1; i >= 0; i--) if (handlers[i] === handler) handlers.splice(i, 1);
3346
+ }
3347
+ };
3348
+ const prev = app.apply;
3349
+ app.apply = (op) => {
3350
+ if (op.type !== "term:paste") return prev(op);
3351
+ const text = op.text ?? "";
3352
+ let consumed = false;
3353
+ if (options.routeToFocused) try {
3354
+ consumed = !!options.routeToFocused(text);
3355
+ } catch (err) {
3356
+ console.error("[withPasteChain] routeToFocused threw", err);
3357
+ }
3358
+ if (!consumed && handlers.length > 0) {
3359
+ for (const handler of handlers) try {
3360
+ handler(text);
3361
+ } catch (err) {
3362
+ console.error("[withPasteChain] handler threw", err);
3363
+ }
3364
+ consumed = true;
3365
+ }
3366
+ if (!consumed) return prev(op);
3367
+ return [{ type: "render" }];
3368
+ };
3369
+ return Object.assign(app, { paste: store });
3370
+ };
3371
+ }
3372
+ //#endregion
3373
+ //#region packages/create/src/runtime/with-input-chain.ts
3374
+ function isModifierOnly(_input, key) {
3375
+ return key?.isModifierOnly === true;
3376
+ }
3377
+ /**
3378
+ * Install the fallback useInput store plugin.
3379
+ *
3380
+ * Pipe order note: place this plugin so it runs AFTER withFocusChain.
3381
+ * Because the last plugin in `pipe()` wraps the outermost layer, the
3382
+ * chain reads left-to-right with the base on the left:
3383
+ *
3384
+ * pipe(create(), withTerminalChain(), withInputChain(), withFocusChain())
3385
+ *
3386
+ * => apply order: focusChain -> inputChain -> terminalChain -> base
3387
+ *
3388
+ * => focused components consume before useInput, which is what we want.
3389
+ */
3390
+ function withInputChain(app) {
3391
+ const entries = [];
3392
+ const store = {
3393
+ handlers: entries,
3394
+ register(handler, active = true) {
3395
+ const entry = {
3396
+ handler,
3397
+ active
3398
+ };
3399
+ entries.push(entry);
3400
+ return () => {
3401
+ const i = entries.indexOf(entry);
3402
+ if (i >= 0) entries.splice(i, 1);
3403
+ };
3404
+ },
3405
+ setActive(handler, active) {
3406
+ for (const entry of entries) if (entry.handler === handler) entry.active = active;
3407
+ },
3408
+ unregister(handler) {
3409
+ for (let i = entries.length - 1; i >= 0; i--) if (entries[i].handler === handler) entries.splice(i, 1);
3410
+ }
3411
+ };
3412
+ const prev = app.apply;
3413
+ app.apply = (op) => {
3414
+ if (op.type !== "input:key") return prev(op);
3415
+ const input = op.input ?? "";
3416
+ const key = op.key;
3417
+ const isRelease = key?.eventType === "release";
3418
+ const modOnly = isModifierOnly(input, key);
3419
+ if (isRelease || modOnly) return prev(op);
3420
+ let hasActive = false;
3421
+ const effects = [];
3422
+ for (const entry of entries) {
3423
+ if (!entry.active) continue;
3424
+ hasActive = true;
3425
+ try {
3426
+ if (entry.handler(input, key ?? {}) === "exit") {
3427
+ effects.push({ type: "exit" });
3428
+ return effects;
3429
+ }
3430
+ } catch (err) {
3431
+ console.error("[withInputChain] handler threw", err);
3432
+ }
3433
+ }
3434
+ if (hasActive) return effects;
3435
+ return prev(op);
3436
+ };
3437
+ return Object.assign(app, { input: store });
3438
+ }
3439
+ //#endregion
3440
+ //#region packages/create/src/runtime/with-custom-events.ts
3441
+ /**
3442
+ * Install the custom-events plugin.
3443
+ *
3444
+ * Exposes the event bus at `app.events`. The plugin is observer-only —
3445
+ * it does not touch the op stream; every op passes straight through to
3446
+ * downstream plugins.
3447
+ */
3448
+ function withCustomEvents(app) {
3449
+ const channels = /* @__PURE__ */ new Map();
3450
+ const store = {
3451
+ on(channel, handler) {
3452
+ let handlers = channels.get(channel);
3453
+ if (!handlers) {
3454
+ handlers = [];
3455
+ channels.set(channel, handlers);
3456
+ }
3457
+ handlers.push(handler);
3458
+ return () => {
3459
+ const list = channels.get(channel);
3460
+ if (!list) return;
3461
+ const i = list.indexOf(handler);
3462
+ if (i >= 0) list.splice(i, 1);
3463
+ };
3464
+ },
3465
+ emit(channel, ...args) {
3466
+ const handlers = channels.get(channel);
3467
+ if (!handlers || handlers.length === 0) return;
3468
+ for (const handler of handlers.slice()) try {
3469
+ handler(...args);
3470
+ } catch (err) {
3471
+ console.error(`[withCustomEvents] handler for "${channel}" threw`, err);
3472
+ }
3473
+ },
3474
+ off(channel, handler) {
3475
+ const handlers = channels.get(channel);
3476
+ if (!handlers) return;
3477
+ for (let i = handlers.length - 1; i >= 0; i--) if (handlers[i] === handler) handlers.splice(i, 1);
3478
+ }
3479
+ };
3480
+ const prev = app.apply;
3481
+ app.apply = (op) => prev(op);
3482
+ return Object.assign(app, { events: store });
3483
+ }
3484
+ //#endregion
3485
+ //#region packages/ag-term/src/runtime/wrap-measurer-registration.ts
3486
+ /**
3487
+ * Wrap-measurer registration for `@silvery/ag-term`.
3488
+ *
3489
+ * `@silvery/ag` exposes a process-level wrap-measurer registry
3490
+ * (`setWrapMeasurer` / `getWrapMeasurer`) so geometry helpers like
3491
+ * `computeSelectionFragments` can ask the active terminal runtime for
3492
+ * grapheme-correct soft-wrap slices. Without registration, the geometry
3493
+ * layer falls back to `\n`-only line splitting — fine for pure-framework
3494
+ * unit tests, wrong for real apps where a 60-char paragraph at width 20
3495
+ * needs to emit 3 fragments.
3496
+ *
3497
+ * **Registration shape (Option B per `hub/silvery/design/overlay-anchor-system.md`
3498
+ * § 8)**: terminal-side `wrapTextWithOffsets` adapts to the registry's
3499
+ * `wrapText(text, maxWidth) → readonly WrapSlice[]` contract. The slice
3500
+ * shape is structurally identical (`text` + `startOffset` + `endOffset`),
3501
+ * so the adapter is a passthrough — no allocation, no reformatting.
3502
+ *
3503
+ * **Lifecycle**: registration happens at module load — see the side-effect
3504
+ * call below. The adapter reads the active scoped measurer (terminal caps,
3505
+ * wide-emoji heuristics, text-sizing) at call time, so caps changes during
3506
+ * a session (e.g., post-DA1 width-detection toggling `maybeWideEmojis`)
3507
+ * are picked up automatically.
3508
+ *
3509
+ * **Test isolation**: tests that need the `\n`-only fallback (pure-`ag`
3510
+ * pathway) call `setWrapMeasurer(null)` in `beforeEach`/`afterEach` and
3511
+ * `restoreDefaultWrapMeasurer()` in `afterAll`. Tests that exercise the
3512
+ * registered path do nothing — the import-time side effect already wired
3513
+ * it up.
3514
+ *
3515
+ * **Multi-Term**: v1 is module-level singleton — see `@silvery/ag/wrap-measurer.ts`
3516
+ * file header for the upgrade path. Single Term-per-process is the
3517
+ * production reality; multi-Term scenarios are research / future work.
3518
+ *
3519
+ * Tracking: bead `km-silvery.softwrap-selection-fragments` (closes Phase
3520
+ * 4b deferred wrap-spanning).
3521
+ */
3522
+ /**
3523
+ * The terminal-runtime adapter — a thin passthrough that converts
3524
+ * `wrapTextWithOffsets`'s return type to `readonly WrapSlice[]`. The
3525
+ * shapes are identical so this is a re-tag, but going through a typed
3526
+ * conversion lets us tighten or widen either side without breaking the
3527
+ * other.
3528
+ */
3529
+ const terminalWrapMeasurer = { wrapText(text, maxWidth) {
3530
+ return wrapTextWithOffsets(text, maxWidth);
3531
+ } };
3532
+ /**
3533
+ * Install the terminal-runtime wrap measurer into `@silvery/ag`'s registry.
3534
+ *
3535
+ * Reads the live registry (`getWrapMeasurer()`) — not a local boolean — to
3536
+ * decide whether to install. This makes the function safe to call after a
3537
+ * `setWrapMeasurer(null)` teardown that bypassed `uninstallTerminalWrapMeasurer`
3538
+ * (e.g. tests that drop the registration through `@silvery/ag`'s own API).
3539
+ *
3540
+ * Idempotent: when our adapter is already the registered measurer, this
3541
+ * is a no-op. When a foreign measurer is registered (rare — only seen if
3542
+ * a test or alternate runtime registered its own), this overwrites it
3543
+ * with our adapter, matching v1's "single Term-per-process" assumption.
3544
+ *
3545
+ * Called automatically at module load (see side-effect below); exported
3546
+ * for tests that want to re-arm after a `setWrapMeasurer(null)` teardown.
3547
+ */
3548
+ function installTerminalWrapMeasurer() {
3549
+ if (getWrapMeasurer() === terminalWrapMeasurer) return;
3550
+ setWrapMeasurer(terminalWrapMeasurer);
3551
+ }
3552
+ /**
3553
+ * Reset registration when our adapter is the active measurer. After
3554
+ * calling this, the `@silvery/ag` registry is empty (`getWrapMeasurer()`
3555
+ * returns null) and `computeSelectionFragments` falls back to `\n`-only
3556
+ * splitting. Tests use this to exercise the fallback path; runtime
3557
+ * teardown uses it on Term dispose.
3558
+ *
3559
+ * No-op when a foreign measurer is registered — clearing it would surprise
3560
+ * whoever installed it. Call `setWrapMeasurer(null)` directly if the goal
3561
+ * is to clear unconditionally.
3562
+ */
3563
+ function uninstallTerminalWrapMeasurer() {
3564
+ if (getWrapMeasurer() !== terminalWrapMeasurer) return;
3565
+ setWrapMeasurer(null);
3566
+ }
3567
+ /**
3568
+ * Test helper: restore the registry to its post-import-time default
3569
+ * state. Equivalent to `installTerminalWrapMeasurer()` but reads more
3570
+ * naturally in `afterEach` / `afterAll` blocks.
3571
+ */
3572
+ function restoreDefaultWrapMeasurer() {
3573
+ installTerminalWrapMeasurer();
3574
+ }
3575
+ /**
3576
+ * Read whether the terminal measurer is currently the active registration.
3577
+ * Sources truth from the live registry so a concurrent test that called
3578
+ * `setWrapMeasurer(null)` is observed correctly.
3579
+ */
3580
+ function isTerminalWrapMeasurerInstalled() {
3581
+ return getWrapMeasurer() === terminalWrapMeasurer;
3582
+ }
3583
+ installTerminalWrapMeasurer();
3584
+ //#endregion
3585
+ //#region packages/ag-react/src/scope-context.ts
3586
+ /**
3587
+ * ScopeContext — plumbs `Scope` down the React tree.
3588
+ *
3589
+ * Two contexts intentionally:
3590
+ *
3591
+ * - `ScopeContext` carries the *current* enclosing scope. Nested
3592
+ * `<ScopeProvider scope={...}>` elements shadow it. `useScope()` reads this.
3593
+ *
3594
+ * - `AppScopeContext` carries the *root app scope*. It is set exactly once,
3595
+ * at the app root, by `withScope()` (wired into `createApp()` in a later
3596
+ * phase) or by `<ScopeProvider scope={root} appScope={root}>` in tests.
3597
+ * `useAppScope()` reads this so whole-app shutdown paths always reach the
3598
+ * root even from deeply nested components.
3599
+ *
3600
+ * Separating them matters: nesting a `<ScopeProvider>` must not change what
3601
+ * `useAppScope()` returns — only `useScope()`.
3602
+ */
3603
+ /** Current enclosing scope. `null` when no provider is above the component. */
3604
+ const ScopeContext = createContext(null);
3605
+ /** Root app scope. Set once by `withScope()` (or in tests). */
3606
+ const AppScopeContext = createContext(null);
3607
+ //#endregion
3608
+ //#region packages/ag-react/src/ScopeProvider.tsx
3609
+ function ScopeProvider({ scope, appScope, children }) {
3610
+ let tree = /* @__PURE__ */ jsx(Fragment, { children });
3611
+ if (scope !== void 0) tree = /* @__PURE__ */ jsx(ScopeContext.Provider, {
3612
+ value: scope,
3613
+ children: tree
3614
+ });
3615
+ if (appScope !== void 0) tree = /* @__PURE__ */ jsx(AppScopeContext.Provider, {
3616
+ value: appScope,
3617
+ children: tree
3618
+ });
3619
+ return tree;
3620
+ }
3621
+ //#endregion
3622
+ //#region packages/ag/src/cls.ts
3623
+ /**
3624
+ * CLS — Cumulative Layout Shift instrumentation for silvery.
3625
+ *
3626
+ * Analogous to Core Web Vitals CLS: a layout shift is the same block
3627
+ * rendering at a different rect in successive frames during a single
3628
+ * user-action window. Score = sum(area × distance) over all shifts.
3629
+ *
3630
+ * This module is pure: it defines the data shape + diff math. The runtime
3631
+ * ClsMonitor (ag-term/runtime/cls-monitor) calls these helpers per render
3632
+ * commit; the strict-mode check (ag-term/strict-cls) consumes the CLSReport.
3633
+ * The termless capture API (ag-term/app) drives begin/end of the capture
3634
+ * window via ClsMonitor.
3635
+ *
3636
+ * Bead: km-silvery.cls-instrumentation-primitive
3637
+ */
3638
+ /**
3639
+ * Default reflow-reason classifier — every shift is "unexpected". Tests
3640
+ * + close-gates use this to surface every shift; apps with legitimate
3641
+ * reflow events (streaming chat, animation) pass a custom classifier.
3642
+ */
3643
+ const defaultClassifier = () => "unexpected";
3644
+ /**
3645
+ * Compute the "impact" of a single shift: max(prev-area, curr-area) ×
3646
+ * euclidean-distance-moved between top-left corners. Returns 0 when the
3647
+ * rect is unchanged.
3648
+ *
3649
+ * Area is in cells (cols × rows); distance is in cells. Web CLS uses
3650
+ * "impact fraction" relative to viewport — we use raw area × distance
3651
+ * because terminal viewports are small and per-block context matters more
3652
+ * than viewport-relative weighting. Score is comparable across runs in the
3653
+ * same terminal-size + content envelope; not directly comparable across
3654
+ * different sizes.
3655
+ */
3656
+ function computeShiftScore(from, to) {
3657
+ if (rectEqual(from, to)) return 0;
3658
+ const dx = to.x - from.x;
3659
+ const dy = to.y - from.y;
3660
+ const distance = Math.sqrt(dx * dx + dy * dy);
3661
+ const fromArea = from.width * from.height;
3662
+ const toArea = to.width * to.height;
3663
+ return Math.max(fromArea, toArea) * distance;
3664
+ }
3665
+ /**
3666
+ * Build a LayoutShift record from a rect transition. Returns null when the
3667
+ * rects are equal (no shift — caller can skip without branching).
3668
+ */
3669
+ function makeShift(blockId, fromRect, toRect, frameTimestamp, reflowReason) {
3670
+ if (rectEqual(fromRect, toRect)) return null;
3671
+ return {
3672
+ blockId,
3673
+ fromRect,
3674
+ toRect,
3675
+ frameTimestamp,
3676
+ reflowReason
3677
+ };
3678
+ }
3679
+ /**
3680
+ * Aggregate a list of shifts into a CLSReport. Pure: callers can stash
3681
+ * the shifts array however they like (per-frame, per-capture, per-test)
3682
+ * and call this once when building the report.
3683
+ *
3684
+ * cumulativeScore sums every shift, regardless of reflowReason — the
3685
+ * intent is "how much did the layout move?" — not "how much surprise?".
3686
+ * For surprise, callers check unexpectedShifts.length or
3687
+ * aggregateUnexpectedScore().
3688
+ */
3689
+ function aggregateReport(shifts) {
3690
+ let cumulativeScore = 0;
3691
+ const unexpectedShifts = [];
3692
+ for (const s of shifts) {
3693
+ cumulativeScore += computeShiftScore(s.fromRect, s.toRect);
3694
+ if (s.reflowReason === "unexpected") unexpectedShifts.push(s);
3695
+ }
3696
+ return {
3697
+ shifts,
3698
+ cumulativeScore,
3699
+ unexpectedShifts
3700
+ };
3701
+ }
3702
+ /**
3703
+ * Sum of impact scores for the unexpected subset only. Strict-mode checks
3704
+ * use this; "all shifts including content-arrival" is too noisy for an
3705
+ * assertion threshold.
3706
+ */
3707
+ function aggregateUnexpectedScore(shifts) {
3708
+ let score = 0;
3709
+ for (const s of shifts) {
3710
+ if (s.reflowReason !== "unexpected") continue;
3711
+ score += computeShiftScore(s.fromRect, s.toRect);
3712
+ }
3713
+ return score;
3714
+ }
3715
+ //#endregion
3716
+ //#region packages/ag-term/src/strict-cls.ts
3717
+ /**
3718
+ * STRICT cls — assert no unexpected layout shifts in a CLS capture window.
3719
+ *
3720
+ * Bead: km-silvery.cls-instrumentation-primitive (Phase 4/7, 2026-05-13).
3721
+ *
3722
+ * The CLS instrumentation primitive (packages/ag/src/cls.ts) measures
3723
+ * layout shifts per frame, classifies each as user-action / animation /
3724
+ * content-arrival / unexpected, and aggregates into a CLSReport. Most
3725
+ * apps want shifts of the first three reasons to be allowed (they
3726
+ * correspond to legitimate reflow events). The "unexpected" reason is
3727
+ * the actionable subset — it's the bug class CLS is designed to catch
3728
+ * (a code fence resizing mid-stream, a status line bouncing, a card
3729
+ * flashing flush-left before snapping to its real position).
3730
+ *
3731
+ * SILVERY_STRICT=cls flips this from "consumers decide" to "assert that
3732
+ * the active capture observed no unexpected shifts." Termless tests can
3733
+ * then ship a green close-gate via the umbrella env var rather than
3734
+ * hand-rolled assertions in every test.
3735
+ *
3736
+ * Tier 2 (paranoid). `SILVERY_STRICT=2` includes it; `SILVERY_STRICT=1`
3737
+ * does NOT — the default fast pass leaves CLS to consumer-side checks
3738
+ * so that pre-existing unfixed shift offenders don't fail every test
3739
+ * run before they're fixed.
3740
+ *
3741
+ * Why ag-term/src/ and not pipeline/strict-cls.ts: this check fires at
3742
+ * capture-end (termless `endCLSCapture()`), not per-frame in the pipeline.
3743
+ * It has no coupling to render-phase / layout-phase internals — it
3744
+ * consumes a CLSReport and decides via the SILVERY_STRICT contract. The
3745
+ * placement parallels `strict-bordered-rect.ts` (another pure assertion
3746
+ * helper that sits outside `pipeline/`).
3747
+ */
3748
+ /** SILVERY_STRICT slug for the CLS check. Tier 2 by design. */
3749
+ const CLS_STRICT_SLUG = "cls";
3750
+ const CLS_STRICT_MIN_TIER = 2;
3751
+ /** Returns true when SILVERY_STRICT=cls (or =2 / =3 / etc.) is enabled. */
3752
+ function isClsStrictEnabled() {
3753
+ return isStrictEnabled("cls", 2);
3754
+ }
3755
+ /**
3756
+ * Thrown when SILVERY_STRICT=cls is enabled and a capture window saw at
3757
+ * least one shift with reflowReason="unexpected".
3758
+ *
3759
+ * Carries the offending shifts AND the cumulative unexpected-only score
3760
+ * so failing tests can point at the worst offenders without rerunning.
3761
+ * `.message` is human-readable; `.shifts` + `.score` are for programmatic
3762
+ * inspection.
3763
+ */
3764
+ var UnexpectedLayoutShiftError = class extends Error {
3765
+ shifts;
3766
+ score;
3767
+ constructor(shifts, score) {
3768
+ const summary = shifts.slice(0, 5).map((s) => ` ${s.blockId}: (${s.fromRect.x},${s.fromRect.y} ${s.fromRect.width}×${s.fromRect.height}) → (${s.toRect.x},${s.toRect.y} ${s.toRect.width}×${s.toRect.height})`).join("\n");
3769
+ const tail = shifts.length > 5 ? `\n ... and ${shifts.length - 5} more` : "";
3770
+ super(`[SILVERY_STRICT=cls] CLSReport contains ${shifts.length} unexpected layout shift(s), cumulative score ${score.toFixed(2)}:\n${summary}${tail}`);
3771
+ this.name = "UnexpectedLayoutShiftError";
3772
+ this.shifts = shifts;
3773
+ this.score = score;
3774
+ }
3775
+ };
3776
+ /**
3777
+ * Throws `UnexpectedLayoutShiftError` if the report contains any
3778
+ * `reflowReason="unexpected"` shifts AND `SILVERY_STRICT=cls` (or a
3779
+ * stricter tier) is enabled. Otherwise a no-op.
3780
+ *
3781
+ * Callers (termless `endCLSCapture`, test harnesses, app-level
3782
+ * close-gate hooks) should invoke this immediately after producing a
3783
+ * CLSReport — the error names the offending blocks so the failure
3784
+ * message points at the bug.
3785
+ */
3786
+ function assertNoUnexpectedShifts(report) {
3787
+ if (!isClsStrictEnabled()) return;
3788
+ if (report.unexpectedShifts.length === 0) return;
3789
+ const score = aggregateUnexpectedScore(report.shifts);
3790
+ throw new UnexpectedLayoutShiftError(report.unexpectedShifts, score);
3791
+ }
3792
+ //#endregion
3793
+ //#region packages/ag-term/src/runtime/cls-monitor.ts
3794
+ /**
3795
+ * Layout-shift (CLS) monitor — post-commit instrumentation.
3796
+ *
3797
+ * Detects two classes of layout instability:
3798
+ *
3799
+ * 1. **Layout shifts** — an AgNode's `screenRect` changes between
3800
+ * consecutive render commits with no scroll/resize cause. Logged as
3801
+ * per-commit counts under `DEBUG=silvery:cls`; quick-reflow storms (the
3802
+ * same path shifting many times in a short window) escalate to a
3803
+ * `warn` summary. Full per-node shift logs live under the narrower
3804
+ * `DEBUG=silvery:cls-detail` namespace so instrumentation does not create
3805
+ * the scroll jank it is meant to diagnose.
3806
+ *
3807
+ * 2. **Unreasonable sizes** — degenerate or oversized rects detected
3808
+ * during the same walk: `width=0` / `height=0` with visible content
3809
+ * underneath, negative coords, rect exceeding the terminal bounds.
3810
+ * Logged at `warn` regardless of `DEBUG`.
3811
+ *
3812
+ * Inspired by Web Vitals' Cumulative Layout Shift. The DOM equivalent
3813
+ * fires on visible content moving; in terminals the same shape produces
3814
+ * the "first centered, then shifts left" symptom the user reported
3815
+ * 2026-05-11 12:42 + 13:05.
3816
+ *
3817
+ * Bead: @km/silvery/layout-shift-instrumentation-cls.
3818
+ *
3819
+ * ## Activation
3820
+ *
3821
+ * The walk runs every commit but does nothing when no logger method is
3822
+ * armed. Activation:
3823
+ *
3824
+ * - `DEBUG=silvery:cls` — summary instrumentation, per-commit shift
3825
+ * counts + storm `warn` logs + size-violation `warn` logs.
3826
+ * - `DEBUG=silvery:cls-detail` — full per-node shift `debug` logs.
3827
+ * - `LOG_LEVEL=warn` only — storm + size-violation `warn` logs (no
3828
+ * per-shift spam). Useful in CI / smoke tests.
3829
+ *
3830
+ * In both modes, the per-commit walk is O(visible-tree-nodes); the
3831
+ * dominant cost is the tree traversal, not the comparison. When
3832
+ * disabled, only the `if (!log.debug && !log.warn) return` guard runs.
3833
+ */
3834
+ const log = createLogger("silvery:cls");
3835
+ const shiftLog = createLogger("silvery:cls-detail");
3836
+ /**
3837
+ * Opt-in gate. CLS instrumentation runs ONLY when one of these
3838
+ * env vars is set:
3839
+ *
3840
+ * - `DEBUG=silvery:cls` / `DEBUG=silvery:cls-detail` (or `DEBUG=silvery:*` / `DEBUG=*`)
3841
+ * - `SILVERY_INSTRUMENT=cls`
3842
+ *
3843
+ * Without the gate, even `warn`-level output stays silent — the
3844
+ * instrumentation produces too much noise in synthetic test
3845
+ * harnesses (createRenderer fixtures often have transient
3846
+ * zero-rect frames during initial measurement) to be safely
3847
+ * always-on. Phase 2 of the bead will tier specific checks back
3848
+ * to always-on once their false-positive rate is characterised.
3849
+ *
3850
+ * Bead: @km/silvery/layout-shift-instrumentation-cls.
3851
+ */
3852
+ function clsEnabled() {
3853
+ if (process.env.SILVERY_INSTRUMENT === "cls") return true;
3854
+ const debug = process.env.DEBUG ?? "";
3855
+ if (!debug) return false;
3856
+ return /(^|,)\s*(silvery:cls|silvery:cls-detail|silvery:\*|\*)\s*(,|$)/.test(debug);
3857
+ }
3858
+ const STORM_PATH_WINDOW_MS = 500;
3859
+ const STORM_PATH_THRESHOLD = 3;
3860
+ const STORM_COMMIT_THRESHOLD = 5;
3861
+ /** Data-* attributes treated as identity tokens for CLS path resolution.
3862
+ * Any component carrying one of these in its props is named in the
3863
+ * layout-shift log even without an explicit `id` / `data-testid`. The
3864
+ * list is intentionally broad so anonymous app-level boxes (cards,
3865
+ * chat blocks, code containers, table grids) show up by role instead
3866
+ * of collapsing to a generic "box" entry. */
3867
+ const IDENTITY_DATA_KEYS = [
3868
+ "data-view",
3869
+ "data-block",
3870
+ "data-component",
3871
+ "data-card-id",
3872
+ "data-pane",
3873
+ "data-role"
3874
+ ];
3875
+ function createClsMonitor() {
3876
+ const envEnabled = clsEnabled();
3877
+ const pathHistory = /* @__PURE__ */ new Map();
3878
+ let prevCols = 0;
3879
+ let prevRows = 0;
3880
+ let capturing = false;
3881
+ let activeClassifier = defaultClassifier;
3882
+ let sessionShifts = [];
3883
+ function ensureHistory(path) {
3884
+ let h = pathHistory.get(path);
3885
+ if (h === void 0) {
3886
+ h = {
3887
+ ts: [],
3888
+ firedKinds: /* @__PURE__ */ new Set(),
3889
+ lastShiftAt: null,
3890
+ inStorm: false
3891
+ };
3892
+ pathHistory.set(path, h);
3893
+ }
3894
+ return h;
3895
+ }
3896
+ function rectEqual(a, b) {
3897
+ if (a === b) return true;
3898
+ if (a === null || b === null) return false;
3899
+ return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
3900
+ }
3901
+ function nodePath(node) {
3902
+ const parts = [];
3903
+ let n = node;
3904
+ let depth = 0;
3905
+ let isLeaf = true;
3906
+ while (n && depth < 20) {
3907
+ const tag = identityTag(n);
3908
+ if (tag !== null) {
3909
+ const typeTag = n.type === "silvery-box" ? "" : `${typeShort(n.type)}`;
3910
+ parts.push(`${typeTag}${tag}`);
3911
+ } else if (isLeaf) parts.push(typeShort(n.type));
3912
+ isLeaf = false;
3913
+ n = n.parent;
3914
+ depth++;
3915
+ }
3916
+ return parts.reverse().join(" > ");
3917
+ }
3918
+ /** Resolve a node's identity tag for CLS paths. Returns null when no
3919
+ * recognised identity prop is set — the node is then treated as a
3920
+ * transparent wrapper. */
3921
+ function identityTag(node) {
3922
+ const props = node.props;
3923
+ const id = props.id;
3924
+ if (typeof id === "string" && id.length > 0) return `#${id}`;
3925
+ const testid = props["data-testid"];
3926
+ if (typeof testid === "string" && testid.length > 0) return `#${testid}`;
3927
+ for (const key of IDENTITY_DATA_KEYS) {
3928
+ const v = props[key];
3929
+ if (typeof v === "string" && v.length > 0) return `[${key}=${v}]`;
3930
+ }
3931
+ return null;
3932
+ }
3933
+ function typeShort(type) {
3934
+ return type.startsWith("silvery-") ? type.slice(8) : type;
3935
+ }
3936
+ function recordShift(rec, now) {
3937
+ const h = ensureHistory(rec.path);
3938
+ rec.dtMs = h.lastShiftAt === null ? null : now - h.lastShiftAt;
3939
+ h.lastShiftAt = now;
3940
+ h.ts.push(now);
3941
+ while (h.ts.length > 0 && h.ts[0] < now - STORM_PATH_WINDOW_MS) h.ts.shift();
3942
+ const inStorm = h.ts.length >= STORM_PATH_THRESHOLD;
3943
+ const justEnteredStorm = inStorm && !h.inStorm;
3944
+ h.inStorm = inStorm;
3945
+ return justEnteredStorm;
3946
+ }
3947
+ function fireOnce(path, key) {
3948
+ const h = ensureHistory(path);
3949
+ if (h.firedKinds.has(key)) return false;
3950
+ h.firedKinds.add(key);
3951
+ return true;
3952
+ }
3953
+ function checkSizeInvariants(node, cols, rows) {
3954
+ const rect = node.screenRect;
3955
+ if (!rect) return;
3956
+ if (node.type === "silvery-text") return;
3957
+ const path = nodePath(node);
3958
+ if (rect.x < 0 || rect.y < 0 || rect.width < 0 || rect.height < 0) {
3959
+ if (fireOnce(path, "negative")) log.warn?.("negative-rect", {
3960
+ path,
3961
+ rect
3962
+ });
3963
+ }
3964
+ if (rect.width > cols || rect.height > rows) {
3965
+ if (fireOnce(path, "overflow")) log.warn?.("rect-overflows-terminal", {
3966
+ path,
3967
+ rect,
3968
+ cols,
3969
+ rows
3970
+ });
3971
+ }
3972
+ if ((rect.width === 0 || rect.height === 0) && hasVisibleContent(node)) {
3973
+ if (fireOnce(path, "zero-area-with-content")) log.warn?.("zero-area-with-content", {
3974
+ path,
3975
+ rect
3976
+ });
3977
+ }
3978
+ }
3979
+ function hasVisibleContent(node) {
3980
+ if (node.type === "silvery-text") return true;
3981
+ const props = node.props;
3982
+ if (props.backgroundColor !== void 0 || props.borderStyle !== void 0) return true;
3983
+ for (const child of node.children) if (hasVisibleContent(child)) return true;
3984
+ return false;
3985
+ }
3986
+ function walk(node, cols, rows, suppressShift, commitShifts) {
3987
+ if (!node.screenRect) {
3988
+ for (const child of node.children) walk(child, cols, rows, suppressShift, commitShifts);
3989
+ return;
3990
+ }
3991
+ if (envEnabled) checkSizeInvariants(node, cols, rows);
3992
+ if (!suppressShift) {
3993
+ const prev = node.prevScreenRect;
3994
+ const next = node.screenRect;
3995
+ if (prev !== null && !rectEqual(prev, next)) {
3996
+ const isFirstMeasure = prev.width === 0 && prev.height === 0 && next.width > 0 && next.height > 0;
3997
+ const isInlineTextNoise = node.type === "silvery-text" && (prev.width === 0 || prev.height === 0) && (next.width === 0 || next.height === 0);
3998
+ if (!isFirstMeasure && !isInlineTextNoise) commitShifts.push({
3999
+ path: nodePath(node),
4000
+ prev,
4001
+ next,
4002
+ dtMs: null
4003
+ });
4004
+ }
4005
+ }
4006
+ for (const child of node.children) walk(child, cols, rows, suppressShift, commitShifts);
4007
+ }
4008
+ function onCommit(root, cols, rows, scrollOrResize) {
4009
+ const suppressShift = scrollOrResize || cols !== prevCols || rows !== prevRows || prevCols === 0 || prevRows === 0;
4010
+ prevCols = cols;
4011
+ prevRows = rows;
4012
+ if (!envEnabled && !capturing) return;
4013
+ if (!root) return;
4014
+ const commitShifts = [];
4015
+ walk(root, cols, rows, suppressShift, commitShifts);
4016
+ if (capturing && !suppressShift && commitShifts.length > 0) {
4017
+ const ts = Date.now();
4018
+ for (const rec of commitShifts) {
4019
+ if (rec.prev === null || rec.next === null) continue;
4020
+ const reason = activeClassifier(rec.path, rec.prev, rec.next, ts);
4021
+ const shift = makeShift(rec.path, rec.prev, rec.next, ts, reason);
4022
+ if (shift !== null) sessionShifts.push(shift);
4023
+ }
4024
+ }
4025
+ if (!envEnabled) return;
4026
+ if (commitShifts.length === 0) return;
4027
+ const now = performance.now();
4028
+ let stormPaths = 0;
4029
+ for (const rec of commitShifts) {
4030
+ if (recordShift(rec, now)) stormPaths++;
4031
+ shiftLog.debug?.("shift", {
4032
+ path: rec.path,
4033
+ prev: rec.prev,
4034
+ next: rec.next,
4035
+ dtMs: rec.dtMs
4036
+ });
4037
+ }
4038
+ log.debug?.("shift-count", {
4039
+ shifts: commitShifts.length,
4040
+ firstPaths: commitShifts.slice(0, 5).map((r) => r.path)
4041
+ });
4042
+ if (stormPaths > 0) log.warn?.("reflow-storm-per-path", {
4043
+ stormPaths,
4044
+ windowMs: STORM_PATH_WINDOW_MS,
4045
+ threshold: STORM_PATH_THRESHOLD
4046
+ });
4047
+ if (commitShifts.length >= STORM_COMMIT_THRESHOLD) log.warn?.("reflow-storm-per-commit", {
4048
+ shifts: commitShifts.length,
4049
+ threshold: STORM_COMMIT_THRESHOLD,
4050
+ firstPaths: commitShifts.slice(0, 5).map((r) => r.path)
4051
+ });
4052
+ }
4053
+ function reset() {
4054
+ pathHistory.clear();
4055
+ prevCols = 0;
4056
+ prevRows = 0;
4057
+ }
4058
+ function beginCapture(classifier) {
4059
+ if (capturing) throw new Error("ClsMonitor.beginCapture: already capturing. Call endCapture() or cancelCapture() before starting a new window.");
4060
+ capturing = true;
4061
+ activeClassifier = classifier ?? defaultClassifier;
4062
+ sessionShifts = [];
4063
+ }
4064
+ function endCapture() {
4065
+ if (!capturing) throw new Error("ClsMonitor.endCapture: not capturing. Call beginCapture() first.");
4066
+ const report = aggregateReport(sessionShifts);
4067
+ capturing = false;
4068
+ activeClassifier = defaultClassifier;
4069
+ sessionShifts = [];
4070
+ assertNoUnexpectedShifts(report);
4071
+ return report;
4072
+ }
4073
+ function cancelCapture() {
4074
+ capturing = false;
4075
+ activeClassifier = defaultClassifier;
4076
+ sessionShifts = [];
4077
+ }
4078
+ return {
4079
+ onCommit,
4080
+ reset,
4081
+ beginCapture,
4082
+ endCapture,
4083
+ cancelCapture
4084
+ };
4085
+ }
4086
+ //#endregion
4087
+ //#region packages/ag-react/src/chain-bridge.ts
4088
+ /**
4089
+ * Build a non-root BaseApp with the canonical plugin chain.
4090
+ *
4091
+ * The returned app is self-contained: ops dispatched through it don't
4092
+ * reach the root runtime. Callers own the lifecycle — there's no exit /
4093
+ * render effect handler wired up; effects are drained and discarded.
4094
+ */
4095
+ function createChildApp() {
4096
+ const base = createBaseApp();
4097
+ const terminal = withTerminalChain()(base);
4098
+ const input = withInputChain(withPasteChain()(terminal));
4099
+ const events = withCustomEvents(withFocusChain({
4100
+ dispatchKey: () => false,
4101
+ hasActiveFocus: () => false
4102
+ })(input));
4103
+ const rawKeyListeners = [];
4104
+ const rawKeys = {
4105
+ register(handler) {
4106
+ rawKeyListeners.push(handler);
4107
+ return () => {
4108
+ const i = rawKeyListeners.indexOf(handler);
4109
+ if (i >= 0) rawKeyListeners.splice(i, 1);
4110
+ };
4111
+ },
4112
+ notify(input, key) {
4113
+ for (const h of rawKeyListeners) h(input, key);
4114
+ }
4115
+ };
4116
+ const focusListeners = [];
4117
+ return Object.assign(events, {
4118
+ rawKeys,
4119
+ focusEvents: {
4120
+ register(handler) {
4121
+ focusListeners.push(handler);
4122
+ return () => {
4123
+ const i = focusListeners.indexOf(handler);
4124
+ if (i >= 0) focusListeners.splice(i, 1);
4125
+ };
4126
+ },
4127
+ notify(focused) {
4128
+ for (const h of focusListeners) h(focused);
4129
+ }
4130
+ }
4131
+ });
4132
+ }
4133
+ /**
4134
+ * Project a {@link ChildApp} to the surface exposed on
4135
+ * {@link ChainAppContextValue}.
4136
+ */
4137
+ function toChainAppContextValue(app) {
4138
+ return {
4139
+ input: app.input,
4140
+ paste: app.paste,
4141
+ focusEvents: app.focusEvents,
4142
+ rawKeys: app.rawKeys,
4143
+ events: app.events
4144
+ };
4145
+ }
4146
+ //#endregion
4147
+ export { createBytesOutMonitor as A, withTerminalChain as B, recentRenderOutputEvents as C, buildMismatchContext as D, protectManagedCursorSuffix as E, compareBuffers as F, pipe as G, DRAG_CAPABILITY as H, formatMismatch as I, dispatchFocusEvent as J, createFocusEvent as K, withKeybindings as L, withRender as M, VirtualTerminal as N, formatMismatchContext as O, withDiagnostics as P, withCommands as R, recentRenderEvents as S, computeManagedFrame as T, SELECTION_CAPABILITY as U, createSelectionBridge as V, createCapabilityRegistry as W, dispatchKeyEvent as Y, withPasteChain as _, CLS_STRICT_SLUG as a, emitRenderOutputFrame as b, ScopeProvider as c, installTerminalWrapMeasurer as d, isTerminalWrapMeasurerInstalled as f, withInputChain as g, withCustomEvents as h, CLS_STRICT_MIN_TIER as i, createBaseApp as j, createMemMonitor as k, AppScopeContext as l, uninstallTerminalWrapMeasurer as m, toChainAppContextValue as n, UnexpectedLayoutShiftError as o, restoreDefaultWrapMeasurer as p, createKeyEvent as q, createClsMonitor as r, isClsStrictEnabled as s, createChildApp as t, ScopeContext as u, RenderScheduler as v, renderTraceDir as w, isRenderTraceEnabled as x, emitRenderDispatched as y, withFocusChain as z };
4148
+
4149
+ //# sourceMappingURL=chain-bridge-B7LNJwVF.mjs.map