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,1590 @@
1
+ import { type AnsiCode, ansiCodesToString, diffAnsiCodes } from '@alcalzone/ansi-tokenize'
2
+
3
+ import { type Point, type Rectangle, type Size, unionRect } from './layout/geometry.js'
4
+ import { BEL, ESC, SEP } from './termio/ansi.js'
5
+ import * as warn from './warn.js'
6
+
7
+ // --- Shared Pools (interning for memory efficiency) ---
8
+
9
+ // Character string pool shared across all screens.
10
+ // With a shared pool, interned char IDs are valid across screens,
11
+ // so blitRegion can copy IDs directly (no re-interning) and
12
+ // diffEach can compare IDs as integers (no string lookup).
13
+ export class CharPool {
14
+ private strings: string[] = [' ', ''] // Index 0 = space, 1 = empty (spacer)
15
+ private stringMap = new Map<string, number>([
16
+ [' ', 0],
17
+ ['', 1]
18
+ ])
19
+ private ascii: Int32Array = initCharAscii() // charCode → index, -1 = not interned
20
+
21
+ intern(char: string): number {
22
+ // ASCII fast-path: direct array lookup instead of Map.get
23
+ if (char.length === 1) {
24
+ const code = char.charCodeAt(0)
25
+
26
+ if (code < 128) {
27
+ const cached = this.ascii[code]!
28
+
29
+ if (cached !== -1) {
30
+ return cached
31
+ }
32
+
33
+ const index = this.strings.length
34
+ this.strings.push(char)
35
+ this.ascii[code] = index
36
+
37
+ return index
38
+ }
39
+ }
40
+
41
+ const existing = this.stringMap.get(char)
42
+
43
+ if (existing !== undefined) {
44
+ return existing
45
+ }
46
+
47
+ const index = this.strings.length
48
+ this.strings.push(char)
49
+ this.stringMap.set(char, index)
50
+
51
+ return index
52
+ }
53
+
54
+ get(index: number): string {
55
+ return this.strings[index] ?? ' '
56
+ }
57
+ }
58
+
59
+ // Hyperlink string pool shared across all screens.
60
+ // Index 0 = no hyperlink.
61
+ export class HyperlinkPool {
62
+ private strings: string[] = [''] // Index 0 = no hyperlink
63
+ private stringMap = new Map<string, number>()
64
+
65
+ intern(hyperlink: string | undefined): number {
66
+ if (!hyperlink) {
67
+ return 0
68
+ }
69
+
70
+ let id = this.stringMap.get(hyperlink)
71
+
72
+ if (id === undefined) {
73
+ id = this.strings.length
74
+ this.strings.push(hyperlink)
75
+ this.stringMap.set(hyperlink, id)
76
+ }
77
+
78
+ return id
79
+ }
80
+
81
+ get(id: number): string | undefined {
82
+ return id === 0 ? undefined : this.strings[id]
83
+ }
84
+ }
85
+
86
+ // SGR 7 (inverse) as an AnsiCode. endCode '\x1b[27m' flags VISIBLE_ON_SPACE
87
+ // so bit 0 of the resulting styleId is set → renderer won't skip inverted
88
+ // spaces as invisible.
89
+ const INVERSE_CODE: AnsiCode = {
90
+ type: 'ansi',
91
+ code: '\x1b[7m',
92
+ endCode: '\x1b[27m'
93
+ }
94
+
95
+ // Bold (SGR 1) — stacks cleanly, no reflow in monospace. endCode 22
96
+ // also cancels dim (SGR 2); harmless here since we never add dim.
97
+ const BOLD_CODE: AnsiCode = {
98
+ type: 'ansi',
99
+ code: '\x1b[1m',
100
+ endCode: '\x1b[22m'
101
+ }
102
+
103
+ // Underline (SGR 4). Kept alongside yellow+bold — the underline is the
104
+ // unambiguous visible-on-any-theme marker. Yellow-bg-via-inverse can
105
+ // clash with existing bg colors (user-prompt style, tool chrome, syntax
106
+ // bg). If you see underline but no yellow, the yellow is being lost in
107
+ // the existing cell styling — the overlay IS finding the match.
108
+ const UNDERLINE_CODE: AnsiCode = {
109
+ type: 'ansi',
110
+ code: '\x1b[4m',
111
+ endCode: '\x1b[24m'
112
+ }
113
+
114
+ // fg→yellow (SGR 33). With inverse already in the stack, the terminal
115
+ // swaps fg↔bg at render — so yellow-fg becomes yellow-BG. Original bg
116
+ // becomes fg (readable on most themes: dark-bg → dark-text on yellow).
117
+ // endCode 39 is 'default fg' — cancels any prior fg color cleanly.
118
+ const YELLOW_FG_CODE: AnsiCode = {
119
+ type: 'ansi',
120
+ code: '\x1b[33m',
121
+ endCode: '\x1b[39m'
122
+ }
123
+
124
+ const MAX_TRANSITION_CACHE = 32768
125
+
126
+ export class StylePool {
127
+ private ids = new Map<string, number>()
128
+ private styles: AnsiCode[][] = []
129
+ private transitionCache = new Map<number, string>()
130
+ readonly none: number
131
+
132
+ constructor() {
133
+ this.none = this.intern([])
134
+ }
135
+
136
+ /**
137
+ * Intern a style and return its ID. Bit 0 of the ID encodes whether the
138
+ * style has a visible effect on space characters (background, inverse,
139
+ * underline, etc.). Foreground-only styles get even IDs; styles visible
140
+ * on spaces get odd IDs. This lets the renderer skip invisible spaces
141
+ * with a single bitmask check on the packed word.
142
+ */
143
+ intern(styles: AnsiCode[]): number {
144
+ const key = styles.length === 0 ? '' : styles.map(s => s.code).join('\0')
145
+ let id = this.ids.get(key)
146
+
147
+ if (id === undefined) {
148
+ const rawId = this.styles.length
149
+ this.styles.push(styles.length === 0 ? [] : styles)
150
+ id = (rawId << 1) | (styles.length > 0 && hasVisibleSpaceEffect(styles) ? 1 : 0)
151
+ this.ids.set(key, id)
152
+ }
153
+
154
+ return id
155
+ }
156
+
157
+ /** Recover styles from an encoded ID. Strips the bit-0 flag via >>> 1. */
158
+ get(id: number): AnsiCode[] {
159
+ return this.styles[id >>> 1] ?? []
160
+ }
161
+
162
+ /**
163
+ * Returns the pre-serialized ANSI string to transition from one style to
164
+ * another. Cached by (fromId, toId) — zero allocations after first call
165
+ * for a given pair. Full-clear at MAX_TRANSITION_CACHE guards against
166
+ * unbounded growth from ever-expanding id spaces; cache repopulates from
167
+ * the next frame's actual transitions.
168
+ */
169
+ transition(fromId: number, toId: number): string {
170
+ if (fromId === toId) {
171
+ return ''
172
+ }
173
+
174
+ const key = fromId * 0x100000 + toId
175
+ let str = this.transitionCache.get(key)
176
+
177
+ if (str === undefined) {
178
+ if (this.transitionCache.size >= MAX_TRANSITION_CACHE) {
179
+ this.transitionCache.clear()
180
+ }
181
+
182
+ str = ansiCodesToString(diffAnsiCodes(this.get(fromId), this.get(toId)))
183
+ this.transitionCache.set(key, str)
184
+ }
185
+
186
+ return str
187
+ }
188
+
189
+ /**
190
+ * Intern a style that is `base + inverse`. Cached by base ID so
191
+ * repeated calls for the same underlying style don't re-scan the
192
+ * AnsiCode[] array. Used by the selection overlay.
193
+ */
194
+ private inverseCache = new Map<number, number>()
195
+ withInverse(baseId: number): number {
196
+ let id = this.inverseCache.get(baseId)
197
+
198
+ if (id === undefined) {
199
+ const baseCodes = this.get(baseId)
200
+ // If already inverted, use as-is (avoids SGR 7 stacking)
201
+ const hasInverse = baseCodes.some(c => c.endCode === '\x1b[27m')
202
+ id = hasInverse ? baseId : this.intern([...baseCodes, INVERSE_CODE])
203
+ this.inverseCache.set(baseId, id)
204
+ }
205
+
206
+ return id
207
+ }
208
+
209
+ /** Inverse + bold + yellow-bg-via-fg-swap for the CURRENT search match.
210
+ * OTHER matches are plain inverse — bg inherits from the theme. Current
211
+ * gets a distinct yellow bg (via fg-then-inverse swap) plus bold weight
212
+ * so it stands out in a sea of inverse. Underline was too subtle. Zero
213
+ * reflow risk: all pure SGR overlays, per-cell, post-layout. The yellow
214
+ * overrides any existing fg (syntax highlighting) on those cells — fine,
215
+ * the "you are here" signal IS the point, syntax color can yield. */
216
+ private currentMatchCache = new Map<number, number>()
217
+ withCurrentMatch(baseId: number): number {
218
+ let id = this.currentMatchCache.get(baseId)
219
+
220
+ if (id === undefined) {
221
+ const baseCodes = this.get(baseId)
222
+
223
+ // Filter BOTH fg + bg so yellow-via-inverse is unambiguous.
224
+ // User-prompt cells have an explicit bg (grey box); with that bg
225
+ // still set, inverse swaps yellow-fg↔grey-bg → grey-on-yellow on
226
+ // SOME terminals, yellow-on-grey on others (inverse semantics vary
227
+ // when both colors are explicit). Filtering both gives clean
228
+ // yellow-bg + terminal-default-fg everywhere. Bold/dim/italic
229
+ // coexist — keep those.
230
+ const codes = baseCodes.filter(c => c.endCode !== '\x1b[39m' && c.endCode !== '\x1b[49m')
231
+
232
+ // fg-yellow FIRST so inverse swaps it to bg. Bold after inverse is
233
+ // fine — SGR 1 is fg-attribute-only, order-independent vs 7.
234
+ codes.push(YELLOW_FG_CODE)
235
+
236
+ if (!baseCodes.some(c => c.endCode === '\x1b[27m')) {
237
+ codes.push(INVERSE_CODE)
238
+ }
239
+
240
+ if (!baseCodes.some(c => c.endCode === '\x1b[22m')) {
241
+ codes.push(BOLD_CODE)
242
+ }
243
+
244
+ // Underline as the unambiguous marker — yellow-bg can clash with
245
+ // existing bg styling (user-prompt bg, syntax bg). If you see
246
+ // underline but no yellow on a match, the overlay IS finding it;
247
+ // the yellow is just losing a styling fight.
248
+ if (!baseCodes.some(c => c.endCode === '\x1b[24m')) {
249
+ codes.push(UNDERLINE_CODE)
250
+ }
251
+
252
+ id = this.intern(codes)
253
+ this.currentMatchCache.set(baseId, id)
254
+ }
255
+
256
+ return id
257
+ }
258
+
259
+ /**
260
+ * Selection overlay: REPLACE the cell's background with a solid color
261
+ * while preserving its foreground (color, bold, italic, dim, underline).
262
+ * Matches native terminal selection — a dedicated bg color, not SGR-7
263
+ * inverse. Inverse swaps fg/bg per-cell, which fragments visually over
264
+ * syntax-highlighted text (every fg color becomes a different bg stripe).
265
+ *
266
+ * Strips any existing bg (endCode 49m — REPLACES, so diff-added green
267
+ * etc. don't bleed through) and any existing inverse (endCode 27m —
268
+ * inverse on top of a solid bg would re-swap and look wrong).
269
+ *
270
+ * bg is set via setSelectionBg(); null → fallback to withInverse() so the
271
+ * overlay still works before theme wiring sets a color (tests, first frame).
272
+ * Cache is keyed by baseId only — setSelectionBg() clears it on change.
273
+ */
274
+ private selectionBgCode: AnsiCode | null = null
275
+ private selectionBgCache = new Map<number, number>()
276
+ setSelectionBg(bg: AnsiCode | null): void {
277
+ if (this.selectionBgCode?.code === bg?.code) {
278
+ return
279
+ }
280
+
281
+ this.selectionBgCode = bg
282
+ this.selectionBgCache.clear()
283
+ }
284
+ withSelectionBg(baseId: number): number {
285
+ const bg = this.selectionBgCode
286
+
287
+ if (bg === null) {
288
+ return this.withInverse(baseId)
289
+ }
290
+
291
+ let id = this.selectionBgCache.get(baseId)
292
+
293
+ if (id === undefined) {
294
+ // Keep everything except bg (49m) and inverse (27m). Fg, bold, dim,
295
+ // italic, underline, strikethrough all preserved.
296
+ const kept = this.get(baseId).filter(c => c.endCode !== '\x1b[49m' && c.endCode !== '\x1b[27m')
297
+
298
+ kept.push(bg)
299
+ id = this.intern(kept)
300
+ this.selectionBgCache.set(baseId, id)
301
+ }
302
+
303
+ return id
304
+ }
305
+ }
306
+
307
+ // endCodes that produce visible effects on space characters
308
+ const VISIBLE_ON_SPACE = new Set([
309
+ '\x1b[49m', // background color
310
+ '\x1b[27m', // inverse
311
+ '\x1b[24m', // underline
312
+ '\x1b[29m', // strikethrough
313
+ '\x1b[55m' // overline
314
+ ])
315
+
316
+ function hasVisibleSpaceEffect(styles: AnsiCode[]): boolean {
317
+ for (const style of styles) {
318
+ if (VISIBLE_ON_SPACE.has(style.endCode)) {
319
+ return true
320
+ }
321
+ }
322
+
323
+ return false
324
+ }
325
+
326
+ /**
327
+ * Cell width classification for handling double-wide characters (CJK, emoji,
328
+ * etc.)
329
+ *
330
+ * We use explicit spacer cells rather than inferring width at render time. This
331
+ * makes the data structure self-describing and simplifies cursor positioning
332
+ * logic.
333
+ *
334
+ * @see https://mitchellh.com/writing/grapheme-clusters-in-terminals
335
+ */
336
+ // const enum is inlined at compile time - no runtime object, no property access
337
+ export const enum CellWidth {
338
+ // Not a wide character, cell width 1
339
+ Narrow = 0,
340
+ // Wide character, cell width 2. This cell contains the actual character.
341
+ Wide = 1,
342
+ // Spacer occupying the second visual column of a wide character. Do not render.
343
+ SpacerTail = 2,
344
+ // Spacer at the end of a soft-wrapped line indicating that a wide character
345
+ // continues on the next line. Used for preserving wide character semantics
346
+ // across line breaks during soft wrapping.
347
+ SpacerHead = 3
348
+ }
349
+
350
+ export type Hyperlink = string | undefined
351
+
352
+ /**
353
+ * Cell is a view type returned by cellAt(). Cells are stored as packed typed
354
+ * arrays internally to avoid GC pressure from allocating objects per cell.
355
+ */
356
+ export type Cell = {
357
+ char: string
358
+ styleId: number
359
+ width: CellWidth
360
+ hyperlink: Hyperlink
361
+ }
362
+
363
+ // Constants for empty/spacer cells to enable fast comparisons
364
+ // These are indices into the charStrings table, not codepoints
365
+ const EMPTY_CHAR_INDEX = 0 // ' ' (space)
366
+ const SPACER_CHAR_INDEX = 1 // '' (empty string for spacer cells)
367
+ // Unwritten cells are [EMPTY_CHAR_INDEX=0, packWord1(emptyStyleId=0,0,0)=0].
368
+ // Since StylePool.none is always 0 (first intern), unwritten cells are
369
+ // indistinguishable from explicitly-cleared cells in the packed array.
370
+ // This is intentional: diffEach can compare raw ints with zero normalization.
371
+ // isEmptyCellByIndex checks if both words are 0 to identify "never visually written" cells.
372
+
373
+ function initCharAscii(): Int32Array {
374
+ const table = new Int32Array(128)
375
+ table.fill(-1)
376
+ table[32] = EMPTY_CHAR_INDEX // ' ' (space)
377
+
378
+ return table
379
+ }
380
+
381
+ // --- Packed cell layout ---
382
+ // Each cell is 2 consecutive Int32 elements in the cells array:
383
+ // word0 (cells[ci]): charId (full 32 bits)
384
+ // word1 (cells[ci + 1]): styleId[31:17] | hyperlinkId[16:2] | width[1:0]
385
+ const STYLE_SHIFT = 17
386
+ const HYPERLINK_SHIFT = 2
387
+ const HYPERLINK_MASK = 0x7fff // 15 bits
388
+ const WIDTH_MASK = 3 // 2 bits
389
+
390
+ // Pack styleId, hyperlinkId, and width into a single Int32
391
+ function packWord1(styleId: number, hyperlinkId: number, width: number): number {
392
+ return (styleId << STYLE_SHIFT) | (hyperlinkId << HYPERLINK_SHIFT) | width
393
+ }
394
+
395
+ // Unwritten cell as BigInt64 — both words are 0, so the 64-bit value is 0n.
396
+ // Used by BigInt64Array.fill() for bulk clears (resetScreen, clearRegion).
397
+ // Not used for comparison — BigInt element reads cause heap allocation.
398
+ const EMPTY_CELL_VALUE = 0n
399
+
400
+ /**
401
+ * Screen uses a packed Int32Array instead of Cell objects to eliminate GC
402
+ * pressure. For a 200x120 screen, this avoids allocating 24,000 objects.
403
+ *
404
+ * Cell data is stored as 2 Int32s per cell in a single contiguous array:
405
+ * word0: charId (full 32 bits — index into CharPool)
406
+ * word1: styleId[31:17] | hyperlinkId[16:2] | width[1:0]
407
+ *
408
+ * This layout halves memory accesses in diffEach (2 int loads vs 4) and
409
+ * enables future SIMD comparison via Bun.indexOfFirstDifference.
410
+ */
411
+ export type Screen = Size & {
412
+ // Packed cell data — 2 Int32s per cell: [charId, packed(styleId|hyperlinkId|width)]
413
+ // cells and cells64 are views over the same ArrayBuffer.
414
+ cells: Int32Array
415
+ cells64: BigInt64Array // 1 BigInt64 per cell — used for bulk fill in resetScreen/clearRegion
416
+
417
+ // Shared pools — IDs are valid across all screens using the same pools
418
+ charPool: CharPool
419
+ hyperlinkPool: HyperlinkPool
420
+
421
+ // Empty style ID for comparisons
422
+ emptyStyleId: number
423
+
424
+ /**
425
+ * Bounding box of cells that were written to (not blitted) during rendering.
426
+ * Used by diff() to limit iteration to only the region that could have changed.
427
+ */
428
+ damage: Rectangle | undefined
429
+
430
+ /**
431
+ * Per-cell noSelect bitmap — 1 byte per cell, 1 = exclude from text
432
+ * selection (copy + highlight). Used by <NoSelect> to mark gutters
433
+ * (line numbers, diff sigils) so click-drag over a diff yields clean
434
+ * copyable code. Fully reset each frame in resetScreen; blitRegion
435
+ * copies it alongside cells so the blit optimization preserves marks.
436
+ */
437
+ noSelect: Uint8Array
438
+
439
+ /**
440
+ * Per-cell written bitmap. A written plain space and never-written padding
441
+ * share the same packed cell value, so selection needs this side channel to
442
+ * preserve code indentation without selecting blank UI margins.
443
+ */
444
+ written: Uint8Array
445
+
446
+ /**
447
+ * Per-ROW soft-wrap continuation marker. softWrap[r]=N>0 means row r
448
+ * is a word-wrap continuation of row r-1 (the `\n` before it was
449
+ * inserted by wrapAnsi, not in the source), and row r-1's written
450
+ * content ends at absolute column N (exclusive — cells [0..N) are the
451
+ * fragment, past N is unwritten padding). 0 means row r is NOT a
452
+ * continuation (hard newline or first row). Selection copy checks
453
+ * softWrap[r]>0 to join row r onto row r-1 without a newline, and
454
+ * reads softWrap[r+1] to know row r's content end when row r+1
455
+ * continues from it. The content-end column is needed because an
456
+ * unwritten cell and a written-unstyled-space are indistinguishable in
457
+ * the packed typed array (both all-zero) — without it we'd either drop
458
+ * the word-separator space (trim) or include trailing padding (no
459
+ * trim). This encoding (continuation-on-self, prev-content-end-here)
460
+ * is chosen so shiftRows preserves the is-continuation semantics: when
461
+ * row r scrolls off the top and row r+1 shifts to row r, sw[r] gets
462
+ * old sw[r+1] — which correctly says the new row r is a continuation
463
+ * of what's now in scrolledOffAbove. Reset each frame; copied by
464
+ * blitRegion/shiftRows.
465
+ */
466
+ softWrap: Int32Array
467
+ }
468
+
469
+ function isEmptyCellByIndex(screen: Screen, index: number): boolean {
470
+ // An empty/unwritten cell has both words === 0:
471
+ // word0 = EMPTY_CHAR_INDEX (0), word1 = packWord1(emptyStyleId=0, 0, 0) = 0.
472
+ const ci = index << 1
473
+
474
+ return screen.cells[ci] === 0 && screen.cells[ci | 1] === 0
475
+ }
476
+
477
+ export function isEmptyCellAt(screen: Screen, x: number, y: number): boolean {
478
+ if (x < 0 || y < 0 || x >= screen.width || y >= screen.height) {
479
+ return true
480
+ }
481
+
482
+ return isEmptyCellByIndex(screen, y * screen.width + x)
483
+ }
484
+
485
+ export function isWrittenCellAt(screen: Screen, x: number, y: number): boolean {
486
+ if (x < 0 || y < 0 || x >= screen.width || y >= screen.height) {
487
+ return false
488
+ }
489
+
490
+ return screen.written[y * screen.width + x] === 1
491
+ }
492
+
493
+ /**
494
+ * Check if a Cell (view object) represents an empty cell.
495
+ */
496
+ export function isCellEmpty(screen: Screen, cell: Cell): boolean {
497
+ // Check if cell looks like an empty cell (space, empty style, narrow, no link).
498
+ // Note: After cellAt mapping, unwritten cells have emptyStyleId, so this
499
+ // returns true for both unwritten AND cleared cells. Use isEmptyCellAt
500
+ // for the internal distinction.
501
+ return cell.char === ' ' && cell.styleId === screen.emptyStyleId && cell.width === CellWidth.Narrow && !cell.hyperlink
502
+ }
503
+
504
+ // Intern a hyperlink string and return its ID (0 = no hyperlink)
505
+ function internHyperlink(screen: Screen, hyperlink: Hyperlink): number {
506
+ return screen.hyperlinkPool.intern(hyperlink)
507
+ }
508
+
509
+ // ---
510
+
511
+ export function createScreen(
512
+ width: number,
513
+ height: number,
514
+ styles: StylePool,
515
+ charPool: CharPool,
516
+ hyperlinkPool: HyperlinkPool
517
+ ): Screen {
518
+ // Warn if dimensions are not valid integers (likely bad yoga layout output)
519
+ warn.ifNotInteger(width, 'createScreen width')
520
+ warn.ifNotInteger(height, 'createScreen height')
521
+
522
+ // Ensure width and height are valid integers to prevent crashes
523
+ if (!Number.isInteger(width) || width < 0) {
524
+ width = Math.max(0, Math.floor(width) || 0)
525
+ }
526
+
527
+ if (!Number.isInteger(height) || height < 0) {
528
+ height = Math.max(0, Math.floor(height) || 0)
529
+ }
530
+
531
+ const size = width * height
532
+
533
+ // Allocate one buffer, two views: Int32Array for per-word access,
534
+ // BigInt64Array for bulk fill in resetScreen/clearRegion.
535
+ // ArrayBuffer is zero-filled, which is exactly the empty cell value:
536
+ // [EMPTY_CHAR_INDEX=0, packWord1(emptyStyleId=0,0,0)=0].
537
+ const buf = new ArrayBuffer(size << 3) // 8 bytes per cell
538
+ const cells = new Int32Array(buf)
539
+ const cells64 = new BigInt64Array(buf)
540
+
541
+ return {
542
+ width,
543
+ height,
544
+ cells,
545
+ cells64,
546
+ charPool,
547
+ hyperlinkPool,
548
+ emptyStyleId: styles.none,
549
+ damage: undefined,
550
+ noSelect: new Uint8Array(size),
551
+ written: new Uint8Array(size),
552
+ softWrap: new Int32Array(height)
553
+ }
554
+ }
555
+
556
+ /**
557
+ * Reset an existing screen for reuse, avoiding allocation of new typed arrays.
558
+ * Resizes if needed and clears all cells to empty/unwritten state.
559
+ *
560
+ * For double-buffering, this allows swapping between front and back buffers
561
+ * without allocating new Screen objects each frame.
562
+ */
563
+ export function resetScreen(screen: Screen, width: number, height: number): void {
564
+ // Warn if dimensions are not valid integers
565
+ warn.ifNotInteger(width, 'resetScreen width')
566
+ warn.ifNotInteger(height, 'resetScreen height')
567
+
568
+ // Ensure width and height are valid integers to prevent crashes
569
+ if (!Number.isInteger(width) || width < 0) {
570
+ width = Math.max(0, Math.floor(width) || 0)
571
+ }
572
+
573
+ if (!Number.isInteger(height) || height < 0) {
574
+ height = Math.max(0, Math.floor(height) || 0)
575
+ }
576
+
577
+ const size = width * height
578
+
579
+ // Resize if needed (only grow, to avoid reallocations)
580
+ if (screen.cells64.length < size) {
581
+ const buf = new ArrayBuffer(size << 3)
582
+ screen.cells = new Int32Array(buf)
583
+ screen.cells64 = new BigInt64Array(buf)
584
+ screen.noSelect = new Uint8Array(size)
585
+ screen.written = new Uint8Array(size)
586
+ }
587
+
588
+ if (screen.softWrap.length < height) {
589
+ screen.softWrap = new Int32Array(height)
590
+ }
591
+
592
+ // Reset all cells — single fill call, no loop
593
+ screen.cells64.fill(EMPTY_CELL_VALUE, 0, size)
594
+ screen.noSelect.fill(0, 0, size)
595
+ screen.written.fill(0, 0, size)
596
+ screen.softWrap.fill(0, 0, height)
597
+
598
+ // Update dimensions
599
+ screen.width = width
600
+ screen.height = height
601
+
602
+ // Shared pools accumulate — no clearing needed. Unique char/hyperlink sets are bounded.
603
+
604
+ // Clear damage tracking
605
+ screen.damage = undefined
606
+ }
607
+
608
+ /**
609
+ * Re-intern a screen's char and hyperlink IDs into new pools.
610
+ * Used for generational pool reset — after migrating, the screen's
611
+ * typed arrays contain valid IDs for the new pools, and the old pools
612
+ * can be GC'd.
613
+ *
614
+ * O(width * height) but only called occasionally (e.g., between conversation turns).
615
+ */
616
+ export function migrateScreenPools(screen: Screen, charPool: CharPool, hyperlinkPool: HyperlinkPool): void {
617
+ const oldCharPool = screen.charPool
618
+ const oldHyperlinkPool = screen.hyperlinkPool
619
+
620
+ if (oldCharPool === charPool && oldHyperlinkPool === hyperlinkPool) {
621
+ return
622
+ }
623
+
624
+ const size = screen.width * screen.height
625
+ const cells = screen.cells
626
+
627
+ // Re-intern chars and hyperlinks in a single pass, stride by 2
628
+ for (let ci = 0; ci < size << 1; ci += 2) {
629
+ // Re-intern charId (word0)
630
+ const oldCharId = cells[ci]!
631
+ cells[ci] = charPool.intern(oldCharPool.get(oldCharId))
632
+
633
+ // Re-intern hyperlinkId (packed in word1)
634
+ const word1 = cells[ci + 1]!
635
+ const oldHyperlinkId = (word1 >>> HYPERLINK_SHIFT) & HYPERLINK_MASK
636
+
637
+ if (oldHyperlinkId !== 0) {
638
+ const oldStr = oldHyperlinkPool.get(oldHyperlinkId)
639
+ const newHyperlinkId = hyperlinkPool.intern(oldStr)
640
+ // Repack word1 with new hyperlinkId, preserving styleId and width
641
+ const styleId = word1 >>> STYLE_SHIFT
642
+ const width = word1 & WIDTH_MASK
643
+ cells[ci + 1] = packWord1(styleId, newHyperlinkId, width)
644
+ }
645
+ }
646
+
647
+ screen.charPool = charPool
648
+ screen.hyperlinkPool = hyperlinkPool
649
+ }
650
+
651
+ /**
652
+ * Get a Cell view at the given position. Returns a new object each call -
653
+ * this is intentional as cells are stored packed, not as objects.
654
+ */
655
+ export function cellAt(screen: Screen, x: number, y: number): Cell | undefined {
656
+ if (x < 0 || y < 0 || x >= screen.width || y >= screen.height) {
657
+ return undefined
658
+ }
659
+
660
+ return cellAtIndex(screen, y * screen.width + x)
661
+ }
662
+
663
+ /**
664
+ * Get a Cell view by pre-computed array index. Skips bounds checks and
665
+ * index computation — caller must ensure index is valid.
666
+ */
667
+ export function cellAtIndex(screen: Screen, index: number): Cell {
668
+ const ci = index << 1
669
+ const word1 = screen.cells[ci + 1]!
670
+ const hid = (word1 >>> HYPERLINK_SHIFT) & HYPERLINK_MASK
671
+
672
+ return {
673
+ // Unwritten cells have charIndex=0 (EMPTY_CHAR_INDEX); charPool.get(0) returns ' '
674
+ char: screen.charPool.get(screen.cells[ci]!),
675
+ styleId: word1 >>> STYLE_SHIFT,
676
+ width: word1 & WIDTH_MASK,
677
+ hyperlink: hid === 0 ? undefined : screen.hyperlinkPool.get(hid)
678
+ }
679
+ }
680
+
681
+ /**
682
+ * Get a Cell at the given index, or undefined if it has no visible content.
683
+ * Returns undefined for spacer cells (charId 1), empty unstyled spaces, and
684
+ * fg-only styled spaces that match lastRenderedStyleId (cursor-forward
685
+ * produces an identical visual result, avoiding a Cell allocation).
686
+ *
687
+ * @param lastRenderedStyleId - styleId of the last rendered cell on this
688
+ * line, or -1 if none yet.
689
+ */
690
+ export function visibleCellAtIndex(
691
+ cells: Int32Array,
692
+ charPool: CharPool,
693
+ hyperlinkPool: HyperlinkPool,
694
+ index: number,
695
+ lastRenderedStyleId: number
696
+ ): Cell | undefined {
697
+ const ci = index << 1
698
+ const charId = cells[ci]!
699
+
700
+ if (charId === 1) {
701
+ return undefined
702
+ } // spacer
703
+
704
+ const word1 = cells[ci + 1]!
705
+
706
+ // For spaces: 0x3fffc masks bits 2-17 (hyperlinkId + styleId visibility
707
+ // bit). If zero, the space has no hyperlink and at most a fg-only style.
708
+ // Then word1 >>> STYLE_SHIFT is the foreground style — skip if it's zero
709
+ // (truly invisible) or matches the last rendered style on this line.
710
+ if (charId === 0 && (word1 & 0x3fffc) === 0) {
711
+ const fgStyle = word1 >>> STYLE_SHIFT
712
+
713
+ if (fgStyle === 0 || fgStyle === lastRenderedStyleId) {
714
+ return undefined
715
+ }
716
+ }
717
+
718
+ const hid = (word1 >>> HYPERLINK_SHIFT) & HYPERLINK_MASK
719
+
720
+ return {
721
+ char: charPool.get(charId),
722
+ styleId: word1 >>> STYLE_SHIFT,
723
+ width: word1 & WIDTH_MASK,
724
+ hyperlink: hid === 0 ? undefined : hyperlinkPool.get(hid)
725
+ }
726
+ }
727
+
728
+ /**
729
+ * Write cell data into an existing Cell object to avoid allocation.
730
+ * Caller must ensure index is valid.
731
+ */
732
+ function cellAtCI(screen: Screen, ci: number, out: Cell): void {
733
+ const w1 = ci | 1
734
+ const word1 = screen.cells[w1]!
735
+ out.char = screen.charPool.get(screen.cells[ci]!)
736
+ out.styleId = word1 >>> STYLE_SHIFT
737
+ out.width = word1 & WIDTH_MASK
738
+ const hid = (word1 >>> HYPERLINK_SHIFT) & HYPERLINK_MASK
739
+ out.hyperlink = hid === 0 ? undefined : screen.hyperlinkPool.get(hid)
740
+ }
741
+
742
+ export function charInCellAt(screen: Screen, x: number, y: number): string | undefined {
743
+ if (x < 0 || y < 0 || x >= screen.width || y >= screen.height) {
744
+ return undefined
745
+ }
746
+
747
+ const ci = (y * screen.width + x) << 1
748
+
749
+ return screen.charPool.get(screen.cells[ci]!)
750
+ }
751
+
752
+ /**
753
+ * Set a cell, optionally creating a spacer for wide characters.
754
+ *
755
+ * Wide characters (CJK, emoji) occupy 2 cells in the buffer:
756
+ * 1. First cell: Contains the actual character with width = Wide
757
+ * 2. Second cell: Spacer cell with width = SpacerTail (empty, not rendered)
758
+ *
759
+ * If the cell has width = Wide, this function automatically creates the
760
+ * corresponding SpacerTail in the next column. This two-cell model keeps
761
+ * the buffer aligned to visual columns, making cursor positioning
762
+ * straightforward.
763
+ *
764
+ * TODO: When soft-wrapping is implemented, SpacerHead cells will be explicitly
765
+ * placed by the wrapping logic at line-end positions where wide characters
766
+ * wrap to the next line. This function doesn't need to handle SpacerHead
767
+ * automatically - it will be set directly by the wrapping code.
768
+ */
769
+ export function setCellAt(screen: Screen, x: number, y: number, cell: Cell): void {
770
+ if (x < 0 || y < 0 || x >= screen.width || y >= screen.height) {
771
+ return
772
+ }
773
+
774
+ const ci = (y * screen.width + x) << 1
775
+ const cells = screen.cells
776
+
777
+ // When a Wide char is overwritten by a Narrow char, its SpacerTail remains
778
+ // as a ghost cell that the diff/render pipeline skips, causing stale content
779
+ // to leak through from previous frames.
780
+ const prevWidth = cells[ci + 1]! & WIDTH_MASK
781
+
782
+ if (prevWidth === CellWidth.Wide && cell.width !== CellWidth.Wide) {
783
+ const spacerX = x + 1
784
+
785
+ if (spacerX < screen.width) {
786
+ const spacerCI = ci + 2
787
+
788
+ if ((cells[spacerCI + 1]! & WIDTH_MASK) === CellWidth.SpacerTail) {
789
+ cells[spacerCI] = EMPTY_CHAR_INDEX
790
+ cells[spacerCI + 1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow)
791
+ screen.written[y * screen.width + spacerX] = 0
792
+ }
793
+ }
794
+ }
795
+
796
+ // Track cleared Wide position for damage expansion below
797
+ let clearedWideX = -1
798
+
799
+ if (prevWidth === CellWidth.SpacerTail && cell.width !== CellWidth.SpacerTail) {
800
+ // Overwriting a SpacerTail: clear the orphaned Wide char at (x-1).
801
+ // Keeping the wide character with Narrow width would cause the terminal
802
+ // to still render it with width 2, desyncing the cursor model.
803
+ if (x > 0) {
804
+ const wideCI = ci - 2
805
+
806
+ if ((cells[wideCI + 1]! & WIDTH_MASK) === CellWidth.Wide) {
807
+ cells[wideCI] = EMPTY_CHAR_INDEX
808
+ cells[wideCI + 1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow)
809
+ screen.written[y * screen.width + x - 1] = 0
810
+ clearedWideX = x - 1
811
+ }
812
+ }
813
+ }
814
+
815
+ // Pack cell data into cells array
816
+ cells[ci] = internCharString(screen, cell.char)
817
+ cells[ci + 1] = packWord1(cell.styleId, internHyperlink(screen, cell.hyperlink), cell.width)
818
+ screen.written[y * screen.width + x] = 1
819
+
820
+ // Track damage - expand bounds in place instead of allocating new objects
821
+ // Include the main cell position and any cleared orphan cells
822
+ const minX = clearedWideX >= 0 ? Math.min(x, clearedWideX) : x
823
+ const damage = screen.damage
824
+
825
+ if (damage) {
826
+ const right = damage.x + damage.width
827
+ const bottom = damage.y + damage.height
828
+
829
+ if (minX < damage.x) {
830
+ damage.width += damage.x - minX
831
+ damage.x = minX
832
+ } else if (x >= right) {
833
+ damage.width = x - damage.x + 1
834
+ }
835
+
836
+ if (y < damage.y) {
837
+ damage.height += damage.y - y
838
+ damage.y = y
839
+ } else if (y >= bottom) {
840
+ damage.height = y - damage.y + 1
841
+ }
842
+ } else {
843
+ screen.damage = { x: minX, y, width: x - minX + 1, height: 1 }
844
+ }
845
+
846
+ // If this is a wide character, create a spacer in the next column
847
+ if (cell.width === CellWidth.Wide) {
848
+ const spacerX = x + 1
849
+
850
+ if (spacerX < screen.width) {
851
+ const spacerCI = ci + 2
852
+
853
+ // If the cell we're overwriting with our SpacerTail is itself Wide,
854
+ // clear ITS SpacerTail at x+2 too. Otherwise the orphan SpacerTail
855
+ // makes diffEach report it as `added` and log-update's skip-spacer
856
+ // rule prevents clearing whatever prev content was at that column.
857
+ // Scenario: [a, 💻, spacer] → [本, spacer, ORPHAN spacer] when
858
+ // yoga squishes a💻 to height 0 and 本 renders at the same y.
859
+ if ((cells[spacerCI + 1]! & WIDTH_MASK) === CellWidth.Wide) {
860
+ const orphanCI = spacerCI + 2
861
+
862
+ if (spacerX + 1 < screen.width && (cells[orphanCI + 1]! & WIDTH_MASK) === CellWidth.SpacerTail) {
863
+ cells[orphanCI] = EMPTY_CHAR_INDEX
864
+ cells[orphanCI + 1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow)
865
+ screen.written[y * screen.width + spacerX + 1] = 0
866
+ }
867
+ }
868
+
869
+ cells[spacerCI] = SPACER_CHAR_INDEX
870
+ cells[spacerCI + 1] = packWord1(screen.emptyStyleId, 0, CellWidth.SpacerTail)
871
+ screen.written[y * screen.width + spacerX] = 1
872
+
873
+ // Expand damage to include SpacerTail so diff() scans it
874
+ const d = screen.damage
875
+
876
+ if (d && spacerX >= d.x + d.width) {
877
+ d.width = spacerX - d.x + 1
878
+ }
879
+ }
880
+ }
881
+ }
882
+
883
+ /**
884
+ * Replace the styleId of a cell in-place without disturbing char, width,
885
+ * or hyperlink. Preserves empty cells as-is (char stays ' '). Tracks damage
886
+ * for the cell so diffEach picks up the change.
887
+ */
888
+ export function setCellStyleId(screen: Screen, x: number, y: number, styleId: number): void {
889
+ if (x < 0 || y < 0 || x >= screen.width || y >= screen.height) {
890
+ return
891
+ }
892
+
893
+ const ci = (y * screen.width + x) << 1
894
+ const cells = screen.cells
895
+ const word1 = cells[ci + 1]!
896
+ const width = word1 & WIDTH_MASK
897
+
898
+ // Skip spacer cells — inverse on the head cell visually covers both columns
899
+ if (width === CellWidth.SpacerTail || width === CellWidth.SpacerHead) {
900
+ return
901
+ }
902
+
903
+ const hid = (word1 >>> HYPERLINK_SHIFT) & HYPERLINK_MASK
904
+ cells[ci + 1] = packWord1(styleId, hid, width)
905
+ // Expand damage so diffEach scans this cell
906
+ const d = screen.damage
907
+
908
+ if (d) {
909
+ screen.damage = unionRect(d, { x, y, width: 1, height: 1 })
910
+ } else {
911
+ screen.damage = { x, y, width: 1, height: 1 }
912
+ }
913
+ }
914
+
915
+ /**
916
+ * Intern a character string via the screen's shared CharPool.
917
+ * Supports grapheme clusters like family emoji.
918
+ */
919
+ function internCharString(screen: Screen, char: string): number {
920
+ return screen.charPool.intern(char)
921
+ }
922
+
923
+ /**
924
+ * Bulk-copy a rectangular region from src to dst using TypedArray.set().
925
+ * Single cells.set() call per row (or one call for contiguous blocks).
926
+ * Damage is computed once for the whole region.
927
+ *
928
+ * Clamps negative regionX/regionY to 0 (matching clearRegion) — absolute-
929
+ * positioned overlays in tiny terminals can compute negative screen coords.
930
+ * maxX/maxY should already be clamped to both screen bounds by the caller.
931
+ */
932
+ export function blitRegion(
933
+ dst: Screen,
934
+ src: Screen,
935
+ regionX: number,
936
+ regionY: number,
937
+ maxX: number,
938
+ maxY: number
939
+ ): void {
940
+ regionX = Math.max(0, regionX)
941
+ regionY = Math.max(0, regionY)
942
+
943
+ if (regionX >= maxX || regionY >= maxY) {
944
+ return
945
+ }
946
+
947
+ const rowLen = maxX - regionX
948
+ const srcStride = src.width << 1
949
+ const dstStride = dst.width << 1
950
+ const rowBytes = rowLen << 1 // 2 Int32s per cell
951
+ const srcCells = src.cells
952
+ const dstCells = dst.cells
953
+ const srcNoSel = src.noSelect
954
+ const dstNoSel = dst.noSelect
955
+ const srcWritten = src.written
956
+ const dstWritten = dst.written
957
+
958
+ // softWrap is per-row — copy the row range regardless of stride/width.
959
+ // Partial-width blits still carry the row's wrap provenance since the
960
+ // blitted content (a cached ink-text node) is what set the bit.
961
+ dst.softWrap.set(src.softWrap.subarray(regionY, maxY), regionY)
962
+
963
+ // Fast path: contiguous memory when copying full-width rows at same stride
964
+ if (regionX === 0 && maxX === src.width && src.width === dst.width) {
965
+ const srcStart = regionY * srcStride
966
+ const totalBytes = (maxY - regionY) * srcStride
967
+ dstCells.set(
968
+ srcCells.subarray(srcStart, srcStart + totalBytes),
969
+ srcStart // srcStart === dstStart when strides match and regionX === 0
970
+ )
971
+ // noSelect is 1 byte/cell vs cells' 8 — same region, different scale
972
+ const nsStart = regionY * src.width
973
+ const nsLen = (maxY - regionY) * src.width
974
+ dstNoSel.set(srcNoSel.subarray(nsStart, nsStart + nsLen), nsStart)
975
+ dstWritten.set(srcWritten.subarray(nsStart, nsStart + nsLen), nsStart)
976
+ } else {
977
+ // Per-row copy for partial-width or mismatched-stride regions
978
+ let srcRowCI = regionY * srcStride + (regionX << 1)
979
+ let dstRowCI = regionY * dstStride + (regionX << 1)
980
+ let srcRowNS = regionY * src.width + regionX
981
+ let dstRowNS = regionY * dst.width + regionX
982
+
983
+ for (let y = regionY; y < maxY; y++) {
984
+ dstCells.set(srcCells.subarray(srcRowCI, srcRowCI + rowBytes), dstRowCI)
985
+ dstNoSel.set(srcNoSel.subarray(srcRowNS, srcRowNS + rowLen), dstRowNS)
986
+ dstWritten.set(srcWritten.subarray(srcRowNS, srcRowNS + rowLen), dstRowNS)
987
+ srcRowCI += srcStride
988
+ dstRowCI += dstStride
989
+ srcRowNS += src.width
990
+ dstRowNS += dst.width
991
+ }
992
+ }
993
+
994
+ // Compute damage once for the whole region
995
+ const regionRect = {
996
+ x: regionX,
997
+ y: regionY,
998
+ width: rowLen,
999
+ height: maxY - regionY
1000
+ }
1001
+
1002
+ if (dst.damage) {
1003
+ dst.damage = unionRect(dst.damage, regionRect)
1004
+ } else {
1005
+ dst.damage = regionRect
1006
+ }
1007
+
1008
+ // Handle wide char at right edge: spacer might be outside blit region
1009
+ // but still within dst bounds. Per-row check only at the boundary column.
1010
+ if (maxX < dst.width) {
1011
+ let srcLastCI = (regionY * src.width + (maxX - 1)) << 1
1012
+ let dstSpacerCI = (regionY * dst.width + maxX) << 1
1013
+ let wroteSpacerOutsideRegion = false
1014
+
1015
+ for (let y = regionY; y < maxY; y++) {
1016
+ if ((srcCells[srcLastCI + 1]! & WIDTH_MASK) === CellWidth.Wide) {
1017
+ dstCells[dstSpacerCI] = SPACER_CHAR_INDEX
1018
+ dstCells[dstSpacerCI + 1] = packWord1(dst.emptyStyleId, 0, CellWidth.SpacerTail)
1019
+ dstWritten[y * dst.width + maxX] = 1
1020
+ wroteSpacerOutsideRegion = true
1021
+ }
1022
+
1023
+ srcLastCI += srcStride
1024
+ dstSpacerCI += dstStride
1025
+ }
1026
+
1027
+ // Expand damage to include SpacerTail column if we wrote any
1028
+ if (wroteSpacerOutsideRegion && dst.damage) {
1029
+ const rightEdge = dst.damage.x + dst.damage.width
1030
+
1031
+ if (rightEdge === maxX) {
1032
+ dst.damage = { ...dst.damage, width: dst.damage.width + 1 }
1033
+ }
1034
+ }
1035
+ }
1036
+ }
1037
+
1038
+ /**
1039
+ * Bulk-clear a rectangular region of the screen.
1040
+ * Uses BigInt64Array.fill() for fast row clears.
1041
+ * Handles wide character boundary cleanup at region edges.
1042
+ */
1043
+ export function clearRegion(
1044
+ screen: Screen,
1045
+ regionX: number,
1046
+ regionY: number,
1047
+ regionWidth: number,
1048
+ regionHeight: number
1049
+ ): void {
1050
+ const startX = Math.max(0, regionX)
1051
+ const startY = Math.max(0, regionY)
1052
+ const maxX = Math.min(regionX + regionWidth, screen.width)
1053
+ const maxY = Math.min(regionY + regionHeight, screen.height)
1054
+
1055
+ if (startX >= maxX || startY >= maxY) {
1056
+ return
1057
+ }
1058
+
1059
+ const cells = screen.cells
1060
+ const cells64 = screen.cells64
1061
+ const written = screen.written
1062
+ const screenWidth = screen.width
1063
+ const rowBase = startY * screenWidth
1064
+ let damageMinX = startX
1065
+ let damageMaxX = maxX
1066
+
1067
+ // EMPTY_CELL_VALUE (0n) matches the zero-initialized state:
1068
+ // word0=EMPTY_CHAR_INDEX(0), word1=packWord1(0,0,0)=0
1069
+ if (startX === 0 && maxX === screenWidth) {
1070
+ // Full-width: single fill, no boundary checks needed
1071
+ cells64.fill(EMPTY_CELL_VALUE, rowBase, rowBase + (maxY - startY) * screenWidth)
1072
+ written.fill(0, rowBase, rowBase + (maxY - startY) * screenWidth)
1073
+ } else {
1074
+ // Partial-width: single loop handles boundary cleanup and fill per row.
1075
+ const stride = screenWidth << 1 // 2 Int32s per cell
1076
+ const rowLen = maxX - startX
1077
+ const checkLeft = startX > 0
1078
+ const checkRight = maxX < screenWidth
1079
+ let leftEdge = (rowBase + startX) << 1
1080
+ let rightEdge = (rowBase + maxX - 1) << 1
1081
+ let fillStart = rowBase + startX
1082
+
1083
+ for (let y = startY; y < maxY; y++) {
1084
+ // Left boundary: if cell at startX is a SpacerTail, the Wide char
1085
+ // at startX-1 (outside the region) will be orphaned. Clear it.
1086
+ if (checkLeft) {
1087
+ // leftEdge points to word0 of cell at startX; +1 is its word1
1088
+ if ((cells[leftEdge + 1]! & WIDTH_MASK) === CellWidth.SpacerTail) {
1089
+ // word1 of cell at startX-1 is leftEdge-1; word0 is leftEdge-2
1090
+ const prevW1 = leftEdge - 1
1091
+
1092
+ if ((cells[prevW1]! & WIDTH_MASK) === CellWidth.Wide) {
1093
+ cells[prevW1 - 1] = EMPTY_CHAR_INDEX
1094
+ cells[prevW1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow)
1095
+ written[y * screenWidth + startX - 1] = 0
1096
+ damageMinX = startX - 1
1097
+ }
1098
+ }
1099
+ }
1100
+
1101
+ // Right boundary: if cell at maxX-1 is Wide, its SpacerTail at maxX
1102
+ // (outside the region) will be orphaned. Clear it.
1103
+ if (checkRight) {
1104
+ // rightEdge points to word0 of cell at maxX-1; +1 is its word1
1105
+ if ((cells[rightEdge + 1]! & WIDTH_MASK) === CellWidth.Wide) {
1106
+ // word1 of cell at maxX is rightEdge+3 (+2 to next word0, +1 to word1)
1107
+ const nextW1 = rightEdge + 3
1108
+
1109
+ if ((cells[nextW1]! & WIDTH_MASK) === CellWidth.SpacerTail) {
1110
+ cells[nextW1 - 1] = EMPTY_CHAR_INDEX
1111
+ cells[nextW1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow)
1112
+ written[y * screenWidth + maxX] = 0
1113
+ damageMaxX = maxX + 1
1114
+ }
1115
+ }
1116
+ }
1117
+
1118
+ cells64.fill(EMPTY_CELL_VALUE, fillStart, fillStart + rowLen)
1119
+ written.fill(0, fillStart, fillStart + rowLen)
1120
+ leftEdge += stride
1121
+ rightEdge += stride
1122
+ fillStart += screenWidth
1123
+ }
1124
+ }
1125
+
1126
+ // Update damage once for the whole region
1127
+ const regionRect = {
1128
+ x: damageMinX,
1129
+ y: startY,
1130
+ width: damageMaxX - damageMinX,
1131
+ height: maxY - startY
1132
+ }
1133
+
1134
+ if (screen.damage) {
1135
+ screen.damage = unionRect(screen.damage, regionRect)
1136
+ } else {
1137
+ screen.damage = regionRect
1138
+ }
1139
+ }
1140
+
1141
+ /**
1142
+ * Shift full-width rows within [top, bottom] (inclusive, 0-indexed) by n.
1143
+ * n > 0 shifts UP (simulating CSI n S); n < 0 shifts DOWN (CSI n T).
1144
+ * Vacated rows are cleared. Does NOT update damage. Both cells and the
1145
+ * noSelect bitmap are shifted so text-selection markers stay aligned when
1146
+ * this is applied to next.screen during scroll fast path.
1147
+ */
1148
+ export function shiftRows(screen: Screen, top: number, bottom: number, n: number): void {
1149
+ if (n === 0 || top < 0 || bottom >= screen.height || top > bottom) {
1150
+ return
1151
+ }
1152
+
1153
+ const w = screen.width
1154
+ const cells64 = screen.cells64
1155
+ const noSel = screen.noSelect
1156
+ const written = screen.written
1157
+ const sw = screen.softWrap
1158
+ const absN = Math.abs(n)
1159
+
1160
+ if (absN > bottom - top) {
1161
+ cells64.fill(EMPTY_CELL_VALUE, top * w, (bottom + 1) * w)
1162
+ noSel.fill(0, top * w, (bottom + 1) * w)
1163
+ written.fill(0, top * w, (bottom + 1) * w)
1164
+ sw.fill(0, top, bottom + 1)
1165
+
1166
+ return
1167
+ }
1168
+
1169
+ if (n > 0) {
1170
+ // SU: row top+n..bottom → top..bottom-n; clear bottom-n+1..bottom
1171
+ cells64.copyWithin(top * w, (top + n) * w, (bottom + 1) * w)
1172
+ noSel.copyWithin(top * w, (top + n) * w, (bottom + 1) * w)
1173
+ written.copyWithin(top * w, (top + n) * w, (bottom + 1) * w)
1174
+ sw.copyWithin(top, top + n, bottom + 1)
1175
+ cells64.fill(EMPTY_CELL_VALUE, (bottom - n + 1) * w, (bottom + 1) * w)
1176
+ noSel.fill(0, (bottom - n + 1) * w, (bottom + 1) * w)
1177
+ written.fill(0, (bottom - n + 1) * w, (bottom + 1) * w)
1178
+ sw.fill(0, bottom - n + 1, bottom + 1)
1179
+ } else {
1180
+ // SD: row top..bottom+n → top-n..bottom; clear top..top-n-1
1181
+ cells64.copyWithin((top - n) * w, top * w, (bottom + n + 1) * w)
1182
+ noSel.copyWithin((top - n) * w, top * w, (bottom + n + 1) * w)
1183
+ written.copyWithin((top - n) * w, top * w, (bottom + n + 1) * w)
1184
+ sw.copyWithin(top - n, top, bottom + n + 1)
1185
+ cells64.fill(EMPTY_CELL_VALUE, top * w, (top - n) * w)
1186
+ noSel.fill(0, top * w, (top - n) * w)
1187
+ written.fill(0, top * w, (top - n) * w)
1188
+ sw.fill(0, top, top - n)
1189
+ }
1190
+ }
1191
+
1192
+ // Matches OSC 8 ; ; URI BEL
1193
+ const OSC8_REGEX = new RegExp(`^${ESC}\\]8${SEP}${SEP}([^${BEL}]*)${BEL}$`)
1194
+ // OSC8 prefix: ESC ] 8 ; — cheap check to skip regex for the vast majority of styles (SGR = ESC [)
1195
+ export const OSC8_PREFIX = `${ESC}]8${SEP}`
1196
+
1197
+ export function extractHyperlinkFromStyles(styles: AnsiCode[]): Hyperlink | null {
1198
+ for (const style of styles) {
1199
+ const code = style.code
1200
+
1201
+ if (code.length < 5 || !code.startsWith(OSC8_PREFIX)) {
1202
+ continue
1203
+ }
1204
+
1205
+ const match = code.match(OSC8_REGEX)
1206
+
1207
+ if (match) {
1208
+ return match[1] || null
1209
+ }
1210
+ }
1211
+
1212
+ return null
1213
+ }
1214
+
1215
+ export function filterOutHyperlinkStyles(styles: AnsiCode[]): AnsiCode[] {
1216
+ return styles.filter(style => !style.code.startsWith(OSC8_PREFIX) || !OSC8_REGEX.test(style.code))
1217
+ }
1218
+
1219
+ // ---
1220
+
1221
+ /**
1222
+ * Returns an array of all changes between two screens. Used by tests.
1223
+ * Production code should use diffEach() to avoid allocations.
1224
+ */
1225
+ export function diff(prev: Screen, next: Screen): [point: Point, removed: Cell | undefined, added: Cell | undefined][] {
1226
+ const output: [Point, Cell | undefined, Cell | undefined][] = []
1227
+ diffEach(prev, next, (x, y, removed, added) => {
1228
+ // Copy cells since diffEach reuses the objects
1229
+ output.push([{ x, y }, removed ? { ...removed } : undefined, added ? { ...added } : undefined])
1230
+ })
1231
+
1232
+ return output
1233
+ }
1234
+
1235
+ type DiffCallback = (x: number, y: number, removed: Cell | undefined, added: Cell | undefined) => boolean | void
1236
+
1237
+ /**
1238
+ * Like diff(), but calls a callback for each change instead of building an array.
1239
+ * Reuses two Cell objects to avoid per-change allocations. The callback must not
1240
+ * retain references to the Cell objects — their contents are overwritten each call.
1241
+ *
1242
+ * Returns true if the callback ever returned true (early exit signal).
1243
+ */
1244
+ export function diffEach(prev: Screen, next: Screen, cb: DiffCallback): boolean {
1245
+ const prevWidth = prev.width
1246
+ const nextWidth = next.width
1247
+ const prevHeight = prev.height
1248
+ const nextHeight = next.height
1249
+
1250
+ let region: Rectangle
1251
+
1252
+ if (prevWidth === 0 && prevHeight === 0) {
1253
+ region = { x: 0, y: 0, width: nextWidth, height: nextHeight }
1254
+ } else if (next.damage) {
1255
+ region = next.damage
1256
+
1257
+ if (prev.damage) {
1258
+ region = unionRect(region, prev.damage)
1259
+ }
1260
+ } else if (prev.damage) {
1261
+ region = prev.damage
1262
+ } else {
1263
+ region = { x: 0, y: 0, width: 0, height: 0 }
1264
+ }
1265
+
1266
+ if (prevHeight > nextHeight) {
1267
+ region = unionRect(region, {
1268
+ x: 0,
1269
+ y: nextHeight,
1270
+ width: prevWidth,
1271
+ height: prevHeight - nextHeight
1272
+ })
1273
+ }
1274
+
1275
+ if (prevWidth > nextWidth) {
1276
+ region = unionRect(region, {
1277
+ x: nextWidth,
1278
+ y: 0,
1279
+ width: prevWidth - nextWidth,
1280
+ height: prevHeight
1281
+ })
1282
+ }
1283
+
1284
+ const maxHeight = Math.max(prevHeight, nextHeight)
1285
+ const maxWidth = Math.max(prevWidth, nextWidth)
1286
+ const endY = Math.min(region.y + region.height, maxHeight)
1287
+ const endX = Math.min(region.x + region.width, maxWidth)
1288
+
1289
+ if (prevWidth === nextWidth) {
1290
+ return diffSameWidth(prev, next, region.x, endX, region.y, endY, cb)
1291
+ }
1292
+
1293
+ return diffDifferentWidth(prev, next, region.x, endX, region.y, endY, cb)
1294
+ }
1295
+
1296
+ /**
1297
+ * Scan for the next cell that differs between two Int32Arrays.
1298
+ * Returns the number of matching cells before the first difference,
1299
+ * or `count` if all cells match. Tiny and pure for JIT inlining.
1300
+ */
1301
+ function findNextDiff(a: Int32Array, b: Int32Array, w0: number, count: number): number {
1302
+ for (let i = 0; i < count; i++, w0 += 2) {
1303
+ const w1 = w0 | 1
1304
+
1305
+ if (a[w0] !== b[w0] || a[w1] !== b[w1]) {
1306
+ return i
1307
+ }
1308
+ }
1309
+
1310
+ return count
1311
+ }
1312
+
1313
+ /**
1314
+ * Diff one row where both screens are in bounds.
1315
+ * Scans for differences with findNextDiff, unpacks and calls cb for each.
1316
+ */
1317
+ function diffRowBoth(
1318
+ prevCells: Int32Array,
1319
+ nextCells: Int32Array,
1320
+ prev: Screen,
1321
+ next: Screen,
1322
+ ci: number,
1323
+ y: number,
1324
+ startX: number,
1325
+ endX: number,
1326
+ prevCell: Cell,
1327
+ nextCell: Cell,
1328
+ cb: DiffCallback
1329
+ ): boolean {
1330
+ let x = startX
1331
+
1332
+ while (x < endX) {
1333
+ const skip = findNextDiff(prevCells, nextCells, ci, endX - x)
1334
+ x += skip
1335
+ ci += skip << 1
1336
+
1337
+ if (x >= endX) {
1338
+ break
1339
+ }
1340
+
1341
+ cellAtCI(prev, ci, prevCell)
1342
+ cellAtCI(next, ci, nextCell)
1343
+
1344
+ if (cb(x, y, prevCell, nextCell)) {
1345
+ return true
1346
+ }
1347
+
1348
+ x++
1349
+ ci += 2
1350
+ }
1351
+
1352
+ return false
1353
+ }
1354
+
1355
+ /**
1356
+ * Emit removals for a row that only exists in prev (height shrank).
1357
+ * Cannot skip empty cells — the terminal still has content from the
1358
+ * previous frame that needs to be cleared.
1359
+ */
1360
+ function diffRowRemoved(
1361
+ prev: Screen,
1362
+ ci: number,
1363
+ y: number,
1364
+ startX: number,
1365
+ endX: number,
1366
+ prevCell: Cell,
1367
+ cb: DiffCallback
1368
+ ): boolean {
1369
+ for (let x = startX; x < endX; x++, ci += 2) {
1370
+ cellAtCI(prev, ci, prevCell)
1371
+
1372
+ if (cb(x, y, prevCell, undefined)) {
1373
+ return true
1374
+ }
1375
+ }
1376
+
1377
+ return false
1378
+ }
1379
+
1380
+ /**
1381
+ * Emit additions for a row that only exists in next (height grew).
1382
+ * Skips empty/unwritten cells.
1383
+ */
1384
+ function diffRowAdded(
1385
+ nextCells: Int32Array,
1386
+ next: Screen,
1387
+ ci: number,
1388
+ y: number,
1389
+ startX: number,
1390
+ endX: number,
1391
+ nextCell: Cell,
1392
+ cb: DiffCallback
1393
+ ): boolean {
1394
+ for (let x = startX; x < endX; x++, ci += 2) {
1395
+ if (nextCells[ci] === 0 && nextCells[ci | 1] === 0) {
1396
+ continue
1397
+ }
1398
+
1399
+ cellAtCI(next, ci, nextCell)
1400
+
1401
+ if (cb(x, y, undefined, nextCell)) {
1402
+ return true
1403
+ }
1404
+ }
1405
+
1406
+ return false
1407
+ }
1408
+
1409
+ /**
1410
+ * Diff two screens with identical width.
1411
+ * Dispatches each row to a small, JIT-friendly function.
1412
+ */
1413
+ function diffSameWidth(
1414
+ prev: Screen,
1415
+ next: Screen,
1416
+ startX: number,
1417
+ endX: number,
1418
+ startY: number,
1419
+ endY: number,
1420
+ cb: DiffCallback
1421
+ ): boolean {
1422
+ const prevCells = prev.cells
1423
+ const nextCells = next.cells
1424
+ const width = prev.width
1425
+ const prevHeight = prev.height
1426
+ const nextHeight = next.height
1427
+ const stride = width << 1
1428
+
1429
+ const prevCell: Cell = {
1430
+ char: ' ',
1431
+ styleId: 0,
1432
+ width: CellWidth.Narrow,
1433
+ hyperlink: undefined
1434
+ }
1435
+
1436
+ const nextCell: Cell = {
1437
+ char: ' ',
1438
+ styleId: 0,
1439
+ width: CellWidth.Narrow,
1440
+ hyperlink: undefined
1441
+ }
1442
+
1443
+ const rowEndX = Math.min(endX, width)
1444
+ let rowCI = (startY * width + startX) << 1
1445
+
1446
+ for (let y = startY; y < endY; y++) {
1447
+ const prevIn = y < prevHeight
1448
+ const nextIn = y < nextHeight
1449
+
1450
+ if (prevIn && nextIn) {
1451
+ if (diffRowBoth(prevCells, nextCells, prev, next, rowCI, y, startX, rowEndX, prevCell, nextCell, cb)) {
1452
+ return true
1453
+ }
1454
+ } else if (prevIn) {
1455
+ if (diffRowRemoved(prev, rowCI, y, startX, rowEndX, prevCell, cb)) {
1456
+ return true
1457
+ }
1458
+ } else if (nextIn) {
1459
+ if (diffRowAdded(nextCells, next, rowCI, y, startX, rowEndX, nextCell, cb)) {
1460
+ return true
1461
+ }
1462
+ }
1463
+
1464
+ rowCI += stride
1465
+ }
1466
+
1467
+ return false
1468
+ }
1469
+
1470
+ /**
1471
+ * Fallback: diff two screens with different widths (resize).
1472
+ * Separate indices for prev and next cells arrays.
1473
+ */
1474
+ function diffDifferentWidth(
1475
+ prev: Screen,
1476
+ next: Screen,
1477
+ startX: number,
1478
+ endX: number,
1479
+ startY: number,
1480
+ endY: number,
1481
+ cb: DiffCallback
1482
+ ): boolean {
1483
+ const prevWidth = prev.width
1484
+ const nextWidth = next.width
1485
+ const prevCells = prev.cells
1486
+ const nextCells = next.cells
1487
+
1488
+ const prevCell: Cell = {
1489
+ char: ' ',
1490
+ styleId: 0,
1491
+ width: CellWidth.Narrow,
1492
+ hyperlink: undefined
1493
+ }
1494
+
1495
+ const nextCell: Cell = {
1496
+ char: ' ',
1497
+ styleId: 0,
1498
+ width: CellWidth.Narrow,
1499
+ hyperlink: undefined
1500
+ }
1501
+
1502
+ const prevStride = prevWidth << 1
1503
+ const nextStride = nextWidth << 1
1504
+ let prevRowCI = (startY * prevWidth + startX) << 1
1505
+ let nextRowCI = (startY * nextWidth + startX) << 1
1506
+
1507
+ for (let y = startY; y < endY; y++) {
1508
+ const prevIn = y < prev.height
1509
+ const nextIn = y < next.height
1510
+ const prevEndX = prevIn ? Math.min(endX, prevWidth) : startX
1511
+ const nextEndX = nextIn ? Math.min(endX, nextWidth) : startX
1512
+ const bothEndX = Math.min(prevEndX, nextEndX)
1513
+
1514
+ let prevCI = prevRowCI
1515
+ let nextCI = nextRowCI
1516
+
1517
+ for (let x = startX; x < bothEndX; x++) {
1518
+ if (prevCells[prevCI] === nextCells[nextCI] && prevCells[prevCI + 1] === nextCells[nextCI + 1]) {
1519
+ prevCI += 2
1520
+ nextCI += 2
1521
+
1522
+ continue
1523
+ }
1524
+
1525
+ cellAtCI(prev, prevCI, prevCell)
1526
+ cellAtCI(next, nextCI, nextCell)
1527
+ prevCI += 2
1528
+ nextCI += 2
1529
+
1530
+ if (cb(x, y, prevCell, nextCell)) {
1531
+ return true
1532
+ }
1533
+ }
1534
+
1535
+ if (prevEndX > bothEndX) {
1536
+ prevCI = prevRowCI + ((bothEndX - startX) << 1)
1537
+
1538
+ for (let x = bothEndX; x < prevEndX; x++) {
1539
+ cellAtCI(prev, prevCI, prevCell)
1540
+ prevCI += 2
1541
+
1542
+ if (cb(x, y, prevCell, undefined)) {
1543
+ return true
1544
+ }
1545
+ }
1546
+ }
1547
+
1548
+ if (nextEndX > bothEndX) {
1549
+ nextCI = nextRowCI + ((bothEndX - startX) << 1)
1550
+
1551
+ for (let x = bothEndX; x < nextEndX; x++) {
1552
+ if (nextCells[nextCI] === 0 && nextCells[nextCI | 1] === 0) {
1553
+ nextCI += 2
1554
+
1555
+ continue
1556
+ }
1557
+
1558
+ cellAtCI(next, nextCI, nextCell)
1559
+ nextCI += 2
1560
+
1561
+ if (cb(x, y, undefined, nextCell)) {
1562
+ return true
1563
+ }
1564
+ }
1565
+ }
1566
+
1567
+ prevRowCI += prevStride
1568
+ nextRowCI += nextStride
1569
+ }
1570
+
1571
+ return false
1572
+ }
1573
+
1574
+ /**
1575
+ * Mark a rectangular region as noSelect (exclude from text selection).
1576
+ * Clamps to screen bounds. Called from output.ts when a <NoSelect> box
1577
+ * renders. No damage tracking — noSelect doesn't affect terminal output,
1578
+ * only getSelectedText/applySelectionOverlay which read it directly.
1579
+ */
1580
+ export function markNoSelectRegion(screen: Screen, x: number, y: number, width: number, height: number): void {
1581
+ const maxX = Math.min(x + width, screen.width)
1582
+ const maxY = Math.min(y + height, screen.height)
1583
+ const noSel = screen.noSelect
1584
+ const stride = screen.width
1585
+
1586
+ for (let row = Math.max(0, y); row < maxY; row++) {
1587
+ const rowStart = row * stride
1588
+ noSel.fill(1, rowStart + Math.max(0, x), rowStart + maxX)
1589
+ }
1590
+ }