@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,661 @@
1
+ import React, { createContext, useContext, useState, useEffect, type ReactNode } from "react"
2
+ import { performance } from "node:perf_hooks"
3
+ import { CellBuffer, Palette, decodeKeys, type KeyMsg } from "@effect-tui/core"
4
+ import { reconciler, type Container } from "./reconciler/host-config.js"
5
+ import type { HostContext } from "./reconciler/types.js"
6
+ import { ANSI, Terminal } from "./terminal.js"
7
+ import * as Prof from "./profiler.js"
8
+ import { DEFAULT_FPS } from "./constants.js"
9
+ import {
10
+ emitRowWithReset,
11
+ rowChanged,
12
+ rowContentWidth,
13
+ findChangeWindow,
14
+ contentHeight,
15
+ } from "./output.js"
16
+
17
+ /** Minimal write stream interface for renderer output */
18
+ export interface TuiWriteStream {
19
+ write(s: string): void
20
+ columns: number
21
+ rows: number
22
+ on(event: string, cb: () => void): void
23
+ }
24
+
25
+ /** Minimal read stream interface for renderer input */
26
+ export interface TuiReadStream {
27
+ isTTY?: boolean
28
+ setRawMode?(mode: boolean): void
29
+ resume?(): void
30
+ on(event: string, cb: (data: Buffer) => void): void
31
+ }
32
+
33
+ export interface TuiRenderer {
34
+ /** Terminal width */
35
+ width: number
36
+ /** Terminal height */
37
+ height: number
38
+ /** Request a re-render */
39
+ requestRender(): void
40
+ /** Subscribe to per-frame stats (if enabled). */
41
+ onFrameStats?(handler: (stats: FrameStats) => void): () => void
42
+ /** Subscribe to keyboard events */
43
+ onKey(handler: (key: KeyMsg) => void): () => void
44
+ /** Subscribe to paste events (bracketed paste mode). */
45
+ onPaste?(handler: (text: string) => void): () => void
46
+ /** Subscribe to resize events */
47
+ onResize(handler: (width: number, height: number) => void): () => void
48
+ /** Stop the renderer */
49
+ stop(): void
50
+ /** Manually trigger one render frame (only in manualMode) */
51
+ flush(): void
52
+ }
53
+
54
+ /** Internal renderer type with container reference */
55
+ interface TuiRendererInternal extends TuiRenderer {
56
+ _container: Container | null
57
+ }
58
+
59
+ // Context for accessing renderer in components
60
+ export const RendererContext = createContext<TuiRenderer | null>(null)
61
+
62
+ export function useRenderer(): TuiRenderer {
63
+ const renderer = useContext(RendererContext)
64
+ if (!renderer) {
65
+ throw new Error("useRenderer must be used within a TUI renderer")
66
+ }
67
+ return renderer
68
+ }
69
+
70
+ /** Hook that returns terminal size and re-renders on resize */
71
+ export function useTerminalSize(): { width: number; height: number } {
72
+ const renderer = useRenderer()
73
+ const [size, setSize] = useState({ width: renderer.width, height: renderer.height })
74
+
75
+ useEffect(() => {
76
+ return renderer.onResize((width, height) => {
77
+ setSize({ width, height })
78
+ })
79
+ }, [renderer])
80
+
81
+ return size
82
+ }
83
+
84
+ export interface RendererOptions {
85
+ fps?: number
86
+ stdout?: NodeJS.WriteStream | TuiWriteStream
87
+ stdin?: NodeJS.ReadStream | TuiReadStream
88
+ /** Render mode: "fullscreen" uses alternate buffer, "inline" renders in-place */
89
+ mode?: "fullscreen" | "inline"
90
+ /** Exit the process on Ctrl+C unless preventDefault was called. Defaults to true. */
91
+ exitOnCtrlC?: boolean
92
+ /** Enable diffed rendering (per-line). Defaults to true in runtime, false in manualMode (tests). */
93
+ diff?: boolean
94
+ /** Enable diffed rendering for inline mode (off by default; eraseLines baseline). */
95
+ diffInline?: boolean
96
+ /** Skip automatic render loop. Call flush() manually to render frames. */
97
+ manualMode?: boolean
98
+ /** Skip fullscreen/raw mode setup (for testing) */
99
+ skipTerminalSetup?: boolean
100
+ /** Enable bracketed paste (default true). */
101
+ enablePaste?: boolean
102
+ /** Optional per-frame diagnostics hook. Called after each frame is written. */
103
+ debug?: {
104
+ onFrame?: (stats: FrameStats) => void
105
+ }
106
+ }
107
+
108
+ export interface FrameStats {
109
+ mode: "fullscreen" | "inline"
110
+ width: number
111
+ height: number
112
+ contentHeight: number
113
+ bytes: number
114
+ frameMs: number
115
+ phases: {
116
+ clear: number
117
+ layout: number
118
+ render: number
119
+ diffAnsi: number
120
+ write: number
121
+ }
122
+ timestamp: number
123
+ }
124
+
125
+ export function createRenderer(options?: RendererOptions): TuiRenderer {
126
+ const fps = options?.fps ?? DEFAULT_FPS
127
+ const stdout: TuiWriteStream = options?.stdout ?? process.stdout
128
+ const stdin: TuiReadStream = options?.stdin ?? process.stdin
129
+ const mode = options?.mode ?? "fullscreen"
130
+ const exitOnCtrlC = options?.exitOnCtrlC ?? true
131
+ const manualMode = options?.manualMode ?? false
132
+ const enableDiff = options?.diff ?? !manualMode
133
+ const enableDiffInline = options?.diffInline ?? false
134
+ const skipTerminalSetup = options?.skipTerminalSetup ?? false
135
+ const enablePaste = options?.enablePaste ?? true
136
+ const debugHook = options?.debug?.onFrame
137
+ const PASTE_START = "\x1b[200~"
138
+ const PASTE_END = "\x1b[201~"
139
+ const PASTE_ENABLE = "\x1b[?2004h"
140
+ const PASTE_DISABLE = "\x1b[?2004l"
141
+
142
+ let width = stdout.columns || 80
143
+ let height = stdout.rows || 24
144
+ let lastWidth = width // Track for shrink detection (like Ink)
145
+ let dirty = true
146
+ let running = true
147
+ let previousHeight = 0 // For inline mode: track how many lines were rendered
148
+ const printedWidths = new Map<number, number>() // track rightmost printed col per row for inline diff
149
+ const keyHandlers = new Set<(key: KeyMsg) => void>()
150
+ const pasteHandlers = new Set<(text: string) => void>()
151
+ const resizeHandlers = new Set<(width: number, height: number) => void>()
152
+ const frameHandlers = new Set<(stats: FrameStats) => void>()
153
+ let pasteActive = false
154
+ let pasteBuffer = ""
155
+
156
+ const palette = new Palette()
157
+ let prevBuffer: CellBuffer | null = null
158
+ let nextBuffer: CellBuffer | null = null
159
+ let loop: ReturnType<typeof setInterval> | null = null
160
+
161
+ const teardown = () => {
162
+ if (skipTerminalSetup) return
163
+ if (mode === "fullscreen") {
164
+ stdout.write(Terminal.exitFullscreen)
165
+ } else {
166
+ stdout.write("\r\n")
167
+ }
168
+ if (enablePaste) {
169
+ stdout.write(PASTE_DISABLE)
170
+ }
171
+ stdout.write(Terminal.showCursor)
172
+ if (stdin.isTTY && stdin.setRawMode) {
173
+ stdin.setRawMode(false)
174
+ }
175
+ }
176
+
177
+ // The actual render logic, extracted for manual flushing
178
+ const renderFrame = () => {
179
+ const frameStart = Prof.startFrame()
180
+ const frameStartMs = performance.now()
181
+ let clearMs = 0
182
+ let layoutMs = 0
183
+ let renderMs = 0
184
+ let diffAnsiMs = 0
185
+ let writeMs = 0
186
+ let contentH = height
187
+ const container = (renderer as TuiRendererInternal)._container
188
+ const root = container?.root ?? null
189
+ if (!dirty || !root) return
190
+ dirty = false
191
+
192
+ // Ensure buffers exist and are correct size
193
+ if (!prevBuffer || !nextBuffer || prevBuffer.w !== width || prevBuffer.h !== height) {
194
+ prevBuffer = new CellBuffer(width, height)
195
+ nextBuffer = new CellBuffer(width, height)
196
+ prevBuffer.clear(0)
197
+ nextBuffer.clear(0)
198
+ }
199
+
200
+ // Clear next buffer
201
+ let t = Prof.startPhase()
202
+ {
203
+ const t0 = performance.now()
204
+ nextBuffer.clear(0)
205
+ clearMs = performance.now() - t0
206
+ }
207
+ Prof.endPhase("clear", t)
208
+
209
+ // Layout
210
+ t = Prof.startPhase()
211
+ {
212
+ const t0 = performance.now()
213
+ root.measure(width, height)
214
+ root.layout({ x: 0, y: 0, w: width, h: height })
215
+ layoutMs = performance.now() - t0
216
+ }
217
+ Prof.endPhase("layout", t)
218
+
219
+ // Render
220
+ t = Prof.startPhase()
221
+ {
222
+ const t0 = performance.now()
223
+ root.render(nextBuffer, palette)
224
+ renderMs = performance.now() - t0
225
+ }
226
+ Prof.endPhase("render", t)
227
+
228
+ // Output based on mode
229
+ t = Prof.startPhase()
230
+ const diffStartMs = performance.now()
231
+ let output = ""
232
+
233
+ if (mode === "fullscreen") {
234
+ // Fullscreen: optionally diff per line for minimal writes
235
+ if (enableDiff && prevBuffer) {
236
+ for (let y = 0; y < height; y++) {
237
+ if (!rowChanged(prevBuffer, nextBuffer, y, width)) continue
238
+ output += ANSI.cursor.to(1, y + 1)
239
+ output += ANSI.line.clear
240
+ output += emitRowWithReset(nextBuffer, palette, y, width)
241
+ }
242
+ } else {
243
+ // Full redraw (tests/manual mode)
244
+ stdout.write(ANSI.cursor.to(1, 1))
245
+ for (let y = 0; y < height; y++) {
246
+ output += emitRowWithReset(nextBuffer, palette, y, width)
247
+ if (y < height - 1) output += "\r\n"
248
+ }
249
+ }
250
+ } else {
251
+ // ============================================================
252
+ // INLINE MODE RENDERING
253
+ // ============================================================
254
+ // Renders in-place without alternate buffer. Key insights from Ink:
255
+ // - Track previousHeight to know how many lines to erase
256
+ // - Trim trailing spaces to prevent wrap on terminal shrink
257
+ // - Safety valve: clear terminal if content >= terminal height
258
+ // ============================================================
259
+
260
+ const newHeight = contentHeight(nextBuffer, width, height)
261
+ contentH = newHeight
262
+
263
+ // Safety valve: if previous content would overflow, clear terminal first
264
+ if (previousHeight >= height) {
265
+ stdout.write(ANSI.screen.clear + ANSI.cursor.to(1, 1))
266
+ previousHeight = 0
267
+ }
268
+
269
+ // Move cursor up to start of previous output (eraseLines pattern)
270
+ if (previousHeight > 0) {
271
+ output += ANSI.cursor.up(previousHeight)
272
+ output += ANSI.cursor.startOfLine
273
+ }
274
+
275
+ if (enableDiffInline && prevBuffer) {
276
+ // Diff-based inline rendering: only update changed regions
277
+ const rowsToProcess = Math.max(newHeight, previousHeight)
278
+
279
+ for (let y = 0; y < rowsToProcess; y++) {
280
+ // Rows beyond newHeight: clear if previously printed
281
+ if (y >= newHeight) {
282
+ if ((printedWidths.get(y) ?? 0) > 0) {
283
+ output += ANSI.cursor.to(y + 1, 1) + palette.sgr(0) + ANSI.line.clear
284
+ printedWidths.set(y, 0)
285
+ }
286
+ continue
287
+ }
288
+
289
+ const change = findChangeWindow(prevBuffer, nextBuffer, y, width)
290
+ const newW = rowContentWidth(nextBuffer, y, width)
291
+ const prevW = printedWidths.get(y) ?? 0
292
+
293
+ if (!change) {
294
+ // No change; maybe need to clear tail if content shrunk
295
+ if (prevW > newW) {
296
+ output += ANSI.cursor.to(y + 1, newW + 1) + palette.sgr(0) + ANSI.line.clearToEnd
297
+ printedWidths.set(y, newW)
298
+ }
299
+ continue
300
+ }
301
+
302
+ // Emit changed region [left..right]
303
+ output += ANSI.cursor.to(y + 1, change.left + 1)
304
+ output += emitRowWithReset(nextBuffer, palette, y, width, change.left, change.right + 1)
305
+
306
+ // Clear tail if shrunk
307
+ const effectiveW = Math.max(newW, change.right + 1)
308
+ if (prevW > effectiveW) {
309
+ output += ANSI.cursor.to(y + 1, effectiveW + 1) + ANSI.line.clearToEnd
310
+ }
311
+ printedWidths.set(y, effectiveW)
312
+ }
313
+
314
+ previousHeight = newHeight
315
+ } else {
316
+ // Full redraw inline: clear and emit each line, trimming trailing spaces
317
+ for (let y = 0; y < newHeight; y++) {
318
+ output += palette.sgr(0) + ANSI.line.clear
319
+ const trimmedWidth = rowContentWidth(nextBuffer, y, width)
320
+ output += emitRowWithReset(nextBuffer, palette, y, width, 0, trimmedWidth)
321
+ output += "\r\n"
322
+ }
323
+
324
+ // Clear any extra lines if content shrank
325
+ for (let y = newHeight; y < previousHeight; y++) {
326
+ output += palette.sgr(0) + ANSI.line.clear + "\r\n"
327
+ }
328
+
329
+ // Move cursor back up to end of content
330
+ if (previousHeight > newHeight) {
331
+ output += ANSI.cursor.up(previousHeight - newHeight)
332
+ }
333
+
334
+ previousHeight = newHeight
335
+ }
336
+ }
337
+
338
+ output += palette.sgr(0) // Reset style
339
+ diffAnsiMs = performance.now() - diffStartMs
340
+ Prof.endPhase("diff+ansi", t)
341
+
342
+ t = Prof.startPhase()
343
+ {
344
+ const t0 = performance.now()
345
+ stdout.write(output)
346
+ writeMs = performance.now() - t0
347
+ }
348
+ Prof.endPhase("write", t)
349
+
350
+ Prof.endFrame(frameStart)
351
+ const frameMs = performance.now() - frameStartMs
352
+
353
+ // Swap buffers
354
+ const tmp = prevBuffer
355
+ prevBuffer = nextBuffer
356
+ nextBuffer = tmp
357
+
358
+ const stats: FrameStats = {
359
+ mode,
360
+ width,
361
+ height,
362
+ contentHeight: contentH,
363
+ bytes: Buffer.byteLength(output, "utf8"),
364
+ frameMs,
365
+ phases: {
366
+ clear: clearMs,
367
+ layout: layoutMs,
368
+ render: renderMs,
369
+ diffAnsi: diffAnsiMs,
370
+ write: writeMs,
371
+ },
372
+ timestamp: performance.now(),
373
+ }
374
+
375
+ if (debugHook) debugHook(stats)
376
+ if (frameHandlers.size > 0) {
377
+ for (const handler of frameHandlers) handler(stats)
378
+ }
379
+ }
380
+
381
+ const renderer: TuiRenderer = {
382
+ get width() {
383
+ return width
384
+ },
385
+ get height() {
386
+ return height
387
+ },
388
+ requestRender() {
389
+ dirty = true
390
+ },
391
+ onKey(handler: (key: KeyMsg) => void) {
392
+ keyHandlers.add(handler)
393
+ return () => keyHandlers.delete(handler)
394
+ },
395
+ onPaste(handler: (text: string) => void) {
396
+ pasteHandlers.add(handler)
397
+ return () => pasteHandlers.delete(handler)
398
+ },
399
+ onResize(handler: (width: number, height: number) => void) {
400
+ resizeHandlers.add(handler)
401
+ return () => resizeHandlers.delete(handler)
402
+ },
403
+ onFrameStats(handler: (stats: FrameStats) => void) {
404
+ frameHandlers.add(handler)
405
+ return () => frameHandlers.delete(handler)
406
+ },
407
+ stop() {
408
+ running = false
409
+ if (loop) {
410
+ clearInterval(loop)
411
+ loop = null
412
+ }
413
+ teardown()
414
+ },
415
+ flush() {
416
+ renderFrame()
417
+ },
418
+ }
419
+
420
+ // Terminal setup (skip for testing)
421
+ if (!skipTerminalSetup) {
422
+ if (mode === "fullscreen") {
423
+ stdout.write(Terminal.enterFullscreen)
424
+ }
425
+ // Hide cursor during rendering (both modes)
426
+ stdout.write(Terminal.hideCursor)
427
+ if (enablePaste) stdout.write(PASTE_ENABLE)
428
+
429
+ if (stdin.isTTY && stdin.setRawMode) {
430
+ stdin.setRawMode(true)
431
+ stdin.resume?.()
432
+ }
433
+ }
434
+
435
+ // Handle keyboard input (and bracketed paste)
436
+ stdin.on("data", (data: Buffer) => {
437
+ let chunk = data.toString("utf8")
438
+
439
+ const emitKeys = (str: string) => {
440
+ if (!str) return
441
+ const keys = decodeKeys(Buffer.from(str, "utf8"))
442
+ for (const key of keys) {
443
+ const wrapped: KeyMsg = {
444
+ ...key,
445
+ defaultPrevented: false,
446
+ preventDefault() {
447
+ wrapped.defaultPrevented = true
448
+ },
449
+ }
450
+ for (const handler of keyHandlers) {
451
+ if (wrapped.defaultPrevented) break
452
+ handler(wrapped)
453
+ }
454
+
455
+ // Default Ctrl+C handling - exit unless user called preventDefault()
456
+ if (exitOnCtrlC && !wrapped.defaultPrevented && key.ctrl && key.text === "c") {
457
+ process.exit(0)
458
+ }
459
+ }
460
+ }
461
+
462
+ while (chunk.length > 0) {
463
+ if (pasteActive) {
464
+ const endIdx = chunk.indexOf(PASTE_END)
465
+ if (endIdx >= 0) {
466
+ pasteBuffer += chunk.slice(0, endIdx)
467
+ pasteHandlers.forEach((h) => h(pasteBuffer))
468
+ pasteBuffer = ""
469
+ pasteActive = false
470
+ chunk = chunk.slice(endIdx + PASTE_END.length)
471
+ continue
472
+ } else {
473
+ pasteBuffer += chunk
474
+ chunk = ""
475
+ break
476
+ }
477
+ }
478
+
479
+ const startIdx = chunk.indexOf(PASTE_START)
480
+ if (startIdx >= 0) {
481
+ // Emit any keys before the paste start
482
+ emitKeys(chunk.slice(0, startIdx))
483
+ pasteActive = true
484
+ pasteBuffer = ""
485
+ chunk = chunk.slice(startIdx + PASTE_START.length)
486
+ continue
487
+ }
488
+
489
+ // No paste markers; treat as normal keys
490
+ emitKeys(chunk)
491
+ chunk = ""
492
+ }
493
+
494
+ dirty = true
495
+ })
496
+
497
+ // Handle resize - render synchronously like Ink does
498
+ stdout.on("resize", () => {
499
+ const newWidth = stdout.columns || 80
500
+ const newHeight = stdout.rows || 24
501
+
502
+ // Fullscreen: clear entire screen on resize to prevent artifacts from terminal reflow
503
+ // The terminal may leave stale content that our diff-based rendering doesn't see
504
+ if (mode === "fullscreen") {
505
+ stdout.write(ANSI.screen.clear + ANSI.cursor.to(1, 1))
506
+ } else if (mode === "inline" && newWidth < lastWidth && previousHeight > 0) {
507
+ // Inline: on width shrink, previous content may have wrapped to MORE lines
508
+ // than previousHeight. Clear from content start to END OF SCREEN to catch all.
509
+ stdout.write(ANSI.cursor.up(previousHeight) + ANSI.cursor.startOfLine)
510
+ stdout.write(ANSI.screen.clearToEnd)
511
+ previousHeight = 0
512
+ }
513
+
514
+ width = newWidth
515
+ height = newHeight
516
+ lastWidth = newWidth
517
+ prevBuffer = null
518
+ nextBuffer = null
519
+ dirty = true
520
+
521
+ // Notify resize handlers
522
+ for (const handler of resizeHandlers) {
523
+ handler(newWidth, newHeight)
524
+ }
525
+
526
+ // Render immediately on resize (like Ink) to prevent cursor corruption
527
+ renderFrame()
528
+ })
529
+
530
+ // Automatic render loop (skip in manual mode)
531
+ if (!manualMode) {
532
+ const frameMs = 1000 / fps
533
+ loop = setInterval(() => {
534
+ if (!running) {
535
+ if (loop) clearInterval(loop)
536
+ teardown()
537
+ return
538
+ }
539
+ renderFrame()
540
+ }, frameMs)
541
+ }
542
+ // Store container reference for direct root access
543
+ ;(renderer as TuiRendererInternal)._container = null
544
+
545
+ return renderer
546
+ }
547
+
548
+ export interface Root {
549
+ render(element: ReactNode, sync?: boolean): void
550
+ unmount(): void
551
+ }
552
+
553
+ export function createRoot(renderer: TuiRenderer): Root {
554
+ const hostContext: HostContext = {
555
+ requestRender: () => renderer.requestRender(),
556
+ }
557
+
558
+ const container: Container = {
559
+ root: null,
560
+ ctx: hostContext,
561
+ }
562
+
563
+ const fiberRoot = reconciler.createContainer(
564
+ container,
565
+ 0, // LegacyRoot
566
+ null, // hydrationCallbacks
567
+ false, // isStrictMode
568
+ null, // concurrentUpdatesByDefaultOverride
569
+ "", // identifierPrefix
570
+ (err: Error) => console.error(err), // onUncaughtError
571
+ (err: Error) => console.error(err), // onCaughtError
572
+ (err: Error) => console.error(err), // onRecoverableError
573
+ () => {}, // onDefaultTransitionIndicator
574
+ null, // transitionCallbacks
575
+ )
576
+
577
+ // Give renderer direct access to container
578
+ ;(renderer as TuiRendererInternal)._container = container
579
+
580
+ const reconcilerAny: any = reconciler
581
+ const runSync = reconcilerAny.flushSync?.bind(reconcilerAny) ?? ((fn: () => void) => fn())
582
+
583
+ return {
584
+ render(element: ReactNode, sync = false) {
585
+ const wrapped = React.createElement(RendererContext.Provider, { value: renderer }, element)
586
+ if (sync) {
587
+ runSync(() => {
588
+ reconciler.updateContainer(wrapped, fiberRoot, null, null)
589
+ })
590
+ renderer.requestRender()
591
+ } else {
592
+ reconciler.updateContainer(wrapped, fiberRoot, null, () => {
593
+ renderer.requestRender()
594
+ })
595
+ }
596
+ },
597
+ unmount() {
598
+ reconciler.updateContainer(null, fiberRoot, null, () => {
599
+ renderer.stop()
600
+ })
601
+ },
602
+ }
603
+ }
604
+
605
+ // High-level convenience API (Ink-style)
606
+ export interface RenderInstance {
607
+ renderer: TuiRenderer
608
+ root: Root
609
+ rerender(element: ReactNode): void
610
+ unmount(): void
611
+ waitUntilExit(): Promise<void>
612
+ }
613
+
614
+ /**
615
+ * Render a React tree to the terminal in one call.
616
+ * Returns helpers similar to Ink: rerender, unmount, waitUntilExit.
617
+ */
618
+ export function render(element: ReactNode, options?: RendererOptions): RenderInstance {
619
+ const renderer = createRenderer(options)
620
+ const root = createRoot(renderer)
621
+
622
+ // Initial render (sync to avoid flicker before the loop runs)
623
+ root.render(element, true)
624
+
625
+ let resolved = false
626
+ let resolveExit: (() => void) | null = null
627
+ const exitPromise = new Promise<void>((resolve) => {
628
+ resolveExit = () => {
629
+ if (resolved) return
630
+ resolved = true
631
+ resolve()
632
+ }
633
+ })
634
+
635
+ const onExit = () => {
636
+ if (!resolved) {
637
+ renderer.stop()
638
+ resolveExit?.()
639
+ }
640
+ }
641
+ process.once("exit", onExit)
642
+
643
+ const unmount = () => {
644
+ process.off("exit", onExit)
645
+ renderer.stop()
646
+ resolveExit?.()
647
+ }
648
+
649
+ const rerender = (next: ReactNode) => {
650
+ if (resolved) return
651
+ root.render(next)
652
+ }
653
+
654
+ return {
655
+ renderer,
656
+ root,
657
+ rerender,
658
+ unmount,
659
+ waitUntilExit: () => exitPromise,
660
+ }
661
+ }