aicodeman 1.34.0 → 1.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (299) hide show
  1. package/README.md +11 -0
  2. package/README.zh-CN.md +8 -0
  3. package/dist/attachment-registry.d.ts +6 -0
  4. package/dist/attachment-registry.d.ts.map +1 -1
  5. package/dist/attachment-registry.js +9 -3
  6. package/dist/attachment-registry.js.map +1 -1
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +2 -2
  9. package/dist/cli.js.map +1 -1
  10. package/dist/config/cli-registry/patterns.d.ts +6 -0
  11. package/dist/config/cli-registry/patterns.d.ts.map +1 -1
  12. package/dist/config/cli-registry/patterns.js +13 -0
  13. package/dist/config/cli-registry/patterns.js.map +1 -1
  14. package/dist/config/cli-registry/schema.d.ts +13 -0
  15. package/dist/config/cli-registry/schema.d.ts.map +1 -1
  16. package/dist/config/cli-registry/schema.js +60 -2
  17. package/dist/config/cli-registry/schema.js.map +1 -1
  18. package/dist/config/cli-registry/stock.d.ts.map +1 -1
  19. package/dist/config/cli-registry/stock.js +175 -7
  20. package/dist/config/cli-registry/stock.js.map +1 -1
  21. package/dist/config/cli-registry/types.d.ts +93 -6
  22. package/dist/config/cli-registry/types.d.ts.map +1 -1
  23. package/dist/config/dependency-registry.d.ts +7 -0
  24. package/dist/config/dependency-registry.d.ts.map +1 -1
  25. package/dist/config/dependency-registry.js +15 -0
  26. package/dist/config/dependency-registry.js.map +1 -1
  27. package/dist/config/path-probe.d.ts +37 -0
  28. package/dist/config/path-probe.d.ts.map +1 -0
  29. package/dist/config/path-probe.js +43 -0
  30. package/dist/config/path-probe.js.map +1 -0
  31. package/dist/deepseek-route-config.d.ts +110 -0
  32. package/dist/deepseek-route-config.d.ts.map +1 -0
  33. package/dist/deepseek-route-config.js +477 -0
  34. package/dist/deepseek-route-config.js.map +1 -0
  35. package/dist/git-workspace-status.d.ts +246 -0
  36. package/dist/git-workspace-status.d.ts.map +1 -0
  37. package/dist/git-workspace-status.js +626 -0
  38. package/dist/git-workspace-status.js.map +1 -0
  39. package/dist/hooks-config.d.ts.map +1 -1
  40. package/dist/hooks-config.js +83 -13
  41. package/dist/hooks-config.js.map +1 -1
  42. package/dist/model-config-resolvers.d.ts +29 -0
  43. package/dist/model-config-resolvers.d.ts.map +1 -0
  44. package/dist/model-config-resolvers.js +34 -0
  45. package/dist/model-config-resolvers.js.map +1 -0
  46. package/dist/session-display-model.d.ts +110 -0
  47. package/dist/session-display-model.d.ts.map +1 -0
  48. package/dist/session-display-model.js +154 -0
  49. package/dist/session-display-model.js.map +1 -0
  50. package/dist/session.d.ts +145 -8
  51. package/dist/session.d.ts.map +1 -1
  52. package/dist/session.js +280 -35
  53. package/dist/session.js.map +1 -1
  54. package/dist/types/api.d.ts +7 -0
  55. package/dist/types/api.d.ts.map +1 -1
  56. package/dist/types/api.js.map +1 -1
  57. package/dist/types/session.d.ts +28 -1
  58. package/dist/types/session.d.ts.map +1 -1
  59. package/dist/types/session.js.map +1 -1
  60. package/dist/types/tools.d.ts +1 -1
  61. package/dist/types/tools.d.ts.map +1 -1
  62. package/dist/utils/bounded-path-probe.d.ts +99 -0
  63. package/dist/utils/bounded-path-probe.d.ts.map +1 -0
  64. package/dist/utils/bounded-path-probe.js +228 -0
  65. package/dist/utils/bounded-path-probe.js.map +1 -0
  66. package/dist/utils/cli-executable-resolver.d.ts +2 -0
  67. package/dist/utils/cli-executable-resolver.d.ts.map +1 -1
  68. package/dist/utils/cli-executable-resolver.js +2 -1
  69. package/dist/utils/cli-executable-resolver.js.map +1 -1
  70. package/dist/utils/deepseek-cli-resolver.d.ts +8 -0
  71. package/dist/utils/deepseek-cli-resolver.d.ts.map +1 -1
  72. package/dist/utils/deepseek-cli-resolver.js +18 -2
  73. package/dist/utils/deepseek-cli-resolver.js.map +1 -1
  74. package/dist/utils/dependency-checker.d.ts +2 -0
  75. package/dist/utils/dependency-checker.d.ts.map +1 -1
  76. package/dist/utils/dependency-checker.js +37 -9
  77. package/dist/utils/dependency-checker.js.map +1 -1
  78. package/dist/utils/index.d.ts +2 -0
  79. package/dist/utils/index.d.ts.map +1 -1
  80. package/dist/utils/index.js +1 -0
  81. package/dist/utils/index.js.map +1 -1
  82. package/dist/web/case-path.d.ts +58 -0
  83. package/dist/web/case-path.d.ts.map +1 -0
  84. package/dist/web/case-path.js +182 -0
  85. package/dist/web/case-path.js.map +1 -0
  86. package/dist/web/launch-defaults.d.ts +22 -0
  87. package/dist/web/launch-defaults.d.ts.map +1 -0
  88. package/dist/web/launch-defaults.js +46 -0
  89. package/dist/web/launch-defaults.js.map +1 -0
  90. package/dist/web/public/admin-ui.js.gz +0 -0
  91. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  92. package/dist/web/public/app.abadc970.js +43 -0
  93. package/dist/web/public/app.abadc970.js.br +0 -0
  94. package/dist/web/public/app.abadc970.js.gz +0 -0
  95. package/dist/web/public/approvals-ui.js.gz +0 -0
  96. package/dist/web/public/{constants.d95fade9.js → constants.c0f8f9de.js} +1009 -127
  97. package/dist/web/public/constants.c0f8f9de.js.br +0 -0
  98. package/dist/web/public/constants.c0f8f9de.js.gz +0 -0
  99. package/dist/web/public/cron-ui.js.gz +0 -0
  100. package/dist/web/public/entrance-animations.js +1 -0
  101. package/dist/web/public/entrance-animations.js.br +0 -0
  102. package/dist/web/public/entrance-animations.js.gz +0 -0
  103. package/dist/web/public/git-status-ui.js +719 -0
  104. package/dist/web/public/git-status-ui.js.br +0 -0
  105. package/dist/web/public/git-status-ui.js.gz +0 -0
  106. package/dist/web/public/home-sessions.js +12 -9
  107. package/dist/web/public/home-sessions.js.br +0 -0
  108. package/dist/web/public/home-sessions.js.gz +0 -0
  109. package/dist/web/public/host-wake-ui.js.gz +0 -0
  110. package/dist/web/public/i18n.a31747a9.js +2 -0
  111. package/dist/web/public/i18n.a31747a9.js.br +0 -0
  112. package/dist/web/public/i18n.a31747a9.js.gz +0 -0
  113. package/dist/web/public/{image-input.a216cf9e.js → image-input.898a538b.js} +18 -8
  114. package/dist/web/public/image-input.898a538b.js.br +0 -0
  115. package/dist/web/public/image-input.898a538b.js.gz +0 -0
  116. package/dist/web/public/index.html +205 -42
  117. package/dist/web/public/index.html.br +0 -0
  118. package/dist/web/public/index.html.gz +0 -0
  119. package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
  120. package/dist/web/public/keyboard-accessory.c1e7c7b7.js.gz +0 -0
  121. package/dist/web/public/{mobile-handlers.23a85db1.js → mobile-handlers.0ef9c311.js} +20 -0
  122. package/dist/web/public/mobile-handlers.0ef9c311.js.br +0 -0
  123. package/dist/web/public/mobile-handlers.0ef9c311.js.gz +0 -0
  124. package/dist/web/public/mobile-ime-preview.2dbaf262.js.gz +0 -0
  125. package/dist/web/public/mobile-overview.js.gz +0 -0
  126. package/dist/web/public/mobile.111b187d.css +1 -0
  127. package/dist/web/public/mobile.111b187d.css.br +0 -0
  128. package/dist/web/public/mobile.111b187d.css.gz +0 -0
  129. package/dist/web/public/notification-manager.50bb87d8.js.gz +0 -0
  130. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  131. package/dist/web/public/{panels-ui.6a5898d9.js → panels-ui.4735dfc2.js} +4 -4
  132. package/dist/web/public/panels-ui.4735dfc2.js.br +0 -0
  133. package/dist/web/public/{panels-ui.6a5898d9.js.gz → panels-ui.4735dfc2.js.gz} +0 -0
  134. package/dist/web/public/ralph-panel.b350e2ef.js.gz +0 -0
  135. package/dist/web/public/ralph-wizard.b6c0dd19.js.gz +0 -0
  136. package/dist/web/public/readmymind-ui.js.gz +0 -0
  137. package/dist/web/public/reboot-restore-ui.js.gz +0 -0
  138. package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
  139. package/dist/web/public/sanitize-html.e7147e76.js.gz +0 -0
  140. package/dist/web/public/session-lineage.js +229 -86
  141. package/dist/web/public/session-lineage.js.br +0 -0
  142. package/dist/web/public/session-lineage.js.gz +0 -0
  143. package/dist/web/public/session-ui.f731602c.js +100 -0
  144. package/dist/web/public/session-ui.f731602c.js.br +0 -0
  145. package/dist/web/public/session-ui.f731602c.js.gz +0 -0
  146. package/dist/web/public/settings-ui.a97ab16f.js +97 -0
  147. package/dist/web/public/settings-ui.a97ab16f.js.br +0 -0
  148. package/dist/web/public/settings-ui.a97ab16f.js.gz +0 -0
  149. package/dist/web/public/spreadsheet-preview-worker.js +290 -0
  150. package/dist/web/public/spreadsheet-preview-worker.js.br +0 -0
  151. package/dist/web/public/spreadsheet-preview-worker.js.gz +0 -0
  152. package/dist/web/public/spreadsheet-preview.af82fa2f.js +555 -0
  153. package/dist/web/public/spreadsheet-preview.af82fa2f.js.br +0 -0
  154. package/dist/web/public/spreadsheet-preview.af82fa2f.js.gz +0 -0
  155. package/dist/web/public/spreadsheet-xlsx-core.js +1006 -0
  156. package/dist/web/public/spreadsheet-xlsx-core.js.br +0 -0
  157. package/dist/web/public/spreadsheet-xlsx-core.js.gz +0 -0
  158. package/dist/web/public/styles.8ee12666.css +1 -0
  159. package/dist/web/public/styles.8ee12666.css.br +0 -0
  160. package/dist/web/public/styles.8ee12666.css.gz +0 -0
  161. package/dist/web/public/subagent-windows.e6ca799f.js.gz +0 -0
  162. package/dist/web/public/sw.js +2 -2
  163. package/dist/web/public/sw.js.br +0 -0
  164. package/dist/web/public/sw.js.gz +0 -0
  165. package/dist/web/public/tab-layout-browser.065fcba4.js +1 -0
  166. package/dist/web/public/tab-layout-browser.065fcba4.js.br +0 -0
  167. package/dist/web/public/tab-layout-browser.065fcba4.js.gz +0 -0
  168. package/dist/web/public/{tab-rail-resize.dcdc5772.js → tab-rail-resize.a5867793.js} +1 -1
  169. package/dist/web/public/tab-rail-resize.a5867793.js.br +0 -0
  170. package/dist/web/public/tab-rail-resize.a5867793.js.gz +0 -0
  171. package/dist/web/public/terminal-keycode229-recovery.af09f99a.js +1 -0
  172. package/dist/web/public/terminal-keycode229-recovery.af09f99a.js.br +0 -0
  173. package/dist/web/public/terminal-keycode229-recovery.af09f99a.js.gz +0 -0
  174. package/dist/web/public/terminal-split.js +146 -649
  175. package/dist/web/public/terminal-split.js.br +0 -0
  176. package/dist/web/public/terminal-split.js.gz +0 -0
  177. package/dist/web/public/terminal-tile.js +1611 -0
  178. package/dist/web/public/terminal-tile.js.br +0 -0
  179. package/dist/web/public/terminal-tile.js.gz +0 -0
  180. package/dist/web/public/terminal-ui.5059b30e.js +2 -0
  181. package/dist/web/public/terminal-ui.5059b30e.js.br +0 -0
  182. package/dist/web/public/terminal-ui.5059b30e.js.gz +0 -0
  183. package/dist/web/public/tile-grid.js +2684 -0
  184. package/dist/web/public/tile-grid.js.br +0 -0
  185. package/dist/web/public/tile-grid.js.gz +0 -0
  186. package/dist/web/public/ultracode-panel.js.gz +0 -0
  187. package/dist/web/public/ultracode-windows.js.gz +0 -0
  188. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  189. package/dist/web/public/vendor/exceljs.min.js +44 -0
  190. package/dist/web/public/vendor/exceljs.min.js.br +0 -0
  191. package/dist/web/public/vendor/exceljs.min.js.gz +0 -0
  192. package/dist/web/public/vendor/fflate.min.js +1 -0
  193. package/dist/web/public/vendor/fflate.min.js.br +0 -0
  194. package/dist/web/public/vendor/fflate.min.js.gz +0 -0
  195. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  196. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  197. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  198. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  199. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  200. package/dist/web/public/vendor/xterm-predictive-echo.ae5e3d5d.js.gz +0 -0
  201. package/dist/web/public/vendor/xterm-zerolag-input.bbb5f84c.js.gz +0 -0
  202. package/dist/web/public/vendor/xterm.css.gz +0 -0
  203. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  204. package/dist/web/public/{voice-input.c4b51eb6.js → voice-input.2342c37b.js} +64 -10
  205. package/dist/web/public/voice-input.2342c37b.js.br +0 -0
  206. package/dist/web/public/voice-input.2342c37b.js.gz +0 -0
  207. package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
  208. package/dist/web/public/webview-tabs.js +9 -1
  209. package/dist/web/public/webview-tabs.js.br +0 -0
  210. package/dist/web/public/webview-tabs.js.gz +0 -0
  211. package/dist/web/routes/case-routes.d.ts.map +1 -1
  212. package/dist/web/routes/case-routes.js +136 -18
  213. package/dist/web/routes/case-routes.js.map +1 -1
  214. package/dist/web/routes/cli-registry-routes.d.ts.map +1 -1
  215. package/dist/web/routes/cli-registry-routes.js +3 -1
  216. package/dist/web/routes/cli-registry-routes.js.map +1 -1
  217. package/dist/web/routes/doctor-routes.d.ts +23 -0
  218. package/dist/web/routes/doctor-routes.d.ts.map +1 -0
  219. package/dist/web/routes/doctor-routes.js +83 -0
  220. package/dist/web/routes/doctor-routes.js.map +1 -0
  221. package/dist/web/routes/file-routes.d.ts +7 -0
  222. package/dist/web/routes/file-routes.d.ts.map +1 -1
  223. package/dist/web/routes/file-routes.js +34 -4
  224. package/dist/web/routes/file-routes.js.map +1 -1
  225. package/dist/web/routes/git-status-routes.d.ts +16 -0
  226. package/dist/web/routes/git-status-routes.d.ts.map +1 -0
  227. package/dist/web/routes/git-status-routes.js +98 -0
  228. package/dist/web/routes/git-status-routes.js.map +1 -0
  229. package/dist/web/routes/index.d.ts +2 -0
  230. package/dist/web/routes/index.d.ts.map +1 -1
  231. package/dist/web/routes/index.js +2 -0
  232. package/dist/web/routes/index.js.map +1 -1
  233. package/dist/web/routes/session-routes.d.ts +18 -0
  234. package/dist/web/routes/session-routes.d.ts.map +1 -1
  235. package/dist/web/routes/session-routes.js +106 -42
  236. package/dist/web/routes/session-routes.js.map +1 -1
  237. package/dist/web/routes/status-telemetry-routes.d.ts +3 -0
  238. package/dist/web/routes/status-telemetry-routes.d.ts.map +1 -1
  239. package/dist/web/routes/status-telemetry-routes.js +12 -0
  240. package/dist/web/routes/status-telemetry-routes.js.map +1 -1
  241. package/dist/web/routes/system-routes.d.ts.map +1 -1
  242. package/dist/web/routes/system-routes.js +13 -0
  243. package/dist/web/routes/system-routes.js.map +1 -1
  244. package/dist/web/schemas.d.ts +28 -0
  245. package/dist/web/schemas.d.ts.map +1 -1
  246. package/dist/web/schemas.js +41 -0
  247. package/dist/web/schemas.js.map +1 -1
  248. package/dist/web/server.d.ts +5 -0
  249. package/dist/web/server.d.ts.map +1 -1
  250. package/dist/web/server.js +35 -12
  251. package/dist/web/server.js.map +1 -1
  252. package/dist/web/session-listener-wiring.d.ts +1 -0
  253. package/dist/web/session-listener-wiring.d.ts.map +1 -1
  254. package/dist/web/session-listener-wiring.js +11 -0
  255. package/dist/web/session-listener-wiring.js.map +1 -1
  256. package/package.json +3 -1
  257. package/scripts/postinstall.js +16 -0
  258. package/skills/codeman/reference/verbs.md +3 -0
  259. package/dist/web/public/app.cf0db8ce.js +0 -43
  260. package/dist/web/public/app.cf0db8ce.js.br +0 -0
  261. package/dist/web/public/app.cf0db8ce.js.gz +0 -0
  262. package/dist/web/public/constants.d95fade9.js.br +0 -0
  263. package/dist/web/public/constants.d95fade9.js.gz +0 -0
  264. package/dist/web/public/i18n.398b8331.js +0 -1
  265. package/dist/web/public/i18n.398b8331.js.br +0 -0
  266. package/dist/web/public/i18n.398b8331.js.gz +0 -0
  267. package/dist/web/public/image-input.a216cf9e.js.br +0 -0
  268. package/dist/web/public/image-input.a216cf9e.js.gz +0 -0
  269. package/dist/web/public/mobile-handlers.23a85db1.js.br +0 -0
  270. package/dist/web/public/mobile-handlers.23a85db1.js.gz +0 -0
  271. package/dist/web/public/mobile.1aa4952d.css +0 -1
  272. package/dist/web/public/mobile.1aa4952d.css.br +0 -0
  273. package/dist/web/public/mobile.1aa4952d.css.gz +0 -0
  274. package/dist/web/public/panels-ui.6a5898d9.js.br +0 -0
  275. package/dist/web/public/session-ui.bf66c13b.js +0 -89
  276. package/dist/web/public/session-ui.bf66c13b.js.br +0 -0
  277. package/dist/web/public/session-ui.bf66c13b.js.gz +0 -0
  278. package/dist/web/public/settings-ui.331d3b1d.js +0 -97
  279. package/dist/web/public/settings-ui.331d3b1d.js.br +0 -0
  280. package/dist/web/public/settings-ui.331d3b1d.js.gz +0 -0
  281. package/dist/web/public/styles.8c485d26.css +0 -1
  282. package/dist/web/public/styles.8c485d26.css.br +0 -0
  283. package/dist/web/public/styles.8c485d26.css.gz +0 -0
  284. package/dist/web/public/tab-layout-browser.4e0ed50c.js +0 -1
  285. package/dist/web/public/tab-layout-browser.4e0ed50c.js.br +0 -0
  286. package/dist/web/public/tab-layout-browser.4e0ed50c.js.gz +0 -0
  287. package/dist/web/public/tab-rail-resize.dcdc5772.js.br +0 -0
  288. package/dist/web/public/tab-rail-resize.dcdc5772.js.gz +0 -0
  289. package/dist/web/public/terminal-keycode229-recovery.fb91b25b.js +0 -1
  290. package/dist/web/public/terminal-keycode229-recovery.fb91b25b.js.br +0 -0
  291. package/dist/web/public/terminal-keycode229-recovery.fb91b25b.js.gz +0 -0
  292. package/dist/web/public/terminal-ui.a4535311.js +0 -2
  293. package/dist/web/public/terminal-ui.a4535311.js.br +0 -0
  294. package/dist/web/public/terminal-ui.a4535311.js.gz +0 -0
  295. package/dist/web/public/upload.html +0 -155
  296. package/dist/web/public/upload.html.br +0 -0
  297. package/dist/web/public/upload.html.gz +0 -0
  298. package/dist/web/public/voice-input.c4b51eb6.js.br +0 -0
  299. package/dist/web/public/voice-input.c4b51eb6.js.gz +0 -0
