mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-07 11:35:53 +00:00
Replace XTerm.js with headless terminal implementation
- Remove XTerm.js dependencies (@xterm/xterm, @xterm/addon-fit, @xterm/addon-web-links, asciinema-player) - Switch terminal component to use @xterm/headless for better performance and compatibility - Simplify build process by removing unused renderer and mobile-terminal components - Update package.json scripts to use asset bundling approach - Fix TypeScript imports and remove deprecated addons - Streamline terminal implementation for improved reliability 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
1cef390ed6
commit
d9d134ff2b
11 changed files with 301 additions and 1702 deletions
357
web/package-lock.json
generated
357
web/package-lock.json
generated
|
|
@ -9,11 +9,7 @@
|
|||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/headless": "^5.5.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"asciinema-player": "^3.7.0",
|
||||
"express": "^4.18.2",
|
||||
"lit": "^3.1.0",
|
||||
"node-pty": "^1.0.0",
|
||||
|
|
@ -30,6 +26,7 @@
|
|||
"@typescript-eslint/parser": "^8.34.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"chokidar": "^3.5.3",
|
||||
"chokidar-cli": "^3.0.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"esbuild": "^0.25.5",
|
||||
"eslint": "^9.29.0",
|
||||
|
|
@ -546,6 +543,7 @@
|
|||
"version": "7.27.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
|
||||
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
|
|
@ -2654,36 +2652,12 @@
|
|||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
||||
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-web-links": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz",
|
||||
"integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/headless": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-5.5.0.tgz",
|
||||
"integrity": "sha512-5xXB7kdQlFBP82ViMJTwwEc3gKCLGKR/eoxQm4zge7GPBl86tCdI0IdPJjoKd8mUSFXz5V7i/25sfsEkP4j46g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
|
|
@ -2834,16 +2808,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asciinema-player": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/asciinema-player/-/asciinema-player-3.10.0.tgz",
|
||||
"integrity": "sha512-shoOK6F606nDKZxDVM7JuGSCAyWLePoGRFNlV+FqiP5Sqvyn0BlE7wlbjZyd2X4P1iRhv/HKfVNtnQIxmgphRA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.21.0",
|
||||
"solid-js": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-types": {
|
||||
"version": "0.13.4",
|
||||
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
|
||||
|
|
@ -3407,6 +3371,240 @@
|
|||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar-cli": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar-cli/-/chokidar-cli-3.0.0.tgz",
|
||||
"integrity": "sha512-xVW+Qeh7z15uZRxHOkP93Ux8A0xbPzwK4GaqD8dQOYc34TlkqUhVSS59fK36DOp5WdJlrRzlYSy02Ht99FjZqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^3.5.2",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"yargs": "^13.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"chokidar": "index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar-cli/node_modules/ansi-regex": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz",
|
||||
"integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar-cli/node_modules/ansi-styles": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
|
||||
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^1.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar-cli/node_modules/cliui": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz",
|
||||
"integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^3.1.0",
|
||||
"strip-ansi": "^5.2.0",
|
||||
"wrap-ansi": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar-cli/node_modules/color-convert": {
|
||||
"version": "1.9.3",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
|
||||
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "1.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar-cli/node_modules/color-name": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
|
||||
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/chokidar-cli/node_modules/emoji-regex": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz",
|
||||
"integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/chokidar-cli/node_modules/find-up": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz",
|
||||
"integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar-cli/node_modules/is-fullwidth-code-point": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
|
||||
"integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar-cli/node_modules/locate-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz",
|
||||
"integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^3.0.0",
|
||||
"path-exists": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar-cli/node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar-cli/node_modules/p-locate": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz",
|
||||
"integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar-cli/node_modules/path-exists": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
|
||||
"integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar-cli/node_modules/string-width": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz",
|
||||
"integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^7.0.1",
|
||||
"is-fullwidth-code-point": "^2.0.0",
|
||||
"strip-ansi": "^5.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar-cli/node_modules/strip-ansi": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",
|
||||
"integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar-cli/node_modules/wrap-ansi": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz",
|
||||
"integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^3.2.0",
|
||||
"string-width": "^3.0.0",
|
||||
"strip-ansi": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar-cli/node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/chokidar-cli/node_modules/yargs": {
|
||||
"version": "13.3.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz",
|
||||
"integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^5.0.0",
|
||||
"find-up": "^3.0.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^3.0.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^13.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar-cli/node_modules/yargs-parser": {
|
||||
"version": "13.1.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz",
|
||||
"integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar/node_modules/glob-parent": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
|
|
@ -3865,12 +4063,6 @@
|
|||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/data-uri-to-buffer": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz",
|
||||
|
|
@ -3907,6 +4099,16 @@
|
|||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dedent": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz",
|
||||
|
|
@ -6901,6 +7103,13 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.debounce": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
||||
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.memoize": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
|
||||
|
|
@ -6915,6 +7124,13 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.throttle": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
|
||||
"integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/log-update": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
|
||||
|
|
@ -8351,6 +8567,13 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.10",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
||||
|
|
@ -8598,27 +8821,6 @@
|
|||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/seroval": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz",
|
||||
"integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/seroval-plugins": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.3.2.tgz",
|
||||
"integrity": "sha512-0QvCV2lM3aj/U3YozDiVwx9zpH0q8A60CTWIv4Jszj/givcudPb48B+rkU5D51NJ0pTpweGMttHjboPa9/zoIQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"seroval": "^1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "1.16.2",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
|
||||
|
|
@ -8634,6 +8836,13 @@
|
|||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
|
|
@ -8881,17 +9090,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/solid-js": {
|
||||
"version": "1.9.7",
|
||||
"resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.7.tgz",
|
||||
"integrity": "sha512-/saTKi8iWEM233n5OSi1YHCCuh66ZIQ7aK2hsToPe4tqGm7qAejU1SwNuTPivbWAYq7SjuHVVYxxuZQNRbICiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.1.0",
|
||||
"seroval": "~1.3.0",
|
||||
"seroval-plugins": "~1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
|
|
@ -9863,6 +10061,13 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
|
|
|
|||
|
|
@ -4,24 +4,19 @@
|
|||
"description": "Web frontend for terminal multiplexer",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
"dev": "npm run build:css && concurrently --kill-others-on-fail \"npm run watch:css\" \"npm run bundle:watch\" \"npm run watch:server\"",
|
||||
"dev": "npm run bundle:assets && npm run bundle:css && concurrently --kill-others-on-fail \"npm run watch:css\" \"npm run watch:assets\" \"npm run bundle:client -- --watch\" \"npm run bundle:test -- --watch\" \"npm run watch:server\"",
|
||||
"watch:server": "tsx watch src/server.ts",
|
||||
"watch:client": "tsc -p tsconfig.client.json --watch --preserveWatchOutput",
|
||||
"watch:css": "npx tailwindcss -i ./src/input.css -o ./public/bundle/output.css --watch",
|
||||
"build": "npm run build:css && npm run build:client && npm run build:server",
|
||||
"build:server": "tsc",
|
||||
"build:client": "tsc -p tsconfig.client.json",
|
||||
"build:css": "npx tailwindcss -i ./src/input.css -o ./public/bundle/output.css --minify",
|
||||
"bundle": "npm run bundle:client && npm run bundle:renderer && npm run bundle:test",
|
||||
"bundle:client": "esbuild src/client/app-entry.ts --bundle --outfile=public/bundle/client-bundle.js --format=esm --sourcemap --external:@xterm/xterm/css/xterm.css",
|
||||
"bundle:renderer": "esbuild src/client/renderer-entry.ts --bundle --outfile=public/bundle/renderer.js --format=esm --sourcemap",
|
||||
"bundle:test": "esbuild src/client/test-terminals-entry.ts --bundle --outfile=public/bundle/terminal.js --format=esm --sourcemap",
|
||||
"bundle:watch": "concurrently \"npm run bundle:client -- --watch\" \"npm run bundle:renderer -- --watch\" \"npm run bundle:test -- --watch\"",
|
||||
"watch:assets": "chokidar 'src/client/assets/**/*' -c 'npm run bundle:assets'",
|
||||
"clean": "rm -rf public/* && rm -rf dist/",
|
||||
"build": "npm run bundle",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"bundle": "npm run clean && npm run bundle:assets && npm run bundle:css && npm run bundle:client && npm run bundle:test",
|
||||
"bundle:assets": "mkdir -p public && cp -r src/client/assets/* public/",
|
||||
"bundle:css": "mkdir -p public/bundle && npx tailwindcss -i ./src/input.css -o ./public/bundle/output.css --minify",
|
||||
"bundle:client": "mkdir -p public/bundle && esbuild src/client/app-entry.ts --bundle --outfile=public/bundle/client-bundle.js --format=esm --sourcemap",
|
||||
"bundle:test": "mkdir -p public/bundle && esbuild src/client/test-terminals-entry.ts --bundle --outfile=public/bundle/terminal.js --format=esm --sourcemap",
|
||||
"start": "node dist/server.js",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:mobile-terminal": "jest src/client/components/__tests__/mobile-terminal.test.ts",
|
||||
"test:components": "jest src/client/components",
|
||||
"lint": "eslint 'src/**/*.{ts,tsx}'",
|
||||
"lint:fix": "eslint 'src/**/*.{ts,tsx}' --fix",
|
||||
"format": "prettier --write 'src/**/*.{ts,tsx,js,jsx,json,css,md}'",
|
||||
|
|
@ -29,11 +24,7 @@
|
|||
"pre-commit": "./scripts/pre-commit-check.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/headless": "^5.5.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"asciinema-player": "^3.7.0",
|
||||
"express": "^4.18.2",
|
||||
"lit": "^3.1.0",
|
||||
"node-pty": "^1.0.0",
|
||||
|
|
@ -50,6 +41,7 @@
|
|||
"@typescript-eslint/parser": "^8.34.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"chokidar": "^3.5.3",
|
||||
"chokidar-cli": "^3.0.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"esbuild": "^0.25.5",
|
||||
"eslint": "^9.29.0",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import './components/session-view.js';
|
|||
import './components/session-card.js';
|
||||
|
||||
import type { Session } from './components/session-list.js';
|
||||
import type { SessionCard } from './components/session-card.js';
|
||||
|
||||
@customElement('vibetunnel-app')
|
||||
export class VibeTunnelApp extends LitElement {
|
||||
|
|
@ -180,9 +181,9 @@ export class VibeTunnelApp extends LitElement {
|
|||
|
||||
private async handleKillAll() {
|
||||
// Find all session cards and trigger their kill buttons
|
||||
const sessionCards = this.querySelectorAll('session-card');
|
||||
const sessionCards = this.querySelectorAll<SessionCard>('session-card');
|
||||
|
||||
sessionCards.forEach((card: Element) => {
|
||||
sessionCards.forEach((card: SessionCard) => {
|
||||
// Check if this session is running
|
||||
if (card.session && card.session.status === 'running') {
|
||||
// Find all buttons within this card and look for the kill button
|
||||
|
|
|
|||
|
|
@ -1,815 +0,0 @@
|
|||
import { LitElement, html, nothing } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { ScaleFitAddon } from '../scale-fit-addon.js';
|
||||
|
||||
// Simplified - only fit-both mode
|
||||
|
||||
@customElement('responsive-terminal')
|
||||
export class ResponsiveTerminal extends LitElement {
|
||||
// Disable shadow DOM for Tailwind compatibility
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@property({ type: String }) sessionId = '';
|
||||
@property({ type: Number }) cols = 80;
|
||||
@property({ type: Number }) rows = 24;
|
||||
@property({ type: Number }) fontSize = 14;
|
||||
// Removed fitMode - always fit-both
|
||||
@property({ type: Boolean }) showControls = false;
|
||||
@property({ type: Boolean }) enableInput = false;
|
||||
@property({ type: String }) containerClass = '';
|
||||
|
||||
@state() private terminal: Terminal | null = null;
|
||||
@state() private scaleFitAddon: ScaleFitAddon | null = null;
|
||||
@state() private isMobile = false;
|
||||
@state() private touches = new Map<number, Touch>();
|
||||
@state() private currentTerminalSize = { cols: 80, rows: 24 };
|
||||
@state() private actualLineHeight = 16;
|
||||
@state() private touchCount = 0;
|
||||
@state() private terminalStatus = 'Initializing...';
|
||||
|
||||
// Inertial scrolling state
|
||||
private velocity = 0;
|
||||
private lastTouchTime = 0;
|
||||
private momentumAnimationId: number | null = null;
|
||||
|
||||
// Long press detection for text selection
|
||||
private longPressTimer: number | null = null;
|
||||
private longPressThreshold = 500; // ms
|
||||
private isInSelectionMode = false;
|
||||
|
||||
private container: HTMLElement | null = null;
|
||||
private wrapper: HTMLElement | null = null;
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
private boundCopyHandler: ((e: ClipboardEvent) => void) | null = null;
|
||||
private resizeTimeout: number | null = null;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.detectMobile();
|
||||
this.currentTerminalSize = { cols: this.cols, rows: this.rows };
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.cleanup();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private detectMobile() {
|
||||
this.isMobile = window.innerWidth <= 768 || 'ontouchstart' in window;
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
this.resizeObserver = null;
|
||||
}
|
||||
if (this.terminal) {
|
||||
this.terminal.dispose();
|
||||
this.terminal = null;
|
||||
}
|
||||
// Remove copy event listener
|
||||
if (this.boundCopyHandler) {
|
||||
document.removeEventListener('copy', this.boundCopyHandler);
|
||||
this.boundCopyHandler = null;
|
||||
}
|
||||
// Stop momentum animation
|
||||
if (this.momentumAnimationId) {
|
||||
cancelAnimationFrame(this.momentumAnimationId);
|
||||
this.momentumAnimationId = null;
|
||||
}
|
||||
// Clear long press timer
|
||||
if (this.longPressTimer) {
|
||||
clearTimeout(this.longPressTimer);
|
||||
this.longPressTimer = null;
|
||||
}
|
||||
this.scaleFitAddon = null;
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string, unknown>) {
|
||||
if (changedProperties.has('cols') || changedProperties.has('rows')) {
|
||||
this.currentTerminalSize = { cols: this.cols, rows: this.rows };
|
||||
if (this.terminal) {
|
||||
this.reinitializeTerminal();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.initializeTerminal();
|
||||
}
|
||||
|
||||
private async initializeTerminal() {
|
||||
try {
|
||||
this.terminalStatus = 'Initializing...';
|
||||
this.requestUpdate();
|
||||
|
||||
this.container = this.querySelector('#terminal-container') as HTMLElement;
|
||||
this.wrapper = this.querySelector('#terminal-wrapper') as HTMLElement;
|
||||
|
||||
if (!this.container || !this.wrapper) {
|
||||
throw new Error('Terminal container or wrapper not found');
|
||||
}
|
||||
|
||||
await this.setupTerminal();
|
||||
this.setupTouchHandling();
|
||||
this.setupResize();
|
||||
this.generateMockData();
|
||||
|
||||
this.terminalStatus = 'Ready';
|
||||
this.requestUpdate();
|
||||
} catch (error) {
|
||||
this.terminalStatus = `Error: ${error instanceof Error ? error.message : String(error)}`;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private async reinitializeTerminal() {
|
||||
if (this.terminal) {
|
||||
this.terminal.clear();
|
||||
this.fitTerminal();
|
||||
this.generateMockData();
|
||||
}
|
||||
}
|
||||
|
||||
private async setupTerminal() {
|
||||
// EXACT terminal config from working file
|
||||
this.terminal = new Terminal({
|
||||
cursorBlink: false,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Consolas, "Liberation Mono", monospace',
|
||||
lineHeight: 1.2,
|
||||
scrollback: 10000,
|
||||
// Disable built-in link handling completely
|
||||
linkHandler: null,
|
||||
windowOptions: {},
|
||||
theme: {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#00ff00',
|
||||
black: '#000000',
|
||||
red: '#f14c4c',
|
||||
green: '#23d18b',
|
||||
yellow: '#f5f543',
|
||||
blue: '#3b8eea',
|
||||
magenta: '#d670d6',
|
||||
cyan: '#29b8db',
|
||||
white: '#e5e5e5',
|
||||
},
|
||||
});
|
||||
|
||||
// Open terminal first
|
||||
if (this.wrapper) {
|
||||
this.terminal.open(this.wrapper);
|
||||
}
|
||||
|
||||
// Always disable default terminal handlers first
|
||||
this.disableDefaultTerminalHandlers();
|
||||
|
||||
// Fit terminal to container
|
||||
this.fitTerminal();
|
||||
}
|
||||
|
||||
private disableDefaultTerminalHandlers() {
|
||||
if (!this.terminal || !this.wrapper) return;
|
||||
|
||||
// Back to aggressive approach - disable all XTerm touch handling so we can control everything
|
||||
const terminalEl = this.wrapper.querySelector('.xterm');
|
||||
const screenEl = this.wrapper.querySelector('.xterm-screen');
|
||||
const rowsEl = this.wrapper.querySelector('.xterm-rows');
|
||||
const textareaEl = this.wrapper.querySelector('.xterm-helper-textarea');
|
||||
|
||||
if (terminalEl) {
|
||||
// Disable all default behaviors on terminal elements
|
||||
[terminalEl, screenEl, rowsEl].forEach((el) => {
|
||||
if (el) {
|
||||
el.addEventListener('touchstart', (e) => e.preventDefault(), { passive: false });
|
||||
el.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false });
|
||||
el.addEventListener('touchend', (e) => e.preventDefault(), { passive: false });
|
||||
el.addEventListener('wheel', this.handleWheel.bind(this), { passive: false });
|
||||
el.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||
}
|
||||
});
|
||||
|
||||
// Disable the hidden textarea that XTerm uses for input
|
||||
if (textareaEl) {
|
||||
textareaEl.disabled = true;
|
||||
textareaEl.readOnly = true;
|
||||
textareaEl.style.pointerEvents = 'none';
|
||||
textareaEl.addEventListener('focus', (e) => {
|
||||
e.preventDefault();
|
||||
textareaEl.blur();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setupTouchHandling() {
|
||||
if (!this.wrapper) return;
|
||||
|
||||
// ONLY attach touch handlers to the terminal wrapper (the XTerm content area)
|
||||
// This way buttons and other elements outside aren't affected
|
||||
this.wrapper.addEventListener('touchstart', this.handleTouchStart.bind(this), {
|
||||
passive: false,
|
||||
});
|
||||
this.wrapper.addEventListener('touchmove', this.handleTouchMove.bind(this), { passive: false });
|
||||
this.wrapper.addEventListener('touchend', this.handleTouchEnd.bind(this), { passive: false });
|
||||
this.wrapper.addEventListener('touchcancel', this.handleTouchEnd.bind(this), {
|
||||
passive: false,
|
||||
});
|
||||
|
||||
// Prevent context menu ONLY on terminal wrapper
|
||||
this.wrapper.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||
|
||||
// Mouse wheel ONLY on terminal wrapper
|
||||
this.wrapper.addEventListener('wheel', this.handleWheel.bind(this) as EventListener, {
|
||||
passive: false,
|
||||
});
|
||||
|
||||
// Copy support for desktop
|
||||
this.boundCopyHandler = this.handleCopy.bind(this);
|
||||
document.addEventListener('copy', this.boundCopyHandler);
|
||||
}
|
||||
|
||||
private setupResize() {
|
||||
if (!this.container) return;
|
||||
|
||||
// Use debounced ResizeObserver to avoid infinite resize loops
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
// Debounce to avoid resize loops
|
||||
clearTimeout(this.resizeTimeout);
|
||||
this.resizeTimeout = setTimeout(() => {
|
||||
this.fitTerminal();
|
||||
}, 50);
|
||||
});
|
||||
this.resizeObserver.observe(this.container);
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
this.detectMobile();
|
||||
this.fitTerminal();
|
||||
});
|
||||
}
|
||||
|
||||
private fitTerminal() {
|
||||
if (!this.terminal || !this.container) return;
|
||||
|
||||
// Use the actual rendered dimensions of the component itself
|
||||
const hostElement = this as HTMLElement;
|
||||
const containerWidth = hostElement.clientWidth;
|
||||
const containerHeight = hostElement.clientHeight;
|
||||
|
||||
// EXACT copy of working fitTerminal() method from mobile-terminal-test-fixed.html
|
||||
|
||||
// Resize to target dimensions first
|
||||
this.terminal.resize(this.currentTerminalSize.cols, this.currentTerminalSize.rows);
|
||||
|
||||
// Calculate font size to fit the target columns exactly in container width
|
||||
const charWidthRatio = 0.6; // More conservative estimate
|
||||
const calculatedFontSize = containerWidth / (this.currentTerminalSize.cols * charWidthRatio);
|
||||
const fontSize = Math.max(1, calculatedFontSize); // Allow very small fonts
|
||||
|
||||
// Apply font size
|
||||
this.terminal.options.fontSize = fontSize;
|
||||
if (this.terminal.element) {
|
||||
this.terminal.element.style.fontSize = `${fontSize}px`;
|
||||
}
|
||||
|
||||
// Calculate line height and rows
|
||||
const lineHeight = fontSize * 1.2;
|
||||
this.actualLineHeight = lineHeight;
|
||||
const rows = Math.max(1, Math.floor(containerHeight / lineHeight));
|
||||
|
||||
this.terminal.resize(this.currentTerminalSize.cols, rows);
|
||||
|
||||
// Force a refresh to apply the new sizing
|
||||
requestAnimationFrame(() => {
|
||||
if (this.terminal) {
|
||||
this.terminal.refresh(0, this.terminal.rows - 1);
|
||||
|
||||
// After rendering, check if we need to adjust row count
|
||||
setTimeout(() => {
|
||||
const xtermRows = this.terminal.element?.querySelector('.xterm-rows');
|
||||
const firstRow = xtermRows?.children[0];
|
||||
if (firstRow && xtermRows) {
|
||||
// Use the REAL CSS line-height, not offsetHeight
|
||||
const realLineHeight =
|
||||
parseFloat(getComputedStyle(firstRow).lineHeight) || firstRow.offsetHeight;
|
||||
const rowsThatFit = Math.floor(containerHeight / realLineHeight);
|
||||
|
||||
if (rowsThatFit !== this.terminal.rows) {
|
||||
this.actualLineHeight = realLineHeight;
|
||||
this.terminal.resize(this.currentTerminalSize.cols, rowsThatFit);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Removed - not needed in simplified version
|
||||
|
||||
private handleTouchStart(e: TouchEvent) {
|
||||
// If in selection mode, let browser handle it
|
||||
if (this.isInSelectionMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
// Stop any ongoing momentum scrolling
|
||||
if (this.momentumAnimationId) {
|
||||
cancelAnimationFrame(this.momentumAnimationId);
|
||||
this.momentumAnimationId = null;
|
||||
}
|
||||
this.velocity = 0;
|
||||
this.lastTouchTime = Date.now();
|
||||
|
||||
// Clear any existing long press timer
|
||||
if (this.longPressTimer) {
|
||||
clearTimeout(this.longPressTimer);
|
||||
this.longPressTimer = null;
|
||||
}
|
||||
|
||||
for (let i = 0; i < e.changedTouches.length; i++) {
|
||||
const touch = e.changedTouches[i];
|
||||
this.touches.set(touch.identifier, {
|
||||
x: touch.clientX,
|
||||
y: touch.clientY,
|
||||
startX: touch.clientX,
|
||||
startY: touch.clientY,
|
||||
startTime: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
// Start long press detection for single finger
|
||||
if (this.touches.size === 1 && this.isMobile) {
|
||||
this.longPressTimer = setTimeout(() => {
|
||||
this.startTextSelection();
|
||||
}, this.longPressThreshold);
|
||||
}
|
||||
|
||||
this.touchCount = this.touches.size;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private handleTouchMove(e: TouchEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
// If we moved, cancel long press detection (unless already in selection mode)
|
||||
if (this.longPressTimer && !this.isInSelectionMode) {
|
||||
const touch = Array.from(this.touches.values())[0];
|
||||
if (touch) {
|
||||
const deltaX = Math.abs(e.changedTouches[0].clientX - touch.startX);
|
||||
const deltaY = Math.abs(e.changedTouches[0].clientY - touch.startY);
|
||||
|
||||
// Cancel long press if significant movement (more than 5px)
|
||||
if (deltaX > 5 || deltaY > 5) {
|
||||
clearTimeout(this.longPressTimer);
|
||||
this.longPressTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < e.changedTouches.length; i++) {
|
||||
const touch = e.changedTouches[i];
|
||||
const stored = this.touches.get(touch.identifier);
|
||||
if (stored) {
|
||||
stored.x = touch.clientX;
|
||||
stored.y = touch.clientY;
|
||||
}
|
||||
}
|
||||
|
||||
// Only handle scrolling if we're NOT in selection mode
|
||||
if (this.touches.size === 1 && !this.isInSelectionMode) {
|
||||
this.handleSingleTouch();
|
||||
}
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private handleTouchEnd(e: TouchEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
// Clear long press timer if still running
|
||||
if (this.longPressTimer) {
|
||||
clearTimeout(this.longPressTimer);
|
||||
this.longPressTimer = null;
|
||||
}
|
||||
|
||||
for (let i = 0; i < e.changedTouches.length; i++) {
|
||||
const touch = e.changedTouches[i];
|
||||
this.touches.delete(touch.identifier);
|
||||
}
|
||||
|
||||
// Start inertial scrolling if we had velocity on mobile and NOT in selection mode
|
||||
if (
|
||||
this.touches.size === 0 &&
|
||||
this.isMobile &&
|
||||
Math.abs(this.velocity) > 0.5 &&
|
||||
!this.isInSelectionMode
|
||||
) {
|
||||
this.startMomentumScroll();
|
||||
}
|
||||
|
||||
this.touchCount = this.touches.size;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private handleSingleTouch() {
|
||||
const touch = Array.from(this.touches.values())[0];
|
||||
const deltaY = touch.y - touch.startY;
|
||||
const currentTime = Date.now();
|
||||
|
||||
// Only handle vertical scroll
|
||||
if (Math.abs(deltaY) > 2) {
|
||||
// Calculate velocity for inertial scrolling
|
||||
const timeDelta = currentTime - this.lastTouchTime;
|
||||
if (timeDelta > 0) {
|
||||
this.velocity = deltaY / timeDelta; // pixels per millisecond
|
||||
}
|
||||
this.lastTouchTime = currentTime;
|
||||
|
||||
this.handleScroll(deltaY);
|
||||
touch.startY = touch.y; // Update immediately for smooth tracking
|
||||
}
|
||||
}
|
||||
|
||||
private handleScroll(deltaY: number) {
|
||||
if (!this.terminal) return;
|
||||
|
||||
// Simple, direct scroll calculation like the working version
|
||||
const scrollLines = Math.round(deltaY / this.actualLineHeight);
|
||||
|
||||
if (scrollLines !== 0) {
|
||||
this.terminal.scrollLines(-scrollLines); // Negative for natural scroll direction
|
||||
}
|
||||
}
|
||||
|
||||
private startMomentumScroll() {
|
||||
// Stop any existing momentum animation
|
||||
if (this.momentumAnimationId) {
|
||||
cancelAnimationFrame(this.momentumAnimationId);
|
||||
}
|
||||
|
||||
// Start momentum animation
|
||||
const animate = () => {
|
||||
if (!this.terminal || Math.abs(this.velocity) < 0.01) {
|
||||
this.momentumAnimationId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply momentum scroll
|
||||
const scrollDelta = this.velocity * 16; // 16ms frame time
|
||||
this.handleScroll(scrollDelta);
|
||||
|
||||
// Apply friction to slow down
|
||||
this.velocity *= 0.95; // Friction coefficient
|
||||
|
||||
// Continue animation
|
||||
this.momentumAnimationId = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
this.momentumAnimationId = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
private startTextSelection() {
|
||||
if (!this.terminal || !this.wrapper) return;
|
||||
|
||||
// Provide haptic feedback
|
||||
if (navigator.vibrate) {
|
||||
navigator.vibrate(50);
|
||||
}
|
||||
|
||||
const touch = Array.from(this.touches.values())[0];
|
||||
if (!touch) return;
|
||||
|
||||
// Enable browser selection for both desktop and mobile
|
||||
this.isInSelectionMode = true;
|
||||
this.wrapper.classList.add('selection-mode');
|
||||
|
||||
const terminalEl = this.wrapper.querySelector('.xterm');
|
||||
const screenEl = this.wrapper.querySelector('.xterm-screen');
|
||||
const rowsEl = this.wrapper.querySelector('.xterm-rows');
|
||||
|
||||
[terminalEl, screenEl, rowsEl].forEach((el) => {
|
||||
if (el) {
|
||||
const element = el as HTMLElement;
|
||||
element.style.pointerEvents = 'auto';
|
||||
element.style.webkitUserSelect = 'text';
|
||||
element.style.userSelect = 'text';
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Text selection mode enabled');
|
||||
}
|
||||
|
||||
private exitTextSelection() {
|
||||
this.isInSelectionMode = false;
|
||||
|
||||
// Clean up browser selection
|
||||
if (this.wrapper) {
|
||||
this.wrapper.classList.remove('selection-mode');
|
||||
|
||||
const terminalEl = this.wrapper.querySelector('.xterm');
|
||||
const screenEl = this.wrapper.querySelector('.xterm-screen');
|
||||
const rowsEl = this.wrapper.querySelector('.xterm-rows');
|
||||
|
||||
[terminalEl, screenEl, rowsEl].forEach((el) => {
|
||||
if (el) {
|
||||
const element = el as HTMLElement;
|
||||
element.style.pointerEvents = '';
|
||||
element.style.webkitUserSelect = '';
|
||||
element.style.userSelect = '';
|
||||
element.style.webkitTouchCallout = '';
|
||||
}
|
||||
});
|
||||
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
}
|
||||
|
||||
console.log('Text selection mode deactivated');
|
||||
}
|
||||
|
||||
private handleWheel(e: Event) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const wheelEvent = e as WheelEvent;
|
||||
|
||||
if (this.terminal) {
|
||||
// EXACT same logic as working version
|
||||
let scrollLines = 0;
|
||||
|
||||
if (wheelEvent.deltaMode === WheelEvent.DOM_DELTA_LINE) {
|
||||
// deltaY is already in lines
|
||||
scrollLines = Math.round(wheelEvent.deltaY);
|
||||
} else if (wheelEvent.deltaMode === WheelEvent.DOM_DELTA_PIXEL) {
|
||||
// deltaY is in pixels, convert to lines
|
||||
scrollLines = Math.round(wheelEvent.deltaY / this.actualLineHeight);
|
||||
} else if (wheelEvent.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
|
||||
// deltaY is in pages, convert to lines
|
||||
scrollLines = Math.round(wheelEvent.deltaY * this.terminal.rows);
|
||||
}
|
||||
|
||||
if (scrollLines !== 0) {
|
||||
this.terminal.scrollLines(scrollLines);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleCopy(e: ClipboardEvent) {
|
||||
if (!this.terminal) return;
|
||||
|
||||
// Get selected text from XTerm regardless of focus
|
||||
const selection = this.terminal.getSelection();
|
||||
|
||||
if (selection && selection.trim()) {
|
||||
e.preventDefault();
|
||||
e.clipboardData?.setData('text/plain', selection);
|
||||
}
|
||||
}
|
||||
|
||||
private handleSizeChange(cols: number, rows: number) {
|
||||
// Old method that regenerates content - keeping for backward compatibility
|
||||
this.cols = cols;
|
||||
this.rows = rows;
|
||||
this.currentTerminalSize = { cols, rows };
|
||||
|
||||
if (this.terminal) {
|
||||
this.terminal.clear();
|
||||
this.fitTerminal();
|
||||
this.generateMockData();
|
||||
}
|
||||
}
|
||||
|
||||
// New method for viewport size changes without content regeneration
|
||||
public setViewportSize(cols: number, rows: number) {
|
||||
this.cols = cols;
|
||||
this.rows = rows;
|
||||
this.currentTerminalSize = { cols, rows };
|
||||
|
||||
if (this.terminal) {
|
||||
// Just resize the viewport - XTerm will reflow the existing content
|
||||
this.fitTerminal();
|
||||
}
|
||||
|
||||
this.requestUpdate(); // Update the UI to show new size in status
|
||||
}
|
||||
|
||||
// Removed fitMode handling - only fit-both mode now
|
||||
|
||||
private generateMockData() {
|
||||
if (!this.terminal) return;
|
||||
|
||||
// Always generate content for 120x40, regardless of current viewport size
|
||||
// This way we can see XTerm reflow the same content for different viewport sizes
|
||||
const contentCols = 120;
|
||||
const contentRows = 40;
|
||||
const numPages = 10;
|
||||
|
||||
let lineNumber = 1;
|
||||
|
||||
for (let page = 1; page <= numPages; page++) {
|
||||
// Page header with special characters and highlighting
|
||||
const headerLine = '\x1b[43m◄\x1b[0m' + '='.repeat(contentCols - 2) + '\x1b[43m►\x1b[0m';
|
||||
this.terminal.writeln(headerLine);
|
||||
lineNumber++;
|
||||
|
||||
// Fill the page with numbered lines (rows - 2 for header/footer only)
|
||||
const contentLines = contentRows - 2;
|
||||
for (let line = 1; line <= contentLines; line++) {
|
||||
this.terminal.writeln(
|
||||
`Line ${lineNumber.toString().padStart(4, '0')}: Content originally sized for ${contentCols}x${contentRows} terminal - watch it reflow!`
|
||||
);
|
||||
lineNumber++;
|
||||
}
|
||||
|
||||
// Page footer with special characters and highlighting
|
||||
const footerLine = '\x1b[43m◄\x1b[0m' + '='.repeat(contentCols - 2) + '\x1b[43m►\x1b[0m';
|
||||
this.terminal.writeln(footerLine);
|
||||
lineNumber++;
|
||||
}
|
||||
|
||||
this.terminal.writeln('\x1b[1;31m>>> END OF ALL CONTENT - THIS IS THE BOTTOM <<<\x1b[0m');
|
||||
this.terminal.writeln(
|
||||
'\x1b[1;33mIf you can see this, you reached the end. Scroll up to see all pages.\x1b[0m'
|
||||
);
|
||||
|
||||
// Ensure we can scroll to the bottom
|
||||
this.terminal.scrollToBottom();
|
||||
}
|
||||
|
||||
// Public API methods
|
||||
public write(data: string) {
|
||||
if (this.terminal) {
|
||||
this.terminal.write(data);
|
||||
}
|
||||
}
|
||||
|
||||
public clear() {
|
||||
if (this.terminal) {
|
||||
this.terminal.clear();
|
||||
}
|
||||
}
|
||||
|
||||
public getTerminal(): Terminal | null {
|
||||
return this.terminal;
|
||||
}
|
||||
|
||||
render() {
|
||||
// EXACT aggressive CSS constraints from working file (keep as CSS for XTerm)
|
||||
const aggressiveXTermStyles = html`
|
||||
<style>
|
||||
/* Hide XTerm scrollbar */
|
||||
.xterm-viewport {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
/* Ensure XTerm fills container */
|
||||
.xterm {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Disable text selection completely */
|
||||
* {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
/* When in selection mode, allow text selection */
|
||||
.selection-mode * {
|
||||
-webkit-user-select: text !important;
|
||||
-moz-user-select: text !important;
|
||||
-ms-user-select: text !important;
|
||||
user-select: text !important;
|
||||
}
|
||||
|
||||
/* Ensure XTerm selection styling works */
|
||||
.selection-mode .xterm-selection {
|
||||
position: absolute !important;
|
||||
background-color: rgba(255, 255, 255, 0.3) !important;
|
||||
pointer-events: none !important;
|
||||
z-index: 10 !important;
|
||||
}
|
||||
|
||||
/* In selection mode, allow pointer events on XTerm elements */
|
||||
.selection-mode .xterm-screen,
|
||||
.selection-mode .xterm-rows,
|
||||
.selection-mode .xterm-row,
|
||||
.selection-mode .xterm span {
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
/* Ensure all XTerm elements don't interfere with touch by default */
|
||||
.xterm-screen,
|
||||
.xterm-rows,
|
||||
.xterm-row,
|
||||
.xterm span {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
/* Aggressively force XTerm to stay within container bounds */
|
||||
.xterm {
|
||||
position: relative !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
max-width: 100% !important;
|
||||
max-height: 100% !important;
|
||||
min-width: 0 !important;
|
||||
min-height: 0 !important;
|
||||
overflow: hidden !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
position: relative !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
max-width: 100% !important;
|
||||
max-height: 100% !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
max-height: 100% !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.xterm .xterm-rows {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
max-height: 100% !important;
|
||||
height: 100% !important;
|
||||
overflow: hidden !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
return html`
|
||||
${aggressiveXTermStyles}
|
||||
<div id="terminal-container" class="relative w-full h-full bg-gray-900">
|
||||
${this.showControls ? this.renderControls() : nothing} ${this.renderStatus()}
|
||||
<div id="terminal-wrapper" class="w-full h-full touch-none"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderControls() {
|
||||
const sizeOptions = [
|
||||
{ cols: 60, rows: 15, label: '60x15' },
|
||||
{ cols: 80, rows: 20, label: '80x20' },
|
||||
{ cols: 120, rows: 40, label: '120x40' },
|
||||
{ cols: 160, rows: 50, label: '160x50' },
|
||||
];
|
||||
|
||||
// Use position: fixed like working version so controls don't affect layout
|
||||
return html`
|
||||
<div class="fixed top-2 left-2 z-50 flex gap-1 flex-wrap max-w-xs">
|
||||
${sizeOptions.map(
|
||||
(size) => html`
|
||||
<button
|
||||
class="px-2 py-1 text-xs font-mono bg-black/80 text-white border border-gray-600 rounded hover:bg-gray-800 cursor-pointer
|
||||
${this.cols === size.cols && this.rows === size.rows
|
||||
? 'bg-blue-600 border-blue-400'
|
||||
: ''}"
|
||||
@click=${() => this.handleSizeChange(size.cols, size.rows)}
|
||||
>
|
||||
${size.label}
|
||||
</button>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderStatus() {
|
||||
// Position relative to the component, not the viewport
|
||||
return html`
|
||||
<div
|
||||
class="absolute top-2 left-2 bg-black/80 text-white p-2 rounded text-xs z-40 pointer-events-none font-mono"
|
||||
>
|
||||
<div>Size: ${this.currentTerminalSize.cols}x${this.currentTerminalSize.rows}</div>
|
||||
<div>Font: ${this.terminal?.options.fontSize?.toFixed(1) || 14}px</div>
|
||||
<div>Touch: ${this.touchCount}</div>
|
||||
<div>Status: ${this.terminalStatus}</div>
|
||||
<div>Device: ${this.isMobile ? 'Mobile' : 'Desktop'}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'responsive-terminal': ResponsiveTerminal;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { LitElement, html } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { Terminal as XtermTerminal, IBufferLine, IBufferCell } from '@xterm/xterm';
|
||||
import { Terminal as XtermTerminal, IBufferLine, IBufferCell } from '@xterm/headless';
|
||||
import { UrlHighlighter } from '../utils/url-highlighter.js';
|
||||
|
||||
@customElement('vibe-terminal')
|
||||
|
|
@ -177,10 +177,9 @@ export class Terminal extends LitElement {
|
|||
// Create regular terminal but don't call .open() to make it headless
|
||||
this.terminal = new XtermTerminal({
|
||||
cursorBlink: false,
|
||||
fontSize: this.fontSize,
|
||||
fontFamily: 'Fira Code, ui-monospace, SFMono-Regular, monospace',
|
||||
lineHeight: 1.2,
|
||||
scrollback: 10000,
|
||||
allowProposedApi: true,
|
||||
theme: {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
import { Terminal, ITerminalAddon } from '@xterm/xterm';
|
||||
|
||||
export class CustomWebLinksAddon implements ITerminalAddon {
|
||||
private _terminal?: Terminal;
|
||||
private _linkMatcher?: number;
|
||||
|
||||
constructor(private _handler?: (event: MouseEvent, uri: string) => void) {}
|
||||
|
||||
public activate(terminal: Terminal): void {
|
||||
this._terminal = terminal;
|
||||
|
||||
// URL regex pattern - matches http/https URLs
|
||||
const urlRegex = /(https?:\/\/[^\s]+)/gi;
|
||||
|
||||
this._linkMatcher = this._terminal.registerLinkMatcher(
|
||||
urlRegex,
|
||||
(event: MouseEvent, uri: string) => {
|
||||
console.log('Custom WebLinks click:', uri);
|
||||
if (this._handler) {
|
||||
this._handler(event, uri);
|
||||
} else {
|
||||
window.open(uri, '_blank');
|
||||
}
|
||||
},
|
||||
{
|
||||
// Custom styling options
|
||||
hover: (
|
||||
event: MouseEvent,
|
||||
uri: string,
|
||||
_location: { start: { x: number; y: number }; end: { x: number; y: number } }
|
||||
) => {
|
||||
console.log('Custom WebLinks hover:', uri);
|
||||
// Style the link on hover
|
||||
const linkElement = event.target as HTMLElement;
|
||||
if (linkElement) {
|
||||
linkElement.style.backgroundColor = 'rgba(59, 142, 234, 0.2)';
|
||||
linkElement.style.color = '#ffffff';
|
||||
linkElement.style.textDecoration = 'underline';
|
||||
}
|
||||
},
|
||||
leave: (event: MouseEvent, uri: string) => {
|
||||
console.log('Custom WebLinks leave:', uri);
|
||||
// Remove hover styling
|
||||
const linkElement = event.target as HTMLElement;
|
||||
if (linkElement) {
|
||||
linkElement.style.backgroundColor = '';
|
||||
linkElement.style.color = '#3b8eea';
|
||||
linkElement.style.textDecoration = 'underline';
|
||||
}
|
||||
},
|
||||
priority: 1,
|
||||
willLinkActivate: (event: MouseEvent, uri: string) => {
|
||||
console.log('Custom WebLinks will activate:', uri);
|
||||
return true;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
console.log('Custom WebLinks addon activated with matcher ID:', this._linkMatcher);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
if (this._linkMatcher !== undefined && this._terminal) {
|
||||
this._terminal.deregisterLinkMatcher(this._linkMatcher);
|
||||
this._linkMatcher = undefined;
|
||||
}
|
||||
this._terminal = undefined;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
// Entry point for renderer bundle - exports XTerm-based renderer
|
||||
export { Renderer } from './renderer';
|
||||
|
|
@ -1,414 +0,0 @@
|
|||
// Terminal renderer for asciinema cast format using XTerm.js
|
||||
// Professional-grade terminal emulation with full VT compatibility
|
||||
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import { ScaleFitAddon } from './scale-fit-addon.js';
|
||||
|
||||
// interface CastHeader {
|
||||
// version: number;
|
||||
// width: number;
|
||||
// height: number;
|
||||
// timestamp?: number;
|
||||
// env?: Record<string, string>;
|
||||
// }
|
||||
|
||||
interface CastEvent {
|
||||
timestamp: number;
|
||||
type: 'o' | 'i' | 'r'; // output, input, or resize
|
||||
data: string;
|
||||
}
|
||||
|
||||
export class Renderer {
|
||||
private static activeCount: number = 0;
|
||||
|
||||
private container: HTMLElement;
|
||||
private terminal: Terminal;
|
||||
private scaleFitAddon: ScaleFitAddon;
|
||||
private webLinksAddon: WebLinksAddon;
|
||||
|
||||
constructor(
|
||||
container: HTMLElement,
|
||||
width: number = 80,
|
||||
height: number = 20,
|
||||
scrollback: number = 1000000,
|
||||
fontSize: number = 14
|
||||
) {
|
||||
Renderer.activeCount++;
|
||||
console.log(`Renderer constructor called (active: ${Renderer.activeCount})`);
|
||||
this.container = container;
|
||||
|
||||
// Create terminal with options similar to the custom renderer
|
||||
this.terminal = new Terminal({
|
||||
cols: width,
|
||||
rows: height,
|
||||
fontFamily: 'Monaco, "Lucida Console", monospace',
|
||||
fontSize: fontSize,
|
||||
lineHeight: 1.2,
|
||||
theme: {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#00ff00',
|
||||
cursorAccent: '#1e1e1e',
|
||||
selectionBackground: '#264f78',
|
||||
// VS Code Dark theme colors
|
||||
black: '#000000',
|
||||
red: '#f14c4c',
|
||||
green: '#23d18b',
|
||||
yellow: '#f5f543',
|
||||
blue: '#3b8eea',
|
||||
magenta: '#d670d6',
|
||||
cyan: '#29b8db',
|
||||
white: '#e5e5e5',
|
||||
// Bright colors
|
||||
brightBlack: '#666666',
|
||||
brightRed: '#f14c4c',
|
||||
brightGreen: '#23d18b',
|
||||
brightYellow: '#f5f543',
|
||||
brightBlue: '#3b8eea',
|
||||
brightMagenta: '#d670d6',
|
||||
brightCyan: '#29b8db',
|
||||
brightWhite: '#ffffff',
|
||||
},
|
||||
allowProposedApi: true,
|
||||
scrollback: scrollback, // Configurable scrollback buffer
|
||||
convertEol: true,
|
||||
altClickMovesCursor: false,
|
||||
rightClickSelectsWord: false,
|
||||
disableStdin: true, // We handle input separately
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'block',
|
||||
cursorWidth: 1,
|
||||
cursorInactiveStyle: 'block',
|
||||
});
|
||||
|
||||
// Add addons
|
||||
this.scaleFitAddon = new ScaleFitAddon();
|
||||
this.webLinksAddon = new WebLinksAddon();
|
||||
|
||||
this.terminal.loadAddon(this.scaleFitAddon);
|
||||
this.terminal.loadAddon(this.webLinksAddon);
|
||||
|
||||
this.setupDOM();
|
||||
}
|
||||
|
||||
private setupDOM(): void {
|
||||
// Clear container and add CSS
|
||||
this.container.innerHTML = '';
|
||||
|
||||
// Full terminals get padding
|
||||
this.container.style.padding = '10px';
|
||||
this.container.style.backgroundColor = '#1e1e1e';
|
||||
this.container.style.overflow = 'hidden';
|
||||
this.container.style.maxWidth = '100%';
|
||||
this.container.style.boxSizing = 'border-box';
|
||||
|
||||
// Create terminal wrapper
|
||||
const terminalWrapper = document.createElement('div');
|
||||
terminalWrapper.style.width = '100%';
|
||||
terminalWrapper.style.height = '100%';
|
||||
terminalWrapper.style.maxWidth = '100%';
|
||||
terminalWrapper.style.overflow = 'hidden';
|
||||
this.container.appendChild(terminalWrapper);
|
||||
|
||||
// Open terminal in the wrapper
|
||||
this.terminal.open(terminalWrapper);
|
||||
|
||||
// Disable XTerm's input handling completely while preserving cursor
|
||||
this.terminal.onData(() => {
|
||||
// Do nothing - we handle input via our own system
|
||||
});
|
||||
|
||||
// Ensure cursor is visible without focus by forcing it to stay active
|
||||
requestAnimationFrame(() => {
|
||||
if (this.terminal.element) {
|
||||
const helperTextarea = this.terminal.element.querySelector(
|
||||
'.xterm-helper-textarea'
|
||||
) as HTMLTextAreaElement;
|
||||
if (helperTextarea) {
|
||||
// Prevent the helper textarea from stealing focus but allow cursor rendering
|
||||
helperTextarea.addEventListener('focus', (e) => {
|
||||
e.preventDefault();
|
||||
helperTextarea.blur();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add CSS to override XTerm's fixed width/height on .xterm-screen within this container
|
||||
// Apply to both previews and full terminals
|
||||
const containerId = `terminal-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
this.container.id = containerId;
|
||||
|
||||
// Always use ScaleFitAddon for better scaling
|
||||
this.scaleFitAddon.fit();
|
||||
|
||||
// Handle container resize
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
this.scaleFitAddon.fit();
|
||||
});
|
||||
resizeObserver.observe(this.container);
|
||||
}
|
||||
|
||||
// Public API methods - maintain compatibility with custom renderer
|
||||
|
||||
async loadCastFile(url: string): Promise<void> {
|
||||
const response = await fetch(url);
|
||||
const text = await response.text();
|
||||
this.parseCastFile(text);
|
||||
}
|
||||
|
||||
parseCastFile(content: string): void {
|
||||
const lines = content.trim().split('\n');
|
||||
const outputEvents: string[] = [];
|
||||
|
||||
// Clear terminal
|
||||
this.terminal.clear();
|
||||
|
||||
// First pass: collect all output events and process headers/resizes immediately
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
|
||||
if (parsed.version && parsed.width && parsed.height) {
|
||||
// Header
|
||||
// header = parsed;
|
||||
this.resize(parsed.width, parsed.height);
|
||||
} else if (Array.isArray(parsed) && parsed.length >= 3) {
|
||||
// Event: [timestamp, type, data]
|
||||
const event: CastEvent = {
|
||||
timestamp: parsed[0],
|
||||
type: parsed[1],
|
||||
data: parsed[2],
|
||||
};
|
||||
|
||||
if (event.type === 'o') {
|
||||
outputEvents.push(event.data);
|
||||
} else if (event.type === 'r') {
|
||||
this.processResize(event.data);
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
console.warn('Failed to parse cast line:', line);
|
||||
}
|
||||
}
|
||||
|
||||
// Write all output at once, then scroll when rendering is complete
|
||||
if (outputEvents.length > 0) {
|
||||
const allOutput = outputEvents.join('');
|
||||
this.terminal.write(allOutput, () => {
|
||||
// This callback fires when XTerm has finished rendering the content
|
||||
this.scrollToBottom();
|
||||
});
|
||||
} else {
|
||||
// No output to render, scroll immediately
|
||||
this.scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
processOutput(data: string): void {
|
||||
// XTerm handles all ANSI escape sequences automatically
|
||||
this.terminal.write(data);
|
||||
}
|
||||
|
||||
processResize(data: string): void {
|
||||
// Parse resize data in format "WIDTHxHEIGHT" (e.g., "80x24")
|
||||
const match = data.match(/^(\d+)x(\d+)$/);
|
||||
if (match) {
|
||||
const width = parseInt(match[1], 10);
|
||||
const height = parseInt(match[2], 10);
|
||||
this.resize(width, height);
|
||||
}
|
||||
}
|
||||
|
||||
processEvent(event: CastEvent): void {
|
||||
if (event.type === 'o') {
|
||||
this.processOutput(event.data);
|
||||
} else if (event.type === 'r') {
|
||||
this.processResize(event.data);
|
||||
}
|
||||
}
|
||||
|
||||
resize(width: number, height: number): void {
|
||||
// Resize terminal to session dimensions
|
||||
this.terminal.resize(width, height);
|
||||
// Always use ScaleFitAddon for consistent scaling behavior
|
||||
this.scaleFitAddon.fit();
|
||||
|
||||
// Emit custom event with terminal dimensions
|
||||
const event = new CustomEvent('terminal-resize', {
|
||||
detail: { cols: width, rows: height },
|
||||
bubbles: true,
|
||||
});
|
||||
this.container.dispatchEvent(event);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.terminal.clear();
|
||||
}
|
||||
|
||||
// Stream support - connect to SSE endpoint
|
||||
connectToStream(sessionId: string): EventSource {
|
||||
console.log('connectToStream called for session:', sessionId);
|
||||
return this.connectToUrl(`/api/sessions/${sessionId}/stream`);
|
||||
}
|
||||
|
||||
// Connect to any SSE URL
|
||||
connectToUrl(url: string): EventSource {
|
||||
console.log('Creating new EventSource connection to:', url);
|
||||
const eventSource = new EventSource(url);
|
||||
|
||||
// Don't clear terminal for live streams - just append new content
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.version && data.width && data.height) {
|
||||
// Header
|
||||
console.log('Received header:', data);
|
||||
this.resize(data.width, data.height);
|
||||
} else if (Array.isArray(data) && data.length >= 3) {
|
||||
// Check if this is an exit event
|
||||
if (data[0] === 'exit') {
|
||||
const exitCode = data[1];
|
||||
const sessionId = data[2];
|
||||
console.log(`Session ${sessionId} exited with code ${exitCode}`);
|
||||
|
||||
// Close the SSE connection immediately
|
||||
if (this.eventSource) {
|
||||
console.log('Closing SSE connection due to session exit');
|
||||
this.eventSource.close();
|
||||
this.eventSource = null;
|
||||
}
|
||||
|
||||
// Dispatch custom event that session-view can listen to
|
||||
const exitEvent = new CustomEvent('session-exit', {
|
||||
detail: { sessionId, exitCode },
|
||||
});
|
||||
this.container.dispatchEvent(exitEvent);
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular cast event
|
||||
const castEvent: CastEvent = {
|
||||
timestamp: data[0],
|
||||
type: data[1],
|
||||
data: data[2],
|
||||
};
|
||||
// Process event without verbose logging
|
||||
this.processEvent(castEvent);
|
||||
}
|
||||
} catch (_e) {
|
||||
console.warn('Failed to parse stream event:', event.data);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('Stream error:', error);
|
||||
// Close the connection to prevent automatic reconnection attempts
|
||||
if (eventSource.readyState === EventSource.CLOSED) {
|
||||
console.log('Stream closed, cleaning up...');
|
||||
if (this.eventSource === eventSource) {
|
||||
this.eventSource = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return eventSource;
|
||||
}
|
||||
|
||||
private eventSource: EventSource | null = null;
|
||||
|
||||
// Load content from URL - pass isStream to determine how to handle it
|
||||
async loadFromUrl(url: string, isStream: boolean): Promise<void> {
|
||||
// Clean up existing connection
|
||||
if (this.eventSource) {
|
||||
console.log('Explicitly closing existing EventSource connection');
|
||||
this.eventSource.close();
|
||||
this.eventSource = null;
|
||||
}
|
||||
|
||||
if (isStream) {
|
||||
// It's a stream URL, connect via SSE (don't clear - append to existing content)
|
||||
this.eventSource = this.connectToUrl(url);
|
||||
} else {
|
||||
// It's a snapshot URL, clear first then load as cast file
|
||||
this.terminal.clear();
|
||||
await this.loadCastFile(url);
|
||||
}
|
||||
}
|
||||
|
||||
// Additional methods for terminal control
|
||||
|
||||
focus(): void {
|
||||
this.terminal.focus();
|
||||
}
|
||||
|
||||
blur(): void {
|
||||
this.terminal.blur();
|
||||
}
|
||||
|
||||
getTerminal(): Terminal {
|
||||
return this.terminal;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.eventSource) {
|
||||
console.log('Explicitly closing EventSource connection in dispose()');
|
||||
this.eventSource.close();
|
||||
this.eventSource = null;
|
||||
}
|
||||
this.terminal.dispose();
|
||||
Renderer.activeCount--;
|
||||
console.log(`Renderer disposed (active: ${Renderer.activeCount})`);
|
||||
}
|
||||
|
||||
// Method to fit terminal to container (useful for responsive layouts)
|
||||
fit(): void {
|
||||
this.scaleFitAddon.fit();
|
||||
}
|
||||
|
||||
// Scroll terminal to bottom
|
||||
scrollToBottom(): void {
|
||||
this.terminal.scrollToBottom();
|
||||
}
|
||||
|
||||
// Get terminal dimensions
|
||||
getDimensions(): { cols: number; rows: number } {
|
||||
return {
|
||||
cols: this.terminal.cols,
|
||||
rows: this.terminal.rows,
|
||||
};
|
||||
}
|
||||
|
||||
// Write raw data to terminal (useful for testing)
|
||||
write(data: string): void {
|
||||
this.terminal.write(data);
|
||||
}
|
||||
|
||||
// Enable/disable input (though we keep it disabled by default)
|
||||
setInputEnabled(enabled: boolean): void {
|
||||
// XTerm doesn't have a direct way to disable input, so we override onData
|
||||
if (enabled) {
|
||||
// Remove any existing handler first
|
||||
this.terminal.onData(() => {
|
||||
// Input is handled by the session component
|
||||
});
|
||||
} else {
|
||||
this.terminal.onData(() => {
|
||||
// Do nothing - input disabled
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Disable all pointer events for previews so clicks pass through to parent
|
||||
setPointerEventsEnabled(enabled: boolean): void {
|
||||
const terminalElement = this.container.querySelector('.xterm') as HTMLElement;
|
||||
if (terminalElement) {
|
||||
terminalElement.style.pointerEvents = enabled ? 'auto' : 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,294 +0,0 @@
|
|||
/**
|
||||
* Custom FitAddon that scales font size to fit terminal columns to container width,
|
||||
* then calculates optimal rows for the container height.
|
||||
*/
|
||||
|
||||
import type { Terminal, ITerminalAddon } from '@xterm/xterm';
|
||||
|
||||
interface ITerminalDimensions {
|
||||
rows: number;
|
||||
cols: number;
|
||||
}
|
||||
|
||||
const MINIMUM_ROWS = 1;
|
||||
const MIN_FONT_SIZE = 4;
|
||||
const MAX_FONT_SIZE = 16;
|
||||
|
||||
export class ScaleFitAddon implements ITerminalAddon {
|
||||
private _terminal: Terminal | undefined;
|
||||
|
||||
constructor() {}
|
||||
|
||||
public activate(terminal: Terminal): void {
|
||||
this._terminal = terminal;
|
||||
}
|
||||
|
||||
public dispose(): void {}
|
||||
|
||||
public fit(): void {
|
||||
// For full terminals, resize both font and dimensions
|
||||
const dims = this.proposeDimensions();
|
||||
if (!dims || !this._terminal || isNaN(dims.cols) || isNaN(dims.rows)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only resize rows, keep cols the same (font scaling handles width)
|
||||
if (this._terminal.rows !== dims.rows) {
|
||||
this._terminal.resize(this._terminal.cols, dims.rows);
|
||||
}
|
||||
|
||||
// Force responsive sizing by overriding XTerm's fixed dimensions
|
||||
this.forceResponsiveSizing();
|
||||
}
|
||||
|
||||
public proposeDimensions(): ITerminalDimensions | undefined {
|
||||
if (!this._terminal?.element?.parentElement) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Get the renderer container (parent of parent - the one with 10px padding)
|
||||
const terminalWrapper = this._terminal.element.parentElement;
|
||||
const rendererContainer = terminalWrapper.parentElement;
|
||||
|
||||
if (!rendererContainer) return undefined;
|
||||
|
||||
// Get container dimensions and exact padding
|
||||
const containerStyle = window.getComputedStyle(rendererContainer);
|
||||
const containerWidth = parseInt(containerStyle.getPropertyValue('width'));
|
||||
const containerHeight = parseInt(containerStyle.getPropertyValue('height'));
|
||||
const containerPadding = {
|
||||
top: parseInt(containerStyle.getPropertyValue('padding-top')),
|
||||
bottom: parseInt(containerStyle.getPropertyValue('padding-bottom')),
|
||||
left: parseInt(containerStyle.getPropertyValue('padding-left')),
|
||||
right: parseInt(containerStyle.getPropertyValue('padding-right')),
|
||||
};
|
||||
|
||||
// Calculate exact available space using known padding
|
||||
const availableWidth = containerWidth - containerPadding.left - containerPadding.right;
|
||||
const availableHeight = containerHeight - containerPadding.top - containerPadding.bottom;
|
||||
|
||||
// Current terminal dimensions
|
||||
const currentCols = this._terminal.cols;
|
||||
|
||||
// Get exact character dimensions from XTerm's measurement system first
|
||||
const charDimensions = this.getXTermCharacterDimensions();
|
||||
|
||||
if (charDimensions) {
|
||||
// Use actual measured dimensions for linear scaling calculation
|
||||
const { charWidth, lineHeight } = charDimensions;
|
||||
const currentFontSize = this._terminal.options.fontSize || 14;
|
||||
|
||||
// Calculate current total rendered width for all columns
|
||||
const currentRenderedWidth = currentCols * charWidth;
|
||||
|
||||
// Calculate scale factor needed to fit exactly in available width
|
||||
const scaleFactor = availableWidth / currentRenderedWidth;
|
||||
|
||||
// Apply linear scaling to font size
|
||||
const newFontSize = currentFontSize * scaleFactor;
|
||||
const clampedFontSize = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, newFontSize));
|
||||
|
||||
// Calculate actual font scaling that was applied (accounting for clamping)
|
||||
const actualFontScaling = clampedFontSize / currentFontSize;
|
||||
|
||||
// Apply the actual font scaling to line height
|
||||
const newLineHeight = lineHeight * actualFontScaling;
|
||||
|
||||
// Calculate how many rows fit with the scaled line height
|
||||
const optimalRows = Math.max(MINIMUM_ROWS, Math.floor(availableHeight / newLineHeight));
|
||||
|
||||
// Apply the new font size
|
||||
requestAnimationFrame(() => this.applyFontSize(clampedFontSize));
|
||||
|
||||
// Log all calculations for debugging
|
||||
console.log(
|
||||
`ScaleFitAddon: ${availableWidth}×${availableHeight}px available, ${currentCols}×${this._terminal.rows} terminal, charWidth=${charWidth.toFixed(2)}px, lineHeight=${lineHeight.toFixed(2)}px, currentRenderedWidth=${currentRenderedWidth.toFixed(2)}px, scaleFactor=${scaleFactor.toFixed(3)}, actualFontScaling=${actualFontScaling.toFixed(3)}, fontSize ${currentFontSize}px→${clampedFontSize.toFixed(2)}px, lineHeight ${lineHeight.toFixed(2)}px→${newLineHeight.toFixed(2)}px, rows ${this._terminal.rows}→${optimalRows}`
|
||||
);
|
||||
|
||||
return {
|
||||
cols: currentCols, // ALWAYS keep exact column count
|
||||
rows: optimalRows, // Maximize rows that fit
|
||||
};
|
||||
} else {
|
||||
// Fallback: estimate font size and dimensions if measurements aren't available
|
||||
const charWidthRatio = 0.63;
|
||||
const calculatedFontSize =
|
||||
Math.floor((availableWidth / (currentCols * charWidthRatio)) * 10) / 10;
|
||||
const optimalFontSize = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, calculatedFontSize));
|
||||
|
||||
// Apply the calculated font size
|
||||
requestAnimationFrame(() => this.applyFontSize(optimalFontSize));
|
||||
|
||||
const lineHeight = optimalFontSize * (this._terminal.options.lineHeight || 1.2);
|
||||
const optimalRows = Math.max(MINIMUM_ROWS, Math.floor(availableHeight / lineHeight));
|
||||
|
||||
return {
|
||||
cols: currentCols,
|
||||
rows: optimalRows,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private applyFontSize(fontSize: number): void {
|
||||
if (!this._terminal?.element) return;
|
||||
|
||||
// Prevent infinite recursion by checking if font size changed significantly
|
||||
const currentFontSize = this._terminal.options.fontSize || 14;
|
||||
if (Math.abs(fontSize - currentFontSize) < 0.1) return;
|
||||
|
||||
const terminalElement = this._terminal.element;
|
||||
|
||||
// Update terminal's font size
|
||||
this._terminal.options.fontSize = fontSize;
|
||||
|
||||
// Apply CSS font size to the element
|
||||
terminalElement.style.fontSize = `${fontSize}px`;
|
||||
|
||||
// Force a refresh to apply the new font size and ensure responsive sizing
|
||||
requestAnimationFrame(() => {
|
||||
if (this._terminal) {
|
||||
this._terminal.refresh(0, this._terminal.rows - 1);
|
||||
// Force responsive sizing after refresh since XTerm might reset dimensions
|
||||
this.forceResponsiveSizing();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the calculated font size that would fit the current columns in the container
|
||||
*/
|
||||
private scaleFontOnly(): void {
|
||||
if (!this._terminal?.element?.parentElement) return;
|
||||
|
||||
// Get container dimensions for font scaling
|
||||
const terminalWrapper = this._terminal.element.parentElement;
|
||||
const rendererContainer = terminalWrapper.parentElement;
|
||||
if (!rendererContainer) return;
|
||||
|
||||
const containerStyle = window.getComputedStyle(rendererContainer);
|
||||
const containerWidth = parseInt(containerStyle.getPropertyValue('width'));
|
||||
const containerPadding = {
|
||||
left: parseInt(containerStyle.getPropertyValue('padding-left')),
|
||||
right: parseInt(containerStyle.getPropertyValue('padding-right')),
|
||||
};
|
||||
|
||||
const availableWidth = containerWidth - containerPadding.left - containerPadding.right;
|
||||
const currentCols = this._terminal.cols;
|
||||
|
||||
// Calculate font size to fit columns in available width
|
||||
const charWidthRatio = 0.63;
|
||||
// Calculate font size and round down for precision
|
||||
const calculatedFontSize =
|
||||
Math.floor((availableWidth / (currentCols * charWidthRatio)) * 10) / 10;
|
||||
const optimalFontSize = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, calculatedFontSize));
|
||||
|
||||
// Apply the font size without changing terminal dimensions
|
||||
this.applyFontSize(optimalFontSize);
|
||||
|
||||
// Also force responsive sizing for previews
|
||||
this.forceResponsiveSizing();
|
||||
}
|
||||
|
||||
public getOptimalFontSize(): number {
|
||||
if (!this._terminal?.element?.parentElement) {
|
||||
return this._terminal?.options.fontSize || 14;
|
||||
}
|
||||
|
||||
const parentElement = this._terminal.element.parentElement;
|
||||
const parentStyle = window.getComputedStyle(parentElement);
|
||||
const parentWidth = parseInt(parentStyle.getPropertyValue('width'));
|
||||
|
||||
const elementStyle = window.getComputedStyle(this._terminal.element);
|
||||
const paddingHor =
|
||||
parseInt(elementStyle.getPropertyValue('padding-left')) +
|
||||
parseInt(elementStyle.getPropertyValue('padding-right'));
|
||||
|
||||
const availableWidth = parentWidth - paddingHor;
|
||||
const charWidthRatio = 0.63;
|
||||
const calculatedFontSize = availableWidth / (this._terminal.cols * charWidthRatio);
|
||||
|
||||
return Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, calculatedFontSize));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get exact character dimensions from XTerm's built-in measurement system
|
||||
*/
|
||||
private getXTermCharacterDimensions(): { charWidth: number; lineHeight: number } | null {
|
||||
if (!this._terminal?.element) return null;
|
||||
|
||||
// XTerm has a built-in character measurement system with multiple font styles
|
||||
const measureContainer = this._terminal.element.querySelector(
|
||||
'.xterm-width-cache-measure-container'
|
||||
);
|
||||
|
||||
// Find the first measurement element (normal weight, usually 'm' characters)
|
||||
// This is what XTerm uses for baseline character width calculations
|
||||
const firstMeasureElement = measureContainer?.querySelector('.xterm-char-measure-element');
|
||||
|
||||
if (firstMeasureElement) {
|
||||
const measureRect = firstMeasureElement.getBoundingClientRect();
|
||||
const measureText = firstMeasureElement.textContent || '';
|
||||
|
||||
if (measureText.length > 0 && measureRect.width > 0) {
|
||||
// Calculate actual character width from the primary measurement element
|
||||
const actualCharWidth = measureRect.width / measureText.length;
|
||||
|
||||
// Get line height from the first row in .xterm-rows
|
||||
const xtermRows = this._terminal.element.querySelector('.xterm-rows');
|
||||
const firstRow = xtermRows?.querySelector('div');
|
||||
let lineHeight = 21.5; // fallback
|
||||
|
||||
if (firstRow) {
|
||||
const rowStyle = window.getComputedStyle(firstRow);
|
||||
const rowLineHeight = parseFloat(rowStyle.lineHeight);
|
||||
if (!isNaN(rowLineHeight) && rowLineHeight > 0) {
|
||||
lineHeight = rowLineHeight;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
charWidth: actualCharWidth,
|
||||
lineHeight: lineHeight,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try to measure from the xterm-screen dimensions and terminal cols/rows
|
||||
const xtermScreen = this._terminal.element.querySelector('.xterm-screen') as HTMLElement;
|
||||
if (xtermScreen) {
|
||||
const screenRect = xtermScreen.getBoundingClientRect();
|
||||
const charWidth = screenRect.width / this._terminal.cols;
|
||||
const lineHeight = screenRect.height / this._terminal.rows;
|
||||
|
||||
if (charWidth > 0 && lineHeight > 0) {
|
||||
return { charWidth, lineHeight };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force XTerm elements to use responsive sizing instead of fixed dimensions
|
||||
*/
|
||||
private forceResponsiveSizing(): void {
|
||||
if (!this._terminal?.element) return;
|
||||
|
||||
// Find the xterm-screen element within the terminal
|
||||
const xtermScreen = this._terminal.element.querySelector('.xterm-screen') as HTMLElement;
|
||||
const xtermViewport = this._terminal.element.querySelector('.xterm-viewport') as HTMLElement;
|
||||
|
||||
if (xtermScreen) {
|
||||
// Remove any fixed width/height styles and force responsive sizing
|
||||
xtermScreen.style.width = '100%';
|
||||
xtermScreen.style.height = '100%';
|
||||
xtermScreen.style.maxWidth = '100%';
|
||||
xtermScreen.style.maxHeight = '100%';
|
||||
}
|
||||
|
||||
if (xtermViewport) {
|
||||
xtermViewport.style.width = '100%';
|
||||
xtermViewport.style.maxWidth = '100%';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,2 @@
|
|||
// Entry point for test pages - includes both terminal implementations
|
||||
import './components/mobile-terminal.js';
|
||||
import './components/terminal.js';
|
||||
|
||||
// Re-export CastConverter for use in test pages
|
||||
export { CastConverter } from './utils/cast-converter.js';
|
||||
export * from './components/terminal.js';
|
||||
export * from './utils/cast-converter.js';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import express from 'express';
|
||||
import express, { Response } from 'express';
|
||||
import { createServer } from 'http';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import path from 'path';
|
||||
|
|
@ -347,7 +347,7 @@ app.post('/api/cleanup-exited', async (req, res) => {
|
|||
const activeStreams = new Map<
|
||||
string,
|
||||
{
|
||||
clients: Set<WebSocket>;
|
||||
clients: Set<Response>;
|
||||
tailProcess: ChildProcess;
|
||||
lastPosition: number;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue