aicodeman 1.35.0 → 1.41.0

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 (316) hide show
  1. package/README.md +33 -7
  2. package/dist/attachment-registry.d.ts +6 -0
  3. package/dist/attachment-registry.d.ts.map +1 -1
  4. package/dist/attachment-registry.js +9 -3
  5. package/dist/attachment-registry.js.map +1 -1
  6. package/dist/cli-agent.d.ts +225 -0
  7. package/dist/cli-agent.d.ts.map +1 -0
  8. package/dist/cli-agent.js +767 -0
  9. package/dist/cli-agent.js.map +1 -0
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +8 -32
  12. package/dist/cli.js.map +1 -1
  13. package/dist/codeman-credentials.d.ts +27 -0
  14. package/dist/codeman-credentials.d.ts.map +1 -0
  15. package/dist/codeman-credentials.js +66 -0
  16. package/dist/codeman-credentials.js.map +1 -0
  17. package/dist/config/cli-registry/patterns.d.ts +6 -0
  18. package/dist/config/cli-registry/patterns.d.ts.map +1 -1
  19. package/dist/config/cli-registry/patterns.js +13 -0
  20. package/dist/config/cli-registry/patterns.js.map +1 -1
  21. package/dist/config/cli-registry/schema.d.ts +15 -0
  22. package/dist/config/cli-registry/schema.d.ts.map +1 -1
  23. package/dist/config/cli-registry/schema.js +63 -2
  24. package/dist/config/cli-registry/schema.js.map +1 -1
  25. package/dist/config/cli-registry/stock.d.ts.map +1 -1
  26. package/dist/config/cli-registry/stock.js +180 -7
  27. package/dist/config/cli-registry/stock.js.map +1 -1
  28. package/dist/config/cli-registry/types.d.ts +105 -7
  29. package/dist/config/cli-registry/types.d.ts.map +1 -1
  30. package/dist/deepseek-route-config.d.ts +110 -0
  31. package/dist/deepseek-route-config.d.ts.map +1 -0
  32. package/dist/deepseek-route-config.js +477 -0
  33. package/dist/deepseek-route-config.js.map +1 -0
  34. package/dist/git-clone.d.ts.map +1 -1
  35. package/dist/git-clone.js +4 -0
  36. package/dist/git-clone.js.map +1 -1
  37. package/dist/git-workspace-status.d.ts +31 -5
  38. package/dist/git-workspace-status.d.ts.map +1 -1
  39. package/dist/git-workspace-status.js +49 -29
  40. package/dist/git-workspace-status.js.map +1 -1
  41. package/dist/image-watcher.d.ts.map +1 -1
  42. package/dist/image-watcher.js +6 -2
  43. package/dist/image-watcher.js.map +1 -1
  44. package/dist/mcp-sync-targets.d.ts +34 -0
  45. package/dist/mcp-sync-targets.d.ts.map +1 -0
  46. package/dist/mcp-sync-targets.js +55 -0
  47. package/dist/mcp-sync-targets.js.map +1 -0
  48. package/dist/mcp-sync.d.ts +2 -1
  49. package/dist/mcp-sync.d.ts.map +1 -1
  50. package/dist/mcp-sync.js +62 -1
  51. package/dist/mcp-sync.js.map +1 -1
  52. package/dist/model-config-resolvers.d.ts +29 -0
  53. package/dist/model-config-resolvers.d.ts.map +1 -0
  54. package/dist/model-config-resolvers.js +34 -0
  55. package/dist/model-config-resolvers.js.map +1 -0
  56. package/dist/session-display-model.d.ts +110 -0
  57. package/dist/session-display-model.d.ts.map +1 -0
  58. package/dist/session-display-model.js +154 -0
  59. package/dist/session-display-model.js.map +1 -0
  60. package/dist/session.d.ts +145 -8
  61. package/dist/session.d.ts.map +1 -1
  62. package/dist/session.js +290 -39
  63. package/dist/session.js.map +1 -1
  64. package/dist/subagent-watcher.d.ts.map +1 -1
  65. package/dist/subagent-watcher.js +29 -6
  66. package/dist/subagent-watcher.js.map +1 -1
  67. package/dist/templates/case-template.md +2 -0
  68. package/dist/tmux-manager.d.ts +5 -0
  69. package/dist/tmux-manager.d.ts.map +1 -1
  70. package/dist/tmux-manager.js +47 -22
  71. package/dist/tmux-manager.js.map +1 -1
  72. package/dist/tui/tui-client.d.ts +3 -17
  73. package/dist/tui/tui-client.d.ts.map +1 -1
  74. package/dist/tui/tui-client.js +3 -44
  75. package/dist/tui/tui-client.js.map +1 -1
  76. package/dist/types/session.d.ts +28 -1
  77. package/dist/types/session.d.ts.map +1 -1
  78. package/dist/types/session.js.map +1 -1
  79. package/dist/types/tools.d.ts +1 -1
  80. package/dist/types/tools.d.ts.map +1 -1
  81. package/dist/utils/deepseek-cli-resolver.d.ts +8 -0
  82. package/dist/utils/deepseek-cli-resolver.d.ts.map +1 -1
  83. package/dist/utils/deepseek-cli-resolver.js +18 -2
  84. package/dist/utils/deepseek-cli-resolver.js.map +1 -1
  85. package/dist/utils/index.d.ts +1 -0
  86. package/dist/utils/index.d.ts.map +1 -1
  87. package/dist/utils/index.js +1 -0
  88. package/dist/utils/index.js.map +1 -1
  89. package/dist/utils/process-exit-wait.d.ts +40 -0
  90. package/dist/utils/process-exit-wait.d.ts.map +1 -0
  91. package/dist/utils/process-exit-wait.js +68 -0
  92. package/dist/utils/process-exit-wait.js.map +1 -0
  93. package/dist/web/launch-defaults.d.ts +22 -0
  94. package/dist/web/launch-defaults.d.ts.map +1 -0
  95. package/dist/web/launch-defaults.js +46 -0
  96. package/dist/web/launch-defaults.js.map +1 -0
  97. package/dist/web/paste-image-gc.d.ts +46 -6
  98. package/dist/web/paste-image-gc.d.ts.map +1 -1
  99. package/dist/web/paste-image-gc.js +106 -32
  100. package/dist/web/paste-image-gc.js.map +1 -1
  101. package/dist/web/public/admin-ui.js.gz +0 -0
  102. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  103. package/dist/web/public/app.6f8552e1.js +43 -0
  104. package/dist/web/public/app.6f8552e1.js.br +0 -0
  105. package/dist/web/public/app.6f8552e1.js.gz +0 -0
  106. package/dist/web/public/approvals-ui.js.gz +0 -0
  107. package/dist/web/public/{constants.d95fade9.js → constants.bc9c1f63.js} +1276 -127
  108. package/dist/web/public/constants.bc9c1f63.js.br +0 -0
  109. package/dist/web/public/constants.bc9c1f63.js.gz +0 -0
  110. package/dist/web/public/cron-ui.js.gz +0 -0
  111. package/dist/web/public/entrance-animations.js +491 -43
  112. package/dist/web/public/entrance-animations.js.br +0 -0
  113. package/dist/web/public/entrance-animations.js.gz +0 -0
  114. package/dist/web/public/git-status-ui.js +55 -9
  115. package/dist/web/public/git-status-ui.js.br +0 -0
  116. package/dist/web/public/git-status-ui.js.gz +0 -0
  117. package/dist/web/public/home-sessions.js +13 -9
  118. package/dist/web/public/home-sessions.js.br +0 -0
  119. package/dist/web/public/home-sessions.js.gz +0 -0
  120. package/dist/web/public/host-wake-ui.js.gz +0 -0
  121. package/dist/web/public/i18n.a9840ea2.js +2 -0
  122. package/dist/web/public/i18n.a9840ea2.js.br +0 -0
  123. package/dist/web/public/i18n.a9840ea2.js.gz +0 -0
  124. package/dist/web/public/{image-input.a216cf9e.js → image-input.e00471c6.js} +21 -9
  125. package/dist/web/public/image-input.e00471c6.js.br +0 -0
  126. package/dist/web/public/image-input.e00471c6.js.gz +0 -0
  127. package/dist/web/public/index.html +250 -59
  128. package/dist/web/public/index.html.br +0 -0
  129. package/dist/web/public/index.html.gz +0 -0
  130. package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
  131. package/dist/web/public/keyboard-accessory.c1e7c7b7.js.gz +0 -0
  132. package/dist/web/public/{mobile-handlers.23a85db1.js → mobile-handlers.0ef9c311.js} +20 -0
  133. package/dist/web/public/mobile-handlers.0ef9c311.js.br +0 -0
  134. package/dist/web/public/mobile-handlers.0ef9c311.js.gz +0 -0
  135. package/dist/web/public/mobile-ime-preview.2dbaf262.js.gz +0 -0
  136. package/dist/web/public/mobile-overview.js.gz +0 -0
  137. package/dist/web/public/mobile.111b187d.css +1 -0
  138. package/dist/web/public/mobile.111b187d.css.br +0 -0
  139. package/dist/web/public/mobile.111b187d.css.gz +0 -0
  140. package/dist/web/public/{notification-manager.50bb87d8.js → notification-manager.feb82c04.js} +19 -2
  141. package/dist/web/public/notification-manager.feb82c04.js.br +0 -0
  142. package/dist/web/public/notification-manager.feb82c04.js.gz +0 -0
  143. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  144. package/dist/web/public/{panels-ui.6a5898d9.js → panels-ui.71846fcb.js} +4 -4
  145. package/dist/web/public/panels-ui.71846fcb.js.br +0 -0
  146. package/dist/web/public/panels-ui.71846fcb.js.gz +0 -0
  147. package/dist/web/public/ralph-panel.b350e2ef.js.gz +0 -0
  148. package/dist/web/public/ralph-wizard.b6c0dd19.js.gz +0 -0
  149. package/dist/web/public/readmymind-ui.js.gz +0 -0
  150. package/dist/web/public/reboot-restore-ui.js.gz +0 -0
  151. package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
  152. package/dist/web/public/sanitize-html.e7147e76.js.gz +0 -0
  153. package/dist/web/public/session-lineage.js +229 -86
  154. package/dist/web/public/session-lineage.js.br +0 -0
  155. package/dist/web/public/session-lineage.js.gz +0 -0
  156. package/dist/web/public/session-ui.f731602c.js +100 -0
  157. package/dist/web/public/session-ui.f731602c.js.br +0 -0
  158. package/dist/web/public/session-ui.f731602c.js.gz +0 -0
  159. package/dist/web/public/settings-ui.4e33390f.js +97 -0
  160. package/dist/web/public/settings-ui.4e33390f.js.br +0 -0
  161. package/dist/web/public/settings-ui.4e33390f.js.gz +0 -0
  162. package/dist/web/public/spreadsheet-preview-worker.js +290 -0
  163. package/dist/web/public/spreadsheet-preview-worker.js.br +0 -0
  164. package/dist/web/public/spreadsheet-preview-worker.js.gz +0 -0
  165. package/dist/web/public/spreadsheet-preview.af82fa2f.js +555 -0
  166. package/dist/web/public/spreadsheet-preview.af82fa2f.js.br +0 -0
  167. package/dist/web/public/spreadsheet-preview.af82fa2f.js.gz +0 -0
  168. package/dist/web/public/spreadsheet-xlsx-core.js +1006 -0
  169. package/dist/web/public/spreadsheet-xlsx-core.js.br +0 -0
  170. package/dist/web/public/spreadsheet-xlsx-core.js.gz +0 -0
  171. package/dist/web/public/styles.81a72906.css +1 -0
  172. package/dist/web/public/styles.81a72906.css.br +0 -0
  173. package/dist/web/public/styles.81a72906.css.gz +0 -0
  174. package/dist/web/public/{subagent-windows.e6ca799f.js → subagent-windows.07277a58.js} +7 -5
  175. package/dist/web/public/subagent-windows.07277a58.js.br +0 -0
  176. package/dist/web/public/subagent-windows.07277a58.js.gz +0 -0
  177. package/dist/web/public/sw.js +2 -2
  178. package/dist/web/public/sw.js.br +0 -0
  179. package/dist/web/public/sw.js.gz +0 -0
  180. package/dist/web/public/tab-layout-browser.065fcba4.js.gz +0 -0
  181. package/dist/web/public/{tab-rail-resize.94cd59f0.js → tab-rail-resize.a5867793.js} +1 -1
  182. package/dist/web/public/tab-rail-resize.a5867793.js.br +0 -0
  183. package/dist/web/public/{tab-rail-resize.94cd59f0.js.gz → tab-rail-resize.a5867793.js.gz} +0 -0
  184. package/dist/web/public/terminal-keycode229-recovery.af09f99a.js +1 -0
  185. package/dist/web/public/terminal-keycode229-recovery.af09f99a.js.br +0 -0
  186. package/dist/web/public/terminal-keycode229-recovery.af09f99a.js.gz +0 -0
  187. package/dist/web/public/terminal-split.js +146 -649
  188. package/dist/web/public/terminal-split.js.br +0 -0
  189. package/dist/web/public/terminal-split.js.gz +0 -0
  190. package/dist/web/public/terminal-tile.js +1688 -0
  191. package/dist/web/public/terminal-tile.js.br +0 -0
  192. package/dist/web/public/terminal-tile.js.gz +0 -0
  193. package/dist/web/public/terminal-ui.b7267005.js +2 -0
  194. package/dist/web/public/terminal-ui.b7267005.js.br +0 -0
  195. package/dist/web/public/terminal-ui.b7267005.js.gz +0 -0
  196. package/dist/web/public/tile-grid.js +2895 -0
  197. package/dist/web/public/tile-grid.js.br +0 -0
  198. package/dist/web/public/tile-grid.js.gz +0 -0
  199. package/dist/web/public/ultracode-panel.js.gz +0 -0
  200. package/dist/web/public/ultracode-windows.js +9 -6
  201. package/dist/web/public/ultracode-windows.js.br +0 -0
  202. package/dist/web/public/ultracode-windows.js.gz +0 -0
  203. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  204. package/dist/web/public/vendor/exceljs.min.js +44 -0
  205. package/dist/web/public/vendor/exceljs.min.js.br +0 -0
  206. package/dist/web/public/vendor/exceljs.min.js.gz +0 -0
  207. package/dist/web/public/vendor/fflate.min.js +1 -0
  208. package/dist/web/public/vendor/fflate.min.js.br +0 -0
  209. package/dist/web/public/vendor/fflate.min.js.gz +0 -0
  210. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  211. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  212. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  213. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  214. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  215. package/dist/web/public/vendor/xterm-predictive-echo.ae5e3d5d.js.gz +0 -0
  216. package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js +4 -0
  217. package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js.br +0 -0
  218. package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js.gz +0 -0
  219. package/dist/web/public/vendor/xterm.css.gz +0 -0
  220. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  221. package/dist/web/public/{voice-input.c4b51eb6.js → voice-input.2342c37b.js} +64 -10
  222. package/dist/web/public/voice-input.2342c37b.js.br +0 -0
  223. package/dist/web/public/voice-input.2342c37b.js.gz +0 -0
  224. package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
  225. package/dist/web/public/webview-tabs.js +9 -1
  226. package/dist/web/public/webview-tabs.js.br +0 -0
  227. package/dist/web/public/webview-tabs.js.gz +0 -0
  228. package/dist/web/routes/cli-registry-routes.d.ts +23 -4
  229. package/dist/web/routes/cli-registry-routes.d.ts.map +1 -1
  230. package/dist/web/routes/cli-registry-routes.js +95 -8
  231. package/dist/web/routes/cli-registry-routes.js.map +1 -1
  232. package/dist/web/routes/file-routes.d.ts +7 -0
  233. package/dist/web/routes/file-routes.d.ts.map +1 -1
  234. package/dist/web/routes/file-routes.js +34 -4
  235. package/dist/web/routes/file-routes.js.map +1 -1
  236. package/dist/web/routes/git-status-routes.d.ts.map +1 -1
  237. package/dist/web/routes/git-status-routes.js +33 -6
  238. package/dist/web/routes/git-status-routes.js.map +1 -1
  239. package/dist/web/routes/mcp-sync-routes.d.ts +7 -2
  240. package/dist/web/routes/mcp-sync-routes.d.ts.map +1 -1
  241. package/dist/web/routes/mcp-sync-routes.js +10 -3
  242. package/dist/web/routes/mcp-sync-routes.js.map +1 -1
  243. package/dist/web/routes/reboot-restore-routes.js +1 -1
  244. package/dist/web/routes/reboot-restore-routes.js.map +1 -1
  245. package/dist/web/routes/session-routes.d.ts +18 -0
  246. package/dist/web/routes/session-routes.d.ts.map +1 -1
  247. package/dist/web/routes/session-routes.js +154 -69
  248. package/dist/web/routes/session-routes.js.map +1 -1
  249. package/dist/web/routes/status-telemetry-routes.d.ts +3 -0
  250. package/dist/web/routes/status-telemetry-routes.d.ts.map +1 -1
  251. package/dist/web/routes/status-telemetry-routes.js +12 -0
  252. package/dist/web/routes/status-telemetry-routes.js.map +1 -1
  253. package/dist/web/routes/system-routes.d.ts.map +1 -1
  254. package/dist/web/routes/system-routes.js +13 -0
  255. package/dist/web/routes/system-routes.js.map +1 -1
  256. package/dist/web/schemas.d.ts +30 -0
  257. package/dist/web/schemas.d.ts.map +1 -1
  258. package/dist/web/schemas.js +43 -0
  259. package/dist/web/schemas.js.map +1 -1
  260. package/dist/web/server.d.ts +6 -1
  261. package/dist/web/server.d.ts.map +1 -1
  262. package/dist/web/server.js +47 -21
  263. package/dist/web/server.js.map +1 -1
  264. package/dist/web/session-listener-wiring.d.ts +1 -0
  265. package/dist/web/session-listener-wiring.d.ts.map +1 -1
  266. package/dist/web/session-listener-wiring.js +11 -0
  267. package/dist/web/session-listener-wiring.js.map +1 -1
  268. package/package.json +6 -4
  269. package/scripts/postinstall.js +16 -0
  270. package/skills/codeman/SKILL.md +6 -0
  271. package/README.zh-CN.md +0 -1139
  272. package/dist/web/public/app.b9516c7b.js +0 -43
  273. package/dist/web/public/app.b9516c7b.js.br +0 -0
  274. package/dist/web/public/app.b9516c7b.js.gz +0 -0
  275. package/dist/web/public/constants.d95fade9.js.br +0 -0
  276. package/dist/web/public/constants.d95fade9.js.gz +0 -0
  277. package/dist/web/public/i18n.52f5b7cc.js +0 -1
  278. package/dist/web/public/i18n.52f5b7cc.js.br +0 -0
  279. package/dist/web/public/i18n.52f5b7cc.js.gz +0 -0
  280. package/dist/web/public/image-input.a216cf9e.js.br +0 -0
  281. package/dist/web/public/image-input.a216cf9e.js.gz +0 -0
  282. package/dist/web/public/mobile-handlers.23a85db1.js.br +0 -0
  283. package/dist/web/public/mobile-handlers.23a85db1.js.gz +0 -0
  284. package/dist/web/public/mobile.1aa4952d.css +0 -1
  285. package/dist/web/public/mobile.1aa4952d.css.br +0 -0
  286. package/dist/web/public/mobile.1aa4952d.css.gz +0 -0
  287. package/dist/web/public/notification-manager.50bb87d8.js.br +0 -0
  288. package/dist/web/public/notification-manager.50bb87d8.js.gz +0 -0
  289. package/dist/web/public/panels-ui.6a5898d9.js.br +0 -0
  290. package/dist/web/public/panels-ui.6a5898d9.js.gz +0 -0
  291. package/dist/web/public/session-ui.6bddf916.js +0 -89
  292. package/dist/web/public/session-ui.6bddf916.js.br +0 -0
  293. package/dist/web/public/session-ui.6bddf916.js.gz +0 -0
  294. package/dist/web/public/settings-ui.6e798b7b.js +0 -97
  295. package/dist/web/public/settings-ui.6e798b7b.js.br +0 -0
  296. package/dist/web/public/settings-ui.6e798b7b.js.gz +0 -0
  297. package/dist/web/public/styles.2d175c60.css +0 -1
  298. package/dist/web/public/styles.2d175c60.css.br +0 -0
  299. package/dist/web/public/styles.2d175c60.css.gz +0 -0
  300. package/dist/web/public/subagent-windows.e6ca799f.js.br +0 -0
  301. package/dist/web/public/subagent-windows.e6ca799f.js.gz +0 -0
  302. package/dist/web/public/tab-rail-resize.94cd59f0.js.br +0 -0
  303. package/dist/web/public/terminal-keycode229-recovery.fb91b25b.js +0 -1
  304. package/dist/web/public/terminal-keycode229-recovery.fb91b25b.js.br +0 -0
  305. package/dist/web/public/terminal-keycode229-recovery.fb91b25b.js.gz +0 -0
  306. package/dist/web/public/terminal-ui.a4535311.js +0 -2
  307. package/dist/web/public/terminal-ui.a4535311.js.br +0 -0
  308. package/dist/web/public/terminal-ui.a4535311.js.gz +0 -0
  309. package/dist/web/public/upload.html +0 -155
  310. package/dist/web/public/upload.html.br +0 -0
  311. package/dist/web/public/upload.html.gz +0 -0
  312. package/dist/web/public/vendor/xterm-zerolag-input.bbb5f84c.js +0 -4
  313. package/dist/web/public/vendor/xterm-zerolag-input.bbb5f84c.js.br +0 -0
  314. package/dist/web/public/vendor/xterm-zerolag-input.bbb5f84c.js.gz +0 -0
  315. package/dist/web/public/voice-input.c4b51eb6.js.br +0 -0
  316. package/dist/web/public/voice-input.c4b51eb6.js.gz +0 -0
