aicodeman 1.40.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 (211) hide show
  1. package/README.md +25 -7
  2. package/dist/cli-agent.d.ts +225 -0
  3. package/dist/cli-agent.d.ts.map +1 -0
  4. package/dist/cli-agent.js +767 -0
  5. package/dist/cli-agent.js.map +1 -0
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +6 -30
  8. package/dist/cli.js.map +1 -1
  9. package/dist/codeman-credentials.d.ts +27 -0
  10. package/dist/codeman-credentials.d.ts.map +1 -0
  11. package/dist/codeman-credentials.js +66 -0
  12. package/dist/codeman-credentials.js.map +1 -0
  13. package/dist/config/cli-registry/schema.d.ts +2 -0
  14. package/dist/config/cli-registry/schema.d.ts.map +1 -1
  15. package/dist/config/cli-registry/schema.js +3 -0
  16. package/dist/config/cli-registry/schema.js.map +1 -1
  17. package/dist/config/cli-registry/stock.d.ts.map +1 -1
  18. package/dist/config/cli-registry/stock.js +5 -0
  19. package/dist/config/cli-registry/stock.js.map +1 -1
  20. package/dist/config/cli-registry/types.d.ts +12 -1
  21. package/dist/config/cli-registry/types.d.ts.map +1 -1
  22. package/dist/git-clone.d.ts.map +1 -1
  23. package/dist/git-clone.js +4 -0
  24. package/dist/git-clone.js.map +1 -1
  25. package/dist/image-watcher.d.ts.map +1 -1
  26. package/dist/image-watcher.js +6 -2
  27. package/dist/image-watcher.js.map +1 -1
  28. package/dist/mcp-sync-targets.d.ts +34 -0
  29. package/dist/mcp-sync-targets.d.ts.map +1 -0
  30. package/dist/mcp-sync-targets.js +55 -0
  31. package/dist/mcp-sync-targets.js.map +1 -0
  32. package/dist/mcp-sync.d.ts +2 -1
  33. package/dist/mcp-sync.d.ts.map +1 -1
  34. package/dist/mcp-sync.js +62 -1
  35. package/dist/mcp-sync.js.map +1 -1
  36. package/dist/session.d.ts.map +1 -1
  37. package/dist/session.js +10 -4
  38. package/dist/session.js.map +1 -1
  39. package/dist/subagent-watcher.d.ts.map +1 -1
  40. package/dist/subagent-watcher.js +29 -6
  41. package/dist/subagent-watcher.js.map +1 -1
  42. package/dist/templates/case-template.md +2 -0
  43. package/dist/tmux-manager.d.ts +5 -0
  44. package/dist/tmux-manager.d.ts.map +1 -1
  45. package/dist/tmux-manager.js +47 -22
  46. package/dist/tmux-manager.js.map +1 -1
  47. package/dist/tui/tui-client.d.ts +3 -17
  48. package/dist/tui/tui-client.d.ts.map +1 -1
  49. package/dist/tui/tui-client.js +3 -44
  50. package/dist/tui/tui-client.js.map +1 -1
  51. package/dist/utils/index.d.ts +1 -0
  52. package/dist/utils/index.d.ts.map +1 -1
  53. package/dist/utils/index.js +1 -0
  54. package/dist/utils/index.js.map +1 -1
  55. package/dist/utils/process-exit-wait.d.ts +40 -0
  56. package/dist/utils/process-exit-wait.d.ts.map +1 -0
  57. package/dist/utils/process-exit-wait.js +68 -0
  58. package/dist/utils/process-exit-wait.js.map +1 -0
  59. package/dist/web/paste-image-gc.d.ts +46 -6
  60. package/dist/web/paste-image-gc.d.ts.map +1 -1
  61. package/dist/web/paste-image-gc.js +106 -32
  62. package/dist/web/paste-image-gc.js.map +1 -1
  63. package/dist/web/public/admin-ui.js.gz +0 -0
  64. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  65. package/dist/web/public/app.6f8552e1.js +43 -0
  66. package/dist/web/public/app.6f8552e1.js.br +0 -0
  67. package/dist/web/public/app.6f8552e1.js.gz +0 -0
  68. package/dist/web/public/approvals-ui.js.gz +0 -0
  69. package/dist/web/public/{constants.c0f8f9de.js → constants.bc9c1f63.js} +295 -28
  70. package/dist/web/public/constants.bc9c1f63.js.br +0 -0
  71. package/dist/web/public/constants.bc9c1f63.js.gz +0 -0
  72. package/dist/web/public/cron-ui.js.gz +0 -0
  73. package/dist/web/public/entrance-animations.js +490 -43
  74. package/dist/web/public/entrance-animations.js.br +0 -0
  75. package/dist/web/public/entrance-animations.js.gz +0 -0
  76. package/dist/web/public/git-status-ui.js.gz +0 -0
  77. package/dist/web/public/home-sessions.js +2 -1
  78. package/dist/web/public/home-sessions.js.br +0 -0
  79. package/dist/web/public/home-sessions.js.gz +0 -0
  80. package/dist/web/public/host-wake-ui.js.gz +0 -0
  81. package/dist/web/public/i18n.a9840ea2.js +2 -0
  82. package/dist/web/public/i18n.a9840ea2.js.br +0 -0
  83. package/dist/web/public/i18n.a9840ea2.js.gz +0 -0
  84. package/dist/web/public/{image-input.898a538b.js → image-input.e00471c6.js} +3 -1
  85. package/dist/web/public/image-input.e00471c6.js.br +0 -0
  86. package/dist/web/public/image-input.e00471c6.js.gz +0 -0
  87. package/dist/web/public/index.html +128 -33
  88. package/dist/web/public/index.html.br +0 -0
  89. package/dist/web/public/index.html.gz +0 -0
  90. package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
  91. package/dist/web/public/keyboard-accessory.c1e7c7b7.js.gz +0 -0
  92. package/dist/web/public/mobile-handlers.0ef9c311.js.gz +0 -0
  93. package/dist/web/public/mobile-ime-preview.2dbaf262.js.gz +0 -0
  94. package/dist/web/public/mobile-overview.js.gz +0 -0
  95. package/dist/web/public/mobile.111b187d.css.gz +0 -0
  96. package/dist/web/public/{notification-manager.50bb87d8.js → notification-manager.feb82c04.js} +19 -2
  97. package/dist/web/public/notification-manager.feb82c04.js.br +0 -0
  98. package/dist/web/public/notification-manager.feb82c04.js.gz +0 -0
  99. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  100. package/dist/web/public/{panels-ui.4735dfc2.js → panels-ui.71846fcb.js} +1 -1
  101. package/dist/web/public/panels-ui.71846fcb.js.br +0 -0
  102. package/dist/web/public/panels-ui.71846fcb.js.gz +0 -0
  103. package/dist/web/public/ralph-panel.b350e2ef.js.gz +0 -0
  104. package/dist/web/public/ralph-wizard.b6c0dd19.js.gz +0 -0
  105. package/dist/web/public/readmymind-ui.js.gz +0 -0
  106. package/dist/web/public/reboot-restore-ui.js.gz +0 -0
  107. package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
  108. package/dist/web/public/sanitize-html.e7147e76.js.gz +0 -0
  109. package/dist/web/public/session-lineage.js.gz +0 -0
  110. package/dist/web/public/session-ui.f731602c.js.gz +0 -0
  111. package/dist/web/public/settings-ui.4e33390f.js +97 -0
  112. package/dist/web/public/settings-ui.4e33390f.js.br +0 -0
  113. package/dist/web/public/settings-ui.4e33390f.js.gz +0 -0
  114. package/dist/web/public/spreadsheet-preview-worker.js.gz +0 -0
  115. package/dist/web/public/spreadsheet-preview.af82fa2f.js.gz +0 -0
  116. package/dist/web/public/spreadsheet-xlsx-core.js.gz +0 -0
  117. package/dist/web/public/styles.81a72906.css +1 -0
  118. package/dist/web/public/styles.81a72906.css.br +0 -0
  119. package/dist/web/public/styles.81a72906.css.gz +0 -0
  120. package/dist/web/public/{subagent-windows.e6ca799f.js → subagent-windows.07277a58.js} +7 -5
  121. package/dist/web/public/subagent-windows.07277a58.js.br +0 -0
  122. package/dist/web/public/subagent-windows.07277a58.js.gz +0 -0
  123. package/dist/web/public/sw.js +2 -2
  124. package/dist/web/public/sw.js.br +0 -0
  125. package/dist/web/public/sw.js.gz +0 -0
  126. package/dist/web/public/tab-layout-browser.065fcba4.js.gz +0 -0
  127. package/dist/web/public/tab-rail-resize.a5867793.js.gz +0 -0
  128. package/dist/web/public/terminal-keycode229-recovery.af09f99a.js.gz +0 -0
  129. package/dist/web/public/terminal-split.js.gz +0 -0
  130. package/dist/web/public/terminal-tile.js +92 -15
  131. package/dist/web/public/terminal-tile.js.br +0 -0
  132. package/dist/web/public/terminal-tile.js.gz +0 -0
  133. package/dist/web/public/terminal-ui.b7267005.js +2 -0
  134. package/dist/web/public/terminal-ui.b7267005.js.br +0 -0
  135. package/dist/web/public/terminal-ui.b7267005.js.gz +0 -0
  136. package/dist/web/public/tile-grid.js +330 -119
  137. package/dist/web/public/tile-grid.js.br +0 -0
  138. package/dist/web/public/tile-grid.js.gz +0 -0
  139. package/dist/web/public/ultracode-panel.js.gz +0 -0
  140. package/dist/web/public/ultracode-windows.js +9 -6
  141. package/dist/web/public/ultracode-windows.js.br +0 -0
  142. package/dist/web/public/ultracode-windows.js.gz +0 -0
  143. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  144. package/dist/web/public/vendor/exceljs.min.js.gz +0 -0
  145. package/dist/web/public/vendor/fflate.min.js.gz +0 -0
  146. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  147. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  148. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  149. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  150. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  151. package/dist/web/public/vendor/xterm-predictive-echo.ae5e3d5d.js.gz +0 -0
  152. package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js +4 -0
  153. package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js.br +0 -0
  154. package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js.gz +0 -0
  155. package/dist/web/public/vendor/xterm.css.gz +0 -0
  156. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  157. package/dist/web/public/voice-input.2342c37b.js.gz +0 -0
  158. package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
  159. package/dist/web/public/webview-tabs.js.gz +0 -0
  160. package/dist/web/routes/cli-registry-routes.d.ts +23 -4
  161. package/dist/web/routes/cli-registry-routes.d.ts.map +1 -1
  162. package/dist/web/routes/cli-registry-routes.js +92 -7
  163. package/dist/web/routes/cli-registry-routes.js.map +1 -1
  164. package/dist/web/routes/mcp-sync-routes.d.ts +7 -2
  165. package/dist/web/routes/mcp-sync-routes.d.ts.map +1 -1
  166. package/dist/web/routes/mcp-sync-routes.js +10 -3
  167. package/dist/web/routes/mcp-sync-routes.js.map +1 -1
  168. package/dist/web/routes/reboot-restore-routes.js +1 -1
  169. package/dist/web/routes/reboot-restore-routes.js.map +1 -1
  170. package/dist/web/routes/session-routes.d.ts.map +1 -1
  171. package/dist/web/routes/session-routes.js +68 -35
  172. package/dist/web/routes/session-routes.js.map +1 -1
  173. package/dist/web/schemas.d.ts +3 -0
  174. package/dist/web/schemas.d.ts.map +1 -1
  175. package/dist/web/schemas.js +9 -0
  176. package/dist/web/schemas.js.map +1 -1
  177. package/dist/web/server.d.ts +1 -1
  178. package/dist/web/server.d.ts.map +1 -1
  179. package/dist/web/server.js +15 -10
  180. package/dist/web/server.js.map +1 -1
  181. package/package.json +4 -4
  182. package/skills/codeman/SKILL.md +6 -0
  183. package/README.zh-CN.md +0 -1147
  184. package/dist/web/public/app.abadc970.js +0 -43
  185. package/dist/web/public/app.abadc970.js.br +0 -0
  186. package/dist/web/public/app.abadc970.js.gz +0 -0
  187. package/dist/web/public/constants.c0f8f9de.js.br +0 -0
  188. package/dist/web/public/constants.c0f8f9de.js.gz +0 -0
  189. package/dist/web/public/i18n.a31747a9.js +0 -2
  190. package/dist/web/public/i18n.a31747a9.js.br +0 -0
  191. package/dist/web/public/i18n.a31747a9.js.gz +0 -0
  192. package/dist/web/public/image-input.898a538b.js.br +0 -0
  193. package/dist/web/public/image-input.898a538b.js.gz +0 -0
  194. package/dist/web/public/notification-manager.50bb87d8.js.br +0 -0
  195. package/dist/web/public/notification-manager.50bb87d8.js.gz +0 -0
  196. package/dist/web/public/panels-ui.4735dfc2.js.br +0 -0
  197. package/dist/web/public/panels-ui.4735dfc2.js.gz +0 -0
  198. package/dist/web/public/settings-ui.a97ab16f.js +0 -97
  199. package/dist/web/public/settings-ui.a97ab16f.js.br +0 -0
  200. package/dist/web/public/settings-ui.a97ab16f.js.gz +0 -0
  201. package/dist/web/public/styles.8ee12666.css +0 -1
  202. package/dist/web/public/styles.8ee12666.css.br +0 -0
  203. package/dist/web/public/styles.8ee12666.css.gz +0 -0
  204. package/dist/web/public/subagent-windows.e6ca799f.js.br +0 -0
  205. package/dist/web/public/subagent-windows.e6ca799f.js.gz +0 -0
  206. package/dist/web/public/terminal-ui.5059b30e.js +0 -2
  207. package/dist/web/public/terminal-ui.5059b30e.js.br +0 -0
  208. package/dist/web/public/terminal-ui.5059b30e.js.gz +0 -0
  209. package/dist/web/public/vendor/xterm-zerolag-input.bbb5f84c.js +0 -4
  210. package/dist/web/public/vendor/xterm-zerolag-input.bbb5f84c.js.br +0 -0
  211. package/dist/web/public/vendor/xterm-zerolag-input.bbb5f84c.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)
