aicodeman 1.34.0 → 1.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/README.zh-CN.md +8 -0
- package/dist/attachment-registry.d.ts +6 -0
- package/dist/attachment-registry.d.ts.map +1 -1
- package/dist/attachment-registry.js +9 -3
- package/dist/attachment-registry.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +2 -2
- package/dist/cli.js.map +1 -1
- package/dist/config/cli-registry/patterns.d.ts +6 -0
- package/dist/config/cli-registry/patterns.d.ts.map +1 -1
- package/dist/config/cli-registry/patterns.js +13 -0
- package/dist/config/cli-registry/patterns.js.map +1 -1
- package/dist/config/cli-registry/schema.d.ts +13 -0
- package/dist/config/cli-registry/schema.d.ts.map +1 -1
- package/dist/config/cli-registry/schema.js +60 -2
- package/dist/config/cli-registry/schema.js.map +1 -1
- package/dist/config/cli-registry/stock.d.ts.map +1 -1
- package/dist/config/cli-registry/stock.js +175 -7
- package/dist/config/cli-registry/stock.js.map +1 -1
- package/dist/config/cli-registry/types.d.ts +93 -6
- package/dist/config/cli-registry/types.d.ts.map +1 -1
- package/dist/config/dependency-registry.d.ts +7 -0
- package/dist/config/dependency-registry.d.ts.map +1 -1
- package/dist/config/dependency-registry.js +15 -0
- package/dist/config/dependency-registry.js.map +1 -1
- package/dist/config/path-probe.d.ts +37 -0
- package/dist/config/path-probe.d.ts.map +1 -0
- package/dist/config/path-probe.js +43 -0
- package/dist/config/path-probe.js.map +1 -0
- package/dist/deepseek-route-config.d.ts +110 -0
- package/dist/deepseek-route-config.d.ts.map +1 -0
- package/dist/deepseek-route-config.js +477 -0
- package/dist/deepseek-route-config.js.map +1 -0
- package/dist/git-workspace-status.d.ts +246 -0
- package/dist/git-workspace-status.d.ts.map +1 -0
- package/dist/git-workspace-status.js +626 -0
- package/dist/git-workspace-status.js.map +1 -0
- package/dist/hooks-config.d.ts.map +1 -1
- package/dist/hooks-config.js +83 -13
- package/dist/hooks-config.js.map +1 -1
- package/dist/model-config-resolvers.d.ts +29 -0
- package/dist/model-config-resolvers.d.ts.map +1 -0
- package/dist/model-config-resolvers.js +34 -0
- package/dist/model-config-resolvers.js.map +1 -0
- package/dist/session-display-model.d.ts +110 -0
- package/dist/session-display-model.d.ts.map +1 -0
- package/dist/session-display-model.js +154 -0
- package/dist/session-display-model.js.map +1 -0
- package/dist/session.d.ts +145 -8
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +280 -35
- package/dist/session.js.map +1 -1
- package/dist/types/api.d.ts +7 -0
- package/dist/types/api.d.ts.map +1 -1
- package/dist/types/api.js.map +1 -1
- package/dist/types/session.d.ts +28 -1
- package/dist/types/session.d.ts.map +1 -1
- package/dist/types/session.js.map +1 -1
- package/dist/types/tools.d.ts +1 -1
- package/dist/types/tools.d.ts.map +1 -1
- package/dist/utils/bounded-path-probe.d.ts +99 -0
- package/dist/utils/bounded-path-probe.d.ts.map +1 -0
- package/dist/utils/bounded-path-probe.js +228 -0
- package/dist/utils/bounded-path-probe.js.map +1 -0
- package/dist/utils/cli-executable-resolver.d.ts +2 -0
- package/dist/utils/cli-executable-resolver.d.ts.map +1 -1
- package/dist/utils/cli-executable-resolver.js +2 -1
- package/dist/utils/cli-executable-resolver.js.map +1 -1
- package/dist/utils/deepseek-cli-resolver.d.ts +8 -0
- package/dist/utils/deepseek-cli-resolver.d.ts.map +1 -1
- package/dist/utils/deepseek-cli-resolver.js +18 -2
- package/dist/utils/deepseek-cli-resolver.js.map +1 -1
- package/dist/utils/dependency-checker.d.ts +2 -0
- package/dist/utils/dependency-checker.d.ts.map +1 -1
- package/dist/utils/dependency-checker.js +37 -9
- package/dist/utils/dependency-checker.js.map +1 -1
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/web/case-path.d.ts +58 -0
- package/dist/web/case-path.d.ts.map +1 -0
- package/dist/web/case-path.js +182 -0
- package/dist/web/case-path.js.map +1 -0
- package/dist/web/launch-defaults.d.ts +22 -0
- package/dist/web/launch-defaults.d.ts.map +1 -0
- package/dist/web/launch-defaults.js +46 -0
- package/dist/web/launch-defaults.js.map +1 -0
- package/dist/web/public/admin-ui.js.gz +0 -0
- package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
- package/dist/web/public/app.abadc970.js +43 -0
- package/dist/web/public/app.abadc970.js.br +0 -0
- package/dist/web/public/app.abadc970.js.gz +0 -0
- package/dist/web/public/approvals-ui.js.gz +0 -0
- package/dist/web/public/{constants.d95fade9.js → constants.c0f8f9de.js} +1009 -127
- package/dist/web/public/constants.c0f8f9de.js.br +0 -0
- package/dist/web/public/constants.c0f8f9de.js.gz +0 -0
- package/dist/web/public/cron-ui.js.gz +0 -0
- package/dist/web/public/entrance-animations.js +1 -0
- package/dist/web/public/entrance-animations.js.br +0 -0
- package/dist/web/public/entrance-animations.js.gz +0 -0
- package/dist/web/public/git-status-ui.js +719 -0
- package/dist/web/public/git-status-ui.js.br +0 -0
- package/dist/web/public/git-status-ui.js.gz +0 -0
- package/dist/web/public/home-sessions.js +12 -9
- package/dist/web/public/home-sessions.js.br +0 -0
- package/dist/web/public/home-sessions.js.gz +0 -0
- package/dist/web/public/host-wake-ui.js.gz +0 -0
- package/dist/web/public/i18n.a31747a9.js +2 -0
- package/dist/web/public/i18n.a31747a9.js.br +0 -0
- package/dist/web/public/i18n.a31747a9.js.gz +0 -0
- package/dist/web/public/{image-input.a216cf9e.js → image-input.898a538b.js} +18 -8
- package/dist/web/public/image-input.898a538b.js.br +0 -0
- package/dist/web/public/image-input.898a538b.js.gz +0 -0
- package/dist/web/public/index.html +205 -42
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
- package/dist/web/public/keyboard-accessory.c1e7c7b7.js.gz +0 -0
- package/dist/web/public/{mobile-handlers.23a85db1.js → mobile-handlers.0ef9c311.js} +20 -0
- package/dist/web/public/mobile-handlers.0ef9c311.js.br +0 -0
- package/dist/web/public/mobile-handlers.0ef9c311.js.gz +0 -0
- package/dist/web/public/mobile-ime-preview.2dbaf262.js.gz +0 -0
- package/dist/web/public/mobile-overview.js.gz +0 -0
- package/dist/web/public/mobile.111b187d.css +1 -0
- package/dist/web/public/mobile.111b187d.css.br +0 -0
- package/dist/web/public/mobile.111b187d.css.gz +0 -0
- package/dist/web/public/notification-manager.50bb87d8.js.gz +0 -0
- package/dist/web/public/orchestrator-panel.js.gz +0 -0
- package/dist/web/public/{panels-ui.6a5898d9.js → panels-ui.4735dfc2.js} +4 -4
- package/dist/web/public/panels-ui.4735dfc2.js.br +0 -0
- package/dist/web/public/{panels-ui.6a5898d9.js.gz → panels-ui.4735dfc2.js.gz} +0 -0
- package/dist/web/public/ralph-panel.b350e2ef.js.gz +0 -0
- package/dist/web/public/ralph-wizard.b6c0dd19.js.gz +0 -0
- package/dist/web/public/readmymind-ui.js.gz +0 -0
- package/dist/web/public/reboot-restore-ui.js.gz +0 -0
- package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
- package/dist/web/public/sanitize-html.e7147e76.js.gz +0 -0
- package/dist/web/public/session-lineage.js +229 -86
- package/dist/web/public/session-lineage.js.br +0 -0
- package/dist/web/public/session-lineage.js.gz +0 -0
- package/dist/web/public/session-ui.f731602c.js +100 -0
- package/dist/web/public/session-ui.f731602c.js.br +0 -0
- package/dist/web/public/session-ui.f731602c.js.gz +0 -0
- package/dist/web/public/settings-ui.a97ab16f.js +97 -0
- package/dist/web/public/settings-ui.a97ab16f.js.br +0 -0
- package/dist/web/public/settings-ui.a97ab16f.js.gz +0 -0
- package/dist/web/public/spreadsheet-preview-worker.js +290 -0
- package/dist/web/public/spreadsheet-preview-worker.js.br +0 -0
- package/dist/web/public/spreadsheet-preview-worker.js.gz +0 -0
- package/dist/web/public/spreadsheet-preview.af82fa2f.js +555 -0
- package/dist/web/public/spreadsheet-preview.af82fa2f.js.br +0 -0
- package/dist/web/public/spreadsheet-preview.af82fa2f.js.gz +0 -0
- package/dist/web/public/spreadsheet-xlsx-core.js +1006 -0
- package/dist/web/public/spreadsheet-xlsx-core.js.br +0 -0
- package/dist/web/public/spreadsheet-xlsx-core.js.gz +0 -0
- package/dist/web/public/styles.8ee12666.css +1 -0
- package/dist/web/public/styles.8ee12666.css.br +0 -0
- package/dist/web/public/styles.8ee12666.css.gz +0 -0
- package/dist/web/public/subagent-windows.e6ca799f.js.gz +0 -0
- package/dist/web/public/sw.js +2 -2
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/tab-layout-browser.065fcba4.js +1 -0
- package/dist/web/public/tab-layout-browser.065fcba4.js.br +0 -0
- package/dist/web/public/tab-layout-browser.065fcba4.js.gz +0 -0
- package/dist/web/public/{tab-rail-resize.dcdc5772.js → tab-rail-resize.a5867793.js} +1 -1
- package/dist/web/public/tab-rail-resize.a5867793.js.br +0 -0
- package/dist/web/public/tab-rail-resize.a5867793.js.gz +0 -0
- package/dist/web/public/terminal-keycode229-recovery.af09f99a.js +1 -0
- package/dist/web/public/terminal-keycode229-recovery.af09f99a.js.br +0 -0
- package/dist/web/public/terminal-keycode229-recovery.af09f99a.js.gz +0 -0
- package/dist/web/public/terminal-split.js +146 -649
- package/dist/web/public/terminal-split.js.br +0 -0
- package/dist/web/public/terminal-split.js.gz +0 -0
- package/dist/web/public/terminal-tile.js +1611 -0
- package/dist/web/public/terminal-tile.js.br +0 -0
- package/dist/web/public/terminal-tile.js.gz +0 -0
- package/dist/web/public/terminal-ui.5059b30e.js +2 -0
- package/dist/web/public/terminal-ui.5059b30e.js.br +0 -0
- package/dist/web/public/terminal-ui.5059b30e.js.gz +0 -0
- package/dist/web/public/tile-grid.js +2684 -0
- package/dist/web/public/tile-grid.js.br +0 -0
- package/dist/web/public/tile-grid.js.gz +0 -0
- package/dist/web/public/ultracode-panel.js.gz +0 -0
- package/dist/web/public/ultracode-windows.js.gz +0 -0
- package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
- package/dist/web/public/vendor/exceljs.min.js +44 -0
- package/dist/web/public/vendor/exceljs.min.js.br +0 -0
- package/dist/web/public/vendor/exceljs.min.js.gz +0 -0
- package/dist/web/public/vendor/fflate.min.js +1 -0
- package/dist/web/public/vendor/fflate.min.js.br +0 -0
- package/dist/web/public/vendor/fflate.min.js.gz +0 -0
- package/dist/web/public/vendor/marked.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-predictive-echo.ae5e3d5d.js.gz +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.bbb5f84c.js.gz +0 -0
- package/dist/web/public/vendor/xterm.css.gz +0 -0
- package/dist/web/public/vendor/xterm.min.js.gz +0 -0
- package/dist/web/public/{voice-input.c4b51eb6.js → voice-input.2342c37b.js} +64 -10
- package/dist/web/public/voice-input.2342c37b.js.br +0 -0
- package/dist/web/public/voice-input.2342c37b.js.gz +0 -0
- package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
- package/dist/web/public/webview-tabs.js +9 -1
- package/dist/web/public/webview-tabs.js.br +0 -0
- package/dist/web/public/webview-tabs.js.gz +0 -0
- package/dist/web/routes/case-routes.d.ts.map +1 -1
- package/dist/web/routes/case-routes.js +136 -18
- package/dist/web/routes/case-routes.js.map +1 -1
- package/dist/web/routes/cli-registry-routes.d.ts.map +1 -1
- package/dist/web/routes/cli-registry-routes.js +3 -1
- package/dist/web/routes/cli-registry-routes.js.map +1 -1
- package/dist/web/routes/doctor-routes.d.ts +23 -0
- package/dist/web/routes/doctor-routes.d.ts.map +1 -0
- package/dist/web/routes/doctor-routes.js +83 -0
- package/dist/web/routes/doctor-routes.js.map +1 -0
- package/dist/web/routes/file-routes.d.ts +7 -0
- package/dist/web/routes/file-routes.d.ts.map +1 -1
- package/dist/web/routes/file-routes.js +34 -4
- package/dist/web/routes/file-routes.js.map +1 -1
- package/dist/web/routes/git-status-routes.d.ts +16 -0
- package/dist/web/routes/git-status-routes.d.ts.map +1 -0
- package/dist/web/routes/git-status-routes.js +98 -0
- package/dist/web/routes/git-status-routes.js.map +1 -0
- package/dist/web/routes/index.d.ts +2 -0
- package/dist/web/routes/index.d.ts.map +1 -1
- package/dist/web/routes/index.js +2 -0
- package/dist/web/routes/index.js.map +1 -1
- package/dist/web/routes/session-routes.d.ts +18 -0
- package/dist/web/routes/session-routes.d.ts.map +1 -1
- package/dist/web/routes/session-routes.js +106 -42
- package/dist/web/routes/session-routes.js.map +1 -1
- package/dist/web/routes/status-telemetry-routes.d.ts +3 -0
- package/dist/web/routes/status-telemetry-routes.d.ts.map +1 -1
- package/dist/web/routes/status-telemetry-routes.js +12 -0
- package/dist/web/routes/status-telemetry-routes.js.map +1 -1
- package/dist/web/routes/system-routes.d.ts.map +1 -1
- package/dist/web/routes/system-routes.js +13 -0
- package/dist/web/routes/system-routes.js.map +1 -1
- package/dist/web/schemas.d.ts +28 -0
- package/dist/web/schemas.d.ts.map +1 -1
- package/dist/web/schemas.js +41 -0
- package/dist/web/schemas.js.map +1 -1
- package/dist/web/server.d.ts +5 -0
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +35 -12
- package/dist/web/server.js.map +1 -1
- package/dist/web/session-listener-wiring.d.ts +1 -0
- package/dist/web/session-listener-wiring.d.ts.map +1 -1
- package/dist/web/session-listener-wiring.js +11 -0
- package/dist/web/session-listener-wiring.js.map +1 -1
- package/package.json +3 -1
- package/scripts/postinstall.js +16 -0
- package/skills/codeman/reference/verbs.md +3 -0
- package/dist/web/public/app.cf0db8ce.js +0 -43
- package/dist/web/public/app.cf0db8ce.js.br +0 -0
- package/dist/web/public/app.cf0db8ce.js.gz +0 -0
- package/dist/web/public/constants.d95fade9.js.br +0 -0
- package/dist/web/public/constants.d95fade9.js.gz +0 -0
- package/dist/web/public/i18n.398b8331.js +0 -1
- package/dist/web/public/i18n.398b8331.js.br +0 -0
- package/dist/web/public/i18n.398b8331.js.gz +0 -0
- package/dist/web/public/image-input.a216cf9e.js.br +0 -0
- package/dist/web/public/image-input.a216cf9e.js.gz +0 -0
- package/dist/web/public/mobile-handlers.23a85db1.js.br +0 -0
- package/dist/web/public/mobile-handlers.23a85db1.js.gz +0 -0
- package/dist/web/public/mobile.1aa4952d.css +0 -1
- package/dist/web/public/mobile.1aa4952d.css.br +0 -0
- package/dist/web/public/mobile.1aa4952d.css.gz +0 -0
- package/dist/web/public/panels-ui.6a5898d9.js.br +0 -0
- package/dist/web/public/session-ui.bf66c13b.js +0 -89
- package/dist/web/public/session-ui.bf66c13b.js.br +0 -0
- package/dist/web/public/session-ui.bf66c13b.js.gz +0 -0
- package/dist/web/public/settings-ui.331d3b1d.js +0 -97
- package/dist/web/public/settings-ui.331d3b1d.js.br +0 -0
- package/dist/web/public/settings-ui.331d3b1d.js.gz +0 -0
- package/dist/web/public/styles.8c485d26.css +0 -1
- package/dist/web/public/styles.8c485d26.css.br +0 -0
- package/dist/web/public/styles.8c485d26.css.gz +0 -0
- package/dist/web/public/tab-layout-browser.4e0ed50c.js +0 -1
- package/dist/web/public/tab-layout-browser.4e0ed50c.js.br +0 -0
- package/dist/web/public/tab-layout-browser.4e0ed50c.js.gz +0 -0
- package/dist/web/public/tab-rail-resize.dcdc5772.js.br +0 -0
- package/dist/web/public/tab-rail-resize.dcdc5772.js.gz +0 -0
- package/dist/web/public/terminal-keycode229-recovery.fb91b25b.js +0 -1
- package/dist/web/public/terminal-keycode229-recovery.fb91b25b.js.br +0 -0
- package/dist/web/public/terminal-keycode229-recovery.fb91b25b.js.gz +0 -0
- package/dist/web/public/terminal-ui.a4535311.js +0 -2
- package/dist/web/public/terminal-ui.a4535311.js.br +0 -0
- package/dist/web/public/terminal-ui.a4535311.js.gz +0 -0
- package/dist/web/public/upload.html +0 -155
- package/dist/web/public/upload.html.br +0 -0
- package/dist/web/public/upload.html.gz +0 -0
- package/dist/web/public/voice-input.c4b51eb6.js.br +0 -0
- package/dist/web/public/voice-input.c4b51eb6.js.gz +0 -0
|
@@ -1,640 +1,16 @@
|
|
|
1
1
|
// src/web/public/terminal-split.js
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* @fileoverview
|
|
5
|
-
* ("Pane B")
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* feature by nature — see docs/split-pane-sessions-plan.md.
|
|
4
|
+
* @fileoverview Split-pane orchestration: opens a second live session
|
|
5
|
+
* ("Pane B") beside the active one, in a TerminalTile (terminal-tile.js), with
|
|
6
|
+
* a draggable divider, a session picker, and auto-collapse when either
|
|
7
|
+
* session ends. Desktop-only; see docs/split-pane-sessions-plan.md.
|
|
9
8
|
*
|
|
10
|
-
* @dependency
|
|
11
|
-
* @dependency constants.js (window.
|
|
12
|
-
* @
|
|
13
|
-
* @loadorder 7.5 of 16 — loaded after terminal-ui.js, before respawn-ui.js
|
|
9
|
+
* @dependency terminal-tile.js (window.TerminalTile)
|
|
10
|
+
* @dependency constants.js (window.CodemanSplitPane, SPLIT_PANE_MIN_WIDTH)
|
|
11
|
+
* @loadorder 7.5 of 16, loaded after terminal-tile.js and before tile-grid.js
|
|
14
12
|
*/
|
|
15
13
|
|
|
16
|
-
(function (global) {
|
|
17
|
-
// How long a scroll-to-top history pull may hold Pane B's live output.
|
|
18
|
-
const HISTORY_PULL_TIMEOUT_MS = 10000;
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Minimal chunked write for Pane B's own xterm instance — write() in
|
|
22
|
-
* TERMINAL_CHUNK_SIZE slices, yielding a frame between each, instead of one
|
|
23
|
-
* giant synchronous write that blocks the main thread while parsing a long
|
|
24
|
-
* scrollback. Deliberately NOT the primary pane's chunkedTerminalWrite
|
|
25
|
-
* (terminal-ui.js): that one is wired into session-switch generation
|
|
26
|
-
* counters and the live-output gate this simpler, independently
|
|
27
|
-
* created/destroyed pane has no equivalent of.
|
|
28
|
-
*/
|
|
29
|
-
function writeChunked(terminal, buffer, isDestroyed) {
|
|
30
|
-
if (!buffer) return Promise.resolve();
|
|
31
|
-
if (buffer.length <= TERMINAL_CHUNK_SIZE) {
|
|
32
|
-
terminal.write(buffer);
|
|
33
|
-
return Promise.resolve();
|
|
34
|
-
}
|
|
35
|
-
// Resolves once the LAST chunk is written (or the pane was destroyed
|
|
36
|
-
// mid-replay), so _loadBuffer() below can hold its single-flight flag
|
|
37
|
-
// across the whole replay rather than just the fetch that precedes it.
|
|
38
|
-
return new Promise((resolve) => {
|
|
39
|
-
let offset = 0;
|
|
40
|
-
const writeNext = () => {
|
|
41
|
-
if (isDestroyed() || !terminal) {
|
|
42
|
-
resolve();
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
const chunk = buffer.slice(offset, offset + TERMINAL_CHUNK_SIZE);
|
|
46
|
-
offset += chunk.length;
|
|
47
|
-
terminal.write(chunk);
|
|
48
|
-
if (offset < buffer.length) {
|
|
49
|
-
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(writeNext);
|
|
50
|
-
else setTimeout(writeNext, 16);
|
|
51
|
-
} else {
|
|
52
|
-
resolve();
|
|
53
|
-
}
|
|
54
|
-
};
|
|
55
|
-
writeNext();
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
class SplitTerminalPane {
|
|
60
|
-
constructor(sessionId, mountEl, opts = {}) {
|
|
61
|
-
this.sessionId = sessionId;
|
|
62
|
-
this.mountEl = mountEl;
|
|
63
|
-
this.sessionMode = opts.mode;
|
|
64
|
-
this.fontSettings = opts.fontSettings || {};
|
|
65
|
-
// Live reference (not a snapshot) to the app's detachedSessions Set —
|
|
66
|
-
// detaching this session AFTER the split is already open must still be
|
|
67
|
-
// seen by _sendResize() below, or it re-creates the exact PTY-size
|
|
68
|
-
// fight the split picker already refuses to open at pick time.
|
|
69
|
-
this.detachedSessions = opts.detachedSessions;
|
|
70
|
-
this.terminal = null;
|
|
71
|
-
this.fitAddon = null;
|
|
72
|
-
this.ws = null;
|
|
73
|
-
this._wsReady = false;
|
|
74
|
-
this._wsClosed = false;
|
|
75
|
-
this._destroyed = false;
|
|
76
|
-
// Single-flight state for _loadBuffer()/_refreshBuffer() below.
|
|
77
|
-
this._bufferLoading = false;
|
|
78
|
-
this._bufferRefreshPending = false;
|
|
79
|
-
// Scroll-to-top history pull (shell panes only), see _maybeLoadMoreHistory().
|
|
80
|
-
// `_liveQueue` is non-null from the pull's response until its finally
|
|
81
|
-
// block: live frames are held there with their arrival time instead of
|
|
82
|
-
// written under the replay. `_markerOwed` is the "disconnected" marker a
|
|
83
|
-
// load still has to write (see _onSocketClosed()/_stampMarkerIfOwed()).
|
|
84
|
-
this._historyPullAt = 0;
|
|
85
|
-
this._historyPullUseless = false;
|
|
86
|
-
this._liveQueue = null;
|
|
87
|
-
this._markerOwed = false;
|
|
88
|
-
this._onWheel = null;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
async connect() {
|
|
92
|
-
const savedFontSize = parseInt(localStorage.getItem('codeman-font-size'), 10);
|
|
93
|
-
this.terminal = new Terminal({
|
|
94
|
-
theme: { ...global.codemanCurrentXtermTheme() },
|
|
95
|
-
fontFamily: global.CodemanTerminalFont.resolve(this.fontSettings.terminalFontFamily),
|
|
96
|
-
...global.CodemanTerminalFont.resolveWeights(this.fontSettings),
|
|
97
|
-
fontSize: Number.isFinite(savedFontSize) ? savedFontSize : 14,
|
|
98
|
-
lineHeight: 1.2,
|
|
99
|
-
cursorBlink: false,
|
|
100
|
-
cursorStyle: 'block',
|
|
101
|
-
minimumContrastRatio: global.codemanCurrentSkinIsLight() ? 4.5 : 1,
|
|
102
|
-
scrollback: DEFAULT_SCROLLBACK,
|
|
103
|
-
allowTransparency: true,
|
|
104
|
-
allowProposedApi: true,
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
this.fitAddon = new FitAddon.FitAddon();
|
|
108
|
-
this.terminal.loadAddon(this.fitAddon);
|
|
109
|
-
this.terminal.open(this.mountEl);
|
|
110
|
-
this.fitAddon.fit();
|
|
111
|
-
|
|
112
|
-
this._installWheelListener();
|
|
113
|
-
|
|
114
|
-
this.terminal.onData((data) => {
|
|
115
|
-
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
116
|
-
this.ws.send(JSON.stringify({ t: 'i', d: data }));
|
|
117
|
-
}
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
// Pane B has no gates of its own by default, so every app-level chord
|
|
121
|
-
// that the document capture-phase handler (app.js) only preventDefault()s
|
|
122
|
-
// — never stopPropagation()s — reaches xterm here too and writes its raw
|
|
123
|
-
// byte/escape sequence into THIS session's PTY on top of whatever the app
|
|
124
|
-
// action already did to Pane A (COD-153; mirrors the primary pane's own
|
|
125
|
-
// gates at terminal-ui.js's attachCustomKeyEventHandler: command palette,
|
|
126
|
-
// Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter
|
|
127
|
-
// newline, and smart-copy Ctrl+C/Ctrl+Shift+C). Routed through the same
|
|
128
|
-
// registry-aware predicates so a rebind or a disable restores plain
|
|
129
|
-
// terminal behavior here too. Ctrl+V is deliberately left on xterm's own
|
|
130
|
-
// default (plain-text paste): Pane B has no image-paste trap to route it
|
|
131
|
-
// to, so intercepting it here would only break paste.
|
|
132
|
-
this.terminal.attachCustomKeyEventHandler((ev) => {
|
|
133
|
-
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true;
|
|
134
|
-
if (
|
|
135
|
-
ev.altKey &&
|
|
136
|
-
!ev.ctrlKey &&
|
|
137
|
-
!ev.shiftKey &&
|
|
138
|
-
/^(Digit[1-9]|BracketLeft|BracketRight|KeyK)$/.test(ev.code || '')
|
|
139
|
-
) {
|
|
140
|
-
return false;
|
|
141
|
-
}
|
|
142
|
-
if (ev.type === 'keydown' && global.app?.shouldOpenCommandPaletteFromShortcut?.(ev)) {
|
|
143
|
-
return false;
|
|
144
|
-
}
|
|
145
|
-
if (ev.type === 'keydown' && global.app?.shouldToggleSessionSidebarFromShortcut?.(ev)) {
|
|
146
|
-
return false;
|
|
147
|
-
}
|
|
148
|
-
// Ctrl+Z (SIGTSTP/job-control suspend): mirrors terminal-ui.js's own
|
|
149
|
-
// swallow — in a plain shell session this is the user's own
|
|
150
|
-
// job-control tool and must reach the PTY, but in every other mode
|
|
151
|
-
// (claude/omp/pi/codex/...) it silently stops an unattended agent
|
|
152
|
-
// loop dead. Pane B has its own PTY/session and must not send a
|
|
153
|
-
// suspend into a non-shell one just because the primary pane's own
|
|
154
|
-
// gate lives elsewhere.
|
|
155
|
-
if (
|
|
156
|
-
ev.type === 'keydown' &&
|
|
157
|
-
ev.key.toLowerCase() === 'z' &&
|
|
158
|
-
ev.ctrlKey &&
|
|
159
|
-
!ev.altKey &&
|
|
160
|
-
!ev.metaKey &&
|
|
161
|
-
!ev.shiftKey &&
|
|
162
|
-
this.sessionMode !== 'shell'
|
|
163
|
-
) {
|
|
164
|
-
return false;
|
|
165
|
-
}
|
|
166
|
-
// Shift+Enter / Ctrl+Enter: insert a newline instead of submitting.
|
|
167
|
-
// Mirrors terminal-ui.js's own handling — xterm sends plain \r for
|
|
168
|
-
// every Enter variant, so an Ink app (Claude Code) can't tell a
|
|
169
|
-
// newline from a submit. Without this gate, Pane B's onData would
|
|
170
|
-
// send that bare \r straight over the WS and submit an incomplete
|
|
171
|
-
// prompt instead of adding a line to it. Targets THIS pane's own
|
|
172
|
-
// session (this.sessionId), never the primary pane's
|
|
173
|
-
// activeSessionId, and has no local-echo overlay of its own to flush
|
|
174
|
-
// first (Pane B is deliberately plainer — see the fileoverview).
|
|
175
|
-
// Swallow keypress/keyup too (xterm would send \r for a Shift-only keypress); only keydown sends.
|
|
176
|
-
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey)) {
|
|
177
|
-
if (ev.type === 'keydown') {
|
|
178
|
-
fetch(`/api/sessions/${this.sessionId}/send-key`, {
|
|
179
|
-
method: 'POST',
|
|
180
|
-
headers: { 'Content-Type': 'application/json' },
|
|
181
|
-
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
|
|
182
|
-
}).catch(() => {
|
|
183
|
-
/* Best-effort, matching this pane's tolerance elsewhere. */
|
|
184
|
-
});
|
|
185
|
-
}
|
|
186
|
-
return false;
|
|
187
|
-
}
|
|
188
|
-
// Smart copy (mirrors terminal-ui.js's Ctrl+C gate, #211): with a
|
|
189
|
-
// selection, Ctrl+C copies THIS pane's own selection instead of
|
|
190
|
-
// sending ^C; with none, plain Ctrl+C must fall through unchanged or
|
|
191
|
-
// the interrupt key is lost. Ctrl+Shift+C is different: it is the
|
|
192
|
-
// explicit, never-falls-through copy chord, and the predicate above
|
|
193
|
-
// does not distinguish it from plain Ctrl+C — ev.shiftKey does, below.
|
|
194
|
-
// xterm's own evaluateKeyboardEvent routes a shifted ctrl-letter into
|
|
195
|
-
// a branch that assigns c.key only for a couple of special cases
|
|
196
|
-
// ("_"->US, "@"->NUL), neither of which is "c", so it emits NOTHING
|
|
197
|
-
// for Ctrl+Shift+C either way — this is not about an accidental
|
|
198
|
-
// interrupt byte reaching the PTY (verified live: it does not).
|
|
199
|
-
// Gating this whole block on hasSelection() (an earlier draft) meant
|
|
200
|
-
// that with no selection Ctrl+Shift+C skipped straight to `return
|
|
201
|
-
// true`, silently ceding the keystroke to the BROWSER's own handling
|
|
202
|
-
// (e.g. Chrome's Inspect-Element binding) with no feedback and no
|
|
203
|
-
// attempt to copy, unlike Pane A, which always intercepts it.
|
|
204
|
-
// Re-implemented against this.terminal rather than reusing
|
|
205
|
-
// app.copyTerminalSelection(), which reads app.terminal — Pane A's —
|
|
206
|
-
// and would copy the wrong pane's selection.
|
|
207
|
-
if (ev.type === 'keydown' && global.app?.shouldCopyTerminalSelectionFromShortcut?.(ev)) {
|
|
208
|
-
const raw = this.terminal?.getSelection?.() || '';
|
|
209
|
-
const isColumnSelection = this.terminal?._core?._selectionService?._activeSelectionMode === 3;
|
|
210
|
-
// Both clean options are read for THIS pane, never the primary one:
|
|
211
|
-
// the gutter width comes from this.sessionId's own run mode, and the
|
|
212
|
-
// partial-first-line flag from this terminal's own selection range.
|
|
213
|
-
// Passing neither left Pane B keeping a margin Pane A dropped, on the
|
|
214
|
-
// same split and the same keystroke.
|
|
215
|
-
const range = global.app?._normalisedSelectionRange?.(this.terminal);
|
|
216
|
-
const selection = isColumnSelection
|
|
217
|
-
? raw
|
|
218
|
-
: (global.CodemanCopySelection?.clean?.(raw, {
|
|
219
|
-
margin: global.app?._cliGutterColumns?.(this.sessionId) ?? 0,
|
|
220
|
-
firstLinePartial: !!range && range.start.x > 0,
|
|
221
|
-
}) ?? raw);
|
|
222
|
-
if (selection.trim()) {
|
|
223
|
-
ev.preventDefault();
|
|
224
|
-
void global.app._copyText?.(selection).then((ok) => {
|
|
225
|
-
this.terminal?.clearSelection?.();
|
|
226
|
-
global.app.showToast?.(ok ? 'Copied to clipboard' : 'Failed to copy', ok ? 'success' : 'error');
|
|
227
|
-
});
|
|
228
|
-
return false;
|
|
229
|
-
}
|
|
230
|
-
// Nothing worth copying — clear for feedback (a padding-only
|
|
231
|
-
// selection cleans to '' and this press still falls through to the
|
|
232
|
-
// PTY as 0x03, matching the primary pane's own rule).
|
|
233
|
-
if (this.terminal?.hasSelection?.()) {
|
|
234
|
-
this.terminal.clearSelection?.();
|
|
235
|
-
global.app.showToast?.('Nothing to copy', 'warning');
|
|
236
|
-
}
|
|
237
|
-
// Ctrl+Shift+C never falls through, even with nothing to copy —
|
|
238
|
-
// matches terminal-ui.js's own ev.shiftKey branch.
|
|
239
|
-
if (ev.shiftKey) {
|
|
240
|
-
ev.preventDefault();
|
|
241
|
-
return false;
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
return true;
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
// Load existing scrollback before going live. The WS below is
|
|
248
|
-
// subscribe-only (ws-routes.ts sends nothing on connect, only future
|
|
249
|
-
// 'terminal' events), so without this Pane B stays blank until the
|
|
250
|
-
// target session happens to produce new output. It LOOKED
|
|
251
|
-
// intermittent rather than always-broken because _sendResize() below
|
|
252
|
-
// often nudges the shared session's real tmux window to a new size,
|
|
253
|
-
// and tmux repaints its current screen on resize — that repaint was
|
|
254
|
-
// getting captured and streamed here, incidentally populating the
|
|
255
|
-
// pane. When Pane B's computed dimensions happened to already match
|
|
256
|
-
// the session's last-known size, Session.resize() (session.ts) skips
|
|
257
|
-
// the resize as a no-op, no repaint fires, and the pane stayed blank.
|
|
258
|
-
// The await covers the whole chunked replay, not just the fetch, so a
|
|
259
|
-
// live frame from the socket below can never land in the middle of it.
|
|
260
|
-
await this._loadBuffer();
|
|
261
|
-
if (this._destroyed) return;
|
|
262
|
-
|
|
263
|
-
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
264
|
-
const url = `${proto}//${location.host}${window.CodemanBase.base}/ws/sessions/${this.sessionId}/terminal`;
|
|
265
|
-
this.ws = new WebSocket(url);
|
|
266
|
-
|
|
267
|
-
this.ws.onopen = () => {
|
|
268
|
-
this._wsReady = true;
|
|
269
|
-
this._sendResize();
|
|
270
|
-
};
|
|
271
|
-
|
|
272
|
-
this.ws.onmessage = (event) => {
|
|
273
|
-
try {
|
|
274
|
-
const msg = JSON.parse(event.data);
|
|
275
|
-
if (msg.t === 'o') {
|
|
276
|
-
this._onLiveOutput(msg.d);
|
|
277
|
-
} else if (msg.t === 'c') {
|
|
278
|
-
this._onLiveClear();
|
|
279
|
-
} else if (msg.t === 'r') {
|
|
280
|
-
// Server-triggered refresh (SSE backpressure cleared, terminal
|
|
281
|
-
// data was dropped). The primary pane routes this to
|
|
282
|
-
// _onSessionNeedsRefresh (app.js:2990) — Pane B has its own
|
|
283
|
-
// buffer loader for the same reason connect() does.
|
|
284
|
-
this._refreshBuffer();
|
|
285
|
-
}
|
|
286
|
-
} catch {
|
|
287
|
-
/* Malformed frame — ignore, matches primary pane's tolerance. */
|
|
288
|
-
}
|
|
289
|
-
};
|
|
290
|
-
|
|
291
|
-
// Mirror app.js's onclose/onerror pattern (app.js:2905-2964): _wsReady
|
|
292
|
-
// must go false on a drop or fit()/_sendResize() silently no-ops on a
|
|
293
|
-
// closed socket per the WebSocket spec (no exception, no log). No
|
|
294
|
-
// reconnect logic here — Pane B is deliberately plainer than the
|
|
295
|
-
// primary pane (see the fileoverview above); a drop just stops
|
|
296
|
-
// resizing until the parent recreates the pane. But onData already
|
|
297
|
-
// silently drops keystrokes while _wsReady is false (below), so
|
|
298
|
-
// without a visible marker a dropped socket left Pane B looking
|
|
299
|
-
// normal while it quietly ate everything typed into it. v1 scope is
|
|
300
|
-
// "say so", not reconnect — collapsing the split would lose the
|
|
301
|
-
// user's place in Pane B's scrollback for a transient blip.
|
|
302
|
-
this.ws.onclose = () => this._onSocketClosed();
|
|
303
|
-
|
|
304
|
-
this.ws.onerror = () => {
|
|
305
|
-
// onclose fires after onerror — cleanup happens there.
|
|
306
|
-
};
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// The socket's close, split out of connect() so the tests can drive it.
|
|
310
|
-
// While any load runs (a history pull or a `{t:'r'}` refresh) the marker is
|
|
311
|
-
// only owed, and that load's finally block settles it (_stampMarkerIfOwed()):
|
|
312
|
-
// written now, it would sit above the output a pull is still holding (flushed
|
|
313
|
-
// after it on a skip, a downgrade or a failed fetch), above a refresh's
|
|
314
|
-
// replay, or in the middle of a chunked replay. A pull still waiting for its
|
|
315
|
-
// response holds the marker too, for as long as the request takes (up to its
|
|
316
|
-
// budget, see _pullHistory()).
|
|
317
|
-
_onSocketClosed() {
|
|
318
|
-
this._wsReady = false;
|
|
319
|
-
this._wsClosed = true;
|
|
320
|
-
if (this._bufferLoading) this._markerOwed = true;
|
|
321
|
-
else this._writeDisconnectedMarker();
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// Settles a marker the pane owes: set when a close lands during a load (the
|
|
325
|
-
// replay would otherwise sit below it) or when a load wipes the terminal on
|
|
326
|
-
// a closed socket. Called from each load's own finally, just before
|
|
327
|
-
// _endBufferLoad() starts any trailing refresh.
|
|
328
|
-
_stampMarkerIfOwed() {
|
|
329
|
-
// A trailing refresh is about to clear() synchronously, while xterm parses
|
|
330
|
-
// a write() on a later tick: a marker written here would land in the
|
|
331
|
-
// freshly cleared buffer ABOVE that refresh's replay, a second, stale copy.
|
|
332
|
-
// The refresh re-owes the marker on a closed socket and stamps it itself.
|
|
333
|
-
if (this._bufferRefreshPending && !this._destroyed) return;
|
|
334
|
-
const owed = this._markerOwed;
|
|
335
|
-
this._markerOwed = false;
|
|
336
|
-
if (owed && this._wsClosed && !this._destroyed) this._writeDisconnectedMarker();
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// Extracted so both _onSocketClosed() and a load that ends owing it on a
|
|
340
|
-
// closed socket can write it (see _stampMarkerIfOwed()).
|
|
341
|
-
_writeDisconnectedMarker() {
|
|
342
|
-
this.terminal?.write('\r\n\x1b[2m[Pane B disconnected — close and reopen the split to reconnect]\x1b[0m\r\n');
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
// Fetches and writes the session's current scrollback. Used both by
|
|
346
|
-
// connect() (initial load) and by the `{t:'r'}` server-refresh frame
|
|
347
|
-
// (below) — the primary pane's own _onSessionNeedsRefresh (app.js) is
|
|
348
|
-
// scoped to `this.activeSessionId` and clears/rewrites the primary
|
|
349
|
-
// terminal, neither of which applies to this independent pane, so this is
|
|
350
|
-
// a standalone equivalent rather than a call into it.
|
|
351
|
-
//
|
|
352
|
-
// Mirrors the primary pane's own mode check (app.js's selectSession /
|
|
353
|
-
// _onSessionNeedsRefresh): a shell session can retain hundreds of
|
|
354
|
-
// thousands of plain scrollback lines, so pulling `?full=1` there parses
|
|
355
|
-
// an unbounded, server-capped (up to terminalBufferMaxBytes, 32MB) body
|
|
356
|
-
// into a 50000-line xterm on every load. Non-shell (TUI) sessions still
|
|
357
|
-
// get one full replay. `fetch` here goes through the global wrapper
|
|
358
|
-
// (constants.js), which already prefixes CodemanBase — unlike the raw
|
|
359
|
-
// WebSocket URL above, which does not.
|
|
360
|
-
//
|
|
361
|
-
// Single-flight: the flag is held across the fetch AND the chunked write
|
|
362
|
-
// (writeChunked resolves after its last chunk), so two replays can never
|
|
363
|
-
// interleave their chunks into one terminal. A second call while one is
|
|
364
|
-
// in flight is dropped here; _refreshBuffer() is the caller that queues
|
|
365
|
-
// a trailing re-run instead.
|
|
366
|
-
async _loadBuffer() {
|
|
367
|
-
if (this._bufferLoading) return;
|
|
368
|
-
this._bufferLoading = true;
|
|
369
|
-
try {
|
|
370
|
-
const query = this.sessionMode === 'shell' ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1';
|
|
371
|
-
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?${query}`);
|
|
372
|
-
const payload = (await res.json())?.data ?? {};
|
|
373
|
-
if (payload.terminalBuffer && this.terminal) {
|
|
374
|
-
await writeChunked(this.terminal, payload.terminalBuffer, () => this._destroyed);
|
|
375
|
-
}
|
|
376
|
-
} catch {
|
|
377
|
-
/* Best-effort — live output still arrives once the socket connects. */
|
|
378
|
-
} finally {
|
|
379
|
-
this._stampMarkerIfOwed();
|
|
380
|
-
this._endBufferLoad();
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
// Ends a single-flight load (initial, refresh or history pull): clears the
|
|
385
|
-
// flag, then runs the ONE trailing refresh that arrived while it was busy.
|
|
386
|
-
_endBufferLoad() {
|
|
387
|
-
this._bufferLoading = false;
|
|
388
|
-
if (this._bufferRefreshPending && !this._destroyed) {
|
|
389
|
-
this._bufferRefreshPending = false;
|
|
390
|
-
this._refreshBuffer();
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
// Live terminal output. Written straight through, except while a history
|
|
395
|
-
// pull is replaying: a capture is current only up to the instant tmux took
|
|
396
|
-
// it, so a frame arriving mid-replay is held with its arrival time and
|
|
397
|
-
// replayed behind the snapshot by _pullHistory() (the primary pane's
|
|
398
|
-
// _finishBufferLoad `since` rule), never written underneath it.
|
|
399
|
-
_onLiveOutput(data) {
|
|
400
|
-
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), data });
|
|
401
|
-
else this.terminal?.write(data);
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// The server's `{t:'c'}` clear frame takes the same route as output, for the
|
|
405
|
-
// same reason: clearing straight away, mid-replay, would wipe the half-written
|
|
406
|
-
// snapshot and leave _pullHistory() measuring a buffer that is no longer the
|
|
407
|
-
// one it is restoring. Queued, it lands in order with the frames around it.
|
|
408
|
-
_onLiveClear() {
|
|
409
|
-
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), clear: true });
|
|
410
|
-
else this.terminal?.clear();
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
// Capture phase, because xterm's own wheel handler stopPropagation()s every
|
|
414
|
-
// event it consumes, so a bubbling listener here would never see the wheel
|
|
415
|
-
// while the pane still has scrollback to scroll. Passive: this only observes,
|
|
416
|
-
// xterm keeps doing the scrolling.
|
|
417
|
-
_installWheelListener() {
|
|
418
|
-
this._onWheel = (ev) => {
|
|
419
|
-
if (ev.deltaY < 0) this._maybeLoadMoreHistory();
|
|
420
|
-
};
|
|
421
|
-
this.mountEl.addEventListener('wheel', this._onWheel, { capture: true, passive: true });
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
// Wheel-up at the top of a SHELL pane's scrollback. tmux repaints a burst of
|
|
425
|
-
// output (`cat` of a file longer than the screen) instead of scrolling it,
|
|
426
|
-
// so this pane's xterm ends up with about one screen of scrollback while
|
|
427
|
-
// tmux holds every line — and nothing here ever went back to ask, so the
|
|
428
|
-
// history was unreachable. The primary pane has the same pull
|
|
429
|
-
// (app.js _maybeRefetchFullHistory); Pane B is a separate xterm and needs its
|
|
430
|
-
// own. Shell only: a non-shell CLI's history is out of scope for this pull
|
|
431
|
-
// (its load already takes `full=1`; codex and Claude's inline renderer do
|
|
432
|
-
// grow tmux history, this just isn't how they recover it). The alternate-
|
|
433
|
-
// screen skip (nano, vim, less) only matters for a direct-PTY shell — under
|
|
434
|
-
// tmux the browser xterm never enters the alternate buffer.
|
|
435
|
-
_maybeLoadMoreHistory() {
|
|
436
|
-
if (this.sessionMode !== 'shell' || this._destroyed || !this.terminal) return;
|
|
437
|
-
if (this._bufferLoading) return;
|
|
438
|
-
// Mirrors app.js _maybeRefetchFullHistory and this pane's own
|
|
439
|
-
// _sendResize(): a detached session's own window already owns its PTY
|
|
440
|
-
// size and scrollback, so Pane B has nothing of its own to reconcile.
|
|
441
|
-
if (this.detachedSessions?.has(this.sessionId)) return;
|
|
442
|
-
const active = this.terminal.buffer.active;
|
|
443
|
-
if (active.type !== 'normal' || active.viewportY !== 0) return;
|
|
444
|
-
// Momentum scrolling fires this dozens of times per flick, so cooldown
|
|
445
|
-
// rather than latch; a pull that could only have downgraded the pane
|
|
446
|
-
// waits far longer.
|
|
447
|
-
const cooldown = this._historyPullUseless ? 60000 : 4000;
|
|
448
|
-
const now = Date.now();
|
|
449
|
-
if (now - this._historyPullAt < cooldown) return;
|
|
450
|
-
this._historyPullAt = now;
|
|
451
|
-
void this._pullHistory();
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
// Pulls a BOUNDED window of tmux's full history (the same TERMINAL_TAIL_SIZE
|
|
455
|
-
// a tab switch loads, so a multi-megabyte capture never lands on xterm's
|
|
456
|
-
// main thread) and replays it under the reader's current place. Holds the
|
|
457
|
-
// single-flight flag across the fetch AND the replay, like _loadBuffer().
|
|
458
|
-
async _pullHistory() {
|
|
459
|
-
this._bufferLoading = true;
|
|
460
|
-
let replayed = false;
|
|
461
|
-
let capturedAt = 0;
|
|
462
|
-
// Two budgets on one signal. The request itself gets the primary pane's
|
|
463
|
-
// (CodemanFetchDeadline, constants.js): live output is not held while it
|
|
464
|
-
// runs, but the single-flight flag is, so a coalesced `{t:'r'}` refresh and
|
|
465
|
-
// the marker owed by a close (_onSocketClosed()) both wait for it, at worst
|
|
466
|
-
// for that whole budget. Once the headers land live output IS held, so the
|
|
467
|
-
// body read gets the short one instead: a body that hangs would otherwise
|
|
468
|
-
// freeze the pane for the long budget. Aborting lands in the catch below,
|
|
469
|
-
// which releases the flag and the queue. AbortSignal.timeout() alone cannot
|
|
470
|
-
// be re-armed, hence the controller; without AbortController the pull
|
|
471
|
-
// simply has no deadline.
|
|
472
|
-
const controller = global.AbortController ? new global.AbortController() : null;
|
|
473
|
-
let abortTimer = null;
|
|
474
|
-
const armDeadline = (ms) => {
|
|
475
|
-
if (!controller) return;
|
|
476
|
-
clearTimeout(abortTimer);
|
|
477
|
-
abortTimer = setTimeout(() => controller.abort(), ms);
|
|
478
|
-
};
|
|
479
|
-
try {
|
|
480
|
-
armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: true }) ?? HISTORY_PULL_TIMEOUT_MS);
|
|
481
|
-
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`, {
|
|
482
|
-
signal: controller?.signal,
|
|
483
|
-
});
|
|
484
|
-
armDeadline(HISTORY_PULL_TIMEOUT_MS);
|
|
485
|
-
// The cutoff below is the response's arrival, the same `since` rule the
|
|
486
|
-
// primary pane uses (_finishBufferLoad). It is a client clock standing in
|
|
487
|
-
// for the instant tmux took the capture, which lies somewhere in the
|
|
488
|
-
// round trip, so a frame in that window can be lost or doubled. Bounded
|
|
489
|
-
// by one round trip and not closable without a server-side capture time.
|
|
490
|
-
capturedAt = performance.now();
|
|
491
|
-
// Opened only now: a frame from before the response is either replaced by
|
|
492
|
-
// the capture or written unchanged, so holding it for the round trip
|
|
493
|
-
// bought nothing and froze the pane for as long as the fetch took.
|
|
494
|
-
this._liveQueue = [];
|
|
495
|
-
const payload = (await res.json())?.data;
|
|
496
|
-
clearTimeout(abortTimer);
|
|
497
|
-
const buffer = payload?.terminalBuffer;
|
|
498
|
-
const term = this.terminal;
|
|
499
|
-
if (!buffer || !term || this._destroyed) return;
|
|
500
|
-
const rowsBefore = term.buffer.active.length;
|
|
501
|
-
const rowsIncoming = global.app?._estimateReplayRows?.(buffer, term.cols) ?? buffer.split('\n').length;
|
|
502
|
-
// xterm keeps at most `scrollback + rows` rows while tmux keeps far more
|
|
503
|
-
// lines, so a window of short lines can carry more rows than this pane
|
|
504
|
-
// can ever hold, and `rowsIncoming <= rowsBefore` would never come true.
|
|
505
|
-
const scrollbackCap = term.options?.scrollback || 0;
|
|
506
|
-
const paneFull = scrollbackCap > 0 && rowsBefore >= scrollbackCap + term.rows;
|
|
507
|
-
// Nothing to gain (this also covers a downgrade, which would delete
|
|
508
|
-
// history mid-scroll), and a reset+rewrite would jump the viewport. An
|
|
509
|
-
// untruncated window IS all of tmux's history and the next burst can add
|
|
510
|
-
// more, so keep the 4 s cooldown. A truncated window can never reach past
|
|
511
|
-
// what the pane shows, and every ask costs the server a capture-pane of
|
|
512
|
-
// the whole history (`tail` is cut after it): back off to 60 s, as the
|
|
513
|
-
// primary pane does (app.js _maybeRefetchFullHistory). A full pane backs
|
|
514
|
-
// off too, since no window can ever fit in it.
|
|
515
|
-
if (rowsIncoming <= rowsBefore || paneFull) {
|
|
516
|
-
if (payload.truncated || paneFull) this._historyPullUseless = true;
|
|
517
|
-
return;
|
|
518
|
-
}
|
|
519
|
-
this._historyPullUseless = false;
|
|
520
|
-
term.write('\x1bc');
|
|
521
|
-
replayed = true;
|
|
522
|
-
if (this._wsClosed) this._markerOwed = true;
|
|
523
|
-
await writeChunked(term, buffer, () => this._destroyed);
|
|
524
|
-
if (this._destroyed || !this.terminal) return;
|
|
525
|
-
// xterm parses asynchronously: an empty write's callback fires only
|
|
526
|
-
// after everything before it, so the row count below is the settled one.
|
|
527
|
-
await new Promise((resolve) => this.terminal.write('', resolve));
|
|
528
|
-
if (this._destroyed || !this.terminal) return;
|
|
529
|
-
// The replay grew the buffer UPWARD, so what was row 0 is now `delta`
|
|
530
|
-
// rows down; land there and the recovered history sits above it.
|
|
531
|
-
const delta = this.terminal.buffer.active.length - rowsBefore;
|
|
532
|
-
if (delta > 0) this.terminal.scrollToLine(delta);
|
|
533
|
-
else this.terminal.scrollToTop();
|
|
534
|
-
} catch {
|
|
535
|
-
/* Best-effort — live output keeps arriving whatever happens here. */
|
|
536
|
-
} finally {
|
|
537
|
-
clearTimeout(abortTimer);
|
|
538
|
-
const queued = this._liveQueue ?? [];
|
|
539
|
-
this._liveQueue = null;
|
|
540
|
-
// After a replay, only frames that arrived after the capture are news;
|
|
541
|
-
// earlier ones are already in it. With no replay, every held frame is.
|
|
542
|
-
const cutoff = replayed ? capturedAt : 0;
|
|
543
|
-
for (const entry of queued) {
|
|
544
|
-
if (entry.at < cutoff) continue;
|
|
545
|
-
if (entry.clear) this.terminal?.clear();
|
|
546
|
-
else this.terminal?.write(entry.data);
|
|
547
|
-
}
|
|
548
|
-
// Settled after the queue flush so the marker is the last thing on
|
|
549
|
-
// screen: a close during the pull wrote nothing (_onSocketClosed() defers
|
|
550
|
-
// it while a load runs), and a replay's own `\x1bc` (flagged above) wipes
|
|
551
|
-
// one written before it, which would paint a fresh, current-looking
|
|
552
|
-
// history while onData keeps silently dropping every keystroke on the
|
|
553
|
-
// dead socket. With a trailing refresh pending (_endBufferLoad) the marker
|
|
554
|
-
// is left to that refresh, which writes it below its own replay.
|
|
555
|
-
this._stampMarkerIfOwed();
|
|
556
|
-
this._endBufferLoad();
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
// The `{t:'r'}` server-refresh path: clear, then replay. Two refresh
|
|
561
|
-
// frames in a row used to start two concurrent replays, each clearing
|
|
562
|
-
// the terminal under the other's chunked write. A refresh that arrives
|
|
563
|
-
// mid-replay is COALESCED into one trailing re-run rather than ignored:
|
|
564
|
-
// the in-flight fetch may predate the drop the new frame is reporting,
|
|
565
|
-
// and no further frame is coming to correct stale content.
|
|
566
|
-
_refreshBuffer() {
|
|
567
|
-
if (this._bufferLoading) {
|
|
568
|
-
this._bufferRefreshPending = true;
|
|
569
|
-
return;
|
|
570
|
-
}
|
|
571
|
-
this.terminal?.clear();
|
|
572
|
-
// The clear wipes a "disconnected" marker (a `{t:'r'}` frame can queue a
|
|
573
|
-
// trailing refresh behind a pull that the socket's close then interrupts),
|
|
574
|
-
// so a refresh on a closed socket owes it back once its replay is written.
|
|
575
|
-
if (this._wsClosed) this._markerOwed = true;
|
|
576
|
-
void this._loadBuffer();
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
// Local reflow only — no PTY resize frame. Split out so a divider drag
|
|
580
|
-
// can reflow both panes at the browser's paint rate (rAF) while sending
|
|
581
|
-
// the actual `{t:'z'}` resize once, at drag end, matching the primary
|
|
582
|
-
// pane's own convention (throttledResize in terminal-ui.js).
|
|
583
|
-
localFit() {
|
|
584
|
-
if (!this.fitAddon) return;
|
|
585
|
-
this.fitAddon.fit();
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
fit() {
|
|
589
|
-
this.localFit();
|
|
590
|
-
this._sendResize();
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
_sendResize() {
|
|
594
|
-
if (!this._wsReady || !this.fitAddon) return;
|
|
595
|
-
// One PTY cannot hold two sizes (mirrors sendResize's own
|
|
596
|
-
// detachedElsewhere yield in terminal-ui.js): the session got detached
|
|
597
|
-
// to its own window AFTER this split was opened, so its own window now
|
|
598
|
-
// owns the PTY's size and Pane B must stand aside.
|
|
599
|
-
if (this.detachedSessions?.has(this.sessionId)) return;
|
|
600
|
-
const dims = this.fitAddon.proposeDimensions();
|
|
601
|
-
if (!dims) return;
|
|
602
|
-
// Send the real proposed dimensions unclamped, matching the primary
|
|
603
|
-
// pane's convention (terminal-ui.js's getTerminalDimensions()) — the
|
|
604
|
-
// server enforces its own valid range ([1,500]/[1,200] in ws-routes.ts).
|
|
605
|
-
// A 40/10 floor here misreported Pane B's real width to the PTY at the
|
|
606
|
-
// divider's own reachable 20% floor position, causing real
|
|
607
|
-
// output-wrapping bugs.
|
|
608
|
-
this.ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows, v: 'desktop' }));
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
destroy() {
|
|
612
|
-
this._destroyed = true;
|
|
613
|
-
if (this._onWheel) {
|
|
614
|
-
this.mountEl?.removeEventListener('wheel', this._onWheel, { capture: true });
|
|
615
|
-
this._onWheel = null;
|
|
616
|
-
}
|
|
617
|
-
if (this.ws) {
|
|
618
|
-
this.ws.onopen = null;
|
|
619
|
-
this.ws.onmessage = null;
|
|
620
|
-
// onclose fires asynchronously AFTER close(); without this it ran
|
|
621
|
-
// its "disconnected" write against a pane already torn down.
|
|
622
|
-
this.ws.onclose = null;
|
|
623
|
-
this.ws.onerror = null;
|
|
624
|
-
this.ws.close();
|
|
625
|
-
this.ws = null;
|
|
626
|
-
}
|
|
627
|
-
if (this.terminal) {
|
|
628
|
-
this.terminal.dispose();
|
|
629
|
-
this.terminal = null;
|
|
630
|
-
}
|
|
631
|
-
this.fitAddon = null;
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
global.SplitTerminalPane = SplitTerminalPane;
|
|
636
|
-
})(window);
|
|
637
|
-
|
|
638
14
|
Object.assign(CodemanApp.prototype, {
|
|
639
15
|
/**
|
|
640
16
|
* Desktop-only gate, same shape as home-sessions.js's shouldShowHomeSessions
|
|
@@ -670,6 +46,9 @@ Object.assign(CodemanApp.prototype, {
|
|
|
670
46
|
// the old exact-node check below) bubbled straight through to
|
|
671
47
|
// `document` and self-closed the menu it just opened.
|
|
672
48
|
event?.stopPropagation();
|
|
49
|
+
// The tile grid and the split are never open together (tile-grid.js); the
|
|
50
|
+
// Split button shows as unavailable meanwhile.
|
|
51
|
+
if (this._tilesOwnTerminal?.()) return;
|
|
673
52
|
if (this._splitPane) {
|
|
674
53
|
this.closeSplitPane();
|
|
675
54
|
return;
|
|
@@ -763,6 +142,8 @@ Object.assign(CodemanApp.prototype, {
|
|
|
763
142
|
// see _applySplitButtonVisibility's comment for why both a JS check and
|
|
764
143
|
// a CSS backstop exist.
|
|
765
144
|
if (window.innerWidth < SPLIT_PANE_MIN_WIDTH) return;
|
|
145
|
+
// Never beside the tile grid: the main terminal is parked while it is open.
|
|
146
|
+
if (this._tilesOwnTerminal?.()) return;
|
|
766
147
|
// No active session means there is no `.terminal-wrap` to split against
|
|
767
148
|
// (the welcome overlay is showing) — without this, a split opened from
|
|
768
149
|
// the home screen still created the container and connected Pane B, just
|
|
@@ -777,7 +158,7 @@ Object.assign(CodemanApp.prototype, {
|
|
|
777
158
|
// B's own session tab while split can otherwise land here with
|
|
778
159
|
// sessionId === activeSessionId: two live WebSockets to the same
|
|
779
160
|
// session, each independently claiming PTY dimensions via its own `{t:'z',...}`
|
|
780
|
-
// resize frame. Refuse before creating any DOM or
|
|
161
|
+
// resize frame. Refuse before creating any DOM or TerminalTile.
|
|
781
162
|
if (sessionId === this.activeSessionId) return;
|
|
782
163
|
// The picker's own exclusions (buildSplitPickerSessions in constants.js),
|
|
783
164
|
// re-applied here: the menu can sit open while a listed session's CLI
|
|
@@ -803,22 +184,38 @@ Object.assign(CodemanApp.prototype, {
|
|
|
803
184
|
|
|
804
185
|
const paneB = document.createElement('div');
|
|
805
186
|
paneB.className = 'terminal-pane-b';
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
187
|
+
// Pane B's header: the harness logo, the name and the model, as on a grid
|
|
188
|
+
// tile, and the close button at a tile button's size.
|
|
189
|
+
const headerB = this._buildSplitPaneHeader();
|
|
190
|
+
const close = document.createElement('button');
|
|
191
|
+
close.type = 'button';
|
|
192
|
+
close.className = 'tile-btn tile-remove terminal-pane-b-close';
|
|
193
|
+
close.title = 'Close split';
|
|
194
|
+
close.setAttribute('aria-label', 'Close split');
|
|
195
|
+
close.textContent = '\u00D7';
|
|
196
|
+
close.addEventListener('click', () => this.closeSplitPane());
|
|
197
|
+
headerB.el.appendChild(close);
|
|
198
|
+
const bodyB = document.createElement('div');
|
|
199
|
+
bodyB.className = 'terminal-pane-b-container';
|
|
200
|
+
paneB.append(headerB.el, bodyB);
|
|
201
|
+
// Pane A is the main terminal, which has no header of its own: while the
|
|
202
|
+
// split is open it gets the same strip, so each session in the split view
|
|
203
|
+
// names its harness and model. It takes height from the main terminal,
|
|
204
|
+
// which the opening resize below fits through syncTerminalGeometry (#464);
|
|
205
|
+
// closeSplitPane gives it back.
|
|
206
|
+
const headerA = this._buildSplitPaneHeader();
|
|
207
|
+
headerA.el.classList.add('terminal-pane-a-header');
|
|
208
|
+
this._splitHeaders = { a: headerA, b: headerB };
|
|
813
209
|
|
|
814
210
|
parent.insertBefore(container, wrap);
|
|
211
|
+
wrap.insertBefore(headerA.el, wrap.firstChild);
|
|
815
212
|
container.appendChild(wrap);
|
|
816
213
|
wrap.style.flexBasis = '50%';
|
|
817
214
|
container.appendChild(divider);
|
|
818
215
|
container.appendChild(paneB);
|
|
819
216
|
paneB.style.flexBasis = '50%';
|
|
820
217
|
|
|
821
|
-
this._splitPane = new window.
|
|
218
|
+
this._splitPane = new window.TerminalTile(sessionId, bodyB, {
|
|
822
219
|
mode: session?.mode,
|
|
823
220
|
fontSettings: this.loadAppSettingsFromStorage?.() || {},
|
|
824
221
|
detachedSessions: this.detachedSessions,
|
|
@@ -828,6 +225,7 @@ Object.assign(CodemanApp.prototype, {
|
|
|
828
225
|
initial load — live output still arrives once/if the socket connects. */
|
|
829
226
|
});
|
|
830
227
|
this._splitSessionId = sessionId;
|
|
228
|
+
this._renderSplitChrome();
|
|
831
229
|
|
|
832
230
|
// Pane A just went from full width to 50%, but nothing has told its
|
|
833
231
|
// session's PTY/tmux window about it yet — the passive ResizeObserver in
|
|
@@ -855,6 +253,9 @@ Object.assign(CodemanApp.prototype, {
|
|
|
855
253
|
this._splitPane.destroy();
|
|
856
254
|
this._splitPane = null;
|
|
857
255
|
this._splitSessionId = null;
|
|
256
|
+
// Before the refit below, so the main terminal gets its full height back.
|
|
257
|
+
this._splitHeaders?.a.el.remove();
|
|
258
|
+
this._splitHeaders = null;
|
|
858
259
|
this._updateSplitButtonState(false);
|
|
859
260
|
|
|
860
261
|
const container = document.querySelector('.terminal-split-container');
|
|
@@ -865,14 +266,16 @@ Object.assign(CodemanApp.prototype, {
|
|
|
865
266
|
parent.insertBefore(wrap, container);
|
|
866
267
|
container.remove();
|
|
867
268
|
|
|
868
|
-
|
|
269
|
+
// Pane A takes the whole width back and, with its header strip gone, its
|
|
270
|
+
// whole height: through syncTerminalGeometry (#464), never a bare
|
|
271
|
+
// fitAddon.fit(). sendResize fits that way as its first step.
|
|
869
272
|
// The Pane-A-ends branch of the _onSessionDeleted wrapper below collapses the split
|
|
870
273
|
// while activeSessionId is still the id the server just removed, so a
|
|
871
274
|
// resize from here would be aimed at a session that no longer exists;
|
|
872
|
-
// the promoted session gets its own resize from selectSession()
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
}
|
|
275
|
+
// the promoted session gets its own resize from selectSession(), and the
|
|
276
|
+
// terminal is only refitted here.
|
|
277
|
+
if (options.skipPrimaryResize) this.syncTerminalGeometry?.();
|
|
278
|
+
else this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
|
|
876
279
|
},
|
|
877
280
|
|
|
878
281
|
// A click on .btn-split does one of two things — open the picker, or
|
|
@@ -880,6 +283,92 @@ Object.assign(CodemanApp.prototype, {
|
|
|
880
283
|
// nothing on the button said which. `.split-open` + aria-pressed give it
|
|
881
284
|
// the same active-state language as the codebase's other toggle buttons
|
|
882
285
|
// (keyboard-accessory's Ctrl key, the voice-input mic).
|
|
286
|
+
/**
|
|
287
|
+
* A split pane's header strip: the harness logo, the session name and the
|
|
288
|
+
* model, the three a grid tile's header shows. Built from nodes (the name
|
|
289
|
+
* and the model are untrusted text) and painted by _renderSplitChrome.
|
|
290
|
+
*/
|
|
291
|
+
_buildSplitPaneHeader() {
|
|
292
|
+
const el = document.createElement('div');
|
|
293
|
+
el.className = 'terminal-pane-b-header';
|
|
294
|
+
const harness = document.createElement('span');
|
|
295
|
+
harness.className = 'split-harness run-mode-dot';
|
|
296
|
+
harness.setAttribute('role', 'img');
|
|
297
|
+
const title = document.createElement('span');
|
|
298
|
+
title.className = 'split-title';
|
|
299
|
+
const name = document.createElement('span');
|
|
300
|
+
// `.session-name` is one of the translator's skipped surfaces (user text).
|
|
301
|
+
name.className = 'session-name';
|
|
302
|
+
// As on a tile: the name inside is never translated, the tooltip may be,
|
|
303
|
+
// and screen readers hear the model once, in the logo's accessible name.
|
|
304
|
+
const model = document.createElement('span');
|
|
305
|
+
model.className = 'split-model';
|
|
306
|
+
model.setAttribute('aria-hidden', 'true');
|
|
307
|
+
model.hidden = true;
|
|
308
|
+
const modelName = document.createElement('span');
|
|
309
|
+
modelName.setAttribute('data-i18n-skip', '');
|
|
310
|
+
model.appendChild(modelName);
|
|
311
|
+
title.append(name, model);
|
|
312
|
+
el.append(harness, title);
|
|
313
|
+
return { el, harness, name, model, modelName };
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Both split headers from their sessions: Pane A shows the active session,
|
|
318
|
+
* Pane B its own. Runs after every tab render, so a rename or a model
|
|
319
|
+
* change reaches them; unchanged values write nothing.
|
|
320
|
+
*/
|
|
321
|
+
_renderSplitChrome() {
|
|
322
|
+
const headers = this._splitHeaders;
|
|
323
|
+
if (!headers || !this._splitPane) return;
|
|
324
|
+
for (const [parts, id] of [
|
|
325
|
+
[headers.a, this.activeSessionId],
|
|
326
|
+
[headers.b, this._splitSessionId],
|
|
327
|
+
]) {
|
|
328
|
+
const session = id ? this.sessions.get(id) : null;
|
|
329
|
+
if (!session) continue;
|
|
330
|
+
const name = this.getSessionName?.(session) || session.name || 'Session';
|
|
331
|
+
if (parts.nameValue !== name) {
|
|
332
|
+
parts.nameValue = name;
|
|
333
|
+
parts.name.textContent = name;
|
|
334
|
+
}
|
|
335
|
+
this._paintSessionHarness(parts, session, 'split-harness');
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Paints a session header's harness logo and model: a grid tile's, and the
|
|
341
|
+
* split panes'. The logo is PR #532's `run-mode-dot <cliId>` slot (the id is
|
|
342
|
+
* data, never a branch), the model is text (describeSessionHarness,
|
|
343
|
+
* constants.js). Diffs against the values it last wrote, kept on `parts`,
|
|
344
|
+
* never against the DOM, which the translator may have rewritten: an
|
|
345
|
+
* unchanged session writes nothing, and this runs on every tab render.
|
|
346
|
+
*
|
|
347
|
+
* @param {{harness: HTMLElement, model: HTMLElement, modelName: HTMLElement}} parts - the
|
|
348
|
+
* header's nodes (the model's box and the name inside it); the memo lives here too
|
|
349
|
+
* @param {object} session - the session the header shows
|
|
350
|
+
* @param {string} logoClass - the header's own class for its logo
|
|
351
|
+
*/
|
|
352
|
+
_paintSessionHarness(parts, session, logoClass) {
|
|
353
|
+
const harness = window.CodemanSessionHarness.describeSessionHarness(session, window.__codemanCliCatalog);
|
|
354
|
+
const cls = `${logoClass} run-mode-dot${harness.id ? ` ${harness.id}` : ''}`;
|
|
355
|
+
if (parts.harnessClass !== cls) {
|
|
356
|
+
parts.harnessClass = cls;
|
|
357
|
+
parts.harness.className = cls;
|
|
358
|
+
}
|
|
359
|
+
if (parts.harnessTitle !== harness.title) {
|
|
360
|
+
parts.harnessTitle = harness.title;
|
|
361
|
+
parts.harness.title = harness.title;
|
|
362
|
+
parts.harness.setAttribute('aria-label', harness.title);
|
|
363
|
+
parts.model.title = harness.title;
|
|
364
|
+
}
|
|
365
|
+
if (parts.modelValue !== harness.model) {
|
|
366
|
+
parts.modelValue = harness.model;
|
|
367
|
+
parts.modelName.textContent = harness.model;
|
|
368
|
+
parts.model.hidden = !harness.model;
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
|
|
883
372
|
_updateSplitButtonState(open) {
|
|
884
373
|
const btn = document.querySelector('.btn-split');
|
|
885
374
|
if (!btn) return;
|
|
@@ -900,10 +389,9 @@ Object.assign(CodemanApp.prototype, {
|
|
|
900
389
|
// frame). Coalesced to one call per animation frame below — a raw
|
|
901
390
|
// mousemove stream fires far faster than the browser repaints, and
|
|
902
391
|
// without the rAF gate each event did a full xterm reflow on BOTH
|
|
903
|
-
// panes AND sent Pane B a `{t:'z'}` resize frame
|
|
904
|
-
//
|
|
905
|
-
//
|
|
906
|
-
// of each dragging across half a wide viewport.
|
|
392
|
+
// panes AND sent Pane B a `{t:'z'}` resize frame, which fanned out
|
|
393
|
+
// into a `tmux resize-window` child plus a SIGWINCH per frame, roughly
|
|
394
|
+
// fifty of each dragging across half a wide viewport.
|
|
907
395
|
const applyDragPercent = (clientX) => {
|
|
908
396
|
const container = divider.parentElement;
|
|
909
397
|
// The split can auto-collapse mid-drag (the other pane's session
|
|
@@ -1039,6 +527,15 @@ CodemanApp.prototype._onSessionDeleted = function (data) {
|
|
|
1039
527
|
// this, Pane A rebinds to a session that Pane B's independent WebSocket is
|
|
1040
528
|
// still attached to — two live WebSockets to one session, each claiming PTY
|
|
1041
529
|
// dimensions via its own `{t:'z',...}` resize frame.
|
|
530
|
+
// Every tab render (any session change: a rename, a model switch) refreshes
|
|
531
|
+
// the split headers too, the way tile-grid.js refreshes the tile headers.
|
|
532
|
+
const _splitOriginalRenderSessionTabsImmediate = CodemanApp.prototype._renderSessionTabsImmediate;
|
|
533
|
+
CodemanApp.prototype._renderSessionTabsImmediate = function (...args) {
|
|
534
|
+
const result = _splitOriginalRenderSessionTabsImmediate.apply(this, args);
|
|
535
|
+
this._renderSplitChrome?.();
|
|
536
|
+
return result;
|
|
537
|
+
};
|
|
538
|
+
|
|
1042
539
|
const _originalSelectSession = CodemanApp.prototype.selectSession;
|
|
1043
540
|
CodemanApp.prototype.selectSession = function (sessionId, ...args) {
|
|
1044
541
|
if (this._splitPane && this._splitSessionId === sessionId) {
|