silvery 0.21.1 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/dist/{Text-BRf-59j2.mjs → Text-Ci6UCSTL.mjs} +46 -46
  2. package/dist/Text-Ci6UCSTL.mjs.map +1 -0
  3. package/dist/{ag-BeFC4S2N.mjs → ag-q9lRlKlt.mjs} +42 -19
  4. package/dist/ag-q9lRlKlt.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-zmNzgkPB.d.mts} +1 -1
  10. package/dist/{ansi-CBkam1ty.d.mts.map → ansi-zmNzgkPB.d.mts.map} +1 -1
  11. package/dist/backend-DSiSIc-h.mjs +13396 -0
  12. package/dist/backend-DSiSIc-h.mjs.map +1 -0
  13. package/dist/chain-bridge-CkYcMxlq.mjs +4149 -0
  14. package/dist/chain-bridge-CkYcMxlq.mjs.map +1 -0
  15. package/dist/{chunk-hT5z_Zn9.mjs → chunk-BEJ448es.mjs} +1 -1
  16. package/dist/cli-VNqUKlXw.mjs +4 -0
  17. package/dist/{context-BU5LkkIy.mjs → context-CEhuD1R_.mjs} +1 -1
  18. package/dist/{context-BU5LkkIy.mjs.map → context-CEhuD1R_.mjs.map} +1 -1
  19. package/dist/devtools-BF4IMumr.mjs +2 -0
  20. package/dist/{devtools-9zfhpFyG.mjs → devtools-td2AjFwc.mjs} +5 -4
  21. package/dist/{devtools-9zfhpFyG.mjs.map → devtools-td2AjFwc.mjs.map} +1 -1
  22. package/dist/{easing-B0oZKDki.d.mts → easing-C5MrdO7V.d.mts} +1 -1
  23. package/dist/{easing-B0oZKDki.d.mts.map → easing-C5MrdO7V.d.mts.map} +1 -1
  24. package/dist/{eta-DGOuC8yU.mjs → eta-BwYWEjfS.mjs} +1 -5
  25. package/dist/{eta-DGOuC8yU.mjs.map → eta-BwYWEjfS.mjs.map} +1 -1
  26. package/dist/{flexily-zero-adapter-D6hcFgrH.mjs → flexily-zero-adapter-DktX3HsX.mjs} +1 -1
  27. package/dist/{flexily-zero-adapter-CEJOcbNp.mjs → flexily-zero-adapter-VoBGHG14.mjs} +1 -1
  28. package/dist/{flexily-zero-adapter-CEJOcbNp.mjs.map → flexily-zero-adapter-VoBGHG14.mjs.map} +1 -1
  29. package/dist/hit-registry-D_nxOivS.d.mts +71 -0
  30. package/dist/hit-registry-D_nxOivS.d.mts.map +1 -0
  31. package/dist/{image-1nRyKa60.mjs → image-CIF75xMr.mjs} +24 -24
  32. package/dist/image-CIF75xMr.mjs.map +1 -0
  33. package/dist/{index-2E1jYgak.d.mts → index-377zdPLP.d.mts} +35 -12
  34. package/dist/index-377zdPLP.d.mts.map +1 -0
  35. package/dist/{index-DnuadDNL.d.mts → index-BcSr0RAQ.d.mts} +8590 -11173
  36. package/dist/index-BcSr0RAQ.d.mts.map +1 -0
  37. package/dist/{index-Dg1YaeJb.d.mts → index-DLnpxDAm.d.mts} +2 -2
  38. package/dist/{index-Dg1YaeJb.d.mts.map → index-DLnpxDAm.d.mts.map} +1 -1
  39. package/dist/{index-Bi4Jdz5g.d.mts → index-DlNmckTq.d.mts} +2 -2
  40. package/dist/{index-Bi4Jdz5g.d.mts.map → index-DlNmckTq.d.mts.map} +1 -1
  41. package/dist/index.d.mts +12 -8
  42. package/dist/index.d.mts.map +1 -1
  43. package/dist/index.mjs +18 -17
  44. package/dist/index.mjs.map +1 -1
  45. package/dist/{layout-engine-Ca_nbtfL.mjs → layout-engine-BgR58vL0.mjs} +3 -3
  46. package/dist/{layout-engine-Ca_nbtfL.mjs.map → layout-engine-BgR58vL0.mjs.map} +1 -1
  47. package/dist/{layout-engine-CgsoBRIn.mjs → layout-engine-DNjTha8f.mjs} +1 -1
  48. package/dist/{layout-signals-Dch2EiCy.mjs → layout-signals-CGYbHmxy.mjs} +2 -2
  49. package/dist/layout-signals-CGYbHmxy.mjs.map +1 -0
  50. package/dist/{bound-term-BumfuXXW.d.mts → mouse-events-B2z5tbsL.d.mts} +540 -401
  51. package/dist/mouse-events-B2z5tbsL.d.mts.map +1 -0
  52. package/dist/{mouse-events-hnbJZRwK.mjs → mouse-events-WQ2h-uqG.mjs} +14 -6
  53. package/dist/mouse-events-WQ2h-uqG.mjs.map +1 -0
  54. package/dist/{multi-progress-Bg4ngK80.d.mts → multi-progress-BaEFfHKP.d.mts} +2 -2
  55. package/dist/{multi-progress-Bg4ngK80.d.mts.map → multi-progress-BaEFfHKP.d.mts.map} +1 -1
  56. package/dist/{multi-progress-CaXTuL9G.mjs → multi-progress-zCWHEm9P.mjs} +3 -3
  57. package/dist/{multi-progress-CaXTuL9G.mjs.map → multi-progress-zCWHEm9P.mjs.map} +1 -1
  58. package/dist/{node-BiFu8I9Y.mjs → node-CJ3J_rW-.mjs} +4 -4
  59. package/dist/node-CJ3J_rW-.mjs.map +1 -0
  60. package/dist/{progress-B_UPy6zk.mjs → progress-6vJ7nxUg.mjs} +4 -4
  61. package/dist/{progress-B_UPy6zk.mjs.map → progress-6vJ7nxUg.mjs.map} +1 -1
  62. package/dist/{progress-bar-DmIMPdL0.mjs → progress-bar-BlFNsZL8.mjs} +4 -4
  63. package/dist/{progress-bar-DmIMPdL0.mjs.map → progress-bar-BlFNsZL8.mjs.map} +1 -1
  64. package/dist/{reconciler-NBDSEm8k.mjs → reconciler--_-biN23.mjs} +111 -150
  65. package/dist/reconciler--_-biN23.mjs.map +1 -0
  66. package/dist/{render-string-BntLj7Xq.mjs → render-string-DaghHpLN.mjs} +1 -1
  67. package/dist/{render-string-B4h4SmK7.mjs → render-string-DtHZP0cU.mjs} +15 -11
  68. package/dist/render-string-DtHZP0cU.mjs.map +1 -0
  69. package/dist/runtime.d.mts +3 -2
  70. package/dist/runtime.mjs +3 -2
  71. package/dist/{schemes-DYt2ushj.mjs → schemes-DWQPxXAi.mjs} +2 -2
  72. package/dist/{schemes-DYt2ushj.mjs.map → schemes-DWQPxXAi.mjs.map} +1 -1
  73. package/dist/{spinner-Py8_-hn9.mjs → spinner-B50hILqx.mjs} +3 -3
  74. package/dist/{spinner-Py8_-hn9.mjs.map → spinner-B50hILqx.mjs.map} +1 -1
  75. package/dist/{spinner-CLgzJ_QF.d.mts → spinner-DN10UfF0.d.mts} +2 -2
  76. package/dist/{spinner-CLgzJ_QF.d.mts.map → spinner-DN10UfF0.d.mts.map} +1 -1
  77. package/dist/{src-Oe6x5PrS.mjs → src-B8lf1kiR.mjs} +190 -83
  78. package/dist/src-B8lf1kiR.mjs.map +1 -0
  79. package/dist/{src-Dvq-s8iD.mjs → src-BOLdi3WG.mjs} +3 -3
  80. package/dist/{src-Dvq-s8iD.mjs.map → src-BOLdi3WG.mjs.map} +1 -1
  81. package/dist/{src-B9S_woYc.mjs → src-BXeTHHlL.mjs} +20289 -23262
  82. package/dist/src-BXeTHHlL.mjs.map +1 -0
  83. package/dist/{src-WeA_J4BV.mjs → src-BqeBFB3X.mjs} +90 -47
  84. package/dist/src-BqeBFB3X.mjs.map +1 -0
  85. package/dist/{src-CbhWmUnF.mjs → src-DbqcSrhy.mjs} +1400 -1427
  86. package/dist/src-DbqcSrhy.mjs.map +1 -0
  87. package/dist/{steps-DYrzCUCK.d.mts → steps-B2kBmFpb.d.mts} +1 -1
  88. package/dist/{steps-DYrzCUCK.d.mts.map → steps-B2kBmFpb.d.mts.map} +1 -1
  89. package/dist/{svg-DhxQkz-O.mjs → svg-NmV9dgB9.mjs} +1 -1
  90. package/dist/{svg-DhxQkz-O.mjs.map → svg-NmV9dgB9.mjs.map} +1 -1
  91. package/dist/{svg-Hk7lIl4F.d.mts → svg-VUr78l5d.d.mts} +1 -1
  92. package/dist/{svg-Hk7lIl4F.d.mts.map → svg-VUr78l5d.d.mts.map} +1 -1
  93. package/dist/term.d.mts +2 -2
  94. package/dist/term.mjs +3 -3
  95. package/dist/test.d.mts +673 -0
  96. package/dist/test.d.mts.map +1 -0
  97. package/dist/test.mjs +2829 -0
  98. package/dist/test.mjs.map +1 -0
  99. package/dist/theme.d.mts +2 -2
  100. package/dist/theme.d.mts.map +1 -1
  101. package/dist/theme.mjs +3 -3
  102. package/dist/{types-Bx-XZNbE.d.mts → types-D_E0FbqA.d.mts} +1 -1
  103. package/dist/{types-Bx-XZNbE.d.mts.map → types-D_E0FbqA.d.mts.map} +1 -1
  104. package/dist/ui/animation.d.mts +2 -2
  105. package/dist/ui/animation.mjs +1 -1
  106. package/dist/ui/ansi.d.mts +1 -1
  107. package/dist/ui/ansi.mjs +1 -1
  108. package/dist/ui/cli.d.mts +3 -3
  109. package/dist/ui/cli.mjs +5 -5
  110. package/dist/ui/image.d.mts +1 -1
  111. package/dist/ui/image.mjs +1 -1
  112. package/dist/ui/progress.d.mts +4 -4
  113. package/dist/ui/progress.mjs +4 -4
  114. package/dist/ui/react.d.mts +1 -1
  115. package/dist/ui/react.mjs +2 -2
  116. package/dist/ui/react.mjs.map +1 -1
  117. package/dist/ui/recording-chrome-react.d.mts +1 -1
  118. package/dist/ui/recording-chrome-react.mjs +2 -2
  119. package/dist/ui/recording-chrome.d.mts +1 -1
  120. package/dist/ui/recording-chrome.mjs +1 -1
  121. package/dist/ui/utils.mjs +1 -1
  122. package/dist/ui/wrappers.d.mts +2 -2
  123. package/dist/ui/wrappers.mjs +1 -1
  124. package/dist/ui.d.mts +6 -6
  125. package/dist/ui.mjs +7 -7
  126. package/dist/{ansi-OYRLxxZQ.mjs → unicode-CxyxVGmB.mjs} +7317 -6909
  127. package/dist/unicode-CxyxVGmB.mjs.map +1 -0
  128. package/dist/{useLatest-DC8i7guK.d.mts → useLatest-UOvWy0ZW.d.mts} +2 -2
  129. package/dist/{useLatest-DC8i7guK.d.mts.map → useLatest-UOvWy0ZW.d.mts.map} +1 -1
  130. package/dist/{useLayout-BKsQl2Or.mjs → useLayout-D5cE8OCI.mjs} +4 -4
  131. package/dist/{useLayout-BKsQl2Or.mjs.map → useLayout-D5cE8OCI.mjs.map} +1 -1
  132. package/dist/{with-text-input-DG4f7JII.d.mts → with-text-input-DLoSCBrm.d.mts} +3 -3
  133. package/dist/{with-text-input-DG4f7JII.d.mts.map → with-text-input-DLoSCBrm.d.mts.map} +1 -1
  134. package/dist/wrap-measurer-registration-IV2HtcCd.d.mts +3106 -0
  135. package/dist/wrap-measurer-registration-IV2HtcCd.d.mts.map +1 -0
  136. package/dist/{wrapper-D7gNSsgf.mjs → wrapper-CD29afOs.mjs} +17 -37
  137. package/dist/wrapper-CD29afOs.mjs.map +1 -0
  138. package/dist/{wrappers-CypAzrMO.mjs → wrappers-CInBJm44.mjs} +6 -6
  139. package/dist/{wrappers-CypAzrMO.mjs.map → wrappers-CInBJm44.mjs.map} +1 -1
  140. package/dist/{yoga-adapter-SsEIqMc1.mjs → yoga-adapter-9uab5Qn-.mjs} +2 -2
  141. package/dist/{yoga-adapter-SsEIqMc1.mjs.map → yoga-adapter-9uab5Qn-.mjs.map} +1 -1
  142. package/dist/yoga-adapter-yqTl1aXK.mjs +2 -0
  143. package/package.json +20 -15
  144. package/dist/Text-BRf-59j2.mjs.map +0 -1
  145. package/dist/ag-BeFC4S2N.mjs.map +0 -1
  146. package/dist/ansi-OYRLxxZQ.mjs.map +0 -1
  147. package/dist/bound-term-BumfuXXW.d.mts.map +0 -1
  148. package/dist/cli-DG6zsfsS.mjs +0 -4
  149. package/dist/devtools-nmPUmYU_.mjs +0 -2
  150. package/dist/image-1nRyKa60.mjs.map +0 -1
  151. package/dist/index-2E1jYgak.d.mts.map +0 -1
  152. package/dist/index-DnuadDNL.d.mts.map +0 -1
  153. package/dist/layout-signals-Dch2EiCy.mjs.map +0 -1
  154. package/dist/mouse-events-hnbJZRwK.mjs.map +0 -1
  155. package/dist/node-BiFu8I9Y.mjs.map +0 -1
  156. package/dist/reconciler-NBDSEm8k.mjs.map +0 -1
  157. package/dist/render-string-B4h4SmK7.mjs.map +0 -1
  158. package/dist/src-B9S_woYc.mjs.map +0 -1
  159. package/dist/src-CbhWmUnF.mjs.map +0 -1
  160. package/dist/src-Oe6x5PrS.mjs.map +0 -1
  161. package/dist/src-WeA_J4BV.mjs.map +0 -1
  162. package/dist/ui/display.d.mts +0 -35
  163. package/dist/ui/display.d.mts.map +0 -1
  164. package/dist/ui/display.mjs +0 -123
  165. package/dist/ui/display.mjs.map +0 -1
  166. package/dist/ui/input.d.mts +0 -184
  167. package/dist/ui/input.d.mts.map +0 -1
  168. package/dist/ui/input.mjs +0 -283
  169. package/dist/ui/input.mjs.map +0 -1
  170. package/dist/wrapper-D7gNSsgf.mjs.map +0 -1
  171. package/dist/yoga-adapter-1ex8r0ws.mjs +0 -2
