feat(v0.3): keybar Claude shortcuts + scrollback search + clickable links
- keybar: add Esc·Esc / ^O / ^T / ^B + per-button tooltips (KEY_MAP 14 keys)
- M1 search: @xterm/addon-search per terminal; 🔍 toolbar button + search box
(Enter=next, Shift+Enter=prev, Esc=close)
- M2 links: @xterm/addon-web-links per terminal (tap URLs Claude prints)
- tab bar split into #tabs (scrollable) + #toolbar (utility cluster) for future
utilities (QR/settings/dashboard)
- Browser-verified: 14 keybar buttons, search highlights matches, no console errors.
199 tests green, typechecks + build ok.
This commit is contained in:
331
package-lock.json
generated
331
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
<link rel="stylesheet" href="./style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="tabbar"></div>
|
||||
<div id="tabbar">
|
||||
<div id="tabs"></div>
|
||||
<div id="toolbar"></div>
|
||||
</div>
|
||||
<div id="term"></div>
|
||||
<div id="keybar"></div>
|
||||
<script type="module" src="./build/main.js"></script>
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
65
public/search.ts
Normal file
65
public/search.ts
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user