From eaeacf3a784730eea40b50df18651af17ee448fe Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Wed, 17 Jun 2026 18:28:40 +0200 Subject: [PATCH 01/15] feat(v0.3): keybar Claude shortcuts + scrollback search + clickable links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- package-lock.json | 331 +++++++++++++++++++++++++++++++++++++ package.json | 4 + public/index.html | 5 +- public/keybar.ts | 73 +++++--- public/main.ts | 27 +-- public/search.ts | 65 ++++++++ public/style.css | 67 +++++++- public/tabs.ts | 12 ++ public/terminal-session.ts | 17 ++ test/keybar.test.ts | 17 +- 10 files changed, 576 insertions(+), 42 deletions(-) create mode 100644 public/search.ts 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', () => { From fbc218c57feed2afb220e92841ba4953e0b6580a Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Wed, 17 Jun 2026 18:32:57 +0200 Subject: [PATCH 02/15] feat(v0.3): QR connect (M5) + PWA installable (M4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M5: ๐Ÿ“ฑ toolbar button โ†’ modal with a client-side QR of location.origin (shareable when opened via the LAN IP) + localhost tip - M4: manifest.webmanifest + icon.svg + sw.js (network-first, never intercepts /term or /hook) + registration in main.ts; apple-touch + theme-color meta - Browser-verified: QR canvas renders, SW 'controlled', manifest linked, no console errors. 199 tests green, build ok. --- public/icon.svg | 7 +++++ public/index.html | 7 ++++- public/main.ts | 11 +++++++ public/manifest.webmanifest | 19 +++++++++++ public/qr.ts | 63 +++++++++++++++++++++++++++++++++++++ public/style.css | 46 +++++++++++++++++++++++++++ public/sw.js | 28 +++++++++++++++++ 7 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 public/icon.svg create mode 100644 public/manifest.webmanifest create mode 100644 public/qr.ts create mode 100644 public/sw.js diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..d0af5c0 --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/index.html b/public/index.html index d896716..df675fe 100644 --- a/public/index.html +++ b/public/index.html @@ -2,8 +2,13 @@ - + Web Terminal + + + + + diff --git a/public/main.ts b/public/main.ts index 748996c..88a0348 100644 --- a/public/main.ts +++ b/public/main.ts @@ -14,6 +14,7 @@ import '@xterm/xterm/css/xterm.css' import { mountKeybar } from './keybar.js' import { TabApp } from './tabs.js' import { mountSearch } from './search.js' +import { mountQrConnect } from './qr.js' const paneHost = document.getElementById('term') const tabs = document.getElementById('tabs') @@ -32,3 +33,13 @@ mountSearch(toolbar, { find: (query, dir) => app.findInActive(query, dir), clear: () => app.clearActiveSearch(), }) +mountQrConnect(toolbar) + +// PWA: register the service worker (installable + offline shell, M4). +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + void navigator.serviceWorker.register('./sw.js').catch(() => { + // SW registration is best-effort; the app works without it. + }) + }) +} diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..d84b8c0 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "Web Terminal", + "short_name": "WebTerm", + "description": "Drive your shell / Claude Code from any device on your LAN.", + "start_url": "./", + "scope": "./", + "display": "standalone", + "orientation": "any", + "background_color": "#1a1a1a", + "theme_color": "#1a1a1a", + "icons": [ + { + "src": "./icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/public/qr.ts b/public/qr.ts new file mode 100644 index 0000000..7995cea --- /dev/null +++ b/public/qr.ts @@ -0,0 +1,63 @@ +/** + * public/qr.ts โ€” "open on another device" QR modal (M5). + * + * Renders a QR of the current origin (client-side, no server call). When the + * page is opened via the host's LAN IP, the QR is directly shareable to a + * phone/tablet on the same network. + */ + +import QRCode from 'qrcode' + +export function mountQrConnect(toolbar: HTMLElement): void { + const modal = document.createElement('div') + modal.id = 'qrmodal' + modal.style.display = 'none' + + const card = document.createElement('div') + card.className = 'qr-card' + + const title = document.createElement('div') + title.className = 'qr-title' + title.textContent = 'Open on another device' + + const canvas = document.createElement('canvas') + const urlEl = document.createElement('div') + urlEl.className = 'qr-url' + const note = document.createElement('div') + note.className = 'qr-note' + + const close = document.createElement('button') + close.className = 'qr-close' + close.textContent = 'Close' + + card.append(title, canvas, urlEl, note, close) + modal.appendChild(card) + document.body.appendChild(modal) + + const open = (): void => { + const origin = location.origin + urlEl.textContent = origin + void QRCode.toCanvas(canvas, origin, { width: 220, margin: 1 }) + const host = location.hostname + note.textContent = + host === 'localhost' || host === '127.0.0.1' + ? 'Tip: open this page via your LAN IP (not localhost) for a shareable code.' + : 'Scan on a device on the same network.' + modal.style.display = 'flex' + } + const hide = (): void => { + modal.style.display = 'none' + } + modal.addEventListener('click', (e) => { + if (e.target === modal) hide() + }) + close.addEventListener('click', hide) + + const toggle = document.createElement('button') + toggle.className = 'toolbtn' + toggle.textContent = '๐Ÿ“ฑ' + toggle.title = 'Connect another device (QR)' + toggle.setAttribute('aria-label', 'Connect another device') + toggle.addEventListener('click', open) + toolbar.appendChild(toggle) +} diff --git a/public/style.css b/public/style.css index e9389c6..bef601a 100644 --- a/public/style.css +++ b/public/style.css @@ -107,6 +107,52 @@ html, body { background: #555; } +/* QR connect modal (M5) */ +#qrmodal { + position: fixed; + inset: 0; + z-index: 1200; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.6); +} +.qr-card { + background: #fff; + color: #111; + border-radius: 12px; + padding: 20px; + text-align: center; + max-width: 90vw; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} +.qr-title { + font-weight: 600; + margin-bottom: 12px; +} +.qr-url { + margin-top: 10px; + font-size: 13px; + color: #333; + word-break: break-all; +} +.qr-note { + margin-top: 8px; + font-size: 12px; + color: #666; + max-width: 240px; +} +.qr-close { + margin-top: 14px; + padding: 6px 18px; + border: none; + border-radius: 6px; + background: #1a1a1a; + color: #fff; + cursor: pointer; + font: inherit; +} + .tab { display: flex; align-items: center; diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..d9032cd --- /dev/null +++ b/public/sw.js @@ -0,0 +1,28 @@ +/** + * public/sw.js โ€” minimal service worker (M4: PWA installability + offline shell). + * + * Network-first so we never serve a stale build while online; falls back to the + * cache when offline. Never intercepts the WebSocket (/term) or hook endpoints. + */ + +const CACHE = 'webterm-v1' + +self.addEventListener('install', () => self.skipWaiting()) +self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim())) + +self.addEventListener('fetch', (e) => { + const req = e.request + if (req.method !== 'GET') return + const url = new URL(req.url) + if (url.pathname === '/term' || url.pathname.startsWith('/hook')) return // WS / hooks + + e.respondWith( + fetch(req) + .then((res) => { + const copy = res.clone() + caches.open(CACHE).then((c) => c.put(req, copy)) + return res + }) + .catch(() => caches.match(req)), + ) +}) From 8c2a6825cc222147548e7641aa53820b01929399 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Wed, 17 Jun 2026 18:33:31 +0200 Subject: [PATCH 03/15] docs: log v0.3 step 1 (FE quick wins) --- docs/PROGRESS_LOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 8b8c12e..13753df 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -121,6 +121,19 @@ - **้—็•™ / ๅพ…ๅŠž**: ๆ— (ๅŽŸๅ‰็ซฏๆ‰“ๅŒ…ๅพ…ๅ†ณ้กนๅทฒ่งฃๅ†ณ,่งไธ‹่กฅ่ฎฐ)ใ€‚ - **commit**: `b126cf0` +### 2026-06-17 ยท v0.3 Step 1 โ€” ๅ‰็ซฏๅฟซ่ตข(้”ฎๆ /ๆœ็ดข/้“พๆŽฅ/QR/PWA) + +- **็Šถๆ€**: `[x]` DONE(ๅˆ†ๆ”ฏ `v0.3-cockpit`;็บฏๅ‰็ซฏ,ๆ— ๆœๅŠก็ซฏๆ”นๅŠจ) +- **่ฎกๅˆ’**: `~/.claude/plans/shimmering-wondering-island.md`(v0.3 cockpit,ๅทฒๆ‰นๅ‡†)ใ€‚ +- **H4-keybar**: ้”ฎๆ ๅŠ  Claude ๅฟซๆท้”ฎ EscยทEsc/^O/^T/^B + ๆฏ้”ฎ tooltip(KEY_MAP 14 ้”ฎ;commit `eaeacf3`)ใ€‚ +- **M1 ๆœ็ดข**: `@xterm/addon-search` ๆฏ็ปˆ็ซฏ + ๐Ÿ” ๅทฅๅ…ทๆ ๆŒ‰้’ฎ + ๆœ็ดขๆก†(Enter ไธ‹ไธ€ไธช/Shift+Enter ไธŠไธ€ไธช/Esc ๅ…ณ)ใ€‚ +- **M2 ๅฏ็‚นๅ‡ป้“พๆŽฅ**: `@xterm/addon-web-links`ใ€‚ +- **็ป“ๆž„**: ๆ ‡็ญพๆ ๆ‹†ๆˆ `#tabs`(ๅฏๆปš)+ `#toolbar`(ๅทฅๅ…ทๆŒ‰้’ฎๅŒบ),ไธบๅŽ็ปญ QR/่ฎพ็ฝฎ/ไปช่กจ็›˜ๅค็”จใ€‚ +- **M5 QR**: ๐Ÿ“ฑ ๆŒ‰้’ฎ โ†’ ๅฎขๆˆท็ซฏๆธฒๆŸ“ location.origin ็š„ไบŒ็ปด็ (็ป LAN IP ๆ‰“ๅผ€ๅณๅฏๅˆ†ไบซ)+ localhost ๆ็คบ(commit `fbc218c`)ใ€‚ +- **M4 PWA**: manifest + icon.svg + sw.js(network-first,ไธๆ‹ฆ /term//hook)+ ๆณจๅ†Œ;ๅฏๅฎ‰่ฃ…ใ€‚ +- **้ชŒ่ฏ(็œŸๆต่งˆๅ™จ)**: 14 ้”ฎๆ ๆŒ‰้’ฎใ€ๆœ็ดข้ซ˜ไบฎๅ‘ฝไธญใ€๐Ÿ”๐Ÿ“ฑ ๅทฅๅ…ทๆŒ‰้’ฎใ€SW controlledใ€QR canvas ๆธฒๆŸ“ใ€0 console errorใ€‚199 ๆต‹่ฏ•ๅ…จ็ปฟใ€ๅŒ tscใ€build ้€š่ฟ‡ใ€‚ๆˆชๅ›พ /tmp/v03-quickwins.pngใ€/tmp/v03-qr.pngใ€‚ +- **commit**: `eaeacf3`ใ€`fbc218c` + ### 2026-06-17 ยท v0.2 ไฟฎๅค โ€” ๅˆ‡ๆขๆ ‡็ญพ่ฆ็‚นๅคšๆฌก - **็Šถๆ€**: `[x]` FIXED From a411c89ee973aceb080524f20ef8ad9c28a6a92c Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Wed, 17 Jun 2026 18:44:07 +0200 Subject: [PATCH 04/15] =?UTF-8?q?feat(v0.3):=20H2=20server=20=E2=80=94=20C?= =?UTF-8?q?laude=20Code=20hooks=20=E2=86=92=20live=20status=20side-channel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types: ClaudeStatus + ServerMessage 'status'; Session.claudeStatus; SessionManager.handleHookEvent - session: inject WEBTERM_SESSION + WEBTERM_HOOK_URL into the spawned shell env so hooks know which tab they belong to - http/hook.ts: pure eventโ†’status mapper (PreToolUseโ†’working, PermissionRequest/ Notification:permission_promptโ†’waiting, Stop/idle_promptโ†’idle); 6 unit tests - server: POST /hook (loopback-only, express.json 64kb) โ†’ manager pushes 'status' - fix: closeIdleConnections() on shutdown so an idle hook keep-alive socket doesn't hang close()/SIGTERM - integration โ‘ฆ (real PTY): attach โ†’ POST /hook โ†’ ws receives status. 205 tests green. --- src/http/hook.ts | 57 +++++++++++++++++++++++++++++++++ src/server.ts | 24 ++++++++++++++ src/session/manager.ts | 25 +++++++++++++-- src/session/session.ts | 14 ++++++-- src/types.ts | 13 ++++++-- test/hook.test.ts | 47 +++++++++++++++++++++++++++ test/integration/server.test.ts | 44 +++++++++++++++++++++++++ 7 files changed, 218 insertions(+), 6 deletions(-) create mode 100644 src/http/hook.ts create mode 100644 test/hook.test.ts diff --git a/src/http/hook.ts b/src/http/hook.ts new file mode 100644 index 0000000..4f9f9c2 --- /dev/null +++ b/src/http/hook.ts @@ -0,0 +1,57 @@ +/** + * src/http/hook.ts (H2) โ€” map a Claude Code hook POST to a session status update. + * + * Pure + never throws. The web-terminal sessionId arrives in the + * `X-Webterm-Session` header (injected as $WEBTERM_SESSION on spawn); the hook + * payload arrives as JSON in the body. We translate the event into a coarse + * ClaudeStatus the UI can show. The server is still a byte-shuttle for the + * terminal stream โ€” this is an out-of-band side-channel. + */ + +import type { ClaudeStatus } from '../types.js'; + +export interface HookEvent { + sessionId: string; + status: ClaudeStatus; + detail?: string; +} + +/** + * @param sessionId value of the `X-Webterm-Session` header + * @param body parsed JSON body of the hook POST (untrusted) + * @returns a status update, or null if the payload is unusable + */ +export function parseHookEvent(sessionId: string | undefined, body: unknown): HookEvent | null { + if (typeof sessionId !== 'string' || sessionId.length === 0) return null; + if (body === null || typeof body !== 'object') return null; + + const b = body as Record; + const event = typeof b['hook_event_name'] === 'string' ? b['hook_event_name'] : ''; + const notif = typeof b['notification_type'] === 'string' ? b['notification_type'] : ''; + const tool = typeof b['tool_name'] === 'string' ? b['tool_name'] : undefined; + + let status: ClaudeStatus; + switch (event) { + case 'PreToolUse': + case 'PostToolUse': + case 'UserPromptSubmit': + status = 'working'; + break; + case 'PermissionRequest': + status = 'waiting'; + break; + case 'Stop': + case 'SessionEnd': + status = 'idle'; + break; + case 'Notification': + status = notif === 'permission_prompt' ? 'waiting' : 'idle'; + break; + default: + return null; + } + + return status === 'waiting' && tool !== undefined + ? { sessionId, status, detail: tool } + : { sessionId, status }; +} diff --git a/src/server.ts b/src/server.ts index b2a00a6..4080949 100644 --- a/src/server.ts +++ b/src/server.ts @@ -35,6 +35,7 @@ import type { IncomingMessage } from 'node:http' import { loadConfig } from './config.js' import { parseClientMessage, serialize } from './protocol.js' import { isOriginAllowed } from './http/origin.js' +import { parseHookEvent } from './http/hook.js' import { createSessionManager } from './session/manager.js' import { detachWs, writeInput, resize } from './session/session.js' import type { Config } from './types.js' @@ -77,6 +78,25 @@ export function startServer(cfg: Config): { close(): Promise } { const publicDir = path.join(__dirname, '..', 'public') app.use(express.static(publicDir)) + // โ”€โ”€ Claude Code hook side-channel (H2) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Hooks running inside a spawned shell POST status here (loopback only โ€” the + // shell runs on the host). sessionId arrives in the X-Webterm-Session header. + app.post('/hook', express.json({ limit: '64kb' }), (req, res) => { + const ip = req.socket.remoteAddress ?? '' + const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1' + if (!isLoopback) { + res.status(403).end() + return + } + const ev = parseHookEvent(req.header('x-webterm-session'), req.body) + if (ev === null) { + res.status(400).end() + return + } + manager.handleHookEvent(ev.sessionId, ev.status, ev.detail) + res.status(204).end() + }) + // โ”€โ”€ HTTP server โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const httpServer = createServer(app) @@ -219,6 +239,7 @@ export function startServer(cfg: Config): { close(): Promise } { console.error('[server] shutdown signal received โ€” cleaning up') doShutdown() httpServer.close(() => process.exit(0)) + httpServer.closeIdleConnections() // drop idle keep-alive (hook) sockets so close() drains } process.on('SIGINT', onSignal) @@ -246,6 +267,9 @@ export function startServer(cfg: Config): { close(): Promise } { return new Promise((resolve) => { doShutdown() httpServer.close(() => resolve()) + // Drop idle keep-alive sockets (e.g. an undici hook connection) so the + // close callback can fire instead of waiting for the keep-alive timeout. + httpServer.closeIdleConnections() }) }, } diff --git a/src/session/manager.ts b/src/session/manager.ts index 480b8b2..1868c1e 100644 --- a/src/session/manager.ts +++ b/src/session/manager.ts @@ -22,7 +22,14 @@ * Coding style: immutable-update preference, no console.log, errors explicit. */ -import type { Config, Dims, Session, SessionManager, WebSocketLike } from '../types.js'; +import type { + ClaudeStatus, + Config, + Dims, + Session, + SessionManager, + WebSocketLike, +} from '../types.js'; import { WS_OPEN } from '../types.js'; import { serialize } from '../protocol.js'; import { createSession, attachWs, kill } from './session.js'; @@ -119,6 +126,20 @@ export function createSessionManager(cfg: Config): SessionManager { return sessions.get(id); } + /** + * H2: a Claude Code hook reported activity for `sessionId`. Record it and push + * a `status` frame to the attached ws (no-op if the session is gone/detached). + */ + function handleHookEvent(sessionId: string, status: ClaudeStatus, detail?: string): void { + const session = sessions.get(sessionId); + if (session === undefined) return; + session.claudeStatus = status; + sendIfOpen( + session.attachedWs, + detail !== undefined ? { type: 'status', status, detail } : { type: 'status', status }, + ); + } + /** * Reclaim detached sessions whose liveness window has expired (M3). * @@ -159,5 +180,5 @@ export function createSessionManager(cfg: Config): SessionManager { sessions = new Map(); } - return { handleAttach, get, reapIdle, shutdown }; + return { handleAttach, get, handleHookEvent, reapIdle, shutdown }; } diff --git a/src/session/session.ts b/src/session/session.ts index c04479f..c98feda 100644 --- a/src/session/session.ts +++ b/src/session/session.ts @@ -58,17 +58,26 @@ export function createSession( now: number, onExit: (session: Session) => void, ): Session { + // Generate the id first so it can be injected into the shell env: Claude Code + // hooks running inside this shell read $WEBTERM_SESSION to tell us which tab + // an event belongs to, and POST to $WEBTERM_HOOK_URL (H2). + const id = randomUUID(); + // M4: let a spawn failure (e.g. missing shell) propagate synchronously. const pty: IPty = spawn(cfg.shellPath, [], { name: 'xterm-256color', cols: dims.cols, rows: dims.rows, cwd: cfg.homeDir, - env: process.env, + env: { + ...process.env, + WEBTERM_SESSION: id, + WEBTERM_HOOK_URL: `http://127.0.0.1:${cfg.port}/hook`, + }, }); const meta: SessionMeta = { - id: randomUUID(), + id, createdAt: now, shellPath: cfg.shellPath, }; @@ -81,6 +90,7 @@ export function createSession( lastOutputAt: now, exitedAt: null, exitCode: null, + claudeStatus: 'unknown', pty, }; diff --git a/src/types.ts b/src/types.ts index 2a48370..f07d2aa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -44,12 +44,17 @@ export type ClientMessage = | { type: 'input'; data: string } | { type: 'resize'; cols: number; rows: number }; +/** Claude Code activity, derived from hooks (H2). 'unknown' = no hook signal yet. */ +export type ClaudeStatus = 'working' | 'waiting' | 'idle' | 'unknown'; + /** server โ†’ client. exit.code = shell code; -1 when spawn never succeeded (M4). - * exit.reason optional normally, REQUIRED on spawn failure / abnormal exit. */ + * exit.reason optional normally, REQUIRED on spawn failure / abnormal exit. + * status (H2) = Claude Code activity pushed from a hook side-channel. */ export type ServerMessage = | { type: 'attached'; sessionId: string } | { type: 'output'; data: string } - | { type: 'exit'; code: number; reason?: string }; + | { type: 'exit'; code: number; reason?: string } + | { type: 'status'; status: ClaudeStatus; detail?: string }; /** parseClientMessage result โ€” never throws; errors flow here (ยง5.3). */ export type ParseResult = @@ -139,6 +144,8 @@ export interface Session { * and attach replays the buffer then re-sends `exit` (L1). */ exitedAt: number | null; exitCode: number | null; + /** Claude Code activity from hooks (H2); updated by manager.handleHookEvent. */ + claudeStatus: ClaudeStatus; readonly pty: IPty; } @@ -156,6 +163,8 @@ export interface Session { export interface SessionManager { handleAttach(ws: WebSocketLike, sessionId: string | null, dims: Dims, now: number): Session; get(id: string): Session | undefined; + /** Set a session's Claude status (from a hook) and push it to the attached ws (H2). */ + handleHookEvent(sessionId: string, status: ClaudeStatus, detail?: string): void; /** reclaim when now - max(detachedAt, lastOutputAt) > idleTtlMs (M3). Returns count. */ reapIdle(now: number): number; shutdown(): void; diff --git a/test/hook.test.ts b/test/hook.test.ts new file mode 100644 index 0000000..daa8ac6 --- /dev/null +++ b/test/hook.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest' +import { parseHookEvent } from '../src/http/hook.js' + +const SID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479' + +describe('parseHookEvent', () => { + it('maps tool events to "working"', () => { + for (const e of ['PreToolUse', 'PostToolUse', 'UserPromptSubmit']) { + expect(parseHookEvent(SID, { hook_event_name: e })).toEqual({ + sessionId: SID, + status: 'working', + }) + } + }) + + it('maps PermissionRequest to "waiting" with the tool name as detail', () => { + expect(parseHookEvent(SID, { hook_event_name: 'PermissionRequest', tool_name: 'Bash' })).toEqual( + { sessionId: SID, status: 'waiting', detail: 'Bash' }, + ) + }) + + it('maps Notification permission_prompt to "waiting", idle_prompt to "idle"', () => { + expect( + parseHookEvent(SID, { hook_event_name: 'Notification', notification_type: 'permission_prompt' }), + ).toEqual({ sessionId: SID, status: 'waiting' }) + expect( + parseHookEvent(SID, { hook_event_name: 'Notification', notification_type: 'idle_prompt' }), + ).toEqual({ sessionId: SID, status: 'idle' }) + }) + + it('maps Stop / SessionEnd to "idle"', () => { + expect(parseHookEvent(SID, { hook_event_name: 'Stop' })).toEqual({ sessionId: SID, status: 'idle' }) + expect(parseHookEvent(SID, { hook_event_name: 'SessionEnd' })).toEqual({ + sessionId: SID, + status: 'idle', + }) + }) + + it('returns null for missing sessionId, bad body, or unknown event', () => { + expect(parseHookEvent(undefined, { hook_event_name: 'Stop' })).toBeNull() + expect(parseHookEvent('', { hook_event_name: 'Stop' })).toBeNull() + expect(parseHookEvent(SID, null)).toBeNull() + expect(parseHookEvent(SID, 'nope')).toBeNull() + expect(parseHookEvent(SID, { hook_event_name: 'SomethingElse' })).toBeNull() + expect(parseHookEvent(SID, {})).toBeNull() + }) +}) diff --git a/test/integration/server.test.ts b/test/integration/server.test.ts index adfec84..a53a601 100644 --- a/test/integration/server.test.ts +++ b/test/integration/server.test.ts @@ -545,4 +545,48 @@ describe('startServer โ€” integration', () => { await waitForClose(ws2, 3_000).catch(() => undefined) }, ) + + // โ”€โ”€ โ‘ฆ Hook side-channel โ†’ status push (H2, needs real PTY โ€” sandbox-off) โ”€โ”€โ”€ + itPty( + 'โ‘ฆ [needs real PTY (sandbox-off)] POST /hook pushes a status frame to the attached ws (H2)', + async () => { + const ws = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, { + headers: { Origin: `http://127.0.0.1:${port}` }, + }) + await waitForOpen(ws, 3_000) + ws.send(JSON.stringify({ type: 'attach', sessionId: null })) + const attached = (await waitForMessage( + ws, + (m) => + typeof m === 'object' && m !== null && (m as Record)['type'] === 'attached', + 5_000, + )) as Record + const sessionId = attached['sessionId'] as string + + // Arm the status listener BEFORE firing the hook โ€” the server pushes the + // status frame synchronously (before the POST returns 204), so attaching + // the listener after the fetch would race past it. + const statusPromise = waitForMessage( + ws, + (m) => + typeof m === 'object' && m !== null && (m as Record)['type'] === 'status', + 5_000, + ) + + // Simulate a Claude Code PermissionRequest hook firing for this session. + const res = await fetch(`http://127.0.0.1:${port}/hook`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId }, + body: JSON.stringify({ hook_event_name: 'PermissionRequest', tool_name: 'Bash' }), + }) + expect(res.status).toBe(204) + + const status = (await statusPromise) as Record + expect(status['status']).toBe('waiting') + expect(status['detail']).toBe('Bash') + + ws.close() + await waitForClose(ws, 3_000).catch(() => undefined) + }, + ) }) From c81cebdc474f5cc6e720fc4b0e5543f7294132ae Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Wed, 17 Jun 2026 19:00:34 +0200 Subject: [PATCH 05/15] =?UTF-8?q?feat(v0.3):=20H4=20=E2=80=94=20live=20Cla?= =?UTF-8?q?ude=20status=20badge=20+=20notifications=20+=20setup-hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - terminal-session: handle 'status' frame โ†’ claudeStatus + onClaudeStatus - tabs: per-tab Claude glyph (โš™ working / โณ waiting / โœ“ idle); inactive 'waiting' tab gets an amber highlight + a browser notification (permission requested on first tab switch) - scripts/setup-hooks.mjs + 'npm run setup-hooks': idempotently install/remove command hooks in ~/.claude/settings.json (inline curl to $WEBTERM_HOOK_URL with $WEBTERM_SESSION; no-op outside web-terminal); backs up settings.json - Browser-verified end-to-end: PermissionRequestโ†’โณ, PreToolUseโ†’โš™, Stopโ†’โœ“; inactive waiting tab shows amber + glyph. 205 tests green. --- package.json | 3 +- public/style.css | 12 ++++++ public/tabs.ts | 36 ++++++++++++++++++ public/terminal-session.ts | 17 ++++++++- scripts/setup-hooks.mjs | 77 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 scripts/setup-hooks.mjs diff --git a/package.json b/package.json index 826aee5..b90c750 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "dev:web": "esbuild public/main.ts --bundle --format=esm --outdir=public/build --sourcemap --watch", "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.web.json", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "setup-hooks": "node scripts/setup-hooks.mjs" }, "dependencies": { "@xterm/addon-fit": "^0.11.0", diff --git a/public/style.css b/public/style.css index bef601a..8f71f9d 100644 --- a/public/style.css +++ b/public/style.css @@ -202,6 +202,18 @@ html, body { font-weight: 600; } +/* Claude Code activity glyph (H2/H4) */ +.tab-claude { + margin-right: 6px; + font-size: 12px; + flex: none; +} +/* A background tab that needs approval gets an amber outline. */ +.tab.claude-waiting { + background-color: #3a2a10; + box-shadow: inset 0 -2px 0 #d29922; +} + /* Drag-to-reorder feedback. */ .tab.dragging { opacity: 0.4; diff --git a/public/tabs.ts b/public/tabs.ts index 0c13027..5e2a09e 100644 --- a/public/tabs.ts +++ b/public/tabs.ts @@ -40,6 +40,7 @@ export class TabApp { private activeIndex = -1 private editingIndex = -1 private dragIndex = -1 + private notifyAsked = false private readonly paneHost: HTMLElement private readonly tabBar: HTMLElement @@ -135,6 +136,13 @@ export class TabApp { this.refreshTab(entry) }, onStatus: () => this.refreshTab(entry), + onClaudeStatus: (status) => { + this.refreshTab(entry) + // Notify when a background tab needs approval (H2/H4). + if (status === 'waiting' && this.tabs.indexOf(entry) !== this.activeIndex) { + this.notify(entry) + } + }, }) this.paneHost.appendChild(entry.session.el) this.tabs.push(entry) @@ -151,6 +159,7 @@ export class TabApp { activate(i: number): void { if (i < 0 || i >= this.tabs.length) return + this.maybeAskNotify() // first switch is a user gesture โ€” request notif permission this.activeIndex = i const entry = this.tabs[i] if (entry) entry.hasActivity = false // viewing clears the unread dot @@ -218,6 +227,22 @@ export class TabApp { this.tabs[this.activeIndex]?.session.clearSearch() } + /** Ask for notification permission once, on a user gesture (H2/H4). */ + private maybeAskNotify(): void { + if (this.notifyAsked) return + this.notifyAsked = true + if (typeof Notification !== 'undefined' && Notification.permission === 'default') { + void Notification.requestPermission() + } + } + + /** Browser notification for a background tab needing approval. */ + private notify(entry: TabEntry): void { + if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return + const title = this.displayTitle(entry, this.tabs.indexOf(entry)) + new Notification(`Claude needs you โ€” ${title}`, { body: 'Waiting for approval' }) + } + /* โ”€โ”€ rendering โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ /** In-place update of one tab's classes/label/dot โ€” never destroys the DOM. */ @@ -227,12 +252,19 @@ export class TabApp { const idx = this.tabs.indexOf(entry) el.classList.toggle('active', idx === this.activeIndex) el.classList.toggle('unread', entry.hasActivity && idx !== this.activeIndex) + const cs = entry.session.claudeStatus + el.classList.toggle('claude-waiting', cs === 'waiting' && idx !== this.activeIndex) const title = this.displayTitle(entry, idx) el.title = title const dot = el.querySelector('.tab-dot') if (dot) dot.className = `tab-dot dot-${entry.session.status}` const label = el.querySelector('.tab-label') if (label) label.textContent = title + const claude = el.querySelector('.tab-claude') + if (claude) { + claude.textContent = + cs === 'working' ? 'โš™' : cs === 'waiting' ? 'โณ' : cs === 'idle' ? 'โœ“' : '' + } } /** Full rebuild โ€” ONLY for structural changes (add/close/reorder/rename). */ @@ -311,6 +343,10 @@ export class TabApp { }) tabEl.appendChild(label) + const claude = document.createElement('span') + claude.className = 'tab-claude' + tabEl.appendChild(claude) + const close = document.createElement('button') close.className = 'tab-close' close.textContent = 'ร—' diff --git a/public/terminal-session.ts b/public/terminal-session.ts index 1a37ef1..64731c7 100644 --- a/public/terminal-session.ts +++ b/public/terminal-session.ts @@ -12,7 +12,7 @@ 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 type { ClaudeStatus, ClientMessage, ServerMessage } from '../src/types.js' import { folderFromTitle } from './title-util.js' const RESET = '\x1b[0m' @@ -51,6 +51,8 @@ export interface TerminalSessionOpts { onTitle?: (title: string) => void /** Optional: fired when connection status changes (for the tab status dot). */ onStatus?: (status: SessionStatus) => void + /** Optional: fired when Claude Code activity changes (from a hook, H2). */ + onClaudeStatus?: (status: ClaudeStatus, detail?: string) => void } export class TerminalSession { @@ -64,7 +66,9 @@ export class TerminalSession { private readonly onActivity: (() => void) | undefined private readonly onTitle: ((title: string) => void) | undefined private readonly onStatus: ((status: SessionStatus) => void) | undefined + private readonly onClaudeStatus: ((status: ClaudeStatus, detail?: string) => void) | undefined private statusValue: SessionStatus = 'connecting' + private claudeStatusValue: ClaudeStatus = 'unknown' private ws: WebSocket | null = null private sessionId: string | null @@ -83,6 +87,7 @@ export class TerminalSession { this.onActivity = opts.onActivity this.onTitle = opts.onTitle this.onStatus = opts.onStatus + this.onClaudeStatus = opts.onClaudeStatus this.el = document.createElement('div') this.el.className = 'term-pane' @@ -118,6 +123,11 @@ export class TerminalSession { return this.statusValue } + /** Current Claude Code activity (from hooks, H2). */ + get claudeStatus(): ClaudeStatus { + return this.claudeStatusValue + } + private setStatus(s: SessionStatus): void { this.statusValue = s this.onStatus?.(s) @@ -206,6 +216,11 @@ export class TerminalSession { if (this.el.style.display === 'none') this.onActivity?.() break } + case 'status': { + this.claudeStatusValue = msg.status + this.onClaudeStatus?.(msg.status, msg.detail) + break + } case 'exit': { this.setStatus('exited') const reason = msg.reason ? ` (${msg.reason})` : '' diff --git a/scripts/setup-hooks.mjs b/scripts/setup-hooks.mjs new file mode 100644 index 0000000..3fb02ac --- /dev/null +++ b/scripts/setup-hooks.mjs @@ -0,0 +1,77 @@ +#!/usr/bin/env node +/** + * scripts/setup-hooks.mjs โ€” install/remove web-terminal's Claude Code hooks (H2). + * + * Adds `command` hooks to ~/.claude/settings.json that POST each hook event to + * the web-terminal server so it can show live Claude status per tab. The command + * is an inline curl that uses two env vars the server injects into each spawned + * shell: $WEBTERM_HOOK_URL (the server's /hook URL, with the right port) and + * $WEBTERM_SESSION (which tab). Outside web-terminal those vars are unset, so + * curl no-ops (|| true) โ€” the hooks are harmless in normal terminals. + * + * Usage: + * node scripts/setup-hooks.mjs # install (idempotent) + * node scripts/setup-hooks.mjs --remove # uninstall + * + * Backs up settings.json to settings.json.webterm-bak before writing. + */ + +import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync } from 'node:fs' +import { homedir } from 'node:os' +import path from 'node:path' + +const SETTINGS = path.join(homedir(), '.claude', 'settings.json') +const MARKER = 'WEBTERM_HOOK_URL' // identifies our hook entries +const COMMAND = + 'curl -s -X POST "$WEBTERM_HOOK_URL" -H "X-Webterm-Session: $WEBTERM_SESSION"' + + ' -H "Content-Type: application/json" --data-binary @- >/dev/null 2>&1 || true' +// Events we care about (status derives from the event/notification_type server-side). +const EVENTS = ['UserPromptSubmit', 'PreToolUse', 'Notification', 'PermissionRequest', 'Stop', 'SessionEnd'] + +const remove = process.argv.includes('--remove') + +let settings = {} +if (existsSync(SETTINGS)) { + try { + settings = JSON.parse(readFileSync(SETTINGS, 'utf8')) + } catch { + console.error(`${SETTINGS} is not valid JSON โ€” aborting (fix it first).`) + process.exit(1) + } + copyFileSync(SETTINGS, `${SETTINGS}.webterm-bak`) +} + +if (typeof settings.hooks !== 'object' || settings.hooks === null) settings.hooks = {} + +// Strip any existing web-terminal hook groups (makes install idempotent + powers --remove). +for (const ev of Object.keys(settings.hooks)) { + const groups = settings.hooks[ev] + if (!Array.isArray(groups)) continue + settings.hooks[ev] = groups.filter( + (g) => + !( + g && + Array.isArray(g.hooks) && + g.hooks.some((h) => typeof h?.command === 'string' && h.command.includes(MARKER)) + ), + ) + if (settings.hooks[ev].length === 0) delete settings.hooks[ev] +} + +if (!remove) { + for (const ev of EVENTS) { + if (!Array.isArray(settings.hooks[ev])) settings.hooks[ev] = [] + settings.hooks[ev].push({ hooks: [{ type: 'command', command: COMMAND }] }) + } +} + +mkdirSync(path.dirname(SETTINGS), { recursive: true }) +writeFileSync(SETTINGS, `${JSON.stringify(settings, null, 2)}\n`) + +console.log( + remove + ? `Removed web-terminal hooks from ${SETTINGS}` + : `Installed web-terminal hooks into ${SETTINGS} (events: ${EVENTS.join(', ')})`, +) +if (existsSync(`${SETTINGS}.webterm-bak`)) console.log(`Backup: ${SETTINGS}.webterm-bak`) +console.log('Restart any running `claude` sessions for the hooks to take effect.') From 04355f05e9825d338a21fc42fdf089d1c623d6ae Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Wed, 17 Jun 2026 19:01:10 +0200 Subject: [PATCH 06/15] docs: log v0.3 H2/H4 Claude status feature --- docs/PROGRESS_LOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 13753df..7464238 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -121,6 +121,23 @@ - **้—็•™ / ๅพ…ๅŠž**: ๆ— (ๅŽŸๅ‰็ซฏๆ‰“ๅŒ…ๅพ…ๅ†ณ้กนๅทฒ่งฃๅ†ณ,่งไธ‹่กฅ่ฎฐ)ใ€‚ - **commit**: `b126cf0` +### 2026-06-17 ยท v0.3 Step 2 โ€” H2/H4 Claude ็Šถๆ€ๆ„Ÿ็Ÿฅ(headline) + +- **็Šถๆ€**: `[x]` DONE(ๅˆ†ๆ”ฏ `v0.3-cockpit`)ใ€‚**็ซฏๅˆฐ็ซฏ็œŸๆต่งˆๅ™จ้ชŒ่ฏ้€š่ฟ‡**ใ€‚ +- **ๆœบๅˆถ**: Claude Code hooks โ†’ ๆœๅŠก็ซฏ `/hook` ไพงไฟก้“ โ†’ WS `status` ๅธง โ†’ ๆ ‡็ญพ่ง’ๆ ‡ๅฎžๆ—ถๆ›ดๆ–ฐใ€‚ๆžถๆž„ๆผ”่ฟ›:ๆœๅŠก็ซฏ่Žทๅพ— hook ไพงไฟก้“(็ปˆ็ซฏๆตไปๆ˜ฏ byte-shuttle)ใ€‚ +- **H2 ๆœๅŠก็ซฏ**(commit `a411c89`): + - types:`ClaudeStatus` + ServerMessage `status`;Session.claudeStatus;manager.handleHookEventใ€‚ + - session:spawn env ๆณจๅ…ฅ `WEBTERM_SESSION`(ๅ“ชไธชๆ ‡็ญพ)+ `WEBTERM_HOOK_URL`(ๆœฌๆœบ /hook)ใ€‚ + - `src/http/hook.ts` ็บฏๆ˜ ๅฐ„(PreToolUseโ†’workingใ€PermissionRequest/permission_promptโ†’waitingใ€Stop/idle_promptโ†’idle);6 ๅ•ๆต‹ใ€‚ + - server:`POST /hook`(ไป… loopback,express.json 64kb)โ†’ ๆŽจ statusใ€‚**้กบๅธฆไฟฎ็œŸ bug**:`closeIdleConnections()` ่ฎฉ hook keep-alive ็ฉบ้—ฒ่ฟžๆŽฅไธๅ†ๅกไฝ close()/SIGTERMใ€‚ + - ้›†ๆˆ โ‘ฆ(็œŸ PTY):attach โ†’ POST /hook โ†’ ws ๆ”ถๅˆฐ status(ๆณจๆ„ๆต‹่ฏ•่ฆๅœจ fetch ๅ‰ๆŒ‚็›‘ๅฌ,ๆŽจ้€ๆ˜ฏๅŒๆญฅ็š„)ใ€‚ +- **H4 ๅ‰็ซฏ**(commit `c81cebd`): + - terminal-session ๅค„็† status โ†’ claudeStatus + onClaudeStatusใ€‚ + - tabs:ๆฏๆ ‡็ญพ Claude ่ง’ๆ ‡(โš™ๅทฅไฝœ/โณ็ญ‰ๆ‰นๅ‡†/โœ“็ฉบ้—ฒ);**้žๆดปๅŠจ + waiting ๆ ‡็ญพๅŠ ็ฅ็€้ซ˜ไบฎ + ๆต่งˆๅ™จ้€š็Ÿฅ**(้ฆ–ๆฌกๅˆ‡ๆข่ฏทๆฑ‚ๆƒ้™)ใ€‚ + - `scripts/setup-hooks.mjs` + `npm run setup-hooks`:ๅน‚็ญ‰่ฃ…/ๅธ ~/.claude/settings.json ็š„ command hook(ๅ†…่” curl,web-terminal ๅค–ๆ˜ฏ no-op),่‡ชๅŠจๅค‡ไปฝใ€‚ +- **้ชŒ่ฏ**: 205 ๆต‹่ฏ•ๅ…จ็ปฟ;ๆต่งˆๅ™จๅฎžๆต‹ PermissionRequestโ†’โณใ€PreToolUseโ†’โš™ใ€Stopโ†’โœ“;้žๆดปๅŠจ waiting ๆ ‡็ญพ็ฅ็€+โณ(ๆˆชๅ›พ /tmp/v03-status.png)ใ€‚0 console errorใ€‚ +- **commit**: `a411c89`(H2 ๆœๅŠก็ซฏ)ใ€`c81cebd`(H4 ๅ‰็ซฏ+่„šๆœฌ) + ### 2026-06-17 ยท v0.3 Step 1 โ€” ๅ‰็ซฏๅฟซ่ตข(้”ฎๆ /ๆœ็ดข/้“พๆŽฅ/QR/PWA) - **็Šถๆ€**: `[x]` DONE(ๅˆ†ๆ”ฏ `v0.3-cockpit`;็บฏๅ‰็ซฏ,ๆ— ๆœๅŠก็ซฏๆ”นๅŠจ) From 9a6150c355b2723aa5b3ad35fb28d87ed39db163 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Wed, 17 Jun 2026 19:13:07 +0200 Subject: [PATCH 07/15] =?UTF-8?q?feat(v0.3):=20H3=20=E2=80=94=20remote=20a?= =?UTF-8?q?pprove/reject=20(no=20typing)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types/protocol: ClientMessage approve/reject; ServerMessage status.pending - server: POST /hook/permission HELD until the client decides; approve/reject ws messages resolve it with {hookSpecificOutput.decision.behavior allow|deny}; 5-min timeout + release-on-disconnect fall back to Claude's own prompt - manager.handleHookEvent threads pending flag - setup-hooks: PermissionRequest uses the held curl (writes decision to stdout); status events stay fire-and-forget - FE: terminal-session approve()/reject() + pending state; tabs approval banner (Approve/Reject) for the active tab's held request - Tests: protocol approve/reject; integration โ‘ง heldโ†’approveโ†’allow. 207 green. - Browser-verified: banner 'Claude wants to use Bash' โ†’ Approve โ†’ resolves allow. --- public/style.css | 37 +++++++++++++++++ public/tabs.ts | 36 +++++++++++++++++ public/terminal-session.ts | 30 +++++++++++++- scripts/setup-hooks.mjs | 22 +++++++--- src/protocol.ts | 10 ++++- src/server.ts | 71 +++++++++++++++++++++++++++++++-- src/session/manager.ts | 18 ++++++--- src/types.ts | 20 +++++++--- test/integration/server.test.ts | 49 +++++++++++++++++++++++ test/protocol.test.ts | 9 +++++ 10 files changed, 279 insertions(+), 23 deletions(-) diff --git a/public/style.css b/public/style.css index 8f71f9d..e3a16c7 100644 --- a/public/style.css +++ b/public/style.css @@ -214,6 +214,43 @@ html, body { box-shadow: inset 0 -2px 0 #d29922; } +/* Approve/reject banner (H3) โ€” sits just above the key bar. */ +#approvalbar { + position: fixed; + left: 0; + right: 0; + bottom: var(--keybar-h); + z-index: 1050; + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + background: #3a2a10; + border-top: 1px solid #d29922; + color: #ffcf8f; + font-size: 14px; +} +.approval-label { + flex: 1 1 auto; +} +#approvalbar button { + flex: none; + border: none; + border-radius: 5px; + padding: 7px 16px; + cursor: pointer; + font: inherit; + font-weight: 600; +} +.approval-yes { + background: #2ea043; + color: #fff; +} +.approval-no { + background: #c93c37; + color: #fff; +} + /* Drag-to-reorder feedback. */ .tab.dragging { opacity: 0.4; diff --git a/public/tabs.ts b/public/tabs.ts index 5e2a09e..ca44dcd 100644 --- a/public/tabs.ts +++ b/public/tabs.ts @@ -43,13 +43,47 @@ export class TabApp { private notifyAsked = false private readonly paneHost: HTMLElement private readonly tabBar: HTMLElement + private readonly approvalBar: HTMLDivElement constructor(paneHost: HTMLElement, tabBar: HTMLElement) { this.paneHost = paneHost this.tabBar = tabBar + this.approvalBar = document.createElement('div') + this.approvalBar.id = 'approvalbar' + this.approvalBar.style.display = 'none' + document.body.appendChild(this.approvalBar) this.restore() } + /** Show/hide the approve/reject banner for the active tab's held request (H3). */ + private updateApprovalBar(): void { + const session = this.tabs[this.activeIndex]?.session + if (!session || !session.pendingApproval) { + this.approvalBar.style.display = 'none' + return + } + this.approvalBar.replaceChildren() + const label = document.createElement('span') + label.className = 'approval-label' + label.textContent = `Claude wants to use ${session.pendingTool ?? 'a tool'}` + const approve = document.createElement('button') + approve.className = 'approval-yes' + approve.textContent = 'โœ“ Approve' + approve.addEventListener('click', () => { + session.approve() + this.updateApprovalBar() + }) + const reject = document.createElement('button') + reject.className = 'approval-no' + reject.textContent = 'โœ— Reject' + reject.addEventListener('click', () => { + session.reject() + this.updateApprovalBar() + }) + this.approvalBar.append(label, approve, reject) + this.approvalBar.style.display = 'flex' + } + private displayTitle(entry: TabEntry, idx: number): string { return entry.customTitle ?? entry.autoTitle ?? `Term ${idx + 1}` } @@ -138,6 +172,7 @@ export class TabApp { onStatus: () => this.refreshTab(entry), onClaudeStatus: (status) => { this.refreshTab(entry) + this.updateApprovalBar() // Notify when a background tab needs approval (H2/H4). if (status === 'waiting' && this.tabs.indexOf(entry) !== this.activeIndex) { this.notify(entry) @@ -165,6 +200,7 @@ export class TabApp { if (entry) entry.hasActivity = false // viewing clears the unread dot this.tabs.forEach((t, idx) => (idx === i ? t.session.show() : t.session.hide())) this.tabs.forEach((t) => this.refreshTab(t)) // in-place class/text update, no rebuild + this.updateApprovalBar() this.persist() } diff --git a/public/terminal-session.ts b/public/terminal-session.ts index 64731c7..ecdbeb1 100644 --- a/public/terminal-session.ts +++ b/public/terminal-session.ts @@ -69,6 +69,8 @@ export class TerminalSession { private readonly onClaudeStatus: ((status: ClaudeStatus, detail?: string) => void) | undefined private statusValue: SessionStatus = 'connecting' private claudeStatusValue: ClaudeStatus = 'unknown' + private pendingApprovalValue = false + private pendingToolValue: string | undefined = undefined private ws: WebSocket | null = null private sessionId: string | null @@ -128,6 +130,14 @@ export class TerminalSession { return this.claudeStatusValue } + /** Whether a tool approval is held server-side and can be resolved (H3). */ + get pendingApproval(): boolean { + return this.pendingApprovalValue + } + get pendingTool(): string | undefined { + return this.pendingToolValue + } + private setStatus(s: SessionStatus): void { this.statusValue = s this.onStatus?.(s) @@ -218,6 +228,8 @@ export class TerminalSession { } case 'status': { this.claudeStatusValue = msg.status + this.pendingApprovalValue = msg.pending === true + this.pendingToolValue = msg.pending === true ? msg.detail : undefined this.onClaudeStatus?.(msg.status, msg.detail) break } @@ -259,10 +271,24 @@ export class TerminalSession { }, delay) } + private sendMsg(msg: ClientMessage): void { + if (this.ws === null || this.ws.readyState !== WebSocket.OPEN) return + this.ws.send(buildMessage(msg)) + } + /** Send raw bytes as input (used by term.onData and the key bar). */ send(data: string): void { - if (this.ws === null || this.ws.readyState !== WebSocket.OPEN) return - this.ws.send(buildMessage({ type: 'input', data })) + this.sendMsg({ type: 'input', data }) + } + + /** Resolve a held PermissionRequest (H3). */ + approve(): void { + this.pendingApprovalValue = false + this.sendMsg({ type: 'approve' }) + } + reject(): void { + this.pendingApprovalValue = false + this.sendMsg({ type: 'reject' }) } /** Scrollback search (M1). */ diff --git a/scripts/setup-hooks.mjs b/scripts/setup-hooks.mjs index 3fb02ac..8bf2a5e 100644 --- a/scripts/setup-hooks.mjs +++ b/scripts/setup-hooks.mjs @@ -22,11 +22,19 @@ import path from 'node:path' const SETTINGS = path.join(homedir(), '.claude', 'settings.json') const MARKER = 'WEBTERM_HOOK_URL' // identifies our hook entries -const COMMAND = + +// Fire-and-forget status events: POST and discard the response. +const FF_COMMAND = 'curl -s -X POST "$WEBTERM_HOOK_URL" -H "X-Webterm-Session: $WEBTERM_SESSION"' + ' -H "Content-Type: application/json" --data-binary @- >/dev/null 2>&1 || true' -// Events we care about (status derives from the event/notification_type server-side). -const EVENTS = ['UserPromptSubmit', 'PreToolUse', 'Notification', 'PermissionRequest', 'Stop', 'SessionEnd'] +const FF_EVENTS = ['UserPromptSubmit', 'PreToolUse', 'Notification', 'Stop', 'SessionEnd'] + +// PermissionRequest is HELD: curl waits for the server's response (the decision +// JSON, produced when you tap Approve/Reject) and writes it to stdout for Claude. +// Empty/failed (outside web-terminal, or timeout) โ†’ Claude shows its own prompt. +const PERM_COMMAND = + 'curl -s --max-time 350 -X POST "${WEBTERM_HOOK_URL}/permission"' + + ' -H "X-Webterm-Session: $WEBTERM_SESSION" -H "Content-Type: application/json" --data-binary @-' const remove = process.argv.includes('--remove') @@ -59,10 +67,12 @@ for (const ev of Object.keys(settings.hooks)) { } if (!remove) { - for (const ev of EVENTS) { + for (const ev of FF_EVENTS) { if (!Array.isArray(settings.hooks[ev])) settings.hooks[ev] = [] - settings.hooks[ev].push({ hooks: [{ type: 'command', command: COMMAND }] }) + settings.hooks[ev].push({ hooks: [{ type: 'command', command: FF_COMMAND }] }) } + if (!Array.isArray(settings.hooks['PermissionRequest'])) settings.hooks['PermissionRequest'] = [] + settings.hooks['PermissionRequest'].push({ hooks: [{ type: 'command', command: PERM_COMMAND }] }) } mkdirSync(path.dirname(SETTINGS), { recursive: true }) @@ -71,7 +81,7 @@ writeFileSync(SETTINGS, `${JSON.stringify(settings, null, 2)}\n`) console.log( remove ? `Removed web-terminal hooks from ${SETTINGS}` - : `Installed web-terminal hooks into ${SETTINGS} (events: ${EVENTS.join(', ')})`, + : `Installed web-terminal hooks into ${SETTINGS} (events: ${[...FF_EVENTS, 'PermissionRequest'].join(', ')})`, ) if (existsSync(`${SETTINGS}.webterm-bak`)) console.log(`Backup: ${SETTINGS}.webterm-bak`) console.log('Restart any running `claude` sessions for the hooks to take effect.') diff --git a/src/protocol.ts b/src/protocol.ts index 8361fb2..83429e2 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -24,7 +24,7 @@ export const SESSION_ID_RE = // โ”€โ”€โ”€ Type whitelist โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -const ALLOWED_TYPES = new Set(['attach', 'input', 'resize']) +const ALLOWED_TYPES = new Set(['attach', 'input', 'resize', 'approve', 'reject']) // โ”€โ”€โ”€ parseClientMessage โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -73,6 +73,14 @@ export function parseClientMessage(raw: string): ParseResult { return validateInput(obj) } + // approve/reject (H3) carry no payload. + if (type === 'approve') { + return { ok: true, message: { type: 'approve' } } + } + if (type === 'reject') { + return { ok: true, message: { type: 'reject' } } + } + // type === 'attach' return validateAttach(obj) } diff --git a/src/server.ts b/src/server.ts index 4080949..9928cd3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -28,6 +28,7 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import express from 'express' +import type { Response } from 'express' import { WebSocketServer } from 'ws' import type { WebSocket as WsWebSocket } from 'ws' import type { IncomingMessage } from 'node:http' @@ -46,6 +47,20 @@ import { WS_OPEN } from './types.js' const DEFAULT_COLS = 80 const DEFAULT_ROWS = 24 +// H3: how long the server holds a PermissionRequest before falling back to +// Claude's own interactive prompt (so it never hangs if nobody responds). +const PERM_TIMEOUT_MS = 5 * 60_000 + +/** The decision JSON a PermissionRequest command hook writes to stdout. */ +function permDecision(behavior: 'allow' | 'deny'): unknown { + return { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior } } } +} + +/** True for loopback peers (hooks always run on the host). */ +function isLoopback(ip: string): boolean { + return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1' +} + // โ”€โ”€ helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ /** Derive __dirname equivalent in ESM. */ @@ -70,6 +85,18 @@ function safeSend(ws: WsWebSocket, data: string): void { export function startServer(cfg: Config): { close(): Promise } { const manager = createSessionManager(cfg) + // H3: held PermissionRequest responses, keyed by sessionId. The express `res` + // is parked here until the client approves/rejects (or it times out). + const pendingApprovals = new Map }>() + + function resolvePending(sessionId: string, decision: unknown): void { + const p = pendingApprovals.get(sessionId) + if (p === undefined) return + clearTimeout(p.timer) + pendingApprovals.delete(sessionId) + p.res.json(decision) + } + // โ”€โ”€ Express static hosting โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const app = express() @@ -82,9 +109,7 @@ export function startServer(cfg: Config): { close(): Promise } { // Hooks running inside a spawned shell POST status here (loopback only โ€” the // shell runs on the host). sessionId arrives in the X-Webterm-Session header. app.post('/hook', express.json({ limit: '64kb' }), (req, res) => { - const ip = req.socket.remoteAddress ?? '' - const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1' - if (!isLoopback) { + if (!isLoopback(req.socket.remoteAddress ?? '')) { res.status(403).end() return } @@ -97,6 +122,36 @@ export function startServer(cfg: Config): { close(): Promise } { res.status(204).end() }) + // H3: PermissionRequest hook โ€” held until the client approves/rejects. The + // hook's curl writes our response (the decision JSON) to stdout for Claude. + app.post('/hook/permission', express.json({ limit: '64kb' }), (req, res) => { + if (!isLoopback(req.socket.remoteAddress ?? '')) { + res.status(403).end() + return + } + const sessionId = req.header('x-webterm-session') + const body = (req.body ?? {}) as Record + const tool = typeof body['tool_name'] === 'string' ? body['tool_name'] : undefined + const session = typeof sessionId === 'string' ? manager.get(sessionId) : undefined + + // Nobody watching this tab (no session / not attached) โ†’ let Claude prompt itself. + if (sessionId === undefined || session === undefined || session.attachedWs === null) { + res.json({}) + return + } + + resolvePending(sessionId, {}) // clear any stale hold for this session + const timer = setTimeout(() => { + pendingApprovals.delete(sessionId) + res.json({}) // timeout โ†’ fall back to Claude's interactive prompt + manager.handleHookEvent(sessionId, 'idle') + }, PERM_TIMEOUT_MS) + pendingApprovals.set(sessionId, { res, timer }) + + // Show the approve/reject affordance on the client. + manager.handleHookEvent(sessionId, 'waiting', tool, true) + }) + // โ”€โ”€ HTTP server โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const httpServer = createServer(app) @@ -202,6 +257,13 @@ export function startServer(cfg: Config): { close(): Promise } { writeInput(session, msg.data) } else if (msg.type === 'resize') { resize(session, msg.cols, msg.rows) + } else if (msg.type === 'approve') { + // H3: resolve the held PermissionRequest with allow. + resolvePending(boundSessionId, permDecision('allow')) + manager.handleHookEvent(boundSessionId, 'working', undefined, false) + } else if (msg.type === 'reject') { + resolvePending(boundSessionId, permDecision('deny')) + manager.handleHookEvent(boundSessionId, 'working', undefined, false) } else if (msg.type === 'attach') { // Re-attach after first bind is not expected; log and ignore. console.error('[server] unexpected re-attach on established connection') @@ -214,6 +276,9 @@ export function startServer(cfg: Config): { close(): Promise } { ws.on('close', () => { if (boundSessionId === null) return + // H3: release any held approval so the hook isn't stuck for the full timeout. + resolvePending(boundSessionId, {}) + const session = manager.get(boundSessionId) if (session === undefined) return diff --git a/src/session/manager.ts b/src/session/manager.ts index 1868c1e..6e84504 100644 --- a/src/session/manager.ts +++ b/src/session/manager.ts @@ -130,14 +130,22 @@ export function createSessionManager(cfg: Config): SessionManager { * H2: a Claude Code hook reported activity for `sessionId`. Record it and push * a `status` frame to the attached ws (no-op if the session is gone/detached). */ - function handleHookEvent(sessionId: string, status: ClaudeStatus, detail?: string): void { + function handleHookEvent( + sessionId: string, + status: ClaudeStatus, + detail?: string, + pending?: boolean, + ): void { const session = sessions.get(sessionId); if (session === undefined) return; session.claudeStatus = status; - sendIfOpen( - session.attachedWs, - detail !== undefined ? { type: 'status', status, detail } : { type: 'status', status }, - ); + const msg: { type: 'status'; status: ClaudeStatus; detail?: string; pending?: boolean } = { + type: 'status', + status, + }; + if (detail !== undefined) msg.detail = detail; + if (pending) msg.pending = true; + sendIfOpen(session.attachedWs, msg); } /** diff --git a/src/types.ts b/src/types.ts index f07d2aa..4969c20 100644 --- a/src/types.ts +++ b/src/types.ts @@ -38,23 +38,26 @@ export type EnvLike = Readonly>; /* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ protocol (ยง3.2) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ -/** client โ†’ server */ +/** client โ†’ server. approve/reject (H3) resolve a held PermissionRequest. */ export type ClientMessage = | { type: 'attach'; sessionId: string | null } | { type: 'input'; data: string } - | { type: 'resize'; cols: number; rows: number }; + | { type: 'resize'; cols: number; rows: number } + | { type: 'approve' } + | { type: 'reject' }; /** Claude Code activity, derived from hooks (H2). 'unknown' = no hook signal yet. */ export type ClaudeStatus = 'working' | 'waiting' | 'idle' | 'unknown'; /** server โ†’ client. exit.code = shell code; -1 when spawn never succeeded (M4). * exit.reason optional normally, REQUIRED on spawn failure / abnormal exit. - * status (H2) = Claude Code activity pushed from a hook side-channel. */ + * status (H2/H3) = Claude Code activity; `pending` true when a tool approval + * is held server-side and the client can approve/reject it. */ export type ServerMessage = | { type: 'attached'; sessionId: string } | { type: 'output'; data: string } | { type: 'exit'; code: number; reason?: string } - | { type: 'status'; status: ClaudeStatus; detail?: string }; + | { type: 'status'; status: ClaudeStatus; detail?: string; pending?: boolean }; /** parseClientMessage result โ€” never throws; errors flow here (ยง5.3). */ export type ParseResult = @@ -163,8 +166,13 @@ export interface Session { export interface SessionManager { handleAttach(ws: WebSocketLike, sessionId: string | null, dims: Dims, now: number): Session; get(id: string): Session | undefined; - /** Set a session's Claude status (from a hook) and push it to the attached ws (H2). */ - handleHookEvent(sessionId: string, status: ClaudeStatus, detail?: string): void; + /** Set a session's Claude status (from a hook) and push it to the attached ws (H2/H3). */ + handleHookEvent( + sessionId: string, + status: ClaudeStatus, + detail?: string, + pending?: boolean, + ): void; /** reclaim when now - max(detachedAt, lastOutputAt) > idleTtlMs (M3). Returns count. */ reapIdle(now: number): number; shutdown(): void; diff --git a/test/integration/server.test.ts b/test/integration/server.test.ts index a53a601..81553b7 100644 --- a/test/integration/server.test.ts +++ b/test/integration/server.test.ts @@ -589,4 +589,53 @@ describe('startServer โ€” integration', () => { await waitForClose(ws, 3_000).catch(() => undefined) }, ) + + // โ”€โ”€ โ‘ง Held PermissionRequest resolves on approve (H3, real PTY โ€” sandbox-off) โ”€ + itPty( + 'โ‘ง [needs real PTY (sandbox-off)] POST /hook/permission is held until approve โ†’ allow decision', + async () => { + const ws = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, { + headers: { Origin: `http://127.0.0.1:${port}` }, + }) + await waitForOpen(ws, 3_000) + ws.send(JSON.stringify({ type: 'attach', sessionId: null })) + const attached = (await waitForMessage( + ws, + (m) => + typeof m === 'object' && m !== null && (m as Record)['type'] === 'attached', + 5_000, + )) as Record + const sessionId = attached['sessionId'] as string + + // Arm the pending-status listener before firing (push is synchronous). + const pendingPromise = waitForMessage( + ws, + (m) => + typeof m === 'object' && m !== null && (m as Record)['pending'] === true, + 5_000, + ) + + // Fire the PermissionRequest hook โ€” this POST is HELD by the server until + // we approve. Do NOT await it yet. + const permPromise = fetch(`http://127.0.0.1:${port}/hook/permission`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId }, + body: JSON.stringify({ tool_name: 'Bash' }), + }) + + const pending = (await pendingPromise) as Record + expect(pending['status']).toBe('waiting') + expect(pending['pending']).toBe(true) + + // Approve over the ws โ†’ the held POST should resolve with an allow decision. + ws.send(JSON.stringify({ type: 'approve' })) + const decision = (await (await permPromise).json()) as { + hookSpecificOutput?: { decision?: { behavior?: string } } + } + expect(decision.hookSpecificOutput?.decision?.behavior).toBe('allow') + + ws.close() + await waitForClose(ws, 3_000).catch(() => undefined) + }, + ) }) diff --git a/test/protocol.test.ts b/test/protocol.test.ts index 3ff3d0d..d9c5392 100644 --- a/test/protocol.test.ts +++ b/test/protocol.test.ts @@ -107,6 +107,15 @@ describe('parseClientMessage โ€” valid messages', () => { expect(result.message).toEqual({ type: 'resize', cols: 120, rows: 40 }) } }) + + it('parses approve / reject (H3, no payload)', () => { + const a = parseClientMessage(JSON.stringify({ type: 'approve' })) + expect(a.ok).toBe(true) + if (a.ok) expect(a.message).toEqual({ type: 'approve' }) + const r = parseClientMessage(JSON.stringify({ type: 'reject' })) + expect(r.ok).toBe(true) + if (r.ok) expect(r.message).toEqual({ type: 'reject' }) + }) }) // โ”€โ”€โ”€ parseClientMessage โ€” invalid / error paths โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ From af2a0879e3ce420359366e92afe0eaaa1440e694 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Wed, 17 Jun 2026 19:13:38 +0200 Subject: [PATCH 08/15] docs: log v0.3 H3 remote approve/reject --- docs/PROGRESS_LOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 7464238..50d3fab 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -121,6 +121,15 @@ - **้—็•™ / ๅพ…ๅŠž**: ๆ— (ๅŽŸๅ‰็ซฏๆ‰“ๅŒ…ๅพ…ๅ†ณ้กนๅทฒ่งฃๅ†ณ,่งไธ‹่กฅ่ฎฐ)ใ€‚ - **commit**: `b126cf0` +### 2026-06-17 ยท v0.3 Step 3 โ€” H3 ่ฟœ็จ‹ๆ‰นๅ‡†/ๆ‹’็ป(ไธๆ‰“ๅญ—) + +- **็Šถๆ€**: `[x]` DONEใ€‚**็ซฏๅˆฐ็ซฏ็œŸๆต่งˆๅ™จ้ชŒ่ฏ้€š่ฟ‡**(commit `9a6150c`)ใ€‚ +- **ๆœบๅˆถ**: `PermissionRequest` hook ็”จ curl POST ๅˆฐ `/hook/permission` ๅนถ**้˜ปๅกž็ญ‰ๅ“ๅบ”**;ๆœๅŠก็ซฏ้•ฟๆŒ‚่ฏฅ HTTP ๅ“ๅบ”,ๆŽจ `status{waiting,pending:true}` ็ป™ๅ‰็ซฏ โ†’ ๅ‰็ซฏๆ˜พ็คบ **[โœ“Approve][โœ—Reject]** ๆจชๅน… โ†’ ็‚นๆŒ‰ๅ‘ ws `approve`/`reject` โ†’ ๆœๅŠก็ซฏ็”จ `{hookSpecificOutput.decision.behavior:allow|deny}` ๅ›žๅบ”่ขซๆŒ‚็š„่ฏทๆฑ‚ โ†’ curl stdout ๆŠŠๅ†ณๅฎšไบค็ป™ Claudeใ€‚**ๅ…จ็จ‹ไธๆ‰“ๅญ—**ใ€‚ +- **ๅ…œๅบ•**: 5 ๅˆ†้’Ÿ่ถ…ๆ—ถ / ๆ–ญๅผ€ๅณ้‡Šๆ”พ โ†’ ๅ›ž็ฉบ `{}`,Claude ๅ›ž้€€ๅˆฐ่‡ชๅทฑ็š„ไบคไบ’ๆ็คบ;web-terminal ๅค– curl ๆ—  URL โ†’ no-opใ€‚ +- **ๆ”นๅŠจ**: types(ClientMessage approve/rejectใ€status.pending)ใ€protocol(็™ฝๅๅ•+่งฃๆž)ใ€manager(pending ้€ไผ )ใ€server(`/hook/permission` ้•ฟๆŒ‚ + approve/reject ่ทฏ็”ฑ + resolvePending)ใ€setup-hooks(PermissionRequest ็”จ held ๅ‘ฝไปค)ใ€FE(terminal-session approve()/reject()+pendingใ€tabs ๆ‰นๅ‡†ๆจชๅน…)ใ€‚ +- **้ชŒ่ฏ**: 207 ๆต‹่ฏ•ๅ…จ็ปฟ(ๅ่ฎฎ approve/reject ๅ•ๆต‹ + ้›†ๆˆ โ‘ง heldโ†’approveโ†’allow);ๆต่งˆๅ™จๅฎžๆต‹ๆจชๅน…"Claude wants to use Bash"โ†’Approveโ†’held ่ฏทๆฑ‚ๅพ—ๅˆฐ allowโ†’ๆจชๅน…ๆถˆๅคฑ(ๆˆชๅ›พ /tmp/v03-approve.png)ใ€‚ +- **commit**: `9a6150c` + ### 2026-06-17 ยท v0.3 Step 2 โ€” H2/H4 Claude ็Šถๆ€ๆ„Ÿ็Ÿฅ(headline) - **็Šถๆ€**: `[x]` DONE(ๅˆ†ๆ”ฏ `v0.3-cockpit`)ใ€‚**็ซฏๅˆฐ็ซฏ็œŸๆต่งˆๅ™จ้ชŒ่ฏ้€š่ฟ‡**ใ€‚ From 9099f73534750eb94c6dea4211e414f1df978d0f Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Wed, 17 Jun 2026 19:48:39 +0200 Subject: [PATCH 09/15] =?UTF-8?q?feat(v0.3):=20H1=20=E2=80=94=20tmux=20kee?= =?UTF-8?q?palive=20(sessions=20survive=20a=20server=20restart)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/session/tmux.ts: sync tmux CLI wrappers (available/has/kill), best-effort - config: useTmux from USE_TMUX env (1/0/autoโ†’detect tmux on PATH) - session: when useTmux, spawn 'tmux new-session -A -s web_ ' (the node-pty proc is a tmux CLIENT); createSession takes an optional id for re-attach; Session.tmuxName; kill() runs tmux kill-session - manager: handleAttach re-attaches to a surviving 'web_' tmux session after a restart (Case 3.5); shutdown kills only the client pty for tmux sessions so the shell keeps running - tests: USE_TMUX:0 in shared integration cfg (no tmux leakage); unit CFGs useTmux:false; integration 'H1' (real tmux): set var โ†’ restart server โ†’ re-attach โ†’ var survived. 208 tests green. --- src/config.ts | 12 +++++ src/session/manager.ts | 24 ++++++++- src/session/session.ts | 28 +++++++--- src/session/tmux.ts | 46 +++++++++++++++++ src/types.ts | 3 ++ test/integration/server.test.ts | 92 +++++++++++++++++++++++++++++++++ test/manager.test.ts | 1 + test/session.test.ts | 1 + 8 files changed, 198 insertions(+), 9 deletions(-) create mode 100644 src/session/tmux.ts diff --git a/src/config.ts b/src/config.ts index 3708fef..1700a5b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,6 +8,15 @@ import os from 'node:os' import type { Config, EnvLike } from './types.js' +import { tmuxAvailable } from './session/tmux.js' + +/** USE_TMUX: '1'/'true'/'on' โ†’ on, '0'/'false'/'off' โ†’ off, else auto-detect tmux. */ +function resolveUseTmux(raw: string | undefined): boolean { + const v = raw?.trim().toLowerCase() + if (v === '1' || v === 'true' || v === 'on') return true + if (v === '0' || v === 'false' || v === 'off') return false + return tmuxAvailable() // unset / 'auto' +} // โ”€โ”€ constants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -141,6 +150,8 @@ export function loadConfig(env: EnvLike): Config { const wsPath = env['WS_PATH'] ?? DEFAULT_WS_PATH + const useTmux = resolveUseTmux(env['USE_TMUX']) + const allowedOrigins = deriveAllowedOrigins(port, env['ALLOWED_ORIGINS']) return Object.freeze({ @@ -152,6 +163,7 @@ export function loadConfig(env: EnvLike): Config { scrollbackBytes, maxPayloadBytes, wsPath, + useTmux, allowedOrigins, } satisfies Config) } diff --git a/src/session/manager.ts b/src/session/manager.ts index 6e84504..82d341f 100644 --- a/src/session/manager.ts +++ b/src/session/manager.ts @@ -33,6 +33,7 @@ import type { import { WS_OPEN } from '../types.js'; import { serialize } from '../protocol.js'; import { createSession, attachWs, kill } from './session.js'; +import { hasSession, tmuxName } from './tmux.js'; /** * Send a serialised ServerMessage to `ws` only when it is OPEN (M5). @@ -113,6 +114,17 @@ export function createSessionManager(cfg: Config): SessionManager { return existing; } + // โ”€โ”€ Case 3.5 (H1): not in our table, but a tmux session survived a restart โ”€ + // Re-attach with the SAME id so `tmux new-session -A -s web_` reconnects + // to the still-running shell instead of spawning a fresh one. + if (cfg.useTmux && hasSession(tmuxName(sessionId))) { + const revived = createSession(cfg, dims, now, onSessionExit, sessionId); + const kickedRevived = attachWs(revived, ws); + if (kickedRevived !== null) kickedRevived.close(); + sessions = new Map(sessions).set(revived.meta.id, revived); + return revived; + } + // โ”€โ”€ Case 4: session not found โ†’ create a new one โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // M4: createSession may throw. Do NOT catch here. const session = createSession(cfg, dims, now, onSessionExit); @@ -180,10 +192,18 @@ export function createSessionManager(cfg: Config): SessionManager { return count; } - /** Kill every live session and clear the table (called on SIGINT/SIGTERM). */ + /** + * Called on SIGINT/SIGTERM/close. For non-tmux sessions, kill the PTY. For + * tmux sessions (H1), kill only the client pty โ€” the tmux server keeps the + * shell alive so it survives the restart and can be re-attached. + */ function shutdown(): void { for (const session of sessions.values()) { - kill(session); + if (session.tmuxName !== null) { + session.pty.kill(); // detach client; tmux keeps the shell + } else { + kill(session); + } } sessions = new Map(); } diff --git a/src/session/session.ts b/src/session/session.ts index c98feda..6bfcbb4 100644 --- a/src/session/session.ts +++ b/src/session/session.ts @@ -37,6 +37,7 @@ import type { import { WS_OPEN } from '../types.js'; import { serialize } from '../protocol.js'; import { createRingBuffer } from './ring-buffer.js'; +import { tmuxName, killSession } from './tmux.js'; /** Send a server message to `ws` only if it is OPEN (M5). Never throws on a * closed socket; forwarding to a dead ws is simply a no-op. */ @@ -57,14 +58,21 @@ export function createSession( dims: Dims, now: number, onExit: (session: Session) => void, + // H1: pass an existing id to RE-ATTACH to a surviving tmux session (e.g. after + // a server restart). Default = a fresh id for a brand-new session. + id: string = randomUUID(), ): Session { - // Generate the id first so it can be injected into the shell env: Claude Code - // hooks running inside this shell read $WEBTERM_SESSION to tell us which tab - // an event belongs to, and POST to $WEBTERM_HOOK_URL (H2). - const id = randomUUID(); + // The id is injected into the shell env so Claude Code hooks know which tab an + // event belongs to ($WEBTERM_SESSION) and where to POST ($WEBTERM_HOOK_URL, H2). + const tName = cfg.useTmux ? tmuxName(id) : null; - // M4: let a spawn failure (e.g. missing shell) propagate synchronously. - const pty: IPty = spawn(cfg.shellPath, [], { + // H1: under tmux, the node-pty process is a tmux CLIENT; `new-session -A` + // attaches to web_ if it exists (restart survival) or creates it. + const file = tName !== null ? 'tmux' : cfg.shellPath; + const args = tName !== null ? ['new-session', '-A', '-s', tName, cfg.shellPath] : []; + + // M4: let a spawn failure (e.g. missing shell / tmux) propagate synchronously. + const pty: IPty = spawn(file, args, { name: 'xterm-256color', cols: dims.cols, rows: dims.rows, @@ -91,6 +99,7 @@ export function createSession( exitedAt: null, exitCode: null, claudeStatus: 'unknown', + tmuxName: tName, pty, }; @@ -149,7 +158,12 @@ export function resize(session: Session, cols: number, rows: number): void { session.pty.resize(cols, rows); } -/** Kill the underlying PTY (process exit / idle reclaim). */ +/** + * Kill the session (idle reclaim). Under tmux this ends the actual shell via + * `tmux kill-session` (H1); killing only the client pty would leave the tmux + * session running. Always also kills the client pty. + */ export function kill(session: Session): void { + if (session.tmuxName !== null) killSession(session.tmuxName); session.pty.kill(); } diff --git a/src/session/tmux.ts b/src/session/tmux.ts new file mode 100644 index 0000000..451120f --- /dev/null +++ b/src/session/tmux.ts @@ -0,0 +1,46 @@ +/** + * src/session/tmux.ts (H1) โ€” thin synchronous wrappers around the tmux CLI. + * + * Used only when cfg.useTmux is on. Running the shell inside a tmux session + * lets it survive a server/host restart: the node-pty process is just a tmux + * *client*; killing it detaches, and the tmux server keeps the shell alive. + * + * Every call is best-effort and never throws (tmux missing / session gone are + * treated as "false"/no-op). + */ + +import { execFileSync } from 'node:child_process' + +/** Is the tmux binary available on PATH? */ +export function tmuxAvailable(): boolean { + try { + execFileSync('tmux', ['-V'], { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +/** tmux session name for a web-terminal session id (UUIDs are tmux-safe). */ +export function tmuxName(sessionId: string): string { + return `web_${sessionId}` +} + +/** Does a tmux session with this name already exist (e.g. after a restart)? */ +export function hasSession(name: string): boolean { + try { + execFileSync('tmux', ['has-session', '-t', name], { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +/** Kill a tmux session (ends the shell). No-op if it's already gone. */ +export function killSession(name: string): void { + try { + execFileSync('tmux', ['kill-session', '-t', name], { stdio: 'ignore' }) + } catch { + // already gone โ€” fine + } +} diff --git a/src/types.ts b/src/types.ts index 4969c20..5cb01ba 100644 --- a/src/types.ts +++ b/src/types.ts @@ -27,6 +27,7 @@ export interface Config { readonly scrollbackBytes: number; // ring buffer capacity, default 2MB readonly maxPayloadBytes: number; // max WS frame bytes, default 1MB (L5) readonly wsPath: string; // WS upgrade path, default '/term' (L3; invariant 8) + readonly useTmux: boolean; // H1: spawn the shell inside tmux so it survives a server restart readonly allowedOrigins: readonly string[]; // derived from NIC IPs, NOT bindHost (M1) } @@ -149,6 +150,8 @@ export interface Session { exitCode: number | null; /** Claude Code activity from hooks (H2); updated by manager.handleHookEvent. */ claudeStatus: ClaudeStatus; + /** tmux session name (H1) when running under tmux, else null. */ + readonly tmuxName: string | null; readonly pty: IPty; } diff --git a/test/integration/server.test.ts b/test/integration/server.test.ts index 81553b7..f8eb4bc 100644 --- a/test/integration/server.test.ts +++ b/test/integration/server.test.ts @@ -25,6 +25,7 @@ */ import net from 'node:net' +import { execFileSync } from 'node:child_process' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import WebSocket from 'ws' @@ -49,6 +50,19 @@ const PTY_AVAILABLE = (() => { })() const itPty = PTY_AVAILABLE ? it : it.skip +/** H1 tmux tests need real PTY + the tmux binary. */ +const TMUX_OK = + PTY_AVAILABLE && + (() => { + try { + execFileSync('tmux', ['-V'], { stdio: 'ignore' }) + return true + } catch { + return false + } + })() +const itTmux = TMUX_OK ? it : it.skip + // โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ /** Pick a free port on 127.0.0.1 by briefly binding to port 0. */ @@ -88,6 +102,7 @@ function makeTestConfig(port: number, shellPath: string, maxPayloadBytes = 512 * // Keep idle TTL tiny so stray sessions don't linger. IDLE_TTL: '86400', MAX_PAYLOAD_BYTES: String(maxPayloadBytes), + USE_TMUX: '0', // default off so most cases don't spawn/leak tmux sessions }) } @@ -638,4 +653,81 @@ describe('startServer โ€” integration', () => { await waitForClose(ws, 3_000).catch(() => undefined) }, ) + + // โ”€โ”€ H1: tmux keepalive โ€” a shell survives a full server restart โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + itTmux( + 'H1 [needs tmux + real PTY] shell state survives a server restart', + async () => { + const delay = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)) + const isAttached = (m: unknown): boolean => + typeof m === 'object' && m !== null && (m as Record)['type'] === 'attached' + + const tport = await getFreePort() + const tcfg = loadConfig({ + PORT: String(tport), + BIND_HOST: '127.0.0.1', + SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh', + ALLOWED_ORIGINS: `http://127.0.0.1:${tport}`, + IDLE_TTL: '86400', + USE_TMUX: '1', + }) + + let sid = '' + let srv = startServer(tcfg) + await delay(150) + try { + // Attach and set a shell variable inside the tmux shell. + const ws1 = new WebSocket(`ws://127.0.0.1:${tport}/term`, { + headers: { Origin: `http://127.0.0.1:${tport}` }, + }) + await waitForOpen(ws1, 3_000) + ws1.send(JSON.stringify({ type: 'attach', sessionId: null })) + const att = (await waitForMessage(ws1, isAttached, 5_000)) as Record + sid = att['sessionId'] as string + await delay(500) + ws1.send(JSON.stringify({ type: 'input', data: 'WEBTERM_MARK=tmuxlives\r' })) + await delay(500) + ws1.close() + await waitForClose(ws1, 3_000).catch(() => undefined) + + // Restart the server โ€” shutdown must keep the tmux session alive (H1). + await srv.close() + srv = startServer(tcfg) + await delay(150) + + // Reconnect with the SAME sessionId โ†’ re-attach to the surviving shell. + const ws2 = new WebSocket(`ws://127.0.0.1:${tport}/term`, { + headers: { Origin: `http://127.0.0.1:${tport}` }, + }) + await waitForOpen(ws2, 3_000) + const out: string[] = [] + ws2.on('message', (raw) => { + try { + const m = JSON.parse(raw.toString('utf8')) as Record + if (m['type'] === 'output' && typeof m['data'] === 'string') out.push(m['data']) + } catch { + /* ignore */ + } + }) + ws2.send(JSON.stringify({ type: 'attach', sessionId: sid })) + await delay(400) + ws2.send(JSON.stringify({ type: 'input', data: 'echo MARK-$WEBTERM_MARK\r' })) + await delay(900) + // The variable set before the restart is still set โ†’ same shell survived. + expect(out.join('')).toContain('MARK-tmuxlives') + ws2.close() + await waitForClose(ws2, 3_000).catch(() => undefined) + } finally { + await srv.close() + if (sid !== '') { + try { + execFileSync('tmux', ['kill-session', '-t', `web_${sid}`], { stdio: 'ignore' }) + } catch { + /* already gone */ + } + } + } + }, + 20_000, + ) }) diff --git a/test/manager.test.ts b/test/manager.test.ts index f0c1383..d5f5a89 100644 --- a/test/manager.test.ts +++ b/test/manager.test.ts @@ -46,6 +46,7 @@ const CFG: Config = { scrollbackBytes: 2 * 1024 * 1024, maxPayloadBytes: 1024 * 1024, wsPath: '/term', + useTmux: false, allowedOrigins: [], }; diff --git a/test/session.test.ts b/test/session.test.ts index e93ed46..70f990b 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -48,6 +48,7 @@ const CFG: Config = { scrollbackBytes: 2 * 1024 * 1024, maxPayloadBytes: 1024 * 1024, wsPath: '/term', + useTmux: false, allowedOrigins: [], }; From dcb1bf48450ba4500fe8e9797733911385af9e35 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Wed, 17 Jun 2026 19:49:30 +0200 Subject: [PATCH 10/15] docs: log v0.3 H1 tmux keepalive --- docs/PROGRESS_LOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 50d3fab..807b856 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -121,6 +121,15 @@ - **้—็•™ / ๅพ…ๅŠž**: ๆ— (ๅŽŸๅ‰็ซฏๆ‰“ๅŒ…ๅพ…ๅ†ณ้กนๅทฒ่งฃๅ†ณ,่งไธ‹่กฅ่ฎฐ)ใ€‚ - **commit**: `b126cf0` +### 2026-06-17 ยท v0.3 Step 4 โ€” H1 tmux ไฟๆดป(ๆœๅŠก้‡ๅฏไธไธขไผš่ฏ) + +- **็Šถๆ€**: `[x]` DONEใ€‚**็œŸ tmux ้›†ๆˆๆต‹่ฏ•้ชŒ่ฏ้€š่ฟ‡**(commit `9099f73`)ใ€‚**่‡ณๆญค H1โ€“H4 ๅ…จ้ƒจ้ซ˜ไปทๅ€ผๅŠŸ่ƒฝๅฎŒๆˆใ€‚** +- **ๆœบๅˆถ**: useTmux ๆ—ถๆŠŠ shell ่ท‘่ฟ› `tmux new-session -A -s web_`;node-pty ่ฟ›็จ‹ๅชๆ˜ฏ tmux **ๅฎขๆˆท็ซฏ**ใ€‚ๆœๅŠก้‡ๅฏๅŽ,ๅฎขๆˆท็ซฏๅธฆๅŒ sessionId ้‡่ฟž โ†’ manager ๅ‘็Žฐ `web_` tmux ไผš่ฏไปๅœจ โ†’ ็”จๅŒ id ้‡ๅปบๅฎขๆˆท็ซฏ(`-A` ้™„็€)โ†’ ๆŽฅๅ›žๅŽŸ shellใ€‚ +- **ๆ”นๅŠจ**: ๆ–ฐๅขž `src/session/tmux.ts`(ๅŒๆญฅ CLI ๅฐ่ฃ… available/has/kill,best-effort);config `useTmux`(env `USE_TMUX` 1/0/autoโ†’ๆฃ€ๆต‹ PATH);session createSession ๅŠ ๅฏ้€‰ id(้‡่ฟž็”จ)+ tmux ๅˆ†ๆ”ฏ spawn + `Session.tmuxName` + kill ่ตฐ `tmux kill-session`;manager handleAttach ๅŠ  Case 3.5(้‡ๅฏๅŽ้‡้™„)+ shutdown ๅฏน tmux ไผš่ฏ**ๅชๆ€ๅฎขๆˆท็ซฏ pty ไธๆ€ tmux**(ไฟๆดป)ใ€‚ +- **ๆต‹่ฏ•**: ๅ…ฑไบซ้›†ๆˆ cfg ่ฎพ `USE_TMUX:0`(้˜ฒ tmux ๆณ„ๆผ);ๅ•ๆต‹ CFG `useTmux:false`;้›†ๆˆ `H1`(็œŸ tmux,gate=tmux+PTY):่ฎพ shell ๅ˜้‡ โ†’ **้‡ๅฏ server** โ†’ ๅŒ id ้‡่ฟž โ†’ ๅ˜้‡ไปๅœจ(`MARK-tmuxlives`)ใ€‚208 ๆต‹่ฏ•ๅ…จ็ปฟ,tsc ้€š่ฟ‡ใ€‚ +- **ๆณจ**: ๅฝ“ๅ‰ๅŽๅฐ dev server(:3000)ๆ˜ฏ H3 ไน‹ๅ‰ๅฏๅŠจ็š„ใ€ไธๅซ H1;่ฆ่ฏ• tmux ไฟๆดป้œ€ `USE_TMUX=1 npm start`ใ€‚ +- **commit**: `9099f73` + ### 2026-06-17 ยท v0.3 Step 3 โ€” H3 ่ฟœ็จ‹ๆ‰นๅ‡†/ๆ‹’็ป(ไธๆ‰“ๅญ—) - **็Šถๆ€**: `[x]` DONEใ€‚**็ซฏๅˆฐ็ซฏ็œŸๆต่งˆๅ™จ้ชŒ่ฏ้€š่ฟ‡**(commit `9a6150c`)ใ€‚ From f79f14b89d0c395d477ab8b8a3caa97ecdb06716 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 18 Jun 2026 05:40:35 +0200 Subject: [PATCH 11/15] feat(v0.3): M3 themes/font settings + M7 session dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M3: settings.ts (โš™ panel: dark/light/solarized theme + font size A-/A+), persisted to localStorage; terminal-session.applyTheme; TabApp.applySettings applies to all + new tabs - M7: dashboard.ts (โ–ฆ overlay listing every tab with connection dot + folder + Claude status; click a row to focus; live re-render while open); TabApp.snapshot()/focusTab() - Browser-verified: light theme renders + persists; dashboard lists tabs and focuses on click. 208 tests green, typechecks + build ok. --- public/dashboard.ts | 94 +++++++++++++++++++++++++++ public/main.ts | 18 ++++++ public/settings.ts | 128 +++++++++++++++++++++++++++++++++++++ public/style.css | 100 +++++++++++++++++++++++++++++ public/tabs.ts | 33 ++++++++++ public/terminal-session.ts | 11 ++++ 6 files changed, 384 insertions(+) create mode 100644 public/dashboard.ts create mode 100644 public/settings.ts diff --git a/public/dashboard.ts b/public/dashboard.ts new file mode 100644 index 0000000..65af614 --- /dev/null +++ b/public/dashboard.ts @@ -0,0 +1,94 @@ +/** + * public/dashboard.ts โ€” all-sessions overview (M7). + * + * A โ–ฆ toolbar button opens an overlay listing every tab with its connection + * dot, folder name, and Claude status; click a row to focus that tab. While + * open it re-renders every second so statuses stay live. + */ + +export interface TabInfo { + idx: number + title: string + conn: string // SessionStatus + claude: string // ClaudeStatus + active: boolean + pending: boolean +} + +export interface DashboardHooks { + snapshot: () => TabInfo[] + focus: (idx: number) => void +} + +function claudeText(claude: string): string { + if (claude === 'working') return 'โš™ working' + if (claude === 'waiting') return 'โณ needs approval' + if (claude === 'idle') return 'โœ“ idle' + return '' +} + +export function mountDashboard(toolbar: HTMLElement, hooks: DashboardHooks): void { + const overlay = document.createElement('div') + overlay.id = 'dashboard' + overlay.style.display = 'none' + + const card = document.createElement('div') + card.className = 'dash-card' + overlay.appendChild(card) + document.body.appendChild(overlay) + + let timer: ReturnType | null = null + + const render = (): void => { + card.replaceChildren() + const title = document.createElement('div') + title.className = 'dash-title' + title.textContent = 'Sessions' + card.appendChild(title) + + for (const t of hooks.snapshot()) { + const row = document.createElement('div') + row.className = t.active ? 'dash-row active' : 'dash-row' + + const dot = document.createElement('span') + dot.className = `tab-dot dot-${t.conn}` + const name = document.createElement('span') + name.className = 'dash-name' + name.textContent = t.title + const claude = document.createElement('span') + claude.className = t.pending ? 'dash-claude pending' : 'dash-claude' + claude.textContent = claudeText(t.claude) + + row.append(dot, name, claude) + row.addEventListener('click', () => { + hooks.focus(t.idx) + hide() + }) + card.appendChild(row) + } + } + + const open = (): void => { + render() + overlay.style.display = 'flex' + timer = setInterval(render, 1000) + } + const hide = (): void => { + overlay.style.display = 'none' + if (timer !== null) { + clearInterval(timer) + timer = null + } + } + overlay.addEventListener('click', (e) => { + if (e.target === overlay) hide() + }) + + const btn = document.createElement('button') + btn.className = 'toolbtn' + btn.textContent = 'โ–ฆ' + btn.title = 'All sessions' + btn.setAttribute('aria-label', 'All sessions') + btn.addEventListener('click', () => (overlay.style.display === 'none' ? open() : hide())) + toolbar.appendChild(btn) +} diff --git a/public/main.ts b/public/main.ts index 88a0348..0361269 100644 --- a/public/main.ts +++ b/public/main.ts @@ -15,6 +15,8 @@ import { mountKeybar } from './keybar.js' import { TabApp } from './tabs.js' import { mountSearch } from './search.js' import { mountQrConnect } from './qr.js' +import { mountSettings, loadSettings, type Settings } from './settings.js' +import { mountDashboard } from './dashboard.js' const paneHost = document.getElementById('term') const tabs = document.getElementById('tabs') @@ -25,6 +27,10 @@ if (!toolbar) throw new Error('#toolbar element not found in DOM') const app = new TabApp(paneHost, tabs) +// Apply saved theme/font (M3) to the restored tabs. +let settings: Settings = loadSettings() +app.applySettings(settings) + // Key bar (mobile + desktop) sends to whichever tab is active. mountKeybar((data) => app.sendToActive(data)) @@ -33,6 +39,18 @@ mountSearch(toolbar, { find: (query, dir) => app.findInActive(query, dir), clear: () => app.clearActiveSearch(), }) +mountSettings( + toolbar, + () => settings, + (s) => { + settings = s + app.applySettings(s) + }, +) +mountDashboard(toolbar, { + snapshot: () => app.snapshot(), + focus: (idx) => app.focusTab(idx), +}) mountQrConnect(toolbar) // PWA: register the service worker (installable + offline shell, M4). diff --git a/public/settings.ts b/public/settings.ts new file mode 100644 index 0000000..7337b17 --- /dev/null +++ b/public/settings.ts @@ -0,0 +1,128 @@ +/** + * public/settings.ts โ€” theme + font-size settings (M3). + * + * A โš™ toolbar button opens a small panel; choices persist to localStorage and + * apply to every terminal via the onChange callback. + */ + +import type { ITheme } from '@xterm/xterm' + +export interface Settings { + theme: string + fontSize: number +} + +export const THEMES: Record = { + dark: { background: '#1a1a1a', foreground: '#e8e8e8', cursor: '#e8e8e8' }, + light: { + background: '#f5f5f5', + foreground: '#1a1a1a', + cursor: '#1a1a1a', + selectionBackground: '#bcd3f5', + }, + solarized: { background: '#002b36', foreground: '#93a1a1', cursor: '#93a1a1' }, +} + +const KEY = 'web-terminal:settings' +export const DEFAULT_SETTINGS: Settings = { theme: 'dark', fontSize: 14 } + +export function loadSettings(): Settings { + try { + const raw = localStorage.getItem(KEY) + if (raw) { + const s = JSON.parse(raw) as Partial + return { + theme: typeof s.theme === 'string' && THEMES[s.theme] ? s.theme : DEFAULT_SETTINGS.theme, + fontSize: + typeof s.fontSize === 'number' && s.fontSize >= 10 && s.fontSize <= 24 + ? s.fontSize + : DEFAULT_SETTINGS.fontSize, + } + } + } catch { + // ignore + } + return { ...DEFAULT_SETTINGS } +} + +function saveSettings(s: Settings): void { + try { + localStorage.setItem(KEY, JSON.stringify(s)) + } catch { + // ignore + } +} + +export function mountSettings( + toolbar: HTMLElement, + current: () => Settings, + onChange: (s: Settings) => void, +): void { + const panel = document.createElement('div') + panel.id = 'settingspanel' + panel.style.display = 'none' + + const apply = (next: Settings): void => { + saveSettings(next) + onChange(next) + render() + } + + function render(): void { + const s = current() + panel.replaceChildren() + + const themeRow = document.createElement('div') + themeRow.className = 'settings-row' + themeRow.append(label('Theme')) + for (const key of Object.keys(THEMES)) { + const b = document.createElement('button') + b.textContent = key + b.className = key === s.theme ? 'settings-opt active' : 'settings-opt' + b.addEventListener('click', () => apply({ ...s, theme: key })) + themeRow.appendChild(b) + } + + const fontRow = document.createElement('div') + fontRow.className = 'settings-row' + fontRow.append(label('Font')) + const minus = document.createElement('button') + minus.className = 'settings-opt' + minus.textContent = 'Aโˆ’' + minus.addEventListener('click', () => apply({ ...s, fontSize: Math.max(10, s.fontSize - 1) })) + const size = document.createElement('span') + size.className = 'settings-size' + size.textContent = String(s.fontSize) + const plus = document.createElement('button') + plus.className = 'settings-opt' + plus.textContent = 'A+' + plus.addEventListener('click', () => apply({ ...s, fontSize: Math.min(24, s.fontSize + 1) })) + fontRow.append(minus, size, plus) + + panel.append(themeRow, fontRow) + } + + function label(text: string): HTMLElement { + const el = document.createElement('span') + el.className = 'settings-label' + el.textContent = text + return el + } + + document.body.appendChild(panel) + + const toggle = document.createElement('button') + toggle.className = 'toolbtn' + toggle.textContent = 'โš™' + toggle.title = 'Settings (theme, font)' + toggle.setAttribute('aria-label', 'Settings') + toggle.addEventListener('click', () => { + if (panel.style.display === 'none') { + render() + panel.style.display = 'block' + } else { + panel.style.display = 'none' + } + }) + toolbar.appendChild(toggle) +} diff --git a/public/style.css b/public/style.css index e3a16c7..46b45ff 100644 --- a/public/style.css +++ b/public/style.css @@ -153,6 +153,106 @@ html, body { font: inherit; } +/* Settings panel (M3) */ +#settingspanel { + position: fixed; + top: calc(var(--tabbar-h) + 6px); + right: 8px; + z-index: 1100; + background: #2a2a2a; + border: 1px solid #3a3a3a; + border-radius: 6px; + padding: 10px; + min-width: 210px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + color: #ddd; +} +.settings-row { + display: flex; + align-items: center; + gap: 6px; + margin: 6px 0; +} +.settings-label { + width: 48px; + color: #aaa; + font-size: 13px; +} +.settings-opt { + background: #333; + border: 1px solid #444; + color: #ddd; + border-radius: 4px; + padding: 4px 9px; + cursor: pointer; + font: inherit; + font-size: 12px; +} +.settings-opt.active { + background: #4a9eff; + color: #fff; + border-color: #4a9eff; +} +.settings-size { + min-width: 22px; + text-align: center; +} + +/* Dashboard (M7) */ +#dashboard { + position: fixed; + inset: 0; + z-index: 1200; + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 56px; + background: rgba(0, 0, 0, 0.6); +} +.dash-card { + background: #222; + border: 1px solid #3a3a3a; + border-radius: 10px; + min-width: 320px; + max-width: 90vw; + max-height: 70vh; + overflow-y: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} +.dash-title { + padding: 12px 16px; + font-weight: 600; + color: #fff; + border-bottom: 1px solid #3a3a3a; +} +.dash-row { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + cursor: pointer; + color: #ddd; + border-bottom: 1px solid #2a2a2a; +} +.dash-row:hover { + background: #2a2a2a; +} +.dash-row.active { + background: #1a1a1a; + box-shadow: inset 3px 0 0 #4a9eff; +} +.dash-name { + flex: 1; +} +.dash-claude { + color: #aaa; + font-size: 13px; +} +.dash-claude.pending { + color: #ffcf8f; + font-weight: 600; +} + .tab { display: flex; align-items: center; diff --git a/public/tabs.ts b/public/tabs.ts index ca44dcd..883f783 100644 --- a/public/tabs.ts +++ b/public/tabs.ts @@ -17,6 +17,7 @@ */ import { TerminalSession } from './terminal-session.js' +import { THEMES, DEFAULT_SETTINGS, type Settings } from './settings.js' const TABS_KEY = 'web-terminal:tabs' const ACTIVE_KEY = 'web-terminal:active' @@ -41,6 +42,7 @@ export class TabApp { private editingIndex = -1 private dragIndex = -1 private notifyAsked = false + private settings: Settings = DEFAULT_SETTINGS private readonly paneHost: HTMLElement private readonly tabBar: HTMLElement private readonly approvalBar: HTMLDivElement @@ -181,6 +183,7 @@ export class TabApp { }) this.paneHost.appendChild(entry.session.el) this.tabs.push(entry) + entry.session.applyTheme(THEMES[this.settings.theme] ?? THEMES['dark']!, this.settings.fontSize) entry.session.connect() return entry } @@ -251,6 +254,36 @@ export class TabApp { this.tabs[this.activeIndex]?.session.send(data) } + /** Apply theme + font settings to every terminal (M3). */ + applySettings(s: Settings): void { + this.settings = s + const theme = THEMES[s.theme] ?? THEMES['dark']! + for (const t of this.tabs) t.session.applyTheme(theme, s.fontSize) + } + + /** A read-only view of all tabs for the dashboard (M7). */ + snapshot(): Array<{ + idx: number + title: string + conn: string + claude: string + active: boolean + pending: boolean + }> { + return this.tabs.map((t, idx) => ({ + idx, + title: this.displayTitle(t, idx), + conn: t.session.status, + claude: t.session.claudeStatus, + active: idx === this.activeIndex, + pending: t.session.pendingApproval, + })) + } + + focusTab(idx: number): void { + this.activate(idx) + } + /** Scrollback search in the active tab (M1). */ findInActive(query: string, dir: 'next' | 'prev'): void { const s = this.tabs[this.activeIndex]?.session diff --git a/public/terminal-session.ts b/public/terminal-session.ts index ecdbeb1..8caf957 100644 --- a/public/terminal-session.ts +++ b/public/terminal-session.ts @@ -9,6 +9,7 @@ */ import { Terminal } from '@xterm/xterm' +import type { ITheme } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' import { SearchAddon } from '@xterm/addon-search' import { WebLinksAddon } from '@xterm/addon-web-links' @@ -302,6 +303,16 @@ export class TerminalSession { this.searchAddon.clearDecorations() } + /** Apply a theme + font size (M3) and re-fit if visible. */ + applyTheme(theme: ITheme, fontSize: number): void { + this.term.options.theme = theme + this.term.options.fontSize = fontSize + if (this.el.style.display !== 'none') { + const dims = this.safefit() + if (dims !== null) this.sendResize(dims.cols, dims.rows) + } + } + /** Make this pane visible, fit it, and focus it. */ show(): void { this.el.style.display = 'block' From 87e11734d80c421fc7947519927d6f2e100bf07c Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 18 Jun 2026 07:14:52 +0200 Subject: [PATCH 12/15] =?UTF-8?q?feat(v0.3):=20M6=20=E2=80=94=20new=20tab?= =?UTF-8?q?=20opens=20in=20the=20active=20tab's=20directory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - title-util.cwdFromOsc7: parse cwd from OSC 7 (file:// URL); 4 unit tests - terminal-session: register OSC 7 handler โ†’ cwd getter; opts.cwd โ†’ sent in the attach frame on a fresh session - protocol/types: ClientMessage attach.cwd (absolute path); validated - session/manager/server: thread cwd โ†’ createSession spawn cwd (defaults homeDir) - tabs: '+' (newTab) opens in the active tab's reported cwd, falls back to home - Verified: server attach.cwd spawns there; cd /private/tmp โ†’ + โ†’ new tab pwd is /private/tmp. 212 tests green. --- public/tabs.ts | 7 +++++-- public/terminal-session.ts | 26 ++++++++++++++++++++++++-- public/title-util.ts | 16 ++++++++++++++++ src/protocol.ts | 20 ++++++++++++++++++-- src/server.ts | 2 +- src/session/manager.ts | 4 +++- src/session/session.ts | 4 +++- src/types.ts | 13 ++++++++++--- test/title-util.test.ts | 18 +++++++++++++++++- 9 files changed, 97 insertions(+), 13 deletions(-) diff --git a/public/tabs.ts b/public/tabs.ts index 883f783..1b4e423 100644 --- a/public/tabs.ts +++ b/public/tabs.ts @@ -151,7 +151,7 @@ export class TabApp { /* โ”€โ”€ tab lifecycle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ - private addEntry(sessionId: string | null, customTitle: string | null): TabEntry { + private addEntry(sessionId: string | null, customTitle: string | null, cwd?: string): TabEntry { const entry: TabEntry = { session: null as unknown as TerminalSession, customTitle, @@ -161,6 +161,7 @@ export class TabApp { } entry.session = new TerminalSession({ sessionId, + ...(cwd !== undefined ? { cwd } : {}), onSessionId: () => this.persist(), // onActivity only fires for hidden (inactive) panes (see TerminalSession). onActivity: () => { @@ -189,7 +190,9 @@ export class TabApp { } newTab(): void { - this.addEntry(null, null) + // M6: open the new tab in the active tab's current directory, if known. + const cwd = this.tabs[this.activeIndex]?.session.cwd ?? undefined + this.addEntry(null, null, cwd) this.persist() this.rebuild() this.activate(this.tabs.length - 1) diff --git a/public/terminal-session.ts b/public/terminal-session.ts index 8caf957..2ec5731 100644 --- a/public/terminal-session.ts +++ b/public/terminal-session.ts @@ -14,7 +14,7 @@ import { FitAddon } from '@xterm/addon-fit' import { SearchAddon } from '@xterm/addon-search' import { WebLinksAddon } from '@xterm/addon-web-links' import type { ClaudeStatus, ClientMessage, ServerMessage } from '../src/types.js' -import { folderFromTitle } from './title-util.js' +import { folderFromTitle, cwdFromOsc7 } from './title-util.js' const RESET = '\x1b[0m' const BOLD = '\x1b[1m' @@ -54,6 +54,8 @@ export interface TerminalSessionOpts { onStatus?: (status: SessionStatus) => void /** Optional: fired when Claude Code activity changes (from a hook, H2). */ onClaudeStatus?: (status: ClaudeStatus, detail?: string) => void + /** Optional: spawn a NEW session in this directory ("new tab here", M6). */ + cwd?: string } export class TerminalSession { @@ -68,8 +70,10 @@ export class TerminalSession { private readonly onTitle: ((title: string) => void) | undefined private readonly onStatus: ((status: SessionStatus) => void) | undefined private readonly onClaudeStatus: ((status: ClaudeStatus, detail?: string) => void) | undefined + private readonly spawnCwd: string | undefined private statusValue: SessionStatus = 'connecting' private claudeStatusValue: ClaudeStatus = 'unknown' + private cwdValue: string | null = null private pendingApprovalValue = false private pendingToolValue: string | undefined = undefined @@ -91,6 +95,7 @@ export class TerminalSession { this.onTitle = opts.onTitle this.onStatus = opts.onStatus this.onClaudeStatus = opts.onClaudeStatus + this.spawnCwd = opts.cwd this.el = document.createElement('div') this.el.className = 'term-pane' @@ -111,6 +116,12 @@ export class TerminalSession { this.term.onData((data) => this.send(data)) // Tab title = current folder, derived from the shell's OSC title. this.term.onTitleChange((title) => this.onTitle?.(folderFromTitle(title) ?? title)) + // OSC 7 reports the full cwd (M6) โ€” used for "new tab here". + this.term.parser.registerOscHandler(7, (payload) => { + const c = cwdFromOsc7(payload) + if (c !== null) this.cwdValue = c + return true + }) this.resizeObserver = new ResizeObserver(() => this.scheduleResize()) this.resizeObserver.observe(this.el) @@ -131,6 +142,11 @@ export class TerminalSession { return this.claudeStatusValue } + /** Current working directory reported by the shell via OSC 7 (M6), or null. */ + get cwd(): string | null { + return this.cwdValue + } + /** Whether a tool approval is held server-side and can be resolved (H3). */ get pendingApproval(): boolean { return this.pendingApprovalValue @@ -189,7 +205,13 @@ export class TerminalSession { this.reconnectDelay = 1000 this.setStatus('connected') this.term.write(statusLine(`${GREEN}${BOLD}Connected${RESET}`)) - socket.send(buildMessage({ type: 'attach', sessionId: this.sessionId })) + // Send cwd only on a fresh session (M6 "new tab here"); on reconnect the + // sessionId is already set and the server ignores cwd. + const attachMsg: ClientMessage = + this.sessionId === null && this.spawnCwd !== undefined + ? { type: 'attach', sessionId: null, cwd: this.spawnCwd } + : { type: 'attach', sessionId: this.sessionId } + socket.send(buildMessage(attachMsg)) }) socket.addEventListener('message', (event: MessageEvent) => { diff --git a/public/title-util.ts b/public/title-util.ts index e3f14fe..95ac781 100644 --- a/public/title-util.ts +++ b/public/title-util.ts @@ -23,3 +23,19 @@ export function folderFromTitle(title: string): string | null { if (slash !== -1) s = s.slice(slash + 1) return s || null } + +/** + * Parse the full cwd path from an OSC 7 sequence payload (M6). + * data = "file://host/Users/me/dir" โ†’ "/Users/me/dir" + * Returns null if it isn't a file:// URL. + */ +export function cwdFromOsc7(data: string): string | null { + const m = /^file:\/\/[^/]*(\/.*)$/.exec(data.trim()) + if (m === null) return null + const path = m[1] as string + try { + return decodeURIComponent(path) + } catch { + return path + } +} diff --git a/src/protocol.ts b/src/protocol.ts index 83429e2..917369c 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -133,8 +133,21 @@ function validateAttach(obj: Record): ParseResult { return { ok: false, error: 'attach.sessionId field is required' } } + // Optional cwd (M6): must be an absolute path string if present. + const rawCwd = obj['cwd'] + let cwd: string | undefined + if (rawCwd !== undefined) { + if (typeof rawCwd !== 'string' || !rawCwd.startsWith('/')) { + return { ok: false, error: 'attach.cwd must be an absolute path string' } + } + cwd = rawCwd + } + if (sessionId === null) { - return { ok: true, message: { type: 'attach', sessionId: null } } + return { + ok: true, + message: cwd !== undefined ? { type: 'attach', sessionId: null, cwd } : { type: 'attach', sessionId: null }, + } } if (typeof sessionId !== 'string') { @@ -151,7 +164,10 @@ function validateAttach(obj: Record): ParseResult { } } - return { ok: true, message: { type: 'attach', sessionId } } + return { + ok: true, + message: cwd !== undefined ? { type: 'attach', sessionId, cwd } : { type: 'attach', sessionId }, + } } // โ”€โ”€โ”€ serialize โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/src/server.ts b/src/server.ts index 9928cd3..9d32ea4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -229,7 +229,7 @@ export function startServer(cfg: Config): { close(): Promise } { let session try { - session = manager.handleAttach(ws, msg.sessionId, dims, Date.now()) + session = manager.handleAttach(ws, msg.sessionId, dims, Date.now(), msg.cwd) } catch (err: unknown) { // M4: spawn failure โ€” send exit(-1) and close only this connection. const reason = diff --git a/src/session/manager.ts b/src/session/manager.ts index 82d341f..c7596a2 100644 --- a/src/session/manager.ts +++ b/src/session/manager.ts @@ -82,11 +82,13 @@ export function createSessionManager(cfg: Config): SessionManager { sessionId: string | null, dims: Dims, now: number, + cwd?: string, ): Session { // โ”€โ”€ Case 1: null โ†’ always create a new session โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ if (sessionId === null) { // M4: createSession may throw (spawn failure). Do NOT catch here. - const session = createSession(cfg, dims, now, onSessionExit); + // M6: cwd (if given) is the spawn directory for "new tab here". + const session = createSession(cfg, dims, now, onSessionExit, undefined, cwd); const kicked = attachWs(session, ws); if (kicked !== null) kicked.close(); sessions = new Map(sessions).set(session.meta.id, session); diff --git a/src/session/session.ts b/src/session/session.ts index 6bfcbb4..6806219 100644 --- a/src/session/session.ts +++ b/src/session/session.ts @@ -61,6 +61,8 @@ export function createSession( // H1: pass an existing id to RE-ATTACH to a surviving tmux session (e.g. after // a server restart). Default = a fresh id for a brand-new session. id: string = randomUUID(), + // M6: spawn directory for a new session ("new tab here"); defaults to homeDir. + cwd?: string, ): Session { // The id is injected into the shell env so Claude Code hooks know which tab an // event belongs to ($WEBTERM_SESSION) and where to POST ($WEBTERM_HOOK_URL, H2). @@ -76,7 +78,7 @@ export function createSession( name: 'xterm-256color', cols: dims.cols, rows: dims.rows, - cwd: cfg.homeDir, + cwd: cwd ?? cfg.homeDir, env: { ...process.env, WEBTERM_SESSION: id, diff --git a/src/types.ts b/src/types.ts index 5cb01ba..e69ad34 100644 --- a/src/types.ts +++ b/src/types.ts @@ -39,9 +39,10 @@ export type EnvLike = Readonly>; /* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ protocol (ยง3.2) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ -/** client โ†’ server. approve/reject (H3) resolve a held PermissionRequest. */ +/** client โ†’ server. approve/reject (H3) resolve a held PermissionRequest. + * attach.cwd (M6) = spawn a new session in this directory ("new tab here"). */ export type ClientMessage = - | { type: 'attach'; sessionId: string | null } + | { type: 'attach'; sessionId: string | null; cwd?: string } | { type: 'input'; data: string } | { type: 'resize'; cols: number; rows: number } | { type: 'approve' } @@ -167,7 +168,13 @@ export interface Session { /* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ manager (ยง3.5) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ export interface SessionManager { - handleAttach(ws: WebSocketLike, sessionId: string | null, dims: Dims, now: number): Session; + handleAttach( + ws: WebSocketLike, + sessionId: string | null, + dims: Dims, + now: number, + cwd?: string, + ): Session; get(id: string): Session | undefined; /** Set a session's Claude status (from a hook) and push it to the attached ws (H2/H3). */ handleHookEvent( diff --git a/test/title-util.test.ts b/test/title-util.test.ts index 5794fc3..87f7e76 100644 --- a/test/title-util.test.ts +++ b/test/title-util.test.ts @@ -1,5 +1,21 @@ import { describe, it, expect } from 'vitest' -import { folderFromTitle } from '../public/title-util.js' +import { folderFromTitle, cwdFromOsc7 } from '../public/title-util.js' + +describe('cwdFromOsc7', () => { + it('extracts the path from a file:// URL with a host', () => { + expect(cwdFromOsc7('file://mac.local/Users/me/web-terminal')).toBe('/Users/me/web-terminal') + }) + it('extracts the path with an empty host (file:///)', () => { + expect(cwdFromOsc7('file:///Users/me/dir')).toBe('/Users/me/dir') + }) + it('decodes percent-encoded spaces', () => { + expect(cwdFromOsc7('file://h/Users/me/my%20dir')).toBe('/Users/me/my dir') + }) + it('returns null for non-file URLs', () => { + expect(cwdFromOsc7('http://x/y')).toBeNull() + expect(cwdFromOsc7('garbage')).toBeNull() + }) +}) describe('folderFromTitle', () => { it('extracts the folder from user@host:~/path', () => { From 915a51e4bbe0ca150363da4fdcc1c3798d2fdf02 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 18 Jun 2026 07:15:35 +0200 Subject: [PATCH 13/15] docs: log v0.3 medium batch M3/M6/M7 --- docs/PROGRESS_LOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 807b856..5771483 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -121,6 +121,15 @@ - **้—็•™ / ๅพ…ๅŠž**: ๆ— (ๅŽŸๅ‰็ซฏๆ‰“ๅŒ…ๅพ…ๅ†ณ้กนๅทฒ่งฃๅ†ณ,่งไธ‹่กฅ่ฎฐ)ใ€‚ - **commit**: `b126cf0` +### 2026-06-18 ยท v0.3 Step 5 โ€” ไธญไปทๅ€ผ M3/M7/M6(ไธป้ข˜/ไปช่กจ็›˜/ๅŒ็›ฎๅฝ•ๆ–ฐๆ ‡็ญพ) + +- **็Šถๆ€**: `[x]` DONEใ€‚็œŸๆต่งˆๅ™จ้ชŒ่ฏ้€š่ฟ‡ใ€‚212 ๆต‹่ฏ•ๅ…จ็ปฟใ€‚ +- **M3 ไธป้ข˜/ๅญ—ไฝ“**(commit `f79f14b`):`settings.ts`(โš™ ้ขๆฟ:dark/light/solarized + ๅญ—ๅท Aโˆ’/A+),localStorage ๆŒไน…ๅŒ–;`terminal-session.applyTheme`;TabApp.applySettings ๅบ”็”จๅˆฐๆ‰€ๆœ‰+ๆ–ฐๆ ‡็ญพใ€‚้ชŒ่ฏ:light ไธป้ข˜ๆธฒๆŸ“+ๆŒไน…ๅŒ–ใ€‚ +- **M7 ไปช่กจ็›˜**(commit `f79f14b`):`dashboard.ts`(โ–ฆ ๆตฎๅฑ‚ๅˆ—ๅ‡บๆ‰€ๆœ‰ๆ ‡็ญพ:่ฟžๆŽฅ็‚น+ๆ–‡ไปถๅคน+Claude ็Šถๆ€;็‚น่กŒๅˆ‡ๆข;ๆ‰“ๅผ€ๆ—ถๆฏ็ง’ๅˆทๆ–ฐ);TabApp.snapshot()/focusTab()ใ€‚้ชŒ่ฏ:ๅˆ—ๅ‡บ 2 ๆ ‡็ญพใ€็‚นๅ‡ปๅˆ‡ๆขใ€‚ +- **M6 ๅŒ็›ฎๅฝ•ๆ–ฐๆ ‡็ญพ**(commit `87e1173`):`title-util.cwdFromOsc7`(่งฃๆž OSC 7 cwd)+4 ๅ•ๆต‹;terminal-session ๆณจๅ†Œ OSC7 handlerโ†’cwd getter,ๆ–ฐไผš่ฏ attach ๅธฆ cwd;protocol/types attach.cwd(็ปๅฏน่ทฏๅพ„);session/manager/server ้€ไผ ๅˆฐ spawn cwd;tabs `+` ๅœจๅฝ“ๅ‰ tab ็š„ cwd ๅผ€ๆ–ฐๆ ‡็ญพ(ๆ— ๅˆ™ๅ›ž home)ใ€‚้ชŒ่ฏ:server attach.cwd ็”Ÿๆ•ˆ;`cd /private/tmp`โ†’`+`โ†’ๆ–ฐๆ ‡็ญพ pwd=/private/tmp(ๆญค zsh ๅ‘ OSC7)ใ€‚ +- **ๅทฅๅ…ทๆ **: ๐Ÿ”ๆœ็ดข ยท โš™่ฎพ็ฝฎ ยท โ–ฆไปช่กจ็›˜ ยท ๐Ÿ“ฑQRใ€‚ +- **commit**: `f79f14b`(M3+M7)ใ€`87e1173`(M6) + ### 2026-06-17 ยท v0.3 Step 4 โ€” H1 tmux ไฟๆดป(ๆœๅŠก้‡ๅฏไธไธขไผš่ฏ) - **็Šถๆ€**: `[x]` DONEใ€‚**็œŸ tmux ้›†ๆˆๆต‹่ฏ•้ชŒ่ฏ้€š่ฟ‡**(commit `9099f73`)ใ€‚**่‡ณๆญค H1โ€“H4 ๅ…จ้ƒจ้ซ˜ไปทๅ€ผๅŠŸ่ƒฝๅฎŒๆˆใ€‚** From eb28a5593f87fdd24b93837d03d6c4ae793b07b8 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 18 Jun 2026 07:28:21 +0200 Subject: [PATCH 14/15] docs: README + CLAUDE.md updated for v0.2/v0.3 (cockpit, tmux, hooks, toolbar) --- CLAUDE.md | 16 +++++++++------- README.md | 27 +++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4f3edb1..63f5b95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,13 +4,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Status -**Planning stage โ€” no code yet.** The `docs/` folder holds the authoritative spec; there is no `src/`, `public/`, or `package.json` yet. Read these before implementing: +**Built and working โ€” v0.1 (core) + v0.2 (multi-tab) + v0.3 (Claude Code cockpit) shipped.** `src/` (TypeScript backend), `public/` (esbuild frontend), and tests all exist; 212 tests pass. v0.3 work happens on the `v0.3-cockpit` branch. Key docs: -- `docs/TECH_DOC.md` โ€” design & rationale (the **why**). Authoritative for protocol (ยง4), session model (ยง5.2), security (ยง7), acceptance criteria (ยง8). -- `docs/ARCHITECTURE.md` โ€” development guide (the **how**). Module contracts at **function-signature level**, dependency rules, build order (ยง6 S1โ€“S8), and the invariant checklist (ยง8). -- `docs/PLAN.md` โ€” the task plan. Dependency-ordered **waves W0โ€“W5**, fine-grained **tasks T1โ€“T21** for multi-agent parallelism (each task has `Owns:` / `Depends:`; per-task model & isolation in ยง4). -- `docs/PROGRESS_LOG.md` โ€” the **memory file** (cross-session). Orchestrator-owned; subagents return their entry rather than writing it. See workflow below. -- `.claude/agents/` โ€” project subagents: `module-builder` (TDD-implements one task), `module-reviewer` (read-only review). +- `docs/TECH_DOC.md` โ€” design & rationale (the **why**). Protocol (ยง4), session model (ยง5.2), security (ยง7), acceptance (ยง8). +- `docs/ARCHITECTURE.md` โ€” function-signature-level contracts, dependency rules, the **invariant checklist (ยง8)**. On conflict, ARCHITECTURE wins on *how*; the M*/L* anchors are cross-validated fixes. +- `docs/PROGRESS_LOG.md` โ€” the **memory file** (cross-session). Read it first; it's the running record of what shipped (v0.1 T1โ€“T21, then v0.2/v0.3 feature entries). Orchestrator-owned. +- `docs/PLAN.md` (v0.1 task plan) and `~/.claude/plans/shimmering-wondering-island.md` (v0.3 cockpit plan). +- `.claude/agents/` โ€” project subagents: `module-builder`, `module-reviewer` (for parallel dev). + +**v0.3 additions** (cockpit): Claude Code **hooks โ†’ live status** per tab (`POST /hook`, `npm run setup-hooks`); **remote approve/reject** (held `POST /hook/permission`); **tmux keepalive** (`USE_TMUX`); plus FE: themes, search, dashboard, QR, PWA, new-tab-in-cwd. The server gained a hook side-channel but the terminal stream is still a byte-shuttle. **Language decision: TypeScript (`.ts`), not `.js`** โ€” ARCHITECTURE ยง0 records this divergence from TECH_DOC's original `.js` filenames. Wherever the two docs conflict, ARCHITECTURE wins on *how* (it was cross-validated and corrected); TECH_DOC wins on *why/scope*. @@ -61,7 +63,7 @@ npm start # listens on 0.0.0.0:3000 npm test # unit tests (vitest, all modules) ``` -Config is via env vars only (no hardcoding): `PORT`, `SHELL_PATH`, `BIND_HOST`, `IDLE_TTL`, `SCROLLBACK_BYTES`, `MAX_PAYLOAD_BYTES`, `ALLOWED_ORIGINS`. Note `allowedOrigins` is derived from the host's network-interface IPs (not from `BIND_HOST` โ€” `0.0.0.0` is never a valid Origin); see ARCHITECTURE ยง3.1. +Config is via env vars only (no hardcoding): `PORT`, `SHELL_PATH`, `BIND_HOST`, `IDLE_TTL`, `SCROLLBACK_BYTES`, `MAX_PAYLOAD_BYTES`, `USE_TMUX` (1/0/auto), `ALLOWED_ORIGINS`. Note `allowedOrigins` is derived from the host's network-interface IPs (not from `BIND_HOST` โ€” `0.0.0.0` is never a valid Origin); see ARCHITECTURE ยง3.1. ## Architecture (the parts that span files) diff --git a/README.md b/README.md index 58beb41..47e365a 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,15 @@ Sessions survive disconnects: the shell (and whatever's running in it) keeps goi > โš ๏ธ **This hands a full shell to anyone who can reach the port.** LAN-only, no authentication. **Never** port-forward or tunnel it to the public internet. See [Security](#security). +## Features + +- **Multi-tab** โ€” each tab is an independent shell session. Tabs auto-name to the current folder (double-click to rename), show a connection dot (๐ŸŸข/๐ŸŸก/๐Ÿ”ด) and an unread-output dot, and can be drag-reordered. `+` opens a new tab in the active tab's directory. +- **Claude Code cockpit** (with hooks, see below) โ€” each tab shows Claude's status (โš™ working / โณ needs approval / โœ“ idle), sends a browser notification when it needs you, and lets you **tap Approve / Reject** on a tool request with no typing. +- **tmux keepalive** (optional) โ€” run the shell inside tmux so sessions survive a server/host restart, not just a disconnect. +- **Mobile + desktop shortcut bar** โ€” one-tap Esc / EscยทEsc / โ‡งTab / arrows / Enter / ^C / ^O / ^T / ^B / Tab / `/` (the Claude Code keys a phone keyboard can't produce). +- **Session keepalive + replay** โ€” the PTY keeps running across disconnects; reconnect replays a ~2 MB scrollback ring buffer. +- **Toolbar** โ€” ๐Ÿ” scrollback search ยท โš™ themes & font size ยท โ–ฆ all-sessions dashboard ยท ๐Ÿ“ฑ QR connect (scan to open on another device). Clickable links. Installable as a PWA. + ## Requirements - Node.js โ‰ฅ 18 (developed on v24) @@ -34,6 +43,23 @@ ipconfig getifaddr en0 For frontend development, run `npm run dev:web` (esbuild --watch) alongside `npm start`. +## Claude Code cockpit (optional) + +To see Claude's status per tab and approve/reject tool calls from your phone, install the hooks once: + +```bash +npm run setup-hooks # adds http hooks to ~/.claude/settings.json (backs it up) +# npm run setup-hooks -- --remove # to uninstall +``` + +Then run `claude` inside a tab. The hooks POST to the server (loopback only) when Claude starts/stops/needs permission; the tab badge updates live, and a `PermissionRequest` shows an **Approve / Reject** bar. The hooks are a no-op outside web-terminal (they curl `$WEBTERM_HOOK_URL`, which is only set in spawned shells), so they're safe to leave installed. + +**tmux keepalive** โ€” run with `USE_TMUX=1` (or just have `tmux` on PATH; it auto-detects) to keep sessions alive across a server restart: + +```bash +USE_TMUX=1 npm start +``` + ## Configuration All via environment variables (no hardcoding): @@ -46,6 +72,7 @@ All via environment variables (no hardcoding): | `IDLE_TTL` | `86400` (s) | reclaim a detached session after this idle time | | `SCROLLBACK_BYTES` | `2097152` | per-session replay ring buffer (bytes) | | `MAX_PAYLOAD_BYTES` | `1048576` | max single WS frame | +| `USE_TMUX` | `auto` | `1`/`0`/`auto` โ€” run the shell inside tmux (keepalive across restart); `auto` = on if `tmux` is on PATH | | `ALLOWED_ORIGINS` | (derived) | extra allowed WS origins, comma-separated | `allowedOrigins` is **derived from the host's network-interface IPs** (plus `localhost` and anything in `ALLOWED_ORIGINS`) โ€” never from `BIND_HOST`, since `0.0.0.0` is never a real browser Origin. From 5b357e67df4c9cb8a2d55724b4a9c1635e925ec0 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 18 Jun 2026 07:28:55 +0200 Subject: [PATCH 15/15] docs: mark v0.3 cockpit complete; only optional O1/O2 remain --- docs/PROGRESS_LOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 5771483..56e37b3 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -24,7 +24,12 @@ > ๆ–ฐไผš่ฏ่ฏปๅˆฐ็š„็ฌฌไธ€ๅ—ใ€‚ไฟๆŒๅ‡†็กฎ,ๅชๆ่ฟฐ"ๆญคๅˆป"ใ€‚ -- **ๅฝ“ๅ‰้˜ถๆฎต**: **v0.2 ่ฟ›่กŒไธญ** โ€”โ€” ๅคšๆ ‡็ญพ + ๆ ‡้ข˜ + ็งปๅŠจ็ซฏ Claude Code ๅฟซๆท้”ฎๆ (โœ… ๅทฒๅš,่งไธ‹ๆ–น v0.2 ๆก็›ฎ)ใ€‚v0.1 ่งไธ‹ใ€‚ +- **ๅฝ“ๅ‰้˜ถๆฎต**: **v0.3 cockpit ๅฎŒๆˆ**(ๅˆ†ๆ”ฏ `v0.3-cockpit`,ๅพ…ๅˆๅนถ main)ใ€‚่ฎกๅˆ’่ง `~/.claude/plans/shimmering-wondering-island.md`ใ€‚ + - โœ… Step1 ๅ‰็ซฏๅฟซ่ตข(้”ฎๆ /ๆœ็ดข/้“พๆŽฅ/QR/PWA) ยท โœ… H2/H4 ็Šถๆ€ๆ„Ÿ็Ÿฅ+้€š็Ÿฅ ยท โœ… H3 ่ฟœ็จ‹ๆ‰นๅ‡†/ๆ‹’็ป ยท โœ… H1 tmux ไฟๆดป ยท โœ… M3/M7/M6(ไธป้ข˜/ไปช่กจ็›˜/ๅŒ็›ฎๅฝ•ๆ–ฐๆ ‡็ญพ)ใ€‚ + - โฌœ ไป…ๅ‰ฉๅฏ้€‰้กน:**O1 token ่ฎค่ฏ**(้ป˜่ฎคๅ…ณ)ใ€**O2 ๅކๅฒไผš่ฏๆต่งˆ**(่ฏป ~/.claude/projects JSONL โ†’ --resume)ใ€‚ + - **212 ๆต‹่ฏ•ๅ…จ็ปฟ**;README/CLAUDE.md ๅทฒๆ›ดๆ–ฐ;ๅทฅๅ…ทๆ  ๐Ÿ”โš™โ–ฆ๐Ÿ“ฑใ€‚ + - **ไธ‹ไธ€ๆญฅ**: ๅˆๅนถ `v0.3-cockpit` โ†’ main;O1/O2 ไฝœไธบๅฏ้€‰ๅŽ็ปญใ€‚ + - v0.2/v0.1 ๅކๅฒ่งไธ‹ๆ–นๆก็›ฎใ€‚ - **Wave**: **W0โ€“W5 ๅŸบๆœฌๅฎŒๆˆ**ใ€‚v0.1 ๅŠŸ่ƒฝๅฎŒๆ•ดใ€ๆต‹่ฏ•ไธŽ็œŸๆœบๆต่งˆๅ™จ้ชŒ่ฏ้€š่ฟ‡ใ€‚ไป…ๅ‰ฉ 2 ้กน้œ€็”จๆˆท็œŸ่ฎพๅค‡ใ€‚ - **่ฟ›ๅบฆ**: **20/21 ไปปๅŠก**(T19 ๅพ…็œŸๆ‰‹ๆœบ)ยท **187 ่‡ชๅŠจๅŒ–ๆต‹่ฏ•ๅ…จ็ปฟ** ยท ็œŸ PTY ็ซฏๅˆฐ็ซฏ้ชŒ่ฏ ยท **็œŸๆต่งˆๅ™จ smoke ้€š่ฟ‡**ใ€‚ - **้ชŒๆ”ถ F1โ€“F9**: โœ… F1/F2(ๆต่งˆๅ™จ smoke:ๆธฒๆŸ“+่พ“ๅ…ฅ่พ“ๅ‡บ+ๅ…จๅฝฉ)ยท โœ… F3(resize ๆ— ้”™)ยท โฌœ **F4 ้œ€ๅฑ€ๅŸŸ็ฝ‘็œŸๆœบ** ยท โœ… F5/F6(้›†ๆˆ โ‘คโ‘ฅ ๅ›žๆ”พ CJK/ANSI)ยท โฌœ **F7 ้œ€็œŸๆ‰‹ๆœบ**(่ฝฏ้”ฎ็›˜ๆ )ยท โœ… F8(ๆต่งˆๅ™จ exitโ†’้‡่ฟžๆ็คบ)ยท โœ… F9(้›†ๆˆ+curl 401)ใ€‚