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