diff --git a/bun.lock b/bun.lock index f8313290..f6c158cc 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "hapi", "devDependencies": { "concurrently": "^9.2.1", + "playwright": "1.49.1", "react-devtools-core": "^7.0.1", "vite-plugin-pwa": "^1.2.0", }, @@ -1062,6 +1063,8 @@ "@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.17.2", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-+cgTpsS5YLgI1Onkw4cApt43l/r/AlrQ9FEU5/I/zyRfdpOhZ4PYN/8mNAh6dp0Z2s3Rk86ARmZ5nqzsjrtnYA=="], + "@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.17.2", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-vFCWnudJa9oDtcTa7OdkEmBvZy6VHUwU+aT0ZnBAA1jRZNPGaw+9FhmGSqVYQL+Zq4EwPak/BABP/JKbM1f3+Q=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], @@ -1802,7 +1805,7 @@ "fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -2426,6 +2429,10 @@ "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + "playwright": ["playwright@1.49.1", "", { "dependencies": { "playwright-core": "1.49.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA=="], + + "playwright-core": ["playwright-core@1.49.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg=="], + "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], "pnpm": ["pnpm@10.27.0", "", { "bin": { "pnpm": "bin/pnpm.cjs", "pnpx": "bin/pnpx.cjs" } }, "sha512-ctaZ2haxF5wUup5k3HHJpAmIy9xlwmTLDkidt96RfyDc9NZNhyNiXylpulLUt+KhFwaC2awqXcrqq3MrfhbwSg=="], @@ -3516,6 +3523,8 @@ "rehype-katex/katex": ["katex@0.16.27", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw=="], + "rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -3556,10 +3565,14 @@ "tsx/esbuild": ["esbuild@0.27.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.1", "@esbuild/android-arm": "0.27.1", "@esbuild/android-arm64": "0.27.1", "@esbuild/android-x64": "0.27.1", "@esbuild/darwin-arm64": "0.27.1", "@esbuild/darwin-x64": "0.27.1", "@esbuild/freebsd-arm64": "0.27.1", "@esbuild/freebsd-x64": "0.27.1", "@esbuild/linux-arm": "0.27.1", "@esbuild/linux-arm64": "0.27.1", "@esbuild/linux-ia32": "0.27.1", "@esbuild/linux-loong64": "0.27.1", "@esbuild/linux-mips64el": "0.27.1", "@esbuild/linux-ppc64": "0.27.1", "@esbuild/linux-riscv64": "0.27.1", "@esbuild/linux-s390x": "0.27.1", "@esbuild/linux-x64": "0.27.1", "@esbuild/netbsd-arm64": "0.27.1", "@esbuild/netbsd-x64": "0.27.1", "@esbuild/openbsd-arm64": "0.27.1", "@esbuild/openbsd-x64": "0.27.1", "@esbuild/openharmony-arm64": "0.27.1", "@esbuild/sunos-x64": "0.27.1", "@esbuild/win32-arm64": "0.27.1", "@esbuild/win32-ia32": "0.27.1", "@esbuild/win32-x64": "0.27.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA=="], + "tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "vite/esbuild": ["esbuild@0.27.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.1", "@esbuild/android-arm": "0.27.1", "@esbuild/android-arm64": "0.27.1", "@esbuild/android-x64": "0.27.1", "@esbuild/darwin-arm64": "0.27.1", "@esbuild/darwin-x64": "0.27.1", "@esbuild/freebsd-arm64": "0.27.1", "@esbuild/freebsd-x64": "0.27.1", "@esbuild/linux-arm": "0.27.1", "@esbuild/linux-arm64": "0.27.1", "@esbuild/linux-ia32": "0.27.1", "@esbuild/linux-loong64": "0.27.1", "@esbuild/linux-mips64el": "0.27.1", "@esbuild/linux-ppc64": "0.27.1", "@esbuild/linux-riscv64": "0.27.1", "@esbuild/linux-s390x": "0.27.1", "@esbuild/linux-x64": "0.27.1", "@esbuild/netbsd-arm64": "0.27.1", "@esbuild/netbsd-x64": "0.27.1", "@esbuild/openbsd-arm64": "0.27.1", "@esbuild/openbsd-x64": "0.27.1", "@esbuild/openharmony-arm64": "0.27.1", "@esbuild/sunos-x64": "0.27.1", "@esbuild/win32-arm64": "0.27.1", "@esbuild/win32-ia32": "0.27.1", "@esbuild/win32-x64": "0.27.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA=="], + "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "vite-node/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], "vite-node/vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], @@ -3588,6 +3601,8 @@ "@builder.io/vite-plugin-jsx-loc/vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + "@builder.io/vite-plugin-jsx-loc/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "@floating-ui/react/@floating-ui/react-dom/@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], @@ -3686,12 +3701,20 @@ "@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@rollup/plugin-babel/rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "@rollup/plugin-node-resolve/@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "@rollup/plugin-replace/rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "@rollup/pluginutils/rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "@shikijs/core/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@3.1.1", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ=="], "@vitejs/plugin-vue/vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + "@vitejs/plugin-vue/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], @@ -3822,6 +3845,8 @@ "vite-node/vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + "vite-node/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA=="], "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.1", "", { "os": "android", "cpu": "arm" }, "sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg=="], @@ -3884,8 +3909,12 @@ "vitepress/vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + "vitepress/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "widest-line/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "workbox-build/rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "wrap-ansi-cjs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -4004,6 +4033,8 @@ "hapi-website/vitest/vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + "hapi-website/vitest/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "qrcode/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "qrcode/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], diff --git a/cli/src/codex/codexRemoteLauncher.test.ts b/cli/src/codex/codexRemoteLauncher.test.ts index 39b8b2c9..7e1f6194 100644 --- a/cli/src/codex/codexRemoteLauncher.test.ts +++ b/cli/src/codex/codexRemoteLauncher.test.ts @@ -69,6 +69,32 @@ vi.mock('./codexAppServerClient', () => { return { turn: { id: turnId } }; } + if (params?.threadId === 'thread-1') { + const commandStart = { + item: { + id: 'cmd-1', + type: 'commandExecution', + command: 'echo ok', + cwd: '/tmp/hapi-update' + } + }; + harness.notifications.push({ method: 'item/started', params: commandStart }); + this.notificationHandler?.('item/started', commandStart); + this.notificationHandler?.('item/commandExecution/outputDelta', { + itemId: 'cmd-1', + delta: 'ok\n' + }); + const commandEnd = { + item: { + id: 'cmd-1', + type: 'commandExecution', + exitCode: 0 + } + }; + harness.notifications.push({ method: 'item/completed', params: commandEnd }); + this.notificationHandler?.('item/completed', commandEnd); + } + const completed = { status: 'Completed', turn: { id: turnId } }; harness.notifications.push({ method: 'turn/completed', params: completed }); this.notificationHandler?.('turn/completed', completed); @@ -250,7 +276,12 @@ describe('codexRemoteLauncher', () => { experimentalApi: true } }]); - expect(harness.notifications.map((entry) => entry.method)).toEqual(['turn/started', 'turn/completed']); + expect(harness.notifications.map((entry) => entry.method)).toEqual([ + 'turn/started', + 'item/started', + 'item/completed', + 'turn/completed' + ]); expect(sessionEvents.filter((event) => event.type === 'ready').length).toBeGreaterThanOrEqual(1); expect(thinkingChanges).toContain(true); expect(session.thinking).toBe(false); @@ -286,6 +317,30 @@ describe('codexRemoteLauncher', () => { expect(session.thinking).toBe(false); }); + it('surfaces Codex bash stdout instead of duplicating raw output json', async () => { + const { session, codexMessages } = createSessionStub(); + + await codexRemoteLauncher(session as never); + + expect(codexMessages).toContainEqual(expect.objectContaining({ + type: 'tool-call-result', + callId: 'cmd-1', + output: expect.objectContaining({ + command: 'echo ok', + cwd: '/tmp/hapi-update', + stdout: 'ok\n', + exit_code: 0 + }) + })); + expect(codexMessages).not.toContainEqual(expect.objectContaining({ + type: 'tool-call-result', + callId: 'cmd-1', + output: expect.objectContaining({ + output: 'ok\n' + }) + })); + }); + it('clears codex thread state without starting a turn', async () => { const { session, sessionEvents, resetThreadCalls } = createSessionStub(['/clear', 'next message']); diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index d0612a1f..64153060 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -410,6 +410,8 @@ class CodexRemoteLauncher extends RemoteLauncherBase { delete output.type; delete output.call_id; delete output.callId; + output.stdout = output.output; + delete output.output; session.sendAgentMessage({ type: 'tool-call-result', @@ -425,6 +427,28 @@ class CodexRemoteLauncher extends RemoteLauncherBase { id: randomUUID() }); } + if (msgType === 'plan_update') { + session.sendAgentMessage({ + type: 'tool-call', + name: 'update_plan', + callId: 'codex-plan-state', + input: { + plan: Array.isArray(msg.plan) ? msg.plan : [], + source: 'codex' + }, + id: randomUUID() + }); + session.sendAgentMessage({ + type: 'tool-call-result', + callId: 'codex-plan-state', + output: { + plan: Array.isArray(msg.plan) ? msg.plan : [], + source: 'codex', + status: 'updated' + }, + id: randomUUID() + }); + } if (msgType === 'patch_apply_begin') { const callId = asString(msg.call_id ?? msg.callId); if (callId) { diff --git a/cli/src/codex/utils/appServerConfig.test.ts b/cli/src/codex/utils/appServerConfig.test.ts index 0951b413..cd8df062 100644 --- a/cli/src/codex/utils/appServerConfig.test.ts +++ b/cli/src/codex/utils/appServerConfig.test.ts @@ -115,6 +115,8 @@ describe('appServerConfig', () => { expect(params.input).toEqual([{ type: 'text', text: 'hello' }]); expect(params.approvalPolicy).toBe('never'); expect(params.sandboxPolicy).toEqual({ type: 'readOnly' }); + expect(params.effort).toBe('high'); + expect(params.summary).toBe('detailed'); expect(params.collaborationMode).toEqual({ mode: 'default', settings: { diff --git a/cli/src/codex/utils/appServerConfig.ts b/cli/src/codex/utils/appServerConfig.ts index 12565909..3df7083c 100644 --- a/cli/src/codex/utils/appServerConfig.ts +++ b/cli/src/codex/utils/appServerConfig.ts @@ -141,6 +141,11 @@ export function buildTurnStartParams(args: { params.sandboxPolicy = sandboxPolicy; } + if (args.mode?.modelReasoningEffort) { + params.effort = args.mode.modelReasoningEffort; + params.summary = 'detailed'; + } + const collaborationMode = args.mode?.collaborationMode; const model = args.overrides?.model ?? args.mode?.model; if (collaborationMode) { diff --git a/cli/src/codex/utils/appServerEventConverter.test.ts b/cli/src/codex/utils/appServerEventConverter.test.ts index 68dd3407..a3f11756 100644 --- a/cli/src/codex/utils/appServerEventConverter.test.ts +++ b/cli/src/codex/utils/appServerEventConverter.test.ts @@ -100,6 +100,75 @@ describe('AppServerEventConverter', () => { }]); }); + it('maps MCP tool call items', () => { + const converter = new AppServerEventConverter(); + + const started = converter.handleNotification('item/started', { + item: { + id: 'call-1', + type: 'mcpToolCall', + server: 'hapi', + tool: 'change_title', + arguments: { title: 'MCP Title' } + } + }); + expect(started).toEqual([{ + type: 'mcp_tool_call_begin', + call_id: 'call-1', + server: 'hapi', + tool: 'change_title', + invocation: { + server: 'hapi', + tool: 'change_title', + arguments: { title: 'MCP Title' } + } + }]); + + const completed = converter.handleNotification('item/completed', { + item: { + id: 'call-1', + type: 'mcpToolCall', + server: 'hapi', + tool: 'change_title', + result: { + content: [{ type: 'text', text: 'done' }] + } + } + }); + + expect(completed).toEqual([{ + type: 'mcp_tool_call_end', + call_id: 'call-1', + server: 'hapi', + tool: 'change_title', + result: { + content: [{ type: 'text', text: 'done' }] + } + }]); + }); + + it('maps MCP tool call item errors', () => { + const converter = new AppServerEventConverter(); + + const completed = converter.handleNotification('item/completed', { + item: { + id: 'call-1', + type: 'mcpToolCall', + server: 'hapi', + tool: 'change_title', + error: 'boom' + } + }); + + expect(completed).toEqual([{ + type: 'mcp_tool_call_end', + call_id: 'call-1', + server: 'hapi', + tool: 'change_title', + result: { Err: 'boom' } + }]); + }); + it('maps reasoning deltas', () => { const converter = new AppServerEventConverter(); @@ -144,6 +213,51 @@ describe('AppServerEventConverter', () => { expect(second).toEqual([]); }); + + + it('maps turn plan updates into update_plan events', () => { + const converter = new AppServerEventConverter(); + + const events = converter.handleNotification('turn/plan/updated', { + plan: [ + { step: 'Inspect Codex events', status: 'completed' }, + { content: 'Render plan state', status: 'in_progress' }, + { title: 'Verify web DOM', status: 'pending' } + ] + }); + + expect(events).toEqual([{ + type: 'plan_update', + plan: [ + { step: 'Inspect Codex events', status: 'completed' }, + { step: 'Render plan state', status: 'in_progress' }, + { step: 'Verify web DOM', status: 'pending' } + ] + }]); + }); + + it('unwraps wrapped codex plan updates', () => { + const converter = new AppServerEventConverter(); + + const events = converter.handleNotification('codex/event/plan_update', { + msg: { + type: 'plan_update', + update: { + items: [ + { text: 'Plan from wrapped event', status: 'completed' } + ] + } + } + }); + + expect(events).toEqual([{ + type: 'plan_update', + plan: [ + { step: 'Plan from wrapped event', status: 'completed' } + ] + }]); + }); + it('maps diff updates', () => { const converter = new AppServerEventConverter(); diff --git a/cli/src/codex/utils/appServerEventConverter.ts b/cli/src/codex/utils/appServerEventConverter.ts index 667f9272..79038233 100644 --- a/cli/src/codex/utils/appServerEventConverter.ts +++ b/cli/src/codex/utils/appServerEventConverter.ts @@ -123,6 +123,50 @@ function extractReasoningText(item: Record): string | null { return null; } +function normalizePlanStatus(value: unknown): 'pending' | 'in_progress' | 'completed' { + const raw = typeof value === 'string' ? value.trim().toLowerCase().replace(/[\s-]/g, '_') : ''; + if (raw === 'completed' || raw === 'complete' || raw === 'done') return 'completed'; + if (raw === 'in_progress' || raw === 'inprogress' || raw === 'active' || raw === 'running') return 'in_progress'; + return 'pending'; +} + +function extractPlanEntries(value: unknown): Array<{ step: string; status: 'pending' | 'in_progress' | 'completed' }> { + const record = asRecord(value); + const entries = Array.isArray(value) + ? value + : Array.isArray(record?.plan) + ? record.plan + : Array.isArray(record?.items) + ? record.items + : Array.isArray(record?.steps) + ? record.steps + : []; + + const plan: Array<{ step: string; status: 'pending' | 'in_progress' | 'completed' }> = []; + for (const entry of entries) { + if (typeof entry === 'string') { + plan.push({ step: entry, status: 'pending' }); + continue; + } + const item = asRecord(entry); + if (!item) continue; + const step = asString(item.step ?? item.content ?? item.text ?? item.title ?? item.description); + if (!step) continue; + plan.push({ + step, + status: normalizePlanStatus(item.status ?? item.state) + }); + } + return plan; +} + +function extractPlanUpdate(params: Record): ConvertedEvent[] { + const plan = extractPlanEntries( + params.plan ?? params.update ?? params.items ?? params.steps ?? params + ); + return plan.length > 0 ? [{ type: 'plan_update', plan }] : []; +} + export class AppServerEventConverter { private readonly agentMessageBuffers = new Map(); private readonly reasoningBuffers = new Map(); @@ -228,10 +272,13 @@ export class AppServerEventConverter { return error ? [{ type: 'task_failed', error }] : []; } + if (msgType === 'plan_update') { + return extractPlanUpdate(msg); + } + if ( msgType === 'mcp_startup_update' || msgType === 'mcp_startup_complete' || - msgType === 'plan_update' || msgType === 'skills_update_available' || msgType === 'stream_error' || msgType === 'warning' || @@ -253,7 +300,11 @@ export class AppServerEventConverter { return this.handleWrappedCodexEvent(paramsRecord) ?? events; } - if (method === 'account/rateLimits/updated' || method === 'turn/plan/updated' || method === 'thread/compacted') { + if (method === 'turn/plan/updated') { + return extractPlanUpdate(paramsRecord); + } + + if (method === 'account/rateLimits/updated' || method === 'thread/compacted') { return events; } @@ -484,6 +535,39 @@ export class AppServerEventConverter { return events; } + if (itemType === 'mcptoolcall') { + const server = asString(item.server ?? item.serverName ?? item.server_name); + const tool = asString(item.tool ?? item.toolName ?? item.tool_name ?? item.name); + const input = item.arguments ?? item.input ?? {}; + + if (method === 'item/started') { + events.push({ + type: 'mcp_tool_call_begin', + call_id: itemId, + server, + tool, + invocation: { + server, + tool, + arguments: input + } + }); + } + + if (method === 'item/completed') { + const error = item.error; + events.push({ + type: 'mcp_tool_call_end', + call_id: itemId, + server, + tool, + result: error ? { Err: error } : item.result + }); + } + + return events; + } + if (itemType === 'filechange') { if (method === 'item/started') { const changes = extractChanges(item.changes ?? item.change ?? item.diff); diff --git a/package.json b/package.json index 8cc9e91a..2e42c216 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ }, "devDependencies": { "concurrently": "^9.2.1", + "playwright": "1.49.1", "react-devtools-core": "^7.0.1", "vite-plugin-pwa": "^1.2.0" } diff --git a/scripts/dev/read-hapi-web.mjs b/scripts/dev/read-hapi-web.mjs new file mode 100755 index 00000000..8b899527 --- /dev/null +++ b/scripts/dev/read-hapi-web.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +import { chromium } from 'playwright' +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' + +function parseArgs(argv) { + const args = { expects: [], timeout: 15000, out: '', screenshot: '' } + const positional = [] + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i] + if (arg === '--expect' || arg === '--wait-text') { + args.expects.push(argv[++i]) + } else if (arg === '--timeout') { + args.timeout = Number(argv[++i]) + } else if (arg === '--out') { + args.out = argv[++i] + } else if (arg === '--screenshot') { + args.screenshot = argv[++i] + } else { + positional.push(arg) + } + } + if (!positional[0]) { + throw new Error('usage: read-hapi-web.mjs [--expect TEXT] [--out FILE] [--screenshot FILE] [--timeout MS]') + } + return { ...args, url: positional[0] } +} + +const args = parseArgs(process.argv.slice(2)) +const browser = await chromium.launch({ headless: true }) +const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } }) +const consoleMessages = [] +const failedRequests = [] +page.on('console', (msg) => consoleMessages.push(`${msg.type()}: ${msg.text()}`)) +page.on('requestfailed', (request) => failedRequests.push(`${request.method()} ${request.url()} ${request.failure()?.errorText ?? ''}`)) + +try { + await page.goto(args.url, { waitUntil: 'domcontentloaded', timeout: args.timeout }) + for (const expected of args.expects) { + await page.getByText(expected, { exact: false }).first().waitFor({ timeout: args.timeout }) + } + const text = await page.locator('body').innerText({ timeout: args.timeout }).catch(async () => await page.textContent('body') ?? '') + const html = await page.locator('body').evaluate((node) => node.innerHTML).catch(() => '') + const result = { + ok: args.expects.every((expected) => text.includes(expected)), + url: page.url().replace(/([?&]token=)[^&]+/g, '$1'), + title: await page.title(), + text, + textLength: text.length, + htmlLength: html.length, + expects: args.expects.map((expected) => ({ text: expected, found: text.includes(expected) })), + consoleMessages, + failedRequests + } + if (args.out) { + const out = resolve(args.out) + mkdirSync(dirname(out), { recursive: true }) + writeFileSync(out, text) + } + if (args.screenshot) { + const screenshot = resolve(args.screenshot) + mkdirSync(dirname(screenshot), { recursive: true }) + await page.screenshot({ path: screenshot, fullPage: true }) + result.screenshot = screenshot + } + console.log(JSON.stringify(result, null, 2)) + if (!result.ok) process.exitCode = 2 +} catch (error) { + if (args.screenshot) { + const screenshot = resolve(args.screenshot) + mkdirSync(dirname(screenshot), { recursive: true }) + await page.screenshot({ path: screenshot, fullPage: true }).catch(() => {}) + } + console.error(JSON.stringify({ + ok: false, + error: error instanceof Error ? error.message : String(error), + url: page.url().replace(/([?&]token=)[^&]+/g, '$1'), + consoleMessages, + failedRequests + }, null, 2)) + process.exitCode = 1 +} finally { + await browser.close() +} diff --git a/scripts/dev/seed-codex-web-fixture.ts b/scripts/dev/seed-codex-web-fixture.ts new file mode 100755 index 00000000..cc68e396 --- /dev/null +++ b/scripts/dev/seed-codex-web-fixture.ts @@ -0,0 +1,159 @@ +#!/usr/bin/env bun +import { Database } from 'bun:sqlite' +import { mkdirSync, rmSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { Store } from '../../hub/src/store' + +function argValue(name: string, fallback?: string): string | undefined { + const prefix = `${name}=` + const directIndex = process.argv.indexOf(name) + if (directIndex >= 0) return process.argv[directIndex + 1] + const direct = process.argv.find((arg) => arg.startsWith(prefix)) + return direct ? direct.slice(prefix.length) : fallback +} + +const dbPath = resolve(argValue('--db', process.env.DB_PATH ?? '/tmp/hapi-dev-codex-web/hapi.db')!) +const reset = process.argv.includes('--reset') +const namespace = argValue('--namespace', 'default')! +const tag = argValue('--tag', 'codex-web-fixture')! +const now = Date.now() + +mkdirSync(dirname(dbPath), { recursive: true }) +if (reset) { + rmSync(dbPath, { force: true }) + rmSync(`${dbPath}-wal`, { force: true }) + rmSync(`${dbPath}-shm`, { force: true }) +} + +const store = new Store(dbPath) +const session = store.sessions.getOrCreateSession(tag, { + path: '/tmp/hapi-fixture-workspace', + host: 'hapi-dev-fixture', + version: 'dev', + flavor: 'codex', + codexSessionId: 'codex-fixture-session', + name: 'Codex Web Fixture' +}, { + controlledByUser: false, + requests: {}, + completedRequests: {} +}, namespace, 'gpt-5.4', undefined, 'xhigh') + +const messages = [ + { + localId: 'fixture-user-1', + content: { + role: 'user', + content: { type: 'text', text: 'Fixture request: show Codex reasoning, MCP calls, and plan status.' }, + meta: { sentFrom: 'fixture' } + } + }, + { + localId: 'fixture-agent-message', + content: { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'message', + message: 'Codex fixture response visible in HAPI Web.', + id: 'fixture-msg-1' + } + }, + meta: { sentFrom: 'fixture' } + } + }, + { + localId: 'fixture-reasoning', + content: { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'reasoning', + message: 'Fixture reasoning detail: inspect event normalization before rendering.', + id: 'fixture-reasoning-1' + } + }, + meta: { sentFrom: 'fixture' } + } + }, + { + localId: 'fixture-mcp-call', + content: { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'tool-call', + name: 'mcp__fixture__lookup_context', + callId: 'fixture-mcp-call-1', + input: { + server: 'fixture-mcp-server', + tool: 'lookup_context', + query: 'codex plan visibility' + }, + id: 'fixture-tool-1' + } + }, + meta: { sentFrom: 'fixture' } + } + }, + { + localId: 'fixture-mcp-result', + content: { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'tool-call-result', + callId: 'fixture-mcp-call-1', + output: { + server: 'fixture-mcp-server', + tool: 'lookup_context', + result: 'Fixture MCP result visible in Web.' + }, + id: 'fixture-tool-result-1' + } + }, + meta: { sentFrom: 'fixture' } + } + }, + { + localId: 'fixture-plan-update', + content: { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'plan_update', + plan: [ + { step: 'Inspect event stream', status: 'completed' }, + { step: 'Render plan card', status: 'in_progress' }, + { step: 'Verify web DOM', status: 'pending' } + ], + id: 'fixture-plan-update-1' + } + }, + meta: { sentFrom: 'fixture' } + } + } +] + +for (const message of messages) { + store.messages.addMessage(session.id, message.content, message.localId) +} + +// Make the fixture visually prominent in session lists without needing a live CLI heartbeat. +const db = new Database(dbPath) +db.prepare('UPDATE sessions SET active = 1, active_at = ?, updated_at = ? WHERE id = ?').run(now, now, session.id) +db.close() + +console.log(JSON.stringify({ + dbPath, + sessionId: session.id, + namespace, + tag, + urlPath: `/sessions/${session.id}`, + messageCount: messages.length +}, null, 2)) diff --git a/scripts/dev/self-test-codex-web-env.sh b/scripts/dev/self-test-codex-web-env.sh new file mode 100755 index 00000000..a12cca1e --- /dev/null +++ b/scripts/dev/self-test-codex-web-env.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +export PATH="$HOME/.bun/bin:$PATH" +export HAPI_DEV_HOME="${HAPI_DEV_HOME:-/tmp/hapi-dev-codex-web}" +export HAPI_DEV_TOKEN="${HAPI_DEV_TOKEN:-hapi-dev-token}" +export HAPI_DEV_HUB_PORT="${HAPI_DEV_HUB_PORT:-3106}" +export HAPI_DEV_WEB_PORT="${HAPI_DEV_WEB_PORT:-5174}" +ARTIFACT_DIR="${ARTIFACT_DIR:-/tmp/hapi-dev-codex-web-artifacts}" +mkdir -p "$ARTIFACT_DIR" + +if ss -ltn | grep -q ":${HAPI_DEV_HUB_PORT} "; then + echo "port ${HAPI_DEV_HUB_PORT} already in use" >&2 + exit 2 +fi +if ss -ltn | grep -q ":${HAPI_DEV_WEB_PORT} "; then + echo "port ${HAPI_DEV_WEB_PORT} already in use" >&2 + exit 2 +fi + +rm -rf "$HAPI_DEV_HOME" +mkdir -p "$HAPI_DEV_HOME" +SEED_JSON="$ARTIFACT_DIR/seed.json" +WEB_JSON="$ARTIFACT_DIR/web-read.json" +WEB_TEXT="$ARTIFACT_DIR/web-visible.txt" +SCREENSHOT="$ARTIFACT_DIR/web.png" +HUB_LOG="$ARTIFACT_DIR/hub.log" +WEB_LOG="$ARTIFACT_DIR/web.log" + +cd "$ROOT" +bun scripts/dev/seed-codex-web-fixture.ts --db "$HAPI_DEV_HOME/hapi.db" --reset > "$SEED_JSON" +SESSION_ID="$(node -e "console.log(JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')).sessionId)" "$SEED_JSON")" + +cleanup() { + if [[ -n "${HUB_PID:-}" ]]; then kill "$HUB_PID" 2>/dev/null || true; fi + if [[ -n "${WEB_PID:-}" ]]; then kill "$WEB_PID" 2>/dev/null || true; fi +} +trap cleanup EXIT + +CLI_API_TOKEN="$HAPI_DEV_TOKEN" \ +HAPI_HOME="$HAPI_DEV_HOME" \ +DB_PATH="$HAPI_DEV_HOME/hapi.db" \ +HAPI_LISTEN_HOST=127.0.0.1 \ +HAPI_LISTEN_PORT="$HAPI_DEV_HUB_PORT" \ +bun run dev:hub > "$HUB_LOG" 2>&1 & +HUB_PID=$! + +for _ in {1..80}; do + if curl -fsS "http://127.0.0.1:${HAPI_DEV_HUB_PORT}/api/auth" \ + -H 'content-type: application/json' \ + -d "{\"accessToken\":\"$HAPI_DEV_TOKEN\"}" >/dev/null 2>&1; then + break + fi + sleep 0.25 +done + +VITE_HUB_PROXY="http://127.0.0.1:${HAPI_DEV_HUB_PORT}" \ +bun --cwd web vite --host 127.0.0.1 --port "$HAPI_DEV_WEB_PORT" --strictPort --force > "$WEB_LOG" 2>&1 & +WEB_PID=$! + +for _ in {1..80}; do + if curl -fsS "http://127.0.0.1:${HAPI_DEV_WEB_PORT}/" >/dev/null 2>&1; then + break + fi + sleep 0.25 +done + +node scripts/dev/read-hapi-web.mjs \ + "http://127.0.0.1:${HAPI_DEV_WEB_PORT}/sessions/${SESSION_ID}?token=${HAPI_DEV_TOKEN}" \ + --expect "Codex fixture response visible" \ + --expect "Fixture reasoning detail" \ + --expect "MCP: Fixture Lookup Context" \ + --expect "Inspect event stream" \ + --expect "Render plan card" \ + --expect "Verify web DOM" \ + --out "$WEB_TEXT" \ + --screenshot "$SCREENSHOT" \ + --timeout 20000 > "$WEB_JSON" + +node -e "const j=JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')); console.log(JSON.stringify({ok:j.ok, textLength:j.textLength, failedRequests:j.failedRequests.length, url:j.url}, null, 2))" "$WEB_JSON" +echo "artifacts: $ARTIFACT_DIR" diff --git a/web/src/chat/modelConfig.test.ts b/web/src/chat/modelConfig.test.ts index f3d14de7..308ff152 100644 --- a/web/src/chat/modelConfig.test.ts +++ b/web/src/chat/modelConfig.test.ts @@ -10,7 +10,11 @@ describe('getContextBudgetTokens', () => { expect(getContextBudgetTokens('claude-sonnet-4-6', 'claude')).toBe(190_000) }) - it('returns null for non-Claude sessions', () => { - expect(getContextBudgetTokens('gpt-5.4', 'codex')).toBeNull() + it('uses Codex app-server context window with headroom', () => { + expect(getContextBudgetTokens('gpt-5.4', 'codex')).toBe(248_400) + }) + + it('returns null for unknown non-Claude sessions', () => { + expect(getContextBudgetTokens('gemini-3-pro', 'gemini')).toBeNull() }) }) diff --git a/web/src/chat/modelConfig.ts b/web/src/chat/modelConfig.ts index ed262d20..47542c10 100644 --- a/web/src/chat/modelConfig.ts +++ b/web/src/chat/modelConfig.ts @@ -13,8 +13,15 @@ import { isClaudeModelPreset } from '@hapi/protocol' const CONTEXT_HEADROOM_TOKENS = 10_000 const DEFAULT_CLAUDE_CONTEXT_WINDOW_TOKENS = 200_000 const LARGE_CLAUDE_CONTEXT_WINDOW_TOKENS = 1_000_000 +// Fallback for Codex sessions when the server has not reported an explicit modelContextWindow. +// The value matches the context window currently reported by Codex App Server token-count events. +const DEFAULT_CODEX_CONTEXT_WINDOW_TOKENS = 258_400 export function getContextBudgetTokens(model: string | null | undefined, flavor?: string | null): number | null { + if (flavor === 'codex') { + return Math.max(1, DEFAULT_CODEX_CONTEXT_WINDOW_TOKENS - CONTEXT_HEADROOM_TOKENS) + } + if (flavor !== 'claude') { return null } diff --git a/web/src/chat/normalize.test.ts b/web/src/chat/normalize.test.ts index 3fc94709..880bec24 100644 --- a/web/src/chat/normalize.test.ts +++ b/web/src/chat/normalize.test.ts @@ -372,4 +372,117 @@ describe('normalizeDecryptedMessage', () => { prompt: 'Some subagent text' }) }) + + it('preserves Codex tool-call-result errors for timeline state', () => { + const message = makeMessage({ + role: 'agent', + content: { + type: 'codex', + data: { + type: 'tool-call-result', + callId: 'call-1', + output: 'tool failed', + is_error: true, + id: 'result-1' + } + } + }) + + const normalized = normalizeDecryptedMessage(message) + + expect(normalized).toMatchObject({ + role: 'agent', + content: [ + { + type: 'tool-result', + tool_use_id: 'call-1', + content: 'tool failed', + is_error: true + } + ] + }) + }) + + it('normalizes Codex plan updates as completed update_plan snapshots', () => { + const message = makeMessage({ + role: 'agent', + content: { + type: 'codex', + data: { + type: 'plan_update', + plan: [ + { step: 'Inspect event stream', status: 'completed' }, + { step: 'Render plan card', status: 'in_progress' } + ], + id: 'plan-update-1' + } + } + }) + + const normalized = normalizeDecryptedMessage(message) + + expect(normalized).toMatchObject({ + role: 'agent', + content: [ + { + type: 'tool-call', + id: 'codex-plan-state', + name: 'update_plan', + input: { + plan: [ + { step: 'Inspect event stream', status: 'completed' }, + { step: 'Render plan card', status: 'in_progress' } + ], + source: 'codex' + } + }, + { + type: 'tool-result', + tool_use_id: 'codex-plan-state', + content: { + plan: [ + { step: 'Inspect event stream', status: 'completed' }, + { step: 'Render plan card', status: 'in_progress' } + ], + source: 'codex', + status: 'updated' + } + } + ] + }) + }) + + it('normalizes Codex token_count as usage data for context display', () => { + const message = makeMessage({ + role: 'agent', + content: { + type: 'codex', + data: { + type: 'token_count', + info: { + total: { + inputTokens: 82_503, + cachedInputTokens: 71_808, + outputTokens: 166 + }, + modelContextWindow: 258_400 + } + } + } + }) + + const normalized = normalizeDecryptedMessage(message) + + expect(normalized).toMatchObject({ + role: 'event', + content: { + type: 'token-count' + }, + usage: { + input_tokens: 82503, + output_tokens: 166 + } + }) + }) + }) diff --git a/web/src/chat/normalizeAgent.ts b/web/src/chat/normalizeAgent.ts index cd9bcdb9..74c257c0 100644 --- a/web/src/chat/normalizeAgent.ts +++ b/web/src/chat/normalizeAgent.ts @@ -32,6 +32,74 @@ function normalizeAgentEvent(value: unknown): AgentEvent | null { return value as AgentEvent } +function normalizeCodexTokenUsage(value: unknown) { + const info = isObject(value) ? value : null + if (!info) return null + // Codex reports both: + // - `total`: cumulative usage for the whole session (can be millions). + // - `last`: current turn/request usage, which matches the live context bar. + // Prefer `last`; falling back to `total` keeps older payloads working. + const usageSource = isObject(info.last) + ? info.last + : isObject(info.total) + ? info.total + : info + const inputTokens = asNumber(usageSource.inputTokens ?? usageSource.input_tokens) + const outputTokens = asNumber(usageSource.outputTokens ?? usageSource.output_tokens) + if (inputTokens === null || outputTokens === null) return null + + return { + input_tokens: inputTokens, + output_tokens: outputTokens, + // Codex `inputTokens` already includes cached input tokens; expose cache + // hits for display, but use `context_tokens` to avoid double-counting. + cache_creation_input_tokens: undefined, + cache_read_input_tokens: asNumber(usageSource.cachedInputTokens ?? usageSource.cacheReadInputTokens ?? usageSource.cache_read_input_tokens) ?? undefined, + context_tokens: inputTokens, + context_window: asNumber(info.modelContextWindow ?? info.model_context_window) ?? undefined + } +} + +function normalizePlanStatus(value: unknown): 'pending' | 'in_progress' | 'completed' { + const raw = typeof value === 'string' ? value.trim().toLowerCase().replace(/[\s-]/g, '_') : '' + if (raw === 'completed' || raw === 'complete' || raw === 'done') return 'completed' + if (raw === 'in_progress' || raw === 'inprogress' || raw === 'active' || raw === 'running') return 'in_progress' + return 'pending' +} + +function normalizePlanEntries(value: unknown): Array<{ step: string; status: 'pending' | 'in_progress' | 'completed' }> { + const record = isObject(value) ? value : null + const entries = Array.isArray(value) + ? value + : Array.isArray(record?.plan) + ? record.plan + : Array.isArray(record?.items) + ? record.items + : Array.isArray(record?.steps) + ? record.steps + : [] + + const plan: Array<{ step: string; status: 'pending' | 'in_progress' | 'completed' }> = [] + for (const entry of entries) { + if (typeof entry === 'string') { + plan.push({ step: entry, status: 'pending' }) + continue + } + if (!isObject(entry)) continue + const step = asString(entry.step) + ?? asString(entry.content) + ?? asString(entry.text) + ?? asString(entry.title) + ?? asString(entry.description) + if (!step) continue + plan.push({ + step, + status: normalizePlanStatus(entry.status ?? entry.state) + }) + } + return plan +} + function normalizeAssistantOutput( messageId: string, localId: string | null, @@ -375,6 +443,23 @@ export function normalizeAgentRecord( } } + if (data.type === 'token_count') { + const usage = normalizeCodexTokenUsage(data.info) + return usage ? { + id: messageId, + localId, + createdAt, + role: 'event', + content: { + type: 'token-count', + info: data.info + }, + isSidechain: false, + meta, + usage + } : null + } + if (data.type === 'tool-call' && typeof data.callId === 'string') { const uuid = asString(data.id) ?? messageId return { @@ -408,13 +493,53 @@ export function normalizeAgentRecord( type: 'tool-result', tool_use_id: data.callId, content: data.output, - is_error: false, + is_error: Boolean(data.is_error), uuid, parentUUID: null }], meta } } + + if (data.type === 'plan_update') { + const plan = normalizePlanEntries(data.plan ?? data.update ?? data.items ?? data.steps ?? data) + if (plan.length === 0) return null + const uuid = asString(data.id) ?? messageId + return { + id: messageId, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [ + { + type: 'tool-call', + id: 'codex-plan-state', + name: 'update_plan', + input: { + plan, + source: 'codex' + }, + description: null, + uuid, + parentUUID: null + }, + { + type: 'tool-result', + tool_use_id: 'codex-plan-state', + content: { + plan, + source: 'codex', + status: 'updated' + }, + is_error: false, + uuid: `${uuid}:result`, + parentUUID: null + } + ], + meta + } + } } return null diff --git a/web/src/chat/presentation.test.ts b/web/src/chat/presentation.test.ts index ebf24ddc..10f608cb 100644 --- a/web/src/chat/presentation.test.ts +++ b/web/src/chat/presentation.test.ts @@ -75,6 +75,27 @@ describe('getEventPresentation — limit-reached', () => { }) }) +describe('getEventPresentation — token-count', () => { + it('formats Codex token-count as compact context usage', () => { + const result = getEventPresentation({ + type: 'token-count', + info: { + total: { + totalTokens: 23745, + inputTokens: 23631, + cachedInputTokens: 18176, + outputTokens: 114, + reasoningOutputTokens: 0 + }, + modelContextWindow: 258400 + } + }) + + expect(result.icon).toBe('◷') + expect(result.text).toBe('Context 23.6k / 258.4k (9%) · out 114 · cached 18.2k') + }) +}) + describe('formatResetTime', () => { it('formats a unix timestamp to a non-empty string', () => { const result = formatResetTime(1774278000) diff --git a/web/src/chat/presentation.ts b/web/src/chat/presentation.ts index 998ab377..8058fa9f 100644 --- a/web/src/chat/presentation.ts +++ b/web/src/chat/presentation.ts @@ -43,6 +43,49 @@ function formatDuration(ms: number): string { return `${mins}m ${secs}s` } +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' ? value as Record : null +} + +function asNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function formatTokenCount(value: number): string { + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M` + if (value >= 10_000) return `${(value / 1_000).toFixed(1)}k` + if (value >= 1_000) return `${Math.round(value / 1_000)}k` + return String(value) +} + +function formatTokenCountEvent(event: AgentEvent): EventPresentation { + const info = asRecord((event as Record).info) + const total = asRecord(info?.total) ?? info + if (!total) return { icon: '◷', text: 'Context updated' } + + const inputTokens = asNumber(total.inputTokens ?? total.input_tokens) + const outputTokens = asNumber(total.outputTokens ?? total.output_tokens) + const cachedTokens = asNumber(total.cachedInputTokens ?? total.cacheReadInputTokens ?? total.cache_read_input_tokens) + const reasoningTokens = asNumber(total.reasoningOutputTokens ?? total.reasoning_output_tokens) + const contextWindow = asNumber(info?.modelContextWindow ?? info?.model_context_window) + + const parts: string[] = [] + if (inputTokens !== null && contextWindow !== null) { + const pct = Math.round((inputTokens / contextWindow) * 100) + parts.push(`Context ${formatTokenCount(inputTokens)} / ${formatTokenCount(contextWindow)} (${pct}%)`) + } else if (inputTokens !== null) { + parts.push(`Context ${formatTokenCount(inputTokens)}`) + } else { + parts.push('Context updated') + } + + if (outputTokens !== null) parts.push(`out ${formatTokenCount(outputTokens)}`) + if (cachedTokens !== null && cachedTokens > 0) parts.push(`cached ${formatTokenCount(cachedTokens)}`) + if (reasoningTokens !== null && reasoningTokens > 0) parts.push(`reasoning ${formatTokenCount(reasoningTokens)}`) + + return { icon: '◷', text: parts.join(' · ') } +} + export type EventPresentation = { icon: string | null text: string @@ -105,6 +148,9 @@ export function getEventPresentation(event: AgentEvent): EventPresentation { if (event.type === 'compact') { return { icon: '📦', text: 'Conversation compacted' } } + if (event.type === 'token-count') { + return formatTokenCountEvent(event) + } try { return { icon: null, text: JSON.stringify(event) } } catch { diff --git a/web/src/chat/reducer.ts b/web/src/chat/reducer.ts index 798499c6..0aa278e0 100644 --- a/web/src/chat/reducer.ts +++ b/web/src/chat/reducer.ts @@ -7,6 +7,9 @@ import { reduceTimeline } from '@/chat/reducerTimeline' // Calculate context size from usage data function calculateContextSize(usage: UsageData): number { + if (typeof usage.context_tokens === 'number') { + return usage.context_tokens + } return (usage.cache_creation_input_tokens || 0) + (usage.cache_read_input_tokens || 0) + usage.input_tokens } @@ -16,6 +19,7 @@ export type LatestUsage = { cacheCreation: number cacheRead: number contextSize: number + contextWindow: number | null timestamp: number } @@ -101,6 +105,7 @@ export function reduceChatBlocks( cacheCreation: msg.usage.cache_creation_input_tokens ?? 0, cacheRead: msg.usage.cache_read_input_tokens ?? 0, contextSize: calculateContextSize(msg.usage), + contextWindow: msg.usage.context_window ?? null, timestamp: msg.createdAt } break diff --git a/web/src/chat/reducerTimeline.ts b/web/src/chat/reducerTimeline.ts index e434af97..f7fd9b31 100644 --- a/web/src/chat/reducerTimeline.ts +++ b/web/src/chat/reducerTimeline.ts @@ -38,6 +38,9 @@ export function reduceTimeline( hasReadyEvent = true continue } + if (msg.content.type === 'token-count') { + continue + } blocks.push({ kind: 'agent-event', id: msg.id, diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index 7261c933..1944ed02 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -5,6 +5,8 @@ export type UsageData = { output_tokens: number cache_creation_input_tokens?: number cache_read_input_tokens?: number + context_tokens?: number + context_window?: number service_tier?: string } diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index b596f651..b5d34da4 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -54,6 +54,8 @@ export function HappyComposer(props: { agentState?: AgentState | null backgroundTaskCount?: number contextSize?: number + contextCacheRead?: number + contextWindow?: number | null controlledByUser?: boolean agentFlavor?: string | null availableModelOptions?: Array<{ value: string | null; label: string }> @@ -88,6 +90,8 @@ export function HappyComposer(props: { agentState, backgroundTaskCount, contextSize, + contextCacheRead, + contextWindow, controlledByUser = false, agentFlavor, availableModelOptions, @@ -754,7 +758,10 @@ export function HappyComposer(props: { agentState={agentState} backgroundTaskCount={backgroundTaskCount} contextSize={contextSize} + contextCacheRead={contextCacheRead} + contextWindow={contextWindow} model={model} + modelReasoningEffort={modelReasoningEffort} permissionMode={permissionMode} collaborationMode={collaborationMode} agentFlavor={agentFlavor} diff --git a/web/src/components/AssistantChat/StatusBar.tsx b/web/src/components/AssistantChat/StatusBar.tsx index 4527311b..879a570c 100644 --- a/web/src/components/AssistantChat/StatusBar.tsx +++ b/web/src/components/AssistantChat/StatusBar.tsx @@ -116,13 +116,38 @@ function getContextWarning(contextSize: number, maxContextSize: number, t: (key: } } +function formatTokenCount(value: number): string { + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M` + if (value >= 1_000) return `${Math.round(value / 1_000)}k` + return String(value) +} + +function formatCodexReasoningLabel(effort?: string | null): string { + const normalized = effort?.trim().toLowerCase() + if (!normalized || normalized === 'default') return 'reasoning default' + return `reasoning ${normalized}` +} + +function isCodexFastMode(model?: string | null, effort?: string | null): boolean { + const normalizedEffort = effort?.trim().toLowerCase() + if (normalizedEffort === 'none' || normalizedEffort === 'minimal' || normalizedEffort === 'low') { + return true + } + + const normalizedModel = model?.trim().toLowerCase() ?? '' + return normalizedModel.includes('mini') || normalizedModel.includes('fast') +} + export function StatusBar(props: { active: boolean thinking: boolean agentState: AgentState | null | undefined backgroundTaskCount?: number contextSize?: number + contextCacheRead?: number + contextWindow?: number | null model?: string | null + modelReasoningEffort?: string | null permissionMode?: PermissionMode collaborationMode?: CodexCollaborationMode agentFlavor?: string | null @@ -137,12 +162,23 @@ export function StatusBar(props: { const contextWarning = useMemo( () => { if (props.contextSize === undefined) return null - const maxContextSize = getContextBudgetTokens(props.model, props.agentFlavor) + const maxContextSize = props.contextWindow ?? getContextBudgetTokens(props.model, props.agentFlavor) if (!maxContextSize) return null return getContextWarning(props.contextSize, maxContextSize, t) }, - [props.contextSize, props.model, props.agentFlavor, t] + [props.contextSize, props.contextWindow, props.model, props.agentFlavor, t] ) + const contextUsageLabel = useMemo(() => { + if (props.contextSize === undefined) return null + const maxContextSize = props.contextWindow ?? getContextBudgetTokens(props.model, props.agentFlavor) + if (!maxContextSize) return `ctx ${formatTokenCount(props.contextSize)}` + const percentageUsed = Math.min(100, Math.round((props.contextSize / maxContextSize) * 100)) + return `ctx ${formatTokenCount(props.contextSize)}/${formatTokenCount(maxContextSize)} (${percentageUsed}%)` + }, [props.contextSize, props.contextWindow, props.model, props.agentFlavor]) + const cacheHitLabel = useMemo(() => { + if (!props.contextCacheRead || props.contextCacheRead <= 0) return null + return `cache ${formatTokenCount(props.contextCacheRead)}` + }, [props.contextCacheRead]) const permissionMode = props.permissionMode const displayPermissionMode = permissionMode @@ -160,6 +196,12 @@ export function StatusBar(props: { const collaborationModeLabel = displayCollaborationMode ? getCodexCollaborationModeLabel(displayCollaborationMode) : null + const codexReasoningLabel = props.agentFlavor === 'codex' + ? formatCodexReasoningLabel(props.modelReasoningEffort) + : null + const codexFastMode = props.agentFlavor === 'codex' + ? isCodexFastMode(props.model, props.modelReasoningEffort) + : false return (
@@ -172,14 +214,29 @@ export function StatusBar(props: { {connectionStatus.text}
- {contextWarning ? ( - - {contextWarning.text} + {contextUsageLabel ? ( + + {contextUsageLabel}{contextWarning ? ` · ${contextWarning.text}` : ''} + + ) : null} + {cacheHitLabel ? ( + + {cacheHitLabel} ) : null} -
+
+ {codexReasoningLabel ? ( + + {codexReasoningLabel} + + ) : null} + {codexFastMode ? ( + + fast + + ) : null} {collaborationModeLabel ? ( {collaborationModeLabel} diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index a8f5d5ef..a27cbbf9 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -443,6 +443,8 @@ export function SessionChat(props: { agentState={props.session.agentState} backgroundTaskCount={props.session.backgroundTaskCount} contextSize={reduced.latestUsage?.contextSize} + contextCacheRead={reduced.latestUsage?.cacheRead} + contextWindow={reduced.latestUsage?.contextWindow} controlledByUser={controlledByUser} onCollaborationModeChange={ codexCollaborationModeSupported && props.session.active && !controlledByUser diff --git a/web/src/components/ToolCard/checklist.tsx b/web/src/components/ToolCard/checklist.tsx index 6d4e8f8a..88e8f1ac 100644 --- a/web/src/components/ToolCard/checklist.tsx +++ b/web/src/components/ToolCard/checklist.tsx @@ -85,6 +85,7 @@ function checklistTone(item: ChecklistItem): string { function checklistIcon(item: ChecklistItem): ReactNode { if (item.status === 'completed') return '☑' + if (item.status === 'in_progress') return '◉' return '☐' } diff --git a/web/src/components/ToolCard/knownTools.tsx b/web/src/components/ToolCard/knownTools.tsx index 253b9bd8..4b6ac59d 100644 --- a/web/src/components/ToolCard/knownTools.tsx +++ b/web/src/components/ToolCard/knownTools.tsx @@ -159,7 +159,12 @@ export const knownTools: Record { + const result = isObject(opts.result) ? opts.result : null + const stdout = result && typeof result.stdout === 'string' ? result.stdout.trim() : '' + const stderr = result && typeof result.stderr === 'string' ? result.stderr.trim() : '' + return stdout.length === 0 && stderr.length === 0 + } }, CodexPermission: { icon: () => , diff --git a/web/src/components/ToolCard/views/_results.test.tsx b/web/src/components/ToolCard/views/_results.test.tsx index 05158961..df24a766 100644 --- a/web/src/components/ToolCard/views/_results.test.tsx +++ b/web/src/components/ToolCard/views/_results.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { extractTextFromResult, getMutationResultRenderMode, getToolResultViewComponent } from '@/components/ToolCard/views/_results' +import { extractCodexBashDisplay, extractTextFromResult, getMutationResultRenderMode, getToolResultViewComponent } from '@/components/ToolCard/views/_results' describe('extractTextFromResult', () => { it('returns string directly', () => { @@ -79,6 +79,35 @@ describe('getMutationResultRenderMode', () => { }) }) +describe('extractCodexBashDisplay', () => { + it('prefers stdout and keeps command metadata out of displayed output', () => { + expect(extractCodexBashDisplay({ + command: '/bin/bash -lc pwd', + cwd: '/tmp/project', + stdout: '/tmp/project\n', + exit_code: 0, + status: 'completed' + })).toEqual({ + stdout: '/tmp/project\n', + stderr: null, + exitCode: 0, + status: 'completed' + }) + }) + + it('accepts legacy output as stdout fallback', () => { + expect(extractCodexBashDisplay({ + output: 'ok\n', + exitCode: 0 + })).toEqual({ + stdout: 'ok\n', + stderr: null, + exitCode: 0, + status: null + }) + }) +}) + describe('getToolResultViewComponent registry', () => { it('uses the same view for Write, Edit, MultiEdit, NotebookEdit', () => { const writeView = getToolResultViewComponent('Write') @@ -96,4 +125,8 @@ describe('getToolResultViewComponent registry', () => { // Both should fall back to GenericResultView expect(mcpView).toBe(unknownView) }) + + it('uses a dedicated result view for CodexBash', () => { + expect(getToolResultViewComponent('CodexBash')).not.toBe(getToolResultViewComponent('SomeUnknownTool')) + }) }) diff --git a/web/src/components/ToolCard/views/_results.tsx b/web/src/components/ToolCard/views/_results.tsx index 889d50ca..4de12bed 100644 --- a/web/src/components/ToolCard/views/_results.tsx +++ b/web/src/components/ToolCard/views/_results.tsx @@ -92,6 +92,24 @@ interface CodexBashOutput { output: string } +export function extractCodexBashDisplay(result: unknown): { stdout: string | null; stderr: string | null; exitCode: number | null; status: string | null } | null { + if (!isObject(result)) return null + const stdout = typeof result.stdout === 'string' + ? result.stdout + : typeof result.output === 'string' + ? result.output + : null + const stderr = typeof result.stderr === 'string' ? result.stderr : null + const exitCode = typeof result.exit_code === 'number' + ? result.exit_code + : typeof result.exitCode === 'number' + ? result.exitCode + : null + const status = typeof result.status === 'string' ? result.status : null + if (stdout === null && stderr === null && exitCode === null && status === null) return null + return { stdout, stderr, exitCode, status } +} + function parseCodexBashOutput(text: string): CodexBashOutput | null { const exitMatch = text.match(/^Exit code:\s*(\d+)/m) const wallMatch = text.match(/^Wall time:\s*(.+)$/m) @@ -272,6 +290,38 @@ const BashResultView: ToolViewComponent = (props: ToolViewProps) => { ) } +const CodexBashResultView: ToolViewComponent = (props: ToolViewProps) => { + const result = props.block.tool.result + + if (result === undefined || result === null) { + return
{placeholderForState(props.block.tool.state)}
+ } + + const display = extractCodexBashDisplay(result) + if (display) { + const stdout = display.stdout?.trimEnd() ?? '' + const stderr = display.stderr?.trimEnd() ?? '' + return ( + <> +
+
+ {display.exitCode !== null ? `exit ${display.exitCode}` : display.status ?? 'completed'} +
+ {stdout ? : null} + {stderr ? : null} + {!stdout && !stderr ? ( +
+ {display.exitCode === 0 || display.status === 'completed' ? 'Done' : '(no output)'} +
+ ) : null} +
+ + + ) + } + + return +} const MarkdownResultView: ToolViewComponent = (props: ToolViewProps) => { const result = props.block.tool.result @@ -634,6 +684,7 @@ export const toolResultViewRegistry: Record = { NotebookRead: ReadResultView, NotebookEdit: MutationResultView, TodoWrite: TodoWriteResultView, + CodexBash: CodexBashResultView, CodexReasoning: CodexReasoningResultView, CodexPatch: CodexPatchResultView, CodexDiff: CodexDiffResultView,