@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,4975 @@
1
+ import { ANSI } from "./ansi.js"
2
+ import { Renderable, RootRenderable } from "./Renderable.js"
3
+ import { BoxRenderable } from "./renderables/Box.js"
4
+ import { CodeRenderable } from "./renderables/Code.js"
5
+ import { TextRenderable } from "./renderables/Text.js"
6
+ import {
7
+ DebugOverlayCorner,
8
+ type CursorStyleOptions,
9
+ type MousePointerStyle,
10
+ type RenderContext,
11
+ type TerminalCapabilities,
12
+ type ThemeMode,
13
+ type ViewportBounds,
14
+ type WidthMethod,
15
+ } from "./types.js"
16
+ import { RGBA, parseColor, type ColorInput } from "./lib/RGBA.js"
17
+ import { sleep } from "./platform/runtime.js"
18
+ import { OptimizedBuffer } from "./buffer.js"
19
+ import {
20
+ resolveRenderLib,
21
+ type NativeBufferedOutput,
22
+ type NativeRenderStats,
23
+ type RenderLib,
24
+ type RendererHandle,
25
+ } from "./zig.js"
26
+ import { NativeSpanFeed } from "./NativeSpanFeed.js"
27
+ import { TerminalConsole, type ConsoleOptions, capture } from "./console.js"
28
+ import { type MouseEventType, type RawMouseEvent, type ScrollInfo } from "./lib/parse.mouse.js"
29
+ import { Selection } from "./lib/selection.js"
30
+ import { Clipboard, type ClipboardTarget } from "./lib/clipboard.js"
31
+ import { EventEmitter } from "events"
32
+ import { singleton } from "./lib/singleton.js"
33
+ import { getObjectsInViewport } from "./lib/objects-in-viewport.js"
34
+ import { KeyHandler, InternalKeyHandler } from "./lib/KeyHandler.js"
35
+ import { isEditBufferRenderable, type EditBufferRenderable } from "./renderables/EditBufferRenderable.js"
36
+ import { env, registerEnvVar } from "./lib/env.js"
37
+ import { destroyTreeSitterClient } from "./lib/tree-sitter/index.js"
38
+ import {
39
+ buildTerminalPaletteSignature,
40
+ createTerminalPalette,
41
+ normalizeTerminalPalette,
42
+ type TerminalPaletteDetector,
43
+ type TerminalColors,
44
+ type GetPaletteOptions,
45
+ } from "./lib/terminal-palette.js"
46
+ import { calculateRenderGeometry } from "./lib/render-geometry.js"
47
+ import {
48
+ isCapabilityResponse,
49
+ isPixelResolutionResponse,
50
+ parsePixelResolution,
51
+ } from "./lib/terminal-capability-detection.js"
52
+ import { type Clock, type TimerHandle, SystemClock } from "./lib/clock.js"
53
+ import { StdinParser, type StdinEvent, type StdinParserProtocolContext } from "./lib/stdin-parser.js"
54
+ import { matchesKeyBinding } from "./lib/keybinding.internal.js"
55
+ import { RendererThemeMode } from "./renderer-theme-mode.js"
56
+
57
+ registerEnvVar({
58
+ name: "OTUI_DUMP_CAPTURES",
59
+ description: "Dump captured stdout and console caches when the renderer exit handler runs.",
60
+ type: "boolean",
61
+ default: false,
62
+ })
63
+
64
+ registerEnvVar({
65
+ name: "OTUI_NO_NATIVE_RENDER",
66
+ description:
67
+ "Skip the Zig/native frame renderer. Useful for debugging the render loop; split-footer stdout flushing may still write ANSI.",
68
+ type: "boolean",
69
+ default: false,
70
+ })
71
+
72
+ registerEnvVar({
73
+ name: "OTUI_USE_ALTERNATE_SCREEN",
74
+ description: "When explicitly set, force screen mode selection: true=alternate-screen, false=main-screen.",
75
+ type: "boolean",
76
+ default: true,
77
+ })
78
+
79
+ registerEnvVar({
80
+ name: "OTUI_OVERRIDE_STDOUT",
81
+ description: "When explicitly set, force stdout routing: false=passthrough, true=capture in split-footer mode.",
82
+ type: "boolean",
83
+ default: true,
84
+ })
85
+
86
+ registerEnvVar({
87
+ name: "OTUI_DEBUG",
88
+ description: "Enable debug mode to capture all raw input for debugging purposes.",
89
+ type: "boolean",
90
+ default: false,
91
+ })
92
+
93
+ registerEnvVar({
94
+ name: "OTUI_SHOW_STATS",
95
+ description: "Show the debug overlay at startup.",
96
+ type: "boolean",
97
+ default: false,
98
+ })
99
+
100
+ export interface CliRendererConfig {
101
+ // Read input from this stream. Defaults to process.stdin. Any `Readable`
102
+ // works; capabilities like `setRawMode` are duck-typed and used when present.
103
+ stdin?: NodeJS.ReadStream
104
+
105
+ // Destination for rendered output. Defaults to process.stdout. When this is
106
+ // not `process.stdout`, the renderer internally wires a `NativeSpanFeed`
107
+ // between Zig and the provided `Writable` so frames flow there instead of
108
+ // the host terminal. TTY capabilities like `columns`/`rows` are duck-typed.
109
+ stdout?: NodeJS.WriteStream
110
+
111
+ // Fallback terminal width when `stdout.columns` is not available. Useful
112
+ // when running against a non-TTY `Writable` (e.g. an SSH channel) where
113
+ // initial dimensions come from elsewhere. Default 80.
114
+ width?: number
115
+
116
+ // Fallback terminal height when `stdout.rows` is not available. Default 24.
117
+ height?: number
118
+
119
+ // Tell the native renderer it is driving a remote terminal. When omitted,
120
+ // native startup auto-detects SSH/mosh sessions; custom stdout feed output
121
+ // defaults to remote because it is not connected to the host TTY directly.
122
+ remote?: boolean
123
+
124
+ // Use an in-memory native buffered output destination instead of process stdout.
125
+ // Intended for test helpers that need native rendering without terminal I/O.
126
+ bufferedOutput?: NativeBufferedOutput
127
+
128
+ // Call renderer.destroy() when Ctrl+C is pressed. Defaults to true.
129
+ exitOnCtrlC?: boolean
130
+
131
+ // Clean up on these signals. Defaults to the common termination signals.
132
+ exitSignals?: NodeJS.Signals[]
133
+
134
+ // Clear owned screen regions on suspend/destroy. Defaults to true.
135
+ clearOnShutdown?: boolean
136
+
137
+ // Forward these env var names to native terminal detection.
138
+ forwardEnvKeys?: string[]
139
+
140
+ // Wait this long before handling resize events. Defaults to 100 ms.
141
+ debounceDelay?: number
142
+
143
+ // Aim for this many frames per second in continuous mode. Defaults to 30.
144
+ targetFps?: number
145
+
146
+ // Cap immediate re-renders at this frame rate. Defaults to 60.
147
+ maxFps?: number
148
+
149
+ // Emit memory snapshots on this interval in ms. Set 0 to disable.
150
+ memorySnapshotInterval?: number
151
+
152
+ // Render from a separate thread when the platform supports it.
153
+ useThread?: boolean
154
+
155
+ // Collect frame timing stats for the debug overlay.
156
+ gatherStats?: boolean
157
+
158
+ // Keep this many timing samples. Defaults to 300.
159
+ maxStatSamples?: number
160
+
161
+ // Pass options to the built-in console overlay.
162
+ consoleOptions?: Omit<ConsoleOptions, "clock">
163
+
164
+ // Run these hooks after each render pass.
165
+ postProcessFns?: ((buffer: OptimizedBuffer, deltaTime: number) => void)[]
166
+
167
+ // Track mouse move events. Defaults to true.
168
+ enableMouseMovement?: boolean
169
+
170
+ // Enable mouse input. Defaults to true.
171
+ useMouse?: boolean
172
+
173
+ // Focus the nearest focusable renderable on left click. Defaults to true.
174
+ autoFocus?: boolean
175
+
176
+ // Choose where the renderer owns terminal space. Defaults to "alternate-screen".
177
+ screenMode?: ScreenMode
178
+
179
+ // Set the requested footer height for "split-footer". Defaults to 12.
180
+ footerHeight?: number
181
+
182
+ // Choose what happens to writes that go through `stdout.write`.
183
+ externalOutputMode?: ExternalOutputMode
184
+
185
+ // Choose what the built-in console overlay does.
186
+ consoleMode?: ConsoleMode
187
+
188
+ // Set Kitty keyboard protocol flags, or null to disable them.
189
+ useKittyKeyboard?: KittyKeyboardOptions | null
190
+
191
+ // Fill the render buffer with this background color. Default transparent.
192
+ backgroundColor?: ColorInput
193
+
194
+ // Open the console overlay on uncaught errors. Defaults to true in development.
195
+ openConsoleOnError?: boolean
196
+
197
+ // Run these input handlers before the built-in handlers.
198
+ prependInputHandlers?: ((sequence: string) => boolean)[]
199
+
200
+ // Cap the stdin parser buffer size in bytes. Defaults to 64 MB.
201
+ stdinParserMaxBufferBytes?: number
202
+
203
+ // Use a custom clock for timers and tests.
204
+ clock?: Clock
205
+
206
+ // Run after destroy() finishes cleanup.
207
+ onDestroy?: () => void
208
+ }
209
+
210
+ // Controls how the renderer uses terminal space:
211
+ //
212
+ // - "alternate-screen": Use the terminal's alternate screen buffer.
213
+ //
214
+ // - "main-screen": Render on the main screen.
215
+ //
216
+ // - "split-footer": Keep the renderer in a reserved footer on the main screen.
217
+ export type ScreenMode = "alternate-screen" | "main-screen" | "split-footer"
218
+
219
+ // Controls writes that go through the configured `stdout.write`.
220
+ //
221
+ // - "capture-stdout": Queue stdout and replay it above the split footer.
222
+ // Only valid with "split-footer".
223
+ //
224
+ // - "passthrough": Leave stdout alone.
225
+ export type ExternalOutputMode = "capture-stdout" | "passthrough"
226
+
227
+ export interface CliRendererExternalOutputEvent {
228
+ snapshot: OptimizedBuffer
229
+ rowColumns: number
230
+ startOnNewLine: boolean
231
+ trailingNewline: boolean
232
+ }
233
+
234
+ // Controls the built-in console overlay:
235
+ //
236
+ // - "console-overlay": Capture `console.*` output and show the overlay.
237
+ //
238
+ // - "disabled": Hide the overlay. `OTUI_USE_CONSOLE` controls global console
239
+ // capture.
240
+ export type ConsoleMode = "console-overlay" | "disabled"
241
+
242
+ export type PixelResolution = {
243
+ width: number
244
+ height: number
245
+ }
246
+
247
+ export interface CliRendererStats extends NativeRenderStats {
248
+ fps: number
249
+ frameCount: number
250
+ frameTimes: number[]
251
+ averageFrameTime: number
252
+ minFrameTime: number
253
+ maxFrameTime: number
254
+ frameCallbackTime: number
255
+ }
256
+
257
+ export interface CliRendererFrameEvent {
258
+ frameId: number
259
+ }
260
+
261
+ export interface RendererSchedulerState {
262
+ isRunning: boolean
263
+ isRendering: boolean
264
+ hasScheduledRender: boolean
265
+ }
266
+
267
+ export interface ScrollbackRenderContext {
268
+ width: number
269
+ widthMethod: WidthMethod
270
+ tailColumn: number
271
+ renderContext: RenderContext
272
+ }
273
+
274
+ export interface ScrollbackSnapshot {
275
+ root: Renderable
276
+ width?: number
277
+ height?: number
278
+ rowColumns?: number
279
+ startOnNewLine?: boolean
280
+ trailingNewline?: boolean
281
+ teardown?: () => void
282
+ }
283
+
284
+ export type ScrollbackWriter = (ctx: ScrollbackRenderContext) => ScrollbackSnapshot
285
+
286
+ export interface ScrollbackSurfaceOptions {
287
+ startOnNewLine?: boolean
288
+ }
289
+
290
+ export interface ScrollbackSurfaceCommitOptions {
291
+ rowColumns?: number
292
+ trailingNewline?: boolean
293
+ }
294
+
295
+ export interface ScrollbackSurface {
296
+ readonly renderContext: RenderContext
297
+ readonly root: Renderable
298
+ readonly width: number
299
+ readonly height: number
300
+ readonly isDestroyed: boolean
301
+
302
+ render(): void
303
+ settle(timeoutMs?: number): Promise<void>
304
+ commitRows(startRow: number, endRowExclusive: number, options?: ScrollbackSurfaceCommitOptions): void
305
+ destroy(): void
306
+ }
307
+
308
+ export interface SplitFooterReplayResetOptions {
309
+ clearSavedLines?: boolean
310
+ }
311
+
312
+ const DEFAULT_FOOTER_HEIGHT = 12
313
+ const MAX_SCROLLBACK_SURFACE_HEIGHT_PASSES = 4
314
+ const TRANSPARENT_RGBA = RGBA.fromValues(0, 0, 0, 0)
315
+
316
+ let scrollbackSurfaceCounter = 0
317
+
318
+ function normalizeFooterHeight(footerHeight: number | undefined): number {
319
+ if (footerHeight === undefined) {
320
+ return DEFAULT_FOOTER_HEIGHT
321
+ }
322
+
323
+ if (!Number.isFinite(footerHeight)) {
324
+ throw new Error("footerHeight must be a finite number")
325
+ }
326
+
327
+ const normalizedFooterHeight = Math.trunc(footerHeight)
328
+ if (normalizedFooterHeight <= 0) {
329
+ throw new Error("footerHeight must be greater than 0")
330
+ }
331
+
332
+ return normalizedFooterHeight
333
+ }
334
+
335
+ function resolveModes(config: CliRendererConfig): {
336
+ screenMode: ScreenMode
337
+ footerHeight: number
338
+ externalOutputMode: ExternalOutputMode
339
+ } {
340
+ let screenMode = config.screenMode ?? "alternate-screen"
341
+ if (process.env.OTUI_USE_ALTERNATE_SCREEN !== undefined) {
342
+ screenMode = env.OTUI_USE_ALTERNATE_SCREEN ? "alternate-screen" : "main-screen"
343
+ }
344
+
345
+ const footerHeight =
346
+ screenMode === "split-footer" ? normalizeFooterHeight(config.footerHeight) : DEFAULT_FOOTER_HEIGHT
347
+
348
+ let externalOutputMode =
349
+ config.externalOutputMode ?? (screenMode === "split-footer" ? "capture-stdout" : "passthrough")
350
+ if (process.env.OTUI_OVERRIDE_STDOUT !== undefined) {
351
+ externalOutputMode = env.OTUI_OVERRIDE_STDOUT && screenMode === "split-footer" ? "capture-stdout" : "passthrough"
352
+ }
353
+
354
+ if (externalOutputMode === "capture-stdout" && screenMode !== "split-footer") {
355
+ throw new Error('externalOutputMode "capture-stdout" requires screenMode "split-footer"')
356
+ }
357
+
358
+ return {
359
+ screenMode,
360
+ footerHeight,
361
+ externalOutputMode,
362
+ }
363
+ }
364
+
365
+ type ExternalOutputCommit = {
366
+ snapshot: OptimizedBuffer
367
+ rowColumns: number
368
+ startOnNewLine: boolean
369
+ trailingNewline: boolean
370
+ }
371
+
372
+ type PendingSplitFooterTransition = {
373
+ mode: "viewport-scroll" | "clear-stale-rows"
374
+ sourceTopLine: number
375
+ sourceHeight: number
376
+ targetTopLine: number
377
+ targetHeight: number
378
+ scrollLines?: number
379
+ }
380
+
381
+ class ExternalOutputQueue {
382
+ private commits: ExternalOutputCommit[] = []
383
+
384
+ get size(): number {
385
+ return this.commits.length
386
+ }
387
+
388
+ writeSnapshot(commit: ExternalOutputCommit): void {
389
+ this.commits.push(commit)
390
+ }
391
+
392
+ peek(limit: number = Number.POSITIVE_INFINITY): readonly ExternalOutputCommit[] {
393
+ const clampedLimit = Number.isFinite(limit) ? Math.max(1, Math.trunc(limit)) : this.commits.length
394
+ return this.commits.slice(0, clampedLimit)
395
+ }
396
+
397
+ claim(limit: number = Number.POSITIVE_INFINITY): ExternalOutputCommit[] {
398
+ if (this.commits.length === 0) {
399
+ return []
400
+ }
401
+
402
+ // Split-footer capture can enqueue many tiny commits in a burst (for example,
403
+ // simulated Ctrl+R hold). Taking everything at once creates very large native
404
+ // frames that increase visible churn. We keep claim() bounded so one render tick
405
+ // produces one modest frame and schedules another tick if work remains.
406
+ const clampedLimit = Number.isFinite(limit) ? Math.max(1, Math.trunc(limit)) : this.commits.length
407
+ if (clampedLimit >= this.commits.length) {
408
+ const output = this.commits
409
+ this.commits = []
410
+ return output
411
+ }
412
+
413
+ const output = this.commits.slice(0, clampedLimit)
414
+ this.commits = this.commits.slice(clampedLimit)
415
+ return output
416
+ }
417
+
418
+ drop(count: number): void {
419
+ for (const commit of this.commits.splice(0, count)) {
420
+ commit.snapshot.destroy()
421
+ }
422
+ }
423
+
424
+ clear(): void {
425
+ this.drop(this.commits.length)
426
+ }
427
+ }
428
+
429
+ const CHAR_FLAG_CONTINUATION = 0xc0000000 >>> 0
430
+ const CHAR_FLAG_MASK = 0xc0000000 >>> 0
431
+
432
+ class ScrollbackSnapshotRenderContext extends EventEmitter implements RenderContext {
433
+ public width: number
434
+ public height: number
435
+ public frameId = 0
436
+ public widthMethod: WidthMethod
437
+ public capabilities: TerminalCapabilities | null = null
438
+ public hasSelection: boolean = false
439
+ public currentFocusedRenderable: Renderable | null = null
440
+ public keyInput: KeyHandler
441
+ public _internalKeyInput: InternalKeyHandler
442
+
443
+ private lifecyclePasses: Set<Renderable> = new Set()
444
+
445
+ constructor(width: number, height: number, widthMethod: WidthMethod) {
446
+ super()
447
+ this.width = width
448
+ this.height = height
449
+ this.widthMethod = widthMethod
450
+ this.keyInput = new KeyHandler()
451
+ this._internalKeyInput = new InternalKeyHandler()
452
+ }
453
+
454
+ public addToHitGrid(_x: number, _y: number, _width: number, _height: number, _id: number): void {}
455
+ public pushHitGridScissorRect(_x: number, _y: number, _width: number, _height: number): void {}
456
+ public popHitGridScissorRect(): void {}
457
+ public clearHitGridScissorRects(): void {}
458
+ public requestRender(): void {}
459
+ public setCursorPosition(_x: number, _y: number, _visible: boolean): void {}
460
+ public setCursorStyle(_options: CursorStyleOptions): void {}
461
+ public setCursorColor(_color: RGBA): void {}
462
+ public setMousePointer(_shape: MousePointerStyle): void {}
463
+ public requestLive(): void {}
464
+ public dropLive(): void {}
465
+ public getSelection(): Selection | null {
466
+ return null
467
+ }
468
+ public get currentFocusedEditor(): EditBufferRenderable | null {
469
+ if (!this.currentFocusedRenderable) return null
470
+ if (!isEditBufferRenderable(this.currentFocusedRenderable)) return null
471
+ return this.currentFocusedRenderable
472
+ }
473
+ public requestSelectionUpdate(): void {}
474
+ public focusRenderable(renderable: Renderable): void {
475
+ this.currentFocusedRenderable = renderable
476
+ }
477
+ public blurRenderable(renderable: Renderable): void {
478
+ if (this.currentFocusedRenderable === renderable) {
479
+ this.currentFocusedRenderable = null
480
+ }
481
+ }
482
+ public registerLifecyclePass(renderable: Renderable): void {
483
+ this.lifecyclePasses.add(renderable)
484
+ }
485
+ public unregisterLifecyclePass(renderable: Renderable): void {
486
+ this.lifecyclePasses.delete(renderable)
487
+ }
488
+ public getLifecyclePasses(): Set<Renderable> {
489
+ return this.lifecyclePasses
490
+ }
491
+ public clearSelection(): void {}
492
+ public startSelection(_renderable: Renderable, _x: number, _y: number): void {}
493
+ public updateSelection(
494
+ _currentRenderable: Renderable | undefined,
495
+ _x: number,
496
+ _y: number,
497
+ _options?: { finishDragging?: boolean },
498
+ ): void {}
499
+ }
500
+
501
+ const DEFAULT_FORWARDED_ENV_KEYS = [
502
+ "TMUX",
503
+ "ZELLIJ",
504
+ "ZELLIJ_SESSION_NAME",
505
+ "ZELLIJ_PANE_ID",
506
+ "TERM",
507
+ "OPENTUI_GRAPHICS",
508
+ "TERM_PROGRAM",
509
+ "TERM_PROGRAM_VERSION",
510
+ "TERM_FEATURES",
511
+ "ALACRITTY_SOCKET",
512
+ "ALACRITTY_LOG",
513
+ "COLORTERM",
514
+ "TERMUX_VERSION",
515
+ "VHS_RECORD",
516
+ "OPENTUI_FORCE_WCWIDTH",
517
+ "OPENTUI_FORCE_UNICODE",
518
+ "OPENTUI_FORCE_NOZWJ",
519
+ "OPENTUI_FORCE_EXPLICIT_WIDTH",
520
+ "OPENTUI_NOTIFICATION_PROTOCOL",
521
+ "OPENTUI_NOTIFICATIONS",
522
+ "WT_SESSION",
523
+ "STY",
524
+ "WSL_DISTRO_NAME",
525
+ "WSL_INTEROP",
526
+ ] as const
527
+
528
+ const NATIVE_RENDER_STATUS_SKIPPED = 1
529
+ const NATIVE_RENDER_STATUS_FAILED = 2
530
+
531
+ // Kitty keyboard protocol flags
532
+ // See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
533
+ const KITTY_FLAG_DISAMBIGUATE = 0b1 // Report disambiguated escape codes
534
+ const KITTY_FLAG_EVENT_TYPES = 0b10 // Report event types (press/repeat/release)
535
+ const KITTY_FLAG_ALTERNATE_KEYS = 0b100 // Report alternate keys (e.g., numpad vs regular)
536
+ const KITTY_FLAG_ALL_KEYS_AS_ESCAPES = 0b1000 // Report all keys as escape codes
537
+ const KITTY_FLAG_REPORT_TEXT = 0b10000 // Report text associated with key events
538
+
539
+ const DEFAULT_STDIN_PARSER_MAX_BUFFER_BYTES = 64 * 1024 * 1024
540
+ const NATIVE_PALETTE_QUERY_SIZE = 16
541
+
542
+ /**
543
+ * Kitty Keyboard Protocol configuration options
544
+ * See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/#progressive-enhancement
545
+ */
546
+ export interface KittyKeyboardOptions {
547
+ /** Disambiguate escape codes (fixes ESC timing, alt+key ambiguity, ctrl+c as event). Default: true */
548
+ disambiguate?: boolean
549
+ /** Report alternate keys (numpad, shifted, base layout) for cross-keyboard shortcuts. Default: true */
550
+ alternateKeys?: boolean
551
+ /** Report event types (press/repeat/release). Default: false */
552
+ events?: boolean
553
+ /** Report all keys as escape codes. Default: false */
554
+ allKeysAsEscapes?: boolean
555
+ /** Report text associated with key events. Default: false */
556
+ reportText?: boolean
557
+ }
558
+
559
+ /**
560
+ * Build kitty keyboard protocol flags based on configuration
561
+ * @param config Kitty keyboard configuration object (null/undefined = disabled)
562
+ * @returns The combined flags value (0 = disabled, >0 = enabled)
563
+ * @internal Exported for testing
564
+ */
565
+ export function buildKittyKeyboardFlags(config: KittyKeyboardOptions | null | undefined): number {
566
+ if (!config) {
567
+ return 0
568
+ }
569
+
570
+ let flags = 0
571
+
572
+ // Default: disambiguate + alternate keys (both default to true)
573
+ // - Disambiguate (0b1): Fixes ESC timing issues, alt+key ambiguity, makes ctrl+c a key event
574
+ // - Alternate keys (0b100): Reports shifted/base-layout keys for cross-keyboard shortcuts
575
+
576
+ // disambiguate defaults to true unless explicitly set to false
577
+ if (config.disambiguate !== false) {
578
+ flags |= KITTY_FLAG_DISAMBIGUATE
579
+ }
580
+
581
+ // alternateKeys defaults to true unless explicitly set to false
582
+ if (config.alternateKeys !== false) {
583
+ flags |= KITTY_FLAG_ALTERNATE_KEYS
584
+ }
585
+
586
+ // Optional flags (default to false, only enabled when explicitly true)
587
+ if (config.events === true) {
588
+ flags |= KITTY_FLAG_EVENT_TYPES
589
+ }
590
+
591
+ if (config.allKeysAsEscapes === true) {
592
+ flags |= KITTY_FLAG_ALL_KEYS_AS_ESCAPES
593
+ }
594
+
595
+ if (config.reportText === true) {
596
+ flags |= KITTY_FLAG_REPORT_TEXT
597
+ }
598
+
599
+ return flags
600
+ }
601
+
602
+ export class MouseEvent {
603
+ public readonly type: MouseEventType
604
+ public readonly button: number
605
+ public readonly x: number
606
+ public readonly y: number
607
+ public readonly source?: Renderable
608
+ public readonly modifiers: {
609
+ shift: boolean
610
+ alt: boolean
611
+ ctrl: boolean
612
+ }
613
+ public readonly scroll?: ScrollInfo
614
+ public readonly target: Renderable | null
615
+ public readonly isDragging?: boolean
616
+ private _propagationStopped: boolean = false
617
+ private _defaultPrevented: boolean = false
618
+
619
+ public get propagationStopped(): boolean {
620
+ return this._propagationStopped
621
+ }
622
+
623
+ public get defaultPrevented(): boolean {
624
+ return this._defaultPrevented
625
+ }
626
+
627
+ constructor(target: Renderable | null, attributes: RawMouseEvent & { source?: Renderable; isDragging?: boolean }) {
628
+ this.target = target
629
+ this.type = attributes.type
630
+ this.button = attributes.button
631
+ this.x = attributes.x
632
+ this.y = attributes.y
633
+ this.modifiers = attributes.modifiers
634
+ this.scroll = attributes.scroll
635
+ this.source = attributes.source
636
+ this.isDragging = attributes.isDragging
637
+ }
638
+
639
+ public stopPropagation(): void {
640
+ this._propagationStopped = true
641
+ }
642
+
643
+ public preventDefault(): void {
644
+ this._defaultPrevented = true
645
+ }
646
+ }
647
+
648
+ export enum MouseButton {
649
+ LEFT = 0,
650
+ MIDDLE = 1,
651
+ RIGHT = 2,
652
+ WHEEL_UP = 4,
653
+ WHEEL_DOWN = 5,
654
+ }
655
+
656
+ const rendererTracker = singleton("RendererTracker", () => ({
657
+ renderers: new Set<CliRenderer>(),
658
+ streamOwners: new WeakMap<object, CliRenderer>(),
659
+ }))
660
+
661
+ /**
662
+ * Create a CLI renderer and run its async terminal setup. The constructor
663
+ * owns all stream and backend decisions; this factory only layers on the
664
+ * `--delay-start` flag and the `await setupTerminal()` convenience.
665
+ */
666
+ export async function createCliRenderer(config: CliRendererConfig = {}): Promise<CliRenderer> {
667
+ if (process.argv.includes("--delay-start")) {
668
+ await new Promise((resolve) => setTimeout(resolve, 5000))
669
+ }
670
+ const stdin = config.stdin ?? process.stdin
671
+ const stdout = config.stdout ?? process.stdout
672
+
673
+ // Prefer real TTY dimensions, then explicit config fallbacks, then defaults.
674
+ // `||` (not `??`) preserves the historical falsy-fallback semantics where
675
+ // `stdout.columns === 0` falls through rather than being treated as valid.
676
+ const width = stdout.columns || config.width || 80
677
+ const height = stdout.rows || config.height || 24
678
+
679
+ const renderer = new CliRenderer(stdin, stdout, width, height, config)
680
+ try {
681
+ await renderer.setupTerminal()
682
+ return renderer
683
+ } catch (error) {
684
+ try {
685
+ renderer.destroy()
686
+ } catch (destroyError) {
687
+ console.error("Error destroying partially-set-up renderer:", destroyError)
688
+ }
689
+ throw error
690
+ }
691
+ }
692
+
693
+ export enum CliRenderEvents {
694
+ RESIZE = "resize",
695
+ FRAME = "frame",
696
+ EXTERNAL_OUTPUT = "external_output",
697
+ FOCUS = "focus",
698
+ BLUR = "blur",
699
+ FOCUSED_RENDERABLE = "focused_renderable",
700
+ FOCUSED_EDITOR = "focused_editor",
701
+ THEME_MODE = "theme_mode",
702
+ PALETTE = "palette",
703
+ CAPABILITIES = "capabilities",
704
+ SELECTION = "selection",
705
+ DEBUG_OVERLAY_TOGGLE = "debugOverlay:toggle",
706
+ DESTROY = "destroy",
707
+ MEMORY_SNAPSHOT = "memory:snapshot",
708
+ }
709
+
710
+ export enum RendererControlState {
711
+ IDLE = "idle",
712
+ AUTO_STARTED = "auto_started",
713
+ EXPLICIT_STARTED = "explicit_started",
714
+ EXPLICIT_PAUSED = "explicit_paused",
715
+ EXPLICIT_SUSPENDED = "explicit_suspended",
716
+ EXPLICIT_STOPPED = "explicit_stopped",
717
+ }
718
+
719
+ export class CliRenderer extends EventEmitter implements RenderContext {
720
+ private static animationFrameId = 0
721
+ private lib: RenderLib
722
+ public rendererPtr: RendererHandle
723
+ public stdin: NodeJS.ReadStream
724
+ private stdout: NodeJS.WriteStream
725
+ private exitOnCtrlC: boolean
726
+ private exitSignals: NodeJS.Signals[]
727
+ private _exitListenersAdded: boolean = false
728
+ private _isDestroyed: boolean = false
729
+ private _destroyPending: boolean = false
730
+ private _destroyFinalized: boolean = false
731
+ private _destroyCleanupPrepared: boolean = false
732
+ private _streamLeaseAcquired: boolean = false
733
+ public nextRenderBuffer: OptimizedBuffer
734
+ public currentRenderBuffer: OptimizedBuffer
735
+ private _isRunning: boolean = false
736
+ private _targetFps: number = 30
737
+ private _maxFps: number = 60
738
+ private automaticMemorySnapshot: boolean = false
739
+ private memorySnapshotInterval: number
740
+ private memorySnapshotTimer: TimerHandle | null = null
741
+ private lastMemorySnapshot: {
742
+ heapUsed: number
743
+ heapTotal: number
744
+ arrayBuffers: number
745
+ } = {
746
+ heapUsed: 0,
747
+ heapTotal: 0,
748
+ arrayBuffers: 0,
749
+ }
750
+ public readonly root: RootRenderable
751
+ public width: number
752
+ public height: number
753
+ private _useThread: boolean = false
754
+ private gatherStats: boolean = false
755
+ private frameTimes: number[] = []
756
+ private maxStatSamples: number = 300
757
+ private postProcessFns: ((buffer: OptimizedBuffer, deltaTime: number) => void)[] = []
758
+ private backgroundColor: RGBA = RGBA.fromInts(0, 0, 0, 0)
759
+ private waitingForPixelResolution: boolean = false
760
+ private readonly clock: Clock
761
+
762
+ private rendering: boolean = false
763
+ private renderingNative: boolean = false
764
+ private renderTimeout: TimerHandle | null = null
765
+ private lastTime: number = 0
766
+ private frameCount: number = 0
767
+ // Bumped once per loop() iteration; see RenderContext.frameId.
768
+ private _frameId: number = 0
769
+ private lastFpsTime: number = 0
770
+ private currentFps: number = 0
771
+ private targetFrameTime: number = 1000 / this._targetFps
772
+ private minTargetFrameTime: number = 1000 / this._maxFps
773
+ private immediateRerenderRequested: boolean = false
774
+ private updateScheduled: boolean = false
775
+
776
+ private liveRequestCounter: number = 0
777
+ private _controlState: RendererControlState = RendererControlState.IDLE
778
+
779
+ private frameCallbacks: ((deltaTime: number) => Promise<void>)[] = []
780
+ private renderStats: {
781
+ frameCount: number
782
+ fps: number
783
+ renderTime?: number
784
+ frameCallbackTime: number
785
+ } = {
786
+ frameCount: 0,
787
+ fps: 0,
788
+ renderTime: 0,
789
+ frameCallbackTime: 0,
790
+ }
791
+ public debugOverlay = {
792
+ enabled: env.OTUI_SHOW_STATS,
793
+ corner: DebugOverlayCorner.bottomRight,
794
+ }
795
+
796
+ private _console: TerminalConsole
797
+ private _resolution: PixelResolution | null = null
798
+ private _keyHandler: InternalKeyHandler
799
+ private stdinParser: StdinParser | null = null
800
+ private readonly oscSubscribers = new Set<(sequence: string) => void>()
801
+ private hasLoggedStdinParserError = false
802
+
803
+ private animationRequest: Map<number, FrameRequestCallback> = new Map()
804
+
805
+ private resizeTimeoutId: TimerHandle | null = null
806
+ private capabilityTimeoutId: TimerHandle | null = null
807
+ private xtVersionWaiters = new Set<() => void>()
808
+ private splitStartupSeedTimeoutId: TimerHandle | null = null
809
+ private pendingSplitStartupCursorSeed: boolean = false
810
+ private resizeDebounceDelay: number = 100
811
+
812
+ private enableMouseMovement: boolean = false
813
+ private _useMouse: boolean = true
814
+ private autoFocus: boolean = true
815
+ private _screenMode: ScreenMode = "alternate-screen"
816
+ private _footerHeight: number = DEFAULT_FOOTER_HEIGHT
817
+ private _externalOutputMode: ExternalOutputMode = "passthrough"
818
+ private clearOnShutdown: boolean = true
819
+ private _suspendedMouseEnabled: boolean = false
820
+ private _previousControlState: RendererControlState = RendererControlState.IDLE
821
+ private pendingSuspendedTerminalSetup: boolean = false
822
+ private suspendedNonAltSurfacePreserved: boolean = false
823
+ private capturedRenderable?: Renderable
824
+ private lastOverRenderableNum: number = 0
825
+ private lastOverRenderable?: Renderable
826
+
827
+ private currentSelection: Selection | null = null
828
+ private selectionContainers: Renderable[] = []
829
+ private clipboard: Clipboard
830
+
831
+ private _splitHeight: number = 0
832
+ private renderOffset: number = 0
833
+ private splitTailColumn: number = 0
834
+ private pendingSplitFooterTransition: PendingSplitFooterTransition | null = null
835
+ // One-shot latch used to request a full split repaint after transitions
836
+ // (resize/mode/output-path changes). Cleared on first renderNative tick.
837
+ private forceFullRepaintRequested: boolean = false
838
+ // Upper bound for captured stdout commits consumed per native frame.
839
+ // This is a visual smoothness control: smaller batches reduce frame envelope
840
+ // churn and keep render latency predictable under heavy scrollback append load.
841
+ private readonly maxSplitCommitsPerFrame: number = 8
842
+
843
+ private _terminalWidth: number = 0
844
+ private _terminalHeight: number = 0
845
+ private _terminalIsSetup: boolean = false
846
+
847
+ private externalOutputQueue = new ExternalOutputQueue()
848
+ private pendingExternalOutputMode: ExternalOutputMode | null = null
849
+ private realStdoutWrite: (chunk: any, encoding?: any, callback?: any) => boolean
850
+
851
+ private _useConsole: boolean = true
852
+ private sigwinchHandler: () => void = (() => {
853
+ const width = this.stdout.columns || 80
854
+ const height = this.stdout.rows || 24
855
+ this.handleResize(width, height)
856
+ }).bind(this)
857
+ private _capabilities: TerminalCapabilities | null = null
858
+ private _latestPointer: { x: number; y: number } = { x: 0, y: 0 }
859
+ private _hasPointer: boolean = false
860
+ private _lastPointerModifiers: RawMouseEvent["modifiers"] = {
861
+ shift: false,
862
+ alt: false,
863
+ ctrl: false,
864
+ }
865
+ private _currentMousePointerStyle: MousePointerStyle | undefined = undefined
866
+
867
+ private _currentFocusedRenderable: Renderable | null = null
868
+ private lifecyclePasses: Set<Renderable> = new Set()
869
+ private _openConsoleOnError: boolean = true
870
+ private _paletteDetector: TerminalPaletteDetector | null = null
871
+ private _paletteCache = new Map<number, TerminalColors>()
872
+ private _paletteDetectionPromise: Promise<TerminalColors> | null = null
873
+ private _paletteDetectionSize = 0
874
+ private _paletteEpoch = 0
875
+ private _nativePaletteSignature: string | null = null
876
+ private _emittedPaletteSignature: string | null = null
877
+ private _palettePublishGeneration = 0
878
+ private _onDestroy?: () => void
879
+ private themeModeState: RendererThemeMode
880
+ private _terminalFocusState: boolean | null = null
881
+
882
+ private sequenceHandlers: ((sequence: string) => boolean)[] = []
883
+ private prependedInputHandlers: ((sequence: string) => boolean)[] = []
884
+ private shouldRestoreModesOnNextFocus: boolean = false
885
+ private themeModeHandler!: (sequence: string) => boolean
886
+
887
+ private idleResolvers: (() => void)[] = []
888
+
889
+ private _debugInputs: Array<{ timestamp: string; sequence: string }> = []
890
+ private _debugModeEnabled: boolean = env.OTUI_DEBUG
891
+
892
+ private handleError: (error: Error) => void = ((error: Error) => {
893
+ console.error(error)
894
+
895
+ if (this._openConsoleOnError) {
896
+ this.console.show()
897
+ }
898
+ }).bind(this)
899
+
900
+ private dumpOutputCache(optionalMessage: string = ""): void {
901
+ const cachedLogs = this.console.getCachedLogs()
902
+ const capturedConsoleOutput = capture.claimOutput()
903
+ const capturedExternalOutputCommits = this.externalOutputQueue.claim()
904
+
905
+ let capturedExternalOutput = ""
906
+ for (const commit of capturedExternalOutputCommits) {
907
+ capturedExternalOutput += `[snapshot ${commit.snapshot.width}x${commit.snapshot.height}]\n`
908
+ commit.snapshot.destroy()
909
+ }
910
+
911
+ if (capturedConsoleOutput.length > 0 || capturedExternalOutput.length > 0 || cachedLogs.length > 0) {
912
+ this.realStdoutWrite.call(this.stdout, optionalMessage)
913
+ }
914
+
915
+ if (cachedLogs.length > 0) {
916
+ this.realStdoutWrite.call(this.stdout, "Console cache:\n")
917
+ this.realStdoutWrite.call(this.stdout, cachedLogs)
918
+ }
919
+
920
+ if (capturedConsoleOutput.length > 0) {
921
+ this.realStdoutWrite.call(this.stdout, "\nCaptured console output:\n")
922
+ this.realStdoutWrite.call(this.stdout, capturedConsoleOutput + "\n")
923
+ }
924
+
925
+ if (capturedExternalOutput.length > 0) {
926
+ this.realStdoutWrite.call(this.stdout, "\nCaptured external output:\n")
927
+ this.realStdoutWrite.call(this.stdout, capturedExternalOutput + "\n")
928
+ }
929
+
930
+ this.realStdoutWrite.call(this.stdout, ANSI.reset)
931
+ }
932
+
933
+ private exitHandler: () => void = (() => {
934
+ this.destroy()
935
+ if (env.OTUI_DUMP_CAPTURES) {
936
+ sleep(100).then(() => {
937
+ this.dumpOutputCache("=== CAPTURED OUTPUT ===\n")
938
+ })
939
+ }
940
+ }).bind(this)
941
+
942
+ private warningHandler: (warning: any) => void = ((warning: any) => {
943
+ console.warn(JSON.stringify(warning.message, null, 2))
944
+ }).bind(this)
945
+
946
+ // Stream identity flag. Used only for SIGWINCH gating (terminal-driven
947
+ // resize only fires for process.stdout). Other per-stream behavior uses
948
+ // identity checks inline (e.g. rendererTracker compares `stdin` directly)
949
+ // or duck-typed capability checks (e.g. `stdin.setRawMode?.()`).
950
+ private readonly _usesProcessStdout: boolean
951
+
952
+ // Feed wiring. Non-null when the given stdout is not process.stdout and native
953
+ // output is not explicitly redirected to a buffered memory destination.
954
+ private _feed: NativeSpanFeed | null = null
955
+ private _detachFeed: (() => void) | null = null
956
+ private _detachFeedError: (() => void) | null = null
957
+ private feedIdleRenderScheduled = false
958
+ private ordinaryFrameWaitingForFeed = false
959
+ private ordinaryFrameWaitControlState: RendererControlState | null = null
960
+
961
+ public get controlState(): RendererControlState {
962
+ return this._controlState
963
+ }
964
+
965
+ /**
966
+ * Construct a renderer over the given streams.
967
+ *
968
+ * If `stdout` is not `process.stdout`, a `NativeSpanFeed` is allocated
969
+ * internally and rendered bytes are piped through it to `stdout` unless
970
+ * `bufferedOutput: "memory"` is set. Prefer `createCliRenderer` for the async
971
+ * `setupTerminal` convenience.
972
+ *
973
+ * Construction side effects (observable before the constructor returns):
974
+ * - Acquires exclusive ownership of the given stdin/stdout streams
975
+ * - Allocates a `NativeSpanFeed` (for non-process stdout unless bufferedOutput is "memory")
976
+ * - Calls `lib.createRenderer` → native Zig allocation
977
+ * - Registers in the process-wide `rendererTracker`
978
+ * - Adds `process.on(...)` listeners for SIGWINCH (process.stdout only),
979
+ * "warning", "uncaughtException", "unhandledRejection", "beforeExit",
980
+ * plus the configured `exitSignals`
981
+ * - Replaces `global.requestAnimationFrame` with the renderer's impl
982
+ * - When `setupTerminal()` is called, it will put `stdin` in raw mode and
983
+ * call `stdin.resume()`
984
+ *
985
+ * Some late constructor side effects are not rolled back if construction
986
+ * throws partway; production callers should use `createCliRenderer`, which
987
+ * wraps `setupTerminal()` in a try/catch that calls `destroy()` on failure.
988
+ */
989
+ constructor(
990
+ stdin: NodeJS.ReadStream,
991
+ stdout: NodeJS.WriteStream,
992
+ width: number,
993
+ height: number,
994
+ config: CliRendererConfig = {},
995
+ ) {
996
+ super()
997
+
998
+ this.stdin = stdin
999
+ this.stdout = stdout
1000
+ this._usesProcessStdout = stdout === process.stdout
1001
+ this.realStdoutWrite = stdout.write
1002
+
1003
+ const lib = resolveRenderLib()
1004
+ const useMemoryBufferedOutput = config.bufferedOutput === "memory"
1005
+ const useFeedOutput = !this._usesProcessStdout && !useMemoryBufferedOutput
1006
+ const { screenMode, footerHeight, externalOutputMode } = resolveModes(config)
1007
+ const initialGeometry = calculateRenderGeometry(screenMode, width, height, footerHeight)
1008
+ const remoteMode = config.remote ?? (useFeedOutput ? true : undefined)
1009
+
1010
+ if (rendererTracker.streamOwners.get(stdin)) {
1011
+ throw new Error("Cannot create CliRenderer: stdin is already used by another CliRenderer")
1012
+ }
1013
+ if (rendererTracker.streamOwners.get(stdout)) {
1014
+ throw new Error("Cannot create CliRenderer: stdout is already used by another CliRenderer")
1015
+ }
1016
+
1017
+ // Feed allocation: only when the output target is a non-process Writable.
1018
+ // Tests use custom Writable instances too; those should exercise the same
1019
+ // output transport as production custom stdout.
1020
+ let feed: NativeSpanFeed | null = null
1021
+ if (useFeedOutput) {
1022
+ try {
1023
+ feed = NativeSpanFeed.create()
1024
+ } catch (error) {
1025
+ throw new Error(
1026
+ `Failed to allocate NativeSpanFeed for custom stdout: ${error instanceof Error ? error.message : String(error)}`,
1027
+ )
1028
+ }
1029
+ }
1030
+
1031
+ // Native renderer creation. Custom writable feed output defaults to remote
1032
+ // so Zig skips local-TTY capability-query timing assumptions; process
1033
+ // stdout and memory output preserve native auto detection.
1034
+ //
1035
+ let rendererPtr: RendererHandle | null
1036
+ try {
1037
+ rendererPtr = lib.createRenderer(initialGeometry.renderWidth, initialGeometry.renderHeight, {
1038
+ remote: remoteMode,
1039
+ feedPtr: feed?.streamPtr ?? null,
1040
+ bufferedOutput: config.bufferedOutput,
1041
+ })
1042
+ } catch (error) {
1043
+ feed?.close()
1044
+ throw error
1045
+ }
1046
+ if (!rendererPtr) {
1047
+ feed?.close()
1048
+ throw new Error("Failed to create renderer")
1049
+ }
1050
+
1051
+ // Threading defaults (on everywhere except linux, where it currently
1052
+ // crashes — likely a missing build dep).
1053
+ if (config.useThread === undefined) config.useThread = true
1054
+ if (process.platform === "linux") config.useThread = false
1055
+ lib.setUseThread(rendererPtr, config.useThread)
1056
+
1057
+ const kittyConfig = config.useKittyKeyboard ?? {}
1058
+ const kittyFlags = buildKittyKeyboardFlags(kittyConfig)
1059
+ lib.setKittyKeyboardFlags(rendererPtr, kittyFlags)
1060
+
1061
+ // Wire feed → Writable piping. The returned Promise keeps the chunk
1062
+ // pinned (refcount held) until Node's write callback fires; this turns
1063
+ // Node's Writable flow control into async backpressure for the feed.
1064
+ // Write errors are surfaced via feed.onError / console.error; we do not
1065
+ // interfere with the Writable's own error handling (matches the
1066
+ // pre-feed-integration posture where process.stdout EPIPE was the OS's
1067
+ // problem, not the renderer's).
1068
+ this._feed = feed
1069
+ if (feed) {
1070
+ this._detachFeed = feed.onData((bytes: Uint8Array) => {
1071
+ return new Promise<void>((resolve) => {
1072
+ // Renderer-owned frame bytes must bypass any later stdout.write
1073
+ // interception (e.g. split-footer capture) and go straight to the
1074
+ // caller's actual sink.
1075
+ this.realStdoutWrite.call(this.stdout, bytes, () => resolve())
1076
+ })
1077
+ })
1078
+ this._detachFeedError = feed.onError((code) => {
1079
+ console.error(`[CliRenderer] NativeSpanFeed error: code=${code}`)
1080
+ })
1081
+ }
1082
+
1083
+ this.lib = lib
1084
+ this._terminalWidth = width
1085
+ this._terminalHeight = height
1086
+ this._useThread = config.useThread
1087
+ this._externalOutputMode = externalOutputMode
1088
+
1089
+ this.width = initialGeometry.renderWidth
1090
+ this.height = initialGeometry.renderHeight
1091
+ this._splitHeight = initialGeometry.effectiveFooterHeight
1092
+ this.renderOffset = screenMode === "split-footer" ? 0 : initialGeometry.renderOffset
1093
+
1094
+ this._footerHeight = footerHeight
1095
+
1096
+ this.rendererPtr = rendererPtr
1097
+ this.clearOnShutdown = config.clearOnShutdown ?? true
1098
+ this.lib.setClearOnShutdown(this.rendererPtr, this.clearOnShutdown)
1099
+
1100
+ const forwardEnvKeys = config.forwardEnvKeys ?? (config.remote === false ? [...DEFAULT_FORWARDED_ENV_KEYS] : [])
1101
+ for (const key of forwardEnvKeys) {
1102
+ const value = process.env[key]
1103
+ if (value === undefined) continue
1104
+ this.lib.setTerminalEnvVar(this.rendererPtr, key, value)
1105
+ }
1106
+
1107
+ this.exitOnCtrlC = config.exitOnCtrlC === undefined ? true : config.exitOnCtrlC
1108
+ this.exitSignals = config.exitSignals || [
1109
+ "SIGINT", // Ctrl+C
1110
+ "SIGTERM", // Termination signal
1111
+ "SIGQUIT", // Ctrl+\
1112
+ "SIGABRT", // Abort signal
1113
+ "SIGHUP", // Hangup (terminal closed)
1114
+ "SIGBREAK", // Ctrl+Break on Windows
1115
+ "SIGPIPE", // Broken pipe
1116
+ "SIGBUS", // Bus error
1117
+ ]
1118
+
1119
+ this.clipboard = new Clipboard(this.lib, this.rendererPtr)
1120
+ this.resizeDebounceDelay = config.debounceDelay || 100
1121
+ this.targetFps = config.targetFps || 30
1122
+ this.maxFps = config.maxFps || 60
1123
+ this.clock = config.clock ?? new SystemClock()
1124
+ this.themeModeState = new RendererThemeMode(
1125
+ {
1126
+ queryThemeColors: () => {
1127
+ this.lib.queryThemeColors(this.rendererPtr)
1128
+ },
1129
+ },
1130
+ this.clock,
1131
+ )
1132
+ this.themeModeHandler = (sequence: string) => {
1133
+ const result = this.themeModeState.handleSequence(sequence)
1134
+ if (result.changedMode) {
1135
+ this.clearPaletteCache()
1136
+ if (this.shouldSyncNativePaletteState() || this.listenerCount(CliRenderEvents.PALETTE) > 0) {
1137
+ this.refreshPalette()
1138
+ }
1139
+ this.emit(CliRenderEvents.THEME_MODE, result.changedMode)
1140
+ }
1141
+ return result.handled
1142
+ }
1143
+ this.memorySnapshotInterval = config.memorySnapshotInterval ?? 0
1144
+ this.gatherStats = config.gatherStats || false
1145
+ this.maxStatSamples = config.maxStatSamples || 300
1146
+ this.enableMouseMovement = config.enableMouseMovement ?? true
1147
+ this._useMouse = config.useMouse ?? true
1148
+ this.autoFocus = config.autoFocus ?? true
1149
+ this.nextRenderBuffer = this.lib.getNextBuffer(this.rendererPtr)
1150
+ this.currentRenderBuffer = this.lib.getCurrentBuffer(this.rendererPtr)
1151
+ this.postProcessFns = config.postProcessFns || []
1152
+ this.prependedInputHandlers = config.prependInputHandlers || []
1153
+
1154
+ this.root = new RootRenderable(this)
1155
+
1156
+ if (this.memorySnapshotInterval > 0) {
1157
+ this.startMemorySnapshotTimer()
1158
+ }
1159
+
1160
+ // Handle terminal resize via SIGWINCH, but only when attached to the
1161
+ // process's real stdout — a custom Writable wouldn't drive SIGWINCH
1162
+ // anyway, and external consumers can call `renderer.resize(w, h)` to
1163
+ // announce dimension changes themselves.
1164
+ if (this._usesProcessStdout) {
1165
+ process.on("SIGWINCH", this.sigwinchHandler)
1166
+ }
1167
+
1168
+ process.on("warning", this.warningHandler)
1169
+
1170
+ process.on("uncaughtException", this.handleError)
1171
+ process.on("unhandledRejection", this.handleError)
1172
+ process.on("beforeExit", this.exitHandler)
1173
+
1174
+ const useKittyForParsing = kittyConfig !== null
1175
+ this._keyHandler = new InternalKeyHandler()
1176
+ this._keyHandler.on("keypress", (event) => {
1177
+ // Use the shared matcher here too. Kitty can report a non-Latin
1178
+ // character plus a base-layout `c`, and Ctrl+C should still exit.
1179
+ if (this.exitOnCtrlC && matchesKeyBinding(event, { name: "c", ctrl: true })) {
1180
+ process.nextTick(() => {
1181
+ this.destroy()
1182
+ })
1183
+ return
1184
+ }
1185
+ })
1186
+
1187
+ this.addExitListeners()
1188
+
1189
+ const stdinParserMaxBufferBytes = config.stdinParserMaxBufferBytes ?? DEFAULT_STDIN_PARSER_MAX_BUFFER_BYTES
1190
+ this.stdinParser = new StdinParser({
1191
+ timeoutMs: 20,
1192
+ maxPendingBytes: stdinParserMaxBufferBytes,
1193
+ armTimeouts: true,
1194
+ onTimeoutFlush: () => {
1195
+ this.drainStdinParser()
1196
+ },
1197
+ useKittyKeyboard: useKittyForParsing,
1198
+ protocolContext: {
1199
+ kittyKeyboardEnabled: useKittyForParsing,
1200
+ privateCapabilityRepliesActive: false,
1201
+ pixelResolutionQueryActive: false,
1202
+ explicitWidthCprActive: false,
1203
+ startupCursorCprActive: false,
1204
+ },
1205
+ clock: this.clock,
1206
+ })
1207
+
1208
+ this._console = new TerminalConsole(this, {
1209
+ ...(config.consoleOptions ?? {}),
1210
+ clock: this.clock,
1211
+ })
1212
+ this.consoleMode = config.consoleMode ?? "console-overlay"
1213
+ this.applyScreenMode(screenMode, false, false)
1214
+ rendererTracker.streamOwners.set(stdin, this)
1215
+ rendererTracker.streamOwners.set(stdout, this)
1216
+ this._streamLeaseAcquired = true
1217
+ rendererTracker.renderers.add(this)
1218
+ this.stdout.write = externalOutputMode === "capture-stdout" ? this.interceptStdoutWrite : this.realStdoutWrite
1219
+ this._openConsoleOnError = config.openConsoleOnError ?? process.env.NODE_ENV !== "production"
1220
+ this._onDestroy = config.onDestroy
1221
+
1222
+ global.requestAnimationFrame = (callback: FrameRequestCallback) => {
1223
+ const id = CliRenderer.animationFrameId++
1224
+ this.animationRequest.set(id, callback)
1225
+ this.requestLive()
1226
+ return id
1227
+ }
1228
+ global.cancelAnimationFrame = (handle: number) => {
1229
+ this.animationRequest.delete(handle)
1230
+ }
1231
+
1232
+ const window = global.window
1233
+ if (!window) {
1234
+ global.window = {} as Window & typeof globalThis
1235
+ }
1236
+ global.window.requestAnimationFrame = requestAnimationFrame
1237
+
1238
+ // Prevents output from being written to the terminal, useful for debugging
1239
+ if (env.OTUI_NO_NATIVE_RENDER) {
1240
+ this.renderNative = () => {
1241
+ if (this._splitHeight > 0) {
1242
+ this.flushStdoutCache(this._splitHeight)
1243
+ }
1244
+ return "rendered"
1245
+ }
1246
+ }
1247
+
1248
+ try {
1249
+ this.setupInput()
1250
+ } catch (error) {
1251
+ try {
1252
+ this.destroy()
1253
+ } catch (destroyError) {
1254
+ console.error("Error destroying renderer after input setup failure:", destroyError)
1255
+ }
1256
+ throw error
1257
+ }
1258
+ }
1259
+
1260
+ private addExitListeners(): void {
1261
+ if (this._exitListenersAdded || this.exitSignals.length === 0) return
1262
+
1263
+ this.exitSignals.forEach((signal) => {
1264
+ process.addListener(signal, this.exitHandler)
1265
+ })
1266
+
1267
+ this._exitListenersAdded = true
1268
+ }
1269
+
1270
+ private removeExitListeners(): void {
1271
+ if (!this._exitListenersAdded || this.exitSignals.length === 0) return
1272
+
1273
+ this.exitSignals.forEach((signal) => {
1274
+ process.removeListener(signal, this.exitHandler)
1275
+ })
1276
+
1277
+ this._exitListenersAdded = false
1278
+ }
1279
+
1280
+ public get isDestroyed(): boolean {
1281
+ return this._isDestroyed
1282
+ }
1283
+
1284
+ public registerLifecyclePass(renderable: Renderable) {
1285
+ this.lifecyclePasses.add(renderable)
1286
+ }
1287
+
1288
+ public unregisterLifecyclePass(renderable: Renderable) {
1289
+ this.lifecyclePasses.delete(renderable)
1290
+ }
1291
+
1292
+ public getLifecyclePasses() {
1293
+ return this.lifecyclePasses
1294
+ }
1295
+
1296
+ public get currentFocusedRenderable(): Renderable | null {
1297
+ return this._currentFocusedRenderable
1298
+ }
1299
+
1300
+ public get currentFocusedEditor(): EditBufferRenderable | null {
1301
+ if (!this._currentFocusedRenderable) return null
1302
+ if (!isEditBufferRenderable(this._currentFocusedRenderable)) return null
1303
+ return this._currentFocusedRenderable
1304
+ }
1305
+
1306
+ private normalizeClockTime(now: number, fallback: number): number {
1307
+ if (Number.isFinite(now)) {
1308
+ return now
1309
+ }
1310
+
1311
+ return Number.isFinite(fallback) ? fallback : 0
1312
+ }
1313
+
1314
+ private getElapsedMs(now: number, then: number): number {
1315
+ if (!Number.isFinite(now) || !Number.isFinite(then)) {
1316
+ return 0
1317
+ }
1318
+
1319
+ return Math.max(now - then, 0)
1320
+ }
1321
+
1322
+ public focusRenderable(renderable: Renderable) {
1323
+ if (this._currentFocusedRenderable === renderable) {
1324
+ return
1325
+ }
1326
+
1327
+ const previousRenderable = this._currentFocusedRenderable
1328
+ const previousEditor = this.currentFocusedEditor
1329
+
1330
+ this._currentFocusedRenderable = renderable
1331
+ previousRenderable?.blur()
1332
+
1333
+ const currentEditor = this.currentFocusedEditor
1334
+ if (previousEditor !== currentEditor) {
1335
+ this.emit(CliRenderEvents.FOCUSED_EDITOR, currentEditor, previousEditor)
1336
+ }
1337
+
1338
+ this.emit(CliRenderEvents.FOCUSED_RENDERABLE, renderable, previousRenderable)
1339
+ }
1340
+
1341
+ public blurRenderable(renderable: Renderable): void {
1342
+ if (this._currentFocusedRenderable !== renderable) {
1343
+ return
1344
+ }
1345
+
1346
+ const previousEditor = this.currentFocusedEditor
1347
+ this._currentFocusedRenderable = null
1348
+
1349
+ if (previousEditor !== null) {
1350
+ this.emit(CliRenderEvents.FOCUSED_EDITOR, null, previousEditor)
1351
+ }
1352
+
1353
+ this.emit(CliRenderEvents.FOCUSED_RENDERABLE, null, renderable)
1354
+ }
1355
+
1356
+ private setCapturedRenderable(renderable: Renderable | undefined): void {
1357
+ if (this.capturedRenderable === renderable) {
1358
+ return
1359
+ }
1360
+ this.capturedRenderable = renderable
1361
+ }
1362
+
1363
+ public addToHitGrid(x: number, y: number, width: number, height: number, id: number) {
1364
+ if (!this._useMouse) return
1365
+ if (id !== this.capturedRenderable?.num) {
1366
+ this.lib.addToHitGrid(this.rendererPtr, x, y, width, height, id)
1367
+ }
1368
+ }
1369
+
1370
+ public pushHitGridScissorRect(x: number, y: number, width: number, height: number): void {
1371
+ this.lib.hitGridPushScissorRect(this.rendererPtr, x, y, width, height)
1372
+ }
1373
+
1374
+ public popHitGridScissorRect(): void {
1375
+ this.lib.hitGridPopScissorRect(this.rendererPtr)
1376
+ }
1377
+
1378
+ public clearHitGridScissorRects(): void {
1379
+ this.lib.hitGridClearScissorRects(this.rendererPtr)
1380
+ }
1381
+
1382
+ public get widthMethod(): WidthMethod {
1383
+ const caps = this.capabilities
1384
+ return caps?.unicode === "wcwidth" ? "wcwidth" : "unicode"
1385
+ }
1386
+
1387
+ public get frameId(): number {
1388
+ return this._frameId
1389
+ }
1390
+
1391
+ private writeOut(chunk: any, encoding?: any, callback?: any): boolean {
1392
+ // Route through the native backend whenever:
1393
+ // (a) threading is on (pre-existing path — batches with the render thread), OR
1394
+ // (b) a feed is wired (custom stdout) — otherwise the else-branch would
1395
+ // write directly to the user's Writable, bypassing the feed and
1396
+ // causing TS-side ANSI to interleave with Zig-emitted frame bytes
1397
+ // on Linux where threading is forced off.
1398
+ if (this.rendererPtr && (this._useThread || this._feed !== null)) {
1399
+ const data = typeof chunk === "string" ? chunk : (chunk?.toString() ?? "")
1400
+ this.lib.writeOut(this.rendererPtr, data)
1401
+ if (typeof callback === "function") {
1402
+ process.nextTick(callback)
1403
+ }
1404
+ return true
1405
+ }
1406
+
1407
+ return this.realStdoutWrite.call(this.stdout, chunk, encoding, callback)
1408
+ }
1409
+
1410
+ private scheduleRenderAfterFeedIdle(): void {
1411
+ const feed = this._feed
1412
+ if (!feed || this.feedIdleRenderScheduled || this._isDestroyed) return
1413
+
1414
+ this.feedIdleRenderScheduled = true
1415
+ feed.idle().then(() => {
1416
+ this.feedIdleRenderScheduled = false
1417
+ const ordinaryFrameWasWaiting = this.ordinaryFrameWaitingForFeed
1418
+ const ordinaryFrameWaitControlState = this.ordinaryFrameWaitControlState
1419
+ this.ordinaryFrameWaitingForFeed = false
1420
+ this.ordinaryFrameWaitControlState = null
1421
+ if (
1422
+ this._isDestroyed ||
1423
+ (ordinaryFrameWasWaiting &&
1424
+ this._controlState !== ordinaryFrameWaitControlState &&
1425
+ (this._controlState === RendererControlState.EXPLICIT_PAUSED ||
1426
+ this._controlState === RendererControlState.EXPLICIT_STOPPED ||
1427
+ this._controlState === RendererControlState.EXPLICIT_SUSPENDED))
1428
+ ) {
1429
+ this.resolveIdleIfNeeded()
1430
+ return
1431
+ }
1432
+
1433
+ this.scheduleRenderTimer()
1434
+ this.resolveIdleIfNeeded()
1435
+ })
1436
+ }
1437
+
1438
+ private handleNativeRenderRejection(status: number): "retryable-skip" | "backpressured" | "failed" {
1439
+ if (status === NATIVE_RENDER_STATUS_SKIPPED && this._feed) {
1440
+ this.ordinaryFrameWaitingForFeed = true
1441
+ this.ordinaryFrameWaitControlState = this._controlState
1442
+ this.scheduleRenderAfterFeedIdle()
1443
+ return "retryable-skip"
1444
+ }
1445
+
1446
+ if (status === NATIVE_RENDER_STATUS_SKIPPED) {
1447
+ if (this._useThread && this._usesProcessStdout) return "backpressured"
1448
+ console.error("[CliRenderer] Native frame render unexpectedly skipped without a feed")
1449
+ return "failed"
1450
+ }
1451
+
1452
+ return this.reportNativeRenderFailure()
1453
+ }
1454
+
1455
+ private reportNativeRenderFailure(): "failed" {
1456
+ console.error("[CliRenderer] Native frame render failed; waiting for the next render request to force repaint")
1457
+ return "failed"
1458
+ }
1459
+
1460
+ private scheduleRenderTimer(): void {
1461
+ if (this.renderTimeout || this._isDestroyed || this._controlState === RendererControlState.EXPLICIT_SUSPENDED)
1462
+ return
1463
+
1464
+ const now = this.normalizeClockTime(this.clock.now(), this.lastTime)
1465
+ const elapsed = this.getElapsedMs(now, this.lastTime)
1466
+ const delay = Math.max(this.minTargetFrameTime - elapsed, 0)
1467
+ this.renderTimeout = this.clock.setTimeout(() => {
1468
+ this.renderTimeout = null
1469
+ this.loop()
1470
+ }, delay)
1471
+ }
1472
+
1473
+ private scheduleRenderAfterBackpressure(): void {
1474
+ if (this._feed) {
1475
+ this.scheduleRenderAfterFeedIdle()
1476
+ return
1477
+ }
1478
+
1479
+ this.scheduleRenderTimer()
1480
+ }
1481
+
1482
+ public requestRender() {
1483
+ if (this._controlState === RendererControlState.EXPLICIT_SUSPENDED) {
1484
+ return
1485
+ }
1486
+
1487
+ // A skipped feed-backed frame already owns the next scheduling attempt through
1488
+ // feed.idle(). Coalesce normal invalidations into that retry so split-footer
1489
+ // output and UI updates cannot start competing render passes while the feed is busy.
1490
+ if (this.feedIdleRenderScheduled) {
1491
+ return
1492
+ }
1493
+
1494
+ if (this._isRunning) {
1495
+ if (!this.rendering && !this.renderTimeout && !this.ordinaryFrameWaitingForFeed) {
1496
+ this.scheduleRenderTimer()
1497
+ }
1498
+ return
1499
+ }
1500
+
1501
+ if (this.ordinaryFrameWaitingForFeed) {
1502
+ return
1503
+ }
1504
+
1505
+ // NOTE: Using a frame callback that causes a re-render while already rendering
1506
+ // leads to a continuous loop of renders.
1507
+ if (this.rendering) {
1508
+ this.immediateRerenderRequested = true
1509
+ return
1510
+ }
1511
+
1512
+ if (!this.updateScheduled && !this.renderTimeout) {
1513
+ this.updateScheduled = true
1514
+ const now = this.normalizeClockTime(this.clock.now(), this.lastTime)
1515
+ const elapsed = this.getElapsedMs(now, this.lastTime)
1516
+ const delay = Math.max(this.minTargetFrameTime - elapsed, 0)
1517
+
1518
+ if (delay === 0) {
1519
+ process.nextTick(() => this.activateFrame())
1520
+ return
1521
+ }
1522
+
1523
+ this.clock.setTimeout(() => this.activateFrame(), delay)
1524
+ }
1525
+ }
1526
+
1527
+ private async activateFrame() {
1528
+ if (!this.updateScheduled) {
1529
+ this.resolveIdleIfNeeded()
1530
+ return
1531
+ }
1532
+
1533
+ try {
1534
+ await this.loop()
1535
+ } finally {
1536
+ this.updateScheduled = false
1537
+ this.resolveIdleIfNeeded()
1538
+ }
1539
+ }
1540
+
1541
+ public get consoleMode(): ConsoleMode {
1542
+ return this._useConsole ? "console-overlay" : "disabled"
1543
+ }
1544
+
1545
+ public set consoleMode(mode: ConsoleMode) {
1546
+ this._useConsole = mode === "console-overlay"
1547
+ if (this._useConsole) {
1548
+ this.console.activate()
1549
+ } else {
1550
+ this.console.deactivate()
1551
+ }
1552
+ }
1553
+
1554
+ public get isRunning(): boolean {
1555
+ return this._isRunning
1556
+ }
1557
+
1558
+ private isIdleNow(): boolean {
1559
+ if (this._isDestroyed) return true
1560
+
1561
+ return (
1562
+ !this._isRunning &&
1563
+ !this.rendering &&
1564
+ !this.renderTimeout &&
1565
+ !this.updateScheduled &&
1566
+ !this.feedIdleRenderScheduled &&
1567
+ !this.immediateRerenderRequested
1568
+ )
1569
+ }
1570
+
1571
+ private resolveIdleIfNeeded(): void {
1572
+ if (!this.isIdleNow()) return
1573
+ const resolvers = this.idleResolvers.splice(0)
1574
+ for (const resolve of resolvers) {
1575
+ resolve()
1576
+ }
1577
+ }
1578
+
1579
+ public idle(): Promise<void> {
1580
+ if (this._isDestroyed) return Promise.resolve()
1581
+ if (this.isIdleNow()) return Promise.resolve()
1582
+ return new Promise<void>((resolve) => {
1583
+ this.idleResolvers.push(resolve)
1584
+ })
1585
+ }
1586
+
1587
+ public getSchedulerState(): RendererSchedulerState {
1588
+ return {
1589
+ isRunning: this._isRunning,
1590
+ isRendering: this.rendering,
1591
+ hasScheduledRender: Boolean(this.renderTimeout || this.updateScheduled || this.immediateRerenderRequested),
1592
+ }
1593
+ }
1594
+
1595
+ public get resolution(): PixelResolution | null {
1596
+ return this._resolution
1597
+ }
1598
+
1599
+ public get console(): TerminalConsole {
1600
+ return this._console
1601
+ }
1602
+
1603
+ public get keyInput(): KeyHandler {
1604
+ return this._keyHandler
1605
+ }
1606
+
1607
+ public get _internalKeyInput(): InternalKeyHandler {
1608
+ return this._keyHandler
1609
+ }
1610
+
1611
+ public get terminalWidth(): number {
1612
+ return this._terminalWidth
1613
+ }
1614
+
1615
+ public get terminalHeight(): number {
1616
+ return this._terminalHeight
1617
+ }
1618
+
1619
+ public get useThread(): boolean {
1620
+ return this._useThread
1621
+ }
1622
+
1623
+ public get targetFps(): number {
1624
+ return this._targetFps
1625
+ }
1626
+
1627
+ public set targetFps(targetFps: number) {
1628
+ this._targetFps = targetFps
1629
+ this.targetFrameTime = 1000 / this._targetFps
1630
+ }
1631
+
1632
+ public get maxFps(): number {
1633
+ return this._maxFps
1634
+ }
1635
+
1636
+ public set maxFps(maxFps: number) {
1637
+ this._maxFps = maxFps
1638
+ this.minTargetFrameTime = 1000 / this._maxFps
1639
+ }
1640
+
1641
+ public get useMouse(): boolean {
1642
+ return this._useMouse
1643
+ }
1644
+
1645
+ public set useMouse(useMouse: boolean) {
1646
+ if (this._useMouse === useMouse) return // No change needed
1647
+
1648
+ this._useMouse = useMouse
1649
+
1650
+ if (useMouse) {
1651
+ this.enableMouse()
1652
+ this.requestRender()
1653
+ } else {
1654
+ this.disableMouse()
1655
+ }
1656
+ }
1657
+
1658
+ public get screenMode(): ScreenMode {
1659
+ return this._screenMode
1660
+ }
1661
+
1662
+ public set screenMode(mode: ScreenMode) {
1663
+ if (this.externalOutputMode === "capture-stdout" && mode !== "split-footer") {
1664
+ if (this.pendingExternalOutputMode === "passthrough") {
1665
+ this.flushPendingSplitOutputBeforeLeavingSplitFooter()
1666
+ }
1667
+
1668
+ if (this.externalOutputMode !== "capture-stdout") {
1669
+ this.applyScreenMode(mode)
1670
+ return
1671
+ }
1672
+
1673
+ throw new Error('externalOutputMode "capture-stdout" requires screenMode "split-footer"')
1674
+ }
1675
+
1676
+ this.applyScreenMode(mode)
1677
+ }
1678
+
1679
+ public get footerHeight(): number {
1680
+ return this._footerHeight
1681
+ }
1682
+
1683
+ public set footerHeight(footerHeight: number) {
1684
+ const normalizedFooterHeight = normalizeFooterHeight(footerHeight)
1685
+ if (normalizedFooterHeight === this._footerHeight) {
1686
+ return
1687
+ }
1688
+
1689
+ this._footerHeight = normalizedFooterHeight
1690
+ if (this.screenMode === "split-footer") {
1691
+ this.applyScreenMode("split-footer")
1692
+ }
1693
+ }
1694
+
1695
+ public get externalOutputMode(): ExternalOutputMode {
1696
+ return this._externalOutputMode
1697
+ }
1698
+
1699
+ public set externalOutputMode(mode: ExternalOutputMode) {
1700
+ if (mode === "capture-stdout" && this.screenMode !== "split-footer") {
1701
+ throw new Error('externalOutputMode "capture-stdout" requires screenMode "split-footer"')
1702
+ }
1703
+
1704
+ const previousMode = this._externalOutputMode
1705
+ if (previousMode === mode) {
1706
+ if (this.pendingExternalOutputMode !== null && this.pendingExternalOutputMode !== mode) {
1707
+ this.pendingExternalOutputMode = null
1708
+ }
1709
+ return
1710
+ }
1711
+
1712
+ const canFlushSplitOutputBeforeTransition = this.canFlushSplitOutputBeforeTransition()
1713
+ const isSplitCaptureToPassthrough =
1714
+ previousMode === "capture-stdout" &&
1715
+ mode === "passthrough" &&
1716
+ this._screenMode === "split-footer" &&
1717
+ this._splitHeight > 0
1718
+
1719
+ if (isSplitCaptureToPassthrough && this.externalOutputQueue.size > 0 && !canFlushSplitOutputBeforeTransition) {
1720
+ this.pendingExternalOutputMode = "passthrough"
1721
+ return
1722
+ }
1723
+
1724
+ if (isSplitCaptureToPassthrough && canFlushSplitOutputBeforeTransition) {
1725
+ this.flushPendingSplitOutputBeforeTransition()
1726
+ }
1727
+
1728
+ this.pendingExternalOutputMode = null
1729
+ this.applyExternalOutputMode(mode)
1730
+ this.afterExternalOutputModeChanged(previousMode, mode)
1731
+ }
1732
+
1733
+ private applyExternalOutputMode(mode: ExternalOutputMode): void {
1734
+ this._externalOutputMode = mode
1735
+ this.stdout.write = mode === "capture-stdout" ? this.interceptStdoutWrite : this.realStdoutWrite
1736
+ }
1737
+
1738
+ private afterExternalOutputModeChanged(previousMode: ExternalOutputMode, mode: ExternalOutputMode): void {
1739
+ if (this._screenMode === "split-footer" && this._splitHeight > 0 && mode === "capture-stdout") {
1740
+ const previousSurfaceTopLine = this.renderOffset + 1
1741
+ const previousSurfaceHeight = this._splitHeight
1742
+
1743
+ this.clearPendingSplitFooterTransition()
1744
+ this.resetSplitScrollback(this.getSplitCursorSeedRows())
1745
+
1746
+ if (previousMode === "passthrough" && this._terminalIsSetup) {
1747
+ const nextSurfaceTopLine = this.renderOffset + 1
1748
+ if (previousSurfaceTopLine !== nextSurfaceTopLine) {
1749
+ this.setPendingSplitFooterTransition({
1750
+ mode: "clear-stale-rows",
1751
+ sourceTopLine: previousSurfaceTopLine,
1752
+ sourceHeight: previousSurfaceHeight,
1753
+ targetTopLine: nextSurfaceTopLine,
1754
+ targetHeight: this._splitHeight,
1755
+ scrollLines: 0,
1756
+ })
1757
+ this.forceFullRepaintRequested = true
1758
+ }
1759
+ }
1760
+
1761
+ this.requestRender()
1762
+ return
1763
+ }
1764
+
1765
+ if (
1766
+ this._screenMode === "split-footer" &&
1767
+ this._splitHeight > 0 &&
1768
+ previousMode === "capture-stdout" &&
1769
+ mode === "passthrough"
1770
+ ) {
1771
+ this.clearPendingSplitFooterTransition()
1772
+ return
1773
+ }
1774
+
1775
+ this.syncSplitFooterState()
1776
+ }
1777
+
1778
+ private applyPendingExternalOutputModeIfReady(): void {
1779
+ const pendingMode = this.pendingExternalOutputMode
1780
+ if (pendingMode === null || this.externalOutputQueue.size > 0) {
1781
+ return
1782
+ }
1783
+
1784
+ const previousMode = this._externalOutputMode
1785
+ this.pendingExternalOutputMode = null
1786
+
1787
+ if (previousMode === pendingMode) {
1788
+ return
1789
+ }
1790
+
1791
+ this.applyExternalOutputMode(pendingMode)
1792
+ this.afterExternalOutputModeChanged(previousMode, pendingMode)
1793
+ }
1794
+
1795
+ private flushPendingSplitOutputBeforeLeavingSplitFooter(): void {
1796
+ if (this.pendingExternalOutputMode !== "passthrough") {
1797
+ return
1798
+ }
1799
+
1800
+ if (this.isSplitCursorSeedFrameBlocked() && this._controlState !== RendererControlState.EXPLICIT_SUSPENDED) {
1801
+ this.abortSplitStartupCursorSeed()
1802
+ }
1803
+
1804
+ this.flushPendingSplitOutputBeforeTransition()
1805
+
1806
+ if (this.pendingExternalOutputMode !== null) {
1807
+ throw new Error("Cannot leave split-footer while captured output is pending")
1808
+ }
1809
+ }
1810
+
1811
+ public get liveRequestCount(): number {
1812
+ return this.liveRequestCounter
1813
+ }
1814
+
1815
+ public get currentControlState(): string {
1816
+ return this._controlState
1817
+ }
1818
+
1819
+ public get capabilities(): TerminalCapabilities | null {
1820
+ return this._capabilities
1821
+ }
1822
+
1823
+ public triggerNotification(message: string, title?: string): boolean {
1824
+ if (this._isDestroyed) return false
1825
+ return this.lib.triggerNotification(this.rendererPtr, message, title)
1826
+ }
1827
+
1828
+ public get themeMode(): ThemeMode | null {
1829
+ return this.themeModeState.themeMode
1830
+ }
1831
+
1832
+ public waitForThemeMode(timeoutMs: number = 1000): Promise<ThemeMode | null> {
1833
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
1834
+ throw new Error("timeoutMs must be a non-negative finite number")
1835
+ }
1836
+
1837
+ return this.themeModeState.waitForThemeMode(timeoutMs, this._isDestroyed)
1838
+ }
1839
+
1840
+ public getDebugInputs(): Array<{ timestamp: string; sequence: string }> {
1841
+ return [...this._debugInputs]
1842
+ }
1843
+
1844
+ public get useKittyKeyboard(): boolean {
1845
+ return this.lib.getKittyKeyboardFlags(this.rendererPtr) > 0
1846
+ }
1847
+
1848
+ public set useKittyKeyboard(use: boolean) {
1849
+ const flags = use ? KITTY_FLAG_DISAMBIGUATE | KITTY_FLAG_ALTERNATE_KEYS : 0
1850
+ this.lib.setKittyKeyboardFlags(this.rendererPtr, flags)
1851
+ }
1852
+
1853
+ public createScrollbackSurface(options: ScrollbackSurfaceOptions = {}): ScrollbackSurface {
1854
+ if (this._screenMode !== "split-footer" || this._externalOutputMode !== "capture-stdout") {
1855
+ throw new Error(
1856
+ 'createScrollbackSurface requires screenMode "split-footer" and externalOutputMode "capture-stdout"',
1857
+ )
1858
+ }
1859
+
1860
+ const renderer = this
1861
+ const surfaceId = scrollbackSurfaceCounter++
1862
+ const startOnNewLine = options.startOnNewLine ?? true
1863
+ const tailColumn = renderer.getPendingSplitTailColumn()
1864
+ const firstLineOffset = !startOnNewLine && tailColumn > 0 && tailColumn < renderer.width ? tailColumn : 0
1865
+
1866
+ const snapshotContext = new ScrollbackSnapshotRenderContext(renderer.width, 1, renderer.widthMethod)
1867
+ let firstLineOffsetOwner: Renderable | null = null
1868
+ const renderContext = Object.create(snapshotContext) as RenderContext
1869
+ Object.defineProperty(renderContext, "claimFirstLineOffset", {
1870
+ value: (renderable?: Renderable): number => {
1871
+ if (firstLineOffsetOwner?.isDestroyed) {
1872
+ firstLineOffsetOwner = null
1873
+ }
1874
+
1875
+ if (firstLineOffsetOwner) {
1876
+ return 0
1877
+ }
1878
+
1879
+ firstLineOffsetOwner = renderable ?? null
1880
+ return firstLineOffset
1881
+ },
1882
+ enumerable: true,
1883
+ configurable: true,
1884
+ })
1885
+
1886
+ const internalRoot = new RootRenderable(renderContext)
1887
+ const publicRoot = new BoxRenderable(renderContext, {
1888
+ id: `scrollback-surface-root-${surfaceId}`,
1889
+ position: "absolute",
1890
+ left: 0,
1891
+ top: 0,
1892
+ width: renderer.width,
1893
+ height: "auto",
1894
+ border: false,
1895
+ backgroundColor: "transparent",
1896
+ shouldFill: false,
1897
+ flexDirection: "column",
1898
+ })
1899
+ internalRoot.add(publicRoot)
1900
+
1901
+ let surfaceWidth = renderer.width
1902
+ let surfaceHeight = 1
1903
+ let surfaceWidthMethod = renderer.widthMethod
1904
+ let surfaceDestroyed = false
1905
+ let hasRendered = false
1906
+ let nextCommitStartOnNewLine = startOnNewLine
1907
+ let backingBuffer = OptimizedBuffer.create(surfaceWidth, surfaceHeight, surfaceWidthMethod, {
1908
+ id: `scrollback-surface-buffer-${surfaceId}`,
1909
+ })
1910
+
1911
+ const destroyListener = (): void => {
1912
+ destroySurface()
1913
+ }
1914
+
1915
+ const assertNotDestroyed = (): void => {
1916
+ if (surfaceDestroyed) {
1917
+ throw new Error("ScrollbackSurface is destroyed")
1918
+ }
1919
+ }
1920
+
1921
+ const assertRendered = (): void => {
1922
+ if (!hasRendered) {
1923
+ throw new Error("ScrollbackSurface.commitRows requires render() before commitRows()")
1924
+ }
1925
+ }
1926
+
1927
+ const assertGeometryStillCurrent = (): void => {
1928
+ if (renderer.width !== surfaceWidth || renderer.widthMethod !== surfaceWidthMethod) {
1929
+ throw new Error("ScrollbackSurface.commitRows requires render() after renderer geometry changes")
1930
+ }
1931
+ }
1932
+
1933
+ const assertRowRange = (startRow: number, endRowExclusive: number): void => {
1934
+ if (!Number.isInteger(startRow) || !Number.isInteger(endRowExclusive)) {
1935
+ throw new Error("ScrollbackSurface.commitRows requires finite integer row bounds")
1936
+ }
1937
+
1938
+ if (startRow < 0) {
1939
+ throw new Error("ScrollbackSurface.commitRows requires startRow >= 0")
1940
+ }
1941
+
1942
+ if (endRowExclusive < startRow) {
1943
+ throw new Error("ScrollbackSurface.commitRows requires endRowExclusive >= startRow")
1944
+ }
1945
+
1946
+ if (endRowExclusive > surfaceHeight) {
1947
+ throw new Error("ScrollbackSurface.commitRows row range exceeds rendered surface height")
1948
+ }
1949
+ }
1950
+
1951
+ const collectPendingCodeRenderables = (node: Renderable): CodeRenderable[] => {
1952
+ const pending: CodeRenderable[] = []
1953
+
1954
+ if (node instanceof CodeRenderable && node.isHighlighting) {
1955
+ pending.push(node)
1956
+ }
1957
+
1958
+ for (const child of node.getChildren()) {
1959
+ pending.push(...collectPendingCodeRenderables(child))
1960
+ }
1961
+
1962
+ return pending
1963
+ }
1964
+
1965
+ const waitForPendingHighlights = async (pending: CodeRenderable[], timeoutMs: number): Promise<void> => {
1966
+ await new Promise<void>((resolve, reject) => {
1967
+ let settled = false
1968
+ const timeoutHandle = renderer.clock.setTimeout(() => {
1969
+ if (settled) {
1970
+ return
1971
+ }
1972
+
1973
+ settled = true
1974
+ reject(new Error("ScrollbackSurface.settle timed out waiting for CodeRenderable highlighting"))
1975
+ }, timeoutMs)
1976
+
1977
+ Promise.all(pending.map((renderable) => renderable.highlightingDone)).then(
1978
+ () => {
1979
+ if (settled) {
1980
+ return
1981
+ }
1982
+
1983
+ settled = true
1984
+ renderer.clock.clearTimeout(timeoutHandle)
1985
+ resolve()
1986
+ },
1987
+ (error) => {
1988
+ if (settled) {
1989
+ return
1990
+ }
1991
+
1992
+ settled = true
1993
+ renderer.clock.clearTimeout(timeoutHandle)
1994
+ reject(error)
1995
+ },
1996
+ )
1997
+ })
1998
+ }
1999
+
2000
+ const renderSurface = (): void => {
2001
+ assertNotDestroyed()
2002
+
2003
+ const width = renderer.width
2004
+ const widthMethod = renderer.widthMethod
2005
+
2006
+ snapshotContext.width = width
2007
+ snapshotContext.widthMethod = widthMethod
2008
+ publicRoot.width = width
2009
+
2010
+ const renderPass = (height: number): void => {
2011
+ snapshotContext.height = height
2012
+ internalRoot.resize(width, height)
2013
+ backingBuffer.resize(width, height)
2014
+ backingBuffer.clear(TRANSPARENT_RGBA)
2015
+ snapshotContext.frameId += 1
2016
+ internalRoot.render(backingBuffer, 0)
2017
+ }
2018
+
2019
+ let targetHeight = Math.max(1, surfaceHeight)
2020
+
2021
+ if (surfaceWidthMethod !== widthMethod) {
2022
+ backingBuffer.destroy()
2023
+ backingBuffer = OptimizedBuffer.create(width, targetHeight, widthMethod, {
2024
+ id: `scrollback-surface-buffer-${surfaceId}`,
2025
+ })
2026
+ } else {
2027
+ backingBuffer.resize(width, targetHeight)
2028
+ }
2029
+
2030
+ for (let pass = 0; pass < MAX_SCROLLBACK_SURFACE_HEIGHT_PASSES; pass += 1) {
2031
+ renderPass(targetHeight)
2032
+
2033
+ const measuredHeight = Math.max(1, publicRoot.height)
2034
+ if (measuredHeight === targetHeight) {
2035
+ surfaceWidth = width
2036
+ surfaceHeight = measuredHeight
2037
+ surfaceWidthMethod = widthMethod
2038
+ hasRendered = true
2039
+ return
2040
+ }
2041
+
2042
+ targetHeight = measuredHeight
2043
+ }
2044
+
2045
+ renderPass(targetHeight)
2046
+
2047
+ surfaceWidth = width
2048
+ surfaceHeight = targetHeight
2049
+ surfaceWidthMethod = widthMethod
2050
+ hasRendered = true
2051
+ }
2052
+
2053
+ const settleSurface = async (timeoutMs: number = 2000): Promise<void> => {
2054
+ assertNotDestroyed()
2055
+
2056
+ const startedAt = renderer.clock.now()
2057
+ renderSurface()
2058
+
2059
+ while (true) {
2060
+ assertNotDestroyed()
2061
+
2062
+ const pending = collectPendingCodeRenderables(publicRoot)
2063
+ if (pending.length === 0) {
2064
+ return
2065
+ }
2066
+
2067
+ const remainingMs = timeoutMs - (renderer.clock.now() - startedAt)
2068
+ if (remainingMs <= 0) {
2069
+ throw new Error("ScrollbackSurface.settle timed out waiting for CodeRenderable highlighting")
2070
+ }
2071
+
2072
+ await waitForPendingHighlights(pending, remainingMs)
2073
+ assertNotDestroyed()
2074
+ renderSurface()
2075
+ }
2076
+ }
2077
+
2078
+ const commitRows = (
2079
+ startRow: number,
2080
+ endRowExclusive: number,
2081
+ commitOptions: ScrollbackSurfaceCommitOptions = {},
2082
+ ): void => {
2083
+ assertNotDestroyed()
2084
+ assertRendered()
2085
+ assertGeometryStillCurrent()
2086
+ assertRowRange(startRow, endRowExclusive)
2087
+
2088
+ if (startRow === endRowExclusive) {
2089
+ return
2090
+ }
2091
+
2092
+ const rowCount = endRowExclusive - startRow
2093
+ const commitBuffer = OptimizedBuffer.create(surfaceWidth, rowCount, surfaceWidthMethod, {
2094
+ id: `scrollback-surface-commit-${surfaceId}`,
2095
+ })
2096
+
2097
+ try {
2098
+ commitBuffer.drawFrameBuffer(0, 0, backingBuffer, 0, startRow, surfaceWidth, rowCount)
2099
+
2100
+ renderer.enqueueRenderedScrollbackCommit({
2101
+ snapshot: commitBuffer,
2102
+ rowColumns: commitOptions.rowColumns,
2103
+ startOnNewLine: nextCommitStartOnNewLine,
2104
+ trailingNewline: commitOptions.trailingNewline ?? true,
2105
+ })
2106
+
2107
+ nextCommitStartOnNewLine = false
2108
+ } catch (error) {
2109
+ commitBuffer.destroy()
2110
+ throw error
2111
+ }
2112
+ }
2113
+
2114
+ const destroySurface = (): void => {
2115
+ if (surfaceDestroyed) {
2116
+ return
2117
+ }
2118
+
2119
+ surfaceDestroyed = true
2120
+ renderer.off(CliRenderEvents.DESTROY, destroyListener)
2121
+
2122
+ let destroyError: unknown = null
2123
+
2124
+ try {
2125
+ internalRoot.destroyRecursively()
2126
+ } catch (error) {
2127
+ destroyError = error
2128
+ }
2129
+
2130
+ try {
2131
+ backingBuffer.destroy()
2132
+ } catch (error) {
2133
+ if (destroyError === null) {
2134
+ destroyError = error
2135
+ }
2136
+ }
2137
+
2138
+ renderContext.removeAllListeners()
2139
+ snapshotContext.removeAllListeners()
2140
+
2141
+ if (destroyError !== null) {
2142
+ throw destroyError
2143
+ }
2144
+ }
2145
+
2146
+ renderer.on(CliRenderEvents.DESTROY, destroyListener)
2147
+
2148
+ return {
2149
+ get renderContext(): RenderContext {
2150
+ return renderContext
2151
+ },
2152
+ get root(): Renderable {
2153
+ return publicRoot
2154
+ },
2155
+ get width(): number {
2156
+ return surfaceWidth
2157
+ },
2158
+ get height(): number {
2159
+ return surfaceHeight
2160
+ },
2161
+ get isDestroyed(): boolean {
2162
+ return surfaceDestroyed
2163
+ },
2164
+ render: renderSurface,
2165
+ settle: settleSurface,
2166
+ commitRows,
2167
+ destroy: destroySurface,
2168
+ }
2169
+ }
2170
+
2171
+ // writeToScrollback is a "render to scrollback commit" API, not a direct stdout
2172
+ // write. The callback returns a renderable tree, we render that tree into an
2173
+ // off-screen OptimizedBuffer, then enqueue the result as one ExternalOutputCommit.
2174
+ //
2175
+ // Why this shape exists:
2176
+ // - It keeps app-authored scrollback output in the same FIFO queue as captured
2177
+ // stdout, so ordering is deterministic even when both sources interleave.
2178
+ // - It lets the render loop batch multiple queued commits into one native frame,
2179
+ // which is the key mechanism that avoids repeated sync/cursor toggles (flicker).
2180
+ // - It reuses the normal renderable pipeline (layout, styling, grapheme shaping),
2181
+ // so scrollback payloads match what users see in the live UI.
2182
+ //
2183
+ // startOnNewLine and trailingNewline preserve newline intent when one logical
2184
+ // write spans multiple commits. startOnNewLine adds a newline before this commit
2185
+ // if the previous commit ended mid-row. trailingNewline adds a newline after this
2186
+ // commit's final row.
2187
+ //
2188
+ // Native split append uses these flags to avoid glued rows (missing newline), and
2189
+ // double-advance gaps (extra newline), while still appending payload and repainting
2190
+ // footer in the same frame.
2191
+ //
2192
+ // Side effects: throws if split-footer capture mode is not active, transfers
2193
+ // snapshot buffer ownership to the queue on success, triggers async render,
2194
+ // and invokes snapshot teardown when cleanup runs.
2195
+ public writeToScrollback(write: ScrollbackWriter): void {
2196
+ if (this._screenMode !== "split-footer" || this._externalOutputMode !== "capture-stdout") {
2197
+ throw new Error('writeToScrollback requires screenMode "split-footer" and externalOutputMode "capture-stdout"')
2198
+ }
2199
+
2200
+ const snapshotContext = new ScrollbackSnapshotRenderContext(this.width, this.height, this.widthMethod)
2201
+ const snapshot = write({
2202
+ width: this.width,
2203
+ widthMethod: this.widthMethod,
2204
+ tailColumn: this.getPendingSplitTailColumn(),
2205
+ renderContext: snapshotContext,
2206
+ })
2207
+
2208
+ if (!snapshot || !snapshot.root) {
2209
+ throw new Error("writeToScrollback must return a snapshot root renderable")
2210
+ }
2211
+
2212
+ let renderFailed = false
2213
+ let snapshotRoot: RootRenderable | null = null
2214
+ let snapshotBuffer: OptimizedBuffer | null = null
2215
+
2216
+ try {
2217
+ const rootRenderable = snapshot.root
2218
+ const snapshotWidth = this.getSnapshotWidth(snapshot.width, rootRenderable.width)
2219
+ const snapshotHeight = this.getSnapshotHeight(snapshot.height, rootRenderable.height)
2220
+
2221
+ snapshotContext.width = snapshotWidth
2222
+ snapshotContext.height = snapshotHeight
2223
+ snapshotContext.widthMethod = this.widthMethod
2224
+
2225
+ snapshotRoot = new RootRenderable(snapshotContext)
2226
+ snapshotBuffer = OptimizedBuffer.create(snapshotWidth, snapshotHeight, this.widthMethod, {
2227
+ id: "scrollback-snapshot-commit",
2228
+ })
2229
+
2230
+ // Render through normal renderables so split scrollback output uses the same
2231
+ // text shaping/styling pipeline as the rest of the renderer.
2232
+ snapshotRoot.add(rootRenderable)
2233
+ snapshotRoot.render(snapshotBuffer, 0)
2234
+ this.enqueueRenderedScrollbackCommit({
2235
+ snapshot: snapshotBuffer,
2236
+ rowColumns: snapshot.rowColumns,
2237
+ startOnNewLine: snapshot.startOnNewLine,
2238
+ trailingNewline: snapshot.trailingNewline,
2239
+ })
2240
+ } catch (error) {
2241
+ renderFailed = true
2242
+ snapshotBuffer?.destroy()
2243
+ throw error
2244
+ } finally {
2245
+ let cleanupError: unknown | null = null
2246
+
2247
+ try {
2248
+ if (snapshotRoot) {
2249
+ snapshotRoot.destroyRecursively()
2250
+ } else {
2251
+ snapshot.root.destroyRecursively()
2252
+ }
2253
+ } catch (error) {
2254
+ cleanupError = error
2255
+ }
2256
+
2257
+ try {
2258
+ snapshot.teardown?.()
2259
+ } catch (error) {
2260
+ if (cleanupError === null) {
2261
+ cleanupError = error
2262
+ }
2263
+ }
2264
+
2265
+ if (!renderFailed && cleanupError) {
2266
+ throw cleanupError
2267
+ }
2268
+ }
2269
+ }
2270
+
2271
+ public resetSplitFooterForReplay(options: SplitFooterReplayResetOptions = {}): void {
2272
+ if (this._isDestroyed) return
2273
+ if (this._screenMode !== "split-footer" || this._externalOutputMode !== "capture-stdout") {
2274
+ throw new Error(
2275
+ 'resetSplitFooterForReplay requires screenMode "split-footer" and externalOutputMode "capture-stdout"',
2276
+ )
2277
+ }
2278
+ if (!this._terminalIsSetup || this._controlState === RendererControlState.EXPLICIT_SUSPENDED) {
2279
+ throw new Error("resetSplitFooterForReplay requires an active terminal")
2280
+ }
2281
+
2282
+ this.flushPendingSplitOutputBeforeTransition(true)
2283
+ this.externalOutputQueue.clear()
2284
+ this.abortSplitStartupCursorSeed()
2285
+ this.clearPendingSplitFooterTransition()
2286
+ this.resetSplitScrollback()
2287
+ this.currentRenderBuffer.clear(this.backgroundColor)
2288
+ this.nextRenderBuffer.clear(this.backgroundColor)
2289
+ this.forceFullRepaintRequested = true
2290
+ this.writeOut(
2291
+ ANSI.resetScrollRegion +
2292
+ ANSI.reset +
2293
+ ANSI.home +
2294
+ ANSI.clearScreen +
2295
+ (options.clearSavedLines ? ANSI.clearSavedLines : "") +
2296
+ ANSI.home,
2297
+ )
2298
+ this.requestRender()
2299
+ }
2300
+
2301
+ private getSnapshotWidth(value: number | undefined, fallback: number): number {
2302
+ const rawValue = value ?? fallback
2303
+
2304
+ if (!Number.isFinite(rawValue)) {
2305
+ throw new Error("writeToScrollback produced a non-finite width")
2306
+ }
2307
+
2308
+ return Math.min(Math.max(Math.trunc(rawValue), 1), Math.max(this.width, 1))
2309
+ }
2310
+
2311
+ private getSnapshotHeight(value: number | undefined, fallback: number): number {
2312
+ const rawValue = value ?? fallback
2313
+
2314
+ if (!Number.isFinite(rawValue)) {
2315
+ throw new Error("writeToScrollback produced a non-finite height")
2316
+ }
2317
+
2318
+ return Math.max(Math.trunc(rawValue), 1)
2319
+ }
2320
+
2321
+ private getSnapshotRowWidths(snapshot: OptimizedBuffer, rowColumns: number): number[] {
2322
+ const widths: number[] = []
2323
+ const limit = Math.min(Math.max(Math.trunc(rowColumns), 0), snapshot.width)
2324
+ const chars = snapshot.buffers.char
2325
+
2326
+ for (let y = 0; y < snapshot.height; y += 1) {
2327
+ let x = limit
2328
+
2329
+ while (x > 0) {
2330
+ const cp = chars[y * snapshot.width + x - 1]
2331
+ if (cp === 0 || (cp & CHAR_FLAG_MASK) === CHAR_FLAG_CONTINUATION) {
2332
+ x -= 1
2333
+ continue
2334
+ }
2335
+
2336
+ break
2337
+ }
2338
+
2339
+ widths.push(x)
2340
+ }
2341
+
2342
+ return widths
2343
+ }
2344
+
2345
+ private advanceSplitTailColumn(tailColumn: number, columns: number, width: number): number {
2346
+ if (columns <= 0) {
2347
+ return tailColumn
2348
+ }
2349
+
2350
+ let tail = tailColumn
2351
+ let remaining = columns
2352
+
2353
+ while (remaining > 0) {
2354
+ if (tail >= width) {
2355
+ tail = 0
2356
+ }
2357
+
2358
+ const step = Math.min(remaining, width - tail)
2359
+ tail += step
2360
+ remaining -= step
2361
+
2362
+ if (remaining > 0 && tail >= width) {
2363
+ tail = 0
2364
+ }
2365
+ }
2366
+
2367
+ return tail
2368
+ }
2369
+
2370
+ private getSplitTailColumnAfterCommit(
2371
+ commit: ExternalOutputCommit,
2372
+ initialTailColumn: number,
2373
+ width: number,
2374
+ ): number {
2375
+ let tailColumn = initialTailColumn
2376
+
2377
+ if (commit.startOnNewLine && tailColumn > 0) {
2378
+ tailColumn = 0
2379
+ }
2380
+
2381
+ const rowWidths = this.getSnapshotRowWidths(commit.snapshot, commit.rowColumns)
2382
+ for (const [index, rowWidth] of rowWidths.entries()) {
2383
+ tailColumn = this.advanceSplitTailColumn(tailColumn, rowWidth, width)
2384
+ if (index < rowWidths.length - 1 || commit.trailingNewline) {
2385
+ tailColumn = 0
2386
+ }
2387
+ }
2388
+
2389
+ return tailColumn
2390
+ }
2391
+
2392
+ private recordSplitCommit(commit: ExternalOutputCommit): void {
2393
+ this.splitTailColumn = this.getSplitTailColumnAfterCommit(commit, this.splitTailColumn, Math.max(this.width, 1))
2394
+ }
2395
+
2396
+ private getPendingSplitTailColumn(): number {
2397
+ const width = Math.max(this.width, 1)
2398
+ let tailColumn = this.splitTailColumn
2399
+
2400
+ for (const commit of this.externalOutputQueue.peek()) {
2401
+ tailColumn = this.getSplitTailColumnAfterCommit(commit, tailColumn, width)
2402
+ }
2403
+
2404
+ return tailColumn
2405
+ }
2406
+
2407
+ private enqueueRenderedScrollbackCommit(options: {
2408
+ snapshot: OptimizedBuffer
2409
+ rowColumns?: number
2410
+ startOnNewLine?: boolean
2411
+ trailingNewline?: boolean
2412
+ }): void {
2413
+ if (this._screenMode !== "split-footer" || this._externalOutputMode !== "capture-stdout") {
2414
+ throw new Error('scrollback commit requires screenMode "split-footer" and externalOutputMode "capture-stdout"')
2415
+ }
2416
+
2417
+ const rowColumns = Math.min(
2418
+ Math.max(Math.trunc(options.rowColumns ?? options.snapshot.width), 0),
2419
+ options.snapshot.width,
2420
+ )
2421
+
2422
+ this.enqueueSplitCommit({
2423
+ snapshot: options.snapshot,
2424
+ rowColumns,
2425
+ startOnNewLine: options.startOnNewLine ?? true,
2426
+ trailingNewline: options.trailingNewline ?? true,
2427
+ })
2428
+
2429
+ this.requestRender()
2430
+ }
2431
+
2432
+ private enqueueSplitCommit(commit: ExternalOutputCommit): void {
2433
+ this.externalOutputQueue.writeSnapshot(commit)
2434
+ if (this.listenerCount(CliRenderEvents.EXTERNAL_OUTPUT) > 0) {
2435
+ this.emit(CliRenderEvents.EXTERNAL_OUTPUT, commit)
2436
+ }
2437
+ }
2438
+
2439
+ private createStdoutSnapshotCommit(line: string, trailingNewline: boolean): ExternalOutputCommit {
2440
+ // Convert captured stdout into the same commit shape used by writeToScrollback.
2441
+ // One commit format keeps split append behavior consistent across both sources.
2442
+ const snapshotContext = new ScrollbackSnapshotRenderContext(this.width, 1, this.widthMethod)
2443
+ const maxWidth = Math.max(1, this.width)
2444
+ const lineCells = [...line]
2445
+ const rowColumns = Math.min(lineCells.length, maxWidth)
2446
+ const renderedLine = lineCells.slice(0, maxWidth).join("")
2447
+ const snapshotRoot = new RootRenderable(snapshotContext)
2448
+ const snapshotRenderable = new TextRenderable(snapshotContext, {
2449
+ id: "captured-stdout-snapshot",
2450
+ position: "absolute",
2451
+ left: 0,
2452
+ top: 0,
2453
+ width: Math.max(1, rowColumns),
2454
+ height: 1,
2455
+ content: renderedLine,
2456
+ })
2457
+ const snapshotBuffer = OptimizedBuffer.create(Math.max(1, rowColumns), 1, this.widthMethod, {
2458
+ id: "captured-stdout-snapshot",
2459
+ })
2460
+
2461
+ try {
2462
+ snapshotRoot.add(snapshotRenderable)
2463
+ snapshotRoot.render(snapshotBuffer, 0)
2464
+ return {
2465
+ snapshot: snapshotBuffer,
2466
+ rowColumns,
2467
+ startOnNewLine: false,
2468
+ trailingNewline,
2469
+ }
2470
+ } catch (error) {
2471
+ snapshotBuffer.destroy()
2472
+ throw error
2473
+ } finally {
2474
+ snapshotRoot.destroyRecursively()
2475
+ }
2476
+ }
2477
+
2478
+ private splitStdoutRows(text: string): Array<{ line: string; trailingNewline: boolean }> {
2479
+ // Captured stdout arrives as an arbitrary byte stream, but split append commits
2480
+ // are row-based (line text + whether that row ended with '\n'). We normalize
2481
+ // here because native split append expects already-decoded row intent, not raw
2482
+ // control characters.
2483
+ //
2484
+ // '\r' must restart the in-progress row so in-place status updates (progress
2485
+ // bars/spinners) do not accumulate stale prefixes in scrollback. '\n' commits
2486
+ // the row and marks newline intent for the final chunk of that logical row.
2487
+ const rows: Array<{ line: string; trailingNewline: boolean }> = []
2488
+ let current = ""
2489
+
2490
+ for (const char of text) {
2491
+ if (char === "\r") {
2492
+ current = ""
2493
+ continue
2494
+ }
2495
+
2496
+ if (char === "\n") {
2497
+ rows.push({ line: current, trailingNewline: true })
2498
+ current = ""
2499
+ continue
2500
+ }
2501
+
2502
+ current += char
2503
+ }
2504
+
2505
+ if (current.length > 0) {
2506
+ rows.push({ line: current, trailingNewline: false })
2507
+ }
2508
+
2509
+ return rows
2510
+ }
2511
+
2512
+ private createStdoutSnapshotCommits(text: string): ExternalOutputCommit[] {
2513
+ if (text.length === 0) {
2514
+ return []
2515
+ }
2516
+
2517
+ // Chunk captured stdout into width-bounded row commits so each commit is a
2518
+ // small, deterministic append step. This keeps bursty output smooth while
2519
+ // preserving newline ownership on the final chunk of each logical row.
2520
+ const commits: ExternalOutputCommit[] = []
2521
+ // Split commits are row-oriented snapshots. We chunk by renderer width so each
2522
+ // commit maps to a single logical terminal row append operation.
2523
+ const chunkWidth = Math.max(1, this.width)
2524
+ for (const row of this.splitStdoutRows(text)) {
2525
+ const rowCells = [...row.line]
2526
+ if (rowCells.length === 0) {
2527
+ // Preserve empty-line writes: newline-only chunks still need a commit so
2528
+ // split scrollback state advances correctly in native code.
2529
+ commits.push(this.createStdoutSnapshotCommit("", row.trailingNewline))
2530
+ continue
2531
+ }
2532
+
2533
+ let offset = 0
2534
+ while (offset < rowCells.length) {
2535
+ const chunk = rowCells.slice(offset, offset + chunkWidth).join("")
2536
+ offset += chunkWidth
2537
+ const isLastChunk = offset >= rowCells.length
2538
+ // Only the final wrapped chunk carries newline intent.
2539
+ commits.push(this.createStdoutSnapshotCommit(chunk, isLastChunk ? row.trailingNewline : false))
2540
+ }
2541
+ }
2542
+
2543
+ return commits
2544
+ }
2545
+
2546
+ private flushPendingSplitCommits(
2547
+ forceFooterRepaint: boolean = false,
2548
+ drainAll: boolean = false,
2549
+ ): "rendered" | "backpressured" | "failed" {
2550
+ // Drain only a bounded prefix so one JS render pass maps to one native frame.
2551
+ // Remaining commits are intentionally left queued and rendered on subsequent
2552
+ // ticks to avoid giant multi-thousand-cell frames that can flicker.
2553
+ const commits = this.externalOutputQueue.peek(drainAll ? Number.POSITIVE_INFINITY : this.maxSplitCommitsPerFrame)
2554
+ let hasCommittedOutput = false
2555
+ const lastCommitIndex = commits.length - 1
2556
+ let acceptedCommits = 0
2557
+ let nativeBackpressured = false
2558
+ let nativeFailed = false
2559
+
2560
+ for (const [index, commit] of commits.entries()) {
2561
+ // Force repaint only on the last commit in a frame. Repainting after every
2562
+ // chunk negates batching and reintroduces duplicate clear/move traffic.
2563
+ const forceCommit = forceFooterRepaint && index === lastCommitIndex
2564
+ // beginFrame/finalizeFrame tell native code whether this commit opens or
2565
+ // closes the shared frame envelope. Intermediate commits append payload only.
2566
+ const beginFrame = index === 0
2567
+ const finalizeFrame = index === lastCommitIndex
2568
+
2569
+ // Keep split append policy in native code so every producer (captured stdout
2570
+ // and writeToScrollback) shares the same cursor/scrollback invariants.
2571
+ const nativeResult = this.lib.commitSplitFooterSnapshot(
2572
+ this.rendererPtr,
2573
+ commit.snapshot,
2574
+ commit.rowColumns,
2575
+ commit.startOnNewLine,
2576
+ commit.trailingNewline,
2577
+ this.getSplitPinnedRenderOffset(),
2578
+ forceCommit,
2579
+ beginFrame,
2580
+ finalizeFrame,
2581
+ )
2582
+ if (nativeResult.status === NATIVE_RENDER_STATUS_SKIPPED) {
2583
+ nativeBackpressured = true
2584
+ break
2585
+ }
2586
+
2587
+ if (nativeResult.status === NATIVE_RENDER_STATUS_FAILED) {
2588
+ nativeFailed = true
2589
+ break
2590
+ }
2591
+
2592
+ this.renderOffset = nativeResult.renderOffset
2593
+ this.recordSplitCommit(commit)
2594
+ hasCommittedOutput = true
2595
+ acceptedCommits++
2596
+ }
2597
+
2598
+ if (acceptedCommits > 0) {
2599
+ this.externalOutputQueue.drop(acceptedCommits)
2600
+ }
2601
+
2602
+ if (nativeFailed) {
2603
+ return this.reportNativeRenderFailure()
2604
+ }
2605
+
2606
+ if (nativeBackpressured) {
2607
+ this.scheduleRenderAfterFeedIdle()
2608
+ return "backpressured"
2609
+ }
2610
+
2611
+ if (!hasCommittedOutput) {
2612
+ const nativeResult = this.lib.repaintSplitFooter(
2613
+ this.rendererPtr,
2614
+ this.getSplitPinnedRenderOffset(),
2615
+ forceFooterRepaint,
2616
+ )
2617
+ if (nativeResult.status === NATIVE_RENDER_STATUS_SKIPPED) {
2618
+ this.scheduleRenderAfterFeedIdle()
2619
+ return "backpressured"
2620
+ }
2621
+ if (nativeResult.status === NATIVE_RENDER_STATUS_FAILED) {
2622
+ return this.reportNativeRenderFailure()
2623
+ }
2624
+ this.renderOffset = nativeResult.renderOffset
2625
+ }
2626
+
2627
+ this.pendingSplitFooterTransition = null
2628
+
2629
+ if (this.externalOutputQueue.size > 0) {
2630
+ // Preserve FIFO ordering without doing unbounded work in one tick.
2631
+ // This keeps sustained stdout bursts smooth instead of blocking on one frame.
2632
+ this.requestRender()
2633
+ } else {
2634
+ this.applyPendingExternalOutputModeIfReady()
2635
+ }
2636
+
2637
+ return "rendered"
2638
+ }
2639
+
2640
+ private interceptStdoutWrite = (chunk: any, encoding?: any, callback?: any): boolean => {
2641
+ const resolvedCallback = typeof encoding === "function" ? encoding : callback
2642
+ const resolvedEncoding = typeof encoding === "string" ? encoding : undefined
2643
+ const text = typeof chunk === "string" ? chunk : (chunk?.toString(resolvedEncoding) ?? "")
2644
+
2645
+ if (this._externalOutputMode === "capture-stdout" && this._screenMode === "split-footer" && this._splitHeight > 0) {
2646
+ // Capture mode intentionally diverts stdout into split commit snapshots
2647
+ // instead of writing directly to process stdout. Native flushing will append
2648
+ // and repaint in one controlled frame, which is what avoids footer flicker.
2649
+ const commits = this.createStdoutSnapshotCommits(text)
2650
+ for (const commit of commits) {
2651
+ this.enqueueSplitCommit(commit)
2652
+ }
2653
+
2654
+ if (commits.length > 0) {
2655
+ // Defer actual terminal writes to the render loop so commits can be batched.
2656
+ this.requestRender()
2657
+ }
2658
+ }
2659
+
2660
+ if (typeof resolvedCallback === "function") {
2661
+ process.nextTick(resolvedCallback)
2662
+ }
2663
+
2664
+ return true
2665
+ }
2666
+
2667
+ private getSplitPinnedRenderOffset(): number {
2668
+ return this._screenMode === "split-footer" ? Math.max(this._terminalHeight - this._splitHeight, 0) : 0
2669
+ }
2670
+
2671
+ private getSplitCursorSeedRows(): number {
2672
+ const cursorState = this.lib.getCursorState(this.rendererPtr)
2673
+ const cursorRow = Number.isFinite(cursorState.y) ? Math.max(Math.trunc(cursorState.y), 1) : 1
2674
+ return Math.min(cursorRow, Math.max(this._terminalHeight, 1))
2675
+ }
2676
+
2677
+ private isSplitCursorSeedFrameBlocked(): boolean {
2678
+ return (
2679
+ this._screenMode === "split-footer" &&
2680
+ this._externalOutputMode === "capture-stdout" &&
2681
+ this._splitHeight > 0 &&
2682
+ this.pendingSplitStartupCursorSeed &&
2683
+ this.splitStartupSeedTimeoutId !== null
2684
+ )
2685
+ }
2686
+
2687
+ private canFlushSplitOutputBeforeTransition(allowSuspended: boolean = false, allowUnsetup: boolean = false): boolean {
2688
+ return (
2689
+ (this._terminalIsSetup || allowUnsetup) &&
2690
+ (allowSuspended || this._controlState !== RendererControlState.EXPLICIT_SUSPENDED) &&
2691
+ !this.isSplitCursorSeedFrameBlocked()
2692
+ )
2693
+ }
2694
+
2695
+ private clearSplitStartupCursorSeed(): void {
2696
+ this.pendingSplitStartupCursorSeed = false
2697
+ if (this.splitStartupSeedTimeoutId !== null) {
2698
+ this.clock.clearTimeout(this.splitStartupSeedTimeoutId)
2699
+ this.splitStartupSeedTimeoutId = null
2700
+ }
2701
+ }
2702
+
2703
+ private abortSplitStartupCursorSeed(): void {
2704
+ this.clearSplitStartupCursorSeed()
2705
+ this.stdinParser?.abortPendingStartupCursorCpr()
2706
+ this.updateStdinParserProtocolContext({ startupCursorCprActive: false })
2707
+ }
2708
+
2709
+ private flushPendingSplitOutputBeforeTransition(
2710
+ forceFooterRepaint: boolean = false,
2711
+ options: { allowSuspended?: boolean; allowPassthrough?: boolean; allowUnsetup?: boolean } = {},
2712
+ ): void {
2713
+ const hasDeferredCapturedOutput = this.externalOutputQueue.size > 0 || this.pendingExternalOutputMode !== null
2714
+ if (
2715
+ this._screenMode !== "split-footer" ||
2716
+ this._splitHeight <= 0 ||
2717
+ (this._externalOutputMode !== "capture-stdout" && !(options.allowPassthrough && hasDeferredCapturedOutput))
2718
+ ) {
2719
+ return
2720
+ }
2721
+
2722
+ if (!this.canFlushSplitOutputBeforeTransition(options.allowSuspended ?? false, options.allowUnsetup ?? false)) {
2723
+ return
2724
+ }
2725
+
2726
+ if (this.externalOutputQueue.size === 0 && !forceFooterRepaint) {
2727
+ this.applyPendingExternalOutputModeIfReady()
2728
+ return
2729
+ }
2730
+
2731
+ this.flushPendingSplitCommits(this._externalOutputMode === "capture-stdout" ? forceFooterRepaint : false, true)
2732
+ }
2733
+
2734
+ private resetSplitScrollback(seedRows: number = 0): void {
2735
+ this.splitTailColumn = 0
2736
+ this.renderOffset = this.lib.resetSplitScrollback(this.rendererPtr, seedRows, this.getSplitPinnedRenderOffset())
2737
+ }
2738
+
2739
+ private syncSplitScrollback(): void {
2740
+ this.renderOffset = this.lib.syncSplitScrollback(this.rendererPtr, this.getSplitPinnedRenderOffset())
2741
+ }
2742
+
2743
+ private getSplitOutputOffset(surfaceOffset: number = this.renderOffset): number {
2744
+ return this.lib.getSplitOutputOffset(this.rendererPtr, surfaceOffset)
2745
+ }
2746
+
2747
+ private clearPendingSplitFooterTransition(): void {
2748
+ if (this.pendingSplitFooterTransition === null) {
2749
+ return
2750
+ }
2751
+
2752
+ this.pendingSplitFooterTransition = null
2753
+ this.lib.clearPendingSplitFooterTransition(this.rendererPtr)
2754
+ }
2755
+
2756
+ private setPendingSplitFooterTransition(transition: PendingSplitFooterTransition): void {
2757
+ this.pendingSplitFooterTransition = transition
2758
+ this.lib.setPendingSplitFooterTransition(
2759
+ this.rendererPtr,
2760
+ transition.mode === "viewport-scroll" ? 1 : 2,
2761
+ transition.sourceTopLine,
2762
+ transition.sourceHeight,
2763
+ transition.targetTopLine,
2764
+ transition.targetHeight,
2765
+ transition.scrollLines ?? 0,
2766
+ )
2767
+ }
2768
+
2769
+ private syncSplitFooterState(): void {
2770
+ const splitActive = this._screenMode === "split-footer" && this._splitHeight > 0
2771
+
2772
+ if (!splitActive) {
2773
+ this.clearPendingSplitFooterTransition()
2774
+ this.splitTailColumn = 0
2775
+ this.lib.resetSplitScrollback(this.rendererPtr, 0, 0)
2776
+ this.renderOffset = 0
2777
+ this.lib.setRenderOffset(this.rendererPtr, this.renderOffset)
2778
+ return
2779
+ }
2780
+
2781
+ if (this._externalOutputMode === "capture-stdout") {
2782
+ this.syncSplitScrollback()
2783
+ } else {
2784
+ this.clearPendingSplitFooterTransition()
2785
+ this.splitTailColumn = 0
2786
+ this.lib.resetSplitScrollback(this.rendererPtr, 0, 0)
2787
+ this.renderOffset = this.getSplitPinnedRenderOffset()
2788
+ this.lib.setRenderOffset(this.rendererPtr, this.renderOffset)
2789
+ }
2790
+ }
2791
+
2792
+ private clearStaleSplitSurfaceRows(
2793
+ previousTopLine: number,
2794
+ previousHeight: number,
2795
+ nextTopLine: number,
2796
+ nextHeight: number,
2797
+ ): void {
2798
+ if (!this._terminalIsSetup || previousHeight <= 0 || this._terminalHeight <= 0) {
2799
+ return
2800
+ }
2801
+
2802
+ const terminalBottom = this._terminalHeight
2803
+ const previousStart = Math.max(1, previousTopLine)
2804
+ const previousEnd = Math.min(terminalBottom, previousTopLine + previousHeight - 1)
2805
+
2806
+ if (previousEnd < previousStart) {
2807
+ return
2808
+ }
2809
+
2810
+ const nextStart = Math.max(1, nextTopLine)
2811
+ const nextEnd = Math.min(terminalBottom, nextTopLine + Math.max(nextHeight, 0) - 1)
2812
+
2813
+ let clear = ""
2814
+ for (let line = previousStart; line <= previousEnd; line += 1) {
2815
+ if (line >= nextStart && line <= nextEnd) {
2816
+ continue
2817
+ }
2818
+
2819
+ clear += `${ANSI.moveCursor(line, 1)}\x1b[2K`
2820
+ }
2821
+
2822
+ if (clear.length > 0) {
2823
+ this.writeOut(clear)
2824
+ }
2825
+ }
2826
+
2827
+ private applyScreenMode(screenMode: ScreenMode, emitResize: boolean = true, requestRender: boolean = true): void {
2828
+ const prevScreenMode = this._screenMode
2829
+ const prevSplitHeight = this._splitHeight
2830
+ const nextGeometry = calculateRenderGeometry(
2831
+ screenMode,
2832
+ this._terminalWidth,
2833
+ this._terminalHeight,
2834
+ this._footerHeight,
2835
+ )
2836
+ const nextSplitHeight = nextGeometry.effectiveFooterHeight
2837
+
2838
+ if (prevScreenMode === screenMode && prevSplitHeight === nextSplitHeight) {
2839
+ return
2840
+ }
2841
+
2842
+ const terminalWritable = this._terminalIsSetup && this._controlState !== RendererControlState.EXPLICIT_SUSPENDED
2843
+ const prevUseAlternateScreen = prevScreenMode === "alternate-screen"
2844
+ const nextUseAlternateScreen = screenMode === "alternate-screen"
2845
+ const terminalScreenModeChanged = this._terminalIsSetup && prevUseAlternateScreen !== nextUseAlternateScreen
2846
+ const leavingSplitFooter = prevSplitHeight > 0 && nextSplitHeight === 0
2847
+
2848
+ if (terminalWritable && prevSplitHeight > 0) {
2849
+ this.flushPendingSplitOutputBeforeTransition()
2850
+ }
2851
+
2852
+ const previousSurfaceTopLine = this.renderOffset + 1
2853
+ const shouldDeferSplitFooterResizeTransition =
2854
+ this._terminalIsSetup &&
2855
+ prevScreenMode === "split-footer" &&
2856
+ screenMode === "split-footer" &&
2857
+ this._externalOutputMode === "capture-stdout" &&
2858
+ prevSplitHeight > 0 &&
2859
+ nextSplitHeight > 0 &&
2860
+ !terminalScreenModeChanged
2861
+ const splitStartupSeedBlocksFirstNativeFrame =
2862
+ this.pendingSplitStartupCursorSeed && this.splitStartupSeedTimeoutId !== null
2863
+ const splitTransitionSourceTopLine = this.pendingSplitFooterTransition?.sourceTopLine ?? previousSurfaceTopLine
2864
+ const splitTransitionSourceHeight = this.pendingSplitFooterTransition?.sourceHeight ?? prevSplitHeight
2865
+ const splitTransitionSourceSurfaceOffset = Math.max(splitTransitionSourceTopLine - 1, 0)
2866
+ const splitTransitionSourceOutputOffset =
2867
+ prevScreenMode === "split-footer" && prevSplitHeight > 0 && this._externalOutputMode === "capture-stdout"
2868
+ ? this.getSplitOutputOffset(splitTransitionSourceSurfaceOffset)
2869
+ : splitTransitionSourceSurfaceOffset
2870
+ const nextPinnedRenderOffset = nextGeometry.renderOffset
2871
+ const nextSplitOutputOffset =
2872
+ screenMode === "split-footer" && nextSplitHeight > 0 && this._externalOutputMode === "capture-stdout"
2873
+ ? this.getSplitOutputOffset(nextPinnedRenderOffset)
2874
+ : nextPinnedRenderOffset
2875
+ const pendingSplitFooterTransition = this.pendingSplitFooterTransition
2876
+ const pendingSplitFooterReturn =
2877
+ pendingSplitFooterTransition !== null && nextSplitHeight === splitTransitionSourceHeight
2878
+ const pendingSplitFooterViewportReturn =
2879
+ pendingSplitFooterReturn &&
2880
+ pendingSplitFooterTransition.mode === "viewport-scroll" &&
2881
+ (pendingSplitFooterTransition.scrollLines ?? 0) > 0
2882
+ const shrinkingSplitFooter = nextSplitHeight > 0 && nextSplitHeight < splitTransitionSourceHeight
2883
+ const growingSplitFooter = nextSplitHeight > splitTransitionSourceHeight && splitTransitionSourceHeight > 0
2884
+ const nextSplitSurfaceOffset =
2885
+ screenMode !== "split-footer" || nextSplitHeight === 0
2886
+ ? 0
2887
+ : pendingSplitFooterViewportReturn
2888
+ ? pendingSplitFooterTransition.targetTopLine - 1
2889
+ : pendingSplitFooterReturn
2890
+ ? splitTransitionSourceSurfaceOffset
2891
+ : shrinkingSplitFooter && splitTransitionSourceSurfaceOffset > 0
2892
+ ? splitTransitionSourceSurfaceOffset
2893
+ : shrinkingSplitFooter
2894
+ ? nextSplitOutputOffset
2895
+ : growingSplitFooter
2896
+ ? Math.max(
2897
+ nextSplitOutputOffset,
2898
+ Math.min(splitTransitionSourceSurfaceOffset, nextPinnedRenderOffset),
2899
+ )
2900
+ : nextPinnedRenderOffset
2901
+ const splitTransitionTargetTopLine = nextSplitSurfaceOffset + 1
2902
+ const splitViewportScrollLines = pendingSplitFooterViewportReturn
2903
+ ? (pendingSplitFooterTransition.scrollLines ?? 0)
2904
+ : nextSplitHeight > 0 && !pendingSplitFooterReturn
2905
+ ? Math.max(splitTransitionSourceOutputOffset - nextSplitOutputOffset, 0)
2906
+ : 0
2907
+ const splitTransitionMode =
2908
+ (!shrinkingSplitFooter || pendingSplitFooterViewportReturn) && splitViewportScrollLines > 0
2909
+ ? "viewport-scroll"
2910
+ : "clear-stale-rows"
2911
+ const splitFooterSurfaceMovesDown = nextSplitSurfaceOffset > splitTransitionSourceSurfaceOffset
2912
+ const splitFooterSurfaceLeavesStaleRows = splitFooterSurfaceMovesDown || shrinkingSplitFooter
2913
+ const shouldClearSplitSurfaceRowsImmediately =
2914
+ terminalWritable &&
2915
+ !terminalScreenModeChanged &&
2916
+ !shouldDeferSplitFooterResizeTransition &&
2917
+ splitFooterSurfaceLeavesStaleRows &&
2918
+ nextSplitHeight > 0
2919
+
2920
+ if (terminalWritable && leavingSplitFooter) {
2921
+ this.clearPendingSplitFooterTransition()
2922
+ this.renderOffset = 0
2923
+ this.lib.setRenderOffset(this.rendererPtr, 0)
2924
+ }
2925
+
2926
+ if (terminalWritable && !terminalScreenModeChanged && !shouldDeferSplitFooterResizeTransition) {
2927
+ if (prevSplitHeight === 0 && nextSplitHeight > 0) {
2928
+ const freedLines = this._terminalHeight - nextSplitHeight
2929
+ const scrollDown = ANSI.scrollDown(freedLines)
2930
+ this.writeOut(scrollDown)
2931
+ } else if (splitViewportScrollLines > 0) {
2932
+ const additionalLines = splitViewportScrollLines
2933
+ const scrollUp = ANSI.scrollUp(additionalLines)
2934
+ this.writeOut(scrollUp)
2935
+ }
2936
+ }
2937
+
2938
+ this._screenMode = screenMode
2939
+ this._splitHeight = nextSplitHeight
2940
+ this.width = nextGeometry.renderWidth
2941
+ this.height = nextGeometry.renderHeight
2942
+
2943
+ this.lib.resizeRenderer(this.rendererPtr, this.width, this.height)
2944
+
2945
+ if (this._screenMode === "split-footer" && this._externalOutputMode === "capture-stdout") {
2946
+ if (prevScreenMode !== "split-footer") {
2947
+ this.resetSplitScrollback(this.getSplitCursorSeedRows())
2948
+ } else {
2949
+ this.renderOffset = nextSplitSurfaceOffset
2950
+ this.lib.setRenderOffset(this.rendererPtr, this.renderOffset)
2951
+ }
2952
+
2953
+ if (shouldDeferSplitFooterResizeTransition) {
2954
+ if (splitStartupSeedBlocksFirstNativeFrame) {
2955
+ this.clearPendingSplitFooterTransition()
2956
+ } else {
2957
+ this.setPendingSplitFooterTransition({
2958
+ mode: splitTransitionMode,
2959
+ sourceTopLine: splitTransitionSourceTopLine,
2960
+ sourceHeight: splitTransitionSourceHeight,
2961
+ targetTopLine: splitTransitionTargetTopLine,
2962
+ targetHeight: nextSplitHeight,
2963
+ scrollLines: splitViewportScrollLines,
2964
+ })
2965
+ }
2966
+ this.forceFullRepaintRequested = true
2967
+ } else if (shouldClearSplitSurfaceRowsImmediately) {
2968
+ this.clearPendingSplitFooterTransition()
2969
+ this.clearStaleSplitSurfaceRows(
2970
+ splitTransitionSourceTopLine,
2971
+ splitTransitionSourceHeight,
2972
+ splitTransitionTargetTopLine,
2973
+ nextSplitHeight,
2974
+ )
2975
+ } else {
2976
+ this.clearPendingSplitFooterTransition()
2977
+ }
2978
+ } else {
2979
+ this.syncSplitFooterState()
2980
+ if (shouldClearSplitSurfaceRowsImmediately) {
2981
+ this.clearStaleSplitSurfaceRows(
2982
+ splitTransitionSourceTopLine,
2983
+ splitTransitionSourceHeight,
2984
+ this.renderOffset + 1,
2985
+ nextSplitHeight,
2986
+ )
2987
+ }
2988
+ }
2989
+
2990
+ this.nextRenderBuffer = this.lib.getNextBuffer(this.rendererPtr)
2991
+ this.currentRenderBuffer = this.lib.getCurrentBuffer(this.rendererPtr)
2992
+
2993
+ this._console.resize(this.width, this.height)
2994
+ this.root.resize(this.width, this.height)
2995
+
2996
+ if (terminalScreenModeChanged) {
2997
+ if (terminalWritable) {
2998
+ this.lib.suspendRenderer(this.rendererPtr)
2999
+ this.lib.setupTerminal(this.rendererPtr, nextUseAlternateScreen)
3000
+ this.pendingSuspendedTerminalSetup = false
3001
+ } else {
3002
+ this.pendingSuspendedTerminalSetup = true
3003
+ }
3004
+
3005
+ if (terminalWritable && this._useMouse) {
3006
+ this.enableMouse()
3007
+ }
3008
+ }
3009
+
3010
+ if (emitResize) {
3011
+ this.emit(CliRenderEvents.RESIZE, this.width, this.height)
3012
+ }
3013
+
3014
+ if (requestRender) {
3015
+ this.requestRender()
3016
+ }
3017
+ }
3018
+
3019
+ // TODO: Move this to native
3020
+ private flushStdoutCache(space: number, force: boolean = false): boolean {
3021
+ if (this.externalOutputQueue.size === 0 && !force) return false
3022
+
3023
+ const outputCommits = this.externalOutputQueue.claim()
3024
+ let output = ""
3025
+ for (const commit of outputCommits) {
3026
+ output += `[snapshot ${commit.snapshot.width}x${commit.snapshot.height}]\n`
3027
+ commit.snapshot.destroy()
3028
+ }
3029
+
3030
+ const rendererStartLine = this.renderOffset + 1
3031
+ const flush = ANSI.moveCursorAndClear(rendererStartLine, 1)
3032
+
3033
+ const outputLine = this.renderOffset + 1
3034
+ const move = ANSI.moveCursor(outputLine, 1)
3035
+
3036
+ let clear = ""
3037
+ if (space > 0) {
3038
+ const backgroundColor = this.backgroundColor.toInts()
3039
+ const newlines = " ".repeat(this.width) + "\n".repeat(space)
3040
+ // Check if background is transparent (alpha = 0)
3041
+ if (backgroundColor[3] === 0) {
3042
+ clear = newlines
3043
+ } else {
3044
+ clear =
3045
+ ANSI.setRgbBackground(backgroundColor[0], backgroundColor[1], backgroundColor[2]) +
3046
+ newlines +
3047
+ ANSI.resetBackground
3048
+ }
3049
+ }
3050
+
3051
+ this.writeOut(flush + move + output + clear)
3052
+
3053
+ return true
3054
+ }
3055
+
3056
+ private enableMouse(): void {
3057
+ this._useMouse = true
3058
+ this.lib.enableMouse(this.rendererPtr, this.enableMouseMovement)
3059
+ }
3060
+
3061
+ private disableMouse(): void {
3062
+ this._useMouse = false
3063
+ this.setCapturedRenderable(undefined)
3064
+ this.stdinParser?.resetMouseState()
3065
+ this.lib.disableMouse(this.rendererPtr)
3066
+ }
3067
+
3068
+ public enableKittyKeyboard(flags: number = 0b00011): void {
3069
+ this.lib.enableKittyKeyboard(this.rendererPtr, flags)
3070
+ this.updateStdinParserProtocolContext({ kittyKeyboardEnabled: true })
3071
+ }
3072
+
3073
+ public disableKittyKeyboard(): void {
3074
+ this.lib.disableKittyKeyboard(this.rendererPtr)
3075
+ this.updateStdinParserProtocolContext({ kittyKeyboardEnabled: false }, true)
3076
+ }
3077
+
3078
+ public set useThread(useThread: boolean) {
3079
+ this._useThread = useThread
3080
+ this.lib.setUseThread(this.rendererPtr, useThread)
3081
+ }
3082
+
3083
+ // TODO: All input management may move to native when zig finally has async io support again,
3084
+ // without rolling a full event loop
3085
+ public async setupTerminal(): Promise<void> {
3086
+ if (this._terminalIsSetup) return
3087
+ this._terminalIsSetup = true
3088
+
3089
+ const startupCursorCprActive = this._screenMode === "split-footer" && this._externalOutputMode === "capture-stdout"
3090
+ this.updateStdinParserProtocolContext({
3091
+ privateCapabilityRepliesActive: true,
3092
+ explicitWidthCprActive: true,
3093
+ startupCursorCprActive,
3094
+ })
3095
+ this.lib.setupTerminal(this.rendererPtr, this._screenMode === "alternate-screen")
3096
+ this._capabilities = this.lib.getTerminalCapabilities(this.rendererPtr)
3097
+
3098
+ if (this.debugOverlay.enabled) {
3099
+ this.lib.setDebugOverlay(this.rendererPtr, true, this.debugOverlay.corner)
3100
+ if (!this.memorySnapshotInterval) {
3101
+ this.memorySnapshotInterval = 3000
3102
+ this.startMemorySnapshotTimer()
3103
+ this.automaticMemorySnapshot = true
3104
+ }
3105
+ }
3106
+
3107
+ this.capabilityTimeoutId = this.clock.setTimeout(() => {
3108
+ this.capabilityTimeoutId = null
3109
+ this.clearSplitStartupCursorSeed()
3110
+
3111
+ if (this._screenMode === "split-footer" && this._externalOutputMode === "capture-stdout") {
3112
+ this.requestRender()
3113
+ }
3114
+
3115
+ this.removeInputHandler(this.capabilityHandler)
3116
+ this.updateStdinParserProtocolContext(
3117
+ {
3118
+ privateCapabilityRepliesActive: false,
3119
+ explicitWidthCprActive: false,
3120
+ startupCursorCprActive: false,
3121
+ },
3122
+ true,
3123
+ )
3124
+ this.resolveXtVersionWaiters()
3125
+ }, 5000)
3126
+
3127
+ if (this._useMouse) {
3128
+ this.enableMouse()
3129
+ }
3130
+
3131
+ if (this._screenMode === "split-footer" && this._externalOutputMode === "capture-stdout") {
3132
+ this.pendingSplitStartupCursorSeed = true
3133
+
3134
+ if (this.splitStartupSeedTimeoutId !== null) {
3135
+ this.clock.clearTimeout(this.splitStartupSeedTimeoutId)
3136
+ }
3137
+
3138
+ this.splitStartupSeedTimeoutId = this.clock.setTimeout(() => {
3139
+ this.splitStartupSeedTimeoutId = null
3140
+
3141
+ if (!this.pendingSplitStartupCursorSeed) {
3142
+ return
3143
+ }
3144
+
3145
+ this.updateStdinParserProtocolContext({ startupCursorCprActive: false })
3146
+
3147
+ if (this._screenMode === "split-footer" && this._externalOutputMode === "capture-stdout") {
3148
+ this.requestRender()
3149
+ }
3150
+
3151
+ this.flushPendingSplitOutputBeforeTransition(false, { allowPassthrough: true })
3152
+ }, 120)
3153
+ }
3154
+
3155
+ this.queryPixelResolution()
3156
+ if (this.shouldSyncNativePaletteState()) {
3157
+ this.refreshPalette()
3158
+ }
3159
+
3160
+ // Feed-backed startup writes are async relative to the JS Writable. Wait
3161
+ // until they drain so callers can safely destroy immediately after
3162
+ // createCliRenderer() resolves.
3163
+ if (this._feed?.isBackpressured()) {
3164
+ await this._feed.idle()
3165
+ }
3166
+ }
3167
+
3168
+ private stdinListener: (chunk: Buffer | string) => void = ((chunk: Buffer | string) => {
3169
+ const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
3170
+ if (!this.stdinParser) return
3171
+
3172
+ try {
3173
+ this.stdinParser.push(data)
3174
+ this.drainStdinParser()
3175
+ } catch (error) {
3176
+ this.handleStdinParserFailure(error)
3177
+ }
3178
+ }).bind(this)
3179
+
3180
+ public addInputHandler(handler: (sequence: string) => boolean): void {
3181
+ this.sequenceHandlers.push(handler)
3182
+ }
3183
+
3184
+ public prependInputHandler(handler: (sequence: string) => boolean): void {
3185
+ this.sequenceHandlers.unshift(handler)
3186
+ }
3187
+
3188
+ public removeInputHandler(handler: (sequence: string) => boolean): void {
3189
+ this.sequenceHandlers = this.sequenceHandlers.filter((candidate) => candidate !== handler)
3190
+ }
3191
+
3192
+ private updateStdinParserProtocolContext(patch: Partial<StdinParserProtocolContext>, drain = false): void {
3193
+ if (!this.stdinParser) return
3194
+ this.stdinParser.updateProtocolContext(patch)
3195
+ if (drain) this.drainStdinParser()
3196
+ }
3197
+
3198
+ public subscribeOsc(handler: (sequence: string) => void): () => void {
3199
+ this.oscSubscribers.add(handler)
3200
+ return () => {
3201
+ this.oscSubscribers.delete(handler)
3202
+ }
3203
+ }
3204
+
3205
+ private processCapabilitySequence(sequence: string, hasCursorReport: boolean): boolean {
3206
+ const hasStandardCapabilitySignature = isCapabilityResponse(sequence)
3207
+ const shouldProcessAsCapability =
3208
+ hasStandardCapabilitySignature || (hasCursorReport && this.capabilityTimeoutId !== null)
3209
+
3210
+ if (!shouldProcessAsCapability) {
3211
+ return false
3212
+ }
3213
+
3214
+ this.lib.processCapabilityResponse(this.rendererPtr, sequence)
3215
+ this._capabilities = this.lib.getTerminalCapabilities(this.rendererPtr)
3216
+ if (this._capabilities?.terminal?.from_xtversion) {
3217
+ this.resolveXtVersionWaiters()
3218
+ }
3219
+ if (hasStandardCapabilitySignature) {
3220
+ this.forceFullRepaintRequested = true
3221
+ this.requestRender()
3222
+ }
3223
+ this.emit(CliRenderEvents.CAPABILITIES, this._capabilities)
3224
+
3225
+ const hadPendingSplitStartupCursorSeed = this.pendingSplitStartupCursorSeed
3226
+
3227
+ if (
3228
+ hadPendingSplitStartupCursorSeed &&
3229
+ hasCursorReport &&
3230
+ this._screenMode === "split-footer" &&
3231
+ this._externalOutputMode === "capture-stdout"
3232
+ ) {
3233
+ this.resetSplitScrollback(this.getSplitCursorSeedRows())
3234
+ this.clearPendingSplitFooterTransition()
3235
+ this.clearSplitStartupCursorSeed()
3236
+ this.updateStdinParserProtocolContext({ startupCursorCprActive: false })
3237
+
3238
+ this.requestRender()
3239
+ if (this.pendingExternalOutputMode === "passthrough") {
3240
+ this.flushPendingSplitOutputBeforeTransition()
3241
+ }
3242
+ }
3243
+
3244
+ const consumeStartupCursorReport =
3245
+ hadPendingSplitStartupCursorSeed && hasCursorReport && this.splitStartupSeedTimeoutId !== null
3246
+
3247
+ return hasStandardCapabilitySignature || consumeStartupCursorReport
3248
+ }
3249
+
3250
+ private capabilityHandler: (sequence: string) => boolean = ((sequence: string) => {
3251
+ return this.processCapabilitySequence(sequence, false)
3252
+ }).bind(this)
3253
+
3254
+ private focusHandler: (sequence: string) => boolean = ((sequence: string) => {
3255
+ if (sequence === "\x1b[I") {
3256
+ // When the terminal regains focus, some terminal emulators (notably
3257
+ // Windows Terminal / ConPTY) may have stripped DEC private modes like
3258
+ // mouse tracking, bracketed paste, and focus tracking itself while the
3259
+ // window was unfocused.
3260
+ if (this.shouldRestoreModesOnNextFocus) {
3261
+ this.lib.restoreTerminalModes(this.rendererPtr)
3262
+ this.shouldRestoreModesOnNextFocus = false
3263
+ }
3264
+ if (this._terminalFocusState !== true) {
3265
+ this._terminalFocusState = true
3266
+ this.emit(CliRenderEvents.FOCUS)
3267
+ }
3268
+ return true
3269
+ }
3270
+ if (sequence === "\x1b[O") {
3271
+ this.shouldRestoreModesOnNextFocus = true
3272
+ if (this._terminalFocusState !== false) {
3273
+ this._terminalFocusState = false
3274
+ this.emit(CliRenderEvents.BLUR)
3275
+ }
3276
+ return true
3277
+ }
3278
+ return false
3279
+ }).bind(this)
3280
+
3281
+ private dispatchSequenceHandlers(sequence: string): boolean {
3282
+ if (this._debugModeEnabled) {
3283
+ this._debugInputs.push({
3284
+ timestamp: new Date().toISOString(),
3285
+ sequence,
3286
+ })
3287
+ }
3288
+
3289
+ for (const handler of this.sequenceHandlers) {
3290
+ if (handler(sequence)) {
3291
+ return true
3292
+ }
3293
+ }
3294
+
3295
+ return false
3296
+ }
3297
+
3298
+ private drainStdinParser(): void {
3299
+ if (!this.stdinParser) return
3300
+
3301
+ this.stdinParser.drain((event) => {
3302
+ this.handleStdinEvent(event)
3303
+ })
3304
+ }
3305
+
3306
+ private handleStdinEvent(event: StdinEvent): void {
3307
+ switch (event.type) {
3308
+ case "key":
3309
+ if (this.dispatchSequenceHandlers(event.raw)) {
3310
+ return
3311
+ }
3312
+
3313
+ this._keyHandler.processParsedKey(event.key)
3314
+ return
3315
+ case "mouse":
3316
+ if (this._useMouse && this.processSingleMouseEvent(event.event)) {
3317
+ return
3318
+ }
3319
+
3320
+ this.dispatchSequenceHandlers(event.raw)
3321
+ return
3322
+ case "paste":
3323
+ this._keyHandler.processPaste(event.bytes, event.metadata)
3324
+ return
3325
+ case "response":
3326
+ if (event.protocol === "osc") {
3327
+ for (const subscriber of this.oscSubscribers) {
3328
+ subscriber(event.sequence)
3329
+ }
3330
+ }
3331
+
3332
+ if (event.protocol === "cpr" && this.processCapabilitySequence(event.sequence, true)) {
3333
+ return
3334
+ }
3335
+
3336
+ this.dispatchSequenceHandlers(event.sequence)
3337
+ return
3338
+ }
3339
+ }
3340
+
3341
+ private handleStdinParserFailure(error: unknown): void {
3342
+ if (!this.hasLoggedStdinParserError) {
3343
+ this.hasLoggedStdinParserError = true
3344
+ if (process.env.NODE_ENV !== "test") {
3345
+ console.error("[stdin-parser-error] parser failure, resetting parser", error)
3346
+ }
3347
+ }
3348
+
3349
+ try {
3350
+ this.stdinParser?.reset()
3351
+ } catch (resetError) {
3352
+ console.error("stdin parser reset failed after parser error", resetError)
3353
+ }
3354
+ }
3355
+
3356
+ private setupInput(): void {
3357
+ for (const handler of this.prependedInputHandlers) {
3358
+ this.addInputHandler(handler)
3359
+ }
3360
+
3361
+ this.addInputHandler((sequence: string) => {
3362
+ if (isPixelResolutionResponse(sequence) && this.waitingForPixelResolution) {
3363
+ const resolution = parsePixelResolution(sequence)
3364
+ if (resolution) {
3365
+ this._resolution = resolution
3366
+ }
3367
+ this.waitingForPixelResolution = false
3368
+ this.updateStdinParserProtocolContext({ pixelResolutionQueryActive: false }, true)
3369
+ return true
3370
+ }
3371
+ return false
3372
+ })
3373
+ this.addInputHandler(this.capabilityHandler)
3374
+ this.addInputHandler(this.focusHandler)
3375
+ this.addInputHandler(this.themeModeHandler)
3376
+
3377
+ if (this.stdin.setRawMode) {
3378
+ this.stdin.setRawMode(true)
3379
+ }
3380
+
3381
+ this.stdin.on("data", this.stdinListener)
3382
+ this.stdin.resume()
3383
+ }
3384
+
3385
+ private dispatchMouseEvent(
3386
+ target: Renderable,
3387
+ attributes: RawMouseEvent & { source?: Renderable; isDragging?: boolean },
3388
+ ): MouseEvent {
3389
+ const event = new MouseEvent(target, attributes)
3390
+ target.processMouseEvent(event)
3391
+
3392
+ if (this.autoFocus && event.type === "down" && event.button === MouseButton.LEFT && !event.defaultPrevented) {
3393
+ let current: Renderable | null = target
3394
+ while (current) {
3395
+ if (current.focusable) {
3396
+ current.focus()
3397
+ break
3398
+ }
3399
+ current = current.parent
3400
+ }
3401
+ }
3402
+
3403
+ return event
3404
+ }
3405
+
3406
+ private processSingleMouseEvent(mouseEvent: RawMouseEvent): boolean {
3407
+ if (this._splitHeight > 0) {
3408
+ if (mouseEvent.y < this.renderOffset) {
3409
+ return false
3410
+ }
3411
+ mouseEvent.y -= this.renderOffset
3412
+ }
3413
+
3414
+ this._latestPointer.x = mouseEvent.x
3415
+ this._latestPointer.y = mouseEvent.y
3416
+ this._hasPointer = true
3417
+ this._lastPointerModifiers = mouseEvent.modifiers
3418
+
3419
+ if (this._console.visible) {
3420
+ const consoleBounds = this._console.bounds
3421
+ if (
3422
+ mouseEvent.x >= consoleBounds.x &&
3423
+ mouseEvent.x < consoleBounds.x + consoleBounds.width &&
3424
+ mouseEvent.y >= consoleBounds.y &&
3425
+ mouseEvent.y < consoleBounds.y + consoleBounds.height
3426
+ ) {
3427
+ const event = new MouseEvent(null, mouseEvent)
3428
+ const handled = this._console.handleMouse(event)
3429
+ if (handled) return true
3430
+ }
3431
+ }
3432
+
3433
+ if (mouseEvent.type === "scroll") {
3434
+ const maybeRenderableId = this.hitTest(mouseEvent.x, mouseEvent.y)
3435
+ const maybeRenderable = Renderable.renderablesByNumber.get(maybeRenderableId)
3436
+ const fallbackTarget =
3437
+ this._currentFocusedRenderable &&
3438
+ !this._currentFocusedRenderable.isDestroyed &&
3439
+ this._currentFocusedRenderable.focused
3440
+ ? this._currentFocusedRenderable
3441
+ : null
3442
+ const scrollTarget = maybeRenderable ?? fallbackTarget
3443
+
3444
+ if (scrollTarget) {
3445
+ const event = new MouseEvent(scrollTarget, mouseEvent)
3446
+ scrollTarget.processMouseEvent(event)
3447
+ }
3448
+ return true
3449
+ }
3450
+
3451
+ const maybeRenderableId = this.hitTest(mouseEvent.x, mouseEvent.y)
3452
+ const sameElement = maybeRenderableId === this.lastOverRenderableNum
3453
+ this.lastOverRenderableNum = maybeRenderableId
3454
+ const maybeRenderable = Renderable.renderablesByNumber.get(maybeRenderableId)
3455
+
3456
+ if (
3457
+ mouseEvent.type === "down" &&
3458
+ mouseEvent.button === MouseButton.LEFT &&
3459
+ !this.currentSelection?.isDragging &&
3460
+ !mouseEvent.modifiers.ctrl
3461
+ ) {
3462
+ const canStartSelection = Boolean(
3463
+ maybeRenderable &&
3464
+ maybeRenderable.selectable &&
3465
+ !maybeRenderable.isDestroyed &&
3466
+ maybeRenderable.shouldStartSelection(mouseEvent.x, mouseEvent.y),
3467
+ )
3468
+
3469
+ if (canStartSelection && maybeRenderable) {
3470
+ this.startSelection(maybeRenderable, mouseEvent.x, mouseEvent.y)
3471
+ this.dispatchMouseEvent(maybeRenderable, mouseEvent)
3472
+ return true
3473
+ }
3474
+ }
3475
+
3476
+ if (mouseEvent.type === "drag" && this.currentSelection?.isDragging) {
3477
+ this.updateSelection(maybeRenderable, mouseEvent.x, mouseEvent.y)
3478
+
3479
+ if (maybeRenderable) {
3480
+ const event = new MouseEvent(maybeRenderable, {
3481
+ ...mouseEvent,
3482
+ isDragging: true,
3483
+ })
3484
+ maybeRenderable.processMouseEvent(event)
3485
+ }
3486
+
3487
+ return true
3488
+ }
3489
+
3490
+ if (mouseEvent.type === "up" && this.currentSelection?.isDragging) {
3491
+ if (maybeRenderable) {
3492
+ const event = new MouseEvent(maybeRenderable, {
3493
+ ...mouseEvent,
3494
+ isDragging: true,
3495
+ })
3496
+ maybeRenderable.processMouseEvent(event)
3497
+ }
3498
+
3499
+ this.finishSelection()
3500
+ return true
3501
+ }
3502
+
3503
+ if (mouseEvent.type === "down" && mouseEvent.button === MouseButton.LEFT && this.currentSelection) {
3504
+ if (mouseEvent.modifiers.ctrl) {
3505
+ this.currentSelection.isDragging = true
3506
+ this.updateSelection(maybeRenderable, mouseEvent.x, mouseEvent.y)
3507
+ return true
3508
+ }
3509
+ }
3510
+
3511
+ if (!sameElement && (mouseEvent.type === "drag" || mouseEvent.type === "move")) {
3512
+ if (
3513
+ this.lastOverRenderable &&
3514
+ this.lastOverRenderable !== this.capturedRenderable &&
3515
+ !this.lastOverRenderable.isDestroyed
3516
+ ) {
3517
+ const event = new MouseEvent(this.lastOverRenderable, {
3518
+ ...mouseEvent,
3519
+ type: "out",
3520
+ })
3521
+ this.lastOverRenderable.processMouseEvent(event)
3522
+ }
3523
+ this.lastOverRenderable = maybeRenderable
3524
+ if (maybeRenderable) {
3525
+ const event = new MouseEvent(maybeRenderable, {
3526
+ ...mouseEvent,
3527
+ type: "over",
3528
+ source: this.capturedRenderable,
3529
+ })
3530
+ maybeRenderable.processMouseEvent(event)
3531
+ }
3532
+ }
3533
+
3534
+ if (this.capturedRenderable && mouseEvent.type !== "up") {
3535
+ const event = new MouseEvent(this.capturedRenderable, mouseEvent)
3536
+ this.capturedRenderable.processMouseEvent(event)
3537
+ return true
3538
+ }
3539
+
3540
+ if (this.capturedRenderable && mouseEvent.type === "up") {
3541
+ const event = new MouseEvent(this.capturedRenderable, {
3542
+ ...mouseEvent,
3543
+ type: "drag-end",
3544
+ })
3545
+ this.capturedRenderable.processMouseEvent(event)
3546
+ this.capturedRenderable.processMouseEvent(new MouseEvent(this.capturedRenderable, mouseEvent))
3547
+ if (maybeRenderable) {
3548
+ const event = new MouseEvent(maybeRenderable, {
3549
+ ...mouseEvent,
3550
+ type: "drop",
3551
+ source: this.capturedRenderable,
3552
+ })
3553
+ maybeRenderable.processMouseEvent(event)
3554
+ }
3555
+ this.lastOverRenderable = this.capturedRenderable
3556
+ this.lastOverRenderableNum = this.capturedRenderable.num
3557
+ this.setCapturedRenderable(undefined)
3558
+ // Dropping the renderable needs to push another frame when the renderer is not live
3559
+ // to update the hit grid, otherwise capturedRenderable won't be in the hit grid and will not receive mouse events
3560
+ this.requestRender()
3561
+ }
3562
+
3563
+ let event: MouseEvent | undefined
3564
+ if (maybeRenderable) {
3565
+ if (mouseEvent.type === "drag" && mouseEvent.button === MouseButton.LEFT) {
3566
+ this.setCapturedRenderable(maybeRenderable)
3567
+ } else {
3568
+ this.setCapturedRenderable(undefined)
3569
+ }
3570
+ event = this.dispatchMouseEvent(maybeRenderable, mouseEvent)
3571
+ } else {
3572
+ this.setCapturedRenderable(undefined)
3573
+ this.lastOverRenderable = undefined
3574
+ }
3575
+
3576
+ if (!event?.defaultPrevented && mouseEvent.type === "down" && this.currentSelection) {
3577
+ this.clearSelection()
3578
+ }
3579
+
3580
+ return true
3581
+ }
3582
+
3583
+ /**
3584
+ * Recheck hover state after hit grid changes.
3585
+ * Called after render when native code detects the hit grid changed.
3586
+ * Fires out/over events if the element under the cursor changed.
3587
+ */
3588
+ private recheckHoverState(): void {
3589
+ if (this._isDestroyed || !this._hasPointer) return
3590
+ if (this.capturedRenderable) return
3591
+
3592
+ const hitId = this.hitTest(this._latestPointer.x, this._latestPointer.y)
3593
+ const hitRenderable = Renderable.renderablesByNumber.get(hitId)
3594
+ const lastOver = this.lastOverRenderable
3595
+
3596
+ // No change
3597
+ if (lastOver?.num === hitId) {
3598
+ this.lastOverRenderableNum = hitId
3599
+ return
3600
+ }
3601
+
3602
+ const baseEvent: RawMouseEvent = {
3603
+ type: "move",
3604
+ button: 0,
3605
+ x: this._latestPointer.x,
3606
+ y: this._latestPointer.y,
3607
+ modifiers: this._lastPointerModifiers,
3608
+ }
3609
+
3610
+ // Fire out on old element
3611
+ if (lastOver && !lastOver.isDestroyed) {
3612
+ const event = new MouseEvent(lastOver, { ...baseEvent, type: "out" })
3613
+ lastOver.processMouseEvent(event)
3614
+ }
3615
+
3616
+ this.lastOverRenderable = hitRenderable
3617
+ this.lastOverRenderableNum = hitId
3618
+
3619
+ // Fire over on new element
3620
+ if (hitRenderable) {
3621
+ const event = new MouseEvent(hitRenderable, {
3622
+ ...baseEvent,
3623
+ type: "over",
3624
+ })
3625
+ hitRenderable.processMouseEvent(event)
3626
+ }
3627
+ }
3628
+ public setMousePointer(style: MousePointerStyle): void {
3629
+ this._currentMousePointerStyle = style
3630
+ this.lib.setCursorStyleOptions(this.rendererPtr, { cursor: style })
3631
+ }
3632
+
3633
+ public hitTest(x: number, y: number): number {
3634
+ return this.lib.checkHit(this.rendererPtr, x, y)
3635
+ }
3636
+
3637
+ private takeMemorySnapshot(): void {
3638
+ if (this._isDestroyed) return
3639
+
3640
+ const memoryUsage = process.memoryUsage()
3641
+ this.lastMemorySnapshot = {
3642
+ heapUsed: memoryUsage.heapUsed,
3643
+ heapTotal: memoryUsage.heapTotal,
3644
+ arrayBuffers: memoryUsage.arrayBuffers,
3645
+ }
3646
+
3647
+ this.lib.updateMemoryStats(
3648
+ this.rendererPtr,
3649
+ this.lastMemorySnapshot.heapUsed,
3650
+ this.lastMemorySnapshot.heapTotal,
3651
+ this.lastMemorySnapshot.arrayBuffers,
3652
+ )
3653
+
3654
+ this.emit(CliRenderEvents.MEMORY_SNAPSHOT, this.lastMemorySnapshot)
3655
+ }
3656
+
3657
+ private startMemorySnapshotTimer(): void {
3658
+ this.stopMemorySnapshotTimer()
3659
+
3660
+ this.memorySnapshotTimer = this.clock.setInterval(() => {
3661
+ this.takeMemorySnapshot()
3662
+ }, this.memorySnapshotInterval)
3663
+ }
3664
+
3665
+ private stopMemorySnapshotTimer(): void {
3666
+ if (this.memorySnapshotTimer) {
3667
+ this.clock.clearInterval(this.memorySnapshotTimer)
3668
+ this.memorySnapshotTimer = null
3669
+ }
3670
+ }
3671
+
3672
+ public setMemorySnapshotInterval(interval: number): void {
3673
+ this.memorySnapshotInterval = interval
3674
+
3675
+ if (this._isRunning && interval > 0) {
3676
+ this.startMemorySnapshotTimer()
3677
+ } else if (interval <= 0 && this.memorySnapshotTimer) {
3678
+ this.clock.clearInterval(this.memorySnapshotTimer)
3679
+ this.memorySnapshotTimer = null
3680
+ }
3681
+ }
3682
+
3683
+ private handleResize(width: number, height: number): void {
3684
+ if (this._isDestroyed) return
3685
+ if (this._splitHeight > 0) {
3686
+ this.processResize(width, height)
3687
+ return
3688
+ }
3689
+
3690
+ if (this.resizeTimeoutId !== null) {
3691
+ this.clock.clearTimeout(this.resizeTimeoutId)
3692
+ this.resizeTimeoutId = null
3693
+ }
3694
+
3695
+ this.resizeTimeoutId = this.clock.setTimeout(() => {
3696
+ this.resizeTimeoutId = null
3697
+ this.processResize(width, height)
3698
+ }, this.resizeDebounceDelay)
3699
+ }
3700
+
3701
+ private queryPixelResolution() {
3702
+ this.waitingForPixelResolution = true
3703
+ this.updateStdinParserProtocolContext({ pixelResolutionQueryActive: true })
3704
+ this.lib.queryPixelResolution(this.rendererPtr)
3705
+ }
3706
+
3707
+ private processResize(width: number, height: number): void {
3708
+ if (width === this._terminalWidth && height === this._terminalHeight) return
3709
+
3710
+ if (
3711
+ this._terminalIsSetup &&
3712
+ this._controlState !== RendererControlState.EXPLICIT_SUSPENDED &&
3713
+ !this.isSplitCursorSeedFrameBlocked()
3714
+ ) {
3715
+ this.flushPendingSplitOutputBeforeTransition()
3716
+ }
3717
+
3718
+ const pendingSplitFooterTransition = this.pendingSplitFooterTransition
3719
+ const previousGeometry = calculateRenderGeometry(
3720
+ this._screenMode,
3721
+ this._terminalWidth,
3722
+ this._terminalHeight,
3723
+ this._footerHeight,
3724
+ )
3725
+ const prevWidth = this._terminalWidth
3726
+ const previousTerminalHeight = this._terminalHeight
3727
+ const visiblePreviousSplitHeight =
3728
+ pendingSplitFooterTransition?.sourceHeight ?? previousGeometry.effectiveFooterHeight
3729
+
3730
+ this._terminalWidth = width
3731
+ this._terminalHeight = height
3732
+ this.queryPixelResolution()
3733
+
3734
+ this.setCapturedRenderable(undefined)
3735
+ this.stdinParser?.resetMouseState()
3736
+
3737
+ const nextGeometry = calculateRenderGeometry(
3738
+ this._screenMode,
3739
+ this._terminalWidth,
3740
+ this._terminalHeight,
3741
+ this._footerHeight,
3742
+ )
3743
+ const splitFooterActive = this._screenMode === "split-footer"
3744
+
3745
+ if (splitFooterActive) {
3746
+ // Width shrink historically needs a broader scrub band, but if resize interrupts
3747
+ // a deferred footer transition we also need to clear from that visible source surface.
3748
+ let clearStart: number | null = null
3749
+
3750
+ if (width < prevWidth && visiblePreviousSplitHeight > 0) {
3751
+ clearStart = Math.max(previousTerminalHeight - visiblePreviousSplitHeight * 2, 1)
3752
+ }
3753
+
3754
+ if (pendingSplitFooterTransition !== null) {
3755
+ clearStart =
3756
+ clearStart === null
3757
+ ? pendingSplitFooterTransition.sourceTopLine
3758
+ : Math.min(clearStart, pendingSplitFooterTransition.sourceTopLine)
3759
+ }
3760
+
3761
+ if (clearStart !== null) {
3762
+ const flush = ANSI.moveCursorAndClear(clearStart, 1)
3763
+ this.writeOut(flush)
3764
+ }
3765
+
3766
+ this.currentRenderBuffer.clear(this.backgroundColor)
3767
+ }
3768
+
3769
+ this.clearPendingSplitFooterTransition()
3770
+
3771
+ this._splitHeight = nextGeometry.effectiveFooterHeight
3772
+ this.width = nextGeometry.renderWidth
3773
+ this.height = nextGeometry.renderHeight
3774
+
3775
+ this.lib.resizeRenderer(this.rendererPtr, this.width, this.height)
3776
+
3777
+ if (this._screenMode === "split-footer" && this._externalOutputMode === "capture-stdout") {
3778
+ this.syncSplitScrollback()
3779
+ } else {
3780
+ this.syncSplitFooterState()
3781
+ }
3782
+
3783
+ this.nextRenderBuffer = this.lib.getNextBuffer(this.rendererPtr)
3784
+ this.currentRenderBuffer = this.lib.getCurrentBuffer(this.rendererPtr)
3785
+ this._console.resize(this.width, this.height)
3786
+ this.root.resize(this.width, this.height)
3787
+ this.emit(CliRenderEvents.RESIZE, this.width, this.height)
3788
+ this.requestRender()
3789
+ }
3790
+
3791
+ /**
3792
+ * Programmatically resize the renderer to new dimensions.
3793
+ *
3794
+ * Use this for externally-driven resize events — for example, an SSH
3795
+ * `window-change` signal or a test harness simulating a terminal resize.
3796
+ * When the renderer is attached to `process.stdout`, `SIGWINCH` is handled
3797
+ * automatically and callers do not need this method.
3798
+ */
3799
+ public resize(width: number, height: number): void {
3800
+ if (this._isDestroyed) return
3801
+ this.processResize(width, height)
3802
+ }
3803
+
3804
+ public setBackgroundColor(color: ColorInput): void {
3805
+ const parsedColor = parseColor(color)
3806
+ this.lib.setBackgroundColor(this.rendererPtr, parsedColor as RGBA)
3807
+ this.backgroundColor = parsedColor as RGBA
3808
+ this.nextRenderBuffer.clear(parsedColor as RGBA)
3809
+ this.requestRender()
3810
+ }
3811
+
3812
+ public toggleDebugOverlay(): void {
3813
+ const willBeEnabled = !this.debugOverlay.enabled
3814
+
3815
+ if (willBeEnabled && !this.memorySnapshotInterval) {
3816
+ this.memorySnapshotInterval = 3000
3817
+ this.startMemorySnapshotTimer()
3818
+ this.automaticMemorySnapshot = true
3819
+ } else if (!willBeEnabled && this.automaticMemorySnapshot) {
3820
+ this.stopMemorySnapshotTimer()
3821
+ this.memorySnapshotInterval = 0
3822
+ this.automaticMemorySnapshot = false
3823
+ }
3824
+
3825
+ this.debugOverlay.enabled = !this.debugOverlay.enabled
3826
+ this.lib.setDebugOverlay(this.rendererPtr, this.debugOverlay.enabled, this.debugOverlay.corner)
3827
+ this.emit(CliRenderEvents.DEBUG_OVERLAY_TOGGLE, this.debugOverlay.enabled)
3828
+ this.requestRender()
3829
+ }
3830
+
3831
+ public configureDebugOverlay(options: { enabled?: boolean; corner?: DebugOverlayCorner }): void {
3832
+ this.debugOverlay.enabled = options.enabled ?? this.debugOverlay.enabled
3833
+ this.debugOverlay.corner = options.corner ?? this.debugOverlay.corner
3834
+ this.lib.setDebugOverlay(this.rendererPtr, this.debugOverlay.enabled, this.debugOverlay.corner)
3835
+ this.requestRender()
3836
+ }
3837
+
3838
+ public setTerminalTitle(title: string): void {
3839
+ this.lib.setTerminalTitle(this.rendererPtr, title)
3840
+ }
3841
+
3842
+ /**
3843
+ * Reset the terminal background color to its default via OSC 111.
3844
+ * Called automatically by destroy() and suspend(), but exposed for
3845
+ * consumers that need explicit control (e.g. before SIGTSTP).
3846
+ */
3847
+ public resetTerminalBgColor(): void {
3848
+ this.writeOut("\x1b]111\x07")
3849
+ }
3850
+
3851
+ public copyToClipboardOSC52(text: string, target?: ClipboardTarget): boolean {
3852
+ return this.clipboard.copyToClipboardOSC52(text, target)
3853
+ }
3854
+
3855
+ public clearClipboardOSC52(target?: ClipboardTarget): boolean {
3856
+ return this.clipboard.clearClipboardOSC52(target)
3857
+ }
3858
+
3859
+ public isOsc52Supported(): boolean {
3860
+ return this._capabilities ? this._capabilities.osc52_support !== "unsupported" : this.clipboard.isOsc52Supported()
3861
+ }
3862
+
3863
+ public dumpHitGrid(): void {
3864
+ this.lib.dumpHitGrid(this.rendererPtr)
3865
+ }
3866
+
3867
+ public dumpBuffers(timestamp?: number): void {
3868
+ this.lib.dumpBuffers(this.rendererPtr, timestamp)
3869
+ }
3870
+
3871
+ public dumpOutputBuffer(timestamp?: number): void {
3872
+ this.lib.dumpOutputBuffer(this.rendererPtr, timestamp)
3873
+ }
3874
+
3875
+ public static setCursorPosition(renderer: CliRenderer, x: number, y: number, visible: boolean = true): void {
3876
+ const lib = resolveRenderLib()
3877
+ lib.setCursorPosition(renderer.rendererPtr, x, y, visible)
3878
+ }
3879
+
3880
+ public static setCursorStyle(renderer: CliRenderer, options: CursorStyleOptions): void {
3881
+ const lib = resolveRenderLib()
3882
+ lib.setCursorStyleOptions(renderer.rendererPtr, options)
3883
+ if (options.cursor !== undefined) {
3884
+ renderer._currentMousePointerStyle = options.cursor
3885
+ }
3886
+ }
3887
+
3888
+ public static setCursorColor(renderer: CliRenderer, color: RGBA): void {
3889
+ const lib = resolveRenderLib()
3890
+ lib.setCursorColor(renderer.rendererPtr, color)
3891
+ }
3892
+
3893
+ public setCursorPosition(x: number, y: number, visible: boolean = true): void {
3894
+ this.lib.setCursorPosition(this.rendererPtr, x, y, visible)
3895
+ }
3896
+
3897
+ public setCursorStyle(options: CursorStyleOptions): void {
3898
+ this.lib.setCursorStyleOptions(this.rendererPtr, options)
3899
+ if (options.cursor !== undefined) {
3900
+ this._currentMousePointerStyle = options.cursor
3901
+ }
3902
+ }
3903
+
3904
+ public setCursorColor(color: RGBA): void {
3905
+ this.lib.setCursorColor(this.rendererPtr, color)
3906
+ }
3907
+
3908
+ public getCursorState() {
3909
+ return this.lib.getCursorState(this.rendererPtr)
3910
+ }
3911
+
3912
+ public addPostProcessFn(processFn: (buffer: OptimizedBuffer, deltaTime: number) => void): void {
3913
+ this.postProcessFns.push(processFn)
3914
+ }
3915
+
3916
+ public removePostProcessFn(processFn: (buffer: OptimizedBuffer, deltaTime: number) => void): void {
3917
+ this.postProcessFns = this.postProcessFns.filter((fn) => fn !== processFn)
3918
+ }
3919
+
3920
+ public clearPostProcessFns(): void {
3921
+ this.postProcessFns = []
3922
+ }
3923
+
3924
+ public setFrameCallback(callback: (deltaTime: number) => Promise<void>): void {
3925
+ this.frameCallbacks.push(callback)
3926
+ }
3927
+
3928
+ public removeFrameCallback(callback: (deltaTime: number) => Promise<void>): void {
3929
+ this.frameCallbacks = this.frameCallbacks.filter((cb) => cb !== callback)
3930
+ }
3931
+
3932
+ public clearFrameCallbacks(): void {
3933
+ this.frameCallbacks = []
3934
+ }
3935
+
3936
+ public requestLive(): void {
3937
+ this.liveRequestCounter++
3938
+
3939
+ if (this._controlState === RendererControlState.IDLE && this.liveRequestCounter > 0) {
3940
+ this._controlState = RendererControlState.AUTO_STARTED
3941
+ this.internalStart()
3942
+ }
3943
+ }
3944
+
3945
+ public dropLive(): void {
3946
+ this.liveRequestCounter = Math.max(0, this.liveRequestCounter - 1)
3947
+
3948
+ if (this._controlState === RendererControlState.AUTO_STARTED && this.liveRequestCounter === 0) {
3949
+ this._controlState = RendererControlState.IDLE
3950
+ this.internalPause()
3951
+ }
3952
+ }
3953
+
3954
+ public start(): void {
3955
+ this._controlState = RendererControlState.EXPLICIT_STARTED
3956
+ this.internalStart()
3957
+ }
3958
+
3959
+ public auto(): void {
3960
+ this._controlState = this._isRunning ? RendererControlState.AUTO_STARTED : RendererControlState.IDLE
3961
+ }
3962
+
3963
+ private internalStart(): void {
3964
+ if (!this._isRunning && !this._isDestroyed) {
3965
+ this._isRunning = true
3966
+
3967
+ // Invalidate any queued idle one-shot frame.
3968
+ // start()/live/resume transition to the continuous loop, so queued
3969
+ // activateFrame callbacks must no-op via !updateScheduled.
3970
+ this.updateScheduled = false
3971
+
3972
+ if (this.memorySnapshotInterval > 0) {
3973
+ this.startMemorySnapshotTimer()
3974
+ }
3975
+
3976
+ this.startRenderLoop()
3977
+ }
3978
+ }
3979
+
3980
+ public pause(): void {
3981
+ this._controlState = RendererControlState.EXPLICIT_PAUSED
3982
+ this.internalPause()
3983
+ }
3984
+
3985
+ public suspend(): void {
3986
+ this._previousControlState = this._controlState
3987
+
3988
+ this._controlState = RendererControlState.EXPLICIT_SUSPENDED
3989
+ this.updateScheduled = false
3990
+ this.internalPause()
3991
+
3992
+ if (this._terminalIsSetup) {
3993
+ this.clearSplitStartupCursorSeed()
3994
+ this.flushPendingSplitOutputBeforeTransition(true, { allowSuspended: true })
3995
+ this.suspendedNonAltSurfacePreserved = this._screenMode !== "alternate-screen" && this.renderOffset > 0
3996
+ } else {
3997
+ this.suspendedNonAltSurfacePreserved = false
3998
+ }
3999
+
4000
+ this._suspendedMouseEnabled = this._useMouse
4001
+
4002
+ this.disableMouse()
4003
+ this.removeExitListeners()
4004
+ this.waitingForPixelResolution = false
4005
+ this.updateStdinParserProtocolContext({
4006
+ privateCapabilityRepliesActive: false,
4007
+ pixelResolutionQueryActive: false,
4008
+ explicitWidthCprActive: false,
4009
+ startupCursorCprActive: false,
4010
+ })
4011
+ this.stdinParser?.reset()
4012
+ this.stdin.removeListener("data", this.stdinListener)
4013
+
4014
+ this.themeModeState.cancelRefresh()
4015
+
4016
+ this.lib.suspendRenderer(this.rendererPtr)
4017
+
4018
+ if (this.stdin.setRawMode) {
4019
+ this.stdin.setRawMode(false)
4020
+ }
4021
+
4022
+ this.stdin.pause()
4023
+ }
4024
+
4025
+ public resume(): void {
4026
+ if (this.stdin.setRawMode) {
4027
+ this.stdin.setRawMode(true)
4028
+ }
4029
+
4030
+ // Drain any input buffered during suspension before registering the
4031
+ // listener. Adding a "data" listener can auto-resume a Readable, so the
4032
+ // drain must come first while the stream is still paused and read()
4033
+ // pulls from the internal buffer rather than being a flowing-mode no-op.
4034
+ while (this.stdin.read() !== null) {}
4035
+ this.stdin.on("data", this.stdinListener)
4036
+ this.stdin.resume()
4037
+ this.addExitListeners()
4038
+
4039
+ const resumePreservedNonAltSurface =
4040
+ this.pendingSuspendedTerminalSetup &&
4041
+ this._screenMode !== "alternate-screen" &&
4042
+ this.suspendedNonAltSurfacePreserved &&
4043
+ this.renderOffset > 0
4044
+
4045
+ if (this.pendingSuspendedTerminalSetup) {
4046
+ this.pendingSuspendedTerminalSetup = false
4047
+ if (resumePreservedNonAltSurface) {
4048
+ this.lib.resumeRenderer(this.rendererPtr)
4049
+ } else {
4050
+ this.lib.setupTerminal(this.rendererPtr, this._screenMode === "alternate-screen")
4051
+ }
4052
+ } else {
4053
+ this.lib.resumeRenderer(this.rendererPtr)
4054
+ }
4055
+
4056
+ this.suspendedNonAltSurfacePreserved = false
4057
+
4058
+ this.flushPendingSplitOutputBeforeTransition(false, { allowSuspended: true, allowPassthrough: true })
4059
+
4060
+ if (this._screenMode === "split-footer" && this._splitHeight > 0) {
4061
+ this.syncSplitFooterState()
4062
+ }
4063
+
4064
+ if (this._suspendedMouseEnabled) {
4065
+ this.enableMouse()
4066
+ }
4067
+
4068
+ this.forceFullRepaintRequested = true
4069
+ this._controlState = this._previousControlState
4070
+
4071
+ if (
4072
+ this._previousControlState === RendererControlState.AUTO_STARTED ||
4073
+ this._previousControlState === RendererControlState.EXPLICIT_STARTED
4074
+ ) {
4075
+ this.internalStart()
4076
+ } else {
4077
+ this.requestRender()
4078
+ }
4079
+ }
4080
+
4081
+ private internalPause(): void {
4082
+ this._isRunning = false
4083
+
4084
+ if (this.renderTimeout) {
4085
+ this.clock.clearTimeout(this.renderTimeout)
4086
+ this.renderTimeout = null
4087
+ }
4088
+
4089
+ if (!this.rendering) {
4090
+ this.resolveIdleIfNeeded()
4091
+ }
4092
+ }
4093
+
4094
+ public stop(): void {
4095
+ this._controlState = RendererControlState.EXPLICIT_STOPPED
4096
+ this.internalStop()
4097
+ }
4098
+
4099
+ private internalStop(): void {
4100
+ if (this.isRunning && !this._isDestroyed) {
4101
+ this._isRunning = false
4102
+
4103
+ if (this.memorySnapshotTimer) {
4104
+ this.clock.clearInterval(this.memorySnapshotTimer)
4105
+ this.memorySnapshotTimer = null
4106
+ }
4107
+
4108
+ if (this.renderTimeout) {
4109
+ this.clock.clearTimeout(this.renderTimeout)
4110
+ this.renderTimeout = null
4111
+ }
4112
+
4113
+ // If we're currently rendering, the frame will resolve idle when it completes
4114
+ // Otherwise, resolve immediately
4115
+ if (!this.rendering) {
4116
+ this.resolveIdleIfNeeded()
4117
+ }
4118
+ }
4119
+ }
4120
+
4121
+ public destroy(): void {
4122
+ if (this._isDestroyed) return
4123
+ this._isDestroyed = true
4124
+ this._destroyPending = true
4125
+ this._palettePublishGeneration++
4126
+
4127
+ if (this.rendering) {
4128
+ // Restore terminal/input state immediately, but defer full native teardown until the frame unwinds.
4129
+ this.prepareDestroyDuringRender()
4130
+ return
4131
+ }
4132
+
4133
+ this.finalizeDestroy()
4134
+ }
4135
+
4136
+ private cleanupBeforeDestroy(): void {
4137
+ if (this._destroyCleanupPrepared) return
4138
+ this._destroyCleanupPrepared = true
4139
+
4140
+ if (this._usesProcessStdout) {
4141
+ process.removeListener("SIGWINCH", this.sigwinchHandler)
4142
+ }
4143
+ process.removeListener("uncaughtException", this.handleError)
4144
+ process.removeListener("unhandledRejection", this.handleError)
4145
+ process.removeListener("warning", this.warningHandler)
4146
+ process.removeListener("beforeExit", this.exitHandler)
4147
+ this.removeExitListeners()
4148
+
4149
+ if (this.resizeTimeoutId !== null) {
4150
+ this.clock.clearTimeout(this.resizeTimeoutId)
4151
+ this.resizeTimeoutId = null
4152
+ }
4153
+
4154
+ if (this.capabilityTimeoutId !== null) {
4155
+ this.clock.clearTimeout(this.capabilityTimeoutId)
4156
+ this.capabilityTimeoutId = null
4157
+ }
4158
+
4159
+ this.clearSplitStartupCursorSeed()
4160
+
4161
+ if (this.memorySnapshotTimer) {
4162
+ this.clock.clearInterval(this.memorySnapshotTimer)
4163
+ this.memorySnapshotTimer = null
4164
+ }
4165
+
4166
+ if (this.renderTimeout) {
4167
+ this.clock.clearTimeout(this.renderTimeout)
4168
+ this.renderTimeout = null
4169
+ }
4170
+
4171
+ this.themeModeState.cancelRefresh()
4172
+
4173
+ this._isRunning = false
4174
+ this.waitingForPixelResolution = false
4175
+ this.updateStdinParserProtocolContext(
4176
+ {
4177
+ privateCapabilityRepliesActive: false,
4178
+ pixelResolutionQueryActive: false,
4179
+ explicitWidthCprActive: false,
4180
+ startupCursorCprActive: false,
4181
+ },
4182
+ true,
4183
+ )
4184
+ this._useMouse = false
4185
+ this.setCapturedRenderable(undefined)
4186
+
4187
+ this.stdin.removeListener("data", this.stdinListener)
4188
+ if (this.stdin.setRawMode) {
4189
+ try {
4190
+ this.stdin.setRawMode(false)
4191
+ } catch (e) {
4192
+ console.error("Error disabling raw mode during destroy:", e)
4193
+ }
4194
+ }
4195
+ try {
4196
+ this.stdin.pause()
4197
+ } catch (e) {
4198
+ console.error("Error pausing stdin during destroy:", e)
4199
+ }
4200
+
4201
+ if (this._feed !== null && this._splitHeight > 0 && !this._terminalIsSetup) {
4202
+ this.flushPendingSplitOutputBeforeTransition(false, { allowSuspended: true, allowUnsetup: true })
4203
+ }
4204
+
4205
+ this.externalOutputMode = "passthrough"
4206
+
4207
+ if (this._splitHeight > 0) {
4208
+ this.flushStdoutCache(this._splitHeight, true)
4209
+ }
4210
+ }
4211
+
4212
+ private prepareDestroyDuringRender(): void {
4213
+ this.cleanupBeforeDestroy()
4214
+ this.lib.suspendRenderer(this.rendererPtr)
4215
+ }
4216
+
4217
+ private finalizeDestroy(): void {
4218
+ if (this._destroyFinalized) return
4219
+
4220
+ this._destroyFinalized = true
4221
+ this._destroyPending = false
4222
+
4223
+ this.cleanupBeforeDestroy()
4224
+
4225
+ // Clean up palette detector
4226
+ if (this._paletteDetector) {
4227
+ this._paletteDetector.cleanup()
4228
+ this._paletteDetector = null
4229
+ }
4230
+ this._paletteCache.clear()
4231
+ this._paletteDetectionPromise = null
4232
+ this._paletteDetectionSize = 0
4233
+ this._nativePaletteSignature = null
4234
+ this._emittedPaletteSignature = null
4235
+ this._paletteEpoch = 0
4236
+ this.resolveXtVersionWaiters()
4237
+
4238
+ this.themeModeState.dispose()
4239
+
4240
+ this.emit(CliRenderEvents.DESTROY)
4241
+
4242
+ try {
4243
+ this.root.destroyRecursively()
4244
+ } catch (e) {
4245
+ console.error("Error destroying root renderable:", e instanceof Error ? e.stack : String(e))
4246
+ }
4247
+
4248
+ this.stdinParser?.destroy()
4249
+ this.stdinParser = null
4250
+ this.oscSubscribers.clear()
4251
+ this._console.destroy()
4252
+
4253
+ // Split-footer cleanup: flush pending output and reset offset before
4254
+ // tearing down the native renderer.
4255
+ if (
4256
+ this._splitHeight > 0 &&
4257
+ this._terminalIsSetup &&
4258
+ this._controlState !== RendererControlState.EXPLICIT_SUSPENDED
4259
+ ) {
4260
+ this.flushPendingSplitOutputBeforeTransition(true)
4261
+ this.renderOffset = 0
4262
+ if (this.clearOnShutdown) {
4263
+ this.lib.setRenderOffset(this.rendererPtr, 0)
4264
+ }
4265
+ }
4266
+
4267
+ this._externalOutputMode = "passthrough"
4268
+ this.pendingExternalOutputMode = null
4269
+ this.stdout.write = this.realStdoutWrite
4270
+ this.externalOutputQueue.clear()
4271
+
4272
+ // Teardown ordering for the feed backend is subtle and must satisfy two
4273
+ // invariants simultaneously:
4274
+ //
4275
+ // 1. The shutdown ANSI sequence emitted by `lib.destroyRenderer` via
4276
+ // FeedBackend.writeOut MUST reach the user's Writable. Otherwise the
4277
+ // remote terminal is left in alt-screen / mouse-tracking / kitty-
4278
+ // keyboard state at session close.
4279
+ //
4280
+ // 2. The data handler (which does `stdout.write`) must be detached
4281
+ // before `feed.close()`, otherwise `close()` will wait for any
4282
+ // in-flight writes that reference chunks about to be freed.
4283
+ //
4284
+ // The correct order is:
4285
+ // a) drain any frames already committed but not yet delivered
4286
+ // b) call lib.destroyRenderer — this commits shutdown bytes into the feed
4287
+ // c) drain again — this flushes those shutdown bytes through the handler
4288
+ // d) detach the handler now that no more data will flow
4289
+ // e) close the feed (releases chunk memory once async handlers settle)
4290
+ //
4291
+ // Memory-lifetime invariant: `lib.destroyRenderer` calls into Zig's
4292
+ // `FeedBackend.deinit`, which is a DOCUMENTED NO-OP — feed memory is
4293
+ // owned by the TS side and only released by `feed.close()` at step (e).
4294
+ // Consequently, step (c)'s drain operates on still-valid chunk memory;
4295
+ // there is no use-after-free window between (b) and (e).
4296
+ //
4297
+ // Caller note: `feed.close()` is queued as a microtask when async handlers
4298
+ // from the final drain are still pending. If the caller tears down the
4299
+ // underlying Writable synchronously right after `destroy()` returns (e.g.
4300
+ // `channel.close()` on the very next line, or `process.exit()`), the
4301
+ // shutdown bytes may not have flushed yet. For async teardown, allow one
4302
+ // microtask tick (e.g. `await new Promise(queueMicrotask)`) before closing
4303
+ // the transport.
4304
+ if (this._feed) {
4305
+ try {
4306
+ this._feed.drainAll()
4307
+ } catch (e) {
4308
+ console.error("Error draining NativeSpanFeed during destroy:", e)
4309
+ }
4310
+ }
4311
+
4312
+ try {
4313
+ this.lib.destroyRenderer(this.rendererPtr)
4314
+ } catch (e) {
4315
+ console.error("Error in lib.destroyRenderer during destroy:", e)
4316
+ }
4317
+ rendererTracker.renderers.delete(this)
4318
+ if (rendererTracker.renderers.size === 0) {
4319
+ void destroyTreeSitterClient().catch((error) => {
4320
+ console.error("Failed to destroy tree-sitter client:", error)
4321
+ })
4322
+ }
4323
+
4324
+ if (this._feed) {
4325
+ try {
4326
+ this._feed.drainAll()
4327
+ } catch (e) {
4328
+ console.error("Error draining NativeSpanFeed shutdown frames:", e)
4329
+ }
4330
+ this._detachFeed?.()
4331
+ this._detachFeed = null
4332
+ this._detachFeedError?.()
4333
+ this._detachFeedError = null
4334
+ this._feed.close()
4335
+ this._feed = null
4336
+ }
4337
+
4338
+ if (this._streamLeaseAcquired) {
4339
+ if (rendererTracker.streamOwners.get(this.stdin) === this) rendererTracker.streamOwners.delete(this.stdin)
4340
+ if (rendererTracker.streamOwners.get(this.stdout) === this) rendererTracker.streamOwners.delete(this.stdout)
4341
+ this._streamLeaseAcquired = false
4342
+ }
4343
+
4344
+ if (this._onDestroy) {
4345
+ try {
4346
+ this._onDestroy()
4347
+ } catch (e) {
4348
+ console.error("Error in onDestroy callback:", e instanceof Error ? e.stack : String(e))
4349
+ }
4350
+ }
4351
+
4352
+ // Resolve any pending idle() calls
4353
+ this.resolveIdleIfNeeded()
4354
+ }
4355
+
4356
+ private startRenderLoop(): void {
4357
+ if (!this._isRunning) return
4358
+
4359
+ this.lastTime = this.normalizeClockTime(this.clock.now(), 0)
4360
+ this.frameCount = 0
4361
+ this.lastFpsTime = this.lastTime
4362
+ this.currentFps = 0
4363
+ this.renderStats.fps = 0
4364
+
4365
+ // Starting continuous mode must not bypass an existing feed-idle retry. Keep
4366
+ // _isRunning true, but let the idle callback schedule the first loop once the
4367
+ // feed is ready; a successful frame will then resume the normal cadence.
4368
+ if (this.feedIdleRenderScheduled) return
4369
+
4370
+ this.loop()
4371
+ }
4372
+
4373
+ private async loop(): Promise<void> {
4374
+ if (this.rendering || this._isDestroyed) return
4375
+ this.renderTimeout = null
4376
+
4377
+ this.rendering = true
4378
+ if (this.renderTimeout) {
4379
+ this.clock.clearTimeout(this.renderTimeout)
4380
+ this.renderTimeout = null
4381
+ }
4382
+ try {
4383
+ // Bump before any work so all callers this iteration see the new id.
4384
+ this._frameId++
4385
+
4386
+ const now = this.normalizeClockTime(this.clock.now(), this.lastTime)
4387
+ const elapsed = this.getElapsedMs(now, this.lastTime)
4388
+
4389
+ const deltaTime = elapsed
4390
+ this.lastTime = now
4391
+
4392
+ this.renderStats.frameCount++
4393
+ const overallStart = performance.now()
4394
+
4395
+ const frameRequests = Array.from(this.animationRequest.values())
4396
+ this.animationRequest.clear()
4397
+ const animationRequestStart = performance.now()
4398
+ for (const callback of frameRequests) {
4399
+ callback(deltaTime)
4400
+ this.dropLive()
4401
+ }
4402
+ const animationRequestEnd = performance.now()
4403
+ const animationRequestTime = animationRequestEnd - animationRequestStart
4404
+
4405
+ const start = performance.now()
4406
+ for (const frameCallback of this.frameCallbacks) {
4407
+ try {
4408
+ await frameCallback(deltaTime)
4409
+ } catch (error) {
4410
+ console.error("Error in frame callback:", error)
4411
+ }
4412
+ }
4413
+ const end = performance.now()
4414
+ this.renderStats.frameCallbackTime = end - start
4415
+
4416
+ this.root.render(this.nextRenderBuffer, deltaTime)
4417
+
4418
+ for (const postProcessFn of this.postProcessFns) {
4419
+ postProcessFn(this.nextRenderBuffer, deltaTime)
4420
+ }
4421
+
4422
+ this._console.renderToBuffer(this.nextRenderBuffer)
4423
+
4424
+ // If destroy() was requested during this frame, skip native work and scheduling.
4425
+ if (!this._isDestroyed) {
4426
+ const nativeStatus = this.renderNative() ?? "rendered"
4427
+ if (nativeStatus === "rendered") this.frameCount++
4428
+ if (this.getElapsedMs(now, this.lastFpsTime) >= 1000) {
4429
+ this.currentFps = this.frameCount
4430
+ this.frameCount = 0
4431
+ this.lastFpsTime = now
4432
+ }
4433
+ this.renderStats.fps = this.currentFps
4434
+
4435
+ if (nativeStatus === "rendered") {
4436
+ // Check if hit grid changed and recheck hover state if needed
4437
+ if (this._useMouse && this.lib.getHitGridDirty(this.rendererPtr)) {
4438
+ this.recheckHoverState()
4439
+ }
4440
+
4441
+ const overallFrameTime = performance.now() - overallStart
4442
+
4443
+ // TODO: Add animationRequestTime to stats
4444
+ this.lib.updateStats(
4445
+ this.rendererPtr,
4446
+ overallFrameTime,
4447
+ this.renderStats.fps,
4448
+ this.renderStats.frameCallbackTime,
4449
+ )
4450
+
4451
+ if (this.listenerCount(CliRenderEvents.FRAME) > 0) {
4452
+ this.emit(CliRenderEvents.FRAME, {
4453
+ frameId: this.frameId,
4454
+ })
4455
+ }
4456
+
4457
+ if (this.gatherStats) {
4458
+ this.collectStatSample(overallFrameTime)
4459
+ }
4460
+
4461
+ if (this._isRunning || this.immediateRerenderRequested) {
4462
+ const targetFrameTime = this.immediateRerenderRequested ? this.minTargetFrameTime : this.targetFrameTime
4463
+ const delay = Math.max(1, targetFrameTime - Math.floor(overallFrameTime))
4464
+ this.immediateRerenderRequested = false
4465
+ this.renderTimeout = this.clock.setTimeout(() => {
4466
+ this.renderTimeout = null
4467
+ this.loop()
4468
+ }, delay)
4469
+ } else {
4470
+ this.clock.clearTimeout(this.renderTimeout!)
4471
+ this.renderTimeout = null
4472
+ }
4473
+ } else if (nativeStatus === "blocked") {
4474
+ const overallFrameTime = performance.now() - overallStart
4475
+
4476
+ if (this._isRunning || this.immediateRerenderRequested) {
4477
+ const targetFrameTime = this.immediateRerenderRequested ? this.minTargetFrameTime : this.targetFrameTime
4478
+ const delay = Math.max(1, targetFrameTime - Math.floor(overallFrameTime))
4479
+ this.immediateRerenderRequested = false
4480
+ this.renderTimeout = this.clock.setTimeout(() => {
4481
+ this.renderTimeout = null
4482
+ this.loop()
4483
+ }, delay)
4484
+ } else {
4485
+ this.clock.clearTimeout(this.renderTimeout!)
4486
+ this.renderTimeout = null
4487
+ }
4488
+ } else if (nativeStatus === "backpressured") {
4489
+ this.scheduleRenderAfterBackpressure()
4490
+ } else if (nativeStatus === "retryable-skip") {
4491
+ this.immediateRerenderRequested = false
4492
+ this.renderTimeout = null
4493
+ } else if (nativeStatus === "failed") {
4494
+ this.immediateRerenderRequested = false
4495
+ this.renderTimeout = null
4496
+ }
4497
+ }
4498
+ } finally {
4499
+ this.rendering = false
4500
+ if (this._destroyPending) {
4501
+ this.finalizeDestroy()
4502
+ }
4503
+ this.resolveIdleIfNeeded()
4504
+ }
4505
+ }
4506
+
4507
+ public intermediateRender(): void {
4508
+ this.immediateRerenderRequested = true
4509
+ this.loop()
4510
+ }
4511
+
4512
+ private renderNative(): "rendered" | "retryable-skip" | "failed" | "blocked" | "backpressured" {
4513
+ if (this.renderingNative) {
4514
+ console.error("Rendering called concurrently")
4515
+ throw new Error("Rendering called concurrently")
4516
+ }
4517
+
4518
+ this.renderingNative = true
4519
+
4520
+ try {
4521
+ if (this.isSplitCursorSeedFrameBlocked()) {
4522
+ return "blocked"
4523
+ }
4524
+
4525
+ if (this._splitHeight > 0 && this._externalOutputMode === "capture-stdout") {
4526
+ // forceFullRepaintRequested is a one-shot latch used when mode/geometry
4527
+ // transitions need a complete footer refresh. Consume it once so steady-state
4528
+ // capture path keeps using diff-based repainting.
4529
+ const forceSplitRepaint = this.forceFullRepaintRequested
4530
+ const status = this.flushPendingSplitCommits(
4531
+ forceSplitRepaint,
4532
+ this.pendingExternalOutputMode === "passthrough",
4533
+ )
4534
+ if (status === "backpressured") {
4535
+ return "backpressured"
4536
+ }
4537
+ if (status === "failed") {
4538
+ return "failed"
4539
+ }
4540
+ this.forceFullRepaintRequested = false
4541
+ this.pendingSplitFooterTransition = null
4542
+ return "rendered"
4543
+ }
4544
+
4545
+ const force = this.forceFullRepaintRequested
4546
+ const nativeStatus = this.lib.render(this.rendererPtr, force)
4547
+ if (nativeStatus === NATIVE_RENDER_STATUS_SKIPPED || nativeStatus === NATIVE_RENDER_STATUS_FAILED) {
4548
+ return this.handleNativeRenderRejection(nativeStatus)
4549
+ }
4550
+
4551
+ this.forceFullRepaintRequested = false
4552
+ this.pendingSplitFooterTransition = null
4553
+ // this.dumpOutputBuffer(Date.now())
4554
+ return "rendered"
4555
+ } finally {
4556
+ this.renderingNative = false
4557
+ }
4558
+ }
4559
+
4560
+ private collectStatSample(frameTime: number): void {
4561
+ this.frameTimes.push(frameTime)
4562
+ if (this.frameTimes.length > this.maxStatSamples) {
4563
+ this.frameTimes.shift()
4564
+ }
4565
+ }
4566
+
4567
+ public getNativeStats(): NativeRenderStats {
4568
+ return this.lib.getRenderStats(this.rendererPtr)
4569
+ }
4570
+
4571
+ public getStats(): CliRendererStats {
4572
+ const nativeStats = this.getNativeStats()
4573
+ const frameTimes = [...this.frameTimes]
4574
+ const sum = frameTimes.reduce((acc, time) => acc + time, 0)
4575
+ const avg = frameTimes.length ? sum / frameTimes.length : 0
4576
+ const min = frameTimes.length ? Math.min(...frameTimes) : 0
4577
+ const max = frameTimes.length ? Math.max(...frameTimes) : 0
4578
+
4579
+ return {
4580
+ ...nativeStats,
4581
+ fps: this.renderStats.fps,
4582
+ frameCount: this.renderStats.frameCount,
4583
+ frameTimes,
4584
+ averageFrameTime: avg,
4585
+ minFrameTime: min,
4586
+ maxFrameTime: max,
4587
+ frameCallbackTime: this.renderStats.frameCallbackTime,
4588
+ }
4589
+ }
4590
+
4591
+ public resetStats(): void {
4592
+ this.frameTimes = []
4593
+ this.renderStats.frameCount = 0
4594
+ }
4595
+
4596
+ public setGatherStats(enabled: boolean): void {
4597
+ this.gatherStats = enabled
4598
+ if (!enabled) {
4599
+ this.frameTimes = []
4600
+ }
4601
+ }
4602
+
4603
+ public getSelection(): Selection | null {
4604
+ return this.currentSelection
4605
+ }
4606
+
4607
+ public get hasSelection(): boolean {
4608
+ return !!this.currentSelection
4609
+ }
4610
+
4611
+ public getSelectionContainer(): Renderable | null {
4612
+ return this.selectionContainers.length > 0 ? this.selectionContainers[this.selectionContainers.length - 1] : null
4613
+ }
4614
+
4615
+ public clearSelection(): void {
4616
+ if (this.currentSelection) {
4617
+ for (const renderable of this.currentSelection.touchedRenderables) {
4618
+ if (renderable.selectable && !renderable.isDestroyed) {
4619
+ renderable.onSelectionChanged(null)
4620
+ }
4621
+ }
4622
+ this.currentSelection = null
4623
+ }
4624
+ this.selectionContainers = []
4625
+ }
4626
+
4627
+ /**
4628
+ * Start a new selection at the given coordinates.
4629
+ * Used by both mouse and keyboard selection.
4630
+ */
4631
+ public startSelection(renderable: Renderable, x: number, y: number): void {
4632
+ if (!renderable.selectable) return
4633
+
4634
+ this.clearSelection()
4635
+ this.selectionContainers.push(renderable.parent || this.root)
4636
+ this.currentSelection = new Selection(renderable, { x, y }, { x, y })
4637
+ this.currentSelection.isStart = true
4638
+
4639
+ this.notifySelectablesOfSelectionChange()
4640
+ }
4641
+
4642
+ public updateSelection(
4643
+ currentRenderable: Renderable | undefined,
4644
+ x: number,
4645
+ y: number,
4646
+ options?: { finishDragging?: boolean },
4647
+ ): void {
4648
+ if (this.currentSelection) {
4649
+ this.currentSelection.isStart = false
4650
+ this.currentSelection.focus = { x, y }
4651
+
4652
+ if (options?.finishDragging) {
4653
+ this.currentSelection.isDragging = false
4654
+ }
4655
+
4656
+ if (this.selectionContainers.length > 0) {
4657
+ const currentContainer = this.selectionContainers[this.selectionContainers.length - 1]
4658
+
4659
+ if (!currentRenderable || !this.isWithinContainer(currentRenderable, currentContainer)) {
4660
+ const parentContainer = currentContainer.parent || this.root
4661
+ this.selectionContainers.push(parentContainer)
4662
+ } else if (currentRenderable && this.selectionContainers.length > 1) {
4663
+ let containerIndex = this.selectionContainers.indexOf(currentRenderable)
4664
+
4665
+ if (containerIndex === -1) {
4666
+ const immediateParent = currentRenderable.parent || this.root
4667
+ containerIndex = this.selectionContainers.indexOf(immediateParent)
4668
+ }
4669
+
4670
+ if (containerIndex !== -1 && containerIndex < this.selectionContainers.length - 1) {
4671
+ this.selectionContainers = this.selectionContainers.slice(0, containerIndex + 1)
4672
+ }
4673
+ }
4674
+ }
4675
+
4676
+ this.notifySelectablesOfSelectionChange()
4677
+ }
4678
+ }
4679
+
4680
+ public requestSelectionUpdate(): void {
4681
+ if (this.currentSelection?.isDragging) {
4682
+ const pointer = this._latestPointer
4683
+
4684
+ const maybeRenderableId = this.hitTest(pointer.x, pointer.y)
4685
+ const maybeRenderable = Renderable.renderablesByNumber.get(maybeRenderableId)
4686
+
4687
+ this.updateSelection(maybeRenderable, pointer.x, pointer.y)
4688
+ }
4689
+ }
4690
+
4691
+ private isWithinContainer(renderable: Renderable, container: Renderable): boolean {
4692
+ let current: Renderable | null = renderable
4693
+ while (current) {
4694
+ if (current === container) return true
4695
+ current = current.parent
4696
+ }
4697
+ return false
4698
+ }
4699
+
4700
+ private finishSelection(): void {
4701
+ if (this.currentSelection) {
4702
+ this.currentSelection.isDragging = false
4703
+ this.emit(CliRenderEvents.SELECTION, this.currentSelection)
4704
+ this.notifySelectablesOfSelectionChange()
4705
+ }
4706
+ }
4707
+
4708
+ private notifySelectablesOfSelectionChange(): void {
4709
+ const selectedRenderables: Renderable[] = []
4710
+ const touchedRenderables: Renderable[] = []
4711
+ const currentContainer =
4712
+ this.selectionContainers.length > 0 ? this.selectionContainers[this.selectionContainers.length - 1] : this.root
4713
+
4714
+ if (this.currentSelection) {
4715
+ this.walkSelectableRenderables(
4716
+ currentContainer,
4717
+ this.currentSelection.bounds,
4718
+ selectedRenderables,
4719
+ touchedRenderables,
4720
+ )
4721
+
4722
+ for (const renderable of this.currentSelection.touchedRenderables) {
4723
+ if (!touchedRenderables.includes(renderable) && !renderable.isDestroyed) {
4724
+ renderable.onSelectionChanged(null)
4725
+ }
4726
+ }
4727
+
4728
+ this.currentSelection.updateSelectedRenderables(selectedRenderables)
4729
+ this.currentSelection.updateTouchedRenderables(touchedRenderables)
4730
+ }
4731
+ }
4732
+
4733
+ private walkSelectableRenderables(
4734
+ container: Renderable,
4735
+ selectionBounds: ViewportBounds,
4736
+ selectedRenderables: Renderable[],
4737
+ touchedRenderables: Renderable[],
4738
+ ): void {
4739
+ const children = getObjectsInViewport<Renderable>(
4740
+ selectionBounds,
4741
+ container.getChildrenSortedByPrimaryAxis(),
4742
+ container.primaryAxis,
4743
+ 0, // padding
4744
+ 0, // minTriggerSize - always perform overlap checks for selection
4745
+ )
4746
+
4747
+ for (const child of children) {
4748
+ if (child.selectable) {
4749
+ const hasSelection = child.onSelectionChanged(this.currentSelection)
4750
+ if (hasSelection) {
4751
+ selectedRenderables.push(child)
4752
+ }
4753
+ touchedRenderables.push(child)
4754
+ }
4755
+ if (child.getChildrenCount() > 0) {
4756
+ this.walkSelectableRenderables(child, selectionBounds, selectedRenderables, touchedRenderables)
4757
+ }
4758
+ }
4759
+ }
4760
+
4761
+ public get paletteDetectionStatus(): "idle" | "detecting" | "cached" {
4762
+ if (this._paletteDetectionPromise) return "detecting"
4763
+ if (this._paletteCache.size > 0) return "cached"
4764
+ return "idle"
4765
+ }
4766
+
4767
+ private getCachedPaletteBySize(size: number): TerminalColors | null {
4768
+ const exactMatch = this._paletteCache.get(size)
4769
+ if (exactMatch) {
4770
+ return exactMatch
4771
+ }
4772
+
4773
+ const largerSize = [...this._paletteCache.keys()].sort((a, b) => a - b).find((candidate) => candidate >= size)
4774
+ if (largerSize === undefined) {
4775
+ return null
4776
+ }
4777
+
4778
+ const source = this._paletteCache.get(largerSize)
4779
+ if (!source) {
4780
+ return null
4781
+ }
4782
+
4783
+ const projected = {
4784
+ ...source,
4785
+ palette: source.palette.slice(0, size),
4786
+ }
4787
+
4788
+ this._paletteCache.set(size, projected)
4789
+ return projected
4790
+ }
4791
+
4792
+ private ensurePaletteDetector(): TerminalPaletteDetector {
4793
+ if (!this._paletteDetector) {
4794
+ const isTmux = Boolean(
4795
+ this.capabilities?.multiplexer === "tmux" || this.capabilities?.terminal?.name?.toLowerCase()?.includes("tmux"),
4796
+ )
4797
+ const isLegacyTmux =
4798
+ this.capabilities?.terminal?.name?.toLowerCase()?.includes("tmux") &&
4799
+ this.capabilities?.terminal?.version?.localeCompare("3.6") < 0
4800
+ this._paletteDetector = createTerminalPalette({
4801
+ stdin: this.stdin,
4802
+ stdout: this.stdout,
4803
+ writeFn: (data) => (this._isDestroyed ? false : this.writeOut(data)),
4804
+ isLegacyTmux,
4805
+ isTmux,
4806
+ oscSource: {
4807
+ subscribeOsc: this.subscribeOsc.bind(this),
4808
+ },
4809
+ clock: this.clock,
4810
+ })
4811
+ }
4812
+
4813
+ return this._paletteDetector
4814
+ }
4815
+
4816
+ private syncNativePaletteState(colors: TerminalColors | null): void {
4817
+ const signature = buildTerminalPaletteSignature(colors)
4818
+ if (this._nativePaletteSignature !== signature) {
4819
+ this._paletteEpoch = (this._paletteEpoch + 1) >>> 0
4820
+ }
4821
+
4822
+ this._nativePaletteSignature = signature
4823
+
4824
+ const normalized = normalizeTerminalPalette(colors)
4825
+ this.lib.rendererSetPaletteState(
4826
+ this.rendererPtr,
4827
+ normalized.palette,
4828
+ normalized.defaultForeground,
4829
+ normalized.defaultBackground,
4830
+ this._paletteEpoch,
4831
+ )
4832
+ }
4833
+
4834
+ private emitPaletteChange(colors: TerminalColors): void {
4835
+ if (this.listenerCount(CliRenderEvents.PALETTE) === 0) return
4836
+
4837
+ const signature = buildTerminalPaletteSignature(colors)
4838
+ if (this._emittedPaletteSignature === signature) return
4839
+
4840
+ this._emittedPaletteSignature = signature
4841
+ this.emit(CliRenderEvents.PALETTE, colors)
4842
+ }
4843
+
4844
+ private resolveXtVersionWaiters(): void {
4845
+ if (this.xtVersionWaiters.size === 0) return
4846
+
4847
+ const resolvers = [...this.xtVersionWaiters]
4848
+ this.xtVersionWaiters.clear()
4849
+ for (const resolve of resolvers) {
4850
+ resolve()
4851
+ }
4852
+ }
4853
+
4854
+ private waitForXtVersion(): Promise<void> {
4855
+ if (this.capabilityTimeoutId === null || this._capabilities?.terminal?.from_xtversion) {
4856
+ return Promise.resolve()
4857
+ }
4858
+
4859
+ return new Promise((resolve) => {
4860
+ this.xtVersionWaiters.add(resolve)
4861
+ })
4862
+ }
4863
+
4864
+ private shouldSyncNativePaletteState(): boolean {
4865
+ return Boolean(
4866
+ this._terminalIsSetup && !this._isDestroyed && this._capabilities?.ansi256 && !this._capabilities?.rgb,
4867
+ )
4868
+ }
4869
+
4870
+ private refreshPalette(): void {
4871
+ const publishGeneration = this._palettePublishGeneration
4872
+
4873
+ void this.getPalette({ size: NATIVE_PALETTE_QUERY_SIZE })
4874
+ .then(() => {
4875
+ if (this._isDestroyed) return
4876
+ if (this._palettePublishGeneration === publishGeneration) this.requestRender()
4877
+ })
4878
+ .catch(() => {})
4879
+ }
4880
+
4881
+ public clearPaletteCache(): void {
4882
+ this._palettePublishGeneration++
4883
+ this._paletteCache.clear()
4884
+ }
4885
+
4886
+ /**
4887
+ * Detects the terminal's color palette
4888
+ *
4889
+ * @returns Promise resolving to TerminalColors object containing palette and special colors
4890
+ * @throws Error if renderer is suspended
4891
+ */
4892
+ public async getPalette(options?: GetPaletteOptions): Promise<TerminalColors> {
4893
+ if (this._controlState === RendererControlState.EXPLICIT_SUSPENDED) {
4894
+ throw new Error("Cannot detect palette while renderer is suspended")
4895
+ }
4896
+
4897
+ const requestedSize = options?.size ?? 16
4898
+ const detectionTimeout = options?.timeout
4899
+
4900
+ const cachedPalette = this.getCachedPaletteBySize(requestedSize)
4901
+ if (cachedPalette) {
4902
+ return cachedPalette
4903
+ }
4904
+
4905
+ // tmux OSC 4 strategy depends on version. Env may provide it via
4906
+ // TERM_PROGRAM=tmux/TERM_PROGRAM_VERSION; otherwise wait for XTVERSION.
4907
+ const terminal = this._capabilities?.terminal
4908
+ const hasTmuxVersion = terminal?.name?.toLowerCase() === "tmux" && Boolean(terminal.version)
4909
+ if (this._capabilities?.multiplexer === "tmux" && !hasTmuxVersion) {
4910
+ await this.waitForXtVersion()
4911
+
4912
+ // Another caller may have populated the cache while this call waited.
4913
+ const afterCapabilityWait = this.getCachedPaletteBySize(requestedSize)
4914
+ if (afterCapabilityWait) {
4915
+ return afterCapabilityWait
4916
+ }
4917
+ }
4918
+
4919
+ if (this._paletteDetectionPromise) {
4920
+ if (this._paletteDetectionSize >= requestedSize) {
4921
+ return this._paletteDetectionPromise.then((palette) => {
4922
+ const cached = this.getCachedPaletteBySize(requestedSize)
4923
+ if (cached) {
4924
+ return cached
4925
+ }
4926
+
4927
+ const projected = {
4928
+ ...palette,
4929
+ palette: palette.palette.slice(0, requestedSize),
4930
+ }
4931
+ this._paletteCache.set(requestedSize, projected)
4932
+ return projected
4933
+ })
4934
+ }
4935
+
4936
+ await this._paletteDetectionPromise
4937
+
4938
+ const afterWait = this.getCachedPaletteBySize(requestedSize)
4939
+ if (afterWait) {
4940
+ return afterWait
4941
+ }
4942
+ }
4943
+
4944
+ const detector = this.ensurePaletteDetector()
4945
+ const publishGeneration = this._palettePublishGeneration
4946
+ this._paletteDetectionSize = requestedSize
4947
+ this._paletteDetectionPromise = detector
4948
+ .detect({ ...options, timeout: detectionTimeout })
4949
+ .then((result) => {
4950
+ this._paletteCache.set(result.palette.length, result)
4951
+ this._paletteDetectionPromise = null
4952
+ this._paletteDetectionSize = 0
4953
+
4954
+ if (!this._isDestroyed && this._palettePublishGeneration === publishGeneration) {
4955
+ this.emitPaletteChange(result)
4956
+ if (this.shouldSyncNativePaletteState() && result.palette.length >= NATIVE_PALETTE_QUERY_SIZE) {
4957
+ this.syncNativePaletteState(result)
4958
+ } else if (this.shouldSyncNativePaletteState() && !this._paletteCache.has(NATIVE_PALETTE_QUERY_SIZE)) {
4959
+ this.refreshPalette()
4960
+ }
4961
+ }
4962
+
4963
+ return result
4964
+ })
4965
+ .catch((error) => {
4966
+ this._paletteDetectionPromise = null
4967
+ this._paletteDetectionSize = 0
4968
+ throw error
4969
+ })
4970
+
4971
+ const detected = await this._paletteDetectionPromise
4972
+ const finalPalette = this.getCachedPaletteBySize(requestedSize) ?? detected
4973
+ return finalPalette
4974
+ }
4975
+ }