@@ -207,6 +207,9 @@ function shouldAutoWrapTabs(input) {
207
207
  if (!input || input.deviceType !== 'desktop') return false;
208
208
  if (input.manualTwoRows) return false;
209
209
  if ((input.tabCount || 0) < 2) return false;
210
+ // A box in the strip already wraps inside itself (a case cluster wider than the
211
+ // whole strip): the strip has rows although nothing overflows.
212
+ if (input.innerWrap) return true;
210
213
 
211
214
  const scrollWidth = Number(input.scrollWidth) || 0;
212
215
  const clientWidth = Number(input.clientWidth) || 0;
@@ -296,139 +299,307 @@ function computeTabScrollLeft(input) {
296
299
  return Math.min(Math.max(Math.round(target), 0), maxScroll);
297
300
  }
298
301
 
299
- // Session lineage lines — geometry for the arc drawn between a tab and a tab it
302
+ // Session lineage lines: geometry for the lines joining a tab to the tabs it
300
303
  // spawned (a worker started through the codeman agent skill, which passes its own
301
304
  // id as parentSessionId). Pure: the caller measures and appends, this decides.
302
305
  //
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.
306
+ // ONE TREE PER SPAWNING TAB. Every family is drawn, the selected tab's emphasized
307
+ // (session-lineage.js). Every route starts at the PARENT and ends at one child, so
308
+ // a parent's routes share their first stretch exactly: overlaid, they read as one
309
+ // trunk with a branch per child, and a dashed (working) route stays in phase with
310
+ // its siblings along the shared part.
309
311
  //
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.
312
+ // ⚠ ROUTES RUN IN THE GAPS, NEVER THROUGH A TAB. This replaced one bezier per
313
+ // child hanging below the strip, which in a wrapped strip crossed every lower
314
+ // row's labels and the terminal text (owner screenshot 2026-10-06: a parent on
315
+ // row 3 with ten children, "too confusing"). A route now moves horizontally only
316
+ // inside a row gap, and vertically only along a tab's own stem (its bottom edge to
317
+ // the gap right under it) or along the SPINE, a channel left of every row that
318
+ // joins the gaps of different rows. styles.css reserves that room
319
+ // (`.session-tabs.lineage-tree`: a wider row gap, bottom padding for the last
320
+ // row's gap, and the spine channel on the left of a wrapped strip).
319
321
  //
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;
322
+ // The channel is at the strip's left edge, except where a tab arrangement puts
323
+ // something there: grouped by state, the edge holds the label column and the
324
+ // channel opens between the labels and the tabs. session-lineage.js measures it
325
+ // and passes its left edge as `spineLeft`; without one it is the strip's edge.
326
+ //
327
+ // Rows come from computeLineageRows() over EVERY tab in the strip, not only the
328
+ // endpoints: a row's gap sits under its TALLEST tab (the active tab is 2px
329
+ // taller), or siblings in one row would hang their bus at different heights.
330
+ //
331
+ // computeLineageTree() returns null when the parent cannot be drawn (missing or
332
+ // degenerate rect, scrolled out of the strip); a child that cannot be drawn is
333
+ // left out of `routes`. `.session-tabs` scrolls, so a scrolled-out tab still HAS a
334
+ // rect, lying over the logo or the header buttons. Skipping is honest; clamping
335
+ // would point at a tab that is not there.
336
+ //
337
+ // A child is also left out when its route has nowhere to run: rows whose spans
338
+ // overlap are one row (no gap between them), a row with no gap under it routes
339
+ // nothing, and a route that would still cross a tab is dropped whole. So no tab
340
+ // arrangement can put a line through a tab; a layout without the reserved room
341
+ // loses lines instead.
342
+ const LINEAGE_CORNER_RADIUS_PX = 10;
343
+ // Families drawn together take separate lanes: gap lines this far apart, spines
344
+ // LINEAGE_SPINE_STEP_PX apart.
345
+ const LINEAGE_LANE_STEP_PX = 3.5;
346
+ // Every family is drawn at once, and a 12px row gap only fits this many lanes at
347
+ // LINEAGE_LANE_STEP_PX; session-lineage.js cycles families through them.
348
+ const LINEAGE_MAX_LANES = 3;
349
+ const LINEAGE_SPINE_INSET_PX = 6;
350
+ const LINEAGE_SPINE_STEP_PX = 4;
351
+ // The last row has no row below it; with no strip rect to measure, its gap is
352
+ // taken to be this deep.
353
+ const LINEAGE_LAST_GAP_PX = 12;
354
+ // Narrower than this, the space between two rows is not a gap a route can run in.
355
+ const LINEAGE_MIN_GAP_PX = 2;
356
+ const LINEAGE_ROW_TOLERANCE_PX = 6;
344
357
  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.
358
+ // How far the vertical rail's track sits in from the rail's left edge. It has to
359
+ // clear the VIEWPORT edge, not just the tabs, or the line reads as a thread pinned
360
+ // to the window frame. The rail reserves the channel itself
361
+ // (`--lineage-vertical-gutter` on the rail's .session-tabs), and the track is
362
+ // still clamped to stay left of every endpoint.
351
363
  const LINEAGE_VERTICAL_TRACK_INSET_PX = 10;
352
- const LINEAGE_VERTICAL_SIBLING_STEP_PX = 3;
364
+ const LINEAGE_VERTICAL_LANE_STEP_PX = 4;
353
365
  const LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX = 4;
354
366
  // 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.
367
+ // (session-lineage.js). Every line leaving one tab shares its colour however many
368
+ // workers it spawns; a child that spawns in turn gets its own for the lines below it.
357
369
  // 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
370
+ // which every skin block tunes for its own background, so a lone family keeps the
359
371
  // 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.
372
+ // (owner call 2026-08-15: matrix green, pinkish, violet, red, turquoise "and so on").
363
373
  const LINEAGE_COLORS = ['', '#00ff66', '#ff5ea8', '#a78bfa', '#ff5252', '#2dd4bf', '#ffa940'];
364
374
 
365
- function computeLineagePath(input) {
366
- const parent = input?.parent;
367
- const child = input?.child;
368
- if (!parent || !child) return null;
375
+ /** A rect normalized to numbers with its edges and center, or null if unusable. */
376
+ function lineageRect(rect) {
377
+ if (!rect) return null;
378
+ const left = Number(rect.left);
379
+ const top = Number(rect.top);
380
+ const width = Number(rect.width);
381
+ const height = Number(rect.height);
382
+ if (![left, top, width, height].every(Number.isFinite) || width <= 0 || height <= 0) return null;
383
+ return {
384
+ left,
385
+ top,
386
+ width,
387
+ height,
388
+ right: left + width,
389
+ bottom: top + height,
390
+ cx: left + width / 2,
391
+ cy: top + height / 2,
392
+ };
393
+ }
394
+
395
+ /**
396
+ * Group tab rects into the strip's visual rows, top to bottom. A row spans from its
397
+ * highest top to its LOWEST bottom, so a taller tab (the active one) sets the row's
398
+ * gap for everyone in it.
399
+ *
400
+ * ⚠ Rows never overlap. Tabs whose spans overlap are in one row however far apart
401
+ * their tops are: case clusters stack and centre tabs inside their boxes, and two
402
+ * overlapping "rows" put the gap of one in the middle of the other's tabs.
403
+ */
404
+ function computeLineageRows(rects) {
405
+ const sorted = [];
406
+ for (const raw of rects || []) {
407
+ const r = lineageRect(raw);
408
+ if (r) sorted.push(r);
409
+ }
410
+ sorted.sort((a, b) => a.top - b.top);
411
+ const rows = [];
412
+ for (const r of sorted) {
413
+ // Sorted by top, so only the last row can take this rect, and merging into it
414
+ // keeps every earlier row clear of it.
415
+ const row = rows[rows.length - 1];
416
+ if (row && (r.top < row.bottom || r.top - row.top <= LINEAGE_ROW_TOLERANCE_PX)) {
417
+ row.bottom = Math.max(row.bottom, r.bottom);
418
+ } else {
419
+ rows.push({ top: r.top, bottom: r.bottom });
420
+ }
421
+ }
422
+ return rows;
423
+ }
424
+
425
+ /** Does an axis-aligned segment pass through the inside of a rect? Touching an edge is fine. */
426
+ function lineageSegmentCrosses([x1, y1], [x2, y2], r) {
427
+ const eps = 0.5;
428
+ if (Math.max(x1, x2) <= r.left + eps || Math.min(x1, x2) >= r.right - eps) return false;
429
+ return Math.max(y1, y2) > r.top + eps && Math.min(y1, y2) < r.bottom - eps;
430
+ }
369
431
 
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;
432
+ /**
433
+ * An orthogonal polyline as an SVG path, each corner rounded by up to `radius`
434
+ * (never more than half of either segment, so short stems stay short). Repeated and
435
+ * collinear points are dropped first, so a degenerate corner draws nothing odd.
436
+ */
437
+ function lineagePolylinePath(points, radius) {
438
+ const pts = [];
439
+ for (const p of points) {
440
+ const prev = pts[pts.length - 1];
441
+ if (prev && Math.abs(prev[0] - p[0]) < 0.5 && Math.abs(prev[1] - p[1]) < 0.5) continue;
442
+ const before = pts[pts.length - 2];
443
+ if (before && prev) {
444
+ const cross = (prev[0] - before[0]) * (p[1] - prev[1]) - (prev[1] - before[1]) * (p[0] - prev[0]);
445
+ if (Math.abs(cross) < 0.01) pts.pop();
446
+ }
447
+ pts.push(p);
448
+ }
449
+ if (pts.length < 2) return null;
450
+ let d = `M ${r1(pts[0][0])} ${r1(pts[0][1])}`;
451
+ for (let i = 1; i < pts.length - 1; i++) {
452
+ const [x0, y0] = pts[i - 1];
453
+ const [x1, y1] = pts[i];
454
+ const [x2, y2] = pts[i + 1];
455
+ const lenIn = Math.hypot(x1 - x0, y1 - y0);
456
+ const lenOut = Math.hypot(x2 - x1, y2 - y1);
457
+ const r = Math.min(radius, lenIn / 2, lenOut / 2);
458
+ if (!(r > 0.5)) {
459
+ d += ` L ${r1(x1)} ${r1(y1)}`;
460
+ continue;
461
+ }
462
+ const ax = x1 - ((x1 - x0) / lenIn) * r;
463
+ const ay = y1 - ((y1 - y0) / lenIn) * r;
464
+ const bx = x1 + ((x2 - x1) / lenOut) * r;
465
+ const by = y1 + ((y2 - y1) / lenOut) * r;
466
+ d += ` L ${r1(ax)} ${r1(ay)} Q ${r1(x1)} ${r1(y1)} ${r1(bx)} ${r1(by)}`;
467
+ }
468
+ const last = pts[pts.length - 1];
469
+ return d + ` L ${r1(last[0])} ${r1(last[1])}`;
470
+ }
375
471
 
472
+ /**
473
+ * Routes from one parent tab to each of its children.
474
+ *
475
+ * input: { parent, children: [{ id, rect }], strip?, tabs?, spineLeft?,
476
+ * orientation?, lane?, laneCount?, radius? }. `tabs` is every tab rect in
477
+ * the strip (rows are derived from it); `spineLeft` is the left edge of the
478
+ * spine channel (default: the strip's left edge); `lane`/`laneCount`
479
+ * separate families drawn together.
480
+ * Returns { routes: [{ id, points, d, endX, endY }] } or null.
481
+ */
482
+ function computeLineageTree(input) {
483
+ const parent = lineageRect(input?.parent);
484
+ if (!parent) return null;
485
+ const strip = lineageRect(input?.strip);
376
486
  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;
487
+ const laneCount = Math.max(1, Math.floor(Number(input?.laneCount)) || 1);
488
+ const lane = Math.max(0, Math.min(laneCount - 1, Math.floor(Number(input?.lane)) || 0));
489
+ const radius = Number.isFinite(Number(input?.radius)) ? Math.max(0, Number(input.radius)) : LINEAGE_CORNER_RADIUS_PX;
490
+ const children = [];
491
+ for (const child of input?.children || []) {
492
+ const rect = lineageRect(child?.rect);
493
+ if (rect) children.push({ id: child.id, rect });
494
+ }
495
+
496
+ const tol = LINEAGE_STRIP_TOLERANCE_PX;
497
+ const inStripY = (r) => !strip || (r.cy >= strip.top - tol && r.cy <= strip.bottom + tol);
498
+ const inStripX = (r) => !strip || (r.cx >= strip.left - tol && r.cx <= strip.right + tol);
499
+ const routes = [];
384
500
 
385
501
  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;
502
+ // The rail: one track down the empty left gutter, shared by every sibling.
503
+ if (!inStripY(parent)) return null;
504
+ const visible = children.filter((c) => inStripY(c.rect));
505
+ if (visible.length === 0) return { routes };
506
+ const minLeft = Math.min(parent.left, ...visible.map((c) => c.rect.left));
507
+ const base = strip ? strip.left : minLeft - LINEAGE_VERTICAL_TRACK_INSET_PX * 2;
508
+ const trackX = Math.min(
509
+ base + LINEAGE_VERTICAL_TRACK_INSET_PX + lane * LINEAGE_VERTICAL_LANE_STEP_PX,
510
+ minLeft - LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX
511
+ );
512
+ for (const { id, rect } of visible) {
513
+ const points = [
514
+ [parent.left, parent.cy],
515
+ [trackX, parent.cy],
516
+ [trackX, rect.cy],
517
+ [rect.left, rect.cy],
518
+ ];
519
+ const d = lineagePolylinePath(points, radius);
520
+ if (d) routes.push({ id, points: roundPoints(points), d, endX: r1(rect.left), endY: r1(rect.cy) });
392
521
  }
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 };
522
+ return { routes };
403
523
  }
404
524
 
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;
525
+ if (!inStripX(parent) || !inStripY(parent)) return null;
526
+ const visible = children.filter((c) => inStripX(c.rect) && inStripY(c.rect));
527
+ if (visible.length === 0) return { routes };
528
+
529
+ const rows = computeLineageRows([...(input?.tabs || []), parent, ...visible.map((c) => c.rect)]);
530
+ const rowOf = (r) => rows.findIndex((row) => r.cy >= row.top - tol && r.cy <= row.bottom + tol);
531
+ const laneOffset = (lane - (laneCount - 1) / 2) * LINEAGE_LANE_STEP_PX;
532
+ // Y of the gap under row i, this family's lane. The offset is clamped so a busy
533
+ // gap never pushes a lane into the tabs on either side of it. Null when there is
534
+ // no gap: the row is not found, or the next row starts (nearly) where it ends.
535
+ const gapUnder = (i) => {
536
+ const row = rows[i];
537
+ if (!row) return null;
538
+ const next = rows[i + 1];
539
+ let bottom;
540
+ if (next) bottom = next.top;
541
+ else if (strip && strip.bottom > row.bottom + 1) bottom = strip.bottom;
542
+ else bottom = row.bottom + LINEAGE_LAST_GAP_PX;
543
+ if (!(bottom - row.bottom >= LINEAGE_MIN_GAP_PX)) return null;
544
+ const half = Math.max(0, (bottom - row.bottom) / 2 - 1);
545
+ return (row.bottom + bottom) / 2 + Math.max(-half, Math.min(half, laneOffset));
546
+ };
547
+ const pRow = rowOf(parent);
548
+ const gp = gapUnder(pRow);
549
+ if (gp === null) return { routes };
550
+ // The spine runs from one row's gap to another's, so it only ever passes BESIDE
551
+ // rows after the first, and only their tabs bound it. The first row may start
552
+ // left of the channel (grouped by state it starts after the brand and a label
553
+ // of its own width), and clamping to it would pull the spine back over the
554
+ // label column.
555
+ const lowerLefts = [parent, ...visible.map((c) => c.rect), ...(input?.tabs || []).map(lineageRect)]
556
+ .filter((r) => r && rowOf(r) > 0)
557
+ .map((r) => r.left);
558
+ const minLeft = lowerLefts.length
559
+ ? Math.min(...lowerLefts)
560
+ : Math.min(parent.left, ...visible.map((c) => c.rect.left));
561
+ const channelLeft = Number(input?.spineLeft);
562
+ const spineBase = strip
563
+ ? Math.max(strip.left, Number.isFinite(channelLeft) ? channelLeft : strip.left)
564
+ : minLeft - LINEAGE_SPINE_INSET_PX * 2;
565
+ const spineX = Math.min(spineBase + LINEAGE_SPINE_INSET_PX + lane * LINEAGE_SPINE_STEP_PX, minLeft - 2);
566
+
567
+ // Every tab a route must stay out of. A segment can only cross one if the rows
568
+ // above failed to describe the layout, so this is a backstop that drops the
569
+ // route whole rather than drawing it through a tab.
570
+ const obstacles = [parent, ...visible.map((c) => c.rect), ...(input?.tabs || []).map(lineageRect)].filter(Boolean);
571
+ for (const { id, rect } of visible) {
572
+ const cRow = rowOf(rect);
573
+ const gc = gapUnder(cRow);
574
+ if (gc === null) continue;
575
+ const points =
576
+ cRow === pRow
577
+ ? [
578
+ [parent.cx, parent.bottom],
579
+ [parent.cx, gp],
580
+ [rect.cx, gp],
581
+ [rect.cx, rect.bottom],
582
+ ]
583
+ : [
584
+ [parent.cx, parent.bottom],
585
+ [parent.cx, gp],
586
+ [spineX, gp],
587
+ [spineX, gc],
588
+ [rect.cx, gc],
589
+ [rect.cx, rect.bottom],
590
+ ];
591
+ // The rounded corners cut inside each turn by a few pixels at most
592
+ // (lineagePolylinePath), so the segments are what has to clear the tabs.
593
+ const blocked = points.some((p, i) => i > 0 && obstacles.some((r) => lineageSegmentCrosses(points[i - 1], p, r)));
594
+ if (blocked) continue;
595
+ const d = lineagePolylinePath(points, radius);
596
+ if (d) routes.push({ id, points: roundPoints(points), d, endX: r1(rect.cx), endY: r1(rect.bottom) });
411
597
  }
598
+ return { routes };
599
+ }
412
600
 
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 };
601
+ function roundPoints(points) {
602
+ return points.map(([x, y]) => [r1(x), r1(y)]);
432
603
  }