@@ -1017,6 +1020,56 @@ function tabClusterNameSplit(name, label) {
1017
1020
  return match[2].slice(1).toLowerCase() === label.toLowerCase() ? { shown: match[1], hidden: match[2] } : null;
1018
1021
  }
1019
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
+
1020
1073
  // Terminal font stack — the single source for every xterm surface (the main
1021
1074
  // terminal in terminal-ui.js, the log-viewer terminal in panels-ui.js).
1022
1075
  // "Symbols Nerd Font Mono" is a bundled icons-only webfont (fonts/ +
@@ -1298,6 +1351,63 @@ function cleanCopiedSelection(text, options) {
1298
1351
  return lines.join('\n');
1299
1352
  }
1300
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
+
1301
1411
  if (typeof window !== 'undefined') {
1302
1412
  window.WEBGL_FALLBACK = WEBGL_FALLBACK;
1303
1413
  window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
@@ -1355,6 +1465,10 @@ if (typeof window !== 'undefined') {
1355
1465
  groupFor: tabTriageGroupFor,
1356
1466
  layout: computeTabTriageLayout,
1357
1467
  };
1468
+ window.CodemanTabSearch = {
1469
+ needle: tabSearchNeedle,
1470
+ filter: filterTabSearchRows,
1471
+ };
1358
1472
  window.CodemanInputLimit = {
1359
1473
  FRAME_MAX_CHARS: INPUT_FRAME_MAX_CHARS,
1360
1474
  PASTE_MAX_CHARS: INPUT_PASTE_MAX_CHARS,
@@ -1367,6 +1481,11 @@ if (typeof window !== 'undefined') {
1367
1481
  window.CodemanCopySelection = {
1368
1482
  clean: cleanCopiedSelection,
1369
1483
  };
1484
+ window.CodemanMarkdownAnchors = {
1485
+ slug: markdownHeadingSlug,
1486
+ assign: assignMarkdownHeadingAnchors,
1487
+ find: findMarkdownAnchorTarget,
1488
+ };
1370
1489
  window.CodemanTerminalFont = {
1371
1490
  DEFAULT_STACK: TERMINAL_FONT_DEFAULT_STACK,
1372
1491
  resolve: resolveTerminalFontFamily,
@@ -2083,11 +2202,11 @@ function tileGridCapacity({ width, height }) {
2083
2202
  }
2084
2203
 
2085
2204
  /**
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.
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.
2091
2210
  *
2092
2211
  * The stored `ids` are the grid's CELLS in reading order, `null` for an empty
2093
2212
  * one (a hole can be any cell). The old packed list (no nulls) reads as cells
@@ -2095,11 +2214,20 @@ function tileGridCapacity({ width, height }) {
2095
2214
  * every list consumer wants) and `cells` keeps the holes: a dropped id (gone,
2096
2215
  * detached, a duplicate, past the cap) becomes `null` there, never a shift.
2097
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
+ *
2098
2226
  * @param {unknown} raw - the parsed value, or the stored JSON string
2099
2227
  * @param {{has(id: string): boolean}|Iterable<string>} liveSessions - ids that exist now
2100
2228
  * @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}
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}
2103
2231
  */
2104
2232
  function sanitizeTileGridState(raw, liveSessions, detachedIds) {
2105
2233
  let value = raw;
@@ -2110,11 +2238,16 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) {
2110
2238
  const live = liveSessions && typeof liveSessions.has === 'function' ? liveSessions : new Set(liveSessions || []);
2111
2239
  const ids = [];
2112
2240
  const cells = [];
2241
+ const freed = [];
2242
+ const named = [];
2113
2243
  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;
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;
2117
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);
2118
2251
  // A malformed entry (not a string, not null) is a hole too.
2119
2252
  cells.push(keep ? id : null);
2120
2253
  }
@@ -2122,11 +2255,17 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) {
2122
2255
  if (!Array.isArray(fr) || fr.length < 1 || fr.length > 3) return null;
2123
2256
  return fr.every((x) => typeof x === 'number' && Number.isFinite(x) && x > 0) ? fr.slice() : null;
2124
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);
2125
2262
  return {
2126
2263
  v: 1,
2127
2264
  open: value.open === true && ids.length > 0,
2128
2265
  ids,
2129
2266
  cells,
2267
+ freed,
2268
+ count,
2130
2269
  focused: ids.includes(value.focused) ? value.focused : (ids[0] ?? null),
2131
2270
  zoomed: ids.includes(value.zoomed) ? value.zoomed : null,
2132
2271
  colFr: fractions(value.colFr),
@@ -2134,6 +2273,53 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) {
2134
2273
  };
2135
2274
  }
2136
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
+
2137
2323
  /**
2138
2324
  * New track fractions after a divider drag (grid-template `fr` values): the two
2139
2325
  * tracks either side of divider `index` trade `deltaPx` of size, each kept at
@@ -2166,7 +2352,8 @@ function dragTrackFractions(fr, index, deltaPx, totalPx, minPx) {
2166
2352
 
2167
2353
  /**
2168
2354
  * 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
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
2170
2357
  * its own window (that window owns its PTY size). A session with no PTY
2171
2358
  * attached IS offered: its tile shows the Attach overlay.
2172
2359
  *
@@ -2180,44 +2367,121 @@ function buildTilePickerSessions(sessions, sessionOrder, detachedIds) {
2180
2367
  for (const id of sessionOrder) {
2181
2368
  if (detachedIds?.has?.(id)) continue;
2182
2369
  const session = sessions.get(id);
2183
- if (!session) continue;
2370
+ if (!session || result.some((r) => r.id === id)) continue;
2184
2371
  result.push({ id, label: session.name || 'Session' });
2185
2372
  }
2186
2373
  return result;
2187
2374
  }
2188
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
+
2189
2438
  /**
2190
2439
  * What the Tiles button and Ctrl+Shift+G open, at once and without asking
2191
2440
  * (owner decision 8). In order:
2192
2441
  * a. the grid this tab last had (`stored`, already sanitized: live, not
2193
- * detached, at most the cap), if any of its sessions survive;
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);
2194
2445
  * 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).
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).
2198
2451
  * Null when there is nothing to open.
2199
2452
  *
2200
- * @param {{stored?: {ids: string[], focused: string|null, zoomed: string|null}|null,
2201
- * split?: string[]|null, sessions: Map<string, object>, sessionOrder: string[],
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[],
2202
2456
  * detachedIds?: {has(id: string): boolean}, activeId?: string|null, limit: number}} p
2203
- * @returns {{source: 'stored'|'split'|'tabs', ids: string[], focusedId: string|null}|null}
2457
+ * @returns {{source: 'stored'|'split'|'ranked', ids: string[], cells?: (string|null)[],
2458
+ * focusedId: string|null}|null}
2204
2459
  */
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
- }
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 };
2210
2475
  const usable = (id) => typeof id === 'string' && sessions.has(id) && !detachedIds?.has?.(id);
