@effect-tui/react 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (277) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +138 -0
  3. package/dist/jsx-dev-runtime.d.ts +3 -0
  4. package/dist/jsx-dev-runtime.d.ts.map +1 -0
  5. package/dist/jsx-dev-runtime.js +3 -0
  6. package/dist/jsx-dev-runtime.js.map +1 -0
  7. package/dist/jsx-runtime.d.ts +47 -0
  8. package/dist/jsx-runtime.d.ts.map +1 -0
  9. package/dist/jsx-runtime.js +6 -0
  10. package/dist/jsx-runtime.js.map +1 -0
  11. package/dist/src/codeblock.d.ts +9 -0
  12. package/dist/src/codeblock.d.ts.map +1 -0
  13. package/dist/src/codeblock.js +24 -0
  14. package/dist/src/codeblock.js.map +1 -0
  15. package/dist/src/constants.d.ts +3 -0
  16. package/dist/src/constants.d.ts.map +1 -0
  17. package/dist/src/constants.js +3 -0
  18. package/dist/src/constants.js.map +1 -0
  19. package/dist/src/debug/DiagnosticsPanel.d.ts +7 -0
  20. package/dist/src/debug/DiagnosticsPanel.d.ts.map +1 -0
  21. package/dist/src/debug/DiagnosticsPanel.js +13 -0
  22. package/dist/src/debug/DiagnosticsPanel.js.map +1 -0
  23. package/dist/src/highlight.d.ts +20 -0
  24. package/dist/src/highlight.d.ts.map +1 -0
  25. package/dist/src/highlight.js +51 -0
  26. package/dist/src/highlight.js.map +1 -0
  27. package/dist/src/hooks/index.d.ts +4 -0
  28. package/dist/src/hooks/index.d.ts.map +1 -0
  29. package/dist/src/hooks/index.js +3 -0
  30. package/dist/src/hooks/index.js.map +1 -0
  31. package/dist/src/hooks/use-keyboard.d.ts +18 -0
  32. package/dist/src/hooks/use-keyboard.d.ts.map +1 -0
  33. package/dist/src/hooks/use-keyboard.js +26 -0
  34. package/dist/src/hooks/use-keyboard.js.map +1 -0
  35. package/dist/src/hooks/use-paste.d.ts +5 -0
  36. package/dist/src/hooks/use-paste.d.ts.map +1 -0
  37. package/dist/src/hooks/use-paste.js +14 -0
  38. package/dist/src/hooks/use-paste.js.map +1 -0
  39. package/dist/src/hooks/useFrameStats.d.ts +7 -0
  40. package/dist/src/hooks/useFrameStats.d.ts.map +1 -0
  41. package/dist/src/hooks/useFrameStats.js +28 -0
  42. package/dist/src/hooks/useFrameStats.js.map +1 -0
  43. package/dist/src/hosts/base.d.ts +22 -0
  44. package/dist/src/hosts/base.d.ts.map +1 -0
  45. package/dist/src/hosts/base.js +53 -0
  46. package/dist/src/hosts/base.js.map +1 -0
  47. package/dist/src/hosts/box.d.ts +26 -0
  48. package/dist/src/hosts/box.d.ts.map +1 -0
  49. package/dist/src/hosts/box.js +84 -0
  50. package/dist/src/hosts/box.js.map +1 -0
  51. package/dist/src/hosts/canvas.d.ts +48 -0
  52. package/dist/src/hosts/canvas.d.ts.map +1 -0
  53. package/dist/src/hosts/canvas.js +109 -0
  54. package/dist/src/hosts/canvas.js.map +1 -0
  55. package/dist/src/hosts/codeblock.d.ts +32 -0
  56. package/dist/src/hosts/codeblock.d.ts.map +1 -0
  57. package/dist/src/hosts/codeblock.js +118 -0
  58. package/dist/src/hosts/codeblock.js.map +1 -0
  59. package/dist/src/hosts/hstack.d.ts +18 -0
  60. package/dist/src/hosts/hstack.d.ts.map +1 -0
  61. package/dist/src/hosts/hstack.js +45 -0
  62. package/dist/src/hosts/hstack.js.map +1 -0
  63. package/dist/src/hosts/index.d.ts +16 -0
  64. package/dist/src/hosts/index.d.ts.map +1 -0
  65. package/dist/src/hosts/index.js +40 -0
  66. package/dist/src/hosts/index.js.map +1 -0
  67. package/dist/src/hosts/spacer.d.ts +19 -0
  68. package/dist/src/hosts/spacer.d.ts.map +1 -0
  69. package/dist/src/hosts/spacer.js +28 -0
  70. package/dist/src/hosts/spacer.js.map +1 -0
  71. package/dist/src/hosts/text.d.ts +43 -0
  72. package/dist/src/hosts/text.d.ts.map +1 -0
  73. package/dist/src/hosts/text.js +148 -0
  74. package/dist/src/hosts/text.js.map +1 -0
  75. package/dist/src/hosts/vstack.d.ts +18 -0
  76. package/dist/src/hosts/vstack.d.ts.map +1 -0
  77. package/dist/src/hosts/vstack.js +45 -0
  78. package/dist/src/hosts/vstack.js.map +1 -0
  79. package/dist/src/hosts/zstack.d.ts +20 -0
  80. package/dist/src/hosts/zstack.d.ts.map +1 -0
  81. package/dist/src/hosts/zstack.js +65 -0
  82. package/dist/src/hosts/zstack.js.map +1 -0
  83. package/dist/src/index.d.ts +20 -0
  84. package/dist/src/index.d.ts.map +1 -0
  85. package/dist/src/index.js +20 -0
  86. package/dist/src/index.js.map +1 -0
  87. package/dist/src/inline/index.d.ts +32 -0
  88. package/dist/src/inline/index.d.ts.map +1 -0
  89. package/dist/src/inline/index.js +111 -0
  90. package/dist/src/inline/index.js.map +1 -0
  91. package/dist/src/jsx.d.ts +2 -0
  92. package/dist/src/jsx.d.ts.map +1 -0
  93. package/dist/src/jsx.js +4 -0
  94. package/dist/src/jsx.js.map +1 -0
  95. package/dist/src/motion/color-motion-value.d.ts +32 -0
  96. package/dist/src/motion/color-motion-value.d.ts.map +1 -0
  97. package/dist/src/motion/color-motion-value.js +80 -0
  98. package/dist/src/motion/color-motion-value.js.map +1 -0
  99. package/dist/src/motion/color.d.ts +30 -0
  100. package/dist/src/motion/color.d.ts.map +1 -0
  101. package/dist/src/motion/color.js +172 -0
  102. package/dist/src/motion/color.js.map +1 -0
  103. package/dist/src/motion/color.test.d.ts +2 -0
  104. package/dist/src/motion/color.test.d.ts.map +1 -0
  105. package/dist/src/motion/color.test.js +97 -0
  106. package/dist/src/motion/color.test.js.map +1 -0
  107. package/dist/src/motion/event-emitter.d.ts +18 -0
  108. package/dist/src/motion/event-emitter.d.ts.map +1 -0
  109. package/dist/src/motion/event-emitter.js +30 -0
  110. package/dist/src/motion/event-emitter.js.map +1 -0
  111. package/dist/src/motion/frame.d.ts +9 -0
  112. package/dist/src/motion/frame.d.ts.map +1 -0
  113. package/dist/src/motion/frame.js +51 -0
  114. package/dist/src/motion/frame.js.map +1 -0
  115. package/dist/src/motion/hooks.d.ts +75 -0
  116. package/dist/src/motion/hooks.d.ts.map +1 -0
  117. package/dist/src/motion/hooks.js +190 -0
  118. package/dist/src/motion/hooks.js.map +1 -0
  119. package/dist/src/motion/index.d.ts +4 -0
  120. package/dist/src/motion/index.d.ts.map +1 -0
  121. package/dist/src/motion/index.js +7 -0
  122. package/dist/src/motion/index.js.map +1 -0
  123. package/dist/src/motion/motion-value.d.ts +40 -0
  124. package/dist/src/motion/motion-value.d.ts.map +1 -0
  125. package/dist/src/motion/motion-value.js +109 -0
  126. package/dist/src/motion/motion-value.js.map +1 -0
  127. package/dist/src/motion/motion-value.test.d.ts +2 -0
  128. package/dist/src/motion/motion-value.test.d.ts.map +1 -0
  129. package/dist/src/motion/motion-value.test.js +177 -0
  130. package/dist/src/motion/motion-value.test.js.map +1 -0
  131. package/dist/src/motion/spring-math.d.ts +28 -0
  132. package/dist/src/motion/spring-math.d.ts.map +1 -0
  133. package/dist/src/motion/spring-math.js +81 -0
  134. package/dist/src/motion/spring-math.js.map +1 -0
  135. package/dist/src/motion/types.d.ts +25 -0
  136. package/dist/src/motion/types.d.ts.map +1 -0
  137. package/dist/src/motion/types.js +13 -0
  138. package/dist/src/motion/types.js.map +1 -0
  139. package/dist/src/output.d.ts +47 -0
  140. package/dist/src/output.d.ts.map +1 -0
  141. package/dist/src/output.js +125 -0
  142. package/dist/src/output.js.map +1 -0
  143. package/dist/src/profiler.d.ts +6 -0
  144. package/dist/src/profiler.d.ts.map +1 -0
  145. package/dist/src/profiler.js +73 -0
  146. package/dist/src/profiler.js.map +1 -0
  147. package/dist/src/reconciler/host-config.d.ts +16 -0
  148. package/dist/src/reconciler/host-config.d.ts.map +1 -0
  149. package/dist/src/reconciler/host-config.js +174 -0
  150. package/dist/src/reconciler/host-config.js.map +1 -0
  151. package/dist/src/reconciler/types.d.ts +52 -0
  152. package/dist/src/reconciler/types.d.ts.map +1 -0
  153. package/dist/src/reconciler/types.js +2 -0
  154. package/dist/src/reconciler/types.js.map +1 -0
  155. package/dist/src/renderer.d.ts +101 -0
  156. package/dist/src/renderer.d.ts.map +1 -0
  157. package/dist/src/renderer.js +509 -0
  158. package/dist/src/renderer.js.map +1 -0
  159. package/dist/src/terminal.d.ts +37 -0
  160. package/dist/src/terminal.d.ts.map +1 -0
  161. package/dist/src/terminal.js +65 -0
  162. package/dist/src/terminal.js.map +1 -0
  163. package/dist/src/test/index.d.ts +3 -0
  164. package/dist/src/test/index.d.ts.map +1 -0
  165. package/dist/src/test/index.js +3 -0
  166. package/dist/src/test/index.js.map +1 -0
  167. package/dist/src/test/mock-streams.d.ts +44 -0
  168. package/dist/src/test/mock-streams.d.ts.map +1 -0
  169. package/dist/src/test/mock-streams.js +136 -0
  170. package/dist/src/test/mock-streams.js.map +1 -0
  171. package/dist/src/test/render-tui.d.ts +47 -0
  172. package/dist/src/test/render-tui.d.ts.map +1 -0
  173. package/dist/src/test/render-tui.js +76 -0
  174. package/dist/src/test/render-tui.js.map +1 -0
  175. package/dist/src/trace/SpanTree.d.ts +10 -0
  176. package/dist/src/trace/SpanTree.d.ts.map +1 -0
  177. package/dist/src/trace/SpanTree.js +104 -0
  178. package/dist/src/trace/SpanTree.js.map +1 -0
  179. package/dist/src/trace/index.d.ts +30 -0
  180. package/dist/src/trace/index.d.ts.map +1 -0
  181. package/dist/src/trace/index.js +142 -0
  182. package/dist/src/trace/index.js.map +1 -0
  183. package/dist/src/trace/location.d.ts +9 -0
  184. package/dist/src/trace/location.d.ts.map +1 -0
  185. package/dist/src/trace/location.js +88 -0
  186. package/dist/src/trace/location.js.map +1 -0
  187. package/dist/src/trace/span-processor.d.ts +16 -0
  188. package/dist/src/trace/span-processor.d.ts.map +1 -0
  189. package/dist/src/trace/span-processor.js +54 -0
  190. package/dist/src/trace/span-processor.js.map +1 -0
  191. package/dist/src/trace/span-state.d.ts +79 -0
  192. package/dist/src/trace/span-state.d.ts.map +1 -0
  193. package/dist/src/trace/span-state.js +229 -0
  194. package/dist/src/trace/span-state.js.map +1 -0
  195. package/dist/src/trace/tui-logger.d.ts +8 -0
  196. package/dist/src/trace/tui-logger.d.ts.map +1 -0
  197. package/dist/src/trace/tui-logger.js +70 -0
  198. package/dist/src/trace/tui-logger.js.map +1 -0
  199. package/dist/src/utils/border.d.ts +31 -0
  200. package/dist/src/utils/border.d.ts.map +1 -0
  201. package/dist/src/utils/border.js +81 -0
  202. package/dist/src/utils/border.js.map +1 -0
  203. package/dist/src/utils/flex-layout.d.ts +20 -0
  204. package/dist/src/utils/flex-layout.d.ts.map +1 -0
  205. package/dist/src/utils/flex-layout.js +85 -0
  206. package/dist/src/utils/flex-layout.js.map +1 -0
  207. package/dist/src/utils/index.d.ts +5 -0
  208. package/dist/src/utils/index.d.ts.map +1 -0
  209. package/dist/src/utils/index.js +5 -0
  210. package/dist/src/utils/index.js.map +1 -0
  211. package/dist/src/utils/padding.d.ts +26 -0
  212. package/dist/src/utils/padding.d.ts.map +1 -0
  213. package/dist/src/utils/padding.js +34 -0
  214. package/dist/src/utils/padding.js.map +1 -0
  215. package/dist/src/utils/styles.d.ts +13 -0
  216. package/dist/src/utils/styles.d.ts.map +1 -0
  217. package/dist/src/utils/styles.js +5 -0
  218. package/dist/src/utils/styles.js.map +1 -0
  219. package/dist/src/visualize/index.d.ts +50 -0
  220. package/dist/src/visualize/index.d.ts.map +1 -0
  221. package/dist/src/visualize/index.js +194 -0
  222. package/dist/src/visualize/index.js.map +1 -0
  223. package/dist/tsconfig.tsbuildinfo +1 -0
  224. package/package.json +94 -0
  225. package/src/codeblock.tsx +47 -0
  226. package/src/constants.ts +2 -0
  227. package/src/debug/DiagnosticsPanel.tsx +38 -0
  228. package/src/highlight.ts +76 -0
  229. package/src/hooks/index.ts +3 -0
  230. package/src/hooks/use-keyboard.ts +37 -0
  231. package/src/hooks/use-paste.ts +14 -0
  232. package/src/hooks/useFrameStats.ts +32 -0
  233. package/src/hosts/base.ts +65 -0
  234. package/src/hosts/box.ts +105 -0
  235. package/src/hosts/canvas.ts +155 -0
  236. package/src/hosts/codeblock.ts +145 -0
  237. package/src/hosts/hstack.ts +64 -0
  238. package/src/hosts/index.ts +45 -0
  239. package/src/hosts/spacer.ts +40 -0
  240. package/src/hosts/text.ts +175 -0
  241. package/src/hosts/vstack.ts +64 -0
  242. package/src/hosts/zstack.ts +77 -0
  243. package/src/index.ts +62 -0
  244. package/src/inline/index.tsx +181 -0
  245. package/src/jsx.ts +3 -0
  246. package/src/motion/color-motion-value.ts +90 -0
  247. package/src/motion/color.test.ts +115 -0
  248. package/src/motion/color.ts +191 -0
  249. package/src/motion/event-emitter.ts +35 -0
  250. package/src/motion/frame.ts +59 -0
  251. package/src/motion/hooks.ts +237 -0
  252. package/src/motion/index.ts +17 -0
  253. package/src/motion/motion-value.test.ts +222 -0
  254. package/src/motion/motion-value.ts +140 -0
  255. package/src/motion/spring-math.ts +114 -0
  256. package/src/motion/types.ts +34 -0
  257. package/src/output.ts +156 -0
  258. package/src/profiler.ts +88 -0
  259. package/src/reconciler/host-config.ts +277 -0
  260. package/src/reconciler/types.ts +66 -0
  261. package/src/renderer.ts +661 -0
  262. package/src/terminal.ts +67 -0
  263. package/src/test/index.ts +8 -0
  264. package/src/test/mock-streams.ts +149 -0
  265. package/src/test/render-tui.ts +118 -0
  266. package/src/trace/SpanTree.tsx +195 -0
  267. package/src/trace/index.tsx +205 -0
  268. package/src/trace/location.ts +90 -0
  269. package/src/trace/span-processor.ts +65 -0
  270. package/src/trace/span-state.ts +286 -0
  271. package/src/trace/tui-logger.ts +72 -0
  272. package/src/utils/border.ts +108 -0
  273. package/src/utils/flex-layout.ts +125 -0
  274. package/src/utils/index.ts +4 -0
  275. package/src/utils/padding.ts +45 -0
  276. package/src/utils/styles.ts +14 -0
  277. package/src/visualize/index.tsx +305 -0
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Spring physics calculations using analytical solutions.
3
+ * Matches framer-motion's formulas.
4
+ */
5
+
6
+ export interface SpringState {
7
+ value: number
8
+ velocity: number
9
+ done: boolean
10
+ }
11
+
12
+ export type SpringResolver = (t: number) => SpringState
13
+
14
+ /**
15
+ * Create a spring resolver function using analytical solution.
16
+ * Returns a function that computes position/velocity at any time t.
17
+ */
18
+ export function createSpringResolver(
19
+ from: number,
20
+ to: number,
21
+ initialVelocity: number,
22
+ stiffness: number,
23
+ damping: number,
24
+ mass: number,
25
+ restSpeed = 0.01,
26
+ restDelta = 0.01,
27
+ ): SpringResolver {
28
+ // Use same convention as framer-motion: initialDelta = from - to
29
+ const initialDelta = from - to
30
+ const dampingRatio = damping / (2 * Math.sqrt(stiffness * mass))
31
+ const angularFreq = Math.sqrt(stiffness / mass)
32
+ const gamma = dampingRatio * angularFreq
33
+
34
+ if (dampingRatio < 1) {
35
+ // Underdamped (oscillates)
36
+ const dampedFreq = angularFreq * Math.sqrt(1 - dampingRatio * dampingRatio)
37
+ // Coefficients matching framer-motion's formula
38
+ const A = initialDelta
39
+ const B = (initialVelocity + gamma * initialDelta) / dampedFreq
40
+
41
+ return (t: number): SpringState => {
42
+ const envelope = Math.exp(-gamma * t)
43
+ const cos = Math.cos(dampedFreq * t)
44
+ const sin = Math.sin(dampedFreq * t)
45
+
46
+ // Position: to + envelope * (A*cos + B*sin)
47
+ const value = to + envelope * (A * cos + B * sin)
48
+
49
+ // Velocity: derivative of position
50
+ const velocity = envelope * ((B * dampedFreq - gamma * A) * cos + (-A * dampedFreq - gamma * B) * sin)
51
+
52
+ const done = Math.abs(velocity) < restSpeed && Math.abs(to - value) < restDelta
53
+
54
+ return { value, velocity, done }
55
+ }
56
+ } else if (dampingRatio === 1) {
57
+ // Critically damped (fastest without oscillation)
58
+ const A = initialDelta
59
+ const B = initialVelocity + angularFreq * initialDelta
60
+
61
+ return (t: number): SpringState => {
62
+ const envelope = Math.exp(-angularFreq * t)
63
+ const value = to + envelope * (A + B * t)
64
+ const velocity = envelope * (B - angularFreq * A - angularFreq * B * t)
65
+
66
+ const done = Math.abs(velocity) < restSpeed && Math.abs(to - value) < restDelta
67
+
68
+ return { value, velocity, done }
69
+ }
70
+ } else {
71
+ // Overdamped (slow, no oscillation)
72
+ const s = Math.sqrt(dampingRatio * dampingRatio - 1)
73
+ const r1 = -angularFreq * (dampingRatio - s)
74
+ const r2 = -angularFreq * (dampingRatio + s)
75
+ // Solve: A + B = initialDelta, A*r1 + B*r2 = initialVelocity
76
+ const A = (initialVelocity - r2 * initialDelta) / (r1 - r2)
77
+ const B = initialDelta - A
78
+
79
+ return (t: number): SpringState => {
80
+ const e1 = Math.exp(r1 * t)
81
+ const e2 = Math.exp(r2 * t)
82
+ const value = to + A * e1 + B * e2
83
+ const velocity = A * r1 * e1 + B * r2 * e2
84
+
85
+ const done = Math.abs(velocity) < restSpeed && Math.abs(to - value) < restDelta
86
+
87
+ return { value, velocity, done }
88
+ }
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Convert visualDuration/bounce to stiffness/damping.
94
+ * Uses Motion's formula from motion-dom.
95
+ */
96
+ export function springFromVisualDuration(
97
+ visualDuration: number,
98
+ bounce: number,
99
+ ): { stiffness: number; damping: number } {
100
+ const root = (2 * Math.PI) / (visualDuration * 1.2)
101
+ const stiffness = root * root
102
+ // Clamp bounce to [0.05, 1], then invert: 0 bounce = damping ratio 1, 1 bounce = 0.05
103
+ const clampedBounce = Math.max(0.05, Math.min(1, 1 - bounce))
104
+ const damping = 2 * clampedBounce * Math.sqrt(stiffness)
105
+
106
+ return { stiffness, damping }
107
+ }
108
+
109
+ /**
110
+ * Check if a spring has settled.
111
+ */
112
+ export function isSpringSettled(velocity: number, delta: number, restSpeed = 0.01, restDelta = 0.01): boolean {
113
+ return Math.abs(velocity) < restSpeed && Math.abs(delta) < restDelta
114
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Shared types for the motion system.
3
+ */
4
+
5
+ export interface SpringOptions {
6
+ /** Stiffness of the spring. Higher = snappier. Default: 100 */
7
+ stiffness?: number
8
+ /** Damping of the spring. Higher = less oscillation. Default: 10 */
9
+ damping?: number
10
+ /** Mass of the spring. Higher = more inertia. Default: 1 */
11
+ mass?: number
12
+ /**
13
+ * Visual duration in seconds. This is the perceptual duration of the animation.
14
+ * The spring will feel like it completes in this time, though it may technically
15
+ * continue settling. Overrides stiffness/damping when provided.
16
+ */
17
+ visualDuration?: number
18
+ /** Bounce amount 0-1 when using visualDuration. 0 = no bounce, 1 = full bounce. Default: 0 */
19
+ bounce?: number
20
+ /** Velocity threshold to consider settled. Default: 0.01 */
21
+ restSpeed?: number
22
+ /** Position threshold to consider settled. Default: 0.01 */
23
+ restDelta?: number
24
+ }
25
+
26
+ export const DEFAULT_SPRING_OPTIONS: Required<SpringOptions> = {
27
+ stiffness: 100,
28
+ damping: 10,
29
+ mass: 1,
30
+ visualDuration: 0,
31
+ bounce: 0,
32
+ restSpeed: 0.01,
33
+ restDelta: 0.01,
34
+ }
package/src/output.ts ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Output helpers for rendering CellBuffer to ANSI strings.
3
+ * Extracts common logic from renderer.ts rendering paths.
4
+ */
5
+
6
+ import type { CellBuffer, Palette } from "@effect-tui/core"
7
+
8
+ /**
9
+ * Emit a row of cells as an ANSI string with run-length SGR encoding.
10
+ * Handles wide characters (cellWidth=0 continuations) and style changes.
11
+ *
12
+ * @param buffer - The cell buffer to read from
13
+ * @param palette - Palette for SGR code generation
14
+ * @param y - Row index
15
+ * @param width - Terminal width
16
+ * @param startX - Start column (default 0)
17
+ * @param endX - End column exclusive (default width)
18
+ * @returns ANSI string for the row (no cursor positioning, no trailing reset)
19
+ */
20
+ export function emitRow(
21
+ buffer: CellBuffer,
22
+ palette: Palette,
23
+ y: number,
24
+ width: number,
25
+ startX = 0,
26
+ endX = width,
27
+ ): { output: string; lastStyle: number } {
28
+ const row = y * width
29
+ let output = ""
30
+ let visualCol = startX
31
+ let currentStyle = -1
32
+
33
+ for (let x = startX; x < endX && visualCol < width; x++) {
34
+ const idx = row + x
35
+ const glyph = buffer.g[idx]
36
+ const styleId = buffer.s[idx]
37
+ const cellWidth = buffer.cw[idx] || 1
38
+
39
+ // Skip continuation cells (wide char second half)
40
+ if (cellWidth === 0) continue
41
+
42
+ // Stop if this char would overflow
43
+ if (visualCol + cellWidth > width) break
44
+
45
+ // Emit SGR only when style changes (run-length encoding)
46
+ if (styleId !== currentStyle) {
47
+ output += palette.sgr(styleId)
48
+ currentStyle = styleId
49
+ }
50
+
51
+ output += glyph === 32 ? " " : String.fromCodePoint(glyph)
52
+ visualCol += cellWidth
53
+ }
54
+
55
+ return { output, lastStyle: currentStyle }
56
+ }
57
+
58
+ /**
59
+ * Emit a row and reset style if needed.
60
+ */
61
+ export function emitRowWithReset(
62
+ buffer: CellBuffer,
63
+ palette: Palette,
64
+ y: number,
65
+ width: number,
66
+ startX = 0,
67
+ endX = width,
68
+ ): string {
69
+ const { output, lastStyle } = emitRow(buffer, palette, y, width, startX, endX)
70
+ return lastStyle !== 0 ? output + palette.sgr(0) : output
71
+ }
72
+
73
+ /**
74
+ * Check if a row changed between two buffers.
75
+ */
76
+ export function rowChanged(prev: CellBuffer, next: CellBuffer, y: number, width: number): boolean {
77
+ const row = y * width
78
+ for (let x = 0; x < width; x++) {
79
+ const idx = row + x
80
+ if (next.g[idx] !== prev.g[idx] || next.s[idx] !== prev.s[idx] || next.cw[idx] !== prev.cw[idx]) {
81
+ return true
82
+ }
83
+ }
84
+ return false
85
+ }
86
+
87
+ /**
88
+ * Find the rightmost column with content (non-space or styled).
89
+ * Returns 0 if row is empty.
90
+ */
91
+ export function rowContentWidth(buffer: CellBuffer, y: number, width: number): number {
92
+ const row = y * width
93
+ for (let x = width - 1; x >= 0; x--) {
94
+ const idx = row + x
95
+ // Skip continuation cells
96
+ if (buffer.cw[idx] === 0) continue
97
+ // Found content if non-space or has style
98
+ if (buffer.g[idx] !== 32 || buffer.s[idx] !== 0) {
99
+ return x + 1
100
+ }
101
+ }
102
+ return 0
103
+ }
104
+
105
+ /**
106
+ * Find the change window between two buffers for a row.
107
+ * Returns the leftmost and rightmost changed columns, or null if no changes.
108
+ */
109
+ export function findChangeWindow(
110
+ prev: CellBuffer,
111
+ next: CellBuffer,
112
+ y: number,
113
+ width: number,
114
+ ): { left: number; right: number } | null {
115
+ const row = y * width
116
+ let left = 0
117
+ let right = width - 1
118
+
119
+ // Find leftmost change
120
+ while (left <= right) {
121
+ const i = row + left
122
+ if (next.g[i] !== prev.g[i] || next.s[i] !== prev.s[i] || next.cw[i] !== prev.cw[i]) {
123
+ break
124
+ }
125
+ left++
126
+ }
127
+
128
+ // No changes found
129
+ if (left > right) return null
130
+
131
+ // Find rightmost change
132
+ while (right >= left) {
133
+ const i = row + right
134
+ if (next.g[i] !== prev.g[i] || next.s[i] !== prev.s[i] || next.cw[i] !== prev.cw[i]) {
135
+ break
136
+ }
137
+ right--
138
+ }
139
+
140
+ return { left, right }
141
+ }
142
+
143
+ /**
144
+ * Find the last row with content in a buffer.
145
+ */
146
+ export function contentHeight(buffer: CellBuffer, width: number, height: number): number {
147
+ for (let y = height - 1; y >= 0; y--) {
148
+ const row = y * width
149
+ for (let x = 0; x < width; x++) {
150
+ const glyph = buffer.g[row + x]
151
+ const style = buffer.s[row + x]
152
+ if (glyph !== 32 || style !== 0) return y + 1
153
+ }
154
+ }
155
+ return 0
156
+ }
@@ -0,0 +1,88 @@
1
+ // Simple render profiler - accumulates timing stats per phase
2
+ // Enable with: PROFILE_TUI=1
3
+ // Output goes to ./tui-profile.txt
4
+
5
+ import { writeFileSync } from "node:fs"
6
+
7
+ const ENABLED = process.env.PROFILE_TUI === "1"
8
+ const OUTPUT_FILE = "tui-profile.txt"
9
+
10
+ interface PhaseStats {
11
+ total: number
12
+ count: number
13
+ min: number
14
+ max: number
15
+ }
16
+
17
+ const phases = new Map<string, PhaseStats>()
18
+ let frameCount = 0
19
+ let frameTotal = 0
20
+
21
+ export function startFrame(): number {
22
+ if (!ENABLED) return 0
23
+ return performance.now()
24
+ }
25
+
26
+ export function endFrame(start: number): void {
27
+ if (!ENABLED) return
28
+ const elapsed = performance.now() - start
29
+ frameCount++
30
+ frameTotal += elapsed
31
+ }
32
+
33
+ export function startPhase(): number {
34
+ if (!ENABLED) return 0
35
+ return performance.now()
36
+ }
37
+
38
+ export function endPhase(name: string, start: number): void {
39
+ if (!ENABLED) return
40
+ const elapsed = performance.now() - start
41
+ let stats = phases.get(name)
42
+ if (!stats) {
43
+ stats = { total: 0, count: 0, min: Infinity, max: 0 }
44
+ phases.set(name, stats)
45
+ }
46
+ stats.total += elapsed
47
+ stats.count++
48
+ stats.min = Math.min(stats.min, elapsed)
49
+ stats.max = Math.max(stats.max, elapsed)
50
+ }
51
+
52
+ export function dumpStats(): void {
53
+ if (!ENABLED || frameCount === 0) return
54
+
55
+ const lines: string[] = []
56
+ const avgFrame = frameTotal / frameCount
57
+ lines.push(`=== TUI Profile (${frameCount} frames, avg ${avgFrame.toFixed(2)}ms) ===`)
58
+
59
+ // Sort by total time descending
60
+ const sorted = [...phases.entries()].sort((a, b) => b[1].total - a[1].total)
61
+
62
+ for (const [name, stats] of sorted) {
63
+ const avg = stats.total / stats.count
64
+ const pct = ((stats.total / frameTotal) * 100).toFixed(1)
65
+ lines.push(
66
+ ` ${name.padEnd(12)} ${avg.toFixed(3)}ms avg ${stats.min.toFixed(3)}-${stats.max.toFixed(3)}ms ${pct}%`,
67
+ )
68
+ }
69
+ lines.push("")
70
+
71
+ try {
72
+ writeFileSync(OUTPUT_FILE, lines.join("\n"))
73
+ // Also print path after alt buffer exit
74
+ setTimeout(() => console.log(`Profile written to ${OUTPUT_FILE}`), 50)
75
+ } catch {
76
+ // Fallback to stderr if file write fails
77
+ console.error(lines.join("\n"))
78
+ }
79
+ }
80
+
81
+ // Auto-dump on exit
82
+ if (ENABLED) {
83
+ process.on("exit", dumpStats)
84
+ process.on("SIGINT", () => {
85
+ dumpStats()
86
+ process.exit(0)
87
+ })
88
+ }
@@ -0,0 +1,277 @@
1
+ import Reconciler from "react-reconciler"
2
+ import { createContext } from "react"
3
+ import type { HostInstance, HostContext } from "./types.js"
4
+ import { createHostInstance, createTextInstance, type RawTextHost, type BaseHost } from "../hosts/index.js"
5
+
6
+ // ============================================================================
7
+ // Type Definitions
8
+ // ============================================================================
9
+
10
+ /** Container is the root of our host tree */
11
+ export interface Container {
12
+ root: HostInstance | null
13
+ ctx: HostContext
14
+ }
15
+
16
+ /** Props passed to host instances */
17
+ type Props = Record<string, unknown>
18
+
19
+ /** Element type (e.g., "text", "vstack", "box") */
20
+ type Type = string
21
+
22
+ /** Our host instance type */
23
+ type Instance = BaseHost
24
+
25
+ /** Text instance type */
26
+ type TextInstance = RawTextHost
27
+
28
+ /** Host context passed during tree traversal */
29
+ interface ReconcilerHostContext {
30
+ isInsideText: boolean
31
+ }
32
+
33
+ // ============================================================================
34
+ // Priority Management
35
+ // ============================================================================
36
+
37
+ const NoEventPriority = 0
38
+ const DefaultEventPriority = 16
39
+ let currentUpdatePriority = NoEventPriority
40
+
41
+ // ============================================================================
42
+ // Host Config
43
+ // ============================================================================
44
+ // Note: We define methods with proper types but cast the final config to any
45
+ // because react-reconciler's HostConfig type has ~80 required methods and
46
+ // changes frequently between versions. This gives us type safety within
47
+ // each method while avoiding type compatibility issues with the reconciler.
48
+
49
+ const hostConfig = {
50
+ supportsMutation: true,
51
+ supportsPersistence: false,
52
+ supportsHydration: false,
53
+
54
+ createInstance(type: Type, props: Props, rootContainer: Container) {
55
+ return createHostInstance(type, props, rootContainer.ctx)
56
+ },
57
+
58
+ appendChild(parent: Instance, child: Instance) {
59
+ parent.appendChild(child)
60
+ },
61
+
62
+ removeChild(parent: Instance, child: Instance) {
63
+ parent.removeChild(child)
64
+ child.destroy()
65
+ },
66
+
67
+ insertBefore(parent: Instance, child: Instance, beforeChild: Instance) {
68
+ parent.insertBefore(child, beforeChild)
69
+ },
70
+
71
+ insertInContainerBefore(container: Container, child: Instance, beforeChild: Instance) {
72
+ if (container.root) {
73
+ container.root.insertBefore(child, beforeChild)
74
+ }
75
+ },
76
+
77
+ removeChildFromContainer(container: Container, child: Instance) {
78
+ if (container.root) {
79
+ container.root.removeChild(child)
80
+ child.destroy()
81
+ }
82
+ },
83
+
84
+ prepareForCommit() {
85
+ return null
86
+ },
87
+
88
+ resetAfterCommit(container: Container) {
89
+ container.ctx.requestRender()
90
+ },
91
+
92
+ getRootHostContext(): ReconcilerHostContext {
93
+ return { isInsideText: false }
94
+ },
95
+
96
+ getChildHostContext(parentHostContext: ReconcilerHostContext, type: Type): ReconcilerHostContext {
97
+ const isInsideText = type === "text" || parentHostContext.isInsideText
98
+ return { ...parentHostContext, isInsideText }
99
+ },
100
+
101
+ shouldSetTextContent() {
102
+ return false
103
+ },
104
+
105
+ createTextInstance(text: string, rootContainer: Container, hostContext: ReconcilerHostContext): TextInstance {
106
+ // Raw text nodes are only valid inside <text> elements
107
+ if (!hostContext.isInsideText) {
108
+ console.warn("Text nodes should be inside <text> elements")
109
+ }
110
+ return createTextInstance(text, rootContainer.ctx)
111
+ },
112
+
113
+ scheduleTimeout: setTimeout,
114
+ cancelTimeout: clearTimeout,
115
+ noTimeout: -1,
116
+
117
+ shouldAttemptEagerTransition() {
118
+ return false
119
+ },
120
+
121
+ finalizeInitialChildren(instance: Instance, _type: Type, props: Props) {
122
+ instance.updateProps(props)
123
+ return false
124
+ },
125
+
126
+ commitMount() {
127
+ // Could handle focus here
128
+ },
129
+
130
+ commitUpdate(
131
+ instance: Instance,
132
+ _updatePayload: unknown,
133
+ _type: Type,
134
+ _oldProps: Props,
135
+ newProps: Props | { pendingProps?: Props; memoizedProps?: Props },
136
+ ) {
137
+ // Handle case where newProps might be a Fiber node (react-reconciler API varies)
138
+ const props =
139
+ (newProps as { pendingProps?: Props }).pendingProps ??
140
+ (newProps as { memoizedProps?: Props }).memoizedProps ??
141
+ (newProps as Props)
142
+ instance.updateProps(props)
143
+ },
144
+
145
+ commitTextUpdate(textInstance: TextInstance, _oldText: string, newText: string) {
146
+ textInstance.updateText(newText)
147
+ },
148
+
149
+ appendChildToContainer(container: Container, child: Instance) {
150
+ // The first child becomes the root
151
+ container.root = child
152
+ child.parent = null
153
+ },
154
+
155
+ appendInitialChild(parent: Instance, child: Instance) {
156
+ parent.appendChild(child)
157
+ },
158
+
159
+ hideInstance() {
160
+ // Could implement visibility
161
+ },
162
+
163
+ unhideInstance() {
164
+ // Could implement visibility
165
+ },
166
+
167
+ hideTextInstance() {},
168
+
169
+ unhideTextInstance() {},
170
+
171
+ clearContainer(container: Container) {
172
+ container.root = null
173
+ },
174
+
175
+ setCurrentUpdatePriority(newPriority: number) {
176
+ currentUpdatePriority = newPriority
177
+ },
178
+
179
+ getCurrentUpdatePriority: () => currentUpdatePriority,
180
+
181
+ resolveUpdatePriority() {
182
+ if (currentUpdatePriority !== NoEventPriority) {
183
+ return currentUpdatePriority
184
+ }
185
+ return DefaultEventPriority
186
+ },
187
+
188
+ maySuspendCommit() {
189
+ return false
190
+ },
191
+
192
+ NotPendingTransition: null,
193
+
194
+ HostTransitionContext: createContext(null),
195
+
196
+ resetFormInstance() {},
197
+
198
+ requestPostPaintCallback() {},
199
+
200
+ trackSchedulerEvent() {},
201
+
202
+ resolveEventType() {
203
+ return null
204
+ },
205
+
206
+ resolveEventTimeStamp() {
207
+ return -1.1
208
+ },
209
+
210
+ preloadInstance() {
211
+ return true
212
+ },
213
+
214
+ startSuspendingCommit() {},
215
+
216
+ suspendInstance() {},
217
+
218
+ waitForCommitToBeReady() {
219
+ return null
220
+ },
221
+
222
+ detachDeletedInstance(instance: Instance) {
223
+ if (!instance.parent) {
224
+ instance.destroy()
225
+ }
226
+ },
227
+
228
+ getPublicInstance(instance: Instance) {
229
+ return instance
230
+ },
231
+
232
+ preparePortalMount() {},
233
+
234
+ isPrimaryRenderer: true,
235
+
236
+ getInstanceFromNode() {
237
+ return null
238
+ },
239
+
240
+ beforeActiveInstanceBlur() {},
241
+
242
+ afterActiveInstanceBlur() {},
243
+
244
+ prepareScopeUpdate() {},
245
+
246
+ getInstanceFromScope() {
247
+ return null
248
+ },
249
+
250
+ prepareUpdate() {
251
+ return true
252
+ },
253
+ }
254
+
255
+ // Cast to any at the boundary - the config is internally typed but
256
+ // react-reconciler's generic constraints are too strict for real-world usage
257
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
258
+ export const reconciler = Reconciler(hostConfig as any)
259
+
260
+ // ============================================================================
261
+ // Reconciler Helpers
262
+ // ============================================================================
263
+ // These helpers guard against API changes between react-reconciler versions
264
+
265
+ type ReconcilerWithOptionalMethods = typeof reconciler & {
266
+ flushPassiveEffects?: () => void
267
+ flushSync?: <T>(fn?: () => T) => T
268
+ batchedUpdates?: <T>(fn: () => T) => T
269
+ discreteUpdates?: <T>(fn: () => T) => T
270
+ }
271
+
272
+ const r = reconciler as ReconcilerWithOptionalMethods
273
+
274
+ export const flushPassiveEffects = r.flushPassiveEffects?.bind(r) ?? (() => {})
275
+ export const flushSync = r.flushSync?.bind(r) ?? (<T>(fn?: () => T) => fn?.())
276
+ export const batchedUpdates = r.batchedUpdates?.bind(r) ?? (<T>(fn: () => T) => fn())
277
+ export const discreteUpdates = r.discreteUpdates?.bind(r) ?? (<T>(fn: () => T) => fn())