package/dist/test.mjs ADDED
@@ -0,0 +1,2829 @@
1
+ import { f as processMouseEvent, g as updateKeyboardModifiers, s as createMouseEventProcessor, v as pointInRect } from "./mouse-events-WQ2h-uqG.mjs";
2
+ import { r as findActiveCursorRect, t as commitLayoutSnapshot } from "./layout-signals-CGYbHmxy.mjs";
3
+ import { Ct as CursorProvider, Nt as createFocusManager, wt as createCursorStore } from "./src-DbqcSrhy.mjs";
4
+ import { D as buildMismatchContext, F as compareBuffers, I as formatMismatch, O as formatMismatchContext, a as CLS_STRICT_SLUG, c as ScopeProvider, i as CLS_STRICT_MIN_TIER, n as toChainAppContextValue, o as UnexpectedLayoutShiftError, r as createClsMonitor, s as isClsStrictEnabled, t as createChildApp } from "./chain-bridge-CkYcMxlq.mjs";
5
+ import { $t as bufferToText, Ct as isStrictEnabled, H as createTerm, Jt as EMPTY_FRAME_CELL, N as stripAnsi, Qt as bufferToStyledText, Zt as bufferToHTML, _n as splitRawInput, en as cellEquals, gt as outputPhase, hn as parseKey, ln as keyToAnsi, mn as parseHotkey, on as CODE_TO_KEY, tn as cellToFrameCell, un as keyToKittyAnsi, ut as parseBracketedPaste, vt as IncrementalRenderMismatchError, xt as preloadStrictTerminalBackends } from "./unicode-CxyxVGmB.mjs";
6
+ import { O as warnOnce, _ as kittyDeleteAllScrimPlacements, ct as deriveTheme, tt as isProtocolError } from "./src-B8lf1kiR.mjs";
7
+ import { a as releaseContainer, b as getRenderEpoch, d as reportDisposeError, i as reconciler, l as setContainerNodeLifecycle, n as createFiberRoot, r as getContainerRoot, t as createContainer, u as createScope, x as isAnyDirty } from "./reconciler--_-biN23.mjs";
8
+ import { i as isLayoutEngineInitialized, o as setLayoutEngine, t as ensureDefaultLayoutEngine } from "./layout-engine-BgR58vL0.mjs";
9
+ import { c as INSTRUMENT, d as assertBoundedConvergence, f as beginConvergenceLoop, g as notePassCommit, h as logPass, l as MAX_CONVERGENCE_PASSES, n as createRenderPostState, o as FRESH_LAYOUT_STRICT_SLUG, p as beginPass, s as markLayoutTreeDirty, t as createAg, v as recordPassRing } from "./ag-q9lRlKlt.mjs";
10
+ import { c as StdoutContext, i as FocusManagerContext, l as TermContext, o as RuntimeContext, r as ChainAppContext } from "./context-CEhuD1R_.mjs";
11
+ import { createLogger } from "loggily";
12
+ import React from "react";
13
+ import { writeFile } from "node:fs/promises";
14
+ import { is } from "css-select";
15
+ import { EventEmitter } from "node:events";
16
+ import { createGhosttyBackend } from "@termless/ghostty";
17
+ import { createGhosttyNativeBackend } from "@termless/ghostty-native";
18
+ import { createXtermBackend } from "@termless/xtermjs";
19
+ //#region packages/test/src/auto-locator.ts
20
+ /**
21
+ * Create an AutoLocator from a container getter function.
22
+ * The getter is called fresh on each resolution.
23
+ */
24
+ function createAutoLocator(getContainer, opts) {
25
+ return new AutoLocatorImpl(getContainer, [], void 0, opts?.strict ?? false);
26
+ }
27
+ /**
28
+ * AutoLocator implementation
29
+ */
30
+ var AutoLocatorImpl = class AutoLocatorImpl {
31
+ constructor(getContainer, predicates, indexSelector, strict = false) {
32
+ this.getContainer = getContainer;
33
+ this.predicates = predicates;
34
+ this.indexSelector = indexSelector;
35
+ this.strict = strict;
36
+ }
37
+ getByText(text) {
38
+ const predicate = (node) => {
39
+ const content = getNodeTextContent(node);
40
+ if (!content) return false;
41
+ if (node.type !== "silvery-text") return false;
42
+ if (node.isRawText && node.parent?.type === "silvery-text") return false;
43
+ if (typeof text === "string") return content.includes(text);
44
+ return text.test(content);
45
+ };
46
+ return new AutoLocatorImpl(this.getContainer, [...this.predicates, predicate], void 0, this.strict);
47
+ }
48
+ getByTestId(id) {
49
+ const predicate = (node) => {
50
+ return getNodeProp(node, "testID") === id;
51
+ };
52
+ return new AutoLocatorImpl(this.getContainer, [...this.predicates, predicate], void 0, this.strict);
53
+ }
54
+ locator(selector) {
55
+ const predicate = (node) => {
56
+ try {
57
+ return is(node, selector, cssSelectOptions);
58
+ } catch {
59
+ return false;
60
+ }
61
+ };
62
+ return new AutoLocatorImpl(this.getContainer, [...this.predicates, predicate], void 0, this.strict);
63
+ }
64
+ filter(optionsOrPredicate) {
65
+ let predicate;
66
+ if (typeof optionsOrPredicate === "function") predicate = optionsOrPredicate;
67
+ else {
68
+ const opts = optionsOrPredicate;
69
+ predicate = (node) => {
70
+ if (opts.hasText !== void 0) {
71
+ const content = getNodeTextContent(node);
72
+ if (typeof opts.hasText === "string") {
73
+ if (!content.includes(opts.hasText)) return false;
74
+ } else if (!opts.hasText.test(content)) return false;
75
+ }
76
+ if (opts.hasTestId !== void 0) {
77
+ if (getNodeProp(node, "testID") !== opts.hasTestId) return false;
78
+ }
79
+ if (opts.has !== void 0) {
80
+ const value = getNodeProp(node, opts.has.attr);
81
+ if (opts.has.value !== void 0) {
82
+ if (value !== opts.has.value) return false;
83
+ } else if (value === void 0) return false;
84
+ }
85
+ return true;
86
+ };
87
+ }
88
+ return new AutoLocatorImpl(this.getContainer, [...this.predicates, predicate], void 0, this.strict);
89
+ }
90
+ first() {
91
+ return new AutoLocatorImpl(this.getContainer, this.predicates, { type: "first" }, this.strict);
92
+ }
93
+ last() {
94
+ return new AutoLocatorImpl(this.getContainer, this.predicates, { type: "last" }, this.strict);
95
+ }
96
+ nth(index) {
97
+ return new AutoLocatorImpl(this.getContainer, this.predicates, {
98
+ type: "nth",
99
+ index
100
+ }, this.strict);
101
+ }
102
+ resolve() {
103
+ const nodes = this.resolveAll();
104
+ if (this.indexSelector) switch (this.indexSelector.type) {
105
+ case "first": return nodes[0] ?? null;
106
+ case "last": return nodes[nodes.length - 1] ?? null;
107
+ case "nth": return nodes[this.indexSelector.index ?? 0] ?? null;
108
+ }
109
+ if (this.strict && nodes.length > 1) throw new Error(`Locator resolved to ${nodes.length} elements. Use .first(), .nth(n), or a more specific selector. Matched types: ${nodes.slice(0, 3).map((n) => n.type).join(", ")}${nodes.length > 3 ? "..." : ""}`);
110
+ return nodes[0] ?? null;
111
+ }
112
+ resolveAll() {
113
+ const container = this.getContainer();
114
+ if (this.predicates.length === 0) return [container];
115
+ const matches = [];
116
+ walkTree(container, (node) => {
117
+ if (this.predicates.every((p) => p(node))) matches.push(node);
118
+ });
119
+ return matches;
120
+ }
121
+ count() {
122
+ return this.resolveAll().length;
123
+ }
124
+ textContent() {
125
+ const node = this.resolve();
126
+ if (!node) return "";
127
+ return getNodeTextContent(node);
128
+ }
129
+ getAttribute(name) {
130
+ const node = this.resolve();
131
+ if (!node) return void 0;
132
+ return getNodeProp(node, name);
133
+ }
134
+ boundingBox() {
135
+ const node = this.resolve();
136
+ if (!node) return null;
137
+ return node.scrollRect ?? null;
138
+ }
139
+ isVisible() {
140
+ const box = this.boundingBox();
141
+ if (!box) return false;
142
+ return box.width > 0 && box.height > 0;
143
+ }
144
+ };
145
+ /**
146
+ * Walk tree depth-first, calling visitor for each node
147
+ */
148
+ function walkTree(node, visitor) {
149
+ visitor(node);
150
+ for (const child of node.children) walkTree(child, visitor);
151
+ }
152
+ /**
153
+ * Get text content of a node (concatenated from all text descendants)
154
+ */
155
+ function getNodeTextContent(node) {
156
+ if (node.textContent !== void 0) return node.textContent;
157
+ return node.children.map(getNodeTextContent).join("");
158
+ }
159
+ /**
160
+ * Get a prop value from node
161
+ */
162
+ function getNodeProp(node, name) {
163
+ const value = node.props[name];
164
+ if (value === void 0 || value === null) return void 0;
165
+ return String(value);
166
+ }
167
+ /** Shared css-select options — case-sensitive tags, no caching (tree changes between queries) */
168
+ const cssSelectOptions = {
169
+ adapter: {
170
+ isTag: (node) => !node.isRawText,
171
+ getAttributeValue: (element, name) => getNodeProp(element, name),
172
+ getChildren: (node) => [...node.children],
173
+ getName: (element) => element.type ?? "unknown",
174
+ getParent: (node) => node.parent,
175
+ getSiblings: (node) => node.parent ? [...node.parent.children] : [node],
176
+ getText: (node) => getNodeTextContent(node),
177
+ hasAttrib: (element, name) => getNodeProp(element, name) !== void 0,
178
+ removeSubsets: (nodes) => {
179
+ return nodes.filter((node, i) => {
180
+ for (let j = 0; j < nodes.length; j++) if (i !== j) {
181
+ let ancestor = node.parent;
182
+ while (ancestor) {
183
+ if (ancestor === nodes[j]) return false;
184
+ ancestor = ancestor.parent;
185
+ }
186
+ }
187
+ return true;
188
+ });
189
+ },
190
+ prevElementSibling: (node) => {
191
+ if (!node.parent) return null;
192
+ const siblings = node.parent.children;
193
+ const index = siblings.indexOf(node);
194
+ for (let i = index - 1; i >= 0; i--) if (!siblings[i].isRawText) return siblings[i];
195
+ return null;
196
+ }
197
+ },
198
+ xmlMode: true,
199
+ cacheResults: false
200
+ };
201
+ //#endregion
202
+ //#region packages/test/src/debug.ts
203
+ /**
204
+ * Pretty-print SilveryNode tree for debugging.
205
+ *
206
+ * @param node - Root node to inspect
207
+ * @param options - Display options
208
+ * @returns Formatted tree string
209
+ */
210
+ function debugTree(node, options = {}) {
211
+ const { depth = Number.POSITIVE_INFINITY, showRects = true, showText = true } = options;
212
+ const lines = [];
213
+ function safeStringify(value) {
214
+ try {
215
+ return JSON.stringify(value);
216
+ } catch {
217
+ if (typeof value === "object" && value !== null) return "[object]";
218
+ return String(value);
219
+ }
220
+ }
221
+ function walk(n, indent, currentDepth) {
222
+ if (currentDepth > depth) return;
223
+ const props = Object.entries(n.props ?? {}).filter(([k]) => !["children"].includes(k)).filter(([, v]) => v !== void 0 && v !== null && v !== false).map(([k, v]) => {
224
+ if (typeof v === "string") return `${k}="${v}"`;
225
+ if (typeof v === "boolean") return k;
226
+ return `${k}=${safeStringify(v)}`;
227
+ }).join(" ");
228
+ let rect = "";
229
+ if (showRects && n.scrollRect) {
230
+ const { x, y, width, height } = n.scrollRect;
231
+ rect = ` [${x},${y} ${width}×${height}]`;
232
+ }
233
+ let text = "";
234
+ if (showText && n.textContent) text = ` "${n.textContent.length > 40 ? n.textContent.slice(0, 37) + "..." : n.textContent}"`;
235
+ const propsStr = props ? " " + props : "";
236
+ lines.push(" ".repeat(indent) + `<${n.type}${propsStr}${text}${rect}>`);
237
+ for (const child of n.children) walk(child, indent + 1, currentDepth + 1);
238
+ }
239
+ walk(node, 0, 0);
240
+ return lines.join("\n");
241
+ }
242
+ //#endregion
243
+ //#region packages/test/src/diagnostic-theme.ts
244
+ /**
245
+ * Diagnostic theme for pipeline regression tests.
246
+ *
247
+ * Built so that **every distinct flat token resolves to a distinct, visible
248
+ * color** — exposing pipeline bugs that would be invisible under themes where
249
+ * tokens collapse to the canvas background.
250
+ *
251
+ * ## Why this exists
252
+ *
253
+ * The default `ansi16DarkTheme` uses Nord-derived hexes. In Nord:
254
+ * - canvas `bg` = `#2e3440` = rgb(46, 52, 64)
255
+ * - legacy `mutedbg` = blend(bg, fg, 0.04) ≈ rgb(52, 58, 70)
256
+ *
257
+ * When tests render at ansi16, the underlying RGBs from `mutedbg` and `bg`
258
+ * differ by only a few units — and crucially, they look "the same" to
259
+ * naive bg-vs-canvas detectors that compare against a fixed canvas color.
260
+ *
261
+ * One concrete consequence: the cyan-strip cold-start bug
262
+ * (`@km/silvery/render-light-blue-bg-strip-residue`) emitted bg=$mutedbg paint
263
+ * at cells past a clip boundary, but every test passed because the bg color
264
+ * was indistinguishable from canvas in the test theme. Real users running at
265
+ * Nord saw a visible strip; tests saw nothing.
266
+ *
267
+ * The diagnostic theme uses pure black canvas + saturated primaries so blends
268
+ * preserve their step magnitude in every channel. Every Sterling/legacy token
269
+ * resolves to a distinct RGB tuple. A test that asserts "no non-canvas bg in
270
+ * region X" catches phantom bg paints regardless of which token emitted them.
271
+ *
272
+ * ## Usage
273
+ *
274
+ * ```ts
275
+ * import { diagnosticTheme } from "@silvery/test"
276
+ * import { createRenderer } from "@silvery/test"
277
+ *
278
+ * const r = createRenderer({ cols: 80, rows: 24, theme: diagnosticTheme })
279
+ * ```
280
+ *
281
+ * Or via `testBoard` in km-tui:
282
+ *
283
+ * ```ts
284
+ * const result = await testBoard(VAULT, { columns: 82, rows: 75, theme: diagnosticTheme })
285
+ * // assert no cells past col 40 have non-canvas bg
286
+ * for (let r = 0; r < 75; r++) {
287
+ * for (let c = 40; c < 82; c++) {
288
+ * const cell = result._result.cell(c, r)
289
+ * expect(cell.bg).toBeNull() // canvas = null bg
290
+ * }
291
+ * }
292
+ * ```
293
+ *
294
+ * ## Distinctness invariant
295
+ *
296
+ * Pure black canvas (rgb 0,0,0) + pure white fg (rgb 255,255,255) means every
297
+ * blend(bg, fg, t) for distinct `t` produces distinct RGBs. Sterling/legacy
298
+ * derive uses these `t` values:
299
+ *
300
+ * - `bg-surface-subtle` = blend(0.03) ≈ rgb(8, 8, 8)
301
+ * - `bg-surface-hover` = blend(0.10) ≈ rgb(26, 26, 26)
302
+ * - `bg-surface-raised` = blend(0.10) ≈ rgb(26, 26, 26)
303
+ * - `bg-surface-overlay` = blend(0.12) ≈ rgb(31, 31, 31)
304
+ * - `bg-muted` (Sterling)= blend(0.08) ≈ rgb(20, 20, 20)
305
+ * - `mutedbg` (legacy) = blend(0.04) ≈ rgb(10, 10, 10)
306
+ *
307
+ * All distinct from canvas (rgb 0,0,0) and from each other. The named ANSI
308
+ * slots are saturated primaries so accentBg, redBg, etc. paint in vivid
309
+ * unmistakable colors.
310
+ */
311
+ /**
312
+ * Maximally-distinct ColorScheme for diagnostic rendering tests.
313
+ *
314
+ * Pure black canvas, pure white fg, saturated ANSI primaries. Every derived
315
+ * token gets a unique visible RGB.
316
+ */
317
+ const diagnosticScheme = {
318
+ name: "diagnostic",
319
+ dark: true,
320
+ background: "#101828",
321
+ foreground: "#f0f8ff",
322
+ primary: "#ff00ff",
323
+ black: "#202830",
324
+ red: "#ff0000",
325
+ green: "#00ff00",
326
+ yellow: "#ffff00",
327
+ blue: "#4060ff",
328
+ magenta: "#ff00ff",
329
+ cyan: "#00ffff",
330
+ white: "#f0f8ff",
331
+ brightBlack: "#606870",
332
+ brightRed: "#ff8080",
333
+ brightGreen: "#80ff80",
334
+ brightYellow: "#ffff80",
335
+ brightBlue: "#8080ff",
336
+ brightMagenta: "#ff80ff",
337
+ brightCyan: "#80ffff",
338
+ brightWhite: "#ffffff",
339
+ cursorColor: "#ffaa00",
340
+ cursorText: "#101828",
341
+ selectionBackground: "#aa00ff",
342
+ selectionForeground: "#ffffff"
343
+ };
344
+ /**
345
+ * Pre-derived diagnostic theme. Pass to `createRenderer({ theme })` or
346
+ * `testBoard({ theme })` for pipeline regression coverage.
347
+ */
348
+ const diagnosticTheme = deriveTheme(diagnosticScheme);
349
+ //#endregion
350
+ //#region packages/test/src/cell-inspector.ts
351
+ function readCellSnapshot(buffer, x, y) {
352
+ return {
353
+ x,
354
+ y,
355
+ ...buffer.getCell(x, y),
356
+ selectable: buffer.isCellSelectable(x, y)
357
+ };
358
+ }
359
+ function readCellRow(buffer, y) {
360
+ const cells = [];
361
+ for (let x = 0; x < buffer.width; x++) cells.push(readCellSnapshot(buffer, x, y));
362
+ return cells;
363
+ }
364
+ function formatSelectableCells(buffer) {
365
+ const lines = [];
366
+ for (let y = 0; y < buffer.height; y++) {
367
+ const chars = readCellRow(buffer, y).map((cell) => {
368
+ if (cell.char === "") return cell.selectable ? "^" : ",";
369
+ if (cell.char === " ") return cell.selectable ? "_" : ".";
370
+ return cell.selectable ? cell.char : cell.char.toLowerCase();
371
+ }).join("");
372
+ lines.push(chars);
373
+ }
374
+ return lines.join("\n");
375
+ }
376
+ //#endregion
377
+ //#region packages/test/src/frame-recorder.ts
378
+ const BLANK_CELL = Object.freeze({
379
+ char: " ",
380
+ fg: null,
381
+ bg: null,
382
+ bold: false,
383
+ dim: false,
384
+ italic: false,
385
+ underline: false,
386
+ underlineColor: null,
387
+ overline: false,
388
+ strikethrough: false,
389
+ inverse: false,
390
+ blink: false,
391
+ hidden: false,
392
+ wide: false,
393
+ continuation: false,
394
+ hyperlink: null
395
+ });
396
+ /**
397
+ * Build an immutable TextFrame snapshot from a termless emulator. Cell values
398
+ * must be copied eagerly: the emulator is mutable, so a lazy `cell()` read
399
+ * would make every historical frame report the terminal's latest style.
400
+ */
401
+ function snapshotEmulator(emu) {
402
+ const width = emu.cols;
403
+ const height = emu.rows;
404
+ const lines = emu.screen.getLines().slice(0, height);
405
+ while (lines.length < height) lines.push("");
406
+ const text = lines.join("\n");
407
+ const cells = [];
408
+ for (let row = 0; row < height; row++) for (let col = 0; col < width; col++) {
409
+ const view = emu.cell(row, col);
410
+ cells.push(Object.freeze({
411
+ char: view.char,
412
+ fg: view.fg,
413
+ bg: view.bg,
414
+ bold: view.bold,
415
+ dim: view.dim,
416
+ italic: view.italic,
417
+ underline: view.underline,
418
+ underlineColor: view.underlineColor,
419
+ overline: view.overline ?? false,
420
+ strikethrough: view.strikethrough,
421
+ inverse: view.inverse,
422
+ blink: view.blink,
423
+ hidden: view.hidden,
424
+ wide: view.wide,
425
+ continuation: view.continuation,
426
+ hyperlink: view.hyperlink
427
+ }));
428
+ }
429
+ return {
430
+ get text() {
431
+ return text;
432
+ },
433
+ get ansi() {
434
+ return text;
435
+ },
436
+ get lines() {
437
+ return lines;
438
+ },
439
+ width,
440
+ height,
441
+ cell(col, row) {
442
+ if (col < 0 || col >= width || row < 0 || row >= height) return BLANK_CELL;
443
+ return cells[row * width + col];
444
+ },
445
+ containsText(needle) {
446
+ return text.includes(needle);
447
+ }
448
+ };
449
+ }
450
+ function cellsEqual(a, b) {
451
+ if (a.char !== b.char) return false;
452
+ if (!rgbEqual(a.fg, b.fg)) return false;
453
+ if (!rgbEqual(a.bg, b.bg)) return false;
454
+ if (a.bold !== b.bold) return false;
455
+ if (a.dim !== b.dim) return false;
456
+ if (a.italic !== b.italic) return false;
457
+ if (a.underline !== b.underline) return false;
458
+ if (!rgbEqual(a.underlineColor, b.underlineColor)) return false;
459
+ if (a.overline !== b.overline) return false;
460
+ if (a.strikethrough !== b.strikethrough) return false;
461
+ if (a.inverse !== b.inverse) return false;
462
+ if (a.blink !== b.blink) return false;
463
+ if (a.hidden !== b.hidden) return false;
464
+ if (a.wide !== b.wide) return false;
465
+ if (a.continuation !== b.continuation) return false;
466
+ if (a.hyperlink !== b.hyperlink) return false;
467
+ return true;
468
+ }
469
+ function rgbEqual(a, b) {
470
+ if (a === b) return true;
471
+ if (!a || !b) return false;
472
+ return a.r === b.r && a.g === b.g && a.b === b.b;
473
+ }
474
+ function diffFrames(a, b) {
475
+ const width = Math.min(a.width, b.width);
476
+ const height = Math.min(a.height, b.height);
477
+ const changes = [];
478
+ for (let row = 0; row < height; row++) for (let col = 0; col < width; col++) {
479
+ const from = a.cell(col, row);
480
+ const to = b.cell(col, row);
481
+ if (!cellsEqual(from, to)) changes.push({
482
+ col,
483
+ row,
484
+ from,
485
+ to
486
+ });
487
+ }
488
+ return changes;
489
+ }
490
+ /**
491
+ * Start recording frames from a Term (or any object exposing `.term`).
492
+ *
493
+ * The recorder observes the emulator's ANSI feed so that every render patch
494
+ * triggers a fresh TextFrame snapshot. Only calls made AFTER `recordFrames()`
495
+ * returns are captured — the initial render is not retroactively recorded.
496
+ * Call `recording.stop()` to end recording early.
497
+ *
498
+ * For emulator-backed terms created via `createTermless()`, snapshots reflect
499
+ * the real terminal state including resolved colors and wide characters. For
500
+ * other Term types, this function throws — cell-level recording requires an
501
+ * emulator.
502
+ */
503
+ function recordFrames(target) {
504
+ const emulator = getEmulator(isFrameRecordable(target) ? target.term : target);
505
+ if (!emulator) throw new Error("recordFrames(): term has no emulator backend. Use createTermless() to create an emulator-backed Term for frame recording.");
506
+ const frames = [];
507
+ let stopped = false;
508
+ const originalFeed = emulator.feed;
509
+ const recordingFeed = function(data) {
510
+ originalFeed.call(this, data);
511
+ if (!stopped) frames.push(snapshotEmulator(emulator));
512
+ };
513
+ emulator.feed = recordingFeed;
514
+ return {
515
+ get frames() {
516
+ return frames;
517
+ },
518
+ get count() {
519
+ return frames.length;
520
+ },
521
+ diff(i, j) {
522
+ const a = frames[i];
523
+ const b = frames[j];
524
+ if (!a || !b) return [];
525
+ return diffFrames(a, b);
526
+ },
527
+ hasTransientChange(predicate) {
528
+ for (let i = 0; i + 2 < frames.length; i++) if (predicate(frames[i], frames[i + 1], frames[i + 2])) return true;
529
+ return false;
530
+ },
531
+ clear() {
532
+ frames.length = 0;
533
+ },
534
+ stop() {
535
+ if (stopped) return;
536
+ stopped = true;
537
+ if (emulator.feed === recordingFeed) emulator.feed = originalFeed;
538
+ }
539
+ };
540
+ }
541
+ function isFrameRecordable(value) {
542
+ if (value === null || typeof value !== "object") return false;
543
+ const maybe = value;
544
+ if (maybe.term == null) return false;
545
+ const t = typeof maybe.term;
546
+ return t === "object" || t === "function";
547
+ }
548
+ function getEmulator(term) {
549
+ const emu = term._emulator;
550
+ if (emu == null) return null;
551
+ const t = typeof emu;
552
+ if (t !== "object" && t !== "function") return null;
553
+ const candidate = emu;
554
+ if (typeof candidate.cols !== "number") return null;
555
+ if (typeof candidate.rows !== "number") return null;
556
+ if (typeof candidate.cell !== "function") return null;
557
+ if (candidate.screen == null) return null;
558
+ return emu;
559
+ }
560
+ //#endregion
561
+ //#region packages/ag-term/src/bound-term.ts
562
+ /**
563
+ * Create a BoundTerm from a buffer and root node getter
564
+ */
565
+ function createBoundTerm(buffer, getRoot, getText) {
566
+ return {
567
+ cell(x, y) {
568
+ return buffer.getCell(x, y);
569
+ },
570
+ nodeAt(x, y) {
571
+ return findNodeAtScreenPosition(getRoot(), x, y);
572
+ },
573
+ get text() {
574
+ return getText();
575
+ },
576
+ get columns() {
577
+ return buffer.width;
578
+ },
579
+ get rows() {
580
+ return buffer.height;
581
+ },
582
+ get buffer() {
583
+ return buffer;
584
+ }
585
+ };
586
+ }
587
+ /**
588
+ * Find the deepest node at the given screen coordinates
589
+ */
590
+ function findNodeAtScreenPosition(node, x, y) {
591
+ const rect = node.scrollRect;
592
+ if (!rect) return null;
593
+ if (x < rect.x || x >= rect.x + rect.width || y < rect.y || y >= rect.y + rect.height) return null;
594
+ for (const child of node.children) {
595
+ const found = findNodeAtScreenPosition(child, x, y);
596
+ if (found) return found;
597
+ }
598
+ if (node.type === "silvery-text") for (let i = node.children.length - 1; i >= 0; i--) {
599
+ const child = node.children[i];
600
+ if (child.inlineRects) {
601
+ for (const inlineRect of child.inlineRects) if (x >= inlineRect.x && x < inlineRect.x + inlineRect.width && y >= inlineRect.y && y < inlineRect.y + inlineRect.height) return child;
602
+ }
603
+ }
604
+ return node;
605
+ }
606
+ //#endregion
607
+ //#region packages/ag-term/src/screenshot.ts
608
+ function createScreenshotter() {
609
+ let browser = null;
610
+ let page = null;
611
+ async function ensureBrowser() {
612
+ if (browser && page) return page;
613
+ const { chromium } = await import("playwright");
614
+ browser = await chromium.launch();
615
+ page = await (await browser.newContext()).newPage();
616
+ return page;
617
+ }
618
+ async function capture(html, outputPath) {
619
+ const p = await ensureBrowser();
620
+ await p.setContent(html, { waitUntil: "load" });
621
+ await p.waitForTimeout(50);
622
+ const buffer = await p.screenshot({ fullPage: true });
623
+ if (outputPath) await writeFile(outputPath, buffer);
624
+ return buffer;
625
+ }
626
+ async function close() {
627
+ if (browser) {
628
+ await browser.close();
629
+ browser = null;
630
+ page = null;
631
+ }
632
+ }
633
+ return {
634
+ capture,
635
+ close,
636
+ [Symbol.asyncDispose]: close
637
+ };
638
+ }
639
+ //#endregion
640
+ //#region packages/ag-react/src/debug/render-path.ts
641
+ function hostTypeLabel(type) {
642
+ if (type === "silvery-box") return "Box";
643
+ if (type === "silvery-text") return "Text";
644
+ return type;
645
+ }
646
+ /**
647
+ * Resolve a node to its display name.
648
+ * Mirrors `getDebugComponentName` in reconciler/host-config.ts (kept in
649
+ * sync; if you change one, update the other).
650
+ */
651
+ function getComponentName(node) {
652
+ const props = node.props;
653
+ const explicit = props["data-component"];
654
+ if (typeof explicit === "string" && explicit.length > 0) return explicit;
655
+ const base = hostTypeLabel(node.type);
656
+ const testID = props.testID;
657
+ if (typeof testID === "string" && testID.length > 0) return `${base}#${testID}`;
658
+ const id = props.id;
659
+ if (typeof id === "string" && id.length > 0) return `${base}#${id}`;
660
+ return base;
661
+ }
662
+ function summarize(node) {
663
+ const out = {
664
+ name: getComponentName(node),
665
+ type: node.type
666
+ };
667
+ if (node.boxRect) out.boxRect = {
668
+ x: node.boxRect.x,
669
+ y: node.boxRect.y,
670
+ width: node.boxRect.width,
671
+ height: node.boxRect.height
672
+ };
673
+ return out;
674
+ }
675
+ /**
676
+ * Return the parent chain from `root` down to the first node matching
677
+ * `componentName`. Each element is a `RenderPathNode` summary.
678
+ *
679
+ * Returns `[]` if no match is found.
680
+ *
681
+ * If multiple nodes match, returns the path to the FIRST in tree order.
682
+ * Use `findNodesByComponentName` to inspect all matches.
683
+ */
684
+ function getRenderPath(root, componentName) {
685
+ function walk(node, chain) {
686
+ const next = [...chain, node];
687
+ if (getComponentName(node) === componentName) return next;
688
+ for (const child of node.children) {
689
+ const hit = walk(child, next);
690
+ if (hit) return hit;
691
+ }
692
+ return null;
693
+ }
694
+ const hit = walk(root, []);
695
+ return hit ? hit.map(summarize) : [];
696
+ }
697
+ /**
698
+ * Recursive JSON dump of the entire mount tree rooted at `root`.
699
+ *
700
+ * Useful for snapshot tests asserting on structural invariants —
701
+ * e.g., "ToolBlock is always wrapped in a Content.Body[width=full]".
702
+ */
703
+ function getMountTree(root) {
704
+ return {
705
+ ...summarize(root),
706
+ children: root.children.map(getMountTree)
707
+ };
708
+ }
709
+ //#endregion
710
+ //#region packages/ag-term/src/app.ts
711
+ /**
712
+ * Create an App instance
713
+ */
714
+ function buildApp(options) {
715
+ const { getContainer, getBuffer, sendInput, rerender, unmount, waitUntilExit, clear, exitCalled = () => false, exitError = () => void 0, freshRender: freshRenderFn, debugFn, frames = [], columns, rows, kittyMode = false, actAndRender, resize: resizeFn, focusManager: fm, waitForLayoutStable: waitForLayoutStableFn, clsMonitor } = options;
716
+ const createLocator = () => createAutoLocator(getContainer);
717
+ const getText = () => {
718
+ const buffer = getBuffer();
719
+ return buffer ? bufferToText(buffer) : "";
720
+ };
721
+ let boundTerm = null;
722
+ const mouseState = createMouseEventProcessor();
723
+ let screenshotter = null;
724
+ const app = {
725
+ get text() {
726
+ return getText();
727
+ },
728
+ get ansi() {
729
+ const buffer = getBuffer();
730
+ return buffer ? bufferToStyledText(buffer) : "";
731
+ },
732
+ get lines() {
733
+ return getText().split("\n");
734
+ },
735
+ get width() {
736
+ return getBuffer()?.width ?? columns;
737
+ },
738
+ get height() {
739
+ return getBuffer()?.height ?? rows;
740
+ },
741
+ cell(col, row) {
742
+ const buffer = getBuffer();
743
+ if (!buffer) return EMPTY_FRAME_CELL;
744
+ return cellToFrameCell(buffer.getCell(col, row));
745
+ },
746
+ containsText(text) {
747
+ return getText().includes(text);
748
+ },
749
+ nodeAt(x, y) {
750
+ return findNodeAtContentPosition(getContainer(), x, y);
751
+ },
752
+ getByTestId(id) {
753
+ return createLocator().getByTestId(id);
754
+ },
755
+ getByText(text) {
756
+ return createLocator().getByText(text);
757
+ },
758
+ locator(selector) {
759
+ return createLocator().locator(selector);
760
+ },
761
+ press(key) {
762
+ return makeChain((async () => {
763
+ const hotkey = parseHotkey(key);
764
+ updateKeyboardModifiers(mouseState, {
765
+ super: hotkey.super,
766
+ hyper: hotkey.hyper,
767
+ eventType: "press"
768
+ });
769
+ sendInput(kittyMode ? keyToKittyAnsi(key) : keyToAnsi(key));
770
+ await Promise.resolve();
771
+ return app;
772
+ })());
773
+ },
774
+ keyDown(key) {
775
+ return makeChain((async () => {
776
+ const hotkey = parseHotkey(key);
777
+ updateKeyboardModifiers(mouseState, {
778
+ super: hotkey.super || keyIsModifier(key, "Super"),
779
+ hyper: hotkey.hyper || keyIsModifier(key, "Hyper"),
780
+ eventType: "press"
781
+ });
782
+ sendInput(injectKittyEventType(kittyMode ? keyToKittyAnsi(key) : keyToAnsi(key), 1));
783
+ await Promise.resolve();
784
+ return app;
785
+ })());
786
+ },
787
+ keyUp(key) {
788
+ return makeChain((async () => {
789
+ parseHotkey(key);
790
+ updateKeyboardModifiers(mouseState, {
791
+ super: false,
792
+ hyper: false,
793
+ eventType: "release"
794
+ });
795
+ sendInput(injectKittyEventType(kittyMode ? keyToKittyAnsi(key) : keyToAnsi(key), 3));
796
+ await Promise.resolve();
797
+ return app;
798
+ })());
799
+ },
800
+ pressSequence(...keys) {
801
+ return makeChain((async () => {
802
+ for (const key of keys) await app.press(key);
803
+ return app;
804
+ })());
805
+ },
806
+ type(text) {
807
+ return makeChain((async () => {
808
+ for (const char of text) sendInput(char);
809
+ await Promise.resolve();
810
+ return app;
811
+ })());
812
+ },
813
+ click(x, y, options) {
814
+ return makeChain((async () => {
815
+ const button = options?.button ?? 0;
816
+ if (options?.cmd) mouseState.keyboardModifiers.super = true;
817
+ const doClick = () => {
818
+ const parsed = {
819
+ button,
820
+ x,
821
+ y,
822
+ coordinateMode: "cell",
823
+ action: "down",
824
+ shift: options?.shift ?? false,
825
+ meta: options?.meta ?? false,
826
+ ctrl: options?.ctrl ?? false
827
+ };
828
+ processMouseEvent(mouseState, parsed, getContainer());
829
+ processMouseEvent(mouseState, {
830
+ ...parsed,
831
+ action: "up"
832
+ }, getContainer());
833
+ };
834
+ if (actAndRender) actAndRender(doClick);
835
+ else doClick();
836
+ if (options?.cmd) mouseState.keyboardModifiers.super = false;
837
+ await Promise.resolve();
838
+ return app;
839
+ })());
840
+ },
841
+ doubleClick(x, y, options) {
842
+ return makeChain((async () => {
843
+ const button = options?.button ?? 0;
844
+ if (options?.cmd) mouseState.keyboardModifiers.super = true;
845
+ const doDblClick = () => {
846
+ const baseParsed = {
847
+ button,
848
+ x,
849
+ y,
850
+ coordinateMode: "cell",
851
+ action: "down",
852
+ shift: options?.shift ?? false,
853
+ meta: options?.meta ?? false,
854
+ ctrl: options?.ctrl ?? false
855
+ };
856
+ processMouseEvent(mouseState, baseParsed, getContainer());
857
+ processMouseEvent(mouseState, {
858
+ ...baseParsed,
859
+ action: "up"
860
+ }, getContainer());
861
+ processMouseEvent(mouseState, baseParsed, getContainer());
862
+ processMouseEvent(mouseState, {
863
+ ...baseParsed,
864
+ action: "up"
865
+ }, getContainer());
866
+ };
867
+ if (actAndRender) actAndRender(doDblClick);
868
+ else doDblClick();
869
+ if (options?.cmd) mouseState.keyboardModifiers.super = false;
870
+ await Promise.resolve();
871
+ return app;
872
+ })());
873
+ },
874
+ hover(x, y, options) {
875
+ return makeChain((async () => {
876
+ if (options?.cmd) mouseState.keyboardModifiers.super = true;
877
+ const doHover = () => {
878
+ processMouseEvent(mouseState, {
879
+ button: 0,
880
+ x,
881
+ y,
882
+ coordinateMode: "cell",
883
+ action: "move",
884
+ shift: options?.shift ?? false,
885
+ meta: options?.meta ?? false,
886
+ ctrl: options?.ctrl ?? false
887
+ }, getContainer());
888
+ };
889
+ if (actAndRender) actAndRender(doHover);
890
+ else doHover();
891
+ if (options?.cmd) mouseState.keyboardModifiers.super = false;
892
+ await Promise.resolve();
893
+ return app;
894
+ })());
895
+ },
896
+ wheel(x, y, delta, options) {
897
+ return makeChain((async () => {
898
+ if (options?.cmd) mouseState.keyboardModifiers.super = true;
899
+ const doWheel = () => {
900
+ processMouseEvent(mouseState, {
901
+ button: 0,
902
+ x,
903
+ y,
904
+ coordinateMode: "cell",
905
+ action: "wheel",
906
+ delta,
907
+ shift: options?.shift ?? false,
908
+ meta: options?.meta ?? false,
909
+ ctrl: options?.ctrl ?? false
910
+ }, getContainer());
911
+ };
912
+ if (actAndRender) actAndRender(doWheel);
913
+ else doWheel();
914
+ if (options?.cmd) mouseState.keyboardModifiers.super = false;
915
+ await Promise.resolve();
916
+ return app;
917
+ })());
918
+ },
919
+ resize(cols, rows) {
920
+ if (!resizeFn) throw new Error("resize() is only available in test renderer");
921
+ resizeFn(cols, rows);
922
+ },
923
+ async run() {
924
+ return waitUntilExit();
925
+ },
926
+ get term() {
927
+ const buffer = getBuffer();
928
+ if (!buffer) return createBoundTerm({
929
+ width: columns,
930
+ height: rows,
931
+ getCell: () => ({
932
+ char: " ",
933
+ fg: null,
934
+ bg: null,
935
+ attrs: {},
936
+ wide: false,
937
+ continuation: false
938
+ }),
939
+ setCell: () => {},
940
+ clear: () => {},
941
+ inBounds: () => false
942
+ }, getContainer, getText);
943
+ if (!boundTerm || boundTerm.buffer !== buffer) boundTerm = createBoundTerm(buffer, getContainer, getText);
944
+ return boundTerm;
945
+ },
946
+ async screenshot(outputPath) {
947
+ const buffer = getBuffer();
948
+ if (!buffer) throw new Error("No buffer available for screenshot");
949
+ const html = bufferToHTML(buffer);
950
+ if (!screenshotter) screenshotter = createScreenshotter();
951
+ return screenshotter.capture(html, outputPath);
952
+ },
953
+ rerender,
954
+ unmount() {
955
+ if (screenshotter) {
956
+ screenshotter.close().catch(() => {});
957
+ screenshotter = null;
958
+ }
959
+ unmount();
960
+ },
961
+ [Symbol.dispose]() {
962
+ app.unmount();
963
+ },
964
+ waitUntilExit,
965
+ waitForLayoutStable(opts) {
966
+ if (!waitForLayoutStableFn) return Promise.reject(/* @__PURE__ */ new Error("waitForLayoutStable() is only available in the test renderer"));
967
+ return waitForLayoutStableFn(opts);
968
+ },
969
+ beginCLSCapture(classifier) {
970
+ if (!clsMonitor) throw new Error("beginCLSCapture: no ClsMonitor was provided to buildApp(). The render harness must instantiate `createClsMonitor()` and pass it via AppOptions.clsMonitor for CLS capture to work.");
971
+ clsMonitor.beginCapture(classifier);
972
+ },
973
+ endCLSCapture() {
974
+ if (!clsMonitor) throw new Error("endCLSCapture: no ClsMonitor was provided to buildApp().");
975
+ return clsMonitor.endCapture();
976
+ },
977
+ cancelCLSCapture() {
978
+ clsMonitor?.cancelCapture();
979
+ },
980
+ clear,
981
+ debug() {
982
+ if (debugFn) debugFn();
983
+ else console.log(app.text);
984
+ },
985
+ freshRender() {
986
+ if (!freshRenderFn) throw new Error("freshRender() is only available in test renderer");
987
+ return freshRenderFn();
988
+ },
989
+ exitCalled,
990
+ exitError,
991
+ stdin: { write: sendInput },
992
+ frames,
993
+ lastFrame() {
994
+ return frames[frames.length - 1];
995
+ },
996
+ lastBuffer() {
997
+ return getBuffer() ?? void 0;
998
+ },
999
+ lastFrameText() {
1000
+ const buffer = getBuffer();
1001
+ return buffer ? bufferToText(buffer) : void 0;
1002
+ },
1003
+ getContainer() {
1004
+ return getContainer();
1005
+ },
1006
+ focus(testID) {
1007
+ if (fm) {
1008
+ const root = getContainer();
1009
+ fm.focusById(testID, root, "programmatic");
1010
+ }
1011
+ },
1012
+ getFocusPath() {
1013
+ if (fm) {
1014
+ const root = getContainer();
1015
+ return fm.getFocusPath(root);
1016
+ }
1017
+ return [];
1018
+ },
1019
+ get focusManager() {
1020
+ if (!fm) throw new Error("FocusManager not available — pass focusManager to buildApp()");
1021
+ return fm;
1022
+ },
1023
+ getCursorState() {
1024
+ const layoutCursor = findActiveCursorRect(getContainer());
1025
+ if (layoutCursor) return {
1026
+ x: layoutCursor.x,
1027
+ y: layoutCursor.y,
1028
+ visible: layoutCursor.visible,
1029
+ shape: layoutCursor.shape
1030
+ };
1031
+ return options.getCursorState?.() ?? null;
1032
+ },
1033
+ renderPath(componentName) {
1034
+ return getRenderPath(getContainer(), componentName);
1035
+ },
1036
+ mountTree() {
1037
+ return getMountTree(getContainer());
1038
+ }
1039
+ };
1040
+ return app;
1041
+ }
1042
+ /**
1043
+ * Wrap a `Promise<App>` in a `ChainableApp` — a thenable that exposes
1044
+ * the action methods so consumers can compose without explicit `await`
1045
+ * between every step:
1046
+ *
1047
+ * await app.keyDown("Super").hover(x, y).keyUp("Super")
1048
+ *
1049
+ * Each chained method returns a new `ChainableApp` whose backing promise
1050
+ * `.then`s into the previous step + the new action. The terminal `await`
1051
+ * resolves the entire chain to the underlying `App`.
1052
+ *
1053
+ * Bead: @km/silvery/fluent-chain-actions.
1054
+ */
1055
+ function makeChain(p) {
1056
+ return {
1057
+ press: (key) => makeChain(p.then((a) => a.press(key))),
1058
+ keyDown: (key) => makeChain(p.then((a) => a.keyDown(key))),
1059
+ keyUp: (key) => makeChain(p.then((a) => a.keyUp(key))),
1060
+ pressSequence: (...keys) => makeChain(p.then((a) => a.pressSequence(...keys))),
1061
+ type: (text) => makeChain(p.then((a) => a.type(text))),
1062
+ click: (x, y, options) => makeChain(p.then((a) => a.click(x, y, options))),
1063
+ doubleClick: (x, y, options) => makeChain(p.then((a) => a.doubleClick(x, y, options))),
1064
+ hover: (x, y, options) => makeChain(p.then((a) => a.hover(x, y, options))),
1065
+ wheel: (x, y, delta, options) => makeChain(p.then((a) => a.wheel(x, y, delta, options))),
1066
+ then: (onFulfilled, onRejected) => p.then(onFulfilled, onRejected)
1067
+ };
1068
+ }
1069
+ /**
1070
+ * Inject a Kitty CSI u event-type byte (`:1` press, `:2` repeat, `:3`
1071
+ * release) before the trailing terminator (`u`, `~`, or a CSI letter)
1072
+ * of a Kitty key sequence. Used by `keyDown`/`keyUp` to express held
1073
+ * vs. transient state — `keyToKittyAnsi` returns the bare-press shape
1074
+ * (no event-type byte), which the parser interprets as "press" by
1075
+ * default; explicit `:1`/`:3` lets tests drive paired down/up events.
1076
+ *
1077
+ * Examples:
1078
+ *
1079
+ * `\x1b[57444;9u` → `\x1b[57444;9:1u` (left-super press)
1080
+ * `\x1b[57444;9u` → `\x1b[57444;9:3u` (left-super release)
1081
+ * `\x1b[1;5A` → `\x1b[1;5:1A` (Ctrl+ArrowUp press)
1082
+ * `\x1b[3;5~` → `\x1b[3;5:1~` (Ctrl+Delete press)
1083
+ *
1084
+ * Sequences without a modifier byte (`\x1b[CPu`) get a `;1:eventType`
1085
+ * insert: `\x1b[57444u` → `\x1b[57444;1:1u`. The base modifier is `1`
1086
+ * (no modifiers + 1 base) per the Kitty spec.
1087
+ *
1088
+ * Bead: @km/silvery/keydown-keyup-test-primitives.
1089
+ */
1090
+ function injectKittyEventType(seq, eventType) {
1091
+ const withMod = seq.replace(/(;[0-9]+)([A-Za-z~])$/, `$1:${eventType}$2`);
1092
+ if (withMod !== seq) return withMod;
1093
+ return seq.replace(/^(\x1b\[[0-9]+)([A-Za-z~])$/, `$1;1:${eventType}$2`);
1094
+ }
1095
+ /**
1096
+ * Detect whether a key string represents a specific bare modifier
1097
+ * (e.g., `keyIsModifier("Super", "Super")` → true,
1098
+ * `keyIsModifier("leftsuper", "Super")` → true,
1099
+ * `keyIsModifier("a", "Super")` → false). Used by `keyDown`/`keyUp`
1100
+ * to update `mouseState.keyboardModifiers` even when the caller passes
1101
+ * a bare modifier name like "Super" rather than a chord like
1102
+ * "Super+a". `parseHotkey` only sets `.super` when the key is a chord
1103
+ * with Super as a modifier; it doesn't set it for the bare modifier
1104
+ * name itself.
1105
+ *
1106
+ * Bead: @km/silvery/keydown-keyup-test-primitives.
1107
+ */
1108
+ function keyIsModifier(key, target) {
1109
+ const parts = key.split("+");
1110
+ const last = parts[parts.length - 1]?.toLowerCase() ?? "";
1111
+ if (target === "Super") return last === "super" || last === "cmd" || last === "command" || last === "leftsuper" || last === "rightsuper";
1112
+ return last === "hyper" || last === "lefthyper" || last === "righthyper";
1113
+ }
1114
+ /**
1115
+ * Find node at content coordinates (not screen coordinates)
1116
+ */
1117
+ function findNodeAtContentPosition(node, x, y) {
1118
+ const rect = node.boxRect;
1119
+ if (!rect) return null;
1120
+ if (!pointInRect(x, y, rect)) return null;
1121
+ for (const child of node.children) {
1122
+ const found = findNodeAtContentPosition(child, x, y);
1123
+ if (found) return found;
1124
+ }
1125
+ return node;
1126
+ }
1127
+ const RESIDUE_SENTINEL_RGB = {
1128
+ r: 254,
1129
+ g: 0,
1130
+ b: 254
1131
+ };
1132
+ /** SILVERY_STRICT slug for the residue check. Tier 2 by design. */
1133
+ const RESIDUE_STRICT_SLUG = "residue";
1134
+ /** Returns true when the residue check should fire. */
1135
+ function isResidueStrictEnabled() {
1136
+ return isStrictEnabled(RESIDUE_STRICT_SLUG, 2);
1137
+ }
1138
+ /**
1139
+ * Fill every cell of `buffer` with the residue sentinel — a magenta-ish
1140
+ * RGB no theme uses + the thorn character "þ". This is a destructive
1141
+ * operation; callers must operate on a clone of the live prev buffer.
1142
+ */
1143
+ function poisonBufferWithSentinel(buffer) {
1144
+ buffer.fill(0, 0, buffer.width, buffer.height, {
1145
+ char: "þ",
1146
+ fg: RESIDUE_SENTINEL_RGB,
1147
+ bg: RESIDUE_SENTINEL_RGB,
1148
+ wide: false,
1149
+ continuation: false
1150
+ });
1151
+ }
1152
+ /**
1153
+ * Compare a sentinel-poison-prev incremental render to a fresh render,
1154
+ * accounting for the legitimate "cascade skipped this cell because prev
1155
+ * was already correct" pattern.
1156
+ *
1157
+ * The check semantic in detail:
1158
+ *
1159
+ * 1. Real prev buffer P captures the pre-frame state (the buffer the
1160
+ * production incremental render correctly clones from).
1161
+ * 2. Poisoned prev P' = P with every cell replaced by sentinel.
1162
+ * 3. Incremental-with-poison buffer I' is what runPipeline produced
1163
+ * against P'. Cells the cascade skipped retain sentinel; cells it
1164
+ * repainted have fresh content.
1165
+ * 4. Fresh-from-zero buffer F is the all-paths-recomputed reference.
1166
+ * 5. INVARIANT: at every (x,y),
1167
+ * I'[x,y] is sentinel ⇒ P[x,y] === F[x,y]
1168
+ * (cascade skipped this cell ⇒ prev pixel was already what fresh
1169
+ * paints; no stale carry-over).
1170
+ * 6. Equivalently: at any cell where P[x,y] ≠ F[x,y], the incremental
1171
+ * cascade MUST repaint, and I'[x,y] ≠ sentinel.
1172
+ *
1173
+ * A violation = "the cascade decided this cell is clean, but the real
1174
+ * prev had different content from what fresh paints". That's exactly
1175
+ * the cyan-strip-residue class: prev had cyan bg at row 5; fresh paints
1176
+ * default bg; cascade skipped row 5; user sees cyan on a row that
1177
+ * should be default. Sentinel-poison reveals it because I' has the
1178
+ * sentinel where the cascade skipped, and F[x,y] ≠ P[x,y] at that cell.
1179
+ *
1180
+ * Pipeline-state contamination is detected as a side effect: if any
1181
+ * cross-pass state (postState / accumulators) leaks, I' diverges from
1182
+ * F at cells the sentinel doesn't cover, and `cellEquals` catches it.
1183
+ *
1184
+ * Throws on first divergence with a diagnostic identifying the verdict
1185
+ * (sentinel leak vs pipeline contamination), the cell coordinates, and
1186
+ * the value triple (P, I', F) for the offending cell.
1187
+ */
1188
+ function verifyNoResidueLeak(realPrevBuffer, incrPoisonedBuffer, freshBuffer, frameCount) {
1189
+ const w = Math.min(realPrevBuffer.width, incrPoisonedBuffer.width, freshBuffer.width);
1190
+ const h = Math.min(realPrevBuffer.height, incrPoisonedBuffer.height, freshBuffer.height);
1191
+ for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
1192
+ const incr = incrPoisonedBuffer.getCell(x, y);
1193
+ const fresh = freshBuffer.getCell(x, y);
1194
+ if (cellEquals(incr, fresh)) continue;
1195
+ if (isSentinelCell(incr)) {
1196
+ const prev = realPrevBuffer.getCell(x, y);
1197
+ if (cellEquals(prev, fresh)) continue;
1198
+ throw new IncrementalRenderMismatchError(`STRICT residue check (frame ${frameCount}): SENTINEL LEAK at (${x},${y})\n real prev: char='${prev.char}' fg=${formatColor(prev.fg)} bg=${formatColor(prev.bg)}\n incremental: char='${incr.char}' fg=${formatColor(incr.fg)} bg=${formatColor(incr.bg)} (sentinel — cascade skipped)\n fresh: char='${fresh.char}' fg=${formatColor(fresh.fg)} bg=${formatColor(fresh.bg)}\n Real prev ≠ fresh at this cell, but the incremental cascade decided\n not to repaint it. The user sees a stale prev pixel where fresh\n would have painted differently — the cyan-strip-residue class.\n Likely root cause: missing dirty flag propagation, a skipBgFill\n that should have been bgRefillNeeded, or a transparent-Box\n ancestorCleared cascade gap.\n Slug: SILVERY_STRICT=${RESIDUE_STRICT_SLUG} (tier 2+).\n Per-test opt-out: SILVERY_STRICT=2,!${RESIDUE_STRICT_SLUG}.`);
1199
+ }
1200
+ throw new IncrementalRenderMismatchError(`STRICT residue check (frame ${frameCount}): pipeline-state contamination at (${x},${y})\n incremental: char='${incr.char}' fg=${formatColor(incr.fg)} bg=${formatColor(incr.bg)} (repainted)\n fresh: char='${fresh.char}' fg=${formatColor(fresh.fg)} bg=${formatColor(fresh.bg)}\n Both passes painted this cell but disagreed. Cross-pass state\n (postState / scrollOffset / outlineSnapshots) is leaking between\n the sentinel-poisoned incremental render and the fresh baseline.\n Slug: SILVERY_STRICT=${RESIDUE_STRICT_SLUG} (tier 2+).\n Per-test opt-out: SILVERY_STRICT=2,!${RESIDUE_STRICT_SLUG}.`);
1201
+ }
1202
+ }
1203
+ /** True when a cell still carries the residue sentinel. */
1204
+ function isSentinelCell(c) {
1205
+ if (c.char !== "þ") return false;
1206
+ const bg = c.bg;
1207
+ if (typeof bg !== "object" || bg === null) return false;
1208
+ const o = bg;
1209
+ return o.r === RESIDUE_SENTINEL_RGB.r && o.g === RESIDUE_SENTINEL_RGB.g && o.b === RESIDUE_SENTINEL_RGB.b;
1210
+ }
1211
+ function formatColor(c) {
1212
+ if (c === null || c === void 0) return "default";
1213
+ if (typeof c === "number") return `${c}`;
1214
+ if (typeof c === "object") {
1215
+ const o = c;
1216
+ return `rgb(${o.r},${o.g},${o.b})`;
1217
+ }
1218
+ return String(c);
1219
+ }
1220
+ //#endregion
1221
+ //#region packages/ag-term/src/renderer.ts
1222
+ /**
1223
+ * Unified Render API for silvery
1224
+ *
1225
+ * Composable primitives:
1226
+ * - render(element, opts | store) — always sync, returns full App
1227
+ * - createStore(providers) — flattens providers into { cols, rows, events() }
1228
+ * - run(app, events?) — event loop driver (sync or async)
1229
+ * - createApp(element, providers) — sugar: render + createStore + run
1230
+ * - createRenderer(opts | store) — factory with auto-cleanup
1231
+ */
1232
+ const act = React.act ?? ((cb) => cb());
1233
+ const log = createLogger("silvery:render");
1234
+ function invalidateRootAfterLayoutFeedback(root) {
1235
+ root.dirtyBits = 31;
1236
+ root.dirtyEpoch = getRenderEpoch();
1237
+ }
1238
+ /**
1239
+ * Track all active (mounted) render instances to detect leaks.
1240
+ * Uses a Set of WeakRefs so GC can clean up unreferenced apps.
1241
+ */
1242
+ const activeRenders = /* @__PURE__ */ new Set();
1243
+ let renderIdCounter = 0;
1244
+ /**
1245
+ * Maximum number of active render instances before throwing.
1246
+ * Set high to allow large test files (each test may create a render without unmount),
1247
+ * but catch genuine leaks like infinite loops creating renders.
1248
+ */
1249
+ const ACTIVE_RENDER_LEAK_THRESHOLD = 1e3;
1250
+ /**
1251
+ * Prune GC'd entries from activeRenders and return live count.
1252
+ */
1253
+ function pruneAndCountActiveRenders() {
1254
+ let count = 0;
1255
+ for (const ref of activeRenders) if (ref.deref() === void 0) activeRenders.delete(ref);
1256
+ else count++;
1257
+ return count;
1258
+ }
1259
+ /**
1260
+ * Assert that the layout engine is initialized before rendering.
1261
+ * This catches the common mistake of calling render() without await ensureEngine().
1262
+ */
1263
+ function assertLayoutEngine() {
1264
+ if (!isLayoutEngineInitialized()) throw new Error("silvery: Layout engine not initialized. Call `await ensureEngine()` before render(), or use the testing module which initializes it automatically via top-level await.");
1265
+ }
1266
+ let engineReady = null;
1267
+ /**
1268
+ * Ensure layout engine is initialized (async, cached).
1269
+ */
1270
+ async function ensureEngine() {
1271
+ if (isLayoutEngineInitialized()) return;
1272
+ if (!engineReady) engineReady = ensureDefaultLayoutEngine();
1273
+ await engineReady;
1274
+ }
1275
+ function isStore(arg) {
1276
+ if (arg === null || typeof arg !== "object") return false;
1277
+ const obj = arg;
1278
+ return typeof obj.cols === "number" && typeof obj.rows === "number" && !("layoutEngine" in obj) && !("debug" in obj) && !("incremental" in obj) && !("maxLayoutPasses" in obj) && !("autoRender" in obj) && !("onFrame" in obj) && !("kittyMode" in obj) && !("wrapRoot" in obj) && !("stdin" in obj);
1279
+ }
1280
+ /**
1281
+ * Render a React element synchronously. Returns a full App with locators,
1282
+ * press(), text, ansi, etc.
1283
+ *
1284
+ * Layout engine must be initialized before calling (use ensureEngine() or
1285
+ * the top-level await in testing module).
1286
+ *
1287
+ * Overloads:
1288
+ * - render(element, { cols, rows }) — headless with dimensions
1289
+ * - render(element, store) — with a Store from createStore()
1290
+ *
1291
+ * @example
1292
+ * ```tsx
1293
+ * const app = render(<Counter />, { cols: 80, rows: 24 })
1294
+ * expect(app.text).toContain('Count: 0')
1295
+ * await app.press('j')
1296
+ * expect(app.text).toContain('Count: 1')
1297
+ * ```
1298
+ */
1299
+ function render(element, optsOrStore = {}) {
1300
+ assertLayoutEngine();
1301
+ const storeMode = isStore(optsOrStore);
1302
+ const cols = storeMode ? optsOrStore.cols : optsOrStore.cols ?? 80;
1303
+ const rows = storeMode ? optsOrStore.rows : optsOrStore.rows ?? 24;
1304
+ const debug = storeMode ? false : optsOrStore.debug ?? false;
1305
+ const incremental = storeMode ? true : optsOrStore.incremental ?? true;
1306
+ const maxLayoutPasses = storeMode ? MAX_CONVERGENCE_PASSES : optsOrStore.maxLayoutPasses ?? MAX_CONVERGENCE_PASSES;
1307
+ const kittyMode = storeMode ? false : optsOrStore.kittyMode ?? false;
1308
+ const autoRender = storeMode ? false : optsOrStore.autoRender ?? false;
1309
+ const handleTabCycling = storeMode ? true : optsOrStore.handleTabCycling ?? true;
1310
+ const onFrame = storeMode ? void 0 : optsOrStore.onFrame;
1311
+ const onBufferReady = storeMode ? void 0 : optsOrStore.onBufferReady;
1312
+ const wrapRoot = storeMode ? void 0 : optsOrStore.wrapRoot;
1313
+ const stdinStream = storeMode ? void 0 : optsOrStore.stdin;
1314
+ const liveCount = pruneAndCountActiveRenders();
1315
+ if (liveCount >= ACTIVE_RENDER_LEAK_THRESHOLD) throw new Error(`silvery: ${liveCount} active render instances without unmount(). This is a render leak. Use createRenderer() for auto-cleanup, or call unmount() when done with each render.`);
1316
+ if (!storeMode && optsOrStore.layoutEngine) setLayoutEngine(optsOrStore.layoutEngine);
1317
+ const renderId = ++renderIdCounter;
1318
+ const instance = {
1319
+ frames: [],
1320
+ container: null,
1321
+ fiberRoot: null,
1322
+ prevBuffer: null,
1323
+ prevPaintedBuffer: null,
1324
+ postState: createRenderPostState(),
1325
+ mounted: true,
1326
+ rendering: false,
1327
+ columns: cols,
1328
+ rows,
1329
+ inputEmitter: new EventEmitter(),
1330
+ debug,
1331
+ incremental,
1332
+ renderCount: 0,
1333
+ maxLayoutPasses,
1334
+ kittyActive: false
1335
+ };
1336
+ let hadReactCommit = false;
1337
+ let autoRenderScheduled = false;
1338
+ let inRenderCycle = false;
1339
+ let currentElement = element;
1340
+ const clsMonitor = createClsMonitor();
1341
+ instance.container = createContainer(() => {
1342
+ hadReactCommit = true;
1343
+ if (autoRender && !instance.rendering && !inRenderCycle && !autoRenderScheduled && instance.mounted) {
1344
+ autoRenderScheduled = true;
1345
+ queueMicrotask(() => {
1346
+ autoRenderScheduled = false;
1347
+ if (!instance.mounted || instance.rendering || inRenderCycle) return;
1348
+ inRenderCycle = true;
1349
+ try {
1350
+ let newFrame = doRender();
1351
+ const root = getContainerRoot(instance.container);
1352
+ hadReactCommit = false;
1353
+ withActEnvironment(() => {
1354
+ act(() => {
1355
+ commitLayoutSnapshot(root);
1356
+ });
1357
+ if (hadReactCommit) act(() => {
1358
+ reconciler.flushSyncWork();
1359
+ });
1360
+ });
1361
+ if (hadReactCommit) newFrame = doRender();
1362
+ instance.frames.push(newFrame);
1363
+ onFrame?.(newFrame, instance.prevBuffer, getRootContentHeight());
1364
+ } finally {
1365
+ inRenderCycle = false;
1366
+ }
1367
+ });
1368
+ }
1369
+ });
1370
+ instance.fiberRoot = createFiberRoot(instance.container);
1371
+ /**
1372
+ * Get the content extent of the root node's children (for buffer padding trimming).
1373
+ * Returns the max outer bottom edge of the root's direct children, including
1374
+ * margins. The root itself stretches to fill the terminal, but its children
1375
+ * have specific layout-computed heights plus margins that extend beyond.
1376
+ * Returns 0 if no children have layout (empty tree).
1377
+ * Returns undefined if root has no layout at all.
1378
+ */
1379
+ const getRootContentHeight = () => {
1380
+ try {
1381
+ const root = getContainerRoot(instance.container);
1382
+ if (!root?.boxRect) return void 0;
1383
+ let maxBottom = 0;
1384
+ let hasChildren = false;
1385
+ for (const child of root.children) if (child.boxRect) {
1386
+ hasChildren = true;
1387
+ const props = child.props;
1388
+ const mb = props.marginBottom ?? props.marginY ?? props.margin ?? 0;
1389
+ const childBottom = child.boxRect.y + child.boxRect.height + mb;
1390
+ if (childBottom > maxBottom) maxBottom = childBottom;
1391
+ }
1392
+ return hasChildren ? maxBottom : 0;
1393
+ } catch {
1394
+ return;
1395
+ }
1396
+ };
1397
+ let exitCalledFlag = false;
1398
+ let exitErrorValue;
1399
+ const handleExit = (error) => {
1400
+ exitCalledFlag = true;
1401
+ exitErrorValue = error;
1402
+ if (debug) console.log("[silvery] exit() called", error ? `with error: ${error.message}` : "");
1403
+ };
1404
+ const stdoutEmitter = new EventEmitter();
1405
+ const mockStdout = {
1406
+ columns: instance.columns,
1407
+ rows: instance.rows,
1408
+ write: () => true,
1409
+ isTTY: true,
1410
+ on: (event, listener) => {
1411
+ stdoutEmitter.on(event, listener);
1412
+ return mockStdout;
1413
+ },
1414
+ off: (event, listener) => {
1415
+ stdoutEmitter.off(event, listener);
1416
+ return mockStdout;
1417
+ },
1418
+ once: (event, listener) => {
1419
+ stdoutEmitter.once(event, listener);
1420
+ return mockStdout;
1421
+ },
1422
+ removeListener: (event, listener) => {
1423
+ stdoutEmitter.removeListener(event, listener);
1424
+ return mockStdout;
1425
+ },
1426
+ addListener: (event, listener) => {
1427
+ stdoutEmitter.addListener(event, listener);
1428
+ return mockStdout;
1429
+ }
1430
+ };
1431
+ const mockTerm = createTerm({
1432
+ cols: instance.columns,
1433
+ rows: instance.rows,
1434
+ caps: { colorLevel: "truecolor" }
1435
+ });
1436
+ const updateMockTermSize = mockTerm.size.update;
1437
+ const focusManager = createFocusManager();
1438
+ setContainerNodeLifecycle(instance.container, {
1439
+ onNodeRemoved: (removedNode) => focusManager.handleSubtreeRemoved(removedNode),
1440
+ onNodeUpdated: (updatedNode) => focusManager.handleNodeUpdated(updatedNode),
1441
+ onSubtreeAttached: (attachedRoot) => focusManager.handleSubtreeAttached(attachedRoot)
1442
+ });
1443
+ const cursorStore = createCursorStore();
1444
+ const renderScope = createScope("test-render");
1445
+ const childApp = createChildApp();
1446
+ const chainAppContextValue = toChainAppContextValue(childApp);
1447
+ instance.inputEmitter.on("input", (data) => {
1448
+ const [input, key] = parseKey(data);
1449
+ childApp.rawKeys.notify(input, key);
1450
+ childApp.dispatch({
1451
+ type: "input:key",
1452
+ input,
1453
+ key
1454
+ });
1455
+ childApp.drainEffects();
1456
+ });
1457
+ instance.inputEmitter.on("paste", (text) => {
1458
+ childApp.dispatch({
1459
+ type: "term:paste",
1460
+ text
1461
+ });
1462
+ childApp.drainEffects();
1463
+ });
1464
+ const runtimeValue = {
1465
+ exit: handleExit,
1466
+ panic: (reason) => handleExit(reason instanceof Error ? reason : new Error(String(reason)))
1467
+ };
1468
+ function wrapWithContexts(el) {
1469
+ const inner = wrapRoot ? wrapRoot(el) : el;
1470
+ return React.createElement(ScopeProvider, {
1471
+ scope: renderScope,
1472
+ appScope: renderScope
1473
+ }, React.createElement(CursorProvider, { store: cursorStore }, React.createElement(TermContext.Provider, { value: mockTerm }, React.createElement(StdoutContext.Provider, { value: {
1474
+ stdout: mockStdout,
1475
+ write: () => {}
1476
+ } }, React.createElement(FocusManagerContext.Provider, { value: focusManager }, React.createElement(RuntimeContext.Provider, { value: runtimeValue }, React.createElement(ChainAppContext.Provider, { value: chainAppContextValue }, inner)))))));
1477
+ }
1478
+ const strictEnv = process.env.SILVERY_STRICT;
1479
+ const strictMode = incremental && strictEnv && strictEnv !== "0" && strictEnv !== "false";
1480
+ /** Run the full pipeline: layout + render + output phase. */
1481
+ function runPipeline(root, cols, rows, prevBuffer, opts) {
1482
+ const ag = createAg(root);
1483
+ ag.layout({
1484
+ cols,
1485
+ rows
1486
+ }, opts);
1487
+ const { buffer, carryForwardBuffer, overlay } = ag.render({
1488
+ prevBuffer,
1489
+ postState: opts?.fresh ? createRenderPostState() : instance.postState
1490
+ });
1491
+ let output = outputPhase(instance.prevPaintedBuffer ?? prevBuffer, buffer, "fullscreen");
1492
+ if (overlay.length > 0) {
1493
+ output += overlay;
1494
+ instance.kittyActive = true;
1495
+ } else if (instance.kittyActive) {
1496
+ output += "\x1B7" + kittyDeleteAllScrimPlacements() + "\x1B8";
1497
+ instance.kittyActive = false;
1498
+ }
1499
+ return {
1500
+ output,
1501
+ buffer,
1502
+ carryForwardBuffer,
1503
+ overlay
1504
+ };
1505
+ }
1506
+ function doRender() {
1507
+ let output;
1508
+ let buffer;
1509
+ let incrementalOverlay = "";
1510
+ let multiPassConverged = false;
1511
+ let passCount = 0;
1512
+ const cap = instance.maxLayoutPasses;
1513
+ if (INSTRUMENT) beginConvergenceLoop();
1514
+ for (let pass = 0; pass < cap; pass++) {
1515
+ hadReactCommit = false;
1516
+ passCount++;
1517
+ if (INSTRUMENT) beginPass(pass);
1518
+ let renderError = null;
1519
+ let carryForwardBuffer;
1520
+ withActEnvironment(() => {
1521
+ act(() => {
1522
+ const root = getContainerRoot(instance.container);
1523
+ try {
1524
+ const result = runPipeline(root, instance.columns, instance.rows, incremental ? instance.prevBuffer : null);
1525
+ output = result.output;
1526
+ buffer = result.buffer;
1527
+ carryForwardBuffer = result.carryForwardBuffer;
1528
+ incrementalOverlay = result.overlay;
1529
+ } catch (e) {
1530
+ renderError = e;
1531
+ const attachedBuffer = e?.__silvery_buffer;
1532
+ if (attachedBuffer) {
1533
+ buffer = attachedBuffer;
1534
+ carryForwardBuffer = attachedBuffer;
1535
+ }
1536
+ }
1537
+ if (buffer) {
1538
+ instance.prevBuffer = carryForwardBuffer ?? buffer;
1539
+ instance.prevPaintedBuffer = buffer;
1540
+ }
1541
+ instance.renderCount++;
1542
+ onBufferReady?.(output, buffer, getRootContentHeight());
1543
+ });
1544
+ if (!hadReactCommit) act(() => {
1545
+ reconciler.flushSyncWork();
1546
+ });
1547
+ });
1548
+ if (renderError) {
1549
+ if (!(renderError instanceof IncrementalRenderMismatchError)) throw renderError;
1550
+ }
1551
+ if (!hadReactCommit) {
1552
+ multiPassConverged = true;
1553
+ break;
1554
+ }
1555
+ invalidateRootAfterLayoutFeedback(getContainerRoot(instance.container));
1556
+ if (pass === cap - 1) recordPassRing("unknown", "layout-pass-exhaustion");
1557
+ if (INSTRUMENT) {
1558
+ notePassCommit(pass);
1559
+ if (pass === cap - 1) logPass({
1560
+ cause: "unknown",
1561
+ detail: "layout-pass-exhaustion"
1562
+ });
1563
+ }
1564
+ }
1565
+ if (hadReactCommit && passCount >= cap) assertBoundedConvergence(passCount, "layout-pass", cap);
1566
+ if (incremental && buffer && passCount > 1) buffer.markAllRowsDirty();
1567
+ clsMonitor.onCommit(getContainerRoot(instance.container), instance.columns, instance.rows, false);
1568
+ if (strictMode && instance.renderCount > 1 && multiPassConverged) {
1569
+ const root = getContainerRoot(instance.container);
1570
+ const { buffer: freshBuffer, overlay: freshOverlay } = doFreshRenderFull();
1571
+ if (incrementalOverlay !== freshOverlay) throw new IncrementalRenderMismatchError(formatRendererOverlayMismatch(incrementalOverlay, freshOverlay, instance.renderCount));
1572
+ for (let y = 0; y < buffer.height; y++) for (let x = 0; x < buffer.width; x++) {
1573
+ const a = buffer.getCell(x, y);
1574
+ const b = freshBuffer.getCell(x, y);
1575
+ if (!cellEquals(a, b)) {
1576
+ let trapInfo = "";
1577
+ const trap = {
1578
+ x,
1579
+ y,
1580
+ log: []
1581
+ };
1582
+ globalThis.__silvery_write_trap = trap;
1583
+ try {
1584
+ doFreshRender();
1585
+ } catch {}
1586
+ globalThis.__silvery_write_trap = null;
1587
+ if (trap.log.length > 0) trapInfo = `\nWRITE TRAP (${trap.log.length} writes to (${x},${y})):\n${trap.log.join("\n")}\n`;
1588
+ else trapInfo = `\nWRITE TRAP: NO WRITES to (${x},${y})\n`;
1589
+ const ctx = buildMismatchContext(root, x, y, a, b, instance.renderCount);
1590
+ const renderPhaseStats = globalThis.__silvery_content_detail ? structuredClone(globalThis.__silvery_content_detail) : void 0;
1591
+ const debugInfo = formatMismatchContext(ctx, renderPhaseStats);
1592
+ const incText = bufferToText(buffer);
1593
+ const freshText = bufferToText(freshBuffer);
1594
+ throw new IncrementalRenderMismatchError(debugInfo + trapInfo + `--- incremental ---\n${incText}\n--- fresh ---\n${freshText}`, {
1595
+ renderPhaseStats,
1596
+ mismatchContext: ctx
1597
+ });
1598
+ }
1599
+ }
1600
+ if (instance.prevBuffer && instance.renderCount > 1 && multiPassConverged && isResidueStrictEnabled()) {
1601
+ const root2 = getContainerRoot(instance.container);
1602
+ const realPrev = instance.prevBuffer.clone();
1603
+ const poisonedPrev = instance.prevBuffer.clone();
1604
+ poisonBufferWithSentinel(poisonedPrev);
1605
+ const sentinelResult = runPipeline(root2, instance.columns, instance.rows, poisonedPrev, {
1606
+ skipLayoutNotifications: true,
1607
+ skipScrollStateUpdates: true,
1608
+ fresh: true
1609
+ });
1610
+ const { buffer: freshFromZero } = doFreshRenderFull();
1611
+ verifyNoResidueLeak(realPrev, sentinelResult.buffer, freshFromZero, instance.renderCount);
1612
+ }
1613
+ }
1614
+ return output;
1615
+ }
1616
+ function doFreshRenderFull() {
1617
+ const root = getContainerRoot(instance.container);
1618
+ const freshLayout = isStrictEnabled(FRESH_LAYOUT_STRICT_SLUG, 2);
1619
+ if (freshLayout) markLayoutTreeDirty(root);
1620
+ const { buffer, overlay } = runPipeline(root, instance.columns, instance.rows, null, {
1621
+ skipLayoutNotifications: true,
1622
+ skipScrollStateUpdates: true,
1623
+ fresh: true,
1624
+ forceFullPropagate: freshLayout
1625
+ });
1626
+ return {
1627
+ buffer,
1628
+ overlay
1629
+ };
1630
+ }
1631
+ function doFreshRender() {
1632
+ return doFreshRenderFull().buffer;
1633
+ }
1634
+ instance.rendering = true;
1635
+ try {
1636
+ withActEnvironment(() => {
1637
+ act(() => {
1638
+ reconciler.updateContainerSync(wrapWithContexts(currentElement), instance.fiberRoot, null, null);
1639
+ reconciler.flushSyncWork();
1640
+ });
1641
+ });
1642
+ } finally {
1643
+ instance.rendering = false;
1644
+ }
1645
+ /**
1646
+ * Promote in-flight rect signals to their committed peers, then drain any
1647
+ * resulting React work with one more `doRender()` if needed.
1648
+ *
1649
+ * Reactive `useBoxRect()` / `useScrollRect()` / `useScreenRect()` hooks
1650
+ * read the COMMITTED rect signals (stable across all convergence passes
1651
+ * within one batch). Calling `commitLayoutSnapshot` advances them by one
1652
+ * frame, which may fire `effect()` callbacks that trigger React
1653
+ * `forceUpdate`s. Those forceUpdates need to land before the test
1654
+ * driver's caller observes the frame — otherwise the first frame would
1655
+ * show the empty-rect fallback for any reactive layout consumer.
1656
+ *
1657
+ * The settle pass is bounded to ONE doRender. Any in-flight rect changes
1658
+ * produced by it are intentionally NOT committed in this batch — they
1659
+ * belong to the next event boundary (one-frame-late contract).
1660
+ *
1661
+ * See bead `@km/silvery/use-deferred-box-rect-and-post-commit-observers`.
1662
+ */
1663
+ function settleAfterCommit(prevOutput) {
1664
+ const root = getContainerRoot(instance.container);
1665
+ let scheduled = false;
1666
+ hadReactCommit = false;
1667
+ withActEnvironment(() => {
1668
+ act(() => {
1669
+ commitLayoutSnapshot(root);
1670
+ });
1671
+ if (hadReactCommit) {
1672
+ act(() => {
1673
+ reconciler.flushSyncWork();
1674
+ });
1675
+ scheduled = true;
1676
+ }
1677
+ });
1678
+ if (!scheduled) return prevOutput;
1679
+ return doRender();
1680
+ }
1681
+ /**
1682
+ * Drain additional commit / layout cycles until the React tree is stable
1683
+ * (no pending commit, no dirty layout nodes) OR a budget is reached.
1684
+ * Resolves once stable so tests can assert post-convergence state.
1685
+ *
1686
+ * The default is production timing — `render()` exposes a frame after the
1687
+ * production-cap `MAX_CONVERGENCE_PASSES`. Tests asserting layout that
1688
+ * requires more passes to settle (multi-layer measurement chains,
1689
+ * Suspense-driven first-paint cascades, etc.) call this explicitly:
1690
+ *
1691
+ * ```ts
1692
+ * const app = render(<MyApp />)
1693
+ * await app.waitForLayoutStable()
1694
+ * expect(app.text).toMatchSnapshot()
1695
+ * ```
1696
+ *
1697
+ * Stability detection: a pass is run, then `flushSyncWork` runs in a
1698
+ * separate `act()`. If `hadReactCommit` flips during the flush, layout
1699
+ * is still in-flight; loop again (run another commit boundary + flush).
1700
+ * Same predicate the renderer uses internally — does not invent a new
1701
+ * readiness probe.
1702
+ *
1703
+ * Bounded by `maxPasses` (default 20) and `timeoutMs` (default 50ms).
1704
+ * If the cap is reached, resolves WITHOUT throwing — an infinitely
1705
+ * non-converging app is a bug, not a test fault. The contract is "best
1706
+ * effort within the budget."
1707
+ *
1708
+ * Bead: `@km/silvery/test-harness-convergence-cap-parity`.
1709
+ */
1710
+ async function waitForLayoutStable(opts) {
1711
+ if (!instance.mounted) return;
1712
+ const timeoutMs = opts?.timeoutMs ?? 50;
1713
+ const maxPasses = opts?.maxPasses ?? 20;
1714
+ const start = performance.now();
1715
+ let lastFrame = instance.frames[instance.frames.length - 1] ?? "";
1716
+ for (let pass = 0; pass < maxPasses; pass++) {
1717
+ if (performance.now() - start >= timeoutMs) return;
1718
+ if (!instance.mounted) return;
1719
+ hadReactCommit = false;
1720
+ instance.rendering = true;
1721
+ try {
1722
+ await act(async () => {
1723
+ await Promise.resolve();
1724
+ });
1725
+ } finally {
1726
+ instance.rendering = false;
1727
+ }
1728
+ const reactCommittedThisPass = hadReactCommit;
1729
+ let next = lastFrame;
1730
+ if (reactCommittedThisPass) next = doRender();
1731
+ next = settleAfterCommit(next);
1732
+ if (!reactCommittedThisPass && next === lastFrame && !hadReactCommit) {
1733
+ try {
1734
+ const root = getContainerRoot(instance.container);
1735
+ if (root && !isAnyDirty(root.dirtyBits, root.dirtyEpoch)) return;
1736
+ } catch {
1737
+ return;
1738
+ }
1739
+ return;
1740
+ }
1741
+ if (next !== lastFrame) {
1742
+ lastFrame = next;
1743
+ instance.frames.push(next);
1744
+ onFrame?.(next, instance.prevBuffer, getRootContentHeight());
1745
+ }
1746
+ }
1747
+ }
1748
+ let output = doRender();
1749
+ output = settleAfterCommit(output);
1750
+ instance.frames.push(output);
1751
+ onFrame?.(output, instance.prevBuffer, getRootContentHeight());
1752
+ if (debug) console.log("[silvery] Initial render:", output);
1753
+ if (instance.prevBuffer) {
1754
+ const bufW = instance.prevBuffer.width;
1755
+ const bufH = instance.prevBuffer.height;
1756
+ const totalCells = bufW * bufH;
1757
+ if (totalCells >= 4e3) {
1758
+ const painted = instance.prevBuffer.countPaintedCells();
1759
+ const ratio = painted / totalCells;
1760
+ if (ratio < .05) {
1761
+ const msg = `silvery: degenerate frame after first render — only ${painted} of ${totalCells} cells painted (${(ratio * 100).toFixed(2)}%) at ${bufW}x${bufH}. Likely cause: the root component does not pin width/height (no <Screen> wrapper). createRenderer({cols, rows}) passes dimensions as the AVAILABLE size to layout, but does NOT set root.style.width/height — wrap the tree in <Box width={cols} height={rows}> or <Screen>. Per-test opt-out: SILVERY_STRICT=1,!canary. See @km/silvery/render-degenerate-frame-canary.`;
1762
+ if (isStrictEnabled("canary", 2)) throw new Error(msg);
1763
+ else log.debug?.(msg);
1764
+ }
1765
+ }
1766
+ }
1767
+ let stdinOnReadable;
1768
+ if (stdinStream) {
1769
+ stdinOnReadable = () => {
1770
+ let chunk;
1771
+ while ((chunk = stdinStream.read?.()) !== null && chunk !== void 0) instance.inputEmitter.emit("input", chunk);
1772
+ };
1773
+ stdinStream.on("readable", stdinOnReadable);
1774
+ }
1775
+ const getContainer = () => getContainerRoot(instance.container);
1776
+ const getBuffer = () => instance.prevPaintedBuffer ?? instance.prevBuffer;
1777
+ const sendInput = (data) => {
1778
+ if (!instance.mounted) throw new Error("Cannot write to stdin after unmount");
1779
+ if (instance.rendering) throw new Error("silvery: Re-entrant render detected. Cannot call press()/stdin.write() from inside a React render or effect. Use setTimeout or an event handler instead.");
1780
+ const t0 = performance.now();
1781
+ instance.rendering = true;
1782
+ try {
1783
+ let pasteResult = null;
1784
+ try {
1785
+ pasteResult = parseBracketedPaste(data);
1786
+ } catch (err) {
1787
+ if (isProtocolError(err)) log.debug?.(`bracketed paste parser flagged malformed input in press(): ${err.reason} (len=${err.inputLength})`);
1788
+ else throw err;
1789
+ }
1790
+ if (pasteResult) withActEnvironment(() => {
1791
+ act(() => {
1792
+ instance.inputEmitter.emit("paste", pasteResult.content);
1793
+ });
1794
+ });
1795
+ else withActEnvironment(() => {
1796
+ for (const keypress of splitRawInput(data)) {
1797
+ const [, key] = parseKey(keypress);
1798
+ if (handleTabCycling && key.tab && !key.shift) {
1799
+ act(() => {
1800
+ const root = getContainerRoot(instance.container);
1801
+ focusManager.focusNext(root);
1802
+ });
1803
+ continue;
1804
+ }
1805
+ if (handleTabCycling && key.tab && key.shift) {
1806
+ act(() => {
1807
+ const root = getContainerRoot(instance.container);
1808
+ focusManager.focusPrev(root);
1809
+ });
1810
+ continue;
1811
+ }
1812
+ if (key.escape && focusManager.activeElement) {
1813
+ act(() => {
1814
+ focusManager.blur();
1815
+ });
1816
+ continue;
1817
+ }
1818
+ act(() => {
1819
+ instance.inputEmitter.emit("input", keypress);
1820
+ });
1821
+ }
1822
+ });
1823
+ } finally {
1824
+ instance.rendering = false;
1825
+ }
1826
+ const t1 = performance.now();
1827
+ let newFrame = doRender();
1828
+ let doRenderCount = 1;
1829
+ if (instance.maxLayoutPasses <= MAX_CONVERGENCE_PASSES) {
1830
+ let flushCount = 0;
1831
+ if (INSTRUMENT) beginConvergenceLoop();
1832
+ for (let flush = 0; flush < MAX_CONVERGENCE_PASSES; flush++) {
1833
+ hadReactCommit = false;
1834
+ flushCount = flush + 1;
1835
+ if (INSTRUMENT) beginPass(flush);
1836
+ withActEnvironment(() => {
1837
+ act(() => {
1838
+ reconciler.flushSyncWork();
1839
+ });
1840
+ });
1841
+ if (!hadReactCommit) break;
1842
+ if (flush === MAX_CONVERGENCE_PASSES - 1) recordPassRing("unknown", "effect-flush-exhaustion");
1843
+ if (INSTRUMENT) {
1844
+ notePassCommit(flush);
1845
+ if (flush === MAX_CONVERGENCE_PASSES - 1) logPass({
1846
+ cause: "unknown",
1847
+ detail: "effect-flush-exhaustion"
1848
+ });
1849
+ }
1850
+ newFrame = doRender();
1851
+ doRenderCount++;
1852
+ }
1853
+ if (flushCount >= MAX_CONVERGENCE_PASSES && hadReactCommit) assertBoundedConvergence(flushCount, "effect-flush", MAX_CONVERGENCE_PASSES);
1854
+ }
1855
+ const settled = settleAfterCommit(newFrame);
1856
+ if (settled !== newFrame) {
1857
+ newFrame = settled;
1858
+ doRenderCount++;
1859
+ }
1860
+ if (incremental && doRenderCount > 1 && instance.prevBuffer) instance.prevBuffer.markAllRowsDirty();
1861
+ const t2 = performance.now();
1862
+ instance.frames.push(newFrame);
1863
+ onFrame?.(newFrame, instance.prevBuffer, getRootContentHeight());
1864
+ if (debug) console.log("[silvery] stdin.write:", newFrame);
1865
+ globalThis.__silvery_last_timing = {
1866
+ actMs: t1 - t0,
1867
+ renderMs: t2 - t1
1868
+ };
1869
+ };
1870
+ const rerenderFn = (newElement) => {
1871
+ if (!instance.mounted) throw new Error("Cannot rerender after unmount");
1872
+ if (instance.rendering) throw new Error("silvery: Re-entrant render detected. Cannot call rerender() from inside a React render or effect.");
1873
+ instance.rendering = true;
1874
+ try {
1875
+ withActEnvironment(() => {
1876
+ act(() => {
1877
+ currentElement = newElement;
1878
+ reconciler.updateContainerSync(wrapWithContexts(currentElement), instance.fiberRoot, null, null);
1879
+ reconciler.flushSyncWork();
1880
+ });
1881
+ });
1882
+ } finally {
1883
+ instance.rendering = false;
1884
+ }
1885
+ const newFrame = settleAfterCommit(doRender());
1886
+ instance.frames.push(newFrame);
1887
+ onFrame?.(newFrame, instance.prevBuffer, getRootContentHeight());
1888
+ if (debug) console.log("[silvery] Rerender:", newFrame);
1889
+ };
1890
+ const renderTracker = {
1891
+ unmount: () => {},
1892
+ id: renderId
1893
+ };
1894
+ const renderRef = new WeakRef(renderTracker);
1895
+ activeRenders.add(renderRef);
1896
+ const unmountFn = () => {
1897
+ if (!instance.mounted) throw new Error("Already unmounted");
1898
+ const fiberRoot = instance.fiberRoot;
1899
+ withActEnvironment(() => {
1900
+ act(() => {
1901
+ reconciler.updateContainerSync(null, fiberRoot, null, null);
1902
+ reconciler.flushSyncWork();
1903
+ });
1904
+ });
1905
+ renderScope[Symbol.asyncDispose]().catch((error) => reportDisposeError(error, {
1906
+ phase: "app-exit",
1907
+ scope: renderScope
1908
+ }));
1909
+ instance.mounted = false;
1910
+ instance.rendering = false;
1911
+ autoRenderScheduled = false;
1912
+ inRenderCycle = false;
1913
+ instance.inputEmitter.removeAllListeners();
1914
+ stdoutEmitter.removeAllListeners();
1915
+ if (stdinStream && stdinOnReadable) {
1916
+ stdinStream.removeListener("readable", stdinOnReadable);
1917
+ stdinOnReadable = void 0;
1918
+ }
1919
+ activeRenders.delete(renderRef);
1920
+ clearFn();
1921
+ instance.kittyActive = false;
1922
+ releaseContainer(instance.container);
1923
+ instance.fiberRoot = null;
1924
+ if (debug) console.log("[silvery] Unmounted");
1925
+ };
1926
+ renderTracker.unmount = unmountFn;
1927
+ const clearFn = () => {
1928
+ instance.frames.length = 0;
1929
+ instance.prevBuffer = null;
1930
+ instance.prevPaintedBuffer = null;
1931
+ instance.postState = createRenderPostState();
1932
+ };
1933
+ const debugFn = () => {
1934
+ console.log(debugTree(getContainerRoot(instance.container)));
1935
+ };
1936
+ const actAndRenderFn = (fn) => {
1937
+ if (!instance.mounted) return;
1938
+ withActEnvironment(() => {
1939
+ act(() => {
1940
+ fn();
1941
+ reconciler.updateContainerSync(wrapWithContexts(currentElement), instance.fiberRoot, null, null);
1942
+ reconciler.flushSyncWork();
1943
+ });
1944
+ });
1945
+ const newFrame = settleAfterCommit(doRender());
1946
+ instance.frames.push(newFrame);
1947
+ onFrame?.(newFrame, instance.prevBuffer, getRootContentHeight());
1948
+ };
1949
+ const resizeFn = (newCols, newRows) => {
1950
+ if (!instance.mounted) throw new Error("Cannot resize after unmount");
1951
+ instance.columns = newCols;
1952
+ instance.rows = newRows;
1953
+ mockStdout.columns = newCols;
1954
+ mockStdout.rows = newRows;
1955
+ updateMockTermSize?.(newCols, newRows);
1956
+ stdoutEmitter.emit("resize");
1957
+ instance.prevBuffer = null;
1958
+ instance.prevPaintedBuffer = null;
1959
+ instance.postState = createRenderPostState();
1960
+ let newFrame = doRender();
1961
+ let doRenderCount = 1;
1962
+ if (instance.maxLayoutPasses <= MAX_CONVERGENCE_PASSES) for (let flush = 0; flush < MAX_CONVERGENCE_PASSES; flush++) {
1963
+ hadReactCommit = false;
1964
+ withActEnvironment(() => {
1965
+ act(() => {
1966
+ reconciler.flushSyncWork();
1967
+ });
1968
+ });
1969
+ if (!hadReactCommit) break;
1970
+ newFrame = doRender();
1971
+ doRenderCount++;
1972
+ }
1973
+ const settled = settleAfterCommit(newFrame);
1974
+ if (settled !== newFrame) {
1975
+ newFrame = settled;
1976
+ doRenderCount++;
1977
+ }
1978
+ const prevBufferForDirtying = instance.prevBuffer;
1979
+ if (incremental && doRenderCount > 1 && prevBufferForDirtying) prevBufferForDirtying.markAllRowsDirty();
1980
+ instance.frames.push(newFrame);
1981
+ onFrame?.(newFrame, instance.prevBuffer, getRootContentHeight());
1982
+ if (debug) console.log("[silvery] Resize:", newCols, "x", newRows);
1983
+ };
1984
+ return buildApp({
1985
+ getContainer,
1986
+ getBuffer,
1987
+ sendInput,
1988
+ rerender: rerenderFn,
1989
+ unmount: unmountFn,
1990
+ waitUntilExit: () => Promise.resolve(),
1991
+ clear: clearFn,
1992
+ exitCalled: () => exitCalledFlag,
1993
+ exitError: () => exitErrorValue,
1994
+ freshRender: doFreshRender,
1995
+ debugFn,
1996
+ frames: instance.frames,
1997
+ columns: cols,
1998
+ rows,
1999
+ kittyMode,
2000
+ actAndRender: actAndRenderFn,
2001
+ resize: resizeFn,
2002
+ focusManager,
2003
+ getCursorState: cursorStore.accessors.getCursorState,
2004
+ waitForLayoutStable,
2005
+ clsMonitor
2006
+ });
2007
+ }
2008
+ /**
2009
+ * Create a Store from provider options.
2010
+ *
2011
+ * A Store is the TermDef-like environment: cols, rows, and optionally events.
2012
+ *
2013
+ * @example
2014
+ * ```tsx
2015
+ * const store = createStore({ cols: 80, rows: 24 })
2016
+ * const app = render(<App />, store)
2017
+ * ```
2018
+ */
2019
+ function createStore(options = {}) {
2020
+ const { cols = 80, rows = 24, events: eventsSource } = options;
2021
+ const store = {
2022
+ cols,
2023
+ rows
2024
+ };
2025
+ if (eventsSource) store.events = () => eventsSource;
2026
+ return store;
2027
+ }
2028
+ /**
2029
+ * Create a render function that auto-cleans previous renders.
2030
+ *
2031
+ * By default, incremental rendering is ENABLED for test renders.
2032
+ * This matches production behavior (live scheduler uses incremental)
2033
+ * and enables withDiagnostics to catch incremental vs fresh mismatches.
2034
+ *
2035
+ * **Instance reuse:** When the same renderer is called repeatedly with a
2036
+ * compatible element (no overrides that would require fresh state), the
2037
+ * returned function calls `current.rerender(element)` instead of
2038
+ * unmount+remount. This avoids paying the full React reconciler init +
2039
+ * fiberRoot creation cost on each iteration — which otherwise dominates
2040
+ * cold-render microbenchmarks. See bead `km-silvery.renderer-reuse`.
2041
+ *
2042
+ * Reuse is skipped (forcing fresh mount) when:
2043
+ * - The previous instance has been unmounted (rerender would throw)
2044
+ * - Per-render overrides are supplied that conflict with baseOpts
2045
+ * (e.g., flipping `incremental`, `maxLayoutPasses`, or `kittyMode`)
2046
+ * - The base options are a Store (store-mode renders manage their own
2047
+ * lifecycle and we conservatively force a fresh mount)
2048
+ *
2049
+ * @example
2050
+ * ```tsx
2051
+ * const render = createRenderer({ cols: 80, rows: 24 })
2052
+ * const app1 = render(<Foo />) // fresh mount
2053
+ * const app2 = render(<Bar />) // reuses app1 via rerender()
2054
+ * // app1 === app2 when reuse kicks in
2055
+ *
2056
+ * // Explicitly disable incremental if needed
2057
+ * const render2 = createRenderer({ cols: 80, rows: 24, incremental: false })
2058
+ * ```
2059
+ */
2060
+ function createRenderer(optsOrStore = {}) {
2061
+ let current = null;
2062
+ let currentReusable = false;
2063
+ const baseOpts = isStore(optsOrStore) ? {
2064
+ incremental: true,
2065
+ cols: optsOrStore.cols,
2066
+ rows: optsOrStore.rows
2067
+ } : {
2068
+ incremental: true,
2069
+ ...optsOrStore
2070
+ };
2071
+ return (element, overrides) => {
2072
+ if (current && currentReusable && canReuseInstance(overrides, baseOpts)) try {
2073
+ current.rerender(element);
2074
+ return current;
2075
+ } catch {
2076
+ currentReusable = false;
2077
+ }
2078
+ if (current) try {
2079
+ current.unmount();
2080
+ } catch {}
2081
+ let opts = baseOpts;
2082
+ if (overrides && !isStore(opts)) opts = {
2083
+ ...opts,
2084
+ ...overrides
2085
+ };
2086
+ current = render(element, opts);
2087
+ currentReusable = true;
2088
+ return current;
2089
+ };
2090
+ }
2091
+ /**
2092
+ * Can we reuse the current App instance for this call? Overrides that would
2093
+ * change reconciler/layout semantics force a fresh mount.
2094
+ */
2095
+ function canReuseInstance(overrides, baseOpts) {
2096
+ if (!overrides) return true;
2097
+ if (overrides.incremental !== void 0 && overrides.incremental !== (baseOpts.incremental ?? true)) return false;
2098
+ if (overrides.maxLayoutPasses !== void 0 && overrides.maxLayoutPasses !== baseOpts.maxLayoutPasses) return false;
2099
+ if (overrides.kittyMode !== void 0 && overrides.kittyMode !== (baseOpts.kittyMode ?? false)) return false;
2100
+ return true;
2101
+ }
2102
+ function run(app, events) {
2103
+ if (events !== void 0 && !isAsyncIterable(events)) {
2104
+ const iter = Array.isArray(events) ? events : events;
2105
+ const processedEvents = [];
2106
+ for (const key of iter) {
2107
+ app.stdin.write(keyToAnsi(key));
2108
+ processedEvents.push(key);
2109
+ }
2110
+ return {
2111
+ get text() {
2112
+ return app.text;
2113
+ },
2114
+ app,
2115
+ [Symbol.iterator]() {
2116
+ return processedEvents[Symbol.iterator]();
2117
+ }
2118
+ };
2119
+ }
2120
+ let stopped = false;
2121
+ const unmount = () => {
2122
+ stopped = true;
2123
+ app.unmount();
2124
+ };
2125
+ return {
2126
+ get text() {
2127
+ return app.text;
2128
+ },
2129
+ app,
2130
+ unmount,
2131
+ then(onfulfilled, onrejected) {
2132
+ return (async () => {
2133
+ if (events) for await (const key of events) {
2134
+ if (stopped) break;
2135
+ await app.press(key);
2136
+ }
2137
+ else await app.run();
2138
+ })().then(onfulfilled, onrejected);
2139
+ },
2140
+ [Symbol.asyncIterator]() {
2141
+ if (!events) {
2142
+ let yielded = false;
2143
+ return { async next() {
2144
+ if (yielded || stopped) return {
2145
+ done: true,
2146
+ value: void 0
2147
+ };
2148
+ yielded = true;
2149
+ return {
2150
+ done: false,
2151
+ value: app.text
2152
+ };
2153
+ } };
2154
+ }
2155
+ const iter = events[Symbol.asyncIterator]();
2156
+ return {
2157
+ async next() {
2158
+ if (stopped) return {
2159
+ done: true,
2160
+ value: void 0
2161
+ };
2162
+ const result = await iter.next();
2163
+ if (result.done) return {
2164
+ done: true,
2165
+ value: void 0
2166
+ };
2167
+ await app.press(result.value);
2168
+ return {
2169
+ done: false,
2170
+ value: app.text
2171
+ };
2172
+ },
2173
+ async return() {
2174
+ unmount();
2175
+ return {
2176
+ done: true,
2177
+ value: void 0
2178
+ };
2179
+ }
2180
+ };
2181
+ }
2182
+ };
2183
+ }
2184
+ function isAsyncIterable(value) {
2185
+ return value !== null && typeof value === "object" && Symbol.asyncIterator in value;
2186
+ }
2187
+ /**
2188
+ * Run a function with IS_REACT_ACT_ENVIRONMENT temporarily set to true.
2189
+ * This ensures act() works correctly without polluting the global scope.
2190
+ */
2191
+ function withActEnvironment(fn) {
2192
+ const prev = globalThis.IS_REACT_ACT_ENVIRONMENT;
2193
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
2194
+ try {
2195
+ fn();
2196
+ } finally {
2197
+ globalThis.IS_REACT_ACT_ENVIRONMENT = prev;
2198
+ }
2199
+ }
2200
+ /**
2201
+ * Get the number of currently active (mounted) render instances.
2202
+ * Useful for tests to verify cleanup.
2203
+ */
2204
+ function getActiveRenderCount() {
2205
+ return pruneAndCountActiveRenders();
2206
+ }
2207
+ /**
2208
+ * Format a human-readable diff when the incremental and fresh Kitty overlays
2209
+ * disagree. The overlay is a Kitty graphics protocol escape sequence emitted
2210
+ * by `realizeToKitty` (see `pipeline/backdrop/`) — it
2211
+ * places translucent "scrim" images over emoji cells inside faded regions.
2212
+ * The invariant is that the fresh and incremental paths emit byte-identical
2213
+ * strings for the same tree; any drift is a determinism bug.
2214
+ *
2215
+ * The diff surfaces the placement IDs that differ — the Kitty protocol uses
2216
+ * `p=<id>` in each placement command, so grep-friendly IDs are the quickest
2217
+ * path from "test failed" to "which cell drifted".
2218
+ */
2219
+ function formatRendererOverlayMismatch(incremental, fresh, renderNum) {
2220
+ const incPlacements = extractOverlayPlacementIds(incremental);
2221
+ const freshPlacements = extractOverlayPlacementIds(fresh);
2222
+ const incSet = new Set(incPlacements);
2223
+ const freshSet = new Set(freshPlacements);
2224
+ const onlyIncremental = incPlacements.filter((id) => !freshSet.has(id));
2225
+ const onlyFresh = freshPlacements.filter((id) => !incSet.has(id));
2226
+ const lines = [
2227
+ `[SILVERY_STRICT] Kitty overlay mismatch at render #${renderNum}`,
2228
+ ` incremental length: ${incremental.length} bytes, placements: ${incPlacements.length}`,
2229
+ ` fresh length: ${fresh.length} bytes, placements: ${freshPlacements.length}`
2230
+ ];
2231
+ if (onlyIncremental.length > 0) lines.push(` only in incremental (moved/appeared): ${onlyIncremental.join(", ")}`);
2232
+ if (onlyFresh.length > 0) lines.push(` only in fresh (missing/disappeared): ${onlyFresh.join(", ")}`);
2233
+ if (onlyIncremental.length === 0 && onlyFresh.length === 0) lines.push(` placements match — drift is in scrim-image payload or ordering`);
2234
+ const cap = 400;
2235
+ lines.push(` incremental[0..${cap}]: ${JSON.stringify(incremental.slice(0, cap))}`, ` fresh [0..${cap}]: ${JSON.stringify(fresh.slice(0, cap))}`);
2236
+ return lines.join("\n");
2237
+ }
2238
+ /**
2239
+ * Extract the list of `p=<id>` placement IDs from a Kitty overlay string, in
2240
+ * emission order. Used by the STRICT diagnostic to show which placements
2241
+ * moved/appeared/disappeared.
2242
+ */
2243
+ function extractOverlayPlacementIds(overlay) {
2244
+ const ids = [];
2245
+ const re = /p=(\d+)/g;
2246
+ let m;
2247
+ while ((m = re.exec(overlay)) !== null) ids.push(m[1]);
2248
+ return ids;
2249
+ }
2250
+ //#endregion
2251
+ //#region packages/test/src/index.tsx
2252
+ /**
2253
+ * Silvery Testing Library
2254
+ *
2255
+ * Unified App-based API for testing Silvery components.
2256
+ * Uses the actual silvery render pipeline for accurate ANSI output.
2257
+ *
2258
+ * ## Import Syntax
2259
+ *
2260
+ * ```tsx
2261
+ * import { createRenderer, bufferToText, stripAnsi } from '@silvery/test';
2262
+ * ```
2263
+ *
2264
+ * ## Auto-cleanup
2265
+ *
2266
+ * Each render() call from createRenderer automatically unmounts the previous render,
2267
+ * so you don't need explicit cleanup.
2268
+ *
2269
+ * ## Basic Testing
2270
+ *
2271
+ * @example
2272
+ * ```tsx
2273
+ * import { createRenderer } from '@silvery/test';
2274
+ * import { Text, Box } from '@silvery/ag-react';
2275
+ *
2276
+ * const render = createRenderer({ cols: 80, rows: 24 });
2277
+ *
2278
+ * test('renders text', () => {
2279
+ * const app = render(<Text>Hello</Text>);
2280
+ *
2281
+ * // Plain text (no ANSI)
2282
+ * expect(app.text).toContain('Hello');
2283
+ *
2284
+ * // Auto-refreshing locators
2285
+ * expect(app.getByText('Hello').count()).toBe(1);
2286
+ * });
2287
+ * ```
2288
+ *
2289
+ * ## Keyboard Input Testing
2290
+ *
2291
+ * @example
2292
+ * ```tsx
2293
+ * test('handles keyboard', () => {
2294
+ * const app = render(<MyComponent />);
2295
+ *
2296
+ * await app.press('j'); // Letter key
2297
+ * await app.press('ArrowUp'); // Arrow keys
2298
+ * await app.press('Escape'); // Special keys
2299
+ * await app.press('Enter'); // Enter
2300
+ *
2301
+ * expect(app.text).toContain('expected result');
2302
+ * });
2303
+ * ```
2304
+ *
2305
+ * ## Auto-refreshing Locators
2306
+ *
2307
+ * @example
2308
+ * ```tsx
2309
+ * test('locators auto-refresh', () => {
2310
+ * const app = render(<Board />);
2311
+ * const cursor = app.locator('[data-cursor]');
2312
+ *
2313
+ * expect(cursor.textContent()).toBe('item1');
2314
+ * await app.press('j');
2315
+ * expect(cursor.textContent()).toBe('item2'); // Same locator, fresh result!
2316
+ * });
2317
+ * ```
2318
+ *
2319
+ * ## Querying by ID
2320
+ *
2321
+ * Two equivalent approaches for identifying components:
2322
+ *
2323
+ * @example
2324
+ * ```tsx
2325
+ * // Option 1: id prop with #id selector (CSS-style, preferred)
2326
+ * const app = render(<Box id="sidebar">Content</Box>);
2327
+ * expect(app.locator('#sidebar').textContent()).toBe('Content');
2328
+ *
2329
+ * // Option 2: testID prop with getByTestId (React Testing Library style)
2330
+ * const app = render(<Box testID="sidebar">Content</Box>);
2331
+ * expect(app.getByTestId('sidebar').textContent()).toBe('Content');
2332
+ * ```
2333
+ */
2334
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
2335
+ await ensureDefaultLayoutEngine();
2336
+ await preloadStrictTerminalBackends({
2337
+ ghostty: true,
2338
+ initGhosttyWasm: false
2339
+ });
2340
+ /**
2341
+ * Live-termless tracker. Each `createTermless()` registers a WeakRef to the
2342
+ * returned Term; when the Term is disposed (Symbol.dispose → emulator.close
2343
+ * → backend.destroy → xterm Terminal.dispose), the GC eventually clears the
2344
+ * ref. A large number of live un-disposed terminals typically means a test
2345
+ * forgot to use `using term = createTermless(...)` — the xterm Terminal
2346
+ * (1000-line scrollback buffer) stays resident until the worker exits.
2347
+ *
2348
+ * See bead km-silvery.termless-memleak.
2349
+ */
2350
+ const liveTermlessInstances = /* @__PURE__ */ new Set();
2351
+ /** Tunable guard threshold — print a warning when we exceed this many live Terms. */
2352
+ const TERMLESS_LEAK_WARN_THRESHOLD = 128;
2353
+ /** Warning ID for the shared warnOnce latch. */
2354
+ const TERMLESS_LEAK_WARNING_ID = "silvery/test:termless-leak";
2355
+ /**
2356
+ * Prune GC'd entries from the tracker and return the count of still-live
2357
+ * instances. Cheap O(n) scan; n is small in practice.
2358
+ */
2359
+ function pruneLiveTermless() {
2360
+ let live = 0;
2361
+ for (const ref of liveTermlessInstances) if (ref.deref() === void 0) liveTermlessInstances.delete(ref);
2362
+ else live++;
2363
+ return live;
2364
+ }
2365
+ /**
2366
+ * Return the current count of live (un-GC'd) termless Term instances.
2367
+ * Exported for tests that want to assert cleanup happened.
2368
+ */
2369
+ function getActiveTermlessCount() {
2370
+ return pruneLiveTermless();
2371
+ }
2372
+ function parseTermlessTrackpadFlickProfileFromDebugLog(logText, options = {}) {
2373
+ const events = [];
2374
+ for (const line of logText.split(/\r?\n/)) {
2375
+ if (!line.startsWith("{")) continue;
2376
+ let record;
2377
+ try {
2378
+ record = JSON.parse(line);
2379
+ } catch {
2380
+ continue;
2381
+ }
2382
+ if (typeof record !== "object" || record === null) continue;
2383
+ const obj = record;
2384
+ if (obj.name !== "silvery:input-owner" || typeof obj.msg !== "string") continue;
2385
+ if (!obj.msg.startsWith("parsed mouse: action=wheel")) continue;
2386
+ const parsed = parseDebugWheelMessage(obj.msg, obj.time);
2387
+ if (parsed !== null) events.push(parsed);
2388
+ }
2389
+ if (events.length === 0) throw new Error("No silvery input-owner wheel events found in debug log");
2390
+ const first = events[0];
2391
+ const cellSize = options.cellSize ?? inferDebugWheelCellSize(first);
2392
+ const coordinateMode = options.coordinateMode ?? (cellSize === void 0 ? "cell" : "pixel");
2393
+ return {
2394
+ x: options.x ?? first.x,
2395
+ y: options.y ?? first.y,
2396
+ direction: first.delta < 0 ? "up" : "down",
2397
+ coordinateMode,
2398
+ ...cellSize === void 0 ? {} : { cellSize },
2399
+ packets: eventsToTrackpadPackets(events)
2400
+ };
2401
+ }
2402
+ function parseDebugWheelMessage(msg, time) {
2403
+ if (typeof time !== "string") return null;
2404
+ const timestamp = Date.parse(time);
2405
+ if (!Number.isFinite(timestamp)) return null;
2406
+ const coords = msg.match(/\bx=([-0-9.]+)\s+y=([-0-9.]+)\s+delta=([-0-9.]+)/);
2407
+ if (coords === null) return null;
2408
+ const x = Number(coords[1]);
2409
+ const y = Number(coords[2]);
2410
+ const delta = Number(coords[3]);
2411
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(delta)) return null;
2412
+ const sgr = msg.match(/\\u001b\[<(\d+);(\d+);(\d+)[Mm]/) ?? msg.match(new RegExp(`${String.fromCharCode(27)}\\[<(\\d+);(\\d+);(\\d+)[Mm]`));
2413
+ return {
2414
+ time: timestamp,
2415
+ x,
2416
+ y,
2417
+ delta,
2418
+ ...sgr === null ? {} : {
2419
+ button: Number(sgr[1]),
2420
+ sgrX: Number(sgr[2]),
2421
+ sgrY: Number(sgr[3])
2422
+ }
2423
+ };
2424
+ }
2425
+ function inferDebugWheelCellSize(event) {
2426
+ if (event.sgrX === void 0 || event.sgrY === void 0 || event.x <= 0 || event.y <= 0) return;
2427
+ const width = (event.sgrX - 1) / event.x;
2428
+ const height = (event.sgrY - 1) / event.y;
2429
+ if (!Number.isFinite(width) || !Number.isFinite(height)) return void 0;
2430
+ if (width <= 1 || height <= 1) return void 0;
2431
+ return {
2432
+ width: Math.round(width * 1e3) / 1e3,
2433
+ height: Math.round(height * 1e3) / 1e3
2434
+ };
2435
+ }
2436
+ function eventsToTrackpadPackets(events) {
2437
+ const firstTime = events[0].time;
2438
+ const packets = [];
2439
+ for (const event of events) {
2440
+ const atMs = Math.max(0, Math.round(event.time - firstTime));
2441
+ const last = packets[packets.length - 1];
2442
+ if (last !== void 0 && last.atMs === atMs && last.delta === event.delta && last.button === event.button && last.x === void 0 && last.y === void 0) {
2443
+ last.count = (last.count ?? 1) + 1;
2444
+ continue;
2445
+ }
2446
+ packets.push({
2447
+ atMs,
2448
+ count: 1,
2449
+ delta: event.delta,
2450
+ ...event.button === void 0 ? {} : { button: event.button }
2451
+ });
2452
+ }
2453
+ return packets;
2454
+ }
2455
+ /** Default sleep between steps in `drag()`. Short enough to be fast, long
2456
+ * enough for async event dispatch to flush. */
2457
+ const DEFAULT_DRAG_STEP_DELAY_MS = 20;
2458
+ const DEFAULT_WHEEL_CADENCE_MS = 8;
2459
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
2460
+ const DEFAULT_REFLOW_RESIDUE_MARKER = "CMUX-REFLOW-RESIDUE";
2461
+ function strictTerminalRequestsGhostty(value) {
2462
+ const normalized = value?.toLowerCase().trim();
2463
+ if (!normalized) return false;
2464
+ if (normalized === "all" || normalized === "both") return true;
2465
+ return normalized.split(",").map((part) => part.trim()).includes("ghostty");
2466
+ }
2467
+ function inferBackendFromStrictTerminal() {
2468
+ if (typeof process === "undefined") return void 0;
2469
+ return strictTerminalRequestsGhostty(process.env.SILVERY_STRICT_TERMINAL) ? "ghostty" : void 0;
2470
+ }
2471
+ function withReflowResidue(backend, marker = DEFAULT_REFLOW_RESIDUE_MARKER) {
2472
+ let armed = false;
2473
+ const encoder = new TextEncoder();
2474
+ const decoder = new TextDecoder();
2475
+ const residue = {
2476
+ marker,
2477
+ arm() {
2478
+ armed = true;
2479
+ },
2480
+ clear() {
2481
+ armed = false;
2482
+ },
2483
+ get armed() {
2484
+ return armed;
2485
+ }
2486
+ };
2487
+ const wrapped = Object.create(backend);
2488
+ Object.defineProperties(wrapped, {
2489
+ name: { get() {
2490
+ return `${backend.name}+reflow-residue`;
2491
+ } },
2492
+ init: { value(opts) {
2493
+ backend.init(opts);
2494
+ } },
2495
+ destroy: { value() {
2496
+ backend.destroy();
2497
+ } },
2498
+ feed: { value(data) {
2499
+ const text = decoder.decode(data);
2500
+ const defeatsResidue = text.includes("\x1B[2J") || text.includes("\x1B[H");
2501
+ if (defeatsResidue) armed = false;
2502
+ backend.feed(data);
2503
+ if (armed && !defeatsResidue) backend.feed(encoder.encode(`\x1b[H${marker}`));
2504
+ } },
2505
+ resize: { value(cols, rows) {
2506
+ backend.resize(cols, rows);
2507
+ armed = true;
2508
+ } }
2509
+ });
2510
+ return {
2511
+ backend: wrapped,
2512
+ residue
2513
+ };
2514
+ }
2515
+ function applyMouseModifierBits(btn, options) {
2516
+ if (options?.shift) btn += 4;
2517
+ if (options?.alt || options?.meta) btn += 8;
2518
+ if (options?.ctrl) btn += 16;
2519
+ return btn;
2520
+ }
2521
+ function sgrButtonByte(options) {
2522
+ return applyMouseModifierBits(options?.button ?? 0, options);
2523
+ }
2524
+ /**
2525
+ * Create a Term backed by a termless xterm.js emulator for full ANSI testing.
2526
+ *
2527
+ * Convenience wrapper around `createTerm(createXtermBackend(), dims)` that
2528
+ * handles the xterm.js backend import. Use with `run()` to render components
2529
+ * into a real terminal emulator in-process — no PTY, no timing issues.
2530
+ *
2531
+ * The returned Term exposes two testing conveniences:
2532
+ * - `term.mouse.*` — ergonomic mouse API (click, drag, down/up/move, wheel)
2533
+ * - `term.clipboard` — OSC 52 capture (`term.clipboard.last`, `.all`)
2534
+ *
2535
+ * **Always dispose with `using` or an explicit `term[Symbol.dispose]()`.**
2536
+ * Without disposal, each call leaks the xterm.js Terminal (~1 MB scrollback
2537
+ * per instance). This accumulated to 18-28 GB per vitest worker in CI before
2538
+ * `km-silvery.termless-memleak` was fixed.
2539
+ *
2540
+ * @example Mouse drag + clipboard capture
2541
+ * ```tsx
2542
+ * using term = createTermless({ cols: 40, rows: 10 })
2543
+ * const handle = await run(<App />, term, { selection: true, mouse: true })
2544
+ *
2545
+ * await term.mouse.drag({ from: [5, 2], to: [20, 2] })
2546
+ * expect(term.clipboard.last).toContain("selected text")
2547
+ * ```
2548
+ *
2549
+ * @example Plain click
2550
+ * ```tsx
2551
+ * await term.mouse.click(10, 5)
2552
+ * await term.mouse.click(10, 5, { shift: true })
2553
+ * ```
2554
+ *
2555
+ * @example Override capabilities (defaults-contract tests)
2556
+ * ```tsx
2557
+ * using term = createTermless({ cols: 80, rows: 24, caps: { colorLevel: '256' } })
2558
+ * ```
2559
+ *
2560
+ * Without a `caps` override, `term.caps` is populated from `defaultCaps()`
2561
+ * (truecolor / unicode / mouse). Callers that want a specific terminal
2562
+ * profile for a test — Apple_Terminal's lack of `maybeWideEmojis`, Kitty's
2563
+ * `kittyKeyboard`, etc. — can pass a `Partial<TerminalCaps>` here.
2564
+ */
2565
+ function createTermless(dims = {
2566
+ cols: 80,
2567
+ rows: 24
2568
+ }) {
2569
+ const envBackend = typeof process !== "undefined" ? process.env.TERMLESS_BACKEND : void 0;
2570
+ const choice = dims.backend ?? envBackend ?? inferBackendFromStrictTerminal() ?? "xterm";
2571
+ let backend;
2572
+ if (choice === "ghostty") backend = createGhosttyBackend();
2573
+ else if (choice === "ghostty-native") backend = createGhosttyNativeBackend();
2574
+ else if (choice === "xterm") backend = createXtermBackend();
2575
+ else throw new Error(`[silvery/test] createTermless: unknown backend "${choice}". Valid choices: "xterm", "ghostty", "ghostty-native". For a custom backend, call createTerm(backend, dims) directly.`);
2576
+ let reflowResidue;
2577
+ if (dims.reflowResidue) {
2578
+ const marker = typeof dims.reflowResidue === "object" ? dims.reflowResidue.marker : void 0;
2579
+ const wrapped = withReflowResidue(backend, marker);
2580
+ backend = wrapped.backend;
2581
+ reflowResidue = wrapped.residue;
2582
+ }
2583
+ const term = createTerm(backend, dims);
2584
+ liveTermlessInstances.add(new WeakRef(term));
2585
+ const live = pruneLiveTermless();
2586
+ if (live >= TERMLESS_LEAK_WARN_THRESHOLD) warnOnce(TERMLESS_LEAK_WARNING_ID, () => {
2587
+ console.warn(`[silvery/test] ${live} live termless Term instances detected — likely a test forgot to use \`using term = createTermless(...)\`. Each un-disposed Term retains an xterm.js Terminal with ~1 MB scrollback. See bead km-silvery.termless-memleak.`);
2588
+ });
2589
+ const sendInput = (data) => {
2590
+ term.sendInput(data);
2591
+ };
2592
+ const PIXEL_CELL_W = 8;
2593
+ const PIXEL_CELL_H = 17;
2594
+ const SGR_PIXELS_ENABLE = "\x1B[?1016h";
2595
+ let cachedPixelMode = null;
2596
+ const out = term.out;
2597
+ function isPixelMode() {
2598
+ if (cachedPixelMode !== null) return cachedPixelMode;
2599
+ if (!out) return false;
2600
+ if (out.getText().includes(SGR_PIXELS_ENABLE)) {
2601
+ cachedPixelMode = true;
2602
+ return true;
2603
+ }
2604
+ return false;
2605
+ }
2606
+ function encodeCoord(cellX, cellY, options) {
2607
+ const coordinateMode = options?.coordinateMode ?? "auto";
2608
+ if (coordinateMode === "pixel" || coordinateMode === "auto" && isPixelMode()) {
2609
+ const cellSize = options?.cellSize ?? {
2610
+ width: PIXEL_CELL_W,
2611
+ height: PIXEL_CELL_H
2612
+ };
2613
+ const pixelX = cellX * cellSize.width;
2614
+ const pixelY = cellY * cellSize.height;
2615
+ return {
2616
+ sgrX: Math.round(pixelX) + 1,
2617
+ sgrY: Math.round(pixelY) + 1
2618
+ };
2619
+ }
2620
+ return {
2621
+ sgrX: cellX + 1,
2622
+ sgrY: cellY + 1
2623
+ };
2624
+ }
2625
+ function packetEventCount(packet) {
2626
+ const count = packet.count ?? packet.events ?? Math.abs(packet.delta ?? 1);
2627
+ return Math.max(0, Math.floor(count));
2628
+ }
2629
+ function packetDelta(packet, profile) {
2630
+ if (packet.delta !== void 0) return packet.delta;
2631
+ return (packet.direction ?? profile.direction ?? "up") === "up" ? -1 : 1;
2632
+ }
2633
+ function packetButton(packet, profile) {
2634
+ if (packet.button !== void 0) return packet.button;
2635
+ return packetDelta(packet, profile) < 0 ? 64 : 65;
2636
+ }
2637
+ function groupTrackpadPackets(packets) {
2638
+ const sorted = packets.map((packet, order) => ({
2639
+ packet,
2640
+ order
2641
+ })).sort((a, b) => a.packet.atMs - b.packet.atMs || a.order - b.order);
2642
+ const groups = [];
2643
+ for (const { packet } of sorted) {
2644
+ const atMs = Math.max(0, Math.round(packet.atMs));
2645
+ const last = groups[groups.length - 1];
2646
+ if (last && last.atMs === atMs) last.packets.push(packet);
2647
+ else groups.push({
2648
+ atMs,
2649
+ packets: [packet]
2650
+ });
2651
+ }
2652
+ return groups;
2653
+ }
2654
+ const mouse = {
2655
+ async down(x, y, options) {
2656
+ const btn = sgrButtonByte(options);
2657
+ const { sgrX, sgrY } = encodeCoord(x, y);
2658
+ sendInput(`\x1b[<${btn};${sgrX};${sgrY}M`);
2659
+ await Promise.resolve();
2660
+ },
2661
+ async up(x, y, options) {
2662
+ const btn = sgrButtonByte(options);
2663
+ const { sgrX, sgrY } = encodeCoord(x, y);
2664
+ sendInput(`\x1b[<${btn};${sgrX};${sgrY}m`);
2665
+ await Promise.resolve();
2666
+ },
2667
+ async move(x, y, options) {
2668
+ const btn = 32 + sgrButtonByte(options);
2669
+ const { sgrX, sgrY } = encodeCoord(x, y);
2670
+ sendInput(`\x1b[<${btn};${sgrX};${sgrY}M`);
2671
+ await Promise.resolve();
2672
+ },
2673
+ async click(x, y, options) {
2674
+ await mouse.down(x, y, options);
2675
+ await mouse.up(x, y, options);
2676
+ },
2677
+ async dblclick(x, y, options) {
2678
+ await mouse.click(x, y, options);
2679
+ await mouse.click(x, y, options);
2680
+ },
2681
+ async drag({ from, to, via, options, stepDelay = DEFAULT_DRAG_STEP_DELAY_MS }) {
2682
+ await mouse.down(from[0], from[1], options);
2683
+ await sleep(stepDelay);
2684
+ if (via) for (const [x, y] of via) {
2685
+ await mouse.move(x, y, options);
2686
+ await sleep(stepDelay);
2687
+ }
2688
+ await mouse.move(to[0], to[1], options);
2689
+ await sleep(stepDelay);
2690
+ await mouse.up(to[0], to[1], options);
2691
+ },
2692
+ async wheel(x, y, deltaOrOptions, maybeOptions) {
2693
+ const options = typeof deltaOrOptions === "number" ? maybeOptions : deltaOrOptions;
2694
+ const deltaX = typeof deltaOrOptions === "number" ? options?.deltaX ?? 0 : deltaOrOptions.deltaX ?? 0;
2695
+ const deltaY = typeof deltaOrOptions === "number" ? deltaOrOptions : deltaOrOptions.deltaY ?? deltaOrOptions.lines ?? 0;
2696
+ const { sgrX, sgrY } = encodeCoord(x, y, {
2697
+ coordinateMode: options?.coordinateMode,
2698
+ cellSize: options?.cellSize
2699
+ });
2700
+ const reports = [];
2701
+ const appendReports = (baseButton, count) => {
2702
+ const button = applyMouseModifierBits(baseButton, options);
2703
+ for (let i = 0; i < count; i++) reports.push(`\x1b[<${button};${sgrX};${sgrY}M`);
2704
+ };
2705
+ const horizontalCount = Math.max(0, Math.floor(Math.abs(deltaX)));
2706
+ if (horizontalCount > 0) appendReports(deltaX < 0 ? 66 : 67, horizontalCount);
2707
+ const verticalCount = Math.max(0, Math.floor(Math.abs(deltaY)));
2708
+ if (verticalCount > 0) appendReports(deltaY < 0 ? 64 : 65, verticalCount);
2709
+ const cadenceMs = options?.cadenceMs ?? DEFAULT_WHEEL_CADENCE_MS;
2710
+ for (let index = 0; index < reports.length; index++) {
2711
+ sendInput(reports[index]);
2712
+ await Promise.resolve();
2713
+ if (cadenceMs > 0 && index < reports.length - 1) await sleep(cadenceMs);
2714
+ }
2715
+ },
2716
+ async trackpadFlick(profile, options) {
2717
+ const groups = groupTrackpadPackets(profile.packets);
2718
+ const deliveredGroups = [];
2719
+ let lastAtMs = 0;
2720
+ let eventCount = 0;
2721
+ for (let index = 0; index < groups.length; index++) {
2722
+ const group = groups[index];
2723
+ const waitMs = Math.max(0, group.atMs - lastAtMs);
2724
+ if (waitMs > 0) await sleep(waitMs);
2725
+ lastAtMs = group.atMs;
2726
+ let chunk = "";
2727
+ let deltaSum = 0;
2728
+ let groupEventCount = 0;
2729
+ for (const packet of group.packets) {
2730
+ const count = packetEventCount(packet);
2731
+ if (count === 0) continue;
2732
+ const delta = packetDelta(packet, profile);
2733
+ const button = packetButton(packet, profile);
2734
+ const { sgrX, sgrY } = encodeCoord(packet.x ?? profile.x, packet.y ?? profile.y, {
2735
+ coordinateMode: profile.coordinateMode,
2736
+ cellSize: profile.cellSize
2737
+ });
2738
+ chunk += Array.from({ length: count }, () => `\x1b[<${button};${sgrX};${sgrY}M`).join("");
2739
+ deltaSum += delta * count;
2740
+ groupEventCount += count;
2741
+ }
2742
+ if (chunk.length === 0) continue;
2743
+ sendInput(chunk);
2744
+ eventCount += groupEventCount;
2745
+ await Promise.resolve();
2746
+ const deliveredGroup = {
2747
+ index,
2748
+ atMs: group.atMs,
2749
+ packetCount: group.packets.length,
2750
+ eventCount,
2751
+ deltaSum
2752
+ };
2753
+ deliveredGroups.push(deliveredGroup);
2754
+ await options?.afterGroup?.(deliveredGroup);
2755
+ }
2756
+ if (profile.settleMs !== void 0 && profile.settleMs > 0) await sleep(profile.settleMs);
2757
+ return {
2758
+ eventCount,
2759
+ durationMs: lastAtMs,
2760
+ groups: deliveredGroups
2761
+ };
2762
+ }
2763
+ };
2764
+ const clipboardPayloads = [];
2765
+ const emulator = term._emulator;
2766
+ if (emulator) {
2767
+ const origFeed = emulator.feed.bind(emulator);
2768
+ const OSC52_RE = /* @__PURE__ */ new RegExp("\\x1b\\]52;c;([A-Za-z0-9+/=]*)\\x07", "g");
2769
+ emulator.feed = (data) => {
2770
+ OSC52_RE.lastIndex = 0;
2771
+ let match;
2772
+ while ((match = OSC52_RE.exec(data)) !== null) try {
2773
+ const decoded = globalThis.Buffer.from(match[1], "base64").toString("utf-8");
2774
+ clipboardPayloads.push(decoded);
2775
+ } catch {}
2776
+ origFeed(data);
2777
+ };
2778
+ }
2779
+ const clipboard = {
2780
+ get last() {
2781
+ return clipboardPayloads.length > 0 ? clipboardPayloads[clipboardPayloads.length - 1] : null;
2782
+ },
2783
+ get all() {
2784
+ return clipboardPayloads;
2785
+ },
2786
+ clear() {
2787
+ clipboardPayloads.length = 0;
2788
+ }
2789
+ };
2790
+ Object.defineProperty(term, "mouse", {
2791
+ value: mouse,
2792
+ enumerable: true
2793
+ });
2794
+ Object.defineProperty(term, "clipboard", {
2795
+ value: clipboard,
2796
+ enumerable: true
2797
+ });
2798
+ if (reflowResidue) Object.defineProperty(term, "reflowResidue", {
2799
+ value: reflowResidue,
2800
+ enumerable: true
2801
+ });
2802
+ return term;
2803
+ }
2804
+ /**
2805
+ * Normalize frame output for comparison.
2806
+ * - Strips ANSI codes
2807
+ * - Trims trailing whitespace from lines
2808
+ * - Removes empty trailing lines
2809
+ */
2810
+ function normalizeFrame(frame) {
2811
+ return stripAnsi(frame).split("\n").map((line) => line.trimEnd()).join("\n").trimEnd();
2812
+ }
2813
+ /**
2814
+ * Wait for a condition to be true, polling at intervals.
2815
+ * Useful for waiting for async state updates.
2816
+ */
2817
+ async function waitFor(condition, { timeout = 1e3, interval = 10 } = {}) {
2818
+ const start = Date.now();
2819
+ while (!condition()) {
2820
+ if (Date.now() - start > timeout) throw new Error(`waitFor timed out after ${timeout}ms`);
2821
+ await new Promise((resolve) => {
2822
+ setTimeout(resolve, interval);
2823
+ });
2824
+ }
2825
+ }
2826
+ //#endregion
2827
+ export { CLS_STRICT_MIN_TIER, CLS_STRICT_SLUG, CODE_TO_KEY, UnexpectedLayoutShiftError, bufferToHTML, bufferToStyledText, bufferToText, compareBuffers, createAutoLocator, createRenderer, createStore, createTermless, debugTree, diagnosticScheme, diagnosticTheme, ensureEngine, formatMismatch, formatSelectableCells, getActiveRenderCount, getActiveTermlessCount, isClsStrictEnabled, keyToAnsi, keyToKittyAnsi, normalizeFrame, parseTermlessTrackpadFlickProfileFromDebugLog, readCellRow, readCellSnapshot, recordFrames, render, run, stripAnsi, waitFor };
2828
+
2829
+ //# sourceMappingURL=test.mjs.map