@@ -0,0 +1,1688 @@
1
+ // src/web/public/terminal-tile.js
2
+
3
+ /**
4
+ * @fileoverview TerminalTile: one independent live terminal pane bound to one
5
+ * session, with its own xterm instance and its own
6
+ * `/ws/sessions/:id/terminal` WebSocket. The split pane (terminal-split.js)
7
+ * uses one as its second pane ("Pane B"); the tile grid (tile-grid.js,
8
+ * docs/tile-grid-plan.md) uses one per tile, and feeds every capture they
9
+ * fetch through ONE TileLoadQueue (below), because each capture is a
10
+ * synchronous tmux call that blocks the server's event loop.
11
+ *
12
+ * Deliberately plainer than the primary pane (this.terminal/this._ws in
13
+ * terminal-ui.js): no local-echo overlay, no CJK IME textarea, no touch/mobile
14
+ * handlers (a swipe on a touch screen pages nothing), and no keyboard
15
+ * accessory bar. Built for wide screens; see docs/split-pane-sessions-plan.md
16
+ * and docs/tile-grid-plan.md.
17
+ *
18
+ * What it does carry over from the primary pane, through the primary pane's
19
+ * own code aimed at THIS pane (its terminal, its session, never the active
20
+ * one):
21
+ * - SGR wheel forwarding (_maybeForwardWheelToCli): Claude's fullscreen
22
+ * renderer scrolls its own transcript on SGR wheel reports, while this
23
+ * xterm holds only replayed repaint frames, so the wheel goes to the CLI
24
+ * as reports at the pointer's cell in this pane, through the primary
25
+ * pane's forwarding gate and its encoding (sgrWheelReports). Shift+wheel
26
+ * scrolls the local scrollback itself (_maybeScrollLocalOnShift), as the
27
+ * primary pane does, since xterm turns it into a horizontal no-op.
28
+ * - Hollow-buffer paging (#555): a CLI that draws in place (opencode on the
29
+ * alternate screen, Claude's repaint mode) leaves the xterm no scrollback,
30
+ * so the wheel pages the CLI's own transcript with PageUp/PageDown
31
+ * (_maybePageCliTranscript) through the primary pane's gates, plus an
32
+ * overflow-row discount for this pane's capture-before-resize load
33
+ * (_localRows), and only while the viewport is on the live screen (a
34
+ * wheel-down from those overflow rows is xterm's, and brings it home).
35
+ * - The desktop click report: a plain left-click hand-encoded as SGR while
36
+ * the session's CLI has mouse tracking on (cliMouseTracking), for the modes
37
+ * whose mouse DECSETs the server strips (_installClickListener), sent
38
+ * ephemeral, like every mouse report from this pane (_onTerminalData).
39
+ * - The soft-keyboard controller (terminal-keycode229-recovery.js), one per
40
+ * pane, on this pane's own textarea and composition helper and sending to
41
+ * this pane's session (_createKeyCode229Recovery): it forwards an
42
+ * `insertText` xterm refused, settles a pending textarea edit at the next
43
+ * keydown ahead of that key (#441: the last character an Android keyboard
44
+ * commits in the same task as Enter), and replaces xterm's append-only
45
+ * keyCode-229 diff with an edit-based one (#541: autocorrect on space
46
+ * duplicated the line). Not a desktop-only concern: the grid and the split
47
+ * are gated on width alone (SPLIT_PANE_MIN_WIDTH, 1180 CSS px), which a wide
48
+ * Android tablet, or a large foldable unfolded in landscape, reaches.
49
+ *
50
+ * @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
51
+ * @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE)
52
+ * @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/wheelDeltaWholeLines/sgrWheelReports/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_clientPointToCell/_handleDesktopTerminalClick)
53
+ * @dependency terminal-keycode229-recovery.js (window.CodemanKeyCode229Recovery, optional: absent, xterm's own textarea handling stands)
54
+ * @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js
55
+ */
56
+
57
+ (function (global) {
58
+ // How long a load may hold this pane's live output while it reads a bounded
59
+ // body: a scroll-to-top history pull, or a refresh of a bounded window.
60
+ const HISTORY_PULL_TIMEOUT_MS = 10000;
61
+
62
+ // How much of a replay is queued in xterm at once: a 1 MiB load goes in one
63
+ // window, and xterm's write queue throws past 50 MB, which an unbounded
64
+ // `full=1` capture (up to the server's 32 MB) would otherwise come near.
65
+ const REPLAY_WINDOW_BYTES = 1024 * 1024;
66
+
67
+ /**
68
+ * Replays a capture into a pane's own xterm: TERMINAL_CHUNK_SIZE slices, all
69
+ * of a window queued at once. xterm 6 parses its write queue in 12 ms slices
70
+ * and yields between them, so a long scrollback never becomes a long task,
71
+ * and it is not held to one slice per animation frame either (that pacing
72
+ * took about a second per 1 MiB, with the grid's load queue waiting behind
73
+ * it). Queued up front, the capture also stays in one piece: live output
74
+ * written during the parse lands after it, not between two of its slices.
75
+ * Deliberately NOT the primary pane's chunkedTerminalWrite (terminal-ui.js):
76
+ * that one is wired into session-switch generation counters and the
77
+ * live-output gate this simpler, independently created/destroyed pane has no
78
+ * equivalent of.
79
+ *
80
+ * Resolves once xterm has parsed the last slice (a write's callback runs once
81
+ * everything queued before it is parsed), so _loadBuffer() below holds its
82
+ * single-flight flag across the whole replay. A disposed xterm never runs its
83
+ * callbacks, so `setCancel` hands the owner a function that settles the
84
+ * replay at once: destroy() calls it, or the pane's flag and the grid's load
85
+ * queue would wait forever.
86
+ */
87
+ function writeChunked(terminal, buffer, isDestroyed, setCancel) {
88
+ if (!buffer || !terminal) return Promise.resolve();
89
+ return new Promise((resolve) => {
90
+ let offset = 0;
91
+ let settled = false;
92
+ const settle = () => {
93
+ if (settled) return;
94
+ settled = true;
95
+ setCancel?.(null);
96
+ resolve();
97
+ };
98
+ const writeWindow = () => {
99
+ if (settled) return;
100
+ if (isDestroyed()) {
101
+ settle();
102
+ return;
103
+ }
104
+ const end = Math.min(buffer.length, offset + REPLAY_WINDOW_BYTES);
105
+ while (offset < end) {
106
+ const chunk = buffer.slice(offset, Math.min(end, offset + TERMINAL_CHUNK_SIZE));
107
+ offset += chunk.length;
108
+ terminal.write(chunk);
109
+ }
110
+ terminal.write('', offset < buffer.length ? writeWindow : settle);
111
+ };
112
+ setCancel?.(settle);
113
+ writeWindow();
114
+ });
115
+ }
116
+
117
+ class TerminalTile {
118
+ constructor(sessionId, mountEl, opts = {}) {
119
+ this.sessionId = sessionId;
120
+ this.mountEl = mountEl;
121
+ this.sessionMode = opts.mode;
122
+ this.fontSettings = opts.fontSettings || {};
123
+ // Live reference (not a snapshot) to the app's detachedSessions Set:
124
+ // detaching this session AFTER the pane opened must still be seen by
125
+ // _sendResize() below, or this pane and the session's own window fight
126
+ // over the PTY's size (which the split picker refuses at pick time).
127
+ this.detachedSessions = opts.detachedSessions;
128
+ // Lines of scrollback this pane's xterm keeps (the grid passes its smaller
129
+ // TILE_SCROLLBACK) and its font size (the grid's own tile font); absent,
130
+ // the primary pane's values.
131
+ this.scrollback = Number.isFinite(opts.scrollback) ? opts.scrollback : null;
132
+ this.fontSize = Number.isFinite(opts.fontSize) ? opts.fontSize : null;
133
+ // `scheduleLoad(tile, kind, run)` runs every capture this pane fetches
134
+ // (`kind`: 'initial', 'refresh' or 'history') when its owner says so, and
135
+ // resolves once `run` has finished or was dropped. The grid passes its one
136
+ // queue so N tiles never fetch at once; absent, a load runs straight away.
137
+ this._scheduleLoad = typeof opts.scheduleLoad === 'function' ? opts.scheduleLoad : null;
138
+ // Loads a BOUNDED window (`full=1&tail=` for a TUI, `tail=` for a shell)
139
+ // instead of a TUI's whole history. Grid tiles do; full history is one
140
+ // "leave the grid" away in the primary pane.
141
+ this.boundedLoad = opts.boundedLoad === true;
142
+ this.terminal = null;
143
+ this.fitAddon = null;
144
+ this.ws = null;
145
+ this._wsReady = false;
146
+ this._wsClosed = false;
147
+ this._destroyed = false;
148
+ // Single-flight state for _loadBuffer()/_refreshBuffer() below.
149
+ this._bufferLoading = false;
150
+ this._bufferRefreshPending = false;
151
+ // True only while a load's work runs, not while it waits in the owner's
152
+ // queue (see _runLoad): a close during the wait writes its marker at once.
153
+ this._loadRunning = false;
154
+ // Aborts the running load's fetch; destroy() uses it so a removed tile
155
+ // does not hold the owner's queue for a whole deadline.
156
+ this._loadAbort = null;
157
+ // Settles a replay xterm is still parsing (writeChunked): destroy() calls
158
+ // it, because a disposed xterm never runs the callback the replay awaits.
159
+ this._cancelReplay = null;
160
+ // Scroll-to-top history pull (shell panes only), see _maybeLoadMoreHistory().
161
+ // `_liveQueue` is non-null from the pull's response until its finally
162
+ // block: live frames are held there with their arrival time instead of
163
+ // written under the replay. `_markerOwed` is the "disconnected" marker a
164
+ // load still has to write (see _onSocketClosed()/_stampMarkerIfOwed()).
165
+ this._historyPullAt = 0;
166
+ this._historyPullUseless = false;
167
+ this._liveQueue = null;
168
+ this._liveQueueBytes = 0;
169
+ this._markerOwed = false;
170
+ // Live-output flow control (_writeLive, TerminalTile.LIVE_BACKLOG_BUDGET):
171
+ // code units written into this xterm and not yet parsed (each write's
172
+ // callback counts its own back down, unless a reset bumped `_liveEpoch`
173
+ // since), whether output was dropped and not yet recovered, when the last
174
+ // frame was dropped, and the debounced, bounded recovery refresh.
175
+ this._liveInFlight = 0;
176
+ this._liveEpoch = 0;
177
+ this._liveDropped = false;
178
+ this._liveDropAt = 0;
179
+ this._dropRecoveryTimer = null;
180
+ this._dropRecoveryAttempt = 0;
181
+ this._onWheel = null;
182
+ // `{ ws, lastRecvAt }`, registered with the app's input-socket map while
183
+ // this pane's socket is open, so the exactly-once input queue delivers this
184
+ // session's keystrokes over it (app.js _inputSocketFor). Null otherwise.
185
+ this._inputHandle = null;
186
+ // Reconnect state. `_socketUrl` is set once connect() opens the first
187
+ // socket: a pane that never connected has nothing to reconnect to.
188
+ // `_reconnectAttempts` counts consecutive failed opens and is reset ONLY by
189
+ // a successful open (resetting it per attempt is the tight-loop bug the
190
+ // primary pane's _disconnectWs documents). `_stoppedCode` is the close code
191
+ // that ended the pane for good; `onExit(code)` tells the owner once.
192
+ this._socketUrl = null;
193
+ this._reconnectAttempts = 0;
194
+ this._reconnectTimer = null;
195
+ this._stoppedCode = null;
196
+ this._markerText = TerminalTile.MARKER_RECONNECTING;
197
+ this.onExit = typeof opts.onExit === 'function' ? opts.onExit : null;
198
+ // The `{ cols, rows }` last sent in a `{t:'z'}` frame, so an unchanged size
199
+ // is not resent (each one costs a `tmux resize-window` and a SIGWINCH).
200
+ // Cleared on every open: a fresh socket must announce its size, which is
201
+ // also what registers it as a desktop viewer server-side.
202
+ this._lastSentDims = null;
203
+ // Whether the pointer is over a link in THIS pane (the primary pane's own
204
+ // flag, app._linkHovered, belongs to its terminal alone).
205
+ this._linkHovered = false;
206
+ this._onFocusIn = null;
207
+ // Hollow-buffer paging (_maybePageCliTranscript): wheel travel short of a
208
+ // whole page, carried to the next wheel event.
209
+ this._pageKeyPending = 0;
210
+ // Shift+wheel travel short of a whole line, carried to the next wheel
211
+ // event (_maybeScrollLocalOnShift).
212
+ this._shiftScrollPending = 0;
213
+ // Page keys waiting for the 40 ms flush, and its timer (_queueScrollBytes).
214
+ this._scrollBytes = '';
215
+ this._scrollFlushTimer = null;
216
+ // Rows above the screen that this pane pushed there itself rather than
217
+ // received as history: a capture taken at the PTY's previous, taller size
218
+ // and row-shrinking fits (_overflowAfterLoad, _noteResizeRows). Not
219
+ // history, so the paging gate leaves them out (_localRows).
220
+ this._overflowRows = 0;
221
+ // The desktop click reporter (_installClickListener).
222
+ this._onClick = null;
223
+ // The soft-keyboard controller (terminal-keycode229-recovery.js): created
224
+ // in connect() once the xterm is open, torn down in destroy().
225
+ this._keyCode229Recovery = null;
226
+ }
227
+
228
+ async connect() {
229
+ const savedFontSize = this.fontSize ?? parseInt(localStorage.getItem('codeman-font-size'), 10);
230
+ this.terminal = new Terminal({
231
+ theme: { ...global.codemanCurrentXtermTheme() },
232
+ fontFamily: global.CodemanTerminalFont.resolve(this.fontSettings.terminalFontFamily),
233
+ ...global.CodemanTerminalFont.resolveWeights(this.fontSettings),
234
+ fontSize: Number.isFinite(savedFontSize) ? savedFontSize : 14,
235
+ lineHeight: 1.2,
236
+ cursorBlink: false,
237
+ cursorStyle: 'block',
238
+ minimumContrastRatio: global.codemanCurrentSkinIsLight() ? 4.5 : 1,
239
+ scrollback: this.scrollback ?? DEFAULT_SCROLLBACK,
240
+ allowTransparency: true,
241
+ allowProposedApi: true,
242
+ });
243
+
244
+ this.fitAddon = new FitAddon.FitAddon();
245
+ this.terminal.loadAddon(this.fitAddon);
246
+ this.terminal.open(this.mountEl);
247
+ this.fitAddon.fit();
248
+
249
+ // File paths and URLs printed here are clickable, through the SAME
250
+ // provider as the primary pane (registerFilePathLinkProvider,
251
+ // terminal-ui.js), and open against THIS pane's session.
252
+ global.app?.registerFilePathLinkProvider?.({
253
+ terminal: this.terminal,
254
+ getSessionId: () => this.sessionId,
255
+ setHovered: (hovered) => {
256
+ this._linkHovered = hovered;
257
+ },
258
+ });
259
+
260
+ this._installWheelListener();
261
+ this._installClickListener();
262
+
263
+ // Focusing this terminal makes it the pane the keyboard is in, so the
264
+ // app-level shortcuts, voice and paste act on it (app._focusedPane).
265
+ this._onFocusIn = () => global.app?._noteFocusedTile?.(this);
266
+ this.terminal.textarea?.addEventListener('focus', this._onFocusIn);
267
+
268
+ this._createKeyCode229Recovery();
269
+ // The twin of terminal-ui.js's onData gate (initTerminal; keep the two in
270
+ // step). Canonical xterm data tells the controller this keystroke was
271
+ // delivered, but not a query reply or a focus/mouse report, which xterm
272
+ // emits on its own and which would otherwise stand a pending recovery
273
+ // down. The notify lives HERE and not in _onTerminalData(): the
274
+ // controller's own recovered bytes go through _onTerminalData() too, and
275
+ // must never count as xterm's, or a second pending character from the
276
+ // same keystroke window would stand down and be lost.
277
+ this.terminal.onData((data) => {
278
+ try {
279
+ const input = global.CodemanTerminalInput;
280
+ if (!input?.shouldSuppressTerminalQueryResponse?.(data) && !input?.isTerminalFocusOrMouseReport?.(data)) {
281
+ this._keyCode229Recovery?.notifyCanonicalData?.();
282
+ }
283
+ } catch {
284
+ /* Bookkeeping must never block real input. */
285
+ }
286
+ this._onTerminalData(data);
287
+ });
288
+
289
+ // xterm has no gates of its own, so every app-level chord that the
290
+ // document capture-phase handler (app.js) only preventDefault()s (never
291
+ // stopPropagation()s) would reach this xterm too and write its raw byte
292
+ // or escape sequence into THIS session's PTY on top of whatever the app
293
+ // action did (COD-153). These are the primary pane's gates
294
+ // (terminal-ui.js attachCustomKeyEventHandler): command palette,
295
+ // Alt+1-9/[/] tab nav, Alt+B sidebar toggle, the tile grid's chords,
296
+ // Ctrl+Z suspend, Shift/Ctrl+Enter newline, and smart-copy
297
+ // Ctrl+C/Ctrl+Shift+C. Routed through the same registry-aware
298
+ // predicates so a rebind or a disable restores plain terminal behavior
299
+ // here too. Ctrl+V goes through the primary pane's paste trap
300
+ // (image-input.js), aimed at this pane (below).
301
+ this.terminal.attachCustomKeyEventHandler((ev) => {
302
+ // FIRST, above the IME early return below, as in terminal-ui.js: every
303
+ // keydown settles this pane's pending textarea edit and drains a
304
+ // pending recovery BEFORE xterm handles the key, so a character an
305
+ // Android keyboard committed in the same task as Enter is sent ahead
306
+ // of the \r. Below that return a keyCode-229 keydown would skip the
307
+ // settle, the drain and the snapshot, and the panes would differ.
308
+ // Read at call time, never captured, so the controller can be swapped
309
+ // (the tests count xterm's emissions through it).
310
+ try {
311
+ this._keyCode229Recovery?.handleKeyEvent?.(ev);
312
+ } catch {
313
+ /* The controller must never interfere with xterm's own handling. */
314
+ }
315
+ if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true;
316
+ if (
317
+ ev.altKey &&
318
+ !ev.ctrlKey &&
319
+ !ev.shiftKey &&
320
+ /^(Digit[1-9]|BracketLeft|BracketRight|KeyK)$/.test(ev.code || '')
321
+ ) {
322
+ return false;
323
+ }
324
+ if (ev.type === 'keydown' && global.app?.shouldOpenCommandPaletteFromShortcut?.(ev)) {
325
+ return false;
326
+ }
327
+ if (ev.type === 'keydown' && global.app?.shouldToggleSessionSidebarFromShortcut?.(ev)) {
328
+ return false;
329
+ }
330
+ // Tile grid chords (focus, toggle): acted on by the capture handler, so
331
+ // they must never reach this tile's PTY. Every event type, and before
332
+ // the Shift+Enter branch below.
333
+ if (global.app?.tileShortcutFor?.(ev)) return false;
334
+ // Ctrl+V / Cmd+V: the primary pane's paste trap, aimed at THIS pane, so
335
+ // a pasted image uploads to this pane's session and its path is typed
336
+ // here, and pasted text goes into this xterm with its bracketed-paste
337
+ // markers intact. Mirrors terminal-ui.js's own Ctrl+V gate; without it
338
+ // xterm's default only ever pasted text.
339
+ if ((ev.ctrlKey || ev.metaKey) && ev.key === 'v' && ev.type === 'keydown') {
340
+ global.app?._handleImagePaste?.({ terminal: this.terminal, sessionId: this.sessionId });
341
+ return false;
342
+ }
343
+ // Ctrl+Z (SIGTSTP/job-control suspend), as terminal-ui.js swallows it:
344
+ // in a plain shell session this is the user's own job-control tool and
345
+ // must reach the PTY, but in every other mode (claude/omp/pi/codex/...)
346
+ // it silently stops an unattended agent loop dead. This pane has its
347
+ // own session and applies the same rule to it.
348
+ if (
349
+ ev.type === 'keydown' &&
350
+ ev.key.toLowerCase() === 'z' &&
351
+ ev.ctrlKey &&
352
+ !ev.altKey &&
353
+ !ev.metaKey &&
354
+ !ev.shiftKey &&
355
+ this.sessionMode !== 'shell'
356
+ ) {
357
+ return false;
358
+ }
359
+ // Shift+Enter / Ctrl+Enter: insert a newline instead of submitting, as
360
+ // terminal-ui.js does. xterm sends plain \r for every Enter variant,
361
+ // so an Ink app (Claude Code) can't tell a newline from a submit, and
362
+ // without this gate this pane's onData would send that bare \r and
363
+ // submit an incomplete prompt instead of adding a line to it. Targets
364
+ // THIS pane's own session (this.sessionId), never the primary pane's
365
+ // activeSessionId, and has no local-echo overlay of its own to flush
366
+ // first (this pane is deliberately plainer, see the fileoverview).
367
+ // Swallow keypress/keyup too (xterm would send \r for a Shift-only keypress); only keydown sends.
368
+ if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey)) {
369
+ if (ev.type === 'keydown') {
370
+ fetch(`/api/sessions/${this.sessionId}/send-key`, {
371
+ method: 'POST',
372
+ headers: { 'Content-Type': 'application/json' },
373
+ body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
374
+ }).catch(() => {
375
+ /* Best-effort, matching this pane's tolerance elsewhere. */
376
+ });
377
+ }
378
+ return false;
379
+ }
380
+ // Smart copy, the primary pane's rule (terminal-ui.js's Ctrl+C gate,
381
+ // #211) through the SAME helpers, aimed at THIS pane: the gutter width
382
+ // comes from this session's run mode, the partial first line from this
383
+ // terminal's selection, and the clear and refocus after the copy land
384
+ // here. With a selection worth copying, Ctrl+C copies instead of
385
+ // sending ^C; with none, plain Ctrl+C falls through unchanged or the
386
+ // interrupt key is lost. Ctrl+Shift+C is the explicit copy chord and
387
+ // never falls through (ev.shiftKey, below): with nothing to copy it
388
+ // would otherwise reach the browser's own binding for that chord.
389
+ // As in the primary gate, the CLEANED selection decides and the copy is
390
+ // handed the RAW one, because the margin strip is not idempotent.
391
+ if (ev.type === 'keydown' && global.app?.shouldCopyTerminalSelectionFromShortcut?.(ev)) {
392
+ const app = global.app;
393
+ const target = { terminal: this.terminal, sessionId: this.sessionId };
394
+ const raw = this.terminal?.getSelection?.() || '';
395
+ if (app.cleanedTerminalSelection?.(raw, target)?.trim()) {
396
+ ev.preventDefault();
397
+ void app.copyTerminalSelection(raw, target);
398
+ return false;
399
+ }
400
+ // Nothing worth copying: cleared for feedback, and the press still
401
+ // reaches the PTY as 0x03, as in the primary pane.
402
+ if (this.terminal?.hasSelection?.()) {
403
+ this.terminal.clearSelection?.();
404
+ app.showToast?.('Nothing to copy', 'warning');
405
+ }
406
+ if (ev.shiftKey) {
407
+ ev.preventDefault();
408
+ return false;
409
+ }
410
+ }
411
+ return true;
412
+ });
413
+
414
+ // Load existing scrollback before going live. The WS below is
415
+ // subscribe-only (ws-routes.ts sends nothing on connect, only future
416
+ // 'terminal' events), so without this the pane stays blank until the
417
+ // session happens to produce new output. The resize _sendResize() sends
418
+ // on open is no substitute: tmux repaints on a resize, but
419
+ // Session.resize() (session.ts) skips one that matches the session's
420
+ // last size, and then nothing repaints at all. The await covers the
421
+ // whole chunked replay, not just the fetch, so a live frame from the
422
+ // socket below can never land in the middle of it.
423
+ await this._loadBuffer();
424
+ if (this._destroyed) return;
425
+
426
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
427
+ // The tab's own connection identity plus a `:tile` suffix. The server
428
+ // supersedes a socket that reuses a cid on the same session (4010), so a
429
+ // pane must never share the primary pane's exact cid: were both ever on
430
+ // one session they would evict each other in a loop. Input frames still
431
+ // carry the BARE clientId, which is what the server dedups on.
432
+ const app = global.app;
433
+ const cid = app?._clientId ? `${app._clientId}:${app._wsTabNonce}:tile` : '';
434
+ const cidQuery = cid ? `?cid=${encodeURIComponent(cid)}` : '';
435
+ this._socketUrl = `${proto}//${location.host}${window.CodemanBase.base}/ws/sessions/${this.sessionId}/terminal${cidQuery}`;
436
+ this._openSocket();
437
+ }
438
+
439
+ // Opens a socket and makes it THE socket. A previous one is detached first
440
+ // (handlers nulled, then closed), and every handler below checks it still
441
+ // belongs to the current socket: a replacement opened while the old socket
442
+ // still looked alive (a half-open connection whose close has not landed)
443
+ // makes the server supersede the old one with a 4010, and that late close
444
+ // must not stop a pane that is already running on its successor.
445
+ _openSocket() {
446
+ if (this._destroyed || !this._socketUrl) return;
447
+ this._detachSocket();
448
+ const ws = new WebSocket(this._socketUrl);
449
+ this.ws = ws;
450
+
451
+ ws.onopen = () => {
452
+ if (ws !== this.ws) return;
453
+ this._onSocketOpen();
454
+ };
455
+
456
+ ws.onmessage = (event) => {
457
+ if (ws !== this.ws) return;
458
+ if (this._inputHandle) this._inputHandle.lastRecvAt = Date.now();
459
+ try {
460
+ const msg = JSON.parse(event.data);
461
+ if (msg.t === 'o') {
462
+ this._onLiveOutput(msg.d);
463
+ } else if (msg.t === 'c') {
464
+ this._onLiveClear();
465
+ } else if (msg.t === 'r') {
466
+ // Server-triggered refresh (SSE backpressure cleared, terminal
467
+ // data was dropped). The primary pane routes this to
468
+ // _onSessionNeedsRefresh (app.js); this pane has its own buffer
469
+ // loader for the same reason connect() does.
470
+ this._refreshBuffer();
471
+ } else if (msg.t === 'ia') {
472
+ // Input ACK. The frame names no session, so it is this pane's.
473
+ global.app?._onWsInputAck?.(msg.seq, msg, this.sessionId);
474
+ } else if (msg.t === 'zc') {
475
+ this._onPtyGeometryReport(msg.c, msg.r);
476
+ }
477
+ } catch {
478
+ /* Malformed frame: ignored, as in the primary pane. */
479
+ }
480
+ };
481
+
482
+ // _wsReady must go false on a drop or fit()/_sendResize() silently
483
+ // no-op on a closed socket per the WebSocket spec (no exception, no log).
484
+ // Input is not lost meanwhile: it waits in the app's durable queue and
485
+ // goes out over HTTP or the next socket. The "disconnected" marker says
486
+ // so on screen, and a transient close reconnects (_onSocketClosed).
487
+ ws.onclose = (event) => {
488
+ if (ws !== this.ws) return;
489
+ this._onSocketClosed(event);
490
+ };
491
+
492
+ ws.onerror = () => {
493
+ // onclose fires after onerror: cleanup happens there.
494
+ };
495
+ }
496
+
497
+ // Lets go of the current socket without running its close handling.
498
+ _detachSocket() {
499
+ const ws = this.ws;
500
+ if (!ws) return;
501
+ ws.onopen = null;
502
+ ws.onmessage = null;
503
+ // onclose fires asynchronously AFTER close(); without this it would run
504
+ // its "disconnected" write against a pane already torn down or replaced.
505
+ ws.onclose = null;
506
+ ws.onerror = null;
507
+ try {
508
+ ws.close();
509
+ } catch {
510
+ /* Already closed. */
511
+ }
512
+ this.ws = null;
513
+ this._wsReady = false;
514
+ this._unregisterInputSocket();
515
+ }
516
+
517
+ // A socket came up. After a drop this is a reconnect: the gap left nothing
518
+ // to replay (output frames carry no sequence number), so the buffer is
519
+ // refreshed. The closed state is reset FIRST, or the refresh would re-owe
520
+ // the "disconnected" marker (_refreshBuffer does on a closed socket) and
521
+ // stamp it under a healthy pane.
522
+ _onSocketOpen() {
523
+ const reconnected = this._wsClosed;
524
+ this._wsReady = true;
525
+ this._wsClosed = false;
526
+ this._markerOwed = false;
527
+ this._reconnectAttempts = 0;
528
+ this._lastSentDims = null;
529
+ this._registerInputSocket();
530
+ this._sendResize();
531
+ if (reconnected) {
532
+ // The gap already cost output, and the refresh below replaces the
533
+ // screen, so live-output accounting starts over with it.
534
+ this._resetLiveFlow();
535
+ this._refreshBuffer();
536
+ }
537
+ }
538
+
539
+ // Opens a replacement socket now instead of waiting out the backoff (for an
540
+ // owner that just learned the server is back). No-op while the current
541
+ // socket is open, after a permanent stop, or once destroyed.
542
+ reconnectNow() {
543
+ if (this._destroyed || this._stoppedCode !== null || !this._socketUrl) return;
544
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
545
+ clearTimeout(this._reconnectTimer);
546
+ this._reconnectTimer = null;
547
+ this._openSocket();
548
+ }
549
+
550
+ // The socket's close, split out of connect() so the tests can drive it.
551
+ // While any load runs (a history pull or a `{t:'r'}` refresh) the marker is
552
+ // only owed, and that load's finally block settles it (_stampMarkerIfOwed()):
553
+ // written now, it would sit above the output a pull is still holding (flushed
554
+ // after it on a skip, a downgrade or a failed fetch), above a refresh's
555
+ // replay, or in the middle of a chunked replay. A pull still waiting for its
556
+ // response holds the marker too, for as long as the request takes (up to its
557
+ // budget, see _pullHistory()).
558
+ //
559
+ // Then decides what comes next. Codes that cannot get better stop the pane
560
+ // for good and report once through `onExit(code)`: 4004/4009 (the session
561
+ // is gone), 4003 (refused: Host/Origin/owner, a retry gets the same answer)
562
+ // and 4010 (another socket with this pane's cid took over; only ever
563
+ // reaches here for the CURRENT socket, see _openSocket). Everything else,
564
+ // including the redelivery sweep force-closing a silent socket (1005), is
565
+ // transient and reconnects on the primary pane's backoff ladder
566
+ // (CodemanWsReconnect, constants.js) plus jitter.
567
+ _onSocketClosed(event) {
568
+ this._wsReady = false;
569
+ this._wsClosed = true;
570
+ this._unregisterInputSocket();
571
+ const code = event?.code;
572
+ const permanent = TerminalTile.STOP_MARKERS[code];
573
+ this._markerText = permanent || TerminalTile.MARKER_RECONNECTING;
574
+ if (this._loadRunning) this._markerOwed = true;
575
+ else this._writeDisconnectedMarker();
576
+ if (this._destroyed) return;
577
+ if (permanent) {
578
+ this._stop(code);
579
+ return;
580
+ }
581
+ this._scheduleReconnect(code);
582
+ }
583
+
584
+ _scheduleReconnect(code) {
585
+ if (this._destroyed || !this._socketUrl || this._reconnectTimer) return;
586
+ const plan = global.CodemanWsReconnect?.plan?.(code ?? 1006, this._reconnectAttempts) || {
587
+ action: 'reconnect',
588
+ delayMs: 1000,
589
+ };
590
+ if (plan.action === 'give-up') {
591
+ this._stop(code);
592
+ return;
593
+ }
594
+ this._reconnectAttempts++;
595
+ const delay = plan.delayMs + Math.floor(Math.random() * 250); // jitter: tiles must not reconnect in lockstep
596
+ this._reconnectTimer = setTimeout(() => {
597
+ this._reconnectTimer = null;
598
+ this._openSocket();
599
+ }, delay);
600
+ }
601
+
602
+ _stop(code) {
603
+ if (this._stoppedCode !== null) return;
604
+ this._stoppedCode = code ?? null;
605
+ clearTimeout(this._reconnectTimer);
606
+ this._reconnectTimer = null;
607
+ if (!this._destroyed) this.onExit?.(code);
608
+ }
609
+
610
+ // Keystrokes and pastes go through the app's exactly-once input queue (seq,
611
+ // ACK, persisted until delivered, redelivered after a drop), over this
612
+ // pane's own socket while it is open and the HTTP fallback while it is not.
613
+ // What xterm GENERATES must not be queued: a query reply (DA/CPR/OSC) is
614
+ // dropped, as the primary pane drops it, because forwarding it types
615
+ // "0;276;0c" into the CLI, and replaying one after a reload would do so
616
+ // again into a later screen. A focus or mouse report is real input the
617
+ // program asked for, but nobody typed it: it goes out once, never
618
+ // persisted. Same predicates as the primary pane (terminal-ui.js onData).
619
+ _onTerminalData(data) {
620
+ const input = global.CodemanTerminalInput;
621
+ if (input?.shouldSuppressTerminalQueryResponse?.(data)) return;
622
+ const app = global.app;
623
+ if (input?.isTerminalFocusOrMouseReport?.(data)) {
624
+ app?._sendInputEphemeral?.(this.sessionId, data);
625
+ return;
626
+ }
627
+ app?._sendInputAsync?.(this.sessionId, data);
628
+ }
629
+
630
+ // The soft-keyboard controller, the twin of the primary pane's wiring in
631
+ // terminal-ui.js initTerminal() (keep the two in step); the behaviour lives
632
+ // once, in terminal-keycode229-recovery.js. Everything it is handed is THIS
633
+ // pane's: its textarea, its xterm's CompositionHelper (whose
634
+ // `_handleAnyTextareaChanges` it patches, per instance) and its send path.
635
+ // Recovered text goes straight to _onTerminalData(), never through xterm's
636
+ // onData, so it is not counted as xterm's own (see connect()'s onData).
637
+ // Created after terminal.open(): xterm's capture `input` listener on the
638
+ // textarea is registered there, and must run before the controller's. No
639
+ // device or mode gate, as in the primary pane: with a hardware keyboard it
640
+ // costs one assignment per keydown. A failure leaves xterm's own handling.
641
+ _createKeyCode229Recovery() {
642
+ this._destroyKeyCode229Recovery();
643
+ if (!this.terminal) return;
644
+ try {
645
+ this._keyCode229Recovery =
646
+ global.CodemanKeyCode229Recovery?.create?.({
647
+ textarea: this.terminal.textarea,
648
+ emitRecovered: (data) => this._onTerminalData(data),
649
+ getCompositionHelper: () => this.terminal?._core?._compositionHelper,
650
+ isScreenReaderMode: () => this.terminal?.options?.screenReaderMode === true,
651
+ }) ?? null;
652
+ } catch {
653
+ this._keyCode229Recovery = null;
654
+ }
655
+ }
656
+
657
+ // Restores xterm's own textarea diff and removes the controller's capture
658
+ // listeners from the live textarea, so it runs before terminal.dispose().
659
+ _destroyKeyCode229Recovery() {
660
+ try {
661
+ this._keyCode229Recovery?.destroy?.();
662
+ } catch {
663
+ /* Optional; teardown must continue. */
664
+ }
665
+ this._keyCode229Recovery = null;
666
+ }
667
+
668
+ // Joins the app's input-socket map for this session and flushes anything
669
+ // already queued for it (typed while the socket was down, or left over from
670
+ // a reload) over the fresh socket. Called from onopen.
671
+ _registerInputSocket() {
672
+ const app = global.app;
673
+ if (!this.ws || !app?._registerInputSocket) return;
674
+ this._unregisterInputSocket();
675
+ this._inputHandle = { ws: this.ws, lastRecvAt: 0 };
676
+ app._registerInputSocket(this.sessionId, this._inputHandle);
677
+ app._onWsReady?.(this.sessionId);
678
+ // The header's connection dot reads tile sockets while the grid is open.
679
+ app._updateConnectionIndicator?.();
680
+ }
681
+
682
+ // Leaves the map; only this pane's own handle is removed (a replacement
683
+ // socket's registration survives a late close of the old one).
684
+ _unregisterInputSocket() {
685
+ if (!this._inputHandle) return;
686
+ global.app?._unregisterInputSocket?.(this.sessionId, this._inputHandle);
687
+ this._inputHandle = null;
688
+ global.app?._updateConnectionIndicator?.();
689
+ }
690
+
691
+ // Settles a marker the pane owes: set when a close lands during a load (the
692
+ // replay would otherwise sit below it) or when a load wipes the terminal on
693
+ // a closed socket. Called from each load's own finally, just before
694
+ // _endBufferLoad() starts any trailing refresh.
695
+ _stampMarkerIfOwed() {
696
+ // A trailing refresh is about to run, and it settles the marker itself:
697
+ // a replay's queued `\x1bc` would wipe one written here (it re-owes the
698
+ // marker on a closed socket and stamps it below the replay), and a refresh
699
+ // that writes nothing stamps the one still owed. Stamped here as well,
700
+ // there would be two marker writes for one close.
701
+ if (this._bufferRefreshPending && !this._destroyed) return;
702
+ const owed = this._markerOwed;
703
+ this._markerOwed = false;
704
+ if (owed && this._wsClosed && !this._destroyed) this._writeDisconnectedMarker();
705
+ }
706
+
707
+ // Extracted so both _onSocketClosed() and a load that ends owing it on a
708
+ // closed socket can write it (see _stampMarkerIfOwed()).
709
+ _writeDisconnectedMarker() {
710
+ this.terminal?.write(`\r\n\x1b[2m${this._markerText}\x1b[0m\r\n`);
711
+ }
712
+
713
+ // Fetches and writes the session's current scrollback. Used both by
714
+ // connect() (initial load) and by the refresh frames (`{t:'r'}`, `{t:'c'}`)
715
+ // and a reconnect (_refreshBuffer). The primary pane's own
716
+ // _onSessionNeedsRefresh (app.js) is scoped to `this.activeSessionId` and
717
+ // rewrites the primary terminal, neither of which applies to this
718
+ // independent pane, so this is a standalone equivalent rather than a call
719
+ // into it, in the primary's order (below).
720
+ //
721
+ // Mirrors the primary pane's own mode check (app.js's selectSession /
722
+ // _onSessionNeedsRefresh): a shell session can retain hundreds of
723
+ // thousands of plain scrollback lines, so pulling `?full=1` there parses
724
+ // an unbounded, server-capped (up to terminalBufferMaxBytes, 32MB) body
725
+ // into this xterm on every load; a shell loads the `tail=` window. A
726
+ // non-shell (TUI) session gets one full replay, or the same bounded window
727
+ // with `boundedLoad` (grid tiles). `fetch` here goes through the global
728
+ // wrapper (constants.js), which already prefixes CodemanBase, unlike the
729
+ // raw WebSocket URL above, which does not.
730
+ //
731
+ // A refresh replaces what the pane shows, in the primary pane's order
732
+ // (_onSessionNeedsRefresh, _resetTerminalForReplay): fetch FIRST, so the
733
+ // pane keeps its last frame through the round trip (and through a grid
734
+ // tile's wait in the load queue); then the queued in-stream `\x1bc`, never
735
+ // xterm's clear(): clear() is synchronous while write() is parsed on a later
736
+ // tick, so live bytes still queued would land after it and fuse into the
737
+ // snapshot, and it keeps the cursor's row, column, SGR and margins, so the
738
+ // capture (raw rows, no home) started wherever the cursor sat. Live frames
739
+ // from the response onward are held (`_liveQueue`, the primary's
740
+ // _finishBufferLoad `since` rule, as _pullHistory() holds them) and only
741
+ // those that arrived after it are written behind the replay. A failed,
742
+ // aborted or empty fetch writes nothing and resets nothing: the pane keeps
743
+ // its last frame and every held frame. The initial load needs none of
744
+ // this: it runs before the pane has a socket, onto a fresh xterm.
745
+ //
746
+ // Single-flight: the flag is held across the fetch AND the chunked write
747
+ // (writeChunked resolves after its last chunk), so two replays can never
748
+ // interleave their chunks into one terminal. A second call while one is
749
+ // in flight is dropped here; _refreshBuffer() is the caller that queues
750
+ // a trailing re-run instead.
751
+ async _loadBuffer({ refresh = false } = {}) {
752
+ if (this._bufferLoading) return;
753
+ this._bufferLoading = true;
754
+ await this._runLoad(refresh ? 'refresh' : 'initial', async () => {
755
+ this._loadRunning = true;
756
+ let replayed = false;
757
+ let capturedAt = 0;
758
+ // A deadline covering the body as well as the headers (the primary
759
+ // pane's budgets, CodemanFetchDeadline): a capture that never answers
760
+ // would otherwise hold this pane's single-flight flag, and in the grid
761
+ // the one load queue every tile waits behind, forever. Re-armed once a
762
+ // refresh's headers land (below), so one signal carries both budgets.
763
+ const controller = global.AbortController ? new global.AbortController() : null;
764
+ let abortTimer = null;
765
+ const armDeadline = (ms) => {
766
+ if (!controller) return;
767
+ clearTimeout(abortTimer);
768
+ abortTimer = setTimeout(() => controller.abort(), ms);
769
+ };
770
+ try {
771
+ if (this._destroyed) return;
772
+ const shell = this.sessionMode === 'shell';
773
+ let query = shell ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1';
774
+ if (this.boundedLoad && !shell) query = `full=1&tail=${TERMINAL_TAIL_SIZE}${this._historyLinesQuery()}`;
775
+ this._loadAbort = controller;
776
+ armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: !shell }) ?? 45000);
777
+ let payload;
778
+ try {
779
+ const res = await fetch(
780
+ `/api/sessions/${this.sessionId}/terminal?${query}`,
781
+ controller ? { signal: controller.signal } : undefined
782
+ );
783
+ if (refresh) {
784
+ // The response's arrival stands in for the instant tmux took the
785
+ // capture (see _pullHistory()). Frames from here on are news the
786
+ // capture cannot hold, so they wait for the replay. From now on
787
+ // live output IS held, so a bounded window's body (at most
788
+ // TERMINAL_TAIL_SIZE) gets the pull's short budget; an unbounded
789
+ // capture (the split's Pane B, up to 32 MB) keeps the request's.
790
+ capturedAt = performance.now();
791
+ this._openLiveQueue();
792
+ if (shell || this.boundedLoad) armDeadline(HISTORY_PULL_TIMEOUT_MS);
793
+ }
794
+ payload = (await res.json())?.data ?? {};
795
+ } finally {
796
+ clearTimeout(abortTimer);
797
+ this._loadAbort = null;
798
+ }
799
+ if (payload.terminalBuffer && this.terminal && !this._destroyed) {
800
+ if (refresh) {
801
+ this.terminal.write('\x1bc');
802
+ this._overflowRows = 0; // the reset leaves nothing above the screen
803
+ replayed = true;
804
+ // The reset wipes a "disconnected" marker (a `{t:'r'}` frame can
805
+ // queue a trailing refresh behind a pull that the socket's close
806
+ // then interrupts), so a refresh on a closed socket owes it back
807
+ // once its replay is written.
808
+ if (this._wsClosed) this._markerOwed = true;
809
+ }
810
+ await writeChunked(
811
+ this.terminal,
812
+ payload.terminalBuffer,
813
+ () => this._destroyed,
814
+ (cancel) => (this._cancelReplay = cancel)
815
+ );
816
+ if (!this._destroyed && this.terminal) this._overflowRows = this._overflowAfterLoad(payload);
817
+ }
818
+ } catch {
819
+ /* Best-effort: live output still arrives once the socket connects. */
820
+ } finally {
821
+ clearTimeout(abortTimer);
822
+ this._loadAbort = null;
823
+ this._loadRunning = false;
824
+ // Before the flush: a refresh that recovered dropped output lets its
825
+ // held frames through.
826
+ if (refresh) this._settleDropRecovery({ replayed, capturedAt, timedOut: !!controller?.signal?.aborted });
827
+ // Held frames before the marker, so the marker stays the last thing on
828
+ // screen (see _pullHistory()).
829
+ this._flushLiveQueue(replayed ? capturedAt : 0);
830
+ this._stampMarkerIfOwed();
831
+ this._endBufferLoad();
832
+ }
833
+ });
834
+ }
835
+
836
+ // Runs a load's work now, or when the owner's queue gives this pane its turn
837
+ // (`scheduleLoad`). The single-flight flag is already set by the caller, so a
838
+ // load waiting in the queue still coalesces refreshes and blocks a second
839
+ // pull; the work itself sets `_loadRunning`. A load the queue drops (this
840
+ // pane was destroyed while it waited) never runs, so its flags are released
841
+ // here.
842
+ async _runLoad(kind, work) {
843
+ let ran = false;
844
+ const run = () => {
845
+ ran = true;
846
+ return work();
847
+ };
848
+ if (!this._scheduleLoad) {
849
+ await run();
850
+ return;
851
+ }
852
+ try {
853
+ await this._scheduleLoad(this, kind, run);
854
+ } catch {
855
+ /* The queue never rejects; a load that failed already settled itself. */
856
+ }
857
+ if (!ran) {
858
+ this._bufferLoading = false;
859
+ this._bufferRefreshPending = false;
860
+ }
861
+ }
862
+
863
+ // Ends a single-flight load (initial, refresh or history pull): clears the
864
+ // flag, then runs the ONE trailing refresh that arrived while it was busy.
865
+ _endBufferLoad() {
866
+ this._bufferLoading = false;
867
+ if (this._bufferRefreshPending && !this._destroyed) {
868
+ this._bufferRefreshPending = false;
869
+ this._refreshBuffer();
870
+ }
871
+ }
872
+
873
+ // Live terminal output. Written straight through, except while a refresh or
874
+ // a history pull is replaying: a capture is current only up to the instant
875
+ // tmux took it, so a frame arriving mid-replay is held with its arrival time
876
+ // and written behind the snapshot by that load's _flushLiveQueue() (the
877
+ // primary pane's _finishBufferLoad `since` rule), never underneath it.
878
+ // Held frames count against the same budget as unparsed ones: a pull or a
879
+ // refresh holds output for up to its body budget, and a flood meanwhile
880
+ // must not grow the queue without bound either.
881
+ _onLiveOutput(data) {
882
+ if (!data) return;
883
+ if (this._liveQueue) {
884
+ if (this._liveQueueBytes + data.length > TerminalTile.LIVE_BACKLOG_BUDGET) {
885
+ this._noteLiveDrop();
886
+ return;
887
+ }
888
+ this._liveQueueBytes += data.length;
889
+ this._liveQueue.push({ at: performance.now(), data });
890
+ return;
891
+ }
892
+ this._writeLive(data);
893
+ }
894
+
895
+ _openLiveQueue() {
896
+ this._liveQueue = [];
897
+ this._liveQueueBytes = 0;
898
+ }
899
+
900
+ // Releases the frames a load held (_liveQueue) and closes the queue. After a
901
+ // replay only those that arrived after the capture are news (`cutoff`, the
902
+ // response's arrival; earlier ones are already in it); with no replay
903
+ // (`cutoff` 0) every one is. Through _writeLive(), so they are counted (and
904
+ // a write that throws cannot skip the load's marker and trailing refresh).
905
+ _flushLiveQueue(cutoff) {
906
+ const queued = this._liveQueue ?? [];
907
+ this._liveQueue = null;
908
+ this._liveQueueBytes = 0;
909
+ for (const entry of queued) {
910
+ if (entry.at < cutoff) continue;
911
+ this._writeLive(entry.data);
912
+ }
913
+ }
914
+
915
+ // Writes one live frame into this xterm, under flow control. The server
916
+ // applies no backpressure (16 KB / 8 ms batches, never a bufferedAmount
917
+ // check), so a flood a tile cannot parse as fast as it arrives (a shell
918
+ // tile running `cat` on a huge log, `yes`) used to pile up in xterm's own
919
+ // write queue without bound, on a main thread six tiles share, until
920
+ // xterm's WriteBuffer throws past 50M code units and the frames were
921
+ // silently lost in onmessage's catch. The primary pane caps its queues
922
+ // and drops then recaptures (_onSessionTerminal, app.js); this is the
923
+ // tile's equivalent. Past TerminalTile.LIVE_BACKLOG_BUDGET unparsed, a
924
+ // frame is dropped and the tile stops writing until a refresh recaptures
925
+ // the screen (_scheduleDropRecovery): every byte after a hole is written
926
+ // onto a screen out of step with the PTY, which that refresh replaces
927
+ // anyway. A write that throws is the same drop, never a malformed frame.
928
+ _writeLive(data) {
929
+ const terminal = this.terminal;
930
+ if (!terminal || this._destroyed || !data) return;
931
+ if (this._liveDropped) {
932
+ this._noteLiveDrop();
933
+ return;
934
+ }
935
+ const n = data.length;
936
+ if (this._liveInFlight + n > TerminalTile.LIVE_BACKLOG_BUDGET) {
937
+ this._noteLiveDrop();
938
+ return;
939
+ }
940
+ const epoch = this._liveEpoch;
941
+ this._liveInFlight += n;
942
+ try {
943
+ terminal.write(data, () => {
944
+ if (epoch === this._liveEpoch) this._liveInFlight -= n;
945
+ });
946
+ } catch {
947
+ if (epoch === this._liveEpoch) this._liveInFlight -= n;
948
+ this._noteLiveDrop();
949
+ }
950
+ }
951
+
952
+ // A live frame was dropped. Marks the tile out of step and arms ONE
953
+ // recovery; later drops only move the stamp the recovery has to beat.
954
+ _noteLiveDrop() {
955
+ this._liveDropAt = performance.now();
956
+ if (this._liveDropped) return;
957
+ this._liveDropped = true;
958
+ this._scheduleDropRecovery();
959
+ }
960
+
961
+ // The primary pane's dropped-output recovery (_scheduleDroppedOutputRecovery,
962
+ // app.js), aimed at this tile: debounced by DROP_RECOVERY_DELAY_MS so a
963
+ // sustained flood collapses into one attempt, and run as an ordinary
964
+ // refresh, which is single-flight, bounded (`lines=`/`tail=`) and waits its
965
+ // turn in the grid's load queue. _settleDropRecovery() decides what the
966
+ // refresh it starts achieved.
967
+ _scheduleDropRecovery() {
968
+ if (this._dropRecoveryTimer || this._destroyed) return;
969
+ const delay = global.CodemanDroppedOutput?.DROP_RECOVERY_DELAY_MS ?? 2000;
970
+ this._dropRecoveryTimer = setTimeout(() => {
971
+ this._dropRecoveryTimer = null;
972
+ if (this._destroyed || !this._liveDropped) return;
973
+ this._dropRecoveryAttempt++;
974
+ this._refreshBuffer();
975
+ }, delay);
976
+ }
977
+
978
+ // A refresh finished while output was marked dropped. Recovered when its
979
+ // replay's capture was taken after the last dropped frame (the response's
980
+ // arrival, the cutoff every load uses): output flows again. Otherwise one
981
+ // more attempt, bounded by the primary pane's rule
982
+ // (shouldRetryDroppedOutputRecovery: DROP_RECOVERY_MAX_ATTEMPTS, and never
983
+ // after a capture cut off at its deadline, a stalled link); past that the
984
+ // flag is released so the tile is never left frozen, and it writes on, out
985
+ // of step, as every tile did before this existed.
986
+ _settleDropRecovery({ replayed, capturedAt, timedOut }) {
987
+ if (!this._liveDropped || this._destroyed) return;
988
+ if (replayed && capturedAt >= this._liveDropAt) {
989
+ this._liveDropped = false;
990
+ this._dropRecoveryAttempt = 0;
991
+ return;
992
+ }
993
+ // Another try is already on its way: the debounce, or a trailing refresh.
994
+ if (this._dropRecoveryTimer || this._bufferRefreshPending) return;
995
+ const retry =
996
+ global.CodemanDroppedOutput?.shouldRetryDroppedOutputRecovery?.({
997
+ repainted: false,
998
+ timedOut,
999
+ attempt: Math.max(0, this._dropRecoveryAttempt - 1),
1000
+ stillActive: true,
1001
+ }) === true;
1002
+ if (retry) {
1003
+ this._scheduleDropRecovery();
1004
+ return;
1005
+ }
1006
+ this._liveDropped = false;
1007
+ this._dropRecoveryAttempt = 0;
1008
+ }
1009
+
1010
+ // Starts live-output accounting over (a reconnect, destroy): write callbacks
1011
+ // still pending from before carry the old epoch and count nothing.
1012
+ _resetLiveFlow() {
1013
+ this._liveEpoch++;
1014
+ this._liveInFlight = 0;
1015
+ this._liveDropped = false;
1016
+ this._dropRecoveryAttempt = 0;
1017
+ clearTimeout(this._dropRecoveryTimer);
1018
+ this._dropRecoveryTimer = null;
1019
+ }
1020
+
1021
+ // The server's `{t:'c'}` frame, which is a refresh, not a wipe. Its one
1022
+ // emitter (Session.startInteractive, session.ts) sends it once a fresh Claude
1023
+ // pane first shows its prompt: the server has just trimmed its own buffer and
1024
+ // means "refresh after startup". The primary pane refetches the capture and
1025
+ // replays it (_onSessionClearTerminal, app.js), and while the grid is open
1026
+ // that handler stands aside for the tiles. A bare xterm clear() here kept
1027
+ // only the cursor's row and dropped the banner and every row above it, and an
1028
+ // idle Claude never repaints static rows, so a Claude session Run into the
1029
+ // grid (or Attached in a tile) sat there as a near-empty tile. So it takes
1030
+ // the `{t:'r'}` route: single-flight, coalesced into one trailing refresh
1031
+ // behind a load already running (a pull's held frames included), and paced
1032
+ // by the grid's load queue.
1033
+ _onLiveClear() {
1034
+ this._refreshBuffer();
1035
+ }
1036
+
1037
+ // Capture phase, because xterm's own wheel handler stopPropagation()s every
1038
+ // event it consumes, so a bubbling listener here would never see the wheel
1039
+ // while the pane still has scrollback to scroll. Not passive: the three
1040
+ // routes this pane takes over, forwarding the wheel to Claude's fullscreen
1041
+ // renderer (_maybeForwardWheelToCli), paging a hollow buffer's CLI
1042
+ // transcript (_maybePageCliTranscript) and Shift+wheel's local scrollback
1043
+ // (_maybeScrollLocalOnShift), are consumed right here (preventDefault plus
1044
+ // stopPropagation in the capture phase, the primary pane's technique), so
1045
+ // xterm's viewport, a descendant, never sees them. Every other wheel is left
1046
+ // to xterm, which keeps doing the scrolling, and only observed for the
1047
+ // shell history pull.
1048
+ _installWheelListener() {
1049
+ this._onWheel = (ev) => {
1050
+ if (this._maybeForwardWheelToCli(ev) || this._maybePageCliTranscript(ev) || this._maybeScrollLocalOnShift(ev)) {
1051
+ ev.preventDefault();
1052
+ ev.stopPropagation();
1053
+ return;
1054
+ }
1055
+ if (ev.deltaY < 0) this._maybeLoadMoreHistory();
1056
+ };
1057
+ this.mountEl.addEventListener('wheel', this._onWheel, { capture: true, passive: false });
1058
+ }
1059
+
1060
+ // SGR wheel forwarding, the twin of the primary pane's capture-phase wheel
1061
+ // handler and _forwardScrollToApp (terminal-ui.js; keep them in step).
1062
+ // Claude's fullscreen renderer (claude 2.1.187+ while its mouse tracking is
1063
+ // on, cliMouseTracking) scrolls its own transcript on SGR wheel reports,
1064
+ // while this xterm holds only Codeman's replayed repaint frames (tmux keeps
1065
+ // no history for such a pane). Left to xterm, the wheel dragged those stale
1066
+ // frames, Claude's pinned input box with them, up the tile, or scrolled
1067
+ // nothing at all. The gate is the primary pane's own, asked for THIS pane
1068
+ // (its terminal, its session, never the active one), so the CLI rules stay
1069
+ // in terminal-ui.js and this file names no CLI; the reports go to this
1070
+ // pane's session through its own coalescer. Returns true when the wheel
1071
+ // belongs to the CLI: a gesture with no whole line or no measurable cell is
1072
+ // consumed too, as in the primary pane, so xterm never scrolls the stale
1073
+ // frames under a forwarding session. Shift fails the gate, so Shift+wheel
1074
+ // still scrolls the local scrollback (_maybeScrollLocalOnShift).
1075
+ _maybeForwardWheelToCli(ev) {
1076
+ if (this._destroyed || !this.terminal || !ev) return false;
1077
+ const app = global.app;
1078
+ const input = global.CodemanTerminalInput;
1079
+ if (!app?._shouldForwardWheelToApp || !input?.sgrWheelReports || !input.wheelDeltaWholeLines) return false;
1080
+ // xterm's own encoder forwards the wheel while the CLI's tracking reaches
1081
+ // it, and its alt-scroll owns the alternate buffer, as in the primary pane.
1082
+ const tracking = this.terminal.modes?.mouseTrackingMode;
1083
+ if (tracking && tracking !== 'none') return false;
1084
+ if (this.terminal.buffer?.active?.type === 'alternate') return false;
1085
+ if (!app._shouldForwardWheelToApp(ev, { terminal: this.terminal, sessionId: this.sessionId })) return false;
1086
+ // SGR coordinates address the live screen, so a report from a scrolled-up
1087
+ // viewport would hit-test another row: snap home first (_forwardScrollToApp).
1088
+ if (!app._terminalViewportAtBottom?.(this.terminal)) this.terminal.scrollToBottom?.();
1089
+ const lines = input.wheelDeltaWholeLines(ev, this.terminal.rows);
1090
+ const pos = app._clientPointToCell?.(ev.clientX, ev.clientY, this.terminal);
1091
+ const bytes = input.sgrWheelReports(lines, pos);
1092
+ if (bytes) this._queueScrollBytes(bytes);
1093
+ return true;
1094
+ }
1095
+
1096
+ // Shift+wheel scrolls this xterm's local scrollback, the explicit "local
1097
+ // history" gesture, here as in the primary pane (whose capture-phase wheel
1098
+ // handler scrolls with terminal.scrollLines() for the same reason). Left to
1099
+ // xterm it was dead off macOS: Chrome on Windows sends Shift+wheel as a
1100
+ // HORIZONTAL wheel (deltaX), and xterm's own scroller turns a Shift+vertical
1101
+ // wheel into a horizontal one, so the viewport never moved. Reads the
1102
+ // dominant axis under Shift (wheelDeltaLines), keeps the sub-line remainder
1103
+ // for the next event (a trackpad's small deltas), and on the way up still
1104
+ // asks a shell pane for more history. Returns true when the wheel was
1105
+ // consumed here.
1106
+ _maybeScrollLocalOnShift(ev) {
1107
+ if (this._destroyed || !this.terminal || !ev?.shiftKey) return false;
1108
+ const input = global.CodemanTerminalInput;
1109
+ if (!input?.wheelDeltaLines) return false;
1110
+ // xterm's own encoder forwards the wheel while the CLI's tracking reaches
1111
+ // it, and its alt-scroll owns the alternate buffer, as in the primary pane.
1112
+ const tracking = this.terminal.modes?.mouseTrackingMode;
1113
+ if (tracking && tracking !== 'none') return false;
1114
+ if (this.terminal.buffer?.active?.type === 'alternate') return false;
1115
+ const total = this._shiftScrollPending + input.wheelDeltaLines(ev, this.terminal.rows);
1116
+ const lines = Math.trunc(total);
1117
+ this._shiftScrollPending = total - lines;
1118
+ if (lines) {
1119
+ this.terminal.scrollLines(lines);
1120
+ if (lines < 0) this._maybeLoadMoreHistory();
1121
+ }
1122
+ return true;
1123
+ }
1124
+
1125
+ // A plain left-click reported to the CLI, the primary pane's desktop click
1126
+ // (terminal-ui.js _handleDesktopTerminalClick) aimed at this pane. The
1127
+ // server strips the mouse DECSETs of some modes (opencode's since #555, so a
1128
+ // drag selects text), which leaves this xterm's own mouse encoder idle for
1129
+ // them; without this a click in such a pane never reached the CLI. Only
1130
+ // while this pane's session has tracking on (cliMouseTracking), through the
1131
+ // same skips as the primary pane. Bubble phase, as there. The target is
1132
+ // built per click, so the terminal and the link hover are read live.
1133
+ _installClickListener() {
1134
+ this._onClick = (ev) => {
1135
+ if (this._destroyed || !this.terminal) return;
1136
+ global.app?._handleDesktopTerminalClick?.(ev, {
1137
+ terminal: this.terminal,
1138
+ sessionId: this.sessionId,
1139
+ linkHovered: this._linkHovered,
1140
+ // Like every mouse report from this pane (_onTerminalData): once,
1141
+ // never persisted, so a reload cannot replay it onto a later screen.
1142
+ ephemeral: true,
1143
+ });
1144
+ };
1145
+ this.mountEl.addEventListener('click', this._onClick);
1146
+ }
1147
+
1148
+ // Hollow-buffer paging, the twin of the primary pane's
1149
+ // _maybePageCliTranscript (terminal-ui.js; keep the two in step). A CLI that
1150
+ // draws in place (opencode, on the alternate screen; Claude's repaint mode)
1151
+ // leaves this xterm no scrollback, so a wheel scrolled nothing; instead the
1152
+ // travel pages the CLI's own transcript with PageUp/PageDown. Every gate is
1153
+ // the primary pane's own, asked for THIS pane (its terminal, its session,
1154
+ // never the active one), so the CLI rules stay in terminal-ui.js and this
1155
+ // file names no CLI. Returns true when the wheel was consumed here.
1156
+ _maybePageCliTranscript(ev) {
1157
+ if (this._destroyed || !this.terminal || !ev || ev.shiftKey) return false;
1158
+ const app = global.app;
1159
+ const input = global.CodemanTerminalInput;
1160
+ if (!app || !input?.pageKeysForTravel || !input.wheelDeltaLines) return false;
1161
+ // xterm's own encoder forwards the wheel while the CLI's tracking reaches
1162
+ // it (a shell running htop), as in the primary pane.
1163
+ const tracking = this.terminal.modes?.mouseTrackingMode;
1164
+ if (tracking && tracking !== 'none') return false;
1165
+ const target = { terminal: this.terminal, sessionId: this.sessionId };
1166
+ // A wheel for Claude's fullscreen renderer was forwarded as SGR reports
1167
+ // before this ran (_maybeForwardWheelToCli); the gate is repeated so a
1168
+ // forwarding session is never paged.
1169
+ if (app._shouldForwardWheelToApp?.(ev, target)) return false;
1170
+ if (!app._localScrollbackIsHollow?.({ ...target, localRows: this._localRows() })) return false;
1171
+ // Only from the live screen. The one gate the primary pane never needs: a
1172
+ // primary hollow buffer has baseY 0, so its viewport is always at the
1173
+ // bottom, while a tile's is hollow with its own overflow rows still above
1174
+ // the screen, and Shift+PageUp, a scrollbar drag or a wheel during the
1175
+ // first replay can leave the viewport up there. Paging from there would
1176
+ // swallow every wheel (wheel-down included) and keep the stale rows on
1177
+ // screen while the CLI pages out of view; left to xterm, a wheel-down
1178
+ // brings the viewport home and paging resumes from there. The click
1179
+ // report refuses an off-bottom viewport for the same reason
1180
+ // (_terminalViewportAtBottom).
1181
+ if (!app._terminalViewportAtBottom?.(this.terminal)) return false;
1182
+ const lines = input.wheelDeltaLines(ev, this.terminal.rows);
1183
+ if (!lines) return false;
1184
+ const step = input.pageKeysForTravel(this._pageKeyPending, lines, this.terminal.rows);
1185
+ this._pageKeyPending = step.pending;
1186
+ if (step.keys) this._queueScrollBytes(step.keys);
1187
+ return true;
1188
+ }
1189
+
1190
+ // Coalesces the scroll bytes (SGR wheel reports and page keys alike) into
1191
+ // one send per 40 ms, bounded at 512 bytes so a fling cannot build a backlog
1192
+ // that keeps scrolling after it stops. A narrow
1193
+ // twin of the primary pane's _queueScrollBytes / _flushWheelSgrQueue
1194
+ // (terminal-ui.js; keep the two in step), which flushes to the active
1195
+ // session only. Sent ephemeral (no seq, never persisted) to THIS pane's
1196
+ // session, over this pane's socket while it is open.
1197
+ _queueScrollBytes(data) {
1198
+ if (!data || this._destroyed) return;
1199
+ if (this._scrollBytes.length > 512) return;
1200
+ this._scrollBytes += data;
1201
+ if (this._scrollFlushTimer) return;
1202
+ this._scrollFlushTimer = setTimeout(() => {
1203
+ this._scrollFlushTimer = null;
1204
+ const bytes = this._scrollBytes;
1205
+ this._scrollBytes = '';
1206
+ if (bytes && !this._destroyed) global.app?._sendInputEphemeral?.(this.sessionId, bytes);
1207
+ }, 40);
1208
+ }
1209
+
1210
+ // History rows in this xterm, for the paging gate: baseY less the rows this
1211
+ // pane pushed up itself. Clamped, because a clear (Ctrl+L) or an ED3/RIS in
1212
+ // the stream drops rows behind this count's back.
1213
+ _localRows() {
1214
+ const baseY = this.terminal?.buffer?.active?.baseY || 0;
1215
+ this._overflowRows = Math.min(this._overflowRows, baseY);
1216
+ return baseY - this._overflowRows;
1217
+ }
1218
+
1219
+ // After a local resize: rows a shrinking fit pushed above the screen count
1220
+ // as overflow, and rows a growing one pulled back come off it. `before` is
1221
+ // baseY just before the resize (xterm resizes synchronously).
1222
+ _noteResizeRows(before) {
1223
+ const after = this.terminal?.buffer?.active?.baseY || 0;
1224
+ this._overflowRows = Math.max(0, Math.min(after, this._overflowRows + (after - before)));
1225
+ }
1226
+
1227
+ // The overflow a finished load leaves: everything above the screen when the
1228
+ // capture held a single screen (the server says how tall in `captureRows`;
1229
+ // the full-history path keeps every pane row and trims one newline), none
1230
+ // when it carried history. The counterpart of the primary pane resizing the
1231
+ // PTY before it captures (app.js selectSession's sendResize), which this
1232
+ // pane does not do: its first capture is taken at the PTY's previous size
1233
+ // (usually the primary pane's, taller) and written into a shorter xterm,
1234
+ // whose extra rows land above the screen with nothing after them to clear
1235
+ // them. Without a `captureRows` the raw baseY stands, as in the primary.
1236
+ _overflowAfterLoad(payload) {
1237
+ const captureRows = payload?.captureRows;
1238
+ if (!Number.isFinite(captureRows)) return 0;
1239
+ const text = payload.terminalBuffer || '';
1240
+ let lines = 1;
1241
+ for (let i = text.indexOf('\n'); i !== -1 && lines <= captureRows; i = text.indexOf('\n', i + 1)) lines++;
1242
+ if (lines > captureRows) return 0;
1243
+ return this.terminal?.buffer?.active?.baseY || 0;
1244
+ }
1245
+
1246
+ // Wheel-up at the top of a SHELL pane's scrollback. tmux repaints a burst of
1247
+ // output (`cat` of a file longer than the screen) instead of scrolling it,
1248
+ // so this pane's xterm ends up with about one screen of scrollback while
1249
+ // tmux holds every line, and without this pull the history is
1250
+ // unreachable. The primary pane has the same pull
1251
+ // (app.js _maybeRefetchFullHistory); a tile is a separate xterm and needs
1252
+ // its own. Shell only: a non-shell CLI's history is out of scope for this
1253
+ // pull (its load already takes `full=1`; codex and Claude's inline renderer
1254
+ // do grow tmux history, this just isn't how they recover it). The
1255
+ // alternate-screen skip (nano, vim, less) only matters for a direct-PTY
1256
+ // shell: under tmux the browser xterm never enters the alternate buffer.
1257
+ _maybeLoadMoreHistory() {
1258
+ if (this.sessionMode !== 'shell' || this._destroyed || !this.terminal) return;
1259
+ if (this._bufferLoading) return;
1260
+ // Mirrors app.js _maybeRefetchFullHistory and this pane's own
1261
+ // _sendResize(): a detached session's own window already owns its PTY
1262
+ // size and scrollback, so this pane has nothing of its own to reconcile.
1263
+ if (this.detachedSessions?.has(this.sessionId)) return;
1264
+ const active = this.terminal.buffer.active;
1265
+ if (active.type !== 'normal' || active.viewportY !== 0) return;
1266
+ // Momentum scrolling fires this dozens of times per flick, so cooldown
1267
+ // rather than latch; a pull that could only have downgraded the pane
1268
+ // waits far longer.
1269
+ const cooldown = this._historyPullUseless ? 60000 : 4000;
1270
+ const now = Date.now();
1271
+ if (now - this._historyPullAt < cooldown) return;
1272
+ this._historyPullAt = now;
1273
+ this._bufferLoading = true;
1274
+ void this._runLoad('history', () => this._pullHistory());
1275
+ }
1276
+
1277
+ // Pulls a BOUNDED window of tmux's full history (the same TERMINAL_TAIL_SIZE
1278
+ // a tab switch loads, so a multi-megabyte capture never lands on xterm's
1279
+ // main thread) and replays it under the reader's current place. Holds the
1280
+ // single-flight flag across the fetch AND the replay, like _loadBuffer().
1281
+ async _pullHistory() {
1282
+ this._bufferLoading = true;
1283
+ if (this._destroyed) {
1284
+ this._endBufferLoad();
1285
+ return;
1286
+ }
1287
+ this._loadRunning = true;
1288
+ let replayed = false;
1289
+ let capturedAt = 0;
1290
+ // Two budgets on one signal. The request itself gets the primary pane's
1291
+ // (CodemanFetchDeadline, constants.js): live output is not held while it
1292
+ // runs, but the single-flight flag is, so a coalesced `{t:'r'}` refresh and
1293
+ // the marker owed by a close (_onSocketClosed()) both wait for it, at worst
1294
+ // for that whole budget. Once the headers land live output IS held, so the
1295
+ // body read gets the short one instead: a body that hangs would otherwise
1296
+ // freeze the pane for the long budget. Aborting lands in the catch below,
1297
+ // which releases the flag and the queue. AbortSignal.timeout() alone cannot
1298
+ // be re-armed, hence the controller; without AbortController the pull
1299
+ // simply has no deadline.
1300
+ const controller = global.AbortController ? new global.AbortController() : null;
1301
+ this._loadAbort = controller;
1302
+ let abortTimer = null;
1303
+ const armDeadline = (ms) => {
1304
+ if (!controller) return;
1305
+ clearTimeout(abortTimer);
1306
+ abortTimer = setTimeout(() => controller.abort(), ms);
1307
+ };
1308
+ try {
1309
+ armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: true }) ?? HISTORY_PULL_TIMEOUT_MS);
1310
+ const res = await fetch(
1311
+ `/api/sessions/${this.sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}${this._historyLinesQuery()}`,
1312
+ { signal: controller?.signal }
1313
+ );
1314
+ armDeadline(HISTORY_PULL_TIMEOUT_MS);
1315
+ // The cutoff below is the response's arrival, the same `since` rule the
1316
+ // primary pane uses (_finishBufferLoad). It is a client clock standing in
1317
+ // for the instant tmux took the capture, which lies somewhere in the
1318
+ // round trip, so a frame in that window can be lost or doubled. Bounded
1319
+ // by one round trip and not closable without a server-side capture time.
1320
+ capturedAt = performance.now();
1321
+ // Opened only now: a frame from before the response is either replaced by
1322
+ // the capture or written unchanged, so holding it for the round trip
1323
+ // would buy nothing and freeze the pane for as long as the fetch took.
1324
+ this._openLiveQueue();
1325
+ const payload = (await res.json())?.data;
1326
+ clearTimeout(abortTimer);
1327
+ const buffer = payload?.terminalBuffer;
1328
+ const term = this.terminal;
1329
+ if (!buffer || !term || this._destroyed) return;
1330
+ const rowsBefore = term.buffer.active.length;
1331
+ const rowsIncoming = global.app?._estimateReplayRows?.(buffer, term.cols) ?? buffer.split('\n').length;
1332
+ // xterm keeps at most `scrollback + rows` rows while tmux keeps far more
1333
+ // lines, so a window of short lines can carry more rows than this pane
1334
+ // can ever hold, and `rowsIncoming <= rowsBefore` would never come true.
1335
+ const scrollbackCap = term.options?.scrollback || 0;
1336
+ const paneFull = scrollbackCap > 0 && rowsBefore >= scrollbackCap + term.rows;
1337
+ // Nothing to gain (this also covers a downgrade, which would delete
1338
+ // history mid-scroll), and a reset+rewrite would jump the viewport. An
1339
+ // untruncated window IS all of tmux's history and the next burst can add
1340
+ // more, so keep the 4 s cooldown. A truncated window can never reach past
1341
+ // what the pane shows, and every ask costs the server a capture-pane of
1342
+ // the whole history (`tail` is cut after it): back off to 60 s, as the
1343
+ // primary pane does (app.js _maybeRefetchFullHistory). A full pane backs
1344
+ // off too, since no window can ever fit in it.
1345
+ if (rowsIncoming <= rowsBefore || paneFull) {
1346
+ if (payload.truncated || paneFull) this._historyPullUseless = true;
1347
+ return;
1348
+ }
1349
+ this._historyPullUseless = false;
1350
+ term.write('\x1bc');
1351
+ this._overflowRows = 0; // the reset leaves nothing above the screen
1352
+ replayed = true;
1353
+ if (this._wsClosed) this._markerOwed = true;
1354
+ await writeChunked(
1355
+ term,
1356
+ buffer,
1357
+ () => this._destroyed,
1358
+ (cancel) => (this._cancelReplay = cancel)
1359
+ );
1360
+ if (this._destroyed || !this.terminal) return;
1361
+ // xterm parses asynchronously: an empty write's callback fires only
1362
+ // after everything before it, so the row count below is the settled one.
1363
+ await new Promise((resolve) => this.terminal.write('', resolve));
1364
+ if (this._destroyed || !this.terminal) return;
1365
+ // The replay grew the buffer UPWARD, so what was row 0 is now `delta`
1366
+ // rows down; land there and the recovered history sits above it.
1367
+ const delta = this.terminal.buffer.active.length - rowsBefore;
1368
+ if (delta > 0) this.terminal.scrollToLine(delta);
1369
+ else this.terminal.scrollToTop();
1370
+ } catch {
1371
+ /* Best-effort: live output keeps arriving whatever happens here. */
1372
+ } finally {
1373
+ clearTimeout(abortTimer);
1374
+ this._loadAbort = null;
1375
+ this._loadRunning = false;
1376
+ // After a replay, only frames that arrived after the capture are news;
1377
+ // earlier ones are already in it. With no replay, every held frame is.
1378
+ this._flushLiveQueue(replayed ? capturedAt : 0);
1379
+ // Settled after the queue flush so the marker is the last thing on
1380
+ // screen: a close during the pull wrote nothing (_onSocketClosed() defers
1381
+ // it while a load runs), and a replay's own `\x1bc` (flagged above) wipes
1382
+ // one written before it, which would paint a fresh, current-looking
1383
+ // history while onData keeps silently dropping every keystroke on the
1384
+ // dead socket. With a trailing refresh pending (_endBufferLoad) the marker
1385
+ // is left to that refresh, which writes it below its own replay.
1386
+ this._stampMarkerIfOwed();
1387
+ this._endBufferLoad();
1388
+ }
1389
+ }
1390
+
1391
+ // A bounded load's `lines=` (grid tiles): tmux history beyond what this
1392
+ // xterm keeps (its scrollback plus the screen) would only be captured to be
1393
+ // thrown away, and a full capture is synchronous work on the server, about
1394
+ // 0.7 s for a 30k-line history. Unbounded panes (the split's Pane B) ask
1395
+ // for everything, as before.
1396
+ _historyLinesQuery() {
1397
+ if (!this.boundedLoad || !Number.isFinite(this.scrollback)) return '';
1398
+ return `&lines=${this.scrollback + (this.terminal?.rows || 0)}`;
1399
+ }
1400
+
1401
+ // The refresh path (`{t:'r'}`, `{t:'c'}`, a reconnect): fetch, then reset
1402
+ // in-stream and replay (_loadBuffer). Two refresh frames in a row must not
1403
+ // start two concurrent replays, each resetting the terminal under the
1404
+ // other's chunked write. A refresh that arrives mid-replay is COALESCED
1405
+ // into one trailing re-run rather than ignored: the in-flight fetch may
1406
+ // predate the drop the new frame is reporting, and no further frame is
1407
+ // coming to correct stale content.
1408
+ _refreshBuffer() {
1409
+ if (this._bufferLoading) {
1410
+ this._bufferRefreshPending = true;
1411
+ return;
1412
+ }
1413
+ void this._loadBuffer({ refresh: true });
1414
+ }
1415
+
1416
+ // Local reflow only, no PTY resize frame. Split out so a divider drag
1417
+ // can reflow the panes at the browser's paint rate (rAF) while sending
1418
+ // the actual `{t:'z'}` resize once, at drag end, matching the primary
1419
+ // pane's own convention (throttledResize in terminal-ui.js).
1420
+ localFit() {
1421
+ if (!this.fitAddon) return;
1422
+ const before = this.terminal?.buffer?.active?.baseY || 0;
1423
+ this.fitAddon.fit();
1424
+ this._noteResizeRows(before);
1425
+ }
1426
+
1427
+ // Reflow to the container and tell the PTY, as one step: the xterm and the
1428
+ // PTY must never disagree about size (#464), and a font change is a size
1429
+ // change too, so the font setters call this rather than localFit().
1430
+ // `force` resends an unchanged size and asks the server to apply it anyway
1431
+ // (Redraw, restoreTerminalSize). Returns whether a resize went out.
1432
+ fit({ force = false } = {}) {
1433
+ this.localFit();
1434
+ return this._sendResize({ force });
1435
+ }
1436
+
1437
+ // Returns true only once a `{t:'z'}` frame was sent, false at every early
1438
+ // exit, so Redraw can say when nothing reached the PTY.
1439
+ _sendResize({ force = false } = {}) {
1440
+ if (!this._wsReady || !this.fitAddon || !this.terminal) return false;
1441
+ // One PTY cannot hold two sizes (mirrors sendResize's own
1442
+ // detachedElsewhere yield in terminal-ui.js): the session got detached
1443
+ // to its own window AFTER this pane was opened, so its own window now
1444
+ // owns the PTY's size and this pane must stand aside.
1445
+ if (this.detachedSessions?.has(this.sessionId)) return false;
1446
+ // A hidden pane (a web tab over it, a zoomed neighbour) measures NaN, and
1447
+ // fit() then leaves the xterm alone: there is no size worth reporting.
1448
+ const dims = this.fitAddon.proposeDimensions();
1449
+ if (!dims || !Number.isFinite(dims.cols) || !Number.isFinite(dims.rows)) return false;
1450
+ // Report what the xterm actually holds, so the PTY gets exactly the size
1451
+ // the pane renders at. Unclamped, unlike the primary pane's 40x10 floor:
1452
+ // a floor would misreport the split's Pane B at its divider's reachable
1453
+ // 20% position (about 28 columns) and wrap output wrongly, and a floored
1454
+ // xterm would be wider than its container. The server enforces
1455
+ // its own valid range ([1,500]/[1,200] in ws-routes.ts).
1456
+ const cols = this.terminal.cols;
1457
+ const rows = this.terminal.rows;
1458
+ const last = this._lastSentDims;
1459
+ if (!force && last && last.cols === cols && last.rows === rows) return false;
1460
+ // `f` is the primary pane's forced resize (sendResize, terminal-ui.js):
1461
+ // Session.resize (session.ts) otherwise skips a size equal to the one it
1462
+ // last applied, so without it a forced resend reached the server and did
1463
+ // nothing there (no tmux resize-window, no PTY resize).
1464
+ const msg = { t: 'z', c: cols, r: rows, v: 'desktop' };
1465
+ if (force) msg.f = true;
1466
+ try {
1467
+ this.ws.send(JSON.stringify(msg));
1468
+ } catch {
1469
+ return false; // nothing went out, so nothing is recorded as sent
1470
+ }
1471
+ this._lastSentDims = { cols, rows };
1472
+ return true;
1473
+ }
1474
+
1475
+ // The session's PTY is new: a tile can connect before its session has a
1476
+ // pane (one Run started while the grid is open joins first), and the
1477
+ // server drops a resize that arrives with no PTY, then spawns at its own
1478
+ // default size. Forget what was sent, so the size goes out now, or with
1479
+ // the next fit() when this tile is hidden right now (a zoomed neighbour):
1480
+ // _sendResize() returns before recording anything it did not send.
1481
+ paneStarted() {
1482
+ this._lastSentDims = null;
1483
+ this._sendResize();
1484
+ }
1485
+
1486
+ // The geometry the PTY actually holds (`{t:'zc'}`, the server's answer to
1487
+ // every resize). A PTY and a terminal that disagree on WIDTH render
1488
+ // garbled, so a different column count is adopted; rows stay local, as in
1489
+ // the primary pane (_onPtyGeometryReport in terminal-ui.js, #464). The
1490
+ // pure verdict is the primary's too (reconcilePtyGeometry, constants.js).
1491
+ _onPtyGeometryReport(cols, rows) {
1492
+ const terminal = this.terminal;
1493
+ if (!terminal) return;
1494
+ const verdict = global.CodemanTerminalGeometry?.reconcilePtyGeometry?.(
1495
+ { cols: terminal.cols, rows: terminal.rows },
1496
+ { cols, rows }
1497
+ );
1498
+ if (!verdict?.adopt) return;
1499
+ const before = terminal.buffer?.active?.baseY || 0;
1500
+ terminal.resize(verdict.cols, terminal.rows);
1501
+ this._noteResizeRows(before); // a column change reflows rows above the screen
1502
+ this._lastSentDims = { cols: verdict.cols, rows: terminal.rows };
1503
+ }
1504
+
1505
+ destroy() {
1506
+ this._destroyed = true;
1507
+ // Anything still queued for this session stays in the app's queue and is
1508
+ // delivered over HTTP by the redelivery sweep, so closing the pane mid-
1509
+ // keystroke loses nothing.
1510
+ clearTimeout(this._reconnectTimer);
1511
+ this._reconnectTimer = null;
1512
+ // A load still fetching would otherwise hold the owner's queue (and the
1513
+ // server's attention) for a pane nobody can see any more.
1514
+ try {
1515
+ this._loadAbort?.abort();
1516
+ } catch {
1517
+ /* Already settled. */
1518
+ }
1519
+ this._loadAbort = null;
1520
+ // Likewise a replay still parsing: the xterm is disposed below, so the
1521
+ // write callback it waits for would never come.
1522
+ this._cancelReplay?.();
1523
+ this._cancelReplay = null;
1524
+ if (this._onWheel) {
1525
+ this.mountEl?.removeEventListener('wheel', this._onWheel, { capture: true });
1526
+ this._onWheel = null;
1527
+ }
1528
+ if (this._onClick) {
1529
+ this.mountEl?.removeEventListener('click', this._onClick);
1530
+ this._onClick = null;
1531
+ }
1532
+ // A disposed xterm never runs its write callbacks, and a pending
1533
+ // recovery would refresh a pane nobody can see.
1534
+ this._resetLiveFlow();
1535
+ // Page keys still waiting for their flush go nowhere: the pane is gone.
1536
+ clearTimeout(this._scrollFlushTimer);
1537
+ this._scrollFlushTimer = null;
1538
+ this._scrollBytes = '';
1539
+ this._pageKeyPending = 0;
1540
+ this._detachSocket();
1541
+ if (this._onFocusIn) {
1542
+ this.terminal?.textarea?.removeEventListener('focus', this._onFocusIn);
1543
+ this._onFocusIn = null;
1544
+ }
1545
+ // Before dispose(): puts xterm's own textarea diff back and takes the
1546
+ // controller's listeners off the textarea; its pending timers are inert
1547
+ // once it is destroyed.
1548
+ this._destroyKeyCode229Recovery();
1549
+ // A destroyed pane cannot hold the keyboard: shortcuts fall back to the
1550
+ // primary terminal (_focusedPane also skips a destroyed tile on its own).
1551
+ if (global.app?._focusedTile === this) global.app._noteFocusedTile?.(null);
1552
+ if (this.terminal) {
1553
+ this.terminal.dispose();
1554
+ this.terminal = null;
1555
+ }
1556
+ this.fitAddon = null;
1557
+ }
1558
+ }
1559
+
1560
+ // Code units of live output a pane lets sit unparsed in its xterm (or held
1561
+ // behind a replay) before it drops a frame and recaptures (_writeLive). Not
1562
+ // the primary pane's 128 KB: that caps its own rAF-paced queues, about two
1563
+ // frames of them, while here xterm itself is the pacer, a burst normally
1564
+ // parses within a frame or two, and a tight cap would trip on ordinary
1565
+ // bursts and blank-and-reload the tile over and over. A few MB keeps a flood
1566
+ // far below xterm's 50M code-unit throw and bounds each tile's memory.
1567
+ TerminalTile.LIVE_BACKLOG_BUDGET = 4 * 1024 * 1024;
1568
+
1569
+ // The marker a pane writes when its socket drops: a transient drop says it is
1570
+ // reconnecting; a permanent stop says why, keyed by close code. All start
1571
+ // with `[disconnected` so a reader (and a test) can tell any of them apart
1572
+ // from session output.
1573
+ TerminalTile.MARKER_RECONNECTING = '[disconnected, reconnecting…]';
1574
+ TerminalTile.STOP_MARKERS = {
1575
+ 4003: '[disconnected: the server refused this connection]',
1576
+ 4004: '[disconnected: the session ended]',
1577
+ 4009: '[disconnected: the session ended]',
1578
+ 4010: '[disconnected: another connection took over this pane]',
1579
+ };
1580
+
1581
+ /**
1582
+ * ONE queue for every capture a set of tiles fetches (`GET
1583
+ * /api/sessions/:id/terminal`): the initial load, the refresh after a
1584
+ * reconnect, a server `{t:'r'}` refresh and the shell history pull. Each
1585
+ * capture runs synchronous tmux calls on the server, so N of them at once do
1586
+ * not run in parallel, they stall every WebSocket and SSE stream on it back to
1587
+ * back. After a deploy restart all N tiles reopen within the same second; this
1588
+ * drains their refreshes one at a time.
1589
+ *
1590
+ * Concurrency 1. Next up is a history pull (the user is waiting on it), then
1591
+ * the lowest `rank(tile)` (the grid ranks the focused tile first, then reading
1592
+ * order), then arrival order. A destroyed tile's entries are dropped, never run.
1593
+ * DOM-free, so the grid owns the policy and tests drive it directly.
1594
+ */
1595
+ class TileLoadQueue {
1596
+ /**
1597
+ * @param {{rank?: (tile: object) => number, onChange?: (tile: object, state: 'queued'|'running'|'idle') => void}} [opts]
1598
+ */
1599
+ constructor(opts = {}) {
1600
+ this._rank = typeof opts.rank === 'function' ? opts.rank : () => 0;
1601
+ this._onChange = typeof opts.onChange === 'function' ? opts.onChange : null;
1602
+ this._pending = [];
1603
+ this._active = null;
1604
+ this._seq = 0;
1605
+ }
1606
+
1607
+ /** The `scheduleLoad` a TerminalTile takes. Resolves once `run` finished or was dropped; never rejects. */
1608
+ schedule(tile, kind, run) {
1609
+ return new Promise((resolve) => {
1610
+ this._pending.push({ tile, kind, run, resolve, seq: this._seq++ });
1611
+ this._notify(tile, 'queued');
1612
+ this._pump();
1613
+ });
1614
+ }
1615
+
1616
+ /** Drops every load still waiting for `tile` (the running one, if any, finishes on its own). */
1617
+ drop(tile) {
1618
+ const keep = [];
1619
+ for (const entry of this._pending) {
1620
+ if (entry.tile === tile) entry.resolve();
1621
+ else keep.push(entry);
1622
+ }
1623
+ this._pending = keep;
1624
+ if (this._active?.tile !== tile) this._notify(tile, 'idle');
1625
+ }
1626
+
1627
+ /** How many loads are waiting (not counting the running one). */
1628
+ get size() {
1629
+ return this._pending.length;
1630
+ }
1631
+
1632
+ /** The tile whose load is running, or null. */
1633
+ get activeTile() {
1634
+ return this._active?.tile ?? null;
1635
+ }
1636
+
1637
+ _notify(tile, state) {
1638
+ try {
1639
+ this._onChange?.(tile, state);
1640
+ } catch {
1641
+ /* A display callback never stops the queue. */
1642
+ }
1643
+ }
1644
+
1645
+ _takeNext() {
1646
+ let best = -1;
1647
+ let bestKey = null;
1648
+ for (let i = 0; i < this._pending.length; i++) {
1649
+ const entry = this._pending[i];
1650
+ if (entry.tile?._destroyed) continue;
1651
+ const key = [entry.kind === 'history' ? 0 : 1, this._rank(entry.tile), entry.seq];
1652
+ const order = bestKey ? key[0] - bestKey[0] || key[1] - bestKey[1] || key[2] - bestKey[2] : -1;
1653
+ if (order < 0) {
1654
+ best = i;
1655
+ bestKey = key;
1656
+ }
1657
+ }
1658
+ // Destroyed tiles' entries go now, resolved but never run.
1659
+ const dropped = this._pending.filter((entry) => entry.tile?._destroyed);
1660
+ const next = best === -1 ? null : this._pending[best];
1661
+ this._pending = this._pending.filter((entry) => entry !== next && !entry.tile?._destroyed);
1662
+ for (const entry of dropped) entry.resolve();
1663
+ return next;
1664
+ }
1665
+
1666
+ async _pump() {
1667
+ if (this._active) return;
1668
+ const entry = this._takeNext();
1669
+ if (!entry) return;
1670
+ this._active = entry;
1671
+ this._notify(entry.tile, 'running');
1672
+ try {
1673
+ await entry.run();
1674
+ } catch {
1675
+ /* A load settles its own failure; the queue only moves on. */
1676
+ } finally {
1677
+ this._active = null;
1678
+ const stillQueued = this._pending.some((e) => e.tile === entry.tile);
1679
+ this._notify(entry.tile, stillQueued ? 'queued' : 'idle');
1680
+ entry.resolve();
1681
+ void this._pump();
1682
+ }
1683
+ }
1684
+ }
1685
+
1686
+ global.TerminalTile = TerminalTile;
1687
+ global.TileLoadQueue = TileLoadQueue;
1688
+ })(window);