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