diff --git a/package-lock.json b/package-lock.json index 2ff190d..529cd14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,14 +10,18 @@ "hasInstallScript": true, "dependencies": { "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-search": "^0.16.0", + "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "express": "^5.2.1", "node-pty": "^1.1.0", + "qrcode": "^1.5.4", "ws": "^8.21.0" }, "devDependencies": { "@types/express": "^5.0.6", "@types/node": "^25.9.3", + "@types/qrcode": "^1.5.6", "@types/ws": "^8.18.1", "esbuild": "^0.28.1", "tsx": "^4.22.4", @@ -910,6 +914,16 @@ "undici-types": ">=7.24.0 <7.24.7" } }, + "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.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", @@ -1074,6 +1088,18 @@ "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", "license": "MIT" }, + "node_modules/@xterm/addon-search": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz", + "integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==", + "license": "MIT" + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", + "license": "MIT" + }, "node_modules/@xterm/xterm": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", @@ -1096,6 +1122,30 @@ "node": ">= 0.6" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "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/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1181,6 +1231,15 @@ "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": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1191,6 +1250,35 @@ "node": ">=18" } }, + "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/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -1255,6 +1343,15 @@ } } }, + "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/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1274,6 +1371,12 @@ "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", @@ -1294,6 +1397,12 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -1499,6 +1608,19 @@ "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", @@ -1541,6 +1663,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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", @@ -1665,6 +1796,15 @@ "node": ">= 0.10" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -1932,6 +2072,18 @@ "url": "https://opencollective.com/parcel" } }, + "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/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2094,6 +2246,42 @@ "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", @@ -2103,6 +2291,15 @@ "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-to-regexp": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", @@ -2140,6 +2337,15 @@ "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.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -2182,6 +2388,23 @@ "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/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", @@ -2221,6 +2444,21 @@ "node": ">= 0.10" } }, + "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/rolldown": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", @@ -2322,6 +2560,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -2440,6 +2684,32 @@ "dev": true, "license": "MIT" }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2758,6 +3028,12 @@ } } }, + "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", @@ -2775,6 +3051,20 @@ "node": ">=8" } }, + "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", @@ -2801,6 +3091,47 @@ "optional": true } } + }, + "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/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } } } } diff --git a/package.json b/package.json index 61574b6..826aee5 100644 --- a/package.json +++ b/package.json @@ -20,14 +20,18 @@ }, "dependencies": { "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-search": "^0.16.0", + "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "express": "^5.2.1", "node-pty": "^1.1.0", + "qrcode": "^1.5.4", "ws": "^8.21.0" }, "devDependencies": { "@types/express": "^5.0.6", "@types/node": "^25.9.3", + "@types/qrcode": "^1.5.6", "@types/ws": "^8.18.1", "esbuild": "^0.28.1", "tsx": "^4.22.4", diff --git a/public/index.html b/public/index.html index f5ef295..d896716 100644 --- a/public/index.html +++ b/public/index.html @@ -8,7 +8,10 @@ -
+
+
+
+
diff --git a/public/keybar.ts b/public/keybar.ts index 2fa408a..747b579 100644 --- a/public/keybar.ts +++ b/public/keybar.ts @@ -1,19 +1,22 @@ /** - * public/keybar.ts — Mobile touch key bar, tuned for Claude Code. + * public/keybar.ts — Mobile/desktop touch key bar, tuned for Claude Code. * - * Exports pure key-to-byte mappings and a mount function that binds DOM buttons - * to onSend on touchstart (bypassing xterm so the soft keyboard doesn't pop up). + * Buttons send terminal byte sequences a phone soft keyboard can't easily + * produce, most-used first. Order/selection follows the official Claude Code + * keyboard-shortcut docs (interactive-mode / keybindings). * - * Order/selection follows Claude Code mobile habits — the keys a phone soft - * keyboard can't easily produce, most-used first (leftmost = no scroll needed): - * Esc \x1b interrupt Claude / dismiss (primary — most used) - * Shift+Tab \x1b[Z cycle plan / auto-accept mode - * ↑ ↓ \x1b[A/B select menu option / history - * Enter \r confirm - * Ctrl+C \x03 cancel / quit - * Tab \t complete / toggle - * ← → \x1b[D/C cursor - * / / slash-command launcher + * Esc \x1b interrupt Claude / dismiss (primary) + * Esc·Esc \x1b\x1b rewind / clear draft + * Shift+Tab \x1b[Z cycle plan / auto-accept mode + * ↑ ↓ \x1b[A/B select menu option / history + * Enter \r confirm + * Ctrl+C \x03 cancel / quit + * Ctrl+O \x0f expand transcript / tool detail + * Ctrl+T \x14 toggle task list + * Ctrl+B \x02 background running task + * Tab \t complete / toggle + * ← → \x1b[D/C cursor + * / / slash-command launcher */ import type { MountKeybar } from '../src/types.js' @@ -21,6 +24,7 @@ import type { MountKeybar } from '../src/types.js' /** Key name → byte string mapping (pure data, for testing). */ export const KEY_MAP = { esc: '\x1b', + escEsc: '\x1b\x1b', shiftTab: '\x1b[Z', arrowUp: '\x1b[A', arrowDown: '\x1b[B', @@ -28,24 +32,38 @@ export const KEY_MAP = { arrowRight: '\x1b[C', enter: '\r', ctrlC: '\x03', + ctrlO: '\x0f', + ctrlT: '\x14', + ctrlB: '\x02', tab: '\t', slash: '/', } as const export type KeyName = keyof typeof KEY_MAP -/** Button layout: most-used Claude Code keys first. `primary` styles a key as prominent. */ -export const KEYBAR_BUTTONS: Array<{ label: string; keyName: KeyName; primary?: boolean }> = [ - { label: 'Esc', keyName: 'esc', primary: true }, - { label: '⇧Tab', keyName: 'shiftTab' }, - { label: '↑', keyName: 'arrowUp' }, - { label: '↓', keyName: 'arrowDown' }, - { label: '⏎', keyName: 'enter' }, - { label: '^C', keyName: 'ctrlC' }, - { label: 'Tab', keyName: 'tab' }, - { label: '←', keyName: 'arrowLeft' }, - { label: '→', keyName: 'arrowRight' }, - { label: '/', keyName: 'slash' }, +interface KeybarButton { + label: string + keyName: KeyName + title: string // tooltip / aria-label + primary?: boolean // styled as prominent +} + +/** Button layout: most-used Claude Code keys first. */ +export const KEYBAR_BUTTONS: KeybarButton[] = [ + { label: 'Esc', keyName: 'esc', title: 'Esc — interrupt Claude / dismiss', primary: true }, + { label: 'Esc²', keyName: 'escEsc', title: 'Esc Esc — rewind / clear draft' }, + { label: '⇧Tab', keyName: 'shiftTab', title: 'Shift+Tab — cycle plan / auto-accept mode' }, + { label: '↑', keyName: 'arrowUp', title: 'Up — previous option / history' }, + { label: '↓', keyName: 'arrowDown', title: 'Down — next option / history' }, + { label: '⏎', keyName: 'enter', title: 'Enter — confirm' }, + { label: '^C', keyName: 'ctrlC', title: 'Ctrl+C — cancel / quit' }, + { label: '^O', keyName: 'ctrlO', title: 'Ctrl+O — expand transcript / tool detail' }, + { label: '^T', keyName: 'ctrlT', title: 'Ctrl+T — toggle task list' }, + { label: '^B', keyName: 'ctrlB', title: 'Ctrl+B — background running task' }, + { label: 'Tab', keyName: 'tab', title: 'Tab — complete / toggle' }, + { label: '←', keyName: 'arrowLeft', title: 'Left' }, + { label: '→', keyName: 'arrowRight', title: 'Right' }, + { label: '/', keyName: 'slash', title: 'Slash — command launcher' }, ] /** Mount the key bar: create buttons in #keybar, bind touchstart → onSend(bytes) + preventDefault. */ @@ -53,13 +71,14 @@ export const mountKeybar: MountKeybar = (onSend) => { const keybarEl = document.getElementById('keybar') if (!keybarEl) return - for (const { label, keyName, primary } of KEYBAR_BUTTONS) { + for (const { label, keyName, title, primary } of KEYBAR_BUTTONS) { const btn = document.createElement('button') btn.textContent = label btn.classList.add('keybar-btn') if (primary) btn.classList.add('keybar-btn-primary') btn.dataset.key = keyName - btn.setAttribute('aria-label', label) + btn.title = title + btn.setAttribute('aria-label', title) const bytes = KEY_MAP[keyName] diff --git a/public/main.ts b/public/main.ts index c65ac05..748996c 100644 --- a/public/main.ts +++ b/public/main.ts @@ -1,27 +1,34 @@ /** * public/main.ts — esbuild entry point for the browser frontend. * - * Bootstraps the multi-tab app: a tab bar (#tabbar) over a stack of terminal - * panes (#term), each tab an independent TerminalSession (own WS + server - * session). The mobile key bar routes input to the active tab. + * Bootstraps the multi-tab app: a tab bar (#tabs) + a utility toolbar (#toolbar) + * over a stack of terminal panes (#term), each tab an independent TerminalSession. + * The mobile key bar routes input to the active tab. * * Per-session terminal/WS/reconnect logic lives in terminal-session.ts; * tab management + persistence in tabs.ts. */ -// CSS side-effect import processed by esbuild (→ public/build/main.css). -// tsc cannot resolve CSS paths; @ts-ignore suppresses the single diagnostic. -// @ts-ignore +// @ts-ignore CSS side-effect import processed by esbuild (→ public/build/main.css). import '@xterm/xterm/css/xterm.css' import { mountKeybar } from './keybar.js' import { TabApp } from './tabs.js' +import { mountSearch } from './search.js' const paneHost = document.getElementById('term') -const tabBar = document.getElementById('tabbar') +const tabs = document.getElementById('tabs') +const toolbar = document.getElementById('toolbar') if (!paneHost) throw new Error('#term element not found in DOM') -if (!tabBar) throw new Error('#tabbar element not found in DOM') +if (!tabs) throw new Error('#tabs element not found in DOM') +if (!toolbar) throw new Error('#toolbar element not found in DOM') -const app = new TabApp(paneHost, tabBar) +const app = new TabApp(paneHost, tabs) -// Key bar (mobile) sends to whichever tab is active. +// Key bar (mobile + desktop) sends to whichever tab is active. mountKeybar((data) => app.sendToActive(data)) + +// Toolbar utilities. +mountSearch(toolbar, { + find: (query, dir) => app.findInActive(query, dir), + clear: () => app.clearActiveSearch(), +}) diff --git a/public/search.ts b/public/search.ts new file mode 100644 index 0000000..a3fb9aa --- /dev/null +++ b/public/search.ts @@ -0,0 +1,65 @@ +/** + * public/search.ts — scrollback search box for the active terminal (M1). + * + * Adds a 🔍 button to the toolbar that toggles a small search overlay. + * Enter = next, Shift+Enter = previous, Esc = close. + */ + +export interface SearchHooks { + find: (query: string, dir: 'next' | 'prev') => void + clear: () => void +} + +function makeBtn(label: string, title: string): HTMLButtonElement { + const b = document.createElement('button') + b.textContent = label + b.title = title + b.setAttribute('aria-label', title) + return b +} + +export function mountSearch(toolbar: HTMLElement, hooks: SearchHooks): void { + const box = document.createElement('div') + box.id = 'searchbox' + box.style.display = 'none' + + const input = document.createElement('input') + input.type = 'text' + input.className = 'search-input' + input.placeholder = 'Search scrollback…' + + const prev = makeBtn('↑', 'Previous match (Shift+Enter)') + const next = makeBtn('↓', 'Next match (Enter)') + const close = makeBtn('×', 'Close (Esc)') + box.append(input, prev, next, close) + document.body.appendChild(box) + + const open = (): void => { + box.style.display = 'flex' + input.focus() + input.select() + } + const hide = (): void => { + box.style.display = 'none' + hooks.clear() + } + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault() + hooks.find(input.value, e.shiftKey ? 'prev' : 'next') + } else if (e.key === 'Escape') { + e.preventDefault() + hide() + } + e.stopPropagation() + }) + prev.addEventListener('click', () => hooks.find(input.value, 'prev')) + next.addEventListener('click', () => hooks.find(input.value, 'next')) + close.addEventListener('click', hide) + + const toggle = makeBtn('🔍', 'Search scrollback') + toggle.className = 'toolbtn' + toggle.addEventListener('click', () => (box.style.display === 'none' ? open() : hide())) + toolbar.appendChild(toggle) +} diff --git a/public/style.css b/public/style.css index f26357b..e9389c6 100644 --- a/public/style.css +++ b/public/style.css @@ -36,12 +36,77 @@ html, body { border-bottom: 1px solid #3a3a3a; display: flex; align-items: stretch; + z-index: 1001; +} + +/* tabs: scrollable; toolbar: fixed cluster on the right */ +#tabs { + flex: 1 1 auto; + display: flex; + align-items: stretch; overflow-x: auto; overflow-y: hidden; - z-index: 1001; scrollbar-width: thin; } +#toolbar { + flex: 0 0 auto; + display: flex; + align-items: center; + border-left: 1px solid #3a3a3a; +} + +.toolbtn { + background: none; + border: none; + color: #aaa; + cursor: pointer; + font-size: 15px; + padding: 0 12px; + height: 100%; +} +.toolbtn:hover { + color: #fff; + background-color: #2a2a2a; +} + +/* Search box overlay (M1) */ +#searchbox { + position: fixed; + top: calc(var(--tabbar-h) + 6px); + right: 8px; + z-index: 1100; + display: flex; + gap: 4px; + padding: 6px; + background-color: #2a2a2a; + border: 1px solid #3a3a3a; + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); +} +.search-input { + width: 180px; + padding: 4px 6px; + font: inherit; + font-size: 13px; + color: #fff; + background: #111; + border: 1px solid #444; + border-radius: 3px; + outline: none; +} +#searchbox button { + background: #333; + border: 1px solid #444; + color: #ddd; + cursor: pointer; + border-radius: 3px; + padding: 0 8px; +} +#searchbox button:hover { + background: #555; +} + .tab { display: flex; align-items: center; diff --git a/public/tabs.ts b/public/tabs.ts index 6a4f673..0c13027 100644 --- a/public/tabs.ts +++ b/public/tabs.ts @@ -206,6 +206,18 @@ export class TabApp { this.tabs[this.activeIndex]?.session.send(data) } + /** Scrollback search in the active tab (M1). */ + findInActive(query: string, dir: 'next' | 'prev'): void { + const s = this.tabs[this.activeIndex]?.session + if (!s) return + if (dir === 'next') s.findNext(query) + else s.findPrevious(query) + } + + clearActiveSearch(): void { + this.tabs[this.activeIndex]?.session.clearSearch() + } + /* ── rendering ───────────────────────────────────────────────── */ /** In-place update of one tab's classes/label/dot — never destroys the DOM. */ diff --git a/public/terminal-session.ts b/public/terminal-session.ts index f56aa23..1a37ef1 100644 --- a/public/terminal-session.ts +++ b/public/terminal-session.ts @@ -10,6 +10,8 @@ import { Terminal } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' +import { SearchAddon } from '@xterm/addon-search' +import { WebLinksAddon } from '@xterm/addon-web-links' import type { ClientMessage, ServerMessage } from '../src/types.js' import { folderFromTitle } from './title-util.js' @@ -56,6 +58,7 @@ export class TerminalSession { private readonly term: Terminal private readonly fitAddon: FitAddon + private readonly searchAddon: SearchAddon private readonly resizeObserver: ResizeObserver private readonly onSessionId: (id: string) => void private readonly onActivity: (() => void) | undefined @@ -92,6 +95,9 @@ export class TerminalSession { }) this.fitAddon = new FitAddon() this.term.loadAddon(this.fitAddon) + this.searchAddon = new SearchAddon() + this.term.loadAddon(this.searchAddon) + this.term.loadAddon(new WebLinksAddon()) // M2: tap a URL Claude prints to open it this.term.open(this.el) this.term.onData((data) => this.send(data)) @@ -244,6 +250,17 @@ export class TerminalSession { this.ws.send(buildMessage({ type: 'input', data })) } + /** Scrollback search (M1). */ + findNext(query: string): void { + this.searchAddon.findNext(query) + } + findPrevious(query: string): void { + this.searchAddon.findPrevious(query) + } + clearSearch(): void { + this.searchAddon.clearDecorations() + } + /** Make this pane visible, fit it, and focus it. */ show(): void { this.el.style.display = 'block' diff --git a/test/keybar.test.ts b/test/keybar.test.ts index d724c63..4869c8f 100644 --- a/test/keybar.test.ts +++ b/test/keybar.test.ts @@ -78,10 +78,18 @@ describe('keybar', () => { expect(KEY_MAP.slash).toHaveLength(1) }) - it('all 10 keys are defined', () => { + it('Claude Code control keys map to the right bytes', () => { + expect(KEY_MAP.escEsc).toBe('\x1b\x1b') // rewind / clear draft + expect(KEY_MAP.ctrlO).toBe('\x0f') // expand transcript + expect(KEY_MAP.ctrlT).toBe('\x14') // task list + expect(KEY_MAP.ctrlB).toBe('\x02') // background + }) + + it('all 14 keys are defined', () => { const keys = Object.keys(KEY_MAP) expect(keys).toEqual([ 'esc', + 'escEsc', 'shiftTab', 'arrowUp', 'arrowDown', @@ -89,6 +97,9 @@ describe('keybar', () => { 'arrowRight', 'enter', 'ctrlC', + 'ctrlO', + 'ctrlT', + 'ctrlB', 'tab', 'slash', ]) @@ -96,8 +107,8 @@ describe('keybar', () => { it('all mapped bytes are unique strings', () => { const bytes = Object.values(KEY_MAP) - expect(bytes).toHaveLength(10) - expect(new Set(bytes).size).toBe(10) // all unique + expect(bytes).toHaveLength(14) + expect(new Set(bytes).size).toBe(14) // all unique }) it('ANSI arrow sequences use correct format', () => {