aicodeman 1.34.0 → 1.40.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 (299) hide show
  1. package/README.md +11 -0
  2. package/README.zh-CN.md +8 -0
  3. package/dist/attachment-registry.d.ts +6 -0
  4. package/dist/attachment-registry.d.ts.map +1 -1
  5. package/dist/attachment-registry.js +9 -3
  6. package/dist/attachment-registry.js.map +1 -1
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +2 -2
  9. package/dist/cli.js.map +1 -1
  10. package/dist/config/cli-registry/patterns.d.ts +6 -0
  11. package/dist/config/cli-registry/patterns.d.ts.map +1 -1
  12. package/dist/config/cli-registry/patterns.js +13 -0
  13. package/dist/config/cli-registry/patterns.js.map +1 -1
  14. package/dist/config/cli-registry/schema.d.ts +13 -0
  15. package/dist/config/cli-registry/schema.d.ts.map +1 -1
  16. package/dist/config/cli-registry/schema.js +60 -2
  17. package/dist/config/cli-registry/schema.js.map +1 -1
  18. package/dist/config/cli-registry/stock.d.ts.map +1 -1
  19. package/dist/config/cli-registry/stock.js +175 -7
  20. package/dist/config/cli-registry/stock.js.map +1 -1
  21. package/dist/config/cli-registry/types.d.ts +93 -6
  22. package/dist/config/cli-registry/types.d.ts.map +1 -1
  23. package/dist/config/dependency-registry.d.ts +7 -0
  24. package/dist/config/dependency-registry.d.ts.map +1 -1
  25. package/dist/config/dependency-registry.js +15 -0
  26. package/dist/config/dependency-registry.js.map +1 -1
  27. package/dist/config/path-probe.d.ts +37 -0
  28. package/dist/config/path-probe.d.ts.map +1 -0
  29. package/dist/config/path-probe.js +43 -0
  30. package/dist/config/path-probe.js.map +1 -0
  31. package/dist/deepseek-route-config.d.ts +110 -0
  32. package/dist/deepseek-route-config.d.ts.map +1 -0
  33. package/dist/deepseek-route-config.js +477 -0
  34. package/dist/deepseek-route-config.js.map +1 -0
  35. package/dist/git-workspace-status.d.ts +246 -0
  36. package/dist/git-workspace-status.d.ts.map +1 -0
  37. package/dist/git-workspace-status.js +626 -0
  38. package/dist/git-workspace-status.js.map +1 -0
  39. package/dist/hooks-config.d.ts.map +1 -1
  40. package/dist/hooks-config.js +83 -13
  41. package/dist/hooks-config.js.map +1 -1
  42. package/dist/model-config-resolvers.d.ts +29 -0
  43. package/dist/model-config-resolvers.d.ts.map +1 -0
  44. package/dist/model-config-resolvers.js +34 -0
  45. package/dist/model-config-resolvers.js.map +1 -0
  46. package/dist/session-display-model.d.ts +110 -0
  47. package/dist/session-display-model.d.ts.map +1 -0
  48. package/dist/session-display-model.js +154 -0
  49. package/dist/session-display-model.js.map +1 -0
  50. package/dist/session.d.ts +145 -8
  51. package/dist/session.d.ts.map +1 -1
  52. package/dist/session.js +280 -35
  53. package/dist/session.js.map +1 -1
  54. package/dist/types/api.d.ts +7 -0
  55. package/dist/types/api.d.ts.map +1 -1
  56. package/dist/types/api.js.map +1 -1
  57. package/dist/types/session.d.ts +28 -1
  58. package/dist/types/session.d.ts.map +1 -1
  59. package/dist/types/session.js.map +1 -1
  60. package/dist/types/tools.d.ts +1 -1
  61. package/dist/types/tools.d.ts.map +1 -1
  62. package/dist/utils/bounded-path-probe.d.ts +99 -0
  63. package/dist/utils/bounded-path-probe.d.ts.map +1 -0
  64. package/dist/utils/bounded-path-probe.js +228 -0
  65. package/dist/utils/bounded-path-probe.js.map +1 -0
  66. package/dist/utils/cli-executable-resolver.d.ts +2 -0
  67. package/dist/utils/cli-executable-resolver.d.ts.map +1 -1
  68. package/dist/utils/cli-executable-resolver.js +2 -1
  69. package/dist/utils/cli-executable-resolver.js.map +1 -1
  70. package/dist/utils/deepseek-cli-resolver.d.ts +8 -0
  71. package/dist/utils/deepseek-cli-resolver.d.ts.map +1 -1
  72. package/dist/utils/deepseek-cli-resolver.js +18 -2
  73. package/dist/utils/deepseek-cli-resolver.js.map +1 -1
  74. package/dist/utils/dependency-checker.d.ts +2 -0
  75. package/dist/utils/dependency-checker.d.ts.map +1 -1
  76. package/dist/utils/dependency-checker.js +37 -9
  77. package/dist/utils/dependency-checker.js.map +1 -1
  78. package/dist/utils/index.d.ts +2 -0
  79. package/dist/utils/index.d.ts.map +1 -1
  80. package/dist/utils/index.js +1 -0
  81. package/dist/utils/index.js.map +1 -1
  82. package/dist/web/case-path.d.ts +58 -0
  83. package/dist/web/case-path.d.ts.map +1 -0
  84. package/dist/web/case-path.js +182 -0
  85. package/dist/web/case-path.js.map +1 -0
  86. package/dist/web/launch-defaults.d.ts +22 -0
  87. package/dist/web/launch-defaults.d.ts.map +1 -0
  88. package/dist/web/launch-defaults.js +46 -0
  89. package/dist/web/launch-defaults.js.map +1 -0
  90. package/dist/web/public/admin-ui.js.gz +0 -0
  91. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  92. package/dist/web/public/app.abadc970.js +43 -0
  93. package/dist/web/public/app.abadc970.js.br +0 -0
  94. package/dist/web/public/app.abadc970.js.gz +0 -0
  95. package/dist/web/public/approvals-ui.js.gz +0 -0
  96. package/dist/web/public/{constants.d95fade9.js → constants.c0f8f9de.js} +1009 -127
  97. package/dist/web/public/constants.c0f8f9de.js.br +0 -0
  98. package/dist/web/public/constants.c0f8f9de.js.gz +0 -0
  99. package/dist/web/public/cron-ui.js.gz +0 -0
  100. package/dist/web/public/entrance-animations.js +1 -0
  101. package/dist/web/public/entrance-animations.js.br +0 -0
  102. package/dist/web/public/entrance-animations.js.gz +0 -0
  103. package/dist/web/public/git-status-ui.js +719 -0
  104. package/dist/web/public/git-status-ui.js.br +0 -0
  105. package/dist/web/public/git-status-ui.js.gz +0 -0
  106. package/dist/web/public/home-sessions.js +12 -9
  107. package/dist/web/public/home-sessions.js.br +0 -0
  108. package/dist/web/public/home-sessions.js.gz +0 -0
  109. package/dist/web/public/host-wake-ui.js.gz +0 -0
  110. package/dist/web/public/i18n.a31747a9.js +2 -0
  111. package/dist/web/public/i18n.a31747a9.js.br +0 -0
  112. package/dist/web/public/i18n.a31747a9.js.gz +0 -0
  113. package/dist/web/public/{image-input.a216cf9e.js → image-input.898a538b.js} +18 -8
  114. package/dist/web/public/image-input.898a538b.js.br +0 -0
  115. package/dist/web/public/image-input.898a538b.js.gz +0 -0
  116. package/dist/web/public/index.html +205 -42
  117. package/dist/web/public/index.html.br +0 -0
  118. package/dist/web/public/index.html.gz +0 -0
  119. package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
  120. package/dist/web/public/keyboard-accessory.c1e7c7b7.js.gz +0 -0
  121. package/dist/web/public/{mobile-handlers.23a85db1.js → mobile-handlers.0ef9c311.js} +20 -0
  122. package/dist/web/public/mobile-handlers.0ef9c311.js.br +0 -0
  123. package/dist/web/public/mobile-handlers.0ef9c311.js.gz +0 -0
  124. package/dist/web/public/mobile-ime-preview.2dbaf262.js.gz +0 -0
  125. package/dist/web/public/mobile-overview.js.gz +0 -0
  126. package/dist/web/public/mobile.111b187d.css +1 -0
  127. package/dist/web/public/mobile.111b187d.css.br +0 -0
  128. package/dist/web/public/mobile.111b187d.css.gz +0 -0
  129. package/dist/web/public/notification-manager.50bb87d8.js.gz +0 -0
  130. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  131. package/dist/web/public/{panels-ui.6a5898d9.js → panels-ui.4735dfc2.js} +4 -4
  132. package/dist/web/public/panels-ui.4735dfc2.js.br +0 -0
  133. package/dist/web/public/{panels-ui.6a5898d9.js.gz → panels-ui.4735dfc2.js.gz} +0 -0
  134. package/dist/web/public/ralph-panel.b350e2ef.js.gz +0 -0
  135. package/dist/web/public/ralph-wizard.b6c0dd19.js.gz +0 -0
  136. package/dist/web/public/readmymind-ui.js.gz +0 -0
  137. package/dist/web/public/reboot-restore-ui.js.gz +0 -0
  138. package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
  139. package/dist/web/public/sanitize-html.e7147e76.js.gz +0 -0
  140. package/dist/web/public/session-lineage.js +229 -86
  141. package/dist/web/public/session-lineage.js.br +0 -0
  142. package/dist/web/public/session-lineage.js.gz +0 -0
  143. package/dist/web/public/session-ui.f731602c.js +100 -0
  144. package/dist/web/public/session-ui.f731602c.js.br +0 -0
  145. package/dist/web/public/session-ui.f731602c.js.gz +0 -0
  146. package/dist/web/public/settings-ui.a97ab16f.js +97 -0
  147. package/dist/web/public/settings-ui.a97ab16f.js.br +0 -0
  148. package/dist/web/public/settings-ui.a97ab16f.js.gz +0 -0
  149. package/dist/web/public/spreadsheet-preview-worker.js +290 -0
  150. package/dist/web/public/spreadsheet-preview-worker.js.br +0 -0
  151. package/dist/web/public/spreadsheet-preview-worker.js.gz +0 -0
  152. package/dist/web/public/spreadsheet-preview.af82fa2f.js +555 -0
  153. package/dist/web/public/spreadsheet-preview.af82fa2f.js.br +0 -0
  154. package/dist/web/public/spreadsheet-preview.af82fa2f.js.gz +0 -0
  155. package/dist/web/public/spreadsheet-xlsx-core.js +1006 -0
  156. package/dist/web/public/spreadsheet-xlsx-core.js.br +0 -0
  157. package/dist/web/public/spreadsheet-xlsx-core.js.gz +0 -0
  158. package/dist/web/public/styles.8ee12666.css +1 -0
  159. package/dist/web/public/styles.8ee12666.css.br +0 -0
  160. package/dist/web/public/styles.8ee12666.css.gz +0 -0
  161. package/dist/web/public/subagent-windows.e6ca799f.js.gz +0 -0
  162. package/dist/web/public/sw.js +2 -2
  163. package/dist/web/public/sw.js.br +0 -0
  164. package/dist/web/public/sw.js.gz +0 -0
  165. package/dist/web/public/tab-layout-browser.065fcba4.js +1 -0
  166. package/dist/web/public/tab-layout-browser.065fcba4.js.br +0 -0
  167. package/dist/web/public/tab-layout-browser.065fcba4.js.gz +0 -0
  168. package/dist/web/public/{tab-rail-resize.dcdc5772.js → tab-rail-resize.a5867793.js} +1 -1
  169. package/dist/web/public/tab-rail-resize.a5867793.js.br +0 -0
  170. package/dist/web/public/tab-rail-resize.a5867793.js.gz +0 -0
  171. package/dist/web/public/terminal-keycode229-recovery.af09f99a.js +1 -0
  172. package/dist/web/public/terminal-keycode229-recovery.af09f99a.js.br +0 -0
  173. package/dist/web/public/terminal-keycode229-recovery.af09f99a.js.gz +0 -0
  174. package/dist/web/public/terminal-split.js +146 -649
  175. package/dist/web/public/terminal-split.js.br +0 -0
  176. package/dist/web/public/terminal-split.js.gz +0 -0
  177. package/dist/web/public/terminal-tile.js +1611 -0
  178. package/dist/web/public/terminal-tile.js.br +0 -0
  179. package/dist/web/public/terminal-tile.js.gz +0 -0
  180. package/dist/web/public/terminal-ui.5059b30e.js +2 -0
  181. package/dist/web/public/terminal-ui.5059b30e.js.br +0 -0
  182. package/dist/web/public/terminal-ui.5059b30e.js.gz +0 -0
  183. package/dist/web/public/tile-grid.js +2684 -0
  184. package/dist/web/public/tile-grid.js.br +0 -0
  185. package/dist/web/public/tile-grid.js.gz +0 -0
  186. package/dist/web/public/ultracode-panel.js.gz +0 -0
  187. package/dist/web/public/ultracode-windows.js.gz +0 -0
  188. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  189. package/dist/web/public/vendor/exceljs.min.js +44 -0
  190. package/dist/web/public/vendor/exceljs.min.js.br +0 -0
  191. package/dist/web/public/vendor/exceljs.min.js.gz +0 -0
  192. package/dist/web/public/vendor/fflate.min.js +1 -0
  193. package/dist/web/public/vendor/fflate.min.js.br +0 -0
  194. package/dist/web/public/vendor/fflate.min.js.gz +0 -0
  195. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  196. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  197. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  198. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  199. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  200. package/dist/web/public/vendor/xterm-predictive-echo.ae5e3d5d.js.gz +0 -0
  201. package/dist/web/public/vendor/xterm-zerolag-input.bbb5f84c.js.gz +0 -0
  202. package/dist/web/public/vendor/xterm.css.gz +0 -0
  203. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  204. package/dist/web/public/{voice-input.c4b51eb6.js → voice-input.2342c37b.js} +64 -10
  205. package/dist/web/public/voice-input.2342c37b.js.br +0 -0
  206. package/dist/web/public/voice-input.2342c37b.js.gz +0 -0
  207. package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
  208. package/dist/web/public/webview-tabs.js +9 -1
  209. package/dist/web/public/webview-tabs.js.br +0 -0
  210. package/dist/web/public/webview-tabs.js.gz +0 -0
  211. package/dist/web/routes/case-routes.d.ts.map +1 -1
  212. package/dist/web/routes/case-routes.js +136 -18
  213. package/dist/web/routes/case-routes.js.map +1 -1
  214. package/dist/web/routes/cli-registry-routes.d.ts.map +1 -1
  215. package/dist/web/routes/cli-registry-routes.js +3 -1
  216. package/dist/web/routes/cli-registry-routes.js.map +1 -1
  217. package/dist/web/routes/doctor-routes.d.ts +23 -0
  218. package/dist/web/routes/doctor-routes.d.ts.map +1 -0
  219. package/dist/web/routes/doctor-routes.js +83 -0
  220. package/dist/web/routes/doctor-routes.js.map +1 -0
  221. package/dist/web/routes/file-routes.d.ts +7 -0
  222. package/dist/web/routes/file-routes.d.ts.map +1 -1
  223. package/dist/web/routes/file-routes.js +34 -4
  224. package/dist/web/routes/file-routes.js.map +1 -1
  225. package/dist/web/routes/git-status-routes.d.ts +16 -0
  226. package/dist/web/routes/git-status-routes.d.ts.map +1 -0
  227. package/dist/web/routes/git-status-routes.js +98 -0
  228. package/dist/web/routes/git-status-routes.js.map +1 -0
  229. package/dist/web/routes/index.d.ts +2 -0
  230. package/dist/web/routes/index.d.ts.map +1 -1
  231. package/dist/web/routes/index.js +2 -0
  232. package/dist/web/routes/index.js.map +1 -1
  233. package/dist/web/routes/session-routes.d.ts +18 -0
  234. package/dist/web/routes/session-routes.d.ts.map +1 -1
  235. package/dist/web/routes/session-routes.js +106 -42
  236. package/dist/web/routes/session-routes.js.map +1 -1
  237. package/dist/web/routes/status-telemetry-routes.d.ts +3 -0
  238. package/dist/web/routes/status-telemetry-routes.d.ts.map +1 -1
  239. package/dist/web/routes/status-telemetry-routes.js +12 -0
  240. package/dist/web/routes/status-telemetry-routes.js.map +1 -1
  241. package/dist/web/routes/system-routes.d.ts.map +1 -1
  242. package/dist/web/routes/system-routes.js +13 -0
  243. package/dist/web/routes/system-routes.js.map +1 -1
  244. package/dist/web/schemas.d.ts +28 -0
  245. package/dist/web/schemas.d.ts.map +1 -1
  246. package/dist/web/schemas.js +41 -0
  247. package/dist/web/schemas.js.map +1 -1
  248. package/dist/web/server.d.ts +5 -0
  249. package/dist/web/server.d.ts.map +1 -1
  250. package/dist/web/server.js +35 -12
  251. package/dist/web/server.js.map +1 -1
  252. package/dist/web/session-listener-wiring.d.ts +1 -0
  253. package/dist/web/session-listener-wiring.d.ts.map +1 -1
  254. package/dist/web/session-listener-wiring.js +11 -0
  255. package/dist/web/session-listener-wiring.js.map +1 -1
  256. package/package.json +3 -1
  257. package/scripts/postinstall.js +16 -0
  258. package/skills/codeman/reference/verbs.md +3 -0
  259. package/dist/web/public/app.cf0db8ce.js +0 -43
  260. package/dist/web/public/app.cf0db8ce.js.br +0 -0
  261. package/dist/web/public/app.cf0db8ce.js.gz +0 -0
  262. package/dist/web/public/constants.d95fade9.js.br +0 -0
  263. package/dist/web/public/constants.d95fade9.js.gz +0 -0
  264. package/dist/web/public/i18n.398b8331.js +0 -1
  265. package/dist/web/public/i18n.398b8331.js.br +0 -0
  266. package/dist/web/public/i18n.398b8331.js.gz +0 -0
  267. package/dist/web/public/image-input.a216cf9e.js.br +0 -0
  268. package/dist/web/public/image-input.a216cf9e.js.gz +0 -0
  269. package/dist/web/public/mobile-handlers.23a85db1.js.br +0 -0
  270. package/dist/web/public/mobile-handlers.23a85db1.js.gz +0 -0
  271. package/dist/web/public/mobile.1aa4952d.css +0 -1
  272. package/dist/web/public/mobile.1aa4952d.css.br +0 -0
  273. package/dist/web/public/mobile.1aa4952d.css.gz +0 -0
  274. package/dist/web/public/panels-ui.6a5898d9.js.br +0 -0
  275. package/dist/web/public/session-ui.bf66c13b.js +0 -89
  276. package/dist/web/public/session-ui.bf66c13b.js.br +0 -0
  277. package/dist/web/public/session-ui.bf66c13b.js.gz +0 -0
  278. package/dist/web/public/settings-ui.331d3b1d.js +0 -97
  279. package/dist/web/public/settings-ui.331d3b1d.js.br +0 -0
  280. package/dist/web/public/settings-ui.331d3b1d.js.gz +0 -0
  281. package/dist/web/public/styles.8c485d26.css +0 -1
  282. package/dist/web/public/styles.8c485d26.css.br +0 -0
  283. package/dist/web/public/styles.8c485d26.css.gz +0 -0
  284. package/dist/web/public/tab-layout-browser.4e0ed50c.js +0 -1
  285. package/dist/web/public/tab-layout-browser.4e0ed50c.js.br +0 -0
  286. package/dist/web/public/tab-layout-browser.4e0ed50c.js.gz +0 -0
  287. package/dist/web/public/tab-rail-resize.dcdc5772.js.br +0 -0
  288. package/dist/web/public/tab-rail-resize.dcdc5772.js.gz +0 -0
  289. package/dist/web/public/terminal-keycode229-recovery.fb91b25b.js +0 -1
  290. package/dist/web/public/terminal-keycode229-recovery.fb91b25b.js.br +0 -0
  291. package/dist/web/public/terminal-keycode229-recovery.fb91b25b.js.gz +0 -0
  292. package/dist/web/public/terminal-ui.a4535311.js +0 -2
  293. package/dist/web/public/terminal-ui.a4535311.js.br +0 -0
  294. package/dist/web/public/terminal-ui.a4535311.js.gz +0 -0
  295. package/dist/web/public/upload.html +0 -155
  296. package/dist/web/public/upload.html.br +0 -0
  297. package/dist/web/public/upload.html.gz +0 -0
  298. package/dist/web/public/voice-input.c4b51eb6.js.br +0 -0
  299. package/dist/web/public/voice-input.c4b51eb6.js.gz +0 -0
