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
@@ -110,6 +110,9 @@ const TITLE_FLASH_INTERVAL_MS = 1500; // Title flash rate
110
110
  const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notifications
111
111
  const MOBILE_RESIZE_RETRY_MS = 30000; // Small-viewport resize re-send while a desktop sizing claim is hot
112
112
  const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
113
+ const DEFAULT_TOAST_DURATION_MS = 3000; // How long a corner toast stays by default
114
+ const MIN_NOTIFICATION_DURATION_MS = 1000; // Shortest configurable toast / browser-notification time
115
+ const MAX_NOTIFICATION_DURATION_MS = 300000; // Longest configurable toast / browser-notification time
113
116
  const THROTTLE_DELAY_MS = 100; // General UI throttle delay
114
117
  const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading
115
118
  const TERMINAL_TAIL_SIZE = 1024 * 1024; // 1MB tail for initial load (more scrollback on tab switch)
@@ -207,6 +210,9 @@ function shouldAutoWrapTabs(input) {
207
210
  if (!input || input.deviceType !== 'desktop') return false;
208
211
  if (input.manualTwoRows) return false;
209
212
  if ((input.tabCount || 0) < 2) return false;
213
+ // A box in the strip already wraps inside itself (a case cluster wider than the
214
+ // whole strip): the strip has rows although nothing overflows.
215
+ if (input.innerWrap) return true;
210
216
 
211
217
  const scrollWidth = Number(input.scrollWidth) || 0;
212
218
  const clientWidth = Number(input.clientWidth) || 0;
@@ -296,139 +302,307 @@ function computeTabScrollLeft(input) {
296
302
  return Math.min(Math.max(Math.round(target), 0), maxScroll);
297
303
  }
298
304
 
299
- // Session lineage lines — geometry for the arc drawn between a tab and a tab it
305
+ // Session lineage lines: geometry for the lines joining a tab to the tabs it
300
306
  // spawned (a worker started through the codeman agent skill, which passes its own
301
307
  // id as parentSessionId). Pure: the caller measures and appends, this decides.
302
308
  //
303
- // ONE shape, because both endpoints live in the same horizontal strip and the subagent
304
- // shape (tab-bottom → window-top) has nothing to aim at: a U-bridge HANGING BELOW the
305
- // strip, from the parent's bottom edge to the child's bottom edge, so it reads as a
306
- // bracket joining two tabs rather than as a line crossing them. The dip grows with
307
- // horizontal distance and with `depth` (the child's index among its siblings), so
308
- // several children of one parent nest instead of overprinting.
309
+ // ONE TREE PER SPAWNING TAB. Every family is drawn, the selected tab's emphasized
310
+ // (session-lineage.js). Every route starts at the PARENT and ends at one child, so
311
+ // a parent's routes share their first stretch exactly: overlaid, they read as one
312
+ // trunk with a branch per child, and a dashed (working) route stays in phase with
313
+ // its siblings along the shared part.
309
314
  //
310
- // ⚠ A WRAPPED STRIP USED TO GET ITS OWN SHAPE, AND THAT SHAPE WAS THE BUG. When the
311
- // desktop strip wraps (`tabs-two-rows` / `tabs-auto-wrap`) a parent on row 1 and its
312
- // child on row 2 are ~4px apart vertically, so the old parent-bottom → child-TOP bezier
313
- // had a 4px span to work with and drew a flat horizontal line inside the row gap
314
- // (reported as "they connect already, but the lines are straight and not easy visible"),
315
- // and three siblings drew three of them on top of each other. Aiming BOTH ends at the
316
- // tab BOTTOMS and putting the control points below the LOWER row gives the wrapped case
317
- // the same bracket as the flat case: it leaves the parent downward, crosses the lower
318
- // row once, and comes back up under the child. Same formula, no branch.
315
+ // ⚠ ROUTES RUN IN THE GAPS, NEVER THROUGH A TAB. This replaced one bezier per
316
+ // child hanging below the strip, which in a wrapped strip crossed every lower
317
+ // row's labels and the terminal text (owner screenshot 2026-10-06: a parent on
318
+ // row 3 with ten children, "too confusing"). A route now moves horizontally only
319
+ // inside a row gap, and vertically only along a tab's own stem (its bottom edge to
320
+ // the gap right under it) or along the SPINE, a channel left of every row that
321
+ // joins the gaps of different rows. styles.css reserves that room
322
+ // (`.session-tabs.lineage-tree`: a wider row gap, bottom padding for the last
323
+ // row's gap, and the spine channel on the left of a wrapped strip).
319
324
  //
320
- // Returns null when the edge must not be drawn: a missing/degenerate rect, or an
321
- // endpoint scrolled outside the strip. `.session-tabs` is `overflow-x: auto`, so a
322
- // scrolled-out tab still HAS a rect — one lying over the logo or the header
323
- // buttons. Skipping is honest; clamping would point at a tab that isn't there.
324
- // ⚠ THE DIP IS WHAT MAKES THE ARC AN ARC, and it has now been mis-tuned in BOTH
325
- // directions, so treat these numbers as a corridor rather than a dial to crank:
326
- // - Too shallow (the first ship, 44px cap): a skill worker is appended to the END of
327
- // the strip, so a lead-to-worker span is 800-1500px, and a 44px cap over 1300px is
328
- // a 33px sag, a line that reads as STRAIGHT across the terminal (#285).
329
- // - Too deep (the 104px cap that replaced it): in the wrapped-strip case the cap and
330
- // the FULL row offset stacked, bowing the bracket ~106px into the terminal text
331
- // (owner screenshot 2026-08-15, "die Linien machen einen grossen Bogen nach unten").
332
- // The dip is measured from the STRIP'S BOTTOM EDGE (falling back to the lower tab
333
- // bottom when the strip rect is missing or shorter than its tabs), which buys two
334
- // things at once: the bow needs no per-row offsets stacked on top, and a same-row
335
- // arc between ROW-1 tabs of a wrapped strip clears row 2's labels instead of being
336
- // drawn through them (the retune's own first draft had exactly that regression).
337
- const LINEAGE_DIP_BASE_PX = 14;
338
- const LINEAGE_DIP_PER_PX = 0.06;
339
- const LINEAGE_DIP_MIN_PX = 22;
340
- const LINEAGE_DIP_MAX_PX = 64;
341
- // Siblings nest by this much. Widened with the stroke: at 2.5px plus its glow, arcs 6px
342
- // apart bled into one thick band instead of reading as three separate lines.
343
- const LINEAGE_SIBLING_STEP_PX = 8;
325
+ // The channel is at the strip's left edge, except where a tab arrangement puts
326
+ // something there: grouped by state, the edge holds the label column and the
327
+ // channel opens between the labels and the tabs. session-lineage.js measures it
328
+ // and passes its left edge as `spineLeft`; without one it is the strip's edge.
329
+ //
330
+ // Rows come from computeLineageRows() over EVERY tab in the strip, not only the
331
+ // endpoints: a row's gap sits under its TALLEST tab (the active tab is 2px
332
+ // taller), or siblings in one row would hang their bus at different heights.
333
+ //
334
+ // computeLineageTree() returns null when the parent cannot be drawn (missing or
335
+ // degenerate rect, scrolled out of the strip); a child that cannot be drawn is
336
+ // left out of `routes`. `.session-tabs` scrolls, so a scrolled-out tab still HAS a
337
+ // rect, lying over the logo or the header buttons. Skipping is honest; clamping
338
+ // would point at a tab that is not there.
339
+ //
340
+ // A child is also left out when its route has nowhere to run: rows whose spans
341
+ // overlap are one row (no gap between them), a row with no gap under it routes
342
+ // nothing, and a route that would still cross a tab is dropped whole. So no tab
343
+ // arrangement can put a line through a tab; a layout without the reserved room
344
+ // loses lines instead.
345
+ const LINEAGE_CORNER_RADIUS_PX = 10;
346
+ // Families drawn together take separate lanes: gap lines this far apart, spines
347
+ // LINEAGE_SPINE_STEP_PX apart.
348
+ const LINEAGE_LANE_STEP_PX = 3.5;
349
+ // Every family is drawn at once, and a 12px row gap only fits this many lanes at
350
+ // LINEAGE_LANE_STEP_PX; session-lineage.js cycles families through them.
351
+ const LINEAGE_MAX_LANES = 3;
352
+ const LINEAGE_SPINE_INSET_PX = 6;
353
+ const LINEAGE_SPINE_STEP_PX = 4;
354
+ // The last row has no row below it; with no strip rect to measure, its gap is
355
+ // taken to be this deep.
356
+ const LINEAGE_LAST_GAP_PX = 12;
357
+ // Narrower than this, the space between two rows is not a gap a route can run in.
358
+ const LINEAGE_MIN_GAP_PX = 2;
359
+ const LINEAGE_ROW_TOLERANCE_PX = 6;
344
360
  const LINEAGE_STRIP_TOLERANCE_PX = 4;
345
- // How far the vertical bracket sits in from the rail's left edge. It has to
346
- // clear the VIEWPORT edge, not just the tabs: the line carries an 11px outer
347
- // glow, so a track at 6px had half of that glow clipped away and the arc read
348
- // as a thin thread pinned to the window frame. The rail reserves the channel
349
- // itself (`--lineage-vertical-gutter` on the rail's .session-tabs), and
350
- // computeLineagePath still clamps the track to stay left of both tabs.
361
+ // How far the vertical rail's track sits in from the rail's left edge. It has to
362
+ // clear the VIEWPORT edge, not just the tabs, or the line reads as a thread pinned
363
+ // to the window frame. The rail reserves the channel itself
364
+ // (`--lineage-vertical-gutter` on the rail's .session-tabs), and the track is
365
+ // still clamped to stay left of every endpoint.
351
366
  const LINEAGE_VERTICAL_TRACK_INSET_PX = 10;
352
- const LINEAGE_VERTICAL_SIBLING_STEP_PX = 3;
367
+ const LINEAGE_VERTICAL_LANE_STEP_PX = 4;
353
368
  const LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX = 4;
354
369
  // Lineage palette, assigned per SPAWNING TAB in first-seen order and cycled
355
- // (session-lineage.js). Every arc leaving one tab shares its colour however many
356
- // workers it spawns; a child that spawns in turn gets its own for the arcs below it.
370
+ // (session-lineage.js). Every line leaving one tab shares its colour however many
371
+ // workers it spawns; a child that spawns in turn gets its own for the lines below it.
357
372
  // The empty FIRST entry means "no override": the CSS then falls back to --session-blue,
358
- // which every skin block tunes for its own background, so a lone arc keeps the
373
+ // which every skin block tunes for its own background, so a lone family keeps the
359
374
  // skin-aware blue that shipped in 1.18.2. The fixed entries are deliberately vivid
360
- // (owner call 2026-08-15: matrix green, pinkish, violet, red, turquoise "and so on");
361
- // they ride the same double glow as the blue, which is what keeps them legible over
362
- // terminal text on every skin.
375
+ // (owner call 2026-08-15: matrix green, pinkish, violet, red, turquoise "and so on").
363
376
  const LINEAGE_COLORS = ['', '#00ff66', '#ff5ea8', '#a78bfa', '#ff5252', '#2dd4bf', '#ffa940'];
364
377
 
365
- function computeLineagePath(input) {
366
- const parent = input?.parent;
367
- const child = input?.child;
368
- if (!parent || !child) return null;
378
+ /** A rect normalized to numbers with its edges and center, or null if unusable. */
379
+ function lineageRect(rect) {
380
+ if (!rect) return null;
381
+ const left = Number(rect.left);
382
+ const top = Number(rect.top);
383
+ const width = Number(rect.width);
384
+ const height = Number(rect.height);
385
+ if (![left, top, width, height].every(Number.isFinite) || width <= 0 || height <= 0) return null;
386
+ return {
387
+ left,
388
+ top,
389
+ width,
390
+ height,
391
+ right: left + width,
392
+ bottom: top + height,
393
+ cx: left + width / 2,
394
+ cy: top + height / 2,
395
+ };
396
+ }
397
+
398
+ /**
399
+ * Group tab rects into the strip's visual rows, top to bottom. A row spans from its
400
+ * highest top to its LOWEST bottom, so a taller tab (the active one) sets the row's
401
+ * gap for everyone in it.
402
+ *
403
+ * ⚠ Rows never overlap. Tabs whose spans overlap are in one row however far apart
404
+ * their tops are: case clusters stack and centre tabs inside their boxes, and two
405
+ * overlapping "rows" put the gap of one in the middle of the other's tabs.
406
+ */
407
+ function computeLineageRows(rects) {
408
+ const sorted = [];
409
+ for (const raw of rects || []) {
410
+ const r = lineageRect(raw);
411
+ if (r) sorted.push(r);
412
+ }
413
+ sorted.sort((a, b) => a.top - b.top);
414
+ const rows = [];
415
+ for (const r of sorted) {
416
+ // Sorted by top, so only the last row can take this rect, and merging into it
417
+ // keeps every earlier row clear of it.
418
+ const row = rows[rows.length - 1];
419
+ if (row && (r.top < row.bottom || r.top - row.top <= LINEAGE_ROW_TOLERANCE_PX)) {
420
+ row.bottom = Math.max(row.bottom, r.bottom);
421
+ } else {
422
+ rows.push({ top: r.top, bottom: r.bottom });
423
+ }
424
+ }
425
+ return rows;
426
+ }
427
+
428
+ /** Does an axis-aligned segment pass through the inside of a rect? Touching an edge is fine. */
429
+ function lineageSegmentCrosses([x1, y1], [x2, y2], r) {
430
+ const eps = 0.5;
431
+ if (Math.max(x1, x2) <= r.left + eps || Math.min(x1, x2) >= r.right - eps) return false;
432
+ return Math.max(y1, y2) > r.top + eps && Math.min(y1, y2) < r.bottom - eps;
433
+ }
369
434
 
370
- const pw = Number(parent.width) || 0;
371
- const ph = Number(parent.height) || 0;
372
- const cw = Number(child.width) || 0;
373
- const ch = Number(child.height) || 0;
374
- if (pw <= 0 || ph <= 0 || cw <= 0 || ch <= 0) return null;
435
+ /**
436
+ * An orthogonal polyline as an SVG path, each corner rounded by up to `radius`
437
+ * (never more than half of either segment, so short stems stay short). Repeated and
438
+ * collinear points are dropped first, so a degenerate corner draws nothing odd.
439
+ */
440
+ function lineagePolylinePath(points, radius) {
441
+ const pts = [];
442
+ for (const p of points) {
443
+ const prev = pts[pts.length - 1];
444
+ if (prev && Math.abs(prev[0] - p[0]) < 0.5 && Math.abs(prev[1] - p[1]) < 0.5) continue;
445
+ const before = pts[pts.length - 2];
446
+ if (before && prev) {
447
+ const cross = (prev[0] - before[0]) * (p[1] - prev[1]) - (prev[1] - before[1]) * (p[0] - prev[0]);
448
+ if (Math.abs(cross) < 0.01) pts.pop();
449
+ }
450
+ pts.push(p);
451
+ }
452
+ if (pts.length < 2) return null;
453
+ let d = `M ${r1(pts[0][0])} ${r1(pts[0][1])}`;
454
+ for (let i = 1; i < pts.length - 1; i++) {
455
+ const [x0, y0] = pts[i - 1];
456
+ const [x1, y1] = pts[i];
457
+ const [x2, y2] = pts[i + 1];
458
+ const lenIn = Math.hypot(x1 - x0, y1 - y0);
459
+ const lenOut = Math.hypot(x2 - x1, y2 - y1);
460
+ const r = Math.min(radius, lenIn / 2, lenOut / 2);
461
+ if (!(r > 0.5)) {
462
+ d += ` L ${r1(x1)} ${r1(y1)}`;
463
+ continue;
464
+ }
465
+ const ax = x1 - ((x1 - x0) / lenIn) * r;
466
+ const ay = y1 - ((y1 - y0) / lenIn) * r;
467
+ const bx = x1 + ((x2 - x1) / lenOut) * r;
468
+ const by = y1 + ((y2 - y1) / lenOut) * r;
469
+ d += ` L ${r1(ax)} ${r1(ay)} Q ${r1(x1)} ${r1(y1)} ${r1(bx)} ${r1(by)}`;
470
+ }
471
+ const last = pts[pts.length - 1];
472
+ return d + ` L ${r1(last[0])} ${r1(last[1])}`;
473
+ }
375
474
 
475
+ /**
476
+ * Routes from one parent tab to each of its children.
477
+ *
478
+ * input: { parent, children: [{ id, rect }], strip?, tabs?, spineLeft?,
479
+ * orientation?, lane?, laneCount?, radius? }. `tabs` is every tab rect in
480
+ * the strip (rows are derived from it); `spineLeft` is the left edge of the
481
+ * spine channel (default: the strip's left edge); `lane`/`laneCount`
482
+ * separate families drawn together.
483
+ * Returns { routes: [{ id, points, d, endX, endY }] } or null.
484
+ */
485
+ function computeLineageTree(input) {
486
+ const parent = lineageRect(input?.parent);
487
+ if (!parent) return null;
488
+ const strip = lineageRect(input?.strip);
376
489
  const orientation = input?.orientation === 'vertical' ? 'vertical' : 'horizontal';
377
- const strip = input?.strip;
378
- const depth = Math.max(0, Math.min(6, Number(input?.depth) || 0));
379
- const pLeft = Number(parent.left);
380
- const cLeft = Number(child.left);
381
- const pTop = Number(parent.top);
382
- const cTop = Number(child.top);
383
- if (![pLeft, cLeft, pTop, cTop].every(Number.isFinite)) return null;
490
+ const laneCount = Math.max(1, Math.floor(Number(input?.laneCount)) || 1);
491
+ const lane = Math.max(0, Math.min(laneCount - 1, Math.floor(Number(input?.lane)) || 0));
492
+ const radius = Number.isFinite(Number(input?.radius)) ? Math.max(0, Number(input.radius)) : LINEAGE_CORNER_RADIUS_PX;
493
+ const children = [];
494
+ for (const child of input?.children || []) {
495
+ const rect = lineageRect(child?.rect);
496
+ if (rect) children.push({ id: child.id, rect });
497
+ }
498
+
499
+ const tol = LINEAGE_STRIP_TOLERANCE_PX;
500
+ const inStripY = (r) => !strip || (r.cy >= strip.top - tol && r.cy <= strip.bottom + tol);
501
+ const inStripX = (r) => !strip || (r.cx >= strip.left - tol && r.cx <= strip.right + tol);
502
+ const routes = [];
384
503
 
385
504
  if (orientation === 'vertical') {
386
- const py = pTop + ph / 2;
387
- const cy = cTop + ch / 2;
388
- if (strip && Number(strip.height) > 0) {
389
- const min = Number(strip.top) - LINEAGE_STRIP_TOLERANCE_PX;
390
- const max = Number(strip.top) + Number(strip.height) + LINEAGE_STRIP_TOLERANCE_PX;
391
- if (py < min || py > max || cy < min || cy > max) return null;
505
+ // The rail: one track down the empty left gutter, shared by every sibling.
506
+ if (!inStripY(parent)) return null;
507
+ const visible = children.filter((c) => inStripY(c.rect));
508
+ if (visible.length === 0) return { routes };
509
+ const minLeft = Math.min(parent.left, ...visible.map((c) => c.rect.left));
510
+ const base = strip ? strip.left : minLeft - LINEAGE_VERTICAL_TRACK_INSET_PX * 2;
511
+ const trackX = Math.min(
512
+ base + LINEAGE_VERTICAL_TRACK_INSET_PX + lane * LINEAGE_VERTICAL_LANE_STEP_PX,
513
+ minLeft - LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX
514
+ );
515
+ for (const { id, rect } of visible) {
516
+ const points = [
517
+ [parent.left, parent.cy],
518
+ [trackX, parent.cy],
519
+ [trackX, rect.cy],
520
+ [rect.left, rect.cy],
521
+ ];
522
+ const d = lineagePolylinePath(points, radius);
523
+ if (d) routes.push({ id, points: roundPoints(points), d, endX: r1(rect.left), endY: r1(rect.cy) });
392
524
  }
393
-
394
- const stripLeft =
395
- strip && Number.isFinite(Number(strip.left))
396
- ? Number(strip.left)
397
- : Math.min(pLeft, cLeft) - LINEAGE_VERTICAL_TRACK_INSET_PX * 2;
398
- const requestedTrack =
399
- stripLeft + LINEAGE_VERTICAL_TRACK_INSET_PX + depth * LINEAGE_VERTICAL_SIBLING_STEP_PX;
400
- const trackX = Math.min(requestedTrack, Math.min(pLeft, cLeft) - LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX);
401
- const d = `M ${r1(pLeft)} ${r1(py)} H ${r1(trackX)} V ${r1(cy)} H ${r1(cLeft)}`;
402
- return { d, endX: cLeft, endY: cy, sameRow: false };
525
+ return { routes };
403
526
  }
404
527
 
405
- const px = pLeft + pw / 2;
406
- const cx = cLeft + cw / 2;
407
- if (strip && Number(strip.width) > 0) {
408
- const min = Number(strip.left) - LINEAGE_STRIP_TOLERANCE_PX;
409
- const max = Number(strip.left) + Number(strip.width) + LINEAGE_STRIP_TOLERANCE_PX;
410
- if (px < min || px > max || cx < min || cx > max) return null;
528
+ if (!inStripX(parent) || !inStripY(parent)) return null;
529
+ const visible = children.filter((c) => inStripX(c.rect) && inStripY(c.rect));
530
+ if (visible.length === 0) return { routes };
531
+
532
+ const rows = computeLineageRows([...(input?.tabs || []), parent, ...visible.map((c) => c.rect)]);
533
+ const rowOf = (r) => rows.findIndex((row) => r.cy >= row.top - tol && r.cy <= row.bottom + tol);
534
+ const laneOffset = (lane - (laneCount - 1) / 2) * LINEAGE_LANE_STEP_PX;
535
+ // Y of the gap under row i, this family's lane. The offset is clamped so a busy
536
+ // gap never pushes a lane into the tabs on either side of it. Null when there is
537
+ // no gap: the row is not found, or the next row starts (nearly) where it ends.
538
+ const gapUnder = (i) => {
539
+ const row = rows[i];
540
+ if (!row) return null;
541
+ const next = rows[i + 1];
542
+ let bottom;
543
+ if (next) bottom = next.top;
544
+ else if (strip && strip.bottom > row.bottom + 1) bottom = strip.bottom;
545
+ else bottom = row.bottom + LINEAGE_LAST_GAP_PX;
546
+ if (!(bottom - row.bottom >= LINEAGE_MIN_GAP_PX)) return null;
547
+ const half = Math.max(0, (bottom - row.bottom) / 2 - 1);
548
+ return (row.bottom + bottom) / 2 + Math.max(-half, Math.min(half, laneOffset));
549
+ };
550
+ const pRow = rowOf(parent);
551
+ const gp = gapUnder(pRow);
552
+ if (gp === null) return { routes };
553
+ // The spine runs from one row's gap to another's, so it only ever passes BESIDE
554
+ // rows after the first, and only their tabs bound it. The first row may start
555
+ // left of the channel (grouped by state it starts after the brand and a label
556
+ // of its own width), and clamping to it would pull the spine back over the
557
+ // label column.
558
+ const lowerLefts = [parent, ...visible.map((c) => c.rect), ...(input?.tabs || []).map(lineageRect)]
559
+ .filter((r) => r && rowOf(r) > 0)
560
+ .map((r) => r.left);
561
+ const minLeft = lowerLefts.length
562
+ ? Math.min(...lowerLefts)
563
+ : Math.min(parent.left, ...visible.map((c) => c.rect.left));
564
+ const channelLeft = Number(input?.spineLeft);
565
+ const spineBase = strip
566
+ ? Math.max(strip.left, Number.isFinite(channelLeft) ? channelLeft : strip.left)
567
+ : minLeft - LINEAGE_SPINE_INSET_PX * 2;
568
+ const spineX = Math.min(spineBase + LINEAGE_SPINE_INSET_PX + lane * LINEAGE_SPINE_STEP_PX, minLeft - 2);
569
+
570
+ // Every tab a route must stay out of. A segment can only cross one if the rows
571
+ // above failed to describe the layout, so this is a backstop that drops the
572
+ // route whole rather than drawing it through a tab.
573
+ const obstacles = [parent, ...visible.map((c) => c.rect), ...(input?.tabs || []).map(lineageRect)].filter(Boolean);
574
+ for (const { id, rect } of visible) {
575
+ const cRow = rowOf(rect);
576
+ const gc = gapUnder(cRow);
577
+ if (gc === null) continue;
578
+ const points =
579
+ cRow === pRow
580
+ ? [
581
+ [parent.cx, parent.bottom],
582
+ [parent.cx, gp],
583
+ [rect.cx, gp],
584
+ [rect.cx, rect.bottom],
585
+ ]
586
+ : [
587
+ [parent.cx, parent.bottom],
588
+ [parent.cx, gp],
589
+ [spineX, gp],
590
+ [spineX, gc],
591
+ [rect.cx, gc],
592
+ [rect.cx, rect.bottom],
593
+ ];
594
+ // The rounded corners cut inside each turn by a few pixels at most
595
+ // (lineagePolylinePath), so the segments are what has to clear the tabs.
596
+ const blocked = points.some((p, i) => i > 0 && obstacles.some((r) => lineageSegmentCrosses(points[i - 1], p, r)));
597
+ if (blocked) continue;
598
+ const d = lineagePolylinePath(points, radius);
599
+ if (d) routes.push({ id, points: roundPoints(points), d, endX: r1(rect.cx), endY: r1(rect.bottom) });
411
600
  }
601
+ return { routes };
602
+ }
412
603
 
413
- const pBottom = pTop + ph;
414
- const cBottom = cTop + ch;
415
- const sameRow = Math.abs(pTop + ph / 2 - (cTop + ch / 2)) <= Math.min(ph, ch) / 2;
416
-
417
- // Both ends anchor on the tab BOTTOM, and the control points hang below the WHOLE
418
- // strip, so one formula covers a flat strip, a wrapped pair, and a same-row pair
419
- // sitting above further rows (see the corridor note above the constants).
420
- const span = Math.abs(cx - px);
421
- const stripBottom =
422
- strip && Number(strip.height) > 0 && Number.isFinite(Number(strip.top))
423
- ? Number(strip.top) + Number(strip.height)
424
- : Number.NEGATIVE_INFINITY;
425
- const baseline = Math.max(pBottom, cBottom, stripBottom);
426
- const dip =
427
- Math.min(LINEAGE_DIP_MAX_PX, Math.max(LINEAGE_DIP_MIN_PX, LINEAGE_DIP_BASE_PX + span * LINEAGE_DIP_PER_PX)) +
428
- depth * LINEAGE_SIBLING_STEP_PX;
429
- const yc = baseline + dip;
430
- const d = `M ${r1(px)} ${r1(pBottom)} C ${r1(px)} ${r1(yc)}, ${r1(cx)} ${r1(yc)}, ${r1(cx)} ${r1(cBottom)}`;
431
- return { d, endX: cx, endY: cBottom, sameRow };
604
+ function roundPoints(points) {
605
+ return points.map(([x, y]) => [r1(x), r1(y)]);
432
606
  }
433
607
 
434
608
  // One decimal is plenty for a screen-space path and keeps the `d` string short.
@@ -658,6 +832,244 @@ function sortSessionsByActivity(rows) {
658
832
  return (Array.isArray(rows) ? rows.slice() : []).sort(compareSessionActivity);
659
833
  }
660
834
 
835
+ // Tab grouping by state (`tabArrangement: 'state'`, Discussion #426 option C).
836
+ //
837
+ // The tab list answers "who wants me?" the way the home screens do: a row (the
838
+ // header strip) or a section (the flat side rail, the sidebar) per state, most
839
+ // urgent on top. The states are the home screens' own, from
840
+ // `_mobileOverviewState()` (mobile-overview.js); this only folds the six into
841
+ // four groups a strip can hold:
842
+ //
843
+ // needs red: a permission or question dialog is blocking the agent. A
844
+ // failed session joins it, since it also needs a human and the home
845
+ // screens rank it right below.
846
+ // waiting yellow: the agent finished its turn and is waiting on you.
847
+ // working a turn is running.
848
+ // idle everything quiet: idle, ended, and an agent that exited inside a
849
+ // live pane (#446), which may still read as working on screen but is
850
+ // running nothing. Web tabs close the group.
851
+ //
852
+ // Applied as the flex `order` property, never by reordering the DOM, the same
853
+ // design as the sorted rail (`_tabRailSortOrder`, app.js): `#sessionTabs` stays
854
+ // in `sessionOrder`, so Alt+N, drag-and-drop and the keyboard walk keep reading
855
+ // the list they always read, and a state change moves one inline style instead
856
+ // of rebuilding the strip. Each group owns a band of `TAB_TRIAGE_STRIDE` order
857
+ // values: its heading at the start of the band, its rows after it, its web tabs
858
+ // after those and the line break that ends the header row at the very end.
859
+ //
860
+ // Pure: no DOM, no `this`. Unit-tested in test/tab-triage.test.ts.
861
+ // `quiet` groups keep their heading element (it anchors the row and holds the
862
+ // row's place beside the brand) but draw no text: everything quiet is the
863
+ // default state of a tab, so naming it only adds noise.
864
+ const TAB_TRIAGE_GROUPS = [
865
+ { key: 'needs', label: 'Needs you' },
866
+ { key: 'waiting', label: 'Waiting' },
867
+ { key: 'working', label: 'Working' },
868
+ { key: 'idle', label: 'Idle', quiet: true },
869
+ ];
870
+
871
+ const TAB_TRIAGE_GROUP_OF_STATE = {
872
+ needs: 'needs',
873
+ error: 'needs',
874
+ waiting: 'waiting',
875
+ working: 'working',
876
+ idle: 'idle',
877
+ done: 'idle',
878
+ };
879
+
880
+ const TAB_TRIAGE_STRIDE = 10000;
881
+ /** Offset of a group's web tabs inside its band, past any plausible session count. */
882
+ const TAB_TRIAGE_WEB_OFFSET = 5000;
883
+
884
+ /**
885
+ * Which group a session belongs to.
886
+ * @param {string} state a `_mobileOverviewState()` value
887
+ * @param {boolean} exited the agent inside the pane has exited (`_mobileOverviewExit()` non-null)
888
+ * @returns {'needs'|'waiting'|'working'|'idle'}
889
+ */
890
+ function tabTriageGroupFor(state, exited) {
891
+ const group = TAB_TRIAGE_GROUP_OF_STATE[state] || 'idle';
892
+ return exited && group === 'working' ? 'idle' : group;
893
+ }
894
+
895
+ /**
896
+ * Order values and visible groups for one pass.
897
+ *
898
+ * @param {Array<{id: string, state: string, exited?: boolean, pos?: number}>} rows
899
+ * live sessions; `pos` ranks a row inside its group (tab order on the header
900
+ * strip, the activity sort's position on a sorted rail). Rows without one keep
901
+ * the order they were passed in.
902
+ * @param {Array<string>} webviewIds open web tabs, in their own tab order
903
+ * @param {{reverse?: boolean}} [options] `reverse` puts the groups the other
904
+ * way up (`tabStateOrder: 'urgent-last'`): idle first, needs you last, for a
905
+ * strip read from the bottom. Rows inside a group keep their order.
906
+ * @returns {{
907
+ * order: Map<string, number>,
908
+ * webOrder: Map<string, number>,
909
+ * groups: Array<{key: string, label: string, quiet: boolean, count: number, headOrder: number, breakOrder: number}>
910
+ * }} `groups` lists only the non-empty groups, in display order (most urgent
911
+ * first, or last with `reverse`).
912
+ */
913
+ function computeTabTriageLayout(rows, webviewIds, options) {
914
+ const list = Array.isArray(rows) ? rows : [];
915
+ const webs = Array.isArray(webviewIds) ? webviewIds : [];
916
+ const sequence = options && options.reverse ? TAB_TRIAGE_GROUPS.slice().reverse() : TAB_TRIAGE_GROUPS;
917
+ const baseOf = {};
918
+ const counts = {};
919
+ sequence.forEach((group, i) => {
920
+ baseOf[group.key] = (i + 1) * TAB_TRIAGE_STRIDE;
921
+ counts[group.key] = 0;
922
+ });
923
+
924
+ const placed = list
925
+ .filter((row) => row && typeof row.id === 'string')
926
+ .map((row, i) => ({
927
+ id: row.id,
928
+ group: tabTriageGroupFor(row.state, !!row.exited),
929
+ pos: Number.isFinite(row.pos) ? row.pos : i,
930
+ index: i,
931
+ }));
932
+ const byGroup = {};
933
+ for (const row of placed) (byGroup[row.group] = byGroup[row.group] || []).push(row);
934
+
935
+ const order = new Map();
936
+ for (const key of Object.keys(byGroup)) {
937
+ // Stable: equal positions keep the order the caller passed.
938
+ byGroup[key].sort((a, b) => a.pos - b.pos || a.index - b.index);
939
+ byGroup[key].forEach((row, i) => order.set(row.id, baseOf[key] + 1 + i));
940
+ counts[key] = byGroup[key].length;
941
+ }
942
+
943
+ const webOrder = new Map();
944
+ webs.forEach((id, i) => {
945
+ if (typeof id === 'string' && id) webOrder.set(id, baseOf.idle + TAB_TRIAGE_WEB_OFFSET + i);
946
+ });
947
+ counts.idle += webOrder.size;
948
+
949
+ const groups = sequence.filter((group) => counts[group.key] > 0).map((group) => ({
950
+ key: group.key,
951
+ label: group.label,
952
+ quiet: !!group.quiet,
953
+ count: counts[group.key],
954
+ headOrder: baseOf[group.key],
955
+ breakOrder: baseOf[group.key] + TAB_TRIAGE_STRIDE - 1,
956
+ }));
957
+
958
+ return { order, webOrder, groups };
959
+ }
960
+
961
+ // Tab clusters by case (`tabArrangement: 'case'`, Discussion #426 option A).
962
+ //
963
+ // One cluster per case, in the order the case first appears in the tab order,
964
+ // so the strip keeps the user's arrangement at the case level. Inside a cluster
965
+ // with two or more tabs the `-<case>` part of a generated `w<n>-<case>` name is
966
+ // redundant and is hidden (`tabClusterNameSplit()`), which is what lets 18 tabs
967
+ // read as 7 things. A case with one tab is still its own (unlabelled) box.
968
+ //
969
+ // Cluster colours come from the session palette (`--session-<colour>`) by a
970
+ // stable hash of the case key, so a case keeps its colour across reloads and
971
+ // devices without anything being stored.
972
+ //
973
+ // Pure: no DOM, no `this`. Unit-tested in test/tab-clusters.test.ts.
974
+ const TAB_CLUSTER_COLORS = ['blue', 'green', 'purple', 'orange', 'pink', 'yellow', 'red'];
975
+
976
+ /** Palette colour for a cluster key: a djb2 hash, so the same key always gets the same colour. */
977
+ function tabClusterColorFor(key) {
978
+ const text = String(key || '');
979
+ let hash = 5381;
980
+ for (let i = 0; i < text.length; i++) hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0;
981
+ return TAB_CLUSTER_COLORS[Math.abs(hash) % TAB_CLUSTER_COLORS.length];
982
+ }
983
+
984
+ /**
985
+ * Group tabs by case.
986
+ * @param {Array<{id: string, key: string, label: string}>} rows live sessions in
987
+ * tab order; `key` identifies the case (its path), `label` names it
988
+ * @returns {Array<{key: string, label: string, color: string, ids: string[]}>}
989
+ * clusters in first-appearance order, members in tab order
990
+ */
991
+ function computeTabClusters(rows) {
992
+ const clusters = [];
993
+ const byKey = new Map();
994
+ for (const row of Array.isArray(rows) ? rows : []) {
995
+ if (!row || typeof row.id !== 'string') continue;
996
+ const key = typeof row.key === 'string' && row.key ? row.key : `session:${row.id}`;
997
+ let cluster = byKey.get(key);
998
+ if (!cluster) {
999
+ cluster = { key, label: typeof row.label === 'string' ? row.label : '', color: tabClusterColorFor(key), ids: [] };
1000
+ byKey.set(key, cluster);
1001
+ clusters.push(cluster);
1002
+ }
1003
+ cluster.ids.push(row.id);
1004
+ }
1005
+ return clusters;
1006
+ }
1007
+
1008
+ /**
1009
+ * Split a generated `w<n>-<case>` / `s<n>-<case>` name into the part a cluster
1010
+ * shows and the case suffix it hides, or null when the name is anything else
1011
+ * (a custom name, a described `w3-x: fix login`, another case's name). Case is
1012
+ * compared case-insensitively; the hidden part keeps its original spelling so
1013
+ * the full name is still in the DOM.
1014
+ * @returns {{shown: string, hidden: string}|null}
1015
+ */
1016
+ function tabClusterNameSplit(name, label) {
1017
+ if (typeof name !== 'string' || typeof label !== 'string' || !label) return null;
1018
+ const match = name.match(/^([ws]\d+)(-.+)$/);
1019
+ if (!match) return null;
1020
+ return match[2].slice(1).toLowerCase() === label.toLowerCase() ? { shown: match[1], hidden: match[2] } : null;
1021
+ }
1022
+
1023
+ /**
1024
+ * Session-list search: the vertical rail's search box and the sidebar's filter
1025
+ * box. Trimmed, case-insensitive substring; a whitespace-only query is no query.
1026
+ * Lower-cased with toLowerCase(), never toLocaleLowerCase(): under a Turkish or
1027
+ * Azeri browser locale "API" lowers to "apı" and a search for "api" would miss it.
1028
+ * @param {unknown} query
1029
+ * @returns {string} the needle, '' when there is nothing to search for
1030
+ */
1031
+ function tabSearchNeedle(query) {
1032
+ return typeof query === 'string' ? query.trim().toLowerCase() : '';
1033
+ }
1034
+
1035
+ /**
1036
+ * Which rows a search hides. Pure: the caller reads the rows off the list it
1037
+ * rendered and applies the result as classes, so the list itself (grouping,
1038
+ * order, Alt+N badges) is never rebuilt or reordered by a search.
1039
+ *
1040
+ * A row flagged `keep: true` is never hidden, matching or not (the caller keeps
1041
+ * a tab with an alert on screen: a prompt waiting on you is never hidden by a
1042
+ * view filter). It counts toward its section, so its group stays on screen with
1043
+ * it, but not toward `matchCount`.
1044
+ *
1045
+ * @param {Array<{key: unknown, text: string, section?: unknown, keep?: boolean}>} rows
1046
+ * in list order; `section` is the row's group or case box, null/undefined for none.
1047
+ * @param {unknown} query
1048
+ * @returns {{active: boolean, hidden: Set<unknown>, counts: Map<unknown, number>, matchCount: number}}
1049
+ * `counts` is the rows left showing per section (kept rows included), every
1050
+ * section seen, an emptied one as 0, so it can be hidden; `matchCount` is the
1051
+ * number of rows whose TEXT matched, so it can be 0 above a lone kept row.
1052
+ */
1053
+ function filterTabSearchRows(rows, query) {
1054
+ const needle = tabSearchNeedle(query);
1055
+ const hidden = new Set();
1056
+ const counts = new Map();
1057
+ let matchCount = 0;
1058
+ for (const row of Array.isArray(rows) ? rows : []) {
1059
+ const hasSection = row.section !== null && row.section !== undefined;
1060
+ if (hasSection && !counts.has(row.section)) counts.set(row.section, 0);
1061
+ const text = typeof row.text === 'string' ? row.text.toLowerCase() : '';
1062
+ const matches = !needle || text.includes(needle);
1063
+ if (!matches && row.keep !== true) {
1064
+ hidden.add(row.key);
1065
+ continue;
1066
+ }
1067
+ if (matches) matchCount++;
1068
+ if (hasSection) counts.set(row.section, counts.get(row.section) + 1);
1069
+ }
1070
+ return { active: needle.length > 0, hidden, counts, matchCount };
1071
+ }
1072
+
661
1073
  // Terminal font stack — the single source for every xterm surface (the main
662
1074
  // terminal in terminal-ui.js, the log-viewer terminal in panels-ui.js).
663
1075
  // "Symbols Nerd Font Mono" is a bundled icons-only webfont (fonts/ +
@@ -939,6 +1351,63 @@ function cleanCopiedSelection(text, options) {
939
1351
  return lines.join('\n');
940
1352
  }
941
1353
 
1354
+ // ── Markdown heading anchors ────────────────────────────────────────────────
1355
+ // marked emits no `id` on headings, so a rendered document's own `[Install](#installation)` links had
1356
+ // nothing to jump to. And with `<base href="/">` a bare `#installation` href points at the dashboard's
1357
+ // root, not at the page, so letting the browser follow it navigates the app away. The click delegate
1358
+ // (`_bindResponseViewerInteractions`) therefore resolves in-document links itself, with the helpers
1359
+ // below. Anchors are `data-md-anchor` attributes, NOT `id`s: a heading titled "Settings" must not claim
1360
+ // the id of an element in the app's own DOM, and the lookup is scoped to the rendered document.
1361
+
1362
+ /**
1363
+ * GitHub's heading slug: lower-cased, anything that is not a letter, mark, number, `_`, `-` or space
1364
+ * dropped, each space a hyphen (`Why `codeman`? → `why-codeman`, `Über uns` → `über-uns`).
1365
+ */
1366
+ function markdownHeadingSlug(text) {
1367
+ return String(text ?? '')
1368
+ .trim()
1369
+ .toLowerCase()
1370
+ .replace(/[^\p{L}\p{M}\p{N}_\- ]/gu, '')
1371
+ .replace(/ /g, '-');
1372
+ }
1373
+
1374
+ /** Give every h1..h6 under `root` its slug in `data-md-anchor`; a repeat gets `-1`, `-2`, ... as on GitHub. Idempotent. */
1375
+ function assignMarkdownHeadingAnchors(root) {
1376
+ const used = new Set();
1377
+ for (const heading of root.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
1378
+ const base = markdownHeadingSlug(heading.textContent);
1379
+ let slug = base;
1380
+ for (let n = 1; used.has(slug); n += 1) slug = `${base}-${n}`;
1381
+ used.add(slug);
1382
+ heading.dataset.mdAnchor = slug;
1383
+ }
1384
+ }
1385
+
1386
+ /**
1387
+ * The element inside `root` that an in-document link (`#installation`, `#Installation`, `#my%20title`)
1388
+ * points at, or null. An empty fragment (`#`) means the top of the document. Headings are matched by
1389
+ * slug, then a heading the author wrote an explicit `<a id="...">`/`id` for, looked up INSIDE `root`
1390
+ * only (never `document.getElementById`, which could find an app element of the same name).
1391
+ */
1392
+ function findMarkdownAnchorTarget(root, href) {
1393
+ let fragment = String(href ?? '').replace(/^#/, '');
1394
+ try {
1395
+ fragment = decodeURIComponent(fragment);
1396
+ } catch {
1397
+ /* a malformed escape: use it as written */
1398
+ }
1399
+ if (!fragment) return root;
1400
+ assignMarkdownHeadingAnchors(root);
1401
+ const wanted = [fragment.toLowerCase(), markdownHeadingSlug(fragment)];
1402
+ for (const heading of root.querySelectorAll('[data-md-anchor]')) {
1403
+ if (wanted.includes(heading.dataset.mdAnchor)) return heading;
1404
+ }
1405
+ for (const el of root.querySelectorAll('[id]')) {
1406
+ if (el.id === fragment) return el;
1407
+ }
1408
+ return null;
1409
+ }
1410
+
942
1411
  if (typeof window !== 'undefined') {
943
1412
  window.WEBGL_FALLBACK = WEBGL_FALLBACK;
944
1413
  window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
@@ -961,12 +1430,13 @@ if (typeof window !== 'undefined') {
961
1430
  plan: planWsReconnect,
962
1431
  };
963
1432
  window.CodemanLineage = {
964
- computePath: computeLineagePath,
965
- DIP_MIN_PX: LINEAGE_DIP_MIN_PX,
966
- DIP_MAX_PX: LINEAGE_DIP_MAX_PX,
967
- SIBLING_STEP_PX: LINEAGE_SIBLING_STEP_PX,
1433
+ computeTree: computeLineageTree,
1434
+ computeRows: computeLineageRows,
1435
+ CORNER_RADIUS_PX: LINEAGE_CORNER_RADIUS_PX,
1436
+ LANE_STEP_PX: LINEAGE_LANE_STEP_PX,
1437
+ MAX_LANES: LINEAGE_MAX_LANES,
1438
+ SPINE_INSET_PX: LINEAGE_SPINE_INSET_PX,
968
1439
  VERTICAL_TRACK_INSET_PX: LINEAGE_VERTICAL_TRACK_INSET_PX,
969
- VERTICAL_SIBLING_STEP_PX: LINEAGE_VERTICAL_SIBLING_STEP_PX,
970
1440
  COLORS: LINEAGE_COLORS,
971
1441
  };
972
1442
  window.CodemanConnectionLoss = {
@@ -983,6 +1453,22 @@ if (typeof window !== 'undefined') {
983
1453
  compare: compareSessionActivity,
984
1454
  sort: sortSessionsByActivity,
985
1455
  };
1456
+ window.CodemanTabClusters = {
1457
+ COLORS: TAB_CLUSTER_COLORS,
1458
+ colorFor: tabClusterColorFor,
1459
+ compute: computeTabClusters,
1460
+ nameSplit: tabClusterNameSplit,
1461
+ };
1462
+ window.CodemanTabTriage = {
1463
+ GROUPS: TAB_TRIAGE_GROUPS,
1464
+ STRIDE: TAB_TRIAGE_STRIDE,
1465
+ groupFor: tabTriageGroupFor,
1466
+ layout: computeTabTriageLayout,
1467
+ };
1468
+ window.CodemanTabSearch = {
1469
+ needle: tabSearchNeedle,
1470
+ filter: filterTabSearchRows,
1471
+ };
986
1472
  window.CodemanInputLimit = {
987
1473
  FRAME_MAX_CHARS: INPUT_FRAME_MAX_CHARS,
988
1474
  PASTE_MAX_CHARS: INPUT_PASTE_MAX_CHARS,
@@ -995,6 +1481,11 @@ if (typeof window !== 'undefined') {
995
1481
  window.CodemanCopySelection = {
996
1482
  clean: cleanCopiedSelection,
997
1483
  };
1484
+ window.CodemanMarkdownAnchors = {
1485
+ slug: markdownHeadingSlug,
1486
+ assign: assignMarkdownHeadingAnchors,
1487
+ find: findMarkdownAnchorTarget,
1488
+ };
998
1489
  window.CodemanTerminalFont = {
999
1490
  DEFAULT_STACK: TERMINAL_FONT_DEFAULT_STACK,
1000
1491
  resolve: resolveTerminalFontFamily,
@@ -1458,7 +1949,7 @@ function computeRewriteScrollLine(input) {
1458
1949
  * a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
1459
1950
  */
1460
1951
  const FILE_PATH_LINK_PATTERN =
1461
- /(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|mp4|webm|mov|mp3|wav))\b/g;
1952
+ /(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|mp3|wav))\b/g;
1462
1953
 
1463
1954
  /** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
1464
1955
  function absoluteFilePathPattern() {
@@ -1476,7 +1967,7 @@ function absoluteFilePathPattern() {
1476
1967
  * file in /tmp played fine. test/media-extension-parity.test.ts pins the sync.
1477
1968
  */
1478
1969
  const FILE_PREVIEW_EXTENSIONS = new Set(
1479
- ('png jpg jpeg gif webp avif bmp ico svg pdf docx pptx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus').split(' ')
1970
+ ('png jpg jpeg gif webp avif bmp ico svg pdf docx pptx xlsx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus').split(' ')
1480
1971
  );
1481
1972
 
1482
1973
  /** Whether a path's extension is one {@link FILE_PREVIEW_EXTENSIONS} covers. */
@@ -1612,27 +2103,605 @@ function buildSplitPickerSessions(sessions, sessionOrder, excludeId, detachedIds
1612
2103
  const result = [];
1613
2104
  for (const id of sessionOrder) {
1614
2105
  if (id === excludeId) continue;
1615
- // A detached (popped-out) session's own window already yields its PTY
1616
- // size (see sendResize's detachedElsewhere guard in terminal-ui.js) —
1617
- // Pane B's SplitTerminalPane._sendResize() has no such check, so letting
1618
- // one into the picker put its detached window and Pane B in a fight over
1619
- // the same PTY's dimensions.
2106
+ // A detached (popped-out) session's own window owns its PTY size (see
2107
+ // sendResize's detachedElsewhere guard in terminal-ui.js;
2108
+ // TerminalTile._sendResize() stands aside the same way), so Pane B could
2109
+ // only show it at a size it cannot set.
1620
2110
  if (detachedIds?.has?.(id)) continue;
1621
2111
  const session = sessions.get(id);
1622
2112
  if (!session) continue;
1623
2113
  // A session with no PTY attached (exited CLI, a crash-looped session
1624
2114
  // whose breaker tripped, a restore that failed to re-attach) has nothing
1625
- // reading its tmux pane. SplitTerminalPane never does selectSession()'s
1626
- // re-attach POST, so its socket would open onto a pane nothing feeds:
1627
- // no terminal events, and Session.write() silently drops every keystroke
1628
- // with no ack either way (Pane B sends no `seq`), so the loss is
1629
- // invisible — the healthy socket never trips the disconnect banner.
2115
+ // reading its tmux pane, and the split never does selectSession()'s
2116
+ // re-attach POST: Pane B would open a healthy-looking socket onto a pane
2117
+ // that nothing feeds and nothing reads.
1630
2118
  if (session.pid === null) continue;
1631
2119
  result.push({ id, label: session.name || 'Session' });
1632
2120
  }
1633
2121
  return result;
1634
2122
  }
1635
2123
 
2124
+ // ── Tile grid (tile-grid.js) ───────────────────────────────────────────────
2125
+ //
2126
+ // Pure layout and state helpers for the tile grid (docs/tile-grid-plan.md):
2127
+ // 1 to TILE_GRID_MAX live sessions side by side, each in its own TerminalTile.
2128
+ // Desktop only, behind the same 1180px gate as the split pane.
2129
+
2130
+ /**
2131
+ * Hard cap on tiles in one grid: the ONE place it is set (owner decision 7 in
2132
+ * docs/tile-grid-plan.md). Six was tested smooth on a real desktop; nine missed
2133
+ * the headless frame bar and is untested on hardware. Everything that limits
2134
+ * the grid reads this, and the layout table still covers up to TILE_LAYOUT_MAX,
2135
+ * so raising the cap is this one line.
2136
+ */
2137
+ const TILE_GRID_MAX = 6;
2138
+ /** The largest count the layout table covers (3x3). Never a cap by itself. */
2139
+ const TILE_LAYOUT_MAX = 9;
2140
+ // The smallest tile worth showing: about 60 columns and a dozen rows at the
2141
+ // default tile font. Bounds how many tiles a window can hold.
2142
+ const TILE_MIN_W = 480;
2143
+ const TILE_MIN_H = 240;
2144
+ // Three tiles go side by side (3x1) only when each still gets ~600px;
2145
+ // otherwise they take three cells of a 2x2.
2146
+ const TILE_GRID_WIDE_3X1 = 1800;
2147
+ // A tile's xterm keeps this many lines, not DEFAULT_SCROLLBACK: a grid of DOM
2148
+ // renderers at 50k lines each is a real memory cost, and a tile's load is a
2149
+ // bounded 1 MiB window anyway, so more scrollback only fills with live output.
2150
+ const TILE_SCROLLBACK = 10000;
2151
+ // Tiles have their own per-device font size (a tile is a fraction of the screen).
2152
+ const TILE_FONT_SIZE_DEFAULT = 13;
2153
+ // What the page's SSE filter names while tiles own the terminal: a value no
2154
+ // session id takes (ids are UUIDs), so the server, whose filter gates only
2155
+ // session:terminal batches, sends none. The tiles carry their own output over
2156
+ // their own sockets, and the parked main terminal only parsed those frames to
2157
+ // drop them (16 to 18 a second for one busy shell). Every other event still
2158
+ // arrives (test/sse-tile-grid-filter.test.ts pins the server's side of this).
2159
+ const TILE_GRID_SSE_FILTER = 'tile-grid';
2160
+
2161
+ /**
2162
+ * Columns and rows for `count` tiles, by count (the spec's table), and whether
2163
+ * that layout gives every cell at least the minimum tile size (TILE_MIN_W x
2164
+ * TILE_MIN_H) in a grid area of `width` x `height` px.
2165
+ *
2166
+ * @param {{count: number, width?: number, height?: number}} p
2167
+ * @returns {{cols: number, rows: number, fits: boolean}}
2168
+ */
2169
+ function computeTileLayout({ count, width = Infinity, height = Infinity }) {
2170
+ const n = Math.min(Math.max(0, Math.floor(Number(count) || 0)), TILE_LAYOUT_MAX);
2171
+ let cols;
2172
+ let rows;
2173
+ if (n === 0) return { cols: 0, rows: 0, fits: true };
2174
+ if (n === 1) { cols = 1; rows = 1; }
2175
+ else if (n === 2) { cols = 2; rows = 1; }
2176
+ else if (n === 3) {
2177
+ if (width >= TILE_GRID_WIDE_3X1) { cols = 3; rows = 1; }
2178
+ else { cols = 2; rows = 2; }
2179
+ }
2180
+ else if (n === 4) { cols = 2; rows = 2; }
2181
+ else if (n <= 6) { cols = 3; rows = 2; }
2182
+ else { cols = 3; rows = 3; }
2183
+ const fits = width / cols >= TILE_MIN_W && height / rows >= TILE_MIN_H;
2184
+ return { cols, rows, fits };
2185
+ }
2186
+
2187
+ /**
2188
+ * How many tiles a grid area can hold: the largest count up to TILE_GRID_MAX
2189
+ * whose layout, and every smaller count's layout, fits. 0 when not even one
2190
+ * tile fits.
2191
+ *
2192
+ * @param {{width: number, height: number}} p
2193
+ * @returns {number}
2194
+ */
2195
+ function tileGridCapacity({ width, height }) {
2196
+ let capacity = 0;
2197
+ for (let n = 1; n <= TILE_GRID_MAX; n++) {
2198
+ if (!computeTileLayout({ count: n, width, height }).fits) break;
2199
+ capacity = n;
2200
+ }
2201
+ return capacity;
2202
+ }
2203
+
2204
+ /**
2205
+ * The stored grid (`codeman:tile-grid`: session ids and the layout, never
2206
+ * content) made safe to apply: unknown, deleted, detached and duplicate ids
2207
+ * are dropped, the list is capped at TILE_GRID_MAX, `focused` / `zoomed` must
2208
+ * name a kept id, and track fractions must be 1 to 3 finite positive numbers.
2209
+ * Anything that is not a v1 object (or its JSON) gives null.
2210
+ *
2211
+ * The stored `ids` are the grid's CELLS in reading order, `null` for an empty
2212
+ * one (a hole can be any cell). The old packed list (no nulls) reads as cells
2213
+ * with no hole. `ids` comes back packed (the tiles in reading order, what
2214
+ * every list consumer wants) and `cells` keeps the holes: a dropped id (gone,
2215
+ * detached, a duplicate, past the cap) becomes `null` there, never a shift.
2216
+ *
2217
+ * `freed` names the cells whose session no longer exists (or was popped out
2218
+ * to its own window) since the grid was stored: the ranking fills those first
2219
+ * when the grid comes back (restoreTileGridCells). A hole the user left empty
2220
+ * is not freed. `count` is how many tiles the grid had after the user's own
2221
+ * last change (a session that went away by itself does not lower it), so the
2222
+ * grid comes back to that many when there are sessions to fill it; a value
2223
+ * stored before it existed, or a malformed one, reads as the number of
2224
+ * sessions the stored cells name.
2225
+ *
2226
+ * @param {unknown} raw - the parsed value, or the stored JSON string
2227
+ * @param {{has(id: string): boolean}|Iterable<string>} liveSessions - ids that exist now
2228
+ * @param {{has(id: string): boolean}} [detachedIds] - sessions popped out to their own window
2229
+ * @returns {{v: 1, open: boolean, ids: string[], cells: (string|null)[], freed: number[], count: number,
2230
+ * focused: string|null, zoomed: string|null, colFr: number[]|null, rowFr: number[]|null}|null}
2231
+ */
2232
+ function sanitizeTileGridState(raw, liveSessions, detachedIds) {
2233
+ let value = raw;
2234
+ if (typeof value === 'string') {
2235
+ try { value = JSON.parse(value); } catch { return null; }
2236
+ }
2237
+ if (!value || typeof value !== 'object' || Array.isArray(value) || value.v !== 1) return null;
2238
+ const live = liveSessions && typeof liveSessions.has === 'function' ? liveSessions : new Set(liveSessions || []);
2239
+ const ids = [];
2240
+ const cells = [];
2241
+ const freed = [];
2242
+ const named = [];
2243
+ for (const id of (Array.isArray(value.ids) ? value.ids : []).slice(0, TILE_LAYOUT_MAX)) {
2244
+ const isId = typeof id === 'string' && id !== '';
2245
+ const present = isId && live.has(id) && !detachedIds?.has?.(id);
2246
+ const keep = present && !ids.includes(id) && ids.length < TILE_GRID_MAX;
2247
+ if (keep) ids.push(id);
2248
+ // Its session went away since: the cell is freed for the ranking to fill.
2249
+ if (isId && !present && !named.includes(id)) freed.push(cells.length);
2250
+ if (isId && !named.includes(id)) named.push(id);
2251
+ // A malformed entry (not a string, not null) is a hole too.
2252
+ cells.push(keep ? id : null);
2253
+ }
2254
+ const fractions = (fr) => {
2255
+ if (!Array.isArray(fr) || fr.length < 1 || fr.length > 3) return null;
2256
+ return fr.every((x) => typeof x === 'number' && Number.isFinite(x) && x > 0) ? fr.slice() : null;
2257
+ };
2258
+ const count =
2259
+ Number.isInteger(value.count) && value.count >= 1 && value.count <= TILE_GRID_MAX
2260
+ ? value.count
2261
+ : Math.min(named.length, TILE_GRID_MAX);
2262
+ return {
2263
+ v: 1,
2264
+ open: value.open === true && ids.length > 0,
2265
+ ids,
2266
+ cells,
2267
+ freed,
2268
+ count,
2269
+ focused: ids.includes(value.focused) ? value.focused : (ids[0] ?? null),
2270
+ zoomed: ids.includes(value.zoomed) ? value.zoomed : null,
2271
+ colFr: fractions(value.colFr),
2272
+ rowFr: fractions(value.rowFr),
2273
+ };
2274
+ }
2275
+
2276
+ /**
2277
+ * A stored grid as it comes back (the Tiles button, a page reload): its cells
2278
+ * exactly as stored, holes the user left included, and its focus (or the tile
2279
+ * it had zoomed). Only when it holds fewer tiles than its `count` (sessions
2280
+ * that went away since, or by themselves while it was open) does it fill, from
2281
+ * `ranked` (best first, never a session already in it): the freed cells first,
2282
+ * then the other empty cells, in reading order; more than the cells hold join
2283
+ * after them (the shape grows when the grid lays them out, reformTileCells).
2284
+ * A cell stays empty only when no other session is left to place. Never
2285
+ * trimmed to the window: a grid larger than the window fits shows its focused
2286
+ * tile alone until the window fits it again, and the arrangement stays.
2287
+ *
2288
+ * @param {{cells?: (string|null)[], ids?: string[], freed?: number[], count?: number,
2289
+ * focused?: string|null, zoomed?: string|null}|null} stored - sanitized (sanitizeTileGridState)
2290
+ * @param {string[]} ranked - the sessions that may fill a cell, best first (rankTileSessions)
2291
+ * @returns {{ids: string[], cells: (string|null)[], focusedId: string}|null} null when none of its sessions survive
2292
+ */
2293
+ function restoreTileGridCells(stored, ranked) {
2294
+ const source = Array.isArray(stored?.cells) ? stored.cells : Array.isArray(stored?.ids) ? stored.ids : [];
2295
+ const cells = [];
2296
+ for (const id of source) cells.push(typeof id === 'string' && id && !cells.includes(id) ? id : null);
2297
+ const tiles = cells.filter(Boolean);
2298
+ if (tiles.length === 0) return null;
2299
+ const focusedId =
2300
+ [stored.zoomed, stored.focused].find((id) => typeof id === 'string' && tiles.includes(id)) ?? tiles[0];
2301
+ const target = Math.min(Math.max(tiles.length, Math.floor(Number(stored.count)) || 0), TILE_GRID_MAX);
2302
+ const fillers = [];
2303
+ for (const id of ranked || []) {
2304
+ if (typeof id === 'string' && id && !cells.includes(id) && !fillers.includes(id)) fillers.push(id);
2305
+ }
2306
+ const freed = new Set(Array.isArray(stored.freed) ? stored.freed : []);
2307
+ const empty = [];
2308
+ cells.forEach((id, k) => {
2309
+ if (id === null) empty.push(k);
2310
+ });
2311
+ // Freed cells first, each group in reading order.
2312
+ empty.sort((a, b) => Number(freed.has(b)) - Number(freed.has(a)) || a - b);
2313
+ let count = tiles.length;
2314
+ for (const k of empty) {
2315
+ if (count >= target || fillers.length === 0) break;
2316
+ cells[k] = fillers.shift();
2317
+ count++;
2318
+ }
2319
+ const extra = fillers.slice(0, Math.max(0, target - count));
2320
+ return { ids: [...cells.filter(Boolean), ...extra], cells, focusedId };
2321
+ }
2322
+
2323
+ /**
2324
+ * New track fractions after a divider drag (grid-template `fr` values): the two
2325
+ * tracks either side of divider `index` trade `deltaPx` of size, each kept at
2326
+ * least `minPx` (or half the pair, if the pair cannot give both the minimum).
2327
+ * Every other track keeps its size. Computed from the fractions the drag
2328
+ * STARTED with and the pointer's total travel, so a drag never drifts.
2329
+ *
2330
+ * @param {number[]} fr - the fractions when the drag started
2331
+ * @param {number} index - the divider: between track `index` and `index + 1`
2332
+ * @param {number} deltaPx - pointer travel since the drag started
2333
+ * @param {number} totalPx - the size the tracks share (dividers and padding excluded)
2334
+ * @param {number} minPx - the smallest a track may get
2335
+ * @returns {number[]} new fractions, same length
2336
+ */
2337
+ function dragTrackFractions(fr, index, deltaPx, totalPx, minPx) {
2338
+ const out = fr.slice();
2339
+ if (index < 0 || index + 1 >= fr.length || !(totalPx > 0)) return out;
2340
+ const sum = fr.reduce((a, b) => a + b, 0);
2341
+ if (!(sum > 0)) return out;
2342
+ const a = (fr[index] / sum) * totalPx;
2343
+ const b = (fr[index + 1] / sum) * totalPx;
2344
+ const pair = a + b;
2345
+ const lo = Math.min(minPx, pair / 2);
2346
+ const hi = pair - lo;
2347
+ const nextA = Math.min(Math.max(a + (Number(deltaPx) || 0), lo), hi);
2348
+ out[index] = (nextA / totalPx) * sum;
2349
+ out[index + 1] = ((pair - nextA) / totalPx) * sum;
2350
+ return out;
2351
+ }
2352
+
2353
+ /**
2354
+ * The sessions the Tiles button can open (case c of tileGridOpenSet, and the
2355
+ * ones a count fills a grid with), in the order given (tab order, or the
2356
+ * ranking): live ones only, never a session popped out to
2357
+ * its own window (that window owns its PTY size). A session with no PTY
2358
+ * attached IS offered: its tile shows the Attach overlay.
2359
+ *
2360
+ * @param {Map<string, {name?: string}>} sessions
2361
+ * @param {string[]} sessionOrder
2362
+ * @param {{has(id: string): boolean}} [detachedIds]
2363
+ * @returns {Array<{id: string, label: string}>}
2364
+ */
2365
+ function buildTilePickerSessions(sessions, sessionOrder, detachedIds) {
2366
+ const result = [];
2367
+ for (const id of sessionOrder) {
2368
+ if (detachedIds?.has?.(id)) continue;
2369
+ const session = sessions.get(id);
2370
+ if (!session || result.some((r) => r.id === id)) continue;
2371
+ result.push({ id, label: session.name || 'Session' });
2372
+ }
2373
+ return result;
2374
+ }
2375
+
2376
+ // Ranking groups (rankTileSessions): working first, then the sessions waiting
2377
+ // on the user, then everything else.
2378
+ const TILE_RANK_GROUP = { working: 0, needs: 1, waiting: 1 };
2379
+
2380
+ /**
2381
+ * The stamp a session is ranked by inside its group. A WORKING session keys
2382
+ * off the pane's last Enter (`lastSubmitAt`) ONLY: a working pane repaints
2383
+ * about once a second, so its last-activity stamp is always "now", and one
2384
+ * that never submitted would otherwise claim the head of the group. 0 means
2385
+ * unknown. Every other state is the home screens' anchor (sessionActivityAnchor:
2386
+ * the last byte the pane printed, i.e. when it went quiet).
2387
+ */
2388
+ function tileRankStamp(row) {
2389
+ if (row.state === 'working') return Number(row.lastSubmitAt) || 0;
2390
+ return sessionActivityAnchor(row);
2391
+ }
2392
+
2393
+ /**
2394
+ * Which open sessions the tile grid shows when nobody said which (the Tiles
2395
+ * button with no stored grid to bring back, and every place the grid fills a
2396
+ * tile on its own: a count picked in its menu, a freed cell), best first
2397
+ * (owner request: "prefer to load in tiles that are working and then the most
2398
+ * recent, so the oldest don't get opened"):
2399
+ * 1. WORKING, the most recently started turn first;
2400
+ * 2. then the ones that NEED INPUT, the red and yellow tab alerts (`needs`: a
2401
+ * permission or question dialog; `waiting`: a finished turn not seen
2402
+ * yet), most recent first;
2403
+ * 3. then every other one (idle, done, error), most recently active first.
2404
+ * Inside a group the newest stamp wins (tileRankStamp) and a session with no
2405
+ * stamp (0) sorts last; the final tiebreak is the tab order (`orderIndex`), so
2406
+ * the result never shuffles. The states are the home screens' own
2407
+ * (`_mobileOverviewState()`, mobile-overview.js), as are the stamps; only the
2408
+ * order differs: the home screens put the longest-running turn first, the grid
2409
+ * the most recent.
2410
+ *
2411
+ * Pure. Unit-tested in test/tile-grid-ranking.test.ts.
2412
+ *
2413
+ * @param {Array<{id: string, state: string, lastActivityAt?: number, lastSubmitAt?: number, orderIndex?: number}>} rows
2414
+ * @returns {string[]} the ids, best first, each once
2415
+ */
2416
+ function rankTileSessions(rows) {
2417
+ const group = (row) => TILE_RANK_GROUP[row.state] ?? 2;
2418
+ const list = (Array.isArray(rows) ? rows : []).filter((row) => row && typeof row.id === 'string' && row.id);
2419
+ list.sort((a, b) => {
2420
+ const byGroup = group(a) - group(b);
2421
+ if (byGroup !== 0) return byGroup;
2422
+ const atA = tileRankStamp(a);
2423
+ const atB = tileRankStamp(b);
2424
+ if (atA !== atB) {
2425
+ if (!atA) return 1;
2426
+ if (!atB) return -1;
2427
+ return atB - atA;
2428
+ }
2429
+ const orderA = Number.isFinite(a.orderIndex) ? a.orderIndex : Number.MAX_SAFE_INTEGER;
2430
+ const orderB = Number.isFinite(b.orderIndex) ? b.orderIndex : Number.MAX_SAFE_INTEGER;
2431
+ return orderA - orderB;
2432
+ });
2433
+ const ids = [];
2434
+ for (const row of list) if (!ids.includes(row.id)) ids.push(row.id);
2435
+ return ids;
2436
+ }
2437
+
2438
+ /**
2439
+ * What the Tiles button and Ctrl+Shift+G open, at once and without asking
2440
+ * (owner decision 8). In order:
2441
+ * a. the grid this tab last had (`stored`, already sanitized: live, not
2442
+ * detached, at most the cap), if any of its sessions survive, exactly
2443
+ * as it was (restoreTileGridCells: its cells and holes, a cell its
2444
+ * session freed filled from the ranking);
2445
+ * b. else an open split's two sessions, Pane A focused;
2446
+ * c. else the open sessions in `ranked` order (rankTileSessions: working,
2447
+ * then needing input, then the most recent; tab order when no ranking is
2448
+ * given), detached ones never, up to `limit`, the active session always
2449
+ * among them and focused (when it ranks past the limit, the first
2450
+ * `limit - 1` others come with it).
2451
+ * Null when there is nothing to open.
2452
+ *
2453
+ * @param {{stored?: {ids: string[], cells?: (string|null)[], freed?: number[], count?: number,
2454
+ * focused: string|null, zoomed: string|null}|null,
2455
+ * split?: string[]|null, ranked?: string[]|null, sessions: Map<string, object>, sessionOrder: string[],
2456
+ * detachedIds?: {has(id: string): boolean}, activeId?: string|null, limit: number}} p
2457
+ * @returns {{source: 'stored'|'split'|'ranked', ids: string[], cells?: (string|null)[],
2458
+ * focusedId: string|null}|null}
2459
+ */
2460
+ function tileGridOpenSet({
2461
+ stored = null,
2462
+ split = null,
2463
+ ranked = null,
2464
+ sessions,
2465
+ sessionOrder,
2466
+ detachedIds,
2467
+ activeId = null,
2468
+ limit,
2469
+ }) {
2470
+ const all = buildTilePickerSessions(sessions, Array.isArray(ranked) ? ranked : sessionOrder, detachedIds).map(
2471
+ (c) => c.id
2472
+ );
2473
+ const restored = stored ? restoreTileGridCells(stored, all) : null;
2474
+ if (restored) return { source: 'stored', ...restored };
2475
+ const usable = (id) => typeof id === 'string' && sessions.has(id) && !detachedIds?.has?.(id);
2476
+ const pair = (split || []).filter(usable);
2477
+ if (split && pair.length) return { source: 'split', ids: [...new Set(pair)], focusedId: pair[0] };
2478
+ const max = Math.max(1, Math.min(Math.floor(Number(limit) || 0), TILE_GRID_MAX));
2479
+ if (all.length === 0) return null;
2480
+ let ids = all.slice(0, max);
2481
+ if (all.includes(activeId) && !ids.includes(activeId)) {
2482
+ ids = [...all.filter((id) => id !== activeId).slice(0, max - 1), activeId];
2483
+ }
2484
+ return { source: 'ranked', ids, focusedId: ids.includes(activeId) ? activeId : ids[0] };
2485
+ }
2486
+
2487
+ /**
2488
+ * The tile counts the Tiles button's right-click menu offers, and the count a
2489
+ * click opens until one is picked (owner decision 10 in docs/tile-grid-plan.md).
2490
+ */
2491
+ const TILE_GRID_COUNTS = [2, 4, 6];
2492
+ const TILE_GRID_COUNT_DEFAULT = 6;
2493
+
2494
+ /** A remembered tile count made safe: one of TILE_GRID_COUNTS, else the default. */
2495
+ function sanitizeTileCount(raw) {
2496
+ const n = Number(raw);
2497
+ return TILE_GRID_COUNTS.includes(n) ? n : TILE_GRID_COUNT_DEFAULT;
2498
+ }
2499
+
2500
+ /**
2501
+ * `base` (what the grid would open, or what an open grid shows, in its order)
2502
+ * trimmed or filled to `n` tiles: trimmed from the end, the session to focus
2503
+ * (`keepId`) always kept (it takes the last place when it sat past `n`, as in
2504
+ * tileGridOpenSet's case c); filled from `all` (the open sessions, best first:
2505
+ * the app passes the ranking, rankTileSessions) with the ones not in it yet.
2506
+ * Fewer sessions than `n` give fewer tiles.
2507
+ *
2508
+ * @param {string[]} base
2509
+ * @param {string[]} all
2510
+ * @param {number} n - at most TILE_GRID_MAX
2511
+ * @param {string|null} [keepId]
2512
+ * @returns {string[]}
2513
+ */
2514
+ function tileGridSetForCount(base, all, n, keepId = null) {
2515
+ const max = Math.max(1, Math.min(Math.floor(Number(n) || 0), TILE_GRID_MAX));
2516
+ const ids = [];
2517
+ for (const id of [...(base || []), ...(all || [])]) {
2518
+ if (typeof id === 'string' && id && !ids.includes(id)) ids.push(id);
2519
+ }
2520
+ // Every `base` id comes before every filler, so a trim never drops a base id
2521
+ // in favour of one.
2522
+ let out = ids.slice(0, max);
2523
+ if (keepId && ids.includes(keepId) && !out.includes(keepId)) out = [...out.slice(0, max - 1), keepId];
2524
+ return out;
2525
+ }
2526
+
2527
+ /**
2528
+ * Which tile takes focus when `id` leaves the grid: the next one in grid
2529
+ * order, else the previous one, else null.
2530
+ *
2531
+ * @param {string[]} ids - the grid's tiles, in reading order
2532
+ * @param {string} id - the tile that is leaving
2533
+ * @returns {string|null}
2534
+ */
2535
+ function tileNeighbor(ids, id) {
2536
+ const i = ids.indexOf(id);
2537
+ if (i === -1) return ids[0] ?? null;
2538
+ return ids[i + 1] ?? ids[i - 1] ?? null;
2539
+ }
2540
+
2541
+ /**
2542
+ * The tile a directional focus chord moves to, in a row-major grid of `cols`
2543
+ * columns whose empty cells are `null` (a hole can be any cell) or simply
2544
+ * missing at the end. Focus never lands on a hole. Left and right go along
2545
+ * the row, past any hole, and never leave it. Up and down take the nearest
2546
+ * row in that direction that has a tile: the tile in the same column, else
2547
+ * the one in the nearest column (the lower column on a tie), so moving down
2548
+ * onto a short or holed last row lands on its nearest tile. Null when there
2549
+ * is no tile in that direction.
2550
+ *
2551
+ * @param {(string|null)[]} cells - the grid's cells in reading order (or its packed tiles)
2552
+ * @param {string} focusedId - the tile the keyboard is in
2553
+ * @param {'left'|'right'|'up'|'down'} direction
2554
+ * @param {number} cols - the layout's column count
2555
+ * @returns {string|null}
2556
+ */
2557
+ function tileInDirection(cells, focusedId, direction, cols) {
2558
+ const i = focusedId ? cells.indexOf(focusedId) : -1;
2559
+ if (i === -1 || !(cols >= 1)) return null;
2560
+ const rows = Math.ceil(cells.length / cols);
2561
+ const row = Math.floor(i / cols);
2562
+ const col = i % cols;
2563
+ const at = (r, c) => cells[r * cols + c] || null;
2564
+ if (direction === 'left' || direction === 'right') {
2565
+ const step = direction === 'left' ? -1 : 1;
2566
+ for (let c = col + step; c >= 0 && c < cols; c += step) {
2567
+ if (at(row, c)) return at(row, c);
2568
+ }
2569
+ return null;
2570
+ }
2571
+ if (direction !== 'up' && direction !== 'down') return null;
2572
+ const step = direction === 'up' ? -1 : 1;
2573
+ for (let r = row + step; r >= 0 && r < rows; r += step) {
2574
+ let best = null;
2575
+ let bestDistance = Infinity;
2576
+ for (let c = 0; c < cols; c++) {
2577
+ const id = at(r, c);
2578
+ if (id && Math.abs(c - col) < bestDistance) {
2579
+ best = id;
2580
+ bestDistance = Math.abs(c - col);
2581
+ }
2582
+ }
2583
+ if (best) return best;
2584
+ }
2585
+ return null;
2586
+ }
2587
+
2588
+ /**
2589
+ * The cell next to cell `index` in that direction (Move Tile: a tile moves
2590
+ * into an empty neighbour cell, or swaps with a tiled one), or -1 at the
2591
+ * edge. Adjacent only: a move never jumps over a cell.
2592
+ *
2593
+ * @param {number} index - the cell, in reading order
2594
+ * @param {'left'|'right'|'up'|'down'} direction
2595
+ * @param {number} cols - the layout's column count
2596
+ * @param {number} cellCount - cols x rows
2597
+ * @returns {number}
2598
+ */
2599
+ function tileCellInDirection(index, direction, cols, cellCount) {
2600
+ if (!(cols >= 1) || index < 0 || index >= cellCount) return -1;
2601
+ const col = index % cols;
2602
+ let j = -1;
2603
+ if (direction === 'left') j = col > 0 ? index - 1 : -1;
2604
+ else if (direction === 'right') j = col < cols - 1 ? index + 1 : -1;
2605
+ else if (direction === 'up') j = index - cols;
2606
+ else if (direction === 'down') j = index + cols;
2607
+ return j >= 0 && j < cellCount ? j : -1;
2608
+ }
2609
+
2610
+ /**
2611
+ * The grid's cells after its shape changed (or to fill one for the first
2612
+ * time): `cols` x `rows` cells, `null` for an empty one. The same shape keeps
2613
+ * every cell as it is. A new shape keeps each tile at its row and column when
2614
+ * every tile still fits there (2x2 growing to 3x2: the four tiles stay put),
2615
+ * and otherwise packs the tiles in reading order from the first cell, holes
2616
+ * collapsed (positions do not map between shapes). `oldCols` 0 (no layout
2617
+ * yet) always packs.
2618
+ *
2619
+ * @param {(string|null)[]} cells - the current cells, laid out `oldCols` wide
2620
+ * @param {number} oldCols - the column count they were laid out with
2621
+ * @param {number} cols
2622
+ * @param {number} rows
2623
+ * @returns {(string|null)[]}
2624
+ */
2625
+ function fitTileCells(cells, oldCols, cols, rows) {
2626
+ const size = Math.max(0, cols * rows);
2627
+ if (oldCols === cols && cells.length === size) return cells.slice();
2628
+ const out = new Array(size).fill(null);
2629
+ const placed = cells.map((id, k) => (id ? { id, row: Math.floor(k / oldCols), col: k % oldCols } : null));
2630
+ const keep = oldCols >= 1 && placed.every((p) => !p || (p.row < rows && p.col < cols));
2631
+ if (keep) {
2632
+ for (const p of placed) if (p) out[p.row * cols + p.col] = p.id;
2633
+ return out;
2634
+ }
2635
+ cells.filter(Boolean).slice(0, size).forEach((id, k) => {
2636
+ out[k] = id;
2637
+ });
2638
+ return out;
2639
+ }
2640
+
2641
+ /**
2642
+ * How many columns a grid of `length` cells was laid out with: stored cells
2643
+ * carry no shape of their own, and the layout table gives each cell count one
2644
+ * shape (computeTileLayout: 1x1, 2x1, 3x1, 2x2, 3x2, 3x3). 0 for any other
2645
+ * length (fitTileCells then packs).
2646
+ *
2647
+ * @param {number} length
2648
+ * @returns {number}
2649
+ */
2650
+ function tileCellCols(length) {
2651
+ // Every cell count is some count's shape on a wide grid area (2x2, the
2652
+ // narrow 3-tile shape, is also the 4-tile one).
2653
+ for (let n = 1; n <= TILE_LAYOUT_MAX; n++) {
2654
+ const { cols, rows } = computeTileLayout({ count: n });
2655
+ if (cols * rows === length) return cols;
2656
+ }
2657
+ return 0;
2658
+ }
2659
+
2660
+ /**
2661
+ * The cells of a grid re-formed to another set of tiles (a count picked in the
2662
+ * Tiles menu, or the Tiles button bringing back a remembered grid with more or
2663
+ * fewer tiles): the tiles in `keep` stay in their cells and every other cell
2664
+ * empties, then the cell model's shape rule (fitTileCells: each tile keeps its
2665
+ * row and column when all fit, else they pack in reading order), then the
2666
+ * tiles in `add` fill the empty cells in reading order, holes first.
2667
+ *
2668
+ * @param {(string|null)[]} cells - the cells now, laid out `oldCols` wide
2669
+ * @param {number} oldCols
2670
+ * @param {string[]} keep - tiles that stay
2671
+ * @param {string[]} add - tiles that join, in the order they fill
2672
+ * @param {number} cols - the new shape
2673
+ * @param {number} rows
2674
+ * @returns {(string|null)[]}
2675
+ */
2676
+ function reformTileCells(cells, oldCols, keep, add, cols, rows) {
2677
+ const kept = (cells || []).map((id) => (id && keep.includes(id) ? id : null));
2678
+ const out = fitTileCells(kept, oldCols, cols, rows);
2679
+ for (const id of add) {
2680
+ if (!id || out.includes(id)) continue;
2681
+ const k = out.indexOf(null);
2682
+ // Not for a shape made for the count; a full grid takes no more.
2683
+ if (k === -1) break;
2684
+ out[k] = id;
2685
+ }
2686
+ return out;
2687
+ }
2688
+
2689
+ /**
2690
+ * The tile Ctrl+Tab / Alt+] (delta 1) or Alt+[ (delta -1) moves to while the
2691
+ * grid is open: tiles cycle in reading order and wrap.
2692
+ *
2693
+ * @param {string[]} ids
2694
+ * @param {string} focusedId
2695
+ * @param {number} delta - +1 or -1
2696
+ * @returns {string|null}
2697
+ */
2698
+ function cycleTile(ids, focusedId, delta) {
2699
+ if (ids.length === 0) return null;
2700
+ const i = ids.indexOf(focusedId);
2701
+ if (i === -1) return ids[0];
2702
+ return ids[(i + delta + ids.length) % ids.length];
2703
+ }
2704
+
1636
2705
  // ── Renderer liveness ──────────────────────────────────────────────────────
1637
2706
  //
1638
2707
  // iOS DISCARDS scheduled requestAnimationFrame callbacks when a PWA goes to
@@ -1869,7 +2938,59 @@ function sessionIdFromFragment(hash) {
1869
2938
  return id && id.trim() ? id.trim() : null;
1870
2939
  }
1871
2940
 
2941
+ /** Longest model name a session header shows (the server caps it as well). */
2942
+ const SESSION_MODEL_MAX_CHARS = 64;
2943
+ /** A CLI registry id (src/config/cli-registry/schema.ts); anything else is not a class name. */
2944
+ const CLI_ID_PATTERN = /^[a-z][a-z0-9-]{0,23}$/;
2945
+
2946
+ /**
2947
+ * What a session's header says about its harness: the CLI id (the
2948
+ * `run-mode-dot <id>` logo class), the registry's label for it, the model the
2949
+ * session runs when the server knows it (`SessionState.displayModel`), and the
2950
+ * tooltip naming both.
2951
+ *
2952
+ * The id is data: the label comes from the injected CLI catalog and falls back
2953
+ * to the id, so a CLI added through clis.json still gets a name. The model is
2954
+ * untrusted text (read off a pane, or a CLI's own report): control characters
2955
+ * are dropped and the length capped here too, and callers render it with
2956
+ * textContent. The tooltip says where a model that is not the CLI's own report
2957
+ * came from, so it never claims more than the server knows: one the session
2958
+ * was launched with may have been switched since, one read from the CLI's
2959
+ * config is what it is configured to run, and a custom endpoint's model is the
2960
+ * endpoint's, whatever the CLI calls it.
2961
+ *
2962
+ * @param {object} session - a session from app.sessions
2963
+ * @param {Array<{id: string, label?: string}>} [catalog] - window.__codemanCliCatalog
2964
+ * @returns {{id: string, label: string, model: string, title: string}}
2965
+ */
2966
+ function describeSessionHarness(session, catalog) {
2967
+ const id = typeof session?.mode === 'string' && CLI_ID_PATTERN.test(session.mode) ? session.mode : '';
2968
+ const entry = id && Array.isArray(catalog) ? catalog.find((cli) => cli?.id === id) : null;
2969
+ const label = (typeof entry?.label === 'string' && entry.label.trim()) || id;
2970
+ const raw = session?.displayModel?.model;
2971
+ const model =
2972
+ typeof raw === 'string'
2973
+ ? raw
2974
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, '')
2975
+ .trim()
2976
+ .slice(0, SESSION_MODEL_MAX_CHARS)
2977
+ : '';
2978
+ const source = session?.displayModel?.source;
2979
+ const qualifier = !model
2980
+ ? ''
2981
+ : source === 'launch'
2982
+ ? ' (set at launch)'
2983
+ : source === 'custom-endpoint'
2984
+ ? ' (custom endpoint)'
2985
+ : source === 'config'
2986
+ ? ' (from config)'
2987
+ : '';
2988
+ const title = [label, model].filter(Boolean).join(' \u00B7 ') + qualifier;
2989
+ return { id, label, model, title };
2990
+ }
2991
+
1872
2992
  if (typeof window !== 'undefined') {
2993
+ window.CodemanSessionHarness = { describeSessionHarness, SESSION_MODEL_MAX_CHARS };
1873
2994
  window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
1874
2995
  window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
1875
2996
  window.CodemanTerminalLines = { terminalLogicalLine };
@@ -1879,6 +3000,34 @@ if (typeof window !== 'undefined') {
1879
3000
  buildSplitPickerSessions,
1880
3001
  SPLIT_PANE_MIN_WIDTH,
1881
3002
  };
3003
+ window.CodemanTileGrid = {
3004
+ computeTileLayout,
3005
+ tileGridCapacity,
3006
+ sanitizeTileGridState,
3007
+ buildTilePickerSessions,
3008
+ dragTrackFractions,
3009
+ tileNeighbor,
3010
+ tileInDirection,
3011
+ tileCellInDirection,
3012
+ fitTileCells,
3013
+ cycleTile,
3014
+ tileGridOpenSet,
3015
+ restoreTileGridCells,
3016
+ rankTileSessions,
3017
+ sanitizeTileCount,
3018
+ tileGridSetForCount,
3019
+ tileCellCols,
3020
+ reformTileCells,
3021
+ TILE_GRID_COUNTS,
3022
+ TILE_GRID_COUNT_DEFAULT,
3023
+ TILE_GRID_MAX,
3024
+ TILE_LAYOUT_MAX,
3025
+ TILE_MIN_W,
3026
+ TILE_MIN_H,
3027
+ TILE_SCROLLBACK,
3028
+ TILE_FONT_SIZE_DEFAULT,
3029
+ TILE_GRID_SSE_FILTER,
3030
+ };
1882
3031
  window.CodemanRenderLiveness = { shouldKickRenderer, RENDER_STALL_MS, RENDER_LIVENESS_POLL_MS };
1883
3032
  window.CodemanFetchDeadline = {
1884
3033
  terminalFetchDeadlineMs,