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:
Yaojia Wang
2026-06-17 18:28:40 +02:00
parent 110c1752d4
commit eaeacf3a78
10 changed files with 576 additions and 42 deletions

331
package-lock.json generated
View File

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

View File

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

View File

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

View File

@@ -1,16 +1,19 @@
/**
* 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)
* 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
@@ -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]

View File

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

View File

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

View File

@@ -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. */

View File

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

View File

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