433
604
 
434
605
  // One decimal is plenty for a screen-space path and keeps the `d` string short.
@@ -658,6 +829,194 @@ function sortSessionsByActivity(rows) {
658
829
  return (Array.isArray(rows) ? rows.slice() : []).sort(compareSessionActivity);
659
830
  }
660
831
 
832
+ // Tab grouping by state (`tabArrangement: 'state'`, Discussion #426 option C).
833
+ //
834
+ // The tab list answers "who wants me?" the way the home screens do: a row (the
835
+ // header strip) or a section (the flat side rail, the sidebar) per state, most
836
+ // urgent on top. The states are the home screens' own, from
837
+ // `_mobileOverviewState()` (mobile-overview.js); this only folds the six into
838
+ // four groups a strip can hold:
839
+ //
840
+ // needs red: a permission or question dialog is blocking the agent. A
841
+ // failed session joins it, since it also needs a human and the home
842
+ // screens rank it right below.
843
+ // waiting yellow: the agent finished its turn and is waiting on you.
844
+ // working a turn is running.
845
+ // idle everything quiet: idle, ended, and an agent that exited inside a
846
+ // live pane (#446), which may still read as working on screen but is
847
+ // running nothing. Web tabs close the group.
848
+ //
849
+ // Applied as the flex `order` property, never by reordering the DOM, the same
850
+ // design as the sorted rail (`_tabRailSortOrder`, app.js): `#sessionTabs` stays
851
+ // in `sessionOrder`, so Alt+N, drag-and-drop and the keyboard walk keep reading
852
+ // the list they always read, and a state change moves one inline style instead
853
+ // of rebuilding the strip. Each group owns a band of `TAB_TRIAGE_STRIDE` order
854
+ // values: its heading at the start of the band, its rows after it, its web tabs
855
+ // after those and the line break that ends the header row at the very end.
856
+ //
857
+ // Pure: no DOM, no `this`. Unit-tested in test/tab-triage.test.ts.
858
+ // `quiet` groups keep their heading element (it anchors the row and holds the
859
+ // row's place beside the brand) but draw no text: everything quiet is the
860
+ // default state of a tab, so naming it only adds noise.
861
+ const TAB_TRIAGE_GROUPS = [
862
+ { key: 'needs', label: 'Needs you' },
863
+ { key: 'waiting', label: 'Waiting' },
864
+ { key: 'working', label: 'Working' },
865
+ { key: 'idle', label: 'Idle', quiet: true },
866
+ ];
867
+
868
+ const TAB_TRIAGE_GROUP_OF_STATE = {
869
+ needs: 'needs',
870
+ error: 'needs',
871
+ waiting: 'waiting',
872
+ working: 'working',
873
+ idle: 'idle',
874
+ done: 'idle',
875
+ };
876
+
877
+ const TAB_TRIAGE_STRIDE = 10000;
878
+ /** Offset of a group's web tabs inside its band, past any plausible session count. */
879
+ const TAB_TRIAGE_WEB_OFFSET = 5000;
880
+
881
+ /**
882
+ * Which group a session belongs to.
883
+ * @param {string} state a `_mobileOverviewState()` value
884
+ * @param {boolean} exited the agent inside the pane has exited (`_mobileOverviewExit()` non-null)
885
+ * @returns {'needs'|'waiting'|'working'|'idle'}
886
+ */
887
+ function tabTriageGroupFor(state, exited) {
888
+ const group = TAB_TRIAGE_GROUP_OF_STATE[state] || 'idle';
889
+ return exited && group === 'working' ? 'idle' : group;
890
+ }
891
+
892
+ /**
893
+ * Order values and visible groups for one pass.
894
+ *
895
+ * @param {Array<{id: string, state: string, exited?: boolean, pos?: number}>} rows
896
+ * live sessions; `pos` ranks a row inside its group (tab order on the header
897
+ * strip, the activity sort's position on a sorted rail). Rows without one keep
898
+ * the order they were passed in.
899
+ * @param {Array<string>} webviewIds open web tabs, in their own tab order
900
+ * @param {{reverse?: boolean}} [options] `reverse` puts the groups the other
901
+ * way up (`tabStateOrder: 'urgent-last'`): idle first, needs you last, for a
902
+ * strip read from the bottom. Rows inside a group keep their order.
903
+ * @returns {{
904
+ * order: Map<string, number>,
905
+ * webOrder: Map<string, number>,
906
+ * groups: Array<{key: string, label: string, quiet: boolean, count: number, headOrder: number, breakOrder: number}>
907
+ * }} `groups` lists only the non-empty groups, in display order (most urgent
908
+ * first, or last with `reverse`).
909
+ */
910
+ function computeTabTriageLayout(rows, webviewIds, options) {
911
+ const list = Array.isArray(rows) ? rows : [];
912
+ const webs = Array.isArray(webviewIds) ? webviewIds : [];
913
+ const sequence = options && options.reverse ? TAB_TRIAGE_GROUPS.slice().reverse() : TAB_TRIAGE_GROUPS;
914
+ const baseOf = {};
915
+ const counts = {};
916
+ sequence.forEach((group, i) => {
917
+ baseOf[group.key] = (i + 1) * TAB_TRIAGE_STRIDE;
918
+ counts[group.key] = 0;
919
+ });
920
+
921
+ const placed = list
922
+ .filter((row) => row && typeof row.id === 'string')
923
+ .map((row, i) => ({
924
+ id: row.id,
925
+ group: tabTriageGroupFor(row.state, !!row.exited),
926
+ pos: Number.isFinite(row.pos) ? row.pos : i,
927
+ index: i,
928
+ }));
929
+ const byGroup = {};
930
+ for (const row of placed) (byGroup[row.group] = byGroup[row.group] || []).push(row);
931
+
932
+ const order = new Map();
933
+ for (const key of Object.keys(byGroup)) {
934
+ // Stable: equal positions keep the order the caller passed.
935
+ byGroup[key].sort((a, b) => a.pos - b.pos || a.index - b.index);
936
+ byGroup[key].forEach((row, i) => order.set(row.id, baseOf[key] + 1 + i));
937
+ counts[key] = byGroup[key].length;
938
+ }
939
+
940
+ const webOrder = new Map();
941
+ webs.forEach((id, i) => {
942
+ if (typeof id === 'string' && id) webOrder.set(id, baseOf.idle + TAB_TRIAGE_WEB_OFFSET + i);
943
+ });
944
+ counts.idle += webOrder.size;
945
+
946
+ const groups = sequence.filter((group) => counts[group.key] > 0).map((group) => ({
947
+ key: group.key,
948
+ label: group.label,
949
+ quiet: !!group.quiet,
950
+ count: counts[group.key],
951
+ headOrder: baseOf[group.key],
952
+ breakOrder: baseOf[group.key] + TAB_TRIAGE_STRIDE - 1,
953
+ }));
954
+
955
+ return { order, webOrder, groups };
956
+ }
957
+
958
+ // Tab clusters by case (`tabArrangement: 'case'`, Discussion #426 option A).
959
+ //
960
+ // One cluster per case, in the order the case first appears in the tab order,
961
+ // so the strip keeps the user's arrangement at the case level. Inside a cluster
962
+ // with two or more tabs the `-<case>` part of a generated `w<n>-<case>` name is
963
+ // redundant and is hidden (`tabClusterNameSplit()`), which is what lets 18 tabs
964
+ // read as 7 things. A case with one tab is still its own (unlabelled) box.
965
+ //
966
+ // Cluster colours come from the session palette (`--session-<colour>`) by a
967
+ // stable hash of the case key, so a case keeps its colour across reloads and
968
+ // devices without anything being stored.
969
+ //
970
+ // Pure: no DOM, no `this`. Unit-tested in test/tab-clusters.test.ts.
971
+ const TAB_CLUSTER_COLORS = ['blue', 'green', 'purple', 'orange', 'pink', 'yellow', 'red'];
972
+
973
+ /** Palette colour for a cluster key: a djb2 hash, so the same key always gets the same colour. */
974
+ function tabClusterColorFor(key) {
975
+ const text = String(key || '');
976
+ let hash = 5381;
977
+ for (let i = 0; i < text.length; i++) hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0;
978
+ return TAB_CLUSTER_COLORS[Math.abs(hash) % TAB_CLUSTER_COLORS.length];
979
+ }
980
+
981
+ /**
982
+ * Group tabs by case.
983
+ * @param {Array<{id: string, key: string, label: string}>} rows live sessions in
984
+ * tab order; `key` identifies the case (its path), `label` names it
985
+ * @returns {Array<{key: string, label: string, color: string, ids: string[]}>}
986
+ * clusters in first-appearance order, members in tab order
987
+ */
988
+ function computeTabClusters(rows) {
989
+ const clusters = [];
990
+ const byKey = new Map();
991
+ for (const row of Array.isArray(rows) ? rows : []) {
992
+ if (!row || typeof row.id !== 'string') continue;
993
+ const key = typeof row.key === 'string' && row.key ? row.key : `session:${row.id}`;
994
+ let cluster = byKey.get(key);
995
+ if (!cluster) {
996
+ cluster = { key, label: typeof row.label === 'string' ? row.label : '', color: tabClusterColorFor(key), ids: [] };
997
+ byKey.set(key, cluster);
998
+ clusters.push(cluster);
999
+ }
1000
+ cluster.ids.push(row.id);
1001
+ }
1002
+ return clusters;
1003
+ }
1004
+
1005
+ /**
1006
+ * Split a generated `w<n>-<case>` / `s<n>-<case>` name into the part a cluster
1007
+ * shows and the case suffix it hides, or null when the name is anything else
1008
+ * (a custom name, a described `w3-x: fix login`, another case's name). Case is
1009
+ * compared case-insensitively; the hidden part keeps its original spelling so
1010
+ * the full name is still in the DOM.
1011
+ * @returns {{shown: string, hidden: string}|null}
1012
+ */
1013
+ function tabClusterNameSplit(name, label) {
1014
+ if (typeof name !== 'string' || typeof label !== 'string' || !label) return null;
1015
+ const match = name.match(/^([ws]\d+)(-.+)$/);
1016
+ if (!match) return null;
1017
+ return match[2].slice(1).toLowerCase() === label.toLowerCase() ? { shown: match[1], hidden: match[2] } : null;
1018
+ }
1019
+
661
1020
  // Terminal font stack — the single source for every xterm surface (the main
