aicodeman 1.40.0 → 1.41.1
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 +81 -13
- package/dist/attachment-registry.d.ts +2 -0
- package/dist/attachment-registry.d.ts.map +1 -1
- package/dist/attachment-registry.js +14 -3
- package/dist/attachment-registry.js.map +1 -1
- package/dist/cli-agent.d.ts +227 -0
- package/dist/cli-agent.d.ts.map +1 -0
- package/dist/cli-agent.js +771 -0
- package/dist/cli-agent.js.map +1 -0
- package/dist/cli.d.ts +25 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +103 -41
- 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/buffer-limits.d.ts +9 -0
- package/dist/config/buffer-limits.d.ts.map +1 -1
- package/dist/config/buffer-limits.js +9 -0
- package/dist/config/buffer-limits.js.map +1 -1
- package/dist/config/cli-registry/schema.d.ts +10 -0
- package/dist/config/cli-registry/schema.d.ts.map +1 -1
- package/dist/config/cli-registry/schema.js +15 -0
- 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 +47 -0
- package/dist/config/cli-registry/stock.js.map +1 -1
- package/dist/config/cli-registry/types.d.ts +47 -1
- package/dist/config/cli-registry/types.d.ts.map +1 -1
- package/dist/folder-listing.d.ts +94 -0
- package/dist/folder-listing.d.ts.map +1 -0
- package/dist/folder-listing.js +241 -0
- package/dist/folder-listing.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/hooks-config.d.ts +36 -16
- package/dist/hooks-config.d.ts.map +1 -1
- package/dist/hooks-config.js +45 -20
- package/dist/hooks-config.js.map +1 -1
- package/dist/html-view-capabilities.d.ts +54 -0
- package/dist/html-view-capabilities.d.ts.map +1 -0
- package/dist/html-view-capabilities.js +103 -0
- package/dist/html-view-capabilities.js.map +1 -0
- 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-server.d.ts +114 -0
- package/dist/mcp-server.d.ts.map +1 -0
- package/dist/mcp-server.js +917 -0
- package/dist/mcp-server.js.map +1 -0
- 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 +7 -1
- package/dist/mcp-sync.d.ts.map +1 -1
- package/dist/mcp-sync.js +113 -6
- package/dist/mcp-sync.js.map +1 -1
- package/dist/mux-interface.d.ts +36 -1
- package/dist/mux-interface.d.ts.map +1 -1
- package/dist/remote-files.d.ts +37 -0
- package/dist/remote-files.d.ts.map +1 -1
- package/dist/remote-files.js +103 -0
- package/dist/remote-files.js.map +1 -1
- package/dist/remote-pane-history.d.ts +103 -0
- package/dist/remote-pane-history.d.ts.map +1 -0
- package/dist/remote-pane-history.js +258 -0
- package/dist/remote-pane-history.js.map +1 -0
- package/dist/remote-ssh-limiter.d.ts +16 -3
- package/dist/remote-ssh-limiter.d.ts.map +1 -1
- package/dist/remote-ssh-limiter.js +37 -6
- package/dist/remote-ssh-limiter.js.map +1 -1
- package/dist/session.d.ts +29 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +63 -4
- 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/tab-layout-legacy-order.d.ts.map +1 -1
- package/dist/tab-layout-legacy-order.js +72 -13
- package/dist/tab-layout-legacy-order.js.map +1 -1
- package/dist/tab-layout-service.d.ts +33 -2
- package/dist/tab-layout-service.d.ts.map +1 -1
- package/dist/tab-layout-service.js +102 -7
- package/dist/tab-layout-service.js.map +1 -1
- package/dist/tab-layout.d.ts +9 -0
- package/dist/tab-layout.d.ts.map +1 -1
- package/dist/tab-layout.js +15 -2
- package/dist/tab-layout.js.map +1 -1
- package/dist/templates/case-template.md +2 -0
- package/dist/tmux-manager.d.ts +76 -2
- package/dist/tmux-manager.d.ts.map +1 -1
- package/dist/tmux-manager.js +216 -40
- 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 +53 -0
- package/dist/types/session.d.ts.map +1 -1
- package/dist/types/session.js.map +1 -1
- package/dist/usage-telemetry.d.ts +44 -7
- package/dist/usage-telemetry.d.ts.map +1 -1
- package/dist/usage-telemetry.js +72 -7
- package/dist/usage-telemetry.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/approval-inbox.d.ts +12 -0
- package/dist/web/approval-inbox.d.ts.map +1 -1
- package/dist/web/approval-inbox.js +1 -0
- package/dist/web/approval-inbox.js.map +1 -1
- package/dist/web/middleware/auth.d.ts.map +1 -1
- package/dist/web/middleware/auth.js +33 -0
- package/dist/web/middleware/auth.js.map +1 -1
- 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/ports/session-port.d.ts +8 -1
- package/dist/web/ports/session-port.d.ts.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.24689381.js +43 -0
- package/dist/web/public/app.24689381.js.br +0 -0
- package/dist/web/public/app.24689381.js.gz +0 -0
- package/dist/web/public/approvals-ui.js +4 -0
- package/dist/web/public/approvals-ui.js.br +0 -0
- package/dist/web/public/approvals-ui.js.gz +0 -0
- package/dist/web/public/{constants.c0f8f9de.js → constants.17d77b65.js} +690 -35
- package/dist/web/public/constants.17d77b65.js.br +0 -0
- package/dist/web/public/constants.17d77b65.js.gz +0 -0
- package/dist/web/public/cron-ui.js.gz +0 -0
- package/dist/web/public/entrance-animations.js +490 -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/gesture/gesture-codeman.js +112 -6
- package/dist/web/public/git-status-ui.js.gz +0 -0
- package/dist/web/public/home-sessions.js +2 -1
- 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.9afd8e4d.js +2 -0
- package/dist/web/public/i18n.9afd8e4d.js.br +0 -0
- package/dist/web/public/i18n.9afd8e4d.js.gz +0 -0
- package/dist/web/public/image-input.9ed1d0d2.js +453 -0
- package/dist/web/public/image-input.9ed1d0d2.js.br +0 -0
- package/dist/web/public/image-input.9ed1d0d2.js.gz +0 -0
- package/dist/web/public/index.html +223 -46
- 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 → keyboard-accessory.c458eea8.js} +115 -35
- package/dist/web/public/keyboard-accessory.c458eea8.js.br +0 -0
- package/dist/web/public/keyboard-accessory.c458eea8.js.gz +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 +11 -2
- package/dist/web/public/mobile-overview.js.br +0 -0
- package/dist/web/public/mobile-overview.js.gz +0 -0
- package/dist/web/public/mobile.2dff1580.css +1 -0
- package/dist/web/public/mobile.2dff1580.css.br +0 -0
- package/dist/web/public/mobile.2dff1580.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.4735dfc2.js → panels-ui.73ec0137.js} +87 -87
- package/dist/web/public/panels-ui.73ec0137.js.br +0 -0
- package/dist/web/public/panels-ui.73ec0137.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.gz +0 -0
- package/dist/web/public/{session-ui.f731602c.js → session-ui.dbc6819b.js} +33 -33
- package/dist/web/public/session-ui.dbc6819b.js.br +0 -0
- package/dist/web/public/session-ui.dbc6819b.js.gz +0 -0
- package/dist/web/public/settings-ui.81ac264e.js +97 -0
- package/dist/web/public/settings-ui.81ac264e.js.br +0 -0
- package/dist/web/public/settings-ui.81ac264e.js.gz +0 -0
- package/dist/web/public/spreadsheet-preview-worker.js +11 -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 → spreadsheet-preview.75c751de.js} +1 -1
- package/dist/web/public/spreadsheet-preview.75c751de.js.br +0 -0
- package/dist/web/public/spreadsheet-preview.75c751de.js.gz +0 -0
- package/dist/web/public/spreadsheet-xlsx-core.js +110 -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.900a28e4.css +1 -0
- package/dist/web/public/styles.900a28e4.css.br +0 -0
- package/dist/web/public/styles.900a28e4.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 +6 -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.2d6189f7.js +1 -0
- package/dist/web/public/tab-layout-browser.2d6189f7.js.br +0 -0
- package/dist/web/public/tab-layout-browser.2d6189f7.js.gz +0 -0
- package/dist/web/public/{tab-rail-resize.a5867793.js → tab-rail-resize.7157e0bd.js} +1 -1
- package/dist/web/public/tab-rail-resize.7157e0bd.js.br +0 -0
- package/dist/web/public/tab-rail-resize.7157e0bd.js.gz +0 -0
- package/dist/web/public/terminal-keycode229-recovery.af09f99a.js.gz +0 -0
- package/dist/web/public/terminal-split.js.gz +0 -0
- package/dist/web/public/terminal-tile.js +135 -38
- 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.858f5da5.js +2 -0
- package/dist/web/public/terminal-ui.858f5da5.js.br +0 -0
- package/dist/web/public/terminal-ui.858f5da5.js.gz +0 -0
- package/dist/web/public/tile-grid.js +769 -137
- 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.gz +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.2342c37b.js.gz +0 -0
- package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
- package/dist/web/public/webview-tabs.js +12 -0
- package/dist/web/public/webview-tabs.js.br +0 -0
- package/dist/web/public/webview-tabs.js.gz +0 -0
- package/dist/web/route-helpers.d.ts +8 -0
- package/dist/web/route-helpers.d.ts.map +1 -1
- package/dist/web/route-helpers.js +8 -0
- package/dist/web/route-helpers.js.map +1 -1
- package/dist/web/routes/admin-routes.d.ts.map +1 -1
- package/dist/web/routes/admin-routes.js +4 -1
- package/dist/web/routes/admin-routes.js.map +1 -1
- package/dist/web/routes/approval-routes.d.ts.map +1 -1
- package/dist/web/routes/approval-routes.js +6 -8
- package/dist/web/routes/approval-routes.js.map +1 -1
- package/dist/web/routes/case-routes.js +1 -1
- package/dist/web/routes/case-routes.js.map +1 -1
- 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 +92 -7
- package/dist/web/routes/cli-registry-routes.js.map +1 -1
- package/dist/web/routes/file-routes.d.ts +46 -1
- package/dist/web/routes/file-routes.d.ts.map +1 -1
- package/dist/web/routes/file-routes.js +214 -27
- package/dist/web/routes/file-routes.js.map +1 -1
- package/dist/web/routes/hook-event-routes.d.ts.map +1 -1
- package/dist/web/routes/hook-event-routes.js +8 -1
- package/dist/web/routes/hook-event-routes.js.map +1 -1
- package/dist/web/routes/html-view-routes.d.ts +60 -0
- package/dist/web/routes/html-view-routes.d.ts.map +1 -0
- package/dist/web/routes/html-view-routes.js +288 -0
- package/dist/web/routes/html-view-routes.js.map +1 -0
- package/dist/web/routes/index.d.ts +1 -0
- package/dist/web/routes/index.d.ts.map +1 -1
- package/dist/web/routes/index.js +1 -0
- package/dist/web/routes/index.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 +3 -3
- package/dist/web/routes/reboot-restore-routes.js.map +1 -1
- package/dist/web/routes/session-routes.d.ts +1 -0
- package/dist/web/routes/session-routes.d.ts.map +1 -1
- package/dist/web/routes/session-routes.js +345 -90
- package/dist/web/routes/session-routes.js.map +1 -1
- package/dist/web/routes/status-telemetry-routes.d.ts.map +1 -1
- package/dist/web/routes/status-telemetry-routes.js +16 -2
- package/dist/web/routes/status-telemetry-routes.js.map +1 -1
- package/dist/web/schemas.d.ts +32 -0
- package/dist/web/schemas.d.ts.map +1 -1
- package/dist/web/schemas.js +74 -0
- package/dist/web/schemas.js.map +1 -1
- package/dist/web/server.d.ts +7 -2
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +77 -24
- package/dist/web/server.js.map +1 -1
- package/dist/web/sse-events.d.ts +5 -2
- package/dist/web/sse-events.d.ts.map +1 -1
- package/dist/web/sse-events.js +5 -2
- package/dist/web/sse-events.js.map +1 -1
- package/package.json +5 -4
- package/skills/codeman/SKILL.md +30 -7
- package/README.zh-CN.md +0 -1147
- package/dist/web/public/app.abadc970.js +0 -43
- package/dist/web/public/app.abadc970.js.br +0 -0
- package/dist/web/public/app.abadc970.js.gz +0 -0
- package/dist/web/public/constants.c0f8f9de.js.br +0 -0
- package/dist/web/public/constants.c0f8f9de.js.gz +0 -0
- package/dist/web/public/i18n.a31747a9.js +0 -2
- package/dist/web/public/i18n.a31747a9.js.br +0 -0
- package/dist/web/public/i18n.a31747a9.js.gz +0 -0
- package/dist/web/public/image-input.898a538b.js +0 -281
- package/dist/web/public/image-input.898a538b.js.br +0 -0
- package/dist/web/public/image-input.898a538b.js.gz +0 -0
- package/dist/web/public/keyboard-accessory.c1e7c7b7.js.br +0 -0
- package/dist/web/public/keyboard-accessory.c1e7c7b7.js.gz +0 -0
- package/dist/web/public/mobile.111b187d.css +0 -1
- 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.br +0 -0
- package/dist/web/public/notification-manager.50bb87d8.js.gz +0 -0
- package/dist/web/public/panels-ui.4735dfc2.js.br +0 -0
- package/dist/web/public/panels-ui.4735dfc2.js.gz +0 -0
- package/dist/web/public/session-ui.f731602c.js.br +0 -0
- package/dist/web/public/session-ui.f731602c.js.gz +0 -0
- package/dist/web/public/settings-ui.a97ab16f.js +0 -97
- package/dist/web/public/settings-ui.a97ab16f.js.br +0 -0
- package/dist/web/public/settings-ui.a97ab16f.js.gz +0 -0
- package/dist/web/public/spreadsheet-preview.af82fa2f.js.br +0 -0
- package/dist/web/public/spreadsheet-preview.af82fa2f.js.gz +0 -0
- package/dist/web/public/styles.8ee12666.css +0 -1
- package/dist/web/public/styles.8ee12666.css.br +0 -0
- package/dist/web/public/styles.8ee12666.css.gz +0 -0
- package/dist/web/public/subagent-windows.e6ca799f.js.br +0 -0
- package/dist/web/public/subagent-windows.e6ca799f.js.gz +0 -0
- package/dist/web/public/tab-layout-browser.065fcba4.js +0 -1
- package/dist/web/public/tab-layout-browser.065fcba4.js.br +0 -0
- package/dist/web/public/tab-layout-browser.065fcba4.js.gz +0 -0
- package/dist/web/public/tab-rail-resize.a5867793.js.br +0 -0
- package/dist/web/public/tab-rail-resize.a5867793.js.gz +0 -0
- package/dist/web/public/terminal-ui.5059b30e.js +0 -2
- package/dist/web/public/terminal-ui.5059b30e.js.br +0 -0
- package/dist/web/public/terminal-ui.5059b30e.js.gz +0 -0
- package/dist/web/public/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
|
@@ -110,9 +110,27 @@ 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)
|
|
119
|
+
// A shell's automatic loads (tab select, drop recovery, a tile's load) read
|
|
120
|
+
// tmux's RENDERED history, never the raw byte recording: that recording holds
|
|
121
|
+
// full-screen repaints (an ssh'd tmux, less, a TUI) drawn for the pane size of
|
|
122
|
+
// the moment, and replayed into a terminal of another height each repaint
|
|
123
|
+
// scrolls its top row into scrollback, so one line shows up dozens of times.
|
|
124
|
+
// `lines=` bounds the synchronous capture-pane (~8 ms per 1000 lines measured,
|
|
125
|
+
// so ~0.1 s here); the server reports a cut as 'tail', and scroll-to-top pulls
|
|
126
|
+
// the older lines as before. `alt=recording`: a pane in the alternate screen (a
|
|
127
|
+
// nested tmux, as in remote SSH and Docker cases, but also vim or less while
|
|
128
|
+
// open) gains no tmux history, so the server loads it the old way, visible
|
|
129
|
+
// frame over the tailed byte recording.
|
|
130
|
+
// A grid tile appends its own `lines=` to SHELL_LOAD_BASE_QUERY.
|
|
131
|
+
const SHELL_LOAD_HISTORY_LINES = 10000;
|
|
132
|
+
const SHELL_LOAD_BASE_QUERY = `full=1&tail=${TERMINAL_TAIL_SIZE}&alt=recording`;
|
|
133
|
+
const SHELL_LOAD_QUERY = `${SHELL_LOAD_BASE_QUERY}&lines=${SHELL_LOAD_HISTORY_LINES}`;
|
|
116
134
|
const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
|
|
117
135
|
const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling
|
|
118
136
|
const TUI_REDRAW_SETTLE_MS = 400; // Grace for a TUI to redraw after a real resize, before fetching its buffer
|
|
@@ -706,6 +724,45 @@ function computeConnectionLossUi(input) {
|
|
|
706
724
|
};
|
|
707
725
|
}
|
|
708
726
|
|
|
727
|
+
// Session Options → Respawn → "Prompt cache": one sentence of the main
|
|
728
|
+
// conversation's cache state as Claude Code reports it on the statusline
|
|
729
|
+
// (`session.promptCache`, v2.1.251+). A clock time rather than a countdown
|
|
730
|
+
// because the row refreshes on SSE, not on a timer, and a countdown would sit
|
|
731
|
+
// stale between turns. Pure: no DOM, `now` is passed in. `statusLine` is the
|
|
732
|
+
// session's `statusLineExporter`: a launch without the exporter never reports, so
|
|
733
|
+
// it says why instead of "not reported yet" forever.
|
|
734
|
+
const PROMPT_CACHE_NOT_COLLECTED = {
|
|
735
|
+
'collection-off':
|
|
736
|
+
'Not collected: Plan Usage was off when this session started. Turn it on in App Settings; sessions started after that report it.',
|
|
737
|
+
'workspace-statusline':
|
|
738
|
+
"Not collected: this workspace's .claude/settings.local.json sets its own statusLine, which Codeman never replaces.",
|
|
739
|
+
remote: 'Not collected for remote and Docker sessions.',
|
|
740
|
+
};
|
|
741
|
+
function formatPromptCacheStatus(cache, now, statusLine) {
|
|
742
|
+
if (PROMPT_CACHE_NOT_COLLECTED[statusLine]) return PROMPT_CACHE_NOT_COLLECTED[statusLine];
|
|
743
|
+
if (!cache || typeof cache.warm !== 'boolean') {
|
|
744
|
+
return 'Not reported yet. Claude Code 2.1.251 or newer reports it after the first response.';
|
|
745
|
+
}
|
|
746
|
+
const clock = (ms) => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
747
|
+
const kTokens = (n) => (n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`);
|
|
748
|
+
const stake = cache.recacheTokensIfCold;
|
|
749
|
+
const tokens = Number.isFinite(stake) && stake > 0 ? kTokens(stake) : null;
|
|
750
|
+
const parts = [];
|
|
751
|
+
if (cache.warm && Number.isFinite(cache.expiresAt) && cache.expiresAt > now) {
|
|
752
|
+
const ttl = cache.ttl ? ` (${cache.ttl} cache)` : '';
|
|
753
|
+
parts.push(`Warm until ${clock(cache.expiresAt)}${ttl}.`);
|
|
754
|
+
if (tokens) parts.push(`About ${tokens} tokens would be re-cached if it goes cold.`);
|
|
755
|
+
} else {
|
|
756
|
+
parts.push(tokens ? `Cold. The next turn re-caches about ${tokens} tokens.` : 'Cold.');
|
|
757
|
+
}
|
|
758
|
+
// A compaction or a tool-result clearing leaves no re-cache stake to show until the next
|
|
759
|
+
// request; naming when it happened explains the gap. Never an alert.
|
|
760
|
+
if (Number.isFinite(cache.compactedAt)) parts.push(`Context compacted at ${clock(cache.compactedAt)}.`);
|
|
761
|
+
if (Number.isFinite(cache.hitRatio)) parts.push(`${Math.round(cache.hitRatio * 100)}% cache hit rate.`);
|
|
762
|
+
if (cache.lastMissCauses && cache.lastMissCauses.length) parts.push(`Last miss: ${cache.lastMissCauses.join(', ')}.`);
|
|
763
|
+
return parts.join(' ');
|
|
764
|
+
}
|
|
765
|
+
|
|
709
766
|
// SSE staleness policy: is this stream a zombie?
|
|
710
767
|
//
|
|
711
768
|
// An EventSource that stops delivering does not always error. A proxy that
|
|
@@ -1017,6 +1074,56 @@ function tabClusterNameSplit(name, label) {
|
|
|
1017
1074
|
return match[2].slice(1).toLowerCase() === label.toLowerCase() ? { shown: match[1], hidden: match[2] } : null;
|
|
1018
1075
|
}
|
|
1019
1076
|
|
|
1077
|
+
/**
|
|
1078
|
+
* Session-list search: the vertical rail's search box and the sidebar's filter
|
|
1079
|
+
* box. Trimmed, case-insensitive substring; a whitespace-only query is no query.
|
|
1080
|
+
* Lower-cased with toLowerCase(), never toLocaleLowerCase(): under a Turkish or
|
|
1081
|
+
* Azeri browser locale "API" lowers to "apı" and a search for "api" would miss it.
|
|
1082
|
+
* @param {unknown} query
|
|
1083
|
+
* @returns {string} the needle, '' when there is nothing to search for
|
|
1084
|
+
*/
|
|
1085
|
+
function tabSearchNeedle(query) {
|
|
1086
|
+
return typeof query === 'string' ? query.trim().toLowerCase() : '';
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* Which rows a search hides. Pure: the caller reads the rows off the list it
|
|
1091
|
+
* rendered and applies the result as classes, so the list itself (grouping,
|
|
1092
|
+
* order, Alt+N badges) is never rebuilt or reordered by a search.
|
|
1093
|
+
*
|
|
1094
|
+
* A row flagged `keep: true` is never hidden, matching or not (the caller keeps
|
|
1095
|
+
* a tab with an alert on screen: a prompt waiting on you is never hidden by a
|
|
1096
|
+
* view filter). It counts toward its section, so its group stays on screen with
|
|
1097
|
+
* it, but not toward `matchCount`.
|
|
1098
|
+
*
|
|
1099
|
+
* @param {Array<{key: unknown, text: string, section?: unknown, keep?: boolean}>} rows
|
|
1100
|
+
* in list order; `section` is the row's group or case box, null/undefined for none.
|
|
1101
|
+
* @param {unknown} query
|
|
1102
|
+
* @returns {{active: boolean, hidden: Set<unknown>, counts: Map<unknown, number>, matchCount: number}}
|
|
1103
|
+
* `counts` is the rows left showing per section (kept rows included), every
|
|
1104
|
+
* section seen, an emptied one as 0, so it can be hidden; `matchCount` is the
|
|
1105
|
+
* number of rows whose TEXT matched, so it can be 0 above a lone kept row.
|
|
1106
|
+
*/
|
|
1107
|
+
function filterTabSearchRows(rows, query) {
|
|
1108
|
+
const needle = tabSearchNeedle(query);
|
|
1109
|
+
const hidden = new Set();
|
|
1110
|
+
const counts = new Map();
|
|
1111
|
+
let matchCount = 0;
|
|
1112
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
1113
|
+
const hasSection = row.section !== null && row.section !== undefined;
|
|
1114
|
+
if (hasSection && !counts.has(row.section)) counts.set(row.section, 0);
|
|
1115
|
+
const text = typeof row.text === 'string' ? row.text.toLowerCase() : '';
|
|
1116
|
+
const matches = !needle || text.includes(needle);
|
|
1117
|
+
if (!matches && row.keep !== true) {
|
|
1118
|
+
hidden.add(row.key);
|
|
1119
|
+
continue;
|
|
1120
|
+
}
|
|
1121
|
+
if (matches) matchCount++;
|
|
1122
|
+
if (hasSection) counts.set(row.section, counts.get(row.section) + 1);
|
|
1123
|
+
}
|
|
1124
|
+
return { active: needle.length > 0, hidden, counts, matchCount };
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1020
1127
|
// Terminal font stack — the single source for every xterm surface (the main
|
|
1021
1128
|
// terminal in terminal-ui.js, the log-viewer terminal in panels-ui.js).
|
|
1022
1129
|
// "Symbols Nerd Font Mono" is a bundled icons-only webfont (fonts/ +
|
|
@@ -1298,6 +1405,63 @@ function cleanCopiedSelection(text, options) {
|
|
|
1298
1405
|
return lines.join('\n');
|
|
1299
1406
|
}
|
|
1300
1407
|
|
|
1408
|
+
// ── Markdown heading anchors ────────────────────────────────────────────────
|
|
1409
|
+
// marked emits no `id` on headings, so a rendered document's own `[Install](#installation)` links had
|
|
1410
|
+
// nothing to jump to. And with `<base href="/">` a bare `#installation` href points at the dashboard's
|
|
1411
|
+
// root, not at the page, so letting the browser follow it navigates the app away. The click delegate
|
|
1412
|
+
// (`_bindResponseViewerInteractions`) therefore resolves in-document links itself, with the helpers
|
|
1413
|
+
// below. Anchors are `data-md-anchor` attributes, NOT `id`s: a heading titled "Settings" must not claim
|
|
1414
|
+
// the id of an element in the app's own DOM, and the lookup is scoped to the rendered document.
|
|
1415
|
+
|
|
1416
|
+
/**
|
|
1417
|
+
* GitHub's heading slug: lower-cased, anything that is not a letter, mark, number, `_`, `-` or space
|
|
1418
|
+
* dropped, each space a hyphen (`Why `codeman`? → `why-codeman`, `Über uns` → `über-uns`).
|
|
1419
|
+
*/
|
|
1420
|
+
function markdownHeadingSlug(text) {
|
|
1421
|
+
return String(text ?? '')
|
|
1422
|
+
.trim()
|
|
1423
|
+
.toLowerCase()
|
|
1424
|
+
.replace(/[^\p{L}\p{M}\p{N}_\- ]/gu, '')
|
|
1425
|
+
.replace(/ /g, '-');
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/** Give every h1..h6 under `root` its slug in `data-md-anchor`; a repeat gets `-1`, `-2`, ... as on GitHub. Idempotent. */
|
|
1429
|
+
function assignMarkdownHeadingAnchors(root) {
|
|
1430
|
+
const used = new Set();
|
|
1431
|
+
for (const heading of root.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
|
1432
|
+
const base = markdownHeadingSlug(heading.textContent);
|
|
1433
|
+
let slug = base;
|
|
1434
|
+
for (let n = 1; used.has(slug); n += 1) slug = `${base}-${n}`;
|
|
1435
|
+
used.add(slug);
|
|
1436
|
+
heading.dataset.mdAnchor = slug;
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
/**
|
|
1441
|
+
* The element inside `root` that an in-document link (`#installation`, `#Installation`, `#my%20title`)
|
|
1442
|
+
* points at, or null. An empty fragment (`#`) means the top of the document. Headings are matched by
|
|
1443
|
+
* slug, then a heading the author wrote an explicit `<a id="...">`/`id` for, looked up INSIDE `root`
|
|
1444
|
+
* only (never `document.getElementById`, which could find an app element of the same name).
|
|
1445
|
+
*/
|
|
1446
|
+
function findMarkdownAnchorTarget(root, href) {
|
|
1447
|
+
let fragment = String(href ?? '').replace(/^#/, '');
|
|
1448
|
+
try {
|
|
1449
|
+
fragment = decodeURIComponent(fragment);
|
|
1450
|
+
} catch {
|
|
1451
|
+
/* a malformed escape: use it as written */
|
|
1452
|
+
}
|
|
1453
|
+
if (!fragment) return root;
|
|
1454
|
+
assignMarkdownHeadingAnchors(root);
|
|
1455
|
+
const wanted = [fragment.toLowerCase(), markdownHeadingSlug(fragment)];
|
|
1456
|
+
for (const heading of root.querySelectorAll('[data-md-anchor]')) {
|
|
1457
|
+
if (wanted.includes(heading.dataset.mdAnchor)) return heading;
|
|
1458
|
+
}
|
|
1459
|
+
for (const el of root.querySelectorAll('[id]')) {
|
|
1460
|
+
if (el.id === fragment) return el;
|
|
1461
|
+
}
|
|
1462
|
+
return null;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1301
1465
|
if (typeof window !== 'undefined') {
|
|
1302
1466
|
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
|
|
1303
1467
|
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
|
|
@@ -1337,6 +1501,9 @@ if (typeof window !== 'undefined') {
|
|
|
1337
1501
|
compute: computeSseStale,
|
|
1338
1502
|
TIMEOUT_MS: SSE_STALE_TIMEOUT_MS,
|
|
1339
1503
|
};
|
|
1504
|
+
window.CodemanPromptCache = {
|
|
1505
|
+
format: formatPromptCacheStatus,
|
|
1506
|
+
};
|
|
1340
1507
|
window.CodemanSessionOrder = {
|
|
1341
1508
|
RANK: SESSION_ACTIVITY_RANK,
|
|
1342
1509
|
anchor: sessionActivityAnchor,
|
|
@@ -1355,6 +1522,10 @@ if (typeof window !== 'undefined') {
|
|
|
1355
1522
|
groupFor: tabTriageGroupFor,
|
|
1356
1523
|
layout: computeTabTriageLayout,
|
|
1357
1524
|
};
|
|
1525
|
+
window.CodemanTabSearch = {
|
|
1526
|
+
needle: tabSearchNeedle,
|
|
1527
|
+
filter: filterTabSearchRows,
|
|
1528
|
+
};
|
|
1358
1529
|
window.CodemanInputLimit = {
|
|
1359
1530
|
FRAME_MAX_CHARS: INPUT_FRAME_MAX_CHARS,
|
|
1360
1531
|
PASTE_MAX_CHARS: INPUT_PASTE_MAX_CHARS,
|
|
@@ -1367,6 +1538,11 @@ if (typeof window !== 'undefined') {
|
|
|
1367
1538
|
window.CodemanCopySelection = {
|
|
1368
1539
|
clean: cleanCopiedSelection,
|
|
1369
1540
|
};
|
|
1541
|
+
window.CodemanMarkdownAnchors = {
|
|
1542
|
+
slug: markdownHeadingSlug,
|
|
1543
|
+
assign: assignMarkdownHeadingAnchors,
|
|
1544
|
+
find: findMarkdownAnchorTarget,
|
|
1545
|
+
};
|
|
1370
1546
|
window.CodemanTerminalFont = {
|
|
1371
1547
|
DEFAULT_STACK: TERMINAL_FONT_DEFAULT_STACK,
|
|
1372
1548
|
resolve: resolveTerminalFontFamily,
|
|
@@ -1388,6 +1564,15 @@ function scheduleBackground(fn) {
|
|
|
1388
1564
|
// but server-sent terminal buffers may still contain markers from Claude CLI.
|
|
1389
1565
|
const DEC_SYNC_STRIP_RE = /\x1b\[\?2026[hl]/g;
|
|
1390
1566
|
|
|
1567
|
+
// OSC 52 clipboard writes stripping — a replay re-feeds the raw byte history, and
|
|
1568
|
+
// xterm parses it after chunkedTerminalWrite has already released the load
|
|
1569
|
+
// gate, so an OSC 52 left in would re-copy old text on every tab switch. The
|
|
1570
|
+
// 8-bit C1 forms (OSC \x9d, ST \x9c) too: xterm.js still parses those.
|
|
1571
|
+
const OSC52_STRIP_RE = /(?:\x1b\]|\x9d)52;[^\x07\x1b\x9c]*(?:\x07|\x1b\\|\x9c)/g;
|
|
1572
|
+
// The same OSC 52 text arriving again within this window is one copy delivered
|
|
1573
|
+
// several ways (direct, tmux load-buffer -w, tmux set-clipboard forwarding).
|
|
1574
|
+
const OSC52_DEDUPE_MS = 1000;
|
|
1575
|
+
|
|
1391
1576
|
// Built-in respawn configuration presets
|
|
1392
1577
|
const BUILTIN_RESPAWN_PRESETS = [
|
|
1393
1578
|
{
|
|
@@ -1513,6 +1698,7 @@ const SSE_EVENTS = {
|
|
|
1513
1698
|
SESSION_INTERACTIVE: 'session:interactive',
|
|
1514
1699
|
SESSION_RUNNING: 'session:running',
|
|
1515
1700
|
SESSION_STATUS_TELEMETRY: 'session:statusTelemetry',
|
|
1701
|
+
SESSION_PROMPT_CACHE: 'session:promptCache',
|
|
1516
1702
|
|
|
1517
1703
|
// Scheduled runs
|
|
1518
1704
|
SCHEDULED_CREATED: 'scheduled:created',
|
|
@@ -1741,7 +1927,10 @@ function escapeHtml(text) {
|
|
|
1741
1927
|
function formatHistoryBytes(bytes) {
|
|
1742
1928
|
const n = typeof bytes === 'number' && isFinite(bytes) && bytes > 0 ? bytes : 0;
|
|
1743
1929
|
if (n < 1024) return 'less than 1 KB';
|
|
1744
|
-
|
|
1930
|
+
// Switch on the ROUNDED value: a 1 MiB tail cut back to a line boundary is
|
|
1931
|
+
// just under 1 MiB and used to print as "1024 KB".
|
|
1932
|
+
const kb = Math.round(n / 1024);
|
|
1933
|
+
if (kb < 1024) return `${kb} KB`;
|
|
1745
1934
|
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
1746
1935
|
}
|
|
1747
1936
|
|
|
@@ -1755,12 +1944,27 @@ function formatHistoryBytes(bytes) {
|
|
|
1755
1944
|
* - atCeiling: the FULL capture itself hit the byte ceiling
|
|
1756
1945
|
* - exhausted: a full pull was refused as a downgrade, so this is all there is
|
|
1757
1946
|
*
|
|
1947
|
+
* ⚠️ `truncated` measures the server's BYTE stream, not history a pull can
|
|
1948
|
+
* return. `paneHistoryLines` (tmux `#{history_size}`) is the latter: 0 means the
|
|
1949
|
+
* pane keeps no scrollback at all (a fullscreen CLI in the alternate screen,
|
|
1950
|
+
* whose transcript lives in the CLI and is scrolled there), so the bytes a tail
|
|
1951
|
+
* cut dropped are old repaint frames that `full=1` cannot bring back. Measured
|
|
1952
|
+
* on live fullscreen claude panes: "4.8 MB more" was ~33 copies of one frame,
|
|
1953
|
+
* and the button only ever ended in the downgrade refusal. Nothing to offer,
|
|
1954
|
+
* nothing to say. Absent means unknown and keeps the byte-based behaviour.
|
|
1955
|
+
*
|
|
1758
1956
|
* @param {{truncated?: boolean, reason?: string|null, source?: string|null,
|
|
1759
|
-
* fullSize?: number, retainedBytes?: number, exhausted?: boolean
|
|
1957
|
+
* fullSize?: number, retainedBytes?: number, exhausted?: boolean,
|
|
1958
|
+
* paneHistoryLines?: number|null, historyLinesCut?: boolean}} state
|
|
1760
1959
|
* @returns {{visible: boolean, message: string, canLoadMore: boolean}}
|
|
1761
1960
|
*/
|
|
1762
1961
|
function computeHistoryTruncationNotice(state = {}) {
|
|
1763
1962
|
if (!state.truncated) return { visible: false, message: '', canLoadMore: false };
|
|
1963
|
+
const historyLines =
|
|
1964
|
+
typeof state.paneHistoryLines === 'number' && Number.isFinite(state.paneHistoryLines)
|
|
1965
|
+
? Math.max(0, state.paneHistoryLines)
|
|
1966
|
+
: null;
|
|
1967
|
+
if (historyLines === 0) return { visible: false, message: '', canLoadMore: false };
|
|
1764
1968
|
|
|
1765
1969
|
const retained = Math.max(0, state.retainedBytes || 0);
|
|
1766
1970
|
const dropped = Math.max(0, (state.fullSize || 0) - retained);
|
|
@@ -1783,9 +1987,22 @@ function computeHistoryTruncationNotice(state = {}) {
|
|
|
1783
1987
|
canLoadMore: false,
|
|
1784
1988
|
};
|
|
1785
1989
|
}
|
|
1990
|
+
// Name what a pull can actually return when the server said: the byte gap
|
|
1991
|
+
// counts repaints and redraw bloat, and overstates it many times over.
|
|
1992
|
+
// Without that count, a `lines=`-bounded capture is cut by tmux before any
|
|
1993
|
+
// bytes are counted, so its fullSize is the window itself and the remainder's
|
|
1994
|
+
// size is unknown: say that more exists without inventing an amount. That
|
|
1995
|
+
// holds even when `tail` also cut bytes off the window (`dropped`), since tmux
|
|
1996
|
+
// still holds more beyond it (`historyLinesCut`, set by the server).
|
|
1997
|
+
const remainder =
|
|
1998
|
+
dropped > 0 && !state.historyLinesCut ? `${formatHistoryBytes(dropped)} more` : 'Earlier output';
|
|
1999
|
+
const more =
|
|
2000
|
+
historyLines !== null
|
|
2001
|
+
? `${historyLines.toLocaleString('en-US')} ${historyLines === 1 ? 'line of scrollback is' : 'lines of scrollback are'} retained.`
|
|
2002
|
+
: `${remainder} may still be retained.`;
|
|
1786
2003
|
return {
|
|
1787
2004
|
visible: true,
|
|
1788
|
-
message: `Showing the most recent ${shown} of this session. ${
|
|
2005
|
+
message: `Showing the most recent ${shown} of this session. ${more}`,
|
|
1789
2006
|
canLoadMore: true,
|
|
1790
2007
|
};
|
|
1791
2008
|
}
|
|
@@ -1830,7 +2047,7 @@ function computeRewriteScrollLine(input) {
|
|
|
1830
2047
|
* a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
|
|
1831
2048
|
*/
|
|
1832
2049
|
const FILE_PATH_LINK_PATTERN =
|
|
1833
|
-
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html
|
|
2050
|
+
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|markdown|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|m4v|ogv|mp3|wav))\b/g;
|
|
1834
2051
|
|
|
1835
2052
|
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
|
|
1836
2053
|
function absoluteFilePathPattern() {
|
|
@@ -1842,13 +2059,18 @@ function absoluteFilePathPattern() {
|
|
|
1842
2059
|
* points at goes to the tail/log viewer, which is the right home for a growing
|
|
1843
2060
|
* text file and the wrong one for bytes (tailing a PNG shows binary noise).
|
|
1844
2061
|
*
|
|
2062
|
+
* Markdown is here too (owner decision 2026-10-13): an agent's `.md` is a
|
|
2063
|
+
* document to read, so the preview renders it (its MD pill gives the source)
|
|
2064
|
+
* instead of the log viewer streaming it as raw text. Other text (.log, .txt,
|
|
2065
|
+
* .json, code) keeps the log viewer, which follows a file still being written.
|
|
2066
|
+
*
|
|
1845
2067
|
* The media entries mirror VIDEO_ATTACHMENT_EXTENSIONS/AUDIO_ATTACHMENT_EXTENSIONS
|
|
1846
2068
|
* (src/attachment-registry.ts, the single source) — they diverged once and an
|
|
1847
2069
|
* in-workspace `.m4a` opened as binary noise in the log viewer while the same
|
|
1848
2070
|
* file in /tmp played fine. test/media-extension-parity.test.ts pins the sync.
|
|
1849
2071
|
*/
|
|
1850
2072
|
const FILE_PREVIEW_EXTENSIONS = new Set(
|
|
1851
|
-
('png jpg jpeg gif webp avif bmp ico svg pdf docx pptx xlsx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus').split(' ')
|
|
2073
|
+
('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 md markdown').split(' ')
|
|
1852
2074
|
);
|
|
1853
2075
|
|
|
1854
2076
|
/** Whether a path's extension is one {@link FILE_PREVIEW_EXTENSIONS} covers. */
|
|
@@ -1857,6 +2079,155 @@ function previewsInFileViewer(filePath) {
|
|
|
1857
2079
|
return FILE_PREVIEW_EXTENSIONS.has(ext);
|
|
1858
2080
|
}
|
|
1859
2081
|
|
|
2082
|
+
/**
|
|
2083
|
+
* Home-relative and relative file paths: `~/repos/x/garage.png` (how Claude Code
|
|
2084
|
+
* echoes an attached image), `builds/captures/shot.png`, `./a.md`, `../b.json`,
|
|
2085
|
+
* or a bare `look_montage.png`. Same extension list and body as
|
|
2086
|
+
* {@link FILE_PATH_LINK_PATTERN} (test/link-provider-regex.test.ts pins the two
|
|
2087
|
+
* equal).
|
|
2088
|
+
*
|
|
2089
|
+
* Without a known root to anchor on, the START is what has to be unambiguous:
|
|
2090
|
+
* group 1 is the boundary in front of the path (line start, whitespace, a quote,
|
|
2091
|
+
* an opening bracket, `=`, `,`, `>`), so a match can never begin in the middle of
|
|
2092
|
+
* a word, of an absolute path (`/nix/store/x.png` is not `nix/store/x.png`) or of
|
|
2093
|
+
* a URL. The path itself is group 2. Its first character is never `/`: absolute
|
|
2094
|
+
* paths belong to the rooted pattern, which deliberately leaves `/etc` out. A
|
|
2095
|
+
* token containing `://` is a URL, never a path. Brackets, `=` and `,` end a
|
|
2096
|
+
* relative path (unlike a rooted one): `(garage.png)` and `OUT=shots/a.png` link
|
|
2097
|
+
* the file, not the punctuation around it.
|
|
2098
|
+
*
|
|
2099
|
+
* ⚠ Like its sibling, never share an instance (`lastIndex`): use
|
|
2100
|
+
* {@link findFilePathLinks}.
|
|
2101
|
+
*/
|
|
2102
|
+
const RELATIVE_FILE_PATH_LINK_PATTERN =
|
|
2103
|
+
/(^|[\s"'`(\[{<>=,])((?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)(?:~\/|[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*\.(?:log|txt|json|markdown|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|m4v|ogv|mp3|wav))\b/g;
|
|
2104
|
+
|
|
2105
|
+
/** The `(?:log|txt|…)` extension group, read off the rooted pattern so it cannot drift. */
|
|
2106
|
+
const FILE_LINK_EXTENSION_GROUP = /\\\.(\(\?:[^()]*\))\)\\b$/.exec(FILE_PATH_LINK_PATTERN.source)[1];
|
|
2107
|
+
|
|
2108
|
+
/**
|
|
2109
|
+
* Every linkable file path in `text`, in order: absolute (rooted) paths first,
|
|
2110
|
+
* then `~/` and relative ones, then folder paths — each only where it overlaps
|
|
2111
|
+
* neither an earlier one nor a URL.
|
|
2112
|
+
*
|
|
2113
|
+
* The one entry point for both consumers (terminal link provider, response
|
|
2114
|
+
* viewer), so a path form is clickable in both or in neither.
|
|
2115
|
+
*
|
|
2116
|
+
* @param {string} text
|
|
2117
|
+
* @returns {Array<{path: string, index: number, folder?: true}>} `index` is where `path` starts in
|
|
2118
|
+
* `text`; `folder` marks a folder path (folder grid) rather than a file.
|
|
2119
|
+
*/
|
|
2120
|
+
function findFilePathLinks(text) {
|
|
2121
|
+
const value = String(text || '');
|
|
2122
|
+
const found = [];
|
|
2123
|
+
const overlaps = (index, length) => found.some((f) => index < f.index + f.path.length && f.index < index + length);
|
|
2124
|
+
// Web URLs are never cut into a path: `https://x.io/a/home/b.png` and
|
|
2125
|
+
// `?img=b.png` belong to the URL link, not to a second, overlapping file link.
|
|
2126
|
+
// (`file:///home/x.png` is not one of them: its path is the file.)
|
|
2127
|
+
const urls = [];
|
|
2128
|
+
const urlPattern = /\bhttps?:\/\/\S+/gi;
|
|
2129
|
+
let match;
|
|
2130
|
+
while ((match = urlPattern.exec(value)) !== null) urls.push({ index: match.index, path: match[0] });
|
|
2131
|
+
const inUrl = (index, length) => urls.some((u) => index < u.index + u.path.length && u.index < index + length);
|
|
2132
|
+
|
|
2133
|
+
const absolute = absoluteFilePathPattern();
|
|
2134
|
+
while ((match = absolute.exec(value)) !== null) {
|
|
2135
|
+
if (!inUrl(match.index, match[1].length)) found.push({ path: match[1], index: match.index });
|
|
2136
|
+
}
|
|
2137
|
+
const relative = new RegExp(RELATIVE_FILE_PATH_LINK_PATTERN.source, 'g');
|
|
2138
|
+
while ((match = relative.exec(value)) !== null) {
|
|
2139
|
+
const path = match[2];
|
|
2140
|
+
const index = match.index + match[1].length;
|
|
2141
|
+
if (overlaps(index, path.length) || inUrl(index, path.length)) continue;
|
|
2142
|
+
found.push({ path, index });
|
|
2143
|
+
}
|
|
2144
|
+
const folders = new RegExp(FOLDER_PATH_LINK_PATTERN.source, 'g');
|
|
2145
|
+
while ((match = folders.exec(value)) !== null) {
|
|
2146
|
+
const path = folderLinkPath(match[2]);
|
|
2147
|
+
const index = match.index + match[1].length;
|
|
2148
|
+
if (!path || overlaps(index, path.length) || inUrl(index, path.length)) continue;
|
|
2149
|
+
found.push({ path, index, folder: true });
|
|
2150
|
+
}
|
|
2151
|
+
return found.sort((a, b) => a.index - b.index);
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
/**
|
|
2155
|
+
* Folder paths, for the folder grid: rooted like {@link FILE_PATH_LINK_PATTERN}
|
|
2156
|
+
* (`/mnt/raid1/neon_getaway/styleframes`), `~/renders`, or relative with a
|
|
2157
|
+
* trailing slash (`builds/captures/`). Same boundary in front as the relative
|
|
2158
|
+
* file pattern (group 1), the candidate itself is group 2 and goes through
|
|
2159
|
+
* {@link folderLinkPath}, which decides whether it reads as a folder at all.
|
|
2160
|
+
* File links are found first and win any overlap. A folder path ends at `:`,
|
|
2161
|
+
* which is also a boundary in front: `PATH=/a/bin:/b/bin` is two candidates, and
|
|
2162
|
+
* the `://` lookahead stops at the next colon instead of rescanning the token from
|
|
2163
|
+
* every one (quadratic on `a:a:a:…//`).
|
|
2164
|
+
*
|
|
2165
|
+
* ⚠ Like its siblings, never share an instance (`lastIndex`): use
|
|
2166
|
+
* {@link findFilePathLinks}.
|
|
2167
|
+
*/
|
|
2168
|
+
const FOLDER_PATH_LINK_PATTERN =
|
|
2169
|
+
/(^|[\s"'`(\[{<>=,:])((?:\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/|~\/|(?![^\s"'`<>|;&()[\]{}=,:\n\x00-\x1f]*:\/\/)[^\s"'`<>|;&()[\]{}=,:\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,:\n\x00-\x1f]*)/g;
|
|
2170
|
+
|
|
2171
|
+
/**
|
|
2172
|
+
* A {@link FOLDER_PATH_LINK_PATTERN} candidate as a folder link, or null.
|
|
2173
|
+
*
|
|
2174
|
+
* Sentence punctuation after the path is not part of it. A path ending in `/` is
|
|
2175
|
+
* a folder; otherwise only a rooted or `~/` path whose last segment has no
|
|
2176
|
+
* extension (`.cache` and `styleframes` do, `a.rs` does not) — a relative word
|
|
2177
|
+
* without a slash at the end is prose, not a folder.
|
|
2178
|
+
*/
|
|
2179
|
+
function folderLinkPath(candidate) {
|
|
2180
|
+
// `$` and `#` too: a shell prompt (`user@host:~/proj$ `, `root@c:/app# `) ends
|
|
2181
|
+
// its cwd with one. A folder whose name really ends in one links one short.
|
|
2182
|
+
const path = String(candidate || '').replace(/[.,:;!?'"$#]+$/, '');
|
|
2183
|
+
if (!path || path === '~/' || /^\/[^/]+\/$/.test(path)) return null;
|
|
2184
|
+
if (path.endsWith('/')) return /[^/.]/.test(path) ? path : null;
|
|
2185
|
+
if (!path.startsWith('/') && !path.startsWith('~/')) return null;
|
|
2186
|
+
const last = path.slice(path.lastIndexOf('/') + 1);
|
|
2187
|
+
return /^\.?[^.]+$/.test(last) ? path : null;
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
/**
|
|
2191
|
+
* Where a clicked path points, as the preview routes need it.
|
|
2192
|
+
*
|
|
2193
|
+
* Absolute and `~/` paths pass through unchanged — `~` is the home of the
|
|
2194
|
+
* session's HOST, which only the server knows (a remote case's home is on the
|
|
2195
|
+
* remote machine), so the server expands it. A relative path is resolved
|
|
2196
|
+
* against the session's working directory, `.` and `..` collapsed, so a
|
|
2197
|
+
* `../other/x.png` ends up outside the workspace and routes like any other
|
|
2198
|
+
* external path. Without a working directory it is returned as is.
|
|
2199
|
+
*
|
|
2200
|
+
* @param {string} filePath
|
|
2201
|
+
* @param {string} [workingDir]
|
|
2202
|
+
* @returns {string}
|
|
2203
|
+
*/
|
|
2204
|
+
function resolveLinkedFilePath(filePath, workingDir) {
|
|
2205
|
+
const path = String(filePath || '');
|
|
2206
|
+
if (!path || path.startsWith('/') || path.startsWith('~/') || !workingDir) return path;
|
|
2207
|
+
const parts = [];
|
|
2208
|
+
for (const part of `${workingDir}/${path}`.split('/')) {
|
|
2209
|
+
if (!part || part === '.') continue;
|
|
2210
|
+
if (part === '..') parts.pop();
|
|
2211
|
+
else parts.push(part);
|
|
2212
|
+
}
|
|
2213
|
+
return `/${parts.join('/')}`;
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
/**
|
|
2217
|
+
* Extensions the file preview renders as a PAGE, from the sandboxed
|
|
2218
|
+
* `/html-view/<cap>/` route (src/web/routes/html-view-routes.ts, whose
|
|
2219
|
+
* HTML_VIEW_EXTENSIONS this mirrors; test/file-preview-html-page.test.ts pins
|
|
2220
|
+
* the two equal). The terminal sends a click on one to the preview, never to
|
|
2221
|
+
* the log viewer, and the preview renders it with a Page pill back to source.
|
|
2222
|
+
*/
|
|
2223
|
+
const HTML_PAGE_EXTENSIONS = new Set(['html', 'htm']);
|
|
2224
|
+
|
|
2225
|
+
/** Whether a path names a file the preview renders as a page ({@link HTML_PAGE_EXTENSIONS}). */
|
|
2226
|
+
function rendersAsPage(filePath) {
|
|
2227
|
+
const ext = String(filePath || '').split('.').pop().toLowerCase();
|
|
2228
|
+
return HTML_PAGE_EXTENSIONS.has(ext);
|
|
2229
|
+
}
|
|
2230
|
+
|
|
1860
2231
|
|
|
1861
2232
|
/**
|
|
1862
2233
|
* The LOGICAL line a terminal row belongs to — the rows it spans, its text as one
|
|
@@ -1961,6 +2332,134 @@ function terminalLogicalLine(buffer, row, cols, maxRows) {
|
|
|
1961
2332
|
return { startRow, endRow, text, offsetToCell, cellToOffset };
|
|
1962
2333
|
}
|
|
1963
2334
|
|
|
2335
|
+
/** Roots of {@link FILE_PATH_LINK_PATTERN}, read off its source (`home|Users|…`). */
|
|
2336
|
+
const FILE_LINK_ROOT_GROUP = /^\(\\\/\(\?:([^()]*)\)/.exec(FILE_PATH_LINK_PATTERN.source)[1];
|
|
2337
|
+
const PATH_TOKEN_ENDS_WITH_EXTENSION = new RegExp(`\\.${FILE_LINK_EXTENSION_GROUP}$`);
|
|
2338
|
+
const PATH_TOKEN_HAS_EXTENSION = new RegExp(`\\.${FILE_LINK_EXTENSION_GROUP}\\b`);
|
|
2339
|
+
const PATH_TOKEN_STARTS_NEW_PATH = new RegExp(`^(?:~\\/|\\/(?:${FILE_LINK_ROOT_GROUP})\\/)`);
|
|
2340
|
+
/** A relative head that names its own location (`./a`, `../a`) is an entry of its own. */
|
|
2341
|
+
const PATH_TOKEN_STARTS_EXPLICIT_RELATIVE = /^\.{1,2}\//;
|
|
2342
|
+
/** First directory of a path, a leading `/` or `./` ignored: `src` for `src/a.ts` and `/src/a.ts`. */
|
|
2343
|
+
const pathFirstSegment = (path) => path.replace(/^(?:\.?\/)+/, '').split('/')[0];
|
|
2344
|
+
|
|
2345
|
+
/**
|
|
2346
|
+
* Whether the line `nextText` carries on a file path `prevText` broke off.
|
|
2347
|
+
*
|
|
2348
|
+
* Answers `'join'` when the path ends (reaches a known extension) on the next
|
|
2349
|
+
* line, `'chain'` when the next line is nothing but more of the path (a path
|
|
2350
|
+
* spanning three or more lines; only worth keeping once a later line ends it),
|
|
2351
|
+
* and `null` otherwise. The broken-off token must already look like a path in
|
|
2352
|
+
* progress: it contains a `/`, ends on a path character and does not yet end
|
|
2353
|
+
* on an extension. A next line that opens a NEW path is never glued on: a rooted
|
|
2354
|
+
* one (`/home/…`, `~/…`), `./…`/`../…`, or a relative one that opens on the same
|
|
2355
|
+
* top directory the broken token did (the next entry of a listing).
|
|
2356
|
+
*/
|
|
2357
|
+
function pathContinuation(prevText, nextText) {
|
|
2358
|
+
const tail = /[^\s"'<>|;&]+$/.exec(String(prevText || '').replace(/\s+$/, ''))?.[0] || '';
|
|
2359
|
+
if (!tail.includes('/') || !/[\w\-.~+@%/]$/.test(tail) || PATH_TOKEN_ENDS_WITH_EXTENSION.test(tail)) return null;
|
|
2360
|
+
const next = String(nextText || '').replace(/^\s+/, '');
|
|
2361
|
+
const head = /^[^\s"'<>|;&]+/.exec(next)?.[0] || '';
|
|
2362
|
+
if (!head || PATH_TOKEN_STARTS_NEW_PATH.test(head) || PATH_TOKEN_STARTS_EXPLICIT_RELATIVE.test(head)) return null;
|
|
2363
|
+
// A listing (`git ls-files`, `find`, `ls -R`) puts one path per row, so `src/components`
|
|
2364
|
+
// followed by `src/index.ts` is two entries, not one path broken after `src/components`.
|
|
2365
|
+
// A wrapped path continues INSIDE a name: its next row does not open on the same top directory.
|
|
2366
|
+
if (head.includes('/') && pathFirstSegment(head) === pathFirstSegment(tail)) return null;
|
|
2367
|
+
if (PATH_TOKEN_HAS_EXTENSION.test(head)) return 'join';
|
|
2368
|
+
return head === next.replace(/\s+$/, '') ? 'chain' : null;
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
/**
|
|
2372
|
+
* {@link terminalLogicalLine}, extended across the line breaks a program puts
|
|
2373
|
+
* INSIDE a file path. Used by the link provider only; touch selection keeps the
|
|
2374
|
+
* plain logical line.
|
|
2375
|
+
*
|
|
2376
|
+
* Claude Code wraps its tool output itself, a few columns short of the terminal
|
|
2377
|
+
* edge, so neither continuation signal of terminalLogicalLine fires (no
|
|
2378
|
+
* `isWrapped`, the row does not reach the last column) and a long path was only
|
|
2379
|
+
* linked up to the break:
|
|
2380
|
+
*
|
|
2381
|
+
* ⎿ BILD /mnt/build/neon_getaway-mgr-hebel/builds/captures/2
|
|
2382
|
+
* 026-10-10/ghost1b_gameplay.png err=0 …
|
|
2383
|
+
*
|
|
2384
|
+
* A following logical line is glued on (its indent dropped, cell mapping kept
|
|
2385
|
+
* exact) when {@link pathContinuation} says it carries the path on and the row it
|
|
2386
|
+
* broke off is at least as long as the row it continues on. Bounded by the same
|
|
2387
|
+
* `maxRows` as the logical line.
|
|
2388
|
+
*
|
|
2389
|
+
* @returns Same shape as {@link terminalLogicalLine}; null when the row does not exist.
|
|
2390
|
+
*/
|
|
2391
|
+
function terminalPathLine(buffer, row, cols, maxRows) {
|
|
2392
|
+
const base = terminalLogicalLine(buffer, row, cols, maxRows);
|
|
2393
|
+
if (!base) return null;
|
|
2394
|
+
const bound = Math.max(1, maxRows || 12);
|
|
2395
|
+
const lineAt = (r) => terminalLogicalLine(buffer, r, cols, bound);
|
|
2396
|
+
const rowsOf = (line) => line.endRow - line.startRow + 1;
|
|
2397
|
+
// A program breaking a long token fills the row to its wrap width, and no
|
|
2398
|
+
// later row of that block is wider. So the row a path broke off is at least as
|
|
2399
|
+
// long as the row it continues on — which keeps `cd /mnt/foo` from being glued
|
|
2400
|
+
// to a longer ` file.png written` below it.
|
|
2401
|
+
const rowLength = (r) => (buffer.getLine(r)?.translateToString(true) || '').length;
|
|
2402
|
+
const continues = (prev, next, prevText) =>
|
|
2403
|
+
rowLength(prev.endRow) >= rowLength(next.startRow) && pathContinuation(prevText, next.text);
|
|
2404
|
+
|
|
2405
|
+
/** `first` and the lines a path carries on into, stopping at the last confirmed join. */
|
|
2406
|
+
const extend = (first) => {
|
|
2407
|
+
const parts = [first];
|
|
2408
|
+
let text = first.text;
|
|
2409
|
+
let rows = rowsOf(first);
|
|
2410
|
+
let confirmed = 1;
|
|
2411
|
+
for (;;) {
|
|
2412
|
+
const next = lineAt(parts[parts.length - 1].endRow + 1);
|
|
2413
|
+
if (!next || rows + rowsOf(next) > bound) break;
|
|
2414
|
+
const kind = continues(parts[parts.length - 1], next, text);
|
|
2415
|
+
if (!kind) break;
|
|
2416
|
+
parts.push(next);
|
|
2417
|
+
text += next.text.replace(/^\s+/, '');
|
|
2418
|
+
rows += rowsOf(next);
|
|
2419
|
+
if (kind === 'join') confirmed = parts.length;
|
|
2420
|
+
}
|
|
2421
|
+
return parts.slice(0, confirmed);
|
|
2422
|
+
};
|
|
2423
|
+
|
|
2424
|
+
// The hovered row may be the second or third line of a broken path: walk back
|
|
2425
|
+
// to where it could start, then let the forward pass decide what really joins.
|
|
2426
|
+
let first = base;
|
|
2427
|
+
let rows = rowsOf(base);
|
|
2428
|
+
while (first.startRow > 0) {
|
|
2429
|
+
const prev = lineAt(first.startRow - 1);
|
|
2430
|
+
if (!prev || rows + rowsOf(prev) > bound || !continues(prev, first, prev.text)) break;
|
|
2431
|
+
first = prev;
|
|
2432
|
+
rows += rowsOf(prev);
|
|
2433
|
+
}
|
|
2434
|
+
let parts = extend(first);
|
|
2435
|
+
if (parts[parts.length - 1].endRow < base.startRow) parts = extend(base);
|
|
2436
|
+
if (parts.length === 1) return parts[0];
|
|
2437
|
+
|
|
2438
|
+
const segments = [];
|
|
2439
|
+
let text = '';
|
|
2440
|
+
for (const [i, part] of parts.entries()) {
|
|
2441
|
+
const skip = i === 0 ? 0 : part.text.length - part.text.replace(/^\s+/, '').length;
|
|
2442
|
+
segments.push({ part, textStart: text.length, skip });
|
|
2443
|
+
text += part.text.slice(skip);
|
|
2444
|
+
}
|
|
2445
|
+
const offsetToCell = (offset) => {
|
|
2446
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
2447
|
+
const seg = segments[i];
|
|
2448
|
+
if (offset >= seg.textStart || i === 0) return seg.part.offsetToCell(offset - seg.textStart + seg.skip);
|
|
2449
|
+
}
|
|
2450
|
+
return segments[0].part.offsetToCell(offset);
|
|
2451
|
+
};
|
|
2452
|
+
const cellToOffset = (targetRow, targetCol) => {
|
|
2453
|
+
for (const seg of segments) {
|
|
2454
|
+
if (targetRow < seg.part.startRow || targetRow > seg.part.endRow) continue;
|
|
2455
|
+
const offset = seg.part.cellToOffset(targetRow, targetCol);
|
|
2456
|
+
return offset < 0 ? -1 : seg.textStart + Math.max(0, offset - seg.skip);
|
|
2457
|
+
}
|
|
2458
|
+
return -1;
|
|
2459
|
+
};
|
|
2460
|
+
return { startRow: parts[0].startRow, endRow: parts[parts.length - 1].endRow, text, offsetToCell, cellToOffset };
|
|
2461
|
+
}
|
|
2462
|
+
|
|
1964
2463
|
// ═══════════════════════════════════════════════════════════════
|
|
1965
2464
|
// Split-Pane Sessions — pure helpers (divider math, picker list)
|
|
1966
2465
|
// ═══════════════════════════════════════════════════════════════
|
|
@@ -2083,11 +2582,11 @@ function tileGridCapacity({ width, height }) {
|
|
|
2083
2582
|
}
|
|
2084
2583
|
|
|
2085
2584
|
/**
|
|
2086
|
-
* The stored grid (`codeman:tile-grid
|
|
2087
|
-
*
|
|
2088
|
-
* TILE_GRID_MAX, `focused` / `zoomed` must
|
|
2089
|
-
* must be 1 to 3 finite positive numbers.
|
|
2090
|
-
* (or its JSON) gives null.
|
|
2585
|
+
* The stored grid (`codeman:tile-grid`: session ids and the layout, never
|
|
2586
|
+
* content) made safe to apply: unknown, deleted, detached and duplicate ids
|
|
2587
|
+
* are dropped, the list is capped at TILE_GRID_MAX, `focused` / `zoomed` must
|
|
2588
|
+
* name a kept id, and track fractions must be 1 to 3 finite positive numbers.
|
|
2589
|
+
* Anything that is not a v1 object (or its JSON) gives null.
|
|
2091
2590
|
*
|
|
2092
2591
|
* The stored `ids` are the grid's CELLS in reading order, `null` for an empty
|
|
2093
2592
|
* one (a hole can be any cell). The old packed list (no nulls) reads as cells
|
|
@@ -2095,11 +2594,20 @@ function tileGridCapacity({ width, height }) {
|
|
|
2095
2594
|
* every list consumer wants) and `cells` keeps the holes: a dropped id (gone,
|
|
2096
2595
|
* detached, a duplicate, past the cap) becomes `null` there, never a shift.
|
|
2097
2596
|
*
|
|
2597
|
+
* `freed` names the cells whose session no longer exists (or was popped out
|
|
2598
|
+
* to its own window) since the grid was stored: the ranking fills those first
|
|
2599
|
+
* when the grid comes back (restoreTileGridCells). A hole the user left empty
|
|
2600
|
+
* is not freed. `count` is how many tiles the grid had after the user's own
|
|
2601
|
+
* last change (a session that went away by itself does not lower it), so the
|
|
2602
|
+
* grid comes back to that many when there are sessions to fill it; a value
|
|
2603
|
+
* stored before it existed, or a malformed one, reads as the number of
|
|
2604
|
+
* sessions the stored cells name.
|
|
2605
|
+
*
|
|
2098
2606
|
* @param {unknown} raw - the parsed value, or the stored JSON string
|
|
2099
2607
|
* @param {{has(id: string): boolean}|Iterable<string>} liveSessions - ids that exist now
|
|
2100
2608
|
* @param {{has(id: string): boolean}} [detachedIds] - sessions popped out to their own window
|
|
2101
|
-
* @returns {{v: 1, open: boolean, ids: string[], cells: (string|null)[],
|
|
2102
|
-
* zoomed: string|null, colFr: number[]|null, rowFr: number[]|null}|null}
|
|
2609
|
+
* @returns {{v: 1, open: boolean, ids: string[], cells: (string|null)[], freed: number[], count: number,
|
|
2610
|
+
* focused: string|null, zoomed: string|null, colFr: number[]|null, rowFr: number[]|null}|null}
|
|
2103
2611
|
*/
|
|
2104
2612
|
function sanitizeTileGridState(raw, liveSessions, detachedIds) {
|
|
2105
2613
|
let value = raw;
|
|
@@ -2110,11 +2618,16 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) {
|
|
|
2110
2618
|
const live = liveSessions && typeof liveSessions.has === 'function' ? liveSessions : new Set(liveSessions || []);
|
|
2111
2619
|
const ids = [];
|
|
2112
2620
|
const cells = [];
|
|
2621
|
+
const freed = [];
|
|
2622
|
+
const named = [];
|
|
2113
2623
|
for (const id of (Array.isArray(value.ids) ? value.ids : []).slice(0, TILE_LAYOUT_MAX)) {
|
|
2114
|
-
const
|
|
2115
|
-
|
|
2116
|
-
|
|
2624
|
+
const isId = typeof id === 'string' && id !== '';
|
|
2625
|
+
const present = isId && live.has(id) && !detachedIds?.has?.(id);
|
|
2626
|
+
const keep = present && !ids.includes(id) && ids.length < TILE_GRID_MAX;
|
|
2117
2627
|
if (keep) ids.push(id);
|
|
2628
|
+
// Its session went away since: the cell is freed for the ranking to fill.
|
|
2629
|
+
if (isId && !present && !named.includes(id)) freed.push(cells.length);
|
|
2630
|
+
if (isId && !named.includes(id)) named.push(id);
|
|
2118
2631
|
// A malformed entry (not a string, not null) is a hole too.
|
|
2119
2632
|
cells.push(keep ? id : null);
|
|
2120
2633
|
}
|
|
@@ -2122,11 +2635,17 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) {
|
|
|
2122
2635
|
if (!Array.isArray(fr) || fr.length < 1 || fr.length > 3) return null;
|
|
2123
2636
|
return fr.every((x) => typeof x === 'number' && Number.isFinite(x) && x > 0) ? fr.slice() : null;
|
|
2124
2637
|
};
|
|
2638
|
+
const count =
|
|
2639
|
+
Number.isInteger(value.count) && value.count >= 1 && value.count <= TILE_GRID_MAX
|
|
2640
|
+
? value.count
|
|
2641
|
+
: Math.min(named.length, TILE_GRID_MAX);
|
|
2125
2642
|
return {
|
|
2126
2643
|
v: 1,
|
|
2127
2644
|
open: value.open === true && ids.length > 0,
|
|
2128
2645
|
ids,
|
|
2129
2646
|
cells,
|
|
2647
|
+
freed,
|
|
2648
|
+
count,
|
|
2130
2649
|
focused: ids.includes(value.focused) ? value.focused : (ids[0] ?? null),
|
|
2131
2650
|
zoomed: ids.includes(value.zoomed) ? value.zoomed : null,
|
|
2132
2651
|
colFr: fractions(value.colFr),
|
|
@@ -2134,6 +2653,53 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) {
|
|
|
2134
2653
|
};
|
|
2135
2654
|
}
|
|
2136
2655
|
|
|
2656
|
+
/**
|
|
2657
|
+
* A stored grid as it comes back (the Tiles button, a page reload): its cells
|
|
2658
|
+
* exactly as stored, holes the user left included, and its focus (or the tile
|
|
2659
|
+
* it had zoomed). Only when it holds fewer tiles than its `count` (sessions
|
|
2660
|
+
* that went away since, or by themselves while it was open) does it fill, from
|
|
2661
|
+
* `ranked` (best first, never a session already in it): the freed cells first,
|
|
2662
|
+
* then the other empty cells, in reading order; more than the cells hold join
|
|
2663
|
+
* after them (the shape grows when the grid lays them out, reformTileCells).
|
|
2664
|
+
* A cell stays empty only when no other session is left to place. Never
|
|
2665
|
+
* trimmed to the window: a grid larger than the window fits shows its focused
|
|
2666
|
+
* tile alone until the window fits it again, and the arrangement stays.
|
|
2667
|
+
*
|
|
2668
|
+
* @param {{cells?: (string|null)[], ids?: string[], freed?: number[], count?: number,
|
|
2669
|
+
* focused?: string|null, zoomed?: string|null}|null} stored - sanitized (sanitizeTileGridState)
|
|
2670
|
+
* @param {string[]} ranked - the sessions that may fill a cell, best first (rankTileSessions)
|
|
2671
|
+
* @returns {{ids: string[], cells: (string|null)[], focusedId: string}|null} null when none of its sessions survive
|
|
2672
|
+
*/
|
|
2673
|
+
function restoreTileGridCells(stored, ranked) {
|
|
2674
|
+
const source = Array.isArray(stored?.cells) ? stored.cells : Array.isArray(stored?.ids) ? stored.ids : [];
|
|
2675
|
+
const cells = [];
|
|
2676
|
+
for (const id of source) cells.push(typeof id === 'string' && id && !cells.includes(id) ? id : null);
|
|
2677
|
+
const tiles = cells.filter(Boolean);
|
|
2678
|
+
if (tiles.length === 0) return null;
|
|
2679
|
+
const focusedId =
|
|
2680
|
+
[stored.zoomed, stored.focused].find((id) => typeof id === 'string' && tiles.includes(id)) ?? tiles[0];
|
|
2681
|
+
const target = Math.min(Math.max(tiles.length, Math.floor(Number(stored.count)) || 0), TILE_GRID_MAX);
|
|
2682
|
+
const fillers = [];
|
|
2683
|
+
for (const id of ranked || []) {
|
|
2684
|
+
if (typeof id === 'string' && id && !cells.includes(id) && !fillers.includes(id)) fillers.push(id);
|
|
2685
|
+
}
|
|
2686
|
+
const freed = new Set(Array.isArray(stored.freed) ? stored.freed : []);
|
|
2687
|
+
const empty = [];
|
|
2688
|
+
cells.forEach((id, k) => {
|
|
2689
|
+
if (id === null) empty.push(k);
|
|
2690
|
+
});
|
|
2691
|
+
// Freed cells first, each group in reading order.
|
|
2692
|
+
empty.sort((a, b) => Number(freed.has(b)) - Number(freed.has(a)) || a - b);
|
|
2693
|
+
let count = tiles.length;
|
|
2694
|
+
for (const k of empty) {
|
|
2695
|
+
if (count >= target || fillers.length === 0) break;
|
|
2696
|
+
cells[k] = fillers.shift();
|
|
2697
|
+
count++;
|
|
2698
|
+
}
|
|
2699
|
+
const extra = fillers.slice(0, Math.max(0, target - count));
|
|
2700
|
+
return { ids: [...cells.filter(Boolean), ...extra], cells, focusedId };
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2137
2703
|
/**
|
|
2138
2704
|
* New track fractions after a divider drag (grid-template `fr` values): the two
|
|
2139
2705
|
* tracks either side of divider `index` trade `deltaPx` of size, each kept at
|
|
@@ -2166,7 +2732,8 @@ function dragTrackFractions(fr, index, deltaPx, totalPx, minPx) {
|
|
|
2166
2732
|
|
|
2167
2733
|
/**
|
|
2168
2734
|
* The sessions the Tiles button can open (case c of tileGridOpenSet, and the
|
|
2169
|
-
* ones a count fills a grid with), in
|
|
2735
|
+
* ones a count fills a grid with), in the order given (tab order, or the
|
|
2736
|
+
* ranking): live ones only, never a session popped out to
|
|
2170
2737
|
* its own window (that window owns its PTY size). A session with no PTY
|
|
2171
2738
|
* attached IS offered: its tile shows the Attach overlay.
|
|
2172
2739
|
*
|
|
@@ -2180,44 +2747,121 @@ function buildTilePickerSessions(sessions, sessionOrder, detachedIds) {
|
|
|
2180
2747
|
for (const id of sessionOrder) {
|
|
2181
2748
|
if (detachedIds?.has?.(id)) continue;
|
|
2182
2749
|
const session = sessions.get(id);
|
|
2183
|
-
if (!session) continue;
|
|
2750
|
+
if (!session || result.some((r) => r.id === id)) continue;
|
|
2184
2751
|
result.push({ id, label: session.name || 'Session' });
|
|
2185
2752
|
}
|
|
2186
2753
|
return result;
|
|
2187
2754
|
}
|
|
2188
2755
|
|
|
2756
|
+
// Ranking groups (rankTileSessions): working first, then the sessions waiting
|
|
2757
|
+
// on the user, then everything else.
|
|
2758
|
+
const TILE_RANK_GROUP = { working: 0, needs: 1, waiting: 1 };
|
|
2759
|
+
|
|
2760
|
+
/**
|
|
2761
|
+
* The stamp a session is ranked by inside its group. A WORKING session keys
|
|
2762
|
+
* off the pane's last Enter (`lastSubmitAt`) ONLY: a working pane repaints
|
|
2763
|
+
* about once a second, so its last-activity stamp is always "now", and one
|
|
2764
|
+
* that never submitted would otherwise claim the head of the group. 0 means
|
|
2765
|
+
* unknown. Every other state is the home screens' anchor (sessionActivityAnchor:
|
|
2766
|
+
* the last byte the pane printed, i.e. when it went quiet).
|
|
2767
|
+
*/
|
|
2768
|
+
function tileRankStamp(row) {
|
|
2769
|
+
if (row.state === 'working') return Number(row.lastSubmitAt) || 0;
|
|
2770
|
+
return sessionActivityAnchor(row);
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
/**
|
|
2774
|
+
* Which open sessions the tile grid shows when nobody said which (the Tiles
|
|
2775
|
+
* button with no stored grid to bring back, and every place the grid fills a
|
|
2776
|
+
* tile on its own: a count picked in its menu, a freed cell), best first
|
|
2777
|
+
* (owner request: "prefer to load in tiles that are working and then the most
|
|
2778
|
+
* recent, so the oldest don't get opened"):
|
|
2779
|
+
* 1. WORKING, the most recently started turn first;
|
|
2780
|
+
* 2. then the ones that NEED INPUT, the red and yellow tab alerts (`needs`: a
|
|
2781
|
+
* permission or question dialog; `waiting`: a finished turn not seen
|
|
2782
|
+
* yet), most recent first;
|
|
2783
|
+
* 3. then every other one (idle, done, error), most recently active first.
|
|
2784
|
+
* Inside a group the newest stamp wins (tileRankStamp) and a session with no
|
|
2785
|
+
* stamp (0) sorts last; the final tiebreak is the tab order (`orderIndex`), so
|
|
2786
|
+
* the result never shuffles. The states are the home screens' own
|
|
2787
|
+
* (`_mobileOverviewState()`, mobile-overview.js), as are the stamps; only the
|
|
2788
|
+
* order differs: the home screens put the longest-running turn first, the grid
|
|
2789
|
+
* the most recent.
|
|
2790
|
+
*
|
|
2791
|
+
* Pure. Unit-tested in test/tile-grid-ranking.test.ts.
|
|
2792
|
+
*
|
|
2793
|
+
* @param {Array<{id: string, state: string, lastActivityAt?: number, lastSubmitAt?: number, orderIndex?: number}>} rows
|
|
2794
|
+
* @returns {string[]} the ids, best first, each once
|
|
2795
|
+
*/
|
|
2796
|
+
function rankTileSessions(rows) {
|
|
2797
|
+
const group = (row) => TILE_RANK_GROUP[row.state] ?? 2;
|
|
2798
|
+
const list = (Array.isArray(rows) ? rows : []).filter((row) => row && typeof row.id === 'string' && row.id);
|
|
2799
|
+
list.sort((a, b) => {
|
|
2800
|
+
const byGroup = group(a) - group(b);
|
|
2801
|
+
if (byGroup !== 0) return byGroup;
|
|
2802
|
+
const atA = tileRankStamp(a);
|
|
2803
|
+
const atB = tileRankStamp(b);
|
|
2804
|
+
if (atA !== atB) {
|
|
2805
|
+
if (!atA) return 1;
|
|
2806
|
+
if (!atB) return -1;
|
|
2807
|
+
return atB - atA;
|
|
2808
|
+
}
|
|
2809
|
+
const orderA = Number.isFinite(a.orderIndex) ? a.orderIndex : Number.MAX_SAFE_INTEGER;
|
|
2810
|
+
const orderB = Number.isFinite(b.orderIndex) ? b.orderIndex : Number.MAX_SAFE_INTEGER;
|
|
2811
|
+
return orderA - orderB;
|
|
2812
|
+
});
|
|
2813
|
+
const ids = [];
|
|
2814
|
+
for (const row of list) if (!ids.includes(row.id)) ids.push(row.id);
|
|
2815
|
+
return ids;
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2189
2818
|
/**
|
|
2190
2819
|
* What the Tiles button and Ctrl+Shift+G open, at once and without asking
|
|
2191
2820
|
* (owner decision 8). In order:
|
|
2192
2821
|
* a. the grid this tab last had (`stored`, already sanitized: live, not
|
|
2193
|
-
* detached, at most the cap), if any of its sessions survive
|
|
2822
|
+
* detached, at most the cap), if any of its sessions survive, exactly
|
|
2823
|
+
* as it was (restoreTileGridCells: its cells and holes, a cell its
|
|
2824
|
+
* session freed filled from the ranking);
|
|
2194
2825
|
* b. else an open split's two sessions, Pane A focused;
|
|
2195
|
-
* c. else the open sessions in
|
|
2196
|
-
*
|
|
2197
|
-
*
|
|
2826
|
+
* c. else the open sessions in `ranked` order (rankTileSessions: working,
|
|
2827
|
+
* then needing input, then the most recent; tab order when no ranking is
|
|
2828
|
+
* given), detached ones never, up to `limit`, the active session always
|
|
2829
|
+
* among them and focused (when it ranks past the limit, the first
|
|
2830
|
+
* `limit - 1` others come with it).
|
|
2198
2831
|
* Null when there is nothing to open.
|
|
2199
2832
|
*
|
|
2200
|
-
* @param {{stored?: {ids: string[],
|
|
2201
|
-
*
|
|
2833
|
+
* @param {{stored?: {ids: string[], cells?: (string|null)[], freed?: number[], count?: number,
|
|
2834
|
+
* focused: string|null, zoomed: string|null}|null,
|
|
2835
|
+
* split?: string[]|null, ranked?: string[]|null, sessions: Map<string, object>, sessionOrder: string[],
|
|
2202
2836
|
* detachedIds?: {has(id: string): boolean}, activeId?: string|null, limit: number}} p
|
|
2203
|
-
* @returns {{source: 'stored'|'split'|'
|
|
2837
|
+
* @returns {{source: 'stored'|'split'|'ranked', ids: string[], cells?: (string|null)[],
|
|
2838
|
+
* focusedId: string|null}|null}
|
|
2204
2839
|
*/
|
|
2205
|
-
function tileGridOpenSet({
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2840
|
+
function tileGridOpenSet({
|
|
2841
|
+
stored = null,
|
|
2842
|
+
split = null,
|
|
2843
|
+
ranked = null,
|
|
2844
|
+
sessions,
|
|
2845
|
+
sessionOrder,
|
|
2846
|
+
detachedIds,
|
|
2847
|
+
activeId = null,
|
|
2848
|
+
limit,
|
|
2849
|
+
}) {
|
|
2850
|
+
const all = buildTilePickerSessions(sessions, Array.isArray(ranked) ? ranked : sessionOrder, detachedIds).map(
|
|
2851
|
+
(c) => c.id
|
|
2852
|
+
);
|
|
2853
|
+
const restored = stored ? restoreTileGridCells(stored, all) : null;
|
|
2854
|
+
if (restored) return { source: 'stored', ...restored };
|
|
2210
2855
|
const usable = (id) => typeof id === 'string' && sessions.has(id) && !detachedIds?.has?.(id);
|
|
2211
2856
|
const pair = (split || []).filter(usable);
|
|
2212
2857
|
if (split && pair.length) return { source: 'split', ids: [...new Set(pair)], focusedId: pair[0] };
|
|
2213
2858
|
const max = Math.max(1, Math.min(Math.floor(Number(limit) || 0), TILE_GRID_MAX));
|
|
2214
|
-
const all = buildTilePickerSessions(sessions, sessionOrder, detachedIds).map((c) => c.id);
|
|
2215
2859
|
if (all.length === 0) return null;
|
|
2216
2860
|
let ids = all.slice(0, max);
|
|
2217
2861
|
if (all.includes(activeId) && !ids.includes(activeId)) {
|
|
2218
2862
|
ids = [...all.filter((id) => id !== activeId).slice(0, max - 1), activeId];
|
|
2219
2863
|
}
|
|
2220
|
-
return { source: '
|
|
2864
|
+
return { source: 'ranked', ids, focusedId: ids.includes(activeId) ? activeId : ids[0] };
|
|
2221
2865
|
}
|
|
2222
2866
|
|
|
2223
2867
|
/**
|
|
@@ -2237,8 +2881,9 @@ function sanitizeTileCount(raw) {
|
|
|
2237
2881
|
* `base` (what the grid would open, or what an open grid shows, in its order)
|
|
2238
2882
|
* trimmed or filled to `n` tiles: trimmed from the end, the session to focus
|
|
2239
2883
|
* (`keepId`) always kept (it takes the last place when it sat past `n`, as in
|
|
2240
|
-
* tileGridOpenSet's case c); filled from `all` (the open sessions
|
|
2241
|
-
* with the ones not in it yet.
|
|
2884
|
+
* tileGridOpenSet's case c); filled from `all` (the open sessions, best first:
|
|
2885
|
+
* the app passes the ranking, rankTileSessions) with the ones not in it yet.
|
|
2886
|
+
* Fewer sessions than `n` give fewer tiles.
|
|
2242
2887
|
*
|
|
2243
2888
|
* @param {string[]} base
|
|
2244
2889
|
* @param {string[]} all
|
|
@@ -2727,8 +3372,16 @@ function describeSessionHarness(session, catalog) {
|
|
|
2727
3372
|
if (typeof window !== 'undefined') {
|
|
2728
3373
|
window.CodemanSessionHarness = { describeSessionHarness, SESSION_MODEL_MAX_CHARS };
|
|
2729
3374
|
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
|
|
2730
|
-
window.CodemanFilePaths = {
|
|
2731
|
-
|
|
3375
|
+
window.CodemanFilePaths = {
|
|
3376
|
+
absoluteFilePathPattern,
|
|
3377
|
+
findFilePathLinks,
|
|
3378
|
+
resolveLinkedFilePath,
|
|
3379
|
+
previewsInFileViewer,
|
|
3380
|
+
FILE_PREVIEW_EXTENSIONS,
|
|
3381
|
+
rendersAsPage,
|
|
3382
|
+
HTML_PAGE_EXTENSIONS,
|
|
3383
|
+
};
|
|
3384
|
+
window.CodemanTerminalLines = { terminalLogicalLine, terminalPathLine };
|
|
2732
3385
|
window.CodemanUrlSession = { sessionIdFromFragment };
|
|
2733
3386
|
window.CodemanSplitPane = {
|
|
2734
3387
|
clampDividerPercent,
|
|
@@ -2747,6 +3400,8 @@ if (typeof window !== 'undefined') {
|
|
|
2747
3400
|
fitTileCells,
|
|
2748
3401
|
cycleTile,
|
|
2749
3402
|
tileGridOpenSet,
|
|
3403
|
+
restoreTileGridCells,
|
|
3404
|
+
rankTileSessions,
|
|
2750
3405
|
sanitizeTileCount,
|
|
2751
3406
|
tileGridSetForCount,
|
|
2752
3407
|
tileCellCols,
|