feat: Claude mode UI, status indicators, IME fix, tunnel support

- Add Claude Code parsed view with block-based rendering (prompt, tool-use,
  tool-result, code, diff, table, selection options, status bar)
- Add session status dots in sidebar and top bar (idle=yellow, working=green
  with pulse animation) parsed from terminal title characters
- Differentiate active input prompt vs previous submitted prompts with
  distinct background colors
- Fix Korean IME composition issue (last character on next line on Enter)
- Fix CSP inline script violation by moving to external sw-unregister.js
- Add per-block DOM diffing to prevent layout shifts on re-render
- Strip box-drawing borders from tool-result blocks for stable layout
- Add ngrok tunnel support, replace qrcode with qrcode-terminal
- Add Claude-specific keyboard action bar with mode toggle and shortcuts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
main
I Luk Kim 5 months ago
parent 239c6d2ef6
commit 5b9780fafb

491
package-lock.json generated

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

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

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

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

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

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

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

@ -6,13 +6,15 @@
<title>cmux-remote</title>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0c0c14">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="stylesheet" href="/css/themes.css">
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/terminal.css">
<link rel="stylesheet" href="/css/keyboard.css">
<link rel="stylesheet" href="/css/sidebar.css">
<link rel="stylesheet" href="/css/themes.css?v=2">
<link rel="stylesheet" href="/css/main.css?v=8">
<link rel="stylesheet" href="/css/terminal.css?v=4">
<link rel="stylesheet" href="/css/keyboard.css?v=6">
<link rel="stylesheet" href="/css/sidebar.css?v=6">
<link rel="stylesheet" href="/css/claude.css?v=6">
</head>
<body>
<!-- Sidebar -->
@ -30,47 +32,53 @@
<!-- Top bar -->
<header class="top-bar">
<button class="menu-btn" id="menu-btn" aria-label="Open sidebar">&#9776;</button>
<span class="app-title">cmux-remote</span>
<span class="app-title" id="app-title"></span>
<div class="top-bar-right">
<span class="connection-status" id="connection-status">Offline</span>
<button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme"></button>
<span class="connection-status" id="connection-status"></span>
<button class="reload-btn" id="reload-btn" aria-label="Reload page" title="Reload">&#x21BB;</button>
<button class="settings-btn" id="settings-btn" aria-label="Settings" title="Settings">
<svg width="17" height="17" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"/></svg>
</button>
</div>
</header>
<!-- Terminal area -->
<main class="terminal-container" id="terminal-container">
<pre class="terminal-output" id="terminal-output"></pre>
<img class="browser-view" id="browser-view" hidden alt="browser">
<button class="browser-zoom-btn" id="browser-zoom-btn" hidden aria-label="Zoom"></button>
<div class="browser-loading" id="browser-loading" hidden></div>
</main>
<!-- Browser zoom lightbox -->
<div class="img-zoom-overlay" id="img-zoom-overlay" hidden>
<button class="img-zoom-close" id="img-zoom-close" aria-label="Close"></button>
<div class="img-zoom-container" id="img-zoom-container">
<img class="img-zoom-img" id="img-zoom-img" alt="">
</div>
</div>
<!-- Virtual keyboard -->
<button class="keyboard-toggle" id="keyboard-toggle" aria-label="Toggle keyboard">&#x2328; Keyboard</button>
<footer class="virtual-keyboard" id="virtual-keyboard">
<div class="key-row special-keys">
<div class="key-row all-keys">
<button class="key" data-key="Escape">Esc</button>
<button class="key" data-key="Tab">Tab</button>
<button class="key modifier" data-modifier="ctrl" id="ctrl-key">Ctrl</button>
<button class="key modifier" data-modifier="alt" id="alt-key">Alt</button>
<button class="key modifier" data-modifier="shift" id="shift-key">&#x21E7;</button>
<span class="key-spacer"></span>
<button class="key" data-key="ArrowUp">&uarr;</button>
<button class="key" data-key="PageUp">PgUp</button>
<button class="key" data-key="PageDown">PgDn</button>
</div>
<div class="key-row shortcut-keys">
<button class="key" data-key="ShiftTab"></button>
<button class="key shortcut" data-key="Backspace"></button>
<button class="key shortcut" data-key="Ctrl-c">^C</button>
<button class="key shortcut" data-key="Ctrl-d">^D</button>
<button class="key shortcut" data-key="Ctrl-z">^Z</button>
<button class="key shortcut" data-key="Ctrl-l">^L</button>
<button class="key shortcut" data-key="Ctrl-a">^A</button>
<button class="key shortcut" data-key="Ctrl-e">^E</button>
<button class="key shortcut" data-key="Ctrl-r">^R</button>
<button class="key shortcut" data-key="Ctrl-w">^W</button>
<button class="key shortcut" data-key="Ctrl-u">^U</button>
<button class="key" data-key="ArrowLeft">&larr;</button>
<button class="key" data-key="ArrowDown">&darr;</button>
<button class="key" data-key="ArrowRight">&rarr;</button>
<button class="key shortcut" data-key="/">/</button>
<button class="key" data-key="ArrowLeft"></button>
<button class="key" data-key="ArrowDown"></button>
<button class="key" data-key="ArrowUp"></button>
<button class="key" data-key="ArrowRight"></button>
</div>
<div class="key-row input-row">
<button class="keyboard-toggle" id="keyboard-toggle" aria-label="Toggle keyboard">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="5" width="20" height="14" rx="2"/><path d="M6 9h.01M10 9h.01M14 9h.01M18 9h.01M8 13h.01M12 13h.01M16 13h.01M6 17h12"/></svg>
</button>
<input type="text" class="text-input" id="text-input" placeholder="Type here..."
autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
<button class="key send-key" id="send-btn">&crarr;</button>
@ -78,20 +86,44 @@
</footer>
</div>
<script src="/js/websocket-client.js"></script>
<script src="/js/terminal-view.js"></script>
<script src="/js/virtual-keyboard.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/gestures.js"></script>
<script src="/js/theme.js"></script>
<script src="/js/app.js"></script>
<script>
// 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());
});
}
</script>
<!-- Settings panel -->
<div class="settings-overlay" id="settings-overlay"></div>
<aside class="settings-panel" id="settings-panel">
<div class="settings-header">
<span>Settings</span>
<button class="settings-close" id="settings-close" aria-label="Close settings">
<svg width="14" height="14" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
</button>
</div>
<div class="settings-body">
<label class="settings-row" for="theme-cb">
<span class="settings-label">Dark Mode</span>
<span class="settings-toggle">
<input type="checkbox" id="theme-cb" class="settings-cb">
<span class="settings-toggle-slider"></span>
</span>
</label>
<label class="settings-row" for="scroll-x-cb">
<span class="settings-label">Horizontal Scroll</span>
<span class="settings-toggle">
<input type="checkbox" id="scroll-x-cb" class="settings-cb">
<span class="settings-toggle-slider"></span>
</span>
</label>
</div>
</aside>
<script src="/js/websocket-client.js?v=2"></script>
<script src="/js/terminal-view.js?v=6"></script>
<script src="/js/virtual-keyboard.js?v=3"></script>
<script src="/js/sidebar.js?v=5"></script>
<script src="/js/gestures.js?v=2"></script>
<script src="/js/theme.js?v=4"></script>
<script src="/js/claude-parser.js?v=9"></script>
<script src="/js/claude-renderer.js?v=9"></script>
<script src="/js/claude-keyboard.js?v=4"></script>
<script src="/js/app.js?v=8"></script>
<script src="/js/debug-safe-area.js"></script>
<script src="/js/sw-unregister.js"></script>
</body>
</html>

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

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

