@xincli/opentui-core 0.4.6

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 (476) hide show
  1. package/README.md +63 -0
  2. package/dev/keypress-debug-renderer.ts +148 -0
  3. package/dev/keypress-debug.ts +43 -0
  4. package/dev/print-env-vars.ts +32 -0
  5. package/dev/test-tmux-graphics-334.sh +68 -0
  6. package/dev/test-tmux-theme-queries.sh +151 -0
  7. package/dev/thai-debug-test.ts +68 -0
  8. package/dev/theme-mode.ts +290 -0
  9. package/docs/development.md +144 -0
  10. package/package.json +102 -0
  11. package/prebuilt/aarch64-android/libopentui.so +0 -0
  12. package/scripts/build-android.ts +226 -0
  13. package/scripts/build-native-termux.sh +525 -0
  14. package/scripts/build.ts +530 -0
  15. package/scripts/dist-test.ts +312 -0
  16. package/scripts/package-prebuilt.ts +110 -0
  17. package/scripts/publish.ts +60 -0
  18. package/scripts/test-node-hook.mjs +16 -0
  19. package/scripts/test-node.ts +241 -0
  20. package/scripts/vendor-deps.sh +73 -0
  21. package/src/NativeSpanFeed.ts +375 -0
  22. package/src/Renderable.ts +1878 -0
  23. package/src/__snapshots__/buffer.test.ts.snap +28 -0
  24. package/src/animation/Timeline.test.ts +2709 -0
  25. package/src/animation/Timeline.ts +598 -0
  26. package/src/ansi.ts +22 -0
  27. package/src/audio.ts +479 -0
  28. package/src/benchmark/attenuation-benchmark.ts +81 -0
  29. package/src/benchmark/audio-playback-benchmark.ts +344 -0
  30. package/src/benchmark/box-draw-benchmark.ts +1042 -0
  31. package/src/benchmark/colormatrix-benchmark.ts +123 -0
  32. package/src/benchmark/gain-benchmark.ts +80 -0
  33. package/src/benchmark/latest-all-bench-run.json +707 -0
  34. package/src/benchmark/latest-async-bench-run.json +336 -0
  35. package/src/benchmark/latest-default-bench-run.json +657 -0
  36. package/src/benchmark/latest-large-bench-run.json +707 -0
  37. package/src/benchmark/latest-quick-bench-run.json +207 -0
  38. package/src/benchmark/layout-benchmark.ts +2547 -0
  39. package/src/benchmark/markdown-benchmark.ts +1796 -0
  40. package/src/benchmark/native-span-feed-async-benchmark.ts +355 -0
  41. package/src/benchmark/native-span-feed-benchmark.md +56 -0
  42. package/src/benchmark/native-span-feed-benchmark.ts +596 -0
  43. package/src/benchmark/native-span-feed-compare.ts +280 -0
  44. package/src/benchmark/render-traversal-benchmark.ts +928 -0
  45. package/src/benchmark/text-buffer-render-benchmark.ts +874 -0
  46. package/src/benchmark/text-table-benchmark.ts +948 -0
  47. package/src/buffer.test.ts +292 -0
  48. package/src/buffer.ts +564 -0
  49. package/src/console.test.ts +629 -0
  50. package/src/console.ts +1253 -0
  51. package/src/edit-buffer.test.ts +1852 -0
  52. package/src/edit-buffer.ts +417 -0
  53. package/src/editor-view.test.ts +1056 -0
  54. package/src/editor-view.ts +290 -0
  55. package/src/index.ts +25 -0
  56. package/src/lib/KeyHandler.integration.test.ts +292 -0
  57. package/src/lib/KeyHandler.stopPropagation.test.ts +289 -0
  58. package/src/lib/KeyHandler.test.ts +662 -0
  59. package/src/lib/KeyHandler.ts +222 -0
  60. package/src/lib/RGBA.test.ts +60 -0
  61. package/src/lib/RGBA.ts +343 -0
  62. package/src/lib/ascii.font.ts +330 -0
  63. package/src/lib/border.test.ts +83 -0
  64. package/src/lib/border.ts +170 -0
  65. package/src/lib/bunfs.test.ts +27 -0
  66. package/src/lib/bunfs.ts +18 -0
  67. package/src/lib/clipboard.test.ts +63 -0
  68. package/src/lib/clipboard.ts +41 -0
  69. package/src/lib/clock.ts +35 -0
  70. package/src/lib/data-paths.test.ts +133 -0
  71. package/src/lib/data-paths.ts +109 -0
  72. package/src/lib/debounce.ts +106 -0
  73. package/src/lib/detect-links.test.ts +98 -0
  74. package/src/lib/detect-links.ts +56 -0
  75. package/src/lib/env.test.ts +228 -0
  76. package/src/lib/env.ts +209 -0
  77. package/src/lib/extmarks-history.ts +51 -0
  78. package/src/lib/extmarks-multiwidth.test.ts +324 -0
  79. package/src/lib/extmarks.test.ts +3547 -0
  80. package/src/lib/extmarks.ts +861 -0
  81. package/src/lib/fonts/block.json +405 -0
  82. package/src/lib/fonts/grid.json +265 -0
  83. package/src/lib/fonts/huge.json +741 -0
  84. package/src/lib/fonts/pallet.json +314 -0
  85. package/src/lib/fonts/shade.json +591 -0
  86. package/src/lib/fonts/slick.json +321 -0
  87. package/src/lib/fonts/tiny.json +69 -0
  88. package/src/lib/hast-styled-text.ts +59 -0
  89. package/src/lib/index.ts +22 -0
  90. package/src/lib/keybinding.internal.test.ts +342 -0
  91. package/src/lib/keybinding.internal.ts +182 -0
  92. package/src/lib/objects-in-viewport.test.ts +789 -0
  93. package/src/lib/objects-in-viewport.ts +153 -0
  94. package/src/lib/output.capture.ts +58 -0
  95. package/src/lib/parse.keypress-kitty.protocol.test.ts +347 -0
  96. package/src/lib/parse.keypress-kitty.test.ts +712 -0
  97. package/src/lib/parse.keypress-kitty.ts +470 -0
  98. package/src/lib/parse.keypress.test.ts +2020 -0
  99. package/src/lib/parse.keypress.ts +480 -0
  100. package/src/lib/parse.mouse.test.ts +552 -0
  101. package/src/lib/parse.mouse.ts +232 -0
  102. package/src/lib/paste.ts +18 -0
  103. package/src/lib/queue.ts +65 -0
  104. package/src/lib/render-geometry.ts +36 -0
  105. package/src/lib/renderable.validations.test.ts +87 -0
  106. package/src/lib/renderable.validations.ts +83 -0
  107. package/src/lib/scroll-acceleration.ts +98 -0
  108. package/src/lib/selection.ts +261 -0
  109. package/src/lib/singleton.ts +34 -0
  110. package/src/lib/stdin-parser.test.ts +2439 -0
  111. package/src/lib/stdin-parser.ts +2007 -0
  112. package/src/lib/styled-text.ts +178 -0
  113. package/src/lib/terminal-capability-detection.test.ts +226 -0
  114. package/src/lib/terminal-capability-detection.ts +98 -0
  115. package/src/lib/terminal-palette.test.ts +954 -0
  116. package/src/lib/terminal-palette.ts +441 -0
  117. package/src/lib/tree-sitter/assets/README.md +119 -0
  118. package/src/lib/tree-sitter/assets/javascript/highlights.scm +205 -0
  119. package/src/lib/tree-sitter/assets/javascript/tree-sitter-javascript.wasm +0 -0
  120. package/src/lib/tree-sitter/assets/markdown/highlights.scm +150 -0
  121. package/src/lib/tree-sitter/assets/markdown/injections.scm +27 -0
  122. package/src/lib/tree-sitter/assets/markdown/tree-sitter-markdown.wasm +0 -0
  123. package/src/lib/tree-sitter/assets/markdown_inline/highlights.scm +115 -0
  124. package/src/lib/tree-sitter/assets/markdown_inline/tree-sitter-markdown_inline.wasm +0 -0
  125. package/src/lib/tree-sitter/assets/typescript/highlights.scm +604 -0
  126. package/src/lib/tree-sitter/assets/typescript/tree-sitter-typescript.wasm +0 -0
  127. package/src/lib/tree-sitter/assets/update.ts +360 -0
  128. package/src/lib/tree-sitter/assets/zig/highlights.scm +284 -0
  129. package/src/lib/tree-sitter/assets/zig/tree-sitter-zig.wasm +0 -0
  130. package/src/lib/tree-sitter/assets.d.ts +9 -0
  131. package/src/lib/tree-sitter/cache.test.ts +291 -0
  132. package/src/lib/tree-sitter/client.test.ts +1434 -0
  133. package/src/lib/tree-sitter/client.ts +847 -0
  134. package/src/lib/tree-sitter/default-parsers.ts +133 -0
  135. package/src/lib/tree-sitter/download-utils.ts +148 -0
  136. package/src/lib/tree-sitter/index.ts +75 -0
  137. package/src/lib/tree-sitter/parser.worker.ts +1098 -0
  138. package/src/lib/tree-sitter/parsers-config.ts +81 -0
  139. package/src/lib/tree-sitter/resolve-ft.test.ts +55 -0
  140. package/src/lib/tree-sitter/resolve-ft.ts +189 -0
  141. package/src/lib/tree-sitter/types.ts +132 -0
  142. package/src/lib/tree-sitter/update-assets.ts +10 -0
  143. package/src/lib/tree-sitter-styled-text.test.ts +1253 -0
  144. package/src/lib/tree-sitter-styled-text.ts +314 -0
  145. package/src/lib/validate-dir-name.ts +55 -0
  146. package/src/lib/yoga.options.test.ts +628 -0
  147. package/src/lib/yoga.options.ts +346 -0
  148. package/src/native-event-worker-repro.test.ts +48 -0
  149. package/src/native-event-worker-repro.worker.ts +9 -0
  150. package/src/native-handle.test.ts +221 -0
  151. package/src/platform/ffi.test.ts +516 -0
  152. package/src/platform/ffi.ts +653 -0
  153. package/src/platform/runtime.test.ts +52 -0
  154. package/src/platform/runtime.ts +121 -0
  155. package/src/platform/test.ts +45 -0
  156. package/src/platform/worker-handler-cleanup.fixture.ts +12 -0
  157. package/src/platform/worker-onmessage-startup.fixture.ts +10 -0
  158. package/src/platform/worker-startup.fixture.ts +9 -0
  159. package/src/platform/worker.node-test.ts +153 -0
  160. package/src/platform/worker.ts +425 -0
  161. package/src/plugins/core-slot.ts +579 -0
  162. package/src/plugins/registry.ts +402 -0
  163. package/src/plugins/types.ts +46 -0
  164. package/src/post/effects.ts +948 -0
  165. package/src/post/filters.ts +512 -0
  166. package/src/post/matrices.ts +288 -0
  167. package/src/renderables/ASCIIFont.ts +219 -0
  168. package/src/renderables/Box.test.ts +474 -0
  169. package/src/renderables/Box.ts +353 -0
  170. package/src/renderables/Code.test.ts +2388 -0
  171. package/src/renderables/Code.ts +555 -0
  172. package/src/renderables/Diff.regression.test.ts +381 -0
  173. package/src/renderables/Diff.test.ts +3212 -0
  174. package/src/renderables/Diff.ts +1278 -0
  175. package/src/renderables/EditBufferRenderable.test.ts +288 -0
  176. package/src/renderables/EditBufferRenderable.ts +1152 -0
  177. package/src/renderables/FrameBuffer.ts +47 -0
  178. package/src/renderables/Input.test.ts +1310 -0
  179. package/src/renderables/Input.ts +273 -0
  180. package/src/renderables/LineNumberRenderable.ts +725 -0
  181. package/src/renderables/Markdown.ts +2144 -0
  182. package/src/renderables/ScrollBar.ts +423 -0
  183. package/src/renderables/ScrollBox.ts +903 -0
  184. package/src/renderables/Select.test.ts +1134 -0
  185. package/src/renderables/Select.ts +535 -0
  186. package/src/renderables/Slider.test.ts +456 -0
  187. package/src/renderables/Slider.ts +342 -0
  188. package/src/renderables/TabSelect.test.ts +238 -0
  189. package/src/renderables/TabSelect.ts +447 -0
  190. package/src/renderables/Text.selection-buffer.test.ts +118 -0
  191. package/src/renderables/Text.test.ts +2662 -0
  192. package/src/renderables/Text.ts +147 -0
  193. package/src/renderables/TextBufferRenderable.ts +504 -0
  194. package/src/renderables/TextNode.test.ts +1137 -0
  195. package/src/renderables/TextNode.ts +379 -0
  196. package/src/renderables/TextTable.test.ts +1411 -0
  197. package/src/renderables/TextTable.ts +1434 -0
  198. package/src/renderables/Textarea.ts +425 -0
  199. package/src/renderables/TimeToFirstDraw.ts +89 -0
  200. package/src/renderables/__snapshots__/Code.test.ts.snap +13 -0
  201. package/src/renderables/__snapshots__/Diff.test.ts.snap +785 -0
  202. package/src/renderables/__snapshots__/Text.test.ts.snap +421 -0
  203. package/src/renderables/__snapshots__/TextTable.test.ts.snap +215 -0
  204. package/src/renderables/__tests__/Code.test.ts +55 -0
  205. package/src/renderables/__tests__/LineNumberRenderable.scrollbox-simple.test.ts +144 -0
  206. package/src/renderables/__tests__/LineNumberRenderable.scrollbox.test.ts +827 -0
  207. package/src/renderables/__tests__/LineNumberRenderable.test.ts +1906 -0
  208. package/src/renderables/__tests__/LineNumberRenderable.wrapping.test.ts +85 -0
  209. package/src/renderables/__tests__/Markdown.code-colors.test.ts +288 -0
  210. package/src/renderables/__tests__/Markdown.test.ts +4175 -0
  211. package/src/renderables/__tests__/MultiRenderable.selection.test.ts +87 -0
  212. package/src/renderables/__tests__/Textarea.buffer.test.ts +683 -0
  213. package/src/renderables/__tests__/Textarea.destroyed-events.test.ts +675 -0
  214. package/src/renderables/__tests__/Textarea.editing.test.ts +2120 -0
  215. package/src/renderables/__tests__/Textarea.error-handling.test.ts +35 -0
  216. package/src/renderables/__tests__/Textarea.events.test.ts +738 -0
  217. package/src/renderables/__tests__/Textarea.highlights.test.ts +590 -0
  218. package/src/renderables/__tests__/Textarea.keybinding.test.ts +3275 -0
  219. package/src/renderables/__tests__/Textarea.paste.test.ts +357 -0
  220. package/src/renderables/__tests__/Textarea.rendering.test.ts +1927 -0
  221. package/src/renderables/__tests__/Textarea.scroll.test.ts +733 -0
  222. package/src/renderables/__tests__/Textarea.selection.test.ts +1589 -0
  223. package/src/renderables/__tests__/Textarea.stress.test.ts +670 -0
  224. package/src/renderables/__tests__/Textarea.undo-redo.test.ts +383 -0
  225. package/src/renderables/__tests__/Textarea.visual-lines.test.ts +310 -0
  226. package/src/renderables/__tests__/__snapshots__/LineNumberRenderable.code.test.ts.snap +221 -0
  227. package/src/renderables/__tests__/__snapshots__/LineNumberRenderable.scrollbox-simple.test.ts.snap +89 -0
  228. package/src/renderables/__tests__/__snapshots__/LineNumberRenderable.scrollbox.test.ts.snap +457 -0
  229. package/src/renderables/__tests__/__snapshots__/LineNumberRenderable.test.ts.snap +158 -0
  230. package/src/renderables/__tests__/__snapshots__/Textarea.rendering.test.ts.snap +387 -0
  231. package/src/renderables/__tests__/markdown-parser.test.ts +241 -0
  232. package/src/renderables/__tests__/renderable-test-utils.ts +60 -0
  233. package/src/renderables/composition/README.md +8 -0
  234. package/src/renderables/composition/VRenderable.ts +32 -0
  235. package/src/renderables/composition/constructs.ts +127 -0
  236. package/src/renderables/composition/vnode.ts +277 -0
  237. package/src/renderables/index.ts +23 -0
  238. package/src/renderables/markdown-parser.ts +79 -0
  239. package/src/renderer-theme-mode.ts +199 -0
  240. package/src/renderer.ts +4975 -0
  241. package/src/runtime-plugin-support-configure.ts +63 -0
  242. package/src/runtime-plugin-support.ts +13 -0
  243. package/src/runtime-plugin.ts +626 -0
  244. package/src/specs/terminal-startup.md +64 -0
  245. package/src/syntax-style.test.ts +841 -0
  246. package/src/syntax-style.ts +274 -0
  247. package/src/testing/README.md +219 -0
  248. package/src/testing/bun-test-node.ts +880 -0
  249. package/src/testing/capture-spans.test.ts +216 -0
  250. package/src/testing/integration.test.ts +276 -0
  251. package/src/testing/manual-clock.ts +117 -0
  252. package/src/testing/mock-keys.test.ts +1378 -0
  253. package/src/testing/mock-keys.ts +457 -0
  254. package/src/testing/mock-mouse.test.ts +218 -0
  255. package/src/testing/mock-mouse.ts +247 -0
  256. package/src/testing/mock-tree-sitter-client.ts +81 -0
  257. package/src/testing/spy.ts +13 -0
  258. package/src/testing/terminal-capabilities.ts +47 -0
  259. package/src/testing/test-recorder.test.ts +410 -0
  260. package/src/testing/test-recorder.ts +133 -0
  261. package/src/testing/test-renderer.ts +375 -0
  262. package/src/testing/test-renderer.wait.test.ts +278 -0
  263. package/src/testing/test-streams.ts +31 -0
  264. package/src/testing.ts +9 -0
  265. package/src/tests/__snapshots__/absolute-positioning.snapshot.test.ts.snap +481 -0
  266. package/src/tests/__snapshots__/renderable.snapshot.test.ts.snap +19 -0
  267. package/src/tests/__snapshots__/scrollbox.test.ts.snap +29 -0
  268. package/src/tests/absolute-positioning.snapshot.test.ts +638 -0
  269. package/src/tests/allocator-stats.test.ts +53 -0
  270. package/src/tests/audio.test.ts +303 -0
  271. package/src/tests/destroy-during-render.test.ts +200 -0
  272. package/src/tests/destroy-on-exit.fixture.ts +36 -0
  273. package/src/tests/destroy-on-exit.test.ts +62 -0
  274. package/src/tests/ffi-borrowed-pointer-callsites.test.ts +153 -0
  275. package/src/tests/hover-cursor.test.ts +98 -0
  276. package/src/tests/native-backed-measurement-lifecycle.test.ts +133 -0
  277. package/src/tests/native-backed-measurement-parity.test.ts +322 -0
  278. package/src/tests/native-span-feed-async.test.ts +173 -0
  279. package/src/tests/native-span-feed-close.test.ts +120 -0
  280. package/src/tests/native-span-feed-coverage.test.ts +227 -0
  281. package/src/tests/native-span-feed-edge-cases.test.ts +384 -0
  282. package/src/tests/native-span-feed-use-after-free.test.ts +45 -0
  283. package/src/tests/opacity.test.ts +123 -0
  284. package/src/tests/renderable.snapshot.test.ts +524 -0
  285. package/src/tests/renderable.test.ts +1851 -0
  286. package/src/tests/renderer.clock.test.ts +270 -0
  287. package/src/tests/renderer.console-startup.test.ts +2151 -0
  288. package/src/tests/renderer.control.test.ts +458 -0
  289. package/src/tests/renderer.core-slot-binding.test.ts +962 -0
  290. package/src/tests/renderer.cursor.test.ts +26 -0
  291. package/src/tests/renderer.custom-stdout.test.ts +1112 -0
  292. package/src/tests/renderer.destroy-during-render.test.ts +147 -0
  293. package/src/tests/renderer.focus-restore.test.ts +257 -0
  294. package/src/tests/renderer.focus.test.ts +329 -0
  295. package/src/tests/renderer.idle.test.ts +219 -0
  296. package/src/tests/renderer.input.test.ts +2615 -0
  297. package/src/tests/renderer.kitty-flags.test.ts +195 -0
  298. package/src/tests/renderer.lifecycle.test.ts +34 -0
  299. package/src/tests/renderer.mouse.test.ts +1315 -0
  300. package/src/tests/renderer.notifications.test.ts +27 -0
  301. package/src/tests/renderer.palette.test.ts +1199 -0
  302. package/src/tests/renderer.remote.test.ts +247 -0
  303. package/src/tests/renderer.render-stats.test.ts +146 -0
  304. package/src/tests/renderer.scrollback-surface.test.ts +474 -0
  305. package/src/tests/renderer.selection.test.ts +138 -0
  306. package/src/tests/renderer.slot-registry.test.ts +684 -0
  307. package/src/tests/renderer.tracker.test.ts +202 -0
  308. package/src/tests/renderer.useMouse.test.ts +83 -0
  309. package/src/tests/runtime-plugin-node-modules-cycle.fixture.ts +76 -0
  310. package/src/tests/runtime-plugin-node-modules-import-like-string.fixture.ts +48 -0
  311. package/src/tests/runtime-plugin-node-modules-mjs.fixture.ts +43 -0
  312. package/src/tests/runtime-plugin-node-modules-no-bare-rewrite.fixture.ts +67 -0
  313. package/src/tests/runtime-plugin-node-modules-package-type-cache.fixture.ts +72 -0
  314. package/src/tests/runtime-plugin-node-modules-runtime-specifier.fixture.ts +44 -0
  315. package/src/tests/runtime-plugin-node-modules-scoped-package-bare-rewrite.fixture.ts +85 -0
  316. package/src/tests/runtime-plugin-path-alias.fixture.ts +43 -0
  317. package/src/tests/runtime-plugin-resolve-roots.fixture.ts +65 -0
  318. package/src/tests/runtime-plugin-support-configure.fixture.ts +31 -0
  319. package/src/tests/runtime-plugin-support-late-addition.fixture.ts +17 -0
  320. package/src/tests/runtime-plugin-support.fixture.ts +11 -0
  321. package/src/tests/runtime-plugin-support.test.ts +54 -0
  322. package/src/tests/runtime-plugin-windows-file-url.fixture.ts +30 -0
  323. package/src/tests/runtime-plugin.fixture.ts +40 -0
  324. package/src/tests/runtime-plugin.test.ts +369 -0
  325. package/src/tests/scrollbox-culling-bug.test.ts +114 -0
  326. package/src/tests/scrollbox-hitgrid-resize.test.ts +136 -0
  327. package/src/tests/scrollbox-hitgrid.test.ts +909 -0
  328. package/src/tests/scrollbox.test.ts +1895 -0
  329. package/src/tests/wrap-resize-perf.test.ts +276 -0
  330. package/src/tests/yoga-api-coverage.test.ts +409 -0
  331. package/src/tests/yoga-callback-stress.test.ts +122 -0
  332. package/src/tests/yoga-ffi.test.ts +117 -0
  333. package/src/tests/yoga-setters.test.ts +921 -0
  334. package/src/tests/yoga-upstream/YGAlignBaselineTest.test.ts +124 -0
  335. package/src/tests/yoga-upstream/YGComputedBorderTest.test.ts +27 -0
  336. package/src/tests/yoga-upstream/YGComputedMarginTest.test.ts +27 -0
  337. package/src/tests/yoga-upstream/YGComputedPaddingTest.test.ts +27 -0
  338. package/src/tests/yoga-upstream/YGDirtiedTest.test.ts +157 -0
  339. package/src/tests/yoga-upstream/YGErrataTest.test.ts +40 -0
  340. package/src/tests/yoga-upstream/YGFlexBasisAuto.test.ts +23 -0
  341. package/src/tests/yoga-upstream/YGHasNewLayout.test.ts +81 -0
  342. package/src/tests/yoga-upstream/YGMeasureCacheTest.test.ts +31 -0
  343. package/src/tests/yoga-upstream/YGMeasureTest.test.ts +70 -0
  344. package/src/tests/yoga-upstream/generated/YGAbsolutePositionTest.test.ts +1864 -0
  345. package/src/tests/yoga-upstream/generated/YGAlignContentTest.test.ts +5829 -0
  346. package/src/tests/yoga-upstream/generated/YGAlignItemsTest.test.ts +2444 -0
  347. package/src/tests/yoga-upstream/generated/YGAlignSelfTest.test.ts +293 -0
  348. package/src/tests/yoga-upstream/generated/YGAndroidNewsFeed.test.ts +315 -0
  349. package/src/tests/yoga-upstream/generated/YGAspectRatioTest.test.ts +231 -0
  350. package/src/tests/yoga-upstream/generated/YGAutoTest.test.ts +347 -0
  351. package/src/tests/yoga-upstream/generated/YGBorderTest.test.ts +256 -0
  352. package/src/tests/yoga-upstream/generated/YGBoxSizingTest.test.ts +2747 -0
  353. package/src/tests/yoga-upstream/generated/YGDimensionTest.test.ts +129 -0
  354. package/src/tests/yoga-upstream/generated/YGDisplayContentsTest.test.ts +109 -0
  355. package/src/tests/yoga-upstream/generated/YGDisplayTest.test.ts +1097 -0
  356. package/src/tests/yoga-upstream/generated/YGFlexDirectionTest.test.ts +4562 -0
  357. package/src/tests/yoga-upstream/generated/YGFlexTest.test.ts +677 -0
  358. package/src/tests/yoga-upstream/generated/YGFlexWrapTest.test.ts +2071 -0
  359. package/src/tests/yoga-upstream/generated/YGGapTest.test.ts +3303 -0
  360. package/src/tests/yoga-upstream/generated/YGIntrinsicSizeTest.test.ts +582 -0
  361. package/src/tests/yoga-upstream/generated/YGJustifyContentTest.test.ts +2150 -0
  362. package/src/tests/yoga-upstream/generated/YGMarginTest.test.ts +1963 -0
  363. package/src/tests/yoga-upstream/generated/YGMinMaxDimensionTest.test.ts +1446 -0
  364. package/src/tests/yoga-upstream/generated/YGPaddingTest.test.ts +354 -0
  365. package/src/tests/yoga-upstream/generated/YGPercentageTest.test.ts +1760 -0
  366. package/src/tests/yoga-upstream/generated/YGRoundingTest.test.ts +1161 -0
  367. package/src/tests/yoga-upstream/generated/YGSizeOverflowTest.test.ts +207 -0
  368. package/src/tests/yoga-upstream/generated/YGStaticPositionTest.test.ts +6338 -0
  369. package/src/tests/yoga-upstream/tools/MeasureCounter.ts +54 -0
  370. package/src/tests/yoga-upstream/tools/utils.ts +81 -0
  371. package/src/text-buffer-view.test.ts +722 -0
  372. package/src/text-buffer-view.ts +193 -0
  373. package/src/text-buffer.test.ts +512 -0
  374. package/src/text-buffer.ts +250 -0
  375. package/src/types.ts +199 -0
  376. package/src/utils.ts +88 -0
  377. package/src/yoga.ts +1146 -0
  378. package/src/zig/ansi.zig +502 -0
  379. package/src/zig/audio.zig +1206 -0
  380. package/src/zig/bench/README.md +50 -0
  381. package/src/zig/bench/buffer-color-blending_bench.zig +350 -0
  382. package/src/zig/bench/buffer-draw-box_bench.zig +786 -0
  383. package/src/zig/bench/buffer-draw-text-buffer_bench.zig +894 -0
  384. package/src/zig/bench/edit-buffer_bench.zig +476 -0
  385. package/src/zig/bench/editor-view_bench.zig +169 -0
  386. package/src/zig/bench/native-span-feed_bench.zig +107 -0
  387. package/src/zig/bench/rope-markers_bench.zig +713 -0
  388. package/src/zig/bench/rope_bench.zig +514 -0
  389. package/src/zig/bench/styled-text_bench.zig +476 -0
  390. package/src/zig/bench/text-buffer-coords_bench.zig +362 -0
  391. package/src/zig/bench/text-buffer-view_bench.zig +459 -0
  392. package/src/zig/bench/text-chunk-graphemes_bench.zig +272 -0
  393. package/src/zig/bench/utf8_bench.zig +799 -0
  394. package/src/zig/bench-utils.zig +431 -0
  395. package/src/zig/bench.zig +223 -0
  396. package/src/zig/buffer-methods.zig +215 -0
  397. package/src/zig/buffer.zig +2515 -0
  398. package/src/zig/build.zig +700 -0
  399. package/src/zig/build.zig.zon +19 -0
  400. package/src/zig/edit-buffer.zig +900 -0
  401. package/src/zig/editor-view.zig +812 -0
  402. package/src/zig/event-bus.zig +28 -0
  403. package/src/zig/event-emitter.zig +65 -0
  404. package/src/zig/file-logger.zig +92 -0
  405. package/src/zig/grapheme.zig +838 -0
  406. package/src/zig/handles.zig +267 -0
  407. package/src/zig/lib.zig +2813 -0
  408. package/src/zig/link.zig +349 -0
  409. package/src/zig/logger.zig +43 -0
  410. package/src/zig/mem-registry.zig +126 -0
  411. package/src/zig/miniaudio_shim.c +21 -0
  412. package/src/zig/native-renderable.zig +112 -0
  413. package/src/zig/native-span-feed-bench-lib.zig +7 -0
  414. package/src/zig/native-span-feed.zig +747 -0
  415. package/src/zig/renderer-output.zig +677 -0
  416. package/src/zig/renderer.zig +2106 -0
  417. package/src/zig/rope.zig +1220 -0
  418. package/src/zig/split-scrollback.zig +88 -0
  419. package/src/zig/syntax-style.zig +174 -0
  420. package/src/zig/terminal.zig +1652 -0
  421. package/src/zig/test.zig +82 -0
  422. package/src/zig/tests/README.md +18 -0
  423. package/src/zig/tests/ansi_test.zig +18 -0
  424. package/src/zig/tests/audio_test.zig +439 -0
  425. package/src/zig/tests/buffer-methods_test.zig +1111 -0
  426. package/src/zig/tests/buffer_test.zig +2926 -0
  427. package/src/zig/tests/edit-buffer-history_test.zig +299 -0
  428. package/src/zig/tests/edit-buffer_test.zig +1688 -0
  429. package/src/zig/tests/editor-view_test.zig +3454 -0
  430. package/src/zig/tests/event-emitter_test.zig +249 -0
  431. package/src/zig/tests/grapheme_test.zig +1329 -0
  432. package/src/zig/tests/handles_test.zig +123 -0
  433. package/src/zig/tests/link_test.zig +220 -0
  434. package/src/zig/tests/mem-registry_test.zig +473 -0
  435. package/src/zig/tests/memory_leak_regression_test.zig +234 -0
  436. package/src/zig/tests/native-renderable_test.zig +16 -0
  437. package/src/zig/tests/native-span-feed_test.zig +1266 -0
  438. package/src/zig/tests/renderer_test.zig +2604 -0
  439. package/src/zig/tests/rope-nested_test.zig +712 -0
  440. package/src/zig/tests/rope_fuzz_test.zig +238 -0
  441. package/src/zig/tests/rope_test.zig +2362 -0
  442. package/src/zig/tests/segment-merge.test.zig +148 -0
  443. package/src/zig/tests/split-scrollback_test.zig +70 -0
  444. package/src/zig/tests/syntax-style_test.zig +558 -0
  445. package/src/zig/tests/terminal_test.zig +1489 -0
  446. package/src/zig/tests/test-renderer.zig +126 -0
  447. package/src/zig/tests/text-buffer-drawing_test.zig +3291 -0
  448. package/src/zig/tests/text-buffer-highlights_test.zig +706 -0
  449. package/src/zig/tests/text-buffer-iterators_test.zig +776 -0
  450. package/src/zig/tests/text-buffer-segment_test.zig +320 -0
  451. package/src/zig/tests/text-buffer-selection_test.zig +1056 -0
  452. package/src/zig/tests/text-buffer-selection_viewport_test.zig +358 -0
  453. package/src/zig/tests/text-buffer-view_test.zig +3672 -0
  454. package/src/zig/tests/text-buffer_test.zig +2191 -0
  455. package/src/zig/tests/unicode-width-map.zon +3909 -0
  456. package/src/zig/tests/utf8_no_zwj_test.zig +260 -0
  457. package/src/zig/tests/utf8_test.zig +4060 -0
  458. package/src/zig/tests/utf8_wcwidth_cursor_test.zig +267 -0
  459. package/src/zig/tests/utf8_wcwidth_test.zig +357 -0
  460. package/src/zig/tests/word-wrap-editing_test.zig +498 -0
  461. package/src/zig/tests/wrap-cache-perf_test.zig +113 -0
  462. package/src/zig/tests/yoga_test.zig +65 -0
  463. package/src/zig/text-buffer-iterators.zig +496 -0
  464. package/src/zig/text-buffer-segment.zig +403 -0
  465. package/src/zig/text-buffer-view.zig +1428 -0
  466. package/src/zig/text-buffer.zig +1272 -0
  467. package/src/zig/utf8.zig +1954 -0
  468. package/src/zig/utils.zig +12 -0
  469. package/src/zig/uucode-stub.zig +76 -0
  470. package/src/zig/vendor/miniaudio/miniaudio.h +93468 -0
  471. package/src/zig/yoga.zig +658 -0
  472. package/src/zig-structs.ts +381 -0
  473. package/src/zig.ts +5260 -0
  474. package/tsconfig.build.json +17 -0
  475. package/tsconfig.json +27 -0
  476. package/tsconfig.node-test.json +138 -0
