@effect-tui/react 0.15.2 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (249) hide show
  1. package/README.md +2 -2
  2. package/dist/src/components/ListView.d.ts +4 -4
  3. package/dist/src/components/ListView.d.ts.map +1 -1
  4. package/dist/src/components/ListView.js +16 -17
  5. package/dist/src/components/ListView.js.map +1 -1
  6. package/dist/src/console/ConsolePopover.d.ts +7 -1
  7. package/dist/src/console/ConsolePopover.d.ts.map +1 -1
  8. package/dist/src/console/ConsolePopover.js +55 -74
  9. package/dist/src/console/ConsolePopover.js.map +1 -1
  10. package/dist/src/debug/DebugOverlay.d.ts.map +1 -1
  11. package/dist/src/debug/DebugOverlay.js +3 -57
  12. package/dist/src/debug/DebugOverlay.js.map +1 -1
  13. package/dist/src/debug/DiagnosticsPanel.js +1 -1
  14. package/dist/src/debug/DiagnosticsPanel.js.map +1 -1
  15. package/dist/src/dev.d.ts +5 -117
  16. package/dist/src/dev.d.ts.map +1 -1
  17. package/dist/src/dev.js +3 -333
  18. package/dist/src/dev.js.map +1 -1
  19. package/dist/src/hooks/use-scroll.d.ts +31 -35
  20. package/dist/src/hooks/use-scroll.d.ts.map +1 -1
  21. package/dist/src/hooks/use-scroll.js +51 -90
  22. package/dist/src/hooks/use-scroll.js.map +1 -1
  23. package/dist/src/hosts/canvas.d.ts +2 -2
  24. package/dist/src/hosts/canvas.d.ts.map +1 -1
  25. package/dist/src/hosts/canvas.js +8 -10
  26. package/dist/src/hosts/canvas.js.map +1 -1
  27. package/dist/src/hosts/codeblock.d.ts +2 -2
  28. package/dist/src/hosts/codeblock.js +2 -2
  29. package/dist/src/hosts/flex-container.d.ts +1 -1
  30. package/dist/src/hosts/flex-container.d.ts.map +1 -1
  31. package/dist/src/hosts/flex-container.js +3 -3
  32. package/dist/src/hosts/flex-container.js.map +1 -1
  33. package/dist/src/hosts/index.d.ts +2 -1
  34. package/dist/src/hosts/index.d.ts.map +1 -1
  35. package/dist/src/hosts/index.js +2 -1
  36. package/dist/src/hosts/index.js.map +1 -1
  37. package/dist/src/hosts/layout-helpers.d.ts +10 -0
  38. package/dist/src/hosts/layout-helpers.d.ts.map +1 -0
  39. package/dist/src/hosts/layout-helpers.js +10 -0
  40. package/dist/src/hosts/layout-helpers.js.map +1 -0
  41. package/dist/src/hosts/leaf.d.ts +14 -0
  42. package/dist/src/hosts/leaf.d.ts.map +1 -0
  43. package/dist/src/hosts/leaf.js +31 -0
  44. package/dist/src/hosts/leaf.js.map +1 -0
  45. package/dist/src/hosts/overlay.d.ts.map +1 -1
  46. package/dist/src/hosts/overlay.js +4 -7
  47. package/dist/src/hosts/overlay.js.map +1 -1
  48. package/dist/src/hosts/scroll.d.ts +47 -24
  49. package/dist/src/hosts/scroll.d.ts.map +1 -1
  50. package/dist/src/hosts/scroll.js +68 -51
  51. package/dist/src/hosts/scroll.js.map +1 -1
  52. package/dist/src/hosts/spacer.d.ts +2 -2
  53. package/dist/src/hosts/spacer.js +2 -2
  54. package/dist/src/hosts/text.d.ts +2 -3
  55. package/dist/src/hosts/text.d.ts.map +1 -1
  56. package/dist/src/hosts/text.js +5 -61
  57. package/dist/src/hosts/text.js.map +1 -1
  58. package/dist/src/hosts/vstack.js +1 -1
  59. package/dist/src/hosts/vstack.js.map +1 -1
  60. package/dist/src/hosts/zstack.d.ts +1 -1
  61. package/dist/src/hosts/zstack.d.ts.map +1 -1
  62. package/dist/src/hosts/zstack.js +6 -6
  63. package/dist/src/hosts/zstack.js.map +1 -1
  64. package/dist/src/index.d.ts +1 -1
  65. package/dist/src/index.d.ts.map +1 -1
  66. package/dist/src/internal/dev/hmr.d.ts +20 -0
  67. package/dist/src/internal/dev/hmr.d.ts.map +1 -0
  68. package/dist/src/internal/dev/hmr.js +93 -0
  69. package/dist/src/internal/dev/hmr.js.map +1 -0
  70. package/dist/src/internal/dev/runtime.d.ts +24 -0
  71. package/dist/src/internal/dev/runtime.d.ts.map +1 -0
  72. package/dist/src/internal/dev/runtime.js +135 -0
  73. package/dist/src/internal/dev/runtime.js.map +1 -0
  74. package/dist/src/internal/dev/ui.d.ts +13 -0
  75. package/dist/src/internal/dev/ui.d.ts.map +1 -0
  76. package/dist/src/internal/dev/ui.js +51 -0
  77. package/dist/src/internal/dev/ui.js.map +1 -0
  78. package/dist/src/internal/renderer/context.d.ts +9 -0
  79. package/dist/src/internal/renderer/context.d.ts.map +1 -0
  80. package/dist/src/internal/renderer/context.js +22 -0
  81. package/dist/src/internal/renderer/context.js.map +1 -0
  82. package/dist/src/internal/renderer/core/FrameBuilder.d.ts +18 -0
  83. package/dist/src/internal/renderer/core/FrameBuilder.d.ts.map +1 -0
  84. package/dist/src/internal/renderer/core/FrameBuilder.js +40 -0
  85. package/dist/src/internal/renderer/core/FrameBuilder.js.map +1 -0
  86. package/dist/src/internal/renderer/core/RendererState.d.ts +41 -0
  87. package/dist/src/internal/renderer/core/RendererState.d.ts.map +1 -0
  88. package/dist/src/internal/renderer/core/RendererState.js +70 -0
  89. package/dist/src/internal/renderer/core/RendererState.js.map +1 -0
  90. package/dist/src/internal/renderer/core/index.d.ts +3 -0
  91. package/dist/src/internal/renderer/core/index.d.ts.map +1 -0
  92. package/dist/src/internal/renderer/core/index.js +3 -0
  93. package/dist/src/internal/renderer/core/index.js.map +1 -0
  94. package/dist/src/internal/renderer/index.d.ts +40 -0
  95. package/dist/src/internal/renderer/index.d.ts.map +1 -0
  96. package/dist/src/internal/renderer/index.js +518 -0
  97. package/dist/src/internal/renderer/index.js.map +1 -0
  98. package/dist/src/internal/renderer/input/InputProcessor.d.ts +30 -0
  99. package/dist/src/internal/renderer/input/InputProcessor.d.ts.map +1 -0
  100. package/dist/src/internal/renderer/input/InputProcessor.js +122 -0
  101. package/dist/src/internal/renderer/input/InputProcessor.js.map +1 -0
  102. package/dist/src/internal/renderer/input/index.d.ts +2 -0
  103. package/dist/src/internal/renderer/input/index.d.ts.map +1 -0
  104. package/dist/src/internal/renderer/input/index.js +2 -0
  105. package/dist/src/internal/renderer/input/index.js.map +1 -0
  106. package/dist/src/internal/renderer/lifecycle/EventBus.d.ts +42 -0
  107. package/dist/src/internal/renderer/lifecycle/EventBus.d.ts.map +1 -0
  108. package/dist/src/internal/renderer/lifecycle/EventBus.js +97 -0
  109. package/dist/src/internal/renderer/lifecycle/EventBus.js.map +1 -0
  110. package/dist/src/internal/renderer/lifecycle/ProcessLifecycle.d.ts +13 -0
  111. package/dist/src/internal/renderer/lifecycle/ProcessLifecycle.d.ts.map +1 -0
  112. package/dist/src/internal/renderer/lifecycle/ProcessLifecycle.js +111 -0
  113. package/dist/src/internal/renderer/lifecycle/ProcessLifecycle.js.map +1 -0
  114. package/dist/src/internal/renderer/lifecycle/RenderCache.d.ts +3 -0
  115. package/dist/src/internal/renderer/lifecycle/RenderCache.d.ts.map +1 -0
  116. package/dist/src/internal/renderer/lifecycle/RenderCache.js +9 -0
  117. package/dist/src/internal/renderer/lifecycle/RenderCache.js.map +1 -0
  118. package/dist/src/internal/renderer/lifecycle/index.d.ts +4 -0
  119. package/dist/src/internal/renderer/lifecycle/index.d.ts.map +1 -0
  120. package/dist/src/internal/renderer/lifecycle/index.js +4 -0
  121. package/dist/src/internal/renderer/lifecycle/index.js.map +1 -0
  122. package/dist/src/internal/renderer/modes/FullscreenRenderer.d.ts +12 -0
  123. package/dist/src/internal/renderer/modes/FullscreenRenderer.d.ts.map +1 -0
  124. package/dist/src/internal/renderer/modes/FullscreenRenderer.js +54 -0
  125. package/dist/src/internal/renderer/modes/FullscreenRenderer.js.map +1 -0
  126. package/dist/src/internal/renderer/modes/InlineRenderer.d.ts +25 -0
  127. package/dist/src/internal/renderer/modes/InlineRenderer.d.ts.map +1 -0
  128. package/dist/src/internal/renderer/modes/InlineRenderer.js +166 -0
  129. package/dist/src/internal/renderer/modes/InlineRenderer.js.map +1 -0
  130. package/dist/src/internal/renderer/modes/RendererMode.d.ts +42 -0
  131. package/dist/src/internal/renderer/modes/RendererMode.d.ts.map +1 -0
  132. package/dist/src/internal/renderer/modes/RendererMode.js +2 -0
  133. package/dist/src/internal/renderer/modes/RendererMode.js.map +1 -0
  134. package/dist/src/internal/renderer/modes/StaticContentRenderer.d.ts +25 -0
  135. package/dist/src/internal/renderer/modes/StaticContentRenderer.d.ts.map +1 -0
  136. package/dist/src/internal/renderer/modes/StaticContentRenderer.js +49 -0
  137. package/dist/src/internal/renderer/modes/StaticContentRenderer.js.map +1 -0
  138. package/dist/src/internal/renderer/modes/index.d.ts +5 -0
  139. package/dist/src/internal/renderer/modes/index.d.ts.map +1 -0
  140. package/dist/src/internal/renderer/modes/index.js +4 -0
  141. package/dist/src/internal/renderer/modes/index.js.map +1 -0
  142. package/dist/src/internal/renderer/terminal/KeyboardCapabilityProbe.d.ts +13 -0
  143. package/dist/src/internal/renderer/terminal/KeyboardCapabilityProbe.d.ts.map +1 -0
  144. package/dist/src/internal/renderer/terminal/KeyboardCapabilityProbe.js +75 -0
  145. package/dist/src/internal/renderer/terminal/KeyboardCapabilityProbe.js.map +1 -0
  146. package/dist/src/internal/renderer/terminal/TerminalSetup.d.ts +29 -0
  147. package/dist/src/internal/renderer/terminal/TerminalSetup.d.ts.map +1 -0
  148. package/dist/src/internal/renderer/terminal/TerminalSetup.js +82 -0
  149. package/dist/src/internal/renderer/terminal/TerminalSetup.js.map +1 -0
  150. package/dist/src/internal/renderer/terminal/index.d.ts +3 -0
  151. package/dist/src/internal/renderer/terminal/index.d.ts.map +1 -0
  152. package/dist/src/internal/renderer/terminal/index.js +3 -0
  153. package/dist/src/internal/renderer/terminal/index.js.map +1 -0
  154. package/dist/src/internal/renderer/types.d.ts +118 -0
  155. package/dist/src/internal/renderer/types.d.ts.map +1 -0
  156. package/dist/src/internal/renderer/types.js +2 -0
  157. package/dist/src/internal/renderer/types.js.map +1 -0
  158. package/dist/src/renderer-context.d.ts +1 -8
  159. package/dist/src/renderer-context.d.ts.map +1 -1
  160. package/dist/src/renderer-context.js +1 -21
  161. package/dist/src/renderer-context.js.map +1 -1
  162. package/dist/src/renderer-types.d.ts +1 -115
  163. package/dist/src/renderer-types.d.ts.map +1 -1
  164. package/dist/src/renderer.d.ts +1 -31
  165. package/dist/src/renderer.d.ts.map +1 -1
  166. package/dist/src/renderer.js +1 -495
  167. package/dist/src/renderer.js.map +1 -1
  168. package/dist/src/test/render-tui.d.ts +3 -3
  169. package/dist/src/test/render-tui.d.ts.map +1 -1
  170. package/dist/src/test/render-tui.js +16 -9
  171. package/dist/src/test/render-tui.js.map +1 -1
  172. package/dist/src/utils/alignment.d.ts +1 -1
  173. package/dist/src/utils/alignment.d.ts.map +1 -1
  174. package/dist/src/utils/alignment.js +0 -2
  175. package/dist/src/utils/alignment.js.map +1 -1
  176. package/dist/src/utils/console-helpers.d.ts +19 -0
  177. package/dist/src/utils/console-helpers.d.ts.map +1 -0
  178. package/dist/src/utils/console-helpers.js +61 -0
  179. package/dist/src/utils/console-helpers.js.map +1 -0
  180. package/dist/src/utils/index.d.ts +1 -1
  181. package/dist/src/utils/index.d.ts.map +1 -1
  182. package/dist/src/utils/index.js +1 -1
  183. package/dist/src/utils/index.js.map +1 -1
  184. package/dist/src/utils/styles.d.ts +8 -1
  185. package/dist/src/utils/styles.d.ts.map +1 -1
  186. package/dist/src/utils/styles.js +10 -8
  187. package/dist/src/utils/styles.js.map +1 -1
  188. package/dist/src/utils/text-wrap.d.ts +5 -0
  189. package/dist/src/utils/text-wrap.d.ts.map +1 -1
  190. package/dist/src/utils/text-wrap.js +110 -48
  191. package/dist/src/utils/text-wrap.js.map +1 -1
  192. package/dist/src/visualize/index.js +1 -1
  193. package/dist/src/visualize/index.js.map +1 -1
  194. package/dist/tsconfig.tsbuildinfo +1 -1
  195. package/package.json +2 -2
  196. package/src/components/ListView.tsx +21 -23
  197. package/src/console/ConsolePopover.tsx +124 -107
  198. package/src/debug/DebugOverlay.ts +15 -74
  199. package/src/debug/DiagnosticsPanel.tsx +1 -1
  200. package/src/dev.tsx +5 -458
  201. package/src/hooks/use-scroll.ts +85 -145
  202. package/src/hosts/canvas.ts +8 -11
  203. package/src/hosts/codeblock.ts +2 -2
  204. package/src/hosts/flex-container.ts +4 -4
  205. package/src/hosts/index.ts +10 -1
  206. package/src/hosts/layout-helpers.ts +20 -0
  207. package/src/hosts/leaf.ts +36 -0
  208. package/src/hosts/overlay.ts +11 -9
  209. package/src/hosts/scroll.ts +94 -69
  210. package/src/hosts/spacer.ts +2 -2
  211. package/src/hosts/text.ts +5 -58
  212. package/src/hosts/vstack.ts +1 -1
  213. package/src/hosts/zstack.ts +7 -7
  214. package/src/index.ts +1 -1
  215. package/src/internal/dev/hmr.ts +101 -0
  216. package/src/internal/dev/runtime.ts +170 -0
  217. package/src/internal/dev/ui.tsx +87 -0
  218. package/src/internal/renderer/context.ts +27 -0
  219. package/src/{renderer → internal/renderer}/core/FrameBuilder.ts +2 -2
  220. package/src/internal/renderer/index.ts +656 -0
  221. package/src/{renderer → internal/renderer}/input/InputProcessor.ts +10 -1
  222. package/src/{renderer → internal/renderer}/lifecycle/EventBus.ts +9 -1
  223. package/src/internal/renderer/lifecycle/ProcessLifecycle.ts +125 -0
  224. package/src/internal/renderer/lifecycle/index.ts +3 -0
  225. package/src/{renderer → internal/renderer}/modes/InlineRenderer.ts +5 -2
  226. package/src/{renderer → internal/renderer}/modes/RendererMode.ts +1 -1
  227. package/src/{renderer → internal/renderer}/modes/StaticContentRenderer.ts +5 -2
  228. package/src/internal/renderer/terminal/KeyboardCapabilityProbe.ts +91 -0
  229. package/src/{renderer/lifecycle → internal/renderer/terminal}/TerminalSetup.ts +4 -22
  230. package/src/internal/renderer/terminal/index.ts +2 -0
  231. package/src/internal/renderer/types.ts +125 -0
  232. package/src/renderer-context.ts +1 -27
  233. package/src/renderer-types.ts +10 -123
  234. package/src/renderer.ts +1 -619
  235. package/src/test/render-tui.ts +16 -10
  236. package/src/utils/alignment.ts +1 -3
  237. package/src/utils/console-helpers.ts +86 -0
  238. package/src/utils/index.ts +1 -1
  239. package/src/utils/styles.ts +16 -4
  240. package/src/utils/text-wrap.ts +139 -48
  241. package/src/visualize/index.tsx +1 -1
  242. package/src/renderer/lifecycle/ResizeManager.ts +0 -65
  243. package/src/renderer/lifecycle/index.ts +0 -4
  244. /package/src/{renderer → internal/renderer}/core/RendererState.ts +0 -0
  245. /package/src/{renderer → internal/renderer}/core/index.ts +0 -0
  246. /package/src/{renderer → internal/renderer}/input/index.ts +0 -0
  247. /package/src/{renderer → internal/renderer}/lifecycle/RenderCache.ts +0 -0
  248. /package/src/{renderer → internal/renderer}/modes/FullscreenRenderer.ts +0 -0
  249. /package/src/{renderer → internal/renderer}/modes/index.ts +0 -0