@@ -1,640 +1,16 @@
1
1
  // src/web/public/terminal-split.js
2
2
 
3
3
  /**
4
- * @fileoverview SplitTerminalPane — a second, independent live terminal pane
5
- * ("Pane B") for split-view sessions. Deliberately plainer than the primary
6
- * pane (this.terminal/this._ws in terminal-ui.js): no local-echo overlay, no
7
- * CJK IME, no touch/mobile handlers, no keyboard accessory bar. Desktop-only
8
- * feature by nature — see docs/split-pane-sessions-plan.md.
4
+ * @fileoverview Split-pane orchestration: opens a second live session
5
+ * ("Pane B") beside the active one, in a TerminalTile (terminal-tile.js), with
6
+ * a draggable divider, a session picker, and auto-collapse when either
7
+ * session ends. Desktop-only; see docs/split-pane-sessions-plan.md.
9
8
  *
10
- * @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
11
- * @dependency constants.js (window.CodemanTerminalFont, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE)
12
- * @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight)
13
- * @loadorder 7.5 of 16 — loaded after terminal-ui.js, before respawn-ui.js
9
+ * @dependency terminal-tile.js (window.TerminalTile)
10
+ * @dependency constants.js (window.CodemanSplitPane, SPLIT_PANE_MIN_WIDTH)
11
+ * @loadorder 7.5 of 16, loaded after terminal-tile.js and before tile-grid.js
14
12
  */
