diff --git a/package-lock.json b/package-lock.json index ec1cdcb..80954e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,12 +8,12 @@ "name": "cmux-remote", "version": "0.1.0", "dependencies": { + "@ngrok/ngrok": "^1.7.0", "bcrypt": "^5.1.0", "commander": "^13.0.0", "express": "^5.0.0", "jsonwebtoken": "^9.0.0", "nanoid": "^5.0.0", - "qrcode": "^1.5.4", "qrcode-terminal": "^0.12.0", "ws": "^8.18.0" }, @@ -25,7 +25,6 @@ "@types/express": "^5.0.0", "@types/jsonwebtoken": "^9.0.0", "@types/node": "^22.0.0", - "@types/qrcode": "^1.5.6", "@types/ws": "^8.5.0", "tsx": "^4.19.0", "typescript": "^5.7.0", @@ -501,6 +500,235 @@ "node-pre-gyp": "bin/node-pre-gyp" } }, + "node_modules/@ngrok/ngrok": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok/-/ngrok-1.7.0.tgz", + "integrity": "sha512-P06o9TpxrJbiRbHQkiwy/rUrlXRupc+Z8KT4MiJfmcdWxvIdzjCaJOdnNkcOTs6DMyzIOefG5tvk/HLdtjqr0g==", + "license": "(MIT OR Apache-2.0)", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@ngrok/ngrok-android-arm64": "1.7.0", + "@ngrok/ngrok-darwin-arm64": "1.7.0", + "@ngrok/ngrok-darwin-universal": "1.7.0", + "@ngrok/ngrok-darwin-x64": "1.7.0", + "@ngrok/ngrok-freebsd-x64": "1.7.0", + "@ngrok/ngrok-linux-arm-gnueabihf": "1.7.0", + "@ngrok/ngrok-linux-arm64-gnu": "1.7.0", + "@ngrok/ngrok-linux-arm64-musl": "1.7.0", + "@ngrok/ngrok-linux-x64-gnu": "1.7.0", + "@ngrok/ngrok-linux-x64-musl": "1.7.0", + "@ngrok/ngrok-win32-arm64-msvc": "1.7.0", + "@ngrok/ngrok-win32-ia32-msvc": "1.7.0", + "@ngrok/ngrok-win32-x64-msvc": "1.7.0" + } + }, + "node_modules/@ngrok/ngrok-android-arm64": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-android-arm64/-/ngrok-android-arm64-1.7.0.tgz", + "integrity": "sha512-8tco3ID6noSaNy+CMS7ewqPoIkIM6XO5COCzsUp3Wv3XEbMSyn65RN6cflX2JdqLfUCHcMyD0ahr9IEiHwqmbQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-darwin-arm64": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-arm64/-/ngrok-darwin-arm64-1.7.0.tgz", + "integrity": "sha512-+dmJSOzSO+MNDVrPOca2yYDP1W3KfP4qOlAkarIeFRIfqonQwq3QCBmcR7HAlZocLsSqEwyG6KP4RRvAuT0WGQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-darwin-universal": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-universal/-/ngrok-darwin-universal-1.7.0.tgz", + "integrity": "sha512-fDEfewyE2pWGFBhOSwQZObeHUkc65U1l+3HIgSOe094TMHsqmyJD0KTCgW9KSn0VP4OvDZbAISi1T3nvqgZYhQ==", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-darwin-x64": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-x64/-/ngrok-darwin-x64-1.7.0.tgz", + "integrity": "sha512-+fwMi5uHd9G8BS42MMa9ye6exI5lwTcjUO6Ut497Vu0qgLONdVRenRqnEePV+Q3KtQR7NjqkMnomVfkr9MBjtw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-freebsd-x64": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-freebsd-x64/-/ngrok-freebsd-x64-1.7.0.tgz", + "integrity": "sha512-2OGgbrjy3yLRrqAz5N6hlUKIWIXSpR5RjQa2chtZMsSbszQ6c9dI+uVQfOKAeo05tHMUgrYAZ7FocC+ig0dzdQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-arm-gnueabihf": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm-gnueabihf/-/ngrok-linux-arm-gnueabihf-1.7.0.tgz", + "integrity": "sha512-SN9YIfEQiR9xN90QVNvdgvAemqMLoFVSeTWZs779145hQMhvF9Qd9rnWi6J+2uNNK10OczdV1oc/nq1es7u/3g==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-arm64-gnu": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm64-gnu/-/ngrok-linux-arm64-gnu-1.7.0.tgz", + "integrity": "sha512-KDMgzPKFU2kbpVSaA2RZBBia5IPdJEe063YlyVFnSMJmPYWCUnMwdybBsucXfV9u1Lw/ZjKTKotIlbTWGn3HGw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-arm64-musl": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm64-musl/-/ngrok-linux-arm64-musl-1.7.0.tgz", + "integrity": "sha512-e66vUdVrBlQ0lT9ZdamB4U604zt5Gualt8/WVcUGzbu8s5LajWd6g/mzZCUjK4UepjvMpfgmCp1/+rX7Rk8d5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-x64-gnu": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-x64-gnu/-/ngrok-linux-x64-gnu-1.7.0.tgz", + "integrity": "sha512-M6gF0DyOEFqXLfWxObfL3bxYZ4+PnKBHuyLVaqNfFN9Y5utY2mdPOn5422Ppbk4XoIK5/YkuhRqPJl/9FivKEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-x64-musl": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-x64-musl/-/ngrok-linux-x64-musl-1.7.0.tgz", + "integrity": "sha512-4Ijm0dKeoyzZTMaYxR2EiNjtlK81ebflg/WYIO1XtleFrVy4UJEGnxtxEidYoT4BfCqi4uvXiK2Mx216xXKvog==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-win32-arm64-msvc": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-win32-arm64-msvc/-/ngrok-win32-arm64-msvc-1.7.0.tgz", + "integrity": "sha512-u7qyWIJI2/YG1HTBnHwUR1+Z2tyGfAsUAItJK/+N1G0FeWJhIWQvSIFJHlaPy4oW1Dc8mSDBX9qvVsiQgLaRFg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-win32-ia32-msvc": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-win32-ia32-msvc/-/ngrok-win32-ia32-msvc-1.7.0.tgz", + "integrity": "sha512-/UdYUsLNv/Q8j9YJsyIfq/jLCoD8WP+NidouucTUzSoDtmOsXBBT3itLrmPiZTEdEgKiFYLuC1Zon8XQQvbVLA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-win32-x64-msvc": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-win32-x64-msvc/-/ngrok-win32-x64-msvc-1.7.0.tgz", + "integrity": "sha512-UFJg/duEWzZlLkEs61Gz6/5nYhGaKI62I8dvUGdBR3NCtIMagehnFaFxmnXZldyHmCM8U0aCIFNpWRaKcrQkoA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", @@ -967,16 +1195,6 @@ "undici-types": "~6.21.0" } }, - "node_modules/@types/qrcode": { - "version": "1.5.6", - "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", - "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", @@ -1177,21 +1395,6 @@ "node": ">=8" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/aproba": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", @@ -1330,15 +1533,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -1375,35 +1569,6 @@ "node": ">=10" } }, - "node_modules/cliui": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", - "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^6.2.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, "node_modules/color-support": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", @@ -1491,15 +1656,6 @@ } } }, - "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==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -1534,12 +1690,6 @@ "node": ">=8" } }, - "node_modules/dijkstrajs": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", - "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", - "license": "MIT" - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1780,19 +1930,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1886,15 +2023,6 @@ "node": ">=10" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -2148,18 +2276,6 @@ "safe-buffer": "^5.0.1" } }, - "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -2485,42 +2601,6 @@ "wrappy": "1" } }, - "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==", - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2530,15 +2610,6 @@ "node": ">= 0.8" } }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -2595,15 +2666,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pngjs": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", - "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", - "license": "MIT", - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", @@ -2665,23 +2727,6 @@ "node": ">= 0.10" } }, - "node_modules/qrcode": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", - "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", - "license": "MIT", - "dependencies": { - "dijkstrajs": "^1.0.1", - "pngjs": "^5.0.0", - "yargs": "^15.3.1" - }, - "bin": { - "qrcode": "bin/qrcode" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/qrcode-terminal": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz", @@ -2743,21 +2788,6 @@ "node": ">= 6" } }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "license": "MIT", - "engines": { - "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==", - "license": "ISC" - }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -3466,12 +3496,6 @@ "webidl-conversions": "^3.0.0" } }, - "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==", - "license": "ISC" - }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -3498,20 +3522,6 @@ "string-width": "^1.0.2 || 2 || 3 || 4" } }, - "node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -3539,52 +3549,11 @@ } } }, - "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==", - "license": "ISC" - }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" - }, - "node_modules/yargs": { - "version": "15.4.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", - "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", - "license": "MIT", - "dependencies": { - "cliui": "^6.0.0", - "decamelize": "^1.2.0", - "find-up": "^4.1.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": "^4.2.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^18.1.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs-parser": { - "version": "18.1.3", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", - "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", - "license": "ISC", - "dependencies": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" - }, - "engines": { - "node": ">=6" - } } } } diff --git a/package.json b/package.json index 02b9e83..ab43041 100644 --- a/package.json +++ b/package.json @@ -15,12 +15,12 @@ "test:watch": "vitest" }, "dependencies": { + "@ngrok/ngrok": "^1.7.0", "bcrypt": "^5.1.0", "commander": "^13.0.0", "express": "^5.0.0", "jsonwebtoken": "^9.0.0", "nanoid": "^5.0.0", - "qrcode": "^1.5.4", "qrcode-terminal": "^0.12.0", "ws": "^8.18.0" }, @@ -29,7 +29,6 @@ "@types/express": "^5.0.0", "@types/jsonwebtoken": "^9.0.0", "@types/node": "^22.0.0", - "@types/qrcode": "^1.5.6", "@types/ws": "^8.5.0", "tsx": "^4.19.0", "typescript": "^5.7.0", diff --git a/public/css/claude.css b/public/css/claude.css new file mode 100644 index 0000000..05689d3 --- /dev/null +++ b/public/css/claude.css @@ -0,0 +1,407 @@ +/* Claude Code client view styles — scoped to body.claude-mode */ + +/* ── Terminal output override ── */ +body.claude-mode .terminal-output { + white-space: normal; + word-break: break-word; + padding: 10px 14px; +} + + +/* ── Scrollable container for wide content (code/diff/tool-result) ── */ +.cc-scrollable { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; + scrollbar-color: var(--border-color) transparent; +} +.cc-scrollable::-webkit-scrollbar { height: 4px; } +.cc-scrollable::-webkit-scrollbar-track { background: transparent; } +.cc-scrollable::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 2px; +} + +/* ────────────────────────────────────── */ +/* Prompt block */ +/* ────────────────────────────────────── */ +body.claude-mode .cc-prompt { + display: flex; + align-items: baseline; + gap: 8px; + padding: 8px 12px 6px; + margin: 4px -14px 0; + border-top: 1px solid var(--border-color); + border-radius: var(--radius-xs); + background: var(--accent-soft); + border-left: 3px solid var(--accent); + flex-wrap: wrap; +} + +body.claude-mode .cc-prompt.cc-prompt-active { + background: var(--bg-tertiary); + border-left-color: var(--green); + border-top-color: var(--border-subtle); +} + +body.claude-mode .cc-prompt-marker { + color: var(--green); + font-weight: 700; + font-size: 1.05em; + flex-shrink: 0; +} + +body.claude-mode .cc-prompt-text { + color: var(--text-primary); + white-space: pre-wrap; + word-break: break-word; + flex: 1; +} + +body.claude-mode .cc-prompt-cont { + width: 100%; + padding-left: 1.8em; + color: var(--text-secondary); + white-space: pre-wrap; + word-break: break-word; +} + +/* ────────────────────────────────────── */ +/* Tool-use block */ +/* ────────────────────────────────────── */ +body.claude-mode .cc-tool-use { + margin: 3px 0; +} + +body.claude-mode .cc-tool-header { + display: flex; + align-items: baseline; + gap: 4px; + overflow: hidden; +} + +body.claude-mode .cc-tool-icon { + color: var(--blue); + flex-shrink: 0; +} + +body.claude-mode .cc-tool-name { + color: var(--blue); + font-weight: 600; + flex-shrink: 0; +} + +body.claude-mode .cc-tool-args { + color: var(--cyan); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} + +body.claude-mode .cc-tool-body { + padding-left: 1.5em; + margin-top: 1px; +} + +body.claude-mode .cc-tool-body .cc-line { + white-space: pre-wrap; + word-break: break-word; + color: var(--text-secondary); + font-size: 0.9em; +} + +/* ────────────────────────────────────── */ +/* Tool-result block */ +/* ────────────────────────────────────── */ +body.claude-mode .cc-tool-result { + border-left: 2px solid var(--blue); + margin: 4px 0; + padding-left: 8px; + opacity: 0.85; +} + +body.claude-mode .cc-tool-result .cc-scrollable pre { + margin: 0; + padding: 4px 0; + white-space: pre-wrap; + word-break: break-word; + color: var(--text-secondary); + font-size: 0.88em; + line-height: 1.5; +} + +/* ────────────────────────────────────── */ +/* Thinking block */ +/* ────────────────────────────────────── */ +body.claude-mode .cc-thinking { + color: var(--purple); + font-style: italic; + opacity: 0.7; + white-space: pre-wrap; + word-break: break-word; + margin: 2px 0; + font-size: 0.9em; +} + +/* ────────────────────────────────────── */ +/* Response / Text blocks */ +/* ────────────────────────────────────── */ +body.claude-mode .cc-response .cc-line, +body.claude-mode .cc-text .cc-line { + white-space: pre-wrap; + word-break: break-word; + line-height: 1.65; +} + +/* ────────────────────────────────────── */ +/* Code block */ +/* ────────────────────────────────────── */ +body.claude-mode .cc-code { + margin: 6px 0; + border-radius: var(--radius-xs); + background: var(--bg-tertiary); + border: 1px solid var(--border-subtle); + overflow: hidden; +} + +body.claude-mode .cc-code-content { + margin: 0; + padding: 10px 14px; + white-space: pre; + word-break: normal; + font-size: 0.88em; + line-height: 1.5; + color: var(--text-primary); +} + +/* ────────────────────────────────────── */ +/* Table block */ +/* ────────────────────────────────────── */ +body.claude-mode .cc-table-wrap { + margin: 6px 0; + border-radius: var(--radius-xs); + border: 1px solid var(--border-subtle); + overflow: hidden; +} + +body.claude-mode .cc-table { + border-collapse: collapse; + width: max-content; + min-width: 100%; + font-size: 0.82em; + line-height: 1.45; +} + +body.claude-mode .cc-table th, +body.claude-mode .cc-table td { + padding: 5px 10px; + border: 1px solid var(--border-subtle); + white-space: nowrap; + text-align: left; +} + +body.claude-mode .cc-table thead th { + background: var(--bg-tertiary); + font-weight: 600; + color: var(--text-primary); + border-bottom: 1px solid var(--border-color); +} + +body.claude-mode .cc-table tbody td { + color: var(--text-secondary); + background: var(--bg-primary); +} + +body.claude-mode .cc-table tbody tr:nth-child(even) td { + background: var(--bg-tertiary); +} + +/* ────────────────────────────────────── */ +/* Diff block */ +/* ────────────────────────────────────── */ +body.claude-mode .cc-diff { + margin: 4px 0; + border-radius: var(--radius-xs); + background: var(--bg-tertiary); + border: 1px solid var(--border-subtle); + overflow: hidden; +} + +body.claude-mode .cc-diff-content { + padding: 6px 10px; + font-size: 0.88em; + line-height: 1.5; +} + +body.claude-mode .cc-diff-line { + display: block; + white-space: pre; + word-break: normal; +} + +body.claude-mode .cc-diff-line.cc-added { + color: var(--green); + background: rgba(52, 211, 153, 0.18); +} + +body.claude-mode .cc-diff-line.cc-removed { + color: var(--red); + background: rgba(248, 113, 113, 0.18); +} + +body.claude-mode .cc-diff-line.cc-hunk { + color: var(--cyan); + opacity: 0.75; +} + +/* ────────────────────────────────────── */ +/* Selection options */ +/* ────────────────────────────────────── */ +body.claude-mode .cc-selection-options { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px 0; +} + +body.claude-mode .cc-option { + display: flex; + align-items: center; + gap: 10px; + padding: 13px 16px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-secondary); + color: var(--text-primary); + font-family: inherit; + font-size: 0.9em; + cursor: pointer; + text-align: left; + width: 100%; + -webkit-user-select: none; + user-select: none; + transition: background var(--transition-fast), border-color var(--transition-fast); +} + +body.claude-mode .cc-option:hover { + background: var(--bg-tertiary); + border-color: var(--accent); +} + +body.claude-mode .cc-option:active { + background: var(--accent-soft); + transform: scale(0.99); +} + +body.claude-mode .cc-option-index { + font-weight: 700; + color: var(--accent); + font-size: 0.85em; + min-width: 1.4em; + flex-shrink: 0; +} + +body.claude-mode .cc-option-label { + flex: 1; +} + +/* ────────────────────────────────────── */ +/* Empty spacer */ +/* ────────────────────────────────────── */ +body.claude-mode .cc-empty { + height: 0.5em; +} + +/* ────────────────────────────────────── */ +/* Status bar */ +/* ────────────────────────────────────── */ +.cc-status-bar { + position: sticky; + bottom: 0; + display: flex; + align-items: center; + gap: 8px; + padding: 6px 14px; + background: var(--bg-secondary); + backdrop-filter: blur(var(--blur-amount)); + -webkit-backdrop-filter: blur(var(--blur-amount)); + border-top: 1px solid var(--border-color); + font-size: 0.76rem; + color: var(--text-secondary); + z-index: 10; + font-family: 'SF Mono', 'JetBrains Mono', 'Menlo', monospace; +} + +.cc-mode-pill { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: var(--radius-pill); + font-weight: 600; + font-size: 0.72em; + cursor: pointer; + border: none; + transition: opacity var(--transition-fast); +} +.cc-mode-pill:active { opacity: 0.7; } + +.cc-mode-plan { background: rgba(251, 191, 36, 0.18); color: var(--yellow); } +.cc-mode-code { background: rgba(52, 211, 153, 0.18); color: var(--green); } +.cc-mode-bypass { background: rgba(248, 113, 113, 0.18); color: var(--red); } + +.cc-status-branch { + color: var(--text-dimmed); +} + +.cc-status-stats { + color: var(--text-dimmed); + margin-left: auto; +} + +/* ────────────────────────────────────── */ +/* Action bar (Claude keyboard row) */ +/* ────────────────────────────────────── */ +.cc-action-bar { + display: flex; + gap: 5px; + padding: 4px 0 0; +} + +.cc-action-btn { + flex: 1; + height: 36px; + border: 1px solid var(--key-border); + border-bottom: 2px solid var(--key-depth); + border-radius: var(--radius-xs); + background: var(--key-bg); + color: var(--text-primary); + font-family: inherit; + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1px; + padding: 0 4px; + -webkit-user-select: none; + user-select: none; + transition: background var(--transition-fast), transform 80ms; +} + +.cc-action-btn:active { + background: var(--key-active-bg); + border-bottom-width: 1px; + transform: translateY(1px); +} + +.cc-action-icon { font-size: 0.9em; line-height: 1; } +.cc-action-label { font-size: 0.6rem; color: var(--text-dimmed); line-height: 1; } + +.cc-action-stop .cc-action-icon { color: var(--red); } + +/* Landscape adjustment */ +@media (max-height: 500px) { + .cc-action-btn { height: 30px; } +} diff --git a/public/css/keyboard.css b/public/css/keyboard.css index 585b136..47be917 100644 --- a/public/css/keyboard.css +++ b/public/css/keyboard.css @@ -4,14 +4,13 @@ backdrop-filter: blur(var(--blur-amount)); -webkit-backdrop-filter: blur(var(--blur-amount)); border-top: 1px solid var(--border-color); - padding: 8px 8px; - padding-bottom: max(8px, env(safe-area-inset-bottom)); + padding: 10px 10px 8px; } .key-row { display: flex; - gap: 5px; - margin-bottom: 5px; + gap: 6px; + margin-bottom: 7px; align-items: center; } @@ -22,15 +21,15 @@ /* ── Physical key style ──────────────────────────── */ .key { flex: 0 0 auto; - min-width: 40px; - height: 36px; + min-width: 44px; + height: 42px; border: 1px solid var(--key-border); border-bottom: 2px solid var(--key-depth); border-radius: var(--radius-xs); background: var(--key-bg); background-image: linear-gradient(180deg, var(--key-bg-highlight) 0%, transparent 60%); color: var(--text-primary); - font-size: 0.77rem; + font-size: 0.88rem; font-weight: 500; font-family: inherit; cursor: pointer; @@ -52,7 +51,7 @@ .key.modifier { font-weight: 600; - font-size: 0.7rem; + font-size: 0.8rem; letter-spacing: 0.03em; text-transform: uppercase; color: var(--text-secondary); @@ -78,10 +77,10 @@ .key.shortcut { font-family: 'SF Mono', 'JetBrains Mono', 'Menlo', monospace; - font-size: 0.7rem; + font-size: 0.82rem; font-weight: 700; color: var(--text-secondary); - min-width: 36px; + min-width: 40px; } .key-spacer { @@ -95,13 +94,13 @@ .text-input { flex: 1; - height: 38px; + height: 44px; border: 1px solid var(--border-color); border-radius: var(--radius-pill); background: var(--bg-primary); color: var(--text-primary); - font-size: 0.9rem; - padding: 0 16px; + font-size: 1rem; + padding: 0 18px; outline: none; font-family: 'SF Mono', 'JetBrains Mono', 'Menlo', monospace; transition: border-color var(--transition-fast), box-shadow var(--transition-fast); @@ -117,8 +116,8 @@ } .send-key { - width: 38px; - height: 38px; + width: 44px; + height: 44px; min-width: unset; padding: 0; flex-shrink: 0; @@ -143,18 +142,19 @@ /* ── Landscape ───────────────────────────────────── */ @media (max-height: 500px) { .key { - height: 30px; - min-width: 34px; - font-size: 0.68rem; + height: 32px; + min-width: 36px; + font-size: 0.75rem; } .text-input, .send-key { - height: 30px; + height: 34px; } .virtual-keyboard { - padding: 4px; + padding: 5px 6px 4px; } .key-row { - margin-bottom: 3px; + gap: 4px; + margin-bottom: 4px; } } @@ -162,7 +162,6 @@ @media (min-width: 1024px) { .virtual-keyboard { padding: 10px 20px; - padding-bottom: max(10px, env(safe-area-inset-bottom)); } .key { @@ -184,24 +183,53 @@ filter: brightness(1.12); box-shadow: 0 4px 16px var(--accent-glow); } + + .text-input { max-width: unset; } + .key-row.all-keys { overflow: visible; } } -.key-row.shortcut-keys { +.key-row.all-keys { overflow-x: auto; flex-wrap: nowrap; -webkit-overflow-scrolling: touch; scrollbar-width: none; } -.key-row.shortcut-keys::-webkit-scrollbar { display: none; } -.key-row.shortcut-keys .key.shortcut { min-width: 32px; padding: 0 5px; } +.key-row.all-keys::-webkit-scrollbar { display: none; } +.key-row.all-keys .key.shortcut { min-width: 32px; padding: 0 5px; } .keyboard-toggle { - display: flex; align-items: center; justify-content: center; gap: 6px; - width: 100%; height: 28px; - background: var(--bg-secondary); border: none; - border-top: 1px solid var(--border-color); - color: var(--text-dimmed); font-size: 0.72rem; - cursor: pointer; flex-shrink: 0; + width: 44px; + height: 44px; + min-width: unset; + flex-shrink: 0; + padding: 0; + border: 1px solid var(--key-border); + border-bottom: 2px solid var(--key-depth); + border-radius: var(--radius-pill); + background: var(--key-bg); + background-image: linear-gradient(180deg, var(--key-bg-highlight) 0%, transparent 60%); + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + -webkit-user-select: none; + user-select: none; + transition: background var(--transition-fast), color var(--transition-fast); } -.keyboard-toggle:hover { color: var(--text-secondary); background: var(--bg-tertiary); } + +.keyboard-toggle:active { + background: var(--key-active-bg); + background-image: none; + border-bottom-width: 1px; + transform: translateY(1px); +} + +.virtual-keyboard.collapsed .keyboard-toggle { + color: var(--accent); + background: var(--accent-soft); + border-color: var(--accent); +} + .virtual-keyboard.collapsed .key-row:not(.input-row) { display: none; } +.virtual-keyboard.collapsed { padding-bottom: env(safe-area-inset-bottom, 8px); } \ No newline at end of file diff --git a/public/css/main.css b/public/css/main.css index e804076..01e57c7 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -3,12 +3,21 @@ -webkit-tap-highlight-color: transparent; } -html, body { +html { margin: 0; padding: 0; height: 100%; overflow: hidden; background: var(--bg-primary); +} + +body { + margin: 0; + padding: 0; + height: 100%; + height: -webkit-fill-available; + overflow: hidden; + background: var(--bg-primary); color: var(--text-primary); font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Inter', system-ui, sans-serif; -webkit-font-smoothing: antialiased; @@ -19,17 +28,32 @@ html, body { display: flex; flex-direction: column; height: 100%; - height: 100dvh; padding-top: env(safe-area-inset-top); - padding-bottom: env(safe-area-inset-bottom); + background: var(--bg-secondary); +} + +/* iOS PWA: window.innerHeight = Safari viewport (793px), screen.height = full screen (852px). + Use screen.height via --screen-h to fill the true physical height. */ +@media (max-width: 1023px) { + html { + background: var(--bg-secondary); + } + .app-container { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 100vh; + height: var(--screen-h, 100vh); + } } /* ── Top bar ─────────────────────────────────────── */ .top-bar { display: flex; align-items: center; - height: 48px; - padding: 0 10px; + height: 52px; + padding: 0 12px; background: var(--bg-secondary); backdrop-filter: blur(var(--blur-amount)); -webkit-backdrop-filter: blur(var(--blur-amount)); @@ -56,9 +80,9 @@ html, body { background: none; border: none; color: var(--text-secondary); - font-size: 1.15rem; - width: 34px; - height: 34px; + font-size: 1.25rem; + width: 38px; + height: 38px; padding: 0; cursor: pointer; border-radius: var(--radius-sm); @@ -76,10 +100,35 @@ html, body { .app-title { font-weight: 700; - font-size: 0.85rem; + font-size: 0.95rem; letter-spacing: -0.02em; color: var(--text-primary); white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + display: flex; + align-items: center; + gap: 7px; +} + +.title-status { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; + background: var(--text-dimmed); + transition: background 0.3s ease; +} + +.title-status.idle { + background: var(--yellow); + opacity: 0.7; +} + +.title-status.working { + background: var(--green); + animation: status-pulse 1.8s ease-in-out infinite; } .top-bar-right { @@ -89,39 +138,18 @@ html, body { gap: 10px; } -/* Connection status pill */ +/* Connection status dot */ .connection-status { - display: inline-flex; - align-items: center; - gap: 5px; - padding: 3px 8px 3px 6px; - border-radius: var(--radius-pill); - background: rgba(248, 113, 113, 0.08); - border: 1px solid rgba(248, 113, 113, 0.15); - font-size: 0.7rem; - font-weight: 600; - color: var(--red); - transition: all var(--transition-normal); - white-space: nowrap; -} - -.connection-status::before { - content: ''; - width: 5px; - height: 5px; + width: 7px; + height: 7px; border-radius: 50%; background: var(--red); - flex-shrink: 0; box-shadow: 0 0 5px var(--red); + flex-shrink: 0; + transition: background var(--transition-normal), box-shadow var(--transition-normal); } .connection-status.connected { - background: rgba(52, 211, 153, 0.08); - border-color: rgba(52, 211, 153, 0.15); - color: var(--green); -} - -.connection-status.connected::before { background: var(--green); box-shadow: 0 0 6px var(--green); } @@ -135,35 +163,42 @@ html, body { animation: status-pulse 2.5s ease-in-out infinite; } -.theme-toggle { + + +.terminal-container.scroll-x { overflow-x: auto; } +.terminal-container.scroll-x .terminal-output { + white-space: pre; + word-break: normal; +} + +.reload-btn { background: none; border: none; color: var(--text-secondary); - font-size: 1rem; + font-size: 1.15rem; padding: 0; cursor: pointer; - width: 32px; - height: 32px; + width: 36px; + height: 36px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-sm); - transition: background var(--transition-fast), color var(--transition-fast); + transition: background var(--transition-fast), color var(--transition-fast), transform 0.3s ease; flex-shrink: 0; } +.reload-btn:hover { background: var(--bg-tertiary); color: var(--text-primary); } +.reload-btn:active { transform: rotate(180deg); } -.theme-toggle:hover { - background: var(--bg-tertiary); - color: var(--text-primary); -} /* ── Terminal container ──────────────────────────── */ .terminal-container { flex: 1; overflow-y: auto; - overflow-x: auto; + overflow-x: hidden; -webkit-overflow-scrolling: touch; background: var(--terminal-bg); + position: relative; } .terminal-container::-webkit-scrollbar { @@ -177,16 +212,59 @@ html, body { border-radius: 2px; } -/* ── QR button ───────────────────────────────────── */ -.qr-btn { +/* ── Desktop layout ──────────────────────────────── */ +@media (min-width: 1024px) { + body { + display: flex; + flex-direction: row; + } + + .sidebar-overlay { + display: none; + } + + .app-container { + flex: 1; + min-width: 0; + } + + .top-bar { + height: 50px; + padding: 0 18px; + } + +} + +@media (prefers-reduced-motion: reduce) { + .connection-status:not(.connected) { animation: none; } +} + +.connection-status.reconnecting { + background: var(--yellow); + box-shadow: 0 0 5px var(--yellow); +} + +.terminal-container.stale { position: relative; } +.terminal-container.stale::after { + content: ''; + position: absolute; + inset: 0; + background: var(--bg-primary); + opacity: 0.4; + pointer-events: none; + z-index: 1; +} + +/* ── Settings button ─────────────────────────── */ +.settings-btn { background: none; border: none; color: var(--text-secondary); - font-size: 1.1rem; + font-size: 1.05rem; padding: 0; cursor: pointer; - width: 32px; - height: 32px; + width: 36px; + height: 36px; display: flex; align-items: center; justify-content: center; @@ -194,129 +272,124 @@ html, body { transition: background var(--transition-fast), color var(--transition-fast); flex-shrink: 0; } +.settings-btn:hover { background: var(--bg-tertiary); color: var(--text-primary); } -.qr-btn:hover { - background: var(--bg-tertiary); - color: var(--text-primary); -} - -/* ── QR modal ────────────────────────────────────── */ -.qr-modal-overlay { +/* ── Settings panel ──────────────────────────── */ +.settings-overlay { + display: none; position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.65); - backdrop-filter: blur(4px); - -webkit-backdrop-filter: blur(4px); + background: rgba(0, 0, 0, 0.4); z-index: 200; - display: flex; - align-items: center; - justify-content: center; + -webkit-backdrop-filter: blur(2px); + backdrop-filter: blur(2px); } +.settings-overlay.visible { display: block; } -.qr-modal-overlay[hidden] { - display: none; -} - -.qr-modal { - position: relative; +.settings-panel { + position: fixed; + top: 0; + right: -280px; + width: 260px; + height: 100%; + padding-top: env(safe-area-inset-top); background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: var(--radius-lg, 12px); - padding: 24px; + border-left: 1px solid var(--border-color); + z-index: 201; display: flex; flex-direction: column; - align-items: center; - gap: 16px; - max-width: min(360px, 90vw); - width: 100%; - box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5); + transition: right var(--transition-normal); } +.settings-panel.open { right: 0; } -.qr-modal-close { - position: absolute; - top: 10px; - right: 10px; +.settings-header { + display: flex; + align-items: center; + justify-content: space-between; + height: 48px; + padding: 0 16px; + border-bottom: 1px solid var(--border-color); + font-weight: 600; + font-size: 0.88rem; + color: var(--text-primary); + flex-shrink: 0; +} +.settings-close { background: none; border: none; color: var(--text-secondary); - font-size: 1.4rem; - line-height: 1; + font-size: 1.2rem; cursor: pointer; - padding: 4px 8px; + padding: 0; + width: 28px; + height: 28px; border-radius: var(--radius-sm); - transition: background var(--transition-fast), color var(--transition-fast); + display: flex; + align-items: center; + justify-content: center; } +.settings-close:hover { background: var(--bg-tertiary); color: var(--text-primary); } -.qr-modal-close:hover { - background: var(--bg-tertiary); - color: var(--text-primary); -} +.settings-body { flex: 1; overflow-y: auto; padding: 8px 0; } -.qr-modal-image { - width: min(260px, 80vw); - height: min(260px, 80vw); - display: block; - border-radius: 8px; - background: #fff; - padding: 8px; +.settings-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 13px 16px; + cursor: pointer; + transition: background var(--transition-fast); + -webkit-user-select: none; + user-select: none; } +.settings-row:hover { background: var(--bg-tertiary); } +.settings-row:active { background: var(--bg-tertiary); } -.qr-modal-url { - font-size: 0.72rem; - color: var(--text-secondary); - word-break: break-all; - text-align: center; - max-width: 100%; - font-family: 'SF Mono', 'Menlo', 'Monaco', 'Consolas', monospace; +.settings-label { + font-size: 0.88rem; + color: var(--text-primary); } -/* ── Desktop layout ──────────────────────────────── */ -@media (min-width: 1024px) { - body { - display: flex; - flex-direction: row; - } - - .sidebar-overlay { - display: none; - } - - .app-container { - flex: 1; - min-width: 0; - } - - .top-bar { - height: 50px; - padding: 0 18px; - } - - .menu-btn { - display: none; - } +/* Toggle switch */ +.settings-toggle { + position: relative; + width: 44px; + height: 26px; + flex-shrink: 0; } -@media (prefers-reduced-motion: reduce) { - .connection-status:not(.connected) { animation: none; } +.settings-cb { + position: absolute; + opacity: 0; + width: 0; + height: 0; } -.connection-status.reconnecting { - background: rgba(251, 191, 36, 0.08); - border-color: rgba(251, 191, 36, 0.15); - color: var(--yellow); -} -.connection-status.reconnecting::before { - background: var(--yellow); - box-shadow: 0 0 5px var(--yellow); +.settings-toggle-slider { + position: absolute; + inset: 0; + background: var(--border-color); + border-radius: 26px; + transition: background 0.2s; } -.terminal-container.stale { position: relative; } -.terminal-container.stale::after { +.settings-toggle-slider::before { content: ''; position: absolute; - inset: 0; - background: var(--bg-primary); - opacity: 0.4; - pointer-events: none; - z-index: 1; + width: 20px; + height: 20px; + left: 3px; + top: 3px; + background: #fff; + border-radius: 50%; + transition: transform 0.2s; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); +} + +.settings-cb:checked + .settings-toggle-slider { + background: var(--accent); +} + +.settings-cb:checked + .settings-toggle-slider::before { + transform: translateX(18px); } diff --git a/public/css/sidebar.css b/public/css/sidebar.css index 827e2a3..3180c93 100644 --- a/public/css/sidebar.css +++ b/public/css/sidebar.css @@ -82,7 +82,8 @@ .sidebar-content { flex: 1; overflow-y: auto; - padding: 10px 0 16px; + overflow-x: hidden; + padding: 10px 0 20px; } .sidebar-content::-webkit-scrollbar { @@ -95,24 +96,30 @@ /* ── Group label ─────────────────────────────────── */ .ws-group-label { - padding: 14px 16px 5px; - font-size: 0.65rem; + padding: 16px 16px 7px; + font-size: 0.78rem; text-transform: uppercase; - letter-spacing: 0.1em; - color: var(--text-dimmed); + letter-spacing: 0.08em; + color: var(--text-secondary); font-weight: 700; } +.ws-group-label:not(:first-child) { + margin-top: 6px; + border-top: 1px solid var(--border-color); + padding-top: 18px; +} + /* ── Workspace item ──────────────────────────────── */ .ws-item { - padding: 9px 14px; + padding: 10px 14px; cursor: pointer; display: flex; - align-items: center; - gap: 9px; - font-size: 0.86rem; + align-items: flex-start; + gap: 10px; + font-size: 0.95rem; font-weight: 500; - color: var(--text-secondary); + color: var(--text-primary); transition: background var(--transition-fast), color var(--transition-fast); margin: 1px 8px; border-radius: var(--radius-sm); @@ -130,30 +137,32 @@ .ws-item .ws-icon { color: var(--text-dimmed); - font-size: 0.8rem; + font-size: 0.85rem; width: 18px; text-align: center; flex-shrink: 0; + padding-top: 2px; } -.ws-item .ws-label { +.ws-item .ws-label, +.surface-item .ws-label { flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + word-break: break-word; + overflow-wrap: break-word; + line-height: 1.45; } /* ── Surface item ────────────────────────────────── */ .surface-item { - padding: 8px 12px; + padding: 10px 14px; cursor: pointer; - font-size: 0.83rem; - color: var(--text-secondary); + font-size: 0.95rem; + color: var(--text-primary); display: flex; - align-items: center; - gap: 8px; + align-items: flex-start; + gap: 10px; transition: background var(--transition-fast), color var(--transition-fast); - margin: 1px 8px; + margin: 2px 8px; border-radius: var(--radius-sm); position: relative; } @@ -182,41 +191,73 @@ border-radius: 0 var(--radius-pill) var(--radius-pill) 0; } -.surface-item .surface-icon { - font-size: 0.75rem; - width: 16px; - text-align: center; +.surface-status { + width: 8px; + height: 8px; + border-radius: 50%; flex-shrink: 0; - opacity: 0.6; + margin-top: 6px; + background: var(--text-dimmed); + transition: background 0.3s ease; +} + +.surface-status.idle { + background: var(--yellow); + opacity: 0.7; } -.surface-item.active .surface-icon { +.surface-status.working { + background: var(--green); + animation: status-pulse 1.8s ease-in-out infinite; +} + +.surface-item.active .surface-status { opacity: 1; } +@keyframes status-pulse { + 0%, 100% { + opacity: 1; + box-shadow: 0 0 4px var(--green-glow); + } + 50% { + opacity: 0.3; + box-shadow: 0 0 8px var(--green-glow); + } +} + /* ── Desktop ─────────────────────────────────────── */ @media (min-width: 1024px) { .sidebar { position: relative; transform: none; - width: 252px; + width: 290px; flex-shrink: 0; box-shadow: none; height: 100vh; height: 100dvh; + overflow: hidden; + transition: width 0.22s ease, border-color 0.22s ease; } .sidebar.open { transform: none; } - .sidebar-overlay { - display: none; + .sidebar.desktop-hidden { + width: 0; + border-right-color: transparent; } - .sidebar-close { + .sidebar-overlay { display: none; } + + /* Font sizes smaller on desktop — mobile sizes are larger for touch */ + .ws-group-label { font-size: 0.7rem; padding-top: 14px; } + .ws-group-label:not(:first-child) { padding-top: 16px; } + .ws-item { font-size: 0.86rem; padding: 8px 12px; } + .surface-item { font-size: 0.84rem; padding: 8px 12px; margin: 1px 8px; } } .sidebar-empty { diff --git a/public/css/terminal.css b/public/css/terminal.css index c920fc3..fffaaaf 100644 --- a/public/css/terminal.css +++ b/public/css/terminal.css @@ -7,16 +7,143 @@ unicode-range: U+23FB-23FE, U+2665, U+26A1, U+2B58, U+E000-E00A, U+E0A0-E0A2, U+E0A3, U+E0B0-E0C8, U+E0CA, U+E0CC-E0D7, U+E200-E2A9, U+E300-E3E3, U+E5FA-E6B7, U+E700-E7C5, U+EA60-EBEB, U+F000-F2FF, U+F400-F533, U+F0001-F1AF0; } +.terminal-container.browser-mode { + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; +} + +.browser-view[hidden], +.browser-zoom-btn[hidden] { display: none; } + +.browser-view { + max-width: 100%; + max-height: 100%; + width: auto; + height: auto; + object-fit: contain; + display: block; + background: #000; +} + +.browser-loading[hidden] { display: none; } +.browser-loading { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + z-index: 30; + overflow: hidden; +} +.browser-loading::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent 0%, var(--accent) 50%, transparent 100%); + background-size: 40% 100%; + background-repeat: no-repeat; + background-position: -40% 0; + animation: browser-sweep 1.6s ease-in-out infinite; + opacity: 0; + transition: opacity 0.3s; +} +.browser-loading.flash::after { + opacity: 1; +} +@keyframes browser-sweep { + 0% { background-position: -40% 0; } + 100% { background-position: 140% 0; } +} + +.browser-zoom-btn { + position: absolute; + top: 10px; + right: 10px; + z-index: 20; + width: 36px; + height: 36px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.55); + border: 1px solid rgba(255, 255, 255, 0.15); + color: #fff; + font-size: 1rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + transition: background 0.15s; +} +.browser-zoom-btn:active { background: rgba(255, 255, 255, 0.2); } + +/* Lightbox */ +.img-zoom-overlay[hidden] { display: none; } +.img-zoom-overlay { + position: fixed; + inset: 0; + background: #000; + z-index: 500; + display: flex; + align-items: stretch; + animation: fadeIn 0.18s ease; +} +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } + +.img-zoom-close { + position: absolute; + top: max(16px, env(safe-area-inset-top)); + right: 16px; + z-index: 10; + width: 36px; + height: 36px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.15); + border: none; + color: #fff; + font-size: 1rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); +} +.img-zoom-close:active { background: rgba(255, 255, 255, 0.3); } + +.img-zoom-container { + flex: 1; + overflow: hidden; + cursor: grab; + display: flex; + align-items: center; + justify-content: center; + touch-action: none; +} +.img-zoom-container.dragging { cursor: grabbing; } + +.img-zoom-img { + max-width: 100%; + max-height: 100%; + pointer-events: none; + user-select: none; + -webkit-user-drag: none; + transform-origin: center center; + will-change: transform; +} + .terminal-output { margin: 0; - padding: 12px 16px; + padding: 14px 16px; font-family: 'SF Mono', 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'Menlo', 'Monaco', 'Consolas', 'SymbolsNerdFont', 'Symbols Nerd Font Mono', monospace; - font-size: 13px; - line-height: 1.0; - white-space: pre; - word-wrap: normal; + font-size: 15px; + line-height: 1.2; + white-space: pre-wrap; + word-break: break-all; color: var(--text-primary); - contain: content; + contain: style; min-height: 100%; -webkit-user-select: text; user-select: text; @@ -36,7 +163,7 @@ } .hl-thinking { - color: var(--purple); + color: var(--yellow); } .hl-output { @@ -86,8 +213,8 @@ } -/* QR code lines — font-size set dynamically in terminal-view.js to fit width */ -.qr-line { +/* QR block — font-size set dynamically in terminal-view.js to fit width */ +.qr-block { display: block; line-height: 1.0; letter-spacing: -0.03em; diff --git a/public/index.html b/public/index.html index 50ad2e5..cfbd4c5 100644 --- a/public/index.html +++ b/public/index.html @@ -6,13 +6,15 @@ cmux-remote + - - - - - + + + + + + @@ -30,47 +32,53 @@
- cmux-remote +
- Offline - + + +

+      
+      
+      
     
+ + + - - - - - - - - - + +
+ + + + + + + + + + + + + + diff --git a/public/js/app.js b/public/js/app.js index 62c9435..83b0c6b 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -13,12 +13,145 @@ // State let currentWorkspace = null; let currentSurface = null; + let lastWorkspaces = []; + + // URL hash helpers — format: #wsRef/surfaceRef + function getHashSurface() { + const hash = location.hash.slice(1); + if (!hash) return null; + const slash = hash.indexOf('/'); + if (slash === -1) return null; + return { + ws: decodeURIComponent(hash.slice(0, slash)), + surface: decodeURIComponent(hash.slice(slash + 1)), + }; + } + + function setHashSurface(wsRef, surfaceRef) { + history.replaceState(null, '', '#' + encodeURIComponent(wsRef) + '/' + encodeURIComponent(surfaceRef)); + } + + function findAndSwitch(workspaces, wsRef, surfaceRef) { + for (const workspace of workspaces) { + if (workspace.ref === wsRef) { + for (const pane of workspace.panes) { + for (const surface of pane.surfaces) { + if (surface.ref === surfaceRef) { + switchSurface(wsRef, surfaceRef); + return true; + } + } + } + } + } + return false; + } // Components const ws = new WebSocketClient(); const terminal = new TerminalView(document.getElementById('terminal-output')); const statusDot = document.getElementById('connection-status'); const terminalContainer = document.getElementById('terminal-container'); + const browserView = document.getElementById('browser-view'); + const terminalOutput = document.getElementById('terminal-output'); + + // ── Image zoom viewer ────────────────────────────────────────── + class ImageZoomViewer { + constructor(img, container) { + this._img = img; + this._con = container; + this._scale = 1; + this._x = 0; this._y = 0; + this._lastDist = 0; + this._dragging = false; + this._lastX = 0; this._lastY = 0; + + container.addEventListener('touchstart', this._ts.bind(this), { passive: false }); + container.addEventListener('touchmove', this._tm.bind(this), { passive: false }); + container.addEventListener('touchend', () => { this._dragging = false; }, { passive: true }); + container.addEventListener('wheel', this._wheel.bind(this), { passive: false }); + container.addEventListener('mousedown', this._md.bind(this)); + container.addEventListener('mousemove', this._mm.bind(this)); + container.addEventListener('mouseup', this._mu.bind(this)); + container.addEventListener('mouseleave', this._mu.bind(this)); + container.addEventListener('dblclick', () => this.reset()); + } + + reset() { this._scale = 1; this._x = 0; this._y = 0; this._apply(); } + + _apply() { + this._img.style.transform = `translate(${this._x}px, ${this._y}px) scale(${this._scale})`; + } + + _clampScale(s) { return Math.min(10, Math.max(0.5, s)); } + + _ts(e) { + if (e.touches.length === 2) { + e.preventDefault(); + this._lastDist = this._dist(e.touches[0], e.touches[1]); + } else if (e.touches.length === 1) { + this._dragging = true; + this._lastX = e.touches[0].clientX; + this._lastY = e.touches[0].clientY; + } + } + + _tm(e) { + e.preventDefault(); + if (e.touches.length === 2) { + const d = this._dist(e.touches[0], e.touches[1]); + this._scale = this._clampScale(this._scale * (d / this._lastDist)); + this._lastDist = d; + this._apply(); + } else if (e.touches.length === 1 && this._dragging) { + this._x += e.touches[0].clientX - this._lastX; + this._y += e.touches[0].clientY - this._lastY; + this._lastX = e.touches[0].clientX; + this._lastY = e.touches[0].clientY; + this._apply(); + } + } + + _wheel(e) { + e.preventDefault(); + this._scale = this._clampScale(this._scale * (e.deltaY < 0 ? 1.12 : 0.9)); + this._apply(); + } + + _md(e) { this._dragging = true; this._lastX = e.clientX; this._lastY = e.clientY; this._con.classList.add('dragging'); } + _mm(e) { + if (!this._dragging) return; + this._x += e.clientX - this._lastX; + this._y += e.clientY - this._lastY; + this._lastX = e.clientX; this._lastY = e.clientY; + this._apply(); + } + _mu() { this._dragging = false; this._con.classList.remove('dragging'); } + + _dist(t1, t2) { + return Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY); + } + } + + const browserLoading = document.getElementById('browser-loading'); + const zoomBtn = document.getElementById('browser-zoom-btn'); + const zoomOverlay = document.getElementById('img-zoom-overlay'); + const zoomContainer = document.getElementById('img-zoom-container'); + const zoomImg = document.getElementById('img-zoom-img'); + const zoomClose = document.getElementById('img-zoom-close'); + let zoomViewer = null; + + zoomBtn.addEventListener('click', () => { + zoomImg.src = browserView.src; + zoomOverlay.hidden = false; + if (!zoomViewer) { + zoomViewer = new ImageZoomViewer(zoomImg, zoomContainer); + } else { + zoomViewer.reset(); + } + }); + + zoomClose.addEventListener('click', () => { zoomOverlay.hidden = true; }); const sidebar = new Sidebar((wsRef, surfaceRef) => { switchSurface(wsRef, surfaceRef); }); @@ -26,99 +159,181 @@ const gestures = new GestureHandler(sidebar); const theme = new ThemeSwitcher(); - // QR modal - const qrOverlay = document.getElementById('qr-modal-overlay'); - const qrBtn = document.getElementById('qr-btn'); - const qrCloseBtn = document.getElementById('qr-modal-close'); - const qrUrlEl = document.getElementById('qr-modal-url'); + // Scrollback state + const PAGE_LINES = 500; + let scrollbackLines = 0; // 0 = live view + let inScrollMode = false; - function _qrKeyHandler(e) { - if (e.key === 'Escape') { - closeQrModal(); - } else if (e.key === 'Tab') { - e.preventDefault(); - qrCloseBtn.focus(); - } + function requestScrollback(lines) { + if (!currentWorkspace || !currentSurface) return; + ws.requestScroll(currentWorkspace, currentSurface, lines); } - async function openQrModal() { - try { - const { url } = await fetch('/api/access-url').then(r => r.json()); - qrUrlEl.textContent = url; - } catch { - qrUrlEl.textContent = window.location.origin; + function exitScrollMode() { + inScrollMode = false; + scrollbackLines = 0; + terminal.autoScroll = true; + // Re-subscribe to get fresh live content + if (currentWorkspace && currentSurface) { + ws.subscribe(currentWorkspace, currentSurface); } - // Reload QR image so it's always fresh - document.getElementById('qr-modal-image').src = '/api/qr.svg?' + Date.now(); - qrOverlay.hidden = false; - qrCloseBtn.focus(); - document.addEventListener('keydown', _qrKeyHandler); } - function closeQrModal() { - qrOverlay.hidden = true; - document.removeEventListener('keydown', _qrKeyHandler); - qrBtn.focus(); - } - - qrBtn.addEventListener('click', openQrModal); - qrCloseBtn.addEventListener('click', closeQrModal); - qrOverlay.addEventListener('click', (e) => { - if (e.target === qrOverlay) closeQrModal(); - }); - const keyboard = new VirtualKeyboard( (text) => { if (currentWorkspace && currentSurface) { + if (inScrollMode) exitScrollMode(); ws.sendText(currentWorkspace, currentSurface, text); } }, (key) => { + if (key === 'PageUp') { + inScrollMode = true; + scrollbackLines += PAGE_LINES; + requestScrollback(scrollbackLines); + return; + } + if (key === 'PageDown') { + if (inScrollMode) { + scrollbackLines = Math.max(0, scrollbackLines - PAGE_LINES); + if (scrollbackLines === 0) { + exitScrollMode(); + } else { + requestScrollback(scrollbackLines); + } + } + return; + } + if (inScrollMode) exitScrollMode(); if (currentWorkspace && currentSurface) { ws.sendKey(currentWorkspace, currentSurface, key); } } ); - // Keyboard toggle (desktop) + const claudeKeyboard = new ClaudeKeyboard( + (text) => { + if (currentWorkspace && currentSurface) { + ws.sendText(currentWorkspace, currentSurface, text); + } + }, + (key) => { + if (currentWorkspace && currentSurface) { + ws.sendKey(currentWorkspace, currentSurface, key); + } + } + ); + + function handleClaudeAction(action, payload) { + if (!currentWorkspace || !currentSurface) return; + if (action === 'select') { + ws.sendText(currentWorkspace, currentSurface, payload + '\n'); + } else if (action === 'mode-toggle') { + ws.sendKey(currentWorkspace, currentSurface, 'Shift-Tab'); + } + } + + // Reload button + document.getElementById('reload-btn').addEventListener('click', () => location.reload()); + + // Horizontal scroll toggle + const scrollXCb = document.getElementById('scroll-x-cb'); + scrollXCb.addEventListener('change', () => { + terminalContainer.classList.toggle('scroll-x', scrollXCb.checked); + }); + + // Keyboard toggle — restore saved state const keyboardToggle = document.getElementById('keyboard-toggle'); const virtualKeyboardEl = document.getElementById('virtual-keyboard'); - if (window.innerWidth >= 1024) { + const savedKbCollapsed = localStorage.getItem('cmux-kb-collapsed'); + if (savedKbCollapsed !== null) { + virtualKeyboardEl.classList.toggle('collapsed', savedKbCollapsed === 'true'); + } else if (window.innerWidth >= 1024) { virtualKeyboardEl.classList.add('collapsed'); - keyboardToggle.hidden = false; } keyboardToggle.addEventListener('click', () => { virtualKeyboardEl.classList.toggle('collapsed'); + localStorage.setItem('cmux-kb-collapsed', virtualKeyboardEl.classList.contains('collapsed')); }); window.addEventListener('resize', () => { if (window.innerWidth < 1024) { - keyboardToggle.hidden = true; virtualKeyboardEl.classList.remove('collapsed'); - } else { - keyboardToggle.hidden = false; + localStorage.removeItem('cmux-kb-collapsed'); } }); + // Settings panel + const settingsBtn = document.getElementById('settings-btn'); + const settingsPanel = document.getElementById('settings-panel'); + const settingsOverlay = document.getElementById('settings-overlay'); + const settingsClose = document.getElementById('settings-close'); + settingsBtn.addEventListener('click', () => { + settingsPanel.classList.add('open'); + settingsOverlay.classList.add('visible'); + }); + function closeSettings() { + settingsPanel.classList.remove('open'); + settingsOverlay.classList.remove('visible'); + } + settingsClose.addEventListener('click', closeSettings); + settingsOverlay.addEventListener('click', closeSettings); + + // Title helper — reuses sidebar's status-parsing logic + function _parseStatus(title) { + const workingRe = /^[\u2800-\u28FF\u23FA\u25CF\u2B24]\s*/; + const idleRe = /^[\u2700-\u27BF]\s*/; + if (workingRe.test(title)) return { state: 'working', title: title.replace(workingRe, '') }; + if (idleRe.test(title)) return { state: 'idle', title: title.replace(idleRe, '') }; + return { state: null, title }; + } + + function updateTitle(wsRef, surfaceRef) { + const el = document.getElementById('app-title'); + if (!el) return; + let rawTitle = surfaceRef || ''; + for (const workspace of lastWorkspaces) { + if (workspace.ref === wsRef) { + for (const pane of workspace.panes) { + for (const surface of pane.surfaces) { + if (surface.ref === surfaceRef) { + rawTitle = surface.title || surface.ref; + break; + } + } + } + } + } + const status = _parseStatus(rawTitle); + el.innerHTML = ''; + if (status.state) { + const dot = document.createElement('span'); + dot.className = 'title-status ' + status.state; + el.appendChild(dot); + } + el.appendChild(document.createTextNode(status.title)); + } + + // Connection events ws.on('connected', () => { statusDot.classList.add('connected'); statusDot.classList.remove('reconnecting'); - statusDot.textContent = 'Connected'; + statusDot.title = 'Connected'; terminalContainer.classList.remove('stale'); }); ws.on('disconnected', () => { statusDot.classList.remove('connected'); statusDot.classList.add('reconnecting'); - statusDot.textContent = 'Reconnecting...'; + statusDot.title = 'Reconnecting...'; terminalContainer.classList.add('stale'); }); ws.on('reconnecting', ({ attempt }) => { - statusDot.textContent = `Reconnecting (${attempt})...`; + statusDot.title = `Reconnecting (${attempt})...`; }); ws.on('auth-ok', () => { @@ -128,9 +343,21 @@ ws.on('workspaces', (msg) => { sidebar.setWorkspaces(msg.workspaces); + lastWorkspaces = msg.workspaces; - // Auto-subscribe to first terminal surface if none selected + // Auto-subscribe only on first load (no surface selected yet) if (!currentSurface && msg.workspaces.length > 0) { + // 1. Try to restore from URL hash + const saved = getHashSurface(); + if (saved && findAndSwitch(msg.workspaces, saved.ws, saved.surface)) return; + + // 2. Try to restore from localStorage + try { + const ls = JSON.parse(localStorage.getItem('cmux-last-surface') || 'null'); + if (ls && findAndSwitch(msg.workspaces, ls.ws, ls.surface)) return; + } catch {} + + // 3. Fall back to first terminal surface const firstWs = msg.workspaces[0]; for (const pane of firstWs.panes) { for (const surface of pane.surfaces) { @@ -140,7 +367,7 @@ } } } - // Fallback: first surface of any type + // 3. Fall back to first surface of any type if (firstWs.panes.length > 0 && firstWs.panes[0].surfaces.length > 0) { const s = firstWs.panes[0].surfaces[0]; switchSurface(firstWs.ref, s.ref); @@ -149,31 +376,111 @@ }); ws.on('screen', (msg) => { - if (msg.surface === currentSurface) { - terminal.setContent(msg.lines); + if (msg.surface !== currentSurface) return; + if (inScrollMode && !msg.scrollback) return; + terminal.setContent(msg.lines); + + if (msg.scrollback) { + terminal.autoScroll = false; + // After render, scroll to show content just before current view (most recently scrolled-off) + requestAnimationFrame(() => { + requestAnimationFrame(() => { + terminalContainer.scrollTop = terminalContainer.scrollHeight - terminalContainer.clientHeight; + }); + }); } }); ws.on('screen-diff', (msg) => { - if (msg.surface === currentSurface) { + if (msg.surface === currentSurface && !inScrollMode) { terminal.applyDiff(msg.patches); } }); - function switchSurface(wsRef, surfaceRef) { - // Unsubscribe from current - if (currentSurface) { - ws.unsubscribe(currentSurface); + ws.on('browser-screenshot', (msg) => { + if (msg.surface !== currentSurface) return; + const mime = msg.mime || 'image/jpeg'; + const src = `data:${mime};base64,${msg.imageData}`; + terminalOutput.hidden = true; + terminalContainer.classList.add('browser-mode'); + + // Hide loading bar, show image + browserLoading.hidden = true; + browserLoading.classList.remove('flash'); + browserView.hidden = false; + zoomBtn.hidden = false; + browserView.src = src; + if (!zoomOverlay.hidden) zoomImg.src = src; + }); + + async function updateClaudeMode(surfaceRef) { + try { + const data = await fetch('/api/claude-surfaces').then(r => r.json()); + if (surfaceRef !== currentSurface) return; + const isClaudeCode = data.surfaces.includes(surfaceRef); + document.body.classList.toggle('claude-mode', isClaudeCode); + terminal.setClaudeMode(isClaudeCode, handleClaudeAction); + if (isClaudeCode) { + claudeKeyboard.activate(); + } else { + claudeKeyboard.deactivate(); + } + } catch { + if (surfaceRef !== currentSurface) return; + document.body.classList.remove('claude-mode'); + terminal.setClaudeMode(false, null); + claudeKeyboard.deactivate(); } + } + + function switchSurface(wsRef, surfaceRef) { + if (currentSurface) ws.unsubscribe(currentSurface); + inScrollMode = false; + scrollbackLines = 0; currentWorkspace = wsRef; currentSurface = surfaceRef; + updateTitle(wsRef, surfaceRef); + localStorage.setItem('cmux-last-surface', JSON.stringify({ ws: wsRef, surface: surfaceRef })); + + // Immediately reset claude mode; updateClaudeMode will re-enable if needed + document.body.classList.remove('claude-mode'); + terminal.setClaudeMode(false, null); + claudeKeyboard.deactivate(); terminal.clear(); + terminal.autoScroll = true; + browserView.hidden = true; + browserLoading.hidden = true; + browserLoading.classList.remove('flash'); + zoomBtn.hidden = true; + zoomOverlay.hidden = true; + terminalOutput.hidden = false; + terminalContainer.classList.remove('browser-mode'); ws.subscribe(wsRef, surfaceRef); sidebar.setActiveSurface(surfaceRef); + setHashSurface(wsRef, surfaceRef); + updateClaudeMode(surfaceRef); + + // If this is a browser surface, show loading indicator immediately + const surfaceInfo = lastWorkspaces.flatMap(w => w.panes.flatMap(p => p.surfaces)) + .find(s => s.ref === surfaceRef); + if (surfaceInfo?.type === 'browser') { + terminalOutput.hidden = true; + terminalContainer.classList.add('browser-mode'); + browserLoading.hidden = false; + browserLoading.classList.add('flash'); + } } + // Browser back/forward navigation + window.addEventListener('hashchange', () => { + const saved = getHashSurface(); + if (saved && ws.isConnected()) { + findAndSwitch(lastWorkspaces, saved.ws, saved.surface); + } + }); + // Visibility API: pause polling when hidden document.addEventListener('visibilitychange', () => { if (document.hidden) { diff --git a/public/js/claude-keyboard.js b/public/js/claude-keyboard.js new file mode 100644 index 0000000..b18bd21 --- /dev/null +++ b/public/js/claude-keyboard.js @@ -0,0 +1,43 @@ +class ClaudeKeyboard { + constructor(onSendText, onSendKey) { + this.onSendText = onSendText; + this.onSendKey = onSendKey; + this._active = false; + this._actionBar = null; + } + + activate() { + if (this._active) return; + this._active = true; + } + + deactivate() { + if (!this._active) return; + this._active = false; + } + + _makeBtn(extraClass, icon, label, onClick) { + const btn = document.createElement('button'); + btn.className = `cc-action-btn ${extraClass}`; + + const iconEl = document.createElement('span'); + iconEl.className = 'cc-action-icon'; + iconEl.textContent = icon; + + const labelEl = document.createElement('span'); + labelEl.className = 'cc-action-label'; + labelEl.textContent = label; + + btn.appendChild(iconEl); + btn.appendChild(labelEl); + btn.addEventListener('click', () => { + if (navigator.vibrate) navigator.vibrate(8); + onClick(); + }); + + return btn; + } + +} + +window.ClaudeKeyboard = ClaudeKeyboard; diff --git a/public/js/claude-parser.js b/public/js/claude-parser.js new file mode 100644 index 0000000..0101ce8 --- /dev/null +++ b/public/js/claude-parser.js @@ -0,0 +1,384 @@ +class ClaudeParser { + constructor() { + this._lastLines = null; + this._lastDoc = null; + } + + parse(lines) { + if (lines === this._lastLines) return this._lastDoc; + this._lastLines = lines; + this._lastDoc = this._doParse(lines); + return this._lastDoc; + } + + _doParse(lines) { + if (!lines || lines.length === 0) { + return { blocks: [], statusBar: null }; + } + + const statusBarIdx = this._findStatusBarIdx(lines); + const statusBar = statusBarIdx >= 0 ? this._tryParseStatusBar(lines[statusBarIdx]) : null; + + // Content lines: exclude the status bar line + const contentLines = statusBarIdx >= 0 + ? lines.filter((_, i) => i !== statusBarIdx) + : lines; + + const blocks = this._parseBlocks(contentLines); + + // Mark the last prompt as active (current input area) + for (let i = blocks.length - 1; i >= 0; i--) { + if (blocks[i].type === 'prompt') { + blocks[i].isActive = true; + break; + } + } + + return { blocks, statusBar }; + } + + _findStatusBarIdx(lines) { + // Last non-empty line only + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]; + if (!line || line.trim() === '') continue; + if (this._tryParseStatusBar(line)) return i; + return -1; // first non-empty from bottom isn't a status bar + } + return -1; + } + + _tryParseStatusBar(line) { + const stripped = line.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '').trim(); + + // Exclude prompt and spinner/thinking lines — not a status bar + if (/^[\u276F\u2700-\u27BF]/.test(stripped)) return null; + + // New format: "⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt 9% until auto-compact" + // "⏸ plan mode (shift+tab to cycle)" + if (/[\u23F8\u23F5]/.test(stripped) && /shift\+tab/i.test(stripped)) { + let mode = 'code'; + if (/\u23F8/.test(stripped)) mode = 'plan'; + else if (/\u23F5/.test(stripped)) mode = 'bypass'; + const pctMatch = stripped.match(/(\d+)%\s+until\s+auto-compact/i); + const stats = pctMatch ? `${pctMatch[1]}% context` : null; + return { mode, branch: null, stats, rawLine: line }; + } + + // Old format: "⏵ bypass · main · 3.2k tokens" + if (!stripped.includes('·')) return null; + + const parts = stripped.split('·').map(p => p.trim()); + if (parts.length < 2) return null; + + let mode = null; + const firstPart = parts[0]; + + if (/\u23F8/.test(firstPart)) { + mode = 'plan'; + } else if (/\u23F5/.test(firstPart)) { + mode = 'bypass'; + } else { + mode = 'code'; + } + + // Branch: first part with no spaces that looks like a branch name + let branch = null; + for (const part of parts) { + if (part && !part.includes(' ') && /^[\w][\w./_-]*$/.test(part) && part.length < 50) { + branch = part; + break; + } + } + + const branchIdx = parts.findIndex(p => p === branch); + const statsParts = parts.filter((_, i) => i !== 0 && i !== branchIdx); + const stats = statsParts.length > 0 ? statsParts.join(' · ') : null; + + return { mode, branch, stats, rawLine: line }; + } + + _parseBlocks(lines) { + const blocks = []; + + // Pre-scan for selection ranges near the bottom + const selectionRanges = this._detectSelectionRanges(lines); + const selectionLineSet = new Set(); + selectionRanges.forEach(r => { + for (let i = r.start; i <= r.end; i++) selectionLineSet.add(i); + }); + + let i = 0; + while (i < lines.length) { + // Selection block + if (selectionLineSet.has(i)) { + const range = selectionRanges.find(r => i === r.start); + if (range) { + const selLines = lines.slice(range.start, range.end + 1); + blocks.push({ + type: 'selection', + lines: selLines, + toolName: null, + options: this._parseOptions(selLines), + }); + i = range.end + 1; + continue; + } + } + + const line = lines[i]; + const s = this._stripAnsi(line); + + // Empty + if (/^\s*$/.test(s)) { + blocks.push({ type: 'empty', lines: [line], toolName: null, options: null }); + i++; + continue; + } + + // Table block: lines using box-drawing table characters (│, ┃, ┌, ├, etc.) + // Must be detected BEFORE _isSeparatorLine because border rows (├──┤) look like separators + if (this._isTableRowLine(line) || this._isTableBorderLine(line)) { + const bl = [line]; i++; + while (i < lines.length) { + if (this._isTableRowLine(lines[i]) || this._isTableBorderLine(lines[i])) { + bl.push(lines[i++]); + } else { + break; + } + } + blocks.push({ type: 'table', lines: bl, toolName: null, options: null }); + continue; + } + + // Skip separator lines (─/━/═ box-drawing horizontal rules used as UI chrome) + if (this._isSeparatorLine(line)) { + i++; + continue; + } + + // ❯ Prompt (U+276F) + if (/^\s*\u276F/.test(s)) { + const bl = [line]; i++; + while (i < lines.length && !this._isBlockStart(lines[i]) && !/^\s*$/.test(lines[i])) { + // Stop collecting at separator lines or selection option lines + if (this._isSeparatorLine(lines[i])) break; + if (selectionLineSet.has(i)) break; + bl.push(lines[i++]); + } + // Skip empty prompt blocks — these are the input cursor, not submitted messages + // Only check the first line's text (not continuation lines) + const firstLineText = bl[0].replace(/^\s*\u276F\s*/, '').trim(); + if (!firstLineText) continue; + blocks.push({ type: 'prompt', lines: bl, toolName: null, options: null }); + continue; + } + + // ⏺/●/⬤ Tool-use (U+23FA / U+25CF / U+2B24) + if (/^\s*[\u23FA\u25CF\u2B24]/.test(s)) { + const toolName = this._extractToolName(line); + const bl = [line]; i++; + // Collect indented continuation lines (use stripped version for indent check) + while (i < lines.length) { + const cs = this._stripAnsi(lines[i]); + if (/^ {2,}/.test(cs) && !this._isBlockStart(lines[i])) { + bl.push(lines[i++]); + } else break; + } + blocks.push({ type: 'tool-use', lines: bl, toolName, options: null }); + continue; + } + + // ⎿ Response output marker (U+23BF) + if (/^\s*\u23BF/.test(s)) { + // Skip tip/hint lines entirely + const lineStripped = line.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ''); + if (/Tip:/i.test(lineStripped)) { i++; continue; } + const bl = [line]; i++; + while (i < lines.length && /^\s*\u23BF/.test(this._stripAnsi(lines[i]))) { + bl.push(lines[i++]); + } + blocks.push({ type: 'response', lines: bl, toolName: null, options: null }); + continue; + } + + // ╭ Tool-result box (U+256D) + if (/^\s*\u256D/.test(s) || s.trimStart().startsWith('╭')) { + const bl = [line]; i++; + while (i < lines.length) { + const l = lines[i]; + bl.push(l); i++; + // Stop at closing corner ╰ (U+2570) or ╯ (U+256F) + if (/[\u2570\u256F]/.test(l)) break; + } + blocks.push({ type: 'tool-result', lines: bl, toolName: null, options: null }); + continue; + } + + // Diff: +/- lines (only when clearly a diff context) + if (/^[+-](?![+-])/.test(s) || /^@@/.test(s)) { + const bl = [line]; i++; + while (i < lines.length) { + const cs = this._stripAnsi(lines[i]); + if (/^[+\- @]/.test(cs) && !this._isBlockStart(lines[i])) { + bl.push(lines[i++]); + } else break; + } + blocks.push({ type: 'diff', lines: bl, toolName: null, options: null }); + continue; + } + + // Code: 4+ space indent with code-like content + if (/^ {4,}/.test(s) && this._isCodeLike(s)) { + const bl = [line]; i++; + while (i < lines.length) { + const cs = this._stripAnsi(lines[i]); + if (/^ {4,}/.test(cs)) { + bl.push(lines[i++]); + } else if (/^\s*$/.test(lines[i]) && i + 1 < lines.length && /^ {4,}/.test(this._stripAnsi(lines[i + 1]))) { + bl.push(lines[i++]); // include bridging empty line + } else { + break; + } + } + // Trim trailing empty lines + while (bl.length > 0 && /^\s*$/.test(bl[bl.length - 1])) bl.pop(); + if (bl.length > 0) { + blocks.push({ type: 'code', lines: bl, toolName: null, options: null }); + } + continue; + } + + // Text: fallback — collect consecutive non-special lines + const bl = [line]; i++; + while (i < lines.length && !this._isBlockStart(lines[i]) && !/^\s*$/.test(lines[i]) && !selectionLineSet.has(i)) { + bl.push(lines[i++]); + } + blocks.push({ type: 'text', lines: bl, toolName: null, options: null }); + } + + return blocks; + } + + _stripAnsi(line) { + // eslint-disable-next-line no-control-regex + return line.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ''); + } + + _isBlockStart(line) { + if (!line || /^\s*$/.test(line)) return false; + const s = this._stripAnsi(line); + return ( + /^\s*\u276F/.test(s) || // ❯ prompt + /^\s*[\u23FA\u25CF\u2B24]/.test(s) || // ⏺●⬤ tool-use +/^\s*\u23BF/.test(s) || // ⎿ response + /^\s*\u256D/.test(s) || // ╭ tool-result + s.trimStart().startsWith('╭') + ); + } + + _extractToolName(line) { + const m = line.match(/[\u23FA\u25CF\u2B24]\s*(\w[\w.]*)/); + return m ? m[1] : null; + } + + _isTableRowLine(line) { + const s = this._stripAnsi(line).trimStart(); + return /^[│┃]/.test(s); + } + + _isTableBorderLine(line) { + const s = this._stripAnsi(line).trimStart(); + return /^[┌┐└┘├┤┬┴┼╔╗╚╝╠╣╦╩╬┏┓┗┛┣┫┳┻╋]/.test(s); + } + + _isSeparatorLine(line) { + // Strip ANSI escape codes (e.g. \x1b[38;5;240m ... \x1b[0m) before analysis + // eslint-disable-next-line no-control-regex + const stripped = line.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '').trim(); + if (stripped.length < 3) return false; + // Must consist mostly of horizontal box-drawing characters + const sepChars = (stripped.match(/[\u2500\u2501\u2550\u254C\u254D\u2574\u2576\u2578\u257A\u2015\u2014]/g) || []).length; + if (sepChars / stripped.length > 0.4) return true; + // Also catch lines that are a single character repeated (any char) — e.g. ─────── or ──── + const uniqueChars = new Set(stripped.replace(/\s/g, '')).size; + return uniqueChars === 1 && stripped.length >= 3 && /[^\w\s]/.test(stripped); + } + + _isCodeLike(line) { + return /[{}\[\]();=]/.test(line) || + /\b(const|let|var|function|class|import|export|if|else|for|while|return|def|async)\b/.test(line) || + /^\s+\w[\w.]*\s*[({]/.test(line); + } + + _detectSelectionRanges(lines) { + // Skip trailing empty lines (terminal padding) to find real content end + let end = lines.length - 1; + while (end >= 0 && /^\s*$/.test(this._stripAnsi(lines[end]))) end--; + + const BOTTOM = 30; + const start = Math.max(0, end - BOTTOM + 1); + const OPTION_RE = /^\s*(\d+|[a-z])[.)]\s+\S/; + // ❯ N. text — cursor-prefixed option (the currently focused selection item) + const CURSOR_OPTION_RE = /^\s*\u276F\s*(\d+|[a-z])[.)]\s+\S/; + // Markdown formatting signals response text, not a real selection prompt + const HAS_MARKDOWN = /\*\*|(?= 2) { + ranges.push({ start: rStart, end: i - 1 }); + } + rStart = -1; + count = 0; + } + } + if (rStart !== -1 && count >= 2) { + ranges.push({ start: rStart, end }); + } + + return ranges.filter(r => { + // Allow only UI chrome after options: ❯ cursor, option-like hint lines ("4. Type here..."), + // and empty lines. Reject if followed by real prose/tool output. + const hasTrailingContent = lines.slice(r.end + 1).some(l => { + const s = this._stripAnsi(l).trim(); + if (!s) return false; // empty line — ignore + if (/^\u276F/.test(s)) return false; // ❯ cursor — ignore + if (OPTION_RE.test(s)) return false; // another option-like hint — ignore + return true; + }); + if (hasTrailingContent) return false; + + // Option text should be reasonably short — real choices are concise. + // Result descriptions are longer ("Updated 5 files in src/...", etc.) + const optLines = lines.slice(r.start, r.end + 1); + let totalLen = 0; + for (const l of optLines) { + const m = this._stripAnsi(l).match(/^\s*(?:\d+|[a-z])[.)]\s+(.+)/); + if (m) totalLen += m[1].trim().length; + } + if (totalLen / optLines.length > 100) return false; + + return true; + }); + } + + _parseOptions(lines) { + const opts = []; + for (const line of lines) { + const m = this._stripAnsi(line).match(/^\s*\u276F?\s*(\d+|[a-z])[.)]\s+(.+)/); + if (m) opts.push({ index: m[1], label: m[2].trim(), key: m[1] }); + } + return opts; + } +} + +window.ClaudeParser = ClaudeParser; diff --git a/public/js/claude-renderer.js b/public/js/claude-renderer.js new file mode 100644 index 0000000..a89d747 --- /dev/null +++ b/public/js/claude-renderer.js @@ -0,0 +1,453 @@ +class ClaudeRenderer { + constructor(outputEl) { + this.outputEl = outputEl; + this._lastSignature = null; + this._onAction = null; + this._blockEls = null; + this._blockSigs = null; + + this.statusBarEl = document.createElement('div'); + this.statusBarEl.className = 'cc-status-bar'; + } + + setOnAction(fn) { + this._onAction = fn; + } + + render(doc) { + const sig = this._signature(doc.blocks); + if (sig !== this._lastSignature) { + this._renderBlocks(doc.blocks); + this._lastSignature = sig; + } + this._renderStatusBar(doc.statusBar); + } + + clear() { + this.outputEl.innerHTML = ''; + if (this.statusBarEl.parentNode) this.statusBarEl.parentNode.removeChild(this.statusBarEl); + this._lastSignature = null; + this._blockEls = null; + this._blockSigs = null; + } + + _blockSig(block) { + return `${block.type}:${block.lines.length}:${block.lines[0] || ''}`; + } + + _signature(blocks) { + return blocks.map(b => this._blockSig(b)).join(','); + } + + _renderBlocks(blocks) { + const newSigs = blocks.map(b => this._blockSig(b)); + + // Full rebuild on first render or block count change + if (!this._blockEls || this._blockEls.length !== blocks.length) { + this._fullRebuild(blocks, newSigs); + return; + } + + // Per-block update: only replace DOM nodes that changed + for (let i = 0; i < blocks.length; i++) { + if (newSigs[i] === this._blockSigs[i]) continue; + + const newEl = this._renderBlock(blocks[i]); + const oldEl = this._blockEls[i]; + + if (newEl && oldEl && oldEl.parentNode) { + oldEl.parentNode.replaceChild(newEl, oldEl); + this._blockEls[i] = newEl; + } else if (!newEl && !oldEl) { + // both null, nothing to do + } else { + // mismatch — fall back to full rebuild + this._fullRebuild(blocks, newSigs); + return; + } + this._blockSigs[i] = newSigs[i]; + } + } + + _fullRebuild(blocks, sigs) { + const frag = document.createDocumentFragment(); + const els = []; + for (const block of blocks) { + const el = this._renderBlock(block); + els.push(el); + if (el) frag.appendChild(el); + } + this.outputEl.innerHTML = ''; + this.outputEl.appendChild(frag); + this._blockEls = els; + this._blockSigs = sigs; + } + + _renderBlock(block) { + switch (block.type) { + case 'prompt': return this._renderPrompt(block); + case 'tool-use': return this._renderToolUse(block); + case 'tool-result': return this._renderToolResult(block); + case 'response': return this._renderResponse(block); + case 'code': return this._renderCode(block); + case 'table': return this._renderTable(block); + case 'diff': return this._renderDiff(block); + case 'selection': return this._renderSelection(block); + case 'text': return this._renderText(block); + case 'empty': return this._renderEmpty(); + default: return null; + } + } + + _esc(text) { + return text + .replace(/&/g, '&') + .replace(//g, '>'); + } + + // Pattern-based coloring for a single line (no ANSI codes from server) + _renderLine(line) { + const stripped = line.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ''); + let e = this._esc(stripped); + // Dingbats — spinner/thinking characters (✻ ✳ ✢ ✦ etc.) + e = e.replace(/([\u2700-\u27BF])/g, '$1'); + return e; + } + + _renderPrompt(block) { + const div = document.createElement('div'); + div.className = block.isActive ? 'cc-prompt cc-prompt-active' : 'cc-prompt'; + + const firstLine = block.lines[0]; + const stripped = firstLine.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ''); + const markerMatch = stripped.match(/^(\s*\u276F\s*)/); + const text = markerMatch ? firstLine.slice(markerMatch[1].length) : firstLine; + + const marker = document.createElement('span'); + marker.className = 'cc-prompt-marker'; + marker.textContent = '❯'; + + const textEl = document.createElement('span'); + textEl.className = 'cc-prompt-text'; + textEl.innerHTML = ansiToHtml(text); + + div.appendChild(marker); + div.appendChild(textEl); + + for (let i = 1; i < block.lines.length; i++) { + const cont = document.createElement('div'); + cont.className = 'cc-prompt-cont'; + cont.innerHTML = ansiToHtml(block.lines[i]); + div.appendChild(cont); + } + + return div; + } + + _renderToolUse(block) { + const div = document.createElement('div'); + const firstLine = block.lines[0]; + // Match: ⏺ ToolName(args) or ⏺ ToolName args + const match = firstLine.match(/[\u23FA\u25CF\u2B24]\s*(\w[\w.]*)?(.*)$/); + + if (!match || !match[1]) { + // No tool name — this is response text with ⏺ prefix; render as plain text + div.className = 'cc-text'; + for (const line of block.lines) { + const lineEl = document.createElement('div'); + lineEl.className = 'cc-line'; + lineEl.innerHTML = ansiToHtml(line.replace(/^\s+/, '')); + div.appendChild(lineEl); + } + return div; + } + + div.className = 'cc-tool-use'; + + const header = document.createElement('div'); + header.className = 'cc-tool-header'; + + const icon = document.createElement('span'); + icon.className = 'cc-tool-icon'; + icon.textContent = '⏺'; + + const name = document.createElement('span'); + name.className = 'cc-tool-name'; + name.textContent = match[1]; + + const args = document.createElement('span'); + args.className = 'cc-tool-args'; + args.textContent = (match[2] || '').trim(); + + header.appendChild(icon); + header.appendChild(name); + if (match[2] && match[2].trim()) header.appendChild(args); + + div.appendChild(header); + + if (block.lines.length > 1) { + const body = document.createElement('div'); + body.className = 'cc-tool-body'; + for (let i = 1; i < block.lines.length; i++) { + const line = document.createElement('div'); + line.className = 'cc-line'; + line.innerHTML = ansiToHtml(block.lines[i]); + body.appendChild(line); + } + div.appendChild(body); + } + + return div; + } + + _renderToolResult(block) { + const div = document.createElement('div'); + div.className = 'cc-tool-result'; + + const scrollable = document.createElement('div'); + scrollable.className = 'cc-scrollable'; + + const pre = document.createElement('pre'); + + // Strip ╭/╰ border lines; for │ content lines, remove the │ markers + // and trailing terminal padding so content is immune to terminal width changes + const stripAnsi = s => s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ''); + const contentLines = []; + for (const line of block.lines) { + const s = stripAnsi(line).trimStart(); + if (/^[│┃]/.test(s)) { + // Remove leading ANSI codes + │ + optional space + let cleaned = line.replace(/^(\x1b\[[0-9;]*[A-Za-z])*[│┃]\s?/, ''); + // Remove trailing space + │ + trailing ANSI codes + cleaned = cleaned.replace(/\s*[│┃](\x1b\[[0-9;]*[A-Za-z])*\s*$/, ''); + // Remove terminal box padding (trailing spaces) + contentLines.push(cleaned.trimEnd()); + } + // Skip ╭/╰ border lines + } + + pre.innerHTML = contentLines.map(l => ansiToHtml(l)).join('\n'); + scrollable.appendChild(pre); + div.appendChild(scrollable); + return div; + } + + _renderThinking(block) { + const div = document.createElement('div'); + div.className = 'cc-thinking'; + // Strip ANSI escape codes — thinking spinner lines are often colorized + // eslint-disable-next-line no-control-regex + div.textContent = block.lines.join('\n').replace(/\x1b\[[0-9;]*[A-Za-z]/g, ''); + return div; + } + + _renderResponse(block) { + const div = document.createElement('div'); + div.className = 'cc-response'; + for (const line of block.lines) { + const lineEl = document.createElement('div'); + lineEl.className = 'cc-line'; + lineEl.innerHTML = this._renderLine(line); + div.appendChild(lineEl); + } + return div; + } + + _renderText(block) { + const div = document.createElement('div'); + div.className = 'cc-text'; + for (const line of block.lines) { + const lineEl = document.createElement('div'); + lineEl.className = 'cc-line'; + lineEl.innerHTML = this._renderLine(line); + div.appendChild(lineEl); + } + return div; + } + + _renderCode(block) { + const div = document.createElement('div'); + div.className = 'cc-code'; + + const scrollable = document.createElement('div'); + scrollable.className = 'cc-scrollable'; + + const pre = document.createElement('pre'); + pre.className = 'cc-code-content'; + pre.innerHTML = block.lines.map(l => ansiToHtml(l)).join('\n'); + + scrollable.appendChild(pre); + div.appendChild(scrollable); + return div; + } + + _renderTable(block) { + const div = document.createElement('div'); + div.className = 'cc-table-wrap'; + + const scrollable = document.createElement('div'); + scrollable.className = 'cc-scrollable'; + + const table = document.createElement('table'); + table.className = 'cc-table'; + + const thead = document.createElement('thead'); + const tbody = document.createElement('tbody'); + let headerDone = false; + + const stripAnsi = s => s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ''); + const isMiddleBorder = s => /^[├╠┣]/.test(s); + const isBorderLine = s => /^[┌┐└┘├┤┬┴┼╔╗╚╝╠╣╦╩╬┏┓┗┛┣┫┳┻╋]/.test(s); + const isDataLine = s => /^[│┃]/.test(s); + + const lines = block.lines; + for (let i = 0; i < lines.length; i++) { + const stripped = stripAnsi(lines[i]).trimStart(); + + if (isBorderLine(stripped)) { + if (isMiddleBorder(stripped)) headerDone = true; + continue; + } + + if (!isDataLine(stripped)) continue; + + // Check if the next border is a middle border (making this row a header) + let nextIsMiddleBorder = false; + if (!headerDone) { + for (let j = i + 1; j < lines.length; j++) { + const ns = stripAnsi(lines[j]).trimStart(); + if (!ns) continue; + if (isMiddleBorder(ns)) nextIsMiddleBorder = true; + break; + } + } + + const isHeader = !headerDone && nextIsMiddleBorder; + const cells = stripped.split(/[│┃]/).slice(1, -1); + + const tr = document.createElement('tr'); + for (const cell of cells) { + const cellEl = document.createElement(isHeader ? 'th' : 'td'); + cellEl.textContent = cell.trim(); + tr.appendChild(cellEl); + } + + if (isHeader) { + thead.appendChild(tr); + } else { + tbody.appendChild(tr); + } + } + + if (thead.children.length > 0) table.appendChild(thead); + table.appendChild(tbody); + scrollable.appendChild(table); + div.appendChild(scrollable); + return div; + } + + _renderDiff(block) { + const div = document.createElement('div'); + div.className = 'cc-diff'; + + const scrollable = document.createElement('div'); + scrollable.className = 'cc-scrollable'; + + const content = document.createElement('div'); + content.className = 'cc-diff-content'; + + for (const line of block.lines) { + const span = document.createElement('span'); + span.className = 'cc-diff-line'; + if (/^\+(?!\+)/.test(line)) span.classList.add('cc-added'); + else if (/^-(?!-)/.test(line)) span.classList.add('cc-removed'); + else if (/^@@/.test(line)) span.classList.add('cc-hunk'); + span.innerHTML = ansiToHtml(line); + content.appendChild(span); + content.appendChild(document.createTextNode('\n')); + } + + scrollable.appendChild(content); + div.appendChild(scrollable); + return div; + } + + _renderSelection(block) { + const div = document.createElement('div'); + div.className = 'cc-selection-options'; + + if (block.options && block.options.length > 0) { + for (const opt of block.options) { + const btn = document.createElement('button'); + btn.className = 'cc-option'; + btn.dataset.key = opt.key; + + const indexEl = document.createElement('span'); + indexEl.className = 'cc-option-index'; + indexEl.textContent = opt.index; + + const labelEl = document.createElement('span'); + labelEl.className = 'cc-option-label'; + labelEl.textContent = opt.label; + + btn.appendChild(indexEl); + btn.appendChild(labelEl); + btn.addEventListener('click', () => { + if (this._onAction) this._onAction('select', opt.key); + }); + div.appendChild(btn); + } + } else { + // Fallback: render lines as text + for (const line of block.lines) { + const lineEl = document.createElement('div'); + lineEl.className = 'cc-line'; + lineEl.textContent = line; + div.appendChild(lineEl); + } + } + + return div; + } + + _renderEmpty() { + const div = document.createElement('div'); + div.className = 'cc-empty'; + return div; + } + + _renderStatusBar(statusBar) { + if (!statusBar || !this.statusBarEl) return; + + this.statusBarEl.innerHTML = ''; + + if (statusBar.mode) { + const pill = document.createElement('button'); + pill.className = `cc-mode-pill cc-mode-${statusBar.mode}`; + const icons = { plan: '⏸', code: '●', bypass: '⏵' }; + const labels = { plan: 'Plan', code: 'Code', bypass: 'Bypass' }; + pill.textContent = `${icons[statusBar.mode] || ''} ${labels[statusBar.mode] || statusBar.mode}`; + pill.addEventListener('click', () => { + if (this._onAction) this._onAction('mode-toggle', statusBar.mode); + }); + this.statusBarEl.appendChild(pill); + } + + if (statusBar.branch) { + const branch = document.createElement('span'); + branch.className = 'cc-status-branch'; + branch.textContent = statusBar.branch; + this.statusBarEl.appendChild(branch); + } + + if (statusBar.stats) { + const stats = document.createElement('span'); + stats.className = 'cc-status-stats'; + stats.textContent = statusBar.stats; + this.statusBarEl.appendChild(stats); + } + } +} + +window.ClaudeRenderer = ClaudeRenderer; diff --git a/public/js/debug-safe-area.js b/public/js/debug-safe-area.js new file mode 100644 index 0000000..bf449aa --- /dev/null +++ b/public/js/debug-safe-area.js @@ -0,0 +1,26 @@ +// iOS PWA: keep app-container sized and positioned to match the visual viewport. +// +// When the iOS software keyboard appears, iOS scrolls the layout viewport to reveal +// the focused input. This makes position:fixed elements scroll too (iOS bug). +// Fix: track visualViewport.offsetTop and move the container to compensate. +function setScreenHeight() { + if (window.innerWidth >= 1024) return; + const el = document.querySelector('.app-container'); + if (!el) return; + if (window.visualViewport) { + el.style.height = window.visualViewport.height + 'px'; + el.style.top = window.visualViewport.offsetTop + 'px'; + } else { + el.style.height = window.innerHeight + 'px'; + } +} + +setScreenHeight(); +window.addEventListener('orientationchange', () => setTimeout(setScreenHeight, 200)); + +if (window.visualViewport) { + window.visualViewport.addEventListener('resize', setScreenHeight); + window.visualViewport.addEventListener('scroll', setScreenHeight); +} else { + window.addEventListener('resize', setScreenHeight); +} diff --git a/public/js/sidebar.js b/public/js/sidebar.js index daa5b66..11058ce 100644 --- a/public/js/sidebar.js +++ b/public/js/sidebar.js @@ -11,7 +11,7 @@ class Sidebar { this.activeSurface = null; this._loaded = false; - this.menuBtn.addEventListener('click', () => this.open()); + this.menuBtn.addEventListener('click', () => this.isOpen() ? this.close() : this.open()); this.closeBtn.addEventListener('click', () => this.close()); this.overlay.addEventListener('click', () => this.close()); @@ -32,13 +32,21 @@ class Sidebar { } open() { - this.sidebar.classList.add('open'); - this.overlay.classList.add('visible'); + if (this.isDesktop()) { + this.sidebar.classList.remove('desktop-hidden'); + } else { + this.sidebar.classList.add('open'); + this.overlay.classList.add('visible'); + } } close() { - this.sidebar.classList.remove('open'); - this.overlay.classList.remove('visible'); + if (this.isDesktop()) { + this.sidebar.classList.add('desktop-hidden'); + } else { + this.sidebar.classList.remove('open'); + this.overlay.classList.remove('visible'); + } } isDesktop() { @@ -46,6 +54,7 @@ class Sidebar { } isOpen() { + if (this.isDesktop()) return !this.sidebar.classList.contains('desktop-hidden'); return this.sidebar.classList.contains('open'); } @@ -87,15 +96,19 @@ class Sidebar { item.classList.add('active'); } - const icon = document.createElement('span'); - icon.className = 'surface-icon'; - icon.textContent = surface.type === 'browser' ? '\u{1F310}' : '\u{1F4BB}'; + const titleRaw = surface.title || surface.ref; + const status = this._parseStatus(titleRaw); + + const dot = document.createElement('span'); + dot.className = 'surface-status'; + if (status.state === 'working') dot.classList.add('working'); + else if (status.state === 'idle') dot.classList.add('idle'); const label = document.createElement('span'); label.className = 'ws-label'; - label.textContent = surface.title || surface.ref; + label.textContent = status.title; - item.appendChild(icon); + item.appendChild(dot); item.appendChild(label); item.addEventListener('click', () => { @@ -109,6 +122,20 @@ class Sidebar { } } } + _parseStatus(title) { + // Working: Braille spinner (U+2800-U+28FF), ⏺ (U+23FA), ● (U+25CF), ⬤ (U+2B24) + const workingRe = /^[\u2800-\u28FF\u23FA\u25CF\u2B24]\s*/; + // Idle/waiting: dingbats range — ✳ ✻ ✢ ✦ etc. + const idleRe = /^[\u2700-\u27BF]\s*/; + + if (workingRe.test(title)) { + return { state: 'working', title: title.replace(workingRe, '') }; + } + if (idleRe.test(title)) { + return { state: 'idle', title: title.replace(idleRe, '') }; + } + return { state: null, title }; + } } window.Sidebar = Sidebar; diff --git a/public/js/sw-unregister.js b/public/js/sw-unregister.js new file mode 100644 index 0000000..d31c53d --- /dev/null +++ b/public/js/sw-unregister.js @@ -0,0 +1,6 @@ +// Unregister any cached service workers so CSS/JS changes are always fresh +if ('serviceWorker' in navigator) { + navigator.serviceWorker.getRegistrations().then(regs => { + regs.forEach(r => r.unregister()); + }); +} diff --git a/public/js/terminal-view.js b/public/js/terminal-view.js index 4071807..d37104a 100644 --- a/public/js/terminal-view.js +++ b/public/js/terminal-view.js @@ -1,9 +1,106 @@ +// ANSI escape code → HTML converter +function ansiToHtml(raw) { + if (!raw) return ''; + + // 16-color palette (One Dark) + const C16 = [ + '#21252b','#e06c75','#98c379','#d19a66', + '#61afef','#c678dd','#56b6c2','#abb2bf', + '#5c6370','#ff6b6b','#b5f5a0','#e5c07b', + '#61afef','#c678dd','#56b6c2','#ffffff', + ]; + + function c256(n) { + if (n < 16) return C16[n]; + if (n >= 232) { const v = 8 + (n - 232) * 10; return `rgb(${v},${v},${v})`; } + n -= 16; + const r = Math.floor(n / 36), g = Math.floor((n % 36) / 6), b = n % 6; + const v = x => x ? 55 + x * 40 : 0; + return `rgb(${v(r)},${v(g)},${v(b)})`; + } + + function escHtml(s) { + return s.replace(/&/g, '&').replace(//g, '>'); + } + + // Strip ALL remaining escape sequences from a text chunk + // Covers: CSI (\x1b[...final), OSC (\x1b]...BEL), other single-char ESC sequences + function stripEsc(s) { + return s.replace(/\x1b(?:\[[^a-zA-Z]*[a-zA-Z]|\][^\x07]*\x07|.)/g, ''); + } + + let fg = null, bg = null, bold = false, italic = false, dim = false; + + function mkStyle() { + const p = []; + if (fg) p.push(`color:${fg}`); + if (bg) p.push(`background:${bg}`); + if (bold) p.push('font-weight:bold'); + if (italic) p.push('font-style:italic'); + if (dim && !fg) p.push('opacity:0.6'); + return p.join(';'); + } + + // Match only SGR sequences (color/attribute codes ending with 'm') + const SGR_RE = /\x1b\[([0-9;]*)m/g; + let html = ''; + let last = 0; + let m; + + function flush(text) { + if (!text) return; + const s = mkStyle(); + const e = escHtml(stripEsc(text)); + if (!e) return; + html += s ? `${e}` : e; + } + + while ((m = SGR_RE.exec(raw)) !== null) { + flush(raw.slice(last, m.index)); + last = m.index + m[0].length; + + const codes = m[1] ? m[1].split(';').map(Number) : [0]; + let i = 0; + while (i < codes.length) { + const c = codes[i++]; + if (c === 0 || isNaN(c)) { fg = bg = null; bold = italic = dim = false; } + else if (c === 1) bold = true; + else if (c === 2) dim = true; + else if (c === 3) italic = true; + else if (c === 22) { bold = dim = false; } + else if (c === 23) italic = false; + else if (c >= 30 && c <= 37) fg = C16[c - 30]; + else if (c === 38) { + if (codes[i] === 5 && i + 1 < codes.length) { fg = c256(codes[i+1]); i += 2; } + else if (codes[i] === 2 && i + 3 < codes.length) { fg = `rgb(${codes[i+1]},${codes[i+2]},${codes[i+3]})`; i += 4; } + } + else if (c === 39) fg = null; + else if (c >= 40 && c <= 47) bg = C16[c - 40]; + else if (c === 48) { + if (codes[i] === 5 && i + 1 < codes.length) { bg = c256(codes[i+1]); i += 2; } + else if (codes[i] === 2 && i + 3 < codes.length) { bg = `rgb(${codes[i+1]},${codes[i+2]},${codes[i+3]})`; i += 4; } + } + else if (c === 49) bg = null; + else if (c >= 90 && c <= 97) fg = C16[c - 90 + 8]; + else if (c >= 100 && c <= 107) bg = C16[c - 100 + 8]; + } + } + + flush(raw.slice(last)); + return html; +} + +window.ansiToHtml = ansiToHtml; + class TerminalView { constructor(element) { this.el = element; this.lines = []; this.renderPending = false; this.autoScroll = true; + this.claudeMode = false; + this.claudeParser = null; + this.claudeRenderer = null; // Track scroll position to determine auto-scroll const container = this.el.parentElement; @@ -23,9 +120,29 @@ class TerminalView { for (const patch of patches) { this.lines.splice(patch.startLine, patch.deleteCount, ...patch.insertLines); } + // Invalidate parser cache since lines were mutated in-place + if (this.claudeParser) this.claudeParser._lastLines = null; this.scheduleRender(); } + setClaudeMode(active, onAction) { + this.claudeMode = active; + if (active) { + if (!this.claudeParser) this.claudeParser = new ClaudeParser(); + if (!this.claudeRenderer) { + this.claudeRenderer = new ClaudeRenderer(this.el); + } + // Attach status bar to container if not already there + const container = this.el.parentElement; + if (container && !this.claudeRenderer.statusBarEl.parentNode) { + container.appendChild(this.claudeRenderer.statusBarEl); + } + this.claudeRenderer.setOnAction(onAction); + } else { + if (this.claudeRenderer) this.claudeRenderer.clear(); + } + } + scheduleRender() { if (this.renderPending) return; this.renderPending = true; @@ -43,6 +160,16 @@ class TerminalView { } render() { + if (this.claudeMode && this.claudeParser && this.claudeRenderer) { + const doc = this.claudeParser.parse(this.lines); + this.claudeRenderer.render(doc); + if (this.autoScroll) { + const container = this.el.parentElement; + container.scrollTop = container.scrollHeight; + } + return; + } + const output = []; let i = 0; while (i < this.lines.length) { @@ -56,15 +183,13 @@ class TerminalView { // Calculate font-size so QR block fits container width without clipping const maxLen = Math.max(...qrBlock.map(l => l.length)); const containerWidth = (this.el.parentElement?.clientWidth || 375) - 32; - // monospace char width ≈ 0.62 × font-size - const fontSize = Math.max(3, Math.floor(containerWidth / (maxLen * 0.62))); - for (const qline of qrBlock) { - const esc = qline - .replace(/&/g, '&') - .replace(//g, '>'); - output.push(`${esc}`); - } + // monospace char width ≈ 0.62 × font-size; cap at 13px (normal size) + const fontSize = Math.max(3, Math.min(13, Math.floor(containerWidth / (maxLen * 0.62)))); + const escapedLines = qrBlock.map(l => + l.replace(/&/g, '&').replace(//g, '>') + ).join('\n'); + // Single block span — no \n between spans so no extra blank lines + output.push(`${escapedLines}`); } else { output.push(this.highlightLine(this.lines[i])); i++; @@ -80,79 +205,40 @@ class TerminalView { } highlightLine(line) { - // Escape HTML entities first - let escaped = line + // cmux strips ANSI codes — use pattern-based coloring + const stripped = line.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ''); + let e = stripped .replace(/&/g, '&') .replace(//g, '>'); - // Apply highlighting patterns (order matters — more specific first) + // Diff lines + if (/^\+[^+]/.test(stripped)) return `${e}`; + if (/^-[^-]/.test(stripped)) return `${e}`; - // Diff: added lines (starts with +) - if (/^\+[^+]/.test(line)) { - return `${escaped}`; - } - - // Diff: removed lines (starts with -) - if (/^-[^-]/.test(line)) { - return `${escaped}`; - } - - // Tool use markers (blue circle) - escaped = escaped.replace( - /(\u23FA|\u25CF|\u2B24)/g, - '$1' - ); - - // Thinking markers - escaped = escaped.replace( - /(\u273B)/g, - '$1' - ); - - // Output indent marker - escaped = escaped.replace( - /(\u23BF)/g, - '$1' - ); - - // Prompt marker - escaped = escaped.replace( - /(\u276F)/g, - '$1' - ); - - // Success markers - escaped = escaped.replace( - /(\u2713|\u2714|passed|PASS)/g, - '$1' - ); - - // Error markers - escaped = escaped.replace( - /(\u2717|\u2718|error|ERROR|FAIL|failed)/g, - '$1' - ); - - // Box drawing characters - escaped = escaped.replace( - /([\u2500-\u257F\u250C\u2510\u2514\u2518\u251C\u2524\u252C\u2534\u253C]+)/g, - '$1' - ); - - // File paths (simplified: word containing / with file extension) - escaped = escaped.replace( + // Tool-use markers ⏺●⬤ + e = e.replace(/([\u23FA\u25CF\u2B24])/g, '$1'); + // Dingbats — spinner/thinking characters (✻ ✳ ✢ etc.) + e = e.replace(/([\u2700-\u27BF])/g, '$1'); + // Output marker ⎿ + e = e.replace(/(\u23BF)/g, '$1'); + // Prompt marker ❯ + e = e.replace(/(\u276F)/g, '$1'); + // Success + e = e.replace(/(\u2713|\u2714|passed|PASS)/g, '$1'); + // Error + e = e.replace(/(\u2717|\u2718|error|ERROR|FAIL|failed)/g, '$1'); + // Box drawing + e = e.replace(/([\u2500-\u257F]+)/g, '$1'); + // File paths + e = e.replace( /(?$1' ); + // Line numbers + e = e.replace(/^(\s*\d+\s*[|:])/, '$1'); - // Line numbers in context (e.g., " 123 |" or ":42:") - escaped = escaped.replace( - /^(\s*\d+\s*[|:])/, - '$1' - ); - - return escaped; + return e; } scrollToBottom() { diff --git a/public/js/theme.js b/public/js/theme.js index e94cbb7..3d686af 100644 --- a/public/js/theme.js +++ b/public/js/theme.js @@ -1,15 +1,14 @@ class ThemeSwitcher { constructor() { - this.btn = document.getElementById('theme-toggle'); + this.cb = document.getElementById('theme-cb'); this.current = localStorage.getItem('cmux-remote-theme') || 'auto'; this.apply(); - this.btn.addEventListener('click', () => this.toggle()); + if (this.cb) this.cb.addEventListener('change', () => this.toggle()); } toggle() { const resolved = this.getResolved(); - // Toggle to opposite of current effective theme this.current = resolved === 'dark' ? 'light' : 'dark'; localStorage.setItem('cmux-remote-theme', this.current); this.apply(); @@ -25,13 +24,10 @@ class ThemeSwitcher { apply() { const theme = this.getResolved(); document.documentElement.setAttribute('data-theme', theme); - this.btn.textContent = theme === 'dark' ? '\u{2600}' : '\u{1F319}'; + if (this.cb) this.cb.checked = (theme === 'dark'); - // Update theme-color meta const meta = document.querySelector('meta[name="theme-color"]'); - if (meta) { - meta.content = theme === 'dark' ? '#0c0c14' : '#f8f9fc'; - } + if (meta) meta.content = theme === 'dark' ? '#0c0c14' : '#f8f9fc'; } } diff --git a/public/js/virtual-keyboard.js b/public/js/virtual-keyboard.js index 1169460..e25932a 100644 --- a/public/js/virtual-keyboard.js +++ b/public/js/virtual-keyboard.js @@ -10,6 +10,7 @@ class VirtualKeyboard { this.shiftLocked = false; this._repeatTimer = null; this._repeatInterval = null; + this._composingEnterPending = false; this.textInput = document.getElementById('text-input'); this.sendBtn = document.getElementById('send-btn'); @@ -30,8 +31,18 @@ class VirtualKeyboard { init() { // Text input + send this.sendBtn.addEventListener('click', () => this.submitText()); + this.textInput.addEventListener('compositionend', () => { + if (this._composingEnterPending) { + this._composingEnterPending = false; + setTimeout(() => this.submitText(), 0); + } + }); this.textInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { + if (e.isComposing || e.keyCode === 229) { + this._composingEnterPending = true; + return; + } e.preventDefault(); this.submitText(); } diff --git a/public/js/websocket-client.js b/public/js/websocket-client.js index 3338767..f1b2370 100644 --- a/public/js/websocket-client.js +++ b/public/js/websocket-client.js @@ -120,6 +120,19 @@ class WebSocketClient { this.send({ type: 'scroll-request', workspace, surface, lines }); } + // Force immediate reconnection (reset backoff, close stale socket) + reconnectNow() { + if (this.ws) { + // Temporarily disable auto-reconnect so onclose doesn't double-connect + this.shouldReconnect = false; + this.ws.close(); + } + this.shouldReconnect = true; + this.currentDelay = this.reconnectDelay; + this.reconnectAttempt = 0; + this.connect(this.authRequired); + } + disconnect() { this.shouldReconnect = false; if (this.ws) this.ws.close(); diff --git a/src/bridge/cmux-client.ts b/src/bridge/cmux-client.ts index 8fa9b73..bd8b2c7 100644 --- a/src/bridge/cmux-client.ts +++ b/src/bridge/cmux-client.ts @@ -1,5 +1,9 @@ import { execFile } from 'node:child_process'; +import { readFile, unlink } from 'node:fs/promises'; +import { createConnection } from 'node:net'; import { promisify } from 'node:util'; +import { homedir, tmpdir } from 'node:os'; +import { join } from 'node:path'; import type { WorkspaceInfo, PaneInfo, SurfaceInfo } from '../protocol/messages.js'; const execFileAsync = promisify(execFile); @@ -183,12 +187,98 @@ export class CmuxClient { return this.exec(args); } + private get socketPath(): string { + return process.env.CMUX_SOCKET_PATH || + join(homedir(), 'Library', 'Application Support', 'cmux', 'cmux.sock'); + } + + private socketQuery(method: string, params: Record): Promise { + return new Promise((resolve, reject) => { + const id = Math.random().toString(36).slice(2); + const conn = createConnection(this.socketPath, () => { + conn.write(JSON.stringify({ id, method, params }) + '\n'); + }); + conn.on('data', (data) => { + conn.destroy(); + const msg = JSON.parse(data.toString()); + if (msg.ok) resolve(msg.result as T); + else reject(new Error(msg.error?.message || 'socket error')); + }); + conn.on('error', reject); + }); + } + + private sendViaSocket(method: string, params: Record): Promise { + return this.socketQuery(method, params).then(() => {}); + } + + async getClaudeCodeSurfaceRefs(): Promise { + const cached = this.getCached('claude-surfaces'); + if (cached) return cached; + + // Step 1: find all claude process PIDs and extract their CMUX_SURFACE_ID + // Use ps -axo to find processes whose executable path ends with /claude + const claudeUuids = new Set(); + try { + const { stdout: psListOut } = await execFileAsync('ps', ['-axo', 'pid=,args='], { timeout: 5000, maxBuffer: 10 * 1024 * 1024 }); + const claudePids: string[] = []; + for (const line of psListOut.split('\n')) { + const parts = line.trim().split(/\s+/); + const cmd = parts[1] || ''; + if (cmd.endsWith('/claude') || cmd === 'claude') { + claudePids.push(parts[0]); + } + } + for (const pid of claudePids) { + try { + const { stdout: psOut } = await execFileAsync('ps', ['-E', '-p', pid], { timeout: 5000 }); + const match = psOut.match(/CMUX_SURFACE_ID=([A-F0-9-]+)/i); + if (match) claudeUuids.add(match[1]); + } catch { /* skip */ } + } + } catch { /* no claude processes */ } + + if (claudeUuids.size === 0) { + this.setCache('claude-surfaces', []); + return []; + } + + // Step 2: get workspace list to iterate + const wsResult = await this.socketQuery<{ workspaces: Array<{ id: string }> }>('workspace.list', {}); + + // Step 3: for each workspace, get surfaces and match UUID → ref + const refs: string[] = []; + for (const ws of wsResult.workspaces) { + try { + const surfResult = await this.socketQuery<{ surfaces: Array<{ id: string; ref: string }> }>('surface.list', { workspace_id: ws.id }); + for (const s of surfResult.surfaces) { + if (claudeUuids.has(s.id)) refs.push(s.ref); + } + } catch { /* skip workspace */ } + } + + this.setCache('claude-surfaces', refs); + return refs; + } + async sendText(workspace: string, surface: string, text: string): Promise { - await this.exec(['send', '--workspace', workspace, '--surface', surface, text]); + await this.sendViaSocket('surface.send_text', { workspace_id: workspace, surface_id: surface, text }); } async sendKey(workspace: string, surface: string, key: string): Promise { - await this.exec(['send-key', '--workspace', workspace, '--surface', surface, key]); + await this.sendViaSocket('surface.send_key', { workspace_id: workspace, surface_id: surface, key }); + } + + + async browserScreenshot(workspace: string, surface: string): Promise<{ data: string; mime: string }> { + const tmpFile = join(tmpdir(), `cmux-screenshot-${surface}-${Date.now()}.jpg`); + try { + await this.exec(['browser', '--surface', surface, 'screenshot', '--out', tmpFile]); + const data = await readFile(tmpFile); + return { data: data.toString('base64'), mime: 'image/jpeg' }; + } finally { + await unlink(tmpFile).catch(() => {}); + } } async capturePaneScrollback( diff --git a/src/bridge/input-handler.ts b/src/bridge/input-handler.ts index 2398313..aa5416d 100644 --- a/src/bridge/input-handler.ts +++ b/src/bridge/input-handler.ts @@ -1,21 +1,21 @@ import type { CmuxClient } from './cmux-client.js'; -// Map browser key names to cmux send-key names -const KEY_MAP: Record = { +// Keys supported by cmux send-key socket API +const SEND_KEY_MAP: Record = { Enter: 'enter', Escape: 'escape', Tab: 'tab', Backspace: 'backspace', - Delete: 'delete', - ArrowUp: 'arrow_up', - ArrowDown: 'arrow_down', - ArrowLeft: 'arrow_left', - ArrowRight: 'arrow_right', + ArrowUp: 'up', + ArrowDown: 'down', + ArrowLeft: 'left', + ArrowRight: 'right', Home: 'home', End: 'end', - PageUp: 'page_up', - PageDown: 'page_down', - Space: 'space', + Delete: 'delete', + PageUp: 'pageup', + PageDown: 'pagedown', + ShiftTab: 'shift+tab', }; export class InputHandler { @@ -30,33 +30,48 @@ export class InputHandler { } async handleKey(workspace: string, surface: string, key: string): Promise { - // Handle Ctrl+key combos (from frontend: "Ctrl-c", "Ctrl-d", etc.) + // Ctrl+key combos — send as control byte via send_text (0x01–0x1A) + // cmux's send_key uses ghostty_surface_key with text:nil which may not deliver, + // but send_text passes control bytes 0x01-0x1A directly to the PTY. const ctrlMatch = key.match(/^(?:Ctrl|Control)[+-](.+)$/i); if (ctrlMatch) { - const letter = ctrlMatch[1].toLowerCase(); - await this.client.sendKey(workspace, surface, `ctrl+${letter}`); + const inner = ctrlMatch[1].toLowerCase(); + if (inner.length === 1 && inner >= 'a' && inner <= 'z') { + const ctrlByte = String.fromCharCode(inner.charCodeAt(0) - 0x60); + await this.client.sendText(workspace, surface, ctrlByte); + return; + } + // Non-letter ctrl combos: fall through to send_key + await this.client.sendKey(workspace, surface, `ctrl+${inner}`); return; } - // Handle Alt+key combos + // Alt+key combos — send as escape + char sequence const altMatch = key.match(/^Alt[+-](.+)$/i); if (altMatch) { - const letter = altMatch[1].toLowerCase(); - await this.client.sendKey(workspace, surface, `alt+${letter}`); + await this.client.sendText(workspace, surface, `\x1b${altMatch[1].toLowerCase()}`); return; } - // Handle Shift+key combos + // Shift+key combos const shiftMatch = key.match(/^Shift[+-](.+)$/i); if (shiftMatch) { const inner = shiftMatch[1]; - const mapped = KEY_MAP[inner] || inner.toLowerCase(); - await this.client.sendKey(workspace, surface, `shift+${mapped}`); + if (inner === 'Tab' || inner === 'tab') { + await this.client.sendKey(workspace, surface, 'shift+tab'); + return; + } + return; + } + + // Keys via send-key socket API + const mapped = SEND_KEY_MAP[key]; + if (mapped) { + await this.client.sendKey(workspace, surface, mapped); return; } - // Map known keys - const mapped = KEY_MAP[key] || key; - await this.client.sendKey(workspace, surface, mapped); + // Fallback: send as text + await this.client.sendText(workspace, surface, key); } } diff --git a/src/bridge/screen-poller.ts b/src/bridge/screen-poller.ts index 35f69d1..326b3fe 100644 --- a/src/bridge/screen-poller.ts +++ b/src/bridge/screen-poller.ts @@ -7,6 +7,7 @@ import type { DiffPatch } from '../protocol/messages.js'; interface PollTarget { workspace: string; surface: string; + type: 'terminal' | 'browser'; subscribers: Set; lastHash: string; lastLines: string[]; @@ -46,6 +47,7 @@ export class ScreenPoller extends EventEmitter { target = { workspace, surface, + type: 'terminal', subscribers: new Set(), lastHash: '', lastLines: [], @@ -104,6 +106,19 @@ export class ScreenPoller extends EventEmitter { private async poll(key: string, target: PollTarget): Promise { if (target.subscribers.size === 0) return; + if (target.type === 'browser') { + await this.pollBrowser(key, target); + } else { + await this.pollTerminal(key, target); + } + + if (target.subscribers.size > 0) { + const delay = target.type === 'browser' ? 2000 : this.getInterval(target); + this.schedulePoll(key, target, delay); + } + } + + private async pollTerminal(key: string, target: PollTarget): Promise { try { const content = await this.client.readScreen(target.workspace, target.surface); const hash = createHash('sha256').update(content).digest('hex'); @@ -140,15 +155,40 @@ export class ScreenPoller extends EventEmitter { target.idleCount++; } } catch (err) { - this.emit('error', { surface: target.surface, error: err }); + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('not a terminal') || msg.includes('invalid_params')) { + // Switch to browser screenshot mode + target.type = 'browser'; + target.lastHash = ''; + } else { + console.error(`[poller] ${key}:`, msg); + } } + } - // Schedule next poll - if (target.subscribers.size > 0) { - this.schedulePoll(key, target, this.getInterval(target)); + private async pollBrowser(key: string, target: PollTarget): Promise { + try { + const { data, mime } = await this.client.browserScreenshot(target.workspace, target.surface); + const hash = createHash('sha256').update(data).digest('hex'); + if (hash !== target.lastHash) { + target.lastHash = hash; + target.idleCount = 0; + this.emit('browser-update', { surface: target.surface, workspace: target.workspace, data, mime }); + } else { + target.idleCount++; + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[poller] browser ${key}:`, msg); } } + getLastLines(workspace: string, surface: string): string[] | null { + const key = `${workspace}:${surface}`; + const target = this.targets.get(key); + return target && target.lastLines.length > 0 ? target.lastLines : null; + } + getSubscriberCount(surface: string): number { for (const [key, target] of this.targets) { if (key.endsWith(`:${surface}`)) return target.subscribers.size; diff --git a/src/index.ts b/src/index.ts index 653ac37..396a528 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,13 @@ import { createServer } from 'node:http'; +import { readFileSync, existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; import { Command } from 'commander'; import qrcode from 'qrcode-terminal'; import { createApp } from './server/app.js'; import { AuthManager, generatePassphrase } from './server/auth.js'; import { WsServer } from './server/websocket.js'; -import { CloudflareTunnel } from './server/tunnel.js'; +import { NgrokTunnel } from './server/tunnel.js'; import { CmuxClient } from './bridge/cmux-client.js'; import { ScreenPoller } from './bridge/screen-poller.js'; import { SessionManager } from './bridge/session-manager.js'; @@ -12,6 +15,20 @@ import { InputHandler } from './bridge/input-handler.js'; import type { Config } from './utils/config.js'; import { DEFAULT_CONFIG } from './utils/config.js'; +// Load ~/.cmux-remote/config +const configFile = join(homedir(), '.cmux-remote', 'config'); +if (existsSync(configFile)) { + for (const line of readFileSync(configFile, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq === -1) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1).trim(); + if (key && !(key in process.env)) process.env[key] = value; + } +} + const program = new Command(); program @@ -20,7 +37,8 @@ program .version('0.1.0') .option('-p, --port ', 'Local port', String(DEFAULT_CONFIG.port)) .option('-P, --password ', 'Access password (or CMUX_REMOTE_PASSWORD env)') - .option('--no-tunnel', 'Disable Cloudflare Tunnel') + .option('--no-tunnel', 'Disable ngrok tunnel') + .option('--tunnel-domain ', 'Fixed ngrok domain (or NGROK_DOMAIN env)') .option('--poll-rate ', 'Poll interval in ms', String(DEFAULT_CONFIG.pollRate)) .option('--theme ', 'Default theme (dark|light)', DEFAULT_CONFIG.theme) .option('-v, --verbose', 'Verbose logging', false) @@ -29,6 +47,7 @@ program port: parseInt(opts.port, 10), password: opts.password || process.env.CMUX_REMOTE_PASSWORD || null, tunnel: opts.tunnel !== false, + tunnelDomain: opts.tunnelDomain || process.env.NGROK_DOMAIN || null, pollRate: parseInt(opts.pollRate, 10), theme: opts.theme as 'dark' | 'light', verbose: opts.verbose, @@ -48,9 +67,7 @@ program const sessions = new SessionManager(poller); const input = new InputHandler(cmux); - // Create HTTP server; qrUrlRef is updated after tunnel starts - const qrUrlRef = { url: `http://localhost:${config.port}` }; - const app = createApp(auth, qrUrlRef); + const app = createApp(auth, cmux); const server = createServer(app); // WebSocket server @@ -80,13 +97,13 @@ program }); // Start tunnel - let tunnel: CloudflareTunnel | null = null; + let tunnel: NgrokTunnel | null = null; + let accessUrl = `http://localhost:${config.port}`; if (config.tunnel) { - tunnel = new CloudflareTunnel(); + tunnel = new NgrokTunnel(); try { - const tunnelUrl = await tunnel.start(config.port); - qrUrlRef.url = tunnelUrl; - console.log(` Tunnel: ${tunnelUrl}`); + accessUrl = await tunnel.start(config.port, config.tunnelDomain ?? undefined); + console.log(` Tunnel: ${accessUrl}`); } catch (err) { console.warn(` Tunnel: Failed - ${(err as Error).message}`); } @@ -94,7 +111,7 @@ program // Print QR code to terminal console.log(''); - qrcode.generate(qrUrlRef.url, { small: true }, (code: string) => { + qrcode.generate(accessUrl, { small: true }, (code: string) => { for (const line of code.split('\n')) { console.log(` ${line}`); } @@ -103,14 +120,14 @@ program // Graceful shutdown let shuttingDown = false; - const shutdown = () => { + const shutdown = async () => { if (shuttingDown) return; shuttingDown = true; console.log('\nShutting down...'); cmux.shutdown(); poller.destroy(); wsServer.close(); - tunnel?.stop(); + await tunnel?.stop(); server.close(() => process.exit(0)); // Force exit after 3s if connections don't drain setTimeout(() => process.exit(0), 3000).unref(); diff --git a/src/protocol/messages.ts b/src/protocol/messages.ts index ed5fe57..cc9c490 100644 --- a/src/protocol/messages.ts +++ b/src/protocol/messages.ts @@ -4,6 +4,7 @@ export interface ScreenMessage { surface: string; content: string; lines: string[]; + scrollback?: boolean; cursor?: { row: number; col: number }; } @@ -54,12 +55,20 @@ export interface ErrorMessage { message: string; } +export interface BrowserScreenshotMessage { + type: 'browser-screenshot'; + surface: string; + imageData: string; // base64 + mime: string; // e.g. 'image/jpeg' +} + export type ServerMessage = | ScreenMessage | ScreenDiffMessage | WorkspacesMessage | AuthResultMessage - | ErrorMessage; + | ErrorMessage + | BrowserScreenshotMessage; // Client -> Server messages export interface AuthMessage { diff --git a/src/server/app.ts b/src/server/app.ts index d247ee5..a0d0ac9 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -1,13 +1,13 @@ import express from 'express'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; -import QRCode from 'qrcode'; import type { AuthManager } from './auth.js'; +import type { CmuxClient } from '../bridge/cmux-client.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -export function createApp(auth: AuthManager, qrUrlRef: { url: string }): express.Express { +export function createApp(auth: AuthManager, cmux: CmuxClient): express.Express { const app = express(); // Security headers @@ -16,22 +16,6 @@ export function createApp(auth: AuthManager, qrUrlRef: { url: string }): express // Parse JSON bodies app.use(express.json()); - // QR code endpoint — generates SVG for the current access URL - app.get('/api/qr.svg', async (_req, res) => { - try { - const svg = await QRCode.toString(qrUrlRef.url, { type: 'svg' }); - res.setHeader('Content-Type', 'image/svg+xml'); - res.send(svg); - } catch { - res.status(500).send('QR generation failed'); - } - }); - - // Access URL endpoint — returns the current access URL (tunnel or local) - app.get('/api/access-url', (_req, res) => { - res.json({ url: qrUrlRef.url }); - }); - // Auth status endpoint app.get('/api/auth/status', (_req, res) => { res.json({ authRequired: !auth.isAuthDisabled() }); @@ -40,6 +24,16 @@ export function createApp(auth: AuthManager, qrUrlRef: { url: string }): express // Auth endpoint app.post('/api/auth/login', auth.loginHandler()); + // Claude Code surface detection + app.get('/api/claude-surfaces', async (_req, res) => { + try { + const surfaces = await cmux.getClaudeCodeSurfaceRefs(); + res.json({ surfaces }); + } catch { + res.json({ surfaces: [] }); + } + }); + // Token verification endpoint app.get('/api/auth/verify', (req, res) => { if (auth.isAuthDisabled()) { diff --git a/src/server/tunnel.ts b/src/server/tunnel.ts index f2480d9..69c281e 100644 --- a/src/server/tunnel.ts +++ b/src/server/tunnel.ts @@ -1,65 +1,38 @@ -import { spawn, type ChildProcess } from 'node:child_process'; - -export class CloudflareTunnel { - private process: ChildProcess | null = null; - private url: string | null = null; - - async start(port: number): Promise { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('Tunnel startup timed out after 30s')); - }, 30000); - - this.process = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${port}`], { - stdio: ['ignore', 'pipe', 'pipe'], - }); - - const handleOutput = (data: Buffer) => { - const text = data.toString(); - // cloudflared prints the URL to stderr - const urlMatch = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/); - if (urlMatch && !this.url) { - this.url = urlMatch[0]; - clearTimeout(timeout); - resolve(this.url); - } - }; - - this.process.stdout?.on('data', handleOutput); - this.process.stderr?.on('data', handleOutput); - - this.process.on('error', (err) => { - clearTimeout(timeout); - if (err.message.includes('ENOENT')) { - reject( - new Error( - 'cloudflared not found. Install with: brew install cloudflared\n' + - 'Or disable tunnel with: --no-tunnel' - ) - ); - } else { - reject(err); - } - }); +import ngrok from '@ngrok/ngrok'; + +export class NgrokTunnel { + private listener: Awaited> | null = null; + + async start(port: number, domain?: string): Promise { + const authtoken = process.env.NGROK_AUTHTOKEN; + if (!authtoken) { + throw new Error( + 'NGROK_AUTHTOKEN environment variable is required.\n' + + 'Get your token at https://dashboard.ngrok.com/get-started/your-authtoken' + ); + } - this.process.on('exit', (code) => { - if (!this.url) { - clearTimeout(timeout); - reject(new Error(`cloudflared exited with code ${code}`)); - } - }); + this.listener = await ngrok.forward({ + addr: port, + authtoken, + ...(domain ? { domain } : {}), }); + + const url = this.listener.url(); + if (!url) { + throw new Error('ngrok tunnel started but no URL was returned'); + } + return url; } getUrl(): string | null { - return this.url; + return this.listener?.url() ?? null; } - stop(): void { - if (this.process) { - this.process.kill('SIGTERM'); - this.process = null; - this.url = null; + async stop(): Promise { + if (this.listener) { + await this.listener.close(); + this.listener = null; } } } diff --git a/src/server/websocket.ts b/src/server/websocket.ts index 2c524cc..85440c5 100644 --- a/src/server/websocket.ts +++ b/src/server/websocket.ts @@ -48,6 +48,7 @@ export class WsServer { // Listen for screen updates from poller this.poller.on('update', (update) => this.broadcastUpdate(update)); + this.poller.on('browser-update', (update) => this.broadcastBrowserUpdate(update)); } private handleConnection(ws: WebSocket): void { @@ -58,12 +59,16 @@ export class WsServer { if (this.verbose) console.log(`[ws] Client connected: ${clientId}`); ws.on('message', (data) => { + let msg: ClientMessage; try { - const msg = JSON.parse(data.toString()) as ClientMessage; - this.handleMessage(clientId, msg); - } catch (err) { + msg = JSON.parse(data.toString()) as ClientMessage; + } catch { this.sendTo(clientId, { type: 'error', message: 'Invalid message format' }); + return; } + this.handleMessage(clientId, msg).catch((err) => { + if (this.verbose) console.error(`[ws] Error handling ${msg.type}:`, err.message); + }); }); ws.on('close', () => { @@ -100,6 +105,18 @@ export class WsServer { case 'subscribe': this.sessions.subscribe(clientId, msg.workspace, msg.surface); if (this.verbose) console.log(`[ws] ${clientId} subscribed to ${msg.surface}`); + // Send cached content immediately so client doesn't wait for next poll cycle + { + const lastLines = this.poller.getLastLines(msg.workspace, msg.surface); + if (lastLines) { + this.sendTo(clientId, { + type: 'screen', + surface: msg.surface, + content: lastLines.join('\n'), + lines: lastLines, + }); + } + } break; case 'unsubscribe': @@ -113,9 +130,10 @@ export class WsServer { break; case 'send-key': - if (this.verbose) console.log(`[ws] ${clientId} send-key ${msg.key} to ${msg.surface}`); this.poller.resetToFast(msg.surface); - await this.input.handleKey(msg.workspace, msg.surface, msg.key); + await this.input.handleKey(msg.workspace, msg.surface, msg.key).catch((err) => { + if (this.verbose) console.error(`[ws] send-key error:`, err); + }); break; case 'list-workspaces': { @@ -133,17 +151,16 @@ export class WsServer { } case 'scroll-request': { - const content = await this.cmux.capturePaneScrollback( - msg.workspace, - msg.surface, - msg.lines - ); + // Use capture-pane for scrollback history (read-screen only shows current visible area) + const content = await this.cmux.capturePaneScrollback(msg.workspace, msg.surface, msg.lines); + console.log(`[ws] scroll-request: got ${content.split('\n').length} lines`); const lines = content.split('\n'); this.sendTo(clientId, { type: 'screen', surface: msg.surface, content, lines, + scrollback: true, }); break; } @@ -180,6 +197,17 @@ export class WsServer { } } + private broadcastBrowserUpdate(update: { surface: string; data: string; mime: string }): void { + for (const clientId of this.sessions.getSubscribedClients(update.surface)) { + this.sendTo(clientId, { + type: 'browser-screenshot', + surface: update.surface, + imageData: update.data, + mime: update.mime, + }); + } + } + private sendTo(clientId: string, msg: ServerMessage): void { const client = this.clients.get(clientId); if (client && client.ws.readyState === WebSocket.OPEN) { diff --git a/src/utils/config.ts b/src/utils/config.ts index 91837d7..92ff88d 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -2,6 +2,7 @@ export interface Config { port: number; password: string | null; tunnel: boolean; + tunnelDomain: string | null; pollRate: number; theme: 'dark' | 'light'; verbose: boolean; @@ -12,6 +13,7 @@ export const DEFAULT_CONFIG: Config = { port: 9870, password: null, tunnel: true, + tunnelDomain: null, pollRate: 200, theme: 'dark', verbose: false,