@textjs/app 0.0.0-stage → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (335) hide show
  1. package/README.md +34 -2
  2. package/bin/text.js +29 -0
  3. package/bin/text.ts +13 -0
  4. package/dist/.tsbuildinfo +1 -0
  5. package/dist/cli/build.d.ts +39 -0
  6. package/dist/cli/build.d.ts.map +1 -0
  7. package/dist/cli/build.js +157 -0
  8. package/dist/cli/build.js.map +1 -0
  9. package/dist/cli/config.d.ts +22 -0
  10. package/dist/cli/config.d.ts.map +1 -0
  11. package/dist/cli/config.js +73 -0
  12. package/dist/cli/config.js.map +1 -0
  13. package/dist/cli/dev.d.ts +12 -0
  14. package/dist/cli/dev.d.ts.map +1 -0
  15. package/dist/cli/dev.js +49 -0
  16. package/dist/cli/dev.js.map +1 -0
  17. package/dist/cli/index.d.ts +26 -0
  18. package/dist/cli/index.d.ts.map +1 -0
  19. package/dist/cli/index.js +192 -0
  20. package/dist/cli/index.js.map +1 -0
  21. package/dist/cli/run.d.ts +31 -0
  22. package/dist/cli/run.d.ts.map +1 -0
  23. package/dist/cli/run.js +111 -0
  24. package/dist/cli/run.js.map +1 -0
  25. package/dist/cli/start.d.ts +14 -0
  26. package/dist/cli/start.d.ts.map +1 -0
  27. package/dist/cli/start.js +183 -0
  28. package/dist/cli/start.js.map +1 -0
  29. package/dist/cli/ui.d.ts +35 -0
  30. package/dist/cli/ui.d.ts.map +1 -0
  31. package/dist/cli/ui.js +73 -0
  32. package/dist/cli/ui.js.map +1 -0
  33. package/dist/client.d.ts +34 -0
  34. package/dist/client.d.ts.map +1 -0
  35. package/dist/client.js +93 -0
  36. package/dist/client.js.map +1 -0
  37. package/dist/config.d.ts +143 -0
  38. package/dist/config.d.ts.map +1 -0
  39. package/dist/config.js +28 -0
  40. package/dist/config.js.map +1 -0
  41. package/dist/context.d.ts +44 -0
  42. package/dist/context.d.ts.map +1 -0
  43. package/dist/context.js +26 -0
  44. package/dist/context.js.map +1 -0
  45. package/dist/dev-errors.d.ts +68 -0
  46. package/dist/dev-errors.d.ts.map +1 -0
  47. package/dist/dev-errors.js +121 -0
  48. package/dist/dev-errors.js.map +1 -0
  49. package/dist/devtools.d.ts +64 -0
  50. package/dist/devtools.d.ts.map +1 -0
  51. package/dist/devtools.js +186 -0
  52. package/dist/devtools.js.map +1 -0
  53. package/dist/frame-mirror.d.ts +19 -0
  54. package/dist/frame-mirror.d.ts.map +1 -0
  55. package/dist/frame-mirror.js +141 -0
  56. package/dist/frame-mirror.js.map +1 -0
  57. package/dist/frame-protocol.d.ts +175 -0
  58. package/dist/frame-protocol.d.ts.map +1 -0
  59. package/dist/frame-protocol.js +369 -0
  60. package/dist/frame-protocol.js.map +1 -0
  61. package/dist/history.d.ts +54 -0
  62. package/dist/history.d.ts.map +1 -0
  63. package/dist/history.js +152 -0
  64. package/dist/history.js.map +1 -0
  65. package/dist/index.d.ts +9 -0
  66. package/dist/index.d.ts.map +1 -0
  67. package/dist/index.js +8 -0
  68. package/dist/index.js.map +1 -0
  69. package/dist/link.d.ts +32 -0
  70. package/dist/link.d.ts.map +1 -0
  71. package/dist/link.js +54 -0
  72. package/dist/link.js.map +1 -0
  73. package/dist/live-player.d.ts +83 -0
  74. package/dist/live-player.d.ts.map +1 -0
  75. package/dist/live-player.js +413 -0
  76. package/dist/live-player.js.map +1 -0
  77. package/dist/live-protocol.d.ts +132 -0
  78. package/dist/live-protocol.d.ts.map +1 -0
  79. package/dist/live-protocol.js +278 -0
  80. package/dist/live-protocol.js.map +1 -0
  81. package/dist/live-stub.d.ts +10 -0
  82. package/dist/live-stub.d.ts.map +1 -0
  83. package/dist/live-stub.js +32 -0
  84. package/dist/live-stub.js.map +1 -0
  85. package/dist/mcp/cli.d.ts +22 -0
  86. package/dist/mcp/cli.d.ts.map +1 -0
  87. package/dist/mcp/cli.js +177 -0
  88. package/dist/mcp/cli.js.map +1 -0
  89. package/dist/mcp/frame.d.ts +58 -0
  90. package/dist/mcp/frame.d.ts.map +1 -0
  91. package/dist/mcp/frame.js +169 -0
  92. package/dist/mcp/frame.js.map +1 -0
  93. package/dist/mcp/index.d.ts +15 -0
  94. package/dist/mcp/index.d.ts.map +1 -0
  95. package/dist/mcp/index.js +15 -0
  96. package/dist/mcp/index.js.map +1 -0
  97. package/dist/mcp/inspect.d.ts +95 -0
  98. package/dist/mcp/inspect.d.ts.map +1 -0
  99. package/dist/mcp/inspect.js +386 -0
  100. package/dist/mcp/inspect.js.map +1 -0
  101. package/dist/mcp/keys.d.ts +20 -0
  102. package/dist/mcp/keys.d.ts.map +1 -0
  103. package/dist/mcp/keys.js +87 -0
  104. package/dist/mcp/keys.js.map +1 -0
  105. package/dist/mcp/logs.d.ts +63 -0
  106. package/dist/mcp/logs.d.ts.map +1 -0
  107. package/dist/mcp/logs.js +217 -0
  108. package/dist/mcp/logs.js.map +1 -0
  109. package/dist/mcp/server.d.ts +64 -0
  110. package/dist/mcp/server.d.ts.map +1 -0
  111. package/dist/mcp/server.js +485 -0
  112. package/dist/mcp/server.js.map +1 -0
  113. package/dist/mcp/session.d.ts +230 -0
  114. package/dist/mcp/session.d.ts.map +1 -0
  115. package/dist/mcp/session.js +742 -0
  116. package/dist/mcp/session.js.map +1 -0
  117. package/dist/navigation-errors.d.ts +28 -0
  118. package/dist/navigation-errors.d.ts.map +1 -0
  119. package/dist/navigation-errors.js +43 -0
  120. package/dist/navigation-errors.js.map +1 -0
  121. package/dist/navigation.d.ts +21 -0
  122. package/dist/navigation.d.ts.map +1 -0
  123. package/dist/navigation.js +33 -0
  124. package/dist/navigation.js.map +1 -0
  125. package/dist/router.d.ts +76 -0
  126. package/dist/router.d.ts.map +1 -0
  127. package/dist/router.js +296 -0
  128. package/dist/router.js.map +1 -0
  129. package/dist/routing.d.ts +101 -0
  130. package/dist/routing.d.ts.map +1 -0
  131. package/dist/routing.js +286 -0
  132. package/dist/routing.js.map +1 -0
  133. package/dist/server/adapters/build-step.d.ts +34 -0
  134. package/dist/server/adapters/build-step.d.ts.map +1 -0
  135. package/dist/server/adapters/build-step.js +112 -0
  136. package/dist/server/adapters/build-step.js.map +1 -0
  137. package/dist/server/adapters/bun.d.ts +8 -0
  138. package/dist/server/adapters/bun.d.ts.map +1 -0
  139. package/dist/server/adapters/bun.js +89 -0
  140. package/dist/server/adapters/bun.js.map +1 -0
  141. package/dist/server/adapters/bundle.d.ts +21 -0
  142. package/dist/server/adapters/bundle.d.ts.map +1 -0
  143. package/dist/server/adapters/bundle.js +86 -0
  144. package/dist/server/adapters/bundle.js.map +1 -0
  145. package/dist/server/adapters/cloudflare.d.ts +29 -0
  146. package/dist/server/adapters/cloudflare.d.ts.map +1 -0
  147. package/dist/server/adapters/cloudflare.js +105 -0
  148. package/dist/server/adapters/cloudflare.js.map +1 -0
  149. package/dist/server/adapters/index.d.ts +10 -0
  150. package/dist/server/adapters/index.d.ts.map +1 -0
  151. package/dist/server/adapters/index.js +24 -0
  152. package/dist/server/adapters/index.js.map +1 -0
  153. package/dist/server/adapters/runtime-node.d.ts +16 -0
  154. package/dist/server/adapters/runtime-node.d.ts.map +1 -0
  155. package/dist/server/adapters/runtime-node.js +29 -0
  156. package/dist/server/adapters/runtime-node.js.map +1 -0
  157. package/dist/server/adapters/runtime.d.ts +42 -0
  158. package/dist/server/adapters/runtime.d.ts.map +1 -0
  159. package/dist/server/adapters/runtime.js +115 -0
  160. package/dist/server/adapters/runtime.js.map +1 -0
  161. package/dist/server/adapters/shared.d.ts +66 -0
  162. package/dist/server/adapters/shared.d.ts.map +1 -0
  163. package/dist/server/adapters/shared.js +124 -0
  164. package/dist/server/adapters/shared.js.map +1 -0
  165. package/dist/server/adapters/static.d.ts +9 -0
  166. package/dist/server/adapters/static.d.ts.map +1 -0
  167. package/dist/server/adapters/static.js +70 -0
  168. package/dist/server/adapters/static.js.map +1 -0
  169. package/dist/server/adapters/vercel.d.ts +43 -0
  170. package/dist/server/adapters/vercel.d.ts.map +1 -0
  171. package/dist/server/adapters/vercel.js +119 -0
  172. package/dist/server/adapters/vercel.js.map +1 -0
  173. package/dist/server/bun-jsx.d.ts +18 -0
  174. package/dist/server/bun-jsx.d.ts.map +1 -0
  175. package/dist/server/bun-jsx.js +56 -0
  176. package/dist/server/bun-jsx.js.map +1 -0
  177. package/dist/server/cache-route.d.ts +38 -0
  178. package/dist/server/cache-route.d.ts.map +1 -0
  179. package/dist/server/cache-route.js +109 -0
  180. package/dist/server/cache-route.js.map +1 -0
  181. package/dist/server/cache.d.ts +131 -0
  182. package/dist/server/cache.d.ts.map +1 -0
  183. package/dist/server/cache.js +409 -0
  184. package/dist/server/cache.js.map +1 -0
  185. package/dist/server/frames.d.ts +45 -0
  186. package/dist/server/frames.d.ts.map +1 -0
  187. package/dist/server/frames.js +294 -0
  188. package/dist/server/frames.js.map +1 -0
  189. package/dist/server/http.d.ts +80 -0
  190. package/dist/server/http.d.ts.map +1 -0
  191. package/dist/server/http.js +453 -0
  192. package/dist/server/http.js.map +1 -0
  193. package/dist/server/jsx-dev-runtime.d.ts +5 -0
  194. package/dist/server/jsx-dev-runtime.d.ts.map +1 -0
  195. package/dist/server/jsx-dev-runtime.js +6 -0
  196. package/dist/server/jsx-dev-runtime.js.map +1 -0
  197. package/dist/server/jsx-runtime.d.ts +10 -0
  198. package/dist/server/jsx-runtime.d.ts.map +1 -0
  199. package/dist/server/jsx-runtime.js +11 -0
  200. package/dist/server/jsx-runtime.js.map +1 -0
  201. package/dist/server/live-app.d.ts +87 -0
  202. package/dist/server/live-app.d.ts.map +1 -0
  203. package/dist/server/live-app.js +307 -0
  204. package/dist/server/live-app.js.map +1 -0
  205. package/dist/server/live-build.d.ts +70 -0
  206. package/dist/server/live-build.d.ts.map +1 -0
  207. package/dist/server/live-build.js +222 -0
  208. package/dist/server/live-build.js.map +1 -0
  209. package/dist/server/live-dev.d.ts +62 -0
  210. package/dist/server/live-dev.d.ts.map +1 -0
  211. package/dist/server/live-dev.js +313 -0
  212. package/dist/server/live-dev.js.map +1 -0
  213. package/dist/server/live-manifest.d.ts +23 -0
  214. package/dist/server/live-manifest.d.ts.map +1 -0
  215. package/dist/server/live-manifest.js +7 -0
  216. package/dist/server/live-manifest.js.map +1 -0
  217. package/dist/server/live-prod.d.ts +30 -0
  218. package/dist/server/live-prod.d.ts.map +1 -0
  219. package/dist/server/live-prod.js +97 -0
  220. package/dist/server/live-prod.js.map +1 -0
  221. package/dist/server/live-routes.d.ts +72 -0
  222. package/dist/server/live-routes.d.ts.map +1 -0
  223. package/dist/server/live-routes.js +274 -0
  224. package/dist/server/live-routes.js.map +1 -0
  225. package/dist/server/live-web.d.ts +16 -0
  226. package/dist/server/live-web.d.ts.map +1 -0
  227. package/dist/server/live-web.js +159 -0
  228. package/dist/server/live-web.js.map +1 -0
  229. package/dist/server/live.d.ts +138 -0
  230. package/dist/server/live.d.ts.map +1 -0
  231. package/dist/server/live.js +542 -0
  232. package/dist/server/live.js.map +1 -0
  233. package/dist/server/middleware-node.d.ts +48 -0
  234. package/dist/server/middleware-node.d.ts.map +1 -0
  235. package/dist/server/middleware-node.js +167 -0
  236. package/dist/server/middleware-node.js.map +1 -0
  237. package/dist/server/middleware-runtime.d.ts +97 -0
  238. package/dist/server/middleware-runtime.d.ts.map +1 -0
  239. package/dist/server/middleware-runtime.js +447 -0
  240. package/dist/server/middleware-runtime.js.map +1 -0
  241. package/dist/server/middleware.d.ts +145 -0
  242. package/dist/server/middleware.d.ts.map +1 -0
  243. package/dist/server/middleware.js +365 -0
  244. package/dist/server/middleware.js.map +1 -0
  245. package/dist/server/ppr.d.ts +22 -0
  246. package/dist/server/ppr.d.ts.map +1 -0
  247. package/dist/server/ppr.js +58 -0
  248. package/dist/server/ppr.js.map +1 -0
  249. package/dist/server/scan.d.ts +48 -0
  250. package/dist/server/scan.d.ts.map +1 -0
  251. package/dist/server/scan.js +161 -0
  252. package/dist/server/scan.js.map +1 -0
  253. package/dist/server/server-components.d.ts +65 -0
  254. package/dist/server/server-components.d.ts.map +1 -0
  255. package/dist/server/server-components.js +215 -0
  256. package/dist/server/server-components.js.map +1 -0
  257. package/dist/terminal.d.ts +5 -0
  258. package/dist/terminal.d.ts.map +1 -0
  259. package/dist/terminal.js +5 -0
  260. package/dist/terminal.js.map +1 -0
  261. package/dist/testing.d.ts +5 -0
  262. package/dist/testing.d.ts.map +1 -0
  263. package/dist/testing.js +5 -0
  264. package/dist/testing.js.map +1 -0
  265. package/dist/vite-plugin.d.ts +51 -0
  266. package/dist/vite-plugin.d.ts.map +1 -0
  267. package/dist/vite-plugin.js +403 -0
  268. package/dist/vite-plugin.js.map +1 -0
  269. package/package.json +130 -4
  270. package/src/cli/build.ts +182 -0
  271. package/src/cli/config.ts +72 -0
  272. package/src/cli/dev.ts +54 -0
  273. package/src/cli/index.ts +185 -0
  274. package/src/cli/run.ts +124 -0
  275. package/src/cli/start.ts +194 -0
  276. package/src/cli/ui.ts +93 -0
  277. package/src/client.tsx +122 -0
  278. package/src/config.ts +155 -0
  279. package/src/context.ts +69 -0
  280. package/src/dev-errors.ts +167 -0
  281. package/src/devtools.tsx +419 -0
  282. package/src/frame-mirror.ts +145 -0
  283. package/src/frame-protocol.ts +435 -0
  284. package/src/history.ts +201 -0
  285. package/src/index.ts +9 -0
  286. package/src/link.tsx +108 -0
  287. package/src/live-player.ts +449 -0
  288. package/src/live-protocol.ts +317 -0
  289. package/src/live-stub.tsx +32 -0
  290. package/src/mcp/cli.ts +188 -0
  291. package/src/mcp/frame.ts +224 -0
  292. package/src/mcp/index.ts +15 -0
  293. package/src/mcp/inspect.ts +445 -0
  294. package/src/mcp/keys.ts +86 -0
  295. package/src/mcp/logs.ts +225 -0
  296. package/src/mcp/server.ts +595 -0
  297. package/src/mcp/session.ts +821 -0
  298. package/src/navigation-errors.ts +60 -0
  299. package/src/navigation.ts +44 -0
  300. package/src/router.tsx +497 -0
  301. package/src/routing.ts +344 -0
  302. package/src/server/adapters/build-step.ts +135 -0
  303. package/src/server/adapters/bun.ts +92 -0
  304. package/src/server/adapters/bundle.ts +97 -0
  305. package/src/server/adapters/cloudflare.ts +111 -0
  306. package/src/server/adapters/index.ts +29 -0
  307. package/src/server/adapters/runtime-node.ts +34 -0
  308. package/src/server/adapters/runtime.ts +145 -0
  309. package/src/server/adapters/shared.ts +159 -0
  310. package/src/server/adapters/static.ts +68 -0
  311. package/src/server/adapters/vercel.ts +149 -0
  312. package/src/server/bun-jsx.ts +65 -0
  313. package/src/server/cache-route.ts +139 -0
  314. package/src/server/cache.ts +498 -0
  315. package/src/server/frames.ts +326 -0
  316. package/src/server/http.ts +467 -0
  317. package/src/server/jsx-dev-runtime.ts +9 -0
  318. package/src/server/jsx-runtime.ts +13 -0
  319. package/src/server/live-app.tsx +374 -0
  320. package/src/server/live-build.ts +258 -0
  321. package/src/server/live-dev.ts +357 -0
  322. package/src/server/live-manifest.ts +25 -0
  323. package/src/server/live-prod.ts +120 -0
  324. package/src/server/live-routes.ts +298 -0
  325. package/src/server/live-web.ts +170 -0
  326. package/src/server/live.ts +551 -0
  327. package/src/server/middleware-node.ts +168 -0
  328. package/src/server/middleware-runtime.ts +478 -0
  329. package/src/server/middleware.ts +451 -0
  330. package/src/server/ppr.ts +62 -0
  331. package/src/server/scan.ts +190 -0
  332. package/src/server/server-components.ts +224 -0
  333. package/src/terminal.ts +5 -0
  334. package/src/testing.ts +5 -0
  335. package/src/vite-plugin.ts +413 -0
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Frame extraction for agents: plain text, styled runs and text search, all in
3
+ * cell coordinates (wide graphemes such as CJK and emoji occupy two columns).
4
+ */
5
+
6
+ import {
7
+ ATTR_BOLD,
8
+ ATTR_DIM,
9
+ ATTR_INVERSE,
10
+ ATTR_ITALIC,
11
+ ATTR_STRIKETHROUGH,
12
+ ATTR_UNDERLINE,
13
+ ATTR_WIDE_CONT,
14
+ type CellBuffer,
15
+ type PackedColor,
16
+ } from "@textjs/core";
17
+
18
+ export interface Region {
19
+ x: number;
20
+ y: number;
21
+ width: number;
22
+ height: number;
23
+ }
24
+
25
+ export interface StyledRun {
26
+ /** Column of the first cell. */
27
+ x: number;
28
+ /** Width in cells. */
29
+ width: number;
30
+ text: string;
31
+ /** `#rrggbb` (or `#rrggbbaa` when translucent); `null` = terminal default. */
32
+ fg: string | null;
33
+ bg: string | null;
34
+ /** Subset of `bold dim italic underline strikethrough inverse`. */
35
+ attrs?: string[];
36
+ }
37
+
38
+ export interface StyledRow {
39
+ y: number;
40
+ runs: StyledRun[];
41
+ }
42
+
43
+ export interface TextMatch {
44
+ x: number;
45
+ y: number;
46
+ width: number;
47
+ height: number;
48
+ /** The full (trimmed) row the match is on. */
49
+ line: string;
50
+ }
51
+
52
+ const ATTR_NAMES: ReadonlyArray<[number, string]> = [
53
+ [ATTR_BOLD, "bold"],
54
+ [ATTR_DIM, "dim"],
55
+ [ATTR_ITALIC, "italic"],
56
+ [ATTR_UNDERLINE, "underline"],
57
+ [ATTR_STRIKETHROUGH, "strikethrough"],
58
+ [ATTR_INVERSE, "inverse"],
59
+ ];
60
+
61
+ const STYLE_MASK = ATTR_BOLD | ATTR_DIM | ATTR_ITALIC | ATTR_UNDERLINE | ATTR_STRIKETHROUGH | ATTR_INVERSE;
62
+
63
+ export function colorHex(c: PackedColor): string | null {
64
+ if (c === 0) return null;
65
+ const v = c >>> 0;
66
+ const rgb = (v >>> 8).toString(16).padStart(6, "0");
67
+ const a = v & 0xff;
68
+ return a === 0xff ? `#${rgb}` : `#${rgb}${a.toString(16).padStart(2, "0")}`;
69
+ }
70
+
71
+ export function attrNames(attrs: number): string[] {
72
+ const out: string[] = [];
73
+ for (const [bit, name] of ATTR_NAMES) if ((attrs & bit) !== 0) out.push(name);
74
+ return out;
75
+ }
76
+
77
+ /** Clamp `region` to the buffer (the whole buffer when omitted). */
78
+ export function clampRegion(buffer: CellBuffer, region?: Partial<Region>): Region {
79
+ const x = Math.max(0, Math.min(buffer.width, Math.floor(region?.x ?? 0)));
80
+ const y = Math.max(0, Math.min(buffer.height, Math.floor(region?.y ?? 0)));
81
+ const width = Math.max(0, Math.min(buffer.width - x, Math.floor(region?.width ?? buffer.width - x)));
82
+ const height = Math.max(0, Math.min(buffer.height - y, Math.floor(region?.height ?? buffer.height - y)));
83
+ return { x, y, width, height };
84
+ }
85
+
86
+ /**
87
+ * Display text of a cell: empty and control cells read as a space, and so
88
+ * does U+00A0 (fields draw their block cursor on a no-break space), so
89
+ * `find` / `wait_for` match what is visible.
90
+ */
91
+ function cellText(ch: string): string {
92
+ return ch === "" || ch === "\u00a0" || ch.charCodeAt(0) < 0x20 ? " " : ch;
93
+ }
94
+
95
+ interface RowCells {
96
+ text: string;
97
+ /** `cols[i]` = column of the grapheme starting at string index `i` (UTF-16 units); `cols[text.length]` = end column. */
98
+ cols: Int32Array;
99
+ }
100
+
101
+ /** A row slice as text plus a string-index → column map. */
102
+ function rowCells(buffer: CellBuffer, y: number, x0: number, x1: number): RowCells {
103
+ let text = "";
104
+ const colsList: number[] = [];
105
+ for (let x = x0; x < x1; x++) {
106
+ const cell = buffer.getCell(x, y);
107
+ if (!cell) break;
108
+ if ((cell.attrs & ATTR_WIDE_CONT) !== 0) {
109
+ // A wide grapheme cut by the region's left edge renders as a blank.
110
+ if (x === x0) {
111
+ colsList.push(x);
112
+ text += " ";
113
+ }
114
+ continue;
115
+ }
116
+ // The lead half of a wide glyph cut by the right edge must occupy one
117
+ // blank column (the symmetric case of a continuation at the left edge).
118
+ const clipped = x === x1 - 1 && ((buffer.getCell(x + 1, y)?.attrs ?? 0) & ATTR_WIDE_CONT) !== 0;
119
+ const ch = clipped ? " " : cellText(cell.char);
120
+ for (let i = 0; i < ch.length; i++) colsList.push(x);
121
+ text += ch;
122
+ }
123
+ colsList.push(x1);
124
+ return { text, cols: Int32Array.from(colsList) };
125
+ }
126
+
127
+ /** Plain text of `region`, one line per row (trailing spaces and blank rows trimmed). */
128
+ export function frameText(buffer: CellBuffer, region?: Partial<Region>, trim = true): string {
129
+ const r = clampRegion(buffer, region);
130
+ const lines: string[] = [];
131
+ for (let y = r.y; y < r.y + r.height; y++) {
132
+ const text = rowCells(buffer, y, r.x, r.x + r.width).text;
133
+ lines.push(trim ? text.trimEnd() : text);
134
+ }
135
+ if (trim) while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
136
+ return lines.join("\n");
137
+ }
138
+
139
+ /**
140
+ * Run-length encoded styled rows for `region`. Rows that are entirely blank,
141
+ * default-styled cells are omitted (each row carries its `y`).
142
+ */
143
+ export function styledRows(buffer: CellBuffer, region?: Partial<Region>): StyledRow[] {
144
+ const r = clampRegion(buffer, region);
145
+ const rows: StyledRow[] = [];
146
+ for (let y = r.y; y < r.y + r.height; y++) {
147
+ const runs: StyledRun[] = [];
148
+ let cur: (StyledRun & { key: string }) | null = null;
149
+ for (let x = r.x; x < r.x + r.width; x++) {
150
+ const cell = buffer.getCell(x, y);
151
+ if (!cell) break;
152
+ const attrs = cell.attrs & STYLE_MASK;
153
+ const key = `${cell.fg}|${cell.bg}|${attrs}`;
154
+ const wideCont = (cell.attrs & ATTR_WIDE_CONT) !== 0;
155
+ // A wide grapheme's second cell extends its run; cut by the region's left edge, it reads as a blank.
156
+ if (wideCont && cur) {
157
+ cur.width++;
158
+ continue;
159
+ }
160
+ const clipped = x === r.x + r.width - 1 && ((buffer.getCell(x + 1, y)?.attrs ?? 0) & ATTR_WIDE_CONT) !== 0;
161
+ const ch = wideCont || clipped ? " " : cellText(cell.char);
162
+ if (cur && cur.key === key) {
163
+ cur.text += ch;
164
+ cur.width++;
165
+ continue;
166
+ }
167
+ if (cur) runs.push(stripKey(cur));
168
+ cur = { key, x, width: 1, text: ch, fg: colorHex(cell.fg), bg: colorHex(cell.bg), ...(attrs ? { attrs: attrNames(attrs) } : {}) };
169
+ }
170
+ if (cur) runs.push(stripKey(cur));
171
+ if (runs.every(isBlankRun)) continue;
172
+ rows.push({ y, runs });
173
+ }
174
+ return rows;
175
+ }
176
+
177
+ function isBlankRun(run: StyledRun): boolean {
178
+ return run.bg === null && run.attrs === undefined && run.text.trim() === "";
179
+ }
180
+
181
+ function stripKey(run: StyledRun & { key: string }): StyledRun {
182
+ const { key: _key, ...rest } = run;
183
+ return rest;
184
+ }
185
+
186
+ export interface FindOptions {
187
+ /** Treat `query` as a regular expression (JavaScript syntax, no flags but `i`). */
188
+ regex?: boolean;
189
+ ignoreCase?: boolean;
190
+ /** Max matches (default 50). */
191
+ limit?: number;
192
+ }
193
+
194
+ /** Find `query` in the frame, row by row (matches never span rows). */
195
+ export function findText(buffer: CellBuffer, query: string, options: FindOptions = {}): TextMatch[] {
196
+ const limit = options.limit ?? 50;
197
+ const out: TextMatch[] = [];
198
+ if (query === "") return out;
199
+ let re: RegExp;
200
+ if (options.regex) re = new RegExp(query, options.ignoreCase ? "gi" : "g");
201
+ else re = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), options.ignoreCase ? "gi" : "g");
202
+ for (let y = 0; y < buffer.height && out.length < limit; y++) {
203
+ const row = rowCells(buffer, y, 0, buffer.width);
204
+ re.lastIndex = 0;
205
+ for (let m = re.exec(row.text); m !== null && out.length < limit; m = re.exec(row.text)) {
206
+ if (m[0] === "") {
207
+ re.lastIndex++;
208
+ continue;
209
+ }
210
+ const x = row.cols[m.index] as number;
211
+ const end = row.cols[m.index + m[0].length] as number;
212
+ // A match ending inside a wide grapheme extends to cover it.
213
+ const width = Math.max(1, end - x);
214
+ out.push({ x, y, width, height: 1, line: row.text.trimEnd() });
215
+ }
216
+ }
217
+ return out;
218
+ }
219
+
220
+ /** Whether `text` appears anywhere in the frame (row by row). */
221
+ export function frameContains(buffer: CellBuffer, text: string): boolean {
222
+ for (let y = 0; y < buffer.height; y++) if (rowCells(buffer, y, 0, buffer.width).text.includes(text)) return true;
223
+ return false;
224
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `@textjs/app/mcp` — drive Text.js apps from coding agents.
3
+ *
4
+ * `text mcp [dir]` serves these tools over stdio; `createTextMcpServer()`
5
+ * embeds the same server in another process (any MCP transport), and
6
+ * `HeadlessSession` is the transport-free core.
7
+ */
8
+
9
+ export { createTextMcpServer, formatFrame, SessionController, type TextMcpServerOptions } from "./server";
10
+ export { HeadlessSession, SessionError, purgeModuleCache, type FetchResult, type FocusSummary, type FrameSnapshot, type SessionOptions, type SettleResult } from "./session";
11
+ export { findText, frameText, styledRows, type Region, type StyledRow, type StyledRun, type TextMatch } from "./frame";
12
+ export { FiberProbe, formatTree, inspect, type Bounds, type FocusableInfo, type InspectOptions, type InspectResult, type TreeNode } from "./inspect";
13
+ export { parseKeyInput, textToKeyEvents, KeySpecError, MAX_KEYS_PER_CALL } from "./keys";
14
+ export { captureConsole, LogBuffer, type LogEntry, type LogLevel } from "./logs";
15
+ export { MCP_HELP, parseMcpArgs, runMcpCli } from "./cli";
@@ -0,0 +1,445 @@
1
+ /**
2
+ * Component + scene tree inspection for `text mcp`.
3
+ *
4
+ * The React tree is read from fibers: `<FiberProbe>` (a class component
5
+ * mounted around the app) exposes its fiber via `_reactInternals`, which React
6
+ * sets on every class instance in development and production builds alike.
7
+ * Host fibers (`tx-box` / `tx-text` / raw text) point at their `SceneNode`
8
+ * (`stateNode`), whose `screenRect` from the last paint gives cell bounds.
9
+ */
10
+
11
+ import { Component, type ReactNode } from "react";
12
+ import type { SceneNode } from "@textjs/core";
13
+ import type { FocusManager } from "@textjs/react";
14
+ import { paintOwners } from "@textjs/react/devtools-hook";
15
+
16
+ /** The subset of React's internal Fiber shape this module reads. */
17
+ interface Fiber {
18
+ tag: number;
19
+ type: unknown;
20
+ elementType?: unknown;
21
+ key: string | null;
22
+ stateNode: unknown;
23
+ return: Fiber | null;
24
+ child: Fiber | null;
25
+ sibling: Fiber | null;
26
+ alternate: Fiber | null;
27
+ memoizedProps: Record<string, unknown> | null;
28
+ memoizedState: unknown;
29
+ }
30
+
31
+ // React 19 work tags (react-reconciler/src/ReactWorkTags.js).
32
+ const FunctionComponent = 0;
33
+ const ClassComponent = 1;
34
+ const HostRoot = 3;
35
+ const HostPortal = 4;
36
+ const HostComponent = 5;
37
+ const HostText = 6;
38
+ const ForwardRef = 11;
39
+ const SuspenseComponent = 13;
40
+ const MemoComponent = 14;
41
+ const SimpleMemoComponent = 15;
42
+ const OffscreenComponent = 22;
43
+ const ActivityComponent = 31;
44
+
45
+ export class FiberProbe extends Component<{ children?: ReactNode }> {
46
+ render(): ReactNode {
47
+ return this.props.children;
48
+ }
49
+ }
50
+
51
+ /** The current HostRoot fiber of the tree `probe` is mounted in. */
52
+ function currentRoot(probe: FiberProbe | null): Fiber | null {
53
+ const fiber = (probe as unknown as { _reactInternals?: Fiber } | null)?._reactInternals;
54
+ if (!fiber) return null;
55
+ let f: Fiber = fiber;
56
+ while (f.return) f = f.return;
57
+ if (f.tag !== HostRoot) return null;
58
+ const root = f.stateNode as { current?: Fiber } | null;
59
+ return root?.current ?? f;
60
+ }
61
+
62
+ export interface Bounds {
63
+ x: number;
64
+ y: number;
65
+ width: number;
66
+ height: number;
67
+ }
68
+
69
+ export interface TreeNode {
70
+ /**
71
+ * `component` (React function/class), `box` / `text` (host scene nodes),
72
+ * `suspense`, or `portal` (content `<Modal>` / `<Dialog>` / toasts render
73
+ * into the root layer; its bounds are not part of the parent's).
74
+ */
75
+ kind: "component" | "box" | "text" | "suspense" | "portal";
76
+ name: string;
77
+ key?: string;
78
+ /** Screen bounds from the last painted frame (union of painted host descendants for components). */
79
+ bounds?: Bounds;
80
+ /** Text content (text hosts; truncated). */
81
+ text?: string;
82
+ /** Focus id owned by this component (`useFocus`), when it can be attributed. */
83
+ focusId?: string;
84
+ focused?: boolean;
85
+ /** `wireframeLabel` / `title` of a box host, when set. */
86
+ label?: string;
87
+ children?: TreeNode[];
88
+ }
89
+
90
+ export interface FocusableInfo {
91
+ id: string;
92
+ focused: boolean;
93
+ /** Included in Tab order and not blocked by a focus trap (e.g. an open `<Modal>`). */
94
+ tabbable: boolean;
95
+ component?: string;
96
+ bounds?: Bounds;
97
+ }
98
+
99
+ export interface InspectOptions {
100
+ /** Include host `box` / `text` nodes (default `true`). */
101
+ hosts?: boolean;
102
+ /** Levels of the returned tree (default 40), counted from the top level, or from each match with `component`. */
103
+ maxDepth?: number;
104
+ /** Only return subtrees rooted at components whose name matches (case-insensitive substring). */
105
+ component?: string;
106
+ /** Hide components that render nothing visible (default `true`). */
107
+ hideEmpty?: boolean;
108
+ }
109
+
110
+ function displayName(fiber: Fiber): string | null {
111
+ const t = fiber.type as { displayName?: string; name?: string; render?: { displayName?: string; name?: string }; type?: unknown } | null;
112
+ switch (fiber.tag) {
113
+ case FunctionComponent:
114
+ case ClassComponent:
115
+ case SimpleMemoComponent:
116
+ return (t && (t.displayName || t.name)) || "Anonymous";
117
+ case ForwardRef: {
118
+ const r = t?.render;
119
+ return t?.displayName || (r && (r.displayName || r.name)) || "ForwardRef";
120
+ }
121
+ case MemoComponent: {
122
+ const inner = t?.type as { displayName?: string; name?: string; render?: { name?: string } } | undefined;
123
+ return t?.displayName || inner?.displayName || inner?.name || inner?.render?.name || "Memo";
124
+ }
125
+ default:
126
+ return null;
127
+ }
128
+ }
129
+
130
+ function sceneOf(fiber: Fiber): SceneNode | null {
131
+ if (fiber.tag !== HostComponent && fiber.tag !== HostText) return null;
132
+ const node = fiber.stateNode as SceneNode | null;
133
+ return node && typeof node === "object" && "screenRect" in node ? node : null;
134
+ }
135
+
136
+ function rectOf(node: SceneNode): Bounds | null {
137
+ if (!node.painted) return null;
138
+ const r = node.screenRect;
139
+ if (r.width <= 0 || r.height <= 0) return null;
140
+ return { x: r.x, y: r.y, width: r.width, height: r.height };
141
+ }
142
+
143
+ function union(a: Bounds | null | undefined, b: Bounds | null | undefined): Bounds | null {
144
+ if (!a) return b ?? null;
145
+ if (!b) return a;
146
+ const x0 = Math.min(a.x, b.x);
147
+ const y0 = Math.min(a.y, b.y);
148
+ const x1 = Math.max(a.x + a.width, b.x + b.width);
149
+ const y1 = Math.max(a.y + a.height, b.y + b.height);
150
+ return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
151
+ }
152
+
153
+ function textOf(node: SceneNode): string {
154
+ if (node.type === "raw-text") return node.text;
155
+ let s = "";
156
+ for (const c of node.children) s += textOf(c);
157
+ return s;
158
+ }
159
+
160
+ function isHiddenOffscreen(fiber: Fiber): boolean {
161
+ return (fiber.tag === OffscreenComponent || fiber.tag === ActivityComponent) && fiber.memoizedState !== null;
162
+ }
163
+
164
+ /** `Symbol.for("textjs.focusOwner")`: the marker `useFocus()` leaves in hook state (`@textjs/react` `FOCUS_OWNER`). */
165
+ const FOCUS_OWNER = Symbol.for("textjs.focusOwner");
166
+
167
+ type Hook = { memoizedState?: unknown; next?: unknown } | null;
168
+
169
+ function hooksOf(fiber: Fiber): Hook {
170
+ if (fiber.tag !== FunctionComponent && fiber.tag !== ForwardRef && fiber.tag !== SimpleMemoComponent) return null;
171
+ return fiber.memoizedState as Hook;
172
+ }
173
+
174
+ /** Focus ids this component registered with `useFocus()` (tagged `useMemo` state). */
175
+ function focusOwnerIds(fiber: Fiber): string[] {
176
+ const out: string[] = [];
177
+ let hook = hooksOf(fiber);
178
+ let guard = 0;
179
+ while (hook && typeof hook === "object" && guard++ < 200) {
180
+ const v = hook.memoizedState;
181
+ const value = Array.isArray(v) ? v[0] : undefined;
182
+ const id = value !== null && typeof value === "object" ? (value as Record<symbol, unknown>)[FOCUS_OWNER] : undefined;
183
+ if (typeof id === "string") out.push(id);
184
+ hook = (hook.next ?? null) as Hook;
185
+ }
186
+ return out;
187
+ }
188
+
189
+ /**
190
+ * Fallback for focus ids registered without the marker (older `@textjs/react`):
191
+ * strings in hook state (`useId()` values, `useCallback` deps).
192
+ */
193
+ function hookStrings(fiber: Fiber, wanted: ReadonlySet<string>): string | undefined {
194
+ let hook = hooksOf(fiber);
195
+ let guard = 0;
196
+ while (hook && typeof hook === "object" && guard++ < 200) {
197
+ const v = hook.memoizedState;
198
+ if (typeof v === "string" && wanted.has(v)) return v;
199
+ // `useCallback(fn, [manager, id])` inside `useFocus` (covers explicit `id` options).
200
+ if (Array.isArray(v) && Array.isArray(v[1])) {
201
+ const dep = (v[1] as unknown[]).find((d) => typeof d === "string" && wanted.has(d));
202
+ if (typeof dep === "string") return dep;
203
+ }
204
+ hook = (hook.next ?? null) as Hook;
205
+ }
206
+ return undefined;
207
+ }
208
+
209
+ /** The focus id `fiber` owns: its `useFocus()` marker, else an unclaimed id seen in its hook state. */
210
+ function focusIdOf(fiber: Fiber, wanted: ReadonlySet<string>, claimed: ReadonlySet<string>): string | undefined {
211
+ const tagged = focusOwnerIds(fiber).find((id) => wanted.has(id));
212
+ if (tagged !== undefined) return tagged;
213
+ if (claimed.size >= wanted.size) return undefined;
214
+ const fallback = hookStrings(fiber, wanted);
215
+ return fallback !== undefined && !claimed.has(fallback) ? fallback : undefined;
216
+ }
217
+
218
+ /** Every registered focus id claimed by a `useFocus()` marker under `start`. */
219
+ function claimedFocusIds(start: Fiber, wanted: ReadonlySet<string>): Set<string> {
220
+ const out = new Set<string>();
221
+ const stack: Fiber[] = [start];
222
+ while (stack.length > 0) {
223
+ const f = stack.pop() as Fiber;
224
+ for (const id of focusOwnerIds(f)) if (wanted.has(id)) out.add(id);
225
+ for (let c = f.child; c; c = c.sibling) stack.push(c);
226
+ }
227
+ return out;
228
+ }
229
+
230
+ export interface InspectResult {
231
+ tree: TreeNode | null;
232
+ focusables: FocusableInfo[];
233
+ activeFocusId: string | null;
234
+ }
235
+
236
+ /**
237
+ * Walk the React tree under `probe`. Components are attributed to the focus id
238
+ * they registered with `useFocus()` (its `FOCUS_OWNER` hook-state marker).
239
+ */
240
+ export function inspect(probe: FiberProbe | null, focus: FocusManager, options: InspectOptions = {}): InspectResult {
241
+ const { hosts = true, maxDepth = 40, component, hideEmpty = true } = options;
242
+ const activeFocusId = focus.getActiveId();
243
+ const entries = focus.snapshot();
244
+ const wanted = new Set(entries.map((e) => e.id));
245
+ const focusBounds = new Map<string, { component: string; bounds: Bounds | null }>();
246
+ const root = currentRoot(probe);
247
+ const probeFiber = root ? findProbe(root) : null;
248
+ const needle = component?.toLowerCase();
249
+ const claimed = probeFiber ? claimedFocusIds(probeFiber, wanted) : new Set<string>();
250
+
251
+ /**
252
+ * Returns the built nodes for `fiber` (several when the fiber itself is
253
+ * transparent), its painted bounds, and the bounds of portals rendered
254
+ * below it (kept apart: they paint in the root layer).
255
+ */
256
+ const visit = (fiber: Fiber): { nodes: TreeNode[]; bounds: Bounds | null; portal: Bounds | null } => {
257
+ if (isHiddenOffscreen(fiber)) return { nodes: [], bounds: null, portal: null };
258
+ const name = displayName(fiber);
259
+ const scene = sceneOf(fiber);
260
+ const isSuspense = fiber.tag === SuspenseComponent;
261
+ const isPortal = fiber.tag === HostPortal;
262
+ const visible = name !== null || (hosts && scene !== null) || isSuspense || isPortal;
263
+ const kids: TreeNode[] = [];
264
+ let bounds: Bounds | null = scene ? rectOf(scene) : null;
265
+ let portalBounds: Bounds | null = null;
266
+ // Raw text inside <Text> has no own layout; its owner's rect covers it.
267
+ if (scene?.type === "raw-text") bounds = null;
268
+ if (fiber.tag !== HostText) {
269
+ for (let c = fiber.child; c; c = c.sibling) {
270
+ const r = visit(c);
271
+ kids.push(...r.nodes);
272
+ // A portal paints in the root layer, not inside its logical parent.
273
+ if (c.tag === HostPortal) portalBounds = union(portalBounds, r.bounds);
274
+ else bounds = union(bounds, r.bounds);
275
+ portalBounds = union(portalBounds, r.portal);
276
+ }
277
+ }
278
+ if (isPortal) {
279
+ bounds = union(bounds, portalBounds);
280
+ portalBounds = null;
281
+ }
282
+ if (!visible) return { nodes: kids, bounds, portal: portalBounds };
283
+ // A component that only renders a portal (<Modal>) is shown where the portal paints.
284
+ const shown = bounds ?? portalBounds;
285
+ const node: TreeNode = { kind: "component", name: name ?? "" };
286
+ if (scene) {
287
+ node.kind = scene.type === "box" ? "box" : "text";
288
+ node.name = scene.type === "box" ? "box" : "text";
289
+ if (scene.type !== "box") {
290
+ const t = textOf(scene);
291
+ node.text = t.length > 120 ? `${t.slice(0, 117)}…` : t;
292
+ } else {
293
+ const label = scene.props.title ?? scene.props.wireframeLabel;
294
+ if (label) node.label = String(label).trim();
295
+ }
296
+ } else if (isSuspense) {
297
+ node.kind = "suspense";
298
+ node.name = "Suspense";
299
+ } else if (isPortal) {
300
+ node.kind = "portal";
301
+ node.name = "Portal";
302
+ }
303
+ if (fiber.key !== null) node.key = fiber.key;
304
+ if (shown) node.bounds = shown;
305
+ if (name !== null) {
306
+ const fid = focusIdOf(fiber, wanted, claimed);
307
+ if (fid !== undefined) {
308
+ node.focusId = fid;
309
+ if (fid === activeFocusId) node.focused = true;
310
+ if (!focusBounds.has(fid)) focusBounds.set(fid, { component: name, bounds: shown });
311
+ }
312
+ }
313
+ // Text hosts already carry their text; raw-text children add nothing.
314
+ const children = node.kind === "text" ? kids.filter((k) => k.kind !== "text" || k.text !== node.text) : kids;
315
+ if (children.length > 0) node.children = children;
316
+ if (hideEmpty && (node.kind === "component" || node.kind === "portal") && !shown && children.length === 0 && node.focusId === undefined) return { nodes: [], bounds, portal: portalBounds };
317
+ return { nodes: [node], bounds, portal: portalBounds };
318
+ };
319
+
320
+ let tree: TreeNode | null = null;
321
+ if (probeFiber) {
322
+ const nodes: TreeNode[] = [];
323
+ let bounds: Bounds | null = null;
324
+ for (let c = probeFiber.child; c; c = c.sibling) {
325
+ const r = visit(c);
326
+ nodes.push(...r.nodes);
327
+ bounds = union(bounds, r.bounds);
328
+ }
329
+ // The whole tree is walked (bounds and focus attribution need every host);
330
+ // `maxDepth` limits what is returned, counted from the matched roots.
331
+ const trim = (n: TreeNode, depth: number): TreeNode => {
332
+ if (!n.children) return n;
333
+ if (depth + 1 >= maxDepth) {
334
+ const { children: _children, ...rest } = n;
335
+ return rest;
336
+ }
337
+ return { ...n, children: n.children.map((c) => trim(c, depth + 1)) };
338
+ };
339
+ tree = { kind: "component", name: "App", ...(bounds ? { bounds } : {}), children: nodes.map((n) => trim(n, 0)) };
340
+ if (needle) {
341
+ const matches: TreeNode[] = [];
342
+ const search = (n: TreeNode): void => {
343
+ if (n.kind === "component" && n.name.toLowerCase().includes(needle)) {
344
+ matches.push(trim(n, 0));
345
+ return;
346
+ }
347
+ n.children?.forEach(search);
348
+ };
349
+ nodes.forEach(search);
350
+ tree = { kind: "component", name: `App (matching "${component}")`, children: matches };
351
+ }
352
+ }
353
+
354
+ const focusables: FocusableInfo[] = entries.map((e) => {
355
+ const hit = focusBounds.get(e.id);
356
+ return {
357
+ id: e.id,
358
+ focused: e.id === activeFocusId,
359
+ tabbable: e.tabbable,
360
+ ...(hit ? { component: hit.component } : {}),
361
+ ...(hit?.bounds ? { bounds: hit.bounds } : {}),
362
+ };
363
+ });
364
+ return { tree, focusables, activeFocusId };
365
+ }
366
+
367
+ function findProbe(root: Fiber): Fiber | null {
368
+ const stack: Fiber[] = [root];
369
+ while (stack.length > 0) {
370
+ const f = stack.pop() as Fiber;
371
+ if (f.tag === ClassComponent && f.type === FiberProbe) return f;
372
+ for (let c = f.child; c; c = c.sibling) stack.push(c);
373
+ }
374
+ return null;
375
+ }
376
+
377
+ /** Render a tree as indented text: `Name [x,y w×h] "text" focus=id`. */
378
+ export function formatTree(node: TreeNode, indent = ""): string {
379
+ let line = `${indent}${node.kind === "component" || node.kind === "suspense" || node.kind === "portal" ? node.name : node.kind}`;
380
+ if (node.key !== undefined) line += ` key=${JSON.stringify(node.key)}`;
381
+ if (node.label) line += ` (${node.label})`;
382
+ if (node.bounds) line += ` [${node.bounds.x},${node.bounds.y} ${node.bounds.width}×${node.bounds.height}]`;
383
+ if (node.text !== undefined) line += ` ${JSON.stringify(node.text)}`;
384
+ if (node.focusId !== undefined) line += ` focus=${node.focusId}${node.focused ? " (focused)" : ""}`;
385
+ const lines = [line];
386
+ for (const c of node.children ?? []) lines.push(formatTree(c, `${indent} `));
387
+ return lines.join("\n");
388
+ }
389
+
390
+ export interface SceneGrid {
391
+ /** The console root's scene node (`ConsoleRoot.rootNode`). */
392
+ root: SceneNode;
393
+ cols: number;
394
+ rows: number;
395
+ }
396
+
397
+ export interface HitResult {
398
+ /** Nearest component above the topmost painted host at the cell. */
399
+ component: string | null;
400
+ /** Component names from the app root down to `component`. */
401
+ path: string[];
402
+ }
403
+
404
+ /**
405
+ * Hit-tester for cells: the topmost painted host scene node at `(x, y)` in
406
+ * paint order (overflow clipping, scroll offsets, absolute `zIndex` layers
407
+ * and portals such as `<Modal>`), attributed to its component chain.
408
+ * Ownership is computed once per tester.
409
+ */
410
+ export function createHitTester(probe: FiberProbe | null, grid: SceneGrid): (x: number, y: number) => HitResult {
411
+ const none: HitResult = { component: null, path: [] };
412
+ const root = currentRoot(probe);
413
+ const start = root ? findProbe(root) : null;
414
+ if (!start) return () => none;
415
+ const { cols, rows } = grid;
416
+ const owners = new Int32Array(cols * rows);
417
+ paintOwners(grid.root, cols, rows, owners);
418
+ const hosts = new Map<number, Fiber>();
419
+ const stack: Fiber[] = [];
420
+ for (let c = start.child; c; c = c.sibling) stack.push(c);
421
+ while (stack.length > 0) {
422
+ const f = stack.pop() as Fiber;
423
+ if (isHiddenOffscreen(f)) continue;
424
+ const scene = sceneOf(f);
425
+ if (scene) hosts.set(scene.id, f);
426
+ for (let c = f.child; c; c = c.sibling) stack.push(c);
427
+ }
428
+ return (x, y) => {
429
+ if (x < 0 || y < 0 || x >= cols || y >= rows) return none;
430
+ const host = hosts.get(owners[y * cols + x] as number);
431
+ if (!host) return none;
432
+ const path: string[] = [];
433
+ for (let f: Fiber | null = host; f && f !== start; f = f.return) {
434
+ const name = displayName(f);
435
+ if (name !== null) path.push(name);
436
+ }
437
+ path.reverse();
438
+ return { component: path[path.length - 1] ?? null, path };
439
+ };
440
+ }
441
+
442
+ /** {@link createHitTester} for a single cell. */
443
+ export function hitTest(probe: FiberProbe | null, grid: SceneGrid, x: number, y: number): HitResult {
444
+ return createHitTester(probe, grid)(x, y);
445
+ }