Compare commits

...
37 changed files with 965 additions and 203 deletions
+44 -14
View File
@@ -1073,9 +1073,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.5.9",
"@opentui/keymap": "0.5.9",
"@opentui/solid": "0.5.9",
"@opentui/core": "https://pkg.pr.new/anomalyco/opentui/@opentui/core@dd9fe495b1be9c7d2558a08216c83d5c985ba643",
"@opentui/keymap": "https://pkg.pr.new/anomalyco/opentui/@opentui/keymap@dd9fe495b1be9c7d2558a08216c83d5c985ba643",
"@opentui/solid": "https://pkg.pr.new/anomalyco/opentui/@opentui/solid@dd9fe495b1be9c7d2558a08216c83d5c985ba643",
"@pierre/diffs": "1.2.10",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.71.0",
@@ -2212,27 +2212,27 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
"@opentui/core": ["@opentui/core@0.5.9", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.9", "@opentui/core-darwin-x64": "0.5.9", "@opentui/core-linux-arm64": "0.5.9", "@opentui/core-linux-arm64-musl": "0.5.9", "@opentui/core-linux-x64": "0.5.9", "@opentui/core-linux-x64-musl": "0.5.9", "@opentui/core-win32-arm64": "0.5.9", "@opentui/core-win32-x64": "0.5.9" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-d0EWYyp6djitu1N1R0o75NrLl4TxY3oJEmRNrX9vSKKC5/jriGQdSV6lJmwiB77O0cxtBnPzWztAV79vA1J2fA=="],
"@opentui/core": ["@opentui/core@https://pkg.pr.new/anomalyco/opentui/@opentui/core@dd9fe495b1be9c7d2558a08216c83d5c985ba643", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-darwin-arm64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-darwin-x64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-darwin-x64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-linux-arm64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-arm64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-linux-arm64-musl": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-arm64-musl@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-linux-x64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-x64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-linux-x64-musl": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-x64-musl@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-win32-arm64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-win32-arm64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "@opentui/core-win32-x64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-win32-x64@dd9fe495b1be9c7d2558a08216c83d5c985ba643" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-iJKwj+aq0w0TYZu0xafb5Nj5eBObZcKc4+qHCpj5EAQBRCid3Z8nROmiwdO+aDDeqwTYzfG53u+MU3urEtiweQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YcpWGD8GwoO3UYYw0kLDI4qof3ElSwWW5M3fNG+Kw87BSjCa5frZqak5xebdZ/XU5Xsk+BjgbBsxbzo9yrIjZQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-darwin-arm64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-8Wo6B6VhFXu5Lq/HDXQCkxCJF4svJ3OPU+WcotQJuLT9/pXzik40WjTKVn3HECKiqQ3ryQS4ZxpnJIxlNDcpmg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-xgdZwgcwlDCqi0WPCS1d0GVSd+oKDFX+UVE1ZPei8u8W3pirXGSExxVHNku7QbEYCRvpCeMtoKIa6xN9cVTsoQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-darwin-x64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-Tt+qGxp5VJZKmw54XdzebE6a+0QVA5Sk8KWUDHfWXn4ley6tvpMZowrG2/O6TpKHP6XdA/bHm+Ewetyk7BO2Zg=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-vHBiJp69wMaL+fRMDpnXCCIpPyQRPZEWCWBatnblUUGIWUseqeQlaPow6ljHSbgAd/GjygAf4tFCyTqNT2ezZA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-arm64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-p62IQHNFFQUzet57HMQndNQZtunDc0peSHudBoatCTV68QYKsD5gYjAK/BW1XY0zUoJM9Jk7BJnPBJPv1s5EYQ=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-A7GW4NCTInj2vy3X2gVO2Sm8jllgdLxwPdOklPqyl+xqivQtyG9qwS5YXoCk9fBJt10ORhYKETEjYEnIE5xvMQ=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-arm64-musl@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-qvQgkAdLSa1+SeMhMNBZ7zx7OlVi8yMsV+DDGTDrcc9sWXDzGJEKUq/R9XqqAag64Sloc12YA5oUYDKKj4E2VA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-rdw7QVopVHwj6fMrJc7hPGVaYg2V5NBlW3MUfkrQBlUlo/87OC4aYDAh5oRIL1CIFD9LjDdh7hwmUNbn/K9Pcw=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-x64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-JIkwBP6yLhh4ViTR53kx/NCY5w1ocTcKMdWidpObKLVLIkBat97y085teSvgkh+RFuJnUaJK7114XgdEONCjSg=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-J4wQs1OMPZ4hR93Op1C/BLFmIta2mUJm4M7djevlgcWcal4RSNO4V8QvUXfogWGci+ADZqEmX2osQQDY5NVJpw=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-x64-musl@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-M1BPZZ7eLE/nfnS7uypyIxg5JKfkkZSkehszISrQOf8zQl3+j3kOPy17RqSCJKZboGFQwmg8rwlaxGNxA6sfVQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-OnmEsGLWwsHrm6sTsWSSesu4LGjSV1JNWqCsEItbuitWaqH6rXdSahSc5jMFsS57bZxUyaRz01yAqiZzUSXnVQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-win32-arm64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-GFQ1GCA4FQg/MVpO1jt53X4fTaYsQTfW1EkOpQqmt/Rvls/oSklpElxh4ww5/giHEVnNxvcqX8+RG9U5DCmLpg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.9", "", { "os": "win32", "cpu": "x64" }, "sha512-/CnAIfKL7+ZeGLyZoXV5zS71Nd8Zn97RUir2DAIY05MJozmfg5s7XOAdUNYuK1cg5mbwlAuqeAsfbuWUAEe15g=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-win32-x64@dd9fe495b1be9c7d2558a08216c83d5c985ba643", {}, "sha512-NKFUOLwBfd59koYiPgeqrP4hZERnG6B7+RnH2DNO3dAnIAl/7dISuOwWw/VacYBck4ToG0ZrbhBMTeBB2i5DqQ=="],
"@opentui/keymap": ["@opentui/keymap@0.5.9", "", { "dependencies": { "@opentui/core": "0.5.9" }, "peerDependencies": { "@opentui/react": "0.5.9", "@opentui/solid": "0.5.9", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-ZcRNeuCDv+LJ89BS5xBw90e0S1Etn6j41jflg6LZlWj4bnqjCU55dq0+zERUdkkC4rqD4SikM8q7V3ZfkwzyyQ=="],
"@opentui/keymap": ["@opentui/keymap@https://pkg.pr.new/anomalyco/opentui/@opentui/keymap@dd9fe495b1be9c7d2558a08216c83d5c985ba643", { "dependencies": { "@opentui/core": "https://pkg.pr.new/anomalyco/opentui/@opentui/core@dd9fe495b1be9c7d2558a08216c83d5c985ba643" }, "peerDependencies": { "@opentui/react": "0.5.9", "@opentui/solid": "0.5.9", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-Lw+wxniAxIqnZz9vFPwl8EsH33HZGnyukQTU/4dI01BwNKbx2hcxuFMjrHFP/MkqhvwmFXcvGSGfJrG8VLwFoA=="],
"@opentui/solid": ["@opentui/solid@0.5.9", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.9", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-zGSP/ia9ww+TTMvQMZjJW2+h7fg9YkVRIM723EgvP+v7ZBZ/0vMzPNh7FJ0jET3ysw0h5hsbvcYISs4sUcf+UA=="],
"@opentui/solid": ["@opentui/solid@https://pkg.pr.new/anomalyco/opentui/@opentui/solid@dd9fe495b1be9c7d2558a08216c83d5c985ba643", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "https://pkg.pr.new/anomalyco/opentui/@opentui/core@dd9fe495b1be9c7d2558a08216c83d5c985ba643", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-wtq+offTD/Hxd9KXW5eVjeBgtBiJiXakkk1Jo/8ULX2fvG+trtdaAHr0DrlHsRv/xyUMfxPBaHRwPe4Gk8+8sw=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -6564,6 +6564,10 @@
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"opentui-spinner/@opentui/core": ["@opentui/core@0.5.9", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.9", "@opentui/core-darwin-x64": "0.5.9", "@opentui/core-linux-arm64": "0.5.9", "@opentui/core-linux-arm64-musl": "0.5.9", "@opentui/core-linux-x64": "0.5.9", "@opentui/core-linux-x64-musl": "0.5.9", "@opentui/core-win32-arm64": "0.5.9", "@opentui/core-win32-x64": "0.5.9" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-d0EWYyp6djitu1N1R0o75NrLl4TxY3oJEmRNrX9vSKKC5/jriGQdSV6lJmwiB77O0cxtBnPzWztAV79vA1J2fA=="],
"opentui-spinner/@opentui/solid": ["@opentui/solid@0.5.9", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.9", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-zGSP/ia9ww+TTMvQMZjJW2+h7fg9YkVRIM723EgvP+v7ZBZ/0vMzPNh7FJ0jET3ysw0h5hsbvcYISs4sUcf+UA=="],
"oxc-parser/@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="],
"p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
@@ -7450,6 +7454,30 @@
"miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
"opentui-spinner/@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YcpWGD8GwoO3UYYw0kLDI4qof3ElSwWW5M3fNG+Kw87BSjCa5frZqak5xebdZ/XU5Xsk+BjgbBsxbzo9yrIjZQ=="],
"opentui-spinner/@opentui/core/@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-xgdZwgcwlDCqi0WPCS1d0GVSd+oKDFX+UVE1ZPei8u8W3pirXGSExxVHNku7QbEYCRvpCeMtoKIa6xN9cVTsoQ=="],
"opentui-spinner/@opentui/core/@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-vHBiJp69wMaL+fRMDpnXCCIpPyQRPZEWCWBatnblUUGIWUseqeQlaPow6ljHSbgAd/GjygAf4tFCyTqNT2ezZA=="],
"opentui-spinner/@opentui/core/@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-A7GW4NCTInj2vy3X2gVO2Sm8jllgdLxwPdOklPqyl+xqivQtyG9qwS5YXoCk9fBJt10ORhYKETEjYEnIE5xvMQ=="],
"opentui-spinner/@opentui/core/@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-rdw7QVopVHwj6fMrJc7hPGVaYg2V5NBlW3MUfkrQBlUlo/87OC4aYDAh5oRIL1CIFD9LjDdh7hwmUNbn/K9Pcw=="],
"opentui-spinner/@opentui/core/@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-J4wQs1OMPZ4hR93Op1C/BLFmIta2mUJm4M7djevlgcWcal4RSNO4V8QvUXfogWGci+ADZqEmX2osQQDY5NVJpw=="],
"opentui-spinner/@opentui/core/@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-OnmEsGLWwsHrm6sTsWSSesu4LGjSV1JNWqCsEItbuitWaqH6rXdSahSc5jMFsS57bZxUyaRz01yAqiZzUSXnVQ=="],
"opentui-spinner/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.9", "", { "os": "win32", "cpu": "x64" }, "sha512-/CnAIfKL7+ZeGLyZoXV5zS71Nd8Zn97RUir2DAIY05MJozmfg5s7XOAdUNYuK1cg5mbwlAuqeAsfbuWUAEe15g=="],
"opentui-spinner/@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
"opentui-spinner/@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
"opentui-spinner/@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"opentui-spinner/@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.12", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.6" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.12" }, "optionalPeers": ["solid-js"] }, "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
@@ -8248,6 +8276,8 @@
"js-beautify/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"opentui-spinner/@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
+3 -3
View File
@@ -49,9 +49,9 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.9",
"@opentui/keymap": "0.5.9",
"@opentui/solid": "0.5.9",
"@opentui/core": "https://pkg.pr.new/anomalyco/opentui/@opentui/core@dd9fe495b1be9c7d2558a08216c83d5c985ba643",
"@opentui/keymap": "https://pkg.pr.new/anomalyco/opentui/@opentui/keymap@dd9fe495b1be9c7d2558a08216c83d5c985ba643",
"@opentui/solid": "https://pkg.pr.new/anomalyco/opentui/@opentui/solid@dd9fe495b1be9c7d2558a08216c83d5c985ba643",
"@tanstack/solid-virtual": "3.13.37",
"@shikijs/stream": "4.4.3",
"@standard-schema/spec": "1.1.0",
@@ -1367,7 +1367,7 @@ export function make(options: ClientOptions) {
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
declaredStatuses: [404, 401, 400],
empty: false,
},
requestOptions,
@@ -1496,7 +1496,7 @@ export function make(options: ClientOptions) {
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
declaredStatuses: [404, 401, 400],
empty: false,
},
requestOptions,
+2
View File
@@ -1,5 +1,7 @@
/// <reference types="@solidjs/start/env" />
import "@solidjs/start"
export declare module "@solidjs/start/server" {
export type APIEvent = { request: Request }
}
+1 -5
View File
@@ -859,16 +859,12 @@ export function configured(options?: Options) {
aggregateID: input.aggregateID,
...(target >= 0 ? { seq: Event.Seq.make(target) } : {}),
}
const replay: Stream.Stream<LogItem> = readThrough(target).pipe(
Stream.map((event): LogItem => event),
Stream.concat(Stream.make(marker)),
)
const replay: Stream.Stream<LogItem> = readThrough(target).pipe(Stream.concat(Stream.make(marker)))
if (!wakes) return replay
const live: Stream.Stream<LogItem> = Stream.fromSubscription(wakes).pipe(
Stream.mapEffect(() => latestSequence(db, input.aggregateID)),
Stream.filter((target) => target > sequence),
Stream.flatMap((target) => readThrough(target)),
Stream.map((event): LogItem => event),
)
return Stream.concat(replay, live)
}),
@@ -279,7 +279,7 @@ export class SQLiteEffectUpdateBase<
: undefined
on = on(
new Proxy(
this.config.table._.columns,
getTableColumnsRuntime(this.config.table),
new SelectionProxyHandler({ sqlAliasedBehavior: "sql", sqlBehavior: "sql" }),
) as any,
from &&
@@ -653,7 +653,11 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
}
if (isActiveText) {
controller.enqueue({ type: "text-end", id: "txt-0" })
controller.enqueue({
type: "text-end",
id: "txt-0",
providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
})
}
// go through all tool calls and send the ones that are not finished
+1
View File
@@ -37,6 +37,7 @@ export function buildLocationServiceMap(
...inner,
get: (ref: Location.Ref) => inner.get(canonical(ref)),
contextEffect: (ref: Location.Ref) => inner.contextEffect(canonical(ref)),
contextEffectOption: (ref: Location.Ref) => inner.contextEffectOption(canonical(ref)),
invalidate: (ref: Location.Ref) => inner.invalidate(canonical(ref)),
}),
),
+3 -1
View File
@@ -84,7 +84,9 @@ export const Plugin = define({
})
function append(template: string, input: string) {
return [template, input.trim()].filter(Boolean).join("\n\n")
const value = input.trim()
if (template.includes("$ARGUMENTS")) return template.replaceAll("$ARGUMENTS", () => value)
return [template, value].filter(Boolean).join("\n\n")
}
function parseArguments(input: string) {
+5 -9
View File
@@ -134,10 +134,9 @@ const layer = Layer.effect(
}),
Stream.take(input.limit + 1),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
const truncated = rows.length > input.limit
if (truncated) return { items: rows.slice(0, input.limit), truncated, partial: false }
if (truncated) return rows.slice(0, input.limit)
const code = yield* handle.exitCode
const stderr = yield* Fiber.join(stderrFiber)
@@ -147,7 +146,7 @@ const layer = Layer.effect(
if (code !== 0 && code !== 1 && code !== 2) {
return yield* failure(stderr.trim() || `ripgrep failed with code ${code}`)
}
return { items: code === 1 ? [] : rows, truncated: false, partial: code === 2 }
return code === 1 ? [] : rows
}),
)
const abortable = input.signal ? program.pipe(Effect.raceFirst(waitForAbort(input.signal))) : program
@@ -178,7 +177,7 @@ const layer = Layer.effect(
parse: (line) => Effect.succeed(normalizePath(line)),
}).pipe(
Effect.map((result) =>
result.items.map((relative) =>
result.map((relative) =>
Entry.make({
path: RelativePath.make(relative),
type: "file",
@@ -212,10 +211,7 @@ const layer = Layer.effect(
)
},
onItem: input.onEntry,
}).pipe(
Effect.map((result) => result.items),
Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))),
),
}).pipe(Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause)))),
grep: (input) =>
run<RawMatchData>({
...input,
@@ -248,7 +244,7 @@ const layer = Layer.effect(
),
}).pipe(
Effect.map((result) =>
result.items.map((match) =>
result.map((match) =>
Match.make({
entry: Entry.make({
path: RelativePath.make(match.path.text),
+1 -1
View File
@@ -287,7 +287,7 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) {
batches(ids.map((row) => row.id)),
(batch) =>
db
.select({ created: EventTable.created, data: EventTable.data })
.select({ data: EventTable.data })
.from(EventTable)
.where(
and(
+6 -3
View File
@@ -84,7 +84,8 @@ describe("Catalog", () => {
return Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* (yield* Integration.Service).transform((editor) => editor.update(integrationID, () => {}))
const integrations = yield* Integration.Service
yield* integrations.transform((editor) => editor.update(integrationID, () => {}))
yield* catalog.transform((editor) =>
editor.provider.update(providerID, (provider) => {
provider.integrationID = integrationID
@@ -92,7 +93,8 @@ describe("Catalog", () => {
)
expect(yield* catalog.provider.available()).toEqual([])
yield* (yield* Credential.Service).create({
const credentials = yield* Credential.Service
yield* credentials.create({
integrationID,
value: Credential.Key.make({ type: "key", key: "secret" }),
})
@@ -112,7 +114,8 @@ describe("Catalog", () => {
return Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* (yield* Integration.Service).transform((editor) => editor.update(integrationID, () => {}))
const integrations = yield* Integration.Service
yield* integrations.transform((editor) => editor.update(integrationID, () => {}))
yield* catalog.transform((editor) =>
editor.provider.update(providerID, (provider) => {
provider.integrationID = integrationID
@@ -15,6 +15,14 @@ const users = sqliteTable("users", {
id: integer().primaryKey({ autoIncrement: true }),
name: text().notNull(),
})
const teams = sqliteTable("teams", {
id: integer().primaryKey(),
name: text().notNull(),
})
const memberships = sqliteTable("memberships", {
user_id: integer().notNull(),
team_id: integer().notNull(),
})
const run = <A, E>(effect: Effect.Effect<A, E, SqlClient>) =>
Effect.runPromise(
@@ -163,3 +171,41 @@ test("supports returning and rejects empty update sets", async () => {
}),
)
})
test("supports function-valued update joins with runtime table columns", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
const query = db
.update(users)
.set({ name: "Grace" })
.from(teams)
.innerJoin(memberships, (update) => eq(update.id, memberships.user_id))
.where(eq(teams.name, "Core"))
expect(query.toSQL()).toEqual({
sql: 'update "users" set "name" = ? from "teams" inner join "memberships" on "users"."id" = "memberships"."user_id" where "teams"."name" = ?',
params: ["Grace", "Core"],
})
}),
)
})
test("supports SQL-valued update joins", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
const query = db
.update(users)
.set({ name: "Lin" })
.from(teams)
.innerJoin(memberships, eq(users.id, memberships.user_id))
.where(eq(teams.name, "Core"))
expect(query.toSQL()).toEqual({
sql: 'update "users" set "name" = ? from "teams" inner join "memberships" on "users"."id" = "memberships"."user_id" where "teams"."name" = ?',
params: ["Lin", "Core"],
})
}),
)
})
@@ -201,7 +201,8 @@ describe("layer node", () => {
Layer.provide(LayerNode.compile(result.hoisted)),
) as unknown as Layer.Layer<App>
const program = Effect.gen(function* () {
return yield* (yield* App).run
const app = yield* App
return yield* app.run
}).pipe(Effect.provide(layer))
expect(await Effect.runPromise(program)).toEqual(["Alice"])
@@ -244,14 +244,25 @@ describe("doStream", () => {
expect(reasoningEndIndex).toBeLessThan(textStartIndex)
// In this fixture, reasoning_opaque comes AFTER content has started (in chunk 4)
// So it arrives too late to be attached to reasoning-end. But it should still
// be captured and included in the finish event's providerMetadata.
// So it arrives too late to be attached to reasoning-end. It should still be
// captured on the completed text part and the finish event.
const reasoningEnd = parts.find((p) => p.type === "reasoning-end")
expect(reasoningEnd).toMatchObject({
type: "reasoning-end",
id: "reasoning-0",
})
const textEnd = parts.find((p) => p.type === "text-end")
expect(textEnd).toEqual({
type: "text-end",
id: "txt-0",
providerMetadata: {
copilot: {
reasoningOpaque: "/PMlTqxqSJZnUBDHgnnJKLVI4eZQ",
},
},
})
// reasoning_opaque should be in the finish event's providerMetadata
const finish = parts.find((p) => p.type === "finish")
expect(finish).toMatchObject({
@@ -305,6 +316,17 @@ describe("doStream", () => {
},
})
const textEnd = parts.find((p) => p.type === "text-end")
expect(textEnd).toEqual({
type: "text-end",
id: "txt-0",
providerMetadata: {
copilot: {
reasoningOpaque: "ExXaGwW7jBo39OXRe9EPoFGN1rOtLJBx",
},
},
})
// Check text deltas
const textDeltas = parts.filter((p) => p.type === "text-delta")
expect(textDeltas).toHaveLength(2)
@@ -51,7 +51,8 @@ describe("FileSystem", () => {
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test"))
const entries = yield* (yield* FileSystem.Service).list()
const filesystem = yield* FileSystem.Service
const entries = yield* filesystem.list()
expect(entries.map((entry) => ({ path: entry.path, type: entry.type }))).toEqual([
{ path: RelativePath.make("src" + path.sep), type: "directory" },
{ path: RelativePath.make("README.md"), type: "file" },
@@ -103,9 +104,8 @@ describe("FileSystem", () => {
it.live("rejects lexical escapes", () =>
withTmp((directory) =>
Effect.gen(function* () {
const result = yield* (yield* FileSystem.Service)
.read({ path: RelativePath.make("../outside.txt") })
.pipe(Effect.exit)
const filesystem = yield* FileSystem.Service
const result = yield* filesystem.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
}).pipe(provide(directory)),
),
+12 -2
View File
@@ -13,6 +13,7 @@ import {
Hash,
Layer,
LayerMap,
Option,
RcMap,
Schema,
Stream,
@@ -517,7 +518,8 @@ describe("LocationServiceMap", () => {
)
const plugins = yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* (yield* PluginSupervisor.Service).flush
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
return yield* plugins.list()
}).pipe(
Effect.scoped,
@@ -674,14 +676,21 @@ describe("LocationServiceMap", () => {
expect(Equal.equals(absent, present)).toBe(false)
if (process.platform === "win32") expect(absent.directory).not.toBe(present.directory)
expect(yield* locations.contextEffectOption(absent)).toEqual(Option.none())
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(0)
const first = yield* locations.contextEffect(absent)
expect(yield* locations.contextEffect(present)).toBe(first)
expect(Option.getOrThrow(yield* locations.contextEffectOption(absent))).toBe(first)
expect(Option.getOrThrow(yield* locations.contextEffectOption(present))).toBe(first)
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([
Location.Ref.make({ directory, workspaceID: undefined }),
])
// Invalidating with the shape opposite to the one that booted must evict.
yield* locations.invalidate(present)
expect(yield* locations.contextEffectOption(absent)).toEqual(Option.none())
expect(yield* locations.contextEffectOption(present)).toEqual(Option.none())
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(0)
}),
),
@@ -934,7 +943,8 @@ describe("LocationServiceMap", () => {
})
yield* plugins.activate([{ ...reviewer, version: "1" }])
expect(yield* (yield* Agent.Service).get(Agent.ID.make("reviewer"))).toMatchObject({
const agents = yield* Agent.Service
expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({
description: "Reviews code",
mode: "subagent",
})
+24 -12
View File
@@ -43,7 +43,8 @@ describe("LocationMutation", () => {
Effect.gen(function* () {
const targetPath = path.join(directory, "hello.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "hello"))
const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "hello.txt" })
expect(target).toMatchObject({
absolute: targetPath,
@@ -58,7 +59,8 @@ describe("LocationMutation", () => {
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("src", "new.txt") })
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: path.join("src", "new.txt") })
expect(target).toMatchObject({
absolute: path.join(directory, "src", "new.txt"),
resource: "src/new.txt",
@@ -70,7 +72,8 @@ describe("LocationMutation", () => {
it.live("requires external-directory authorization for a relative lexical escape", () =>
withTmp((directory) =>
Effect.gen(function* () {
const target = yield* (yield* LocationMutation.Service).resolve({ path: "../outside.txt" })
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "../outside.txt" })
const root = path.dirname(directory)
expect(target).toMatchObject({
absolute: path.join(root, "outside.txt"),
@@ -117,7 +120,8 @@ describe("LocationMutation", () => {
await fs.mkdir(outside)
await fs.symlink(outside, path.join(directory, "escape"))
})
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") })
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: path.join("escape", "new.txt") })
expect(target).toMatchObject({
absolute: path.join(directory, "escape", "new.txt"),
resource: "escape/new.txt",
@@ -137,7 +141,8 @@ describe("LocationMutation", () => {
await fs.symlink(path.join(directory, "actual"), path.join(directory, "linked"))
})
expect(yield* (yield* LocationMutation.Service).resolve({ path: "linked/new.txt" })).toMatchObject({
const mutation = yield* LocationMutation.Service
expect(yield* mutation.resolve({ path: "linked/new.txt" })).toMatchObject({
absolute: path.join(directory, "linked", "new.txt"),
resource: "linked/new.txt",
})
@@ -149,7 +154,8 @@ describe("LocationMutation", () => {
withTmp((directory) =>
Effect.gen(function* () {
const targetPath = path.join(directory, "new.txt")
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: targetPath })
expect(target).toMatchObject({
absolute: targetPath,
resource: "new.txt",
@@ -164,7 +170,8 @@ describe("LocationMutation", () => {
withTmp((outside) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "new.txt")
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: targetPath })
const root = outside
expect(target).toMatchObject({
absolute: path.join(root, "new.txt"),
@@ -185,7 +192,8 @@ describe("LocationMutation", () => {
Effect.gen(function* () {
const targetPath = path.join(outside, "existing.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "existing"))
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: targetPath })
expect(target).toMatchObject({ absolute: targetPath })
expect(target.externalDirectory?.directory).toBe(outside)
}).pipe(provide(directory)),
@@ -197,7 +205,8 @@ describe("LocationMutation", () => {
withTmp((directory) =>
withTmp((outside) =>
Effect.gen(function* () {
const target = yield* (yield* LocationMutation.Service).resolve({ path: outside, kind: "file" })
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: outside, kind: "file" })
expect(target.externalDirectory).toMatchObject({
directory: path.dirname(outside),
resource: path.join(path.dirname(outside), "*").replaceAll("\\", "/"),
@@ -212,7 +221,8 @@ describe("LocationMutation", () => {
withTmp((outside) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "new", "nested", "file.txt")
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: targetPath })
const parent = path.dirname(targetPath)
expect(target.externalDirectory).toMatchObject({
directory: parent,
@@ -254,7 +264,8 @@ describe("LocationMutation", () => {
it.live("resolves a tilde path as an external home target", () =>
withTmp((directory) =>
Effect.gen(function* () {
const target = yield* (yield* LocationMutation.Service).resolve({ path: "~/notes.md" })
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "~/notes.md" })
const absolute = path.resolve(Global.Path.home, "notes.md")
expect(target).toMatchObject({
absolute,
@@ -270,7 +281,8 @@ describe("LocationMutation", () => {
it.live("treats a tilde path as in-location when the location is home", () =>
Effect.gen(function* () {
const target = yield* (yield* LocationMutation.Service).resolve({ path: "~/notes.md" })
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "~/notes.md" })
expect(target).toMatchObject({
absolute: path.resolve(Global.Path.home, "notes.md"),
resource: "notes.md",
+48 -3
View File
@@ -16,6 +16,7 @@ import { emptyMcpLayer } from "../fixture/mcp"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { host } from "./host"
import PROMPT_INITIALIZE from "../../src/plugin/command/initialize.txt"
import PROMPT_REVIEW from "../../src/plugin/command/review.txt"
const directory = AbsolutePath.make("/repo/packages/app")
@@ -40,7 +41,11 @@ describe("CommandPlugin.Plugin", () => {
it.effect("registers built-in init and review commands", () =>
Effect.gen(function* () {
const command = yield* Command.Service
const prompts: { text: string; files?: readonly { readonly uri: string }[] }[] = []
const prompts: {
text: string
files?: readonly { readonly uri: string }[]
delivery?: "steer" | "queue"
}[] = []
yield* CommandPlugin.Plugin.effect(
host({
command: {
@@ -51,7 +56,7 @@ describe("CommandPlugin.Plugin", () => {
session: {
prompt: (input) =>
Effect.sync(() => {
prompts.push({ text: input.text, files: input.files })
prompts.push({ text: input.text, files: input.files, delivery: input.delivery })
return SessionInbox.User.make({
id: SessionMessage.ID.make("msg_test"),
sessionID: input.sessionID,
@@ -86,10 +91,50 @@ describe("CommandPlugin.Plugin", () => {
delivery: "queue",
},
})
yield* command.execute({
name: "review",
invocation: {
sessionID: Session.ID.make("ses_test"),
prompt: { text: " branch $& $$ $` $' " },
delivery: "steer",
},
})
yield* command.execute({
name: "init",
invocation: {
sessionID: Session.ID.make("ses_test"),
prompt: { text: "" },
delivery: "steer",
},
})
yield* command.execute({
name: "review",
invocation: {
sessionID: Session.ID.make("ses_test"),
prompt: { text: " " },
delivery: "steer",
},
})
expect(prompts).toEqual([
{
text: expect.stringContaining("extra context"),
text: PROMPT_INITIALIZE.replace("${path}", project).replaceAll("$ARGUMENTS", "extra context"),
files: [{ uri: "file:///tmp/context.md" }],
delivery: "queue",
},
{
text: PROMPT_REVIEW.replace("${path}", project).replaceAll("$ARGUMENTS", () => "branch $& $$ $` $'"),
files: undefined,
delivery: "steer",
},
{
text: PROMPT_INITIALIZE.replace("${path}", project).replaceAll("$ARGUMENTS", ""),
files: undefined,
delivery: "steer",
},
{
text: PROMPT_REVIEW.replace("${path}", project).replaceAll("$ARGUMENTS", ""),
files: undefined,
delivery: "steer",
},
])
}),
@@ -306,6 +306,34 @@ describe("AppProcess", () => {
}),
)
it.live(
"includes stderr in output while retaining capped failure diagnostics",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const lines: string[] = []
const exit = yield* Effect.exit(
svc
.runStream(
cmd(
"-e",
"console.log('stdout-line'); console.error('stderr-line'); console.error('diagnostic-tail'); process.exit(2)",
),
{ includeStderr: true, maxErrorBytes: 20, okExitCodes: [0] },
)
.pipe(Stream.runForEach((line) => Effect.sync(() => lines.push(line)))),
)
expect(lines.toSorted()).toEqual(["diagnostic-tail", "stderr-line", "stdout-line"])
expect(Exit.isFailure(exit)).toBe(true)
if (!Exit.isFailure(exit)) return
const reason = exit.cause.reasons[0]
expect(reason?._tag).toBe("Fail")
if (!reason || reason._tag !== "Fail") return
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
expect(reason.error.stderr).toBe("stderr-line\ndiagnost")
}),
)
it.live(
"without okExitCodes, never fails on exit code",
Effect.gen(function* () {
+4 -2
View File
@@ -25,7 +25,8 @@ describe("RepositoryCache", () => {
await fs.writeFile(path.join(localPath, "stale.txt"), "stale")
})
const result = yield* (yield* RepositoryCache.Service).ensure({ reference: fixture.reference })
const cache = yield* RepositoryCache.Service
const result = yield* cache.ensure({ reference: fixture.reference })
expect(result.status).toBe("cloned")
expect(yield* exists(path.join(localPath, "stale.txt"))).toBe(false)
@@ -94,7 +95,8 @@ describe("RepositoryCache", () => {
Effect.gen(function* () {
yield* Effect.promise(() => git(fixture.root, "clone", fixture.remote, path.join(fixture.root, "repos")))
const result = yield* (yield* RepositoryCache.Service).ensure({ reference: fixture.reference })
const cache = yield* RepositoryCache.Service
const result = yield* cache.ensure({ reference: fixture.reference })
expect(result.status).toBe("cloned")
expect(yield* read(path.join(result.localPath, "README.md"))).toBe("one\n")
+2 -2
View File
@@ -1,5 +1,5 @@
import type { WebSearch } from "@opencode-ai/schema/websearch"
import type { WebsearchApi } from "@opencode-ai/client/effect/api"
import type { WebSearchApi } from "@opencode-ai/client/effect/api"
import type { Effect } from "effect"
import type { Transform } from "./registration.js"
@@ -9,7 +9,7 @@ export interface WebSearchDefinition {
readonly execute: (input: WebSearch.ProviderInput) => Effect.Effect<readonly WebSearch.Result[], unknown>
}
export interface WebSearchDomain extends WebsearchApi<unknown> {
export interface WebSearchDomain extends WebSearchApi<unknown> {
readonly transform: Transform<WebSearchDraft>
readonly reload: () => Effect.Effect<void>
}
+7 -9
View File
@@ -55,15 +55,13 @@ export const makeFormGroup = <
params: { sessionID: Schema.String },
success: Schema.Struct({ data: Schema.Array(Form.Info) }),
error: SessionNotFoundError,
})
.middleware(formLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.form.list",
summary: "List session forms",
description: "Retrieve pending forms for a session.",
}),
),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.form.list",
summary: "List session forms",
description: "Retrieve pending forms for a session.",
}),
),
)
.add(
HttpApiEndpoint.post("session.form.create", "/api/session/:sessionID/form", {
+7 -9
View File
@@ -90,15 +90,13 @@ export const makePermissionGroup = <
params: { sessionID: Session.ID },
success: Schema.Struct({ data: Schema.Array(Permission.Request) }),
error: SessionNotFoundError,
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.list",
summary: "List session permission requests",
description: "Retrieve pending permission requests owned by a session.",
}),
),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.list",
summary: "List session permission requests",
description: "Retrieve pending permission requests owned by a session.",
}),
),
)
.add(
HttpApiEndpoint.get("session.permission.get", "/api/session/:sessionID/permission/:requestID", {
+16 -5
View File
@@ -1,4 +1,6 @@
import { Database } from "@opencode-ai/core/database/database"
import { Form } from "@opencode-ai/core/form"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import {
ConflictError,
FormAlreadySettledError,
@@ -6,10 +8,10 @@ import {
FormNotFoundError,
InvalidRequestError,
} from "@opencode-ai/protocol/errors"
import { Effect } from "effect"
import { Effect, Option } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../location"
import { requestRef, response, sessionRef, withLoadedLocationServices } from "../location"
function missingForm(id: Form.ID) {
return new FormNotFoundError({ id, message: `Form not found: ${id}` })
@@ -17,6 +19,8 @@ function missingForm(id: Form.ID) {
export const FormHandler = HttpApiBuilder.group(Api, "server.form", (handlers) =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const database = yield* Database.Service
const requireOwnedForm = Effect.fnUntraced(function* (sessionID: Form.Info["sessionID"], formID: Form.ID) {
const form = yield* Form.Service
const info = yield* form.get(formID).pipe(Effect.catchTag("Form.NotFoundError", () => missingForm(formID)))
@@ -35,9 +39,16 @@ export const FormHandler = HttpApiBuilder.group(Api, "server.form", (handlers) =
.handle(
"session.form.list",
Effect.fn(function* (ctx) {
const form = yield* Form.Service
const forms = yield* form.list({ sessionID: ctx.params.sessionID })
return { data: forms }
const ref =
ctx.params.sessionID === "global"
? requestRef(ctx.request)
: yield* sessionRef(database, ctx.params.sessionID)
const forms = yield* withLoadedLocationServices(
locations,
ref,
Form.Service.use((form) => form.list({ sessionID: ctx.params.sessionID })),
)
return { data: Option.getOrElse(forms, () => []) }
}),
)
.handle(
+13 -4
View File
@@ -1,11 +1,13 @@
import { Database } from "@opencode-ai/core/database/database"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Permission } from "@opencode-ai/core/permission"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { Effect } from "effect"
import { Effect, Option } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { PermissionNotFoundError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
import { response } from "../location"
import { response, sessionRef, withLoadedLocationServices } from "../location"
function missingRequest(id: Permission.ID) {
return new PermissionNotFoundError({ requestID: id, message: `Permission request not found: ${id}` })
@@ -13,6 +15,8 @@ function missingRequest(id: Permission.ID) {
export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission", (handlers) =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const database = yield* Database.Service
const requireOwnedRequest = Effect.fnUntraced(function* (
sessionID: Permission.Request["sessionID"],
requestID: Permission.ID,
@@ -63,8 +67,13 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission",
.handle(
"session.permission.list",
Effect.fn(function* (ctx) {
const permission = yield* Permission.Service
return { data: yield* permission.forSession(ctx.params.sessionID) }
const ref = yield* sessionRef(database, ctx.params.sessionID)
const requests = yield* withLoadedLocationServices(
locations,
ref,
Permission.Service.use((permission) => permission.forSession(ctx.params.sessionID)),
)
return { data: Option.getOrElse(requests, () => []) }
}),
)
.handle(
+41 -1
View File
@@ -1,8 +1,13 @@
import { Database } from "@opencode-ai/core/database/database"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { Workspace } from "@opencode-ai/core/workspace"
import { Effect, Layer } from "effect"
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
import { eq } from "drizzle-orm"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { HttpServerRequest } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
@@ -26,6 +31,41 @@ export function response<A, E, R>(data: Effect.Effect<A, E, R>) {
})
}
const decodeSessionID = Schema.decodeUnknownEffect(Session.ID)
export function sessionRef(database: Context.Service.Shape<typeof Database.Service>, sessionID: unknown) {
return Effect.gen(function* () {
const id = yield* decodeSessionID(sessionID).pipe(
Effect.mapError(() => new InvalidRequestError({ message: "Invalid session ID", field: "sessionID" })),
)
const row = yield* database.db
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, id))
.get()
.pipe(Effect.orDie)
if (!row) return yield* new SessionNotFoundError({ sessionID: id, message: `Session not found: ${id}` })
return Location.Ref.make({
directory: AbsolutePath.make(row.directory),
workspaceID: row.workspaceID ? Workspace.ID.make(row.workspaceID) : undefined,
})
})
}
export function withLoadedLocationServices<A, E>(
locations: Context.Service.Shape<typeof LocationServiceMap.Service>,
ref: Location.Ref,
effect: Effect.Effect<A, E, LocationServices>,
) {
return Effect.scoped(
Effect.gen(function* () {
const context = yield* locations.contextEffectOption(ref)
if (Option.isNone(context)) return Option.none<A>()
return Option.some(yield* effect.pipe(Effect.provide(context.value)))
}),
)
}
export function requestRef(request: HttpServerRequest.HttpServerRequest): Location.Ref {
const query = new URL(request.url, "http://localhost").searchParams
const workspaceID = query.get("location[workspace]") || request.headers["x-opencode-workspace"]
@@ -1,16 +1,10 @@
import { Database } from "@opencode-ai/core/database/database"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { Workspace } from "@opencode-ai/core/workspace"
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
import { eq } from "drizzle-orm"
import { Effect, Layer, Schema } from "effect"
import { Effect, Layer } from "effect"
import { HttpRouter, HttpServerRequest } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import { requestRef, type LocationServices } from "../location"
import { requestRef, sessionRef, type LocationServices } from "../location"
export class FormLocationMiddleware extends HttpApiMiddleware.Service<
FormLocationMiddleware,
@@ -19,12 +13,10 @@ export class FormLocationMiddleware extends HttpApiMiddleware.Service<
error: [InvalidRequestError, SessionNotFoundError],
}) {}
const decodeSessionID = Schema.decodeUnknownEffect(Session.ID)
export const formLocationLayer = Layer.effect(
FormLocationMiddleware,
Effect.gen(function* () {
const { db } = yield* Database.Service
const database = yield* Database.Service
const locations = yield* LocationServiceMap.Service
return FormLocationMiddleware.of((effect) =>
@@ -38,38 +30,8 @@ export const formLocationLayer = Layer.effect(
return yield* effect.pipe(Effect.provide(locations.get(requestRef(request))))
}
const sessionID = yield* decodeSessionID(route.params.sessionID).pipe(
Effect.mapError(
() =>
new InvalidRequestError({
message: "Invalid session ID",
field: "sessionID",
}),
),
)
const row = yield* db
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
if (!row) {
return yield* new SessionNotFoundError({
sessionID,
message: `Session not found: ${sessionID}`,
})
}
return yield* effect.pipe(
Effect.provide(
locations.get(
Location.Ref.make({
directory: AbsolutePath.make(row.directory),
workspaceID: row.workspaceID ? Workspace.ID.make(row.workspaceID) : undefined,
}),
),
),
)
const ref = yield* sessionRef(database, route.params.sessionID)
return yield* effect.pipe(Effect.provide(locations.get(ref)))
}),
)
}),
@@ -1,16 +1,10 @@
import { Database } from "@opencode-ai/core/database/database"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { Workspace } from "@opencode-ai/core/workspace"
import { eq } from "drizzle-orm"
import { Effect, Layer, Schema } from "effect"
import { Effect, Layer } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
import type { LocationServices } from "../location"
import { sessionRef, type LocationServices } from "../location"
export class SessionLocationMiddleware extends HttpApiMiddleware.Service<
SessionLocationMiddleware,
@@ -19,48 +13,17 @@ export class SessionLocationMiddleware extends HttpApiMiddleware.Service<
error: [InvalidRequestError, SessionNotFoundError],
}) {}
const decodeSessionID = Schema.decodeUnknownEffect(Session.ID)
export const sessionLocationLayer = Layer.effect(
SessionLocationMiddleware,
Effect.gen(function* () {
const { db } = yield* Database.Service
const database = yield* Database.Service
const locations = yield* LocationServiceMap.Service
return SessionLocationMiddleware.of((effect) =>
Effect.gen(function* () {
const route = yield* HttpRouter.RouteContext
const sessionID = yield* decodeSessionID(route.params.sessionID).pipe(
Effect.mapError(
() =>
new InvalidRequestError({
message: "Invalid session ID",
field: "sessionID",
}),
),
)
const row = yield* db
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
if (!row)
return yield* new SessionNotFoundError({
sessionID,
message: `Session not found: ${sessionID}`,
})
return yield* effect.pipe(
Effect.provide(
locations.get(
Location.Ref.make({
directory: AbsolutePath.make(row.directory),
workspaceID: row.workspaceID ? Workspace.ID.make(row.workspaceID) : undefined,
}),
),
),
)
const ref = yield* sessionRef(database, route.params.sessionID)
return yield* effect.pipe(Effect.provide(locations.get(ref)))
}),
)
}),
+124
View File
@@ -4,6 +4,7 @@ import { makeMemoryDriver } from "@opencode-ai/core/environment/index"
import { Workspace } from "@opencode-ai/core/workspace"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { Effect } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerFetch } from "../src/fetch"
@@ -276,6 +277,129 @@ it.live("serves the session view operation and missing-session error", () =>
}),
)
it.live("does not load a location when reading pending session requests", () =>
Effect.gen(function* () {
const config = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-pending-read-")))
const handler = yield* ServerFetch.make({
...options,
config: {
directory: config.path,
project: false,
content: JSON.stringify({ permissions: [{ action: "shell", resource: "*", effect: "ask" }] }),
},
})
const created = (yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/session", {
method: "POST",
headers: { "content-type": "application/json" },
body: "{}",
}),
).then((response) => response.json()),
)) as { data: { id: string } }
const loaded = () =>
Effect.promise(() =>
handler(new Request("http://opencode.local/api/debug/location")).then(
(response) => response.json() as Promise<unknown[]>,
),
)
expect(yield* loaded()).toEqual([])
for (const resource of ["permission", "form"]) {
const response = yield* Effect.promise(() =>
handler(new Request(`http://opencode.local/api/session/${created.data.id}/${resource}`)),
)
expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.json())).toEqual({ data: [] })
const missing = yield* Effect.promise(() =>
handler(new Request(`http://opencode.local/api/session/ses_missing_pending/${resource}`)),
)
expect(missing.status).toBe(404)
}
const global = yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/session/global/form", {
headers: { "x-opencode-directory": encodeURIComponent(process.cwd()) },
}),
),
)
expect(global.status).toBe(200)
expect(yield* Effect.promise(() => global.json())).toEqual({ data: [] })
expect(yield* loaded()).toEqual([])
const createdForm = yield* Effect.promise(() =>
handler(
new Request(`http://opencode.local/api/session/${created.data.id}/form`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ title: "Test form", fields: [{ key: "answer", type: "string" }] }),
}),
),
)
expect(createdForm.status).toBe(200)
const forms = yield* Effect.promise(() =>
handler(new Request(`http://opencode.local/api/session/${created.data.id}/form`)),
)
expect(forms.status).toBe(200)
expect(yield* Effect.promise(() => forms.json())).toMatchObject({
data: [{ title: "Test form" }],
})
expect(yield* loaded()).toHaveLength(1)
const globalForm = yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/session/global/form", {
method: "POST",
headers: {
"content-type": "application/json",
"x-opencode-directory": encodeURIComponent(process.cwd()),
},
body: JSON.stringify({ title: "Global form", fields: [{ key: "answer", type: "string" }] }),
}),
),
)
expect(globalForm.status).toBe(200)
const globalForms = yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/session/global/form", {
headers: { "x-opencode-directory": encodeURIComponent(process.cwd()) },
}),
),
)
expect(globalForms.status).toBe(200)
expect(yield* Effect.promise(() => globalForms.json())).toMatchObject({ data: [{ title: "Global form" }] })
// Agent permission policy is installed by plugin activation.
expect((yield* ready(handler)).status).toBe(200)
const createdPermission = yield* Effect.promise(() =>
handler(
new Request(`http://opencode.local/api/session/${created.data.id}/permission`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ id: "per_pending_read", action: "shell", resources: ["pwd"] }),
}),
),
)
expect(createdPermission.status).toBe(200)
expect(yield* Effect.promise(() => createdPermission.json())).toEqual({
data: { id: "per_pending_read", effect: "ask" },
})
const permissions = yield* Effect.promise(() =>
handler(new Request(`http://opencode.local/api/session/${created.data.id}/permission`)),
)
expect(permissions.status).toBe(200)
expect(yield* Effect.promise(() => permissions.json())).toMatchObject({
data: [{ id: "per_pending_read", sessionID: created.data.id, action: "shell", resources: ["pwd"] }],
})
expect(yield* loaded()).toHaveLength(1)
}),
)
// Pins the eager-boot guarantee: the application layer is built before the handler returns, so
// an aborted first request cannot interrupt layer construction and wedge every later request
// (the Effect-TS/effect#6319 failure class that lazy first-request builds are prone to).
+1
View File
@@ -7,6 +7,7 @@
"license": "MIT",
"scripts": {
"test": "bun test --timeout 30000 --only-failures",
"bench:diff": "bun run --conditions=browser script/bench-diff.tsx",
"typecheck": "tsgo -b"
},
"exports": {
+68
View File
@@ -0,0 +1,68 @@
# Diff Rendering
## Goal
Keep complete large diffs usable without hiding patches or requiring loading consent. Measure initial rendering and scrolling independently, and preserve first/last-line access, wrapping, gutters, navigation, syntax, and selection.
## Benchmark
Run from `packages/tui`:
```sh
bun run bench:diff hunks 1000
bun run bench:diff lines 50000
DIFF_BENCH_WIDTH=80 bun run bench:diff lines 50000
bun run bench:diff long 20000
DIFF_BENCH_FILES=40 bun run bench:diff hunks 25
```
The benchmark renders the production `PatchDiff`, with one containing box per file as in the viewer. It bypasses size guards, verifies access to the complete patch's last line, warms up once, and measures seven runs by default. `DIFF_BENCH_RUNS` overrides the measured run count; `DIFF_BENCH_SYNTAX=1` enables JSON highlighting. Report first-frame median/spread, scrolling p95/max, and process RSS separately.
Do not render hunks directly under the benchmark's scrollbox: that gives them direct-child viewport culling that the viewer's file cards do not have.
## Hypotheses
1. Per-hunk gutter-sync scheduling repeats whole-file work. Coalescing callbacks should remove that quadratic mount cost without changing rendered content.
2. Gutter framebuffers and background painting scale with content height. Viewport-bounded painting should reduce giant-hunk scroll cost and native allocation without windowing text content.
3. Repeated `lineInfo` reads decode full-document arrays. Correctly invalidated caching or range reads should reduce metadata allocation while preserving wrapped/source-line mappings.
## Results
Measured on the local machine with one warmup and three measured runs:
```sh
DIFF_BENCH_RUNS=3 bun run bench:diff hunks 1000
```
| Experiment | Ready Median | Ready Range | Scroll p95 | Scroll Max | Process RSS Max |
| ---------------------------------------------- | ------------ | ------------ | ---------- | ---------- | --------------- |
| Baseline, complete file-card subtree | 8762 ms | 7845-9027 ms | 33.36 ms | 70.61 ms | 1616 MB |
| Coalesce whole-file gutter callbacks | 324 ms | 306-377 ms | 31.58 ms | 34.27 ms | 967 MB |
| Read widths without resetting number/sign maps | 307 ms | 301-318 ms | 29.61 ms | 34.86 ms | 967 MB |
Keep callback coalescing: initial rendering improves substantially with the same complete content and tail access. It does not solve steady-state painting; scrolling remains about 32 ms p95. RSS includes JavaScript/native allocation and process high-water behavior across repeated mounts, not just gutter storage.
The narrower width pass removes redundant map replacement and unbounded argument-list maxima. Its timing difference overlaps the earlier spread; keep it for fewer data mutations, not as an independently proven speedup.
Before the renderer patch, the same component with a 50,000-line modified hunk at 160 columns measured 225 ms median ready time (215-239 ms), 22.55 ms scroll p95, and 25.52 ms scroll maximum across three runs. A 100,000-line modified hunk at 80 columns measured 530 ms ready time and 60.51 ms scroll p95 in a single measured probe; that single-run probe is diagnostic, not a stable benchmark comparison.
The benchmark's initial setup is not a performance result; production-component and full-viewer Drive verification are complementary. Record subsequent isolated experiments here before keeping them.
## Final Matrix
The unguarded V2 base `0d42e76006` and the final consumer patch use the same harness and fixtures. Each case warms up once and measures seven runs; all cases run sequentially without concurrent build/test activity. Syntax is disabled in this rendering benchmark; the production viewer tests and Drive fixtures exercise JSON highlighting separately.
| Fixture | Ready Median Before / After | Scroll p95 Before / After | Process RSS Max Before / After |
| ------------------------------------- | --------------------------- | ------------------------- | ------------------------------ |
| 1,000 original hunks, 160 columns | 9083 / 264 ms | 53.11 / 7.20 ms | 1600 / 871 MB |
| 50,000 modified lines, split | 231 / 160 ms | 38.57 / 0.34 ms | 784 / 677 MB |
| 50,000 modified lines, unified | 236 / 164 ms | 21.67 / 0.29 ms | 1029 / 674 MB |
| 20,000-character modified line, split | 59 / 77 ms | 0.33 / 0.27 ms | 240 / 234 MB |
| 40 files with 25 hunks each | 343 / 161 ms | 1.48 / 1.28 ms | 790 / 568 MB |
| 100,000 modified lines, unified | 552 / 331 ms | 53.42 / 0.36 ms | 1532 / 1084 MB |
All cases expose their complete tails. Long markers can cross wrap rows, so that case reconstructs the visible final Code pane without interleaving the other side; the earlier contiguous-screen-marker check falsely rejected both revisions and was corrected identically before accepting these results.
The long-line opening adds about one frame (18 ms) to settle actual pane-width alignment. That is a measured correctness tradeoff, not a speedup. The many-hunk base also shows substantial spread (7879-17307 ms ready, 309 ms maximum scroll frame); the final ready range is 259-301 ms with a 19 ms maximum scroll frame. Measurements include process/compiler/allocator behavior and should not be presented as hard latency or memory guarantees.
The dependency patch retains complete native text/layout and selection owners. It bounds decoration raster allocation, paint visits, and source-ID readback, not source loading or full content-node virtualization. Native `0.5.9` platform binaries remain unchanged.
+175
View File
@@ -0,0 +1,175 @@
/** @jsxImportSource @opentui/solid */
import { CodeRenderable, type Renderable, type ScrollBoxRenderable } from "@opentui/core"
import { testRender, useKeyboard } from "@opentui/solid"
import { For } from "solid-js"
import { PatchDiff } from "../src/component/patch-diff"
import { ConfigProvider } from "../src/config"
import { ThemeProvider, useThemes } from "../src/context/theme"
import { TestTuiContexts } from "../test/fixture/tui-environment"
import { emptyThemeSource } from "../test/fixture/fixture"
import { largeDiffFixture } from "../test/fixture/large-diff"
import { createTuiResolvedConfig } from "../test/fixture/tui-runtime"
const kind = process.argv[2] ?? "hunks"
if (kind !== "hunks" && kind !== "lines" && kind !== "long") throw new Error("Use hunks, lines, or long")
const count = Number(process.argv[3] ?? (kind === "hunks" ? 1000 : 20000))
const width = Number(process.env.DIFF_BENCH_WIDTH ?? 160)
const files = Number(process.env.DIFF_BENCH_FILES ?? 1)
const iterations = Number(process.env.DIFF_BENCH_RUNS ?? 7)
const fixture = largeDiffFixture(kind, count)
const results: { ready: number; scroll: number[]; rss: number }[] = []
for (let iteration = 0; iteration <= iterations; iteration++) {
let scroll: ScrollBoxRenderable | undefined
function Content() {
const themes = useThemes()
const theme = themes.current
useKeyboard((key) => {
if (key.name === "j") scroll?.scrollBy(1)
})
return (
<scrollbox
ref={(node: ScrollBoxRenderable) => (scroll = node)}
width="100%"
height="100%"
verticalScrollbarOptions={{ visible: false }}
>
<For each={Array.from({ length: files }, (_, index) => index)}>
{() => (
<box>
<PatchDiff
diff={fixture.patch}
view={width >= 100 ? "split" : "unified"}
width="100%"
wrapMode="char"
filetype={process.env.DIFF_BENCH_SYNTAX ? "json" : undefined}
syntaxStyle={themes.currentSyntax()}
showLineNumbers
hunkFg={theme.diff.text.hunkHeader}
fg={theme.text.default}
addedBg={theme.diff.background.added}
removedBg={theme.diff.background.removed}
contextBg={theme.diff.background.context}
addedSignColor={theme.diff.highlight.added}
removedSignColor={theme.diff.highlight.removed}
lineNumberFg={theme.diff.lineNumber.text}
lineNumberBg={theme.diff.background.context}
addedLineNumberBg={theme.diff.lineNumber.background.added}
removedLineNumberBg={theme.diff.lineNumber.background.removed}
/>
</box>
)}
</For>
</scrollbox>
)
}
const started = performance.now()
const app = await testRender(
() => (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig()}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<Content />
</ThemeProvider>
</ConfigProvider>
</TestTuiContexts>
),
{ width, height: 30 },
)
try {
await app.flush()
const ready = performance.now() - started
if (!scroll) throw new Error("Missing patch viewport")
scroll.scrollTo(scroll.scrollHeight)
await app.flush()
const code = kind === "long" ? codeNodes(app.renderer.root).at(-1) : undefined
if (kind === "long" && !code) throw new Error("Missing diff code owner")
const frame = app.captureCharFrame()
// A long marker may cross wrap rows; reconstruct its pane without interleaving the other side.
const visible = code
? frame
.split("\n")
.map((line) => line.slice(code.x, code.x + code.width))
.join("")
.replace(/\s/g, "")
: frame
if (!visible.includes(fixture.tail)) throw new Error("The end of the full diff is not accessible")
scroll.scrollTo(Math.floor(scroll.scrollHeight / 2))
await app.flush()
const frames: number[] = []
for (let frame = 0; frame < 20; frame++) {
const started = performance.now()
app.mockInput.pressKey("j")
await app.renderOnce()
frames.push(performance.now() - started)
}
const result = { ready, scroll: frames, rss: process.memoryUsage().rss }
if (iteration > 0) results.push(result)
console.log(
JSON.stringify({
iteration,
warmup: iteration === 0,
ready_ms: ready,
scroll_p95_ms: percentile(frames, 0.95),
rss_bytes: result.rss,
}),
)
} finally {
app.renderer.destroy()
Bun.gc(true)
}
}
console.log(
JSON.stringify({
kind,
count,
width,
files,
iterations,
ready_median_ms: percentile(
results.map((result) => result.ready),
0.5,
),
ready_min_ms: percentile(
results.map((result) => result.ready),
0,
),
ready_max_ms: percentile(
results.map((result) => result.ready),
1,
),
scroll_p95_ms: percentile(
results.flatMap((result) => result.scroll),
0.95,
),
scroll_max_ms: percentile(
results.flatMap((result) => result.scroll),
1,
),
rss_max_bytes: percentile(
results.map((result) => result.rss),
1,
),
}),
)
console.log(
`METRIC diff_ready_ms=${percentile(
results.map((result) => result.ready),
0.5,
).toFixed(2)}`,
)
console.log(
`METRIC diff_scroll_p95_ms=${percentile(
results.flatMap((result) => result.scroll),
0.95,
).toFixed(2)}`,
)
function percentile(values: number[], fraction: number) {
return [...values].sort((left, right) => left - right)[Math.ceil((values.length - 1) * fraction)]
}
function codeNodes(root: Renderable): CodeRenderable[] {
return root instanceof CodeRenderable ? [root] : root.getChildren().flatMap(codeNodes)
}
+26 -19
View File
@@ -1,7 +1,7 @@
/** @jsxImportSource @opentui/solid */
import { DiffRenderable, LineNumberRenderable, type ColorInput } from "@opentui/core"
import type { JSX } from "@opentui/solid"
import { createMemo, For, Show, splitProps } from "solid-js"
import { createMemo, For, onCleanup, Show, splitProps } from "solid-js"
import { splitPatchHunks } from "../util/diff"
import { stringWidth } from "../util/string-width"
@@ -20,6 +20,10 @@ export function PatchDiff(props: Props) {
const [local, diffProps] = splitProps(props, ["diff", "hunkFg", "lineNumberBg", "ref"])
const hunks = createMemo(() => splitPatchHunks(local.diff))
const nodes = new Map<number, DiffRenderable>()
let gutterFrame: number | undefined
onCleanup(() => {
if (gutterFrame !== undefined) cancelAnimationFrame(gutterFrame)
})
local.ref?.({
hunks: () =>
[...nodes.entries()]
@@ -28,28 +32,31 @@ export function PatchDiff(props: Props) {
.filter((node) => !node.isDestroyed),
})
const syncGutters = (attempt = 0) => {
requestAnimationFrame(() => {
// Registrations share one whole-file pass instead of repeating it for every hunk.
if (gutterFrame !== undefined) return
gutterFrame = requestAnimationFrame(() => {
gutterFrame = undefined
const sides = [...nodes.values()]
.filter((item) => !item.isDestroyed)
.flatMap((item) => item.getChildren().filter((side) => side instanceof LineNumberRenderable))
const lineNumbers = sides.map((side) => new Map([...side.getLineNumbers()].filter(([line]) => line >= 0)))
const digits = lineNumbers.map((numbers) => Math.max(0, ...numbers.values()).toString().length)
const after = sides.map((side) =>
Math.max(
0,
...[...side.getLineSigns()].filter(([line]) => line >= 0).map(([, sign]) => stringWidth(sign.after ?? "")),
),
const widths = sides.map((side) => {
const width = { line: 0, sign: 0 }
side.getLineNumbers().forEach((number, line) => {
if (line >= 0) width.line = Math.max(width.line, number)
})
side.getLineSigns().forEach((sign, line) => {
if (line >= 0) width.sign = Math.max(width.sign, stringWidth(sign.after ?? ""))
})
return { digits: width.line.toString().length, after: width.sign }
})
const max = widths.reduce(
(max, width) => ({ digits: Math.max(max.digits, width.digits), after: Math.max(max.after, width.after) }),
{ digits: 0, after: 0 },
)
const maxDigits = Math.max(...digits)
const maxAfter = Math.max(...after)
if (!maxDigits && attempt < 2) return syncGutters(attempt + 1)
if (!maxDigits) return
sides.forEach((side) => {
const index = sides.indexOf(side)
const signs = new Map([...side.getLineSigns()].filter(([line]) => line >= 0))
signs.set(-1, { after: " ".repeat(maxAfter + maxDigits - digits[index]) })
side.setLineNumbers(lineNumbers[index])
side.setLineSigns(signs)
if (!max.digits && attempt < 2) return syncGutters(attempt + 1)
if (!max.digits) return
sides.forEach((side, index) => {
side.setLineSign(-1, { after: " ".repeat(max.after + max.digits - widths[index].digits) })
})
})
}
@@ -2,6 +2,7 @@
import { expect, test } from "bun:test"
import {
BoxRenderable,
CodeRenderable,
DiffRenderable,
ImageRenderable,
MouseButton,
@@ -32,6 +33,7 @@ import { createDialogApi } from "../../../src/plugin/api"
import { ToastProvider } from "../../../src/ui/toast"
import { createSignal, Show } from "solid-js"
import { diffImageFixture } from "../../fixture/diff-image"
import { largeDiffFixture } from "../../fixture/large-diff"
test("closing the diff viewer returns to the route it opened from", async () => {
const viewer = await renderDiffViewer([])
@@ -80,6 +82,154 @@ test("shows an error instead of an empty diff when loading fails", async () => {
}
})
test.each([80, 160])(
"large diffs render automatically and expose their complete content at %i columns",
async (width) => {
const file = largeDiffFixture("lines", 20000)
const viewer = await renderDiffViewer([file], { width, height: 30 })
try {
await viewer.app.waitForFrame((frame) => frame.includes('"column_0"'))
expect(findDiffs(viewer.app.renderer.root)).toHaveLength(1)
expect(viewer.commands.has("diff.load_patch")).toBe(false)
expect(viewer.app.captureCharFrame()).not.toContain("Large diff hidden")
viewer.app.mockInput.pressKey("G")
await viewer.app.waitForFrame((frame) => frame.includes(file.tail))
expect(
viewer.app
.captureCharFrame()
.split("\n")
.find((line) => line.includes(file.tail)),
).toMatch(/20000\s+/)
viewer.app.mockInput.pressKey("g")
viewer.app.mockInput.pressKey("g")
await viewer.app.waitForFrame((frame) => frame.includes('"column_0"'))
expect(findScrollBox(viewer.app.renderer.root)!.scrollTop).toBe(0)
viewer.app.mockInput.pressKey("c", { ctrl: true })
await viewer.app.waitFor(() => viewer.current().type !== "plugin")
expect(viewer.current()).toEqual(startRoute)
} finally {
viewer.app.renderer.destroy()
}
},
)
test("all original hunks stay available for forward, reverse, and end navigation", async () => {
const file = largeDiffFixture("hunks", 1000)
const viewer = await renderDiffViewer([file], { width: 160, height: 30 })
try {
await viewer.app.waitForFrame((frame) => frame.includes('"column_1"'))
await viewer.app.flush()
const hunks = findDiffs(viewer.app.renderer.root)
expect(hunks).toHaveLength(1000)
const scroll = findScrollBox(viewer.app.renderer.root)!
viewer.app.mockInput.pressKey("]")
await viewer.app.flush()
expect(hunks[1].y).toBeGreaterThanOrEqual(scroll.viewport.y + 2)
expect(hunks[1].y).toBeLessThanOrEqual(scroll.viewport.y + 3)
viewer.app.mockInput.pressKey("[")
await viewer.app.flush()
expect(scroll.scrollTop).toBe(0)
viewer.app.mockInput.pressKey("G")
await viewer.app.waitForFrame((frame) => frame.includes(file.tail))
expect(
viewer.app
.captureCharFrame()
.split("\n")
.find((line) => line.includes(file.tail)),
).toMatch(/39985\s+/)
viewer.app.mockInput.pressKey("g")
viewer.app.mockInput.pressKey("g")
await viewer.app.waitForFrame((frame) => frame.includes('"column_1"'))
expect(findDiffs(viewer.app.renderer.root)[0]).toBe(hunks[0])
expect(findDiffs(viewer.app.renderer.root).at(-1)).toBe(hunks.at(-1))
} finally {
viewer.app.renderer.destroy()
}
})
test("long wrapped lines retain their tails through narrow and wide resizing", async () => {
const file = largeDiffFixture("long", 20000)
const viewer = await renderDiffViewer([file], { width: 160, height: 30 })
try {
await viewer.app.waitForFrame((frame) => frame.includes("xxxx"))
await viewer.app.flush()
viewer.app.mockInput.pressKey("G")
await viewer.app.waitForFrame((frame) => frame.includes(file.tail))
viewer.app.resize(80, 24)
await viewer.app.flush()
expect(findDiffs(viewer.app.renderer.root)[0].view).toBe("unified")
viewer.app.mockInput.pressKey("G")
await viewer.app.waitForFrame((frame) => frame.includes(file.tail))
viewer.app.resize(160, 30)
await viewer.app.flush()
expect(findDiffs(viewer.app.renderer.root)[0].view).toBe("split")
viewer.app.mockInput.pressKey("G")
await viewer.app.waitForFrame((frame) => frame.includes(file.tail))
viewer.app.mockInput.pressKey("g")
viewer.app.mockInput.pressKey("g")
await viewer.app.waitForFrame((frame) => frame.includes("xxxx"))
} finally {
viewer.app.renderer.destroy()
}
})
test("large file review and source replacement keep the full patch usable", async () => {
const file = largeDiffFixture("lines", 20000)
const viewer = await renderDiffViewer([file], { width: 160, height: 30 })
try {
await viewer.app.waitForFrame((frame) => frame.includes('"column_0"'))
viewer.app.mockInput.pressKey("m")
await viewer.app.flush()
expect(findDiffs(viewer.app.renderer.root)).toHaveLength(0)
viewer.app.mockInput.pressKey("m")
await viewer.app.waitForFrame((frame) => frame.includes('"column_0"'))
const previous = findDiffs(viewer.app.renderer.root)[0]
viewer.app.mockInput.pressKey("d")
await viewer.app.waitForFrame((frame) => frame.includes("Switch source"))
viewer.app.mockInput.pressArrow("down")
viewer.app.mockInput.pressEnter()
await viewer.app.waitForFrame((frame) => frame.includes("Main branch") && frame.includes('"column_0"'))
expect(previous.isDestroyed).toBe(true)
viewer.app.mockInput.pressKey("G")
await viewer.app.waitForFrame((frame) => frame.includes(file.tail))
} finally {
viewer.app.renderer.destroy()
}
})
test("large diff selection survives offscreen scrolling and same-view resizing", async () => {
const viewer = await renderDiffViewer([largeDiffFixture("lines", 20000)], { width: 160, height: 30 })
try {
await viewer.app.waitForFrame((frame) => frame.includes('"column_0"'))
await viewer.app.flush()
const code = findDiffs(viewer.app.renderer.root)[0]
.getChildren()
.flatMap((side) => side.getChildren())
.find((node) => node instanceof CodeRenderable)
if (!code) throw new Error("Missing diff text owner")
const first = code.content.split("\n")[0]
await viewer.app.mockMouse.drag(code.x + 2, code.y, code.x + first.length - 1, code.y)
const selection = viewer.app.renderer.getSelection()
expect(selection?.getSelectedText()).toBe(first.slice(2))
const scroll = findScrollBox(viewer.app.renderer.root)!
scroll.scrollTo(15000)
await viewer.app.flush()
expect(viewer.app.renderer.getSelection()).toBe(selection)
expect(selection?.getSelectedText()).toBe(first.slice(2))
viewer.app.resize(180, 30)
await viewer.app.flush()
expect(selection?.getSelectedText()).toBe(first.slice(2))
scroll.scrollTo(0)
await viewer.app.flush()
expect(selection?.getSelectedText()).toBe(first.slice(2))
expect(code.isDestroyed).toBe(false)
viewer.app.renderer.clearSelection()
expect(viewer.app.renderer.getSelection()).toBeNull()
} finally {
viewer.app.renderer.destroy()
}
})
test("uses the active location when opened outside a session", async () => {
const viewer = await renderDiffViewer([], { initialRoute: { type: "home" } })
try {
+45
View File
@@ -0,0 +1,45 @@
export function largeDiffFixture(kind: "hunks" | "lines" | "long", count: number) {
const file = {
file: "snapshot.json",
additions: kind === "long" ? 1 : count,
deletions: kind === "long" ? 1 : count,
status: "modified" as const,
}
const prefix = "--- a/snapshot.json\n+++ b/snapshot.json\n"
if (kind === "long") {
return {
...file,
patch: `${prefix}@@ -1 +1 @@\n-${"x".repeat(count)}_OLD_END\n+${"y".repeat(count)}_NEW_END\n`,
tail: "_NEW_END",
}
}
if (kind === "lines") {
return {
...file,
patch:
prefix +
`@@ -1,${count} +1,${count} @@\n` +
Array.from({ length: count }, (_, index) => `- "column_${index}": ${index},\n`).join("") +
Array.from({ length: count }, (_, index) => `+ "column_${index}": ${index + 1},\n`).join(""),
tail: `"column_${count - 1}"`,
}
}
return {
...file,
patch:
prefix +
Array.from({ length: count }, (_, index) => {
const start = index * 40 + 1
return (
`@@ -${start},25 +${start},25 @@\n` +
Array.from({ length: 12 }, (_, offset) => ` "column_${start + offset}": ${start + offset},\n`).join("") +
`- "column_${start + 12}": ${start + 12},\n+ "column_${start + 12}": ${start + 13},\n` +
Array.from(
{ length: 12 },
(_, offset) => ` "column_${start + 13 + offset}": ${start + 13 + offset},\n`,
).join("")
)
}).join(""),
tail: `"column_${(count - 1) * 40 + 25}"`,
}
}
+14 -3
View File
@@ -220,11 +220,22 @@ const layer = Layer.effect(
const built: Stream.Stream<string, AppProcessError | PlatformError> = Stream.unwrap(
Effect.gen(function* () {
const handle = yield* spawner.spawn(command)
const streams =
options?.includeStderr === true
? yield* handle.stderr.pipe(
Stream.broadcastN({ n: 2, capacity: 16 }),
Effect.map((copies) => ({
source: Stream.merge(handle.stdout, copies[0]),
diagnostics: copies[1],
})),
)
: { source: handle.stdout, diagnostics: handle.stderr }
const stderrFiber = yield* Effect.forkScoped(
collectStream(handle.stderr, options?.maxErrorBytes).pipe(Effect.map((x) => x.buffer.toString("utf8"))),
collectStream(streams.diagnostics, options?.maxErrorBytes).pipe(
Effect.map((x) => x.buffer.toString("utf8")),
),
)
const source = options?.includeStderr === true ? handle.all : handle.stdout
const lines = source.pipe(
const lines = streams.source.pipe(
Stream.decodeText,
Stream.splitLines,
Stream.filter((line) => line.length > 0),