Compare commits

..

4 Commits

Author SHA1 Message Date
Simon Klee 26795f7ad2 feat(tui): render interactive transcript images 2026-08-07 16:31:58 +02:00
Simon Klee db4772f27c feat(tui): add prompt image previews 2026-08-07 16:31:53 +02:00
Simon Klee ad4145a4a5 chore(tui): upgrade integrated OpenTUI snapshot 2026-08-07 16:31:28 +02:00
Simon Klee b1839ebceb feat(tui): use OpenTUI clipboard service 2026-08-07 16:31:28 +02:00
78 changed files with 2229 additions and 2451 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

+31 -96
View File
@@ -395,7 +395,6 @@
"ignore": "7.0.5",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0",
@@ -598,9 +597,9 @@
},
"peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.4.5",
"@opentui/keymap": ">=0.4.5",
"@opentui/solid": ">=0.4.5",
"@opentui/core": "0.0.0-20260807-6b4336f3",
"@opentui/keymap": "0.0.0-20260807-6b4336f3",
"@opentui/solid": "0.0.0-20260807-6b4336f3",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
@@ -679,7 +678,6 @@
"@opencode-ai/util": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"modal": "0.9.0",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
@@ -892,7 +890,6 @@
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
"clipboardy": "4.0.0",
"effect": "catalog:",
"fuzzysort": "catalog:",
"get-east-asian-width": "catalog:",
@@ -1076,9 +1073,9 @@
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
},
"overrides": {
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@opentui/core": "0.0.0-20260807-6b4336f3",
"@opentui/keymap": "0.0.0-20260807-6b4336f3",
"@opentui/solid": "0.0.0-20260807-6b4336f3",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"effect": "catalog:",
@@ -1096,9 +1093,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.4.5",
"@opentui/keymap": "0.4.5",
"@opentui/solid": "0.4.5",
"@opentui/core": "0.0.0-20260807-6b4336f3",
"@opentui/keymap": "0.0.0-20260807-6b4336f3",
"@opentui/solid": "0.0.0-20260807-6b4336f3",
"@pierre/diffs": "1.2.10",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -1513,18 +1510,6 @@
"@capsizecss/unpack": ["@capsizecss/unpack@2.4.0", "", { "dependencies": { "blob-to-buffer": "^1.2.8", "cross-fetch": "^3.0.4", "fontkit": "^2.0.2" } }, "sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q=="],
"@cbor-extract/cbor-extract-darwin-arm64": ["@cbor-extract/cbor-extract-darwin-arm64@2.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA=="],
"@cbor-extract/cbor-extract-darwin-x64": ["@cbor-extract/cbor-extract-darwin-x64@2.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q=="],
"@cbor-extract/cbor-extract-linux-arm": ["@cbor-extract/cbor-extract-linux-arm@2.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ=="],
"@cbor-extract/cbor-extract-linux-arm64": ["@cbor-extract/cbor-extract-linux-arm64@2.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg=="],
"@cbor-extract/cbor-extract-linux-x64": ["@cbor-extract/cbor-extract-linux-x64@2.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA=="],
"@cbor-extract/cbor-extract-win32-x64": ["@cbor-extract/cbor-extract-win32-x64@2.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA=="],
"@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="],
"@clack/core": ["@clack/core@1.0.0-alpha.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rFbCU83JnN7l3W1nfgCqqme4ZZvTTgsiKQ6FM0l+r0P+o2eJpExcocBUWUIwnDzL76Aca9VhUdWmB2MbUv+Qyg=="],
@@ -1733,10 +1718,6 @@
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="],
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="],
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
"@hono/node-server": ["@hono/node-server@1.19.15", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg=="],
@@ -1831,8 +1812,6 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
"@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="],
"@jsx-email/all": ["@jsx-email/all@2.2.3", "", { "dependencies": { "@jsx-email/body": "1.0.2", "@jsx-email/button": "1.0.4", "@jsx-email/column": "1.0.3", "@jsx-email/container": "1.0.2", "@jsx-email/font": "1.0.3", "@jsx-email/head": "1.0.2", "@jsx-email/heading": "1.0.2", "@jsx-email/hr": "1.0.2", "@jsx-email/html": "1.0.2", "@jsx-email/img": "1.0.2", "@jsx-email/link": "1.0.2", "@jsx-email/markdown": "2.0.4", "@jsx-email/preview": "1.0.2", "@jsx-email/render": "1.1.1", "@jsx-email/row": "1.0.2", "@jsx-email/section": "1.0.2", "@jsx-email/tailwind": "2.4.4", "@jsx-email/text": "1.0.2" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-OBvLe/hVSQc0LlMSTJnkjFoqs3bmxcC4zpy/5pT5agPCSKMvAKQjzmsc2xJ2wO73jSpRV1K/g38GmvdCfrhSoQ=="],
@@ -2153,27 +2132,27 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
"@opentui/core": ["@opentui/core@0.4.5", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.5", "@opentui/core-darwin-x64": "0.4.5", "@opentui/core-linux-arm64": "0.4.5", "@opentui/core-linux-arm64-musl": "0.4.5", "@opentui/core-linux-x64": "0.4.5", "@opentui/core-linux-x64-musl": "0.4.5", "@opentui/core-win32-arm64": "0.4.5", "@opentui/core-win32-x64": "0.4.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig=="],
"@opentui/core": ["@opentui/core@0.0.0-20260807-6b4336f3", "", { "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.0.0-20260807-6b4336f3", "@opentui/core-darwin-x64": "0.0.0-20260807-6b4336f3", "@opentui/core-linux-arm64": "0.0.0-20260807-6b4336f3", "@opentui/core-linux-arm64-musl": "0.0.0-20260807-6b4336f3", "@opentui/core-linux-x64": "0.0.0-20260807-6b4336f3", "@opentui/core-linux-x64-musl": "0.0.0-20260807-6b4336f3", "@opentui/core-win32-arm64": "0.0.0-20260807-6b4336f3", "@opentui/core-win32-x64": "0.0.0-20260807-6b4336f3" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-ExOJ/jlJwDEh/S+IadNdG4ueTSG1rMu5r7Vyr6PXHqMNd1oY4T94aabRCXZJ+weL3flO07Iw8OW92EvvC4Nf+A=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.0.0-20260807-6b4336f3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cL9wjMoQdXbIVi60Xc0ktkbsHEvG9wePXcmS55LXIAoPQE2xbbGOOBmRQL8nrBzs5kdOAehzaYaIrbT5H290KA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.0.0-20260807-6b4336f3", "", { "os": "darwin", "cpu": "x64" }, "sha512-Vtdr6QCBhIc+gZYpcBhPBbUNV6j3IcfBdf3auKEAYUGEYjyD0aHlKsur8nKndeEDoEOuSeWMxAs97on2ejpetw=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.0.0-20260807-6b4336f3", "", { "os": "linux", "cpu": "arm64" }, "sha512-zN82yIVO7pEse3ZdOmIJAuFtCMbAURkMausD769NdJiJP0OAVs+21T0TMPRNB9fLgaIWDy1Ojk0mn2xPnF3QWg=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.0.0-20260807-6b4336f3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0xjJU9q/v5YUTzEE2pxdNFCYVlNzvNx45zSTOIPGrodXo1ecuwBh+0IyCa0EnCIciK7y2wHBd5AatUv0WXEbsw=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.0.0-20260807-6b4336f3", "", { "os": "linux", "cpu": "x64" }, "sha512-RuUEbnNfb8gzwVODCF5mUii0FW2r6P0rorSQbI/4+CwJgq9kTr5Nml4/J2wvxvxFiROWFgciohyFfiIgR7bhDA=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.0.0-20260807-6b4336f3", "", { "os": "linux", "cpu": "x64" }, "sha512-2DvIF+3CArHx7cY/KI3lmMdqKjPoxAuP/Haj+Fm+uCKRT4mQGGfkfRnparbFPs2dNxl4zmrT8/DolDZhpvcX8g=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.0.0-20260807-6b4336f3", "", { "os": "win32", "cpu": "arm64" }, "sha512-JYj7hDjx6qvpuL8C9bvhlvkD01sj3wSzCbuTMb1hq9lq1nMbtaekUWklLr6tEyxjDmu5+XkxCoxbUSbO7BvT6A=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.0.0-20260807-6b4336f3", "", { "os": "win32", "cpu": "x64" }, "sha512-++aXqPN0LlWUvNpzqUztGa3iI6EFrGxLC/EZxhi6NzBCRZN8nyfT8lzEyr6A5cMtjtv0/c0otSOU9fy1wgJkAQ=="],
"@opentui/keymap": ["@opentui/keymap@0.4.5", "", { "dependencies": { "@opentui/core": "0.4.5" }, "peerDependencies": { "@opentui/react": "0.4.5", "@opentui/solid": "0.4.5", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-S1wzKHhF70zT6bH+VBFY+lSeTImLcIFW28JNQiME8MoPcy6KGPs7rKFSHrb/U7P8rsTJeRfW5A4d1Cy6PKodDg=="],
"@opentui/keymap": ["@opentui/keymap@0.0.0-20260807-6b4336f3", "", { "dependencies": { "@opentui/core": "0.0.0-20260807-6b4336f3" }, "peerDependencies": { "@opentui/react": "0.0.0-20260807-6b4336f3", "@opentui/solid": "0.0.0-20260807-6b4336f3", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-WE24FrJqv/cZSuNRB0MAIZrTZRNBFIeTahscRYD8C0PoQ9eMJAcVT0RDy85gMONfCOW9G1O2g1WQlWU1nz/cWg=="],
"@opentui/solid": ["@opentui/solid@0.4.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.5", "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-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g=="],
"@opentui/solid": ["@opentui/solid@0.0.0-20260807-6b4336f3", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.0.0-20260807-6b4336f3", "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-Vn8SG0Zh2uWICXlz5ms0CSiRZrVO7zbQZOfj/NFE4h1fRm0n3oqXJBtmSbX/KWCzq9l181hP0PtbLomqgmONiQ=="],
"@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="],
@@ -3317,8 +3296,6 @@
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"abort-controller-x": ["abort-controller-x@0.5.0", "", {}, "sha512-yTt9CI0x+nRfX6BFMenEGP8ooPvErGH6AbFz20C2IeOLIlDsrw/VHpgne3GsCEuTA410IiFiaLVFKmgM4bKEPQ=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
@@ -3523,7 +3500,7 @@
"builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="],
"bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="],
"bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="],
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
@@ -3559,10 +3536,6 @@
"caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
"cbor-extract": ["cbor-extract@2.2.2", "", { "dependencies": { "node-gyp-build-optional-packages": "5.1.1" }, "optionalDependencies": { "@cbor-extract/cbor-extract-darwin-arm64": "2.2.2", "@cbor-extract/cbor-extract-darwin-x64": "2.2.2", "@cbor-extract/cbor-extract-linux-arm": "2.2.2", "@cbor-extract/cbor-extract-linux-arm64": "2.2.2", "@cbor-extract/cbor-extract-linux-x64": "2.2.2", "@cbor-extract/cbor-extract-win32-x64": "2.2.2" }, "bin": { "download-cbor-prebuilds": "bin/download-prebuilds.js" } }, "sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng=="],
"cbor-x": ["cbor-x@1.6.5", "", { "optionalDependencies": { "cbor-extract": "^2.2.2" } }, "sha512-yO64CxnSh6kp+pHNRK9IfwnMvCB+c8HvmUjQY/9l9YRF0/cAPka/tUHLwS64QqUpFCq3/OtbKziVJYXH2EaRig=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
@@ -3607,8 +3580,6 @@
"cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="],
"clipboardy": ["clipboardy@4.0.0", "", { "dependencies": { "execa": "^8.0.1", "is-wsl": "^3.1.0", "is64bit": "^2.0.0" } }, "sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
@@ -4025,7 +3996,7 @@
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
"execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="],
"execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
"exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="],
@@ -4307,7 +4278,7 @@
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="],
"human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
@@ -4451,8 +4422,6 @@
"is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
"is64bit": ["is64bit@2.0.0", "", { "dependencies": { "system-architecture": "^0.1.0" } }, "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw=="],
"isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
"isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
@@ -4595,8 +4564,6 @@
"lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
"lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
@@ -4793,7 +4760,7 @@
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="],
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
@@ -4823,8 +4790,6 @@
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
"modal": ["modal@0.9.0", "", { "dependencies": { "cbor-x": "^1.6.0", "long": "^5.3.1", "nice-grpc": "^2.1.12", "protobufjs": "^7.5.0", "smol-toml": "^1.3.3", "uuid": "^11.1.0" } }, "sha512-kCXcdJkhbJorf/q/6T9Wdlg6in9JmRnCNQnV6rVBMyeqNV/iXI6BYk4IzY4cvZ6dbauNeDMjk/Q08cbxvoIaXg=="],
"morphdom": ["morphdom@2.7.8", "", {}, "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg=="],
"motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="],
@@ -4863,10 +4828,6 @@
"nf3": ["nf3@0.1.12", "", {}, "sha512-qbMXT7RTGh74MYWPeqTIED8nDW70NXOULVHpdWcdZ7IVHVnAsMV9fNugSNnvooipDc1FMOzpis7T9nXJEbJhvQ=="],
"nice-grpc": ["nice-grpc@2.1.16", "", { "dependencies": { "@grpc/grpc-js": "^1.14.0", "abort-controller-x": "^0.5.0", "nice-grpc-common": "^2.0.3" } }, "sha512-Cl3Pn00212Hl8/U6bpgMxmhZj5lyv3nWoJov4cd3FjWarktrMHP4DNvSjCnDwkMWYx4W1tyscEia4JX6Y4GVCQ=="],
"nice-grpc-common": ["nice-grpc-common@2.0.3", "", { "dependencies": { "ts-error": "^1.0.6" } }, "sha512-MEhnD3JMah0mgyivpb9hpRDbOBuXBxI/TVO+OK1h6rC97WM42HsPMR+zzRNQ0C5BqYJTw1nyWiQRD0DucO+pjQ=="],
"nitro": ["nitro@3.0.1-alpha.1", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.1", "db0": "^0.3.4", "h3": "2.0.1-rc.5", "jiti": "^2.6.1", "nf3": "^0.1.10", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "oxc-minify": "^0.96.0", "oxc-transform": "^0.96.0", "srvx": "^0.9.5", "undici": "^7.16.0", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.4" }, "peerDependencies": { "rolldown": "*", "rollup": "^4", "vite": "^7", "xml2js": "^0.6.2" }, "optionalPeers": ["rolldown", "rollup", "vite", "xml2js"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-U4AxIsXxdkxzkFrK0XAw0e5Qbojk8jQ50MjjRBtBakC4HurTtQoiZvF+lSe382jhuQZCfAyywGWOFa9QzXLFaw=="],
"nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="],
@@ -4919,7 +4880,7 @@
"npm-registry-fetch": ["npm-registry-fetch@19.1.1", "", { "dependencies": { "@npmcli/redact": "^4.0.0", "jsonparse": "^1.3.1", "make-fetch-happen": "^15.0.0", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minizlib": "^3.0.1", "npm-package-arg": "^13.0.0", "proc-log": "^6.0.0" } }, "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw=="],
"npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="],
"npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
@@ -4945,7 +4906,7 @@
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="],
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
@@ -5545,7 +5506,7 @@
"strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="],
"strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="],
"strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
@@ -5577,8 +5538,6 @@
"svgo": ["svgo@4.0.2", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng=="],
"system-architecture": ["system-architecture@0.1.0", "", {}, "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
"tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="],
@@ -5665,8 +5624,6 @@
"ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="],
"ts-error": ["ts-error@1.0.6", "", {}, "sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
@@ -6519,8 +6476,6 @@
"@vercel/cli-config/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
"@vercel/cli-exec/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
"@vercel/functions/@vercel/oidc": ["@vercel/oidc@3.8.1", "", { "dependencies": { "@vercel/cli-config": "0.2.1", "@vercel/cli-exec": "1.0.0", "jose": "^5.9.6" } }, "sha512-ufdalm2MWOYksyj8KVpWjoOFPJO6zoYpuyvIggIQ2bB0CFCjTCiTkGXHqAKwG77GVRjOaN3/8S5ITlZpXWmqOw=="],
"@vercel/nft/acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
@@ -6625,8 +6580,6 @@
"builder-util/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
"cbor-extract/node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.1.1", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-test": "build-test.js", "node-gyp-build-optional-packages-optional": "optional.js" } }, "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw=="],
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -6707,9 +6660,11 @@
"estree-util-to-js/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"execa/get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="],
"execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
"execa/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="],
"execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
"execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
@@ -6771,8 +6726,6 @@
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"modal/uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="],
"motion/framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="],
"nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="],
@@ -6783,8 +6736,6 @@
"node-gyp/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="],
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
"openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
@@ -7555,20 +7506,6 @@
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
"@vercel/cli-exec/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
"@vercel/cli-exec/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
"@vercel/cli-exec/execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
"@vercel/cli-exec/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
"@vercel/cli-exec/execa/onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"@vercel/cli-exec/execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"@vercel/cli-exec/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
"@vercel/functions/@vercel/oidc/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
@@ -8625,8 +8562,6 @@
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
"@vercel/cli-exec/execa/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
+6 -6
View File
@@ -46,9 +46,9 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.4.5",
"@opentui/keymap": "0.4.5",
"@opentui/solid": "0.4.5",
"@opentui/core": "0.0.0-20260807-6b4336f3",
"@opentui/keymap": "0.0.0-20260807-6b4336f3",
"@opentui/solid": "0.0.0-20260807-6b4336f3",
"@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0",
@@ -149,9 +149,9 @@
"electron"
],
"overrides": {
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@opentui/core": "0.0.0-20260807-6b4336f3",
"@opentui/keymap": "0.0.0-20260807-6b4336f3",
"@opentui/solid": "0.0.0-20260807-6b4336f3",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"effect": "catalog:"
+13 -12
View File
@@ -688,8 +688,6 @@ export default function Page() {
return {
queryKey: [...vcsKey(), mode] as const,
enabled,
refetchOnMount: "always" as const,
refetchOnWindowFocus: true,
queryFn: mode
? () =>
sdk()
@@ -703,16 +701,6 @@ export default function Page() {
}
})
const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100)
createEffect(
on(
() => desktopReviewOpen() || mobileChanges(),
(open, previous) => {
if (!open || previous || !desktopFileTreeOpen() || vcsQuery.isFetching) return
refreshVcs()
},
{ defer: true },
),
)
const reviewDiffs = () => {
if (reviewMode() === "git" || reviewMode() === "branch")
// avoids suspense
@@ -959,6 +947,19 @@ export default function Page() {
),
)
const stopVcs = sdk().event.listen((evt) => {
const details = evt.details as { type: string; properties?: unknown }
if (details.type !== "file.watcher.updated" && details.type !== "filesystem.changed") return
const props =
typeof details.properties === "object" && details.properties
? (details.properties as Record<string, unknown>)
: undefined
const file = typeof props?.file === "string" ? props.file : undefined
if (!file || file.startsWith(".git/")) return
refreshVcs()
})
onCleanup(stopVcs)
createEffect(
on(
() => sdk().directory,
-2
View File
@@ -18,7 +18,6 @@
},
"exports": {
"./environment": "./src/environment/index.ts",
"./testing/environment-conformance": "./test/lib/environment-conformance.ts",
"./session/runner": "./src/session/runner/index.ts",
"./instructions": "./src/instructions/index.ts",
"./*": "./src/*.ts"
@@ -119,7 +118,6 @@
"immer": "11.1.4",
"ignore": "7.0.5",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"turndown": "7.2.0",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
+4 -6
View File
@@ -13,14 +13,12 @@ export const Plugin = define({
const config = yield* Config.Service
const loaded = { entries: yield* config.entries() }
yield* ctx.integration.transform((integrations) => {
const configuredIntegrations = new Set(
configuredProviders(loaded.entries).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])),
)
for (const [id, provider] of configuredProviders(loaded.entries)) {
const integrationID = id
if (!integrations.get(integrationID)) {
integrations.method.update({
integrationID,
method: { type: "key", label: "Manually enter API Key" },
})
}
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
integrations.update(integrationID, (integration) => {
integration.name = provider.name ?? integration.name
})
@@ -1,26 +0,0 @@
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer } from "effect"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import type { Files } from "./files"
import { makeFiles } from "./index"
import { makeLocalDriver } from "./local"
export interface Interface {
readonly files: Files
readonly spawner: ChildProcessSpawner["Service"]
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Environment") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
return Service.of({ files: makeFiles(makeLocalDriver(spawner)), spawner })
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [CrossSpawnSpawner.node] })
export * as EnvironmentService from "./environment"
@@ -50,13 +50,13 @@ fi
`
const listScript = `
${loadMetadata("-L")}
${loadMetadata()}
kind=\${metadata%%${TAB}*}
if [ "$kind" != directory ]; then
printf '%s' "$kind" >&2
exit ${WRONG_KIND}
fi
find -H "$1" -mindepth 1 -maxdepth 1 -printf '%y\\0%f\\0'
find "$1" -mindepth 1 -maxdepth 1 -printf '%y\\0%f\\0'
`
const moveScript = `
+2 -19
View File
@@ -30,8 +30,7 @@ export class Failed extends Schema.TaggedErrorClass<Failed>()("Environment.Faile
export interface FilesImpl {
/**
* Content operations (`read`, `list`) follow final symlinks; metadata operations (`stat` and entry
* tags returned by `list`) do not. `info` describes the target file whose bytes are returned.
* Reads a file, following a final symlink so `info` describes the target whose bytes are returned.
* The process-backed default caps collected output at 64 MiB; larger whole-file reads fail with
* `Failed`, so callers must use ranges for larger files.
*/
@@ -42,7 +41,7 @@ export interface FilesImpl {
readonly write: (path: string, bytes: Uint8Array) => Effect.Effect<void, Failed>
/** Describes the path entry itself, so a final symlink is reported as `symlink` rather than followed. */
readonly stat: (path: string) => Effect.Effect<FileInfo, NotFound | Failed>
/** Follows a final symlink to the listed directory while preserving each returned entry's own type. */
/** Lists a directory entry without following a final symlink; intermediate symlinks are traversed. */
readonly list: (path: string) => Effect.Effect<ReadonlyArray<DirEntry>, NotFound | WrongKind | Failed>
readonly remove: (path: string) => Effect.Effect<void, Failed>
readonly move: (from: string, to: string) => Effect.Effect<void, NotFound | Failed>
@@ -51,20 +50,4 @@ export interface FilesImpl {
export interface Files extends FilesImpl {}
/**
* Derives a follow-stat kind from the lstat-like Files contract. A dangling
* symlink fails with `NotFound`.
*/
export const typeFollowing = (files: Files, path: string) =>
files.stat(path).pipe(
Effect.flatMap((info) =>
info.type === "symlink"
? files.read(path, { offset: 0, length: 0 }).pipe(
Effect.map((result) => result.info.type),
Effect.catchTag("Environment.WrongKind", (error) => Effect.succeed(error.actual)),
)
: Effect.succeed(info.type),
),
)
export * as EnvironmentFiles from "./files"
-3
View File
@@ -9,13 +9,10 @@ export {
type FilesImpl,
type FileType,
NotFound,
typeFollowing,
WrongKind,
} from "./files"
export { execDefaults } from "./exec-defaults"
export { makeLocalDriver } from "./local"
export { makeMemoryDriver, type MemoryDriver } from "./memory"
export { type Interface, node, Service } from "./environment"
import type { Driver } from "./driver"
import { execDefaults } from "./exec-defaults"
-103
View File
@@ -1,103 +0,0 @@
import fs from "node:fs/promises"
import path from "node:path"
import { Effect } from "effect"
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import type { Driver } from "./driver"
import { Failed, NotFound, WrongKind, type FileInfo, type FilesImpl, type FileType } from "./files"
/**
* The host filesystem binding. Deliberately raw node:fs rather than effect's
* FileSystem service or FSUtil: the contract needs lstat semantics (stat
* reports "symlink") and typed directory entries, and effect's node
* FileSystem provides neither — its stat always follows symlinks and
* readDirectory returns names only. FSUtil hits the same gap and its
* readDirectoryEntries already bypasses to raw node readdir internally.
* Nothing above the environment seam touches node:fs.
*/
export const makeLocalDriver = (spawner: ChildProcessSpawner["Service"]): Driver => {
const overrides: FilesImpl = {
read: (value, range) =>
Effect.gen(function* () {
const info = yield* stat(value, true)
if (info.type !== "file") return yield* new WrongKind({ path: value, actual: info.type })
if (range === undefined) {
const bytes = yield* attempt(value, () => fs.readFile(value), true)
return { info, bytes }
}
const bytes = yield* attempt(
value,
async () => {
const handle = await fs.open(value, "r")
try {
const buffer = new Uint8Array(range.length)
const result = await handle.read(buffer, 0, range.length, range.offset)
return buffer.subarray(0, result.bytesRead)
} finally {
await handle.close()
}
},
true,
)
return { info, bytes }
}),
stat: (value) => stat(value, false),
list: (value) =>
Effect.gen(function* () {
const info = yield* stat(value, true)
if (info.type !== "directory") return yield* new WrongKind({ path: value, actual: info.type })
const entries = yield* attempt(value, () => fs.readdir(value, { withFileTypes: true }), true)
return entries.map((entry) => ({ name: entry.name, type: fileType(entry) }))
}),
write: (value, bytes) =>
attempt(value, async () => {
await fs.mkdir(path.dirname(value), { recursive: true })
await fs.writeFile(value, bytes)
}),
remove: (value) => attempt(value, () => fs.rm(value, { recursive: true, force: true })),
move: (from, to) =>
Effect.gen(function* () {
yield* stat(from, false)
const destination = yield* stat(to, false).pipe(
Effect.map((info) => (info.type === "directory" ? path.join(to, path.basename(from)) : to)),
Effect.catchIf(
(error) => error instanceof NotFound,
() => Effect.succeed(to),
),
)
yield* attempt(from, () => fs.rename(from, destination))
}),
mkdir: (value) => attempt(value, () => fs.mkdir(value, { recursive: true }).then(() => undefined)),
}
return { spawner, overrides }
}
const stat = (value: string, follow: boolean) =>
attempt(value, () => (follow ? fs.stat(value) : fs.lstat(value)), true).pipe(
Effect.map((stats): FileInfo => ({ type: fileType(stats), size: stats.size, mtimeMs: stats.mtimeMs })),
)
const fileType = (entry: { isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean }): FileType => {
if (entry.isFile()) return "file"
if (entry.isDirectory()) return "directory"
if (entry.isSymbolicLink()) return "symlink"
return "other"
}
function attempt<A>(value: string, run: () => Promise<A>): Effect.Effect<A, Failed>
function attempt<A>(value: string, run: () => Promise<A>, missing: true): Effect.Effect<A, NotFound | Failed>
function attempt<A>(value: string, run: () => Promise<A>, missing = false) {
return Effect.tryPromise({
try: run,
catch: (cause) =>
missing && isMissing(cause) ? new NotFound({ path: value }) : new Failed({ path: value, cause }),
})
}
const isMissing = (cause: unknown) =>
cause !== null &&
typeof cause === "object" &&
"code" in cause &&
(cause.code === "ENOENT" || cause.code === "ENOTDIR")
export * as EnvironmentLocal from "./local"
+1 -1
View File
@@ -90,7 +90,7 @@ export const makeMemoryDriver = (): MemoryDriver => {
catch: (cause) => failed(value, cause),
}),
list: (value) => {
const target = resolveKey(value, true) ?? key(value)
const target = resolveKey(value, false) ?? key(value)
const node = nodes.get(target)
if (!node) return Effect.fail(new NotFound({ path: value }))
if (node.type !== "directory") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
+12 -48
View File
@@ -5,8 +5,6 @@ import { Context, Effect, Layer } from "effect"
import { KeyedMutex } from "./effect/keyed-mutex"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Bom } from "@opencode-ai/util/bom"
import { Environment } from "./environment"
import type { Files } from "./environment"
export interface Target {
readonly absolute: string
@@ -31,36 +29,13 @@ export interface WriteResult {
}
export interface Interface {
/** Serialize a complete read/prepare/write mutation transaction by resolved path. */
readonly withLock: (
targets: ReadonlyArray<string>,
) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, Environment.Failed>
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
/** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */
readonly writeTextPreservingBom: (
input: TextWriteInput,
) => Effect.Effect<WriteResult, Environment.WrongKind | Environment.Failed>
readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
export const readText = Effect.fn("FileMutation.readText")(function* (files: Files, target: string) {
return Bom.decodeBytes((yield* files.read(target)).bytes)
})
export const syncTextBom = Effect.fn("FileMutation.syncTextBom")(function* (
files: Files,
target: string,
bom: boolean,
) {
const synced = Bom.syncBytes((yield* files.read(target)).bytes, bom)
if (synced.bytes) yield* files.write(target, synced.bytes)
return synced.text
})
/** Share transaction locks across Location graphs that address the same file. */
const transactionLocks = KeyedMutex.makeUnsafe<string>()
/**
* Serialize file changes by absolute target. Conditional writes compare and
* write under the same process-local lock so cooperating OpenCode mutations do
@@ -69,12 +44,8 @@ const transactionLocks = KeyedMutex.makeUnsafe<string>()
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const environment = yield* Environment.Service
const fs = yield* FSUtil.Service
const locks = KeyedMutex.makeUnsafe<string>()
const withLock: Interface["withLock"] = (targets) => (effect) =>
[...new Set(targets.map(FSUtil.resolve))]
.sort()
.reduceRight((result, target) => transactionLocks.withLock(target)(result), effect)
const withTargetLock =
(target: Target) =>
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
@@ -90,14 +61,8 @@ const layer = Layer.effect(
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
withTargetLock(input.target)(
Effect.gen(function* () {
const existed = yield* environment.files.stat(input.target.absolute).pipe(
Effect.as(true),
Effect.catchTag("Environment.NotFound", () => Effect.succeed(false)),
)
yield* environment.files.write(
input.target.absolute,
typeof input.content === "string" ? new TextEncoder().encode(input.content) : input.content,
)
const existed = yield* fs.exists(input.target.absolute)
yield* fs.writeWithDirs(input.target.absolute, input.content)
return writeResult(input.target, existed)
}),
),
@@ -107,24 +72,23 @@ const layer = Layer.effect(
withTargetLock(input.target)(
Effect.gen(function* () {
const next = Bom.split(input.content)
const current = yield* environment.files.read(input.target.absolute, { offset: 0, length: 3 }).pipe(
Effect.map((result) => result.bytes),
Effect.catchTag("Environment.NotFound", () => Effect.succeed(undefined)),
)
yield* environment.files.write(
const current = yield* fs
.readFile(input.target.absolute)
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
yield* fs.writeWithDirs(
input.target.absolute,
new TextEncoder().encode(Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom)),
Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom),
)
return writeResult(input.target, current !== undefined)
}),
),
)
return Service.of({ withLock, write, writeTextPreservingBom })
return Service.of({ write, writeTextPreservingBom })
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Environment.node] })
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
/**
* Deferred until the corresponding integrations exist.
@@ -11,6 +11,15 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "../git"
import { Location } from "../location"
import { Watcher } from "./watcher"
import { Ignore } from "./ignore"
import { Protected } from "./protected"
function protecteds(dir: string) {
return Protected.paths().filter((item) => {
const relative = path.relative(dir, item)
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)
})
}
export interface Interface {}
@@ -35,6 +44,19 @@ const layer = Layer.effect(
const config = (yield* configService.entries())
.filter((entry): entry is Document => entry.type === "document")
.flatMap((item) => item.info.watcher?.ignore ?? [])
const home = Protected.isHome(location.directory)
if (!home && location.vcs) {
const updates = yield* watcher.subscribe({
path: location.directory,
type: "directory",
ignore: [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)],
})
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
}
if (home) {
yield* Effect.logInfo("location watcher skipped home directory", { directory: location.directory })
}
if (location.vcs?.type === "git") {
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
@@ -42,7 +64,10 @@ const layer = Layer.effect(
? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved)))
: undefined
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
const updates = yield* watcher.subscribe({ path: path.join(vcs, "HEAD"), type: "file" })
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
(entry) => (entry.name === "HEAD" ? [] : [entry.name]),
)
const updates = yield* watcher.subscribe({ path: vcs, type: "directory", ignore })
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
}
}
-2
View File
@@ -8,7 +8,6 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Node } from "@opencode-ai/util/effect/app-node"
import { Bus } from "./bus"
import { FileMutation } from "./file-mutation"
import { Environment } from "./environment"
import { Formatter } from "./formatter"
import { FileSystem } from "./filesystem"
import { FileSystemSearch } from "./filesystem/search"
@@ -54,7 +53,6 @@ export { LocationServiceMap } from "./location-service-map"
const locationServiceNodes = [
Location.node,
Environment.node,
Config.node,
Agent.node,
Command.node,
-3
View File
@@ -16,7 +16,6 @@ import { ConfigReferencePlugin } from "../config/plugin/reference"
import { ConfigSkillPlugin } from "../config/plugin/skill"
import { ConfigWebSearchPlugin } from "../config/plugin/websearch"
import { Bus } from "../bus"
import { Environment } from "../environment"
import { FileMutation } from "../file-mutation"
import { Formatter } from "../formatter"
import { Form } from "../form"
@@ -71,7 +70,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const config = yield* Config.Service
const credential = yield* Credential.Service
const bus = yield* Bus.Service
const environment = yield* Environment.Service
const mutation = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const filesystem = yield* FileSystem.Service
@@ -104,7 +102,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Config.Service, config),
Context.make(Credential.Service, credential),
Context.make(Bus.Service, bus),
Context.make(Environment.Service, environment),
Context.make(FileMutation.Service, mutation),
Context.make(Formatter.Service, formatter),
Context.make(FileSystem.Service, filesystem),
+1 -5
View File
@@ -14,7 +14,6 @@ import { Credential } from "../credential"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { Bus } from "../bus"
import { Environment } from "../environment"
import { FileMutation } from "../file-mutation"
import { Formatter } from "../formatter"
import { FileSystem } from "../filesystem"
@@ -283,9 +282,7 @@ const layer = Layer.effect(
})
const updates = Stream.merge(
config.changes().pipe(
Stream.filterEffect((update) =>
Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path)),
),
Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path))),
Stream.merge(Stream.fromPubSub(configuredChanges)),
),
bus.subscribe([Event.Updated, SdkPlugins.Updated]),
@@ -323,7 +320,6 @@ export const node = makeLocationNode({
Config.node,
Credential.node,
Bus.node,
Environment.node,
FileMutation.node,
Formatter.node,
FileSystem.node,
+5 -7
View File
@@ -3,9 +3,8 @@ export * as Ripgrep from "./ripgrep"
import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { Entry, Match } from "@opencode-ai/schema/filesystem"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { collectStream, waitForAbort } from "@opencode-ai/util/process"
import { Environment } from "./environment"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { AppProcess, collectStream, waitForAbort } from "@opencode-ai/util/process"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
import { RipgrepBinary } from "./ripgrep/binary"
@@ -94,7 +93,7 @@ const isInvalidPattern = (stderr: string) =>
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const environment = yield* Environment.Service
const process = yield* AppProcess.Service
const binary = yield* RipgrepBinary.Service
const run = <A>(input: {
@@ -108,8 +107,7 @@ const layer = Layer.effect(
}) => {
const program = Effect.scoped(
Effect.gen(function* () {
// Hosted environments will resolve rg through their driver image; the spawner is the execution seam.
const handle = yield* environment.spawner.spawn(
const handle = yield* process.spawn(
ChildProcess.make(yield* binary.filepath, input.args, { cwd: input.cwd, extendEnv: true, stdin: "ignore" }),
)
const stderrFiber = yield* collectStream(handle.stderr, ERROR_BYTES).pipe(
@@ -277,4 +275,4 @@ const layer = Layer.effect(
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Environment.node, RipgrepBinary.node] })
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] })
+1 -16
View File
@@ -2,14 +2,9 @@ export * as SessionRestart from "./restart"
import { Context, Effect, Layer } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../../bus"
import { SessionEvent } from "../event"
import { SessionExecution } from "../execution"
import { SessionStore } from "../store"
const CONTINUE_AFTER_SERVER_RESTART =
"The server restarted while you were working. Continue from where you left off without repeating completed work."
export interface Interface {
/**
* Marks every execution active in this process for resumption by the next server start.
@@ -31,7 +26,6 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const store = yield* SessionStore.Service
const execution = yield* SessionExecution.Service
const bus = yield* Bus.Service
return Service.of({
suspendActiveSessions: Effect.gen(function* () {
yield* store.suspend(yield* execution.active)
@@ -43,11 +37,6 @@ export const layer = Layer.effect(
(sessionID) =>
Effect.gen(function* () {
if (!(yield* store.consumeSuspended(sessionID))) return
yield* bus.publish(SessionEvent.Synthetic, {
sessionID,
text: CONTINUE_AFTER_SERVER_RESTART,
description: "Continuing after restart",
})
// Drain failures are already logged and durably recorded by the execution layer.
yield* Effect.ignore(execution.resume(sessionID))
}),
@@ -58,8 +47,4 @@ export const layer = Layer.effect(
}),
)
export const node = makeGlobalNode({
service: Service,
layer,
deps: [SessionStore.node, SessionExecution.node, Bus.node],
})
export const node = makeGlobalNode({ service: Service, layer, deps: [SessionStore.node, SessionExecution.node] })
+270 -269
View File
@@ -6,9 +6,9 @@ import { ChildProcess } from "effect/unstable/process"
import { produce } from "immer"
import { Shell } from "@opencode-ai/schema/shell"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { AppProcess } from "@opencode-ai/util/process"
import { Config } from "./config"
import { Bus } from "./bus"
import { Environment } from "./environment"
import { Location } from "./location"
import { Global } from "@opencode-ai/util/global"
import { ShellSelect } from "./shell/select"
@@ -65,284 +65,285 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Shell") {}
export const layer = (options?: ShellSelect.Options) =>
Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const location = yield* Location.Service
const config = yield* Config.Service
const global = yield* Global.Service
const environment = yield* Environment.Service
const hooks = yield* PluginHooks.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<string, Active>()
const exitOrder: string[] = []
export const layer = (options?: ShellSelect.Options) => Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const location = yield* Location.Service
const config = yield* Config.Service
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const hooks = yield* PluginHooks.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<string, Active>()
const exitOrder: string[] = []
const outputDir = path.join(global.data, "shell", location.project.id)
const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises"))
const { createWriteStream, createReadStream } = yield* Effect.promise(() => import("fs"))
yield* Effect.promise(() => mkdir(outputDir, { recursive: true }))
const outputDir = path.join(global.data, "shell", location.project.id)
const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises"))
const { createWriteStream, createReadStream } = yield* Effect.promise(() => import("fs"))
yield* Effect.promise(() => mkdir(outputDir, { recursive: true }))
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
for (const session of sessions.values()) {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
// Unblock waiters still pending at teardown; succeed is a no-op once already resolved.
yield* Deferred.fail(session.done, new NotFoundError({ id: Shell.ID.make(session.info.id) }))
}
sessions.clear()
exitOrder.length = 0
}),
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
for (const session of sessions.values()) {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
// Unblock waiters still pending at teardown; succeed is a no-op once already resolved.
yield* Deferred.fail(session.done, new NotFoundError({ id: Shell.ID.make(session.info.id) }))
}
sessions.clear()
exitOrder.length = 0
}),
)
const require = Effect.fn("Shell.require")(function* (id: Shell.ID) {
const session = sessions.get(id)
if (!session) return yield* new NotFoundError({ id })
return session
})
const removeSession = Effect.fnUntraced(function* (id: Shell.ID) {
const session = sessions.get(id)
if (!session) return
sessions.delete(id)
const index = exitOrder.indexOf(id)
if (index !== -1) exitOrder.splice(index, 1)
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
// Unblock any wait still pending when the command is removed before it terminated.
yield* Deferred.fail(session.done, new NotFoundError({ id }))
yield* Effect.promise(() => unlink(session.file).catch(() => {}))
yield* bus.publish(Shell.Event.Deleted, { id })
})
const remove = Effect.fn("Shell.remove")(function* (id: Shell.ID) {
yield* require(id)
yield* removeSession(id)
})
const list = Effect.fn("Shell.list")(function* () {
return Array.from(sessions.values())
.filter((session) => session.info.status === "running")
.map((session) => session.info)
})
const get = Effect.fn("Shell.get")(function* (id: Shell.ID) {
return (yield* require(id)).info
})
const wait = Effect.fn("Shell.wait")(function* (id: Shell.ID) {
return yield* Deferred.await((yield* require(id)).done)
})
const timeout = Effect.fn("Shell.timeout")(function* (id: Shell.ID, duration: number) {
const session = yield* require(id)
if (session.info.status !== "running" || !session.timeout) return session.info
yield* session.timeout(duration)
return session.info
})
const resolve = () =>
config
.entries()
.pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options)))
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
const session = yield* require(id)
const cursor = input?.cursor ?? 0
const limit = input?.limit ?? 65536
if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false }
const start = Math.max(0, cursor)
const length = Math.min(limit, session.size - start)
const buffer = Buffer.alloc(length)
const bytesRead = yield* Effect.promise(
() =>
new Promise<number>((resolve) => {
const stream = createReadStream(session.file, { start, end: start + length - 1 })
let offset = 0
stream.on("data", (chunk: string | Buffer) => {
const bytes = Buffer.from(chunk)
bytes.copy(buffer, offset)
offset += bytes.length
})
stream.on("end", () => resolve(offset))
stream.on("error", () => resolve(0))
}),
)
return {
output: buffer.subarray(0, bytesRead).toString("utf8"),
cursor: start + bytesRead,
size: session.size,
truncated: false,
}
})
const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
input: Shell.CreateInput,
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
) {
const invocation: ShellCreateBefore = {
command: input.command,
cwd: input.cwd ?? location.directory,
timeout: input.timeout,
shell: yield* resolve(),
env: {
...process.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
},
}
yield* hooks.trigger("shell", "create.before", invocation)
if (before) yield* before(invocation)
const id = Shell.ID.ascending()
const args = ShellSelect.args(invocation.shell, invocation.command)
const file = path.join(outputDir, `${id}.out`)
const info: Info = {
id,
status: "running",
command: invocation.command,
cwd: invocation.cwd,
shell: invocation.shell,
file,
metadata: input.metadata ?? {},
time: { started: Date.now() },
}
// Spawn via AppProcess and stream combined output to the file. The handle is scope-bound, so
// the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
// end). `create` returns once `ready` resolves with the registered session.
const ready = Deferred.makeUnsafe<Active>()
runFork(
Effect.scoped(
Effect.gen(function* () {
const handle = yield* appProcess.spawn(
ChildProcess.make(invocation.shell, args, {
cwd: invocation.cwd,
env: invocation.env,
stdin: "ignore",
detached: process.platform !== "win32",
forceKillAfter: Duration.seconds(3),
}),
)
const session: Active = {
info: produce(info, (draft) => {
draft.pid = handle.pid
}),
file,
size: 0,
done: Deferred.makeUnsafe<Info, NotFoundError>(),
}
sessions.set(id, session)
const stream = createWriteStream(file)
const outputDone = Deferred.makeUnsafe<void>()
const pump = handle.all.pipe(
Stream.runForEach((chunk: Uint8Array) =>
Effect.sync(() => {
stream.write(chunk)
session.size += chunk.length
}),
),
)
runFork(
Effect.gen(function* () {
yield* pump.pipe(Effect.catch(() => Effect.void))
yield* Effect.promise(
() =>
new Promise<void>((resolve) => {
stream.end(() => resolve())
}),
)
yield* Deferred.succeed(outputDone, undefined)
}).pipe(Effect.catch(() => Deferred.succeed(outputDone, undefined))),
)
yield* Effect.promise(
() =>
new Promise<void>((resolve) => {
stream.once("open", () => resolve())
stream.once("error", () => resolve())
}),
)
const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) =>
Effect.gen(function* () {
if (session.info.status !== "running") return
session.info = produce(session.info, (draft) => {
draft.status = status
if (exit !== undefined) draft.exit = exit
draft.time.completed = Date.now()
})
yield* beforeWait
yield* Deferred.await(outputDone)
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
// session still reports success rather than the removal NotFoundError. This runs before
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
// this very fiber (finish is invoked by the timeout fiber) before waiters are resolved.
yield* Deferred.succeed(session.done, session.info)
yield* bus.publish(Shell.Event.Exited, {
id,
...(exit !== undefined ? { exit } : {}),
status,
})
exitOrder.push(id)
while (exitOrder.length > EXITED_LIMIT) {
const oldest = exitOrder[0]
if (!oldest) break
yield* removeSession(Shell.ID.make(oldest))
}
// Cancel a pending timeout once the command exits on its own. Interrupting last avoids
// aborting finish when finish itself runs on the timeout fiber.
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
})
session.timeout = (duration) =>
Effect.gen(function* () {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
session.timeoutFiber = undefined
if (duration === 0 || session.info.status !== "running") return
session.timeoutFiber = runFork(
Effect.sleep(Duration.millis(duration)).pipe(
Effect.flatMap(() =>
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
),
Effect.catch(() => Effect.void),
),
)
})
yield* session.timeout(invocation.timeout)
runFork(
handle.exitCode.pipe(
Effect.flatMap((code) => finish("exited", code)),
Effect.catch(() => Effect.void),
),
)
yield* bus.publish(Shell.Event.Created, { info })
yield* Deferred.succeed(ready, session)
// Hold the handle's scope open until the command terminates; closing it earlier would
// release (kill) the process before its exit is observed.
yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void))
}),
).pipe(Effect.catch(() => Effect.void)),
)
const require = Effect.fn("Shell.require")(function* (id: Shell.ID) {
const session = sessions.get(id)
if (!session) return yield* new NotFoundError({ id })
return session
})
const session = yield* Deferred.await(ready)
return session.info
})
const removeSession = Effect.fnUntraced(function* (id: Shell.ID) {
const session = sessions.get(id)
if (!session) return
sessions.delete(id)
const index = exitOrder.indexOf(id)
if (index !== -1) exitOrder.splice(index, 1)
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
// Unblock any wait still pending when the command is removed before it terminated.
yield* Deferred.fail(session.done, new NotFoundError({ id }))
yield* Effect.promise(() => unlink(session.file).catch(() => {}))
yield* bus.publish(Shell.Event.Deleted, { id })
})
const remove = Effect.fn("Shell.remove")(function* (id: Shell.ID) {
yield* require(id)
yield* removeSession(id)
})
const list = Effect.fn("Shell.list")(function* () {
return Array.from(sessions.values())
.filter((session) => session.info.status === "running")
.map((session) => session.info)
})
const get = Effect.fn("Shell.get")(function* (id: Shell.ID) {
return (yield* require(id)).info
})
const wait = Effect.fn("Shell.wait")(function* (id: Shell.ID) {
return yield* Deferred.await((yield* require(id)).done)
})
const timeout = Effect.fn("Shell.timeout")(function* (id: Shell.ID, duration: number) {
const session = yield* require(id)
if (session.info.status !== "running" || !session.timeout) return session.info
yield* session.timeout(duration)
return session.info
})
const resolve = () =>
config.entries().pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options)))
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
const session = yield* require(id)
const cursor = input?.cursor ?? 0
const limit = input?.limit ?? 65536
if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false }
const start = Math.max(0, cursor)
const length = Math.min(limit, session.size - start)
const buffer = Buffer.alloc(length)
const bytesRead = yield* Effect.promise(
() =>
new Promise<number>((resolve) => {
const stream = createReadStream(session.file, { start, end: start + length - 1 })
let offset = 0
stream.on("data", (chunk: string | Buffer) => {
const bytes = Buffer.from(chunk)
bytes.copy(buffer, offset)
offset += bytes.length
})
stream.on("end", () => resolve(offset))
stream.on("error", () => resolve(0))
}),
)
return {
output: buffer.subarray(0, bytesRead).toString("utf8"),
cursor: start + bytesRead,
size: session.size,
truncated: false,
}
})
const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
input: Shell.CreateInput,
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
) {
const invocation: ShellCreateBefore = {
command: input.command,
cwd: input.cwd ?? location.directory,
timeout: input.timeout,
shell: yield* resolve(),
env: {
...process.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
},
}
yield* hooks.trigger("shell", "create.before", invocation)
if (before) yield* before(invocation)
const id = Shell.ID.ascending()
const args = ShellSelect.args(invocation.shell, invocation.command)
const file = path.join(outputDir, `${id}.out`)
const info: Info = {
id,
status: "running",
command: invocation.command,
cwd: invocation.cwd,
shell: invocation.shell,
file,
metadata: input.metadata ?? {},
time: { started: Date.now() },
}
// Spawn through the Environment and stream combined output to the file. The handle is scope-bound, so
// the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
// end). `create` returns once `ready` resolves with the registered session.
const ready = Deferred.makeUnsafe<Active>()
runFork(
Effect.scoped(
Effect.gen(function* () {
const handle = yield* environment.spawner.spawn(
ChildProcess.make(invocation.shell, args, {
cwd: invocation.cwd,
env: invocation.env,
stdin: "ignore",
detached: process.platform !== "win32",
forceKillAfter: Duration.seconds(3),
}),
)
const session: Active = {
info: produce(info, (draft) => {
draft.pid = handle.pid
}),
file,
size: 0,
done: Deferred.makeUnsafe<Info, NotFoundError>(),
}
sessions.set(id, session)
const stream = createWriteStream(file)
const outputDone = Deferred.makeUnsafe<void>()
const pump = handle.all.pipe(
Stream.runForEach((chunk: Uint8Array) =>
Effect.sync(() => {
stream.write(chunk)
session.size += chunk.length
}),
),
)
runFork(
Effect.gen(function* () {
yield* pump.pipe(Effect.catch(() => Effect.void))
yield* Effect.promise(
() =>
new Promise<void>((resolve) => {
stream.end(() => resolve())
}),
)
yield* Deferred.succeed(outputDone, undefined)
}).pipe(Effect.catch(() => Deferred.succeed(outputDone, undefined))),
)
yield* Effect.promise(
() =>
new Promise<void>((resolve) => {
stream.once("open", () => resolve())
stream.once("error", () => resolve())
}),
)
const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) =>
Effect.gen(function* () {
if (session.info.status !== "running") return
session.info = produce(session.info, (draft) => {
draft.status = status
if (exit !== undefined) draft.exit = exit
draft.time.completed = Date.now()
})
yield* beforeWait
yield* Deferred.await(outputDone)
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
// session still reports success rather than the removal NotFoundError. This runs before
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
// this very fiber (finish is invoked by the timeout fiber) before waiters are resolved.
yield* Deferred.succeed(session.done, session.info)
yield* bus.publish(Shell.Event.Exited, {
id,
...(exit !== undefined ? { exit } : {}),
status,
})
exitOrder.push(id)
while (exitOrder.length > EXITED_LIMIT) {
const oldest = exitOrder[0]
if (!oldest) break
yield* removeSession(Shell.ID.make(oldest))
}
// Cancel a pending timeout once the command exits on its own. Interrupting last avoids
// aborting finish when finish itself runs on the timeout fiber.
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
})
session.timeout = (duration) =>
Effect.gen(function* () {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
session.timeoutFiber = undefined
if (duration === 0 || session.info.status !== "running") return
session.timeoutFiber = runFork(
Effect.sleep(Duration.millis(duration)).pipe(
Effect.flatMap(() =>
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
),
Effect.catch(() => Effect.void),
),
)
})
yield* session.timeout(invocation.timeout)
runFork(
handle.exitCode.pipe(
Effect.flatMap((code) => finish("exited", code)),
Effect.catch(() => Effect.void),
),
)
yield* bus.publish(Shell.Event.Created, { info })
yield* Deferred.succeed(ready, session)
// Hold the handle's scope open until the command terminates; closing it earlier would
// release (kill) the process before its exit is observed.
yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void))
}),
).pipe(Effect.catch(() => Effect.void)),
)
const session = yield* Deferred.await(ready)
return session.info
})
return Service.of({ name, create, list, get, wait, timeout, output, remove })
}),
)
return Service.of({ name, create, list, get, wait, timeout, output, remove })
}),
)
export function configured(options?: ShellSelect.Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [Bus.node, Location.node, Config.node, Global.node, Environment.node, PluginHooks.node],
deps: [Bus.node, Location.node, Config.node, Global.node, AppProcess.node, PluginHooks.node],
})
}
+34 -105
View File
@@ -2,7 +2,8 @@ export * as Skill from "./skill"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path"
import { Context, Effect, FiberMap, Layer, PubSub, Schema, Semaphore, Stream, Types } from "effect"
import { Context, Effect, Layer, Schema, Stream, Types } from "effect"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Skill } from "@opencode-ai/schema/skill"
import { Agent } from "./agent"
import { ConfigMarkdown } from "./config/markdown"
@@ -12,7 +13,6 @@ import { Permission } from "./permission"
import { AbsolutePath } from "./schema"
import { SkillDiscovery } from "./skill/discovery"
import { State } from "./state"
import { Watcher } from "./filesystem/watcher"
export const DirectorySource = Skill.DirectorySource
export type DirectorySource = Skill.DirectorySource
@@ -81,82 +81,6 @@ const layer = Layer.effect(
const discovery = yield* SkillDiscovery.Service
const fs = yield* FSUtil.Service
const bus = yield* Bus.Service
const watcher = yield* Watcher.Service
const cache = new Map<string, { skills: Info[]; paths: readonly string[] }>()
const watches = yield* FiberMap.make<string>()
const lock = Semaphore.makeUnsafe(1)
const changes = yield* PubSub.unbounded<string>()
const invalidate = Effect.fn("Skill.invalidateFromWatcher")(function* (file: string) {
const changed = yield* lock.withPermit(
Effect.gen(function* () {
const invalidated = Array.from(cache.entries()).filter(([, loaded]) =>
loaded.paths.some((item) => FSUtil.overlaps(item, file)),
)
if (invalidated.length === 0) return false
cache.clear()
yield* FiberMap.clear(watches)
yield* Effect.logInfo("skill cache invalidated", {
file,
sources: invalidated.map(([key]) => key),
skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.id)),
})
return true
}),
)
if (!changed) return
yield* bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid)
})
yield* Stream.fromPubSub(changes).pipe(Stream.runForEach(invalidate), Effect.forkScoped({ startImmediately: true }))
const watch = Effect.fn("Skill.watch")(function* (directory: string, type: Watcher.WatchInput["type"]) {
const target = path.resolve(directory)
const updates = yield* watcher.subscribe(
type === "file" ? { path: target, type: "file" } : { path: target, type: "directory" },
)
yield* FiberMap.run(
watches,
`${type}:${target}`,
updates.pipe(Stream.runForEach((update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid))),
{
onlyIfMissing: true,
startImmediately: true,
},
)
})
function firstMissing(target: string): Effect.Effect<string | undefined> {
const parent = path.dirname(target)
if (parent === target) return Effect.succeed(undefined)
return fs.isDir(parent).pipe(Effect.flatMap((exists) => (exists ? Effect.succeed(target) : firstMissing(parent))))
}
const watchDirectory: (directory: string) => Effect.Effect<string[]> = Effect.fn("Skill.watchDirectory")(function* (
directory: string,
) {
const target = path.resolve(directory)
const resolved = yield* fs.realPath(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (resolved) {
yield* watch(resolved, "directory")
if (resolved !== target) {
yield* watch(target, "file")
}
return resolved === target ? [target] : [target, resolved]
}
const missing = yield* firstMissing(target)
if (missing) yield* watch(missing, "file")
if (
yield* fs.realPath(directory).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
)
) {
if (missing) yield* FiberMap.remove(watches, `file:${path.resolve(missing)}`)
return yield* watchDirectory(directory)
}
return [target]
})
const state = State.create<Data, Draft>({
name: "skill",
@@ -168,10 +92,7 @@ const layer = Layer.effect(
},
list: () => draft.sources as Source[],
}),
finalize: () =>
lock
.withPermit(FiberMap.clear(watches).pipe(Effect.andThen(Effect.sync(() => cache.clear())), Effect.asVoid))
.pipe(Effect.andThen(bus.publish(Skill.Event.Updated, {})), Effect.asVoid),
finalize: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
})
const load = Effect.fn("Skill.load")(function* (source: Source) {
@@ -183,22 +104,14 @@ const layer = Layer.effect(
directories: [],
skills: [source.skill.id],
})
return { skills: [source.skill], paths: [] }
return { skills: [source.skill], directories: [] }
}
const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url)
const roots = (yield* Effect.forEach(directories, watchDirectory)).flat()
const paths = [...roots]
for (const directory of directories) {
const files = yield* fs
.scan("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true })
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
for (const filepath of files.toSorted()) {
const resolved = yield* fs.realPath(filepath).pipe(Effect.catch(() => Effect.succeed(filepath)))
if (!roots.some((root) => FSUtil.contains(root, resolved))) {
const external = path.dirname(resolved)
paths.push(external)
yield* watch(external, "directory")
}
const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!content) continue
const markdown = ConfigMarkdown.parseOption(content)
@@ -226,22 +139,38 @@ const layer = Layer.effect(
directories,
skills: skills.map((skill) => skill.id),
})
return { skills, paths }
return { skills, directories }
})
const list = Effect.fn("Skill.list")(function* () {
return yield* lock.withPermit(
Effect.gen(function* () {
const skills = new Map<ID, Info>()
for (const source of state.get().sources) {
const key = Source.key(source)
const loaded = cache.get(key) ?? (yield* load(source))
cache.set(key, loaded)
for (const skill of loaded.skills) skills.set(skill.id, skill)
}
return Array.from(skills.values())
}),
const cache = new Map<string, { skills: Info[]; directories: readonly string[] }>()
const invalidate = Effect.fn("Skill.invalidateFromWatcher")(function* (file: string) {
const invalidated = Array.from(cache.entries()).filter(([, loaded]) =>
loaded.directories.some((directory) => FSUtil.contains(directory, file)),
)
if (invalidated.length === 0) return
for (const [key] of invalidated) cache.delete(key)
yield* Effect.logInfo("skill cache invalidated", {
file,
sources: invalidated.map(([key]) => key),
skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.id)),
})
yield* bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid)
})
yield* bus.subscribe(FileSystem.Event.Changed).pipe(
Stream.runForEach((event) => invalidate(event.data.file)),
Effect.forkScoped({ startImmediately: true }),
)
const list = Effect.fn("Skill.list")(function* () {
const skills = new Map<ID, Info>()
for (const source of state.get().sources) {
const key = Source.key(source)
const loaded = cache.get(key) ?? (yield* load(source))
cache.set(key, loaded)
for (const skill of loaded.skills) skills.set(skill.id, skill)
}
return Array.from(skills.values())
})
return Service.of({
@@ -258,5 +187,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [SkillDiscovery.node, FSUtil.node, Bus.node, Watcher.node],
deps: [SkillDiscovery.node, FSUtil.node, Bus.node],
})
+17 -20
View File
@@ -11,11 +11,9 @@ import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { Bom } from "@opencode-ai/util/bom"
import { Effect, Schema } from "effect"
import path from "path"
import { Environment } from "../../environment"
import { FileMutation } from "../../file-mutation"
import { Formatter } from "../../formatter"
import { Location } from "../../location"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission"
import { fileDiff } from "./file-diff"
@@ -111,10 +109,9 @@ export const Plugin = {
id: "opencode.tool.edit",
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service
const fileMutation = yield* FileMutation.Service
const environment = yield* Environment.Service
const files = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const location = yield* Location.Service
const fs = yield* FSUtil.Service
const permission = yield* Permission.Service
yield* ctx.tool
@@ -155,16 +152,17 @@ export const Plugin = {
})
}
const original = yield* FileMutation.readText(environment.files, target.absolute).pipe(
Effect.catchTag("Environment.NotFound", () =>
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
),
Effect.catchTag("Environment.WrongKind", (error) =>
error.actual === "directory"
? Effect.fail(new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` }))
: Effect.fail(new ToolFailure({ message: `Unable to edit ${input.path}`, error })),
),
)
const info = yield* fs
.stat(target.absolute)
.pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
),
)
if (info.type === "Directory") {
return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })
}
const original = yield* Bom.readFile(fs, target.absolute)
const source = original.text
const ending = source.includes(crlf) ? crlf : "\n"
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
@@ -206,20 +204,19 @@ export const Plugin = {
})
}
const replacementBom = replaced.startsWith("\uFEFF")
const result = yield* fileMutation.write({
const result = yield* files.write({
target,
content: Bom.join(replaced, original.bom || replacementBom),
})
const bom = original.bom || replacementBom
const formatted = (yield* formatter.file(target.absolute))
? yield* FileMutation.syncTextBom(environment.files, target.absolute, bom)
: (yield* FileMutation.readText(environment.files, target.absolute)).text
? yield* Bom.syncFile(fs, target.absolute, bom)
: (yield* Bom.readFile(fs, target.absolute)).text
return {
files: [fileDiff(result.resource, source, formatted)],
replacements,
} satisfies Output
}).pipe(
fileMutation.withLock([path.resolve(location.directory, input.path)]),
Effect.map((output) => ({
output,
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
+12 -10
View File
@@ -4,8 +4,8 @@ import { ToolFailure } from "@opencode-ai/ai"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Schema } from "effect"
import path from "path"
import { Environment } from "../../environment"
import { FileSystem } from "../../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../../location"
import { LocationMutation } from "../../location-mutation"
import { Ripgrep } from "../../ripgrep"
@@ -42,7 +42,7 @@ export const toModelContent = (entries: EncodedOutput, truncated = false) => {
export const Plugin = {
id: "opencode.tool.glob",
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) {
const environment = yield* Environment.Service
const fs = yield* FSUtil.Service
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service
const mutation = yield* LocationMutation.Service
@@ -82,20 +82,22 @@ export const Plugin = {
agent: context.agent,
source,
})
const type = yield* Environment.typeFollowing(environment.files, target.absolute).pipe(
Effect.catchTag("Environment.NotFound", () =>
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
),
)
if (type !== "directory")
const info = yield* fs
.stat(target.absolute)
.pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
),
)
if (info.type !== "Directory")
return yield* Effect.fail(
new ToolFailure({ message: `Search path is not a directory: ${searchPath ?? "."}` }),
)
const root = target.absolute
const root = path.resolve(location.directory, searchPath ?? ".")
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
const entries = yield* ripgrep
.glob({
cwd: root,
cwd: target.absolute,
pattern: input.pattern,
limit: limit + 1,
})
+94 -90
View File
@@ -4,8 +4,8 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import path from "path"
import { Environment } from "../../environment"
import { FileSystem } from "../../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../../location"
import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission"
@@ -15,11 +15,11 @@ import { RelativePath } from "../../schema"
export const name = "grep"
export const Input = Schema.Struct({
pattern: FileSystem.GrepInput.fields.pattern
.check(Schema.isMinLength(1, { message: "Pattern must not be empty" }))
.annotate({
description: "Regular expression to search for in file contents (ripgrep syntax)",
}),
pattern: FileSystem.GrepInput.fields.pattern.check(
Schema.isMinLength(1, { message: "Pattern must not be empty" }),
).annotate({
description: "Regular expression to search for in file contents (ripgrep syntax)",
}),
path: Schema.optionalKey(RelativePath).annotate({
description: "File or directory to search. Defaults to the current working directory.",
}),
@@ -58,7 +58,7 @@ export const toModelContent = (matches: EncodedOutput, truncated = false) => {
export const Plugin = {
id: "opencode.tool.grep",
effect: Effect.fn("GrepTool.Plugin")(function* (ctx: PluginContext) {
const environment = yield* Environment.Service
const fs = yield* FSUtil.Service
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service
const mutation = yield* LocationMutation.Service
@@ -66,100 +66,104 @@ export const Plugin = {
yield* ctx.tool
.transform((draft) =>
draft.add({
name,
options: { codemode: false },
description:
"Search file contents using regular expressions. Use it to locate specific code, symbols, or text patterns, and narrow searches with `path` or `include`. Returns matching file paths, line numbers, and line previews.",
input: Input,
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
const target = yield* mutation.resolve({ path: input.path ?? "." })
if (target.externalDirectory)
draft.add(
({
name,
options: { codemode: false },
description:
"Search file contents using regular expressions. Use it to locate specific code, symbols, or text patterns, and narrow searches with `path` or `include`. Returns matching file paths, line numbers, and line previews.",
input: Input,
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
const target = yield* mutation.resolve({ path: input.path ?? "." })
if (target.externalDirectory)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
sessionID: context.sessionID,
agent: context.agent,
source,
})
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
action: name,
resources: [input.pattern],
save: ["*"],
metadata: {
root: ".",
path: input.path,
include: input.include,
limit: input.limit,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
yield* permission.assert({
action: name,
resources: [input.pattern],
save: ["*"],
metadata: {
root: ".",
path: input.path,
include: input.include,
limit: input.limit,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
const root = target.absolute
const type = yield* Environment.typeFollowing(environment.files, root).pipe(
Effect.catchTag("Environment.NotFound", () =>
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
),
)
const cwd = type === "directory" ? root : path.dirname(root)
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
const matches = yield* ripgrep
.grep({
cwd,
pattern: input.pattern,
file: type === "file" ? path.basename(root) : undefined,
include: input.include,
limit: limit + 1,
})
.pipe(
Effect.timeoutOrElse({
duration: FileSystem.DEFAULT_SEARCH_TIMEOUT_MS,
orElse: () =>
Effect.fail(
new ToolFailure({
message: `Search timed out after ${FileSystem.DEFAULT_SEARCH_TIMEOUT_MS / 1_000} seconds. Consider using a more specific path or pattern.`,
const root = path.resolve(location.directory, input.path ?? ".")
const info = yield* fs
.stat(root)
.pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
),
)
const cwd = info?.type === "Directory" ? root : path.dirname(root)
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
const matches = yield* ripgrep
.grep({
cwd,
pattern: input.pattern,
file: info?.type === "File" ? path.basename(root) : undefined,
include: input.include,
limit: limit + 1,
})
.pipe(
Effect.timeoutOrElse({
duration: FileSystem.DEFAULT_SEARCH_TIMEOUT_MS,
orElse: () =>
Effect.fail(
new ToolFailure({
message: `Search timed out after ${FileSystem.DEFAULT_SEARCH_TIMEOUT_MS / 1_000} seconds. Consider using a more specific path or pattern.`,
}),
),
}),
Effect.map((result) =>
result.map((match) =>
FileSystem.Match.make({
...match,
entry: FileSystem.Entry.make({
...match.entry,
path: RelativePath.make(
path.relative(location.directory, path.resolve(cwd, match.entry.path)),
),
}),
}),
),
}),
Effect.map((result) =>
result.map((match) =>
FileSystem.Match.make({
...match,
entry: FileSystem.Entry.make({
...match.entry,
path: RelativePath.make(
path.relative(location.directory, path.resolve(cwd, match.entry.path)),
),
}),
}),
),
)
return { matches: matches.slice(0, limit), truncated: matches.length > limit }
}).pipe(
Effect.map((result) => ({
output: result.matches,
content: toModelContent(
result.matches.map((match) => ({
...match,
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
})),
result.truncated,
),
)
return { matches: matches.slice(0, limit), truncated: matches.length > limit }
}).pipe(
Effect.map((result) => ({
output: result.matches,
content: toModelContent(
result.matches.map((match) => ({
...match,
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
})),
result.truncated,
),
metadata: { matches: result.matches.length, truncated: result.truncated },
})),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: error instanceof Ripgrep.InvalidPatternError
? new ToolFailure({ message: `Invalid regex pattern: ${error.message}` })
metadata: { matches: result.matches.length, truncated: result.truncated },
})),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: error instanceof Ripgrep.InvalidPatternError
? new ToolFailure({ message: `Invalid regex pattern: ${error.message}` })
: new ToolFailure({ message: `Unable to grep for ${input.pattern}`, error }),
),
),
),
}),
}),
),
)
.pipe(Effect.orDie)
}),
+42 -41
View File
@@ -4,13 +4,12 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Result, Schema } from "effect"
import { Effect, Schema } from "effect"
import { PlatformError } from "effect/PlatformError"
import path from "path"
import { Bom } from "@opencode-ai/util/bom"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Environment } from "../../environment"
import { Formatter } from "../../formatter"
import { FileMutation } from "../../file-mutation"
import { Location } from "../../location"
import { Patch } from "@opencode-ai/util/patch"
import { Permission } from "../../permission"
@@ -45,13 +44,7 @@ export const toModelOutput = (output: Output) =>
].join("\n")
type Prepared =
| (Extract<Patch.Hunk, { readonly type: "add" }> & {
readonly target: Target
readonly content: string
readonly before: string
readonly after: string
})
| (Extract<Patch.Hunk, { readonly type: "delete" }> & {
| (Extract<Patch.Hunk, { readonly type: "add" | "delete" }> & {
readonly target: Target
readonly before: string
readonly after: string
@@ -76,8 +69,7 @@ interface Target {
export const Plugin = {
id: "opencode.tool.patch",
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
const environment = yield* Environment.Service
const mutation = yield* FileMutation.Service
const fs = yield* FSUtil.Service
const formatter = yield* Formatter.Service
const location = yield* Location.Service
const permission = yield* Permission.Service
@@ -92,13 +84,6 @@ export const Plugin = {
output: Output,
execute: (input, context) => {
const applied: Array<typeof Applied.Type> = []
const parsed = Patch.parse(input.patchText)
const lockTargets = Result.isSuccess(parsed)
? parsed.success.flatMap((hunk) => [
path.resolve(location.directory, hunk.path),
...(hunk.type === "update" && hunk.movePath ? [path.resolve(location.directory, hunk.movePath)] : []),
])
: []
const fail = (operation: string, error: unknown) => {
const completed = applied.map((item) => item.resource).join(", ")
return new ToolFailure({
@@ -112,7 +97,7 @@ export const Plugin = {
id: context.id,
}
if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
const hunks = yield* Effect.fromResult(parsed).pipe(
const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe(
Effect.mapError((error) => new ToolFailure({ message: `patch verification failed: ${error.message}` })),
)
if (hunks.length === 0) {
@@ -140,19 +125,18 @@ export const Plugin = {
})
}
if (hunk.type === "add") {
const content =
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
prepared.push({
...hunk,
target,
content,
before: "",
after: Bom.split(content).text,
after: Bom.split(
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`,
).text,
})
return
}
if (hunk.type === "delete") {
const content = yield* FileMutation.readText(environment.files, target.absolute).pipe(
const content = yield* Bom.readFile(fs, target.absolute).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
@@ -167,7 +151,20 @@ export const Plugin = {
const original =
previous ??
(yield* Effect.gen(function* () {
const content = yield* FileMutation.readText(environment.files, target.absolute).pipe(
const stats = yield* fs.stat(target.absolute).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.absolute}: ${errorMessage(error)}`,
}),
),
)
if (stats.type === "Directory") {
return yield* new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.absolute}: path is a directory`,
})
}
const content = yield* Bom.readFile(fs, target.absolute).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
@@ -236,8 +233,13 @@ export const Plugin = {
(change) =>
Effect.gen(function* () {
if (change.type === "add") {
yield* environment.files
.write(change.target.absolute, new TextEncoder().encode(change.content))
yield* fs
.writeWithDirs(
change.target.absolute,
change.contents.endsWith("\n") || change.contents === ""
? change.contents
: `${change.contents}\n`,
)
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
applied.push({
type: change.type,
@@ -247,7 +249,7 @@ export const Plugin = {
return
}
if (change.type === "delete") {
yield* environment.files
yield* fs
.remove(change.target.absolute)
.pipe(Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)))
applied.push({
@@ -259,10 +261,10 @@ export const Plugin = {
}
if (change.moveTarget) {
const moveTarget = change.moveTarget
yield* environment.files
.write(moveTarget.absolute, new TextEncoder().encode(change.content))
yield* fs
.writeWithDirs(moveTarget.absolute, change.content)
.pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
yield* environment.files
yield* fs
.remove(change.target.absolute)
.pipe(
Effect.mapError((error) =>
@@ -276,8 +278,8 @@ export const Plugin = {
})
return
}
yield* environment.files
.write(change.target.absolute, new TextEncoder().encode(change.content))
yield* fs
.writeWithDirs(change.target.absolute, change.content)
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
applied.push({
type: change.type,
@@ -292,13 +294,13 @@ export const Plugin = {
[...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))],
(target) =>
Effect.gen(function* () {
const current = yield* FileMutation.readText(environment.files, target).pipe(
const current = yield* Bom.readFile(fs, target).pipe(
Effect.mapError((error) => fail(`Failed to read ${target}`, error)),
)
formatted.set(
target,
(yield* formatter.file(target))
? yield* FileMutation.syncTextBom(environment.files, target, current.bom).pipe(
? yield* Bom.syncFile(fs, target, current.bom).pipe(
Effect.mapError((error) => fail(`Failed to sync ${target}`, error)),
)
: current.text,
@@ -313,7 +315,6 @@ export const Plugin = {
})
return { applied, files }
}).pipe(
mutation.withLock(lockTargets),
Effect.map((output) => ({
output,
content: toModelOutput(output),
@@ -344,10 +345,10 @@ export const Plugin = {
}
function errorMessage(error: unknown) {
if (error instanceof Environment.NotFound) return "file does not exist"
if (error instanceof Environment.WrongKind)
return error.actual === "directory" ? "path is a directory" : `path is ${error.actual}`
if (error instanceof Environment.Failed) return errorMessage(error.cause)
if (error instanceof PlatformError) {
if (error.reason._tag === "NotFound") return "file does not exist"
return error.reason.description ?? error.reason.message
}
return error instanceof Error ? error.message : String(error)
}
+11 -8
View File
@@ -11,7 +11,6 @@ import { Permission } from "../../permission"
import { SessionInstructions } from "../../session/instructions"
import { AbsolutePath } from "../../schema"
import { ReadToolFileSystem } from "../read-filesystem"
import { Environment } from "../../environment"
export const name = "read"
const FILENAME = "AGENTS.md"
@@ -73,12 +72,16 @@ export const Plugin = {
agent: context.agent,
source,
})
const content = yield* reader.read(absolute, resource, { offset: input.offset, limit: input.limit }).pipe(
Effect.catchIf(
(error) => error instanceof Environment.NotFound,
() => missing(input.path, target.absolute),
),
)
const type = yield* reader
.inspect(absolute)
.pipe(Effect.catchReason("PlatformError", "NotFound", () => missing(input.path, target.absolute)))
const content =
type === "directory"
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
: yield* reader.read(absolute, resource, {
offset: input.offset,
limit: input.limit,
})
// After a successful read, discover nearby AGENTS.md walking up to the Location
// root exclusive and inject them as durable synthetic instructions. For a
// directory listing the walk starts at the directory itself (so its own AGENTS.md
@@ -92,7 +95,7 @@ export const Plugin = {
// supplied by core initial instructions) is dropped by the dirname filter.
const discovered = yield* fs.up({
targets: [FILENAME],
start: content.type === "list-page" ? resolved : dirname(resolved),
start: type === "directory" ? resolved : dirname(resolved),
stop: root,
})
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter(
+10 -8
View File
@@ -5,8 +5,8 @@ import { ToolFailure } from "@opencode-ai/ai"
import type { Content } from "@opencode-ai/schema/tool"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { Deferred, Effect, Schema, Scope } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Config } from "../../config"
import { Environment } from "../../environment"
import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission"
import { PluginRuntime } from "../../plugin/runtime"
@@ -83,7 +83,7 @@ export const Plugin = {
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) {
const runtime = yield* PluginRuntime.Service
const scope = yield* Scope.Scope
const environment = yield* Environment.Service
const fsUtil = yield* FSUtil.Service
const mutation = yield* LocationMutation.Service
const shell = yield* Shell.Service
const permission = yield* Permission.Service
@@ -179,12 +179,14 @@ export const Plugin = {
agent: context.agent,
source,
})
const workdir = yield* Environment.typeFollowing(environment.files, target.absolute).pipe(
Effect.catchTag("Environment.NotFound", () =>
Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)),
),
)
if (workdir !== "directory")
const workdir = yield* fsUtil
.stat(target.absolute)
.pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)),
),
)
if (workdir.type !== "Directory")
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.absolute}`))
}),
)
+8 -10
View File
@@ -10,7 +10,7 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { Bom } from "@opencode-ai/util/bom"
import { Environment } from "../../environment"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { FileMutation } from "../../file-mutation"
import { Formatter } from "../../formatter"
import { LocationMutation } from "../../location-mutation"
@@ -47,9 +47,9 @@ export const Plugin = {
id: "opencode.tool.write",
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service
const fileMutation = yield* FileMutation.Service
const environment = yield* Environment.Service
const files = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const fs = yield* FSUtil.Service
const permission = yield* Permission.Service
yield* ctx.tool
@@ -77,8 +77,8 @@ export const Plugin = {
agent: context.agent,
source,
})
const current = yield* FileMutation.readText(environment.files, target.absolute).pipe(
Effect.catchTag("Environment.NotFound", () => Effect.succeed(undefined)),
const current = yield* Bom.readFile(fs, target.absolute).pipe(
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
)
const next = Bom.split(input.content)
const preview = fileDiff(target.resource, current?.text ?? "", next.text, current ? "modified" : "added")
@@ -91,11 +91,9 @@ export const Plugin = {
agent: context.agent,
source,
})
const result = yield* fileMutation.writeTextPreservingBom({ target, content: input.content })
const bom = (yield* FileMutation.readText(environment.files, target.absolute)).bom
if (yield* formatter.file(target.absolute)) {
yield* FileMutation.syncTextBom(environment.files, target.absolute, bom)
}
const result = yield* files.writeTextPreservingBom({ target, content: input.content })
const bom = (yield* Bom.readFile(fs, target.absolute)).bom
if (yield* formatter.file(target.absolute)) yield* Bom.syncFile(fs, target.absolute, bom)
return result
}).pipe(
Effect.map((output) => ({ output, content: toModelOutput(output) })),
+191 -152
View File
@@ -2,22 +2,17 @@ export * as ReadToolFileSystem from "./read-filesystem"
import path from "path"
import { pathToFileURL } from "url"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Schema } from "effect"
import { lookup } from "mime-types"
import { Environment } from "../environment"
import type { Files } from "../environment"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { FileSystem } from "../filesystem"
import { Mime } from "../mime"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath } from "../schema"
export const MAX_READ_LINES = 2_000
export const MAX_READ_BYTES = 50 * 1024
export const MAX_MEDIA_INGEST_BYTES = 20 * 1024 * 1024
const FIRST_CHUNK = 256 * 1024
const MAX_LINE_LENGTH = 2_000
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
const MEDIA_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"])
export class BinaryFileError extends Schema.TaggedErrorClass<BinaryFileError>()("ReadTool.BinaryFileError", {
resource: Schema.String,
@@ -57,13 +52,8 @@ export class PathKindError extends Schema.TaggedErrorClass<PathKindError>()("Rea
}
}
export type ReadError =
| Environment.NotFound
| Environment.Failed
| BinaryFileError
| MediaIngestLimitError
| OffsetOutOfRangeError
| PathKindError
export type InspectError = FSUtil.Error | PathKindError
export type ReadError = FSUtil.Error | BinaryFileError | MediaIngestLimitError | OffsetOutOfRangeError | PathKindError
export const PageInput = Schema.Struct({
offset: Schema.optionalKey(NonNegativeInt),
@@ -100,113 +90,202 @@ export class ListPage extends Schema.Class<ListPage>("ReadTool.ListPage")({
}) {}
export interface Interface {
readonly inspect: (path: AbsolutePath) => Effect.Effect<"file" | "directory", InspectError>
readonly read: (
path: AbsolutePath,
resource: string,
page?: PageInput,
) => Effect.Effect<FileContent | TextPage | ListPage, ReadError>
) => Effect.Effect<FileContent | TextPage, ReadError>
readonly list: (path: AbsolutePath, page?: PageInput) => Effect.Effect<ListPage, FSUtil.Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ReadToolFileSystem") {}
const mimeType = (value: string) => lookup(value) || "application/octet-stream"
const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value)
const mediaMime = (bytes: Uint8Array) => {
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg"
if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif"
if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50]))
return "image/webp"
if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf"
}
const binary = (bytes: Uint8Array) => {
if (bytes.length === 0) return false
let nonPrintable = 0
for (const byte of bytes) {
if (byte === 0) return true
if (byte < 9 || (byte > 13 && byte < 32)) nonPrintable++
}
return nonPrintable / bytes.length > 0.3
}
const decodeUtf8 = (decoder: TextDecoder, bytes?: Uint8Array) => decoder.decode(bytes, { stream: bytes !== undefined })
const decodeChunk = (resource: string, decoder: TextDecoder, bytes: Uint8Array) =>
bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : Effect.succeed(decodeUtf8(decoder, bytes))
export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, input: string) {
const info = yield* fs.stat(input)
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
if (!type) return yield* Effect.fail(new PathKindError({ resource: input, expected: "a file or directory" }))
return type
})
export const read = Effect.fn("ReadTool.read")(function* (
files: Files,
input: AbsolutePath,
fs: FSUtil.Interface,
input: string,
resource: string,
page: PageInput = {},
) {
const first = yield* files.read(input, { offset: 0, length: FIRST_CHUNK }).pipe(
Effect.catchTag("Environment.WrongKind", (error) => {
if (error.actual !== "directory")
return Effect.fail(new PathKindError({ resource, expected: "a file or directory" }))
return files.list(input).pipe(
Effect.map((entries) => list(entries, page)),
Effect.catchTag("Environment.WrongKind", () =>
Effect.fail(new PathKindError({ resource, expected: "a file or directory" })),
),
const real = yield* fs.realPath(input)
return yield* Effect.scoped(
Effect.gen(function* () {
const file = yield* fs.open(real, { flag: "r" })
const info = yield* file.stat
if (info.type !== "File") return yield* Effect.fail(new PathKindError({ resource, expected: "a file" }))
const first = Option.getOrElse(
yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || 4 * 1024)),
() => new Uint8Array(),
)
const mime = mediaMime(first)
if (mime) {
if (info.size > MAX_MEDIA_INGEST_BYTES)
return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES }))
const chunks = [first]
let total = first.length
while (total <= MAX_MEDIA_INGEST_BYTES) {
const chunk = yield* file.readAlloc(Math.min(64 * 1024, MAX_MEDIA_INGEST_BYTES + 1 - total))
if (Option.isNone(chunk)) break
chunks.push(chunk.value)
total += chunk.value.length
}
if (total > MAX_MEDIA_INGEST_BYTES)
return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES }))
return {
type: "file" as const,
uri: pathToFileURL(real).href,
name: path.basename(real),
content: Buffer.concat(
chunks.map((chunk) => Buffer.from(chunk)),
total,
).toString("base64"),
encoding: "base64" as const,
mime,
}
}
const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
if (!paged) {
if (binary(first)) return yield* Effect.fail(new BinaryFileError({ resource }))
const decoder = new TextDecoder()
const text = [decodeUtf8(decoder, first)]
while (true) {
const chunk = yield* file.readAlloc(64 * 1024)
if (Option.isNone(chunk)) break
text.push(yield* decodeChunk(resource, decoder, chunk.value))
}
text.push(decodeUtf8(decoder))
return {
type: "file" as const,
uri: pathToFileURL(real).href,
name: path.basename(real),
content: text.join(""),
encoding: "utf8" as const,
mime: FSUtil.mimeType(real),
}
}
const offset = page.offset || 1
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
const lines: string[] = []
const decoder = new TextDecoder()
let pending = ""
let discard = false
let line = 1
let bytes = 0
let next: number | undefined
const append = (input: string) => {
if (line < offset) {
line++
return true
}
if (lines.length >= limit || bytes >= MAX_READ_BYTES) {
next = line
return false
}
const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input
const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0)
if (bytes + size > MAX_READ_BYTES) {
next = line
return false
}
lines.push(text)
bytes += size
line++
return true
}
const consume = (input: string) => {
let text = input
while (true) {
const index = text.indexOf("\n")
if (index === -1) {
if (!discard) {
pending += text
if (pending.length > MAX_LINE_LENGTH) {
pending = pending.slice(0, MAX_LINE_LENGTH + 1)
discard = true
}
}
break
}
const current = pending + (discard ? "" : text.slice(0, index))
pending = ""
discard = false
text = text.slice(index + 1)
if (!append(current.endsWith("\r") ? current.slice(0, -1) : current)) return false
}
return true
}
const consumeChunk = Effect.fnUntraced(function* (chunk: Uint8Array) {
let start = 0
while (start < chunk.length) {
if (lines.length >= limit || bytes >= MAX_READ_BYTES) {
next = line
return false
}
const newline = chunk.indexOf(10, start)
const end = newline === -1 ? chunk.length : newline + 1
const segment = chunk.subarray(start, end)
if (binary(segment)) return yield* Effect.fail(new BinaryFileError({ resource }))
if (!consume(decodeUtf8(decoder, segment))) return false
start = end
}
return true
})
let done = !(yield* consumeChunk(first))
while (!done) {
const chunk = yield* file.readAlloc(64 * 1024)
if (Option.isNone(chunk)) break
done = !(yield* consumeChunk(chunk.value))
}
if (!done) {
const tail = decodeUtf8(decoder)
if (!discard) pending += tail
if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)
}
if (lines.length === 0 && offset !== 1) return yield* Effect.fail(new OffsetOutOfRangeError({ offset }))
return new TextPage({
type: "text-page",
content: lines.join("\n"),
mime: FSUtil.mimeType(real),
offset,
truncated: next !== undefined,
...(next === undefined ? {} : { next }),
})
}),
)
if (first instanceof ListPage) return first
const media = Mime.detect(first.bytes)
if (MEDIA_MIMES.has(media)) {
if (first.info.size > MAX_MEDIA_INGEST_BYTES)
return yield* new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES })
const whole = yield* readFile(files, input, resource)
return {
type: "file" as const,
uri: pathToFileURL(input).href,
name: path.basename(input),
content: Buffer.from(whole.bytes).toString("base64"),
encoding: "base64" as const,
mime: media,
}
}
const paged = first.info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
if (!paged) {
if (first.bytes.includes(0)) return yield* new BinaryFileError({ resource })
return {
type: "file" as const,
uri: pathToFileURL(input).href,
name: path.basename(input),
content: new TextDecoder().decode(first.bytes),
encoding: "utf8" as const,
mime: mimeType(input),
}
}
const chunks = [first.bytes]
while (true) {
const bytes = Buffer.concat(chunks)
const eof = bytes.length >= first.info.size
const result = textPage(bytes, eof, page)
if (result !== undefined) return yield* makeTextPage(bytes, input, resource, result)
const next = yield* readFile(files, input, resource, { offset: bytes.length, length: FIRST_CHUNK })
if (next.bytes.length === 0) {
const result = textPage(bytes, true, page)
if (result === undefined) return yield* Effect.die("Read page did not settle at EOF")
return yield* makeTextPage(bytes, input, resource, result)
}
chunks.push(next.bytes)
}
})
const readFile = (
files: Files,
input: AbsolutePath,
resource: string,
range?: { readonly offset: number; readonly length: number },
) =>
files
.read(input, range)
.pipe(
Effect.catchTag("Environment.WrongKind", () => Effect.fail(new PathKindError({ resource, expected: "a file" }))),
)
const makeTextPage = Effect.fnUntraced(function* (
bytes: Uint8Array,
input: AbsolutePath,
resource: string,
result: NonNullable<ReturnType<typeof textPage>>,
) {
if (bytes.subarray(0, result.consumed).includes(0)) return yield* new BinaryFileError({ resource })
if (result.entries.length === 0 && result.offset !== 1)
return yield* new OffsetOutOfRangeError({ offset: result.offset })
return new TextPage({
type: "text-page",
content: result.entries.join("\n"),
mime: mimeType(input),
offset: result.offset,
truncated: result.next !== undefined,
...(result.next === undefined ? {} : { next: result.next }),
})
})
const list = (items: ReadonlyArray<Environment.DirEntry>, page: PageInput) => {
export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface, input: string, page: PageInput = {}) {
const real = yield* fs.realPath(input)
const items = yield* fs.readDirectoryEntries(real)
const offset = page.offset || 1
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
const visible = items
@@ -237,58 +316,18 @@ const list = (items: ReadonlyArray<Environment.DirEntry>, page: PageInput) => {
truncated,
...(truncated ? { next: offset + selected.length } : {}),
})
}
const textPage = (bytes: Uint8Array, eof: boolean, page: PageInput) => {
const offset = page.offset || 1
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
const decoded = new TextDecoder().decode(bytes)
const split = decoded.split("\n")
const complete = eof ? (split.at(-1) === "" ? split.slice(0, -1) : split) : split.slice(0, -1)
const available = complete.map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line))
const entries: string[] = []
let size = 0
let next: number | undefined
for (const [index, value] of available.slice(offset - 1).entries()) {
const line = offset + index
if (entries.length >= limit || size >= MAX_READ_BYTES) {
next = line
break
}
const text = value.length > MAX_LINE_LENGTH ? value.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : value
const lineSize = Buffer.byteLength(text, "utf-8") + (entries.length > 0 ? 1 : 0)
if (size + lineSize > MAX_READ_BYTES) {
next = line
break
}
entries.push(text)
size += lineSize
}
if (next === undefined && entries.length >= limit && (!eof || offset - 1 + entries.length < available.length))
next = offset + entries.length
if (!eof && next === undefined) return
const consumedLines = next === undefined ? available.length : next - 1
const consumed = consumedLines === 0 ? 0 : (nthNewline(bytes, consumedLines) ?? bytes.length)
return { entries, offset, next, consumed }
}
const nthNewline = (bytes: Uint8Array, count: number) => {
let found = 0
for (const [index, byte] of bytes.entries()) {
if (byte !== 10) continue
found++
if (found === count) return index + 1
}
}
})
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const environment = yield* Environment.Service
return Service.of({ read: (path, resource, page) => read(environment.files, path, resource, page) })
const fs = yield* FSUtil.Service
return Service.of({
inspect: (path) => inspect(fs, path),
read: (path, resource, page) => read(fs, path, resource, page),
list: (path, page) => list(fs, path, page),
})
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Environment.node] })
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
@@ -50,33 +50,6 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
const decode = Schema.decodeUnknownSync(Info)
describe("ConfigProviderPlugin.Plugin", () => {
it.effect("adds key auth for custom providers without env credentials", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const entries = [
new Document({
type: "document",
info: decode({
providers: {
litellm: {
package: "aisdk:@ai-sdk/openai-compatible",
models: { chat: {} },
},
},
}),
}),
]
yield* addPlugin(entries)
expect(yield* integrations.get(Integration.ID.make("litellm"))).toMatchObject({
id: "litellm",
name: "litellm",
methods: [{ type: "key", label: "Manually enter API Key" }],
})
}),
)
it.effect("defaults custom models to agent capabilities", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
+1 -50
View File
@@ -1,39 +1,11 @@
import fs from "node:fs/promises"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { ChildProcessSpawner } from "effect/unstable/process"
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import {
execDefaults,
Failed,
makeFiles,
makeLocalDriver,
makeMemoryDriver,
NotFound,
typeFollowing,
} from "../src/environment/index"
import { execDefaults, Failed, makeFiles, makeMemoryDriver } from "../src/environment/index"
import { tmpdir } from "./fixture/tmpdir"
import { environmentConformance } from "./lib/environment-conformance"
import { it } from "./lib/effect"
describe("typeFollowing", () => {
it.effect("follows symlinks without changing stat semantics", () =>
Effect.gen(function* () {
const driver = makeMemoryDriver()
const files = makeFiles(driver)
yield* files.mkdir("/directory")
yield* files.write("/file", new Uint8Array())
yield* driver.symlink("/directory", "/directory-link")
yield* driver.symlink("/file", "/file-link")
yield* driver.symlink("/missing", "/dangling-link")
expect(yield* typeFollowing(files, "/directory-link")).toBe("directory")
expect(yield* typeFollowing(files, "/file-link")).toBe("file")
expect(yield* typeFollowing(files, "/dangling-link").pipe(Effect.flip)).toBeInstanceOf(NotFound)
}),
)
})
environmentConformance("memory environment", () =>
Effect.sync(() => {
@@ -46,27 +18,6 @@ environmentConformance("memory environment", () =>
}),
)
environmentConformance("local environment", () =>
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const tmp = yield* Effect.promise(() => tmpdir("opencode-local-environment-"))
return {
files: makeFiles(makeLocalDriver(spawner)),
root: tmp.path,
...(process.platform === "win32"
? {}
: {
symlink: (target: string, link: string) =>
Effect.tryPromise({
try: () => fs.symlink(target, link),
catch: (cause) => new Failed({ path: link, cause }),
}),
}),
dispose: Effect.promise(() => tmp[Symbol.asyncDispose]()),
}
}).pipe(Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))),
)
environmentConformance(
"GNU exec environment",
() =>
+12 -63
View File
@@ -5,7 +5,7 @@ import { Deferred, Effect, Fiber, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Environment } from "@opencode-ai/core/environment"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -13,7 +13,7 @@ import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
function provide(directory: string, environmentLayer = LayerNode.compile(Environment.node)) {
function provide(directory: string, filesystemLayer = LayerNode.compile(FSUtil.node)) {
const activeLocation = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
@@ -21,7 +21,7 @@ function provide(directory: string, environmentLayer = LayerNode.compile(Environ
return Effect.provide(
AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [
[Location.node, activeLocation],
[Environment.node, environmentLayer],
[FSUtil.node, filesystemLayer],
]),
)
}
@@ -152,57 +152,6 @@ describe("FileMutation", () => {
),
)
it.live("shares transaction locks across Location service instances", () =>
withTmp((directory) =>
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
const target = path.join(directory, "shared.txt")
const first = yield* Effect.gen(function* () {
const files = yield* FileMutation.Service
yield* files.withLock([target])(
Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
)
}).pipe(provide(directory), Effect.forkChild)
yield* Deferred.await(firstStarted)
const second = yield* Effect.gen(function* () {
const files = yield* FileMutation.Service
yield* files.withLock([target])(Deferred.succeed(secondStarted, undefined))
}).pipe(provide(directory), Effect.forkChild)
yield* Effect.yieldNow
expect(yield* Deferred.isDone(secondStarted)).toBe(false)
yield* Deferred.succeed(releaseFirst, undefined)
yield* Deferred.await(secondStarted)
yield* Fiber.join(first)
yield* Fiber.join(second)
}),
),
)
it.live("allows transaction locks for distinct resolved paths to proceed independently", () =>
withTmp((directory) =>
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
const secondFinished = yield* Deferred.make<void>()
const files = yield* FileMutation.Service
const first = yield* files
.withLock([path.join(directory, "first.txt")])(
Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
)
.pipe(Effect.forkChild)
yield* Deferred.await(firstStarted)
yield* files.withLock([path.join(directory, "second.txt")])(Deferred.succeed(secondFinished, undefined))
expect(yield* Deferred.isDone(secondFinished)).toBe(true)
yield* Deferred.succeed(releaseFirst, undefined)
yield* Fiber.join(first)
}).pipe(provide(directory)),
),
)
it.live("allows distinct absolute targets to proceed independently", () =>
withTmp((directory) =>
Effect.gen(function* () {
@@ -242,16 +191,16 @@ describe("FileMutation", () => {
function instrumentWrites(run: <E>(write: Effect.Effect<void, E>, target: string) => Effect.Effect<void, E>) {
return Layer.effect(
Environment.Service,
FSUtil.Service,
Effect.gen(function* () {
const environment = yield* Environment.Service
return Environment.Service.of({
...environment,
files: {
...environment.files,
write: (target, content) => run(environment.files.write(target, content), target),
},
const filesystem = yield* FSUtil.Service
return FSUtil.Service.of({
...filesystem,
writeWithDirs: (target, content, mode) => run(filesystem.writeWithDirs(target, content, mode), target),
writeFile: (target, content, options) => run(filesystem.writeFile(target, content, options), target),
writeFileString: (target, content, options) =>
run(filesystem.writeFileString(target, content, options), target),
})
}),
).pipe(Layer.provide(LayerNode.compile(Environment.node)))
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
}
+126 -93
View File
@@ -17,8 +17,9 @@ import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
const describeWatcher = Watcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
const describeNative = process.env.CI ? describe.skip : describe
const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.node])))
@@ -74,9 +75,10 @@ describe("Watcher lifecycle", () => {
const interrupted = yield* Deferred.make<void>()
yield* Effect.gen(function* () {
const watcher = yield* Watcher.Service
const consumer = yield* watcher
.subscribe({ path: "/pending", type: "directory" })
.pipe(Effect.flatMap(Stream.runDrain), Effect.forkScoped({ startImmediately: true }))
const consumer = yield* watcher.subscribe({ path: "/pending", type: "directory" }).pipe(
Effect.flatMap(Stream.runDrain),
Effect.forkScoped({ startImmediately: true }),
)
yield* Deferred.await(started)
yield* Fiber.interrupt(consumer)
expect(yield* Deferred.isDone(interrupted)).toBe(true)
@@ -97,9 +99,10 @@ describe("Watcher lifecycle", () => {
return Effect.gen(function* () {
const watcher = yield* Watcher.Service
const consume = () =>
watcher
.subscribe({ path: "/shared", type: "directory" })
.pipe(Effect.flatMap(Stream.runDrain), Effect.forkScoped({ startImmediately: true }))
watcher.subscribe({ path: "/shared", type: "directory" }).pipe(
Effect.flatMap(Stream.runDrain),
Effect.forkScoped({ startImmediately: true }),
)
const first = yield* consume()
const second = yield* consume()
yield* Effect.yieldNow
@@ -135,26 +138,22 @@ describe("Watcher lifecycle", () => {
})
})
function provide(directory: string, vcs?: Location.Interface["vcs"], watcher?: Layer.Layer<Watcher.Service>) {
function provide(directory: string, vcs?: Location.Interface["vcs"]) {
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
)
const built = AppNodeBuilder.build(LocationWatcher.node, [
[Config.node, configLayer],
[Location.node, locationLayer],
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
])
return Effect.provide(built)
return Effect.provide(
AppNodeBuilder.build(LocationWatcher.node, [
[Config.node, configLayer],
[Location.node, locationLayer],
]),
)
}
function withTmp<A, E, R>(
f: (directory: string, vcs?: Location.Interface["vcs"]) => Effect.Effect<A, E, R>,
options?: {
vcs?: "git" | "hg"
init?: (directory: string) => Promise<void>
watcher?: Layer.Layer<Watcher.Service>
},
options?: { vcs?: "git" | "hg"; init?: (directory: string) => Promise<void> },
) {
return Effect.acquireRelease(
Effect.promise(async () => {
@@ -174,57 +173,9 @@ function withTmp<A, E, R>(
return { tmp, vcs: { type: "git" as const, store: AbsolutePath.make(path.join(tmp.path, ".git")) } }
}),
({ tmp }) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap(({ tmp, vcs }) => f(tmp.path, vcs).pipe(provide(tmp.path, vcs, options?.watcher))))
).pipe(Effect.flatMap(({ tmp, vcs }) => f(tmp.path, vcs).pipe(provide(tmp.path, vcs))))
}
describe("LocationWatcher subscriptions", () => {
it.live("watches only exact Git branch metadata", () => {
const subscriptions: Watcher.WatchInput[] = []
const watcher = Layer.succeed(
Watcher.Service,
Watcher.Service.of({
subscribe: (input) => Effect.sync(() => subscriptions.push(input)).pipe(Effect.as(Stream.empty)),
}),
)
return withTmp(
(directory) =>
Effect.gen(function* () {
yield* LocationWatcher.Service
yield* Effect.sync(() => subscriptions.length).pipe(
Effect.filterOrFail((count) => count > 0),
Effect.retry(Schedule.spaced("10 millis")),
)
yield* Effect.sleep("10 millis")
expect(subscriptions).toEqual([{ path: path.join(directory, ".git", "HEAD"), type: "file" }])
}),
{ vcs: "git", watcher },
)
})
it.live("watches only exact Hg branch metadata", () => {
const subscriptions: Watcher.WatchInput[] = []
const watcher = Layer.succeed(
Watcher.Service,
Watcher.Service.of({
subscribe: (input) => Effect.sync(() => subscriptions.push(input)).pipe(Effect.as(Stream.empty)),
}),
)
return withTmp(
(directory) =>
Effect.gen(function* () {
yield* LocationWatcher.Service
yield* Effect.sync(() => subscriptions.length).pipe(
Effect.filterOrFail((count) => count > 0),
Effect.retry(Schedule.spaced("10 millis")),
)
yield* Effect.sleep("10 millis")
expect(subscriptions).toEqual([{ path: path.join(directory, ".hg", "branch"), type: "file" }])
}),
{ vcs: "hg", watcher },
)
})
})
function wait(check: (event: WatcherEvent) => boolean) {
return Effect.gen(function* () {
const bus = yield* Bus.Service
@@ -275,18 +226,31 @@ function eventuallyUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: (
)
}
function ready(file: string, eventFile = file) {
function noUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>, timeout = 500) {
return Effect.acquireUseRelease(
wait(check),
({ deferred }) =>
trigger.pipe(
Effect.andThen(Deferred.await(deferred)),
Effect.timeoutOption(`${timeout} millis`),
Effect.tap((result) => Effect.sync(() => expect(result).toEqual(Option.none()))),
),
({ fiber }) => Fiber.interrupt(fiber),
)
}
function ready(directory: string) {
const file = path.join(directory, `.watcher-${Math.random().toString(36).slice(2)}`)
return Effect.gen(function* () {
const fs = yield* FSUtil.Service
const content = (yield* fs.readFileStringSafe(file)) ?? `ready-${Math.random()}`
yield* eventuallyUpdate(
(event) => event.file === eventFile,
() => fs.writeFileString(file, content),
).pipe(Effect.asVoid)
(event) => event.file === file,
() => fs.writeFileString(file, `ready-${Math.random()}`),
).pipe(Effect.ensuring(fs.remove(file, { force: true }).pipe(Effect.ignore)), Effect.asVoid)
})
}
describeNative("LocationWatcher", () => {
describeWatcher("LocationWatcher", () => {
it.live("limits file watches to the exact target", () =>
withTmp((directory) =>
Effect.gen(function* () {
@@ -312,25 +276,94 @@ describeNative("LocationWatcher", () => {
),
)
it.live("detects creation of a missing directory target", () =>
it.live("publishes root create, update, and delete events", () =>
withTmp(
(directory) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const file = path.join(directory, "watch.txt")
yield* ready(directory)
for (const item of [
{ event: "add" as const, trigger: fs.writeFileString(file, "a") },
{ event: "change" as const, trigger: fs.writeFileString(file, "b") },
{ event: "unlink" as const, trigger: fs.remove(file) },
]) {
expect(
yield* nextUpdate((event) => event.file === file && event.event === item.event, item.trigger),
).toEqual({
file,
event: item.event,
})
}
}),
{ vcs: "git" },
),
)
it.live("skips non-git roots", () =>
withTmp((directory) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const watcher = yield* Watcher.Service
const target = path.join(directory, "generated")
const updates = yield* watcher.subscribe({ path: target, type: "file" })
const update = yield* updates.pipe(
Stream.take(1),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
const creates = yield* Effect.suspend(() =>
fs.remove(target, { recursive: true, force: true }).pipe(Effect.andThen(fs.ensureDir(target))),
).pipe(Effect.repeat(Schedule.spaced("10 millis")), Effect.forkScoped)
const event = yield* Fiber.join(update).pipe(Effect.ensuring(Fiber.interrupt(creates)))
const file = path.join(directory, "plain.txt")
yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "plain"))
}),
),
)
expect(event.valueOrUndefined?.path).toBe(target)
}).pipe(Effect.provide(AppNodeBuilder.build(Watcher.node))),
it.live("ignores dependency, VCS, and build directories at any depth", () =>
withTmp(
(directory) =>
Effect.gen(function* () {
const afs = yield* FSUtil.Service
yield* ready(directory)
const roots = ["node_modules", ".git", "dist"].map((name) => path.join(directory, "nested", name))
const files = roots.map((root) => path.join(root, "package", "index.js"))
yield* noUpdate(
(event) => roots.some((root) => event.file === root || event.file.startsWith(`${root}${path.sep}`)),
Effect.forEach(files, (file) => afs.writeWithDirs(file, "ignored"), {
concurrency: "unbounded",
discard: true,
}),
)
}),
{ vcs: "git" },
),
)
it.live("cleanup stops publishing events", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const fs = yield* FSUtil.Service
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* ready(tmp.path).pipe(
provide(tmp.path, { type: "git", store: AbsolutePath.make(path.join(tmp.path, ".git")) }),
Effect.scoped,
)
const file = path.join(tmp.path, "after-dispose.txt")
yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "gone")).pipe(
Effect.provideService(Bus.Service, bus),
)
}).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.node])))),
)
it.live("ignores .git/index changes", () =>
withTmp(
(directory) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const index = path.join(directory, ".git", "index")
yield* ready(directory)
yield* noUpdate(
(event) => event.file === index,
fs
.writeFileString(path.join(directory, "tracked.txt"), "a")
.pipe(Effect.andThen(Effect.promise(() => $`git add .`.cwd(directory).quiet())), Effect.asVoid),
)
}),
{ vcs: "git" },
),
)
@@ -341,11 +374,11 @@ describeNative("LocationWatcher", () => {
const fs = yield* FSUtil.Service
const head = path.join(directory, ".git", "HEAD")
const branch = `watch-${Math.random().toString(36).slice(2)}`
yield* ready(head)
yield* ready(directory)
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
expect(
yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)),
).toEqual({ file: head, event: "change" })
).toMatchObject({ file: head })
}),
{ vcs: "git" },
),
@@ -360,8 +393,8 @@ describeNative("LocationWatcher", () => {
const afs = yield* FSUtil.Service
const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(actual, { recursive: true, force: true })))
yield* ready(directory)
const head = path.join(directory, ".git", "HEAD")
yield* ready(head, path.join(actual, "HEAD"))
const branch = `watch-${Math.random().toString(36).slice(2)}`
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
expect(
@@ -389,7 +422,7 @@ describeNative("LocationWatcher", () => {
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const branch = path.join(directory, ".hg", "branch")
yield* ready(branch)
yield* ready(directory)
expect(
yield* nextUpdate((event) => event.file === branch, fs.writeFileString(branch, "feature\n")),
).toMatchObject({ file: branch })
@@ -105,29 +105,19 @@ export const environmentConformance = <E>(
}),
)
check("preserves symlink metadata while following symlinks for content", (harness) =>
check("reports symlinks without resolving them", (harness) =>
Effect.gen(function* () {
if (!harness.symlink) return
yield* harness.files.write(`${harness.root}/target`, bytes("target"))
yield* harness.files.write(`${harness.root}/target-dir/file`, bytes("through link"))
yield* harness.symlink("../target", `${harness.root}/target-dir/entry-link`)
yield* harness.symlink("target", `${harness.root}/link`)
yield* harness.symlink("target-dir", `${harness.root}/link-dir`)
yield* harness.symlink("missing", `${harness.root}/dangling-link`)
expect((yield* harness.files.stat(`${harness.root}/link`)).type).toBe("symlink")
expect(yield* harness.files.list(harness.root)).toContainEqual({ name: "link", type: "symlink" })
expect(text((yield* harness.files.read(`${harness.root}/link-dir/file`)).bytes)).toBe("through link")
expect(
(yield* harness.files.list(`${harness.root}/link-dir`)).toSorted((a, b) => a.name.localeCompare(b.name)),
).toEqual([
{ name: "entry-link", type: "symlink" },
{ name: "file", type: "file" },
])
const fileError = yield* Effect.flip(harness.files.list(`${harness.root}/link`))
expect(fileError).toBeInstanceOf(WrongKind)
expect((fileError as WrongKind).actual).toBe("file")
expect(yield* Effect.flip(harness.files.list(`${harness.root}/dangling-link`))).toBeInstanceOf(NotFound)
const listError = yield* Effect.flip(harness.files.list(`${harness.root}/link-dir`))
expect(listError).toBeInstanceOf(WrongKind)
expect((listError as WrongKind).actual).toBe("symlink")
}),
)
@@ -13,7 +13,6 @@ import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { UserInterruptedError } from "@opencode-ai/core/session/error"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionRunner } from "@opencode-ai/core/session/runner"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
@@ -128,34 +127,23 @@ describe("SessionExecution lifecycle", () => {
it.effect("resumes each suspended Session at most once", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
const first = Session.ID.make("ses_resume_first")
const second = Session.ID.make("ses_resume_second")
yield* seedSessions(database, [first, second], { time_suspended: Date.now() })
const drained: string[] = []
const continued: SessionEvent.Synthetic[] = []
const scope = yield* Scope.make()
const context = yield* buildExecution(scope, ({ sessionID }) => Effect.sync(() => void drained.push(sessionID)))
const execution = Context.get(context, SessionExecution.Service)
const restart = Context.get(context, SessionRestart.Service)
yield* bus.project(SessionEvent.Synthetic, (event) => Effect.sync(() => void continued.push(event)))
yield* restart.resumeSuspendedSessions
yield* Effect.forEach([first, second], execution.awaitIdle, { discard: true })
expect(drained.toSorted()).toEqual([first, second])
expect(continued.map((event) => event.data).toSorted((a, b) => a.sessionID.localeCompare(b.sessionID))).toEqual(
[first, second].map((sessionID) => ({
sessionID,
text: "The server restarted while you were working. Continue from where you left off without repeating completed work.",
description: "Continuing after restart",
})),
)
expect(yield* suspensions(database)).toEqual({ [first]: false, [second]: false })
yield* restart.resumeSuspendedSessions
expect(drained.length).toBe(2)
expect(continued.length).toBe(2)
yield* Scope.close(scope, Exit.void)
}),
)
+17 -218
View File
@@ -6,10 +6,11 @@ import { Agent } from "@opencode-ai/core/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Skill } from "@opencode-ai/core/skill"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@@ -24,15 +25,8 @@ const discovery = Layer.succeed(
},
}),
)
const watcherLayer = Watcher.testLayer
const it = testEffect(
Layer.mergeAll(
AppNodeBuilder.build(LayerNode.group([Skill.node, Agent.node, Bus.node]), [
[SkillDiscovery.node, discovery],
[Watcher.node, watcherLayer],
]),
watcherLayer,
),
AppNodeBuilder.build(LayerNode.group([Skill.node, Agent.node, Bus.node]), [[SkillDiscovery.node, discovery]]),
)
function write(directory: string, name: string, description: string) {
@@ -59,24 +53,6 @@ function waitForSkillUpdate() {
})
}
function expectSubscription(check: (input: Watcher.WatchInput) => boolean) {
return Effect.gen(function* () {
const watcher = yield* Watcher.Test
expect((yield* watcher.subscriptions()).some(check)).toBe(true)
})
}
function emitAndWait(update: Watcher.Update) {
return Effect.gen(function* () {
const watcher = yield* Watcher.Test
yield* Effect.acquireUseRelease(
waitForSkillUpdate(),
({ deferred }) => watcher.emit(update).pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
({ fiber }) => Fiber.interrupt(fiber),
)
})
}
describe("Skill", () => {
it.live("publishes updates when skill sources change", () =>
Effect.gen(function* () {
@@ -113,7 +89,6 @@ describe("Skill", () => {
})
const skill = yield* Skill.Service
const watcher = yield* Watcher.Test
yield* skill.transform((editor) => {
editor.source({ type: "directory", path: AbsolutePath.make(first) })
editor.source({ type: "directory", path: AbsolutePath.make(first) })
@@ -144,21 +119,6 @@ describe("Skill", () => {
content: "# review",
},
])
expect(yield* watcher.subscriptions()).toEqual([
{ path: first, type: "directory" },
{ path: second, type: "directory" },
])
yield* Effect.promise(() => write(second, "review", "Updated Second"))
yield* emitAndWait({ type: "update", path: path.join(second, "review", "SKILL.md") })
expect((yield* skill.list()).find((item) => item.id === "review")?.description).toBe("Updated Second")
expect(yield* watcher.subscriptions()).toEqual([
{ path: first, type: "directory" },
{ path: second, type: "directory" },
{ path: first, type: "directory" },
{ path: second, type: "directory" },
])
}),
),
),
@@ -238,7 +198,7 @@ metadata:
),
)
it.live("clears cached skills when sources reload", () =>
it.live("invalidates cached skills and publishes updates for watcher changes", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
@@ -250,187 +210,26 @@ metadata:
await write(tmp.path, "deploy", "Initial deploy")
})
const skill = yield* Skill.Service
const watcher = yield* Watcher.Test
const bus = yield* Bus.Service
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Initial deploy")
expect(yield* watcher.subscriptions()).toEqual([{ path: tmp.path, type: "directory" }])
let refreshed: Skill.Info[] = []
const unsubscribe = yield* bus.listen((event) => {
if (event.type !== Skill.Event.Updated.type) return Effect.void
return skill.list().pipe(
Effect.tap((items) => Effect.sync(() => (refreshed = items))),
Effect.asVoid,
)
})
yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy"))
yield* skill.reload().pipe(Effect.timeout("1 second"))
yield* unsubscribe
expect(refreshed.find((item) => item.id === "deploy")?.description).toBe("Updated deploy")
expect(yield* watcher.subscriptions()).toEqual([
{ path: tmp.path, type: "directory" },
{ path: tmp.path, type: "directory" },
])
}),
),
),
)
it.live("reloads project sources created after their missing parent", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const source = path.join(tmp.path, "generated", "skills")
const file = path.join(source, "deploy", "SKILL.md")
const skill = yield* Skill.Service
const watcher = yield* Watcher.Test
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
expect(yield* skill.list()).toEqual([])
expect(yield* watcher.subscriptions()).toEqual([{ path: path.join(tmp.path, "generated"), type: "file" }])
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "generated")))
yield* emitAndWait({ type: "create", path: path.join(tmp.path, "generated") })
expect(yield* skill.list()).toEqual([])
expect(yield* watcher.subscriptions()).toEqual([
{ path: path.join(tmp.path, "generated"), type: "file" },
{ path: source, type: "file" },
])
yield* Effect.promise(async () => {
await fs.mkdir(path.dirname(file), { recursive: true })
await write(source, "deploy", "Deploy production")
})
yield* emitAndWait({ type: "create", path: source })
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
expect(yield* watcher.subscriptions()).toEqual([
{ path: path.join(tmp.path, "generated"), type: "file" },
{ path: source, type: "file" },
{ path: source, type: "directory" },
])
}),
),
),
)
it.live("watches directory sources for added and changed skills", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
await write(tmp.path, "deploy", "Initial deploy")
})
const skill = yield* Skill.Service
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
yield* expectSubscription((input) => input.type === "directory" && input.path === tmp.path)
const deploy = path.join(tmp.path, "deploy", "SKILL.md")
expect((yield* skill.list()).find((item) => item.name === "deploy")?.description).toBe("Initial deploy")
const file = path.join(tmp.path, "deploy", "SKILL.md")
yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy"))
yield* emitAndWait({ type: "update", path: deploy })
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Updated deploy")
expect((yield* skill.list()).find((item) => item.name === "deploy")?.description).toBe("Initial deploy")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "review"), { recursive: true })
await write(tmp.path, "review", "Review changes")
})
const review = path.join(tmp.path, "review", "SKILL.md")
yield* emitAndWait({ type: "create", path: review })
expect((yield* skill.list()).map((item) => item.id)).toEqual([
Skill.ID.make("deploy"),
Skill.ID.make("review"),
])
yield* Effect.acquireUseRelease(
waitForSkillUpdate(),
({ deferred }) =>
bus
.publish(FileSystem.Event.Changed, { file, event: "change" })
.pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
({ fiber }) => Fiber.interrupt(fiber),
)
yield* Effect.promise(() => fs.rm(path.join(tmp.path, "review"), { recursive: true }))
yield* emitAndWait({ type: "delete", path: review })
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
}),
),
),
)
it.live("watches canonical directories behind symlinked skills", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const source = path.join(tmp.path, "source")
const target = path.join(tmp.path, "target", "bro")
const file = path.join(target, "SKILL.md")
yield* Effect.promise(async () => {
await fs.mkdir(source, { recursive: true })
await fs.mkdir(target, { recursive: true })
await fs.writeFile(file, "---\nname: bro\ndescription: Initial\n---\n# bro")
await fs.symlink(target, path.join(source, "bro"))
})
const skill = yield* Skill.Service
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Initial")
yield* expectSubscription((input) => input.type === "directory" && input.path === target)
yield* Effect.promise(() => fs.writeFile(file, "---\nname: bro\ndescription: Updated\n---\n# bro"))
yield* emitAndWait({ type: "update", path: file })
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Updated")
}),
),
),
)
it.live("invalidates symlinked sources when their target changes", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const source = path.join(tmp.path, "source")
const first = path.join(tmp.path, "first")
const second = path.join(tmp.path, "second")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(first, "bro"), { recursive: true })
await fs.mkdir(path.join(second, "bro"), { recursive: true })
await write(first, "bro", "First")
await write(second, "bro", "Second")
await fs.symlink(first, source)
})
const skill = yield* Skill.Service
const watcher = yield* Watcher.Test
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("First")
expect(yield* watcher.subscriptions()).toEqual([
{ path: first, type: "directory" },
{ path: source, type: "file" },
])
yield* Effect.promise(async () => {
await fs.unlink(source)
await fs.symlink(second, source)
})
yield* emitAndWait({ type: "update", path: source })
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Second")
expect(yield* watcher.subscriptions()).toEqual([
{ path: first, type: "directory" },
{ path: source, type: "file" },
{ path: second, type: "directory" },
{ path: source, type: "file" },
])
expect((yield* skill.list()).find((item) => item.name === "deploy")?.description).toBe("Updated deploy")
}),
),
),
+33 -68
View File
@@ -4,9 +4,9 @@ import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Formatter } from "@opencode-ai/core/formatter"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Permission } from "@opencode-ai/core/permission"
@@ -23,15 +23,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const editToolNode = makeLocationNode({
name: "test/edit-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
deps: [
Tool.node,
LocationMutation.node,
FileMutation.node,
Environment.node,
Formatter.node,
Location.node,
Permission.node,
],
deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node],
})
const sessionID = Session.ID.make("ses_edit_tool_test")
@@ -80,28 +72,29 @@ const reset = () => {
formatFile = () => Effect.succeed(false)
}
const environment = Layer.effect(
Environment.Service,
const filesystem = Layer.effect(
FSUtil.Service,
Effect.gen(function* () {
const current = yield* Environment.Service
return Environment.Service.of({
...current,
files: {
...current.files,
read: (target, range) =>
current.files
.read(target, range)
.pipe(
Effect.tap((result) =>
Effect.sync(() => reads++).pipe(Effect.andThen(Effect.suspend(() => afterRead(target, result.bytes)))),
),
const fs = yield* FSUtil.Service
return FSUtil.Service.of({
...fs,
readFile: (target) =>
fs
.readFile(target)
.pipe(
Effect.tap((content) =>
Effect.sync(() => reads++).pipe(Effect.andThen(Effect.suspend(() => afterRead(target, content)))),
),
write: (target, content) =>
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(current.files.write(target, content))),
},
),
writeWithDirs: (target, content, mode) =>
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeWithDirs(target, content, mode))),
writeFile: (target, content, options) =>
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeFile(target, content, options))),
writeFileString: (target, content, options) =>
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeFileString(target, content, options))),
})
}),
).pipe(Layer.provide(LayerNode.compile(Environment.node)))
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) => {
const activeLocation = Layer.succeed(
@@ -113,9 +106,15 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
}).pipe(
Effect.provide(
AppNodeBuilder.build(
LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, editToolNode]),
LayerNode.group([
Tool.node,
Tool.node,
LocationMutation.node,
FileMutation.node,
editToolNode,
]),
[
[Environment.node, environment],
[FSUtil.node, filesystem],
[Location.node, activeLocation],
[Formatter.node, formatter],
[Permission.node, permission],
@@ -472,7 +471,10 @@ describe("EditTool", () => {
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect(
yield* executeTool(registry, call({ path: "missing.ts", oldString: "before", newString: "after" })),
yield* executeTool(
registry,
call({ path: "missing.ts", oldString: "before", newString: "after" }),
),
).toEqual({
status: "error",
error: { type: "tool.execution", message: "File not found: missing.ts" },
@@ -643,43 +645,6 @@ describe("EditTool", () => {
),
)
it.live("serializes concurrent edit transactions", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const target = path.join(tmp.path, "concurrent.txt")
afterRead = () => (reads === 1 ? Effect.sleep("50 millis") : Effect.void)
return Effect.promise(() => fs.writeFile(target, "one\ntwo\n")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.all(
[
executeTool(
registry,
call({ path: "concurrent.txt", oldString: "one", newString: "ONE" }, "call-edit-one"),
),
executeTool(
registry,
call({ path: "concurrent.txt", oldString: "two", newString: "TWO" }, "call-edit-two"),
),
],
{ concurrency: "unbounded" },
),
),
),
Effect.andThen((results) =>
Effect.gen(function* () {
expect(results.map((result) => result.status)).toEqual(["completed", "completed"])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("ONE\nTWO\n")
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("applies the edit when content changes after matching", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
+42 -65
View File
@@ -2,12 +2,11 @@ import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Exit, Layer, Schema } from "effect"
import { systemError } from "effect/PlatformError"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Formatter } from "@opencode-ai/core/formatter"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Location } from "@opencode-ai/core/location"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -23,7 +22,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const patchToolNode = makeLocationNode({
name: "test/patch-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
deps: [Tool.node, FileMutation.node, Environment.node, Formatter.node, Location.node, Permission.node],
deps: [Tool.node, Formatter.node, FSUtil.node, Location.node, Permission.node],
})
const sessionID = Session.ID.make("ses_patch_tool_test")
@@ -82,33 +81,48 @@ const reset = () => {
formatFile = () => Effect.succeed(false)
}
const environment = Layer.effect(
Environment.Service,
const filesystem = Layer.effect(
FSUtil.Service,
Effect.gen(function* () {
const current = yield* Environment.Service
return Environment.Service.of({
...current,
files: {
...current.files,
read: (target, range) =>
Effect.sync(() => {
if (!editApproved) readsBeforeEditApproval++
}).pipe(Effect.andThen(current.files.read(target, range))),
remove: (target) => {
if (failRemoveTarget && path.basename(target) === failRemoveTarget) return Effect.die("forced remove failure")
if (failRemoveErrorTarget && path.basename(target) === failRemoveErrorTarget)
return Effect.fail(new Environment.Failed({ path: target, cause: new Error("forced remove failure") }))
return current.files.remove(target)
},
write: (target, content) => {
if (failWriteTarget && path.basename(target) === failWriteTarget)
return Effect.fail(new Environment.Failed({ path: target, cause: new Error("forced write failure") }))
return current.files.write(target, content)
},
const fs = yield* FSUtil.Service
return FSUtil.Service.of({
...fs,
readFile: (target) =>
Effect.sync(() => {
if (!editApproved) readsBeforeEditApproval++
}).pipe(Effect.andThen(fs.readFile(target))),
remove: (target, options) => {
if (failRemoveTarget && path.basename(target) === failRemoveTarget) return Effect.die("forced remove failure")
if (failRemoveErrorTarget && path.basename(target) === failRemoveErrorTarget) {
return Effect.fail(
systemError({
_tag: "Unknown",
module: "FileSystem",
method: "remove",
description: "forced remove failure",
pathOrDescriptor: target,
}),
)
}
return fs.remove(target, options)
},
writeWithDirs: (target, content, mode) => {
if (failWriteTarget && path.basename(target) === failWriteTarget) {
return Effect.fail(
systemError({
_tag: "Unknown",
module: "FileSystem",
method: "writeWithDirs",
description: "forced write failure",
pathOrDescriptor: target,
}),
)
}
return fs.writeWithDirs(target, content, mode)
},
})
}),
).pipe(Layer.provide(LayerNode.compile(Environment.node)))
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const withTool = <A, E, R>(
directory: string,
@@ -125,8 +139,8 @@ const withTool = <A, E, R>(
return yield* body(yield* Tool.Service)
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Tool.node, FileMutation.node, patchToolNode]), [
[Environment.node, environment],
AppNodeBuilder.build(LayerNode.group([Tool.node, patchToolNode]), [
[FSUtil.node, filesystem],
[Location.node, activeLocation],
[Formatter.node, formatter],
[Permission.node, permission],
@@ -248,43 +262,6 @@ describe("PatchTool", () => {
),
)
it.live("serializes concurrent patch transactions", () =>
withTempTool((directory, registry) => {
const target = path.join(directory, "concurrent.txt")
afterEditApproval = () =>
assertions.filter((input) => input.action === "edit").length === 1 ? Effect.sleep("50 millis") : Effect.void
return Effect.promise(() => fs.writeFile(target, "one\ntwo\n")).pipe(
Effect.andThen(
Effect.all(
[
executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: concurrent.txt\n@@\n-one\n+ONE\n*** End Patch",
"call-patch-one",
),
),
executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: concurrent.txt\n@@\n-two\n+TWO\n*** End Patch",
"call-patch-two",
),
),
],
{ concurrency: "unbounded" },
),
),
Effect.andThen((results) =>
Effect.gen(function* () {
expect(results.map((result) => result.status)).toEqual(["completed", "completed"])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("ONE\nTWO\n")
}),
),
)
}),
)
it.live("returns file diffs for final formatted content", () =>
withTempTool((directory, registry) => {
const target = path.join(directory, "formatted.txt")
+51 -161
View File
@@ -1,65 +1,66 @@
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Environment } from "@opencode-ai/core/environment"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
import { Effect, FileSystem } from "effect"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Effect, FileSystem } from "effect"
import { ChildProcessSpawner } from "effect/unstable/process"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { testEffect } from "./lib/effect"
const it = testEffect(LayerNode.compile(LayerNode.group([CrossSpawnSpawner.node, LayerNodePlatform.filesystem])))
const it = testEffect(LayerNode.compile(LayerNode.group([FSUtil.node, LayerNodePlatform.filesystem])))
const fixture = Effect.gen(function* () {
const fs = yield* FSUtil.Service
const files = yield* FileSystem.FileSystem
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const directory = yield* files.makeTempDirectoryScoped()
return { environment: Environment.makeFiles(Environment.makeLocalDriver(spawner)), files, directory }
return { fs, files, directory }
})
const absolute = (value: string) => AbsolutePath.make(value)
describe("ReadToolFileSystem", () => {
it.effect("preserves the environment not-found error", () =>
it.effect("fails with a typed filesystem error when a resolved file disappears", () =>
Effect.gen(function* () {
const { environment, directory } = yield* fixture
const { fs, directory } = yield* fixture
const file = path.join(directory, "missing.txt")
const error = yield* ReadToolFileSystem.read(environment, absolute(file), "missing.txt").pipe(Effect.flip)
const error = yield* ReadToolFileSystem.read(fs, file, "missing.txt").pipe(Effect.flip)
expect(error).toBeInstanceOf(Environment.NotFound)
expect(error).toMatchObject({ _tag: "PlatformError" })
}),
)
it.effect("returns a listing when read reports a directory", () =>
it.effect("fails when a file becomes the wrong path kind", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
yield* files.makeDirectory(path.join(directory, "folder"))
yield* files.writeFileString(path.join(directory, "file.txt"), "hello")
const { fs, directory } = yield* fixture
const result = yield* ReadToolFileSystem.read(environment, absolute(directory), "folder")
const error = yield* ReadToolFileSystem.read(fs, directory, "folder").pipe(Effect.flip)
expect(result).toMatchObject({
type: "list-page",
entries: [
{ path: `folder${path.sep}`, type: "directory" },
{ path: "file.txt", type: "file" },
],
})
expect(error).toBeInstanceOf(ReadToolFileSystem.PathKindError)
}),
)
it.effect("fails with a typed filesystem error when directory listing fails", () =>
Effect.gen(function* () {
const { fs, files, directory } = yield* fixture
const file = path.join(directory, "file.txt")
yield* files.writeFileString(file, "hello")
const error = yield* ReadToolFileSystem.list(fs, file).pipe(Effect.flip)
expect(error).toBeInstanceOf(FSUtil.FileSystemError)
if (error instanceof FSUtil.FileSystemError) expect(error.method).toBe("readDirectoryEntries")
}),
)
it.effect("reads malformed UTF-8 lossily and still rejects null-byte binary content", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const { fs, files, directory } = yield* fixture
const binary = path.join(directory, "archive.dat")
const malformed = path.join(directory, "malformed.txt")
yield* files.writeFile(binary, Uint8Array.of(0, 1, 2, 3))
yield* files.writeFile(malformed, Uint8Array.of(0x68, 0x69, 0x80))
const binaryError = yield* ReadToolFileSystem.read(environment, absolute(binary), "archive.dat").pipe(Effect.flip)
const malformedResult = yield* ReadToolFileSystem.read(environment, absolute(malformed), "malformed.txt")
const binaryError = yield* ReadToolFileSystem.read(fs, binary, "archive.dat").pipe(Effect.flip)
const malformedResult = yield* ReadToolFileSystem.read(fs, malformed, "malformed.txt")
expect(binaryError).toBeInstanceOf(ReadToolFileSystem.BinaryFileError)
expect(binaryError.message).toBe("Cannot read binary file: archive.dat")
@@ -69,11 +70,11 @@ describe("ReadToolFileSystem", () => {
it.effect("reads text despite a binary-associated extension", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const { fs, files, directory } = yield* fixture
const file = path.join(directory, "notes.docx")
yield* files.writeFileString(file, "plain text")
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "notes.docx")
const result = yield* ReadToolFileSystem.read(fs, file, "notes.docx")
expect(result).toMatchObject({ type: "file", content: "plain text", encoding: "utf8" })
}),
@@ -82,17 +83,15 @@ describe("ReadToolFileSystem", () => {
it.effect("lists unresolved symlinks, including broken and escaping links", () =>
Effect.gen(function* () {
if (process.platform === "win32") return
const { environment, files, directory } = yield* fixture
const { fs: service, files, directory } = yield* fixture
const outside = yield* files.makeTempDirectoryScoped()
yield* files.makeDirectory(path.join(directory, "folder"))
yield* files.writeFileString(path.join(directory, "file.txt"), "hello")
yield* Effect.promise(() => fs.symlink(path.join(outside, "target.txt"), path.join(directory, "escape")))
yield* Effect.promise(() => fs.symlink(path.join(directory, "missing.txt"), path.join(directory, "broken")))
const result = yield* ReadToolFileSystem.read(environment, absolute(directory), "folder")
const result = yield* ReadToolFileSystem.list(service, directory)
expect(result.type).toBe("list-page")
if (result.type !== "list-page") return
expect(result.entries.map((entry) => ({ ...entry, path: String(entry.path) }))).toEqual([
{ path: `folder${path.sep}`, type: "directory" },
{ path: "broken", type: "symlink" },
@@ -102,154 +101,45 @@ describe("ReadToolFileSystem", () => {
}),
)
it.effect("reads a symlinked directory as a listing", () =>
Effect.gen(function* () {
if (process.platform === "win32") return
const { environment, files, directory } = yield* fixture
const target = path.join(directory, "target")
const link = path.join(directory, "link")
yield* files.makeDirectory(target)
yield* files.writeFileString(path.join(target, "file.txt"), "hello")
yield* Effect.promise(() => fs.symlink(target, link))
const result = yield* ReadToolFileSystem.read(environment, absolute(link), "link")
expect(result).toMatchObject({
type: "list-page",
entries: [{ path: "file.txt", type: "file" }],
})
}),
)
it.effect("reports out-of-range pagination as a typed error", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const { fs, files, directory } = yield* fixture
const file = path.join(directory, "short.txt")
yield* files.writeFileString(file, "one\n")
const error = yield* ReadToolFileSystem.read(environment, absolute(file), "short.txt", { offset: 2 }).pipe(
Effect.flip,
)
const error = yield* ReadToolFileSystem.read(fs, file, "short.txt", { offset: 2 }).pipe(Effect.flip)
expect(error).toBeInstanceOf(ReadToolFileSystem.OffsetOutOfRangeError)
expect(error.message).toBe("Offset 2 is out of range")
}),
)
it.effect("pages text with one-based offsets", () =>
it.effect("stops reading after the requested page is complete", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const file = path.join(directory, "lines.txt")
yield* files.writeFileString(file, "one\r\ntwo\nthree")
const { fs, files, directory } = yield* fixture
const prefix = new TextEncoder().encode("one\n")
for (const [name, trailing] of [
["malformed.txt", 0x80],
["nul.txt", 0],
] as const) {
const file = path.join(directory, name)
yield* files.writeFile(file, Uint8Array.from([...prefix, trailing]))
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "lines.txt", {
offset: 2,
limit: 1,
})
const result = yield* ReadToolFileSystem.read(fs, file, name, { limit: 1 })
expect(result).toMatchObject({ type: "text-page", content: "two", offset: 2, truncated: true, next: 3 })
}),
)
it.effect("truncates long lines", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const file = path.join(directory, "long.txt")
yield* files.writeFileString(file, "a".repeat(2_001))
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "long.txt", { limit: 1 })
expect(result).toMatchObject({
type: "text-page",
content: `${"a".repeat(2_000)}... (line truncated to 2000 chars)`,
truncated: false,
})
}),
)
it.effect("enforces line and byte budgets with continuation offsets", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const linesFile = path.join(directory, "many-lines.txt")
const bytesFile = path.join(directory, "many-bytes.txt")
yield* files.writeFileString(linesFile, Array.from({ length: 2_001 }, (_, index) => String(index)).join("\n"))
yield* files.writeFileString(bytesFile, Array.from({ length: 200 }, () => "a".repeat(2_000)).join("\n"))
const ranges: Array<{ readonly offset: number; readonly length: number } | undefined> = []
const tracked = {
...environment,
read: (path: string, range?: { readonly offset: number; readonly length: number }) =>
Effect.sync(() => ranges.push(range)).pipe(Effect.andThen(environment.read(path, range))),
expect(result).toMatchObject({ type: "text-page", content: "one", truncated: true, next: 2 })
}
const lines = yield* ReadToolFileSystem.read(environment, absolute(linesFile), "many-lines.txt", { limit: 2_000 })
const bytes = yield* ReadToolFileSystem.read(tracked, absolute(bytesFile), "many-bytes.txt", {})
expect(lines).toMatchObject({ type: "text-page", truncated: true, next: 2_001 })
expect(lines.type === "text-page" ? lines.content.split("\n") : []).toHaveLength(2_000)
expect(bytes).toMatchObject({ type: "text-page", truncated: true, next: 26 })
expect(bytes.type === "text-page" ? Buffer.byteLength(bytes.content) : Infinity).toBeLessThanOrEqual(
ReadToolFileSystem.MAX_READ_BYTES,
)
expect(ranges).toEqual([{ offset: 0, length: 256 * 1024 }])
}),
)
it.effect("sorts and pages directory entries", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
yield* files.makeDirectory(path.join(directory, "z"))
yield* files.makeDirectory(path.join(directory, "a"))
yield* files.writeFileString(path.join(directory, "b.txt"), "")
const result = yield* ReadToolFileSystem.read(environment, absolute(directory), "folder", {
offset: 2,
limit: 1,
})
expect(result).toMatchObject({
type: "list-page",
entries: [{ path: `z${path.sep}`, type: "directory" }],
truncated: true,
next: 3,
})
}),
)
it.effect("stops checking for null bytes after the requested page", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const file = path.join(directory, "nul.txt")
yield* files.writeFile(file, Uint8Array.from([...new TextEncoder().encode("one\n"), 0]))
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "nul.txt", { limit: 1 })
expect(result).toMatchObject({ type: "text-page", content: "one", truncated: true, next: 2 })
}),
)
it.effect("reads page two after fetching more than the first 256KB range", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const file = path.join(directory, "large.txt")
yield* files.writeFileString(file, `${"a".repeat(300 * 1024)}\nsecond\n`)
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "large.txt", {
offset: 2,
limit: 1,
})
expect(result).toMatchObject({ type: "text-page", content: "second", offset: 2, truncated: false })
}),
)
it.effect("preserves the media ingestion limit message", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const { fs, files, directory } = yield* fixture
const file = path.join(directory, "oversized.png")
yield* files.writeFile(file, Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a))
yield* files.truncate(file, ReadToolFileSystem.MAX_MEDIA_INGEST_BYTES + 1)
const error = yield* ReadToolFileSystem.read(environment, absolute(file), "oversized.png").pipe(Effect.flip)
const error = yield* ReadToolFileSystem.read(fs, file, "oversized.png").pipe(Effect.flip)
expect(error).toBeInstanceOf(ReadToolFileSystem.MediaIngestLimitError)
expect(error.message).toBe(
@@ -260,11 +150,11 @@ describe("ReadToolFileSystem", () => {
it.effect("reads PDFs as bounded media", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const { fs, files, directory } = yield* fixture
const file = path.join(directory, "document.pdf")
yield* files.writeFileString(file, "%PDF-1.7\ncontent")
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "document.pdf")
const result = yield* ReadToolFileSystem.read(fs, file, "document.pdf")
expect(result).toMatchObject({
type: "file",
+48 -23
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect } from "bun:test"
import path from "path"
import { Effect, Exit, Layer, Stream } from "effect"
import { Effect, Exit, Layer, PlatformError, Stream } from "effect"
import { Config } from "@opencode-ai/core/config"
import { Document, Info } from "@opencode-ai/schema/config"
import { ConfigMedia } from "@opencode-ai/schema/config/media"
@@ -21,7 +21,6 @@ import { ReadTool } from "@opencode-ai/core/tool/plugin/read"
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { SessionInstructions } from "@opencode-ai/core/session/instructions"
import { Environment } from "@opencode-ai/core/environment"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
@@ -43,13 +42,24 @@ const readToolNode = makeLocationNode({
const assertions: Permission.AssertInput[] = []
const missingPath = "__missing_read_target__.txt"
const missingAbsolutePath = path.join(process.cwd(), missingPath)
const notFound = (target: string) =>
PlatformError.systemError({
_tag: "NotFound",
module: "FileSystem",
method: "stat",
pathOrDescriptor: target,
})
const readCalls: {
input: AbsolutePath
page: ReadToolFileSystem.PageInput
}[] = []
const listCalls: ReadToolFileSystem.PageInput[] = []
let listResult = new ReadToolFileSystem.ListPage({ type: "list-page", entries: [], truncated: false })
let resolvedType: "file" | "directory" = "file"
let resolveFailure: unknown
let inspectFailure: ReadToolFileSystem.InspectError | undefined
let directoryEntries: string[] = []
let readResult: ReadToolFileSystem.FileContent | ReadToolFileSystem.TextPage | ReadToolFileSystem.ListPage = {
let readResult: ReadToolFileSystem.FileContent | ReadToolFileSystem.TextPage = {
type: "file",
uri: "file:///README.md",
name: "README.md",
@@ -61,12 +71,22 @@ let readFailure: ReadToolFileSystem.ReadError | undefined
const reader = Layer.succeed(
ReadToolFileSystem.Service,
ReadToolFileSystem.Service.of({
inspect: () =>
resolveFailure !== undefined
? Effect.die(resolveFailure)
: inspectFailure !== undefined
? Effect.fail(inspectFailure)
: Effect.succeed(resolvedType),
read: (input, _resource, page = {}) => {
readCalls.push({ input, page })
if (resolveFailure !== undefined) return Effect.die(resolveFailure)
if (readFailure !== undefined) return Effect.fail(readFailure)
return Effect.succeed(readResult)
},
list: (_path, input = {}) =>
Effect.sync(() => {
listCalls.push(input)
return listResult
}),
}),
)
let allow = true
@@ -105,6 +125,17 @@ const testFileSystem = Layer.effect(
FSUtil.Service.of({
...fs,
readDirectory: () => Effect.succeed(directoryEntries),
realPath: (path) =>
path === missingAbsolutePath
? Effect.fail(
PlatformError.systemError({
_tag: "NotFound",
module: "FileSystem",
method: "realPath",
pathOrDescriptor: path,
}),
)
: Effect.succeed(path),
}),
),
),
@@ -164,8 +195,11 @@ describe("ReadTool", () => {
beforeEach(() => {
assertions.length = 0
readCalls.length = 0
listCalls.length = 0
allow = true
resolvedType = "file"
resolveFailure = undefined
inspectFailure = undefined
directoryEntries = []
readResult = {
type: "file",
@@ -176,6 +210,7 @@ describe("ReadTool", () => {
mime: "text/plain",
}
readFailure = undefined
listResult = new ReadToolFileSystem.ListPage({ type: "list-page", entries: [], truncated: false })
})
it.effect("registers, authorizes, and reads through the location filesystem", () =>
@@ -637,7 +672,7 @@ describe("ReadTool", () => {
it.effect("returns missing paths as model-visible tool failures", () =>
Effect.gen(function* () {
readFailure = new Environment.NotFound({ path: missingAbsolutePath })
inspectFailure = notFound(missingAbsolutePath)
directoryEntries = [
"__missing_read_target__.txt.bak",
"copy___missing_read_target__.txt",
@@ -661,18 +696,14 @@ describe("ReadTool", () => {
},
})
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: [missingPath], save: ["*"] }])
expect(readCalls).toEqual([
{
input: AbsolutePath.make(missingAbsolutePath),
page: { offset: undefined, limit: undefined },
},
])
expect(readCalls).toEqual([])
}),
)
it.effect("lists a bounded directory page through read", () =>
Effect.gen(function* () {
readResult = new ReadToolFileSystem.ListPage({
resolvedType = "directory"
listResult = new ReadToolFileSystem.ListPage({
type: "list-page",
entries: [
FileSystem.Entry.make({ path: RelativePath.make("components/"), type: "directory" }),
@@ -695,7 +726,7 @@ describe("ReadTool", () => {
})
expect(result).toMatchObject({
status: "completed",
output: { entries: readResult.entries, truncated: true, next: 4 },
output: { entries: listResult.entries, truncated: true, next: 4 },
})
if (result.status !== "completed") return
expect(result.metadata).toEqual({ truncated: true })
@@ -706,15 +737,14 @@ describe("ReadTool", () => {
},
])
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["src"], save: ["*"] }])
expect(readCalls).toEqual([
{ input: AbsolutePath.make(path.join(process.cwd(), "src")), page: { offset: 2, limit: 10 } },
])
expect(listCalls).toEqual([{ offset: 2, limit: 10 }])
}),
)
it.effect("does not list a directory when permission is denied", () =>
Effect.gen(function* () {
allow = false
resolvedType = "directory"
const registry = yield* Tool.Service
expect(
@@ -724,7 +754,7 @@ describe("ReadTool", () => {
call: { type: "tool-call", id: "call-read-directory-denied", name: "read", input: { path: "src" } },
}),
).toEqual({ status: "error", error: { type: "permission.rejected", message: "Permission denied: read" } })
expect(readCalls).toEqual([])
expect(listCalls).toEqual([])
}),
)
@@ -743,12 +773,7 @@ describe("ReadTool", () => {
),
).toBe(true)
expect(readCalls).toEqual([
{
input: AbsolutePath.make(path.join(process.cwd(), "missing.txt")),
page: { offset: undefined, limit: undefined },
},
])
expect(readCalls).toEqual([])
}),
)
+19 -6
View File
@@ -5,8 +5,8 @@ import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Permission } from "@opencode-ai/core/permission"
@@ -24,12 +24,19 @@ import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
const globToolNode = makeLocationNode({
name: "test/glob-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)),
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
deps: [
Tool.node,
FSUtil.node,
Ripgrep.node,
Location.node,
LocationMutation.node,
Permission.node,
],
})
const grepToolNode = makeLocationNode({
name: "test/grep-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)),
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
deps: [Tool.node, FSUtil.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
})
const sessionID = Session.ID.make("ses_search_tool_test")
@@ -179,7 +186,9 @@ describe("search tools", () => {
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "haystack\n")).pipe(
Effect.andThen(withTools(tmp.path, (registry) => executeTool(registry, call("grep", { pattern: "needle" })))),
Effect.andThen(
withTools(tmp.path, (registry) => executeTool(registry, call("grep", { pattern: "needle" }))),
),
Effect.tap((result) =>
Effect.sync(() => {
expect(result).toMatchObject({
@@ -288,7 +297,9 @@ describe("search tools", () => {
(tmp) =>
Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "content\n")).pipe(
Effect.andThen(
withTools(tmp.path, (registry) => executeTool(registry, call("glob", { path: "file.txt", pattern: "*" }))),
withTools(tmp.path, (registry) =>
executeTool(registry, call("glob", { path: "file.txt", pattern: "*" })),
),
),
Effect.tap((result) =>
Effect.sync(() => {
@@ -320,7 +331,9 @@ describe("search tools", () => {
Effect.sync(() => {
expect(result.status).toBe("completed")
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "glob"])
expect(assertions[0]?.resources).toEqual([path.join(outside.path, "*").replaceAll("\\", "/")])
expect(assertions[0]?.resources).toEqual([
path.join(outside.path, "*").replaceAll("\\", "/"),
])
}),
),
)
+1 -2
View File
@@ -524,8 +524,7 @@ describe("ShellTool", () => {
const content = settled.content?.[0]
if (!content || content.type !== "text") throw new Error("Expected text content")
expect(content.text).not.toContain("one")
// Windows shells emit CRLF; the assertion targets line limits, not line endings.
expect(content.text.replaceAll("\r\n", "\n")).toStartWith("two\nthree")
expect(content.text).toStartWith("two\nthree")
expect(content.text).toContain("output truncated; full output saved to:")
})
},
+11 -14
View File
@@ -6,7 +6,7 @@ import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Formatter } from "@opencode-ai/core/formatter"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Permission } from "@opencode-ai/core/permission"
@@ -23,7 +23,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const writeToolNode = makeLocationNode({
name: "test/write-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)),
deps: [Tool.node, LocationMutation.node, FileMutation.node, Environment.node, Formatter.node, Permission.node],
deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node],
})
const sessionID = Session.ID.make("ses_write_tool_test")
@@ -68,20 +68,17 @@ const reset = () => {
denyAction = undefined
}
const environment = Layer.effect(
Environment.Service,
const filesystem = Layer.effect(
FSUtil.Service,
Effect.gen(function* () {
const current = yield* Environment.Service
return Environment.Service.of({
...current,
files: {
...current.files,
write: (target, content) =>
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(current.files.write(target, content))),
},
const fs = yield* FSUtil.Service
return FSUtil.Service.of({
...fs,
writeWithDirs: (target, content, mode) =>
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeWithDirs(target, content, mode))),
})
}),
).pipe(Layer.provide(LayerNode.compile(Environment.node)))
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) => {
const activeLocation = Layer.succeed(
@@ -95,7 +92,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
AppNodeBuilder.build(
LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]),
[
[Environment.node, environment],
[FSUtil.node, filesystem],
[Location.node, activeLocation],
[Formatter.node, formatter],
[Permission.node, permission],
+3 -3
View File
@@ -31,9 +31,9 @@
},
"peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.4.5",
"@opentui/keymap": ">=0.4.5",
"@opentui/solid": ">=0.4.5",
"@opentui/core": "0.0.0-20260807-6b4336f3",
"@opentui/keymap": "0.0.0-20260807-6b4336f3",
"@opentui/solid": "0.0.0-20260807-6b4336f3",
"solid-js": ">=1.9.0"
},
"peerDependenciesMeta": {
+1 -2
View File
@@ -20,8 +20,7 @@
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/util": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"modal": "0.9.0"
"effect": "catalog:"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
-179
View File
@@ -1,179 +0,0 @@
import { Effect, Sink, Stream } from "effect"
import { systemError } from "effect/PlatformError"
import type { Command, KillOptions } from "effect/unstable/process/ChildProcess"
import { ExitCode, make, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner"
import type { Driver } from "@opencode-ai/core/environment"
import type { ModalClientParams, Sandbox, SandboxCreateParams } from "modal"
const INNER_WRAPPER = `
pidfile=$1
shift
printf "%s" "$$" > "$pidfile"
trap 'rm -f -- "$pidfile"' EXIT
"$@"
`
const KILL = `
i=0
while [ ! -s "$1" ] && [ "$i" -lt 250 ]; do sleep 0.02; i=$((i + 1)); done
if [ -s "$1" ]; then
pid=$(cat "$1")
/bin/kill "-$2" "-$pid" 2>/dev/null || true
else
exit 47
fi
`
export interface ModalImageSpec {
readonly registry: string
readonly dockerfileCommands: ReadonlyArray<string>
}
export interface ModalSandboxOptions {
readonly app: string
readonly client?: ModalClientParams
readonly image?: ModalImageSpec
readonly sandbox?: SandboxCreateParams
}
/**
* Ubuntu supplies the GNU coreutils and findutils required by the derived Files
* scripts. Busybox images do not satisfy the Environment contract.
*/
export const ubuntuImage: ModalImageSpec = {
registry: "ubuntu:24.04",
dockerfileCommands: [
"RUN apt-get update && apt-get install -y --no-install-recommends git bash ripgrep ca-certificates coreutils findutils util-linux",
],
}
/** Creates a Modal sandbox lazily, keeping the SDK off the server startup path when Modal is unused. */
export const createModalSandbox = async (options: ModalSandboxOptions) => {
const { ModalClient } = await import("modal")
const client = new ModalClient(options.client)
const app = await client.apps.fromName(options.app, { createIfMissing: true })
const imageSpec = options.image ?? ubuntuImage
const image = client.images.fromRegistry(imageSpec.registry).dockerfileCommands([...imageSpec.dockerfileCommands])
const sandbox = await client.sandboxes.create(app, image, options.sandbox)
return {
driver: makeModalDriver(sandbox),
sandbox,
terminate: () => sandbox.terminate(),
}
}
/**
* Adapts Modal exec to the Environment driver. Files intentionally has no native
* overrides: Modal exec and filesystem tools share the same roughly 175ms floor,
* so the derived exec defaults are the simplest implementation with no measured loss.
*
* Modal cannot signal a ContainerProcess. Each command therefore starts a new
* process group and records its leader in a unique pid file; kill runs a second
* sandbox command that signals that group. Pid files are removed best-effort.
*/
export const makeModalDriver = (sandbox: Sandbox): Driver => {
const spawn = Effect.fnUntraced(function* (command: Command) {
if (command._tag === "PipedCommand") {
return yield* Effect.fail(spawnError("spawn", "piped commands unsupported"))
}
if (command.options.additionalFds) {
return yield* Effect.fail(spawnError("spawn", "additional file descriptors unsupported"))
}
const pidFile = `/tmp/opencode-process-${crypto.randomUUID()}.pid`
const env = compact(command.options.env)
const isolatedEnv =
command.options.extendEnv === false || (!command.options.extendEnv && command.options.env !== undefined)
const argv = isolatedEnv ? ["env", "-i", ...Object.entries(env ?? {}).map(([key, value]) => `${key}=${value}`)] : []
const process = yield* Effect.tryPromise({
try: () =>
sandbox.exec(
["setsid", "--wait", "sh", "-c", INNER_WRAPPER, "sh", pidFile, ...argv, command.command, ...command.args],
{
mode: "binary",
stdout: "pipe",
stderr: "pipe",
workdir: command.options.cwd,
env: isolatedEnv ? undefined : env,
},
),
catch: (cause) => spawnError("spawn", undefined, cause),
})
const onError = (cause: unknown) => spawnError("process", undefined, cause)
let exited = false
const writer = process.stdin.getWriter()
let closingStdin: Promise<void> | undefined
const waited = process.wait().then((code) => {
exited = true
if (!closingStdin) writer.releaseLock()
return code
})
const exitCode = Effect.tryPromise({ try: () => waited, catch: onError }).pipe(Effect.map(ExitCode))
const kill = (options?: KillOptions) => {
if (exited) return Effect.void
return Effect.tryPromise({
try: async () => {
const killer = await sandbox.exec(["sh", "-c", KILL, "sh", pidFile, options?.killSignal ?? "SIGTERM"], {
stdout: "pipe",
stderr: "pipe",
})
const code = await killer.wait()
if (code !== 0) throw new Error(`modal kill exited ${code}`)
},
catch: onError,
}).pipe(Effect.andThen(exitCode), Effect.asVoid)
}
yield* Effect.addFinalizer(() => kill(command.options).pipe(Effect.ignore))
const closeStdin = Effect.tryPromise({
try: () => (closingStdin ??= writer.close().finally(() => writer.releaseLock())),
catch: onError,
})
const writeStdin = Sink.forEach((chunk: Uint8Array) =>
Effect.tryPromise({ try: () => writer.write(chunk), catch: onError }),
)
const inputConfig = command.options.stdin
const inputOptions =
inputConfig !== undefined && typeof inputConfig === "object" && !Stream.isStream(inputConfig)
? inputConfig
: undefined
const input = inputOptions?.stream ?? inputConfig
const stdin = inputOptions?.endOnDone === false ? writeStdin : writeStdin.pipe(Sink.ensuring(closeStdin))
if (input === "ignore") {
yield* closeStdin
}
if (Stream.isStream(input)) {
yield* Effect.forkScoped(Stream.run(input, stdin))
}
const stdout = Stream.fromReadableStream({ evaluate: () => process.stdout, onError })
const stderr = Stream.fromReadableStream({ evaluate: () => process.stderr, onError })
return makeHandle({
pid: ProcessId(crypto.getRandomValues(new Uint32Array(1))[0]),
exitCode,
isRunning: Effect.sync(() => !exited),
kill,
stdin,
stdout,
stderr,
all: Stream.merge(stdout, stderr),
getInputFd: () => Sink.fail(spawnError("getInputFd", "unsupported")),
getOutputFd: () => Stream.fail(spawnError("getOutputFd", "unsupported")),
unref: Effect.succeed(Effect.void),
})
})
return { spawner: make(spawn) }
}
const compact = (env: Record<string, string | undefined> | undefined) => {
if (!env) return undefined
return Object.fromEntries(Object.entries(env).flatMap(([key, value]) => (value === undefined ? [] : [[key, value]])))
}
const spawnError = (method: string, description?: string, cause?: unknown) =>
systemError({ _tag: "Unknown", module: "ModalDriver", method, description, cause })
export * as ModalDriver from "./modal"
@@ -1,67 +0,0 @@
import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import { afterAll, expect, test } from "bun:test"
import { Effect } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { Failed, makeFiles } from "@opencode-ai/core/environment"
import { environmentConformance } from "@opencode-ai/core/testing/environment-conformance"
import { createModalSandbox } from "../src/workspace/modal"
const enabled =
!!process.env.OPENCODE_TEST_MODAL &&
((!!process.env.MODAL_TOKEN_ID && !!process.env.MODAL_TOKEN_SECRET) ||
fs.existsSync(path.join(os.homedir(), ".modal.toml")))
const modalTest = enabled ? test : test.skip
const root = `/tmp/opencode-environment-${crypto.randomUUID()}`
const sandbox = enabled
? createModalSandbox({
app: "opencode-environment-tests",
sandbox: { timeoutMs: 10 * 60 * 1000 },
})
: undefined
modalTest(
"kills a running modal process",
async () => {
const value = await sandbox!
const started = performance.now()
const exitCode = await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const handle = yield* value.driver.spawner.spawn(ChildProcess.make("sleep", ["300"]))
yield* handle.kill()
return yield* handle.exitCode
}),
).pipe(Effect.timeout("10 seconds")),
)
expect(exitCode).not.toBe(0)
expect(performance.now() - started).toBeLessThan(10_000)
},
15_000,
)
environmentConformance(
"modal environment",
() =>
Effect.promise(async () => {
const value = await sandbox!
return {
files: makeFiles(value.driver),
root,
symlink: (target, link) =>
Effect.scoped(value.driver.spawner.exitCode(ChildProcess.make("ln", ["-s", "--", target, link]))).pipe(
Effect.flatMap((code) =>
code === 0 ? Effect.void : Effect.fail(new Failed({ path: link, cause: new Error(`ln exited ${code}`) })),
),
Effect.mapError((cause) => (cause instanceof Failed ? cause : new Failed({ path: link, cause }))),
),
}
}),
!enabled,
)
afterAll(async () => {
if (!sandbox) return
await (await sandbox).terminate()
})
-1
View File
@@ -92,7 +92,6 @@
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
"clipboardy": "4.0.0",
"effect": "catalog:",
"fuzzysort": "catalog:",
"get-east-asian-width": "catalog:",
+15 -3
View File
@@ -97,6 +97,7 @@ import { destroyRenderer } from "./util/renderer"
import { cliErrorMessage, errorFormat } from "./util/error"
import { AttentionProvider } from "./context/attention"
import { StorageProvider } from "./context/storage"
import { createTuiClipboard } from "./clipboard"
registerOpencodeSpinner()
@@ -250,6 +251,13 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
(renderer) => Effect.sync(() => destroyRenderer(renderer)),
)
})
const clipboard = yield* Effect.acquireRelease(
Effect.sync(() => createTuiClipboard(renderer)),
(clipboard) =>
Effect.tryPromise(() => clipboard.dispose()).pipe(
Effect.catch((error) => Effect.sync(() => log("error", "Failed to dispose TUI clipboard", { error }))),
),
)
win32DisableProcessedInput()
const finalizers = new Set<() => Promise<void>>()
yield* Effect.addFinalizer(() =>
@@ -286,7 +294,11 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<EpilogueProvider set={(value) => (exit.epilogue = value)}>
<TuiAppProvider value={input.app}>
<ErrorBoundary
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}
fallback={(error, reset) => (
<ClipboardProvider value={clipboard}>
<ErrorComponent error={error} reset={reset} mode={mode} />
</ClipboardProvider>
)}
>
<TuiPathsProvider
value={{
@@ -335,7 +347,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
}}
>
<ClipboardProvider>
<ClipboardProvider value={clipboard}>
<ArgsProvider {...input.args}>
<ConfigProvider
config={config}
@@ -503,7 +515,7 @@ function App(props: { pair?: DialogPairCredentials }) {
if (!text || text.length === 0) return
await clipboard
.write?.(text)
.write(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
+59 -117
View File
@@ -1,124 +1,66 @@
import { execFile, spawn } from "node:child_process"
import { readFile, rm } from "node:fs/promises"
import { platform, release, tmpdir } from "node:os"
import path from "node:path"
import { promisify } from "node:util"
import {
createClipboard,
createHostClipboard,
createRendererClipboardAdapter,
decodePasteBytes,
type ClipboardService as CoreClipboardService,
type RendererClipboardBoundary,
} from "@opentui/core"
import type { ClipboardContent, ClipboardService } from "./context/clipboard"
const exec = promisify(execFile)
export type OwnedClipboardService = Required<ClipboardService> & Readonly<{ dispose(): Promise<void> }>
function command(command: string, args: string[] = [], input?: string) {
return new Promise<Buffer>((resolve, reject) => {
const child = spawn(command, args, { stdio: [input === undefined ? "ignore" : "pipe", "pipe", "ignore"] })
const output: Buffer[] = []
child.on("error", reject)
child.stdout?.on("data", (chunk: Buffer) => output.push(chunk))
child.on("close", (code) => {
if (code === 0) return resolve(Buffer.concat(output))
reject(new Error(`${command} exited with code ${code}`))
})
if (input !== undefined) child.stdin?.end(input)
})
export function createTuiClipboard(renderer: RendererClipboardBoundary): OwnedClipboardService {
return createClipboardAdapter(
createClipboard({
host: createHostClipboard(),
terminal: createRendererClipboardAdapter(renderer),
}),
)
}
function writeOsc52(text: string) {
if (!process.stdout.isTTY) return
const sequence = `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`
process.stdout.write(process.env.TMUX || process.env.STY ? `\x1bPtmux;\x1b${sequence}\x1b\\` : sequence)
}
export async function read() {
if (platform() === "darwin") {
const file = path.join(tmpdir(), "opencode-clipboard.png")
try {
await exec("osascript", [
"-e",
'set imageData to the clipboard as "PNGf"',
"-e",
`set fileRef to open for access POSIX file "${file}" with write permission`,
"-e",
"set eof fileRef to 0",
"-e",
"write imageData to fileRef",
"-e",
"close access fileRef",
])
return { data: (await readFile(file)).toString("base64"), mime: "image/png" }
} catch {
// Fall through to text clipboard.
} finally {
await rm(file, { force: true }).catch(() => {})
}
}
if (platform() === "win32" || release().includes("WSL")) {
const script =
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
const image = await command("powershell.exe", ["-NonInteractive", "-NoProfile", "-command", script]).catch(() =>
Buffer.alloc(0),
)
if (image.length) return { data: image.toString().trim(), mime: "image/png" }
}
if (platform() === "linux") {
const wayland = await command("wl-paste", ["-t", "image/png"]).catch(() => Buffer.alloc(0))
if (wayland.length) return { data: wayland.toString("base64"), mime: "image/png" }
const x11 = await command("xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]).catch(() =>
Buffer.alloc(0),
)
if (x11.length) return { data: x11.toString("base64"), mime: "image/png" }
}
const { default: clipboardy } = await import("clipboardy")
const text = await clipboardy.read().catch(() => undefined)
if (text) return { data: text, mime: "text/plain" }
}
export function copyCommand(
os: NodeJS.Platform,
wayland: boolean,
has: (name: string) => boolean,
): string[] | undefined {
if (os === "darwin" && has("osascript")) return ["osascript"]
if (os === "linux" && wayland && has("wl-copy")) return ["wl-copy"]
if (os === "linux" && has("xclip")) return ["xclip", "-selection", "clipboard"]
if (os === "linux" && has("xsel")) return ["xsel", "--clipboard", "--input"]
if (os === "win32" && has("powershell.exe")) {
return [
"powershell.exe",
"-NonInteractive",
"-NoProfile",
"-Command",
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
]
}
}
let copyMethod: Promise<(text: string) => Promise<void>> | undefined
function getCopyMethod() {
return (copyMethod ??= (async () => {
const { which } = await import("@opencode-ai/core/util/which")
const native = copyCommand(platform(), Boolean(process.env.WAYLAND_DISPLAY), (name) => Boolean(which(name)))
if (native?.[0] === "osascript") {
return async (text: string) => {
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
await command("osascript", ["-e", `set the clipboard to "${escaped}"`]).catch(() => undefined)
export function createClipboardAdapter(clipboard: CoreClipboardService): OwnedClipboardService {
return {
async read(): Promise<ClipboardContent | undefined> {
const result = await clipboard.read({
preferredTypes: ["image/png", "text/plain"],
selection: "clipboard",
})
if (result.status !== "read") {
if (result.status === "failed") throw result.error
if (result.status === "timed-out") throw new Error("Clipboard read timed out")
if (result.status === "limit-exceeded") {
throw new RangeError("Clipboard content exceeded configured read or image conversion limits")
}
return undefined
}
}
if (native) {
return async (text: string) => {
await command(native[0], native.slice(1), text).catch(() => undefined)
}
}
return async (text: string) => {
const { default: clipboardy } = await import("clipboardy")
await clipboardy.write(text).catch(() => undefined)
}
})())
}
export async function write(text: string) {
writeOsc52(text)
const method = await getCopyMethod()
await method(text)
if (result.representation.mimeType === "image/png") {
return {
data: Buffer.from(result.representation.bytes).toString("base64"),
mime: result.representation.mimeType,
}
}
if (result.representation.mimeType === "text/plain") {
if (result.representation.bytes.length === 0) return undefined
return {
data: decodePasteBytes(result.representation.bytes),
mime: result.representation.mimeType,
}
}
throw new Error(`Unexpected clipboard MIME type: ${result.representation.mimeType}`)
},
async write(text) {
const result = await clipboard.writeText(text, {
destination: "all-available",
selection: "clipboard",
})
if (result.host.status === "written" || result.terminal.status === "attempted") return
if (result.host.status === "failed") throw result.host.error
throw new Error(`Clipboard write failed (host: ${result.host.status}, terminal: ${result.terminal.status})`)
},
dispose() {
return clipboard.dispose()
},
}
}
@@ -84,6 +84,15 @@ export const settings: Setting[] = [
values: ["none", "auto"],
keywords: ["transcript", "messages"],
},
{
title: "Transcript images",
category: "Session",
path: ["session", "image_preview"],
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["attachments", "images", "tool output"],
},
{
title: "Enabled",
category: "Tabs",
@@ -188,6 +197,15 @@ export const settings: Setting[] = [
values: ["compact", "full"],
keywords: ["paste summary", "clipboard", "pasted content"],
},
{
title: "Image previews",
category: "Input",
path: ["prompt", "image_preview"],
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["attachments", "clipboard", "images", "prompt"],
},
{
title: "Leader timeout",
category: "Input",
+1 -1
View File
@@ -39,7 +39,7 @@ export function DialogDebug() {
.map((entry) => `${entry.label}: ${entry.value}`)
.join("\n")
void clipboard
.write?.(text)
.write(text)
.then(() => {
setCopied(true)
toast.show({ message: "Debug info copied to clipboard", variant: "info" })
@@ -0,0 +1,71 @@
import { TextAttributes } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import { createMemo, createSignal } from "solid-js"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
type ImagePreviewItem = Readonly<{
uri: string
mention?: Readonly<{ text: string }>
}>
export function DialogImagePreview(props: { images: readonly ImagePreviewItem[]; initial: number }) {
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const [index, setIndex] = createSignal(Math.max(0, Math.min(props.images.length - 1, props.initial)))
const [failed, setFailed] = createSignal(false)
const current = createMemo(() => props.images[index()])
const imageHeight = createMemo(() => Math.max(3, dimensions().height - 8))
dialog.setSize("xlarge")
dialog.setCentered(true)
function move(direction: number) {
if (props.images.length < 2) return
setFailed(false)
setIndex((value) => (value + direction + props.images.length) % props.images.length)
}
Keymap.createLayer(() => ({
mode: "modal",
commands: [
{ bind: "left", title: "Previous image", group: "Dialog", run: () => move(-1) },
{ bind: "right", title: "Next image", group: "Dialog", run: () => move(1) },
],
}))
return (
<box id="prompt-image-viewer" paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
Image {index() + 1} of {props.images.length}
</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<image
id="prompt-image-viewer-image"
source={current().uri}
fit="fit"
protocol="auto"
width="100%"
height={imageHeight()}
onError={() => setFailed(true)}
/>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text.subdued} onMouseUp={() => move(-1)}>
{props.images.length > 1 ? "← previous" : ""}
</text>
<text fg={failed() ? theme.text.feedback.error.default : theme.text.subdued} wrapMode="none" truncate>
{failed() ? "No preview" : (current().mention?.text ?? `Image ${index() + 1}`)}
</text>
<text fg={theme.text.subdued} onMouseUp={() => move(1)}>
{props.images.length > 1 ? "next →" : ""}
</text>
</box>
</box>
)
}
@@ -466,7 +466,7 @@ function OAuthAuto(props: {
run: () => {
const value = props.attempt.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.attempt.url
clipboard
.write?.(value)
.write(value)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
},
@@ -172,7 +172,6 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
onMount(() => dialog.setSize("large"))
const copy = () => {
if (!clipboard.write) return
void clipboard
.write(error())
.then(() => setCopied(true))
+25 -8
View File
@@ -12,7 +12,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
const exit = useExit()
const clipboard = useClipboard()
const app = useTuiApp()
const [copied, setCopied] = createSignal(false)
const [copyState, setCopyState] = createSignal<"idle" | "copied" | "failed">("idle")
// Safe fallback palette per mode (mirrors theme/assets/opencode.json) since the
// theme context may be the thing that crashed.
@@ -46,11 +46,19 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
const issueURL = buildIssueURL(message, stack, app.version)
const copyReport = () => {
void clipboard.write?.(issueURL.toString()).then(() => setCopied(true))
void clipboard
.write(issueURL.toString())
.then(() => setCopyState("copied"))
.catch(() => setCopyState("failed"))
}
const actions = [
{ key: "c", label: () => (copied() ? "✓ Copied" : "Copy report"), copy: true, onUse: copyReport },
{
key: "c",
label: () => ({ idle: "Copy report", copied: "✓ Copied", failed: "Copy failed" })[copyState()],
copy: true,
onUse: copyReport,
},
{ key: "r", label: () => "Restart", onUse: props.reset },
{ key: "q", label: () => "Quit", onUse: () => exit() },
]
@@ -135,13 +143,20 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
<For each={actions}>
{(action, index) => {
const isSelected = () => selected() === index()
const isCopied = () => action.copy && copied()
const copyColor = () =>
action.copy
? copyState() === "copied"
? colors.success
: copyState() === "failed"
? colors.error
: undefined
: undefined
return (
<box flexDirection="column" alignItems="center" flexShrink={0}>
<box
onMouseDown={() => setSelected(index())}
onMouseUp={() => action.onUse()}
backgroundColor={isCopied() ? colors.success : isSelected() ? colors.primary : colors.element}
backgroundColor={copyColor() ?? (isSelected() ? colors.primary : colors.element)}
minWidth={15}
alignItems="center"
paddingLeft={2}
@@ -149,7 +164,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
>
<text
attributes={TextAttributes.BOLD}
fg={isCopied() || isSelected() ? colors.onPrimary : colors.text}
fg={copyColor() || isSelected() ? colors.onPrimary : colors.text}
>
{action.label()}
</text>
@@ -189,9 +204,11 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
<Show when={showFooter()}>
<box flexDirection="column" alignItems="center" flexShrink={0}>
<text fg={colors.muted}>
{copied()
{copyState() === "copied"
? "Report copied — paste it into a new GitHub issue."
: "Copy the report and open a GitHub issue to help us fix this."}
: copyState() === "failed"
? "Clipboard write failed. Try again or report the crash manually."
: "Copy the report and open a GitHub issue to help us fix this."}
</text>
<text fg={colors.muted}>OpenCode {app.version}</text>
</box>
+180 -45
View File
@@ -7,9 +7,8 @@ import {
decodePasteBytes,
type KeyEvent,
} from "@opentui/core"
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match, For } from "solid-js"
import path from "path"
import { fileURLToPath } from "url"
import { useLocal } from "../../context/local"
import { useTheme, useThemes } from "../../context/theme"
import { tint } from "../../theme/color"
@@ -47,13 +46,20 @@ import { DialogSkill } from "../dialog-skill"
import { useArgs } from "../../context/args"
import { useConfig } from "../../config"
import { usePromptMove } from "./move"
import { readLocalAttachment } from "./local-attachment"
import {
normalizePastedFilepath,
parsePastedFilepaths,
readLocalAttachment,
MAX_LOCAL_ATTACHMENT_BYTES,
type LocalAttachment,
} from "./local-attachment"
import { useData } from "../../context/data"
import { useLocation } from "../../context/location"
import { Keymap, type KeymapCommand } from "../../context/keymap"
import { abbreviateHome } from "../../runtime"
import { PluginSlot } from "../../plugin/render"
import type { SessionPending } from "@opencode-ai/schema/session-pending"
import { DialogImagePreview } from "../dialog-image-preview"
export type PromptProps = {
sessionID?: string
@@ -71,17 +77,6 @@ export type PromptProps = {
}
}
function pastedFilepath(value: string, platform: string) {
const raw = value.replace(/^['"]+|['"]+$/g, "")
if (raw.startsWith("file://")) {
try {
return fileURLToPath(raw)
} catch {}
}
if (platform === "win32") return raw
return raw.replace(/\\(.)/g, "$1")
}
export type PromptRef = {
focused: boolean
current: PromptInfo
@@ -309,6 +304,41 @@ export function Prompt(props: PromptProps) {
extmarkToPart: new Map(),
interrupt: 0,
})
let disposed = false
let pasteQueue = Promise.resolve()
function enqueuePaste(run: (changed: () => boolean) => Promise<void>) {
pasteQueue = pasteQueue
.then(async () => {
if (disposed || input.isDestroyed) return
const before = { sessionID: props.sessionID, mode: store.mode, text: input.plainText }
await run(
() =>
disposed ||
input.isDestroyed ||
props.sessionID !== before.sessionID ||
store.mode !== before.mode ||
input.plainText !== before.text,
)
})
.catch((error) => {
if (!disposed) toast.error(error)
})
return pasteQueue
}
const imageAttachments = createMemo(() =>
(store.prompt.files ?? []).filter((file) => typeof file.uri === "string" && file.uri.startsWith("data:image/")),
)
const imagePreviewHeight = createMemo(() => Math.max(4, Math.min(8, Math.floor(dimensions().height / 4))))
const imagePreviewWidth = createMemo(() => imagePreviewHeight() * 2)
const visibleImageAttachments = createMemo(() => imageAttachments().slice(0, 3))
function openImagePreview(initial: number) {
const images = imageAttachments()
if (images.length === 0) return
dialog.replace(() => <DialogImagePreview images={images} initial={initial} />)
}
createEffect(
on(
@@ -388,22 +418,32 @@ export function Prompt(props: PromptProps) {
name: "prompt.paste",
category: "Prompt",
palette: undefined,
run: async (_input: string | undefined, event?: KeyEvent) => {
run: (_input: string | undefined, event?: KeyEvent) => {
event?.preventDefault()
event?.stopPropagation()
const content = await clipboard.read?.()
if (content?.mime.startsWith("image/")) {
await pasteAttachment({
filename: "clipboard",
uri: `data:${content.mime};base64,${content.data}`,
})
return
}
if (content?.mime === "text/plain") {
await pasteInputText(content.data)
}
return enqueuePaste(async (changed) => {
const content = await clipboard.read()
if (changed()) return
if (content?.mime.startsWith("image/")) {
pasteAttachment({
filename: "clipboard",
uri: `data:${content.mime};base64,${content.data}`,
})
return
}
if (content?.mime === "text/plain") {
await pasteInputText(content.data, changed)
}
})
},
},
{
title: "View image attachments",
name: "prompt.images.view",
category: "Prompt",
enabled: imageAttachments().length > 0,
run: () => openImagePreview(0),
},
{
title: "Interrupt session",
name: "session.interrupt",
@@ -541,6 +581,7 @@ export function Prompt(props: PromptProps) {
"prompt.submit",
"prompt.editor",
"prompt.editor_context.clear",
"prompt.images.view",
"prompt.stash",
"prompt.stash.pop",
"prompt.stash.list",
@@ -594,6 +635,7 @@ export function Prompt(props: PromptProps) {
})
onCleanup(() => {
disposed = true
if (store.prompt.text) {
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
}
@@ -1222,27 +1264,39 @@ export function Prompt(props: PromptProps) {
return true
}
async function pasteInputText(text: string) {
async function pasteInputText(text: string, changed: () => boolean) {
const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const pastedContent = normalizedText.trim()
const filepath = pastedFilepath(pastedContent, terminalEnvironment.platform)
const filepath = normalizePastedFilepath(pastedContent, terminalEnvironment.platform)
const isUrl = /^(https?):\/\//.test(filepath)
if (!isUrl) {
const attachment = await readLocalAttachment(filepath)
const filename = path.basename(filepath)
if (attachment?.type === "text") {
pasteText(attachment.content, `[SVG: ${filename ?? "image"}]`)
if (attachment) {
if (changed()) return
pasteLocalAttachment(filepath, attachment)
return
}
if (attachment?.type === "binary") {
await pasteAttachment({
filename,
uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`,
})
return
const filepaths = parsePastedFilepaths(pastedContent, terminalEnvironment.platform)
if (filepaths.length > 1) {
let remaining = MAX_LOCAL_ATTACHMENT_BYTES
const attachments: Array<{ filepath: string; attachment: LocalAttachment }> = []
for (const candidate of filepaths) {
const next = await readLocalAttachment(candidate, remaining)
if (!next) break
remaining -= typeof next.content === "string" ? Buffer.byteLength(next.content) : next.content.byteLength
attachments.push({ filepath: candidate, attachment: next })
}
if (attachments.length === filepaths.length) {
if (changed()) return
for (const item of attachments) pasteLocalAttachment(item.filepath, item.attachment)
return
}
}
}
if (changed()) return
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
if ((lineCount >= 3 || pastedContent.length > 150) && config.prompt?.paste !== "full") {
const extmark = input.extmarks.getAllForTypeId(promptPartTypeId).find((extmark) => {
@@ -1267,12 +1321,27 @@ export function Prompt(props: PromptProps) {
}, 0)
}
async function pasteAttachment(file: { filename?: string; uri: string }) {
function pasteLocalAttachment(filepath: string, attachment: LocalAttachment) {
const filename = path.basename(filepath)
if (attachment.type === "text") {
pasteText(attachment.content, `[SVG: ${filename || "image"}]`)
return
}
pasteAttachment({
filename,
uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`,
})
}
function pasteAttachment(file: { filename?: string; uri: string }) {
const currentOffset = input.cursorOffset
const extmarkStart = currentOffset
const pdf = file.uri.startsWith("data:application/pdf;")
const prefix = pdf ? "data:application/pdf;" : "data:image/"
const count = store.prompt.files?.filter((attachment) => attachment.uri.startsWith(prefix)).length ?? 0
const count = pdf
? (store.prompt.files?.filter(
(attachment) => typeof attachment.uri === "string" && attachment.uri.startsWith("data:application/pdf;"),
).length ?? 0)
: imageAttachments().length
const virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]`
const extmarkEnd = extmarkStart + virtualText.length
const textToInsert = virtualText + " "
@@ -1304,7 +1373,6 @@ export function Prompt(props: PromptProps) {
draft.extmarkToPart.set(extmarkId, { type: "file", index })
}),
)
return
}
function clearPrompt() {
@@ -1428,6 +1496,74 @@ export function Prompt(props: PromptProps) {
flexGrow={1}
width="100%"
>
<Show when={config.prompt?.image_preview && visibleImageAttachments().length > 0}>
<box
width="100%"
height={imagePreviewHeight() + 1}
flexDirection="row"
flexShrink={0}
justifyContent="flex-start"
gap={1}
paddingBottom={1}
>
<For each={visibleImageAttachments()}>
{(file, index) => {
const [failed, setFailed] = createSignal(false)
return (
<box
width={imagePreviewWidth()}
height={imagePreviewHeight()}
flexBasis={imagePreviewWidth()}
flexShrink={1}
onMouseUp={(event: MouseEvent) => {
if (event.button !== 0) return
event.stopPropagation()
openImagePreview(index())
}}
>
<Show
when={!failed()}
fallback={
<box width="100%" height="100%" alignItems="center" justifyContent="center">
<text fg={theme.text.subdued}>No preview</text>
</box>
}
>
<image
id={`prompt-image-preview-${index()}`}
source={file.uri}
fit="cover"
protocol="auto"
width="100%"
height="100%"
onError={() => setFailed(true)}
/>
</Show>
</box>
)
}}
</For>
<Show when={imageAttachments().length > visibleImageAttachments().length}>
<box
width={8}
height={imagePreviewHeight()}
flexBasis={8}
flexShrink={1}
alignItems="center"
justifyContent="center"
onMouseUp={(event: MouseEvent) => {
if (event.button !== 0) return
event.stopPropagation()
openImagePreview(visibleImageAttachments().length)
}}
>
<text fg={theme.text.subdued} wrapMode="none" truncate>
+{imageAttachments().length - visibleImageAttachments().length} more
</text>
</box>
</Show>
</box>
</Show>
<textarea
width="100%"
placeholder={placeholderText()}
@@ -1455,7 +1591,7 @@ export function Prompt(props: PromptProps) {
// hangul) is flushed to plainText before we read it for submission.
setTimeout(() => setTimeout(() => submit(), 0), 0)
}}
onPaste={async (event: PasteEvent) => {
onPaste={(event: PasteEvent) => {
if (props.disabled) {
event.preventDefault()
return
@@ -1465,11 +1601,10 @@ export function Prompt(props: PromptProps) {
// Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
// Replace CRLF first, then any remaining CR
const normalizedText = decodePasteBytes(event.bytes).replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const pastedContent = normalizedText.trim()
// Windows Terminal <1.25 can surface image-only clipboard as an
// empty bracketed paste. Windows Terminal 1.25+ does not.
if (!pastedContent) {
if (event.bytes.byteLength === 0) {
keymap.dispatch("prompt.paste")
return
}
@@ -1478,7 +1613,7 @@ export function Prompt(props: PromptProps) {
// default paste unless we suppress it first and handle insertion ourselves.
event.preventDefault()
await pasteInputText(normalizedText)
void enqueuePaste((changed) => pasteInputText(normalizedText, changed))
}}
ref={(r: TextareaRenderable) => {
input = r
@@ -1,9 +1,13 @@
import { readFile } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
// Bound filesystem work per terminal paste; the byte budget also bounds staged data.
const MAX_PASTED_FILEPATHS = 32
export const MAX_LOCAL_ATTACHMENT_BYTES = 20 * 1024 * 1024
export type LocalFiles = Readonly<{
readText(path: string): Promise<string>
readBytes(path: string): Promise<Uint8Array>
readText(path: string, maxBytes: number): Promise<string>
readBytes(path: string, maxBytes: number): Promise<Uint8Array>
mime(path: string): Promise<string>
}>
@@ -11,14 +15,15 @@ export type LocalAttachment =
| Readonly<{ type: "text"; mime: "image/svg+xml"; content: string }>
| Readonly<{ type: "binary"; mime: string; content: Uint8Array }>
export function readLocalAttachment(file: string) {
export function readLocalAttachment(file: string, maxBytes = MAX_LOCAL_ATTACHMENT_BYTES) {
return readLocalAttachmentWith(
{
readText: (value) => readFile(value, "utf8"),
readBytes: (value) => readFile(value),
readText: async (value, limit) => (await readFileBounded(value, limit)).toString("utf8"),
readBytes: readFileBounded,
mime: async (value) => mimeTypes[path.extname(value).toLowerCase()] ?? "application/octet-stream",
},
file,
maxBytes,
)
}
@@ -33,16 +38,99 @@ const mimeTypes: Record<string, string> = {
".webp": "image/webp",
}
export async function readLocalAttachmentWith(files: LocalFiles, path: string): Promise<LocalAttachment | undefined> {
async function readFileBounded(file: string, maxBytes: number) {
const source = Bun.file(file)
if (!(await source.exists())) throw new Error("Attachment does not exist")
if (source.size > maxBytes) throw new Error("Attachment exceeds the local file limit")
const content = Buffer.from(await source.slice(0, maxBytes + 1).arrayBuffer())
if (content.byteLength > maxBytes) throw new Error("Attachment exceeds the local file limit")
return content
}
export function normalizePastedFilepath(value: string, platform: string) {
const raw = value.replace(/^['"]+|['"]+$/g, "")
const url = decodeFileURL(raw)
if (url) return url
if (platform === "win32") return raw
return raw.replace(/\\(.)/g, "$1")
}
function decodeFileURL(value: string): string | undefined {
if (!value.startsWith("file://")) return undefined
try {
return fileURLToPath(value)
} catch {
return undefined
}
}
export function parsePastedFilepaths(value: string, platform: string) {
const result: string[] = []
let current = ""
let quote = ""
function push() {
if (!current) return
result.push(decodeFileURL(current) ?? current)
current = ""
}
const input = value.includes("file://")
? value
.split(/\r?\n/)
.filter((line) => !line.trimStart().startsWith("#"))
.join("\n")
: value
for (let index = 0; index < input.length; index++) {
const character = input[index]
if (quote) {
if (character === quote) {
quote = ""
continue
}
if (character === "\\" && platform !== "win32" && quote === '"' && index + 1 < input.length) {
current += input[++index]
continue
}
current += character
continue
}
if (character === "'" || character === '"') {
quote = character
continue
}
if (character === "\\" && platform !== "win32" && index + 1 < input.length) {
current += input[++index]
continue
}
if (/\s/.test(character)) {
push()
if (result.length > MAX_PASTED_FILEPATHS) return []
continue
}
current += character
}
if (quote) return []
push()
if (result.length > MAX_PASTED_FILEPATHS) return []
return result
}
export async function readLocalAttachmentWith(
files: LocalFiles,
path: string,
maxBytes = MAX_LOCAL_ATTACHMENT_BYTES,
): Promise<LocalAttachment | undefined> {
const mime = await files.mime(path).catch(() => undefined)
if (!mime) return
if (!mime) return undefined
if (!mime.startsWith("image/") && mime !== "application/pdf") return undefined
if (mime === "image/svg+xml") {
const content = await files.readText(path).catch(() => undefined)
if (!content) return
const content = await files.readText(path, maxBytes).catch(() => undefined)
if (!content || Buffer.byteLength(content) > maxBytes) return undefined
return { type: "text", mime, content }
}
if (!mime.startsWith("image/") && mime !== "application/pdf") return
const content = await files.readBytes(path).catch(() => undefined)
if (!content) return
const content = await files.readBytes(path, maxBytes).catch(() => undefined)
if (!content || content.byteLength > maxBytes) return undefined
return { type: "binary", mime, content }
}
+6
View File
@@ -105,6 +105,9 @@ export const Info = Schema.Struct({
paste: Schema.optional(Schema.Literals(["compact", "full"])).annotate({
description: "Display large pastes as compact placeholders or full text",
}),
image_preview: Schema.optional(Schema.Boolean).annotate({
description: "Show image attachment previews above the prompt input",
}),
}),
).annotate({ description: "Prompt input behavior" }),
session: Schema.optional(
@@ -119,6 +122,9 @@ export const Info = Schema.Struct({
grouping: Schema.optional(Schema.Literals(["auto", "none"])).annotate({
description: "Group related transcript items automatically or render each item separately",
}),
image_preview: Schema.optional(Schema.Boolean).annotate({
description: "Show user attachment and tool-result images in the session transcript",
}),
markdown: Schema.optional(Schema.Literals(["source", "rendered"])).annotate({
description: "Show Markdown syntax markers or conceal them in rendered transcript content",
}),
+2
View File
@@ -164,6 +164,7 @@ export const Definitions = {
prompt_submit: keybind("none", "Submit prompt"),
prompt_queue: keybind("alt+return", "Queue prompt"),
prompt_editor_context_clear: keybind("none", "Clear editor context"),
prompt_images_view: keybind("<leader>i", "View image attachments"),
prompt_skills: keybind("none", "Open skill selector"),
prompt_stash: keybind("none", "Stash prompt"),
prompt_stash_pop: keybind("none", "Pop stashed prompt"),
@@ -364,6 +365,7 @@ export const CommandMap = {
prompt_submit: "prompt.submit",
prompt_queue: "prompt.queue",
prompt_editor_context_clear: "prompt.editor_context.clear",
prompt_images_view: "prompt.images.view",
prompt_skills: "prompt.skills",
prompt_stash: "prompt.stash",
prompt_stash_pop: "prompt.stash.pop",
+9 -8
View File
@@ -1,18 +1,19 @@
import { createContext, type JSX, useContext } from "solid-js"
import { read, write } from "../clipboard"
export type ClipboardContent = Readonly<{ data: string; mime: string }>
export type ClipboardService = Readonly<{
read?(): Promise<ClipboardContent | undefined>
write?(text: string): Promise<void>
read(): Promise<ClipboardContent | undefined>
write(text: string): Promise<void>
}>
const clipboard = { read, write }
const ClipboardContext = createContext<ClipboardService>(clipboard)
export function ClipboardProvider(props: { value?: ClipboardService; children: JSX.Element }) {
return <ClipboardContext.Provider value={props.value ?? clipboard}>{props.children}</ClipboardContext.Provider>
const ClipboardContext = createContext<ClipboardService>()
export function ClipboardProvider(props: { value: ClipboardService; children: JSX.Element }) {
return <ClipboardContext.Provider value={props.value}>{props.children}</ClipboardContext.Provider>
}
export function useClipboard() {
return useContext(ClipboardContext)
const value = useContext(ClipboardContext)
if (!value) throw new Error("useClipboard must be used within a ClipboardProvider")
return value
}
+2 -20
View File
@@ -27,7 +27,6 @@ export interface Storage {
* JSON-serializable.
*/
memory<Value extends object>(key: string, options: { readonly initial: Value }): MemoryEntry<Value>
flush(): Promise<void>
}
function clone<Value extends object>(value: Value) {
@@ -47,7 +46,6 @@ function segment(value: string) {
function createStorage(root: string, channel: string) {
const entries = new Map<string, { readonly value: Entry<object>; readonly reload: () => void }>()
const memories = new Map<string, MemoryEntry<object>>()
const pending = new Set<Promise<void>>()
const directory = path.join(root, segment(channel), "tui")
const locks = path.join(root, segment(channel), "locks")
mkdirSync(directory, { recursive: true })
@@ -68,8 +66,8 @@ function createStorage(root: string, channel: string) {
const [store, setStore] = createStore(load())
const merge = (next: Value) => reconcile(next, { key: options.key })
const reload = () => batch(() => setStore(merge(load())))
const update = (mutation: (draft: Value) => void) => {
const operation = Flock.withLock(
const update = (mutation: (draft: Value) => void) =>
Flock.withLock(
file,
async () => {
const draft = load()
@@ -80,13 +78,6 @@ function createStorage(root: string, channel: string) {
},
{ dir: locks },
)
pending.add(operation)
operation.then(
() => pending.delete(operation),
() => pending.delete(operation),
)
return operation
}
const entry = [store, update] as const
entries.set(file, { value: entry as Entry<object>, reload })
return entry
@@ -99,15 +90,6 @@ function createStorage(root: string, channel: string) {
memories.set(key, entry as MemoryEntry<object>)
return entry
},
async flush() {
const failures: unknown[] = []
while (pending.size > 0) {
const results = await Promise.allSettled(pending)
failures.push(...results.filter((result) => result.status === "rejected").map((result) => result.reason))
}
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, "Storage writes failed")
},
}
const watcher = watch(directory, () => entries.forEach((entry) => entry.reload()))
@@ -66,8 +66,12 @@ export function DialogMessage(props: {
: "text" in value
? value.text
: ""
await clipboard.write?.(text)
dialog.clear()
try {
await clipboard.write(text)
dialog.clear()
} catch (error) {
toast.error(error)
}
},
},
{
+2 -2
View File
@@ -364,7 +364,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
function copyExternal() {
const current = externalField()
if (!current || !clipboard.write) return
if (!current) return
void clipboard
.write(current.url)
.then(() => {
@@ -992,7 +992,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
>
enter <span style={{ fg: theme.text.subdued }}>{actionLabel()}</span>
</text>
<Show when={externalField() && clipboard.write}>
<Show when={externalField()}>
<text fg={theme.text.default} onMouseUp={copyExternal}>
c <span style={{ fg: theme.text.subdued }}>copy</span>
</text>
+91 -13
View File
@@ -23,8 +23,8 @@ import { SplitBorder } from "../../ui/border"
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
import { PatchDiff } from "../../component/patch-diff"
import { createSyntaxStyleMemo, ThemeContextProvider, useTheme, useThemes } from "../../context/theme"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
import { ThemeContextProvider, useTheme, useThemes } from "../../context/theme"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA, MouseEvent } from "@opentui/core"
import { Prompt, type PromptRef } from "../../component/prompt"
import type {
ModelInfo,
@@ -54,6 +54,7 @@ import { openEditor } from "../../editor"
import { useDialog } from "../../ui/dialog"
import { DialogSelect } from "../../ui/dialog-select"
import { DialogSessionRename } from "../../component/dialog-session-rename"
import { DialogImagePreview } from "../../component/dialog-image-preview"
import { DialogMessage } from "./dialog-message"
import { DialogFork } from "./dialog-fork"
import { DialogTimeline } from "./dialog-timeline"
@@ -100,7 +101,6 @@ import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallbac
import { useSessionTabs } from "../../context/session-tabs"
import { createSingleFlight } from "../../util/single-flight"
import type { SessionPending } from "@opencode-ai/schema/session-pending"
import { generateThinkingSyntax } from "./thinking-syntax"
addDefaultParsers(parsers.parsers)
@@ -844,7 +844,7 @@ export function Session() {
}
clipboard
.write?.(text)
.write(text)
.then(() => toast.show({ message: "Message copied to clipboard!", variant: "success" }))
.catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" }))
dialog.clear()
@@ -862,7 +862,7 @@ export function Session() {
const sessionData = session()
if (!sessionData) return
const transcript = formatSessionTranscript(sessionData, messages(), showThinking())
await clipboard.write?.(transcript)
await clipboard.write(transcript)
toast.show({ message: "Session transcript copied to clipboard!", variant: "success" })
} catch {
toast.show({ message: "Failed to copy session transcript", variant: "error" })
@@ -896,7 +896,7 @@ export function Session() {
) + EOL
if (options.action === "copy") {
await clipboard.write?.(content)
await clipboard.write(content)
dialog.clear()
toast.show({ message: "Copied to clipboard", variant: "success" })
return
@@ -1433,7 +1433,6 @@ function SessionReasoningGroupView(props: {
const ctx = use()
const theme = useTheme()
const { currentSyntax: syntax } = useThemes()
const thinkingSyntax = createSyntaxStyleMemo(() => generateThinkingSyntax(syntax(), theme.text.subdued))
const renderer = useRenderer()
const [expanded, setExpanded] = createSignal(false)
const [hover, setHover] = createSignal(false)
@@ -1529,7 +1528,7 @@ function SessionReasoningGroupView(props: {
filetype="markdown"
drawUnstyledText={false}
streaming={part()?.time?.completed === undefined && message()?.time.completed === undefined}
syntaxStyle={thinkingSyntax()}
syntaxStyle={syntax()}
content={content()}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.text.subdued}
@@ -1605,8 +1604,9 @@ function SessionGroupView(props: {
</InlineToolRow>
</Show>
<Show when={expanded() && grouped().length > 0}>
<For each={grouped()}>{(part) => <ToolPart part={part} />}</For>
<For each={grouped()}>{(part) => <ToolPart part={part} images={false} />}</For>
</Show>
<ToolImages parts={grouped()} />
<For each={pending()}>{(part) => <ToolPart part={part} />}</For>
</Show>
</Show>
@@ -1917,6 +1917,11 @@ function UserMessage(props: { message: SessionMessageUser }) {
const data = useData()
const local = useLocal()
const files = createMemo(() => props.message.files ?? [])
const images = createMemo(() =>
files().flatMap((file) =>
file.mime.startsWith("image/") ? [{ uri: `data:${file.mime};base64,${file.data}` }] : [],
),
)
const themes = useThemes()
const theme = useTheme("elevated")
const mode = themes.mode
@@ -1938,6 +1943,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
borderColor={delivery() ? theme.border.default : color()}
customBorderChars={SplitBorder.customBorderChars}
>
<SessionImages images={images()} />
<box
onMouseOver={() => {
setHover(true)
@@ -2062,7 +2068,6 @@ function ReasoningPart(props: {
}) {
const theme = useTheme()
const { currentSyntax: syntax } = useThemes()
const thinkingSyntax = createSyntaxStyleMemo(() => generateThinkingSyntax(syntax(), theme.text.subdued))
const ctx = use()
// Collapsed by default in hide mode: a single line throughout, so the
// layout never shifts. Click to open the full markdown block, click to close.
@@ -2115,7 +2120,7 @@ function ReasoningPart(props: {
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={thinkingSyntax()}
syntaxStyle={syntax()}
content={content()}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.text.subdued}
@@ -2206,7 +2211,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
// Pending messages moved to individual tool pending functions
function ToolPart(props: { part: SessionMessageAssistantTool }) {
function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }) {
const display = createMemo(() => toolDisplay(props.part.name))
const toolprops = {
@@ -2230,7 +2235,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
},
}
return (
const content = (
<Switch>
<Match when={display() === "shell"}>
<Shell {...toolprops} />
@@ -2276,6 +2281,79 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
</Match>
</Switch>
)
return [
content,
<Show when={props.images !== false}>
<ToolImages parts={[props.part]} />
</Show>,
]
}
function ToolImages(props: { parts: readonly SessionMessageAssistantTool[] }) {
const images = createMemo(() => props.parts.flatMap(inlineToolImages))
return <SessionImages images={images()} />
}
function SessionImages(props: { images: readonly { uri: string }[] }) {
const ctx = use()
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const images = createMemo(() => (ctx.config.session?.image_preview ? props.images : []))
const height = createMemo(() => Math.max(4, Math.min(8, Math.floor(dimensions().height / 4))))
const visible = createMemo(() => images().slice(0, 3))
return (
<Show when={visible().length > 0}>
<box flexDirection="row" flexShrink={0} paddingTop={1} paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
<For each={visible()}>
{(image, index) => {
const [failed, setFailed] = createSignal(false)
return (
<box
width={height() * 2}
height={height()}
flexBasis={height() * 2}
flexShrink={1}
alignItems="center"
justifyContent="center"
onMouseUp={(event: MouseEvent) => {
if (event.button !== 0) return
event.stopPropagation()
dialog.replace(() => <DialogImagePreview images={images()} initial={index()} />)
}}
>
<Show when={!failed()} fallback={<text>No preview</text>}>
<image
source={image.uri}
fit="cover"
protocol="auto"
width="100%"
height="100%"
onError={() => setFailed(true)}
/>
</Show>
</box>
)
}}
</For>
<Show when={images().length > visible().length}>
<box width={8} height={height()} flexShrink={1} alignItems="center" justifyContent="center">
<text wrapMode="none" truncate>
+{images().length - visible().length} more
</text>
</box>
</Show>
</box>
</Show>
)
}
function inlineToolImages(part: SessionMessageAssistantTool) {
return toolDisplayContent(part.state).flatMap((content) =>
content.type === "file" && content.mime.startsWith("image/") && content.uri.startsWith("data:image/")
? [{ uri: content.uri }]
: [],
)
}
type ToolProps = {
@@ -1,9 +0,0 @@
import { SyntaxStyle, type RGBA } from "@opentui/core"
export function generateThinkingSyntax(syntax: SyntaxStyle, foreground: RGBA) {
return SyntaxStyle.fromStyles(
Object.fromEntries(
syntax.getRegisteredNames().map((name) => [name, { ...syntax.getStyle(name), fg: foreground }]),
),
)
}
+1 -1
View File
@@ -204,7 +204,7 @@ export function DialogProvider(props: ParentProps) {
function copySelection() {
const text = renderer.getSelection()?.getSelectedText()
if (!text || !clipboard.write) return false
if (!text) return false
void clipboard.write(text).then(
() => toast.show({ message: "Copied to clipboard", variant: "info" }),
(error) => toast.error(error),
+1 -1
View File
@@ -35,7 +35,7 @@ export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardServi
focus?.getClipboardText && selection.selectedRenderables.includes(focus) ? focus.getClipboardText(text) : text
clipboard
?.write?.(clipboardText)
.write(clipboardText)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
@@ -74,11 +74,11 @@ test("searches settings globally and opens the matching setting", async () => {
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
app.mockInput.pressArrow("down")
for (const key of "sounds") app.mockInput.pressKey(key)
for (const key of "image preview") app.mockInput.pressKey(key)
app.mockInput.pressEnter()
await app.waitForFrame((frame) => frame.includes("Settings") && frame.includes("Sounds"))
await app.waitForFrame((frame) => frame.includes("Settings") && frame.includes("Image previews"))
app.mockInput.pressEnter()
await app.waitFor(() => current.attention?.sound === false)
await app.waitFor(() => current.prompt?.image_preview === true)
} finally {
app.renderer.destroy()
}
+10 -12
View File
@@ -1,6 +1,9 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { mkdtempSync, rmSync } from "fs"
import { tmpdir } from "os"
import path from "path"
import { onMount } from "solid-js"
import { DialogOpen } from "../../../src/component/dialog-open"
import { ConfigProvider } from "../../../src/config"
@@ -11,13 +14,12 @@ import { LocationProvider, useLocation } from "../../../src/context/location"
import { RouteProvider, useRoute } from "../../../src/context/route"
import { TuiAppProvider } from "../../../src/context/runtime"
import { SessionTabsProvider } from "../../../src/context/session-tabs"
import { StorageProvider, useStorage } from "../../../src/context/storage"
import { StorageProvider } from "../../../src/context/storage"
import { ThemeProvider } from "../../../src/context/theme"
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
import { createApi, createEventStream, createFetch, json, type FetchHandler } from "../../fixture/tui-client"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { tmpdir } from "../../fixture/fixture"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
test("selecting an unhydrated session preserves its location", async () => {
@@ -50,7 +52,7 @@ test("selecting an unhydrated session preserves its location", async () => {
expect(fixture.route.data).toEqual({ type: "session", sessionID: "ses_remote" })
expect(fixture.location.ref).toEqual(remote)
} finally {
await fixture.dispose()
fixture.dispose()
}
})
@@ -92,7 +94,7 @@ test("shows the current project and opens its root", async () => {
expect(fixture.route.data).toEqual({ type: "home", location: { directory: root } })
expect(fixture.location.ref).toEqual({ directory: root })
} finally {
await fixture.dispose()
fixture.dispose()
}
})
@@ -147,7 +149,7 @@ test("preserves a moved project when sessions arrive", async () => {
expect(fixture.route.data).toEqual({ type: "home", location: { directory: "/tmp/opencode/second" } })
} finally {
await fixture.dispose()
fixture.dispose()
}
})
@@ -158,21 +160,18 @@ async function renderOpen(
location: ReturnType<typeof useLocation>
}) => void | Promise<void>,
) {
const temporary = await tmpdir()
const state = temporary.path
const state = mkdtempSync(path.join(tmpdir(), "opencode-dialog-open-"))
const events = createEventStream()
const calls = createFetch(handler, events)
let route!: ReturnType<typeof useRoute>
let location!: ReturnType<typeof useLocation>
let data!: ReturnType<typeof useData>
let storage!: ReturnType<typeof useStorage>
function Probe() {
const dialog = useDialog()
route = useRoute()
location = useLocation()
data = useData()
storage = useStorage()
onMount(
() => void Promise.resolve(beforeOpen?.({ data, location })).then(() => dialog.replace(() => <DialogOpen />)),
)
@@ -224,10 +223,9 @@ async function renderOpen(
get data() {
return data
},
async dispose() {
dispose() {
app.renderer.destroy()
await storage.flush()
await temporary[Symbol.asyncDispose]()
rmSync(state, { recursive: true, force: true })
},
}
}
+3
View File
@@ -60,6 +60,9 @@ async function mountForm(root: string, width = 80) {
>
<ClipboardProvider
value={{
async read() {
return undefined
},
write(text) {
copied.push(text)
return Promise.resolve()
+121 -11
View File
@@ -1,19 +1,129 @@
import { expect, test } from "bun:test"
import { copyCommand } from "../src/clipboard"
import {
createClipboard,
type ClipboardReadOptions,
type ClipboardReadResult,
type ClipboardService as CoreClipboardService,
type ClipboardWriteOptions,
type ClipboardWriteResult,
type HostClipboardService,
} from "@opentui/core"
import { createClipboardAdapter } from "../src/clipboard"
test("prefers Wayland clipboard when available", () => {
expect(copyCommand("linux", true, (name) => name === "wl-copy")).toEqual(["wl-copy"])
function coreClipboard(options: {
read?: ClipboardReadResult
write?: ClipboardWriteResult
onRead?: (input: ClipboardReadOptions) => void
onWrite?: (text: string, input: ClipboardWriteOptions) => void
}): CoreClipboardService {
return {
async read(input) {
options.onRead?.(input)
return options.read ?? { status: "empty" }
},
async writeText(text, input) {
options.onWrite?.(text, input)
return (
options.write ?? {
host: { status: "written" },
terminal: { status: "not-attempted", capability: "unknown" },
}
)
},
async clear() {
return {
host: { status: "cleared" },
terminal: { status: "not-attempted", capability: "unknown" },
}
},
async dispose() {},
}
}
test("adapts OpenTUI image and text reads", async () => {
const requests: ClipboardReadOptions[] = []
const image = createClipboardAdapter(
coreClipboard({
read: {
status: "read",
representation: { mimeType: "image/png", bytes: new Uint8Array([0, 1, 2, 255]) },
},
onRead: (input) => requests.push(input),
}),
)
const text = "line 1\r\n\t世界"
const plain = createClipboardAdapter(
coreClipboard({
read: {
status: "read",
representation: { mimeType: "text/plain", bytes: new TextEncoder().encode(text) },
},
}),
)
expect(await image.read()).toEqual({ data: "AAEC/w==", mime: "image/png" })
expect(await plain.read()).toEqual({ data: text, mime: "text/plain" })
expect(requests).toEqual([{ preferredTypes: ["image/png", "text/plain"], selection: "clipboard" }])
})
test("uses osascript on macOS", () => {
expect(copyCommand("darwin", false, (name) => name === "osascript")).toEqual(["osascript"])
test("uses all available routes but skips the process host remotely", async () => {
const writes = { host: 0, terminal: 0 }
const host: HostClipboardService = {
maxWriteBytes: 8 * 1024 * 1024,
async read() {
return { status: "empty" }
},
async writeText() {
writes.host++
return { status: "written" }
},
async clear() {
return { status: "cleared" }
},
async dispose() {},
}
const clipboard = createClipboardAdapter(
createClipboard({
host,
terminal: {
remote: true,
writeText() {
writes.terminal++
return { status: "attempted", capability: "supported" }
},
clear() {
return { status: "attempted", capability: "supported" }
},
},
}),
)
expect(await clipboard.write("hello")).toBeUndefined()
expect(writes).toEqual({ host: 0, terminal: 1 })
})
test("falls back through X11 clipboard commands", () => {
expect(copyCommand("linux", true, (name) => name === "xclip")).toEqual(["xclip", "-selection", "clipboard"])
expect(copyCommand("linux", false, (name) => name === "xsel")).toEqual(["xsel", "--clipboard", "--input"])
})
test("rejects only when no clipboard route accepted the write", async () => {
const writes: [string, ClipboardWriteOptions][] = []
const failure = new Error("native clipboard failed")
const fallback = createClipboardAdapter(
coreClipboard({
write: {
host: { status: "written" },
terminal: { status: "local-failure", capability: "supported" },
},
}),
)
const clipboard = createClipboardAdapter(
coreClipboard({
write: {
host: { status: "failed", error: failure },
terminal: { status: "local-failure", capability: "supported" },
},
onWrite: (text, input) => writes.push([text, input]),
}),
)
test("returns undefined when native clipboard is unavailable", () => {
expect(copyCommand("linux", false, () => false)).toBeUndefined()
expect(await fallback.write("hello")).toBeUndefined()
expect(await clipboard.write("hello").then(undefined, (error) => error)).toBe(failure)
expect(writes).toEqual([["hello", { destination: "all-available", selection: "clipboard" }]])
})
+2
View File
@@ -23,6 +23,8 @@ test("validates the session tabs setting", () => {
})
expect(() => decode({ tabs: { layout: true } })).toThrow()
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
expect(decode({ prompt: { image_preview: true } })).toEqual({ prompt: { image_preview: true } })
expect(decode({ session: { image_preview: true } })).toEqual({ session: { image_preview: true } })
})
test("resolves nested config and keybind defaults", () => {
+41 -24
View File
@@ -1,8 +1,9 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { afterAll, expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode-ai/client"
import { testRender } from "@opentui/solid"
import { mkdirSync, watch } from "fs"
import { mkdirSync, mkdtempSync, readdirSync, rmSync, watch } from "fs"
import { tmpdir } from "os"
import path from "path"
import { ConfigProvider } from "../../src/config"
import { ClientProvider, useClient } from "../../src/context/client"
@@ -11,10 +12,9 @@ import { RouteProvider, useRoute } from "../../src/context/route"
import { TuiAppProvider } from "../../src/context/runtime"
import { SessionTabsProvider, useSessionTabs } from "../../src/context/session-tabs"
import { NEW_SESSION_TAB_TITLE } from "../../src/context/session-tabs-model"
import { StorageProvider, useStorage } from "../../src/context/storage"
import { StorageProvider } from "../../src/context/storage"
import { createApi, createEventStream, createFetch, directory, json } from "../fixture/tui-client"
import { TestTuiContexts } from "../fixture/tui-environment"
import { tmpdir } from "../fixture/fixture"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
async function wait(fn: () => boolean | Promise<boolean>, timeout = 2_000) {
@@ -25,12 +25,35 @@ async function wait(fn: () => boolean | Promise<boolean>, timeout = 2_000) {
}
}
// State directories are removed after the whole suite instead of per test: persistence writes are
// fire-and-forget behind a file lock, so a teardown-time removal races any still-queued write.
const stateDirs: string[] = []
afterAll(async () => {
for (const dir of stateDirs) {
// Drain any lock still held by a late write before deleting the tree beneath it.
await wait(() => {
try {
return readdirSync(path.join(dir, "test", "locks")).length === 0
} catch {
return true
}
}).catch(() => undefined)
rmSync(dir, { recursive: true, force: true })
}
})
function stateDir(prefix: string) {
const dir = mkdtempSync(path.join(tmpdir(), prefix))
stateDirs.push(dir)
return dir
}
async function renderSessionTabs(
initialSessionID: string,
options?: { state?: string; title?: string; home?: boolean; persisted?: string[]; sessionGate?: Promise<void> },
) {
const temporary = options?.state ? undefined : await tmpdir()
const state = options?.state ?? temporary!.path
const state = options?.state ?? stateDir("opencode-session-tabs-")
if (options?.persisted) {
const file = path.join(state, "test", "tui", "tabs.json")
mkdirSync(path.dirname(file), { recursive: true })
@@ -65,14 +88,12 @@ async function renderSessionTabs(
let route!: ReturnType<typeof useRoute>
let client!: ReturnType<typeof useClient>
let data!: ReturnType<typeof useData>
let storage!: ReturnType<typeof useStorage>
function Probe() {
tabs = useSessionTabs()
route = useRoute()
client = useClient()
data = useData()
storage = useStorage()
return <box />
}
@@ -106,10 +127,8 @@ async function renderSessionTabs(
sessions,
state,
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
async destroy() {
destroy() {
app.renderer.destroy()
await storage.flush()
await temporary?.[Symbol.asyncDispose]()
},
}
}
@@ -130,7 +149,7 @@ test("loads persisted tab metadata concurrently on connect", async () => {
await wait(() => setup.data.session.get("first") !== undefined && setup.data.session.get("second") !== undefined)
} finally {
release()
await setup.destroy()
setup.destroy()
}
})
@@ -140,19 +159,17 @@ test("stores session tabs for the current working directory by default", async (
try {
const file = path.join(setup.state, "test", "tui", "tabs.json")
await wait(() => Bun.file(file).size > 0)
const stored = await Bun.file(file).json()
expect(stored.global).toEqual({ tabs: [], unread: {} })
expect(Object.keys(stored.cwd)).toEqual([directory])
expect(stored.cwd[directory].tabs.map((tab: { sessionID: string }) => tab.sessionID)).toEqual(["first"])
expect(stored.cwd[directory].unread).toEqual({})
expect(await Bun.file(file).json()).toEqual({
global: { tabs: [], unread: {} },
cwd: { [directory]: { tabs: [{ sessionID: "first" }], unread: {} } },
})
} finally {
await setup.destroy()
setup.destroy()
}
})
test("concurrent TUIs do not alternate shared tab titles from divergent session caches", async () => {
await using temporary = await tmpdir()
const state = temporary.path
const state = stateDir("opencode-session-tabs-shared-")
let titled: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
let untitled: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
@@ -189,8 +206,8 @@ test("concurrent TUIs do not alternate shared tab titles from divergent session
expect(observed).toEqual(["Generated title"])
} finally {
if (titled) await titled.destroy()
if (untitled) await untitled.destroy()
titled?.destroy()
untitled?.destroy()
}
})
@@ -238,7 +255,7 @@ test("user prompt admissions pulse an already-busy background tab", async () =>
expect(setup.tabs.status("active").promptPulse).toBe(0)
expect(setup.tabs.status("background")).toMatchObject({ promptPulse: 2, busy: true })
} finally {
await setup.destroy()
setup.destroy()
}
})
@@ -269,6 +286,6 @@ test("tracks a temporary new session tab across close and creation", async () =>
expect(setup.tabs.newTab()).toBe(false)
expect(setup.tabs.tabs().find((tab) => tab.sessionID === "third")?.title).toBe(NEW_SESSION_TAB_TITLE)
} finally {
await setup.destroy()
setup.destroy()
}
})
+12 -1
View File
@@ -7,6 +7,14 @@ import {
} from "../../src/context/runtime"
import type { ParentProps } from "solid-js"
import { LogProvider, type LogSink } from "../../src/context/log"
import { ClipboardProvider, type ClipboardService } from "../../src/context/clipboard"
const clipboard: ClipboardService = {
async read() {
return undefined
},
async write() {},
}
export function TestTuiContexts(
props: ParentProps<{
@@ -14,6 +22,7 @@ export function TestTuiContexts(
directory?: string
paths?: Partial<TuiPaths>
log?: LogSink
clipboard?: ClipboardService
}>,
) {
return (
@@ -28,7 +37,9 @@ export function TestTuiContexts(
}}
>
<TuiTerminalEnvironmentProvider value={{ platform: "linux" }}>
<TuiStartupProvider value={{ skipInitialLoading: false }}>{props.children}</TuiStartupProvider>
<TuiStartupProvider value={{ skipInitialLoading: false }}>
<ClipboardProvider value={props.clipboard ?? clipboard}>{props.children}</ClipboardProvider>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiPathsProvider>
</LogProvider>
+239
View File
@@ -0,0 +1,239 @@
import { afterAll, expect, mock, test } from "bun:test"
import { mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { ImageRenderable, TextareaRenderable, type ClipboardReadResult, type HostClipboardService } from "@opentui/core"
import { createTestRenderer, MouseButtons } from "@opentui/core/testing"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/util/global"
import { Effect, FileSystem } from "effect"
import { createComponent } from "solid-js"
import { Prompt, type PromptRef } from "../../src/component/prompt"
import { createEventStream, createFetch } from "../fixture/tui-client"
const openTui = { ...(await import("@opentui/core")) }
const PNG_1X1_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4AWP4z8DwHwAFAAH/e+m+7wAAAABJRU5ErkJggg=="
const PNG_1X1 = Buffer.from(PNG_1X1_BASE64, "base64")
const readPngClipboard = async (): Promise<ClipboardReadResult> => ({
status: "read",
representation: { mimeType: "image/png", bytes: PNG_1X1 },
})
let activeSetup: Awaited<ReturnType<typeof createTestRenderer>> | undefined
let activeHost: HostClipboardService | undefined
let activePromptRef: PromptRef | undefined
await mock.module("@opentui/core", () => ({
...openTui,
createCliRenderer: async () => {
if (!activeSetup) throw new Error("Prompt renderer is not mounted")
return activeSetup.renderer
},
createHostClipboard: () => {
if (!activeHost) throw new Error("Prompt clipboard is not mounted")
return activeHost
},
}))
await mock.module("../../src/routes/home", () => ({
Home: () =>
createComponent(Prompt, {
ref: (value) => (activePromptRef = value),
showPlaceholder: false,
}),
}))
const { run } = await import("../../src/app")
afterAll(() => mock.restore())
async function mountPrompt(read: () => Promise<ClipboardReadResult>, imagePreview = false) {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
let reads = 0
let ready!: () => void
const mounted = new Promise<void>((resolve) => (ready = resolve))
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
setup.renderer.setTerminalTitle = (title) => {
if (title === "OpenCode") ready()
setTitle(title)
}
const host: HostClipboardService = {
maxWriteBytes: 8 * 1024 * 1024,
async read() {
reads++
return read()
},
async writeText() {
return { status: "written" }
},
async clear() {
return { status: "cleared" }
},
async dispose() {},
}
activeSetup = setup
activeHost = host
activePromptRef = undefined
const events = createEventStream()
const calls = createFetch(undefined, events)
let preloaded!: () => void
const preload = new Promise<void>((resolve) => (preloaded = resolve))
const server = Bun.serve({
port: 0,
fetch: async (request) => {
const response = await calls.fetch(request)
const url = new URL(request.url)
if (url.pathname === "/api/session/active") preloaded()
return response
},
})
let task: Promise<unknown> | undefined
try {
task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: {
get: async () => ({ prompt: { paste: "full" as const, image_preview: imagePreview } }),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
args: {},
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
)
await mounted
await setup.waitFor(() => activePromptRef?.focused === true)
await preload
await Bun.sleep(0)
} catch (error) {
setup.renderer.destroy()
await task?.catch(() => undefined)
await server.stop()
activeSetup = undefined
activeHost = undefined
activePromptRef = undefined
throw error
}
return {
setup,
get input() {
const input = setup.renderer.currentFocusedEditor
if (!(input instanceof TextareaRenderable)) throw new Error("Prompt textarea is not focused")
return input
},
get reads() {
return reads
},
get prompt() {
if (!activePromptRef) throw new Error("Prompt ref is not mounted")
return activePromptRef
},
async dispose() {
activePromptRef?.reset()
setup.renderer.destroy()
await task
await server.stop()
activeSetup = undefined
activeHost = undefined
activePromptRef = undefined
},
}
}
async function pasteImages(prompt: Awaited<ReturnType<typeof mountPrompt>>, count: number) {
for (let index = 0; index < count; index++) {
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
await prompt.setup.waitFor(() => prompt.reads === index + 1)
}
}
test("creates one image mention from PNG clipboard bytes", async () => {
const prompt = await mountPrompt(readPngClipboard)
try {
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
await prompt.setup.waitFor(() => prompt.input.plainText === "[Image 1] ")
expect(prompt.input.plainText).toBe("[Image 1] ")
expect(prompt.input.extmarks.getVirtual()).toHaveLength(1)
expect(prompt.prompt.current.files).toEqual([
{
uri: `data:image/png;base64,${PNG_1X1_BASE64}`,
name: "clipboard",
mention: { start: 0, end: 9, text: "[Image 1]" },
},
])
expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0")).toBeUndefined()
expect(prompt.reads).toBe(1)
} finally {
await prompt.dispose()
}
})
test("renders at most three left-aligned cropped thumbnails", async () => {
const prompt = await mountPrompt(readPngClipboard, true)
try {
await pasteImages(prompt, 4)
const first = prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0")
if (!(first instanceof ImageRenderable)) throw new Error("Image preview did not render")
await first.loadPromise
expect(first.fit).toBe("cover")
expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-1")).toBeInstanceOf(ImageRenderable)
expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-2")).toBeInstanceOf(ImageRenderable)
expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-3")).toBeUndefined()
await prompt.setup.waitForFrame((frame) => frame.includes("+1 more"))
} finally {
await prompt.dispose()
}
})
test("opens image attachments by keyboard and mouse", async () => {
const prompt = await mountPrompt(readPngClipboard, true)
try {
await pasteImages(prompt, 2)
const thumbnail = prompt.setup.renderer.root.findDescendantById("prompt-image-preview-1")
if (!(thumbnail instanceof ImageRenderable)) throw new Error("Second image thumbnail did not render")
prompt.setup.mockInput.pressKey("x", { ctrl: true })
prompt.setup.mockInput.pressKey("i")
await prompt.setup.waitForFrame((frame) => frame.includes("Image 1 of 2"))
prompt.setup.mockInput.pressCtrlC()
await prompt.setup.waitForFrame((frame) => !frame.includes("Image 1 of 2"))
await prompt.setup.mockMouse.click(thumbnail.x, thumbnail.y, MouseButtons.LEFT)
await prompt.setup.waitForFrame((frame) => frame.includes("Image 2 of 2"))
const large = prompt.setup.renderer.root.findDescendantById("prompt-image-viewer-image")
if (!(large instanceof ImageRenderable)) throw new Error("Large image preview did not render")
expect(large.fit).toBe("fit")
expect(large.height).toBeGreaterThan(thumbnail.height)
prompt.setup.mockInput.pressArrow("left")
await prompt.setup.waitForFrame((frame) => frame.includes("Image 1 of 2"))
prompt.setup.mockInput.pressCtrlC()
await prompt.setup.waitForFrame((frame) => !frame.includes("Image 1 of 2"))
await prompt.setup.waitFor(() => prompt.setup.renderer.currentFocusedEditor === prompt.input)
} finally {
await prompt.dispose()
}
})
test("attaches multiple images from one terminal drop", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "opencode-drop-"))
const first = path.join(directory, "one image.png")
const second = path.join(directory, "two image.png")
await Promise.all([writeFile(first, PNG_1X1), writeFile(second, PNG_1X1)])
const prompt = await mountPrompt(async () => ({ status: "empty" }), true)
try {
await prompt.setup.mockInput.pasteBracketedText(`'${first}' '${second}'`)
await prompt.setup.waitFor(() => prompt.prompt.current.files?.length === 2)
expect(prompt.input.plainText).toBe("[Image 1] [Image 2] ")
expect(prompt.prompt.current.files?.map((file) => file.name)).toEqual(["one image.png", "two image.png"])
} finally {
await prompt.dispose()
await rm(directory, { recursive: true, force: true })
}
})
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { readLocalAttachmentWith } from "../../src/component/prompt/local-attachment"
import { parsePastedFilepaths, readLocalAttachmentWith } from "../../src/component/prompt/local-attachment"
import type { LocalFiles } from "../../src/component/prompt/local-attachment"
function files(input: { mime: string; text?: string; bytes?: Uint8Array }): LocalFiles {
@@ -11,6 +11,40 @@ function files(input: { mime: string; text?: string; bytes?: Uint8Array }): Loca
}
describe("prompt local attachments", () => {
test("parses multi-file drops from POSIX, URI-list, and Windows terminals", () => {
expect(parsePastedFilepaths("'/tmp/one image.png' /tmp/two\\ image.webp", "linux")).toEqual([
"/tmp/one image.png",
"/tmp/two image.webp",
])
expect(parsePastedFilepaths("file:///tmp/one%20image.png\r\nfile:///tmp/two.webp", "linux")).toEqual([
"/tmp/one image.png",
"/tmp/two.webp",
])
expect(parsePastedFilepaths("# dropped files\nfile:///tmp/one.png\nfile:///tmp/two.webp", "linux")).toEqual([
"/tmp/one.png",
"/tmp/two.webp",
])
expect(parsePastedFilepaths("/tmp/one\\\\image.png /tmp/two.webp", "linux")).toEqual([
"/tmp/one\\image.png",
"/tmp/two.webp",
])
expect(parsePastedFilepaths('"C:\\one image.png" "C:\\two.webp"', "win32")).toEqual([
"C:\\one image.png",
"C:\\two.webp",
])
expect(parsePastedFilepaths('"/tmp/O\'Brien.png" /tmp/two.webp', "linux")).toEqual([
"/tmp/O'Brien.png",
"/tmp/two.webp",
])
})
test("rejects unbounded and malformed multi-file drops", () => {
expect(parsePastedFilepaths("'/tmp/one.png /tmp/two.png", "linux")).toEqual([])
expect(
parsePastedFilepaths(Array.from({ length: 33 }, (_, index) => `/tmp/${index}.png`).join(" "), "linux"),
).toEqual([])
})
test("reads SVG attachments as text", async () => {
expect(await readLocalAttachmentWith(files({ mime: "image/svg+xml", text: "<svg />" }), "/tmp/image.svg")).toEqual({
type: "text",
@@ -39,5 +73,8 @@ describe("prompt local attachments", () => {
"/tmp/missing.png",
),
).toBeUndefined()
expect(
await readLocalAttachmentWith(files({ mime: "image/png", bytes: new Uint8Array(2) }), "/tmp/large.png", 1),
).toBeUndefined()
})
})
+7 -15
View File
@@ -20,25 +20,17 @@ export function has(content: Uint8Array) {
return content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf
}
export function decodeBytes(content: Uint8Array) {
return split(decode(content))
}
export function syncBytes(content: Uint8Array, bom: boolean) {
const decoded = decode(content)
const current = split(decoded)
const canonical = join(current.text, bom)
return { text: current.text, bytes: decoded === canonical ? undefined : new TextEncoder().encode(canonical) }
}
export const readFile = Effect.fn("Bom.readFile")(function* (fs: FSUtil.Interface, filepath: string) {
return decodeBytes(yield* fs.readFile(filepath))
return split(decode(yield* fs.readFile(filepath)))
})
export const syncFile = Effect.fn("Bom.syncFile")(function* (fs: FSUtil.Interface, filepath: string, bom: boolean) {
const synced = syncBytes(yield* fs.readFile(filepath), bom)
if (synced.bytes) yield* fs.writeWithDirs(filepath, synced.bytes)
return synced.text
const decoded = decode(yield* fs.readFile(filepath))
const current = split(decoded)
const canonical = join(current.text, bom)
if (decoded === canonical) return current.text
yield* fs.writeWithDirs(filepath, canonical)
return current.text
})
function decode(content: Uint8Array) {