nastech-tui 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (424) hide show
  1. package/.prettierrc +11 -0
  2. package/README.md +346 -0
  3. package/eslint.config.mjs +111 -0
  4. package/package.json +51 -0
  5. package/packages/nastech-ink/ambient.d.ts +83 -0
  6. package/packages/nastech-ink/index.d.ts +40 -0
  7. package/packages/nastech-ink/index.js +1 -0
  8. package/packages/nastech-ink/nastech-ink/ambient.d.ts +83 -0
  9. package/packages/nastech-ink/nastech-ink/index.d.ts +40 -0
  10. package/packages/nastech-ink/nastech-ink/index.js +1 -0
  11. package/packages/nastech-ink/nastech-ink/package.json +54 -0
  12. package/packages/nastech-ink/nastech-ink/src/bootstrap/state.ts +9 -0
  13. package/packages/nastech-ink/nastech-ink/src/entry-exports.ts +32 -0
  14. package/packages/nastech-ink/nastech-ink/src/hooks/use-stderr.ts +15 -0
  15. package/packages/nastech-ink/nastech-ink/src/hooks/use-stdout.ts +15 -0
  16. package/packages/nastech-ink/nastech-ink/src/ink/Ansi.tsx +435 -0
  17. package/packages/nastech-ink/nastech-ink/src/ink/app-mouse.test.ts +123 -0
  18. package/packages/nastech-ink/nastech-ink/src/ink/bidi.ts +145 -0
  19. package/packages/nastech-ink/nastech-ink/src/ink/cache-eviction.ts +45 -0
  20. package/packages/nastech-ink/nastech-ink/src/ink/clearTerminal.ts +68 -0
  21. package/packages/nastech-ink/nastech-ink/src/ink/colorize.test.ts +60 -0
  22. package/packages/nastech-ink/nastech-ink/src/ink/colorize.ts +277 -0
  23. package/packages/nastech-ink/nastech-ink/src/ink/components/AlternateScreen.tsx +133 -0
  24. package/packages/nastech-ink/nastech-ink/src/ink/components/App.tsx +830 -0
  25. package/packages/nastech-ink/nastech-ink/src/ink/components/AppContext.ts +20 -0
  26. package/packages/nastech-ink/nastech-ink/src/ink/components/Box.tsx +294 -0
  27. package/packages/nastech-ink/nastech-ink/src/ink/components/Button.tsx +236 -0
  28. package/packages/nastech-ink/nastech-ink/src/ink/components/ClockContext.tsx +133 -0
  29. package/packages/nastech-ink/nastech-ink/src/ink/components/CursorAdvanceContext.ts +35 -0
  30. package/packages/nastech-ink/nastech-ink/src/ink/components/CursorDeclarationContext.ts +28 -0
  31. package/packages/nastech-ink/nastech-ink/src/ink/components/ErrorOverview.tsx +130 -0
  32. package/packages/nastech-ink/nastech-ink/src/ink/components/Link.tsx +38 -0
  33. package/packages/nastech-ink/nastech-ink/src/ink/components/Newline.tsx +43 -0
  34. package/packages/nastech-ink/nastech-ink/src/ink/components/NoSelect.tsx +73 -0
  35. package/packages/nastech-ink/nastech-ink/src/ink/components/RawAnsi.tsx +61 -0
  36. package/packages/nastech-ink/nastech-ink/src/ink/components/ScrollBox.tsx +290 -0
  37. package/packages/nastech-ink/nastech-ink/src/ink/components/Spacer.tsx +23 -0
  38. package/packages/nastech-ink/nastech-ink/src/ink/components/StdinContext.ts +25 -0
  39. package/packages/nastech-ink/nastech-ink/src/ink/components/TerminalFocusContext.tsx +63 -0
  40. package/packages/nastech-ink/nastech-ink/src/ink/components/TerminalSizeContext.tsx +7 -0
  41. package/packages/nastech-ink/nastech-ink/src/ink/components/Text.test.ts +38 -0
  42. package/packages/nastech-ink/nastech-ink/src/ink/components/Text.tsx +336 -0
  43. package/packages/nastech-ink/nastech-ink/src/ink/constants.ts +6 -0
  44. package/packages/nastech-ink/nastech-ink/src/ink/cursor.ts +5 -0
  45. package/packages/nastech-ink/nastech-ink/src/ink/devtools.ts +2 -0
  46. package/packages/nastech-ink/nastech-ink/src/ink/dom.ts +495 -0
  47. package/packages/nastech-ink/nastech-ink/src/ink/events/click-event.ts +38 -0
  48. package/packages/nastech-ink/nastech-ink/src/ink/events/cmd-shortcuts.test.ts +65 -0
  49. package/packages/nastech-ink/nastech-ink/src/ink/events/dispatcher.ts +242 -0
  50. package/packages/nastech-ink/nastech-ink/src/ink/events/emitter.ts +40 -0
  51. package/packages/nastech-ink/nastech-ink/src/ink/events/event-handlers.ts +84 -0
  52. package/packages/nastech-ink/nastech-ink/src/ink/events/event.ts +11 -0
  53. package/packages/nastech-ink/nastech-ink/src/ink/events/focus-event.ts +18 -0
  54. package/packages/nastech-ink/nastech-ink/src/ink/events/input-event.ts +176 -0
  55. package/packages/nastech-ink/nastech-ink/src/ink/events/keyboard-event.ts +57 -0
  56. package/packages/nastech-ink/nastech-ink/src/ink/events/mouse-event.ts +18 -0
  57. package/packages/nastech-ink/nastech-ink/src/ink/events/paste-event.ts +10 -0
  58. package/packages/nastech-ink/nastech-ink/src/ink/events/resize-event.ts +12 -0
  59. package/packages/nastech-ink/nastech-ink/src/ink/events/terminal-event.ts +107 -0
  60. package/packages/nastech-ink/nastech-ink/src/ink/events/terminal-focus-event.ts +19 -0
  61. package/packages/nastech-ink/nastech-ink/src/ink/focus.ts +219 -0
  62. package/packages/nastech-ink/nastech-ink/src/ink/frame.ts +124 -0
  63. package/packages/nastech-ink/nastech-ink/src/ink/get-max-width.ts +27 -0
  64. package/packages/nastech-ink/nastech-ink/src/ink/global.d.ts +1 -0
  65. package/packages/nastech-ink/nastech-ink/src/ink/hit-test.test.ts +38 -0
  66. package/packages/nastech-ink/nastech-ink/src/ink/hit-test.ts +224 -0
  67. package/packages/nastech-ink/nastech-ink/src/ink/hooks/use-animation-frame.ts +62 -0
  68. package/packages/nastech-ink/nastech-ink/src/ink/hooks/use-app.ts +9 -0
  69. package/packages/nastech-ink/nastech-ink/src/ink/hooks/use-cursor-advance.ts +33 -0
  70. package/packages/nastech-ink/nastech-ink/src/ink/hooks/use-declared-cursor.ts +75 -0
  71. package/packages/nastech-ink/nastech-ink/src/ink/hooks/use-external-process.ts +27 -0
  72. package/packages/nastech-ink/nastech-ink/src/ink/hooks/use-input.ts +95 -0
  73. package/packages/nastech-ink/nastech-ink/src/ink/hooks/use-interval.ts +71 -0
  74. package/packages/nastech-ink/package.json +57 -0
  75. package/packages/nastech-ink/src/bootstrap/state.ts +9 -0
  76. package/packages/nastech-ink/src/entry-exports.ts +32 -0
  77. package/packages/nastech-ink/src/hooks/use-stderr.ts +15 -0
  78. package/packages/nastech-ink/src/hooks/use-stdout.ts +15 -0
  79. package/packages/nastech-ink/src/ink/Ansi.tsx +435 -0
  80. package/packages/nastech-ink/src/ink/app-mouse.test.ts +123 -0
  81. package/packages/nastech-ink/src/ink/app-rawmode-mouse.test.ts +91 -0
  82. package/packages/nastech-ink/src/ink/bidi.ts +145 -0
  83. package/packages/nastech-ink/src/ink/cache-eviction.ts +45 -0
  84. package/packages/nastech-ink/src/ink/clearTerminal.ts +68 -0
  85. package/packages/nastech-ink/src/ink/colorize.test.ts +60 -0
  86. package/packages/nastech-ink/src/ink/colorize.ts +277 -0
  87. package/packages/nastech-ink/src/ink/components/AlternateScreen.tsx +133 -0
  88. package/packages/nastech-ink/src/ink/components/App.tsx +855 -0
  89. package/packages/nastech-ink/src/ink/components/AppContext.ts +20 -0
  90. package/packages/nastech-ink/src/ink/components/Box.tsx +294 -0
  91. package/packages/nastech-ink/src/ink/components/Button.tsx +236 -0
  92. package/packages/nastech-ink/src/ink/components/ClockContext.tsx +133 -0
  93. package/packages/nastech-ink/src/ink/components/CursorAdvanceContext.ts +35 -0
  94. package/packages/nastech-ink/src/ink/components/CursorDeclarationContext.ts +28 -0
  95. package/packages/nastech-ink/src/ink/components/ErrorOverview.tsx +130 -0
  96. package/packages/nastech-ink/src/ink/components/Link.tsx +38 -0
  97. package/packages/nastech-ink/src/ink/components/Newline.tsx +43 -0
  98. package/packages/nastech-ink/src/ink/components/NoSelect.tsx +73 -0
  99. package/packages/nastech-ink/src/ink/components/RawAnsi.tsx +61 -0
  100. package/packages/nastech-ink/src/ink/components/ScrollBox.tsx +290 -0
  101. package/packages/nastech-ink/src/ink/components/Spacer.tsx +23 -0
  102. package/packages/nastech-ink/src/ink/components/StdinContext.ts +25 -0
  103. package/packages/nastech-ink/src/ink/components/TerminalFocusContext.tsx +63 -0
  104. package/packages/nastech-ink/src/ink/components/TerminalSizeContext.tsx +7 -0
  105. package/packages/nastech-ink/src/ink/components/Text.test.ts +38 -0
  106. package/packages/nastech-ink/src/ink/components/Text.tsx +336 -0
  107. package/packages/nastech-ink/src/ink/constants.ts +6 -0
  108. package/packages/nastech-ink/src/ink/cursor.ts +5 -0
  109. package/packages/nastech-ink/src/ink/devtools.ts +2 -0
  110. package/packages/nastech-ink/src/ink/dom.ts +495 -0
  111. package/packages/nastech-ink/src/ink/events/click-event.ts +38 -0
  112. package/packages/nastech-ink/src/ink/events/cmd-shortcuts.test.ts +65 -0
  113. package/packages/nastech-ink/src/ink/events/dispatcher.ts +242 -0
  114. package/packages/nastech-ink/src/ink/events/emitter.ts +40 -0
  115. package/packages/nastech-ink/src/ink/events/event-handlers.ts +84 -0
  116. package/packages/nastech-ink/src/ink/events/event.ts +11 -0
  117. package/packages/nastech-ink/src/ink/events/focus-event.ts +18 -0
  118. package/packages/nastech-ink/src/ink/events/input-event.ts +176 -0
  119. package/packages/nastech-ink/src/ink/events/keyboard-event.ts +57 -0
  120. package/packages/nastech-ink/src/ink/events/mouse-event.ts +18 -0
  121. package/packages/nastech-ink/src/ink/events/paste-event.ts +10 -0
  122. package/packages/nastech-ink/src/ink/events/resize-event.ts +12 -0
  123. package/packages/nastech-ink/src/ink/events/terminal-event.ts +107 -0
  124. package/packages/nastech-ink/src/ink/events/terminal-focus-event.ts +19 -0
  125. package/packages/nastech-ink/src/ink/focus.ts +219 -0
  126. package/packages/nastech-ink/src/ink/frame.ts +124 -0
  127. package/packages/nastech-ink/src/ink/get-max-width.ts +27 -0
  128. package/packages/nastech-ink/src/ink/global.d.ts +1 -0
  129. package/packages/nastech-ink/src/ink/hit-test.test.ts +38 -0
  130. package/packages/nastech-ink/src/ink/hit-test.ts +224 -0
  131. package/packages/nastech-ink/src/ink/hooks/use-animation-frame.ts +62 -0
  132. package/packages/nastech-ink/src/ink/hooks/use-app.ts +9 -0
  133. package/packages/nastech-ink/src/ink/hooks/use-cursor-advance.ts +33 -0
  134. package/packages/nastech-ink/src/ink/hooks/use-declared-cursor.ts +75 -0
  135. package/packages/nastech-ink/src/ink/hooks/use-external-process.ts +27 -0
  136. package/packages/nastech-ink/src/ink/hooks/use-input.ts +95 -0
  137. package/packages/nastech-ink/src/ink/hooks/use-interval.ts +71 -0
  138. package/packages/nastech-ink/src/ink/hooks/use-search-highlight.ts +56 -0
  139. package/packages/nastech-ink/src/ink/hooks/use-selection.ts +101 -0
  140. package/packages/nastech-ink/src/ink/hooks/use-stdin.ts +9 -0
  141. package/packages/nastech-ink/src/ink/hooks/use-tab-status.ts +71 -0
  142. package/packages/nastech-ink/src/ink/hooks/use-terminal-focus.ts +18 -0
  143. package/packages/nastech-ink/src/ink/hooks/use-terminal-title.ts +34 -0
  144. package/packages/nastech-ink/src/ink/hooks/use-terminal-viewport.ts +100 -0
  145. package/packages/nastech-ink/src/ink/hyperlinkHover.ts +52 -0
  146. package/packages/nastech-ink/src/ink/ink-cursor-advance.test.ts +234 -0
  147. package/packages/nastech-ink/src/ink/ink-resize.test.ts +50 -0
  148. package/packages/nastech-ink/src/ink/ink.tsx +2705 -0
  149. package/packages/nastech-ink/src/ink/instances.ts +10 -0
  150. package/packages/nastech-ink/src/ink/layout/engine.ts +6 -0
  151. package/packages/nastech-ink/src/ink/layout/geometry.ts +98 -0
  152. package/packages/nastech-ink/src/ink/layout/node.ts +145 -0
  153. package/packages/nastech-ink/src/ink/layout/yoga.ts +313 -0
  154. package/packages/nastech-ink/src/ink/line-width-cache.ts +38 -0
  155. package/packages/nastech-ink/src/ink/log-update.test.ts +223 -0
  156. package/packages/nastech-ink/src/ink/log-update.ts +752 -0
  157. package/packages/nastech-ink/src/ink/lru.ts +14 -0
  158. package/packages/nastech-ink/src/ink/measure-element.ts +23 -0
  159. package/packages/nastech-ink/src/ink/measure-text.ts +50 -0
  160. package/packages/nastech-ink/src/ink/node-cache.ts +53 -0
  161. package/packages/nastech-ink/src/ink/optimizer.ts +99 -0
  162. package/packages/nastech-ink/src/ink/output.ts +845 -0
  163. package/packages/nastech-ink/src/ink/parse-keypress.test.ts +133 -0
  164. package/packages/nastech-ink/src/ink/parse-keypress.ts +848 -0
  165. package/packages/nastech-ink/src/ink/reconciler.ts +382 -0
  166. package/packages/nastech-ink/src/ink/render-border.ts +206 -0
  167. package/packages/nastech-ink/src/ink/render-node-to-output.ts +1582 -0
  168. package/packages/nastech-ink/src/ink/render-to-screen.ts +236 -0
  169. package/packages/nastech-ink/src/ink/renderer.ts +169 -0
  170. package/packages/nastech-ink/src/ink/root.ts +204 -0
  171. package/packages/nastech-ink/src/ink/screen.ts +1590 -0
  172. package/packages/nastech-ink/src/ink/searchHighlight.ts +91 -0
  173. package/packages/nastech-ink/src/ink/selection.test.ts +82 -0
  174. package/packages/nastech-ink/src/ink/selection.ts +1143 -0
  175. package/packages/nastech-ink/src/ink/squash-text-nodes.ts +74 -0
  176. package/packages/nastech-ink/src/ink/stringWidth.ts +341 -0
  177. package/packages/nastech-ink/src/ink/styles.ts +750 -0
  178. package/packages/nastech-ink/src/ink/supports-hyperlinks.ts +51 -0
  179. package/packages/nastech-ink/src/ink/tabstops.ts +44 -0
  180. package/packages/nastech-ink/src/ink/terminal-focus-state.ts +52 -0
  181. package/packages/nastech-ink/src/ink/terminal-querier.ts +222 -0
  182. package/packages/nastech-ink/src/ink/terminal.test.ts +15 -0
  183. package/packages/nastech-ink/src/ink/terminal.ts +299 -0
  184. package/packages/nastech-ink/src/ink/termio/ansi.ts +75 -0
  185. package/packages/nastech-ink/src/ink/termio/csi.ts +334 -0
  186. package/packages/nastech-ink/src/ink/termio/dec.ts +99 -0
  187. package/packages/nastech-ink/src/ink/termio/esc.ts +69 -0
  188. package/packages/nastech-ink/src/ink/termio/osc.test.ts +191 -0
  189. package/packages/nastech-ink/src/ink/termio/osc.ts +724 -0
  190. package/packages/nastech-ink/src/ink/termio/parser.ts +467 -0
  191. package/packages/nastech-ink/src/ink/termio/sgr.ts +362 -0
  192. package/packages/nastech-ink/src/ink/termio/tokenize.test.ts +185 -0
  193. package/packages/nastech-ink/src/ink/termio/tokenize.ts +350 -0
  194. package/packages/nastech-ink/src/ink/termio/types.ts +230 -0
  195. package/packages/nastech-ink/src/ink/termio.ts +42 -0
  196. package/packages/nastech-ink/src/ink/useTerminalNotification.ts +110 -0
  197. package/packages/nastech-ink/src/ink/warn.ts +15 -0
  198. package/packages/nastech-ink/src/ink/widest-line.ts +22 -0
  199. package/packages/nastech-ink/src/ink/wrap-text.test.ts +17 -0
  200. package/packages/nastech-ink/src/ink/wrap-text.ts +144 -0
  201. package/packages/nastech-ink/src/ink/wrapAnsi.ts +13 -0
  202. package/packages/nastech-ink/src/native-ts/yoga-layout/enums.ts +112 -0
  203. package/packages/nastech-ink/src/native-ts/yoga-layout/index.ts +2326 -0
  204. package/packages/nastech-ink/src/utils/debug.ts +6 -0
  205. package/packages/nastech-ink/src/utils/earlyInput.ts +131 -0
  206. package/packages/nastech-ink/src/utils/env.ts +66 -0
  207. package/packages/nastech-ink/src/utils/envUtils.ts +13 -0
  208. package/packages/nastech-ink/src/utils/execFileNoThrow.test.ts +146 -0
  209. package/packages/nastech-ink/src/utils/execFileNoThrow.ts +115 -0
  210. package/packages/nastech-ink/src/utils/fullscreen.ts +3 -0
  211. package/packages/nastech-ink/src/utils/intl.ts +87 -0
  212. package/packages/nastech-ink/src/utils/log.ts +7 -0
  213. package/packages/nastech-ink/src/utils/semver.ts +57 -0
  214. package/packages/nastech-ink/src/utils/sliceAnsi.ts +106 -0
  215. package/packages/nastech-ink/text-input.d.ts +2 -0
  216. package/packages/nastech-ink/text-input.js +1 -0
  217. package/scripts/build.mjs +61 -0
  218. package/scripts/profile-tui.mjs +121 -0
  219. package/src/__tests__/activeSessionSwitcher.test.ts +157 -0
  220. package/src/__tests__/appChromeStatusRule.test.tsx +84 -0
  221. package/src/__tests__/appChromeStatusRuleDevCredits.test.tsx +73 -0
  222. package/src/__tests__/approvalAction.test.ts +50 -0
  223. package/src/__tests__/asCommandDispatch.test.ts +27 -0
  224. package/src/__tests__/blockLayout.test.ts +122 -0
  225. package/src/__tests__/clipboard.test.ts +369 -0
  226. package/src/__tests__/constants.test.ts +53 -0
  227. package/src/__tests__/createGatewayEventHandler.test.ts +1091 -0
  228. package/src/__tests__/createSlashHandler.test.ts +822 -0
  229. package/src/__tests__/creditsCommand.test.ts +144 -0
  230. package/src/__tests__/cursorDriftRegression.test.ts +114 -0
  231. package/src/__tests__/details.test.ts +115 -0
  232. package/src/__tests__/emoji.test.ts +64 -0
  233. package/src/__tests__/externalLink.test.ts +144 -0
  234. package/src/__tests__/forceTruecolor.test.ts +191 -0
  235. package/src/__tests__/gatewayClient.test.ts +394 -0
  236. package/src/__tests__/gatewayRecovery.test.ts +47 -0
  237. package/src/__tests__/markdown.test.ts +331 -0
  238. package/src/__tests__/mathUnicode.test.ts +293 -0
  239. package/src/__tests__/memoryMonitor.test.ts +102 -0
  240. package/src/__tests__/messageLine.test.ts +19 -0
  241. package/src/__tests__/messages.test.ts +92 -0
  242. package/src/__tests__/orchestratorPromptSession.test.ts +64 -0
  243. package/src/__tests__/osc52.test.ts +67 -0
  244. package/src/__tests__/parentLog.test.ts +75 -0
  245. package/src/__tests__/paths.test.ts +70 -0
  246. package/src/__tests__/platform.test.ts +556 -0
  247. package/src/__tests__/precisionWheel.test.ts +44 -0
  248. package/src/__tests__/prompt.test.ts +31 -0
  249. package/src/__tests__/providers.test.ts +65 -0
  250. package/src/__tests__/reasoning.test.ts +76 -0
  251. package/src/__tests__/rpc.test.ts +27 -0
  252. package/src/__tests__/scroll.test.ts +99 -0
  253. package/src/__tests__/slashParity.test.ts +123 -0
  254. package/src/__tests__/spawnHistoryStore.test.ts +46 -0
  255. package/src/__tests__/stateIsolation.test.ts +46 -0
  256. package/src/__tests__/statusBarTicker.test.ts +18 -0
  257. package/src/__tests__/statusRule.test.ts +32 -0
  258. package/src/__tests__/streamingMarkdown.test.ts +121 -0
  259. package/src/__tests__/subagentTree.test.ts +407 -0
  260. package/src/__tests__/syntax.test.ts +45 -0
  261. package/src/__tests__/terminalModes.test.ts +39 -0
  262. package/src/__tests__/terminalParity.test.ts +77 -0
  263. package/src/__tests__/terminalSetup.test.ts +386 -0
  264. package/src/__tests__/termux.test.ts +35 -0
  265. package/src/__tests__/termuxComposerLayout.test.ts +40 -0
  266. package/src/__tests__/text.test.ts +233 -0
  267. package/src/__tests__/textInputBurstInput.test.ts +40 -0
  268. package/src/__tests__/textInputCursorSourceOfTruth.test.ts +50 -0
  269. package/src/__tests__/textInputFastEcho.test.ts +200 -0
  270. package/src/__tests__/textInputLineNav.test.ts +55 -0
  271. package/src/__tests__/textInputPassThrough.test.ts +59 -0
  272. package/src/__tests__/textInputRightClick.test.ts +48 -0
  273. package/src/__tests__/textInputWrap.test.ts +151 -0
  274. package/src/__tests__/theme.test.ts +311 -0
  275. package/src/__tests__/turnControllerNotice.test.ts +43 -0
  276. package/src/__tests__/turnStore.test.ts +66 -0
  277. package/src/__tests__/useCompletion.test.ts +35 -0
  278. package/src/__tests__/useComposerState.test.ts +59 -0
  279. package/src/__tests__/useConfigSync.test.ts +460 -0
  280. package/src/__tests__/useInputHandlers.test.ts +77 -0
  281. package/src/__tests__/useQueue.test.ts +28 -0
  282. package/src/__tests__/useSessionLifecycle.test.ts +60 -0
  283. package/src/__tests__/useVirtualHistoryHeights.test.ts +39 -0
  284. package/src/__tests__/viewport.test.ts +58 -0
  285. package/src/__tests__/viewportStore.test.ts +85 -0
  286. package/src/__tests__/virtualHeights.test.ts +96 -0
  287. package/src/__tests__/virtualHistoryClamp.test.ts +19 -0
  288. package/src/__tests__/virtualHistoryOffsetCache.test.ts +282 -0
  289. package/src/__tests__/wheelAccel.test.ts +138 -0
  290. package/src/app/createGatewayEventHandler.ts +833 -0
  291. package/src/app/createSlashHandler.ts +130 -0
  292. package/src/app/delegationStore.ts +77 -0
  293. package/src/app/gatewayContext.tsx +19 -0
  294. package/src/app/gatewayRecovery.ts +35 -0
  295. package/src/app/inputSelectionStore.ts +15 -0
  296. package/src/app/interfaces.ts +394 -0
  297. package/src/app/overlayStore.ts +53 -0
  298. package/src/app/scroll.ts +71 -0
  299. package/src/app/setupHandoff.ts +54 -0
  300. package/src/app/slash/commands/core.ts +648 -0
  301. package/src/app/slash/commands/credits.ts +57 -0
  302. package/src/app/slash/commands/debug.ts +48 -0
  303. package/src/app/slash/commands/ops.ts +717 -0
  304. package/src/app/slash/commands/session.ts +554 -0
  305. package/src/app/slash/commands/setup.ts +20 -0
  306. package/src/app/slash/registry.ts +20 -0
  307. package/src/app/slash/types.ts +21 -0
  308. package/src/app/spawnHistoryStore.ts +159 -0
  309. package/src/app/turnController.ts +866 -0
  310. package/src/app/turnStore.ts +85 -0
  311. package/src/app/uiStore.ts +44 -0
  312. package/src/app/useComposerState.ts +367 -0
  313. package/src/app/useConfigSync.ts +288 -0
  314. package/src/app/useInputHandlers.ts +576 -0
  315. package/src/app/useLongRunToolCharms.ts +69 -0
  316. package/src/app/useMainApp.ts +1039 -0
  317. package/src/app/useSessionLifecycle.ts +366 -0
  318. package/src/app/useSubmission.ts +429 -0
  319. package/src/app.tsx +25 -0
  320. package/src/banner.ts +93 -0
  321. package/src/components/activeSessionSwitcher.tsx +635 -0
  322. package/src/components/agentsOverlay.tsx +1073 -0
  323. package/src/components/appChrome.tsx +554 -0
  324. package/src/components/appLayout.tsx +444 -0
  325. package/src/components/appOverlays.tsx +254 -0
  326. package/src/components/branding.tsx +466 -0
  327. package/src/components/fpsOverlay.tsx +30 -0
  328. package/src/components/helpHint.tsx +73 -0
  329. package/src/components/markdown.tsx +1119 -0
  330. package/src/components/maskedPrompt.tsx +34 -0
  331. package/src/components/messageLine.tsx +237 -0
  332. package/src/components/modelPicker.tsx +527 -0
  333. package/src/components/overlayControls.tsx +50 -0
  334. package/src/components/pluginsHub.tsx +238 -0
  335. package/src/components/prompts.tsx +276 -0
  336. package/src/components/queuedMessages.tsx +64 -0
  337. package/src/components/sessionPicker.tsx +227 -0
  338. package/src/components/skillsHub.tsx +308 -0
  339. package/src/components/streamingAssistant.tsx +110 -0
  340. package/src/components/streamingMarkdown.tsx +174 -0
  341. package/src/components/textInput.tsx +1340 -0
  342. package/src/components/themed.tsx +30 -0
  343. package/src/components/thinking.tsx +1224 -0
  344. package/src/components/todoPanel.tsx +93 -0
  345. package/src/config/env.ts +64 -0
  346. package/src/config/limits.ts +13 -0
  347. package/src/config/timing.ts +6 -0
  348. package/src/content/charms.ts +1 -0
  349. package/src/content/faces.ts +17 -0
  350. package/src/content/fortunes.ts +30 -0
  351. package/src/content/hotkeys.ts +37 -0
  352. package/src/content/placeholders.ts +13 -0
  353. package/src/content/setup.ts +17 -0
  354. package/src/content/verbs.ts +38 -0
  355. package/src/domain/blockLayout.ts +146 -0
  356. package/src/domain/details.ts +76 -0
  357. package/src/domain/messages.ts +91 -0
  358. package/src/domain/paths.ts +16 -0
  359. package/src/domain/providers.ts +11 -0
  360. package/src/domain/roles.ts +9 -0
  361. package/src/domain/slash.ts +10 -0
  362. package/src/domain/usage.ts +3 -0
  363. package/src/domain/viewport.ts +51 -0
  364. package/src/entry.tsx +104 -0
  365. package/src/gatewayClient.ts +730 -0
  366. package/src/gatewayTypes.ts +568 -0
  367. package/src/hooks/useCompletion.ts +112 -0
  368. package/src/hooks/useGitBranch.ts +72 -0
  369. package/src/hooks/useInputHistory.ts +11 -0
  370. package/src/hooks/useQueue.ts +76 -0
  371. package/src/hooks/useVirtualHistory.ts +554 -0
  372. package/src/lib/circularBuffer.ts +48 -0
  373. package/src/lib/clipboard.ts +182 -0
  374. package/src/lib/editor.test.ts +74 -0
  375. package/src/lib/editor.ts +47 -0
  376. package/src/lib/emoji.ts +55 -0
  377. package/src/lib/externalCli.ts +16 -0
  378. package/src/lib/externalLink.ts +435 -0
  379. package/src/lib/forceTruecolor.ts +60 -0
  380. package/src/lib/fpsStore.ts +51 -0
  381. package/src/lib/fuzzy.test.ts +109 -0
  382. package/src/lib/fuzzy.ts +177 -0
  383. package/src/lib/gracefulExit.ts +47 -0
  384. package/src/lib/history.ts +82 -0
  385. package/src/lib/inputMetrics.ts +203 -0
  386. package/src/lib/liveProgress.test.ts +116 -0
  387. package/src/lib/liveProgress.ts +79 -0
  388. package/src/lib/mathUnicode.ts +770 -0
  389. package/src/lib/memory.test.ts +155 -0
  390. package/src/lib/memory.ts +188 -0
  391. package/src/lib/memoryMonitor.ts +109 -0
  392. package/src/lib/messages.test.ts +29 -0
  393. package/src/lib/messages.ts +8 -0
  394. package/src/lib/openExternalUrl.test.ts +217 -0
  395. package/src/lib/openExternalUrl.ts +158 -0
  396. package/src/lib/osc52.ts +73 -0
  397. package/src/lib/parentLog.ts +57 -0
  398. package/src/lib/perfPane.tsx +107 -0
  399. package/src/lib/platform.ts +409 -0
  400. package/src/lib/precisionWheel.ts +48 -0
  401. package/src/lib/prompt.ts +35 -0
  402. package/src/lib/reasoning.ts +55 -0
  403. package/src/lib/rpc.ts +41 -0
  404. package/src/lib/subagentTree.ts +355 -0
  405. package/src/lib/syntax.ts +117 -0
  406. package/src/lib/terminalModes.ts +51 -0
  407. package/src/lib/terminalParity.ts +78 -0
  408. package/src/lib/terminalSetup.ts +444 -0
  409. package/src/lib/termux.ts +29 -0
  410. package/src/lib/text.test.ts +18 -0
  411. package/src/lib/text.ts +339 -0
  412. package/src/lib/todo.test.ts +21 -0
  413. package/src/lib/todo.ts +9 -0
  414. package/src/lib/viewportStore.ts +124 -0
  415. package/src/lib/virtualHeights.ts +145 -0
  416. package/src/lib/wheelAccel.ts +190 -0
  417. package/src/protocol/interpolation.ts +3 -0
  418. package/src/protocol/paste.ts +1 -0
  419. package/src/theme.ts +589 -0
  420. package/src/types/nastech-ink.d.ts +176 -0
  421. package/src/types.ts +212 -0
  422. package/tsconfig.build.json +9 -0
  423. package/tsconfig.json +19 -0
  424. package/vitest.config.ts +7 -0
