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