@@ -0,0 +1,4175 @@
1
+ import { test, expect, beforeAll, beforeEach, afterEach, afterAll } from "bun:test"
2
+ import { Edge } from "../../yoga.js"
3
+ import { Lexer } from "marked"
4
+ import { createMarkdownCodeBlockRenderer, MarkdownRenderable, type MarkdownOptions } from "../Markdown.js"
5
+ import { CodeRenderable } from "../Code.js"
6
+ import { BoxRenderable } from "../Box.js"
7
+ import { TextRenderable } from "../Text.js"
8
+ import { TextTableRenderable } from "../TextTable.js"
9
+ import { SyntaxStyle } from "../../syntax-style.js"
10
+ import { RGBA } from "../../lib/RGBA.js"
11
+ import { TreeSitterClient } from "../../lib/tree-sitter/index.js"
12
+ import { tmpdir } from "node:os"
13
+ import { join } from "node:path"
14
+ import { mkdir } from "node:fs/promises"
15
+ import {
16
+ createTestRenderer,
17
+ type MockMouse,
18
+ type TestRenderer,
19
+ MockTreeSitterClient,
20
+ TestRecorder,
21
+ } from "../../testing.js"
22
+ import { ManualClock } from "../../testing/manual-clock.js"
23
+ import { TextAttributes, type CapturedFrame } from "../../types.js"
24
+
25
+ let renderer: TestRenderer
26
+ let mockMouse: MockMouse
27
+ let renderOnce: () => Promise<void>
28
+ let captureFrame: () => string
29
+ let captureSpans: () => CapturedFrame
30
+ let markdownTreeSitterClient: TreeSitterClient
31
+ let mockTreeSitterClients: MockTreeSitterClient[] = []
32
+ const HIGHLIGHT_TIMEOUT_MS = 5000
33
+
34
+ const syntaxStyle = SyntaxStyle.fromStyles({
35
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
36
+ })
37
+
38
+ beforeAll(async () => {
39
+ const dataPath = join(tmpdir(), "tree-sitter-markdown-renderable-test-data")
40
+ await mkdir(dataPath, { recursive: true })
41
+
42
+ markdownTreeSitterClient = new TreeSitterClient({ dataPath })
43
+ await markdownTreeSitterClient.initialize()
44
+ })
45
+
46
+ beforeEach(async () => {
47
+ mockTreeSitterClients = []
48
+ const testRenderer = await createTestRenderer({ width: 60, height: 40 })
49
+ renderer = testRenderer.renderer
50
+ mockMouse = testRenderer.mockMouse
51
+ renderOnce = testRenderer.renderOnce
52
+ captureFrame = testRenderer.captureCharFrame
53
+ captureSpans = testRenderer.captureSpans
54
+ })
55
+
56
+ afterEach(async () => {
57
+ if (renderer) {
58
+ renderer.destroy()
59
+ }
60
+
61
+ for (const client of mockTreeSitterClients) {
62
+ client.resolveAllHighlightOnce()
63
+ await client.destroy()
64
+ }
65
+ })
66
+
67
+ afterAll(async () => {
68
+ await markdownTreeSitterClient.destroy()
69
+ })
70
+
71
+ function createMarkdownRenderable(options: MarkdownOptions): MarkdownRenderable {
72
+ return new MarkdownRenderable(renderer, {
73
+ treeSitterClient: markdownTreeSitterClient,
74
+ ...options,
75
+ })
76
+ }
77
+
78
+ function createMockTreeSitterClient(): MockTreeSitterClient {
79
+ const client = new MockTreeSitterClient()
80
+ mockTreeSitterClients.push(client)
81
+ return client
82
+ }
83
+
84
+ async function flushAsync(): Promise<void> {
85
+ await Promise.resolve()
86
+ await Promise.resolve()
87
+ }
88
+
89
+ async function waitForHighlight(codeRenderable: CodeRenderable): Promise<void> {
90
+ let timeoutId: ReturnType<typeof setTimeout> | undefined
91
+ try {
92
+ await Promise.race([
93
+ codeRenderable.highlightingDone,
94
+ new Promise<never>((_, reject) => {
95
+ timeoutId = setTimeout(
96
+ () => reject(new Error("Timed out waiting for CodeRenderable highlighting")),
97
+ HIGHLIGHT_TIMEOUT_MS,
98
+ )
99
+ }),
100
+ ])
101
+ } finally {
102
+ if (timeoutId !== undefined) {
103
+ clearTimeout(timeoutId)
104
+ }
105
+ }
106
+
107
+ await flushAsync()
108
+ }
109
+
110
+ function getPendingMarkdownParagraphHighlights(md: MarkdownRenderable): CodeRenderable[] {
111
+ const children = [...md.getChildren()]
112
+ const pending: CodeRenderable[] = []
113
+
114
+ while (children.length > 0) {
115
+ const child = children.pop()!
116
+ if (child instanceof CodeRenderable && child.filetype === "markdown" && child.isHighlighting) {
117
+ pending.push(child)
118
+ }
119
+ children.push(...child.getChildren())
120
+ }
121
+
122
+ return pending
123
+ }
124
+
125
+ async function renderMarkdownRenderable(md: MarkdownRenderable): Promise<void> {
126
+ await renderOnce()
127
+
128
+ for (let attempt = 0; attempt < 20; attempt++) {
129
+ const pendingHighlights = getPendingMarkdownParagraphHighlights(md)
130
+ if (pendingHighlights.length === 0) {
131
+ await renderOnce()
132
+ return
133
+ }
134
+
135
+ await Promise.all(pendingHighlights.map((codeBlock) => waitForHighlight(codeBlock)))
136
+ await renderOnce()
137
+ }
138
+
139
+ throw new Error("Timed out waiting for markdown paragraph highlights")
140
+ }
141
+
142
+ async function renderMarkdown(markdown: string, conceal: boolean = true): Promise<string> {
143
+ const md = createMarkdownRenderable({
144
+ id: "markdown",
145
+ content: markdown,
146
+ syntaxStyle,
147
+ conceal,
148
+ tableOptions: { widthMode: "content" },
149
+ })
150
+
151
+ renderer.root.add(md)
152
+ await renderMarkdownRenderable(md)
153
+
154
+ const lines = captureFrame()
155
+ .split("\n")
156
+ .map((line) => line.trimEnd())
157
+ return "\n" + lines.join("\n").trimEnd()
158
+ }
159
+
160
+ function findSpanContaining(frame: CapturedFrame, text: string) {
161
+ for (const line of frame.lines) {
162
+ const span = line.spans.find((candidate) => candidate.text.includes(text))
163
+ if (span) return span
164
+ }
165
+
166
+ return undefined
167
+ }
168
+
169
+ function getMarginBottom(renderable: { getLayoutNode(): { getMargin(edge: Edge): unknown } }): number {
170
+ const margin = renderable.getLayoutNode().getMargin(Edge.Bottom) as unknown
171
+ if (typeof margin === "number") return margin
172
+ if (typeof margin === "object" && margin && "value" in margin && typeof margin.value === "number") {
173
+ return margin.value
174
+ }
175
+ return 0
176
+ }
177
+
178
+ test("basic table alignment", async () => {
179
+ const markdown = `| Name | Age |
180
+ |---|---|
181
+ | Alice | 30 |
182
+ | Bob | 5 |`
183
+
184
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
185
+ "
186
+ ┌─────┬───┐
187
+ │Name │Age│
188
+ ├─────┼───┤
189
+ │Alice│30 │
190
+ ├─────┼───┤
191
+ │Bob │5 │
192
+ └─────┴───┘"
193
+ `)
194
+ })
195
+
196
+ test("tableOptions.widthMode configures markdown table layout", async () => {
197
+ const md = createMarkdownRenderable({
198
+ id: "markdown-table-width-mode",
199
+ content: "| Name | Age |\n|---|---|\n| Alice | 30 |",
200
+ syntaxStyle,
201
+ tableOptions: {
202
+ widthMode: "full",
203
+ columnFitter: "balanced",
204
+ },
205
+ })
206
+
207
+ renderer.root.add(md)
208
+ await renderer.idle()
209
+
210
+ const table = md._blockStates[0]?.renderable as TextTableRenderable
211
+ expect(table).toBeInstanceOf(TextTableRenderable)
212
+ expect(table.columnWidthMode).toBe("full")
213
+ expect(table.columnFitter).toBe("balanced")
214
+ })
215
+
216
+ test("tableOptions updates existing markdown table renderable", async () => {
217
+ const md = createMarkdownRenderable({
218
+ id: "markdown-table-updates",
219
+ content: "| Name | Age |\n|---|---|\n| Alice | 30 |",
220
+ syntaxStyle,
221
+ })
222
+
223
+ renderer.root.add(md)
224
+ await renderer.idle()
225
+
226
+ const table = md._blockStates[0]?.renderable as TextTableRenderable
227
+ expect(table).toBeInstanceOf(TextTableRenderable)
228
+ expect(table.columnWidthMode).toBe("full")
229
+
230
+ md.tableOptions = {
231
+ widthMode: "full",
232
+ columnFitter: "balanced",
233
+ wrapMode: "word",
234
+ cellPadding: 1,
235
+ cellPaddingX: 2,
236
+ cellPaddingY: 0,
237
+ borders: false,
238
+ selectable: false,
239
+ }
240
+
241
+ await renderer.idle()
242
+
243
+ const updatedTable = md._blockStates[0]?.renderable as TextTableRenderable
244
+ expect(updatedTable).toBe(table)
245
+ expect(updatedTable.columnWidthMode).toBe("full")
246
+ expect(updatedTable.columnFitter).toBe("balanced")
247
+ expect(updatedTable.wrapMode).toBe("word")
248
+ expect(updatedTable.cellPadding).toBe(0)
249
+ expect(updatedTable.cellPaddingX).toBe(2)
250
+ expect(updatedTable.cellPaddingY).toBe(0)
251
+ expect(updatedTable.border).toBe(false)
252
+ expect(updatedTable.outerBorder).toBe(false)
253
+ expect(updatedTable.showBorders).toBe(false)
254
+ expect(updatedTable.selectable).toBe(false)
255
+ })
256
+
257
+ test("tableOptions.cellPaddingX pads cells horizontally without vertical padding", async () => {
258
+ const md = createMarkdownRenderable({
259
+ id: "markdown-table-horizontal-padding",
260
+ content: "| A | B |\n|---|---|\n| 1 | 2 |",
261
+ syntaxStyle,
262
+ tableOptions: { style: "grid", widthMode: "content", cellPaddingX: 1, cellPaddingY: 0 },
263
+ })
264
+
265
+ renderer.root.add(md)
266
+ await renderMarkdownRenderable(md)
267
+
268
+ const lines = captureFrame()
269
+ .split("\n")
270
+ .map((line) => line.trimEnd())
271
+ expect("\n" + lines.join("\n").trimEnd()).toMatchInlineSnapshot(`
272
+ "
273
+ ┌───┬───┐
274
+ │ A │ B │
275
+ ├───┼───┤
276
+ │ 1 │ 2 │
277
+ └───┴───┘"
278
+ `)
279
+ })
280
+
281
+ test("internalBlockMode=top-level defaults markdown tables to borderless columns", async () => {
282
+ const md = createMarkdownRenderable({
283
+ id: "markdown-top-level-table-default-style",
284
+ content: "| Name | Age |\n|---|---|\n| Alice | 30 |",
285
+ syntaxStyle,
286
+ internalBlockMode: "top-level",
287
+ })
288
+
289
+ renderer.root.add(md)
290
+ await renderMarkdownRenderable(md)
291
+
292
+ const table = md._blockStates[0]?.renderable as TextTableRenderable
293
+ expect(table).toBeInstanceOf(TextTableRenderable)
294
+ expect(table.columnWidthMode).toBe("content")
295
+ expect(table.columnGap).toBe(2)
296
+ expect(table.border).toBe(false)
297
+ expect(table.outerBorder).toBe(false)
298
+ expect(table.showBorders).toBe(false)
299
+
300
+ const rendered = captureFrame()
301
+ .split("\n")
302
+ .map((line) => line.trimEnd())
303
+ .join("\n")
304
+
305
+ expect(rendered).not.toContain("┌")
306
+ expect(rendered).toMatch(/Name\s{2,}Age/)
307
+ expect(rendered).toMatch(/Alice\s{2,}30/)
308
+ })
309
+
310
+ test("tableOptions.style updates existing markdown table renderable content layout", async () => {
311
+ const md = createMarkdownRenderable({
312
+ id: "markdown-table-style-updates",
313
+ content: "| Name | Age |\n|---|---|\n| Alice | 30 |",
314
+ syntaxStyle,
315
+ })
316
+
317
+ renderer.root.add(md)
318
+ await renderMarkdownRenderable(md)
319
+
320
+ const table = md._blockStates[0]?.renderable as TextTableRenderable
321
+ expect(table).toBeInstanceOf(TextTableRenderable)
322
+ expect(table.border).toBe(true)
323
+
324
+ md.tableOptions = { style: "columns" }
325
+
326
+ await renderer.idle()
327
+
328
+ const updatedTable = md._blockStates[0]?.renderable as TextTableRenderable
329
+ expect(updatedTable).toBe(table)
330
+ expect(updatedTable.columnWidthMode).toBe("content")
331
+ expect(updatedTable.columnGap).toBe(2)
332
+ expect(updatedTable.border).toBe(false)
333
+ expect(updatedTable.outerBorder).toBe(false)
334
+ expect(updatedTable.showBorders).toBe(false)
335
+
336
+ const rendered = captureFrame()
337
+ .split("\n")
338
+ .map((line) => line.trimEnd())
339
+ .join("\n")
340
+
341
+ expect(rendered).not.toContain("┌")
342
+ expect(rendered).toMatch(/Name\s{2,}Age/)
343
+ expect(rendered).toMatch(/Alice\s{2,}30/)
344
+ })
345
+
346
+ test("borderless column tables keep visual spacing out of copied text", async () => {
347
+ const md = createMarkdownRenderable({
348
+ id: "markdown-top-level-table-selection",
349
+ content: "| Name | Age |\n|---|---|\n| Alice | 30 |",
350
+ syntaxStyle,
351
+ internalBlockMode: "top-level",
352
+ })
353
+
354
+ renderer.root.add(md)
355
+ await renderMarkdownRenderable(md)
356
+
357
+ const table = md._blockStates[0]?.renderable as TextTableRenderable
358
+ expect(table).toBeInstanceOf(TextTableRenderable)
359
+
360
+ const lines = captureFrame().split("\n")
361
+ const headerY = lines.findIndex((line) => line.includes("Name"))
362
+ const rowY = lines.findIndex((line) => line.includes("Alice"))
363
+ const startX = lines[headerY]!.indexOf("Name")
364
+ const endX = table.x + table.width - 1
365
+
366
+ await mockMouse.drag(startX, headerY, endX, rowY)
367
+ await renderer.idle()
368
+
369
+ expect(table.getSelectedText()).toBe("Name\tAge\nAlice\t30")
370
+ })
371
+
372
+ test("table with inline code (backticks)", async () => {
373
+ const markdown = `| Command | Description |
374
+ |---|---|
375
+ | \`npm install\` | Install deps |
376
+ | \`npm run build\` | Build project |
377
+ | \`npm test\` | Run tests |`
378
+
379
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
380
+ "
381
+ ┌─────────────┬─────────────┐
382
+ │Command │Description │
383
+ ├─────────────┼─────────────┤
384
+ │npm install │Install deps │
385
+ ├─────────────┼─────────────┤
386
+ │npm run build│Build project│
387
+ ├─────────────┼─────────────┤
388
+ │npm test │Run tests │
389
+ └─────────────┴─────────────┘"
390
+ `)
391
+ })
392
+
393
+ test("table with bold text", async () => {
394
+ const markdown = `| Feature | Status |
395
+ |---|---|
396
+ | **Authentication** | Done |
397
+ | **API** | WIP |`
398
+
399
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
400
+ "
401
+ ┌──────────────┬──────┐
402
+ │Feature │Status│
403
+ ├──────────────┼──────┤
404
+ │Authentication│Done │
405
+ ├──────────────┼──────┤
406
+ │API │WIP │
407
+ └──────────────┴──────┘"
408
+ `)
409
+ })
410
+
411
+ test("table with italic text", async () => {
412
+ const markdown = `| Item | Note |
413
+ |---|---|
414
+ | One | *important* |
415
+ | Two | *ok* |`
416
+
417
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
418
+ "
419
+ ┌────┬─────────┐
420
+ │Item│Note │
421
+ ├────┼─────────┤
422
+ │One │important│
423
+ ├────┼─────────┤
424
+ │Two │ok │
425
+ └────┴─────────┘"
426
+ `)
427
+ })
428
+
429
+ test("table with mixed formatting", async () => {
430
+ const markdown = `| Type | Value | Notes |
431
+ |---|---|---|
432
+ | **Bold** | \`code\` | *italic* |
433
+ | Plain | **strong** | \`cmd\` |`
434
+
435
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
436
+ "
437
+ ┌─────┬──────┬──────┐
438
+ │Type │Value │Notes │
439
+ ├─────┼──────┼──────┤
440
+ │Bold │code │italic│
441
+ ├─────┼──────┼──────┤
442
+ │Plain│strong│cmd │
443
+ └─────┴──────┴──────┘"
444
+ `)
445
+ })
446
+
447
+ test("table with alignment markers (left, center, right)", async () => {
448
+ const markdown = `| Left | Center | Right |
449
+ |:---|:---:|---:|
450
+ | A | B | C |
451
+ | Long text | X | Y |`
452
+
453
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
454
+ "
455
+ ┌─────────┬──────┬─────┐
456
+ │Left │Center│Right│
457
+ ├─────────┼──────┼─────┤
458
+ │A │B │C │
459
+ ├─────────┼──────┼─────┤
460
+ │Long text│X │Y │
461
+ └─────────┴──────┴─────┘"
462
+ `)
463
+ })
464
+
465
+ test("table with empty cells", async () => {
466
+ const markdown = `| A | B |
467
+ |---|---|
468
+ | X | |
469
+ | | Y |`
470
+
471
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
472
+ "
473
+ ┌─┬─┐
474
+ │A│B│
475
+ ├─┼─┤
476
+ │X│ │
477
+ ├─┼─┤
478
+ │ │Y│
479
+ └─┴─┘"
480
+ `)
481
+ })
482
+
483
+ test("table with long header and short content", async () => {
484
+ const markdown = `| Very Long Column Header | Short |
485
+ |---|---|
486
+ | A | B |`
487
+
488
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
489
+ "
490
+ ┌───────────────────────┬─────┐
491
+ │Very Long Column Header│Short│
492
+ ├───────────────────────┼─────┤
493
+ │A │B │
494
+ └───────────────────────┴─────┘"
495
+ `)
496
+ })
497
+
498
+ test("table with short header and long content", async () => {
499
+ const markdown = `| X | Y |
500
+ |---|---|
501
+ | This is very long content | Short |`
502
+
503
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
504
+ "
505
+ ┌─────────────────────────┬─────┐
506
+ │X │Y │
507
+ ├─────────────────────────┼─────┤
508
+ │This is very long content│Short│
509
+ └─────────────────────────┴─────┘"
510
+ `)
511
+ })
512
+
513
+ test("table inside code block should NOT be formatted", async () => {
514
+ const markdown = `\`\`\`
515
+ | Not | A | Table |
516
+ |---|---|---|
517
+ | Should | Stay | Raw |
518
+ \`\`\`
519
+
520
+ | Real | Table |
521
+ |---|---|
522
+ | Is | Formatted |`
523
+
524
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
525
+ "
526
+ | Not | A | Table |
527
+ |---|---|---|
528
+ | Should | Stay | Raw |
529
+
530
+ ┌────┬─────────┐
531
+ │Real│Table │
532
+ ├────┼─────────┤
533
+ │Is │Formatted│
534
+ └────┴─────────┘"
535
+ `)
536
+ })
537
+
538
+ test("paragraphs and fenced code blocks keep markdown block spacing", async () => {
539
+ const markdown = `Before
540
+
541
+ \`\`\`ts
542
+ const value = 1
543
+ \`\`\`
544
+
545
+ After`
546
+
547
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
548
+ "
549
+ Before
550
+
551
+ const value = 1
552
+
553
+ After"
554
+ `)
555
+ })
556
+
557
+ test("paragraphs keep spacing when a fenced code block is appended", async () => {
558
+ const md = createMarkdownRenderable({
559
+ id: "markdown-append-code-spacing",
560
+ content: "Before",
561
+ syntaxStyle,
562
+ })
563
+
564
+ renderer.root.add(md)
565
+ await renderMarkdownRenderable(md)
566
+
567
+ md.content = `Before
568
+
569
+ \`\`\`ts
570
+ const value = 1
571
+ \`\`\``
572
+ await renderMarkdownRenderable(md)
573
+
574
+ expect(md._blockStates.map((state) => state.token.type)).toEqual(["paragraph", "code"])
575
+ expect(getMarginBottom(md._blockStates[0]!.renderable)).toBe(1)
576
+
577
+ const lines = captureFrame()
578
+ .split("\n")
579
+ .map((line) => line.trimEnd())
580
+ expect("\n" + lines.join("\n").trimEnd()).toMatchInlineSnapshot(`
581
+ "
582
+ Before
583
+
584
+ const value = 1"
585
+ `)
586
+ })
587
+
588
+ test("paragraph margins update when a following fenced code block is removed", async () => {
589
+ const md = createMarkdownRenderable({
590
+ id: "markdown-remove-code-spacing",
591
+ content: `Before
592
+
593
+ \`\`\`ts
594
+ const value = 1
595
+ \`\`\``,
596
+ syntaxStyle,
597
+ })
598
+
599
+ renderer.root.add(md)
600
+ await renderMarkdownRenderable(md)
601
+
602
+ expect(getMarginBottom(md._blockStates[0]!.renderable)).toBe(1)
603
+
604
+ md.content = "Before"
605
+ await renderMarkdownRenderable(md)
606
+
607
+ expect(md._blockStates.map((state) => state.token.type)).toEqual(["paragraph"])
608
+ expect(getMarginBottom(md._blockStates[0]!.renderable)).toBe(0)
609
+ })
610
+
611
+ test("code block margins update when a following paragraph is removed", async () => {
612
+ const md = createMarkdownRenderable({
613
+ id: "markdown-remove-paragraph-after-code-spacing",
614
+ content: `\`\`\`ts
615
+ const value = 1
616
+ \`\`\`
617
+
618
+ After`,
619
+ syntaxStyle,
620
+ })
621
+
622
+ renderer.root.add(md)
623
+ await renderMarkdownRenderable(md)
624
+
625
+ expect(md._blockStates.map((state) => state.token.type)).toEqual(["code", "paragraph"])
626
+ expect(getMarginBottom(md._blockStates[0]!.renderable)).toBe(1)
627
+
628
+ md.content = `\`\`\`ts
629
+ const value = 1
630
+ \`\`\``
631
+ await renderMarkdownRenderable(md)
632
+
633
+ expect(md._blockStates.map((state) => state.token.type)).toEqual(["code"])
634
+ expect(getMarginBottom(md._blockStates[0]!.renderable)).toBe(0)
635
+ })
636
+
637
+ test("tight paragraphs and fenced code blocks keep exactly one separator row", async () => {
638
+ const markdown = `Before
639
+ \`\`\`ts
640
+ const value = 1
641
+ \`\`\``
642
+
643
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
644
+ "
645
+ Before
646
+
647
+ const value = 1"
648
+ `)
649
+ })
650
+
651
+ test("headings and fenced code blocks keep exactly one separator row", async () => {
652
+ const markdown = `## Before
653
+
654
+ \`\`\`ts
655
+ const value = 1
656
+ \`\`\``
657
+
658
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
659
+ "
660
+ Before
661
+
662
+ const value = 1"
663
+ `)
664
+ })
665
+
666
+ test("headings and tables keep exactly one separator row", async () => {
667
+ const markdown = `## Before
668
+
669
+ | A | B |
670
+ |---|---|
671
+ | 1 | 2 |`
672
+
673
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
674
+ "
675
+ Before
676
+
677
+ ┌─┬─┐
678
+ │A│B│
679
+ ├─┼─┤
680
+ │1│2│
681
+ └─┴─┘"
682
+ `)
683
+ })
684
+
685
+ test("headings and blockquotes keep exactly one separator row", async () => {
686
+ const markdown = `## Before
687
+
688
+ > quoted text`
689
+
690
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
691
+ "
692
+ Before
693
+
694
+ │ quoted text"
695
+ `)
696
+ })
697
+
698
+ test("headings and horizontal rules keep exactly one separator row", async () => {
699
+ const markdown = `## Before
700
+
701
+ ---`
702
+
703
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
704
+ "
705
+ Before
706
+
707
+ ────────────────────────────────────────────────────────────"
708
+ `)
709
+ })
710
+
711
+ test("multiple tables in same document", async () => {
712
+ const markdown = `| Table1 | A |
713
+ |---|---|
714
+ | X | Y |
715
+
716
+ Some text between.
717
+
718
+ | Table2 | BB |
719
+ |---|---|
720
+ | Long content | Z |`
721
+
722
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
723
+ "
724
+ ┌──────┬─┐
725
+ │Table1│A│
726
+ ├──────┼─┤
727
+ │X │Y│
728
+ └──────┴─┘
729
+
730
+ Some text between.
731
+
732
+ ┌────────────┬──┐
733
+ │Table2 │BB│
734
+ ├────────────┼──┤
735
+ │Long content│Z │
736
+ └────────────┴──┘"
737
+ `)
738
+ })
739
+
740
+ test("table with escaped pipe character", async () => {
741
+ const markdown = `| Command | Output |
742
+ |---|---|
743
+ | echo | Hello |
744
+ | ls \\| grep | Filtered |`
745
+
746
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
747
+ "
748
+ ┌─────────┬────────┐
749
+ │Command │Output │
750
+ ├─────────┼────────┤
751
+ │echo │Hello │
752
+ ├─────────┼────────┤
753
+ │ls | grep│Filtered│
754
+ └─────────┴────────┘"
755
+ `)
756
+ })
757
+
758
+ test("table with unicode characters", async () => {
759
+ const markdown = `| Emoji | Name |
760
+ |---|---|
761
+ | 🎉 | Party |
762
+ | 🚀 | Rocket |
763
+ | 日本語 | Japanese |`
764
+
765
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
766
+ "
767
+ ┌──────┬────────┐
768
+ │Emoji │Name │
769
+ ├──────┼────────┤
770
+ │🎉 │Party │
771
+ ├──────┼────────┤
772
+ │🚀 │Rocket │
773
+ ├──────┼────────┤
774
+ │日本語│Japanese│
775
+ └──────┴────────┘"
776
+ `)
777
+ })
778
+
779
+ test("table with links", async () => {
780
+ const markdown = `| Name | Link |
781
+ |---|---|
782
+ | Google | [link](https://google.com) |
783
+ | GitHub | [gh](https://github.com) |`
784
+
785
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
786
+ "
787
+ ┌──────┬─────────────────────────┐
788
+ │Name │Link │
789
+ ├──────┼─────────────────────────┤
790
+ │Google│link (https://google.com)│
791
+ ├──────┼─────────────────────────┤
792
+ │GitHub│gh (https://github.com) │
793
+ └──────┴─────────────────────────┘"
794
+ `)
795
+ })
796
+
797
+ test("single row table (header + delimiter only)", async () => {
798
+ const markdown = `| Only | Header |
799
+ |---|---|`
800
+
801
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
802
+ "
803
+ | Only | Header |
804
+ |---|---|"
805
+ `)
806
+ })
807
+
808
+ test("table with many columns", async () => {
809
+ const markdown = `| A | B | C | D | E |
810
+ |---|---|---|---|---|
811
+ | 1 | 2 | 3 | 4 | 5 |`
812
+
813
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
814
+ "
815
+ ┌─┬─┬─┬─┬─┐
816
+ │A│B│C│D│E│
817
+ ├─┼─┼─┼─┼─┤
818
+ │1│2│3│4│5│
819
+ └─┴─┴─┴─┴─┘"
820
+ `)
821
+ })
822
+
823
+ test("no tables returns original content", async () => {
824
+ const markdown = `# Just a heading
825
+
826
+ Some paragraph text.
827
+
828
+ - List item`
829
+
830
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
831
+ "
832
+ Just a heading
833
+
834
+ Some paragraph text.
835
+
836
+ - List item"
837
+ `)
838
+ })
839
+
840
+ test("table with nested inline formatting", async () => {
841
+ const markdown = `| Description |
842
+ |---|
843
+ | This has **bold and \`code\`** together |
844
+ | And *italic with **nested bold*** |`
845
+
846
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
847
+ "
848
+ ┌───────────────────────────────┐
849
+ │Description │
850
+ ├───────────────────────────────┤
851
+ │This has bold and code together│
852
+ ├───────────────────────────────┤
853
+ │And italic with nested bold │
854
+ └───────────────────────────────┘"
855
+ `)
856
+ })
857
+
858
+ // Tests with conceal=false - formatting markers should be visible and columns sized accordingly
859
+
860
+ test("conceal=false: table with bold text", async () => {
861
+ const markdown = `| Feature | Status |
862
+ |---|---|
863
+ | **Authentication** | Done |
864
+ | **API** | WIP |`
865
+
866
+ expect(await renderMarkdown(markdown, false)).toMatchInlineSnapshot(`
867
+ "
868
+ ┌──────────────────┬──────┐
869
+ │Feature │Status│
870
+ ├──────────────────┼──────┤
871
+ │**Authentication**│Done │
872
+ ├──────────────────┼──────┤
873
+ │**API** │WIP │
874
+ └──────────────────┴──────┘"
875
+ `)
876
+ })
877
+
878
+ test("conceal=false: table with inline code", async () => {
879
+ const markdown = `| Command | Description |
880
+ |---|---|
881
+ | \`npm install\` | Install deps |
882
+ | \`npm run build\` | Build project |`
883
+
884
+ expect(await renderMarkdown(markdown, false)).toMatchInlineSnapshot(`
885
+ "
886
+ ┌───────────────┬─────────────┐
887
+ │Command │Description │
888
+ ├───────────────┼─────────────┤
889
+ │\`npm install\` │Install deps │
890
+ ├───────────────┼─────────────┤
891
+ │\`npm run build\`│Build project│
892
+ └───────────────┴─────────────┘"
893
+ `)
894
+ })
895
+
896
+ test("conceal=false: table with italic text", async () => {
897
+ const markdown = `| Item | Note |
898
+ |---|---|
899
+ | One | *important* |
900
+ | Two | *ok* |`
901
+
902
+ expect(await renderMarkdown(markdown, false)).toMatchInlineSnapshot(`
903
+ "
904
+ ┌────┬───────────┐
905
+ │Item│Note │
906
+ ├────┼───────────┤
907
+ │One │*important*│
908
+ ├────┼───────────┤
909
+ │Two │*ok* │
910
+ └────┴───────────┘"
911
+ `)
912
+ })
913
+
914
+ test("conceal=false: table with mixed formatting", async () => {
915
+ const markdown = `| Type | Value | Notes |
916
+ |---|---|---|
917
+ | **Bold** | \`code\` | *italic* |
918
+ | Plain | **strong** | \`cmd\` |`
919
+
920
+ expect(await renderMarkdown(markdown, false)).toMatchInlineSnapshot(`
921
+ "
922
+ ┌────────┬──────────┬────────┐
923
+ │Type │Value │Notes │
924
+ ├────────┼──────────┼────────┤
925
+ │**Bold**│\`code\` │*italic*│
926
+ ├────────┼──────────┼────────┤
927
+ │Plain │**strong**│\`cmd\` │
928
+ └────────┴──────────┴────────┘"
929
+ `)
930
+ })
931
+
932
+ test("conceal=false: table with unicode characters", async () => {
933
+ const markdown = `| Emoji | Name |
934
+ |---|---|
935
+ | 🎉 | Party |
936
+ | 🚀 | Rocket |
937
+ | 日本語 | Japanese |`
938
+
939
+ expect(await renderMarkdown(markdown, false)).toMatchInlineSnapshot(`
940
+ "
941
+ ┌──────┬────────┐
942
+ │Emoji │Name │
943
+ ├──────┼────────┤
944
+ │🎉 │Party │
945
+ ├──────┼────────┤
946
+ │🚀 │Rocket │
947
+ ├──────┼────────┤
948
+ │日本語│Japanese│
949
+ └──────┴────────┘"
950
+ `)
951
+ })
952
+
953
+ test("conceal=false: basic table alignment", async () => {
954
+ const markdown = `| Name | Age |
955
+ |---|---|
956
+ | Alice | 30 |
957
+ | Bob | 5 |`
958
+
959
+ expect(await renderMarkdown(markdown, false)).toMatchInlineSnapshot(`
960
+ "
961
+ ┌─────┬───┐
962
+ │Name │Age│
963
+ ├─────┼───┤
964
+ │Alice│30 │
965
+ ├─────┼───┤
966
+ │Bob │5 │
967
+ └─────┴───┘"
968
+ `)
969
+ })
970
+
971
+ test("table with paragraphs before and after", async () => {
972
+ const markdown = `This is a paragraph before the table.
973
+
974
+ | Name | Age |
975
+ |---|---|
976
+ | Alice | 30 |
977
+
978
+ This is a paragraph after the table.`
979
+
980
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
981
+ "
982
+ This is a paragraph before the table.
983
+
984
+ ┌─────┬───┐
985
+ │Name │Age│
986
+ ├─────┼───┤
987
+ │Alice│30 │
988
+ └─────┴───┘
989
+
990
+ This is a paragraph after the table."
991
+ `)
992
+ })
993
+
994
+ test("selection across markdown table includes table data", async () => {
995
+ const markdown = `Intro line above table.
996
+
997
+ | Component | Status | Notes |
998
+ |---|---|---|
999
+ | Authentication | **Done** | OAuth2 + SSO |
1000
+ | Payments API | *In Progress* | Retry + idempotency |
1001
+ | Search Indexer | \`Done\` | Ranking + typo fix |
1002
+
1003
+ Outro line below table.`
1004
+
1005
+ const md = createMarkdownRenderable({
1006
+ id: "markdown",
1007
+ content: markdown,
1008
+ syntaxStyle,
1009
+ })
1010
+
1011
+ renderer.root.add(md)
1012
+ await renderMarkdownRenderable(md)
1013
+
1014
+ const topBlock = md._blockStates[0]?.renderable as CodeRenderable | undefined
1015
+ const tableBlock = md._blockStates[1]?.renderable as TextTableRenderable | undefined
1016
+ const bottomBlock = md._blockStates[2]?.renderable as CodeRenderable | undefined
1017
+
1018
+ expect(topBlock).toBeInstanceOf(CodeRenderable)
1019
+ expect(tableBlock).toBeInstanceOf(TextTableRenderable)
1020
+ expect(bottomBlock).toBeInstanceOf(CodeRenderable)
1021
+
1022
+ const startX = topBlock!.x + 1
1023
+ const startY = topBlock!.y
1024
+ const endX = Math.max(bottomBlock!.x + bottomBlock!.width - 2, startX + 1)
1025
+ const endY = bottomBlock!.y
1026
+
1027
+ await mockMouse.drag(startX, startY, endX, endY)
1028
+ await renderer.idle()
1029
+
1030
+ const selectedText = renderer.getSelection()?.getSelectedText() ?? ""
1031
+
1032
+ expect(selectedText).toContain("Authentication")
1033
+ expect(selectedText).toContain("Payments API")
1034
+ expect(selectedText).toContain("Retry + idempotency")
1035
+ })
1036
+
1037
+ // Code block tests
1038
+
1039
+ test("code block with language", async () => {
1040
+ const markdown = `\`\`\`typescript
1041
+ const x = 1;
1042
+ console.log(x);
1043
+ \`\`\``
1044
+
1045
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
1046
+ "
1047
+ const x = 1;
1048
+ console.log(x);"
1049
+ `)
1050
+ })
1051
+
1052
+ test("code block without language", async () => {
1053
+ const markdown = `\`\`\`
1054
+ plain code block
1055
+ with multiple lines
1056
+ \`\`\``
1057
+
1058
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
1059
+ "
1060
+ plain code block
1061
+ with multiple lines"
1062
+ `)
1063
+ })
1064
+
1065
+ test("code block mixed with text", async () => {
1066
+ const markdown = `Here is some code:
1067
+
1068
+ \`\`\`js
1069
+ function hello() {
1070
+ return "world";
1071
+ }
1072
+ \`\`\`
1073
+
1074
+ And here is more text after.`
1075
+
1076
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
1077
+ "
1078
+ Here is some code:
1079
+
1080
+ function hello() {
1081
+ return "world";
1082
+ }
1083
+
1084
+ And here is more text after."
1085
+ `)
1086
+ })
1087
+
1088
+ test("multiple code blocks", async () => {
1089
+ const markdown = `First block:
1090
+
1091
+ \`\`\`python
1092
+ print("hello")
1093
+ \`\`\`
1094
+
1095
+ Second block:
1096
+
1097
+ \`\`\`rust
1098
+ fn main() {}
1099
+ \`\`\``
1100
+
1101
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
1102
+ "
1103
+ First block:
1104
+
1105
+ print("hello")
1106
+
1107
+ Second block:
1108
+
1109
+ fn main() {}"
1110
+ `)
1111
+ })
1112
+
1113
+ test("code block in conceal=false mode", async () => {
1114
+ const markdown = `\`\`\`js
1115
+ const x = 1;
1116
+ \`\`\``
1117
+
1118
+ expect(await renderMarkdown(markdown, false)).toMatchInlineSnapshot(`
1119
+ "
1120
+ const x = 1;"
1121
+ `)
1122
+ })
1123
+
1124
+ test("code block concealment is disabled by default", async () => {
1125
+ const mockTreeSitterClient = createMockTreeSitterClient()
1126
+ mockTreeSitterClient.setMockResult({
1127
+ highlights: [[0, 1, "conceal", { conceal: "" }]],
1128
+ })
1129
+
1130
+ const md = createMarkdownRenderable({
1131
+ id: "markdown-code-default-conceal",
1132
+ content: "```markdown\n# Hidden heading\n```",
1133
+ syntaxStyle,
1134
+ conceal: true,
1135
+ treeSitterClient: mockTreeSitterClient,
1136
+ })
1137
+
1138
+ renderer.root.add(md)
1139
+ await renderer.idle()
1140
+ expect(mockTreeSitterClient.isHighlighting()).toBe(true)
1141
+
1142
+ const codeBlock = md._blockStates[0]?.renderable as CodeRenderable
1143
+ mockTreeSitterClient.resolveAllHighlightOnce()
1144
+ await waitForHighlight(codeBlock)
1145
+ await renderer.idle()
1146
+
1147
+ const frame = captureFrame()
1148
+ expect(frame).toContain("# Hidden heading")
1149
+ })
1150
+
1151
+ test("code block concealment can be enabled with concealCode", async () => {
1152
+ const mockTreeSitterClient = createMockTreeSitterClient()
1153
+ mockTreeSitterClient.setMockResult({
1154
+ highlights: [[0, 1, "conceal", { conceal: "" }]],
1155
+ })
1156
+
1157
+ const md = createMarkdownRenderable({
1158
+ id: "markdown-code-conceal-enabled",
1159
+ content: "```markdown\n# Hidden heading\n```",
1160
+ syntaxStyle,
1161
+ conceal: true,
1162
+ concealCode: true,
1163
+ treeSitterClient: mockTreeSitterClient,
1164
+ })
1165
+
1166
+ renderer.root.add(md)
1167
+ await renderer.idle()
1168
+ expect(mockTreeSitterClient.isHighlighting()).toBe(true)
1169
+
1170
+ const codeBlock = md._blockStates[0]?.renderable as CodeRenderable
1171
+ mockTreeSitterClient.resolveAllHighlightOnce()
1172
+ await waitForHighlight(codeBlock)
1173
+ await renderer.idle()
1174
+
1175
+ const frame = captureFrame()
1176
+ expect(frame).not.toContain("# Hidden heading")
1177
+ expect(frame).toContain("Hidden heading")
1178
+ })
1179
+
1180
+ test("toggling concealCode updates existing code block renderables", async () => {
1181
+ const mockTreeSitterClient = createMockTreeSitterClient()
1182
+ mockTreeSitterClient.setMockResult({
1183
+ highlights: [[0, 1, "conceal", { conceal: "" }]],
1184
+ })
1185
+
1186
+ const md = createMarkdownRenderable({
1187
+ id: "markdown-code-conceal-toggle",
1188
+ content: "```markdown\n# Hidden heading\n```",
1189
+ syntaxStyle,
1190
+ conceal: true,
1191
+ concealCode: false,
1192
+ treeSitterClient: mockTreeSitterClient,
1193
+ })
1194
+
1195
+ renderer.root.add(md)
1196
+ await renderer.idle()
1197
+ expect(mockTreeSitterClient.isHighlighting()).toBe(true)
1198
+
1199
+ const codeBlock = md._blockStates[0]?.renderable as CodeRenderable
1200
+ mockTreeSitterClient.resolveAllHighlightOnce()
1201
+ await waitForHighlight(codeBlock)
1202
+ await renderer.idle()
1203
+
1204
+ const frameBefore = captureFrame()
1205
+ expect(frameBefore).toContain("# Hidden heading")
1206
+
1207
+ md.concealCode = true
1208
+ renderer.requestRender()
1209
+ await renderer.idle()
1210
+ expect(mockTreeSitterClient.isHighlighting()).toBe(true)
1211
+
1212
+ mockTreeSitterClient.resolveAllHighlightOnce()
1213
+ await waitForHighlight(codeBlock)
1214
+ await renderer.idle()
1215
+
1216
+ const frameAfter = captureFrame()
1217
+ expect(frameAfter).not.toContain("# Hidden heading")
1218
+ expect(frameAfter).toContain("Hidden heading")
1219
+ })
1220
+
1221
+ // Heading tests
1222
+
1223
+ test("headings h1 through h3", async () => {
1224
+ const markdown = `# Heading 1
1225
+
1226
+ ## Heading 2
1227
+
1228
+ ### Heading 3`
1229
+
1230
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
1231
+ "
1232
+ Heading 1
1233
+
1234
+ Heading 2
1235
+
1236
+ Heading 3"
1237
+ `)
1238
+ })
1239
+
1240
+ test("headings with conceal=false show markers", async () => {
1241
+ const markdown = `# Heading 1
1242
+
1243
+ ## Heading 2`
1244
+
1245
+ expect(await renderMarkdown(markdown, false)).toMatchInlineSnapshot(`
1246
+ "
1247
+ # Heading 1
1248
+
1249
+ ## Heading 2"
1250
+ `)
1251
+ })
1252
+
1253
+ // List tests
1254
+
1255
+ test("unordered list", async () => {
1256
+ const markdown = `- Item one
1257
+ - Item two
1258
+ - Item three`
1259
+
1260
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
1261
+ "
1262
+ - Item one
1263
+ - Item two
1264
+ - Item three"
1265
+ `)
1266
+ })
1267
+
1268
+ test("ordered list", async () => {
1269
+ const markdown = `1. First item
1270
+ 2. Second item
1271
+ 3. Third item`
1272
+
1273
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
1274
+ "
1275
+ 1. First item
1276
+ 2. Second item
1277
+ 3. Third item"
1278
+ `)
1279
+ })
1280
+
1281
+ test("list with inline formatting", async () => {
1282
+ const markdown = `- **Bold** item
1283
+ - *Italic* item
1284
+ - \`Code\` item`
1285
+
1286
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
1287
+ "
1288
+ - Bold item
1289
+ - Italic item
1290
+ - Code item"
1291
+ `)
1292
+ })
1293
+
1294
+ test("task lists render checkbox and text on the same line", async () => {
1295
+ const md = createMarkdownRenderable({
1296
+ id: "markdown-structured-task-list",
1297
+ content: `- [x] Done
1298
+ - [ ] Todo`,
1299
+ syntaxStyle,
1300
+ internalBlockMode: "top-level",
1301
+ })
1302
+
1303
+ renderer.root.add(md)
1304
+ await renderMarkdownRenderable(md)
1305
+
1306
+ const lines = captureFrame()
1307
+ .split("\n")
1308
+ .map((line) => line.trimEnd())
1309
+
1310
+ expect("\n" + lines.join("\n").trimEnd()).toMatchInlineSnapshot(`
1311
+ "
1312
+ - Done
1313
+ - Todo"
1314
+ `)
1315
+ })
1316
+
1317
+ test("selection across top-level unordered list copies marker and text on same line", async () => {
1318
+ const md = createMarkdownRenderable({
1319
+ id: "markdown-structured-list-selection",
1320
+ content: `- First item
1321
+ - Second item`,
1322
+ syntaxStyle,
1323
+ internalBlockMode: "top-level",
1324
+ })
1325
+
1326
+ renderer.root.add(md)
1327
+ await renderMarkdownRenderable(md)
1328
+
1329
+ const list = md._blockStates[0]?.renderable
1330
+ expect(list).toBeInstanceOf(BoxRenderable)
1331
+
1332
+ await mockMouse.drag(list!.x, list!.y, list!.x + 20, list!.y + 1)
1333
+ await renderer.idle()
1334
+
1335
+ expect(renderer.getSelection()?.getSelectedText()).toBe("- First item\n- Second item")
1336
+ })
1337
+
1338
+ test("selection across top-level ordered list copies marker and text on same line", async () => {
1339
+ const md = createMarkdownRenderable({
1340
+ id: "markdown-structured-ordered-list-selection",
1341
+ content: `9. Nine
1342
+ 10. Ten`,
1343
+ syntaxStyle,
1344
+ internalBlockMode: "top-level",
1345
+ })
1346
+
1347
+ renderer.root.add(md)
1348
+ await renderMarkdownRenderable(md)
1349
+
1350
+ const list = md._blockStates[0]?.renderable
1351
+ expect(list).toBeInstanceOf(BoxRenderable)
1352
+
1353
+ await mockMouse.drag(list!.x, list!.y, list!.x + 20, list!.y + 1)
1354
+ await renderer.idle()
1355
+
1356
+ expect(renderer.getSelection()?.getSelectedText()).toBe(" 9. Nine\n10. Ten")
1357
+ })
1358
+
1359
+ test("top-level structured lists align nested fenced code under nested content", async () => {
1360
+ const md = createMarkdownRenderable({
1361
+ id: "markdown-structured-list-code",
1362
+ content: `1. First ordered item with \`inline code\`.
1363
+ 2. Second ordered item before a nested list:
1364
+ - Nested bullet before fenced code:
1365
+
1366
+ \`\`\`ts
1367
+ const nested = true
1368
+ \`\`\`
1369
+
1370
+ 3. Third ordered item after the nested fence.`,
1371
+ syntaxStyle,
1372
+ internalBlockMode: "top-level",
1373
+ })
1374
+
1375
+ renderer.root.add(md)
1376
+ await renderMarkdownRenderable(md)
1377
+
1378
+ const lines = captureFrame()
1379
+ .split("\n")
1380
+ .map((line) => line.trimEnd())
1381
+ expect("\n" + lines.join("\n").trimEnd()).toMatchInlineSnapshot(`
1382
+ "
1383
+ 1. First ordered item with inline code.
1384
+ 2. Second ordered item before a nested list:
1385
+ - Nested bullet before fenced code:
1386
+
1387
+ const nested = true
1388
+
1389
+ 3. Third ordered item after the nested fence."
1390
+ `)
1391
+ })
1392
+
1393
+ test("top-level structured ordered lists align multi-digit markers", async () => {
1394
+ const md = createMarkdownRenderable({
1395
+ id: "markdown-structured-list-numbering",
1396
+ content: `9. nine
1397
+ 10. ten
1398
+ 11. eleven`,
1399
+ syntaxStyle,
1400
+ internalBlockMode: "top-level",
1401
+ })
1402
+
1403
+ renderer.root.add(md)
1404
+ await renderMarkdownRenderable(md)
1405
+
1406
+ const lines = captureFrame()
1407
+ .split("\n")
1408
+ .map((line) => line.trimEnd())
1409
+ expect("\n" + lines.join("\n").trimEnd()).toMatchInlineSnapshot(`
1410
+ "
1411
+ 9. nine
1412
+ 10. ten
1413
+ 11. eleven"
1414
+ `)
1415
+ })
1416
+
1417
+ test("streaming structured lists reuse existing renderables while appending", async () => {
1418
+ const md = createMarkdownRenderable({
1419
+ id: "markdown-streaming-structured-list-reuse",
1420
+ content: "- first",
1421
+ syntaxStyle,
1422
+ streaming: true,
1423
+ internalBlockMode: "top-level",
1424
+ })
1425
+
1426
+ renderer.root.add(md)
1427
+ await renderMarkdownRenderable(md)
1428
+
1429
+ const listBefore = md._blockStates[0]?.renderable
1430
+ const firstRowBefore = listBefore?.getChildren()[0]
1431
+ const firstTextBefore = firstRowBefore?.getChildren()[1]?.getChildren()[0]
1432
+
1433
+ expect(listBefore).toBeInstanceOf(BoxRenderable)
1434
+ expect(firstRowBefore).toBeInstanceOf(BoxRenderable)
1435
+ expect(firstTextBefore).toBeInstanceOf(CodeRenderable)
1436
+
1437
+ md.content = "- first\n- second"
1438
+ await renderMarkdownRenderable(md)
1439
+
1440
+ const listAfter = md._blockStates[0]?.renderable
1441
+ const firstRowAfter = listAfter?.getChildren()[0]
1442
+ const firstTextAfter = firstRowAfter?.getChildren()[1]?.getChildren()[0]
1443
+
1444
+ expect(listAfter).toBe(listBefore)
1445
+ expect(firstRowAfter).toBe(firstRowBefore)
1446
+ expect(firstTextAfter).toBe(firstTextBefore)
1447
+ })
1448
+
1449
+ test("streaming structured list updates keep previous item text visible while highlighting", async () => {
1450
+ const mockTreeSitterClient = new MockTreeSitterClient()
1451
+ const md = createMarkdownRenderable({
1452
+ id: "markdown-streaming-structured-list-no-flicker",
1453
+ content: "- alp\n- bet\n- gam",
1454
+ syntaxStyle,
1455
+ streaming: true,
1456
+ internalBlockMode: "top-level",
1457
+ treeSitterClient: mockTreeSitterClient,
1458
+ })
1459
+
1460
+ renderer.root.add(md)
1461
+ await renderOnce()
1462
+ expect(mockTreeSitterClient.isHighlighting()).toBe(true)
1463
+ const initialHighlights = getPendingMarkdownParagraphHighlights(md)
1464
+ expect(initialHighlights.length).toBeGreaterThan(0)
1465
+ mockTreeSitterClient.resolveAllHighlightOnce()
1466
+ await Promise.all(initialHighlights.map((codeBlock) => waitForHighlight(codeBlock)))
1467
+ await renderOnce()
1468
+
1469
+ const settledFrame = captureFrame()
1470
+ expect(settledFrame).toContain("- alp")
1471
+ expect(settledFrame).toContain("- bet")
1472
+ expect(settledFrame).toContain("- gam")
1473
+
1474
+ const clock = new ManualClock()
1475
+ const recorder = new TestRecorder(renderer, { now: () => clock.now() })
1476
+ recorder.rec()
1477
+
1478
+ md.content = "- alpha\n- beta\n- gamma"
1479
+ await renderOnce()
1480
+ clock.advance(16)
1481
+ await renderOnce()
1482
+ const updatedHighlights = getPendingMarkdownParagraphHighlights(md)
1483
+ expect(updatedHighlights.length).toBeGreaterThan(0)
1484
+
1485
+ const framesBeforeHighlight = recorder.recordedFrames.map((recorded) => recorded.frame)
1486
+ expect(framesBeforeHighlight.length).toBeGreaterThan(0)
1487
+ for (const frame of framesBeforeHighlight) {
1488
+ expect(frame).toContain("- alp")
1489
+ expect(frame).toContain("- bet")
1490
+ expect(frame).toContain("- gam")
1491
+ }
1492
+
1493
+ expect(mockTreeSitterClient.isHighlighting()).toBe(true)
1494
+ mockTreeSitterClient.resolveAllHighlightOnce()
1495
+ await Promise.all(updatedHighlights.map((codeBlock) => waitForHighlight(codeBlock)))
1496
+ await renderOnce()
1497
+ recorder.stop()
1498
+
1499
+ const finalFrame = captureFrame()
1500
+ expect(finalFrame).toContain("- alpha")
1501
+ expect(finalFrame).toContain("- beta")
1502
+ expect(finalFrame).toContain("- gamma")
1503
+ })
1504
+
1505
+ test("streaming nested structured list updates keep previous nested text visible while highlighting", async () => {
1506
+ const mockTreeSitterClient = new MockTreeSitterClient()
1507
+ const initialContent = `1. First ordered item with \`inline code\`.
1508
+ 2. Second ordered item before a nested list:
1509
+ - Nested bullet with a long phrase.
1510
+ - Nested bullet before fenced co
1511
+ 3. Third ordered item after the nested fence.`
1512
+ const updatedContent = `1. First ordered item with \`inline code\`.
1513
+ 2. Second ordered item before a nested list:
1514
+ - Nested bullet with a long phrase that should wrap without swallowing the marker or changing indentation.
1515
+ - Nested bullet before fenced code:
1516
+
1517
+ \`\`\`ts
1518
+ const nested = true
1519
+ \`\`\`
1520
+
1521
+ 3. Third ordered item after the nested fence.`
1522
+ const md = createMarkdownRenderable({
1523
+ id: "markdown-streaming-nested-structured-list-no-flicker",
1524
+ content: initialContent,
1525
+ syntaxStyle,
1526
+ streaming: true,
1527
+ internalBlockMode: "top-level",
1528
+ treeSitterClient: mockTreeSitterClient,
1529
+ })
1530
+
1531
+ renderer.root.add(md)
1532
+ await renderOnce()
1533
+ expect(mockTreeSitterClient.isHighlighting()).toBe(true)
1534
+ const initialHighlights = getPendingMarkdownParagraphHighlights(md)
1535
+ expect(initialHighlights.length).toBeGreaterThan(0)
1536
+ mockTreeSitterClient.resolveAllHighlightOnce()
1537
+ await Promise.all(initialHighlights.map((codeBlock) => waitForHighlight(codeBlock)))
1538
+ await renderOnce()
1539
+
1540
+ const settledFrame = captureFrame()
1541
+ expect(settledFrame).toContain("2. Second ordered item before a nested list:")
1542
+ expect(settledFrame).toContain("- Nested bullet with a long phrase.")
1543
+ expect(settledFrame).toContain("- Nested bullet before fenced co")
1544
+
1545
+ const clock = new ManualClock()
1546
+ const recorder = new TestRecorder(renderer, { now: () => clock.now() })
1547
+ recorder.rec()
1548
+
1549
+ md.content = updatedContent
1550
+ await renderOnce()
1551
+ clock.advance(16)
1552
+ await renderOnce()
1553
+ const updatedHighlights = getPendingMarkdownParagraphHighlights(md)
1554
+ expect(updatedHighlights.length).toBeGreaterThan(0)
1555
+
1556
+ const framesBeforeHighlight = recorder.recordedFrames.map((recorded) => recorded.frame)
1557
+ expect(framesBeforeHighlight.length).toBeGreaterThan(0)
1558
+ for (const frame of framesBeforeHighlight) {
1559
+ expect(frame).toContain("2. Second ordered item before a nested list:")
1560
+ expect(frame).toContain("- Nested bullet with a long phrase.")
1561
+ expect(frame).toContain("- Nested bullet before fenced co")
1562
+ }
1563
+
1564
+ expect(mockTreeSitterClient.isHighlighting()).toBe(true)
1565
+ mockTreeSitterClient.resolveAllHighlightOnce()
1566
+ await Promise.all(updatedHighlights.map((codeBlock) => waitForHighlight(codeBlock)))
1567
+ await renderOnce()
1568
+ recorder.stop()
1569
+
1570
+ const finalFrame = captureFrame()
1571
+ expect(finalFrame).toContain("Nested bullet with a long phrase that should wrap")
1572
+ expect(finalFrame).toContain("- Nested bullet before fenced code:")
1573
+ expect(finalFrame).toContain("const nested = true")
1574
+ })
1575
+
1576
+ test("assistant-style top-level markdown layout", async () => {
1577
+ const md = createMarkdownRenderable({
1578
+ id: "assistant-style-layout",
1579
+ content: `# OpenTUI Markdown Demo
1580
+
1581
+ Welcome to the **MarkdownRenderable** showcase! This demonstrates automatic table alignment.
1582
+
1583
+ ## Features
1584
+
1585
+ - Automatic **table column alignment** based on content width
1586
+ - Proper handling of \`inline code\`, **bold**, and *italic* in tables
1587
+
1588
+ ## Renderer Stress Cases
1589
+
1590
+ ### Interleaved Code
1591
+
1592
+ Start with a short conclusion before any code appears.
1593
+
1594
+ \`\`\`ts
1595
+ export function parse(input: string) {
1596
+ return input.trim().split(/\\\\s+/)
1597
+ }
1598
+ \`\`\`
1599
+
1600
+ Then continue with prose immediately after the code block.
1601
+
1602
+ ### Quote, Table, Diff
1603
+
1604
+ > Quoted note after the list. It should preserve quote styling.
1605
+
1606
+ | Feature | Stress |
1607
+ | --- | --- |
1608
+ | Markdown | prose/code/table interleave |
1609
+ | Renderer | wrapping and spacing |
1610
+
1611
+ \`\`\`diff
1612
+ - const renderer = oldMarkdown
1613
+ + const renderer = experimentalMarkdown
1614
+ \`\`\`
1615
+
1616
+ ---`,
1617
+ syntaxStyle,
1618
+ internalBlockMode: "top-level",
1619
+ tableOptions: { style: "grid", widthMode: "content", cellPaddingX: 1 },
1620
+ })
1621
+
1622
+ renderer.root.add(md)
1623
+ await renderMarkdownRenderable(md)
1624
+
1625
+ const lines = captureFrame()
1626
+ .split("\n")
1627
+ .map((line) => line.trimEnd())
1628
+ expect("\n" + lines.join("\n").trimEnd()).toMatchInlineSnapshot(`
1629
+ "
1630
+ OpenTUI Markdown Demo
1631
+
1632
+ Welcome to the MarkdownRenderable showcase! This
1633
+ demonstrates automatic table alignment.
1634
+
1635
+ Features
1636
+
1637
+ - Automatic table column alignment based on content width
1638
+ - Proper handling of inline code, bold, and italic in tables
1639
+
1640
+ Renderer Stress Cases
1641
+
1642
+ Interleaved Code
1643
+
1644
+ Start with a short conclusion before any code appears.
1645
+
1646
+ export function parse(input: string) {
1647
+ return input.trim().split(/\\\\s+/)
1648
+ }
1649
+
1650
+ Then continue with prose immediately after the code block.
1651
+
1652
+ Quote, Table, Diff
1653
+
1654
+ │ Quoted note after the list. It should preserve quote
1655
+ │ styling.
1656
+
1657
+ ┌──────────┬─────────────────────────────┐
1658
+ │ Feature │ Stress │
1659
+ ├──────────┼─────────────────────────────┤
1660
+ │ Markdown │ prose/code/table interleave │
1661
+ ├──────────┼─────────────────────────────┤
1662
+ │ Renderer │ wrapping and spacing │
1663
+ └──────────┴─────────────────────────────┘
1664
+
1665
+ - const renderer = oldMarkdown
1666
+ + const renderer = experimentalMarkdown
1667
+
1668
+ ────────────────────────────────────────────────────────────"
1669
+ `)
1670
+ })
1671
+
1672
+ test("top-level structural markdown blocks have exactly one blank row between them", async () => {
1673
+ const md = createMarkdownRenderable({
1674
+ id: "markdown-structural-spacing",
1675
+ content: `Paragraph before quote.
1676
+
1677
+ - First bullet
1678
+ - Second bullet
1679
+
1680
+ > Quote text.
1681
+
1682
+ 1. First step
1683
+ 2. Second step
1684
+
1685
+ | A | B |
1686
+ | --- | --- |
1687
+ | 1 | 2 |
1688
+
1689
+ \`\`\`diff
1690
+ - old
1691
+ + new
1692
+ \`\`\`
1693
+
1694
+ Paragraph after diff.
1695
+
1696
+ ---
1697
+
1698
+ ## Next Section`,
1699
+ syntaxStyle,
1700
+ internalBlockMode: "top-level",
1701
+ tableOptions: { style: "grid", widthMode: "content", cellPaddingX: 1 },
1702
+ })
1703
+
1704
+ renderer.root.add(md)
1705
+ await renderMarkdownRenderable(md)
1706
+
1707
+ const lines = captureFrame()
1708
+ .split("\n")
1709
+ .map((line) => line.trimEnd())
1710
+ expect("\n" + lines.join("\n").trimEnd()).toMatchInlineSnapshot(`
1711
+ "
1712
+ Paragraph before quote.
1713
+
1714
+ - First bullet
1715
+ - Second bullet
1716
+
1717
+ │ Quote text.
1718
+
1719
+ 1. First step
1720
+ 2. Second step
1721
+
1722
+ ┌───┬───┐
1723
+ │ A │ B │
1724
+ ├───┼───┤
1725
+ │ 1 │ 2 │
1726
+ └───┴───┘
1727
+
1728
+ - old
1729
+ + new
1730
+
1731
+ Paragraph after diff.
1732
+
1733
+ ────────────────────────────────────────────────────────────
1734
+
1735
+ Next Section"
1736
+ `)
1737
+ })
1738
+
1739
+ // Blockquote tests
1740
+
1741
+ test("simple blockquote", async () => {
1742
+ const markdown = `> This is a quote
1743
+ > spanning multiple lines`
1744
+
1745
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
1746
+ "
1747
+ │ This is a quote
1748
+ │ spanning multiple lines"
1749
+ `)
1750
+ })
1751
+
1752
+ test("blockquote uses markup.quote style for text and conceal style for bar", async () => {
1753
+ const quoteColor = RGBA.fromValues(0.25, 0.5, 0.75, 1)
1754
+ const concealColor = RGBA.fromValues(0.1, 0.2, 0.3, 1)
1755
+ const md = createMarkdownRenderable({
1756
+ id: "markdown-blockquote-style",
1757
+ content: "> Quote text",
1758
+ syntaxStyle: SyntaxStyle.fromStyles({
1759
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
1760
+ conceal: { fg: concealColor },
1761
+ "markup.quote": { fg: quoteColor, italic: true },
1762
+ }),
1763
+ })
1764
+
1765
+ renderer.root.add(md)
1766
+ await renderMarkdownRenderable(md)
1767
+
1768
+ const spans = captureSpans()
1769
+ expect(findSpanContaining(spans, "│")?.fg?.toInts()).toEqual(concealColor.toInts())
1770
+
1771
+ const textSpan = findSpanContaining(spans, "Quote text")
1772
+ expect(textSpan?.fg?.toInts()).toEqual(quoteColor.toInts())
1773
+ expect((textSpan?.attributes ?? 0) & TextAttributes.ITALIC).toBe(TextAttributes.ITALIC)
1774
+ })
1775
+
1776
+ test("blockquote updates quote text and bar colors when syntaxStyle changes", async () => {
1777
+ const quoteColor1 = RGBA.fromValues(0.25, 0.5, 0.75, 1)
1778
+ const quoteColor2 = RGBA.fromValues(0.75, 0.5, 0.25, 1)
1779
+ const concealColor1 = RGBA.fromValues(0.1, 0.2, 0.3, 1)
1780
+ const concealColor2 = RGBA.fromValues(0.3, 0.2, 0.1, 1)
1781
+ const theme1 = SyntaxStyle.fromStyles({
1782
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
1783
+ conceal: { fg: concealColor1 },
1784
+ "markup.quote": { fg: quoteColor1, italic: true },
1785
+ })
1786
+ const theme2 = SyntaxStyle.fromStyles({
1787
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
1788
+ conceal: { fg: concealColor2 },
1789
+ "markup.quote": { fg: quoteColor2, italic: true },
1790
+ })
1791
+ const md = createMarkdownRenderable({
1792
+ id: "markdown-blockquote-style-update",
1793
+ content: "> Quote text",
1794
+ syntaxStyle: theme1,
1795
+ })
1796
+
1797
+ renderer.root.add(md)
1798
+ await renderMarkdownRenderable(md)
1799
+ expect(findSpanContaining(captureSpans(), "│")?.fg?.toInts()).toEqual(concealColor1.toInts())
1800
+ expect(findSpanContaining(captureSpans(), "Quote text")?.fg?.toInts()).toEqual(quoteColor1.toInts())
1801
+
1802
+ md.syntaxStyle = theme2
1803
+ renderer.requestRender()
1804
+ await renderMarkdownRenderable(md)
1805
+
1806
+ expect(findSpanContaining(captureSpans(), "│")?.fg?.toInts()).toEqual(concealColor2.toInts())
1807
+ expect(findSpanContaining(captureSpans(), "Quote text")?.fg?.toInts()).toEqual(quoteColor2.toInts())
1808
+ })
1809
+
1810
+ test("fenced diff blocks color added and removed lines", async () => {
1811
+ const mockTreeSitterClient = createMockTreeSitterClient()
1812
+ mockTreeSitterClient.setMockResult({
1813
+ highlights: [
1814
+ [0, 5, "diff.minus"],
1815
+ [6, 11, "diff.plus"],
1816
+ ],
1817
+ })
1818
+
1819
+ const md = createMarkdownRenderable({
1820
+ id: "markdown-diff-fence",
1821
+ content: "```diff\n- old\n+ new\n unchanged\n```",
1822
+ treeSitterClient: mockTreeSitterClient,
1823
+ syntaxStyle: SyntaxStyle.fromStyles({
1824
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
1825
+ "diff.minus": { fg: RGBA.fromValues(1, 0, 0, 1) },
1826
+ "diff.plus": { fg: RGBA.fromValues(0, 1, 0, 1) },
1827
+ }),
1828
+ })
1829
+
1830
+ renderer.root.add(md)
1831
+ await renderMarkdownRenderable(md)
1832
+
1833
+ const codeBlock = md._blockStates[0]?.renderable
1834
+ expect(codeBlock).toBeInstanceOf(CodeRenderable)
1835
+ expect((codeBlock as CodeRenderable).filetype).toBe("diff")
1836
+ expect(mockTreeSitterClient.isHighlighting()).toBe(true)
1837
+
1838
+ mockTreeSitterClient.resolveAllHighlightOnce()
1839
+ await waitForHighlight(codeBlock as CodeRenderable)
1840
+ await renderOnce()
1841
+
1842
+ expect(findSpanContaining(captureSpans(), "- old")?.fg?.toInts()).toEqual(RGBA.fromValues(1, 0, 0, 1).toInts())
1843
+ expect(findSpanContaining(captureSpans(), "+ new")?.fg?.toInts()).toEqual(RGBA.fromValues(0, 1, 0, 1).toInts())
1844
+ })
1845
+
1846
+ // Inline formatting tests
1847
+
1848
+ test("bold text", async () => {
1849
+ const markdown = `This has **bold** text in it.`
1850
+
1851
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
1852
+ "
1853
+ This has bold text in it."
1854
+ `)
1855
+ })
1856
+
1857
+ test("italic text", async () => {
1858
+ const markdown = `This has *italic* text in it.`
1859
+
1860
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
1861
+ "
1862
+ This has italic text in it."
1863
+ `)
1864
+ })
1865
+
1866
+ test("inline code", async () => {
1867
+ const markdown = `Use \`console.log()\` to debug.`
1868
+
1869
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
1870
+ "
1871
+ Use console.log() to debug."
1872
+ `)
1873
+ })
1874
+
1875
+ test("mixed inline formatting", async () => {
1876
+ const markdown = `**Bold**, *italic*, and \`code\` together.`
1877
+
1878
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
1879
+ "
1880
+ Bold, italic, and code together."
1881
+ `)
1882
+ })
1883
+
1884
+ test("inline formatting with conceal=false", async () => {
1885
+ const markdown = `**Bold**, *italic*, and \`code\` together.`
1886
+
1887
+ expect(await renderMarkdown(markdown, false)).toMatchInlineSnapshot(`
1888
+ "
1889
+ **Bold**, *italic*, and \`code\` together."
1890
+ `)
1891
+ })
1892
+
1893
+ // Link tests
1894
+
1895
+ test("links with conceal mode", async () => {
1896
+ const markdown = `Check out [OpenTUI](https://github.com/sst/opentui) for more.`
1897
+
1898
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
1899
+ "
1900
+ Check out OpenTUI (https://github.com/sst/opentui) for more."
1901
+ `)
1902
+ })
1903
+
1904
+ test("links with conceal=false", async () => {
1905
+ const markdown = `Check out [OpenTUI](https://github.com/sst/opentui) for more.`
1906
+
1907
+ expect(await renderMarkdown(markdown, false)).toMatchInlineSnapshot(`
1908
+ "
1909
+ Check out [OpenTUI](https://github.com/sst/opentui) for
1910
+ more."
1911
+ `)
1912
+ })
1913
+
1914
+ // Horizontal rule
1915
+
1916
+ test("horizontal rule", async () => {
1917
+ const markdown = `Before
1918
+
1919
+ ---
1920
+
1921
+ After`
1922
+
1923
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
1924
+ "
1925
+ Before
1926
+
1927
+ ────────────────────────────────────────────────────────────
1928
+
1929
+ After"
1930
+ `)
1931
+ })
1932
+
1933
+ test("horizontal rule has one blank row before and after", async () => {
1934
+ const md = createMarkdownRenderable({
1935
+ id: "markdown-hr-heading-spacing",
1936
+ content: "Before\n\n---\n\n## After",
1937
+ syntaxStyle,
1938
+ internalBlockMode: "top-level",
1939
+ })
1940
+
1941
+ renderer.root.add(md)
1942
+ await renderMarkdownRenderable(md)
1943
+
1944
+ const lines = captureFrame()
1945
+ .split("\n")
1946
+ .map((line) => line.trimEnd())
1947
+ expect("\n" + lines.join("\n").trimEnd()).toMatchInlineSnapshot(`
1948
+ "
1949
+ Before
1950
+
1951
+ ────────────────────────────────────────────────────────────
1952
+
1953
+ After"
1954
+ `)
1955
+ })
1956
+
1957
+ // Complex document
1958
+
1959
+ test("complex markdown document", async () => {
1960
+ const markdown = `# Project Title
1961
+
1962
+ Welcome to **OpenTUI**, a terminal UI library.
1963
+
1964
+ ## Features
1965
+
1966
+ - Automatic table alignment
1967
+ - \`inline code\` support
1968
+ - *Italic* and **bold** text
1969
+
1970
+ ## Code Example
1971
+
1972
+ \`\`\`typescript
1973
+ const md = new MarkdownRenderable(ctx, {
1974
+ content: "# Hello",
1975
+ })
1976
+ \`\`\`
1977
+
1978
+ ## Links
1979
+
1980
+ Visit [GitHub](https://github.com) for more.
1981
+
1982
+ ---
1983
+
1984
+ *Press \`?\` for help*`
1985
+
1986
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
1987
+ "
1988
+ Project Title
1989
+
1990
+ Welcome to OpenTUI, a terminal UI library.
1991
+
1992
+ Features
1993
+
1994
+ - Automatic table alignment
1995
+ - inline code support
1996
+ - Italic and bold text
1997
+
1998
+ Code Example
1999
+
2000
+ const md = new MarkdownRenderable(ctx, {
2001
+ content: "# Hello",
2002
+ })
2003
+
2004
+ Links
2005
+
2006
+ Visit GitHub (https://github.com) for more.
2007
+
2008
+ ────────────────────────────────────────────────────────────
2009
+
2010
+ Press ? for help"
2011
+ `)
2012
+ })
2013
+
2014
+ // Custom renderNode tests
2015
+
2016
+ test("custom renderNode can override heading rendering", async () => {
2017
+ const { TextRenderable } = await import("../Text.js")
2018
+ const { StyledText } = await import("../../lib/styled-text.js")
2019
+
2020
+ // Helper to extract text from marked tokens
2021
+ const extractText = (node: any): string => {
2022
+ if (node.type === "text") return node.text
2023
+ if (node.tokens) return node.tokens.map(extractText).join("")
2024
+ return ""
2025
+ }
2026
+
2027
+ const md = createMarkdownRenderable({
2028
+ id: "custom-heading",
2029
+ content: `# Custom Heading
2030
+
2031
+ Regular paragraph.`,
2032
+ syntaxStyle,
2033
+ renderNode: (node, ctx) => {
2034
+ if (node.type === "heading") {
2035
+ const text = extractText(node)
2036
+ return new TextRenderable(renderer, {
2037
+ id: "custom",
2038
+ content: new StyledText([{ __isChunk: true, text: `[CUSTOM] ${text}`, attributes: 0 }]),
2039
+ width: "100%",
2040
+ })
2041
+ }
2042
+ return ctx.defaultRender()
2043
+ },
2044
+ })
2045
+
2046
+ renderer.root.add(md)
2047
+ await renderMarkdownRenderable(md)
2048
+
2049
+ const lines = captureFrame()
2050
+ .split("\n")
2051
+ .map((line) => line.trimEnd())
2052
+ expect("\n" + lines.join("\n").trimEnd()).toMatchInlineSnapshot(`
2053
+ "
2054
+ [CUSTOM] Custom Heading
2055
+ Regular paragraph."
2056
+ `)
2057
+ })
2058
+
2059
+ test("custom renderNode can override code block rendering", async () => {
2060
+ const { BoxRenderable } = await import("../Box.js")
2061
+ const { TextRenderable } = await import("../Text.js")
2062
+
2063
+ const md = createMarkdownRenderable({
2064
+ id: "custom-code",
2065
+ content: `\`\`\`js
2066
+ const x = 1;
2067
+ \`\`\``,
2068
+ syntaxStyle,
2069
+ renderNode: (node, ctx) => {
2070
+ if (node.type === "code") {
2071
+ const box = new BoxRenderable(renderer, {
2072
+ id: "code-box",
2073
+ border: true,
2074
+ borderStyle: "single",
2075
+ })
2076
+ box.add(
2077
+ new TextRenderable(renderer, {
2078
+ id: "code-text",
2079
+ content: `CODE: ${(node as any).text}`,
2080
+ }),
2081
+ )
2082
+ return box
2083
+ }
2084
+ return ctx.defaultRender()
2085
+ },
2086
+ })
2087
+
2088
+ renderer.root.add(md)
2089
+ await renderMarkdownRenderable(md)
2090
+
2091
+ const lines = captureFrame()
2092
+ .split("\n")
2093
+ .map((line) => line.trimEnd())
2094
+ expect("\n" + lines.join("\n").trimEnd()).toMatchInlineSnapshot(`
2095
+ "
2096
+ ┌──────────────────────────────────────────────────────────┐
2097
+ │CODE: const x = 1; │
2098
+ └──────────────────────────────────────────────────────────┘"
2099
+ `)
2100
+ })
2101
+
2102
+ test("custom code block renderable updates when fenced content changes", async () => {
2103
+ const md = createMarkdownRenderable({
2104
+ id: "custom-code-update",
2105
+ content: "```widget\nfirst\n```",
2106
+ syntaxStyle,
2107
+ renderNode: (node, ctx) => {
2108
+ if (node.type !== "code" || node.lang !== "widget") return ctx.defaultRender()
2109
+
2110
+ return new TextRenderable(renderer, {
2111
+ id: "custom-widget",
2112
+ content: `WIDGET: ${node.text}`,
2113
+ width: "100%",
2114
+ })
2115
+ },
2116
+ })
2117
+
2118
+ renderer.root.add(md)
2119
+ await renderMarkdownRenderable(md)
2120
+ expect(captureFrame()).toContain("WIDGET: first")
2121
+
2122
+ md.content = "```widget\nsecond\n```"
2123
+ await renderMarkdownRenderable(md)
2124
+
2125
+ const frame = captureFrame()
2126
+ expect(frame).toContain("WIDGET: second")
2127
+ expect(frame).not.toContain("WIDGET: first")
2128
+ })
2129
+
2130
+ test("createMarkdownCodeBlockRenderer dispatches fenced code by language", async () => {
2131
+ const md = createMarkdownRenderable({
2132
+ id: "language-code-renderer",
2133
+ content: `Before
2134
+
2135
+
2136
+ \`\`\`taskflow title=Deploy
2137
+ step test done
2138
+ step preview active
2139
+ \`\`\`
2140
+
2141
+ \`\`\`tsx
2142
+ <Button />
2143
+ \`\`\`
2144
+
2145
+ After`,
2146
+ syntaxStyle,
2147
+ renderNode: createMarkdownCodeBlockRenderer({
2148
+ taskflow: (node) =>
2149
+ new TextRenderable(renderer, {
2150
+ id: "taskflow-renderer",
2151
+ content: `TASKFLOW:\n${node.text.replaceAll("step ", "- ")}`,
2152
+ width: "100%",
2153
+ }),
2154
+ typescriptreact: (node) =>
2155
+ new TextRenderable(renderer, {
2156
+ id: "tsx-renderer",
2157
+ content: `TSX: ${node.text}`,
2158
+ width: "100%",
2159
+ }),
2160
+ }),
2161
+ })
2162
+
2163
+ renderer.root.add(md)
2164
+ await renderMarkdownRenderable(md)
2165
+
2166
+ const frame = captureFrame()
2167
+ expect(frame).toContain("Before")
2168
+ expect(frame).toContain("TASKFLOW:")
2169
+ expect(frame).toContain("- test done")
2170
+ expect(frame).toContain("- preview active")
2171
+ expect(frame).toContain("TSX: <Button />")
2172
+ expect(frame).toContain("After")
2173
+ })
2174
+
2175
+ test("createMarkdownCodeBlockRenderer accepts renderer maps", async () => {
2176
+ const md = createMarkdownRenderable({
2177
+ id: "language-code-renderer-map",
2178
+ content: "```widget\nfrom map\n```",
2179
+ syntaxStyle,
2180
+ renderNode: createMarkdownCodeBlockRenderer(
2181
+ new Map([
2182
+ [
2183
+ "widget",
2184
+ (node) =>
2185
+ new TextRenderable(renderer, {
2186
+ id: "widget-map-renderer",
2187
+ content: `MAP: ${node.text}`,
2188
+ width: "100%",
2189
+ }),
2190
+ ],
2191
+ ]),
2192
+ ),
2193
+ })
2194
+
2195
+ renderer.root.add(md)
2196
+ await renderMarkdownRenderable(md)
2197
+
2198
+ expect(captureFrame()).toContain("MAP: from map")
2199
+ })
2200
+
2201
+ test("code block renderer preserves coalesced prose spacing", async () => {
2202
+ const md = createMarkdownRenderable({
2203
+ id: "language-code-renderer-coalesced-prose",
2204
+ content: `First paragraph.
2205
+
2206
+ Second paragraph.
2207
+
2208
+ \`\`\`widget
2209
+ custom
2210
+ \`\`\`
2211
+
2212
+ Third paragraph.`,
2213
+ syntaxStyle,
2214
+ renderNode: createMarkdownCodeBlockRenderer({
2215
+ widget: (node) => new TextRenderable(renderer, { content: `WIDGET: ${node.text}`, width: "100%" }),
2216
+ }),
2217
+ })
2218
+
2219
+ renderer.root.add(md)
2220
+ await renderMarkdownRenderable(md)
2221
+
2222
+ expect(md._blockStates.map((state) => state.token.type)).toEqual(["paragraph", "code", "paragraph"])
2223
+
2224
+ const lines = captureFrame()
2225
+ .split("\n")
2226
+ .map((line) => line.trimEnd())
2227
+ expect("\n" + lines.join("\n").trimEnd()).toMatchInlineSnapshot(`
2228
+ "
2229
+ First paragraph.
2230
+
2231
+ Second paragraph.
2232
+
2233
+ WIDGET: custom
2234
+ Third paragraph."
2235
+ `)
2236
+ })
2237
+
2238
+ test("default code blocks reuse their renderable when a custom renderer ignores them", async () => {
2239
+ const md = createMarkdownRenderable({
2240
+ id: "default-code-update-with-custom-renderer",
2241
+ content: "```ts\nconst first = true\n```",
2242
+ syntaxStyle,
2243
+ renderNode: (node) => {
2244
+ if (node.type !== "code" || node.lang !== "widget") return null
2245
+ return new TextRenderable(renderer, { content: `WIDGET: ${node.text}` })
2246
+ },
2247
+ })
2248
+
2249
+ renderer.root.add(md)
2250
+ await renderMarkdownRenderable(md)
2251
+ const initial = md._blockStates[0]?.renderable
2252
+
2253
+ md.content = "```ts\nconst second = true\n```"
2254
+ await renderMarkdownRenderable(md)
2255
+
2256
+ expect(md._blockStates[0]?.renderable).toBe(initial)
2257
+ expect(captureFrame()).toContain("const second = true")
2258
+ })
2259
+
2260
+ test("default-render delegation reuses renderable on same-type updates", async () => {
2261
+ const md = createMarkdownRenderable({
2262
+ id: "default-render-delegation-update",
2263
+ content: "```ts\nconst first = true\n```",
2264
+ syntaxStyle,
2265
+ renderNode: (_node, ctx) => ctx.defaultRender(),
2266
+ })
2267
+
2268
+ renderer.root.add(md)
2269
+ await renderMarkdownRenderable(md)
2270
+ const initial = md._blockStates[0]?.renderable
2271
+
2272
+ md.content = "```ts\nconst second = true\n```"
2273
+ await renderMarkdownRenderable(md)
2274
+
2275
+ expect(md._blockStates[0]?.renderable).toBe(initial)
2276
+ expect(captureFrame()).toContain("const second = true")
2277
+ })
2278
+
2279
+ test("a default code block becomes custom once its updated content is handled", async () => {
2280
+ const md = createMarkdownRenderable({
2281
+ id: "default-to-custom-code-update",
2282
+ content: "```widget\nincomplete\n```",
2283
+ syntaxStyle,
2284
+ renderNode: (node) => {
2285
+ if (node.type !== "code" || node.lang !== "widget" || !node.text.includes("ready")) return null
2286
+ return new TextRenderable(renderer, { content: `WIDGET: ${node.text}` })
2287
+ },
2288
+ })
2289
+
2290
+ renderer.root.add(md)
2291
+ await renderMarkdownRenderable(md)
2292
+ expect(captureFrame()).toContain("incomplete")
2293
+
2294
+ md.content = "```widget\nready\n```"
2295
+ await renderMarkdownRenderable(md)
2296
+
2297
+ expect(captureFrame()).toContain("WIDGET: ready")
2298
+ })
2299
+
2300
+ test("a top-level default code block becomes custom once updated content is handled", async () => {
2301
+ const md = createMarkdownRenderable({
2302
+ id: "top-level-default-to-custom-code-update",
2303
+ content: "```widget\nincomplete\n```",
2304
+ syntaxStyle,
2305
+ internalBlockMode: "top-level",
2306
+ renderNode: (node) => {
2307
+ if (node.type !== "code" || node.lang !== "widget" || !node.text.includes("ready")) return null
2308
+ return new TextRenderable(renderer, { content: `WIDGET: ${node.text}` })
2309
+ },
2310
+ })
2311
+
2312
+ renderer.root.add(md)
2313
+ await renderMarkdownRenderable(md)
2314
+ expect(captureFrame()).toContain("incomplete")
2315
+
2316
+ md.content = "```widget\nready\n```"
2317
+ await renderMarkdownRenderable(md)
2318
+
2319
+ expect(captureFrame()).toContain("WIDGET: ready")
2320
+ })
2321
+
2322
+ test("custom renderNode output survives top-level spacing updates", async () => {
2323
+ const md = createMarkdownRenderable({
2324
+ id: "custom-spacing-update",
2325
+ content: "Paragraph\n# Heading",
2326
+ syntaxStyle,
2327
+ internalBlockMode: "top-level",
2328
+ renderNode: (node, ctx) => {
2329
+ if (node.type === "heading") {
2330
+ return new TextRenderable(renderer, {
2331
+ id: "custom-text-spacing",
2332
+ content: "CUSTOM",
2333
+ width: "100%",
2334
+ })
2335
+ }
2336
+
2337
+ return ctx.defaultRender()
2338
+ },
2339
+ })
2340
+
2341
+ renderer.root.add(md)
2342
+ await renderMarkdownRenderable(md)
2343
+
2344
+ md.content = "Paragraph\n\n# Heading"
2345
+ await renderMarkdownRenderable(md)
2346
+
2347
+ expect(md._blockStates.map((state) => state.marginTop ?? 0)).toEqual([0, 1])
2348
+
2349
+ const lines = captureFrame()
2350
+ .split("\n")
2351
+ .map((line) => line.trimEnd())
2352
+
2353
+ expect("\n" + lines.join("\n").trimEnd()).toMatchInlineSnapshot(`
2354
+ "
2355
+ Paragraph
2356
+ CUSTOM"
2357
+ `)
2358
+ })
2359
+
2360
+ test("renderNode setter updates existing markdown renderable", async () => {
2361
+ const md = createMarkdownRenderable({
2362
+ id: "render-node-setter",
2363
+ content: "# Heading",
2364
+ syntaxStyle,
2365
+ })
2366
+
2367
+ renderer.root.add(md)
2368
+ await renderMarkdownRenderable(md)
2369
+
2370
+ md.renderNode = (node, ctx) => {
2371
+ if (node.type !== "heading") return ctx.defaultRender()
2372
+ return new TextRenderable(renderer, {
2373
+ content: "CUSTOM",
2374
+ width: "100%",
2375
+ })
2376
+ }
2377
+ await renderMarkdownRenderable(md)
2378
+
2379
+ const lines = captureFrame()
2380
+ .split("\n")
2381
+ .map((line) => line.trimEnd())
2382
+ expect("\n" + lines.join("\n").trimEnd()).toMatchInlineSnapshot(`
2383
+ "
2384
+ CUSTOM"
2385
+ `)
2386
+ })
2387
+
2388
+ test("renderNode setter rerenders same-type top-level blocks", async () => {
2389
+ const md = createMarkdownRenderable({
2390
+ id: "render-node-setter-top-level",
2391
+ content: "# Heading",
2392
+ syntaxStyle,
2393
+ internalBlockMode: "top-level",
2394
+ })
2395
+
2396
+ renderer.root.add(md)
2397
+ await renderMarkdownRenderable(md)
2398
+
2399
+ md.renderNode = (node, ctx) => {
2400
+ if (node.type !== "heading") return ctx.defaultRender()
2401
+ return new TextRenderable(renderer, {
2402
+ content: "CUSTOM",
2403
+ width: "100%",
2404
+ })
2405
+ }
2406
+ await renderMarkdownRenderable(md)
2407
+
2408
+ const lines = captureFrame()
2409
+ .split("\n")
2410
+ .map((line) => line.trimEnd())
2411
+ expect("\n" + lines.join("\n").trimEnd()).toMatchInlineSnapshot(`
2412
+ "
2413
+ CUSTOM"
2414
+ `)
2415
+ })
2416
+
2417
+ test("internalBlockMode setter updates existing markdown renderable", async () => {
2418
+ const md = createMarkdownRenderable({
2419
+ id: "internal-block-mode-setter",
2420
+ content: "Paragraph\n\n```ts\nconst x = 1\n```",
2421
+ syntaxStyle,
2422
+ })
2423
+
2424
+ renderer.root.add(md)
2425
+ await renderMarkdownRenderable(md)
2426
+
2427
+ md.internalBlockMode = "top-level"
2428
+ await renderMarkdownRenderable(md)
2429
+
2430
+ expect(md._blockStates.map((state) => state.token.type)).toEqual(["paragraph", "code"])
2431
+ expect(md._blockStates.map((state) => state.marginTop ?? 0)).toEqual([0, 1])
2432
+ })
2433
+
2434
+ test("custom top-level renderNode can increase default block margins", async () => {
2435
+ const md = createMarkdownRenderable({
2436
+ id: "custom-top-level-margin",
2437
+ content: "Paragraph\n\n# Heading",
2438
+ syntaxStyle,
2439
+ internalBlockMode: "top-level",
2440
+ renderNode: (node, ctx) => {
2441
+ const renderable = ctx.defaultRender()
2442
+ if (node.type === "heading" && renderable) renderable.marginTop = 2
2443
+ return renderable
2444
+ },
2445
+ })
2446
+
2447
+ renderer.root.add(md)
2448
+ await renderMarkdownRenderable(md)
2449
+
2450
+ expect(md._blockStates.map((state) => state.renderable.marginTop ?? 0)).toEqual([0, 2])
2451
+ })
2452
+
2453
+ test("custom renderNode returning null uses default", async () => {
2454
+ const md = createMarkdownRenderable({
2455
+ id: "custom-null",
2456
+ content: `# Heading
2457
+
2458
+ Paragraph text.`,
2459
+ syntaxStyle,
2460
+ renderNode: () => null,
2461
+ })
2462
+
2463
+ renderer.root.add(md)
2464
+ await renderMarkdownRenderable(md)
2465
+
2466
+ const lines = captureFrame()
2467
+ .split("\n")
2468
+ .map((line) => line.trimEnd())
2469
+ expect("\n" + lines.join("\n").trimEnd()).toMatchInlineSnapshot(`
2470
+ "
2471
+ Heading
2472
+
2473
+
2474
+ Paragraph text."
2475
+ `)
2476
+ })
2477
+
2478
+ // Incomplete/invalid markdown tests
2479
+
2480
+ test("incomplete code block (no closing fence)", async () => {
2481
+ const markdown = `Here is some code:
2482
+
2483
+ \`\`\`javascript
2484
+ const x = 1;
2485
+ console.log(x);`
2486
+
2487
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
2488
+ "
2489
+ Here is some code:
2490
+
2491
+ const x = 1;
2492
+ console.log(x);"
2493
+ `)
2494
+ })
2495
+
2496
+ test("incomplete bold (no closing **)", async () => {
2497
+ const markdown = `This has **unclosed bold text`
2498
+
2499
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
2500
+ "
2501
+ This has **unclosed bold text"
2502
+ `)
2503
+ })
2504
+
2505
+ test("incomplete italic (no closing *)", async () => {
2506
+ const markdown = `This has *unclosed italic text`
2507
+
2508
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
2509
+ "
2510
+ This has *unclosed italic text"
2511
+ `)
2512
+ })
2513
+
2514
+ test("incomplete link (no closing paren)", async () => {
2515
+ const markdown = `Check out [this link](https://example.com`
2516
+
2517
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
2518
+ "
2519
+ Check out this link(https://example.com"
2520
+ `)
2521
+ })
2522
+
2523
+ test("incomplete table (only header)", async () => {
2524
+ const markdown = `| Header1 | Header2 |`
2525
+
2526
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
2527
+ "
2528
+ | Header1 | Header2 |"
2529
+ `)
2530
+ })
2531
+
2532
+ test("incomplete table (header + delimiter, no rows)", async () => {
2533
+ const markdown = `| Header1 | Header2 |
2534
+ |---|---|`
2535
+
2536
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
2537
+ "
2538
+ | Header1 | Header2 |
2539
+ |---|---|"
2540
+ `)
2541
+ })
2542
+
2543
+ test("streaming-like content with partial code block", async () => {
2544
+ const markdown = `# Title
2545
+
2546
+ Some text before code.
2547
+
2548
+ \`\`\`py`
2549
+
2550
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
2551
+ "
2552
+ Title
2553
+
2554
+ Some text before code."
2555
+ `)
2556
+ })
2557
+
2558
+ test("malformed table with missing pipes", async () => {
2559
+ const markdown = `| A | B
2560
+ |---|---
2561
+ | 1 | 2`
2562
+
2563
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
2564
+ "
2565
+ ┌─┬─┐
2566
+ │A│B│
2567
+ ├─┼─┤
2568
+ │1│2│
2569
+ └─┴─┘"
2570
+ `)
2571
+ })
2572
+
2573
+ test("trailing blank lines do not add spacing", async () => {
2574
+ const markdown = `# Heading
2575
+
2576
+ Paragraph text.
2577
+
2578
+
2579
+ `
2580
+
2581
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
2582
+ "
2583
+ Heading
2584
+
2585
+ Paragraph text."
2586
+ `)
2587
+ })
2588
+
2589
+ test("multiple trailing blank lines do not add spacing", async () => {
2590
+ const markdown = `First paragraph.
2591
+
2592
+ Second paragraph.
2593
+
2594
+
2595
+
2596
+ `
2597
+
2598
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
2599
+ "
2600
+ First paragraph.
2601
+
2602
+ Second paragraph."
2603
+ `)
2604
+ })
2605
+
2606
+ test("blank lines between blocks add spacing", async () => {
2607
+ const markdown = `First
2608
+
2609
+ Second
2610
+
2611
+ Third`
2612
+
2613
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
2614
+ "
2615
+ First
2616
+
2617
+ Second
2618
+
2619
+ Third"
2620
+ `)
2621
+ })
2622
+
2623
+ test("code block at end with trailing blank lines", async () => {
2624
+ const markdown = `Text before
2625
+
2626
+ \`\`\`js
2627
+ const x = 1;
2628
+ \`\`\`
2629
+
2630
+ `
2631
+
2632
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
2633
+ "
2634
+ Text before
2635
+
2636
+ const x = 1;"
2637
+ `)
2638
+ })
2639
+
2640
+ test("table at end with trailing blank lines", async () => {
2641
+ const markdown = `| A | B |
2642
+ |---|---|
2643
+ | 1 | 2 |
2644
+
2645
+
2646
+ `
2647
+
2648
+ expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(`
2649
+ "
2650
+ ┌─┬─┐
2651
+ │A│B│
2652
+ ├─┼─┤
2653
+ │1│2│
2654
+ └─┴─┘"
2655
+ `)
2656
+ })
2657
+
2658
+ // Incremental parsing tests
2659
+
2660
+ test("internalBlockMode=top-level preserves top-level block boundaries", async () => {
2661
+ const md = createMarkdownRenderable({
2662
+ id: "markdown-top-level-blocks",
2663
+ content: "# Title\n\n```ts\nconst x = 1\n```\n\n| A |\n|---|\n| 1 |",
2664
+ syntaxStyle,
2665
+ streaming: false,
2666
+ internalBlockMode: "top-level",
2667
+ tableOptions: { widthMode: "content" },
2668
+ })
2669
+
2670
+ renderer.root.add(md)
2671
+ await renderMarkdownRenderable(md)
2672
+
2673
+ expect(md._blockStates.map((state) => state.token.type)).toEqual(["heading", "code", "table"])
2674
+ expect(md._blockStates[1]?.renderable).toBeInstanceOf(CodeRenderable)
2675
+ expect(md._blockStates[2]?.renderable).toBeInstanceOf(TextTableRenderable)
2676
+ expect(md._stableBlockCount).toBe(3)
2677
+ })
2678
+
2679
+ test("internalBlockMode=top-level reuses table renderable when rows stream in", async () => {
2680
+ const md = createMarkdownRenderable({
2681
+ id: "markdown-top-level-table-reuse",
2682
+ content: "| A | B |\n|---|---|\n| 1 | 2 |",
2683
+ syntaxStyle,
2684
+ streaming: true,
2685
+ internalBlockMode: "top-level",
2686
+ tableOptions: { widthMode: "content" },
2687
+ })
2688
+
2689
+ renderer.root.add(md)
2690
+ await renderMarkdownRenderable(md)
2691
+
2692
+ const table = md._blockStates[0]?.renderable
2693
+ expect(table).toBeInstanceOf(TextTableRenderable)
2694
+
2695
+ md.content += "\n| 3 | 4 |"
2696
+ await renderMarkdownRenderable(md)
2697
+
2698
+ expect(md._blockStates[0]?.renderable).toBe(table)
2699
+ })
2700
+
2701
+ test("internalBlockMode=top-level updates code renderable filetype when fence changes to diff", async () => {
2702
+ const md = createMarkdownRenderable({
2703
+ id: "markdown-top-level-code-kind-change",
2704
+ content: "```ts\nconst x = 1\n```",
2705
+ syntaxStyle,
2706
+ streaming: true,
2707
+ internalBlockMode: "top-level",
2708
+ })
2709
+
2710
+ renderer.root.add(md)
2711
+ await renderMarkdownRenderable(md)
2712
+
2713
+ const first = md._blockStates[0]?.renderable
2714
+ expect(first).toBeInstanceOf(CodeRenderable)
2715
+
2716
+ md.content = "```diff\n- const x = 1\n+ const y = 2\n```"
2717
+ await renderMarkdownRenderable(md)
2718
+
2719
+ expect(md._blockStates[0]?.renderable).toBe(first)
2720
+ expect((md._blockStates[0]?.renderable as CodeRenderable).filetype).toBe("diff")
2721
+ })
2722
+
2723
+ test("internalBlockMode=top-level preserves child order when replacing an earlier block", () => {
2724
+ const md = createMarkdownRenderable({
2725
+ id: "markdown-top-level-replacement-order",
2726
+ content: "# A\n\n```ts\nconst a = 1\n```\n\nTail A",
2727
+ syntaxStyle,
2728
+ internalBlockMode: "top-level",
2729
+ })
2730
+
2731
+ renderer.root.add(md)
2732
+ const codeBlock = md._blockStates[1]?.renderable
2733
+
2734
+ md.content = "Intro B\n\n```ts\nconst b = 2\n```\n\nTail B"
2735
+
2736
+ expect(md._blockStates.map((state) => state.token.type)).toEqual(["paragraph", "code", "paragraph"])
2737
+ expect(md._blockStates[1]?.renderable).toBe(codeBlock)
2738
+ expect(md.getChildren().map((child) => child.id)).toEqual(md._blockStates.map((state) => state.renderable.id))
2739
+ })
2740
+
2741
+ test("incremental update preserves child order when replacing an earlier coalesced block", () => {
2742
+ const md = createMarkdownRenderable({
2743
+ id: "markdown-coalesced-replacement-order",
2744
+ content: "- one\n\n```ts\nconst a = 1\n```\n\nTail A",
2745
+ syntaxStyle,
2746
+ })
2747
+
2748
+ renderer.root.add(md)
2749
+ const codeBlock = md._blockStates[1]?.renderable
2750
+
2751
+ md.content = "Intro B\n\n```ts\nconst b = 2\n```\n\nTail B"
2752
+
2753
+ expect(md._blockStates.map((state) => state.token.type)).toEqual(["paragraph", "code", "paragraph"])
2754
+ expect(md._blockStates[1]?.renderable).toBe(codeBlock)
2755
+ expect(md.getChildren().map((child) => child.id)).toEqual(md._blockStates.map((state) => state.renderable.id))
2756
+ })
2757
+
2758
+ test("refreshStyles preserves child order when replacing an earlier table renderable", async () => {
2759
+ const md = createMarkdownRenderable({
2760
+ id: "markdown-table-refresh-order",
2761
+ content: "| A | B |\n|---|---|\n| 1 | 2 |\n\n```ts\nconst x = 1\n```\n\nTail",
2762
+ syntaxStyle,
2763
+ })
2764
+
2765
+ renderer.root.add(md)
2766
+ await renderMarkdownRenderable(md)
2767
+
2768
+ const codeBlock = md._blockStates[1]?.renderable
2769
+ const tailBlock = md._blockStates[2]?.renderable
2770
+ const staleTable = md._blockStates[0]?.renderable
2771
+
2772
+ staleTable?.destroyRecursively()
2773
+ const wrongRenderable = new BoxRenderable(renderer, { id: "markdown-table-refresh-order-wrong", width: "100%" })
2774
+ md.add(wrongRenderable, 0)
2775
+ md._blockStates[0]!.renderable = wrongRenderable
2776
+
2777
+ md.refreshStyles()
2778
+ await renderMarkdownRenderable(md)
2779
+
2780
+ expect(md._blockStates[0]?.renderable).toBeInstanceOf(TextTableRenderable)
2781
+ expect(md._blockStates[1]?.renderable).toBe(codeBlock)
2782
+ expect(md._blockStates[2]?.renderable).toBe(tailBlock)
2783
+ expect(md.getChildren().map((child) => child.id)).toEqual(md._blockStates.map((state) => state.renderable.id))
2784
+ })
2785
+
2786
+ test("refreshStyles preserves child order when replacing an earlier header-only table fallback", async () => {
2787
+ const md = createMarkdownRenderable({
2788
+ id: "markdown-table-fallback-refresh-order",
2789
+ content: "| A | B |\n|---|---|\n\n```ts\nconst x = 1\n```\n\nTail",
2790
+ syntaxStyle,
2791
+ })
2792
+
2793
+ renderer.root.add(md)
2794
+ await renderMarkdownRenderable(md)
2795
+
2796
+ const codeBlock = md._blockStates[1]?.renderable
2797
+ const tailBlock = md._blockStates[2]?.renderable
2798
+ const staleFallback = md._blockStates[0]?.renderable
2799
+
2800
+ staleFallback?.destroyRecursively()
2801
+ const wrongRenderable = new BoxRenderable(renderer, {
2802
+ id: "markdown-table-fallback-refresh-order-wrong",
2803
+ width: "100%",
2804
+ })
2805
+ md.add(wrongRenderable, 0)
2806
+ md._blockStates[0]!.renderable = wrongRenderable
2807
+
2808
+ md.refreshStyles()
2809
+ await renderMarkdownRenderable(md)
2810
+
2811
+ expect(md._blockStates[0]?.renderable).toBeInstanceOf(CodeRenderable)
2812
+ expect(md._blockStates[1]?.renderable).toBe(codeBlock)
2813
+ expect(md._blockStates[2]?.renderable).toBe(tailBlock)
2814
+ expect(md.getChildren().map((child) => child.id)).toEqual(md._blockStates.map((state) => state.renderable.id))
2815
+ })
2816
+
2817
+ test("internalBlockMode=top-level normalizes one blank row between top-level blocks", async () => {
2818
+ const md = createMarkdownRenderable({
2819
+ id: "markdown-top-level-spacing",
2820
+ content: "# Title\n\nParagraph\n\n```ts\nconst x = 1\n```",
2821
+ syntaxStyle,
2822
+ internalBlockMode: "top-level",
2823
+ })
2824
+
2825
+ renderer.root.add(md)
2826
+ await renderMarkdownRenderable(md)
2827
+
2828
+ const lines = captureFrame()
2829
+ .split("\n")
2830
+ .map((line) => line.trimEnd())
2831
+
2832
+ expect("\n" + lines.join("\n").trimEnd()).toMatchInlineSnapshot(`
2833
+ "
2834
+ Title
2835
+
2836
+ Paragraph
2837
+
2838
+ const x = 1"
2839
+ `)
2840
+ })
2841
+
2842
+ test("internalBlockMode=top-level adds spacing before lists", async () => {
2843
+ const md = createMarkdownRenderable({
2844
+ id: "markdown-top-level-tight-list-spacing",
2845
+ content: "Paragraph:\n- one\n- two",
2846
+ syntaxStyle,
2847
+ internalBlockMode: "top-level",
2848
+ })
2849
+
2850
+ renderer.root.add(md)
2851
+ await renderMarkdownRenderable(md)
2852
+
2853
+ expect(md._blockStates.map((state) => state.marginTop ?? 0)).toEqual([0, 1])
2854
+
2855
+ const lines = captureFrame()
2856
+ .split("\n")
2857
+ .map((line) => line.trimEnd())
2858
+
2859
+ expect("\n" + lines.join("\n").trimEnd()).toMatchInlineSnapshot(`
2860
+ "
2861
+ Paragraph:
2862
+
2863
+ - one
2864
+ - two"
2865
+ `)
2866
+ })
2867
+
2868
+ test("internalBlockMode=top-level preserves source blank line before lists", async () => {
2869
+ const md = createMarkdownRenderable({
2870
+ id: "markdown-top-level-list-spacing",
2871
+ content: "The table alignment uses:\n\n1. AST-based parsing\n2. Caching",
2872
+ syntaxStyle,
2873
+ internalBlockMode: "top-level",
2874
+ })
2875
+
2876
+ renderer.root.add(md)
2877
+ await renderMarkdownRenderable(md)
2878
+
2879
+ const lines = captureFrame()
2880
+ .split("\n")
2881
+ .map((line) => line.trimEnd())
2882
+ expect("\n" + lines.join("\n").trimEnd()).toMatchInlineSnapshot(`
2883
+ "
2884
+ The table alignment uses:
2885
+
2886
+ 1. AST-based parsing
2887
+ 2. Caching"
2888
+ `)
2889
+ })
2890
+
2891
+ test("internalBlockMode=top-level adds spacing after unordered lists", async () => {
2892
+ const md = createMarkdownRenderable({
2893
+ id: "markdown-top-level-unordered-list-after-spacing",
2894
+ content: "- one\n- two\n\nParagraph after list",
2895
+ syntaxStyle,
2896
+ internalBlockMode: "top-level",
2897
+ })
2898
+
2899
+ renderer.root.add(md)
2900
+ await renderMarkdownRenderable(md)
2901
+
2902
+ expect(md._blockStates.map((state) => state.marginTop ?? 0)).toEqual([0, 1])
2903
+
2904
+ const lines = captureFrame()
2905
+ .split("\n")
2906
+ .map((line) => line.trimEnd())
2907
+
2908
+ expect("\n" + lines.join("\n").trimEnd()).toMatchInlineSnapshot(`
2909
+ "
2910
+ - one
2911
+ - two
2912
+
2913
+ Paragraph after list"
2914
+ `)
2915
+ })
2916
+
2917
+ test("internalBlockMode=top-level adds spacing after ordered lists", async () => {
2918
+ const md = createMarkdownRenderable({
2919
+ id: "markdown-top-level-ordered-list-after-spacing",
2920
+ content: "1. one\n2. two\n\nParagraph after list",
2921
+ syntaxStyle,
2922
+ internalBlockMode: "top-level",
2923
+ })
2924
+
2925
+ renderer.root.add(md)
2926
+ await renderMarkdownRenderable(md)
2927
+
2928
+ expect(md._blockStates.map((state) => state.marginTop ?? 0)).toEqual([0, 1])
2929
+
2930
+ const lines = captureFrame()
2931
+ .split("\n")
2932
+ .map((line) => line.trimEnd())
2933
+
2934
+ expect("\n" + lines.join("\n").trimEnd()).toMatchInlineSnapshot(`
2935
+ "
2936
+ 1. one
2937
+ 2. two
2938
+
2939
+ Paragraph after list"
2940
+ `)
2941
+ })
2942
+
2943
+ test("internalBlockMode=top-level treats lists as separated blocks", async () => {
2944
+ const md = createMarkdownRenderable({
2945
+ id: "markdown-top-level-list-block-spacing",
2946
+ content: `Paragraph before unordered list.
2947
+ - one
2948
+ - two
2949
+
2950
+ Paragraph after unordered list.
2951
+ 1. one
2952
+ 2. two
2953
+
2954
+ Paragraph after ordered list.`,
2955
+ syntaxStyle,
2956
+ internalBlockMode: "top-level",
2957
+ })
2958
+
2959
+ renderer.root.add(md)
2960
+ await renderMarkdownRenderable(md)
2961
+
2962
+ const lines = captureFrame()
2963
+ .split("\n")
2964
+ .map((line) => line.trimEnd())
2965
+
2966
+ expect("\n" + lines.join("\n").trimEnd()).toMatchInlineSnapshot(`
2967
+ "
2968
+ Paragraph before unordered list.
2969
+
2970
+ - one
2971
+ - two
2972
+
2973
+ Paragraph after unordered list.
2974
+
2975
+ 1. one
2976
+ 2. two
2977
+
2978
+ Paragraph after ordered list."
2979
+ `)
2980
+ })
2981
+
2982
+ test("internalBlockMode=top-level preserves list spacing when a blank line is removed", async () => {
2983
+ const md = createMarkdownRenderable({
2984
+ id: "markdown-top-level-tighten-spacing",
2985
+ content: "Paragraph\n\n- one\n- two",
2986
+ syntaxStyle,
2987
+ internalBlockMode: "top-level",
2988
+ })
2989
+
2990
+ renderer.root.add(md)
2991
+ await renderMarkdownRenderable(md)
2992
+
2993
+ md.content = "Paragraph\n- one\n- two"
2994
+ await renderMarkdownRenderable(md)
2995
+
2996
+ expect(md._blockStates.map((state) => state.marginTop ?? 0)).toEqual([0, 1])
2997
+
2998
+ const lines = captureFrame()
2999
+ .split("\n")
3000
+ .map((line) => line.trimEnd())
3001
+
3002
+ expect("\n" + lines.join("\n").trimEnd()).toMatchInlineSnapshot(`
3003
+ "
3004
+ Paragraph
3005
+
3006
+ - one
3007
+ - two"
3008
+ `)
3009
+ })
3010
+
3011
+ test("internalBlockMode=top-level preserves spacing after tight fenced code blocks", async () => {
3012
+ const md = createMarkdownRenderable({
3013
+ id: "markdown-top-level-tight-code-spacing",
3014
+ content: "```ts\nconst x = 1\n```\nParagraph",
3015
+ syntaxStyle,
3016
+ internalBlockMode: "top-level",
3017
+ })
3018
+
3019
+ renderer.root.add(md)
3020
+ await renderMarkdownRenderable(md)
3021
+
3022
+ expect(md._blockStates.map((state) => state.marginTop ?? 0)).toEqual([0, 1])
3023
+
3024
+ const lines = captureFrame()
3025
+ .split("\n")
3026
+ .map((line) => line.trimEnd())
3027
+
3028
+ expect("\n" + lines.join("\n").trimEnd()).toMatchInlineSnapshot(`
3029
+ "
3030
+ const x = 1
3031
+
3032
+ Paragraph"
3033
+ `)
3034
+ })
3035
+
3036
+ test("incremental update reuses unchanged blocks when appending", async () => {
3037
+ const md = createMarkdownRenderable({
3038
+ id: "markdown",
3039
+ content: "# Hello\n\nParagraph 1",
3040
+ syntaxStyle,
3041
+ streaming: true,
3042
+ })
3043
+
3044
+ renderer.root.add(md)
3045
+ await renderer.idle()
3046
+
3047
+ // Get reference to first block
3048
+ const firstBlockBefore = md._blockStates[0]?.renderable
3049
+
3050
+ // Append content
3051
+ md.content = "# Hello\n\nParagraph 1\n\nParagraph 2"
3052
+ await renderer.idle()
3053
+
3054
+ // First block should be reused (same object reference)
3055
+ const firstBlockAfter = md._blockStates[0]?.renderable
3056
+ expect(firstBlockAfter).toBe(firstBlockBefore)
3057
+ })
3058
+
3059
+ test("streaming mode keeps trailing tokens unstable", async () => {
3060
+ const md = createMarkdownRenderable({
3061
+ id: "markdown",
3062
+ content: "# Hello",
3063
+ syntaxStyle,
3064
+ streaming: true,
3065
+ })
3066
+
3067
+ renderer.root.add(md)
3068
+ await renderMarkdownRenderable(md)
3069
+
3070
+ const frame1 = captureFrame()
3071
+ .split("\n")
3072
+ .map((line) => line.trimEnd())
3073
+ .join("\n")
3074
+ .trimEnd()
3075
+ expect(frame1).toContain("Hello")
3076
+
3077
+ // Extend the heading
3078
+ md.content = "# Hello World"
3079
+ await renderMarkdownRenderable(md)
3080
+
3081
+ const frame2 = captureFrame()
3082
+ .split("\n")
3083
+ .map((line) => line.trimEnd())
3084
+ .join("\n")
3085
+ .trimEnd()
3086
+ expect(frame2).toContain("Hello World")
3087
+ })
3088
+
3089
+ test("streaming code blocks with concealCode=true do not flash unconcealed markdown", async () => {
3090
+ const mockTreeSitterClient = createMockTreeSitterClient()
3091
+ mockTreeSitterClient.setMockResult({
3092
+ highlights: [[0, 1, "conceal", { conceal: "" }]],
3093
+ })
3094
+
3095
+ const recorder = new TestRecorder(renderer)
3096
+ recorder.rec()
3097
+
3098
+ const md = createMarkdownRenderable({
3099
+ id: "markdown-streaming-conceal-flicker",
3100
+ content: "# Stream\n\n```markdown\n# Hidden heading\n```",
3101
+ syntaxStyle,
3102
+ conceal: true,
3103
+ concealCode: true,
3104
+ streaming: true,
3105
+ treeSitterClient: mockTreeSitterClient,
3106
+ })
3107
+
3108
+ renderer.root.add(md)
3109
+ await renderer.idle()
3110
+
3111
+ expect(mockTreeSitterClient.isHighlighting()).toBe(true)
3112
+
3113
+ const codeBlock = md._blockStates[1]?.renderable as CodeRenderable
3114
+ mockTreeSitterClient.resolveAllHighlightOnce()
3115
+ await waitForHighlight(codeBlock)
3116
+ await renderer.idle()
3117
+
3118
+ recorder.stop()
3119
+
3120
+ const frames = recorder.recordedFrames.map((frame) => frame.frame)
3121
+ const unconcealedFrames = frames.filter((frame) => frame.includes("# Hidden heading"))
3122
+ expect(unconcealedFrames.length).toBe(0)
3123
+ })
3124
+
3125
+ test("streaming demo-style fenced code block does not flicker unhighlighted", async () => {
3126
+ const keywordFg = RGBA.fromValues(1, 0, 0, 1)
3127
+ const defaultFg = RGBA.fromValues(1, 1, 1, 1)
3128
+ const mockTreeSitterClient = new MockTreeSitterClient()
3129
+ mockTreeSitterClient.setMockResult({
3130
+ highlights: [[0, 6, "keyword"]],
3131
+ })
3132
+
3133
+ const contentBeforeFence = `# OpenTUI Markdown Demo
3134
+
3135
+ Welcome to the **MarkdownRenderable** showcase.
3136
+
3137
+ `
3138
+ const fencedCodeBlock = `\`\`\`ts
3139
+ export function appendMarkdownChunk(buffer: string): string {
3140
+ return buffer
3141
+ }
3142
+ \`\`\``
3143
+ const contentAfterFence = `
3144
+
3145
+ The fenced block above appears near the top so streaming mode exercises a larger CodeRenderable before the rest of the document arrives.`
3146
+ const fullContent = contentBeforeFence + fencedCodeBlock + contentAfterFence
3147
+
3148
+ const md = createMarkdownRenderable({
3149
+ id: "markdown-streaming-demo-fence-no-flicker",
3150
+ content: "",
3151
+ syntaxStyle: SyntaxStyle.fromStyles({
3152
+ default: { fg: defaultFg },
3153
+ keyword: { fg: keywordFg },
3154
+ "markup.heading.1": { fg: RGBA.fromValues(0, 1, 0, 1) },
3155
+ "markup.strong": { fg: RGBA.fromValues(0, 1, 1, 1), bold: true },
3156
+ }),
3157
+ fg: defaultFg,
3158
+ bg: RGBA.fromValues(0, 0, 0, 1),
3159
+ conceal: true,
3160
+ streaming: true,
3161
+ internalBlockMode: "top-level",
3162
+ tableOptions: { style: "grid", widthMode: "content", cellPaddingX: 1 },
3163
+ treeSitterClient: mockTreeSitterClient,
3164
+ width: "100%",
3165
+ })
3166
+
3167
+ renderer.root.add(md)
3168
+
3169
+ const recorder = new TestRecorder(renderer, { recordBuffers: { fg: true } })
3170
+ recorder.rec()
3171
+
3172
+ for (const streamedContent of [
3173
+ contentBeforeFence,
3174
+ contentBeforeFence + fencedCodeBlock.slice(0, fencedCodeBlock.indexOf("\n```")),
3175
+ contentBeforeFence + fencedCodeBlock,
3176
+ fullContent,
3177
+ ]) {
3178
+ md.content = streamedContent
3179
+ await renderer.idle()
3180
+ }
3181
+
3182
+ const codeBlock = md._blockStates.find((state) => state.token.type === "code")?.renderable
3183
+ expect(codeBlock).toBeInstanceOf(CodeRenderable)
3184
+ expect(mockTreeSitterClient.isHighlighting()).toBe(true)
3185
+ mockTreeSitterClient.resolveAllHighlightOnce()
3186
+ await (codeBlock as CodeRenderable).highlightingDone
3187
+ await renderer.idle()
3188
+ recorder.stop()
3189
+
3190
+ const frameWidth = renderer.currentRenderBuffer.width
3191
+ const expectedKeywordFg = [...keywordFg.buffer]
3192
+
3193
+ const findTextFg = (recordedFrame: (typeof recorder.recordedFrames)[number], text: string): number[] | undefined => {
3194
+ const fgBuffer = recordedFrame.buffers?.fg
3195
+ if (!fgBuffer) return undefined
3196
+
3197
+ const lines = recordedFrame.frame.split("\n")
3198
+ for (let y = 0; y < lines.length; y += 1) {
3199
+ const x = lines[y].indexOf(text)
3200
+ if (x === -1) continue
3201
+
3202
+ const offset = (y * frameWidth + x) * 4
3203
+ return [...fgBuffer.slice(offset, offset + 4)]
3204
+ }
3205
+
3206
+ return undefined
3207
+ }
3208
+
3209
+ const visibleCodeFrames = recorder.recordedFrames
3210
+ .map((recordedFrame) => ({
3211
+ frameNumber: recordedFrame.frameNumber,
3212
+ exportFg: findTextFg(recordedFrame, "export function appendMarkdownChunk"),
3213
+ }))
3214
+ .filter((frame) => frame.exportFg !== undefined)
3215
+
3216
+ expect(visibleCodeFrames.length).toBeGreaterThan(0)
3217
+ expect(visibleCodeFrames.some((frame) => frame.exportFg!.join(",") === expectedKeywordFg.join(","))).toBe(true)
3218
+ expect(visibleCodeFrames.filter((frame) => frame.exportFg!.join(",") !== expectedKeywordFg.join(","))).toEqual([])
3219
+ })
3220
+
3221
+ test("non-streaming mode parses all tokens as stable", async () => {
3222
+ const md = createMarkdownRenderable({
3223
+ id: "markdown",
3224
+ content: "# Hello\n\nPara 1\n\nPara 2",
3225
+ syntaxStyle,
3226
+ streaming: false,
3227
+ })
3228
+
3229
+ renderer.root.add(md)
3230
+ await renderer.idle()
3231
+
3232
+ // Get parse state
3233
+ const parseState = md._parseState
3234
+ expect(parseState).not.toBeNull()
3235
+ expect(parseState!.tokens.length).toBeGreaterThan(0)
3236
+ })
3237
+
3238
+ test("internalBlockMode=top-level exposes a conservative stable block prefix", async () => {
3239
+ const md = createMarkdownRenderable({
3240
+ id: "markdown-top-level-stable-prefix",
3241
+ content: "# Title\n\nPara 1\n\n",
3242
+ syntaxStyle,
3243
+ streaming: true,
3244
+ internalBlockMode: "top-level",
3245
+ })
3246
+
3247
+ renderer.root.add(md)
3248
+ await renderMarkdownRenderable(md)
3249
+
3250
+ md.content = "# Title\n\nPara 1\n\nPara 2"
3251
+ await renderMarkdownRenderable(md)
3252
+
3253
+ expect(md._blockStates.map((state) => state.token.type)).toEqual(["heading", "paragraph", "paragraph"])
3254
+ expect(md._stableBlockCount).toBe(1)
3255
+ })
3256
+
3257
+ test("default block mode still coalesces ordinary markdown blocks", async () => {
3258
+ const md = createMarkdownRenderable({
3259
+ id: "markdown-default-coalesced-blocks",
3260
+ content: "# Title\n\nPara 1",
3261
+ syntaxStyle,
3262
+ })
3263
+
3264
+ renderer.root.add(md)
3265
+ await renderMarkdownRenderable(md)
3266
+
3267
+ expect(md._blockStates).toHaveLength(1)
3268
+ expect(md._blockStates[0]?.token.type).toBe("paragraph")
3269
+ expect(md._stableBlockCount).toBe(0)
3270
+ })
3271
+
3272
+ test("parse failure fallback leaves stable block count at zero", async () => {
3273
+ const lexerRef = Lexer as unknown as { lex: typeof Lexer.lex }
3274
+ const originalLex = lexerRef.lex
3275
+
3276
+ lexerRef.lex = (() => {
3277
+ throw new Error("parse failed")
3278
+ }) as typeof Lexer.lex
3279
+
3280
+ try {
3281
+ const md = createMarkdownRenderable({
3282
+ id: "markdown-parse-failure-stable-blocks",
3283
+ content: "# Broken",
3284
+ syntaxStyle,
3285
+ streaming: true,
3286
+ internalBlockMode: "top-level",
3287
+ })
3288
+
3289
+ renderer.root.add(md)
3290
+ await renderMarkdownRenderable(md)
3291
+
3292
+ expect(md._stableBlockCount).toBe(0)
3293
+ expect(md._blockStates).toHaveLength(1)
3294
+ expect(md._blockStates[0]?.renderable).toBeInstanceOf(CodeRenderable)
3295
+ } finally {
3296
+ lexerRef.lex = originalLex
3297
+ }
3298
+ })
3299
+
3300
+ test("content update with same text does not rebuild", async () => {
3301
+ const md = createMarkdownRenderable({
3302
+ id: "markdown",
3303
+ content: "# Hello",
3304
+ syntaxStyle,
3305
+ })
3306
+
3307
+ renderer.root.add(md)
3308
+ await renderer.idle()
3309
+
3310
+ const blockBefore = md._blockStates[0]?.renderable
3311
+
3312
+ // Set same content
3313
+ md.content = "# Hello"
3314
+ await renderer.idle()
3315
+
3316
+ const blockAfter = md._blockStates[0]?.renderable
3317
+ expect(blockAfter).toBe(blockBefore)
3318
+ })
3319
+
3320
+ test("block type change creates new renderable", async () => {
3321
+ const md = createMarkdownRenderable({
3322
+ id: "markdown",
3323
+ content: "# Hello",
3324
+ syntaxStyle,
3325
+ })
3326
+
3327
+ renderer.root.add(md)
3328
+ await renderer.idle()
3329
+
3330
+ const blockBefore = md._blockStates[0]?.renderable
3331
+
3332
+ // Change from heading to paragraph
3333
+ md.content = "Hello"
3334
+ await renderer.idle()
3335
+
3336
+ const blockAfter = md._blockStates[0]?.renderable
3337
+ // Non-special markdown blocks are coalesced and reused as one markdown code renderable
3338
+ expect(blockAfter).toBe(blockBefore)
3339
+ })
3340
+
3341
+ test("streaming property can be toggled", async () => {
3342
+ const md = createMarkdownRenderable({
3343
+ id: "markdown",
3344
+ content: "# Hello",
3345
+ syntaxStyle,
3346
+ streaming: false,
3347
+ })
3348
+
3349
+ renderer.root.add(md)
3350
+ await renderMarkdownRenderable(md)
3351
+
3352
+ expect(md.streaming).toBe(false)
3353
+ const blockBefore = md._blockStates[0]?.renderable
3354
+
3355
+ md.streaming = true
3356
+ expect(md.streaming).toBe(true)
3357
+
3358
+ await renderMarkdownRenderable(md)
3359
+
3360
+ const blockAfter = md._blockStates[0]?.renderable
3361
+ expect(blockAfter).toBe(blockBefore)
3362
+
3363
+ const frame = captureFrame()
3364
+ .split("\n")
3365
+ .map((line) => line.trimEnd())
3366
+ .join("\n")
3367
+ .trimEnd()
3368
+ expect(frame).toContain("Hello")
3369
+ })
3370
+
3371
+ test("clearCache forces full rebuild", async () => {
3372
+ const md = createMarkdownRenderable({
3373
+ id: "markdown",
3374
+ content: "# Hello\n\nWorld",
3375
+ syntaxStyle,
3376
+ })
3377
+
3378
+ renderer.root.add(md)
3379
+ await renderer.idle()
3380
+
3381
+ const parseStateBefore = md._parseState
3382
+
3383
+ md.clearCache()
3384
+ await renderer.idle()
3385
+
3386
+ const parseStateAfter = md._parseState
3387
+ // Parse state should be different (was cleared and rebuilt)
3388
+ expect(parseStateAfter).not.toBe(parseStateBefore)
3389
+ })
3390
+
3391
+ test("streaming->non-streaming transition keeps final table row visible", async () => {
3392
+ const md = createMarkdownRenderable({
3393
+ id: "markdown",
3394
+ content: "| Value |\n|---|\n| first |\n| second |",
3395
+ syntaxStyle,
3396
+ streaming: true,
3397
+ })
3398
+
3399
+ renderer.root.add(md)
3400
+ await renderer.idle()
3401
+
3402
+ const tableWhileStreaming = md._blockStates[0]?.renderable
3403
+
3404
+ let frame = captureFrame()
3405
+ .split("\n")
3406
+ .map((line) => line.trimEnd())
3407
+ .join("\n")
3408
+
3409
+ expect(frame).toContain("first")
3410
+ expect(frame).toContain("second")
3411
+
3412
+ md.streaming = false
3413
+ await renderer.idle()
3414
+
3415
+ frame = captureFrame()
3416
+ .split("\n")
3417
+ .map((line) => line.trimEnd())
3418
+ .join("\n")
3419
+
3420
+ expect(frame).toContain("first")
3421
+ expect(frame).toContain("second")
3422
+ expect(md._blockStates[0]?.renderable).toBe(tableWhileStreaming)
3423
+ })
3424
+
3425
+ test("streaming table remains visible when a new block starts", async () => {
3426
+ const tableMarkdown = "| Value |\n|---|\n| first |\n| second |"
3427
+ const md = createMarkdownRenderable({
3428
+ id: "markdown",
3429
+ content: tableMarkdown,
3430
+ syntaxStyle,
3431
+ streaming: true,
3432
+ })
3433
+
3434
+ renderer.root.add(md)
3435
+ await renderer.idle()
3436
+
3437
+ const tableWhileTrailing = md._blockStates[0]?.renderable
3438
+
3439
+ let frame = captureFrame()
3440
+ .split("\n")
3441
+ .map((line) => line.trimEnd())
3442
+ .join("\n")
3443
+
3444
+ expect(frame).toContain("first")
3445
+ expect(frame).toContain("second")
3446
+
3447
+ md.content = `${tableMarkdown}\n\nAfter table block.`
3448
+ await renderer.idle()
3449
+
3450
+ frame = captureFrame()
3451
+ .split("\n")
3452
+ .map((line) => line.trimEnd())
3453
+ .join("\n")
3454
+
3455
+ expect(md.streaming).toBe(true)
3456
+ expect(frame).toContain("first")
3457
+ expect(frame).toContain("second")
3458
+ expect(md._blockStates.length).toBeGreaterThan(1)
3459
+ expect(md._blockStates[0]?.renderable).toBe(tableWhileTrailing)
3460
+ })
3461
+
3462
+ test("stream end mid-table finalizes full table snapshot", async () => {
3463
+ const md = createMarkdownRenderable({
3464
+ id: "markdown",
3465
+ content: "",
3466
+ syntaxStyle,
3467
+ streaming: true,
3468
+ })
3469
+
3470
+ renderer.root.add(md)
3471
+
3472
+ md.content = "| Name | Score |\n|---|---|\n"
3473
+ await renderer.idle()
3474
+
3475
+ md.content = "| Name | Score |\n|---|---|\n| Alpha | 10 |\n"
3476
+ await renderer.idle()
3477
+
3478
+ md.content = "| Name | Score |\n|---|---|\n| Alpha | 10 |\n| Bravo | 20 |\n"
3479
+ await renderer.idle()
3480
+
3481
+ md.content = "| Name | Score |\n|---|---|\n| Alpha | 10 |\n| Bravo | 20 |\n| Charlie | 30 |"
3482
+ await renderer.idle()
3483
+
3484
+ let frame = captureFrame()
3485
+ .split("\n")
3486
+ .map((line) => line.trimEnd())
3487
+ .join("\n")
3488
+
3489
+ expect(frame).toContain("Charlie")
3490
+
3491
+ md.streaming = false
3492
+ await renderer.idle()
3493
+
3494
+ frame = captureFrame()
3495
+ .split("\n")
3496
+ .map((line) => line.trimEnd())
3497
+ .join("\n")
3498
+ .trimEnd()
3499
+
3500
+ expect(frame).toMatchInlineSnapshot(`
3501
+ "┌──────────────────────────────┬───────────────────────────┐
3502
+ │Name │Score │
3503
+ ├──────────────────────────────┼───────────────────────────┤
3504
+ │Alpha │10 │
3505
+ ├──────────────────────────────┼───────────────────────────┤
3506
+ │Bravo │20 │
3507
+ ├──────────────────────────────┼───────────────────────────┤
3508
+ │Charlie │30 │
3509
+ └──────────────────────────────┴───────────────────────────┘"
3510
+ `)
3511
+ })
3512
+
3513
+ test("ignores content updates after markdown renderable is destroyed during streaming", async () => {
3514
+ const md = createMarkdownRenderable({
3515
+ id: "markdown",
3516
+ content: "",
3517
+ syntaxStyle,
3518
+ streaming: true,
3519
+ })
3520
+
3521
+ renderer.root.add(md)
3522
+
3523
+ md.content = "| Name | Score |\n|---|---|\n| Alpha | 10 |\n"
3524
+ await renderer.idle()
3525
+
3526
+ md.destroyRecursively()
3527
+ expect(md.isDestroyed).toBe(true)
3528
+
3529
+ expect(() => {
3530
+ md.content = "| Name | Score |\n|---|---|\n| Alpha | 10 |\n| Bravo | 20 |\n"
3531
+ md.streaming = false
3532
+ }).not.toThrow()
3533
+
3534
+ await renderer.idle()
3535
+ })
3536
+
3537
+ test("non-streaming->streaming transition keeps final table row visible", async () => {
3538
+ const md = createMarkdownRenderable({
3539
+ id: "markdown",
3540
+ content: "| Value |\n|---|\n| first |\n| second |",
3541
+ syntaxStyle,
3542
+ streaming: false,
3543
+ })
3544
+
3545
+ renderer.root.add(md)
3546
+ await renderer.idle()
3547
+
3548
+ const tableWhileStable = md._blockStates[0]?.renderable
3549
+
3550
+ let frame = captureFrame()
3551
+ .split("\n")
3552
+ .map((line) => line.trimEnd())
3553
+ .join("\n")
3554
+
3555
+ expect(frame).toContain("first")
3556
+ expect(frame).toContain("second")
3557
+
3558
+ md.streaming = true
3559
+ await renderer.idle()
3560
+
3561
+ frame = captureFrame()
3562
+ .split("\n")
3563
+ .map((line) => line.trimEnd())
3564
+ .join("\n")
3565
+
3566
+ expect(frame).toContain("first")
3567
+ expect(frame).toContain("second")
3568
+ expect(md._blockStates[0]?.renderable).toBe(tableWhileStable)
3569
+ })
3570
+
3571
+ test("streaming table reuses renderable while updating row content", async () => {
3572
+ const md = createMarkdownRenderable({
3573
+ id: "markdown",
3574
+ content: "| A |\n|---|\n| 1 |",
3575
+ syntaxStyle,
3576
+ streaming: true,
3577
+ })
3578
+
3579
+ renderer.root.add(md)
3580
+ await renderer.idle()
3581
+
3582
+ const tableBefore = md._blockStates[0]?.renderable
3583
+
3584
+ md.content = "| B |\n|---|\n| 2 |"
3585
+ await renderer.idle()
3586
+
3587
+ const tableAfterSameRows = md._blockStates[0]?.renderable
3588
+ expect(tableAfterSameRows).toBe(tableBefore)
3589
+
3590
+ md.content = "| B |\n|---|\n| 2 |\n| 3 |"
3591
+ await renderer.idle()
3592
+
3593
+ const tableAfterNewRow = md._blockStates[0]?.renderable
3594
+ expect(tableAfterNewRow).toBe(tableBefore)
3595
+ })
3596
+
3597
+ test("table shows all rows when streaming is false", async () => {
3598
+ const md = createMarkdownRenderable({
3599
+ id: "markdown",
3600
+ content: "| A |\n|---|\n| 1 |",
3601
+ syntaxStyle,
3602
+ streaming: false,
3603
+ })
3604
+
3605
+ renderer.root.add(md)
3606
+ await renderer.idle()
3607
+
3608
+ // Non-streaming should show all rows including the last
3609
+ const frame = captureFrame()
3610
+ .split("\n")
3611
+ .map((line) => line.trimEnd())
3612
+ .join("\n")
3613
+ expect(frame).toContain("1")
3614
+ })
3615
+
3616
+ test("table updates content when not streaming", async () => {
3617
+ const md = createMarkdownRenderable({
3618
+ id: "markdown",
3619
+ content: "| A |\n|---|\n| 1 |",
3620
+ syntaxStyle,
3621
+ streaming: false,
3622
+ })
3623
+
3624
+ renderer.root.add(md)
3625
+ await renderer.idle()
3626
+
3627
+ const frame1 = captureFrame()
3628
+ expect(frame1).toContain("1")
3629
+
3630
+ // Change cell content - should update immediately when not streaming
3631
+ md.content = "| A |\n|---|\n| 2 |"
3632
+ await renderer.idle()
3633
+
3634
+ const frame2 = captureFrame()
3635
+ expect(frame2).toContain("2")
3636
+ expect(frame2).not.toContain("1")
3637
+ })
3638
+
3639
+ test("table keeps unchanged cell chunks stable across updates", async () => {
3640
+ const md = createMarkdownRenderable({
3641
+ id: "markdown",
3642
+ content: "| A | B |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |",
3643
+ syntaxStyle,
3644
+ streaming: false,
3645
+ })
3646
+
3647
+ renderer.root.add(md)
3648
+ await renderer.idle()
3649
+
3650
+ const table = md._blockStates[0]?.renderable as TextTableRenderable
3651
+ expect(table).toBeInstanceOf(TextTableRenderable)
3652
+
3653
+ const headerBefore = table.content[0]?.[0]
3654
+ const firstRowBefore = table.content[1]?.[0]
3655
+ const secondRowSecondCellBefore = table.content[2]?.[1]
3656
+ const changedCellBefore = table.content[2]?.[0]
3657
+
3658
+ md.content = "| A | B |\n|---|---|\n| 1 | 2 |\n| 33 | 4 |"
3659
+ await renderer.idle()
3660
+
3661
+ const tableAfter = md._blockStates[0]?.renderable as TextTableRenderable
3662
+ expect(tableAfter).toBe(table)
3663
+ expect(tableAfter.content[0]?.[0]).toBe(headerBefore)
3664
+ expect(tableAfter.content[1]?.[0]).toBe(firstRowBefore)
3665
+ expect(tableAfter.content[2]?.[1]).toBe(secondRowSecondCellBefore)
3666
+ expect(tableAfter.content[2]?.[0]).not.toBe(changedCellBefore)
3667
+ })
3668
+
3669
+ test("streaming table updates trailing row content", async () => {
3670
+ const md = createMarkdownRenderable({
3671
+ id: "markdown",
3672
+ content: "| A |\n|---|\n| 1 |\n| 2 |",
3673
+ syntaxStyle,
3674
+ streaming: true,
3675
+ })
3676
+
3677
+ renderer.root.add(md)
3678
+ await renderer.idle()
3679
+
3680
+ const table = md._blockStates[0]?.renderable as TextTableRenderable
3681
+ const contentBefore = table.content
3682
+
3683
+ md.content = "| A |\n|---|\n| 1 |\n| 200 |"
3684
+ await renderer.idle()
3685
+
3686
+ const tableAfter = md._blockStates[0]?.renderable as TextTableRenderable
3687
+ const frame = captureFrame()
3688
+ expect(tableAfter).toBe(table)
3689
+ expect(tableAfter.content).not.toBe(contentBefore)
3690
+ expect(frame).toContain("200")
3691
+ })
3692
+
3693
+ test("streaming complex tables keep final rows visible (issue #15244)", async () => {
3694
+ const vmHeader = "| VM | 状态 | Owner | Zone | CPU | Mem(GB) | Disk(GB) | Net | Uptime | Cost/月 | Notes |"
3695
+ const vmDelimiter = "|---|---|---|---|---|---|---|---|---|---|---|"
3696
+ const vmRows = [
3697
+ "| vm-api-01 | 🟢 运行中 | alice | us-east-1a | 8 | 32 | 500 | 1.2Gbps | 99.99% | 12,345 | 主节点 — steady |",
3698
+ "| vm-job-02 | 🟢 运行中 | bob | ap-south-1b | 16 | 64 | 1,024 | 950Mbps | 98.70% | 23,456 | 批处理 — spikes |",
3699
+ "| vm-batch-03 | 🟡 维护中 | carol | eu-west-1c | 32 | 128 | 2,048 | 2.4Gbps | 97.10% | 34,567 | 最后一行 — must stay |",
3700
+ ] as const
3701
+
3702
+ const storageHeader = "| 存储池 | 状态 | 使用率 | 可用(GB) | 已用(GB) | 冗余 | 备注 |"
3703
+ const storageDelimiter = "|---|---|---|---|---|---|---|"
3704
+ const storageRows = [
3705
+ "| 热池A | 🟢 正常 | 72% | 12,500 | 32,500 | 3x | 混合负载 |",
3706
+ "| 温池B | 🟢 正常 | 81% | 8,250 | 35,750 | 2x | 历史数据 |",
3707
+ "| 冷池C | 🟡 告警 | 93% | 2,100 | 27,900 | 2x | 最后一行 — must stay |",
3708
+ ] as const
3709
+
3710
+ const buildContent = (vmRowCount: number, storageRowCount: number): string =>
3711
+ `### VM details\n\n${vmHeader}\n${vmDelimiter}\n${vmRows.slice(0, vmRowCount).join("\n")}\n\n### Storage details\n\n${storageHeader}\n${storageDelimiter}\n${storageRows.slice(0, storageRowCount).join("\n")}`
3712
+
3713
+ const md = createMarkdownRenderable({
3714
+ id: "markdown",
3715
+ content: "",
3716
+ syntaxStyle,
3717
+ streaming: true,
3718
+ })
3719
+
3720
+ renderer.root.add(md)
3721
+
3722
+ for (const [vmRowCount, storageRowCount] of [
3723
+ [2, 2],
3724
+ [3, 2],
3725
+ [3, 3],
3726
+ ] as const) {
3727
+ md.content = buildContent(vmRowCount, storageRowCount)
3728
+ await renderMarkdownRenderable(md)
3729
+ }
3730
+
3731
+ const tableBlocks = md._blockStates
3732
+ .map((state) => state.renderable)
3733
+ .filter((renderable): renderable is TextTableRenderable => renderable instanceof TextTableRenderable)
3734
+
3735
+ const cellText = (cell: { text: string }[] | null | undefined): string =>
3736
+ cell?.map((chunk) => chunk.text).join("") ?? ""
3737
+
3738
+ expect(tableBlocks).toHaveLength(2)
3739
+
3740
+ const vmTable = tableBlocks[0]
3741
+ const storageTable = tableBlocks[1]
3742
+
3743
+ expect(vmTable.content.length).toBe(4)
3744
+ expect(storageTable.content.length).toBe(4)
3745
+ expect(cellText(vmTable.content[3]?.[0])).toContain("vm-batch-03")
3746
+ expect(cellText(storageTable.content[3]?.[0])).toContain("冷池C")
3747
+ })
3748
+
3749
+ test("streaming table with incomplete first row is rendered with padded cells", async () => {
3750
+ const md = createMarkdownRenderable({
3751
+ id: "markdown",
3752
+ content: "| A |\n|---|\n|",
3753
+ syntaxStyle,
3754
+ streaming: true,
3755
+ })
3756
+
3757
+ renderer.root.add(md)
3758
+ await renderMarkdownRenderable(md)
3759
+
3760
+ const frame1 = captureFrame()
3761
+ .split("\n")
3762
+ .map((line) => line.trimEnd())
3763
+ .join("\n")
3764
+
3765
+ expect(frame1).toMatch(/[┌│└]/)
3766
+ expect(frame1).toContain("A")
3767
+
3768
+ md.content = "| A |\n|---|\n| 1"
3769
+ await renderMarkdownRenderable(md)
3770
+
3771
+ const frame2 = captureFrame()
3772
+ .split("\n")
3773
+ .map((line) => line.trimEnd())
3774
+ .join("\n")
3775
+
3776
+ expect(frame2).toMatch(/[┌│└]/)
3777
+ expect(frame2).toContain("1")
3778
+
3779
+ md.content = "| A |\n|---|\n| 1 |\n| 2 |"
3780
+ await renderMarkdownRenderable(md)
3781
+
3782
+ const frame3 = captureFrame()
3783
+ .split("\n")
3784
+ .map((line) => line.trimEnd())
3785
+ .join("\n")
3786
+
3787
+ expect(frame3).toMatch(/[┌│└]/)
3788
+ expect(frame3).toContain("1")
3789
+ expect(frame3).toContain("2")
3790
+ })
3791
+
3792
+ test("streaming table transitions from raw text to table once first row appears", async () => {
3793
+ const md = createMarkdownRenderable({
3794
+ id: "markdown",
3795
+ content: "| Header |",
3796
+ syntaxStyle,
3797
+ streaming: true,
3798
+ })
3799
+
3800
+ renderer.root.add(md)
3801
+ await renderMarkdownRenderable(md)
3802
+
3803
+ let frame = captureFrame()
3804
+ .split("\n")
3805
+ .map((line) => line.trimEnd())
3806
+ .join("\n")
3807
+ expect(frame).toContain("| Header |")
3808
+ expect(frame).not.toMatch(/[┌│└]/)
3809
+
3810
+ md.content = "| Header |\n|---|"
3811
+ await renderMarkdownRenderable(md)
3812
+
3813
+ frame = captureFrame()
3814
+ .split("\n")
3815
+ .map((line) => line.trimEnd())
3816
+ .join("\n")
3817
+ expect(frame).toContain("|---|")
3818
+ expect(frame).not.toMatch(/[┌│└]/)
3819
+
3820
+ md.content = "| Header |\n|---|\n| D"
3821
+ await renderMarkdownRenderable(md)
3822
+
3823
+ frame = captureFrame()
3824
+ .split("\n")
3825
+ .map((line) => line.trimEnd())
3826
+ .join("\n")
3827
+ expect(frame).toMatch(/[┌│└]/)
3828
+ expect(frame).toContain("Header")
3829
+ expect(frame).toContain("D")
3830
+ expect(frame).not.toContain("|---|")
3831
+ })
3832
+
3833
+ test("streaming table remains rendered when row count decreases", async () => {
3834
+ const md = createMarkdownRenderable({
3835
+ id: "markdown",
3836
+ content: "| A |\n|---|\n| 1 |\n| 2 |",
3837
+ syntaxStyle,
3838
+ streaming: true,
3839
+ })
3840
+
3841
+ renderer.root.add(md)
3842
+ await renderMarkdownRenderable(md)
3843
+
3844
+ let frame = captureFrame()
3845
+ .split("\n")
3846
+ .map((line) => line.trimEnd())
3847
+ .join("\n")
3848
+ expect(frame).toMatch(/[┌│└]/)
3849
+ expect(frame).toContain("1")
3850
+ expect(frame).toContain("2")
3851
+
3852
+ md.content = "| A |\n|---|\n| 1 |"
3853
+ await renderMarkdownRenderable(md)
3854
+
3855
+ frame = captureFrame()
3856
+ .split("\n")
3857
+ .map((line) => line.trimEnd())
3858
+ .join("\n")
3859
+ expect(frame).toMatch(/[┌│└]/)
3860
+ expect(frame).toContain("1")
3861
+ expect(frame).not.toContain("|---|")
3862
+ })
3863
+
3864
+ test("conceal change updates rendered content", async () => {
3865
+ const md = createMarkdownRenderable({
3866
+ id: "markdown",
3867
+ content: "# Hello **bold**",
3868
+ syntaxStyle,
3869
+ conceal: true,
3870
+ })
3871
+
3872
+ renderer.root.add(md)
3873
+ await renderMarkdownRenderable(md)
3874
+
3875
+ const frame1 = captureFrame()
3876
+ expect(frame1).not.toContain("**")
3877
+ expect(frame1).not.toContain("#")
3878
+
3879
+ md.conceal = false
3880
+ renderer.requestRender()
3881
+ await renderMarkdownRenderable(md)
3882
+
3883
+ const frame2 = captureFrame()
3884
+ expect(frame2).toContain("**")
3885
+ expect(frame2).toContain("#")
3886
+ })
3887
+
3888
+ test("theme switching (syntaxStyle change)", async () => {
3889
+ const theme1 = SyntaxStyle.fromStyles({
3890
+ default: { fg: RGBA.fromValues(1, 0, 0, 1) }, // Red
3891
+ "markup.heading.1": { fg: RGBA.fromValues(0, 1, 0, 1), bold: true }, // Green
3892
+ })
3893
+
3894
+ const theme2 = SyntaxStyle.fromStyles({
3895
+ default: { fg: RGBA.fromValues(0, 0, 1, 1) }, // Blue
3896
+ "markup.heading.1": { fg: RGBA.fromValues(1, 1, 0, 1), bold: true }, // Yellow
3897
+ })
3898
+
3899
+ // Use the EXACT content from markdown-demo.ts to reproduce the issue
3900
+ const content = `# OpenTUI Markdown Demo
3901
+
3902
+ Welcome to the **MarkdownRenderable** showcase! This demonstrates automatic table alignment and syntax highlighting.
3903
+
3904
+ ## Features
3905
+
3906
+ - Automatic **table column alignment** based on content width
3907
+ - Proper handling of \`inline code\`, **bold**, and *italic* in tables
3908
+ - Multiple syntax themes to choose from
3909
+ - Conceal mode hides formatting markers
3910
+
3911
+ ## Comparison Table
3912
+
3913
+ | Feature | Status | Priority | Notes |
3914
+ |---|---|---|---|
3915
+ | Table alignment | **Done** | High | Uses \`marked\` parser |
3916
+ | Conceal mode | *Working* | Medium | Hides \`**\`, \`\`\`, etc. |
3917
+ | Theme switching | **Done** | Low | 3 themes available |
3918
+ | Unicode support | 日本語 | High | CJK characters |
3919
+
3920
+ ## Code Examples
3921
+
3922
+ Here's how to use it:
3923
+
3924
+ \`\`\`typescript
3925
+ import { MarkdownRenderable } from "@opentui/core"
3926
+
3927
+ const md = createMarkdownRenderable({
3928
+ content: "# Hello World",
3929
+ syntaxStyle: mySyntaxStyle,
3930
+ conceal: true, // Hide formatting markers
3931
+ })
3932
+ \`\`\`
3933
+
3934
+ ### API Reference
3935
+
3936
+ | Method | Parameters | Returns | Description |
3937
+ |---|---|---|---|
3938
+ | \`constructor\` | \`ctx, options\` | \`MarkdownRenderable\` | Create new instance |
3939
+ | \`clearCache\` | none | \`void\` | Force re-render content |
3940
+
3941
+ ## Inline Formatting Examples
3942
+
3943
+ | Style | Syntax | Rendered |
3944
+ |---|---|---|
3945
+ | Bold | \`**text**\` | **bold text** |
3946
+ | Italic | \`*text*\` | *italic text* |
3947
+ | Code | \`code\` | \`inline code\` |
3948
+ | Link | \`[text](url)\` | [OpenTUI](https://github.com) |
3949
+
3950
+ ## Mixed Content
3951
+
3952
+ > **Note**: This blockquote contains **bold** and \`code\` formatting.
3953
+ > It should render correctly with proper styling.
3954
+
3955
+ ### Emoji Support
3956
+
3957
+ | Emoji | Name | Category |
3958
+ |---|---|---|
3959
+ | 🚀 | Rocket | Transport |
3960
+ | 🎨 | Palette | Art |
3961
+ | ⚡ | Lightning | Nature |
3962
+ | 🔥 | Fire | Nature |
3963
+
3964
+ ---
3965
+
3966
+ ## Alignment Examples
3967
+
3968
+ | Left | Center | Right |
3969
+ |:---|:---:|---:|
3970
+ | L1 | C1 | R1 |
3971
+ | Left aligned | Centered text | Right aligned |
3972
+ | Short | Medium length | Longer content here |
3973
+
3974
+ ## Performance
3975
+
3976
+ The table alignment uses:
3977
+ 1. AST-based parsing with \`marked\`
3978
+ 2. Caching for repeated content
3979
+ 3. Smart width calculation accounting for concealed chars
3980
+
3981
+ ---
3982
+
3983
+ *Press \`?\` for keybindings*
3984
+ `
3985
+
3986
+ const md = createMarkdownRenderable({
3987
+ id: "markdown",
3988
+ content,
3989
+ syntaxStyle: theme1,
3990
+ conceal: true,
3991
+ })
3992
+
3993
+ renderer.root.add(md)
3994
+ await renderMarkdownRenderable(md)
3995
+
3996
+ const frame1 = captureSpans()
3997
+ const headingSpan1 = findSpanContaining(frame1, "OpenTUI Markdown Demo")
3998
+ expect(headingSpan1).toBeDefined()
3999
+ expect(headingSpan1!.fg.r).toBe(0)
4000
+ expect(headingSpan1!.fg.g).toBe(1)
4001
+ expect(headingSpan1!.fg.b).toBe(0)
4002
+ expect(headingSpan1!.attributes & TextAttributes.BOLD).toBeTruthy()
4003
+
4004
+ // Switch theme
4005
+ md.syntaxStyle = theme2
4006
+ renderer.requestRender()
4007
+ await renderMarkdownRenderable(md)
4008
+
4009
+ const frame2 = captureSpans()
4010
+ const headingSpan2 = findSpanContaining(frame2, "OpenTUI Markdown Demo")
4011
+ expect(headingSpan2).toBeDefined()
4012
+ expect(headingSpan2!.fg.r).toBe(1)
4013
+ expect(headingSpan2!.fg.g).toBe(1)
4014
+ expect(headingSpan2!.fg.b).toBe(0)
4015
+ expect(headingSpan2!.attributes & TextAttributes.BOLD).toBeTruthy()
4016
+ })
4017
+
4018
+ // Paragraph rendering tests
4019
+
4020
+ test("paragraph links are rendered with markdown conceal behavior", async () => {
4021
+ const md = createMarkdownRenderable({
4022
+ id: "markdown",
4023
+ content: "Check [Google](https://google.com) out",
4024
+ syntaxStyle,
4025
+ conceal: true,
4026
+ })
4027
+
4028
+ renderer.root.add(md)
4029
+ await renderMarkdownRenderable(md)
4030
+
4031
+ const paragraphChildren = md.getChildren()
4032
+ expect(paragraphChildren.length).toBe(1)
4033
+ expect(paragraphChildren[0]).toBeInstanceOf(CodeRenderable)
4034
+ expect(paragraphChildren[0]).not.toBeInstanceOf(TextRenderable)
4035
+
4036
+ const frame = captureFrame()
4037
+ expect(frame).toContain("Google")
4038
+ expect(frame).toContain("https://google.com")
4039
+ expect(frame).not.toContain("[Google](https://google.com)")
4040
+ })
4041
+
4042
+ test("paragraph initial render does not flash raw markdown markers", async () => {
4043
+ const recorder = new TestRecorder(renderer)
4044
+ recorder.rec()
4045
+
4046
+ const md = createMarkdownRenderable({
4047
+ id: "markdown",
4048
+ content: "This has **bold** text.",
4049
+ syntaxStyle,
4050
+ conceal: true,
4051
+ })
4052
+
4053
+ renderer.root.add(md)
4054
+ await renderMarkdownRenderable(md)
4055
+ recorder.stop()
4056
+
4057
+ const paragraphChildren = md.getChildren()
4058
+ expect(paragraphChildren.length).toBe(1)
4059
+ expect(paragraphChildren[0]).toBeInstanceOf(CodeRenderable)
4060
+ expect(paragraphChildren[0]).not.toBeInstanceOf(TextRenderable)
4061
+
4062
+ const rawMarkdownFrames = recorder.recordedFrames.filter((recorded) => recorded.frame.includes("**bold**"))
4063
+ expect(rawMarkdownFrames.length).toBe(0)
4064
+
4065
+ const finalFrame = captureFrame()
4066
+ expect(finalFrame).toContain("This has bold text.")
4067
+ })
4068
+
4069
+ test("paragraph updates do not flash raw markdown markers", async () => {
4070
+ const md = createMarkdownRenderable({
4071
+ id: "markdown",
4072
+ content: "**First** value",
4073
+ syntaxStyle,
4074
+ conceal: true,
4075
+ })
4076
+
4077
+ renderer.root.add(md)
4078
+ await renderMarkdownRenderable(md)
4079
+
4080
+ const paragraphChildrenBefore = md.getChildren()
4081
+ expect(paragraphChildrenBefore.length).toBe(1)
4082
+ expect(paragraphChildrenBefore[0]).toBeInstanceOf(CodeRenderable)
4083
+ expect(paragraphChildrenBefore[0]).not.toBeInstanceOf(TextRenderable)
4084
+
4085
+ const recorder = new TestRecorder(renderer)
4086
+ recorder.rec()
4087
+
4088
+ md.content = "**Second** value"
4089
+ await renderMarkdownRenderable(md)
4090
+ recorder.stop()
4091
+
4092
+ const paragraphChildrenAfter = md.getChildren()
4093
+ expect(paragraphChildrenAfter.length).toBe(1)
4094
+ expect(paragraphChildrenAfter[0]).toBeInstanceOf(CodeRenderable)
4095
+ expect(paragraphChildrenAfter[0]).not.toBeInstanceOf(TextRenderable)
4096
+
4097
+ const rawMarkdownFrames = recorder.recordedFrames.filter((recorded) => recorded.frame.includes("**Second**"))
4098
+ expect(rawMarkdownFrames.length).toBe(0)
4099
+
4100
+ const finalFrame = captureFrame()
4101
+ expect(finalFrame).toContain("Second value")
4102
+ expect(finalFrame).not.toContain("**Second**")
4103
+ })
4104
+
4105
+ test("top-level list does not insert blank line before nested list when source has blank line", async () => {
4106
+ const md = createMarkdownRenderable({
4107
+ id: "markdown-list-blank-before-nested",
4108
+ content: `- Added t topic edit mode in TUI:
4109
+
4110
+ - t focuses input with current topic
4111
+ - enter saves via daemon /topic
4112
+ - esc cancels
4113
+ - Topic is now shown prominently near the top of the TUI.`,
4114
+ syntaxStyle,
4115
+ internalBlockMode: "top-level",
4116
+ })
4117
+
4118
+ renderer.root.add(md)
4119
+ await renderMarkdownRenderable(md)
4120
+
4121
+ const lines = captureFrame()
4122
+ .split("\n")
4123
+ .map((line) => line.trimEnd())
4124
+ expect("\n" + lines.join("\n").trimEnd()).toMatchInlineSnapshot(`
4125
+ "
4126
+ - Added t topic edit mode in TUI:
4127
+ - t focuses input with current topic
4128
+ - enter saves via daemon /topic
4129
+ - esc cancels
4130
+ - Topic is now shown prominently near the top of the TUI."
4131
+ `)
4132
+ })
4133
+
4134
+ test("top-level lists keep tight multi-level nesting compact", async () => {
4135
+ const md = createMarkdownRenderable({
4136
+ id: "markdown-tight-multi-level-nested-list",
4137
+ content: `- Main section:
4138
+ - Supporting point:
4139
+ - Third-level detail
4140
+ - Another detail with **emphasis**
4141
+ - Another supporting point:
4142
+ 1. First numbered item
4143
+ 2. Second numbered item:
4144
+ - Nested bullet beneath a numbered item
4145
+ - Second section:
4146
+ - Short detail
4147
+ - Lead-in item:
4148
+ - Explanation below the lead-in`,
4149
+ syntaxStyle,
4150
+ streaming: true,
4151
+ internalBlockMode: "top-level",
4152
+ })
4153
+
4154
+ renderer.root.add(md)
4155
+ await renderMarkdownRenderable(md)
4156
+
4157
+ const lines = captureFrame()
4158
+ .split("\n")
4159
+ .map((line) => line.trimEnd())
4160
+ expect("\n" + lines.join("\n").trimEnd()).toMatchInlineSnapshot(`
4161
+ "
4162
+ - Main section:
4163
+ - Supporting point:
4164
+ - Third-level detail
4165
+ - Another detail with emphasis
4166
+ - Another supporting point:
4167
+ 1. First numbered item
4168
+ 2. Second numbered item:
4169
+ - Nested bullet beneath a numbered item
4170
+ - Second section:
4171
+ - Short detail
4172
+ - Lead-in item:
4173
+ - Explanation below the lead-in"
4174
+ `)
4175
+ })