15
13
 
16
- (function (global) {
17
- // How long a scroll-to-top history pull may hold Pane B's live output.
18
- const HISTORY_PULL_TIMEOUT_MS = 10000;
19
-
20
- /**
21
- * Minimal chunked write for Pane B's own xterm instance — write() in
22
- * TERMINAL_CHUNK_SIZE slices, yielding a frame between each, instead of one
23
- * giant synchronous write that blocks the main thread while parsing a long
24
- * scrollback. Deliberately NOT the primary pane's chunkedTerminalWrite
25
- * (terminal-ui.js): that one is wired into session-switch generation
26
- * counters and the live-output gate this simpler, independently
27
- * created/destroyed pane has no equivalent of.
28
- */
29
- function writeChunked(terminal, buffer, isDestroyed) {
30
- if (!buffer) return Promise.resolve();
31
- if (buffer.length <= TERMINAL_CHUNK_SIZE) {
32
- terminal.write(buffer);
33
- return Promise.resolve();
34
- }
35
- // Resolves once the LAST chunk is written (or the pane was destroyed
36
- // mid-replay), so _loadBuffer() below can hold its single-flight flag
37
- // across the whole replay rather than just the fetch that precedes it.
38
- return new Promise((resolve) => {
39
- let offset = 0;
40
- const writeNext = () => {
41
- if (isDestroyed() || !terminal) {
42
- resolve();
43
- return;
44
- }
45
- const chunk = buffer.slice(offset, offset + TERMINAL_CHUNK_SIZE);
46
- offset += chunk.length;
47
- terminal.write(chunk);
48
- if (offset < buffer.length) {
49
- if (typeof requestAnimationFrame === 'function') requestAnimationFrame(writeNext);
50
- else setTimeout(writeNext, 16);
51
- } else {
52
- resolve();
53
- }
54
- };
55
- writeNext();
56
- });
57
- }
58
-
59
- class SplitTerminalPane {
60
- constructor(sessionId, mountEl, opts = {}) {
61
- this.sessionId = sessionId;
62
- this.mountEl = mountEl;
63
- this.sessionMode = opts.mode;
64
- this.fontSettings = opts.fontSettings || {};
65
- // Live reference (not a snapshot) to the app's detachedSessions Set —
66
- // detaching this session AFTER the split is already open must still be
67
- // seen by _sendResize() below, or it re-creates the exact PTY-size
68
- // fight the split picker already refuses to open at pick time.
69
- this.detachedSessions = opts.detachedSessions;
70
- this.terminal = null;
71
- this.fitAddon = null;
72
- this.ws = null;
73
- this._wsReady = false;
74
- this._wsClosed = false;
75
- this._destroyed = false;
76
- // Single-flight state for _loadBuffer()/_refreshBuffer() below.
77
- this._bufferLoading = false;
78
- this._bufferRefreshPending = false;
79
- // Scroll-to-top history pull (shell panes only), see _maybeLoadMoreHistory().
80
- // `_liveQueue` is non-null from the pull's response until its finally
81
- // block: live frames are held there with their arrival time instead of
82
- // written under the replay. `_markerOwed` is the "disconnected" marker a
83
- // load still has to write (see _onSocketClosed()/_stampMarkerIfOwed()).
84
- this._historyPullAt = 0;
85
- this._historyPullUseless = false;
86
- this._liveQueue = null;
87
- this._markerOwed = false;
88
- this._onWheel = null;
89
- }
90
-
91
- async connect() {
92
- const savedFontSize = parseInt(localStorage.getItem('codeman-font-size'), 10);
93
- this.terminal = new Terminal({
94
- theme: { ...global.codemanCurrentXtermTheme() },
95
- fontFamily: global.CodemanTerminalFont.resolve(this.fontSettings.terminalFontFamily),
96
- ...global.CodemanTerminalFont.resolveWeights(this.fontSettings),
97
- fontSize: Number.isFinite(savedFontSize) ? savedFontSize : 14,
98
- lineHeight: 1.2,
99
- cursorBlink: false,
100
- cursorStyle: 'block',
101
- minimumContrastRatio: global.codemanCurrentSkinIsLight() ? 4.5 : 1,
102
- scrollback: DEFAULT_SCROLLBACK,
103
- allowTransparency: true,
104
- allowProposedApi: true,
105
- });
106
-
107
- this.fitAddon = new FitAddon.FitAddon();
108
- this.terminal.loadAddon(this.fitAddon);
109
- this.terminal.open(this.mountEl);
110
- this.fitAddon.fit();
111
-
112
- this._installWheelListener();
113
-
114
- this.terminal.onData((data) => {
115
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
116
- this.ws.send(JSON.stringify({ t: 'i', d: data }));
117
- }
118
- });
119
-
120
- // Pane B has no gates of its own by default, so every app-level chord
121
- // that the document capture-phase handler (app.js) only preventDefault()s
122
- // — never stopPropagation()s — reaches xterm here too and writes its raw
123
- // byte/escape sequence into THIS session's PTY on top of whatever the app
124
- // action already did to Pane A (COD-153; mirrors the primary pane's own
125
- // gates at terminal-ui.js's attachCustomKeyEventHandler: command palette,
126
- // Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter
127
- // newline, and smart-copy Ctrl+C/Ctrl+Shift+C). Routed through the same
128
- // registry-aware predicates so a rebind or a disable restores plain
129
- // terminal behavior here too. Ctrl+V is deliberately left on xterm's own
130
- // default (plain-text paste): Pane B has no image-paste trap to route it
131
- // to, so intercepting it here would only break paste.
132
- this.terminal.attachCustomKeyEventHandler((ev) => {
133
- if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true;
134
- if (
135
- ev.altKey &&
136
- !ev.ctrlKey &&
137
- !ev.shiftKey &&
138
- /^(Digit[1-9]|BracketLeft|BracketRight|KeyK)$/.test(ev.code || '')
139
- ) {
140
- return false;
141
- }
142
- if (ev.type === 'keydown' && global.app?.shouldOpenCommandPaletteFromShortcut?.(ev)) {
143
- return false;
144
- }
145
- if (ev.type === 'keydown' && global.app?.shouldToggleSessionSidebarFromShortcut?.(ev)) {
146
- return false;
147
- }
148
- // Ctrl+Z (SIGTSTP/job-control suspend): mirrors terminal-ui.js's own
149
- // swallow — in a plain shell session this is the user's own
150
- // job-control tool and must reach the PTY, but in every other mode
151
- // (claude/omp/pi/codex/...) it silently stops an unattended agent
152
- // loop dead. Pane B has its own PTY/session and must not send a
153
- // suspend into a non-shell one just because the primary pane's own
154
- // gate lives elsewhere.
155
- if (
156
- ev.type === 'keydown' &&
157
- ev.key.toLowerCase() === 'z' &&
158
- ev.ctrlKey &&
159
- !ev.altKey &&
160
- !ev.metaKey &&
161
- !ev.shiftKey &&
162
- this.sessionMode !== 'shell'
163
- ) {
164
- return false;
165
- }
166
- // Shift+Enter / Ctrl+Enter: insert a newline instead of submitting.
167
- // Mirrors terminal-ui.js's own handling — xterm sends plain \r for
168
- // every Enter variant, so an Ink app (Claude Code) can't tell a
169
- // newline from a submit. Without this gate, Pane B's onData would
170
- // send that bare \r straight over the WS and submit an incomplete
171
- // prompt instead of adding a line to it. Targets THIS pane's own
172
- // session (this.sessionId), never the primary pane's
173
- // activeSessionId, and has no local-echo overlay of its own to flush
174
- // first (Pane B is deliberately plainer — see the fileoverview).
175
- // Swallow keypress/keyup too (xterm would send \r for a Shift-only keypress); only keydown sends.
176
- if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey)) {
177
- if (ev.type === 'keydown') {
178
- fetch(`/api/sessions/${this.sessionId}/send-key`, {
179
- method: 'POST',
180
- headers: { 'Content-Type': 'application/json' },
181
- body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
182
- }).catch(() => {
183
- /* Best-effort, matching this pane's tolerance elsewhere. */
184
- });
185
- }
186
- return false;
187
- }
188
- // Smart copy (mirrors terminal-ui.js's Ctrl+C gate, #211): with a
189
- // selection, Ctrl+C copies THIS pane's own selection instead of
190
- // sending ^C; with none, plain Ctrl+C must fall through unchanged or
191
- // the interrupt key is lost. Ctrl+Shift+C is different: it is the
192
- // explicit, never-falls-through copy chord, and the predicate above
193
- // does not distinguish it from plain Ctrl+C — ev.shiftKey does, below.
194
- // xterm's own evaluateKeyboardEvent routes a shifted ctrl-letter into
195
- // a branch that assigns c.key only for a couple of special cases
196
- // ("_"->US, "@"->NUL), neither of which is "c", so it emits NOTHING
197
- // for Ctrl+Shift+C either way — this is not about an accidental
198
- // interrupt byte reaching the PTY (verified live: it does not).
199
- // Gating this whole block on hasSelection() (an earlier draft) meant
200
- // that with no selection Ctrl+Shift+C skipped straight to `return
201
- // true`, silently ceding the keystroke to the BROWSER's own handling
202
- // (e.g. Chrome's Inspect-Element binding) with no feedback and no
203
- // attempt to copy, unlike Pane A, which always intercepts it.
204
- // Re-implemented against this.terminal rather than reusing
205
- // app.copyTerminalSelection(), which reads app.terminal — Pane A's —
206
- // and would copy the wrong pane's selection.
207
- if (ev.type === 'keydown' && global.app?.shouldCopyTerminalSelectionFromShortcut?.(ev)) {
208
- const raw = this.terminal?.getSelection?.() || '';
209
- const isColumnSelection = this.terminal?._core?._selectionService?._activeSelectionMode === 3;
210
- // Both clean options are read for THIS pane, never the primary one:
211
- // the gutter width comes from this.sessionId's own run mode, and the
212
- // partial-first-line flag from this terminal's own selection range.
213
- // Passing neither left Pane B keeping a margin Pane A dropped, on the
214
- // same split and the same keystroke.
215
- const range = global.app?._normalisedSelectionRange?.(this.terminal);
216
- const selection = isColumnSelection
217
- ? raw
218
- : (global.CodemanCopySelection?.clean?.(raw, {
219
- margin: global.app?._cliGutterColumns?.(this.sessionId) ?? 0,
220
- firstLinePartial: !!range && range.start.x > 0,
221
- }) ?? raw);
222
- if (selection.trim()) {
223
- ev.preventDefault();
224
- void global.app._copyText?.(selection).then((ok) => {
225
- this.terminal?.clearSelection?.();
226
- global.app.showToast?.(ok ? 'Copied to clipboard' : 'Failed to copy', ok ? 'success' : 'error');
227
- });
228
- return false;
229
- }
230
- // Nothing worth copying — clear for feedback (a padding-only
231
- // selection cleans to '' and this press still falls through to the
232
- // PTY as 0x03, matching the primary pane's own rule).
233
- if (this.terminal?.hasSelection?.()) {
234
- this.terminal.clearSelection?.();
235
- global.app.showToast?.('Nothing to copy', 'warning');
236
- }
237
- // Ctrl+Shift+C never falls through, even with nothing to copy —
238
- // matches terminal-ui.js's own ev.shiftKey branch.
239
- if (ev.shiftKey) {
240
- ev.preventDefault();
241
- return false;
242
- }
243
- }
244
- return true;
245
- });
246
-
247
- // Load existing scrollback before going live. The WS below is
248
- // subscribe-only (ws-routes.ts sends nothing on connect, only future
249
- // 'terminal' events), so without this Pane B stays blank until the
250
- // target session happens to produce new output. It LOOKED
251
- // intermittent rather than always-broken because _sendResize() below
252
- // often nudges the shared session's real tmux window to a new size,
253
- // and tmux repaints its current screen on resize — that repaint was
254
- // getting captured and streamed here, incidentally populating the
255
- // pane. When Pane B's computed dimensions happened to already match
256
- // the session's last-known size, Session.resize() (session.ts) skips
257
- // the resize as a no-op, no repaint fires, and the pane stayed blank.
258
- // The await covers the whole chunked replay, not just the fetch, so a
259
- // live frame from the socket below can never land in the middle of it.
260
- await this._loadBuffer();
261
- if (this._destroyed) return;
262
-
263
- const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
264
- const url = `${proto}//${location.host}${window.CodemanBase.base}/ws/sessions/${this.sessionId}/terminal`;
265
- this.ws = new WebSocket(url);
266
-
267
- this.ws.onopen = () => {
268
- this._wsReady = true;
269
- this._sendResize();
270
- };
271
-
272
- this.ws.onmessage = (event) => {
273
- try {
274
- const msg = JSON.parse(event.data);
275
- if (msg.t === 'o') {
276
- this._onLiveOutput(msg.d);
277
- } else if (msg.t === 'c') {
278
- this._onLiveClear();
279
- } else if (msg.t === 'r') {
280
- // Server-triggered refresh (SSE backpressure cleared, terminal
281
- // data was dropped). The primary pane routes this to
282
- // _onSessionNeedsRefresh (app.js:2990) — Pane B has its own
283
- // buffer loader for the same reason connect() does.
284
- this._refreshBuffer();
285
- }
286
- } catch {
287
- /* Malformed frame — ignore, matches primary pane's tolerance. */
288
- }
289
- };
290
-
291
- // Mirror app.js's onclose/onerror pattern (app.js:2905-2964): _wsReady
292
- // must go false on a drop or fit()/_sendResize() silently no-ops on a
293
- // closed socket per the WebSocket spec (no exception, no log). No
294
- // reconnect logic here — Pane B is deliberately plainer than the
295
- // primary pane (see the fileoverview above); a drop just stops
296
- // resizing until the parent recreates the pane. But onData already
297
- // silently drops keystrokes while _wsReady is false (below), so
298
- // without a visible marker a dropped socket left Pane B looking
299
- // normal while it quietly ate everything typed into it. v1 scope is
300
- // "say so", not reconnect — collapsing the split would lose the
301
- // user's place in Pane B's scrollback for a transient blip.
302
- this.ws.onclose = () => this._onSocketClosed();
303
-
304
- this.ws.onerror = () => {
305
- // onclose fires after onerror — cleanup happens there.
306
- };
307
- }
308
-
309
- // The socket's close, split out of connect() so the tests can drive it.
310
- // While any load runs (a history pull or a `{t:'r'}` refresh) the marker is
311
- // only owed, and that load's finally block settles it (_stampMarkerIfOwed()):
312
- // written now, it would sit above the output a pull is still holding (flushed
313
- // after it on a skip, a downgrade or a failed fetch), above a refresh's
314
- // replay, or in the middle of a chunked replay. A pull still waiting for its
315
- // response holds the marker too, for as long as the request takes (up to its
316
- // budget, see _pullHistory()).
317
- _onSocketClosed() {
318
- this._wsReady = false;
319
- this._wsClosed = true;
320
- if (this._bufferLoading) this._markerOwed = true;
321
- else this._writeDisconnectedMarker();
322
- }
323
-
324
- // Settles a marker the pane owes: set when a close lands during a load (the
325
- // replay would otherwise sit below it) or when a load wipes the terminal on
326
- // a closed socket. Called from each load's own finally, just before
327
- // _endBufferLoad() starts any trailing refresh.
328
- _stampMarkerIfOwed() {
329
- // A trailing refresh is about to clear() synchronously, while xterm parses
330
- // a write() on a later tick: a marker written here would land in the
331
- // freshly cleared buffer ABOVE that refresh's replay, a second, stale copy.
332
- // The refresh re-owes the marker on a closed socket and stamps it itself.
333
- if (this._bufferRefreshPending && !this._destroyed) return;
334
- const owed = this._markerOwed;
335
- this._markerOwed = false;
336
- if (owed && this._wsClosed && !this._destroyed) this._writeDisconnectedMarker();
337
- }
338
-
339
- // Extracted so both _onSocketClosed() and a load that ends owing it on a
340
- // closed socket can write it (see _stampMarkerIfOwed()).
341
- _writeDisconnectedMarker() {
342
- this.terminal?.write('\r\n\x1b[2m[Pane B disconnected — close and reopen the split to reconnect]\x1b[0m\r\n');
343
- }
344
-
345
- // Fetches and writes the session's current scrollback. Used both by
346
- // connect() (initial load) and by the `{t:'r'}` server-refresh frame
347
- // (below) — the primary pane's own _onSessionNeedsRefresh (app.js) is
348
- // scoped to `this.activeSessionId` and clears/rewrites the primary
349
- // terminal, neither of which applies to this independent pane, so this is
350
- // a standalone equivalent rather than a call into it.
351
- //
352
- // Mirrors the primary pane's own mode check (app.js's selectSession /
353
- // _onSessionNeedsRefresh): a shell session can retain hundreds of
354
- // thousands of plain scrollback lines, so pulling `?full=1` there parses
355
- // an unbounded, server-capped (up to terminalBufferMaxBytes, 32MB) body
356
- // into a 50000-line xterm on every load. Non-shell (TUI) sessions still
357
- // get one full replay. `fetch` here goes through the global wrapper
358
- // (constants.js), which already prefixes CodemanBase — unlike the raw
359
- // WebSocket URL above, which does not.
360
- //
361
- // Single-flight: the flag is held across the fetch AND the chunked write
362
- // (writeChunked resolves after its last chunk), so two replays can never
363
- // interleave their chunks into one terminal. A second call while one is
364
- // in flight is dropped here; _refreshBuffer() is the caller that queues
365
- // a trailing re-run instead.
366
- async _loadBuffer() {
367
- if (this._bufferLoading) return;
368
- this._bufferLoading = true;
369
- try {
370
- const query = this.sessionMode === 'shell' ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1';
371
- const res = await fetch(`/api/sessions/${this.sessionId}/terminal?${query}`);
372
- const payload = (await res.json())?.data ?? {};
373
- if (payload.terminalBuffer && this.terminal) {
374
- await writeChunked(this.terminal, payload.terminalBuffer, () => this._destroyed);
375
- }
376
- } catch {
377
- /* Best-effort — live output still arrives once the socket connects. */
378
- } finally {
379
- this._stampMarkerIfOwed();
380
- this._endBufferLoad();
381
- }
382
- }
383
-
384
- // Ends a single-flight load (initial, refresh or history pull): clears the
385
- // flag, then runs the ONE trailing refresh that arrived while it was busy.
386
- _endBufferLoad() {
387
- this._bufferLoading = false;
388
- if (this._bufferRefreshPending && !this._destroyed) {
389
- this._bufferRefreshPending = false;
390
- this._refreshBuffer();
391
- }
392
- }
393
-
394
- // Live terminal output. Written straight through, except while a history
395
- // pull is replaying: a capture is current only up to the instant tmux took
396
- // it, so a frame arriving mid-replay is held with its arrival time and
397
- // replayed behind the snapshot by _pullHistory() (the primary pane's
398
- // _finishBufferLoad `since` rule), never written underneath it.
399
- _onLiveOutput(data) {
400
- if (this._liveQueue) this._liveQueue.push({ at: performance.now(), data });
401
- else this.terminal?.write(data);
402
- }
403
-
404
- // The server's `{t:'c'}` clear frame takes the same route as output, for the
405
- // same reason: clearing straight away, mid-replay, would wipe the half-written
406
- // snapshot and leave _pullHistory() measuring a buffer that is no longer the
407
- // one it is restoring. Queued, it lands in order with the frames around it.
408
- _onLiveClear() {
409
- if (this._liveQueue) this._liveQueue.push({ at: performance.now(), clear: true });
410
- else this.terminal?.clear();
411
- }
412
-
413
- // Capture phase, because xterm's own wheel handler stopPropagation()s every
414
- // event it consumes, so a bubbling listener here would never see the wheel
415
- // while the pane still has scrollback to scroll. Passive: this only observes,
416
- // xterm keeps doing the scrolling.
417
- _installWheelListener() {
418
- this._onWheel = (ev) => {
419
- if (ev.deltaY < 0) this._maybeLoadMoreHistory();
420
- };
421
- this.mountEl.addEventListener('wheel', this._onWheel, { capture: true, passive: true });
422
- }
423
-
424
- // Wheel-up at the top of a SHELL pane's scrollback. tmux repaints a burst of
425
- // output (`cat` of a file longer than the screen) instead of scrolling it,
426
- // so this pane's xterm ends up with about one screen of scrollback while
427
- // tmux holds every line — and nothing here ever went back to ask, so the
428
- // history was unreachable. The primary pane has the same pull
429
- // (app.js _maybeRefetchFullHistory); Pane B is a separate xterm and needs its
430
- // own. Shell only: a non-shell CLI's history is out of scope for this pull
431
- // (its load already takes `full=1`; codex and Claude's inline renderer do
432
- // grow tmux history, this just isn't how they recover it). The alternate-
433
- // screen skip (nano, vim, less) only matters for a direct-PTY shell — under
434
- // tmux the browser xterm never enters the alternate buffer.
435
- _maybeLoadMoreHistory() {
436
- if (this.sessionMode !== 'shell' || this._destroyed || !this.terminal) return;
437
- if (this._bufferLoading) return;
438
- // Mirrors app.js _maybeRefetchFullHistory and this pane's own
439
- // _sendResize(): a detached session's own window already owns its PTY
440
- // size and scrollback, so Pane B has nothing of its own to reconcile.
441
- if (this.detachedSessions?.has(this.sessionId)) return;
442
- const active = this.terminal.buffer.active;
443
- if (active.type !== 'normal' || active.viewportY !== 0) return;
444
- // Momentum scrolling fires this dozens of times per flick, so cooldown
445
- // rather than latch; a pull that could only have downgraded the pane
446
- // waits far longer.
447
- const cooldown = this._historyPullUseless ? 60000 : 4000;
448
- const now = Date.now();
449
- if (now - this._historyPullAt < cooldown) return;
450
- this._historyPullAt = now;
451
- void this._pullHistory();
452
- }
453
-
454
- // Pulls a BOUNDED window of tmux's full history (the same TERMINAL_TAIL_SIZE
455
- // a tab switch loads, so a multi-megabyte capture never lands on xterm's
456
- // main thread) and replays it under the reader's current place. Holds the
457
- // single-flight flag across the fetch AND the replay, like _loadBuffer().
458
- async _pullHistory() {
459
- this._bufferLoading = true;
460
- let replayed = false;
461
- let capturedAt = 0;
462
- // Two budgets on one signal. The request itself gets the primary pane's
463
- // (CodemanFetchDeadline, constants.js): live output is not held while it
464
- // runs, but the single-flight flag is, so a coalesced `{t:'r'}` refresh and
465
- // the marker owed by a close (_onSocketClosed()) both wait for it, at worst
466
- // for that whole budget. Once the headers land live output IS held, so the
467
- // body read gets the short one instead: a body that hangs would otherwise
468
- // freeze the pane for the long budget. Aborting lands in the catch below,
469
- // which releases the flag and the queue. AbortSignal.timeout() alone cannot
470
- // be re-armed, hence the controller; without AbortController the pull
471
- // simply has no deadline.
472
- const controller = global.AbortController ? new global.AbortController() : null;
473
- let abortTimer = null;
474
- const armDeadline = (ms) => {
475
- if (!controller) return;
476
- clearTimeout(abortTimer);
477
- abortTimer = setTimeout(() => controller.abort(), ms);
478
- };
479
- try {
480
- armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: true }) ?? HISTORY_PULL_TIMEOUT_MS);
481
- const res = await fetch(`/api/sessions/${this.sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`, {
482
- signal: controller?.signal,
483
- });
484
- armDeadline(HISTORY_PULL_TIMEOUT_MS);
485
- // The cutoff below is the response's arrival, the same `since` rule the
486
- // primary pane uses (_finishBufferLoad). It is a client clock standing in
487
- // for the instant tmux took the capture, which lies somewhere in the
488
- // round trip, so a frame in that window can be lost or doubled. Bounded
489
- // by one round trip and not closable without a server-side capture time.
490
- capturedAt = performance.now();
491
- // Opened only now: a frame from before the response is either replaced by
492
- // the capture or written unchanged, so holding it for the round trip
493
- // bought nothing and froze the pane for as long as the fetch took.
494
- this._liveQueue = [];
495
- const payload = (await res.json())?.data;
496
- clearTimeout(abortTimer);
497
- const buffer = payload?.terminalBuffer;
498
- const term = this.terminal;
499
- if (!buffer || !term || this._destroyed) return;
500
- const rowsBefore = term.buffer.active.length;
501
- const rowsIncoming = global.app?._estimateReplayRows?.(buffer, term.cols) ?? buffer.split('\n').length;
502
- // xterm keeps at most `scrollback + rows` rows while tmux keeps far more
503
- // lines, so a window of short lines can carry more rows than this pane
504
- // can ever hold, and `rowsIncoming <= rowsBefore` would never come true.
505
- const scrollbackCap = term.options?.scrollback || 0;
506
- const paneFull = scrollbackCap > 0 && rowsBefore >= scrollbackCap + term.rows;
507
- // Nothing to gain (this also covers a downgrade, which would delete
508
- // history mid-scroll), and a reset+rewrite would jump the viewport. An
509
- // untruncated window IS all of tmux's history and the next burst can add
510
- // more, so keep the 4 s cooldown. A truncated window can never reach past
511
- // what the pane shows, and every ask costs the server a capture-pane of
512
- // the whole history (`tail` is cut after it): back off to 60 s, as the
513
- // primary pane does (app.js _maybeRefetchFullHistory). A full pane backs
514
- // off too, since no window can ever fit in it.
515
- if (rowsIncoming <= rowsBefore || paneFull) {
516
- if (payload.truncated || paneFull) this._historyPullUseless = true;
517
- return;
518
- }
519
- this._historyPullUseless = false;
520
- term.write('\x1bc');
521
- replayed = true;
522
- if (this._wsClosed) this._markerOwed = true;
523
- await writeChunked(term, buffer, () => this._destroyed);
524
- if (this._destroyed || !this.terminal) return;
525
- // xterm parses asynchronously: an empty write's callback fires only
526
- // after everything before it, so the row count below is the settled one.
527
- await new Promise((resolve) => this.terminal.write('', resolve));
528
- if (this._destroyed || !this.terminal) return;
529
- // The replay grew the buffer UPWARD, so what was row 0 is now `delta`
530
- // rows down; land there and the recovered history sits above it.
531
- const delta = this.terminal.buffer.active.length - rowsBefore;
532
- if (delta > 0) this.terminal.scrollToLine(delta);
533
- else this.terminal.scrollToTop();
534
- } catch {
535
- /* Best-effort — live output keeps arriving whatever happens here. */
536
- } finally {
537
- clearTimeout(abortTimer);
538
- const queued = this._liveQueue ?? [];
539
- this._liveQueue = null;
540
- // After a replay, only frames that arrived after the capture are news;
541
- // earlier ones are already in it. With no replay, every held frame is.
542
- const cutoff = replayed ? capturedAt : 0;
543
- for (const entry of queued) {
544
- if (entry.at < cutoff) continue;
545
- if (entry.clear) this.terminal?.clear();
546
- else this.terminal?.write(entry.data);
547
- }
548
- // Settled after the queue flush so the marker is the last thing on
549
- // screen: a close during the pull wrote nothing (_onSocketClosed() defers
550
- // it while a load runs), and a replay's own `\x1bc` (flagged above) wipes
551
- // one written before it, which would paint a fresh, current-looking
552
- // history while onData keeps silently dropping every keystroke on the
553
- // dead socket. With a trailing refresh pending (_endBufferLoad) the marker
554
- // is left to that refresh, which writes it below its own replay.
555
- this._stampMarkerIfOwed();
556
- this._endBufferLoad();
557
- }
558
- }
559
-
560
- // The `{t:'r'}` server-refresh path: clear, then replay. Two refresh
561
- // frames in a row used to start two concurrent replays, each clearing
562
- // the terminal under the other's chunked write. A refresh that arrives
563
- // mid-replay is COALESCED into one trailing re-run rather than ignored:
564
- // the in-flight fetch may predate the drop the new frame is reporting,
565
- // and no further frame is coming to correct stale content.
566
- _refreshBuffer() {
567
- if (this._bufferLoading) {
568
- this._bufferRefreshPending = true;
569
- return;
570
- }
571
- this.terminal?.clear();
572
- // The clear wipes a "disconnected" marker (a `{t:'r'}` frame can queue a
573
- // trailing refresh behind a pull that the socket's close then interrupts),
574
- // so a refresh on a closed socket owes it back once its replay is written.
575
- if (this._wsClosed) this._markerOwed = true;
576
- void this._loadBuffer();
577
- }
578
-
579
- // Local reflow only — no PTY resize frame. Split out so a divider drag
580
- // can reflow both panes at the browser's paint rate (rAF) while sending
581
- // the actual `{t:'z'}` resize once, at drag end, matching the primary
582
- // pane's own convention (throttledResize in terminal-ui.js).
583
- localFit() {
584
- if (!this.fitAddon) return;
585
- this.fitAddon.fit();
586
- }
587
-
588
- fit() {
589
- this.localFit();
590
- this._sendResize();
591
- }
592
-
593
- _sendResize() {
594
- if (!this._wsReady || !this.fitAddon) return;
595
- // One PTY cannot hold two sizes (mirrors sendResize's own
596
- // detachedElsewhere yield in terminal-ui.js): the session got detached
597
- // to its own window AFTER this split was opened, so its own window now
598
- // owns the PTY's size and Pane B must stand aside.
599
- if (this.detachedSessions?.has(this.sessionId)) return;
600
- const dims = this.fitAddon.proposeDimensions();
601
- if (!dims) return;
602
- // Send the real proposed dimensions unclamped, matching the primary
603
- // pane's convention (terminal-ui.js's getTerminalDimensions()) — the
604
- // server enforces its own valid range ([1,500]/[1,200] in ws-routes.ts).
605
- // A 40/10 floor here misreported Pane B's real width to the PTY at the
606
- // divider's own reachable 20% floor position, causing real
607
- // output-wrapping bugs.
608
- this.ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows, v: 'desktop' }));
609
- }
610
-
611
- destroy() {
612
- this._destroyed = true;
613
- if (this._onWheel) {
614
- this.mountEl?.removeEventListener('wheel', this._onWheel, { capture: true });
615
- this._onWheel = null;
616
- }
617
- if (this.ws) {
618
- this.ws.onopen = null;
619
- this.ws.onmessage = null;
620
- // onclose fires asynchronously AFTER close(); without this it ran
621
- // its "disconnected" write against a pane already torn down.
622
- this.ws.onclose = null;
623
- this.ws.onerror = null;
624
- this.ws.close();
625
- this.ws = null;
626
- }
627
- if (this.terminal) {
628
- this.terminal.dispose();
629
- this.terminal = null;
630
- }
631
- this.fitAddon = null;
632
- }
633
- }
634
-
635
- global.SplitTerminalPane = SplitTerminalPane;
636
- })(window);
637
-
638
14
  Object.assign(CodemanApp.prototype, {
639
15
  /**
640
16
  * Desktop-only gate, same shape as home-sessions.js's shouldShowHomeSessions
@@ -670,6 +46,9 @@ Object.assign(CodemanApp.prototype, {
670
46
  // the old exact-node check below) bubbled straight through to
671
47
  // `document` and self-closed the menu it just opened.
672
48
  event?.stopPropagation();
49
+ // The tile grid and the split are never open together (tile-grid.js); the
50
+ // Split button shows as unavailable meanwhile.
51
+ if (this._tilesOwnTerminal?.()) return;
673
52
  if (this._splitPane) {
674
53
  this.closeSplitPane();
675
54
  return;
@@ -763,6 +142,8 @@ Object.assign(CodemanApp.prototype, {
763
142
  // see _applySplitButtonVisibility's comment for why both a JS check and
764
143
  // a CSS backstop exist.
765
144
  if (window.innerWidth < SPLIT_PANE_MIN_WIDTH) return;
145
+ // Never beside the tile grid: the main terminal is parked while it is open.
146
+ if (this._tilesOwnTerminal?.()) return;
766
147
  // No active session means there is no `.terminal-wrap` to split against
767
148
  // (the welcome overlay is showing) — without this, a split opened from
768
149
  // the home screen still created the container and connected Pane B, just
@@ -777,7 +158,7 @@ Object.assign(CodemanApp.prototype, {
777
158
  // B's own session tab while split can otherwise land here with
778
159
  // sessionId === activeSessionId: two live WebSockets to the same
779
160
  // session, each independently claiming PTY dimensions via its own `{t:'z',...}`
780
- // resize frame. Refuse before creating any DOM or SplitTerminalPane.
161
+ // resize frame. Refuse before creating any DOM or TerminalTile.
781
162
  if (sessionId === this.activeSessionId) return;
782
163
  // The picker's own exclusions (buildSplitPickerSessions in constants.js),
783
164
  // re-applied here: the menu can sit open while a listed session's CLI
@@ -803,22 +184,38 @@ Object.assign(CodemanApp.prototype, {
803
184
 
804
185
  const paneB = document.createElement('div');
805
186
  paneB.className = 'terminal-pane-b';
806
- paneB.innerHTML = `
807
- <div class="terminal-pane-b-header">
808
- <span class="session-name">${escapeHtml(session?.name || 'Session')}</span>
809
- <button type="button" class="terminal-pane-b-close" onclick="app.closeSplitPane()" aria-label="Close split">&times;</button>
810
- </div>
811
- <div class="terminal-pane-b-container"></div>
812
- `;
187
+ // Pane B's header: the harness logo, the name and the model, as on a grid
188
+ // tile, and the close button at a tile button's size.
189
+ const headerB = this._buildSplitPaneHeader();
190
+ const close = document.createElement('button');
191
+ close.type = 'button';
192
+ close.className = 'tile-btn tile-remove terminal-pane-b-close';
193
+ close.title = 'Close split';
194
+ close.setAttribute('aria-label', 'Close split');
195
+ close.textContent = '\u00D7';
196
+ close.addEventListener('click', () => this.closeSplitPane());
197
+ headerB.el.appendChild(close);
198
+ const bodyB = document.createElement('div');
199
+ bodyB.className = 'terminal-pane-b-container';
200
+ paneB.append(headerB.el, bodyB);
201
+ // Pane A is the main terminal, which has no header of its own: while the
202
+ // split is open it gets the same strip, so each session in the split view
203
+ // names its harness and model. It takes height from the main terminal,
204
+ // which the opening resize below fits through syncTerminalGeometry (#464);
205
+ // closeSplitPane gives it back.
206
+ const headerA = this._buildSplitPaneHeader();
207
+ headerA.el.classList.add('terminal-pane-a-header');
208
+ this._splitHeaders = { a: headerA, b: headerB };
813
209
 
814
210
  parent.insertBefore(container, wrap);
211
+ wrap.insertBefore(headerA.el, wrap.firstChild);
815
212
  container.appendChild(wrap);
816
213
  wrap.style.flexBasis = '50%';
817
214
  container.appendChild(divider);
818
215
  container.appendChild(paneB);
819
216
  paneB.style.flexBasis = '50%';
820
217
 
821
- this._splitPane = new window.SplitTerminalPane(sessionId, paneB.querySelector('.terminal-pane-b-container'), {
218
+ this._splitPane = new window.TerminalTile(sessionId, bodyB, {
822
219
  mode: session?.mode,
823
220
  fontSettings: this.loadAppSettingsFromStorage?.() || {},
824
221
  detachedSessions: this.detachedSessions,
@@ -828,6 +225,7 @@ Object.assign(CodemanApp.prototype, {
828
225
  initial load — live output still arrives once/if the socket connects. */
829
226
  });