2211
2476
  const pair = (split || []).filter(usable);
2212
2477
  if (split && pair.length) return { source: 'split', ids: [...new Set(pair)], focusedId: pair[0] };
2213
2478
  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
2479
  if (all.length === 0) return null;
2216
2480
  let ids = all.slice(0, max);
2217
2481
  if (all.includes(activeId) && !ids.includes(activeId)) {
2218
2482
  ids = [...all.filter((id) => id !== activeId).slice(0, max - 1), activeId];
2219
2483
  }
2220
- return { source: 'tabs', ids, focusedId: ids.includes(activeId) ? activeId : ids[0] };
2484
+ return { source: 'ranked', ids, focusedId: ids.includes(activeId) ? activeId : ids[0] };
2221
2485
  }
2222
2486
 
2223
2487
  /**
@@ -2237,8 +2501,9 @@ function sanitizeTileCount(raw) {
2237
2501
  * `base` (what the grid would open, or what an open grid shows, in its order)
2238
2502
  * trimmed or filled to `n` tiles: trimmed from the end, the session to focus
2239
2503
  * (`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.
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.
2242
2507
  *
2243
2508
  * @param {string[]} base
2244
2509
  * @param {string[]} all
@@ -2747,6 +3012,8 @@ if (typeof window !== 'undefined') {
2747
3012
  fitTileCells,
2748
3013
  cycleTile,
2749
3014
  tileGridOpenSet,
3015
+ restoreTileGridCells,
3016
+ rankTileSessions,
2750
3017
  sanitizeTileCount,
2751
3018
  tileGridSetForCount,
2752
3019
  tileCellCols,
Binary file