package/src/renderer.ts CHANGED
@@ -1,619 +1 @@
1
- import { performance } from "node:perf_hooks"
2
- import { fileURLToPath } from "node:url"
3
- import { ANSI, bufferToString, type KeyMsg, type MouseMsg } from "@effect-tui/core"
4
- import React, { type ReactNode } from "react"
5
- import { createTerminalWriter, writeToTerminal } from "./console/ConsoleCapture.js"
6
- import { DEFAULT_FPS } from "./constants.js"
7
- import { requestExit } from "./exit.js"
8
- import * as Prof from "./profiler.js"
9
- import { flushSync, reconciler } from "./reconciler/host-config.js"
10
- import type { HostContext } from "./reconciler/types.js"
11
- // Extracted modules
12
- import { FrameBuilder, RendererState } from "./renderer/core/index.js"
13
- import { InputProcessor } from "./renderer/input/index.js"
14
- import { EventBus, getRenderCache, TerminalSetup } from "./renderer/lifecycle/index.js"
15
- import { FullscreenRenderer, InlineRenderer, StaticContentRenderer } from "./renderer/modes/index.js"
16
- import { RendererContext } from "./renderer-context.js"
17
- import { startDevRuntime, type DevOptions } from "./dev.js"
18
- import type {
19
- Container,
20
- FrameStats,
21
- PasteMsg,
22
- RendererOptions,
23
- TuiReadStream,
24
- TuiRenderer,
25
- TuiRendererInternal,
26
- TuiWriteStream,
27
- } from "./renderer-types.js"
28
-
29
- export { RendererContext, useRenderer, useTerminalSize } from "./renderer-context.js"
30
- // Re-export types and context for backwards compatibility
31
- export type {
32
- FrameStats,
33
- PasteMsg,
34
- RendererOptions,
35
- TuiReadStream,
36
- TuiRenderer,
37
- TuiWriteStream,
38
- } from "./renderer-types.js"
39
-
40
- type HandledSignal = "SIGINT" | "SIGTERM"
41
-
42
- export function createRenderer(options?: RendererOptions): TuiRenderer {
43
- const fps = options?.fps ?? DEFAULT_FPS
44
- // Use custom stdout if provided, otherwise use process.stdout with bypassed capture
45
- let stdout: TuiWriteStream
46
- if (options?.stdout) {
47
- // Use custom stdout as-is (e.g., for testing with MockStdout)
48
- stdout = options.stdout
49
- } else {
50
- // Create a proxy that bypasses console capture for writes
51
- // This ensures Effect.log etc. don't corrupt the TUI
52
- const terminalWrite = createTerminalWriter()
53
- stdout = new Proxy(process.stdout as TuiWriteStream, {
54
- get(target, prop) {
55
- if (prop === "write") {
56
- return terminalWrite
57
- }
58
- const value = (target as unknown as Record<string | symbol, unknown>)[prop]
59
- // Bind methods to preserve `this` context (critical for .on(), .removeListener(), etc.)
60
- if (typeof value === "function") {
61
- return value.bind(target)
62
- }
63
- return value
64
- },
65
- })
66
- }
67
- const stdin: TuiReadStream = options?.stdin ?? process.stdin
68
- const mode = options?.mode ?? "fullscreen"
69
- const exitOnCtrlC = options?.exitOnCtrlC ?? true
70
- const handleSignals = options?.handleSignals ?? true
71
- const exitOnSignal = options?.exitOnSignal ?? true
72
- const signalExitCodes: Record<HandledSignal, number> = {
73
- SIGINT: 130,
74
- SIGTERM: 143,
75
- ...options?.signalExitCodes,
76
- }
77
- const manualMode = options?.manualMode ?? false
78
- const enableDiff = options?.diff ?? !manualMode
79
- const skipTerminalSetup = options?.skipTerminalSetup ?? false
80
- const enablePaste = options?.enablePaste ?? true
81
- const enableMouse = options?.enableMouse ?? mode === "fullscreen"
82
- const debugHook = options?.debug?.onFrame
83
-
84
- // Initialize state
85
- const state = new RendererState(stdout.columns || 80, stdout.rows || 24)
86
- const events = new EventBus()
87
- const frameBuilder = new FrameBuilder()
88
-
89
- // Terminal setup/teardown
90
- const terminal = new TerminalSetup(stdout, stdin, {
91
- mode,
92
- enablePaste,
93
- enableMouse,
94
- skipTerminalSetup,
95
- })
96
-
97
- // Render mode (fullscreen or inline)
98
- const renderMode = mode === "fullscreen" ? new FullscreenRenderer() : new InlineRenderer()
99
-
100
- // Static content renderer (inline mode only)
101
- const staticRenderer = mode === "inline" ? new StaticContentRenderer(stdout, state.palette) : null
102
-
103
- // Input processing
104
- const inputProcessor = new InputProcessor({
105
- exitOnCtrlC,
106
- dispatchKey: (key) => {
107
- events.dispatchKey(key)
108
- return key.defaultPrevented ?? false
109
- },
110
- dispatchMouse: (mouse) => events.dispatchMouse(mouse),
111
- dispatchPaste: (text) => events.dispatchPaste(text),
112
- flushSync: (fn) => flushSync(fn) ?? (undefined as never),
113
- onInputProcessed: () => {
114
- if (!manualMode) renderFrame()
115
- },
116
- onQuit: () => {
117
- // Clean up terminal state before exiting
118
- renderer.stop()
119
- requestExit(0)
120
- },
121
- })
122
-
123
- const handleInlineFullRerender = () => {
124
- if (mode !== "inline" || !staticRenderer) return
125
- const inlineMode = renderMode as InlineRenderer
126
- if (!inlineMode.needsFullRerender()) return
127
-
128
- // Clear screen + scrollback + cursor home
129
- stdout.write(ANSI.screen.clear + ANSI.screen.clearScrollback + ANSI.cursor.home)
130
- // Replay all cached static content
131
- const cachedStatic = staticRenderer.getCachedOutput()
132
- if (cachedStatic) {
133
- stdout.write(cachedStatic)
134
- }
135
-
136
- // Reset state
137
- inlineMode.clearFullRerenderFlag()
138
- state.invalidateBuffers()
139
- }
140
-
141
- const flushInlineStatic = (container: Container | null, frameWidth: number) => {
142
- if (mode !== "inline" || !container?.staticDirty || !container.staticRoot || !staticRenderer) {
143
- return ""
144
- }
145
-
146
- const inlineMode = renderMode as InlineRenderer
147
- const prevHeight = inlineMode.getPreviousHeight()
148
- let output = ""
149
-
150
- // Step 1: Clear the dynamic area (move up + clear to end of screen)
151
- if (prevHeight > 0) {
152
- output += ANSI.cursor.up(prevHeight) + ANSI.cursor.startOfLine + ANSI.screen.clearToEnd
153
- }
154
-
155
- // Step 2: Append static content (cursor ends at bottom of static)
156
- output += staticRenderer.render(container.staticRoot, frameWidth)
157
-
158
- // Step 3: Reset previousHeight to 0 (we cleared dynamic, starting fresh)
159
- inlineMode.reset()
160
- inlineMode.forceFullOutputOnce() // Force full output to resync cursor tracking after static
161
- state.invalidateBuffers()
162
- container.staticDirty = false
163
-
164
- return output
165
- }
166
-
167
- // The render frame logic
168
- const renderFrame = () => {
169
- const frameStart = Prof.startFrame()
170
- const frameStartMs = performance.now()
171
- // Flush any pending React updates before measuring/layout.
172
- // Ensures resize-driven state (useTerminalSize) is reflected in the host tree.
173
- flushSync(() => {})
174
- const frameWidth = state.width
175
- const frameHeight = state.height
176
- let contentH = frameHeight
177
-
178
- const container = (renderer as TuiRendererInternal)._container
179
- const root = container?.root ?? null
180
-
181
- // Must render if dirty OR if static content needs flushing
182
- if ((!state.dirty && !container?.staticDirty) || !root) return
183
- state.dirty = false
184
-
185
- try {
186
- // Handle full rerender on resize (Ink-style: clear everything + replay static)
187
- handleInlineFullRerender()
188
-
189
- // Handle static content: clear dynamic area, append static, then fresh dynamic render
190
- // Note: IL (insert lines) won't work here because inline mode uses relative positioning
191
- // and IL would desync the screen state from our buffer tracking.
192
- const staticOutput = flushInlineStatic(container ?? null, frameWidth)
193
-
194
- // For inline mode, measure content unconstrained to handle overflow
195
- let actualContentHeight = frameHeight
196
- if (mode === "inline") {
197
- const size = root.measure(frameWidth, Number.MAX_SAFE_INTEGER)
198
- actualContentHeight = size.h
199
- }
200
-
201
- // Buffer height: content height for inline (to capture all content), terminal height for fullscreen
202
- const bufferHeight = mode === "inline" ? Math.max(actualContentHeight, frameHeight) : frameHeight
203
-
204
- // Ensure buffers exist
205
- state.ensureBuffers(frameWidth, bufferHeight)
206
- if (!state.nextBuffer) return
207
-
208
- // Build frame (clear, layout, render)
209
- const timings = frameBuilder.build(root, state.nextBuffer, state.palette, frameWidth, bufferHeight)
210
-
211
- // Generate output
212
- const t = Prof.startPhase()
213
- const diffStartMs = performance.now()
214
-
215
- const { output: modeOutput, contentHeight } = renderMode.generateOutput({
216
- nextBuffer: state.nextBuffer,
217
- prevBuffer: state.prevBuffer,
218
- palette: state.palette,
219
- frameWidth,
220
- frameHeight,
221
- contentHeight: actualContentHeight,
222
- enableDiff,
223
- stdout,
224
- })
225
-
226
- // Combine static + dynamic output for atomic write
227
- // Wrap in synchronized output mode (DECSET 2026) to prevent tearing
228
- // Terminals that don't support it will safely ignore these sequences
229
- const output = ANSI.sync.begin + staticOutput + modeOutput + state.palette.sgr(0) + ANSI.sync.end
230
- contentH = contentHeight
231
- const diffAnsiMs = performance.now() - diffStartMs
232
- Prof.endPhase("diff+ansi", t)
233
-
234
- // Write output (single atomic write prevents visual glitches)
235
- const writeT = Prof.startPhase()
236
- const writeStart = performance.now()
237
- stdout.write(output)
238
- const writeMs = performance.now() - writeStart
239
- Prof.endPhase("write", writeT)
240
-
241
- Prof.endFrame(frameStart)
242
- const frameMs = performance.now() - frameStartMs
243
-
244
- // Swap buffers
245
- state.swapBuffers()
246
-
247
- // Build stats
248
- const stats: FrameStats = {
249
- mode,
250
- width: state.width,
251
- height: state.height,
252
- contentHeight: contentH,
253
- bytes: Buffer.byteLength(output, "utf8"),
254
- frameMs,
255
- phases: {
256
- clear: timings.clear,
257
- layout: timings.layout,
258
- render: timings.render,
259
- diffAnsi: diffAnsiMs,
260
- write: writeMs,
261
- },
262
- timestamp: performance.now(),
263
- }
264
-
265
- if (debugHook) debugHook(stats)
266
- if (events.hasFrameHandlers) events.dispatchFrame(stats)
267
- } catch (err) {
268
- console.error("[effect-tui] Render error:", err)
269
- state.markDirty()
270
- }
271
- }
272
-
273
- let onExit: (() => void) | null = null
274
- let onSignal: ((signal: NodeJS.Signals) => void) | null = null
275
- let onUncaughtException: ((err: Error) => void) | null = null
276
- let onUnhandledRejection: ((reason: unknown) => void) | null = null
277
-
278
- // Build renderer object
279
- const renderer: TuiRenderer = {
280
- get width() {
281
- return state.width
282
- },
283
- get height() {
284
- return state.height
285
- },
286
- requestRender() {
287
- state.markDirty()
288
- },
289
- onKey: (handler: (key: KeyMsg) => void) => events.onKey(handler),
290
- onMouse: (handler: (mouse: MouseMsg) => void) => events.onMouse(handler),
291
- onPaste: (handler: (paste: PasteMsg) => void) => events.onPaste(handler),
292
- onResize: (handler: (width: number, height: number) => void) => events.onResize(handler),
293
- onFrameStats: (handler: (stats: FrameStats) => void) => events.onFrameStats(handler),
294
- stop() {
295
- state.running = false
296
- if (onExit) {
297
- process.off("exit", onExit)
298
- onExit = null
299
- }
300
- if (onSignal) {
301
- process.off("SIGINT", onSignal)
302
- process.off("SIGTERM", onSignal)
303
- onSignal = null
304
- }
305
- if (onUncaughtException) {
306
- process.off("uncaughtException", onUncaughtException)
307
- onUncaughtException = null
308
- }
309
- if (onUnhandledRejection) {
310
- process.off("unhandledRejection", onUnhandledRejection)
311
- onUnhandledRejection = null
312
- }
313
- if (state.loop) {
314
- clearInterval(state.loop)
315
- state.loop = null
316
- }
317
- if (state.inputHandler) {
318
- stdin.removeListener("data", state.inputHandler)
319
- state.inputHandler = null
320
- }
321
- if (state.resizeHandler) {
322
- stdout.removeListener("resize", state.resizeHandler)
323
- state.resizeHandler = null
324
- }
325
- terminal.teardown()
326
- },
327
- flush() {
328
- renderFrame()
329
- },
330
- getScreenshot() {
331
- // Return the previous buffer as ANSI string (it has the last rendered frame)
332
- if (state.prevBuffer) {
333
- return bufferToString(state.prevBuffer, state.palette, state.width, state.height)
334
- }
335
- return ""
336
- },
337
- dispatchKey(key: KeyMsg) {
338
- events.dispatchKey(key)
339
- if (!manualMode) renderFrame()
340
- },
341
- dispatchPaste(text: string) {
342
- events.dispatchPaste(text)
343
- if (!manualMode) renderFrame()
344
- },
345
- dispatchResize(width: number, height: number) {
346
- renderMode.handleResize(width, height, state.lastWidth)
347
- state.updateDimensions(width, height)
348
- state.invalidateBuffers()
349
- state.markDirty()
350
- flushSync(() => {
351
- events.dispatchResize(width, height)
352
- })
353
- if (!manualMode) renderFrame()
354
- },
355
- }
356
-
357
- // Terminal setup
358
- terminal.setup()
359
-
360
- // Input handling
361
- state.inputHandler = (data: Buffer) => inputProcessor.process(data)
362
- stdin.on("data", state.inputHandler)
363
-
364
- // Resize handling
365
- state.resizeHandler = () => {
366
- const newWidth = stdout.columns || 80
367
- const newHeight = stdout.rows || 24
368
-
369
- renderMode.handleResize(newWidth, newHeight, state.lastWidth)
370
-
371
- state.updateDimensions(newWidth, newHeight)
372
- state.invalidateBuffers()
373
- state.markDirty()
374
-
375
- flushSync(() => {
376
- events.dispatchResize(newWidth, newHeight)
377
- })
378
- }
379
- stdout.on("resize", state.resizeHandler)
380
-
381
- // Render loop
382
- if (!manualMode) {
383
- const frameMs = 1000 / fps
384
- state.loop = setInterval(() => {
385
- if (!state.running) {
386
- if (state.loop) clearInterval(state.loop)
387
- terminal.teardown()
388
- return
389
- }
390
- renderFrame()
391
- }, frameMs)
392
- }
393
-
394
- // Process exit handlers - ensure terminal is restored on any exit
395
- // These handlers are critical for proper cleanup when process.exit() is called
396
- let cleanedUp = false
397
- const cleanup = () => {
398
- if (cleanedUp) return
399
- cleanedUp = true
400
- renderer.stop()
401
- }
402
-
403
- if (handleSignals) {
404
- // Handle normal process exit (synchronous - runs before exit completes)
405
- onExit = () => cleanup()
406
- process.on("exit", onExit)
407
-
408
- // Handle SIGINT (Ctrl+C from shell, not from TUI input) and SIGTERM
409
- onSignal = (signal: NodeJS.Signals) => {
410
- cleanup()
411
- if (!exitOnSignal) return
412
- const code = signalExitCodes[signal as HandledSignal] ?? 0
413
- requestExit(code)
414
- }
415
- process.on("SIGINT", onSignal)
416
- process.on("SIGTERM", onSignal)
417
-
418
- // Handle uncaught exceptions - ensure error is visible before exit
419
- onUncaughtException = (err: Error) => {
420
- cleanup()
421
- // Write directly to terminal, bypassing console capture
422
- writeToTerminal(`\n[effect-tui] Uncaught exception:\n${err.stack || err.message}\n`)
423
- process.exit(1)
424
- }
425
- process.on("uncaughtException", onUncaughtException)
426
-
427
- // Handle unhandled promise rejections
428
- onUnhandledRejection = (reason: unknown) => {
429
- cleanup()
430
- const message = reason instanceof Error ? reason.stack || reason.message : String(reason)
431
- writeToTerminal(`\n[effect-tui] Unhandled rejection:\n${message}\n`)
432
- process.exit(1)
433
- }
434
- process.on("unhandledRejection", onUnhandledRejection)
435
- }
436
-
437
- ;(renderer as TuiRendererInternal)._container = null
438
- return renderer
439
- }
440
-
441
- export interface Root {
442
- render(element: ReactNode, sync?: boolean): void
443
- unmount(): void
444
- }
445
-
446
- export function createRoot(renderer: TuiRenderer): Root {
447
- const hostContext: HostContext = {
448
- requestRender: () => renderer.requestRender(),
449
- requestImmediateRender: () => renderer.flush(),
450
- }
451
-
452
- const container: Container = {
453
- root: null,
454
- ctx: hostContext,
455
- }
456
-
457
- const fiberRoot = reconciler.createContainer(
458
- container,
459
- 0,
460
- null,
461
- false,
462
- null,
463
- "",
464
- (err: Error) => console.error(err),
465
- (err: Error) => console.error(err),
466
- (err: Error) => console.error(err),
467
- () => {},
468
- null,
469
- )
470
-
471
- ;(renderer as TuiRendererInternal)._container = container
472
-
473
- return {
474
- render(element: ReactNode, sync = false) {
475
- const wrapped = React.createElement(RendererContext.Provider, { value: renderer }, element)
476
- if (sync) {
477
- flushSync(() => {
478
- reconciler.updateContainer(wrapped, fiberRoot, null, null)
479
- })
480
- renderer.requestRender()
481
- } else {
482
- reconciler.updateContainer(wrapped, fiberRoot, null, () => {
483
- renderer.requestRender()
484
- })
485
- }
486
- },
487
- unmount() {
488
- reconciler.updateContainer(null, fiberRoot, null, () => {
489
- renderer.stop()
490
- })
491
- },
492
- }
493
- }
494
-
495
- // High-level convenience API (Ink-style)
496
- export interface RenderInstance {
497
- renderer: TuiRenderer
498
- root: Root
499
- rerender(element: ReactNode): void
500
- unmount(): void
501
- waitUntilExit(): Promise<void>
502
- /** Cleanly exit the application, restoring terminal state before process.exit() */
503
- quit(code?: number): void
504
- }
505
-
506
- export type ImportMetaLike = {
507
- url: string
508
- main?: boolean
509
- }
510
-
511
- export type RenderOptions = RendererOptions &
512
- DevOptions & {
513
- /** Enable dev runtime (HMR, console overlay, remote control). */
514
- dev?: boolean
515
- /** Required when passing options. */
516
- importMeta: ImportMetaLike
517
- }
518
-
519
- const stripQuery = (url: string): string => url.split("?")[0]
520
-
521
- const createRenderInstance = (
522
- renderer: TuiRenderer,
523
- root: Root,
524
- element: ReactNode,
525
- stop: () => void,
526
- skipInitialRender = false,
527
- ): RenderInstance => {
528
- if (!skipInitialRender) {
529
- root.render(element, true)
530
- }
531
-
532
- let resolved = false
533
- let resolveExit: (() => void) | null = null
534
- const exitPromise = new Promise<void>((resolve) => {
535
- resolveExit = () => {
536
- if (resolved) return
537
- resolved = true
538
- resolve()
539
- }
540
- })
541
-
542
- // Resolve the exit promise on process exit
543
- // Note: Terminal cleanup is handled by createRenderer's exit handlers
544
- const onExit = () => {
545
- resolveExit?.()
546
- }
547
- process.once("exit", onExit)
548
-
549
- const unmount = () => {
550
- process.off("exit", onExit)
551
- if (!resolved) {
552
- resolved = true
553
- stop()
554
- resolveExit?.()
555
- }
556
- }
557
-
558
- const rerender = (next: ReactNode) => {
559
- if (resolved) return
560
- root.render(next)
561
- }
562
-
563
- // Clean quit function - stop() + process.exit()
564
- // The createRenderer exit handler will also run, but it's idempotent
565
- const quit = (code = 0) => {
566
- stop()
567
- requestExit(code)
568
- }
569
-
570
- return {
571
- renderer,
572
- root,
573
- rerender,
574
- unmount,
575
- waitUntilExit: () => exitPromise,
576
- quit,
577
- }
578
- }
579
-
580
- export function render(element: ReactNode, options?: RenderOptions): RenderInstance {
581
- if (options && !options.importMeta) {
582
- throw new Error("[effect-tui] render(..., options) requires { importMeta: import.meta }")
583
- }
584
-
585
- if (!options?.dev) {
586
- const renderer = createRenderer(options)
587
- const root = createRoot(renderer)
588
- return createRenderInstance(renderer, root, element, () => renderer.stop())
589
- }
590
-
591
- const importMeta = options.importMeta
592
- if (!importMeta) {
593
- throw new Error("[effect-tui] render(..., { dev: true, importMeta }) is required in dev mode")
594
- }
595
-
596
- const baseUrl = stripQuery(importMeta.url)
597
- const renderCache = getRenderCache<RenderInstance>()
598
- const cached = renderCache.get(baseUrl)
599
- if (cached) return cached
600
-
601
- const renderer = createRenderer(options)
602
- const root = createRoot(renderer)
603
- const entryPath = fileURLToPath(new URL(baseUrl))
604
-
605
- const devRuntime = startDevRuntime(entryPath, renderer, root, options)
606
-
607
- const instance = createRenderInstance(
608
- renderer,
609
- root,
610
- element,
611
- () => {
612
- void devRuntime.stop()
613
- },
614
- true,
615
- )
616
-
617
- renderCache.set(baseUrl, instance)
618
- return instance
619
- }
1
+ export * from "./internal/renderer/index.js"
@@ -21,7 +21,7 @@ export interface RenderTUIResult {
21
21
  /** Send a key event */
22
22
  sendKey(key: KeyMsg): void
23
23
  /** Trigger a render cycle and process React updates */
24
- flush(): void
24
+ renderNow(): void
25
25
  /** Unmount the component and stop the renderer */
26
26
  unmount(): void
27
27
  /** Access mock stdout for advanced assertions */
@@ -40,12 +40,12 @@ export interface RenderTUIResult {
40
40
  *
41
41
  * @example
42
42
  * ```ts
43
- * const { lastFrame, sendKey, flush, unmount } = renderTUI(<Counter />)
43
+ * const { lastFrame, sendKey, renderNow, unmount } = renderTUI(<Counter />)
44
44
  *
45
45
  * expect(lastFrame()).toContain("Count: 0")
46
46
  *
47
47
  * sendKey({ key: "up" })
48
- * flush()
48
+ * renderNow()
49
49
  *
50
50
  * expect(lastFrame()).toContain("Count: 1")
51
51
  *
@@ -84,9 +84,9 @@ export function renderTUI(element: ReactElement, options?: RenderTUIOptions): Re
84
84
  flushPassiveEffects()
85
85
 
86
86
  // Initial render
87
- renderer.flush()
87
+ renderer.renderNow()
88
88
 
89
- const flush = () => {
89
+ const renderNow = () => {
90
90
  // Flush React updates synchronously
91
91
  flushSync(() => {
92
92
  bump?.()
@@ -95,7 +95,7 @@ export function renderTUI(element: ReactElement, options?: RenderTUIOptions): Re
95
95
  // Clear buffer before re-render to get clean frame
96
96
  stdout.clear()
97
97
  renderer.requestRender()
98
- renderer.flush()
98
+ renderer.renderNow()
99
99
  // Flush updates scheduled during layout (e.g., viewport/content size callbacks)
100
100
  flushSync(() => {
101
101
  bump?.()
@@ -103,7 +103,7 @@ export function renderTUI(element: ReactElement, options?: RenderTUIOptions): Re
103
103
  flushPassiveEffects()
104
104
  stdout.clear()
105
105
  renderer.requestRender()
106
- renderer.flush()
106
+ renderer.renderNow()
107
107
  }
108
108
 
109
109
  return {
@@ -123,7 +123,7 @@ export function renderTUI(element: ReactElement, options?: RenderTUIOptions): Re
123
123
  stdin.sendKey(key)
124
124
  },
125
125
 
126
- flush,
126
+ renderNow,
127
127
 
128
128
  unmount() {
129
129
  root.unmount()
@@ -134,8 +134,14 @@ export function renderTUI(element: ReactElement, options?: RenderTUIOptions): Re
134
134
  renderer,
135
135
 
136
136
  resize(w: number, h: number) {
137
- stdout.resize(w, h)
138
- flush()
137
+ stdout.columns = w
138
+ stdout.rows = h
139
+ if (renderer.dispatchResize) {
140
+ renderer.dispatchResize(w, h)
141
+ } else {
142
+ stdout.resize(w, h)
143
+ }
144
+ renderNow()
139
145
  },
140
146
  }
141
147
  }