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:
Mario Zechner 2025-06-18 12:48:24 +02:00
parent 1cef390ed6
commit d9d134ff2b
11 changed files with 301 additions and 1702 deletions

357
web/package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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

View file

@ -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;
}
}

View file

@ -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',

View file

@ -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;
}
}

View file

@ -1,2 +0,0 @@
// Entry point for renderer bundle - exports XTerm-based renderer
export { Renderer } from './renderer';

View file

@ -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';
}
}
}

View file

@ -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%';
}
}
}

View file

@ -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';

View file

@ -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;
}