@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,67 @@
1
+ // ANSI escape sequences for terminal control
2
+
3
+ export const ANSI = {
4
+ cursor: {
5
+ to: (x: number, y: number) => `\x1b[${y};${x}H`,
6
+ toCol: (col: number) => `\x1b[${col}G`,
7
+ up: (n: number) => (n > 0 ? `\x1b[${n}A` : ""),
8
+ down: (n: number) => (n > 0 ? `\x1b[${n}B` : ""),
9
+ hide: "\x1b[?25l",
10
+ show: "\x1b[?25h",
11
+ startOfLine: "\r",
12
+ },
13
+ line: {
14
+ clear: "\x1b[2K",
15
+ clearToEnd: "\x1b[0K",
16
+ },
17
+ screen: {
18
+ enterAlt: "\x1b[?1049h",
19
+ exitAlt: "\x1b[?1049l",
20
+ clear: "\x1b[2J",
21
+ clearToEnd: "\x1b[J", // Clear from cursor to end of screen
22
+ },
23
+ style: (s: {
24
+ fg?: number
25
+ bg?: number
26
+ bold?: boolean
27
+ italic?: boolean
28
+ underline?: boolean
29
+ inverse?: boolean
30
+ }) => {
31
+ const codes: number[] = []
32
+ if (s.bold) codes.push(1)
33
+ if (s.italic) codes.push(3)
34
+ if (s.underline) codes.push(4)
35
+ if (s.inverse) codes.push(7)
36
+ if (s.fg !== undefined) {
37
+ if (s.fg < 256) {
38
+ codes.push(38, 5, s.fg)
39
+ } else {
40
+ // Truecolor: decode from packed RGB
41
+ const r = (s.fg >> 16) & 0xff
42
+ const g = (s.fg >> 8) & 0xff
43
+ const b = s.fg & 0xff
44
+ codes.push(38, 2, r, g, b)
45
+ }
46
+ }
47
+ if (s.bg !== undefined) {
48
+ if (s.bg < 256) {
49
+ codes.push(48, 5, s.bg)
50
+ } else {
51
+ const r = (s.bg >> 16) & 0xff
52
+ const g = (s.bg >> 8) & 0xff
53
+ const b = s.bg & 0xff
54
+ codes.push(48, 2, r, g, b)
55
+ }
56
+ }
57
+ return codes.length > 0 ? `\x1b[${codes.join(";")}m` : ""
58
+ },
59
+ resetStyle: "\x1b[0m",
60
+ }
61
+
62
+ export const Terminal = {
63
+ enterFullscreen: ANSI.screen.enterAlt + ANSI.screen.clear + ANSI.cursor.hide,
64
+ exitFullscreen: ANSI.screen.exitAlt + ANSI.cursor.show,
65
+ hideCursor: ANSI.cursor.hide,
66
+ showCursor: ANSI.cursor.show,
67
+ }
@@ -0,0 +1,8 @@
1
+ export { renderTUI, type RenderTUIOptions, type RenderTUIResult } from "./render-tui.js"
2
+ export {
3
+ MockStdout,
4
+ MockStdin,
5
+ encodeKey,
6
+ stripAnsi,
7
+ getVisibleLines,
8
+ } from "./mock-streams.js"
@@ -0,0 +1,149 @@
1
+ import { EventEmitter } from "node:events"
2
+ import type { KeyMsg } from "@effect-tui/core"
3
+
4
+ /**
5
+ * Mock stdout for testing. Captures all writes and simulates terminal properties.
6
+ */
7
+ export class MockStdout extends EventEmitter {
8
+ columns: number
9
+ rows: number
10
+ buf = ""
11
+
12
+ constructor(width = 80, height = 24) {
13
+ super()
14
+ this.columns = width
15
+ this.rows = height
16
+ }
17
+
18
+ write(s: string): boolean {
19
+ this.buf += s
20
+ return true
21
+ }
22
+
23
+ /** Clear captured output */
24
+ clear(): void {
25
+ this.buf = ""
26
+ }
27
+
28
+ /** Simulate terminal resize */
29
+ resize(width: number, height: number): void {
30
+ this.columns = width
31
+ this.rows = height
32
+ this.emit("resize")
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Mock stdin for testing. Simulates TTY input with key event emission.
38
+ */
39
+ export class MockStdin extends EventEmitter {
40
+ isTTY = true
41
+
42
+ setRawMode(_mode: boolean): this {
43
+ return this
44
+ }
45
+
46
+ resume(): this {
47
+ return this
48
+ }
49
+
50
+ pause(): this {
51
+ return this
52
+ }
53
+
54
+ /** Send raw bytes (for testing decodeKeys directly) */
55
+ sendRaw(data: Buffer): void {
56
+ this.emit("data", data)
57
+ }
58
+
59
+ /** Send a key by encoding it to bytes */
60
+ sendKey(key: KeyMsg): void {
61
+ const encoded = encodeKey(key)
62
+ this.emit("data", encoded)
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Encode a KeyMsg to raw bytes for stdin simulation.
68
+ * This is the reverse of decodeKeys().
69
+ */
70
+ export function encodeKey(key: KeyMsg): Buffer {
71
+ const name = key.name
72
+ const text = key.text
73
+
74
+ // Control keys (Ctrl + printable character)
75
+ if (key.ctrl && text) {
76
+ const lower = text.toLowerCase()
77
+ if (lower === "c") return Buffer.from([0x03])
78
+ if (lower === "d") return Buffer.from([0x04])
79
+ if (lower === "z") return Buffer.from([0x1a])
80
+ if (lower === "w") return Buffer.from([0x17])
81
+ // Ctrl+letter = letter code - 0x60
82
+ if (text.length === 1) {
83
+ const code = lower.charCodeAt(0) - 0x60
84
+ if (code >= 1 && code <= 26) return Buffer.from([code])
85
+ }
86
+ }
87
+
88
+ // Special keys (escape sequences)
89
+ switch (name) {
90
+ case "up":
91
+ return Buffer.from([0x1b, 0x5b, 0x41])
92
+ case "down":
93
+ return Buffer.from([0x1b, 0x5b, 0x42])
94
+ case "right":
95
+ return Buffer.from([0x1b, 0x5b, 0x43])
96
+ case "left":
97
+ return Buffer.from([0x1b, 0x5b, 0x44])
98
+ case "home":
99
+ return Buffer.from([0x1b, 0x5b, 0x48])
100
+ case "end":
101
+ return Buffer.from([0x1b, 0x5b, 0x46])
102
+ case "pageup":
103
+ return Buffer.from([0x1b, 0x5b, 0x35, 0x7e])
104
+ case "pagedown":
105
+ return Buffer.from([0x1b, 0x5b, 0x36, 0x7e])
106
+ case "delete":
107
+ return Buffer.from([0x1b, 0x5b, 0x33, 0x7e])
108
+ case "insert":
109
+ return Buffer.from([0x1b, 0x5b, 0x32, 0x7e])
110
+ case "escape":
111
+ return Buffer.from([0x1b])
112
+ case "tab":
113
+ return Buffer.from([0x09])
114
+ case "enter":
115
+ case "return":
116
+ return Buffer.from([0x0d])
117
+ case "backspace":
118
+ return Buffer.from([0x7f])
119
+ case "space":
120
+ return Buffer.from([0x20])
121
+ }
122
+
123
+ // Regular character
124
+ if (text) {
125
+ return Buffer.from(text, "utf8")
126
+ }
127
+
128
+ // Unknown key, return empty
129
+ return Buffer.from([])
130
+ }
131
+
132
+ /**
133
+ * Strip ANSI escape codes from a string for easier assertions.
134
+ */
135
+ export function stripAnsi(str: string): string {
136
+ // eslint-disable-next-line no-control-regex
137
+ return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").replace(/\x1b\][^\x07]*\x07/g, "")
138
+ }
139
+
140
+ /**
141
+ * Extract visible text lines from raw terminal output.
142
+ * Removes ANSI codes and splits by newlines.
143
+ */
144
+ export function getVisibleLines(output: string): string[] {
145
+ const stripped = stripAnsi(output)
146
+ // Replace carriage returns with newlines for consistency
147
+ const normalized = stripped.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
148
+ return normalized.split("\n")
149
+ }
@@ -0,0 +1,118 @@
1
+ import type { ReactElement } from "react"
2
+ import type { KeyMsg } from "@effect-tui/core"
3
+ import { createRenderer, createRoot } from "../renderer.js"
4
+ import { flushPassiveEffects, flushSync, discreteUpdates } from "../reconciler/host-config.js"
5
+ import { MockStdout, MockStdin, stripAnsi, getVisibleLines } from "./mock-streams.js"
6
+
7
+ export interface RenderTUIOptions {
8
+ width?: number
9
+ height?: number
10
+ }
11
+
12
+ export interface RenderTUIResult {
13
+ /** Get last rendered frame as raw string (includes ANSI codes) */
14
+ lastFrameRaw(): string
15
+ /** Get last rendered frame with ANSI codes stripped */
16
+ lastFrame(): string
17
+ /** Get visible text lines from last frame */
18
+ lines(): string[]
19
+ /** Send a key event */
20
+ sendKey(key: KeyMsg): void
21
+ /** Trigger a render cycle and process React updates */
22
+ flush(): void
23
+ /** Unmount the component and stop the renderer */
24
+ unmount(): void
25
+ /** Access mock stdout for advanced assertions */
26
+ stdout: MockStdout
27
+ /** Access mock stdin for advanced input simulation */
28
+ stdin: MockStdin
29
+ /** Resize the terminal */
30
+ resize(width: number, height: number): void
31
+ }
32
+
33
+ /**
34
+ * Render a TUI component for testing.
35
+ * Similar to ink-testing-library's render().
36
+ *
37
+ * @example
38
+ * ```ts
39
+ * const { lastFrame, sendKey, flush, unmount } = renderTUI(<Counter />)
40
+ *
41
+ * expect(lastFrame()).toContain("Count: 0")
42
+ *
43
+ * sendKey({ key: "up" })
44
+ * flush()
45
+ *
46
+ * expect(lastFrame()).toContain("Count: 1")
47
+ *
48
+ * unmount()
49
+ * ```
50
+ */
51
+ export function renderTUI(element: ReactElement, options?: RenderTUIOptions): RenderTUIResult {
52
+ const width = options?.width ?? 80
53
+ const height = options?.height ?? 24
54
+
55
+ const stdout = new MockStdout(width, height)
56
+ const stdin = new MockStdin()
57
+
58
+ const renderer = createRenderer({
59
+ stdout: stdout as any,
60
+ stdin: stdin as any,
61
+ manualMode: true,
62
+ skipTerminalSetup: true,
63
+ })
64
+
65
+ const root = createRoot(renderer)
66
+ root.render(element, true) // sync mode
67
+
68
+ // Flush effects
69
+ flushPassiveEffects()
70
+
71
+ // Initial render
72
+ renderer.flush()
73
+
74
+ const flush = () => {
75
+ // Flush React updates synchronously
76
+ flushSync(() => {})
77
+ flushPassiveEffects()
78
+ // Clear buffer before re-render to get clean frame
79
+ stdout.clear()
80
+ renderer.requestRender()
81
+ renderer.flush()
82
+ }
83
+
84
+ return {
85
+ lastFrameRaw() {
86
+ return stdout.buf
87
+ },
88
+
89
+ lastFrame() {
90
+ return stripAnsi(stdout.buf)
91
+ },
92
+
93
+ lines() {
94
+ return getVisibleLines(stdout.buf)
95
+ },
96
+
97
+ sendKey(key: KeyMsg) {
98
+ // Use discreteUpdates to ensure React processes state updates synchronously
99
+ discreteUpdates(() => {
100
+ stdin.sendKey(key)
101
+ })
102
+ },
103
+
104
+ flush,
105
+
106
+ unmount() {
107
+ root.unmount()
108
+ },
109
+
110
+ stdout,
111
+ stdin,
112
+
113
+ resize(w: number, h: number) {
114
+ stdout.resize(w, h)
115
+ flush()
116
+ },
117
+ }
118
+ }
@@ -0,0 +1,195 @@
1
+ // React component for visualizing span tree
2
+
3
+ import { Colors, type ColorValue } from "@effect-tui/core"
4
+ import type { SpanNode, SpanTreeState } from "./span-state.js"
5
+
6
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠸", "⠴", "⠦", "⠇"] as const
7
+
8
+ function formatDuration(ms: number): string {
9
+ if (ms < 1000) return `${Math.round(ms)}ms`
10
+ return `${(ms / 1000).toFixed(1)}s`
11
+ }
12
+
13
+ // Map opacity (0-1) to gray level (8-23 for visible range)
14
+ function opacityToGray(opacity: number): number {
15
+ const minGray = 8
16
+ const maxGray = 23
17
+ return Math.round(minGray + opacity * (maxGray - minGray))
18
+ }
19
+
20
+ // Interpolate RGB color based on opacity
21
+ function dimColor(r: number, g: number, b: number, opacity: number): ColorValue {
22
+ const minOpacity = 0.3 // Don't go completely dark
23
+ const factor = minOpacity + opacity * (1 - minOpacity)
24
+ return Colors.rgb(Math.round(r * factor), Math.round(g * factor), Math.round(b * factor))
25
+ }
26
+
27
+ // Get log level color
28
+ function logLevelColor(level: string, opacity: number): ColorValue {
29
+ switch (level) {
30
+ case "ERROR":
31
+ return dimColor(255, 80, 80, opacity)
32
+ case "WARN":
33
+ return dimColor(255, 200, 0, opacity)
34
+ case "INFO":
35
+ return dimColor(100, 180, 255, opacity)
36
+ default:
37
+ return Colors.gray(opacityToGray(opacity))
38
+ }
39
+ }
40
+
41
+ interface SpanRowProps {
42
+ node: SpanNode
43
+ state: SpanTreeState
44
+ spinnerIndex: number
45
+ prefix: string
46
+ isLast: boolean
47
+ }
48
+
49
+ function SpanRow({ node, state, spinnerIndex, prefix, isLast }: SpanRowProps) {
50
+ const connector = isLast ? "└─" : "├─"
51
+ const childPrefix = prefix + (isLast ? " " : "│ ")
52
+
53
+ const opacity = node.opacity
54
+ const grayLevel = opacityToGray(opacity)
55
+
56
+ const isSelected = state.selectedSpanId === node.id
57
+ const isExpanded = state.expandedSpanIds.has(node.id)
58
+
59
+ // Status icon
60
+ let icon: string
61
+ let iconColor: ColorValue | number
62
+ if (node.status === "running") {
63
+ icon = SPINNER_FRAMES[spinnerIndex]
64
+ iconColor = Colors.brightCyan
65
+ } else if (node.status === "success") {
66
+ icon = "✓"
67
+ iconColor = dimColor(0, 255, 0, opacity)
68
+ } else {
69
+ icon = "✗"
70
+ iconColor = dimColor(255, 80, 80, opacity)
71
+ }
72
+
73
+ // Name color - selected gets blue bg with bright text
74
+ const nameColor = isSelected
75
+ ? Colors.brightWhite
76
+ : node.status === "running"
77
+ ? Colors.brightWhite
78
+ : Colors.gray(grayLevel)
79
+ const nameBg = isSelected ? Colors.rgb(30, 60, 120) : undefined
80
+
81
+ // Duration display
82
+ const durationStr =
83
+ node.duration !== undefined
84
+ ? formatDuration(node.duration)
85
+ : node.status === "running"
86
+ ? formatDuration(Date.now() - node.startTime)
87
+ : ""
88
+ const durationColor = Colors.gray(Math.max(8, grayLevel - 4))
89
+
90
+ // Log counts from logEntries
91
+ const logCounts = { info: 0, warn: 0, error: 0, debug: 0 }
92
+ for (const entry of node.logEntries) {
93
+ if (entry.level === "INFO") logCounts.info++
94
+ else if (entry.level === "WARN") logCounts.warn++
95
+ else if (entry.level === "ERROR") logCounts.error++
96
+ else logCounts.debug++
97
+ }
98
+
99
+ const logParts: Array<{ text: string; color: ColorValue | number }> = []
100
+ if (logCounts.error > 0) logParts.push({ text: `${logCounts.error} err`, color: dimColor(255, 80, 80, opacity) })
101
+ if (logCounts.warn > 0) logParts.push({ text: `${logCounts.warn} warn`, color: dimColor(255, 200, 0, opacity) })
102
+ if (logCounts.info > 0) logParts.push({ text: `${logCounts.info} info`, color: dimColor(100, 180, 255, opacity) })
103
+ if (logCounts.debug > 0) logParts.push({ text: `${logCounts.debug} dbg`, color: Colors.gray(grayLevel) })
104
+
105
+ return (
106
+ <vstack>
107
+ <hstack>
108
+ <text fg={Colors.gray(Math.max(8, grayLevel - 2))}>
109
+ {prefix}
110
+ {connector}
111
+ </text>
112
+ <text fg={iconColor}> {icon} </text>
113
+ <text fg={nameColor} bg={nameBg}>
114
+ {node.name}
115
+ </text>
116
+ {durationStr && <text fg={durationColor}> {durationStr}</text>}
117
+ {logParts.length > 0 && <text fg={durationColor}> [</text>}
118
+ {logParts.map((p, i) => (
119
+ <text key={i} fg={p.color}>
120
+ {i > 0 ? " " : ""}
121
+ {p.text}
122
+ </text>
123
+ ))}
124
+ {logParts.length > 0 && <text fg={durationColor}>]</text>}
125
+ {node.error && <text fg={Colors.red}> ({node.error})</text>}
126
+ </hstack>
127
+ {/* Expanded log entries */}
128
+ {isExpanded &&
129
+ node.logEntries.map((entry, i) => (
130
+ <hstack key={i}>
131
+ <text fg={Colors.gray(10)}>
132
+ {childPrefix}
133
+ {node.children.length > 0 ? "│ " : " "}
134
+ </text>
135
+ <text fg={logLevelColor(entry.level, opacity)}>{entry.level}</text>
136
+ <text fg={Colors.gray(16)}> {entry.message}</text>
137
+ </hstack>
138
+ ))}
139
+ {node.children.map((child, i) => (
140
+ <SpanRow
141
+ key={child.id}
142
+ node={child}
143
+ state={state}
144
+ spinnerIndex={spinnerIndex}
145
+ prefix={childPrefix}
146
+ isLast={i === node.children.length - 1}
147
+ />
148
+ ))}
149
+ </vstack>
150
+ )
151
+ }
152
+
153
+ interface SpanTreeProps {
154
+ state: SpanTreeState
155
+ spinnerIndex: number
156
+ serviceName?: string
157
+ showExitPrompt?: boolean
158
+ }
159
+
160
+ export function SpanTree({ state, spinnerIndex, serviceName, showExitPrompt }: SpanTreeProps) {
161
+ // No subscription needed - parent's 60fps loop drives re-renders
162
+ const roots = state.getRoots()
163
+
164
+ if (roots.length === 0) {
165
+ return <text fg={Colors.gray(10)}>Waiting for spans...</text>
166
+ }
167
+
168
+ const hasAnyLogs = state.getSpansWithLogs().length > 0
169
+
170
+ return (
171
+ <vstack>
172
+ {serviceName && (
173
+ <text fg={Colors.brightWhite} bold>
174
+ {serviceName}
175
+ </text>
176
+ )}
177
+ {roots.map((root, i) => (
178
+ <SpanRow
179
+ key={root.id}
180
+ node={root}
181
+ state={state}
182
+ spinnerIndex={spinnerIndex}
183
+ prefix=""
184
+ isLast={i === roots.length - 1}
185
+ />
186
+ ))}
187
+ {showExitPrompt && (
188
+ <text fg={Colors.gray(12)}>
189
+ {"\n"}
190
+ {hasAnyLogs ? "↑↓ navigate, Enter expand, O open, q to exit" : "q to exit"}
191
+ </text>
192
+ )}
193
+ </vstack>
194
+ )
195
+ }