@@ -0,0 +1,1582 @@
1
+ import indentString from 'indent-string'
2
+
3
+ import { applyTextStyles } from './colorize.js'
4
+ import type { DOMElement } from './dom.js'
5
+ import getMaxWidth from './get-max-width.js'
6
+ import type { Rectangle } from './layout/geometry.js'
7
+ import { LayoutDisplay, LayoutEdge, type LayoutNode } from './layout/node.js'
8
+ import { nodeCache, pendingClears } from './node-cache.js'
9
+ import type Output from './output.js'
10
+ import renderBorder from './render-border.js'
11
+ import type { Screen } from './screen.js'
12
+ import { squashTextNodesToSegments, type StyledSegment } from './squash-text-nodes.js'
13
+ import type { Color } from './styles.js'
14
+ import { isXtermJs } from './terminal.js'
15
+ import { widestLine } from './widest-line.js'
16
+ import wrapText from './wrap-text.js'
17
+
18
+ // Matches detectXtermJsWheel() in ScrollKeybindingHandler.tsx — the curve
19
+ // and drain must agree on terminal detection. TERM_PROGRAM check is the sync
20
+ // fallback; isXtermJs() is the authoritative XTVERSION-probe result.
21
+ function isXtermJsHost(): boolean {
22
+ return process.env.TERM_PROGRAM === 'vscode' || isXtermJs()
23
+ }
24
+
25
+ // Per-frame scratch: set when any node's yoga position/size differs from
26
+ // its cached value, or a child was removed. Read by ink.tsx to decide
27
+ // whether the full-damage sledgehammer (PR #20120) is needed this frame.
28
+ // Applies on both alt-screen and main-screen. Steady-state frames
29
+ // (spinner tick, clock tick, text append into a fixed-height box) don't
30
+ // shift layout → narrow damage bounds → O(changed cells) diff instead of
31
+ // O(rows×cols).
32
+ let layoutShifted = false
33
+ let absoluteOverlayMoved = false
34
+
35
+ export function resetLayoutShifted(): void {
36
+ layoutShifted = false
37
+ absoluteOverlayMoved = false
38
+ }
39
+
40
+ export function didLayoutShift(): boolean {
41
+ return layoutShifted
42
+ }
43
+
44
+ export function didAbsoluteOverlayMove(): boolean {
45
+ return absoluteOverlayMoved
46
+ }
47
+
48
+ // DECSTBM scroll optimization hint. When a ScrollBox's scrollTop changes
49
+ // between frames (and nothing else moved), log-update.ts can emit a
50
+ // hardware scroll (DECSTBM + SU/SD) instead of rewriting the whole
51
+ // viewport. top/bottom are 0-indexed inclusive screen rows; delta > 0 =
52
+ // content moved up (scrollTop increased, CSI n S).
53
+ export type ScrollHint = { top: number; bottom: number; delta: number }
54
+ let scrollHint: ScrollHint | null = null
55
+
56
+ // Rects of position:absolute nodes from the PREVIOUS frame, used by
57
+ // ScrollBox's blit+shift third-pass repair (see usage site). Recorded at
58
+ // three paths — full-render nodeCache.set, node-level blit early-return,
59
+ // blitEscapingAbsoluteDescendants — so clean-overlay consecutive scrolls
60
+ // still have the rect.
61
+ let absoluteRectsPrev: Rectangle[] = []
62
+ let absoluteRectsCur: Rectangle[] = []
63
+
64
+ export function resetScrollHint(): void {
65
+ scrollHint = null
66
+ absoluteRectsPrev = absoluteRectsCur
67
+ absoluteRectsCur = []
68
+ }
69
+
70
+ // Fast-path diagnostics. Bumped from the ScrollBox fast-path branch
71
+ // whenever a scroll hint was captured. Reveals why a fast path was
72
+ // declined (heightDelta mismatch, no prevScreen, etc.) so we can chase
73
+ // the last mile of PageUp/wheel latency. Zero cost when no reader —
74
+ // it's all integer bumps. Exposed as a counter object so external
75
+ // probes can snapshot + diff.
76
+ export type ScrollFastPathStats = {
77
+ captured: number
78
+ taken: number
79
+ declined: {
80
+ noPrevScreen: number
81
+ heightDeltaMismatch: number
82
+ other: number
83
+ }
84
+ lastDeclineReason?: string
85
+ lastHeightDelta?: number
86
+ lastHintDelta?: number
87
+ lastScrollHeight?: number
88
+ lastPrevHeight?: number
89
+ }
90
+
91
+ export const scrollFastPathStats: ScrollFastPathStats = {
92
+ captured: 0,
93
+ taken: 0,
94
+ declined: {
95
+ noPrevScreen: 0,
96
+ heightDeltaMismatch: 0,
97
+ other: 0
98
+ }
99
+ }
100
+
101
+ export function getScrollHint(): ScrollHint | null {
102
+ return scrollHint
103
+ }
104
+
105
+ // The ScrollBox DOM node (if any) with pendingScrollDelta left after this
106
+ // frame's drain. renderer.ts calls markDirty(it) post-render so the NEXT
107
+ // frame's root blit check fails and we descend to continue draining.
108
+ // Without this, after the scrollbox's dirty flag is cleared (line ~721),
109
+ // the next frame blits root and never reaches the scrollbox — drain stalls.
110
+ let scrollDrainNode: DOMElement | null = null
111
+
112
+ export function resetScrollDrainNode(): void {
113
+ scrollDrainNode = null
114
+ }
115
+
116
+ export function getScrollDrainNode(): DOMElement | null {
117
+ return scrollDrainNode
118
+ }
119
+
120
+ // At-bottom follow scroll event this frame. When streaming content
121
+ // triggers scrollTop = maxScroll, the ScrollBox records the delta +
122
+ // viewport bounds here. ink.tsx consumes it post-render to translate any active
123
+ // text selection by -delta so the highlight stays anchored to the TEXT
124
+ // (native terminal behavior — the selection walks up the screen as content
125
+ // scrolls, eventually clipping at the top). The frontFrame screen buffer
126
+ // still holds the old content at that point — captureScrolledRows reads
127
+ // from it before the front/back swap to preserve the text for copy.
128
+ export type FollowScroll = {
129
+ delta: number
130
+ viewportTop: number
131
+ viewportBottom: number
132
+ }
133
+ let followScroll: FollowScroll | null = null
134
+
135
+ export function consumeFollowScroll(): FollowScroll | null {
136
+ const f = followScroll
137
+ followScroll = null
138
+
139
+ return f
140
+ }
141
+
142
+ // ── Native terminal drain (iTerm2/Ghostty/etc. — proportional events) ──
143
+ // Minimum rows applied per frame. Above this, drain is proportional (~3/4
144
+ // of remaining) so big bursts catch up in log₄ frames while the tail
145
+ // decelerates smoothly. Hard cap is innerHeight-1 so DECSTBM hint fires.
146
+ const SCROLL_MIN_PER_FRAME = 4
147
+
148
+ // ── xterm.js (VS Code) smooth drain ──
149
+ // Low pending (≤5) drains ALL in one frame — slow wheel clicks should be
150
+ // instant (click → visible jump → done), not micro-stutter 1-row frames.
151
+ // Higher pending drains at a small fixed step so fast-scroll animation
152
+ // stays smooth (no big jumps). Pending >MAX snaps excess.
153
+ const SCROLL_INSTANT_THRESHOLD = 5 // ≤ this: drain all at once
154
+ const SCROLL_HIGH_PENDING = 12 // threshold for HIGH step
155
+ const SCROLL_STEP_MED = 2 // pending (INSTANT, HIGH): catch-up
156
+ const SCROLL_STEP_HIGH = 3 // pending ≥ HIGH: fast flick
157
+ const SCROLL_MAX_PENDING = 30 // snap excess beyond this
158
+
159
+ // xterm.js adaptive drain. Returns rows applied; mutates pendingScrollDelta.
160
+ function drainAdaptive(node: DOMElement, pending: number, innerHeight: number): number {
161
+ const sign = pending > 0 ? 1 : -1
162
+ let abs = Math.abs(pending)
163
+ let applied = 0
164
+
165
+ // Snap excess beyond animation window so big flicks don't coast.
166
+ if (abs > SCROLL_MAX_PENDING) {
167
+ applied += sign * (abs - SCROLL_MAX_PENDING)
168
+ abs = SCROLL_MAX_PENDING
169
+ }
170
+
171
+ // ≤5: drain all (slow click = instant). Above: small fixed step.
172
+ const step = abs <= SCROLL_INSTANT_THRESHOLD ? abs : abs < SCROLL_HIGH_PENDING ? SCROLL_STEP_MED : SCROLL_STEP_HIGH
173
+
174
+ applied += sign * step
175
+ const rem = abs - step
176
+ // Cap total at innerHeight-1 so DECSTBM blit+shift fast path fires
177
+ // (matches drainProportional). Excess stays in pendingScrollDelta.
178
+ const cap = Math.max(1, innerHeight - 1)
179
+ const totalAbs = Math.abs(applied)
180
+
181
+ if (totalAbs > cap) {
182
+ const excess = totalAbs - cap
183
+ node.pendingScrollDelta = sign * (rem + excess)
184
+
185
+ return sign * cap
186
+ }
187
+
188
+ node.pendingScrollDelta = rem > 0 ? sign * rem : undefined
189
+
190
+ return applied
191
+ }
192
+
193
+ // Native proportional drain. step = max(MIN, floor(abs*3/4)), capped at
194
+ // innerHeight-1 so DECSTBM + blit+shift fast path fire.
195
+ function drainProportional(node: DOMElement, pending: number, innerHeight: number): number {
196
+ const abs = Math.abs(pending)
197
+ const cap = Math.max(1, innerHeight - 1)
198
+ const step = Math.min(cap, Math.max(SCROLL_MIN_PER_FRAME, (abs * 3) >> 2))
199
+
200
+ if (abs <= step) {
201
+ node.pendingScrollDelta = undefined
202
+
203
+ return pending
204
+ }
205
+
206
+ const applied = pending > 0 ? step : -step
207
+ node.pendingScrollDelta = pending - applied
208
+
209
+ return applied
210
+ }
211
+
212
+ // OSC 8 hyperlink escape sequences. Empty params (;;) — ansi-tokenize only
213
+ // recognizes this exact prefix. The id= param (for grouping wrapped lines)
214
+ // is added at terminal-output time in termio/osc.ts link().
215
+ const OSC = '\u001B]'
216
+ const BEL = '\u0007'
217
+
218
+ function wrapWithOsc8Link(text: string, url: string): string {
219
+ return `${OSC}8;;${url}${BEL}${text}${OSC}8;;${BEL}`
220
+ }
221
+
222
+ /**
223
+ * Build a mapping from each character position in the plain text to its segment index.
224
+ * Returns an array where charToSegment[i] is the segment index for character i.
225
+ */
226
+ function buildCharToSegmentMap(segments: StyledSegment[]): number[] {
227
+ const map: number[] = []
228
+
229
+ for (let i = 0; i < segments.length; i++) {
230
+ const len = segments[i]!.text.length
231
+
232
+ for (let j = 0; j < len; j++) {
233
+ map.push(i)
234
+ }
235
+ }
236
+
237
+ return map
238
+ }
239
+
240
+ /**
241
+ * Apply styles to wrapped text by mapping each character back to its original segment.
242
+ * This preserves per-segment styles even when text wraps across lines.
243
+ *
244
+ * @param trimEnabled - Whether whitespace trimming is enabled (wrap-trim mode).
245
+ * When true, we skip whitespace in the original that was trimmed from the output.
246
+ * When false (wrap mode), all whitespace is preserved so no skipping is needed.
247
+ */
248
+ function applyStylesToWrappedText(
249
+ wrappedPlain: string,
250
+ segments: StyledSegment[],
251
+ charToSegment: number[],
252
+ originalPlain: string,
253
+ trimEnabled: boolean = false
254
+ ): string {
255
+ const lines = wrappedPlain.split('\n')
256
+ const resultLines: string[] = []
257
+
258
+ let charIndex = 0
259
+
260
+ for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
261
+ const line = lines[lineIdx]!
262
+
263
+ let styledLine = ''
264
+ let runStart = 0
265
+ let runSegmentIndex = charToSegment[charIndex] ?? 0
266
+
267
+ for (let i = 0; i < line.length; i++) {
268
+ const currentSegmentIndex = charToSegment[charIndex] ?? runSegmentIndex
269
+
270
+ if (currentSegmentIndex !== runSegmentIndex) {
271
+ // Flush the current run
272
+ const runText = line.slice(runStart, i)
273
+ const segment = segments[runSegmentIndex]
274
+
275
+ if (segment) {
276
+ let styled = applyTextStyles(runText, segment.styles)
277
+
278
+ if (segment.hyperlink) {
279
+ styled = wrapWithOsc8Link(styled, segment.hyperlink)
280
+ }
281
+
282
+ styledLine += styled
283
+ } else {
284
+ styledLine += runText
285
+ }
286
+
287
+ runStart = i
288
+ runSegmentIndex = currentSegmentIndex
289
+ }
290
+
291
+ charIndex++
292
+ }
293
+
294
+ // Flush the final run
295
+ const runText = line.slice(runStart)
296
+ const segment = segments[runSegmentIndex]
297
+
298
+ if (segment) {
299
+ let styled = applyTextStyles(runText, segment.styles)
300
+
301
+ if (segment.hyperlink) {
302
+ styled = wrapWithOsc8Link(styled, segment.hyperlink)
303
+ }
304
+
305
+ styledLine += styled
306
+ } else {
307
+ styledLine += runText
308
+ }
309
+
310
+ resultLines.push(styledLine)
311
+
312
+ // Skip newline character in original that corresponds to this line break.
313
+ // This is needed when the original text contains actual newlines (not just
314
+ // wrapping-inserted newlines). Without this, charIndex gets out of sync
315
+ // because the newline is in originalPlain/charToSegment but not in the
316
+ // split lines.
317
+ if (charIndex < originalPlain.length && originalPlain[charIndex] === '\n') {
318
+ charIndex++
319
+ } else if (trimEnabled && lineIdx < lines.length - 1 && /\s/.test(originalPlain[charIndex] ?? '')) {
320
+ // wrap-trim removes exactly one whitespace character at each soft-wrap boundary.
321
+ // Keep the style map aligned without eating preserved indentation/spaces.
322
+ charIndex++
323
+ }
324
+ }
325
+
326
+ return resultLines.join('\n')
327
+ }
328
+
329
+ /**
330
+ * Wrap text and record which output lines are soft-wrap continuations
331
+ * (i.e. the `\n` before them was inserted by word-wrap, not in the
332
+ * source). wrapAnsi already processes each input line independently, so
333
+ * wrapping per-input-line here gives identical output to a single
334
+ * whole-string wrap while letting us mark per-piece provenance.
335
+ * Truncate modes never add newlines (cli-truncate is whole-string) so
336
+ * they fall through with softWrap undefined — no tracking, no behavior
337
+ * change from the pre-softWrap path.
338
+ */
339
+ function wrapWithSoftWrap(
340
+ plainText: string,
341
+ maxWidth: number,
342
+ textWrap: Parameters<typeof wrapText>[2]
343
+ ): { wrapped: string; softWrap: boolean[] | undefined } {
344
+ if (textWrap !== 'wrap' && textWrap !== 'wrap-char' && textWrap !== 'wrap-trim') {
345
+ return {
346
+ wrapped: wrapText(plainText, maxWidth, textWrap),
347
+ softWrap: undefined
348
+ }
349
+ }
350
+
351
+ const origLines = plainText.split('\n')
352
+ const outLines: string[] = []
353
+ const softWrap: boolean[] = []
354
+
355
+ for (const orig of origLines) {
356
+ const pieces = wrapText(orig, maxWidth, textWrap).split('\n')
357
+
358
+ for (let i = 0; i < pieces.length; i++) {
359
+ outLines.push(pieces[i]!)
360
+ softWrap.push(i > 0)
361
+ }
362
+ }
363
+
364
+ return { wrapped: outLines.join('\n'), softWrap }
365
+ }
366
+
367
+ // If parent container is `<Box>`, text nodes will be treated as separate nodes in
368
+ // the tree and will have their own coordinates in the layout.
369
+ // To ensure text nodes are aligned correctly, take X and Y of the first text node
370
+ // and use it as offset for the rest of the nodes
371
+ // Only first node is taken into account, because other text nodes can't have margin or padding,
372
+ // so their coordinates will be relative to the first node anyway
373
+ function applyPaddingToText(node: DOMElement, text: string, softWrap?: boolean[]): string {
374
+ const yogaNode = node.childNodes[0]?.yogaNode
375
+
376
+ if (yogaNode) {
377
+ const offsetX = yogaNode.getComputedLeft()
378
+ const offsetY = yogaNode.getComputedTop()
379
+ text = '\n'.repeat(offsetY) + indentString(text, offsetX)
380
+
381
+ if (softWrap && offsetY > 0) {
382
+ // Prepend `false` for each padding line so indices stay aligned
383
+ // with text.split('\n'). Mutate in place — caller owns the array.
384
+ softWrap.unshift(...Array<boolean>(offsetY).fill(false))
385
+ }
386
+ }
387
+
388
+ return text
389
+ }
390
+
391
+ // After nodes are laid out, render each to output object, which later gets rendered to terminal
392
+ function renderNodeToOutput(
393
+ node: DOMElement,
394
+ output: Output,
395
+ {
396
+ offsetX = 0,
397
+ offsetY = 0,
398
+ prevScreen,
399
+ skipSelfBlit = false,
400
+ inheritedBackgroundColor
401
+ }: {
402
+ offsetX?: number
403
+ offsetY?: number
404
+ prevScreen: Screen | undefined
405
+ // Force this node to descend instead of blitting its own rect, while
406
+ // still passing prevScreen to children. Used for non-opaque absolute
407
+ // overlays over a dirty clipped region: the overlay's full rect has
408
+ // transparent gaps (stale underlying content in prevScreen), but its
409
+ // opaque descendants' narrower rects are safe to blit.
410
+ skipSelfBlit?: boolean
411
+ inheritedBackgroundColor?: Color
412
+ }
413
+ ): void {
414
+ const { yogaNode } = node
415
+
416
+ if (yogaNode) {
417
+ if (yogaNode.getDisplay() === LayoutDisplay.None) {
418
+ // Clear old position if node was visible before becoming hidden
419
+ if (node.dirty) {
420
+ const cached = nodeCache.get(node)
421
+
422
+ if (cached) {
423
+ output.clear({
424
+ x: Math.floor(cached.x),
425
+ y: Math.floor(cached.y),
426
+ width: Math.floor(cached.width),
427
+ height: Math.floor(cached.height)
428
+ })
429
+ // Drop descendants' cache too — hideInstance's markDirty walks UP
430
+ // only, so descendants' .dirty stays false. Their nodeCache entries
431
+ // survive with pre-hide rects. On unhide, if position didn't shift,
432
+ // the blit check at line ~432 passes and copies EMPTY cells from
433
+ // prevScreen (cleared here) → content vanishes.
434
+ dropSubtreeCache(node)
435
+ layoutShifted = true
436
+ }
437
+ }
438
+
439
+ return
440
+ }
441
+
442
+ // Left and top positions in Yoga are relative to their parent node
443
+ const x = offsetX + yogaNode.getComputedLeft()
444
+ const yogaTop = yogaNode.getComputedTop()
445
+ let y = offsetY + yogaTop
446
+ const width = yogaNode.getComputedWidth()
447
+ const height = yogaNode.getComputedHeight()
448
+
449
+ // Absolute-positioned overlays (e.g. autocomplete menus with bottom='100%')
450
+ // can compute negative screen y when they extend above the viewport. Without
451
+ // clamping, setCellAt drops cells at y<0, clipping the TOP of the content
452
+ // (best matches in an autocomplete). By clamping to 0, we shift the element
453
+ // down so the top rows are visible and the bottom overflows below — the
454
+ // opaque prop ensures it paints over whatever is underneath.
455
+ if (y < 0 && node.style.position === 'absolute') {
456
+ y = 0
457
+ }
458
+
459
+ // Check if we can skip this subtree (clean node with unchanged layout).
460
+ // Blit cells from previous screen instead of re-rendering.
461
+ const cached = nodeCache.get(node)
462
+
463
+ if (
464
+ !node.dirty &&
465
+ !skipSelfBlit &&
466
+ node.pendingScrollDelta === undefined &&
467
+ cached &&
468
+ cached.x === x &&
469
+ cached.y === y &&
470
+ cached.width === width &&
471
+ cached.height === height &&
472
+ prevScreen
473
+ ) {
474
+ const fx = Math.floor(x)
475
+ const fy = Math.floor(y)
476
+ const fw = Math.floor(width)
477
+ const fh = Math.floor(height)
478
+ output.blit(prevScreen, fx, fy, fw, fh)
479
+
480
+ if (node.style.position === 'absolute') {
481
+ absoluteRectsCur.push(cached)
482
+ }
483
+
484
+ // Absolute descendants can paint outside this node's layout bounds
485
+ // (e.g. a slash menu with position='absolute' bottom='100%' floats
486
+ // above). If a dirty clipped sibling re-rendered and overwrote those
487
+ // cells, the blit above only restored this node's own rect — the
488
+ // absolute descendants' cells are lost. Re-blit them from prevScreen
489
+ // so the overlays survive.
490
+ blitEscapingAbsoluteDescendants(node, output, prevScreen, fx, fy, fw, fh)
491
+
492
+ return
493
+ }
494
+
495
+ // Clear stale content from the old position when re-rendering.
496
+ // Dirty: content changed. Moved: position/size changed (e.g., sibling
497
+ // above changed height), old cells still on the terminal.
498
+ const positionChanged =
499
+ cached !== undefined && (cached.x !== x || cached.y !== y || cached.width !== width || cached.height !== height)
500
+
501
+ if (positionChanged) {
502
+ layoutShifted = true
503
+ absoluteOverlayMoved ||= node.style.position === 'absolute'
504
+ }
505
+
506
+ if (cached && (node.dirty || positionChanged)) {
507
+ output.clear(
508
+ {
509
+ x: Math.floor(cached.x),
510
+ y: Math.floor(cached.y),
511
+ width: Math.floor(cached.width),
512
+ height: Math.floor(cached.height)
513
+ },
514
+ node.style.position === 'absolute'
515
+ )
516
+ }
517
+
518
+ // Read before deleting — hasRemovedChild disables prevScreen blitting
519
+ // for siblings to prevent stale overflow content from being restored.
520
+ const clears = pendingClears.get(node)
521
+ const hasRemovedChild = clears !== undefined
522
+
523
+ if (hasRemovedChild) {
524
+ layoutShifted = true
525
+
526
+ for (const rect of clears) {
527
+ output.clear({
528
+ x: Math.floor(rect.x),
529
+ y: Math.floor(rect.y),
530
+ width: Math.floor(rect.width),
531
+ height: Math.floor(rect.height)
532
+ })
533
+ }
534
+
535
+ pendingClears.delete(node)
536
+ }
537
+
538
+ // Yoga squeezed this node to zero height (overflow in a height-constrained
539
+ // parent) AND a sibling lands at the same y. Skip rendering — both would
540
+ // write to the same row; if the sibling's content is shorter, this node's
541
+ // tail chars ghost (e.g. "false" + "true" = "truee"). The clear above
542
+ // already handled the visible→squeezed transition.
543
+ //
544
+ // The sibling-overlap check is load-bearing: Yoga's pixel-grid rounding
545
+ // can give a box h=0 while still leaving a row for it (next sibling at
546
+ // y+1, not y). HelpV2's third shortcuts column hits this — skipping
547
+ // unconditionally drops "ctrl + z to suspend" from /help output.
548
+ if (height === 0 && siblingSharesY(node, yogaNode)) {
549
+ nodeCache.set(node, { x, y, width, height, top: yogaTop })
550
+ node.dirty = false
551
+
552
+ return
553
+ }
554
+
555
+ if (node.nodeName === 'ink-raw-ansi') {
556
+ // Pre-rendered ANSI content. The producer already wrapped to width and
557
+ // emitted terminal-ready escape codes. Skip squash, measure, wrap, and
558
+ // style re-application — output.write() parses ANSI directly into cells.
559
+ const text = node.attributes['rawText'] as string
560
+
561
+ if (text) {
562
+ output.write(x, y, text)
563
+ }
564
+ } else if (node.nodeName === 'ink-text') {
565
+ const segments = squashTextNodesToSegments(
566
+ node,
567
+ inheritedBackgroundColor ? { backgroundColor: inheritedBackgroundColor } : undefined
568
+ )
569
+
570
+ // First, get plain text to check if wrapping is needed
571
+ const plainText = segments.map(s => s.text).join('')
572
+
573
+ if (plainText.length > 0) {
574
+ // Upstream Ink uses getMaxWidth(yogaNode) unclamped here. That
575
+ // width comes from Yoga's AtMost pass and can exceed the actual
576
+ // screen space (see getMaxWidth docstring). Yoga's height for this
577
+ // node already reflects the constrained Exactly pass, so clamping
578
+ // the wrap width here keeps line count consistent with layout.
579
+ // Without this, characters past the screen edge are dropped by
580
+ // setCellAt's bounds check.
581
+ const maxWidth = Math.min(getMaxWidth(yogaNode), output.width - x)
582
+ const textWrap = node.style.textWrap ?? 'wrap'
583
+
584
+ // Check if wrapping is needed
585
+ const needsWrapping = widestLine(plainText) > maxWidth
586
+
587
+ let text: string
588
+ let softWrap: boolean[] | undefined
589
+
590
+ if (needsWrapping && segments.length === 1) {
591
+ // Single segment: wrap plain text first, then apply styles to each line
592
+ const segment = segments[0]!
593
+ const w = wrapWithSoftWrap(plainText, maxWidth, textWrap)
594
+ softWrap = w.softWrap
595
+ text = w.wrapped
596
+ .split('\n')
597
+ .map(line => {
598
+ let styled = applyTextStyles(line, segment.styles)
599
+
600
+ // Apply OSC 8 hyperlink per-line so each line is independently
601
+ // clickable. output.ts splits on newlines and tokenizes each
602
+ // line separately, so a single wrapper around the whole block
603
+ // would only apply the hyperlink to the first line.
604
+ if (segment.hyperlink) {
605
+ styled = wrapWithOsc8Link(styled, segment.hyperlink)
606
+ }
607
+
608
+ return styled
609
+ })
610
+ .join('\n')
611
+ } else if (needsWrapping) {
612
+ // Multiple segments with wrapping: wrap plain text first, then re-apply
613
+ // each segment's styles based on character positions. This preserves
614
+ // per-segment styles even when text wraps across lines.
615
+ const w = wrapWithSoftWrap(plainText, maxWidth, textWrap)
616
+ softWrap = w.softWrap
617
+ const charToSegment = buildCharToSegmentMap(segments)
618
+ text = applyStylesToWrappedText(w.wrapped, segments, charToSegment, plainText, textWrap === 'wrap-trim')
619
+ // Hyperlinks are handled per-run in applyStylesToWrappedText via
620
+ // wrapWithOsc8Link, similar to how styles are applied per-run.
621
+ } else {
622
+ // No wrapping needed: apply styles directly
623
+ text = segments
624
+ .map(segment => {
625
+ let styledText = applyTextStyles(segment.text, segment.styles)
626
+
627
+ if (segment.hyperlink) {
628
+ styledText = wrapWithOsc8Link(styledText, segment.hyperlink)
629
+ }
630
+
631
+ return styledText
632
+ })
633
+ .join('')
634
+ }
635
+
636
+ text = applyPaddingToText(node, text, softWrap)
637
+
638
+ output.write(x, y, text, softWrap)
639
+ }
640
+ } else if (node.nodeName === 'ink-box') {
641
+ const boxBackgroundColor = node.style.backgroundColor ?? inheritedBackgroundColor
642
+
643
+ // Mark this box's region as non-selectable (fullscreen text
644
+ // selection). noSelect ops are applied AFTER blits/writes in
645
+ // output.get(), so this wins regardless of what's rendered into
646
+ // the region — including blits from prevScreen when the box is
647
+ // clean (the op is emitted on both the dirty-render path here
648
+ // AND on the blit fast-path at line ~235 since blitRegion copies
649
+ // the noSelect bitmap alongside cells).
650
+ //
651
+ // 'from-left-edge' extends the exclusion from col 0 so any
652
+ // upstream indentation (tool prefix, tree lines) is covered too
653
+ // — a multi-row drag over a diff gutter shouldn't pick up the
654
+ // ` ⎿ ` prefix on row 0 or the blank cells under it on row 1+.
655
+ if (node.style.noSelect) {
656
+ const boxX = Math.floor(x)
657
+ const fromEdge = node.style.noSelect === 'from-left-edge'
658
+ output.noSelect({
659
+ x: fromEdge ? 0 : boxX,
660
+ y: Math.floor(y),
661
+ width: fromEdge ? boxX + Math.floor(width) : Math.floor(width),
662
+ height: Math.floor(height)
663
+ })
664
+ }
665
+
666
+ const overflowX = node.style.overflowX ?? node.style.overflow
667
+ const overflowY = node.style.overflowY ?? node.style.overflow
668
+ const clipHorizontally = overflowX === 'hidden' || overflowX === 'scroll'
669
+ const clipVertically = overflowY === 'hidden' || overflowY === 'scroll'
670
+ const isScrollY = overflowY === 'scroll'
671
+
672
+ const needsClip = clipHorizontally || clipVertically
673
+ let y1: number | undefined
674
+ let y2: number | undefined
675
+
676
+ if (needsClip) {
677
+ const x1 = clipHorizontally ? x + yogaNode.getComputedBorder(LayoutEdge.Left) : undefined
678
+
679
+ const x2 = clipHorizontally
680
+ ? x + yogaNode.getComputedWidth() - yogaNode.getComputedBorder(LayoutEdge.Right)
681
+ : undefined
682
+
683
+ y1 = clipVertically ? y + yogaNode.getComputedBorder(LayoutEdge.Top) : undefined
684
+
685
+ y2 = clipVertically
686
+ ? y + yogaNode.getComputedHeight() - yogaNode.getComputedBorder(LayoutEdge.Bottom)
687
+ : undefined
688
+
689
+ output.clip({ x1, x2, y1, y2 })
690
+ }
691
+
692
+ if (isScrollY) {
693
+ // Scroll containers follow the ScrollBox component structure:
694
+ // a single content-wrapper child with flexShrink:0 (doesn't shrink
695
+ // to fit), whose children are the scrollable items. scrollHeight
696
+ // comes from the wrapper's intrinsic Yoga height. The wrapper is
697
+ // rendered with its Y translated by -scrollTop; its children are
698
+ // culled against the visible window.
699
+ const padTop = yogaNode.getComputedPadding(LayoutEdge.Top)
700
+
701
+ const innerHeight = Math.max(
702
+ 0,
703
+ (y2 ?? y + height) - (y1 ?? y) - padTop - yogaNode.getComputedPadding(LayoutEdge.Bottom)
704
+ )
705
+
706
+ const content = node.childNodes.find(c => (c as DOMElement).yogaNode) as DOMElement | undefined
707
+
708
+ const contentYoga = content?.yogaNode
709
+ // scrollHeight is the intrinsic height of the content wrapper, but
710
+ // after terminal resizes Yoga can leave tall descendants overflowing
711
+ // that wrapper. Use the deepest direct child bottom so sticky-bottom
712
+ // math can still reach the real final rendered row.
713
+ let scrollHeight = Math.ceil(contentYoga?.getComputedHeight() ?? 0)
714
+
715
+ if (content) {
716
+ for (const child of content.childNodes) {
717
+ const childYoga = (child as DOMElement).yogaNode
718
+
719
+ if (childYoga) {
720
+ scrollHeight = Math.max(scrollHeight, Math.ceil(childYoga.getComputedTop() + childYoga.getComputedHeight()))
721
+ }
722
+ }
723
+ }
724
+
725
+ // Capture previous scroll bounds BEFORE overwriting — the at-bottom
726
+ // follow check compares against last frame's max.
727
+ const prevScrollHeight = node.scrollHeight ?? scrollHeight
728
+ const prevInnerHeight = node.scrollViewportHeight ?? innerHeight
729
+ node.scrollHeight = scrollHeight
730
+ node.scrollViewportHeight = innerHeight
731
+ // Absolute screen-buffer row where the scrollable area (inside
732
+ // padding) begins. Exposed via ScrollBoxHandle.getViewportTop() so
733
+ // drag-to-scroll can detect when the drag leaves the scroll viewport.
734
+ node.scrollViewportTop = (y1 ?? y) + padTop
735
+
736
+ const maxScroll = Math.max(0, scrollHeight - innerHeight)
737
+
738
+ // scrollAnchor: scroll so the anchored element's top is at the
739
+ // viewport top (plus offset). Yoga is FRESH — same calculateLayout
740
+ // pass that just produced scrollHeight. Deterministic alternative
741
+ // to scrollTo(N) which bakes a number that's stale by the throttled
742
+ // render; the element ref defers the read to now. One-shot snap.
743
+ // A prior eased-seek version (proportional drain over ~5 frames)
744
+ // moved scrollTop without firing React's notify → parent's quantized
745
+ // store snapshot never updated → StickyTracker got stale range props
746
+ // → firstVisible wrong. Also: SCROLL_MIN_PER_FRAME=4 with snap-at-1
747
+ // ping-ponged forever at delta=2. Smooth needs drain-end notify
748
+ // plumbing; shipping instant first. stickyScroll overrides.
749
+ if (node.scrollAnchor) {
750
+ const anchorTop = node.scrollAnchor.el.yogaNode?.getComputedTop()
751
+
752
+ if (anchorTop != null) {
753
+ node.scrollTop = anchorTop + node.scrollAnchor.offset
754
+ node.pendingScrollDelta = undefined
755
+ }
756
+
757
+ node.scrollAnchor = undefined
758
+ }
759
+
760
+ // At-bottom follow. Positional: if scrollTop was at (or past) the
761
+ // previous max, pin to the new max. Scroll away → stop following;
762
+ // scroll back (or scrollToBottom/sticky attr) → resume. The sticky
763
+ // flag is OR'd in for cold start (scrollTop=0 before first layout)
764
+ // and scrollToBottom-from-far-away (flag set before scrollTop moves)
765
+ // — the imperative field takes precedence over the attribute so
766
+ // scrollTo/scrollBy can break stickiness. pendingDelta<0 guard:
767
+ // don't cancel an in-flight scroll-up when content races in.
768
+ // Capture scrollTop before follow so ink.tsx can translate any
769
+ // active text selection by the same delta (native terminal behavior:
770
+ // view keeps scrolling, highlight walks up with the text).
771
+ const scrollTopBeforeFollow = node.scrollTop ?? 0
772
+ const stickyBeforeFollow = node.stickyScroll
773
+
774
+ const sticky = node.stickyScroll ?? Boolean(node.attributes['stickyScroll'])
775
+
776
+ const prevMaxScroll = Math.max(0, prevScrollHeight - prevInnerHeight)
777
+ // Positional check only valid when content grew — virtualization can
778
+ // transiently SHRINK scrollHeight (tail unmount + stale heightCache
779
+ // spacer) making scrollTop >= prevMaxScroll true by artifact, not
780
+ // because the user was at bottom.
781
+ const grew = scrollHeight >= prevScrollHeight
782
+
783
+ const atBottom = sticky || (grew && scrollTopBeforeFollow >= prevMaxScroll)
784
+
785
+ if (atBottom && (node.pendingScrollDelta ?? 0) >= 0) {
786
+ node.scrollTop = maxScroll
787
+ node.pendingScrollDelta = undefined
788
+
789
+ // Sync flag so useVirtualScroll's isSticky() agrees with positional
790
+ // state — sticky-broken-but-at-bottom (wheel tremor, click-select
791
+ // at max) otherwise leaves useVirtualScroll's clamp holding the
792
+ // viewport short of new streaming content. scrollTo/scrollBy set
793
+ // false; this restores true, same as scrollToBottom() would.
794
+ // Only restore when (a) positionally at bottom and (b) the flag
795
+ // was explicitly broken (===false) by scrollTo/scrollBy. When
796
+ // undefined (never set by user action) leave it alone — setting it
797
+ // would make the sticky flag sticky-by-default and lock out
798
+ // direct scrollTop writes (e.g. the alt-screen-perf test).
799
+ if (node.stickyScroll === false && scrollTopBeforeFollow >= prevMaxScroll) {
800
+ node.stickyScroll = true
801
+ }
802
+ }
803
+
804
+ const followDelta = (node.scrollTop ?? 0) - scrollTopBeforeFollow
805
+
806
+ if (followDelta > 0) {
807
+ const vpTop = node.scrollViewportTop ?? 0
808
+ followScroll = {
809
+ delta: followDelta,
810
+ viewportTop: vpTop,
811
+ viewportBottom: vpTop + innerHeight - 1
812
+ }
813
+ }
814
+
815
+ // Drain pendingScrollDelta. Native terminals (proportional burst
816
+ // events) use proportional drain; xterm.js (VS Code, sparse events +
817
+ // app-side accel curve) uses adaptive small-step drain. isXtermJs()
818
+ // depends on the async XTVERSION probe, but by the time this runs
819
+ // (pendingScrollDelta is only set by wheel events, >>50ms after
820
+ // startup) the probe has resolved — same timing guarantee the
821
+ // wheel-accel curve relies on.
822
+ let cur = node.scrollTop ?? 0
823
+ const pending = node.pendingScrollDelta
824
+ const cMin = node.scrollClampMin
825
+ const cMax = node.scrollClampMax
826
+ const haveClamp = cMin !== undefined && cMax !== undefined
827
+
828
+ if (pending !== undefined && pending !== 0) {
829
+ // Drain continues even past the clamp — the render-clamp below
830
+ // holds the VISUAL at the mounted edge regardless. Hard-stopping
831
+ // here caused stop-start jutter: drain hits edge → pause → React
832
+ // commits → clamp widens → drain resumes → edge again. Letting
833
+ // scrollTop advance smoothly while the clamp lags gives continuous
834
+ // visual scroll at React's commit rate (the clamp catches up each
835
+ // commit). But THROTTLE the drain when already past the clamp so
836
+ // scrollTop doesn't race 5000 rows ahead of the mounted range
837
+ // (slide-cap would then take 200 commits to catch up = long
838
+ // perceived stall at the edge). Past-clamp drain caps at ~4 rows/
839
+ // frame, roughly matching React's slide rate so the gap stays
840
+ // bounded and catch-up is quick once input stops.
841
+ const pastClamp = haveClamp && ((pending < 0 && cur < cMin) || (pending > 0 && cur > cMax))
842
+
843
+ const eff = pastClamp ? Math.min(4, innerHeight >> 3) : innerHeight
844
+ cur += isXtermJsHost() ? drainAdaptive(node, pending, eff) : drainProportional(node, pending, eff)
845
+ } else if (pending === 0) {
846
+ // Opposite scrollBy calls cancelled to zero — clear so we don't
847
+ // schedule an infinite loop of no-op drain frames.
848
+ node.pendingScrollDelta = undefined
849
+ }
850
+
851
+ let scrollTop = Math.max(0, Math.min(cur, maxScroll))
852
+
853
+ // Virtual-scroll clamp: if scrollTop raced past the currently-mounted
854
+ // range (burst PageUp before React re-renders), render at the EDGE of
855
+ // the mounted children instead of blank spacer. Do NOT write back to
856
+ // node.scrollTop — the clamped value is for this paint only; the real
857
+ // scrollTop stays so React's next commit sees the target and mounts
858
+ // the right range. Not scheduling scrollDrainNode here keeps the
859
+ // clamp passive — React's commit → resetAfterCommit → onRender will
860
+ // paint again with fresh bounds.
861
+ const clamped = haveClamp ? Math.max(cMin, Math.min(scrollTop, cMax)) : scrollTop
862
+
863
+ node.scrollTop = scrollTop
864
+
865
+ // Clamp hitting top/bottom consumes any remainder. Set drainPending
866
+ // only after clamp so a wasted no-op frame isn't scheduled.
867
+ if (scrollTop !== cur) {
868
+ node.pendingScrollDelta = undefined
869
+ }
870
+
871
+ if (node.pendingScrollDelta !== undefined) {
872
+ scrollDrainNode = node
873
+ }
874
+
875
+ if (
876
+ (node.scrollTop ?? 0) !== scrollTopBeforeFollow ||
877
+ node.stickyScroll !== stickyBeforeFollow ||
878
+ scrollHeight !== prevScrollHeight ||
879
+ innerHeight !== prevInnerHeight
880
+ ) {
881
+ node.notifyScrollChange?.()
882
+ }
883
+
884
+ scrollTop = clamped
885
+
886
+ if (content && contentYoga) {
887
+ // Compute content wrapper's absolute render position with scroll
888
+ // offset applied, then render its children with culling.
889
+ const contentX = x + contentYoga.getComputedLeft()
890
+ const contentY = y + contentYoga.getComputedTop() - scrollTop
891
+ // layoutShifted detection gap: when scrollTop moves by >= viewport
892
+ // height (batched PageUps, fast wheel), every visible child gets
893
+ // culled (cache dropped) and every newly-visible child has no
894
+ // cache — so the children's positionChanged check can't fire.
895
+ // The content wrapper's cached y (which encodes -scrollTop) is
896
+ // the only node that survives to witness the scroll.
897
+ const contentCached = nodeCache.get(content)
898
+ let hint: ScrollHint | null = null
899
+
900
+ if (contentCached && contentCached.y !== contentY) {
901
+ // delta = newScrollTop - oldScrollTop (positive = scrolled down).
902
+ // Capture a DECSTBM hint if the container itself didn't move
903
+ // and the shift fits within the viewport — otherwise the full
904
+ // rewrite is needed anyway, and layoutShifted stays the fallback.
905
+ const delta = contentCached.y - contentY
906
+ const regionTop = Math.floor(y + contentYoga.getComputedTop())
907
+ const regionBottom = regionTop + innerHeight - 1
908
+
909
+ if (
910
+ cached?.x === x &&
911
+ cached.y === y &&
912
+ cached.width === width &&
913
+ cached.height === height &&
914
+ innerHeight > 0 &&
915
+ Math.abs(delta) < innerHeight
916
+ ) {
917
+ hint = { top: regionTop, bottom: regionBottom, delta }
918
+ scrollHint = hint
919
+ } else {
920
+ layoutShifted = true
921
+ }
922
+ }
923
+
924
+ // Fast path: scroll (hint captured) with usable prevScreen.
925
+ // Blit prevScreen's scroll region into next.screen, shift in-place
926
+ // by delta (mirrors DECSTBM), then render ONLY the edge rows. The
927
+ // nested clip keeps child writes out of stable rows — a tall child
928
+ // that spans edge+stable still renders but stable cells are
929
+ // clipped, preserving the blit. Avoids re-rendering every visible
930
+ // child (expensive for long syntax-highlighted transcripts).
931
+ //
932
+ // When content.dirty (e.g. streaming text at the bottom of the
933
+ // scroll), we still use the fast path — the dirty child is almost
934
+ // always in the edge rows (the bottom, where new content appears).
935
+ // After edge rendering, any dirty children in stable rows are
936
+ // re-rendered in a second pass to avoid showing stale blitted
937
+ // content.
938
+ //
939
+ // Guard: the fast path only handles pure scroll or bottom-append.
940
+ // Child removal/insertion changes the content height in a way that
941
+ // doesn't match the scroll delta — fall back to the full path so
942
+ // removed children don't leave stale cells and shifted siblings
943
+ // render at their new positions.
944
+ const scrollHeight = contentYoga.getComputedHeight()
945
+ const prevHeight = contentCached?.height ?? scrollHeight
946
+ const heightDelta = scrollHeight - prevHeight
947
+
948
+ const safeForFastPath = !hint || heightDelta === 0 || (hint.delta > 0 && heightDelta === hint.delta)
949
+
950
+ // Diagnostics (opt-in via scrollFastPathStats reader). Only
951
+ // counts when a hint was captured — cases where nothing scrolled
952
+ // (hint === null) are not declines, just idle frames.
953
+ if (hint) {
954
+ scrollFastPathStats.captured++
955
+ scrollFastPathStats.lastHintDelta = hint.delta
956
+ scrollFastPathStats.lastScrollHeight = scrollHeight
957
+ scrollFastPathStats.lastPrevHeight = prevHeight
958
+ scrollFastPathStats.lastHeightDelta = heightDelta
959
+
960
+ if (!safeForFastPath) {
961
+ scrollFastPathStats.declined.heightDeltaMismatch++
962
+ scrollFastPathStats.lastDeclineReason = `heightDelta=${heightDelta} hintDelta=${hint.delta}`
963
+ } else if (!prevScreen) {
964
+ scrollFastPathStats.declined.noPrevScreen++
965
+ scrollFastPathStats.lastDeclineReason = 'noPrevScreen'
966
+ } else {
967
+ scrollFastPathStats.taken++
968
+ }
969
+ }
970
+
971
+ // scrollHint is set above when hint is captured. If safeForFastPath
972
+ // is false the full path renders a next.screen that doesn't match
973
+ // the DECSTBM shift — emitting DECSTBM leaves stale rows (seen as
974
+ // content bleeding through during scroll-up + streaming). Clear it.
975
+ if (!safeForFastPath) {
976
+ scrollHint = null
977
+ }
978
+
979
+ if (hint && prevScreen && safeForFastPath) {
980
+ const { top, bottom, delta } = hint
981
+ const w = Math.floor(width)
982
+ output.blit(prevScreen, Math.floor(x), top, w, bottom - top + 1)
983
+ output.shift(top, bottom, delta)
984
+ // Edge rows: new content entering the viewport.
985
+ const edgeTop = delta > 0 ? bottom - delta + 1 : top
986
+ const edgeBottom = delta > 0 ? bottom : top - delta - 1
987
+ output.clear({
988
+ x: Math.floor(x),
989
+ y: edgeTop,
990
+ width: w,
991
+ height: edgeBottom - edgeTop + 1
992
+ })
993
+ output.clip({
994
+ x1: undefined,
995
+ x2: undefined,
996
+ y1: edgeTop,
997
+ y2: edgeBottom + 1
998
+ })
999
+
1000
+ // Snapshot dirty children before the first pass — the first
1001
+ // pass clears dirty flags, and edge-spanning children would be
1002
+ // missed by the second pass without this snapshot.
1003
+ const dirtyChildren = content.dirty
1004
+ ? new Set(content.childNodes.filter(c => (c as DOMElement).dirty))
1005
+ : null
1006
+
1007
+ renderScrolledChildren(
1008
+ content,
1009
+ output,
1010
+ contentX,
1011
+ contentY,
1012
+ hasRemovedChild,
1013
+ undefined,
1014
+ // Cull to edge in child-local coords (inverse of contentY offset).
1015
+ edgeTop - contentY,
1016
+ edgeBottom + 1 - contentY,
1017
+ boxBackgroundColor,
1018
+ true
1019
+ )
1020
+ output.unclip()
1021
+
1022
+ // Second pass: re-render children in stable rows whose screen
1023
+ // position doesn't match where the shift put their old pixels.
1024
+ // Covers TWO cases:
1025
+ // 1. Dirty children — their content changed, blitted pixels are
1026
+ // stale regardless of position.
1027
+ // 2. Clean children BELOW a middle-growth point — when a dirty
1028
+ // sibling above them grows, their yogaTop increases but
1029
+ // scrollTop increases by the same amount (sticky), so their
1030
+ // screenY is CONSTANT. The shift moved their old pixels to
1031
+ // screenY-delta (wrong); they should stay at screenY. Without
1032
+ // this, the spinner/tmux-monitor ghost at shifted positions
1033
+ // during streaming (e.g. triple spinner, pill duplication).
1034
+ // For bottom-append (the common case), all clean children are
1035
+ // ABOVE the growth point; their screenY decreased by delta and
1036
+ // the shift put them at the right place — skipped here, fast
1037
+ // path preserved.
1038
+ if (dirtyChildren) {
1039
+ const edgeTopLocal = edgeTop - contentY
1040
+ const edgeBottomLocal = edgeBottom + 1 - contentY
1041
+ const spaces = ' '.repeat(w)
1042
+ // Track cumulative height change of children iterated so far.
1043
+ // A clean child's yogaTop is unchanged iff this is zero (no
1044
+ // sibling above it grew/shrank/mounted). When zero, the skip
1045
+ // check cached.y−delta === screenY reduces to delta === delta
1046
+ // (tautology) → skip without yoga reads. Restores O(dirty)
1047
+ // that #24536 traded away: for bottom-append the dirty child
1048
+ // is last (all clean children skip); for virtual-scroll range
1049
+ // shift the topSpacer shrink + new-item heights self-balance
1050
+ // to zero before reaching the clean block. Middle-growth
1051
+ // leaves shift non-zero → clean children after the growth
1052
+ // point fall through to yoga + the fine-grained check below,
1053
+ // preserving the ghost-box fix.
1054
+ let cumHeightShift = 0
1055
+
1056
+ for (const childNode of content.childNodes) {
1057
+ const childElem = childNode as DOMElement
1058
+ const isDirty = dirtyChildren.has(childNode)
1059
+
1060
+ if (!isDirty && cumHeightShift === 0) {
1061
+ if (nodeCache.has(childElem)) {
1062
+ continue
1063
+ }
1064
+ // Uncached = culled last frame, now re-entering. blit
1065
+ // never painted it → fall through to yoga + render.
1066
+ // Height unchanged (clean), so cumHeightShift stays 0.
1067
+ }
1068
+
1069
+ const cy = childElem.yogaNode
1070
+
1071
+ if (!cy) {
1072
+ continue
1073
+ }
1074
+
1075
+ const childTop = cy.getComputedTop()
1076
+ const childH = cy.getComputedHeight()
1077
+ const childBottom = childTop + childH
1078
+
1079
+ if (isDirty) {
1080
+ const prev = nodeCache.get(childElem)
1081
+ cumHeightShift += childH - (prev ? prev.height : 0)
1082
+ }
1083
+
1084
+ // Skip culled children (outside viewport)
1085
+ if (childBottom <= scrollTop || childTop >= scrollTop + innerHeight) {
1086
+ continue
1087
+ }
1088
+
1089
+ // Skip children entirely within edge rows (already rendered)
1090
+ if (childTop >= edgeTopLocal && childBottom <= edgeBottomLocal) {
1091
+ continue
1092
+ }
1093
+
1094
+ const screenY = Math.floor(contentY + childTop)
1095
+
1096
+ // Clean children reaching here have cumHeightShift ≠ 0 OR
1097
+ // no cache. Re-check precisely: cached.y − delta is where
1098
+ // the shift left old pixels; if it equals new screenY the
1099
+ // blit is correct (shift re-balanced at this child, or
1100
+ // yogaTop happens to net out). No cache → blit never
1101
+ // painted it → render.
1102
+ if (!isDirty) {
1103
+ const childCached = nodeCache.get(childElem)
1104
+
1105
+ if (childCached && Math.floor(childCached.y) - delta === screenY) {
1106
+ continue
1107
+ }
1108
+ }
1109
+
1110
+ // Wipe this child's region with spaces to overwrite stale
1111
+ // blitted content — output.clear() only expands damage and
1112
+ // cannot zero cells that the blit already wrote.
1113
+ const screenBottom = Math.min(
1114
+ Math.floor(contentY + childBottom),
1115
+ Math.floor((y1 ?? y) + padTop + innerHeight)
1116
+ )
1117
+
1118
+ if (screenY < screenBottom) {
1119
+ const fill = Array(screenBottom - screenY)
1120
+ .fill(spaces)
1121
+ .join('\n')
1122
+
1123
+ output.write(Math.floor(x), screenY, fill)
1124
+ output.clip({
1125
+ x1: undefined,
1126
+ x2: undefined,
1127
+ y1: screenY,
1128
+ y2: screenBottom
1129
+ })
1130
+ renderNodeToOutput(childElem, output, {
1131
+ offsetX: contentX,
1132
+ offsetY: contentY,
1133
+ prevScreen: undefined,
1134
+ inheritedBackgroundColor: boxBackgroundColor
1135
+ })
1136
+ output.unclip()
1137
+ }
1138
+ }
1139
+ }
1140
+
1141
+ // Third pass: repair rows where shifted copies of absolute
1142
+ // overlays landed. The blit copied prevScreen cells INCLUDING
1143
+ // overlay pixels (overlays render AFTER this ScrollBox so they
1144
+ // painted into prevScreen's scroll region). After shift, those
1145
+ // pixels sit at (rect.y - delta) — neither edge render nor the
1146
+ // overlay's own re-render covers them. Wipe and re-render
1147
+ // ScrollBox content so the diff writes correct cells.
1148
+ const spaces = absoluteRectsPrev.length ? ' '.repeat(w) : ''
1149
+
1150
+ for (const r of absoluteRectsPrev) {
1151
+ if (r.y >= bottom + 1 || r.y + r.height <= top) {
1152
+ continue
1153
+ }
1154
+
1155
+ const shiftedTop = Math.max(top, Math.floor(r.y) - delta)
1156
+
1157
+ const shiftedBottom = Math.min(bottom + 1, Math.floor(r.y + r.height) - delta)
1158
+
1159
+ // Skip if entirely within edge rows (already rendered).
1160
+ if (shiftedTop >= edgeTop && shiftedBottom <= edgeBottom + 1) {
1161
+ continue
1162
+ }
1163
+
1164
+ if (shiftedTop >= shiftedBottom) {
1165
+ continue
1166
+ }
1167
+
1168
+ const fill = Array(shiftedBottom - shiftedTop)
1169
+ .fill(spaces)
1170
+ .join('\n')
1171
+
1172
+ output.write(Math.floor(x), shiftedTop, fill)
1173
+ output.clip({
1174
+ x1: undefined,
1175
+ x2: undefined,
1176
+ y1: shiftedTop,
1177
+ y2: shiftedBottom
1178
+ })
1179
+ renderScrolledChildren(
1180
+ content,
1181
+ output,
1182
+ contentX,
1183
+ contentY,
1184
+ hasRemovedChild,
1185
+ undefined,
1186
+ shiftedTop - contentY,
1187
+ shiftedBottom - contentY,
1188
+ boxBackgroundColor,
1189
+ true
1190
+ )
1191
+ output.unclip()
1192
+ }
1193
+ } else {
1194
+ // Full path. Two sub-cases:
1195
+ //
1196
+ // Scrolled without a usable hint (big jump, container moved):
1197
+ // child positions in prevScreen are stale. Clear the viewport
1198
+ // and disable blit so children don't restore shifted content.
1199
+ //
1200
+ // No scroll (spinner tick, content edit): child positions in
1201
+ // prevScreen are still valid. Skip the viewport clear and pass
1202
+ // prevScreen so unchanged children blit. Dirty children already
1203
+ // self-clear via their own cached-rect clear. Without this, a
1204
+ // spinner inside ScrollBox forces a full-content rewrite every
1205
+ // frame — on wide terminals over tmux (no BSU/ESU) the
1206
+ // bandwidth crosses the chunk boundary and the frame tears.
1207
+ const scrolled = contentCached && contentCached.y !== contentY
1208
+
1209
+ if (scrolled && y1 !== undefined && y2 !== undefined) {
1210
+ output.clear({
1211
+ x: Math.floor(x),
1212
+ y: Math.floor(y1),
1213
+ width: Math.floor(width),
1214
+ height: Math.floor(y2 - y1)
1215
+ })
1216
+ }
1217
+
1218
+ // positionChanged (ScrollBox height shrunk — pill mount) means a
1219
+ // child spanning the old bottom edge would blit its full cached
1220
+ // rect past the new clip. output.ts clips blits now, but also
1221
+ // disable prevScreen here so the partial-row child re-renders at
1222
+ // correct bounds instead of blitting a clipped (truncated) old
1223
+ // rect.
1224
+ renderScrolledChildren(
1225
+ content,
1226
+ output,
1227
+ contentX,
1228
+ contentY,
1229
+ hasRemovedChild,
1230
+ scrolled || positionChanged ? undefined : prevScreen,
1231
+ scrollTop,
1232
+ scrollTop + innerHeight,
1233
+ boxBackgroundColor
1234
+ )
1235
+ }
1236
+
1237
+ nodeCache.set(content, {
1238
+ x: contentX,
1239
+ y: contentY,
1240
+ width: contentYoga.getComputedWidth(),
1241
+ height: contentYoga.getComputedHeight()
1242
+ })
1243
+ content.dirty = false
1244
+ }
1245
+ } else {
1246
+ // Fill interior with background color before rendering children.
1247
+ // This covers padding areas and empty space; child text inherits
1248
+ // the color via inheritedBackgroundColor so written cells also
1249
+ // get the background.
1250
+ // Disable prevScreen for children: the fill overwrites the entire
1251
+ // interior each render, so child blits from prevScreen would restore
1252
+ // stale cells (wrong bg if it changed) on top of the fresh fill.
1253
+ const ownBackgroundColor = node.style.backgroundColor
1254
+
1255
+ if (ownBackgroundColor || node.style.opaque) {
1256
+ const borderLeft = yogaNode.getComputedBorder(LayoutEdge.Left)
1257
+ const borderRight = yogaNode.getComputedBorder(LayoutEdge.Right)
1258
+ const borderTop = yogaNode.getComputedBorder(LayoutEdge.Top)
1259
+ const borderBottom = yogaNode.getComputedBorder(LayoutEdge.Bottom)
1260
+ const innerWidth = Math.floor(width) - borderLeft - borderRight
1261
+ const innerHeight = Math.floor(height) - borderTop - borderBottom
1262
+
1263
+ if (innerWidth > 0 && innerHeight > 0) {
1264
+ const spaces = ' '.repeat(innerWidth)
1265
+
1266
+ const fillLine = ownBackgroundColor
1267
+ ? applyTextStyles(spaces, { backgroundColor: ownBackgroundColor })
1268
+ : spaces
1269
+
1270
+ const fill = Array(innerHeight).fill(fillLine).join('\n')
1271
+ output.write(x + borderLeft, y + borderTop, fill)
1272
+ }
1273
+ }
1274
+
1275
+ renderChildren(
1276
+ node,
1277
+ output,
1278
+ x,
1279
+ y,
1280
+ hasRemovedChild,
1281
+ // backgroundColor and opaque both disable child blit: the fill
1282
+ // overwrites the entire interior each render, so any child whose
1283
+ // layout position shifted would blit stale cells from prevScreen
1284
+ // on top of the fresh fill. Previously opaque kept blit enabled
1285
+ // on the assumption that plain-space fill + unchanged children =
1286
+ // valid composite, but children CAN reposition (ScrollBox remeasure
1287
+ // on re-render → /permissions body blanked on Down arrow, #25436).
1288
+ ownBackgroundColor || node.style.opaque ? undefined : prevScreen,
1289
+ boxBackgroundColor
1290
+ )
1291
+ }
1292
+
1293
+ if (needsClip) {
1294
+ output.unclip()
1295
+ }
1296
+
1297
+ // Render border AFTER children to ensure it's not overwritten by child
1298
+ // clearing operations. When a child shrinks, it clears its old area,
1299
+ // which may overlap with where the parent's border now is.
1300
+ renderBorder(x, y, node, output)
1301
+ } else if (node.nodeName === 'ink-root') {
1302
+ renderChildren(node, output, x, y, hasRemovedChild, prevScreen, inheritedBackgroundColor)
1303
+ }
1304
+
1305
+ // Cache layout bounds for dirty tracking
1306
+ const rect = { x, y, width, height, top: yogaTop }
1307
+ nodeCache.set(node, rect)
1308
+
1309
+ if (node.style.position === 'absolute') {
1310
+ absoluteRectsCur.push(rect)
1311
+ }
1312
+
1313
+ node.dirty = false
1314
+ }
1315
+ }
1316
+
1317
+ // Overflow contamination: content overflows right/down, so clean siblings
1318
+ // AFTER a dirty/removed sibling can contain stale overflow in prevScreen.
1319
+ // Disable blit for siblings after a dirty child — but still pass prevScreen
1320
+ // TO the dirty child itself so its clean descendants can blit. The dirty
1321
+ // child's own blit check already fails (node.dirty=true at line 216), so
1322
+ // passing prevScreen only benefits its subtree.
1323
+ // For removed children we don't know their original position, so
1324
+ // conservatively disable blit for all.
1325
+ //
1326
+ // Clipped children (overflow hidden/scroll on both axes) cannot overflow
1327
+ // onto later siblings — their content is confined to their layout bounds.
1328
+ // Skip the contamination guard for them so later siblings can still blit.
1329
+ // Without this, a spinner inside a ScrollBox dirties the wrapper on every
1330
+ // tick and the bottom prompt section never blits → 100% writes every frame.
1331
+ //
1332
+ // Exception: absolute-positioned clipped children may have layout bounds
1333
+ // that overlap arbitrary siblings, so the clipping does not help.
1334
+ //
1335
+ // Overlap contamination (seenDirtyClipped): a later ABSOLUTE sibling whose
1336
+ // rect sits inside a dirty clipped child's bounds would blit stale cells
1337
+ // from prevScreen — the clipped child just rewrote those cells this frame.
1338
+ // The clipsBothAxes skip only protects against OVERFLOW (clipped child
1339
+ // painting outside its bounds), not overlap (absolute sibling painting
1340
+ // inside them). For non-opaque absolute siblings, skipSelfBlit forces
1341
+ // descent (the full-width rect has transparent gaps → stale blit) while
1342
+ // still passing prevScreen so opaque descendants can blit their narrower
1343
+ // rects (NewMessagesPill's inner Text with backgroundColor). Opaque
1344
+ // absolute siblings fill their entire rect — direct blit is safe.
1345
+ function renderChildren(
1346
+ node: DOMElement,
1347
+ output: Output,
1348
+ offsetX: number,
1349
+ offsetY: number,
1350
+ hasRemovedChild: boolean,
1351
+ prevScreen: Screen | undefined,
1352
+ inheritedBackgroundColor: Color | undefined
1353
+ ): void {
1354
+ let seenDirtyChild = false
1355
+ let seenDirtyClipped = false
1356
+
1357
+ for (const childNode of node.childNodes) {
1358
+ const childElem = childNode as DOMElement
1359
+ // Capture dirty before rendering — renderNodeToOutput clears the flag
1360
+ const wasDirty = childElem.dirty
1361
+ const isAbsolute = childElem.style.position === 'absolute'
1362
+ renderNodeToOutput(childElem, output, {
1363
+ offsetX,
1364
+ offsetY,
1365
+ prevScreen: hasRemovedChild || seenDirtyChild ? undefined : prevScreen,
1366
+ // Short-circuits on seenDirtyClipped (false in the common case) so
1367
+ // the opaque/bg reads don't happen per-child per-frame.
1368
+ skipSelfBlit:
1369
+ seenDirtyClipped && isAbsolute && !childElem.style.opaque && childElem.style.backgroundColor === undefined,
1370
+ inheritedBackgroundColor
1371
+ })
1372
+
1373
+ if (wasDirty && !seenDirtyChild) {
1374
+ if (!clipsBothAxes(childElem) || isAbsolute) {
1375
+ seenDirtyChild = true
1376
+ } else {
1377
+ seenDirtyClipped = true
1378
+ }
1379
+ }
1380
+ }
1381
+ }
1382
+
1383
+ function clipsBothAxes(node: DOMElement): boolean {
1384
+ const ox = node.style.overflowX ?? node.style.overflow
1385
+ const oy = node.style.overflowY ?? node.style.overflow
1386
+
1387
+ return (ox === 'hidden' || ox === 'scroll') && (oy === 'hidden' || oy === 'scroll')
1388
+ }
1389
+
1390
+ // When Yoga squeezes a box to h=0, the ghost only happens if a sibling
1391
+ // lands at the same computed top — then both write to that row and the
1392
+ // shorter content leaves the longer's tail visible. Yoga's pixel-grid
1393
+ // rounding can give h=0 while still advancing the next sibling's top
1394
+ // (HelpV2's third shortcuts column), so h=0 alone isn't sufficient.
1395
+ function siblingSharesY(node: DOMElement, yogaNode: LayoutNode): boolean {
1396
+ const parent = node.parentNode
1397
+
1398
+ if (!parent) {
1399
+ return false
1400
+ }
1401
+
1402
+ const myTop = yogaNode.getComputedTop()
1403
+ const siblings = parent.childNodes
1404
+ const idx = siblings.indexOf(node)
1405
+
1406
+ for (let i = idx + 1; i < siblings.length; i++) {
1407
+ const sib = (siblings[i] as DOMElement).yogaNode
1408
+
1409
+ if (!sib) {
1410
+ continue
1411
+ }
1412
+
1413
+ return sib.getComputedTop() === myTop
1414
+ }
1415
+
1416
+ // No next sibling with a yoga node — check previous. A run of h=0 boxes
1417
+ // at the tail would all share y with each other.
1418
+ for (let i = idx - 1; i >= 0; i--) {
1419
+ const sib = (siblings[i] as DOMElement).yogaNode
1420
+
1421
+ if (!sib) {
1422
+ continue
1423
+ }
1424
+
1425
+ return sib.getComputedTop() === myTop
1426
+ }
1427
+
1428
+ return false
1429
+ }
1430
+
1431
+ // When a node blits, its absolute-positioned descendants that paint outside
1432
+ // the node's layout bounds are NOT covered by the blit (which only copies
1433
+ // the node's own rect). If a dirty sibling re-rendered and overwrote those
1434
+ // cells, we must re-blit them from prevScreen so the overlays survive.
1435
+ // Example: PromptInputFooter's slash menu uses position='absolute' bottom='100%'
1436
+ // to float above the prompt; a spinner tick in the ScrollBox above re-renders
1437
+ // and overwrites those cells. Without this, the menu vanishes on the next frame.
1438
+ function blitEscapingAbsoluteDescendants(
1439
+ node: DOMElement,
1440
+ output: Output,
1441
+ prevScreen: Screen,
1442
+ px: number,
1443
+ py: number,
1444
+ pw: number,
1445
+ ph: number
1446
+ ): void {
1447
+ const pr = px + pw
1448
+ const pb = py + ph
1449
+
1450
+ for (const child of node.childNodes) {
1451
+ if (child.nodeName === '#text') {
1452
+ continue
1453
+ }
1454
+
1455
+ const elem = child as DOMElement
1456
+
1457
+ if (elem.style.position === 'absolute') {
1458
+ const cached = nodeCache.get(elem)
1459
+
1460
+ if (cached) {
1461
+ absoluteRectsCur.push(cached)
1462
+ const cx = Math.floor(cached.x)
1463
+ const cy = Math.floor(cached.y)
1464
+ const cw = Math.floor(cached.width)
1465
+ const ch = Math.floor(cached.height)
1466
+
1467
+ // Only blit rects that extend outside the parent's layout bounds —
1468
+ // cells within the parent rect are already covered by the parent blit.
1469
+ if (cx < px || cy < py || cx + cw > pr || cy + ch > pb) {
1470
+ output.blit(prevScreen, cx, cy, cw, ch)
1471
+ }
1472
+ }
1473
+ }
1474
+
1475
+ // Recurse — absolute descendants can be nested arbitrarily deep
1476
+ blitEscapingAbsoluteDescendants(elem, output, prevScreen, px, py, pw, ph)
1477
+ }
1478
+ }
1479
+
1480
+ // Render children of a scroll container with viewport culling.
1481
+ // scrollTopY..scrollBottomY are the visible window in CHILD-LOCAL Yoga coords
1482
+ // (i.e. what getComputedTop() returns). Children entirely outside this window
1483
+ // are skipped; their nodeCache entry is deleted so if they re-enter the
1484
+ // viewport later they don't emit a stale clear for a position now occupied
1485
+ // by a sibling.
1486
+ function renderScrolledChildren(
1487
+ node: DOMElement,
1488
+ output: Output,
1489
+ offsetX: number,
1490
+ offsetY: number,
1491
+ hasRemovedChild: boolean,
1492
+ prevScreen: Screen | undefined,
1493
+ scrollTopY: number,
1494
+ scrollBottomY: number,
1495
+ inheritedBackgroundColor: Color | undefined,
1496
+ // When true (DECSTBM fast path), culled children keep their cache —
1497
+ // the blit+shift put stable rows in next.screen so stale cache is
1498
+ // never read. Avoids walking O(total_children * subtree_depth) per frame.
1499
+ preserveCulledCache = false
1500
+ ): void {
1501
+ let seenDirtyChild = false
1502
+ // Track cumulative height shift of dirty children iterated so far. When
1503
+ // zero, a clean child's yogaTop is unchanged (no sibling above it grew),
1504
+ // so cached.top is fresh and the cull check skips yoga. Bottom-append
1505
+ // has the dirty child last → all prior clean children hit cache →
1506
+ // O(dirty) not O(mounted). Middle-growth leaves shift non-zero after
1507
+ // the dirty child → subsequent children yoga-read (needed for correct
1508
+ // culling since their yogaTop shifted).
1509
+ let cumHeightShift = 0
1510
+
1511
+ for (const childNode of node.childNodes) {
1512
+ const childElem = childNode as DOMElement
1513
+ const cy = childElem.yogaNode
1514
+
1515
+ if (cy) {
1516
+ const cached = nodeCache.get(childElem)
1517
+ let top: number
1518
+ let height: number
1519
+
1520
+ if (cached?.top !== undefined && !childElem.dirty && cumHeightShift === 0) {
1521
+ top = cached.top
1522
+ height = cached.height
1523
+ } else {
1524
+ top = cy.getComputedTop()
1525
+ height = cy.getComputedHeight()
1526
+
1527
+ if (childElem.dirty) {
1528
+ cumHeightShift += height - (cached ? cached.height : 0)
1529
+ }
1530
+
1531
+ // Refresh cached top so next frame's cumShift===0 path stays
1532
+ // correct. For culled children with preserveCulledCache=true this
1533
+ // is the ONLY refresh point — without it, a middle-growth frame
1534
+ // leaves stale tops that misfire next frame.
1535
+ if (cached) {
1536
+ cached.top = top
1537
+ }
1538
+ }
1539
+
1540
+ const bottom = top + height
1541
+
1542
+ if (bottom <= scrollTopY || top >= scrollBottomY) {
1543
+ // Culled — outside visible window. Drop stale cache entries from
1544
+ // the subtree so when this child re-enters it doesn't fire clears
1545
+ // at positions now occupied by siblings. The viewport-clear on
1546
+ // scroll-change handles the visible-area repaint.
1547
+ if (!preserveCulledCache) {
1548
+ dropSubtreeCache(childElem)
1549
+ }
1550
+
1551
+ continue
1552
+ }
1553
+ }
1554
+
1555
+ const wasDirty = childElem.dirty
1556
+ renderNodeToOutput(childElem, output, {
1557
+ offsetX,
1558
+ offsetY,
1559
+ prevScreen: hasRemovedChild || seenDirtyChild ? undefined : prevScreen,
1560
+ inheritedBackgroundColor
1561
+ })
1562
+
1563
+ if (wasDirty) {
1564
+ seenDirtyChild = true
1565
+ }
1566
+ }
1567
+ }
1568
+
1569
+ function dropSubtreeCache(node: DOMElement): void {
1570
+ nodeCache.delete(node)
1571
+
1572
+ for (const child of node.childNodes) {
1573
+ if (child.nodeName !== '#text') {
1574
+ dropSubtreeCache(child as DOMElement)
1575
+ }
1576
+ }
1577
+ }
1578
+
1579
+ // Exported for testing
1580
+ export { applyStylesToWrappedText, buildCharToSegmentMap }
1581
+
1582
+ export default renderNodeToOutput