@ -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 = /\*\*|(?<!\w)`(?!\s)/;
const ranges = [];
let rStart = -1;
let count = 0;
for (let i = start; i <= end; i++) {
const stripped = this._stripAnsi(lines[i]);
if ((OPTION_RE.test(stripped) || CURSOR_OPTION_RE.test(stripped)) && !HAS_MARKDOWN.test(stripped)) {
if (rStart === -1) rStart = i;
count++;
} else {
if (rStart !== -1 && count >= 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;

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// 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, '<span style="color:var(--yellow)">$1</span>');
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;

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

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

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

@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// 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 ? `<span style="${s}">${e}</span>` : 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
output.push(`<span class="qr-line" style="font-size:${fontSize}px">${esc}</span>`);
}
// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
).join('\n');
// Single block span — no \n between spans so no extra blank lines
output.push(`<span class="qr-block" style="font-size:${fontSize}px">${escapedLines}</span>`);
} 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// Apply highlighting patterns (order matters — more specific first)
// Diff lines
if (/^\+[^+]/.test(stripped)) return `<span class="hl-added">${e}</span>`;
if (/^-[^-]/.test(stripped)) return `<span class="hl-removed">${e}</span>`;
// Diff: added lines (starts with +)
if (/^\+[^+]/.test(line)) {
return `<span class="hl-added">${escaped}</span>`;
}
// Diff: removed lines (starts with -)
if (/^-[^-]/.test(line)) {
return `<span class="hl-removed">${escaped}</span>`;
}
// Tool use markers (blue circle)
escaped = escaped.replace(
/(\u23FA|\u25CF|\u2B24)/g,
'<span class="hl-tool">$1</span>'
);
// Thinking markers
escaped = escaped.replace(
/(\u273B)/g,
'<span class="hl-thinking">$1</span>'
);
// Output indent marker
escaped = escaped.replace(
/(\u23BF)/g,
'<span class="hl-output">$1</span>'
);
// Prompt marker
escaped = escaped.replace(
/(\u276F)/g,
'<span class="hl-prompt">$1</span>'
);
// Success markers
escaped = escaped.replace(
/(\u2713|\u2714|passed|PASS)/g,
'<span class="hl-success">$1</span>'
);
// Error markers
escaped = escaped.replace(
/(\u2717|\u2718|error|ERROR|FAIL|failed)/g,
'<span class="hl-error">$1</span>'
);
// Box drawing characters
escaped = escaped.replace(
/([\u2500-\u257F\u250C\u2510\u2514\u2518\u251C\u2524\u252C\u2534\u253C]+)/g,
'<span class="hl-box">$1</span>'
);
// File paths (simplified: word containing / with file extension)
escaped = escaped.replace(
// Tool-use markers ⏺●⬤
e = e.replace(/([\u23FA\u25CF\u2B24])/g, '<span class="hl-tool">$1</span>');
// Dingbats — spinner/thinking characters (✻ ✳ ✢ etc.)
e = e.replace(/([\u2700-\u27BF])/g, '<span class="hl-thinking">$1</span>');
// Output marker ⎿
e = e.replace(/(\u23BF)/g, '<span class="hl-output">$1</span>');
// Prompt marker
e = e.replace(/(\u276F)/g, '<span class="hl-prompt">$1</span>');
// Success
e = e.replace(/(\u2713|\u2714|passed|PASS)/g, '<span class="hl-success">$1</span>');
// Error
e = e.replace(/(\u2717|\u2718|error|ERROR|FAIL|failed)/g, '<span class="hl-error">$1</span>');
// Box drawing
e = e.replace(/([\u2500-\u257F]+)/g, '<span class="hl-box">$1</span>');
// File paths
e = e.replace(
/(?<!\w)((?:\.?\.?\/)?(?:[\w.-]+\/)+[\w.-]+\.[\w]+)/g,
'<span class="hl-path">$1</span>'
);
// Line numbers
e = e.replace(/^(\s*\d+\s*[|:])/, '<span class="hl-linenum">$1</span>');
// Line numbers in context (e.g., " 123 |" or ":42:")
escaped = escaped.replace(
/^(\s*\d+\s*[|:])/,
'<span class="hl-linenum">$1</span>'
);
return escaped;
return e;
}
scrollToBottom() {

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

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

@ -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();

@ -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<T>(method: string, params: Record<string, unknown>): Promise<T> {
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<string, unknown>): Promise<void> {
return this.socketQuery<void>(method, params).then(() => {});
}
async getClaudeCodeSurfaceRefs(): Promise<string[]> {
const cached = this.getCached<string[]>('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<string>();
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<void> {
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<void> {
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(

@ -1,21 +1,21 @@
import type { CmuxClient } from './cmux-client.js';
// Map browser key names to cmux send-key names
const KEY_MAP: Record<string, string> = {
// Keys supported by cmux send-key socket API
const SEND_KEY_MAP: Record<string, string> = {
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<void> {
// Handle Ctrl+key combos (from frontend: "Ctrl-c", "Ctrl-d", etc.)
// Ctrl+key combos — send as control byte via send_text (0x010x1A)
// 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);
}
}

@ -7,6 +7,7 @@ import type { DiffPatch } from '../protocol/messages.js';
interface PollTarget {
workspace: string;
surface: string;
type: 'terminal' | 'browser';
subscribers: Set<string>;
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<void> {
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<void> {
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<void> {
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;

@ -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 <port>', 'Local port', String(DEFAULT_CONFIG.port))
.option('-P, --password <pass>', 'Access password (or CMUX_REMOTE_PASSWORD env)')
.option('--no-tunnel', 'Disable Cloudflare Tunnel')
.option('--no-tunnel', 'Disable ngrok tunnel')
.option('--tunnel-domain <domain>', 'Fixed ngrok domain (or NGROK_DOMAIN env)')
.option('--poll-rate <ms>', 'Poll interval in ms', String(DEFAULT_CONFIG.pollRate))
.option('--theme <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();

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

@ -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()) {

@ -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<string> {
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<ReturnType<typeof ngrok.forward>> | null = null;
async start(port: number, domain?: string): Promise<string> {
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<void> {
if (this.listener) {
await this.listener.close();
this.listener = null;
}
}
}

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

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

Loading…
Cancel
Save