662
1021
  // terminal in terminal-ui.js, the log-viewer terminal in panels-ui.js).
663
1022
  // "Symbols Nerd Font Mono" is a bundled icons-only webfont (fonts/ +
@@ -961,12 +1320,13 @@ if (typeof window !== 'undefined') {
961
1320
  plan: planWsReconnect,
962
1321
  };
963
1322
  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,
1323
+ computeTree: computeLineageTree,
1324
+ computeRows: computeLineageRows,
1325
+ CORNER_RADIUS_PX: LINEAGE_CORNER_RADIUS_PX,
1326
+ LANE_STEP_PX: LINEAGE_LANE_STEP_PX,
1327
+ MAX_LANES: LINEAGE_MAX_LANES,
1328
+ SPINE_INSET_PX: LINEAGE_SPINE_INSET_PX,
968
1329
  VERTICAL_TRACK_INSET_PX: LINEAGE_VERTICAL_TRACK_INSET_PX,
969
- VERTICAL_SIBLING_STEP_PX: LINEAGE_VERTICAL_SIBLING_STEP_PX,
970
1330
  COLORS: LINEAGE_COLORS,
971
1331
  };
972
1332
  window.CodemanConnectionLoss = {
@@ -983,6 +1343,18 @@ if (typeof window !== 'undefined') {
983
1343
  compare: compareSessionActivity,
984
1344
  sort: sortSessionsByActivity,
985
1345
  };
1346
+ window.CodemanTabClusters = {
1347
+ COLORS: TAB_CLUSTER_COLORS,
1348
+ colorFor: tabClusterColorFor,
1349
+ compute: computeTabClusters,
1350
+ nameSplit: tabClusterNameSplit,
1351
+ };
1352
+ window.CodemanTabTriage = {
1353
+ GROUPS: TAB_TRIAGE_GROUPS,
1354
+ STRIDE: TAB_TRIAGE_STRIDE,
1355
+ groupFor: tabTriageGroupFor,
1356
+ layout: computeTabTriageLayout,
1357
+ };
986
1358
  window.CodemanInputLimit = {
987
1359
  FRAME_MAX_CHARS: INPUT_FRAME_MAX_CHARS,
988
1360
  PASTE_MAX_CHARS: INPUT_PASTE_MAX_CHARS,
@@ -1458,7 +1830,7 @@ function computeRewriteScrollLine(input) {
1458
1830
  * a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
1459
1831
  */
1460
1832
  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;
1833
+ /(\/(?: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
1834
 
1463
1835
  /** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
1464
1836
  function absoluteFilePathPattern() {
@@ -1476,7 +1848,7 @@ function absoluteFilePathPattern() {
1476
1848
  * file in /tmp played fine. test/media-extension-parity.test.ts pins the sync.
1477
1849
  */
1478
1850
  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(' ')
1851
+ ('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
1852
  );
1481
1853
 
1482
1854
  /** Whether a path's extension is one {@link FILE_PREVIEW_EXTENSIONS} covers. */
@@ -1612,27 +1984,459 @@ function buildSplitPickerSessions(sessions, sessionOrder, excludeId, detachedIds
1612
1984
  const result = [];
1613
1985
  for (const id of sessionOrder) {
1614
1986
  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.
1987
+ // A detached (popped-out) session's own window owns its PTY size (see
1988
+ // sendResize's detachedElsewhere guard in terminal-ui.js;
1989
+ // TerminalTile._sendResize() stands aside the same way), so Pane B could
1990
+ // only show it at a size it cannot set.
1620
1991
  if (detachedIds?.has?.(id)) continue;
1621
1992
  const session = sessions.get(id);
1622
1993
  if (!session) continue;
1623
1994
  // A session with no PTY attached (exited CLI, a crash-looped session
1624
1995
  // 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.
1996
+ // reading its tmux pane, and the split never does selectSession()'s
1997
+ // re-attach POST: Pane B would open a healthy-looking socket onto a pane
1998
+ // that nothing feeds and nothing reads.
1630
1999
  if (session.pid === null) continue;
1631
2000
  result.push({ id, label: session.name || 'Session' });
1632
2001
  }
1633
2002
  return result;
1634
2003
  }
1635
2004
 
2005
+ // ── Tile grid (tile-grid.js) ───────────────────────────────────────────────
2006
+ //
2007
+ // Pure layout and state helpers for the tile grid (docs/tile-grid-plan.md):
2008
+ // 1 to TILE_GRID_MAX live sessions side by side, each in its own TerminalTile.
2009
+ // Desktop only, behind the same 1180px gate as the split pane.
2010
+
2011
+ /**
2012
+ * Hard cap on tiles in one grid: the ONE place it is set (owner decision 7 in
2013
+ * docs/tile-grid-plan.md). Six was tested smooth on a real desktop; nine missed
2014
+ * the headless frame bar and is untested on hardware. Everything that limits
2015
+ * the grid reads this, and the layout table still covers up to TILE_LAYOUT_MAX,
2016
+ * so raising the cap is this one line.
2017
+ */
2018
+ const TILE_GRID_MAX = 6;
2019
+ /** The largest count the layout table covers (3x3). Never a cap by itself. */
2020
+ const TILE_LAYOUT_MAX = 9;
2021
+ // The smallest tile worth showing: about 60 columns and a dozen rows at the
2022
+ // default tile font. Bounds how many tiles a window can hold.
2023
+ const TILE_MIN_W = 480;
2024
+ const TILE_MIN_H = 240;
2025
+ // Three tiles go side by side (3x1) only when each still gets ~600px;
2026
+ // otherwise they take three cells of a 2x2.
2027
+ const TILE_GRID_WIDE_3X1 = 1800;
2028
+ // A tile's xterm keeps this many lines, not DEFAULT_SCROLLBACK: a grid of DOM
2029
+ // renderers at 50k lines each is a real memory cost, and a tile's load is a
2030
+ // bounded 1 MiB window anyway, so more scrollback only fills with live output.
2031
+ const TILE_SCROLLBACK = 10000;
2032
+ // Tiles have their own per-device font size (a tile is a fraction of the screen).
2033
+ const TILE_FONT_SIZE_DEFAULT = 13;
2034
+ // What the page's SSE filter names while tiles own the terminal: a value no
2035
+ // session id takes (ids are UUIDs), so the server, whose filter gates only
2036
+ // session:terminal batches, sends none. The tiles carry their own output over
2037
+ // their own sockets, and the parked main terminal only parsed those frames to
2038
+ // drop them (16 to 18 a second for one busy shell). Every other event still
2039
+ // arrives (test/sse-tile-grid-filter.test.ts pins the server's side of this).
2040
+ const TILE_GRID_SSE_FILTER = 'tile-grid';
2041
+
2042
+ /**
2043
+ * Columns and rows for `count` tiles, by count (the spec's table), and whether
2044
+ * that layout gives every cell at least the minimum tile size (TILE_MIN_W x
2045
+ * TILE_MIN_H) in a grid area of `width` x `height` px.
2046
+ *
2047
+ * @param {{count: number, width?: number, height?: number}} p
2048
+ * @returns {{cols: number, rows: number, fits: boolean}}
2049
+ */
2050
+ function computeTileLayout({ count, width = Infinity, height = Infinity }) {
2051
+ const n = Math.min(Math.max(0, Math.floor(Number(count) || 0)), TILE_LAYOUT_MAX);
2052
+ let cols;
2053
+ let rows;
2054
+ if (n === 0) return { cols: 0, rows: 0, fits: true };
2055
+ if (n === 1) { cols = 1; rows = 1; }
2056
+ else if (n === 2) { cols = 2; rows = 1; }
2057
+ else if (n === 3) {
2058
+ if (width >= TILE_GRID_WIDE_3X1) { cols = 3; rows = 1; }
2059
+ else { cols = 2; rows = 2; }
2060
+ }
2061
+ else if (n === 4) { cols = 2; rows = 2; }
2062
+ else if (n <= 6) { cols = 3; rows = 2; }
2063
+ else { cols = 3; rows = 3; }
2064
+ const fits = width / cols >= TILE_MIN_W && height / rows >= TILE_MIN_H;
2065
+ return { cols, rows, fits };
2066
+ }
2067
+
2068
+ /**
2069
+ * How many tiles a grid area can hold: the largest count up to TILE_GRID_MAX
2070
+ * whose layout, and every smaller count's layout, fits. 0 when not even one
2071
+ * tile fits.
2072
+ *
2073
+ * @param {{width: number, height: number}} p
2074
+ * @returns {number}
2075
+ */
2076
+ function tileGridCapacity({ width, height }) {
2077
+ let capacity = 0;
2078
+ for (let n = 1; n <= TILE_GRID_MAX; n++) {
2079
+ if (!computeTileLayout({ count: n, width, height }).fits) break;
2080
+ capacity = n;
2081
+ }
2082
+ return capacity;
2083
+ }
2084
+
2085
+ /**
2086
+ * The stored grid (`codeman:tile-grid`, ids only) made safe to apply: unknown,
2087
+ * deleted, detached and duplicate ids are dropped, the list is capped at
2088
+ * TILE_GRID_MAX, `focused` / `zoomed` must name a kept id, and track fractions
2089
+ * must be 1 to 3 finite positive numbers. Anything that is not a v1 object
2090
+ * (or its JSON) gives null.
2091
+ *
2092
+ * The stored `ids` are the grid's CELLS in reading order, `null` for an empty
2093
+ * one (a hole can be any cell). The old packed list (no nulls) reads as cells
2094
+ * with no hole. `ids` comes back packed (the tiles in reading order, what
2095
+ * every list consumer wants) and `cells` keeps the holes: a dropped id (gone,
2096
+ * detached, a duplicate, past the cap) becomes `null` there, never a shift.
2097
+ *
2098
+ * @param {unknown} raw - the parsed value, or the stored JSON string
2099
+ * @param {{has(id: string): boolean}|Iterable<string>} liveSessions - ids that exist now
2100
+ * @param {{has(id: string): boolean}} [detachedIds] - sessions popped out to their own window
2101
+ * @returns {{v: 1, open: boolean, ids: string[], cells: (string|null)[], focused: string|null,
2102
+ * zoomed: string|null, colFr: number[]|null, rowFr: number[]|null}|null}
2103
+ */
2104
+ function sanitizeTileGridState(raw, liveSessions, detachedIds) {
2105
+ let value = raw;
2106
+ if (typeof value === 'string') {
2107
+ try { value = JSON.parse(value); } catch { return null; }
2108
+ }
2109
+ if (!value || typeof value !== 'object' || Array.isArray(value) || value.v !== 1) return null;
2110
+ const live = liveSessions && typeof liveSessions.has === 'function' ? liveSessions : new Set(liveSessions || []);
2111
+ const ids = [];
2112
+ const cells = [];
2113
+ for (const id of (Array.isArray(value.ids) ? value.ids : []).slice(0, TILE_LAYOUT_MAX)) {
2114
+ const keep =
2115
+ typeof id === 'string' && id && !ids.includes(id) && live.has(id) && !detachedIds?.has?.(id) &&
2116
+ ids.length < TILE_GRID_MAX;
2117
+ if (keep) ids.push(id);
2118
+ // A malformed entry (not a string, not null) is a hole too.
2119
+ cells.push(keep ? id : null);
2120
+ }
2121
+ const fractions = (fr) => {
2122
+ if (!Array.isArray(fr) || fr.length < 1 || fr.length > 3) return null;
2123
+ return fr.every((x) => typeof x === 'number' && Number.isFinite(x) && x > 0) ? fr.slice() : null;
2124
+ };
2125
+ return {
2126
+ v: 1,
2127
+ open: value.open === true && ids.length > 0,
2128
+ ids,
2129
+ cells,
2130
+ focused: ids.includes(value.focused) ? value.focused : (ids[0] ?? null),
2131
+ zoomed: ids.includes(value.zoomed) ? value.zoomed : null,
2132
+ colFr: fractions(value.colFr),
2133
+ rowFr: fractions(value.rowFr),
2134
+ };
2135
+ }
2136
+
2137
+ /**
2138
+ * New track fractions after a divider drag (grid-template `fr` values): the two
2139
+ * tracks either side of divider `index` trade `deltaPx` of size, each kept at
2140
+ * least `minPx` (or half the pair, if the pair cannot give both the minimum).
2141
+ * Every other track keeps its size. Computed from the fractions the drag
2142
+ * STARTED with and the pointer's total travel, so a drag never drifts.
2143
+ *
2144
+ * @param {number[]} fr - the fractions when the drag started
2145
+ * @param {number} index - the divider: between track `index` and `index + 1`
2146
+ * @param {number} deltaPx - pointer travel since the drag started
2147
+ * @param {number} totalPx - the size the tracks share (dividers and padding excluded)
2148
+ * @param {number} minPx - the smallest a track may get
2149
+ * @returns {number[]} new fractions, same length
2150
+ */
2151
+ function dragTrackFractions(fr, index, deltaPx, totalPx, minPx) {
2152
+ const out = fr.slice();
2153
+ if (index < 0 || index + 1 >= fr.length || !(totalPx > 0)) return out;
2154
+ const sum = fr.reduce((a, b) => a + b, 0);
2155
+ if (!(sum > 0)) return out;
2156
+ const a = (fr[index] / sum) * totalPx;
2157
+ const b = (fr[index + 1] / sum) * totalPx;
2158
+ const pair = a + b;
2159
+ const lo = Math.min(minPx, pair / 2);
2160
+ const hi = pair - lo;
2161
+ const nextA = Math.min(Math.max(a + (Number(deltaPx) || 0), lo), hi);
2162
+ out[index] = (nextA / totalPx) * sum;
2163
+ out[index + 1] = ((pair - nextA) / totalPx) * sum;
2164
+ return out;
2165
+ }
2166
+
2167
+ /**
2168
+ * The sessions the Tiles button can open (case c of tileGridOpenSet, and the
2169
+ * ones a count fills a grid with), in tab order: live ones only, never a session popped out to
2170
+ * its own window (that window owns its PTY size). A session with no PTY
2171
+ * attached IS offered: its tile shows the Attach overlay.
2172
+ *
2173
+ * @param {Map<string, {name?: string}>} sessions
2174
+ * @param {string[]} sessionOrder
2175
+ * @param {{has(id: string): boolean}} [detachedIds]
2176
+ * @returns {Array<{id: string, label: string}>}
2177
+ */
2178
+ function buildTilePickerSessions(sessions, sessionOrder, detachedIds) {
2179
+ const result = [];
2180
+ for (const id of sessionOrder) {
2181
+ if (detachedIds?.has?.(id)) continue;
2182
+ const session = sessions.get(id);
2183
+ if (!session) continue;
2184
+ result.push({ id, label: session.name || 'Session' });
2185
+ }
2186
+ return result;
2187
+ }
2188
+
2189
+ /**
2190
+ * What the Tiles button and Ctrl+Shift+G open, at once and without asking
2191
+ * (owner decision 8). In order:
2192
+ * a. the grid this tab last had (`stored`, already sanitized: live, not
2193
+ * detached, at most the cap), if any of its sessions survive;
2194
+ * b. else an open split's two sessions, Pane A focused;
2195
+ * c. else the open sessions in tab order (buildTilePickerSessions: no
2196
+ * detached ones), up to `limit`, the active session always among them and focused
2197
+ * (when it sits past the limit, the first `limit - 1` others come with it).
2198
+ * Null when there is nothing to open.
2199
+ *
2200
+ * @param {{stored?: {ids: string[], focused: string|null, zoomed: string|null}|null,
2201
+ * split?: string[]|null, sessions: Map<string, object>, sessionOrder: string[],
2202
+ * detachedIds?: {has(id: string): boolean}, activeId?: string|null, limit: number}} p
2203
+ * @returns {{source: 'stored'|'split'|'tabs', ids: string[], focusedId: string|null}|null}
2204
+ */
2205
+ function tileGridOpenSet({ stored = null, split = null, sessions, sessionOrder, detachedIds, activeId = null, limit }) {
2206
+ if (stored?.ids?.length) {
2207
+ const focus = stored.zoomed || stored.focused;
2208
+ return { source: 'stored', ids: stored.ids.slice(), focusedId: stored.ids.includes(focus) ? focus : stored.ids[0] };
2209
+ }
2210
+ const usable = (id) => typeof id === 'string' && sessions.has(id) && !detachedIds?.has?.(id);
2211
+ const pair = (split || []).filter(usable);
2212
+ if (split && pair.length) return { source: 'split', ids: [...new Set(pair)], focusedId: pair[0] };
2213
+ const max = Math.max(1, Math.min(Math.floor(Number(limit) || 0), TILE_GRID_MAX));
2214
+ const all = buildTilePickerSessions(sessions, sessionOrder, detachedIds).map((c) => c.id);
2215
+ if (all.length === 0) return null;
2216
+ let ids = all.slice(0, max);
2217
+ if (all.includes(activeId) && !ids.includes(activeId)) {
2218
+ ids = [...all.filter((id) => id !== activeId).slice(0, max - 1), activeId];
2219
+ }
2220
+ return { source: 'tabs', ids, focusedId: ids.includes(activeId) ? activeId : ids[0] };
2221
+ }
2222
+
2223
+ /**
2224
+ * The tile counts the Tiles button's right-click menu offers, and the count a
2225
+ * click opens until one is picked (owner decision 10 in docs/tile-grid-plan.md).
2226
+ */
2227
+ const TILE_GRID_COUNTS = [2, 4, 6];
2228
+ const TILE_GRID_COUNT_DEFAULT = 6;
2229
+
2230
+ /** A remembered tile count made safe: one of TILE_GRID_COUNTS, else the default. */
2231
+ function sanitizeTileCount(raw) {
2232
+ const n = Number(raw);
2233
+ return TILE_GRID_COUNTS.includes(n) ? n : TILE_GRID_COUNT_DEFAULT;
2234
+ }
2235
+
2236
+ /**
2237
+ * `base` (what the grid would open, or what an open grid shows, in its order)
2238
+ * trimmed or filled to `n` tiles: trimmed from the end, the session to focus
2239
+ * (`keepId`) always kept (it takes the last place when it sat past `n`, as in
2240
+ * tileGridOpenSet's case c); filled from `all` (the open sessions in tab order)
2241
+ * with the ones not in it yet. Fewer sessions than `n` give fewer tiles.
2242
+ *
2243
+ * @param {string[]} base
2244
+ * @param {string[]} all
2245
+ * @param {number} n - at most TILE_GRID_MAX
2246
+ * @param {string|null} [keepId]
2247
+ * @returns {string[]}
2248
+ */
2249
+ function tileGridSetForCount(base, all, n, keepId = null) {
2250
+ const max = Math.max(1, Math.min(Math.floor(Number(n) || 0), TILE_GRID_MAX));
2251
+ const ids = [];
2252
+ for (const id of [...(base || []), ...(all || [])]) {
2253
+ if (typeof id === 'string' && id && !ids.includes(id)) ids.push(id);
2254
+ }
2255
+ // Every `base` id comes before every filler, so a trim never drops a base id
2256
+ // in favour of one.
2257
+ let out = ids.slice(0, max);
2258
+ if (keepId && ids.includes(keepId) && !out.includes(keepId)) out = [...out.slice(0, max - 1), keepId];
2259
+ return out;
2260
+ }
2261
+
2262
+ /**
2263
+ * Which tile takes focus when `id` leaves the grid: the next one in grid
2264
+ * order, else the previous one, else null.
2265
+ *
2266
+ * @param {string[]} ids - the grid's tiles, in reading order
2267
+ * @param {string} id - the tile that is leaving
2268
+ * @returns {string|null}
2269
+ */
2270
+ function tileNeighbor(ids, id) {
2271
+ const i = ids.indexOf(id);
2272
+ if (i === -1) return ids[0] ?? null;
2273
+ return ids[i + 1] ?? ids[i - 1] ?? null;
2274
+ }
2275
+
2276
+ /**
2277
+ * The tile a directional focus chord moves to, in a row-major grid of `cols`
2278
+ * columns whose empty cells are `null` (a hole can be any cell) or simply
2279
+ * missing at the end. Focus never lands on a hole. Left and right go along
2280
+ * the row, past any hole, and never leave it. Up and down take the nearest
2281
+ * row in that direction that has a tile: the tile in the same column, else
2282
+ * the one in the nearest column (the lower column on a tie), so moving down
2283
+ * onto a short or holed last row lands on its nearest tile. Null when there
2284
+ * is no tile in that direction.
2285
+ *
2286
+ * @param {(string|null)[]} cells - the grid's cells in reading order (or its packed tiles)
2287
+ * @param {string} focusedId - the tile the keyboard is in
2288
+ * @param {'left'|'right'|'up'|'down'} direction
2289
+ * @param {number} cols - the layout's column count
2290
+ * @returns {string|null}
2291
+ */
2292
+ function tileInDirection(cells, focusedId, direction, cols) {
2293
+ const i = focusedId ? cells.indexOf(focusedId) : -1;
2294
+ if (i === -1 || !(cols >= 1)) return null;
2295
+ const rows = Math.ceil(cells.length / cols);
2296
+ const row = Math.floor(i / cols);
2297
+ const col = i % cols;
2298
+ const at = (r, c) => cells[r * cols + c] || null;
2299
+ if (direction === 'left' || direction === 'right') {
2300
+ const step = direction === 'left' ? -1 : 1;
2301
+ for (let c = col + step; c >= 0 && c < cols; c += step) {
2302
+ if (at(row, c)) return at(row, c);
2303
+ }
2304
+ return null;
2305
+ }
2306
+ if (direction !== 'up' && direction !== 'down') return null;
2307
+ const step = direction === 'up' ? -1 : 1;
2308
+ for (let r = row + step; r >= 0 && r < rows; r += step) {
2309
+ let best = null;
2310
+ let bestDistance = Infinity;
2311
+ for (let c = 0; c < cols; c++) {
2312
+ const id = at(r, c);
2313
+ if (id && Math.abs(c - col) < bestDistance) {
2314
+ best = id;
2315
+ bestDistance = Math.abs(c - col);
2316
+ }
2317
+ }
2318
+ if (best) return best;
2319
+ }
2320
+ return null;
2321
+ }
2322
+
2323
+ /**
2324
+ * The cell next to cell `index` in that direction (Move Tile: a tile moves
2325
+ * into an empty neighbour cell, or swaps with a tiled one), or -1 at the
2326
+ * edge. Adjacent only: a move never jumps over a cell.
2327
+ *
2328
+ * @param {number} index - the cell, in reading order
2329
+ * @param {'left'|'right'|'up'|'down'} direction
2330
+ * @param {number} cols - the layout's column count
2331
+ * @param {number} cellCount - cols x rows
2332
+ * @returns {number}
2333
+ */
2334
+ function tileCellInDirection(index, direction, cols, cellCount) {
2335
+ if (!(cols >= 1) || index < 0 || index >= cellCount) return -1;
2336
+ const col = index % cols;
2337
+ let j = -1;
2338
+ if (direction === 'left') j = col > 0 ? index - 1 : -1;
2339
+ else if (direction === 'right') j = col < cols - 1 ? index + 1 : -1;
2340
+ else if (direction === 'up') j = index - cols;
2341
+ else if (direction === 'down') j = index + cols;
2342
+ return j >= 0 && j < cellCount ? j : -1;
2343
+ }
2344
+
2345
+ /**
2346
+ * The grid's cells after its shape changed (or to fill one for the first
2347
+ * time): `cols` x `rows` cells, `null` for an empty one. The same shape keeps
2348
+ * every cell as it is. A new shape keeps each tile at its row and column when
2349
+ * every tile still fits there (2x2 growing to 3x2: the four tiles stay put),
2350
+ * and otherwise packs the tiles in reading order from the first cell, holes
2351
+ * collapsed (positions do not map between shapes). `oldCols` 0 (no layout
2352
+ * yet) always packs.
2353
+ *
2354
+ * @param {(string|null)[]} cells - the current cells, laid out `oldCols` wide
2355
+ * @param {number} oldCols - the column count they were laid out with
2356
+ * @param {number} cols
2357
+ * @param {number} rows
2358
+ * @returns {(string|null)[]}
2359
+ */
2360
+ function fitTileCells(cells, oldCols, cols, rows) {
2361
+ const size = Math.max(0, cols * rows);
2362
+ if (oldCols === cols && cells.length === size) return cells.slice();
2363
+ const out = new Array(size).fill(null);
2364
+ const placed = cells.map((id, k) => (id ? { id, row: Math.floor(k / oldCols), col: k % oldCols } : null));
2365
+ const keep = oldCols >= 1 && placed.every((p) => !p || (p.row < rows && p.col < cols));
2366
+ if (keep) {
2367
+ for (const p of placed) if (p) out[p.row * cols + p.col] = p.id;
2368
+ return out;
2369
+ }
2370
+ cells.filter(Boolean).slice(0, size).forEach((id, k) => {
2371
+ out[k] = id;
2372
+ });
2373
+ return out;
2374
+ }
2375
+
2376
+ /**
2377
+ * How many columns a grid of `length` cells was laid out with: stored cells
2378
+ * carry no shape of their own, and the layout table gives each cell count one
2379
+ * shape (computeTileLayout: 1x1, 2x1, 3x1, 2x2, 3x2, 3x3). 0 for any other
2380
+ * length (fitTileCells then packs).
2381
+ *
2382
+ * @param {number} length
2383
+ * @returns {number}
2384
+ */
2385
+ function tileCellCols(length) {
2386
+ // Every cell count is some count's shape on a wide grid area (2x2, the
2387
+ // narrow 3-tile shape, is also the 4-tile one).
2388
+ for (let n = 1; n <= TILE_LAYOUT_MAX; n++) {
2389
+ const { cols, rows } = computeTileLayout({ count: n });
2390
+ if (cols * rows === length) return cols;
2391
+ }
2392
+ return 0;
2393
+ }
2394
+
2395
+ /**
2396
+ * The cells of a grid re-formed to another set of tiles (a count picked in the
2397
+ * Tiles menu, or the Tiles button bringing back a remembered grid with more or
2398
+ * fewer tiles): the tiles in `keep` stay in their cells and every other cell
2399
+ * empties, then the cell model's shape rule (fitTileCells: each tile keeps its
2400
+ * row and column when all fit, else they pack in reading order), then the
2401
+ * tiles in `add` fill the empty cells in reading order, holes first.
2402
+ *
2403
+ * @param {(string|null)[]} cells - the cells now, laid out `oldCols` wide
2404
+ * @param {number} oldCols
2405
+ * @param {string[]} keep - tiles that stay
2406
+ * @param {string[]} add - tiles that join, in the order they fill
2407
+ * @param {number} cols - the new shape
2408
+ * @param {number} rows
2409
+ * @returns {(string|null)[]}
2410
+ */
2411
+ function reformTileCells(cells, oldCols, keep, add, cols, rows) {
2412
+ const kept = (cells || []).map((id) => (id && keep.includes(id) ? id : null));
2413
+ const out = fitTileCells(kept, oldCols, cols, rows);
2414
+ for (const id of add) {
2415
+ if (!id || out.includes(id)) continue;
2416
+ const k = out.indexOf(null);
2417
+ // Not for a shape made for the count; a full grid takes no more.
2418
+ if (k === -1) break;
2419
+ out[k] = id;
2420
+ }
2421
+ return out;
2422
+ }
2423
+
2424
+ /**
2425
+ * The tile Ctrl+Tab / Alt+] (delta 1) or Alt+[ (delta -1) moves to while the
2426
+ * grid is open: tiles cycle in reading order and wrap.
2427
+ *
2428
+ * @param {string[]} ids
2429
+ * @param {string} focusedId
2430
+ * @param {number} delta - +1 or -1
2431
+ * @returns {string|null}
2432
+ */
2433
+ function cycleTile(ids, focusedId, delta) {
2434
+ if (ids.length === 0) return null;
2435
+ const i = ids.indexOf(focusedId);
2436
+ if (i === -1) return ids[0];
2437
+ return ids[(i + delta + ids.length) % ids.length];
2438
+ }
2439
+
1636
2440
  // ── Renderer liveness ──────────────────────────────────────────────────────
1637
2441
  //
1638
2442
  // iOS DISCARDS scheduled requestAnimationFrame callbacks when a PWA goes to
@@ -1869,7 +2673,59 @@ function sessionIdFromFragment(hash) {
1869
2673
  return id && id.trim() ? id.trim() : null;
1870
2674
  }
1871
2675
 
2676
+ /** Longest model name a session header shows (the server caps it as well). */
2677
+ const SESSION_MODEL_MAX_CHARS = 64;
2678
+ /** A CLI registry id (src/config/cli-registry/schema.ts); anything else is not a class name. */
2679
+ const CLI_ID_PATTERN = /^[a-z][a-z0-9-]{0,23}$/;
2680
+
2681
+ /**
2682
+ * What a session's header says about its harness: the CLI id (the
2683
+ * `run-mode-dot <id>` logo class), the registry's label for it, the model the
2684
+ * session runs when the server knows it (`SessionState.displayModel`), and the
2685
+ * tooltip naming both.
2686
+ *
2687
+ * The id is data: the label comes from the injected CLI catalog and falls back
2688
+ * to the id, so a CLI added through clis.json still gets a name. The model is
2689
+ * untrusted text (read off a pane, or a CLI's own report): control characters
2690
+ * are dropped and the length capped here too, and callers render it with
2691
+ * textContent. The tooltip says where a model that is not the CLI's own report
2692
+ * came from, so it never claims more than the server knows: one the session
2693
+ * was launched with may have been switched since, one read from the CLI's
2694
+ * config is what it is configured to run, and a custom endpoint's model is the
2695
+ * endpoint's, whatever the CLI calls it.
2696
+ *
2697
+ * @param {object} session - a session from app.sessions
2698
+ * @param {Array<{id: string, label?: string}>} [catalog] - window.__codemanCliCatalog
2699
+ * @returns {{id: string, label: string, model: string, title: string}}
2700
+ */
2701
+ function describeSessionHarness(session, catalog) {
2702
+ const id = typeof session?.mode === 'string' && CLI_ID_PATTERN.test(session.mode) ? session.mode : '';
2703
+ const entry = id && Array.isArray(catalog) ? catalog.find((cli) => cli?.id === id) : null;
2704
+ const label = (typeof entry?.label === 'string' && entry.label.trim()) || id;
2705
+ const raw = session?.displayModel?.model;
2706
+ const model =
2707
+ typeof raw === 'string'
2708
+ ? raw
2709
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, '')
2710
+ .trim()
2711
+ .slice(0, SESSION_MODEL_MAX_CHARS)
2712
+ : '';
2713
+ const source = session?.displayModel?.source;
2714
+ const qualifier = !model
2715
+ ? ''
2716
+ : source === 'launch'
2717
+ ? ' (set at launch)'
2718
+ : source === 'custom-endpoint'
2719
+ ? ' (custom endpoint)'
2720
+ : source === 'config'
2721
+ ? ' (from config)'
2722
+ : '';
2723
+ const title = [label, model].filter(Boolean).join(' \u00B7 ') + qualifier;
2724
+ return { id, label, model, title };
2725
+ }
2726
+
1872
2727
  if (typeof window !== 'undefined') {
2728
+ window.CodemanSessionHarness = { describeSessionHarness, SESSION_MODEL_MAX_CHARS };
1873
2729
  window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
1874
2730
  window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
1875
2731
  window.CodemanTerminalLines = { terminalLogicalLine };
@@ -1879,6 +2735,32 @@ if (typeof window !== 'undefined') {
1879
2735
  buildSplitPickerSessions,
1880
2736
  SPLIT_PANE_MIN_WIDTH,
1881
2737
  };
2738
+ window.CodemanTileGrid = {
2739
+ computeTileLayout,
2740
+ tileGridCapacity,
2741
+ sanitizeTileGridState,
2742
+ buildTilePickerSessions,
2743
+ dragTrackFractions,
2744
+ tileNeighbor,
2745
+ tileInDirection,
2746
+ tileCellInDirection,
2747
+ fitTileCells,
2748
+ cycleTile,
2749
+ tileGridOpenSet,
2750
+ sanitizeTileCount,
2751
+ tileGridSetForCount,
2752
+ tileCellCols,
2753
+ reformTileCells,
2754
+ TILE_GRID_COUNTS,
2755
+ TILE_GRID_COUNT_DEFAULT,
2756
+ TILE_GRID_MAX,
2757
+ TILE_LAYOUT_MAX,
2758
+ TILE_MIN_W,
2759
+ TILE_MIN_H,
2760
+ TILE_SCROLLBACK,
2761
+ TILE_FONT_SIZE_DEFAULT,
2762
+ TILE_GRID_SSE_FILTER,
2763
+ };
1882
2764
  window.CodemanRenderLiveness = { shouldKickRenderer, RENDER_STALL_MS, RENDER_LIVENESS_POLL_MS };
1883
2765
  window.CodemanFetchDeadline = {
1884
2766
  terminalFetchDeadlineMs,