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.
- package/README.md +11 -0
- package/README.zh-CN.md +8 -0
- package/dist/attachment-registry.d.ts +6 -0
- package/dist/attachment-registry.d.ts.map +1 -1
- package/dist/attachment-registry.js +9 -3
- package/dist/attachment-registry.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +2 -2
- package/dist/cli.js.map +1 -1
- package/dist/config/cli-registry/patterns.d.ts +6 -0
- package/dist/config/cli-registry/patterns.d.ts.map +1 -1
- package/dist/config/cli-registry/patterns.js +13 -0
- package/dist/config/cli-registry/patterns.js.map +1 -1
- package/dist/config/cli-registry/schema.d.ts +13 -0
- package/dist/config/cli-registry/schema.d.ts.map +1 -1
- package/dist/config/cli-registry/schema.js +60 -2
- package/dist/config/cli-registry/schema.js.map +1 -1
- package/dist/config/cli-registry/stock.d.ts.map +1 -1
- package/dist/config/cli-registry/stock.js +175 -7
- package/dist/config/cli-registry/stock.js.map +1 -1
- package/dist/config/cli-registry/types.d.ts +93 -6
- package/dist/config/cli-registry/types.d.ts.map +1 -1
- package/dist/config/dependency-registry.d.ts +7 -0
- package/dist/config/dependency-registry.d.ts.map +1 -1
- package/dist/config/dependency-registry.js +15 -0
- package/dist/config/dependency-registry.js.map +1 -1
- package/dist/config/path-probe.d.ts +37 -0
- package/dist/config/path-probe.d.ts.map +1 -0
- package/dist/config/path-probe.js +43 -0
- package/dist/config/path-probe.js.map +1 -0
- package/dist/deepseek-route-config.d.ts +110 -0
- package/dist/deepseek-route-config.d.ts.map +1 -0
- package/dist/deepseek-route-config.js +477 -0
- package/dist/deepseek-route-config.js.map +1 -0
- package/dist/git-workspace-status.d.ts +246 -0
- package/dist/git-workspace-status.d.ts.map +1 -0
- package/dist/git-workspace-status.js +626 -0
- package/dist/git-workspace-status.js.map +1 -0
- package/dist/hooks-config.d.ts.map +1 -1
- package/dist/hooks-config.js +83 -13
- package/dist/hooks-config.js.map +1 -1
- package/dist/model-config-resolvers.d.ts +29 -0
- package/dist/model-config-resolvers.d.ts.map +1 -0
- package/dist/model-config-resolvers.js +34 -0
- package/dist/model-config-resolvers.js.map +1 -0
- package/dist/session-display-model.d.ts +110 -0
- package/dist/session-display-model.d.ts.map +1 -0
- package/dist/session-display-model.js +154 -0
- package/dist/session-display-model.js.map +1 -0
- package/dist/session.d.ts +145 -8
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +280 -35
- package/dist/session.js.map +1 -1
- package/dist/types/api.d.ts +7 -0
- package/dist/types/api.d.ts.map +1 -1
- package/dist/types/api.js.map +1 -1
- package/dist/types/session.d.ts +28 -1
- package/dist/types/session.d.ts.map +1 -1
- package/dist/types/session.js.map +1 -1
- package/dist/types/tools.d.ts +1 -1
- package/dist/types/tools.d.ts.map +1 -1
- package/dist/utils/bounded-path-probe.d.ts +99 -0
- package/dist/utils/bounded-path-probe.d.ts.map +1 -0
- package/dist/utils/bounded-path-probe.js +228 -0
- package/dist/utils/bounded-path-probe.js.map +1 -0
- package/dist/utils/cli-executable-resolver.d.ts +2 -0
- package/dist/utils/cli-executable-resolver.d.ts.map +1 -1
- package/dist/utils/cli-executable-resolver.js +2 -1
- package/dist/utils/cli-executable-resolver.js.map +1 -1
- package/dist/utils/deepseek-cli-resolver.d.ts +8 -0
- package/dist/utils/deepseek-cli-resolver.d.ts.map +1 -1
- package/dist/utils/deepseek-cli-resolver.js +18 -2
- package/dist/utils/deepseek-cli-resolver.js.map +1 -1
- package/dist/utils/dependency-checker.d.ts +2 -0
- package/dist/utils/dependency-checker.d.ts.map +1 -1
- package/dist/utils/dependency-checker.js +37 -9
- package/dist/utils/dependency-checker.js.map +1 -1
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/web/case-path.d.ts +58 -0
- package/dist/web/case-path.d.ts.map +1 -0
- package/dist/web/case-path.js +182 -0
- package/dist/web/case-path.js.map +1 -0
- package/dist/web/launch-defaults.d.ts +22 -0
- package/dist/web/launch-defaults.d.ts.map +1 -0
- package/dist/web/launch-defaults.js +46 -0
- package/dist/web/launch-defaults.js.map +1 -0
- package/dist/web/public/admin-ui.js.gz +0 -0
- package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
- package/dist/web/public/app.abadc970.js +43 -0
- package/dist/web/public/app.abadc970.js.br +0 -0
- package/dist/web/public/app.abadc970.js.gz +0 -0
- package/dist/web/public/approvals-ui.js.gz +0 -0
- package/dist/web/public/{constants.d95fade9.js → constants.c0f8f9de.js} +1009 -127
- package/dist/web/public/constants.c0f8f9de.js.br +0 -0
- package/dist/web/public/constants.c0f8f9de.js.gz +0 -0
- package/dist/web/public/cron-ui.js.gz +0 -0
- package/dist/web/public/entrance-animations.js +1 -0
- package/dist/web/public/entrance-animations.js.br +0 -0
- package/dist/web/public/entrance-animations.js.gz +0 -0
- package/dist/web/public/git-status-ui.js +719 -0
- package/dist/web/public/git-status-ui.js.br +0 -0
- package/dist/web/public/git-status-ui.js.gz +0 -0
- package/dist/web/public/home-sessions.js +12 -9
- package/dist/web/public/home-sessions.js.br +0 -0
- package/dist/web/public/home-sessions.js.gz +0 -0
- package/dist/web/public/host-wake-ui.js.gz +0 -0
- package/dist/web/public/i18n.a31747a9.js +2 -0
- package/dist/web/public/i18n.a31747a9.js.br +0 -0
- package/dist/web/public/i18n.a31747a9.js.gz +0 -0
- package/dist/web/public/{image-input.a216cf9e.js → image-input.898a538b.js} +18 -8
- package/dist/web/public/image-input.898a538b.js.br +0 -0
- package/dist/web/public/image-input.898a538b.js.gz +0 -0
- package/dist/web/public/index.html +205 -42
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
- package/dist/web/public/keyboard-accessory.c1e7c7b7.js.gz +0 -0
- package/dist/web/public/{mobile-handlers.23a85db1.js → mobile-handlers.0ef9c311.js} +20 -0
- package/dist/web/public/mobile-handlers.0ef9c311.js.br +0 -0
- package/dist/web/public/mobile-handlers.0ef9c311.js.gz +0 -0
- package/dist/web/public/mobile-ime-preview.2dbaf262.js.gz +0 -0
- package/dist/web/public/mobile-overview.js.gz +0 -0
- package/dist/web/public/mobile.111b187d.css +1 -0
- package/dist/web/public/mobile.111b187d.css.br +0 -0
- package/dist/web/public/mobile.111b187d.css.gz +0 -0
- package/dist/web/public/notification-manager.50bb87d8.js.gz +0 -0
- package/dist/web/public/orchestrator-panel.js.gz +0 -0
- package/dist/web/public/{panels-ui.6a5898d9.js → panels-ui.4735dfc2.js} +4 -4
- package/dist/web/public/panels-ui.4735dfc2.js.br +0 -0
- package/dist/web/public/{panels-ui.6a5898d9.js.gz → panels-ui.4735dfc2.js.gz} +0 -0
- package/dist/web/public/ralph-panel.b350e2ef.js.gz +0 -0
- package/dist/web/public/ralph-wizard.b6c0dd19.js.gz +0 -0
- package/dist/web/public/readmymind-ui.js.gz +0 -0
- package/dist/web/public/reboot-restore-ui.js.gz +0 -0
- package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
- package/dist/web/public/sanitize-html.e7147e76.js.gz +0 -0
- package/dist/web/public/session-lineage.js +229 -86
- package/dist/web/public/session-lineage.js.br +0 -0
- package/dist/web/public/session-lineage.js.gz +0 -0
- package/dist/web/public/session-ui.f731602c.js +100 -0
- package/dist/web/public/session-ui.f731602c.js.br +0 -0
- package/dist/web/public/session-ui.f731602c.js.gz +0 -0
- package/dist/web/public/settings-ui.a97ab16f.js +97 -0
- package/dist/web/public/settings-ui.a97ab16f.js.br +0 -0
- package/dist/web/public/settings-ui.a97ab16f.js.gz +0 -0
- package/dist/web/public/spreadsheet-preview-worker.js +290 -0
- package/dist/web/public/spreadsheet-preview-worker.js.br +0 -0
- package/dist/web/public/spreadsheet-preview-worker.js.gz +0 -0
- package/dist/web/public/spreadsheet-preview.af82fa2f.js +555 -0
- package/dist/web/public/spreadsheet-preview.af82fa2f.js.br +0 -0
- package/dist/web/public/spreadsheet-preview.af82fa2f.js.gz +0 -0
- package/dist/web/public/spreadsheet-xlsx-core.js +1006 -0
- package/dist/web/public/spreadsheet-xlsx-core.js.br +0 -0
- package/dist/web/public/spreadsheet-xlsx-core.js.gz +0 -0
- package/dist/web/public/styles.8ee12666.css +1 -0
- package/dist/web/public/styles.8ee12666.css.br +0 -0
- package/dist/web/public/styles.8ee12666.css.gz +0 -0
- package/dist/web/public/subagent-windows.e6ca799f.js.gz +0 -0
- package/dist/web/public/sw.js +2 -2
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/tab-layout-browser.065fcba4.js +1 -0
- package/dist/web/public/tab-layout-browser.065fcba4.js.br +0 -0
- package/dist/web/public/tab-layout-browser.065fcba4.js.gz +0 -0
- package/dist/web/public/{tab-rail-resize.dcdc5772.js → tab-rail-resize.a5867793.js} +1 -1
- package/dist/web/public/tab-rail-resize.a5867793.js.br +0 -0
- package/dist/web/public/tab-rail-resize.a5867793.js.gz +0 -0
- package/dist/web/public/terminal-keycode229-recovery.af09f99a.js +1 -0
- package/dist/web/public/terminal-keycode229-recovery.af09f99a.js.br +0 -0
- package/dist/web/public/terminal-keycode229-recovery.af09f99a.js.gz +0 -0
- package/dist/web/public/terminal-split.js +146 -649
- package/dist/web/public/terminal-split.js.br +0 -0
- package/dist/web/public/terminal-split.js.gz +0 -0
- package/dist/web/public/terminal-tile.js +1611 -0
- package/dist/web/public/terminal-tile.js.br +0 -0
- package/dist/web/public/terminal-tile.js.gz +0 -0
- package/dist/web/public/terminal-ui.5059b30e.js +2 -0
- package/dist/web/public/terminal-ui.5059b30e.js.br +0 -0
- package/dist/web/public/terminal-ui.5059b30e.js.gz +0 -0
- package/dist/web/public/tile-grid.js +2684 -0
- package/dist/web/public/tile-grid.js.br +0 -0
- package/dist/web/public/tile-grid.js.gz +0 -0
- package/dist/web/public/ultracode-panel.js.gz +0 -0
- package/dist/web/public/ultracode-windows.js.gz +0 -0
- package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
- package/dist/web/public/vendor/exceljs.min.js +44 -0
- package/dist/web/public/vendor/exceljs.min.js.br +0 -0
- package/dist/web/public/vendor/exceljs.min.js.gz +0 -0
- package/dist/web/public/vendor/fflate.min.js +1 -0
- package/dist/web/public/vendor/fflate.min.js.br +0 -0
- package/dist/web/public/vendor/fflate.min.js.gz +0 -0
- package/dist/web/public/vendor/marked.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-predictive-echo.ae5e3d5d.js.gz +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.bbb5f84c.js.gz +0 -0
- package/dist/web/public/vendor/xterm.css.gz +0 -0
- package/dist/web/public/vendor/xterm.min.js.gz +0 -0
- package/dist/web/public/{voice-input.c4b51eb6.js → voice-input.2342c37b.js} +64 -10
- package/dist/web/public/voice-input.2342c37b.js.br +0 -0
- package/dist/web/public/voice-input.2342c37b.js.gz +0 -0
- package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
- package/dist/web/public/webview-tabs.js +9 -1
- package/dist/web/public/webview-tabs.js.br +0 -0
- package/dist/web/public/webview-tabs.js.gz +0 -0
- package/dist/web/routes/case-routes.d.ts.map +1 -1
- package/dist/web/routes/case-routes.js +136 -18
- package/dist/web/routes/case-routes.js.map +1 -1
- package/dist/web/routes/cli-registry-routes.d.ts.map +1 -1
- package/dist/web/routes/cli-registry-routes.js +3 -1
- package/dist/web/routes/cli-registry-routes.js.map +1 -1
- package/dist/web/routes/doctor-routes.d.ts +23 -0
- package/dist/web/routes/doctor-routes.d.ts.map +1 -0
- package/dist/web/routes/doctor-routes.js +83 -0
- package/dist/web/routes/doctor-routes.js.map +1 -0
- package/dist/web/routes/file-routes.d.ts +7 -0
- package/dist/web/routes/file-routes.d.ts.map +1 -1
- package/dist/web/routes/file-routes.js +34 -4
- package/dist/web/routes/file-routes.js.map +1 -1
- package/dist/web/routes/git-status-routes.d.ts +16 -0
- package/dist/web/routes/git-status-routes.d.ts.map +1 -0
- package/dist/web/routes/git-status-routes.js +98 -0
- package/dist/web/routes/git-status-routes.js.map +1 -0
- package/dist/web/routes/index.d.ts +2 -0
- package/dist/web/routes/index.d.ts.map +1 -1
- package/dist/web/routes/index.js +2 -0
- package/dist/web/routes/index.js.map +1 -1
- package/dist/web/routes/session-routes.d.ts +18 -0
- package/dist/web/routes/session-routes.d.ts.map +1 -1
- package/dist/web/routes/session-routes.js +106 -42
- package/dist/web/routes/session-routes.js.map +1 -1
- package/dist/web/routes/status-telemetry-routes.d.ts +3 -0
- package/dist/web/routes/status-telemetry-routes.d.ts.map +1 -1
- package/dist/web/routes/status-telemetry-routes.js +12 -0
- package/dist/web/routes/status-telemetry-routes.js.map +1 -1
- package/dist/web/routes/system-routes.d.ts.map +1 -1
- package/dist/web/routes/system-routes.js +13 -0
- package/dist/web/routes/system-routes.js.map +1 -1
- package/dist/web/schemas.d.ts +28 -0
- package/dist/web/schemas.d.ts.map +1 -1
- package/dist/web/schemas.js +41 -0
- package/dist/web/schemas.js.map +1 -1
- package/dist/web/server.d.ts +5 -0
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +35 -12
- package/dist/web/server.js.map +1 -1
- package/dist/web/session-listener-wiring.d.ts +1 -0
- package/dist/web/session-listener-wiring.d.ts.map +1 -1
- package/dist/web/session-listener-wiring.js +11 -0
- package/dist/web/session-listener-wiring.js.map +1 -1
- package/package.json +3 -1
- package/scripts/postinstall.js +16 -0
- package/skills/codeman/reference/verbs.md +3 -0
- package/dist/web/public/app.cf0db8ce.js +0 -43
- package/dist/web/public/app.cf0db8ce.js.br +0 -0
- package/dist/web/public/app.cf0db8ce.js.gz +0 -0
- package/dist/web/public/constants.d95fade9.js.br +0 -0
- package/dist/web/public/constants.d95fade9.js.gz +0 -0
- package/dist/web/public/i18n.398b8331.js +0 -1
- package/dist/web/public/i18n.398b8331.js.br +0 -0
- package/dist/web/public/i18n.398b8331.js.gz +0 -0
- package/dist/web/public/image-input.a216cf9e.js.br +0 -0
- package/dist/web/public/image-input.a216cf9e.js.gz +0 -0
- package/dist/web/public/mobile-handlers.23a85db1.js.br +0 -0
- package/dist/web/public/mobile-handlers.23a85db1.js.gz +0 -0
- package/dist/web/public/mobile.1aa4952d.css +0 -1
- package/dist/web/public/mobile.1aa4952d.css.br +0 -0
- package/dist/web/public/mobile.1aa4952d.css.gz +0 -0
- package/dist/web/public/panels-ui.6a5898d9.js.br +0 -0
- package/dist/web/public/session-ui.bf66c13b.js +0 -89
- package/dist/web/public/session-ui.bf66c13b.js.br +0 -0
- package/dist/web/public/session-ui.bf66c13b.js.gz +0 -0
- package/dist/web/public/settings-ui.331d3b1d.js +0 -97
- package/dist/web/public/settings-ui.331d3b1d.js.br +0 -0
- package/dist/web/public/settings-ui.331d3b1d.js.gz +0 -0
- package/dist/web/public/styles.8c485d26.css +0 -1
- package/dist/web/public/styles.8c485d26.css.br +0 -0
- package/dist/web/public/styles.8c485d26.css.gz +0 -0
- package/dist/web/public/tab-layout-browser.4e0ed50c.js +0 -1
- package/dist/web/public/tab-layout-browser.4e0ed50c.js.br +0 -0
- package/dist/web/public/tab-layout-browser.4e0ed50c.js.gz +0 -0
- package/dist/web/public/tab-rail-resize.dcdc5772.js.br +0 -0
- package/dist/web/public/tab-rail-resize.dcdc5772.js.gz +0 -0
- package/dist/web/public/terminal-keycode229-recovery.fb91b25b.js +0 -1
- package/dist/web/public/terminal-keycode229-recovery.fb91b25b.js.br +0 -0
- package/dist/web/public/terminal-keycode229-recovery.fb91b25b.js.gz +0 -0
- package/dist/web/public/terminal-ui.a4535311.js +0 -2
- package/dist/web/public/terminal-ui.a4535311.js.br +0 -0
- package/dist/web/public/terminal-ui.a4535311.js.gz +0 -0
- package/dist/web/public/upload.html +0 -155
- package/dist/web/public/upload.html.br +0 -0
- package/dist/web/public/upload.html.gz +0 -0
- package/dist/web/public/voice-input.c4b51eb6.js.br +0 -0
- 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
|
|
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
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
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
|
-
// ⚠
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
// row
|
|
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
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
const
|
|
341
|
-
//
|
|
342
|
-
// apart
|
|
343
|
-
const
|
|
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
|
|
346
|
-
// clear the VIEWPORT edge, not just the tabs
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
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
|
|
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
|
|
356
|
-
// workers it spawns; a child that spawns in turn gets its own for the
|
|
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
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
|
378
|
-
const
|
|
379
|
-
const
|
|
380
|
-
const
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
406
|
-
const
|
|
407
|
-
if (
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
414
|
-
|
|
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
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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
|
|
1616
|
-
//
|
|
1617
|
-
//
|
|
1618
|
-
//
|
|
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
|
|
1626
|
-
// re-attach POST
|
|
1627
|
-
//
|
|
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,
|