@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.
- package/README.md +63 -0
- package/dev/keypress-debug-renderer.ts +148 -0
- package/dev/keypress-debug.ts +43 -0
- package/dev/print-env-vars.ts +32 -0
- package/dev/test-tmux-graphics-334.sh +68 -0
- package/dev/test-tmux-theme-queries.sh +151 -0
- package/dev/thai-debug-test.ts +68 -0
- package/dev/theme-mode.ts +290 -0
- package/docs/development.md +144 -0
- package/package.json +102 -0
- package/prebuilt/aarch64-android/libopentui.so +0 -0
- package/scripts/build-android.ts +226 -0
- package/scripts/build-native-termux.sh +525 -0
- package/scripts/build.ts +530 -0
- package/scripts/dist-test.ts +312 -0
- package/scripts/package-prebuilt.ts +110 -0
- package/scripts/publish.ts +60 -0
- package/scripts/test-node-hook.mjs +16 -0
- package/scripts/test-node.ts +241 -0
- package/scripts/vendor-deps.sh +73 -0
- package/src/NativeSpanFeed.ts +375 -0
- package/src/Renderable.ts +1878 -0
- package/src/__snapshots__/buffer.test.ts.snap +28 -0
- package/src/animation/Timeline.test.ts +2709 -0
- package/src/animation/Timeline.ts +598 -0
- package/src/ansi.ts +22 -0
- package/src/audio.ts +479 -0
- package/src/benchmark/attenuation-benchmark.ts +81 -0
- package/src/benchmark/audio-playback-benchmark.ts +344 -0
- package/src/benchmark/box-draw-benchmark.ts +1042 -0
- package/src/benchmark/colormatrix-benchmark.ts +123 -0
- package/src/benchmark/gain-benchmark.ts +80 -0
- package/src/benchmark/latest-all-bench-run.json +707 -0
- package/src/benchmark/latest-async-bench-run.json +336 -0
- package/src/benchmark/latest-default-bench-run.json +657 -0
- package/src/benchmark/latest-large-bench-run.json +707 -0
- package/src/benchmark/latest-quick-bench-run.json +207 -0
- package/src/benchmark/layout-benchmark.ts +2547 -0
- package/src/benchmark/markdown-benchmark.ts +1796 -0
- package/src/benchmark/native-span-feed-async-benchmark.ts +355 -0
- package/src/benchmark/native-span-feed-benchmark.md +56 -0
- package/src/benchmark/native-span-feed-benchmark.ts +596 -0
- package/src/benchmark/native-span-feed-compare.ts +280 -0
- package/src/benchmark/render-traversal-benchmark.ts +928 -0
- package/src/benchmark/text-buffer-render-benchmark.ts +874 -0
- package/src/benchmark/text-table-benchmark.ts +948 -0
- package/src/buffer.test.ts +292 -0
- package/src/buffer.ts +564 -0
- package/src/console.test.ts +629 -0
- package/src/console.ts +1253 -0
- package/src/edit-buffer.test.ts +1852 -0
- package/src/edit-buffer.ts +417 -0
- package/src/editor-view.test.ts +1056 -0
- package/src/editor-view.ts +290 -0
- package/src/index.ts +25 -0
- package/src/lib/KeyHandler.integration.test.ts +292 -0
- package/src/lib/KeyHandler.stopPropagation.test.ts +289 -0
- package/src/lib/KeyHandler.test.ts +662 -0
- package/src/lib/KeyHandler.ts +222 -0
- package/src/lib/RGBA.test.ts +60 -0
- package/src/lib/RGBA.ts +343 -0
- package/src/lib/ascii.font.ts +330 -0
- package/src/lib/border.test.ts +83 -0
- package/src/lib/border.ts +170 -0
- package/src/lib/bunfs.test.ts +27 -0
- package/src/lib/bunfs.ts +18 -0
- package/src/lib/clipboard.test.ts +63 -0
- package/src/lib/clipboard.ts +41 -0
- package/src/lib/clock.ts +35 -0
- package/src/lib/data-paths.test.ts +133 -0
- package/src/lib/data-paths.ts +109 -0
- package/src/lib/debounce.ts +106 -0
- package/src/lib/detect-links.test.ts +98 -0
- package/src/lib/detect-links.ts +56 -0
- package/src/lib/env.test.ts +228 -0
- package/src/lib/env.ts +209 -0
- package/src/lib/extmarks-history.ts +51 -0
- package/src/lib/extmarks-multiwidth.test.ts +324 -0
- package/src/lib/extmarks.test.ts +3547 -0
- package/src/lib/extmarks.ts +861 -0
- package/src/lib/fonts/block.json +405 -0
- package/src/lib/fonts/grid.json +265 -0
- package/src/lib/fonts/huge.json +741 -0
- package/src/lib/fonts/pallet.json +314 -0
- package/src/lib/fonts/shade.json +591 -0
- package/src/lib/fonts/slick.json +321 -0
- package/src/lib/fonts/tiny.json +69 -0
- package/src/lib/hast-styled-text.ts +59 -0
- package/src/lib/index.ts +22 -0
- package/src/lib/keybinding.internal.test.ts +342 -0
- package/src/lib/keybinding.internal.ts +182 -0
- package/src/lib/objects-in-viewport.test.ts +789 -0
- package/src/lib/objects-in-viewport.ts +153 -0
- package/src/lib/output.capture.ts +58 -0
- package/src/lib/parse.keypress-kitty.protocol.test.ts +347 -0
- package/src/lib/parse.keypress-kitty.test.ts +712 -0
- package/src/lib/parse.keypress-kitty.ts +470 -0
- package/src/lib/parse.keypress.test.ts +2020 -0
- package/src/lib/parse.keypress.ts +480 -0
- package/src/lib/parse.mouse.test.ts +552 -0
- package/src/lib/parse.mouse.ts +232 -0
- package/src/lib/paste.ts +18 -0
- package/src/lib/queue.ts +65 -0
- package/src/lib/render-geometry.ts +36 -0
- package/src/lib/renderable.validations.test.ts +87 -0
- package/src/lib/renderable.validations.ts +83 -0
- package/src/lib/scroll-acceleration.ts +98 -0
- package/src/lib/selection.ts +261 -0
- package/src/lib/singleton.ts +34 -0
- package/src/lib/stdin-parser.test.ts +2439 -0
- package/src/lib/stdin-parser.ts +2007 -0
- package/src/lib/styled-text.ts +178 -0
- package/src/lib/terminal-capability-detection.test.ts +226 -0
- package/src/lib/terminal-capability-detection.ts +98 -0
- package/src/lib/terminal-palette.test.ts +954 -0
- package/src/lib/terminal-palette.ts +441 -0
- package/src/lib/tree-sitter/assets/README.md +119 -0
- package/src/lib/tree-sitter/assets/javascript/highlights.scm +205 -0
- package/src/lib/tree-sitter/assets/javascript/tree-sitter-javascript.wasm +0 -0
- package/src/lib/tree-sitter/assets/markdown/highlights.scm +150 -0
- package/src/lib/tree-sitter/assets/markdown/injections.scm +27 -0
- package/src/lib/tree-sitter/assets/markdown/tree-sitter-markdown.wasm +0 -0
- package/src/lib/tree-sitter/assets/markdown_inline/highlights.scm +115 -0
- package/src/lib/tree-sitter/assets/markdown_inline/tree-sitter-markdown_inline.wasm +0 -0
- package/src/lib/tree-sitter/assets/typescript/highlights.scm +604 -0
- package/src/lib/tree-sitter/assets/typescript/tree-sitter-typescript.wasm +0 -0
- package/src/lib/tree-sitter/assets/update.ts +360 -0
- package/src/lib/tree-sitter/assets/zig/highlights.scm +284 -0
- package/src/lib/tree-sitter/assets/zig/tree-sitter-zig.wasm +0 -0
- package/src/lib/tree-sitter/assets.d.ts +9 -0
- package/src/lib/tree-sitter/cache.test.ts +291 -0
- package/src/lib/tree-sitter/client.test.ts +1434 -0
- package/src/lib/tree-sitter/client.ts +847 -0
- package/src/lib/tree-sitter/default-parsers.ts +133 -0
- package/src/lib/tree-sitter/download-utils.ts +148 -0
- package/src/lib/tree-sitter/index.ts +75 -0
- package/src/lib/tree-sitter/parser.worker.ts +1098 -0
- package/src/lib/tree-sitter/parsers-config.ts +81 -0
- package/src/lib/tree-sitter/resolve-ft.test.ts +55 -0
- package/src/lib/tree-sitter/resolve-ft.ts +189 -0
- package/src/lib/tree-sitter/types.ts +132 -0
- package/src/lib/tree-sitter/update-assets.ts +10 -0
- package/src/lib/tree-sitter-styled-text.test.ts +1253 -0
- package/src/lib/tree-sitter-styled-text.ts +314 -0
- package/src/lib/validate-dir-name.ts +55 -0
- package/src/lib/yoga.options.test.ts +628 -0
- package/src/lib/yoga.options.ts +346 -0
- package/src/native-event-worker-repro.test.ts +48 -0
- package/src/native-event-worker-repro.worker.ts +9 -0
- package/src/native-handle.test.ts +221 -0
- package/src/platform/ffi.test.ts +516 -0
- package/src/platform/ffi.ts +653 -0
- package/src/platform/runtime.test.ts +52 -0
- package/src/platform/runtime.ts +121 -0
- package/src/platform/test.ts +45 -0
- package/src/platform/worker-handler-cleanup.fixture.ts +12 -0
- package/src/platform/worker-onmessage-startup.fixture.ts +10 -0
- package/src/platform/worker-startup.fixture.ts +9 -0
- package/src/platform/worker.node-test.ts +153 -0
- package/src/platform/worker.ts +425 -0
- package/src/plugins/core-slot.ts +579 -0
- package/src/plugins/registry.ts +402 -0
- package/src/plugins/types.ts +46 -0
- package/src/post/effects.ts +948 -0
- package/src/post/filters.ts +512 -0
- package/src/post/matrices.ts +288 -0
- package/src/renderables/ASCIIFont.ts +219 -0
- package/src/renderables/Box.test.ts +474 -0
- package/src/renderables/Box.ts +353 -0
- package/src/renderables/Code.test.ts +2388 -0
- package/src/renderables/Code.ts +555 -0
- package/src/renderables/Diff.regression.test.ts +381 -0
- package/src/renderables/Diff.test.ts +3212 -0
- package/src/renderables/Diff.ts +1278 -0
- package/src/renderables/EditBufferRenderable.test.ts +288 -0
- package/src/renderables/EditBufferRenderable.ts +1152 -0
- package/src/renderables/FrameBuffer.ts +47 -0
- package/src/renderables/Input.test.ts +1310 -0
- package/src/renderables/Input.ts +273 -0
- package/src/renderables/LineNumberRenderable.ts +725 -0
- package/src/renderables/Markdown.ts +2144 -0
- package/src/renderables/ScrollBar.ts +423 -0
- package/src/renderables/ScrollBox.ts +903 -0
- package/src/renderables/Select.test.ts +1134 -0
- package/src/renderables/Select.ts +535 -0
- package/src/renderables/Slider.test.ts +456 -0
- package/src/renderables/Slider.ts +342 -0
- package/src/renderables/TabSelect.test.ts +238 -0
- package/src/renderables/TabSelect.ts +447 -0
- package/src/renderables/Text.selection-buffer.test.ts +118 -0
- package/src/renderables/Text.test.ts +2662 -0
- package/src/renderables/Text.ts +147 -0
- package/src/renderables/TextBufferRenderable.ts +504 -0
- package/src/renderables/TextNode.test.ts +1137 -0
- package/src/renderables/TextNode.ts +379 -0
- package/src/renderables/TextTable.test.ts +1411 -0
- package/src/renderables/TextTable.ts +1434 -0
- package/src/renderables/Textarea.ts +425 -0
- package/src/renderables/TimeToFirstDraw.ts +89 -0
- package/src/renderables/__snapshots__/Code.test.ts.snap +13 -0
- package/src/renderables/__snapshots__/Diff.test.ts.snap +785 -0
- package/src/renderables/__snapshots__/Text.test.ts.snap +421 -0
- package/src/renderables/__snapshots__/TextTable.test.ts.snap +215 -0
- package/src/renderables/__tests__/Code.test.ts +55 -0
- package/src/renderables/__tests__/LineNumberRenderable.scrollbox-simple.test.ts +144 -0
- package/src/renderables/__tests__/LineNumberRenderable.scrollbox.test.ts +827 -0
- package/src/renderables/__tests__/LineNumberRenderable.test.ts +1906 -0
- package/src/renderables/__tests__/LineNumberRenderable.wrapping.test.ts +85 -0
- package/src/renderables/__tests__/Markdown.code-colors.test.ts +288 -0
- package/src/renderables/__tests__/Markdown.test.ts +4175 -0
- package/src/renderables/__tests__/MultiRenderable.selection.test.ts +87 -0
- package/src/renderables/__tests__/Textarea.buffer.test.ts +683 -0
- package/src/renderables/__tests__/Textarea.destroyed-events.test.ts +675 -0
- package/src/renderables/__tests__/Textarea.editing.test.ts +2120 -0
- package/src/renderables/__tests__/Textarea.error-handling.test.ts +35 -0
- package/src/renderables/__tests__/Textarea.events.test.ts +738 -0
- package/src/renderables/__tests__/Textarea.highlights.test.ts +590 -0
- package/src/renderables/__tests__/Textarea.keybinding.test.ts +3275 -0
- package/src/renderables/__tests__/Textarea.paste.test.ts +357 -0
- package/src/renderables/__tests__/Textarea.rendering.test.ts +1927 -0
- package/src/renderables/__tests__/Textarea.scroll.test.ts +733 -0
- package/src/renderables/__tests__/Textarea.selection.test.ts +1589 -0
- package/src/renderables/__tests__/Textarea.stress.test.ts +670 -0
- package/src/renderables/__tests__/Textarea.undo-redo.test.ts +383 -0
- package/src/renderables/__tests__/Textarea.visual-lines.test.ts +310 -0
- package/src/renderables/__tests__/__snapshots__/LineNumberRenderable.code.test.ts.snap +221 -0
- package/src/renderables/__tests__/__snapshots__/LineNumberRenderable.scrollbox-simple.test.ts.snap +89 -0
- package/src/renderables/__tests__/__snapshots__/LineNumberRenderable.scrollbox.test.ts.snap +457 -0
- package/src/renderables/__tests__/__snapshots__/LineNumberRenderable.test.ts.snap +158 -0
- package/src/renderables/__tests__/__snapshots__/Textarea.rendering.test.ts.snap +387 -0
- package/src/renderables/__tests__/markdown-parser.test.ts +241 -0
- package/src/renderables/__tests__/renderable-test-utils.ts +60 -0
- package/src/renderables/composition/README.md +8 -0
- package/src/renderables/composition/VRenderable.ts +32 -0
- package/src/renderables/composition/constructs.ts +127 -0
- package/src/renderables/composition/vnode.ts +277 -0
- package/src/renderables/index.ts +23 -0
- package/src/renderables/markdown-parser.ts +79 -0
- package/src/renderer-theme-mode.ts +199 -0
- package/src/renderer.ts +4975 -0
- package/src/runtime-plugin-support-configure.ts +63 -0
- package/src/runtime-plugin-support.ts +13 -0
- package/src/runtime-plugin.ts +626 -0
- package/src/specs/terminal-startup.md +64 -0
- package/src/syntax-style.test.ts +841 -0
- package/src/syntax-style.ts +274 -0
- package/src/testing/README.md +219 -0
- package/src/testing/bun-test-node.ts +880 -0
- package/src/testing/capture-spans.test.ts +216 -0
- package/src/testing/integration.test.ts +276 -0
- package/src/testing/manual-clock.ts +117 -0
- package/src/testing/mock-keys.test.ts +1378 -0
- package/src/testing/mock-keys.ts +457 -0
- package/src/testing/mock-mouse.test.ts +218 -0
- package/src/testing/mock-mouse.ts +247 -0
- package/src/testing/mock-tree-sitter-client.ts +81 -0
- package/src/testing/spy.ts +13 -0
- package/src/testing/terminal-capabilities.ts +47 -0
- package/src/testing/test-recorder.test.ts +410 -0
- package/src/testing/test-recorder.ts +133 -0
- package/src/testing/test-renderer.ts +375 -0
- package/src/testing/test-renderer.wait.test.ts +278 -0
- package/src/testing/test-streams.ts +31 -0
- package/src/testing.ts +9 -0
- package/src/tests/__snapshots__/absolute-positioning.snapshot.test.ts.snap +481 -0
- package/src/tests/__snapshots__/renderable.snapshot.test.ts.snap +19 -0
- package/src/tests/__snapshots__/scrollbox.test.ts.snap +29 -0
- package/src/tests/absolute-positioning.snapshot.test.ts +638 -0
- package/src/tests/allocator-stats.test.ts +53 -0
- package/src/tests/audio.test.ts +303 -0
- package/src/tests/destroy-during-render.test.ts +200 -0
- package/src/tests/destroy-on-exit.fixture.ts +36 -0
- package/src/tests/destroy-on-exit.test.ts +62 -0
- package/src/tests/ffi-borrowed-pointer-callsites.test.ts +153 -0
- package/src/tests/hover-cursor.test.ts +98 -0
- package/src/tests/native-backed-measurement-lifecycle.test.ts +133 -0
- package/src/tests/native-backed-measurement-parity.test.ts +322 -0
- package/src/tests/native-span-feed-async.test.ts +173 -0
- package/src/tests/native-span-feed-close.test.ts +120 -0
- package/src/tests/native-span-feed-coverage.test.ts +227 -0
- package/src/tests/native-span-feed-edge-cases.test.ts +384 -0
- package/src/tests/native-span-feed-use-after-free.test.ts +45 -0
- package/src/tests/opacity.test.ts +123 -0
- package/src/tests/renderable.snapshot.test.ts +524 -0
- package/src/tests/renderable.test.ts +1851 -0
- package/src/tests/renderer.clock.test.ts +270 -0
- package/src/tests/renderer.console-startup.test.ts +2151 -0
- package/src/tests/renderer.control.test.ts +458 -0
- package/src/tests/renderer.core-slot-binding.test.ts +962 -0
- package/src/tests/renderer.cursor.test.ts +26 -0
- package/src/tests/renderer.custom-stdout.test.ts +1112 -0
- package/src/tests/renderer.destroy-during-render.test.ts +147 -0
- package/src/tests/renderer.focus-restore.test.ts +257 -0
- package/src/tests/renderer.focus.test.ts +329 -0
- package/src/tests/renderer.idle.test.ts +219 -0
- package/src/tests/renderer.input.test.ts +2615 -0
- package/src/tests/renderer.kitty-flags.test.ts +195 -0
- package/src/tests/renderer.lifecycle.test.ts +34 -0
- package/src/tests/renderer.mouse.test.ts +1315 -0
- package/src/tests/renderer.notifications.test.ts +27 -0
- package/src/tests/renderer.palette.test.ts +1199 -0
- package/src/tests/renderer.remote.test.ts +247 -0
- package/src/tests/renderer.render-stats.test.ts +146 -0
- package/src/tests/renderer.scrollback-surface.test.ts +474 -0
- package/src/tests/renderer.selection.test.ts +138 -0
- package/src/tests/renderer.slot-registry.test.ts +684 -0
- package/src/tests/renderer.tracker.test.ts +202 -0
- package/src/tests/renderer.useMouse.test.ts +83 -0
- package/src/tests/runtime-plugin-node-modules-cycle.fixture.ts +76 -0
- package/src/tests/runtime-plugin-node-modules-import-like-string.fixture.ts +48 -0
- package/src/tests/runtime-plugin-node-modules-mjs.fixture.ts +43 -0
- package/src/tests/runtime-plugin-node-modules-no-bare-rewrite.fixture.ts +67 -0
- package/src/tests/runtime-plugin-node-modules-package-type-cache.fixture.ts +72 -0
- package/src/tests/runtime-plugin-node-modules-runtime-specifier.fixture.ts +44 -0
- package/src/tests/runtime-plugin-node-modules-scoped-package-bare-rewrite.fixture.ts +85 -0
- package/src/tests/runtime-plugin-path-alias.fixture.ts +43 -0
- package/src/tests/runtime-plugin-resolve-roots.fixture.ts +65 -0
- package/src/tests/runtime-plugin-support-configure.fixture.ts +31 -0
- package/src/tests/runtime-plugin-support-late-addition.fixture.ts +17 -0
- package/src/tests/runtime-plugin-support.fixture.ts +11 -0
- package/src/tests/runtime-plugin-support.test.ts +54 -0
- package/src/tests/runtime-plugin-windows-file-url.fixture.ts +30 -0
- package/src/tests/runtime-plugin.fixture.ts +40 -0
- package/src/tests/runtime-plugin.test.ts +369 -0
- package/src/tests/scrollbox-culling-bug.test.ts +114 -0
- package/src/tests/scrollbox-hitgrid-resize.test.ts +136 -0
- package/src/tests/scrollbox-hitgrid.test.ts +909 -0
- package/src/tests/scrollbox.test.ts +1895 -0
- package/src/tests/wrap-resize-perf.test.ts +276 -0
- package/src/tests/yoga-api-coverage.test.ts +409 -0
- package/src/tests/yoga-callback-stress.test.ts +122 -0
- package/src/tests/yoga-ffi.test.ts +117 -0
- package/src/tests/yoga-setters.test.ts +921 -0
- package/src/tests/yoga-upstream/YGAlignBaselineTest.test.ts +124 -0
- package/src/tests/yoga-upstream/YGComputedBorderTest.test.ts +27 -0
- package/src/tests/yoga-upstream/YGComputedMarginTest.test.ts +27 -0
- package/src/tests/yoga-upstream/YGComputedPaddingTest.test.ts +27 -0
- package/src/tests/yoga-upstream/YGDirtiedTest.test.ts +157 -0
- package/src/tests/yoga-upstream/YGErrataTest.test.ts +40 -0
- package/src/tests/yoga-upstream/YGFlexBasisAuto.test.ts +23 -0
- package/src/tests/yoga-upstream/YGHasNewLayout.test.ts +81 -0
- package/src/tests/yoga-upstream/YGMeasureCacheTest.test.ts +31 -0
- package/src/tests/yoga-upstream/YGMeasureTest.test.ts +70 -0
- package/src/tests/yoga-upstream/generated/YGAbsolutePositionTest.test.ts +1864 -0
- package/src/tests/yoga-upstream/generated/YGAlignContentTest.test.ts +5829 -0
- package/src/tests/yoga-upstream/generated/YGAlignItemsTest.test.ts +2444 -0
- package/src/tests/yoga-upstream/generated/YGAlignSelfTest.test.ts +293 -0
- package/src/tests/yoga-upstream/generated/YGAndroidNewsFeed.test.ts +315 -0
- package/src/tests/yoga-upstream/generated/YGAspectRatioTest.test.ts +231 -0
- package/src/tests/yoga-upstream/generated/YGAutoTest.test.ts +347 -0
- package/src/tests/yoga-upstream/generated/YGBorderTest.test.ts +256 -0
- package/src/tests/yoga-upstream/generated/YGBoxSizingTest.test.ts +2747 -0
- package/src/tests/yoga-upstream/generated/YGDimensionTest.test.ts +129 -0
- package/src/tests/yoga-upstream/generated/YGDisplayContentsTest.test.ts +109 -0
- package/src/tests/yoga-upstream/generated/YGDisplayTest.test.ts +1097 -0
- package/src/tests/yoga-upstream/generated/YGFlexDirectionTest.test.ts +4562 -0
- package/src/tests/yoga-upstream/generated/YGFlexTest.test.ts +677 -0
- package/src/tests/yoga-upstream/generated/YGFlexWrapTest.test.ts +2071 -0
- package/src/tests/yoga-upstream/generated/YGGapTest.test.ts +3303 -0
- package/src/tests/yoga-upstream/generated/YGIntrinsicSizeTest.test.ts +582 -0
- package/src/tests/yoga-upstream/generated/YGJustifyContentTest.test.ts +2150 -0
- package/src/tests/yoga-upstream/generated/YGMarginTest.test.ts +1963 -0
- package/src/tests/yoga-upstream/generated/YGMinMaxDimensionTest.test.ts +1446 -0
- package/src/tests/yoga-upstream/generated/YGPaddingTest.test.ts +354 -0
- package/src/tests/yoga-upstream/generated/YGPercentageTest.test.ts +1760 -0
- package/src/tests/yoga-upstream/generated/YGRoundingTest.test.ts +1161 -0
- package/src/tests/yoga-upstream/generated/YGSizeOverflowTest.test.ts +207 -0
- package/src/tests/yoga-upstream/generated/YGStaticPositionTest.test.ts +6338 -0
- package/src/tests/yoga-upstream/tools/MeasureCounter.ts +54 -0
- package/src/tests/yoga-upstream/tools/utils.ts +81 -0
- package/src/text-buffer-view.test.ts +722 -0
- package/src/text-buffer-view.ts +193 -0
- package/src/text-buffer.test.ts +512 -0
- package/src/text-buffer.ts +250 -0
- package/src/types.ts +199 -0
- package/src/utils.ts +88 -0
- package/src/yoga.ts +1146 -0
- package/src/zig/ansi.zig +502 -0
- package/src/zig/audio.zig +1206 -0
- package/src/zig/bench/README.md +50 -0
- package/src/zig/bench/buffer-color-blending_bench.zig +350 -0
- package/src/zig/bench/buffer-draw-box_bench.zig +786 -0
- package/src/zig/bench/buffer-draw-text-buffer_bench.zig +894 -0
- package/src/zig/bench/edit-buffer_bench.zig +476 -0
- package/src/zig/bench/editor-view_bench.zig +169 -0
- package/src/zig/bench/native-span-feed_bench.zig +107 -0
- package/src/zig/bench/rope-markers_bench.zig +713 -0
- package/src/zig/bench/rope_bench.zig +514 -0
- package/src/zig/bench/styled-text_bench.zig +476 -0
- package/src/zig/bench/text-buffer-coords_bench.zig +362 -0
- package/src/zig/bench/text-buffer-view_bench.zig +459 -0
- package/src/zig/bench/text-chunk-graphemes_bench.zig +272 -0
- package/src/zig/bench/utf8_bench.zig +799 -0
- package/src/zig/bench-utils.zig +431 -0
- package/src/zig/bench.zig +223 -0
- package/src/zig/buffer-methods.zig +215 -0
- package/src/zig/buffer.zig +2515 -0
- package/src/zig/build.zig +700 -0
- package/src/zig/build.zig.zon +19 -0
- package/src/zig/edit-buffer.zig +900 -0
- package/src/zig/editor-view.zig +812 -0
- package/src/zig/event-bus.zig +28 -0
- package/src/zig/event-emitter.zig +65 -0
- package/src/zig/file-logger.zig +92 -0
- package/src/zig/grapheme.zig +838 -0
- package/src/zig/handles.zig +267 -0
- package/src/zig/lib.zig +2813 -0
- package/src/zig/link.zig +349 -0
- package/src/zig/logger.zig +43 -0
- package/src/zig/mem-registry.zig +126 -0
- package/src/zig/miniaudio_shim.c +21 -0
- package/src/zig/native-renderable.zig +112 -0
- package/src/zig/native-span-feed-bench-lib.zig +7 -0
- package/src/zig/native-span-feed.zig +747 -0
- package/src/zig/renderer-output.zig +677 -0
- package/src/zig/renderer.zig +2106 -0
- package/src/zig/rope.zig +1220 -0
- package/src/zig/split-scrollback.zig +88 -0
- package/src/zig/syntax-style.zig +174 -0
- package/src/zig/terminal.zig +1652 -0
- package/src/zig/test.zig +82 -0
- package/src/zig/tests/README.md +18 -0
- package/src/zig/tests/ansi_test.zig +18 -0
- package/src/zig/tests/audio_test.zig +439 -0
- package/src/zig/tests/buffer-methods_test.zig +1111 -0
- package/src/zig/tests/buffer_test.zig +2926 -0
- package/src/zig/tests/edit-buffer-history_test.zig +299 -0
- package/src/zig/tests/edit-buffer_test.zig +1688 -0
- package/src/zig/tests/editor-view_test.zig +3454 -0
- package/src/zig/tests/event-emitter_test.zig +249 -0
- package/src/zig/tests/grapheme_test.zig +1329 -0
- package/src/zig/tests/handles_test.zig +123 -0
- package/src/zig/tests/link_test.zig +220 -0
- package/src/zig/tests/mem-registry_test.zig +473 -0
- package/src/zig/tests/memory_leak_regression_test.zig +234 -0
- package/src/zig/tests/native-renderable_test.zig +16 -0
- package/src/zig/tests/native-span-feed_test.zig +1266 -0
- package/src/zig/tests/renderer_test.zig +2604 -0
- package/src/zig/tests/rope-nested_test.zig +712 -0
- package/src/zig/tests/rope_fuzz_test.zig +238 -0
- package/src/zig/tests/rope_test.zig +2362 -0
- package/src/zig/tests/segment-merge.test.zig +148 -0
- package/src/zig/tests/split-scrollback_test.zig +70 -0
- package/src/zig/tests/syntax-style_test.zig +558 -0
- package/src/zig/tests/terminal_test.zig +1489 -0
- package/src/zig/tests/test-renderer.zig +126 -0
- package/src/zig/tests/text-buffer-drawing_test.zig +3291 -0
- package/src/zig/tests/text-buffer-highlights_test.zig +706 -0
- package/src/zig/tests/text-buffer-iterators_test.zig +776 -0
- package/src/zig/tests/text-buffer-segment_test.zig +320 -0
- package/src/zig/tests/text-buffer-selection_test.zig +1056 -0
- package/src/zig/tests/text-buffer-selection_viewport_test.zig +358 -0
- package/src/zig/tests/text-buffer-view_test.zig +3672 -0
- package/src/zig/tests/text-buffer_test.zig +2191 -0
- package/src/zig/tests/unicode-width-map.zon +3909 -0
- package/src/zig/tests/utf8_no_zwj_test.zig +260 -0
- package/src/zig/tests/utf8_test.zig +4060 -0
- package/src/zig/tests/utf8_wcwidth_cursor_test.zig +267 -0
- package/src/zig/tests/utf8_wcwidth_test.zig +357 -0
- package/src/zig/tests/word-wrap-editing_test.zig +498 -0
- package/src/zig/tests/wrap-cache-perf_test.zig +113 -0
- package/src/zig/tests/yoga_test.zig +65 -0
- package/src/zig/text-buffer-iterators.zig +496 -0
- package/src/zig/text-buffer-segment.zig +403 -0
- package/src/zig/text-buffer-view.zig +1428 -0
- package/src/zig/text-buffer.zig +1272 -0
- package/src/zig/utf8.zig +1954 -0
- package/src/zig/utils.zig +12 -0
- package/src/zig/uucode-stub.zig +76 -0
- package/src/zig/vendor/miniaudio/miniaudio.h +93468 -0
- package/src/zig/yoga.zig +658 -0
- package/src/zig-structs.ts +381 -0
- package/src/zig.ts +5260 -0
- package/tsconfig.build.json +17 -0
- package/tsconfig.json +27 -0
- package/tsconfig.node-test.json +138 -0
package/src/renderer.ts
ADDED
|
@@ -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
|
+
}
|