830
227
  this._splitSessionId = sessionId;
228
+ this._renderSplitChrome();
831
229
 
832
230
  // Pane A just went from full width to 50%, but nothing has told its
833
231
  // session's PTY/tmux window about it yet — the passive ResizeObserver in
@@ -855,6 +253,9 @@ Object.assign(CodemanApp.prototype, {
855
253
  this._splitPane.destroy();
856
254
  this._splitPane = null;
857
255
  this._splitSessionId = null;
256
+ // Before the refit below, so the main terminal gets its full height back.
257
+ this._splitHeaders?.a.el.remove();
258
+ this._splitHeaders = null;
858
259
  this._updateSplitButtonState(false);
859
260
 
860
261
  const container = document.querySelector('.terminal-split-container');
@@ -865,14 +266,16 @@ Object.assign(CodemanApp.prototype, {
865
266
  parent.insertBefore(wrap, container);
866
267
  container.remove();
867
268
 
868
- if (this.fitAddon) this.fitAddon.fit();
269
+ // Pane A takes the whole width back and, with its header strip gone, its
270
+ // whole height: through syncTerminalGeometry (#464), never a bare
271
+ // fitAddon.fit(). sendResize fits that way as its first step.
869
272
  // The Pane-A-ends branch of the _onSessionDeleted wrapper below collapses the split
870
273
  // while activeSessionId is still the id the server just removed, so a
871
274
  // resize from here would be aimed at a session that no longer exists;
872
- // the promoted session gets its own resize from selectSession().
873
- if (!options.skipPrimaryResize) {
874
- this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
875
- }
275
+ // the promoted session gets its own resize from selectSession(), and the
276
+ // terminal is only refitted here.
277
+ if (options.skipPrimaryResize) this.syncTerminalGeometry?.();
278
+ else this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
876
279
  },
877
280
 
878
281
  // A click on .btn-split does one of two things — open the picker, or
@@ -880,6 +283,92 @@ Object.assign(CodemanApp.prototype, {
880
283
  // nothing on the button said which. `.split-open` + aria-pressed give it
881
284
  // the same active-state language as the codebase's other toggle buttons
882
285
  // (keyboard-accessory's Ctrl key, the voice-input mic).
286
+ /**
287
+ * A split pane's header strip: the harness logo, the session name and the
288
+ * model, the three a grid tile's header shows. Built from nodes (the name
289
+ * and the model are untrusted text) and painted by _renderSplitChrome.
290
+ */
291
+ _buildSplitPaneHeader() {
292
+ const el = document.createElement('div');
293
+ el.className = 'terminal-pane-b-header';
294
+ const harness = document.createElement('span');
295
+ harness.className = 'split-harness run-mode-dot';
296
+ harness.setAttribute('role', 'img');
297
+ const title = document.createElement('span');
298
+ title.className = 'split-title';
299
+ const name = document.createElement('span');
300
+ // `.session-name` is one of the translator's skipped surfaces (user text).
301
+ name.className = 'session-name';
302
+ // As on a tile: the name inside is never translated, the tooltip may be,
303
+ // and screen readers hear the model once, in the logo's accessible name.
304
+ const model = document.createElement('span');
305
+ model.className = 'split-model';
306
+ model.setAttribute('aria-hidden', 'true');
307
+ model.hidden = true;
308
+ const modelName = document.createElement('span');
309
+ modelName.setAttribute('data-i18n-skip', '');
310
+ model.appendChild(modelName);
311
+ title.append(name, model);
312
+ el.append(harness, title);
313
+ return { el, harness, name, model, modelName };
314
+ },
315
+
316
+ /**
317
+ * Both split headers from their sessions: Pane A shows the active session,
318
+ * Pane B its own. Runs after every tab render, so a rename or a model
319
+ * change reaches them; unchanged values write nothing.
320
+ */
321
+ _renderSplitChrome() {
322
+ const headers = this._splitHeaders;
323
+ if (!headers || !this._splitPane) return;
324
+ for (const [parts, id] of [
325
+ [headers.a, this.activeSessionId],
326
+ [headers.b, this._splitSessionId],
327
+ ]) {
328
+ const session = id ? this.sessions.get(id) : null;
329
+ if (!session) continue;
330
+ const name = this.getSessionName?.(session) || session.name || 'Session';
331
+ if (parts.nameValue !== name) {
332
+ parts.nameValue = name;
333
+ parts.name.textContent = name;
334
+ }
335
+ this._paintSessionHarness(parts, session, 'split-harness');
336
+ }
337
+ },
338
+
339
+ /**
340
+ * Paints a session header's harness logo and model: a grid tile's, and the
341
+ * split panes'. The logo is PR #532's `run-mode-dot <cliId>` slot (the id is
342
+ * data, never a branch), the model is text (describeSessionHarness,
343
+ * constants.js). Diffs against the values it last wrote, kept on `parts`,
344
+ * never against the DOM, which the translator may have rewritten: an
345
+ * unchanged session writes nothing, and this runs on every tab render.
346
+ *
347
+ * @param {{harness: HTMLElement, model: HTMLElement, modelName: HTMLElement}} parts - the
348
+ * header's nodes (the model's box and the name inside it); the memo lives here too
349
+ * @param {object} session - the session the header shows
350
+ * @param {string} logoClass - the header's own class for its logo
351
+ */
352
+ _paintSessionHarness(parts, session, logoClass) {
353
+ const harness = window.CodemanSessionHarness.describeSessionHarness(session, window.__codemanCliCatalog);
354
+ const cls = `${logoClass} run-mode-dot${harness.id ? ` ${harness.id}` : ''}`;
355
+ if (parts.harnessClass !== cls) {
356
+ parts.harnessClass = cls;
357
+ parts.harness.className = cls;
358
+ }
359
+ if (parts.harnessTitle !== harness.title) {
360
+ parts.harnessTitle = harness.title;
361
+ parts.harness.title = harness.title;
362
+ parts.harness.setAttribute('aria-label', harness.title);
363
+ parts.model.title = harness.title;
364
+ }
365
+ if (parts.modelValue !== harness.model) {
366
+ parts.modelValue = harness.model;
367
+ parts.modelName.textContent = harness.model;
368
+ parts.model.hidden = !harness.model;
369
+ }
370
+ },
371
+
883
372
  _updateSplitButtonState(open) {
884
373
  const btn = document.querySelector('.btn-split');
885
374
  if (!btn) return;
@@ -900,10 +389,9 @@ Object.assign(CodemanApp.prototype, {
900
389
  // frame). Coalesced to one call per animation frame below — a raw
901
390
  // mousemove stream fires far faster than the browser repaints, and
902
391
  // without the rAF gate each event did a full xterm reflow on BOTH
903
- // panes AND sent Pane B a `{t:'z'}` resize frame (SplitTerminalPane has
904
- // no client-side "dims unchanged" skip), which fanned out into a
905
- // `tmux resize-window` child plus a SIGWINCH per frame — roughly fifty
906
- // of each dragging across half a wide viewport.
392
+ // panes AND sent Pane B a `{t:'z'}` resize frame, which fanned out
393
+ // into a `tmux resize-window` child plus a SIGWINCH per frame, roughly
394
+ // fifty of each dragging across half a wide viewport.
907
395
  const applyDragPercent = (clientX) => {
908
396
  const container = divider.parentElement;
909
397
  // The split can auto-collapse mid-drag (the other pane's session
@@ -1039,6 +527,15 @@ CodemanApp.prototype._onSessionDeleted = function (data) {
1039
527
  // this, Pane A rebinds to a session that Pane B's independent WebSocket is
1040
528
  // still attached to — two live WebSockets to one session, each claiming PTY
1041
529
  // dimensions via its own `{t:'z',...}` resize frame.
530
+ // Every tab render (any session change: a rename, a model switch) refreshes
531
+ // the split headers too, the way tile-grid.js refreshes the tile headers.
532
+ const _splitOriginalRenderSessionTabsImmediate = CodemanApp.prototype._renderSessionTabsImmediate;
533
+ CodemanApp.prototype._renderSessionTabsImmediate = function (...args) {
534
+ const result = _splitOriginalRenderSessionTabsImmediate.apply(this, args);
535
+ this._renderSplitChrome?.();
536
+ return result;
537
+ };
538
+
1042
539
  const _originalSelectSession = CodemanApp.prototype.selectSession;
1043
540
  CodemanApp.prototype.selectSession = function (sessionId, ...args) {
1044
541
  if (this._splitPane && this._splitSessionId === sessionId) {