Compare commits

..

32 Commits

Author SHA1 Message Date
Deven Navani ceb4890ca3 docs: add Modal provider setup (#39710) 2026-07-30 13:29:32 -05:00
opencode-agent[bot] 3f239b3834 chore: update nix node_modules hashes 2026-07-30 16:46:38 +00:00
Aiden Cline c1ee3c6e34 fix(opencode): stop MCP SSE error reconnect loops (#39697) 2026-07-30 11:26:48 -05:00
opencode-agent[bot] a1ab489e61 fix(provider): widen interleaved reasoning fields (#39556)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-30 11:10:34 -05:00
opencode 8c38d260eb sync release versions for v1.18.10 2026-07-30 14:43:50 +00:00
opencode-agent[bot] a4f25a94b4 fix: use OpenCode model catalog URL (#39672)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-30 10:12:08 -04:00
opencode-agent[bot] ff0382e971 chore: generate 2026-07-30 09:31:27 +00:00
OpeOginni ac5380614f fix(app): repair malformed persisted tabs (#39645) 2026-07-30 17:29:40 +08:00
opencode-agent[bot] c08350b83e chore: update nix node_modules hashes 2026-07-30 09:11:45 +00:00
Aarav Sareen c5cf416633 feat(app): fix composer attachment fades (#38547) 2026-07-30 17:07:35 +08:00
Aarav Sareen 3dbce3dade feat(app): adds fades to file tree (#38549) 2026-07-30 17:07:06 +08:00
opencode-agent[bot] a277557a5f chore: generate 2026-07-30 09:05:12 +00:00
Aarav Sareen 005bf088c9 feat(app): fix file tree button bg (#38629) 2026-07-30 17:03:23 +08:00
Aarav Sareen eca5e68a5e feat(app): disallow duplicate attachments (#39464) 2026-07-30 17:02:15 +08:00
Aarav Sareen 5b11635d97 feat(app): always show new session button (#39520)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-30 16:58:38 +08:00
Aarav Sareen a9eda2e99e feat(app): migrate to solid-sonner (#39519)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-30 08:57:19 +00:00
Aarav Sareen 73009f0b09 feat(app): update arrow buttons in review tab top bar (#39469)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-30 16:56:16 +08:00
Frank f720490219 fix(console): add public fetch compatibility flag 2026-07-30 00:40:52 -04:00
Deven Navani 341c64cc97 feat: discover Modal models (#39066) 2026-07-29 23:06:19 -05:00
opencode-agent[bot] 1e17856ba4 chore: update nix node_modules hashes 2026-07-29 18:06:26 +00:00
Lakshya Agarwal a6f7fe739c docs(ecosystem): add opencode-tavily plugin (#38709) 2026-07-29 12:52:12 -05:00
Vladimir Glafirov 02f50a6e80 chore: bump gitlab-ai-provider to 6.12.1 (#39490) 2026-07-29 12:45:40 -05:00
opencode-agent[bot] 7565e03536 chore: generate 2026-07-29 08:36:03 +00:00
usrnk1 035a5a1a67 feat(desktop): refine tab states (#39472) 2026-07-29 08:34:37 +00:00
Brendan Allan e8b0992788 fix(app): defer model variant selector (#39445) 2026-07-29 05:16:30 +00:00
opencode-agent[bot] 8cbea4fbb7 chore: generate 2026-07-29 01:26:18 +00:00
Brendan Allan f256a4c538 fix(app): preserve agent picker for existing users (#39300) 2026-07-29 01:24:31 +00:00
opencode a45c2b917e sync release versions for v1.18.9 2026-07-28 18:14:59 +00:00
opencode-agent[bot] f28d72d15e chore: update nix node_modules hashes 2026-07-28 17:12:42 +00:00
opencode-agent[bot] d8a9d8a76d chore: generate 2026-07-28 16:55:30 +00:00
Aiden Cline 982a9044c5 fix(mcp): restore legacy SDK compatibility (#39373) 2026-07-28 11:53:35 -05:00
opencode-agent[bot] 7edefb3347 chore(mcp): upgrade client to 2.0.0 (#39369)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-07-28 11:37:58 -05:00
116 changed files with 2515 additions and 1123 deletions
+102 -49
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.18.8",
"version": "1.18.10",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -96,7 +96,7 @@
},
"packages/cli": {
"name": "@opencode-ai/cli",
"version": "1.18.8",
"version": "1.18.10",
"bin": {
"lildax": "./bin/lildax.cjs",
},
@@ -144,7 +144,7 @@
},
"packages/codemode": {
"name": "@opencode-ai/codemode",
"version": "1.18.8",
"version": "1.18.10",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -158,7 +158,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.18.8",
"version": "1.18.10",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -194,7 +194,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.18.8",
"version": "1.18.10",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -221,7 +221,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.18.8",
"version": "1.18.10",
"dependencies": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
@@ -243,7 +243,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.18.8",
"version": "1.18.10",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -267,7 +267,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "1.18.8",
"version": "1.18.10",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@@ -287,7 +287,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.18.8",
"version": "1.18.10",
"bin": {
"opencode": "./bin/opencode",
},
@@ -339,7 +339,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.12.0",
"gitlab-ai-provider": "6.12.1",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -381,7 +381,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.18.8",
"version": "1.18.10",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"effect": "catalog:",
@@ -435,7 +435,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.18.8",
"version": "1.18.10",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -449,7 +449,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.18.8",
"version": "1.18.10",
"dependencies": {
"effect": "catalog:",
},
@@ -461,7 +461,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.18.8",
"version": "1.18.10",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -493,7 +493,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.18.8",
"version": "1.18.10",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -509,7 +509,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.18.8",
"version": "1.18.10",
"dependencies": {
"@effect/platform-node": "4.0.0-beta.83",
"@effect/platform-node-shared": "4.0.0-beta.83",
@@ -540,7 +540,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.18.8",
"version": "1.18.10",
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14",
@@ -559,7 +559,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.18.8",
"version": "1.18.10",
"bin": {
"opencode": "./bin/opencode",
},
@@ -592,7 +592,7 @@
"@effect/platform-node": "catalog:",
"@ff-labs/fff-bun": "0.9.4",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@modelcontextprotocol/client": "2.0.0-beta.5",
"@modelcontextprotocol/sdk": "1.29.0",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
@@ -632,7 +632,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.12.0",
"gitlab-ai-provider": "6.12.1",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -666,7 +666,6 @@
},
"devDependencies": {
"@babel/core": "7.28.4",
"@modelcontextprotocol/server": "2.0.0-beta.5",
"@octokit/webhooks-types": "7.6.1",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/http-recorder": "workspace:*",
@@ -691,7 +690,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.18.8",
"version": "1.18.10",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/sdk": "workspace:*",
@@ -767,7 +766,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.18.8",
"version": "1.18.10",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -782,7 +781,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.18.8",
"version": "1.18.10",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
@@ -797,7 +796,7 @@
},
"packages/session-ui": {
"name": "@opencode-ai/session-ui",
"version": "1.18.8",
"version": "1.18.10",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz",
@@ -842,7 +841,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.18.8",
"version": "1.18.10",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -855,7 +854,7 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.18.8",
"version": "1.18.10",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -889,7 +888,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.18.8",
"version": "1.18.10",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -908,7 +907,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.18.8",
"version": "1.18.10",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -950,7 +949,7 @@
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "1.18.8",
"version": "1.18.10",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -977,7 +976,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.18.8",
"version": "1.18.10",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -1003,6 +1002,7 @@
"remend": "catalog:",
"shiki": "catalog:",
"solid-list": "catalog:",
"solid-sonner": "catalog:",
"strip-ansi": "7.1.2",
},
"devDependencies": {
@@ -1028,7 +1028,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.18.8",
"version": "1.18.10",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1071,9 +1071,9 @@
],
"patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/client@2.0.0-beta.5": "patches/@modelcontextprotocol%2Fclient@2.0.0-beta.5.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
@@ -1147,6 +1147,7 @@
"shiki": "4.2.0",
"solid-js": "1.9.10",
"solid-list": "0.3.0",
"solid-sonner": "0.3.1",
"sst": "4.13.1",
"tailwindcss": "4.1.11",
"typescript": "5.8.2",
@@ -1676,6 +1677,8 @@
"@hey-api/types": ["@hey-api/types@0.1.2", "", {}, "sha512-uNNtiVAWL7XNrV/tFXx7GLY9lwaaDazx1173cGW3+UEaw4RUPsHEmiB4DSpcjNxMIcrctfz2sGKLnVx5PBG2RA=="],
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
"@hono/standard-validator": ["@hono/standard-validator@0.2.0", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-pFq0UVAnjzXcDAgqFpDeVL3MOUPrlIh/kPqBDvbCYoThVhhS+Vf37VcdsakdOFFGiqoiYVxp3LifXFhGhp/rgQ=="],
"@ibm/plex": ["@ibm/plex@6.4.1", "", { "dependencies": { "@ibm/telemetry-js": "^1.5.1" } }, "sha512-fnsipQywHt3zWvsnlyYKMikcVI7E2fEwpiPnIHFqlbByXVfQfANAAeJk1IV4mNnxhppUIDlhU0TzwYwL++Rn2g=="],
@@ -1830,11 +1833,7 @@
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
"@modelcontextprotocol/client": ["@modelcontextprotocol/client@2.0.0-beta.5", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0-beta.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "jose": "^6.1.3", "pkce-challenge": "^5.0.0", "zod": "^4.2.0" } }, "sha512-YuuNm5f2TMoFQRje1UqVP8TJRjijCXMz4ckvoVpx1cUXuBEmykWQ2d8R536pek6UKcXT41T5nWc4qR1JFIbEmg=="],
"@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.0.0-beta.5", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-HKbY9XTbsDy1Y6r2I55TGE3JEapM0vg96e1MUmBIF9LGjos5gjhcIrTz1yvBPLg2aFKHjwhUAQfRdrCEnPxNew=="],
"@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.0.0-beta.5", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0-beta.5", "zod": "^4.2.0" } }, "sha512-i1E5l75rQKsgY/AKAIspgMBH1vEL7dqiK7tHr0L+raYcb0SWOziqNGJXGIG6NY4AlXDWIKGJQGB7Nqfs3oUi5g=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="],
@@ -3394,7 +3393,7 @@
"content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="],
"content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
@@ -3406,6 +3405,8 @@
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"crc": ["crc@3.8.0", "", { "dependencies": { "buffer": "^5.1.0" } }, "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ=="],
"crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
@@ -3710,6 +3711,8 @@
"express": ["express@4.22.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q=="],
"express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="],
"expressive-code": ["expressive-code@0.41.7", "", { "dependencies": { "@expressive-code/core": "^0.41.7", "@expressive-code/plugin-frames": "^0.41.7", "@expressive-code/plugin-shiki": "^0.41.7", "@expressive-code/plugin-text-markers": "^0.41.7" } }, "sha512-2wZjC8OQ3TaVEMcBtYY4Va3lo6J+Ai9jf3d4dbhURMJcU4Pbqe6EcHe424MIZI0VHUA1bR6xdpoHYi3yxokWqA=="],
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
@@ -3854,7 +3857,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.12.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-uGan7MQaNfmV5SH9wiMJkZ7D5QxM4KVPRcjIHtPCM5icZjgXl5mrHMjr3/e7TAHa9OjEJKT0a2FxzPgwRtN3PQ=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.12.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-Qn5iHqvjG8yktI5MWaUgdRR94l7O4WtYW0CAbhsCh1Tj0Fei/DeprOYPVyf4Nht1Ix6U2PXSYM32QOHI6Z2TDw=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@@ -4094,6 +4097,8 @@
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="],
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
@@ -5002,6 +5007,8 @@
"rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
@@ -5122,6 +5129,8 @@
"solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="],
"solid-sonner": ["solid-sonner@0.3.1", "", { "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-F/+zi9yKJTHh5hX1UGJfkDvyC+F34Vi3jgy44NJwOKCgic1QtAon0b1iT9OsDO77RTgR+PCil+3Y5B8T2Owy1Q=="],
"solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="],
"solid-transition-size": ["solid-transition-size@0.1.4", "", { "dependencies": { "@corvu/utils": "~0.3.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ocHVnbfy23CgfaH4cEUR/AFg0Y3CEL8Oh3n9Qv8OHFJgPh+zkmERKZQfi/xH5XvxDCizg8VjPrVUhiHB1Gza8g=="],
@@ -5950,13 +5959,15 @@
"@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"@modelcontextprotocol/client/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
"@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"@modelcontextprotocol/client/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@modelcontextprotocol/sdk/hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="],
"@modelcontextprotocol/core/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@modelcontextprotocol/sdk/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
"@modelcontextprotocol/server/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"@modelcontextprotocol/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@npmcli/config/ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="],
@@ -6232,8 +6243,6 @@
"babel-plugin-module-resolver/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
"body-parser/content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
@@ -6316,8 +6325,6 @@
"execa/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="],
"express/content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
@@ -6466,6 +6473,8 @@
"roarr/sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
"router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
@@ -6794,28 +6803,58 @@
"@malept/flatpak-bundler/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"@modelcontextprotocol/sdk/express/content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
"@modelcontextprotocol/sdk/express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"@modelcontextprotocol/sdk/express/cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"@modelcontextprotocol/sdk/express/finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"@modelcontextprotocol/sdk/express/fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"@modelcontextprotocol/sdk/express/merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"@modelcontextprotocol/sdk/express/send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
"@modelcontextprotocol/sdk/express/serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"@modelcontextprotocol/sdk/express/type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
"@octokit/auth-app/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
"@octokit/auth-app/@octokit/request/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
"@octokit/auth-app/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"@octokit/auth-app/@octokit/request-error/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
"@octokit/auth-oauth-app/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
"@octokit/auth-oauth-app/@octokit/request/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="],
"@octokit/auth-oauth-app/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"@octokit/auth-oauth-app/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
"@octokit/auth-oauth-device/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
"@octokit/auth-oauth-device/@octokit/request/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="],
"@octokit/auth-oauth-device/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"@octokit/auth-oauth-device/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
"@octokit/auth-oauth-user/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
"@octokit/auth-oauth-user/@octokit/request/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="],
"@octokit/auth-oauth-user/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"@octokit/auth-oauth-user/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
"@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="],
@@ -6828,10 +6867,14 @@
"@octokit/graphql/@octokit/request/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
"@octokit/graphql/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"@octokit/graphql/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@26.0.0", "", {}, "sha512-7AtcfKtpo77j7Ts73b4OWhOZHTKo/gGY8bB3bNBQz4H+GRSWqx2yvj8TXRsbdTE0eRmYmXOEY66jM7mJ7LzfsA=="],
"@octokit/oauth-methods/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
"@octokit/oauth-methods/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"@octokit/oauth-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
"@octokit/plugin-paginate-rest/@octokit/core/@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="],
@@ -7340,6 +7383,10 @@
"@jsx-email/cli/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
"@modelcontextprotocol/sdk/express/type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"@octokit/auth-app/@octokit/request-error/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
"@octokit/auth-app/@octokit/request/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
@@ -7348,14 +7395,20 @@
"@octokit/plugin-paginate-rest/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
"@octokit/plugin-paginate-rest/@octokit/core/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"@octokit/plugin-paginate-rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
"@octokit/plugin-rest-endpoint-methods/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
"@octokit/plugin-rest-endpoint-methods/@octokit/core/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"@octokit/plugin-rest-endpoint-methods/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
"@octokit/rest/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
"@octokit/rest/@octokit/core/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
"@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+6 -2
View File
@@ -288,8 +288,12 @@ new sst.cloudflare.x.SolidStart("Console", {
server: {
placement: { region: "aws:us-east-2" },
transform: {
worker: {
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
worker: (args) => {
args.compatibilityFlags = $resolve(args.compatibilityFlags).apply((flags) => [
...(flags ?? []),
"global_fetch_strictly_public",
])
args.tailConsumers = [{ service: logProcessor.nodes.worker.scriptName }]
},
},
},
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-wLjPbbeOF9OUNNGceYtEJNiBtF6TPjX/lnyMkdW170c=",
"aarch64-linux": "sha256-P7hrfVp3pILY/uAOdJweGg6IcXBLuU69yqko1qd1caw=",
"aarch64-darwin": "sha256-MJzCshCjkbypnW3yhmR8/zCadIopcHUfVT293N0FMY8=",
"x86_64-darwin": "sha256-6fR137gR66eI+VyvDkR2U51yoJ9BuvaK4YoMw+3E2A0="
"x86_64-linux": "sha256-AD5kt1o035hdvYPyrvjiz2mcsXFJ7UJVEmbE9AJFDFs=",
"aarch64-linux": "sha256-f1gVXUpIRWC/I8dxIigdGGvPsju04MXVjZhtq5EYL64=",
"aarch64-darwin": "sha256-c+JRpjs2gsgQ2czyM6bTbYEdMbX/erOSQdX6TdrI59k=",
"x86_64-darwin": "sha256-iB7l4PtQQ9Mx/XAdtyayiLe6pZKhuXB9lpmrbTr+uWc="
}
}
+3 -2
View File
@@ -90,6 +90,7 @@
"@sentry/solid": "10.36.0",
"@sentry/vite-plugin": "4.6.0",
"solid-js": "1.9.10",
"solid-sonner": "0.3.1",
"vite-plugin-solid": "2.11.10",
"@lydell/node-pty": "1.2.0-beta.12"
}
@@ -155,8 +156,8 @@
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@modelcontextprotocol/client@2.0.0-beta.5": "patches/@modelcontextprotocol%2Fclient@2.0.0-beta.5.patch"
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
}
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.18.8",
"version": "1.18.10",
"description": "",
"type": "module",
"exports": {
@@ -19,9 +19,9 @@
"build": "vite build",
"serve": "vite preview",
"test": "bun run test:unit && bun run test:browser",
"test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
"test:unit": "bun test --conditions=solid --only-failures --preload ./happydom.ts ./src",
"test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test",
"test:e2e:local": "playwright test",
"test:e2e:ui": "playwright test --ui",
@@ -55,6 +55,7 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
controller={props.controller}
borderUnderlay={props.borderUnderlay}
class={props.class}
variantControlVisible={!props.controller.model.loading}
attachKeybind={command.keybindParts("file.attach")}
attachShortcut={command.keybind("file.attach")}
modelControl={
@@ -367,6 +368,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
title: language.t("prompt.toast.pasteUnsupported.title"),
description: language.t("prompt.toast.pasteUnsupported.description"),
}),
duplicate: () => showToast({ title: language.t("prompt.toast.attachmentDuplicate.title") }),
onError: (error) =>
showToast({
variant: "error",
@@ -9,53 +9,86 @@
justify-content: center;
}
[data-titlebar-tab] {
--tab-base: var(--v2-background-bg-deep);
--tab-overlay: transparent;
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
}
[data-titlebar-tab]:is(:hover, :has(> [data-slot="tab-link"]:focus-visible)):not([data-state="pressed"]):not(
[data-dragging="true"]
):not([data-editing="true"]) {
--tab-base: var(--v2-background-bg-layer-02);
--tab-overlay: var(--v2-overlay-simple-overlay-hover);
}
[data-titlebar-tab]:is([data-state="pressed"], [data-dragging="true"], [data-editing="true"]) {
--tab-base: var(--v2-background-bg-layer-02);
--tab-overlay: var(--v2-overlay-simple-overlay-pressed);
}
[data-titlebar-tab]:is(:hover, [data-state="pressed"]) [data-slot="tab-close"] {
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
}
[data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] {
display: none;
}
[data-titlebar-tab-list] {
gap: 13.5px;
gap: 6px;
}
[data-titlebar-tab-slot] {
--tab-separator: var(--v2-background-bg-layer-03);
position: relative;
}
[data-color-scheme="dark"] [data-titlebar-tab-slot] {
--tab-separator: var(--v2-background-bg-layer-02);
}
[data-titlebar-tab-slot]:not(:first-child):not([data-active="true"])::before {
content: "";
position: absolute;
top: 8px;
left: -6.75px;
left: -3.75px;
width: 1.5px;
height: 12px;
border-radius: 9999px;
background: var(--v2-background-bg-layer-02);
background: var(--tab-separator);
}
[data-titlebar-tab-slot][data-active="true"] + [data-titlebar-tab-slot]::before {
display: none;
}
[data-titlebar-tab] [data-slot="tab-close"]::before {
content: "";
position: absolute;
top: 0;
height: 28px;
width: 16px;
pointer-events: none;
background: linear-gradient(to right, transparent, var(--tab-bg));
[data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover)::before,
[data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover) + [data-titlebar-tab-slot]::before {
display: none;
}
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-close"]::before {
display: block;
right: 0;
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"] {
--tab-title-fade-offset: 4px;
-webkit-mask-image: linear-gradient(
to right,
black 0,
black calc(100% - var(--tab-title-fade-offset) - 16px),
transparent calc(100% - var(--tab-title-fade-offset)),
transparent 100%
);
mask-image: linear-gradient(
to right,
black 0,
black calc(100% - var(--tab-title-fade-offset) - 16px),
transparent calc(100% - var(--tab-title-fade-offset)),
transparent 100%
);
}
[data-titlebar-tab][data-title-overflow="true"]:hover:not([data-editing="true"]) [data-slot="tab-close"]::before,
[data-titlebar-tab][data-title-overflow="true"][data-active="true"]:not([data-editing="true"])
[data-slot="tab-close"]::before {
right: 100%;
[data-titlebar-tab][data-title-overflow="true"]:is(:hover, [data-active="true"]):not([data-editing="true"])
[data-slot="tab-link"] {
--tab-title-fade-offset: 24px;
}
[data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"])
@@ -180,11 +180,11 @@ export function TabNavItem(props: {
data-slot="titlebar-tab-item"
data-title-overflow={titleOverflowing()}
data-editing={editing()}
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] px-1.5 [container-type:inline-size]"
classList={{ invisible: props.hidden }}
data-active={props.active}
data-dragging={props.dragging}
data-pressed={props.pressed}
data-state={props.active || props.pressed ? "pressed" : undefined}
onMouseDown={(event) => {
if (event.button !== MIDDLE_MOUSE_BUTTON) return
event.preventDefault()
@@ -276,7 +276,7 @@ export function TabNavItem(props: {
/>
</a>
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
<div data-slot="tab-close">
<IconButtonV2
size="small"
variant="ghost-muted"
@@ -334,8 +334,8 @@ export function DraftTabItem(props: {
data-slot="titlebar-tab-item"
data-active={props.active}
data-dragging={props.dragging}
data-pressed={props.pressed}
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
data-state={props.active || props.pressed ? "pressed" : undefined}
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] px-1.5 [container-type:inline-size] whitespace-nowrap"
classList={{ invisible: props.hidden }}
onMouseDown={(event) => {
if (event.button !== MIDDLE_MOUSE_BUTTON) return
@@ -381,7 +381,7 @@ export function DraftTabItem(props: {
{props.title}
</span>
</a>
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
<div data-slot="tab-close">
<IconButtonV2
size="small"
variant="ghost-muted"
+19 -21
View File
@@ -395,27 +395,25 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
<Show when={!creating()}>
<TooltipV2
placement="bottom"
value={
<>
{language.t("command.session.new")}
<KeybindV2 keys={newTabTooltipKeybind(command)} variant="neutral" />
</>
}
>
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="shrink-0"
icon={<IconV2 name="plus" />}
onClick={openNewTab}
aria-label={language.t("command.session.new")}
/>
</TooltipV2>
</Show>
<TooltipV2
placement="bottom"
value={
<>
{language.t("command.session.new")}
<KeybindV2 keys={newTabTooltipKeybind(command)} variant="neutral" />
</>
}
>
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="shrink-0"
icon={<IconV2 name="plus" />}
onClick={openNewTab}
aria-label={language.t("command.session.new")}
/>
</TooltipV2>
<div class="flex-1" />
<TitlebarV2Right state={v2RightState()} />
</div>
+17
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import {
hasExistingWebState,
initialAgentVisibility,
isAppUpgrade,
layoutTransitionState,
maximumSunsetTimeout,
@@ -11,6 +12,22 @@ import {
shouldEnableNewLayout,
} from "./settings"
describe("agent visibility", () => {
test("shows the picker for existing profiles and hides it for first-time installs", () => {
expect(initialAgentVisibility(undefined, true)).toBe(true)
expect(initialAgentVisibility(undefined, false)).toBe(false)
})
test("shows the picker when updating from a recent release", () => {
expect(initialAgentVisibility(undefined, false, "1.18.8")).toBe(true)
})
test("preserves the preference after initialization", () => {
expect(initialAgentVisibility(true, true, "1.18.8")).toBeUndefined()
expect(initialAgentVisibility(true, false)).toBeUndefined()
})
})
describe("layout transition", () => {
test("blank profiles default to the new layout", () => {
expect(newLayoutDesignsDefault).toBe(true)
+19 -3
View File
@@ -1,5 +1,5 @@
import { createStore, reconcile } from "solid-js/store"
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { persisted } from "@/utils/persist"
import { usePlatform } from "@/context/platform"
@@ -36,6 +36,7 @@ export interface Settings {
mobileTitlebarPosition: "top" | "bottom"
newLayoutDesigns?: boolean
layoutTransitionEligible?: boolean
agentVisibilityInitialized?: boolean
newInterfaceNoticeDismissed?: boolean
shouldDisplayTabsToast?: boolean
}
@@ -93,6 +94,11 @@ export function hasExistingWebState(settings: Promise<string> | string | null, p
return settings !== null || previousVersion !== undefined
}
export function initialAgentVisibility(initialized: boolean | undefined, existing: boolean, previousVersion?: string) {
if (initialized === true) return
return existing || previousVersion !== undefined
}
export function shouldEnableNewLayout(previous: string | undefined, current: string | undefined) {
if (!current) return false
const currentComparison = compareVersions(current, newLayoutDesignsUpgradeCutoff)
@@ -271,6 +277,14 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
)
})
const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
const initializeAgentVisibility = (existing: boolean) => {
const initial = initialAgentVisibility(store.general?.agentVisibilityInitialized, existing, launchState.previous)
if (initial === undefined) return
batch(() => {
setStore("general", "showCustomAgents", initial)
setStore("general", "agentVisibilityInitialized", true)
})
}
if (sunset && !oldInterfaceRetired()) {
const timeout = { current: undefined as ReturnType<typeof setTimeout> | undefined }
@@ -299,8 +313,9 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
createEffect(() => {
if (!ready() || !launchState.classified || platform.platform !== "web") return
if (layoutTransitionClassified()) return
setStore("general", "layoutTransitionEligible", hasExistingWebState(settingsInit, launchState.previous))
const existing = hasExistingWebState(settingsInit, launchState.previous)
if (!layoutTransitionClassified()) setStore("general", "layoutTransitionEligible", existing)
initializeAgentVisibility(existing)
})
createEffect(() => {
@@ -426,6 +441,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
if (typeof current === "boolean") return
setStore("general", "layoutTransitionEligible", eligible)
},
initializeAgentVisibility,
layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available),
newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice),
dismissNewInterfaceNotice() {
+23
View File
@@ -0,0 +1,23 @@
import type { ServerConnection } from "./server"
import type { Tab } from "./tabs"
export function migrateTabs(value: unknown, fallback: ServerConnection.Key): Tab[] {
if (!Array.isArray(value)) return []
return value.flatMap<Tab>((tab) => {
if (!tab || typeof tab !== "object") return []
if ("server" in tab && typeof tab.server !== "string") return []
const server = ("server" in tab ? tab.server : fallback) as ServerConnection.Key
if (tab.type === "session" && typeof tab.sessionId === "string") {
return [{ type: tab.type, server, sessionId: tab.sessionId }]
}
if (
tab.type === "draft" &&
typeof tab.draftID === "string" &&
typeof tab.directory === "string" &&
(tab.worktree === undefined || typeof tab.worktree === "string")
) {
return [{ type: tab.type, server, draftID: tab.draftID, directory: tab.directory, worktree: tab.worktree }]
}
return []
})
}
+18
View File
@@ -3,6 +3,7 @@ import { createRoot, getOwner, onCleanup } from "solid-js"
import { createTabMemory } from "./tab-memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
import type { SessionTab, Tab } from "./tabs"
import { migrateTabs } from "./tab-migration"
import type { ServerConnection } from "./server"
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
@@ -11,6 +12,23 @@ function sessionTab(sessionId: string): SessionTab {
return { type: "session", server, sessionId }
}
describe("tab migration", () => {
test("drops null and malformed persisted tabs", () => {
expect(
migrateTabs([null, sessionTab("a"), { type: "session", server }, { type: "unknown", server }, "invalid"], server),
).toEqual([sessionTab("a")])
})
test("adds the fallback server to valid legacy tabs", () => {
expect(migrateTabs([{ type: "session", sessionId: "a", dirBase64: "legacy" }], server)).toEqual([sessionTab("a")])
})
test("replaces invalid top-level persisted data", () => {
expect(migrateTabs(null, server)).toEqual([])
expect(migrateTabs({}, server)).toEqual([])
})
})
describe("tab memory", () => {
test("keeps state until its tab is removed", () => {
createRoot((dispose) => {
+2 -7
View File
@@ -12,6 +12,7 @@ import { sessionHref } from "@/utils/session-route"
import { createTabMemory } from "./tab-memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
import { createDraftPromptSession, type PromptModel } from "./prompt-state"
import { migrateTabs } from "./tab-migration"
export type SessionTab = {
type: "session"
@@ -59,13 +60,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const [store, setStore, _, ready] = persisted(
{
...Persist.window("tabs"),
migrate: (value: unknown) => {
if (!Array.isArray(value)) return value
return value.map((tab) => {
if (!tab || typeof tab !== "object" || "server" in tab) return tab
return { ...tab, server: fallback }
})
},
migrate: (value: unknown) => migrateTabs(value, fallback),
},
createStore<Tab[]>([]),
)
+1
View File
@@ -277,6 +277,7 @@ export const dict = {
"prompt.action.send": "إرسال",
"prompt.action.stop": "توقف",
"prompt.toast.pasteUnsupported.title": "مرفق غير مدعوم",
"prompt.toast.attachmentDuplicate.title": "تم تحميل هذا الملف بالفعل",
"prompt.toast.pasteUnsupported.description": "يمكن إرفاق الصور أو ملفات PDF أو الملفات النصية فقط هنا.",
"prompt.toast.modelAgentRequired.title": "حدد وكيلاً ونموذجاً",
"prompt.toast.modelAgentRequired.description": "اختر وكيلاً ونموذجاً قبل إرسال الموجه.",
+1
View File
@@ -277,6 +277,7 @@ export const dict = {
"prompt.action.send": "Enviar",
"prompt.action.stop": "Parar",
"prompt.toast.pasteUnsupported.title": "Anexo não suportado",
"prompt.toast.attachmentDuplicate.title": "Este arquivo já foi enviado",
"prompt.toast.pasteUnsupported.description": "Apenas imagens, PDFs ou arquivos de texto podem ser anexados aqui.",
"prompt.toast.modelAgentRequired.title": "Selecione um agente e modelo",
"prompt.toast.modelAgentRequired.description": "Escolha um agente e modelo antes de enviar um prompt.",
+1
View File
@@ -298,6 +298,7 @@ export const dict = {
"prompt.action.stop": "Zaustavi",
"prompt.toast.pasteUnsupported.title": "Nepodržan prilog",
"prompt.toast.attachmentDuplicate.title": "Ova datoteka je već učitana",
"prompt.toast.pasteUnsupported.description": "Ovdje se mogu priložiti samo slike, PDF-ovi ili tekstualne datoteke.",
"prompt.toast.modelAgentRequired.title": "Odaberi agenta i model",
"prompt.toast.modelAgentRequired.description": "Odaberi agenta i model prije slanja upita.",
+1
View File
@@ -296,6 +296,7 @@ export const dict = {
"prompt.action.stop": "Stop",
"prompt.toast.pasteUnsupported.title": "Ikke understøttet vedhæftning",
"prompt.toast.attachmentDuplicate.title": "Denne fil er allerede uploadet",
"prompt.toast.pasteUnsupported.description": "Kun billeder, PDF'er eller tekstfiler kan vedhæftes her.",
"prompt.toast.modelAgentRequired.title": "Vælg en agent og model",
"prompt.toast.modelAgentRequired.description": "Vælg en agent og model før du sender en forespørgsel.",
+1
View File
@@ -282,6 +282,7 @@ export const dict = {
"prompt.action.send": "Senden",
"prompt.action.stop": "Stopp",
"prompt.toast.pasteUnsupported.title": "Nicht unterstützter Anhang",
"prompt.toast.attachmentDuplicate.title": "Diese Datei wurde bereits hochgeladen",
"prompt.toast.pasteUnsupported.description": "Hier können nur Bilder, PDFs oder Textdateien angehängt werden.",
"prompt.toast.modelAgentRequired.title": "Wählen Sie einen Agenten und ein Modell",
"prompt.toast.modelAgentRequired.description":
+1
View File
@@ -301,6 +301,7 @@ export const dict = {
"prompt.toast.pasteUnsupported.title": "Unsupported attachment",
"prompt.toast.pasteUnsupported.description": "Only images, PDFs, or text files can be attached here.",
"prompt.toast.attachmentDuplicate.title": "This file has already been uploaded",
"prompt.toast.modelAgentRequired.title": "Select an agent and model",
"prompt.toast.modelAgentRequired.description": "Choose an agent and model before sending a prompt.",
"prompt.toast.worktreeCreateFailed.title": "Failed to create worktree",
+1
View File
@@ -297,6 +297,7 @@ export const dict = {
"prompt.action.stop": "Detener",
"prompt.toast.pasteUnsupported.title": "Adjunto no compatible",
"prompt.toast.attachmentDuplicate.title": "Este archivo ya se ha subido",
"prompt.toast.pasteUnsupported.description": "Solo se pueden adjuntar imágenes, PDFs o archivos de texto aquí.",
"prompt.toast.modelAgentRequired.title": "Selecciona un agente y modelo",
"prompt.toast.modelAgentRequired.description": "Elige un agente y modelo antes de enviar un prompt.",
+1
View File
@@ -277,6 +277,7 @@ export const dict = {
"prompt.action.send": "Envoyer",
"prompt.action.stop": "Arrêter",
"prompt.toast.pasteUnsupported.title": "Pièce jointe non prise en charge",
"prompt.toast.attachmentDuplicate.title": "Ce fichier a déjà été téléversé",
"prompt.toast.pasteUnsupported.description":
"Seules les images, les PDF ou les fichiers texte peuvent être joints ici.",
"prompt.toast.modelAgentRequired.title": "Sélectionnez un agent et un modèle",
+1
View File
@@ -276,6 +276,7 @@ export const dict = {
"prompt.action.send": "送信",
"prompt.action.stop": "停止",
"prompt.toast.pasteUnsupported.title": "サポートされていない添付ファイル",
"prompt.toast.attachmentDuplicate.title": "このファイルはすでにアップロードされています",
"prompt.toast.pasteUnsupported.description": "画像、PDF、またはテキストファイルのみ添付できます。",
"prompt.toast.modelAgentRequired.title": "エージェントとモデルを選択",
"prompt.toast.modelAgentRequired.description": "プロンプトを送信する前にエージェントとモデルを選択してください。",
+1
View File
@@ -264,6 +264,7 @@ export const dict = {
"prompt.action.send": "전송",
"prompt.action.stop": "중지",
"prompt.toast.pasteUnsupported.title": "지원되지 않는 첨부 파일",
"prompt.toast.attachmentDuplicate.title": "이 파일은 이미 업로드되었습니다",
"prompt.toast.pasteUnsupported.description": "이미지, PDF 또는 텍스트 파일만 첨부할 수 있습니다.",
"prompt.toast.modelAgentRequired.title": "에이전트 및 모델 선택",
"prompt.toast.modelAgentRequired.description": "프롬프트를 보내기 전에 에이전트와 모델을 선택하세요.",
+1
View File
@@ -288,6 +288,7 @@ export const dict = {
"prompt.action.stop": "Stopp",
"prompt.toast.pasteUnsupported.title": "Ikke støttet vedlegg",
"prompt.toast.attachmentDuplicate.title": "Denne filen er allerede lastet opp",
"prompt.toast.pasteUnsupported.description": "Kun bilder, PDF-er eller tekstfiler kan legges ved her.",
"prompt.toast.modelAgentRequired.title": "Velg en agent og modell",
"prompt.toast.modelAgentRequired.description": "Velg en agent og modell før du sender en forespørsel.",
+1
View File
@@ -278,6 +278,7 @@ export const dict = {
"prompt.action.send": "Wyślij",
"prompt.action.stop": "Zatrzymaj",
"prompt.toast.pasteUnsupported.title": "Nieobsługiwany załącznik",
"prompt.toast.attachmentDuplicate.title": "Ten plik został już przesłany",
"prompt.toast.pasteUnsupported.description": "Można tutaj załączać tylko obrazy, pliki PDF lub pliki tekstowe.",
"prompt.toast.modelAgentRequired.title": "Wybierz agenta i model",
"prompt.toast.modelAgentRequired.description": "Wybierz agenta i model przed wysłaniem zapytania.",
+1
View File
@@ -297,6 +297,7 @@ export const dict = {
"prompt.action.stop": "Остановить",
"prompt.toast.pasteUnsupported.title": "Неподдерживаемое вложение",
"prompt.toast.attachmentDuplicate.title": "Этот файл уже загружен",
"prompt.toast.pasteUnsupported.description": "Здесь можно прикрепить только изображения, PDF или текстовые файлы.",
"prompt.toast.modelAgentRequired.title": "Выберите агента и модель",
"prompt.toast.modelAgentRequired.description": "Выберите агента и модель перед отправкой запроса.",
+1
View File
@@ -297,6 +297,7 @@ export const dict = {
"prompt.action.stop": "หยุด",
"prompt.toast.pasteUnsupported.title": "ไฟล์แนบที่ไม่รองรับ",
"prompt.toast.attachmentDuplicate.title": "ไฟล์นี้ถูกอัปโหลดแล้ว",
"prompt.toast.pasteUnsupported.description": "แนบได้เฉพาะรูปภาพ, PDF หรือไฟล์ข้อความเท่านั้น",
"prompt.toast.modelAgentRequired.title": "เลือกเอเจนต์และโมเดล",
"prompt.toast.modelAgentRequired.description": "เลือกเอเจนต์และโมเดลก่อนส่งพร้อมท์",
+1
View File
@@ -302,6 +302,7 @@ export const dict = {
"prompt.action.stop": "Durdur",
"prompt.toast.pasteUnsupported.title": "Desteklenmeyen ek",
"prompt.toast.attachmentDuplicate.title": "Bu dosya zaten yüklendi",
"prompt.toast.pasteUnsupported.description": "Buraya yalnızca resimler, PDF'ler veya metin dosyaları eklenebilir.",
"prompt.toast.modelAgentRequired.title": "Bir ajan ve model seçin",
"prompt.toast.modelAgentRequired.description": "Komut göndermeden önce bir ajan ve model seçin.",
+1
View File
@@ -299,6 +299,7 @@ export const dict = {
"prompt.action.stop": "Зупинити",
"prompt.toast.pasteUnsupported.title": "Непідтримуване вкладення",
"prompt.toast.attachmentDuplicate.title": "Цей файл уже завантажено",
"prompt.toast.pasteUnsupported.description": "Сюди можна прикріплювати лише зображення, PDF або текстові файли.",
"prompt.toast.modelAgentRequired.title": "Виберіть агента та модель",
"prompt.toast.modelAgentRequired.description": "Виберіть агента та модель перед надсиланням запиту.",
+1
View File
@@ -316,6 +316,7 @@ export const dict = {
"prompt.action.send": "发送",
"prompt.action.stop": "停止",
"prompt.toast.pasteUnsupported.title": "不支持的附件",
"prompt.toast.attachmentDuplicate.title": "此文件已上传",
"prompt.toast.pasteUnsupported.description": "此处仅能附加图片、PDF 或文本文件。",
"prompt.toast.modelAgentRequired.title": "请选择智能体和模型",
"prompt.toast.modelAgentRequired.description": "发送提示前请先选择智能体和模型。",
+1
View File
@@ -297,6 +297,7 @@ export const dict = {
"prompt.action.stop": "停止",
"prompt.toast.pasteUnsupported.title": "不支援的附件",
"prompt.toast.attachmentDuplicate.title": "此檔案已上傳",
"prompt.toast.pasteUnsupported.description": "此處僅能附加圖片、PDF 或文字檔案。",
"prompt.toast.modelAgentRequired.title": "請選擇代理程式和模型",
"prompt.toast.modelAgentRequired.description": "傳送提示前請先選擇代理程式和模型。",
+4 -5
View File
@@ -33,8 +33,7 @@ import { createStore, produce, reconcile } from "solid-js/store"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useProviders } from "@/hooks/use-providers"
import { toaster } from "@opencode-ai/ui/toast"
import { setV2Toast, showToast, ToastRegion } from "@/utils/toast"
import { dismissToast, setV2Toast, showToast, ToastRegion } from "@/utils/toast"
import { useServerSDK } from "@/context/server-sdk"
import { normalizeProjectInfo } from "@/context/global-sync/utils"
import { clearWorkspaceTerminals } from "@/context/terminal"
@@ -384,7 +383,7 @@ export default function LegacyLayout(props: ParentProps) {
const dismissSessionAlert = (sessionKey: string) => {
const toastId = toastBySession.get(sessionKey)
if (toastId === undefined) return
toaster.dismiss(toastId)
dismissToast(toastId)
toastBySession.delete(sessionKey)
alertedAtBySession.delete(sessionKey)
}
@@ -1459,7 +1458,7 @@ export default function LegacyLayout(props: ParentProps) {
title: language.t("workspace.resetting.title"),
description: language.t("workspace.resetting.description"),
})
const dismiss = () => toaster.dismiss(progress)
const dismiss = () => dismissToast(progress)
const sessions = await listAllSessions(serverSDK().api.session, { directory, order: "desc" }).catch(() => [])
@@ -2450,7 +2449,7 @@ function UpdateAvailableToast(props: {
onCleanup(() => {
if (toastId === undefined) return
toaster.dismiss(toastId)
dismissToast(toastId)
})
return null
+15 -2
View File
@@ -1,6 +1,12 @@
import { Icon, type IconProps } from "@opencode-ai/ui/icon"
import { Toast, showToast as showLegacyToast, type ToastOptions, type ToastVariant } from "@opencode-ai/ui/toast"
import { ToastV2, showToastV2 } from "@opencode-ai/ui/v2/toast-v2"
import {
Toast,
showToast as showLegacyToast,
toaster as legacyToaster,
type ToastOptions,
type ToastVariant,
} from "@opencode-ai/ui/toast"
import { ToastV2, showToastV2, toasterV2 } from "@opencode-ai/ui/v2/toast-v2"
let v2 = false
@@ -27,6 +33,13 @@ export function showToast(options: ToastOptions | string) {
})
}
// v1 and v2 ids come from separate registries, so dismissal has to use the same
// implementation that issued the id.
export function dismissToast(toastId: number) {
if (!v2) return legacyToaster.dismiss(toastId)
return toasterV2.dismiss(toastId)
}
function resolveIcon(icon: IconProps["name"] | undefined, variant: ToastVariant | undefined) {
const name = icon ?? (variant === "success" ? "check" : undefined)
if (!name) return
@@ -1,7 +1,10 @@
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { createPromptAttachmentsCore } from "@/components/prompt-input/attachments"
import { createPromptState } from "@/context/prompt"
import { createPromptInputV2Attachments } from "../../session-ui/src/v2/components/prompt-input/attachments"
import type { PromptInputV2Prompt } from "../../session-ui/src/v2/components/prompt-input/types"
describe("prompt attachment session ownership", () => {
test("adds an asynchronously read image to the session where the read started", async () => {
@@ -85,6 +88,88 @@ describe("prompt attachment session ownership", () => {
})
})
test("rejects a duplicate native clipboard attachment in the V2 prompt store", async () => {
await createRoot(async (dispose) => {
const [state, setState] = createStore({ prompt: [] as PromptInputV2Prompt })
const duplicate = Promise.withResolvers<void>()
const files = [
new File(["hello"], "clipboard-1.txt", { type: "text/plain" }),
new File(["hello"], "clipboard-2.txt", { type: "text/plain" }),
]
const attachments = createPromptInputV2Attachments({
capture: () => ({
current: () => state.prompt,
cursor: () => 0,
set: (prompt) => setState("prompt", prompt),
}),
editor: () => document.createElement("div"),
focusEditor: () => undefined,
addPart: () => false,
setDraggingType: () => undefined,
directory: () => "/",
isDialogActive: () => false,
warn: () => undefined,
duplicate: duplicate.resolve,
onError: () => undefined,
readClipboardImage: async () => files.shift() ?? null,
})
const event = {
clipboardData: { items: [], getData: () => "" },
preventDefault: () => undefined,
stopPropagation: () => undefined,
} as unknown as ClipboardEvent
await attachments.handlePaste(event)
await attachments.handlePaste(event)
await duplicate.promise
expect(state.prompt).toHaveLength(1)
dispose()
})
})
test("rejects desktop duplicates and keeps changed files in the V2 prompt store", async () => {
await createRoot(async (dispose) => {
const [state, setState] = createStore({ prompt: [] as PromptInputV2Prompt })
const duplicates: string[] = []
const attachments = createPromptInputV2Attachments({
capture: () => ({
current: () => state.prompt,
cursor: () => 0,
set: (prompt) => setState("prompt", prompt),
}),
editor: () => document.createElement("div"),
focusEditor: () => undefined,
addPart: () => false,
setDraggingType: () => undefined,
directory: () => "/",
isDialogActive: () => false,
warn: () => undefined,
duplicate: () => duplicates.push("duplicate"),
onError: () => undefined,
getPathForFile: (file) => (file.name === "browser.txt" ? "" : `/tmp/${file.name}`),
})
const first = new File(["first"], "a.txt", { type: "text/plain" })
const second = new File(["second"], "b.txt", { type: "text/plain" })
await attachments.addAttachments([first, second])
await attachments.addAttachments([first, second])
expect(state.prompt).toHaveLength(2)
expect(duplicates).toEqual(["duplicate", "duplicate"])
await attachments.addAttachments([new File(["edited"], "a.txt", { type: "text/plain" })])
expect(state.prompt).toHaveLength(3)
await attachments.addAttachments([
new File(["same"], "browser.txt", { type: "text/plain" }),
new File(["same"], "browser.txt", { type: "text/plain" }),
])
expect(state.prompt).toHaveLength(4)
expect(duplicates).toHaveLength(3)
dispose()
})
})
function images(prompt: ReturnType<typeof createPromptState>) {
return prompt.current().filter((part) => part.type === "image")
}
+39 -1
View File
@@ -1,8 +1,46 @@
import { describe, expect, test } from "bun:test"
import { beforeEach, describe, expect, test } from "bun:test"
import { createSignal, type JSX } from "solid-js"
import { showToastV2, toasterV2 } from "@opencode-ai/ui/v2/toast-v2"
describe("showToastV2", () => {
// The toast registry is module state, so each test starts from an empty stack.
beforeEach(() => {
toasterV2.dismiss()
})
test("coalesces exact active content", () => {
const first = showToastV2({ title: "Repeated error", description: "Try again" })
const second = showToastV2({ title: "Repeated error", description: "Try again" })
const different = showToastV2({ title: "Repeated error", description: "A different error" })
expect(second).toBe(first)
expect(different).not.toBe(first)
toasterV2.dismiss(first)
toasterV2.dismiss(different)
})
test("allows dismissed content to appear again", () => {
const first = showToastV2("Dismiss and retry")
toasterV2.dismiss(first)
const second = showToastV2("Dismiss and retry")
expect(second).not.toBe(first)
toasterV2.dismiss(second)
})
test("recreates matching content when it is not the topmost toast", () => {
const first = showToastV2("First toast")
const topmost = showToastV2("Topmost toast")
const repeated = showToastV2("First toast")
expect(repeated).not.toBe(first)
toasterV2.dismiss(topmost)
toasterV2.dismiss(repeated)
})
test("creates no reactive computations at call time", () => {
const [tick, setTick] = createSignal(0)
let reads = 0
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli",
"version": "1.18.8",
"version": "1.18.10",
"type": "module",
"license": "MIT",
"bin": {
+1 -1
View File
@@ -1,4 +1,4 @@
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.opencode.ai"
export const modelsData = process.env.MODELS_DEV_API_JSON
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/codemode",
"version": "1.18.8",
"version": "1.18.10",
"description": "Effect-native confined code execution over schema-described tools",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.18.8",
"version": "1.18.10",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.18.8",
"version": "1.18.10",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.18.8",
"version": "1.18.10",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.18.8",
"version": "1.18.10",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-support",
"version": "1.18.8",
"version": "1.18.10",
"type": "module",
"license": "MIT",
"scripts": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.8",
"version": "1.18.10",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
@@ -108,7 +108,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.12.0",
"gitlab-ai-provider": "6.12.1",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
+10 -4
View File
@@ -15,6 +15,11 @@ import { httpClient } from "./effect/app-node-platform"
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
export type CatalogModelStatus = typeof CatalogModelStatus.Type
const InterleavedField = Schema.Union([
Schema.Literals(["reasoning", "reasoning_content", "reasoning_text"]),
Schema.String,
])
const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}`
const CostTier = Schema.Struct({
@@ -71,9 +76,10 @@ export const Model = Schema.Struct({
reasoning_options: Schema.optional(Schema.Array(ReasoningOption)),
interleaved: Schema.optional(
Schema.Union([
Schema.Literal(true),
Schema.Boolean,
InterleavedField,
Schema.Struct({
field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]),
field: InterleavedField,
}),
]),
),
@@ -151,10 +157,10 @@ const layer = Layer.effect(
),
)
const source = Flag.OPENCODE_MODELS_URL || "https://models.dev"
const source = Flag.OPENCODE_MODELS_URL || "https://models.opencode.ai"
const filepath = path.join(
Global.Path.cache,
source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`,
source === "https://models.opencode.ai" ? "models.json" : `models-${Hash.fast(source)}.json`,
)
const ttl = Duration.minutes(5)
const lockKey = `models-dev:${filepath}`
+8 -2
View File
@@ -5,6 +5,11 @@ import { PositiveInt } from "../../schema"
export const ModelStatus = Schema.Literals(["alpha", "beta", "deprecated", "active"])
const InterleavedField = Schema.Union([
Schema.Literals(["reasoning", "reasoning_content", "reasoning_text"]),
Schema.String,
])
export const Model = Schema.Struct({
id: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
@@ -16,9 +21,10 @@ export const Model = Schema.Struct({
tool_call: Schema.optional(Schema.Boolean),
interleaved: Schema.optional(
Schema.Union([
Schema.Literal(true),
Schema.Boolean,
InterleavedField,
Schema.Struct({
field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]),
field: InterleavedField,
}),
]),
),
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.18.8",
"version": "1.18.10",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
@@ -17,6 +17,7 @@ export function DesktopFirstLaunchOnboarding(props: { initialUrl: string; onLoad
)
const existingInstall = await window.api.isOldLayoutEligible()
settings.general.setOldLayoutEligible(existingInstall)
settings.general.initializeAgentVisibility(existingInstall)
if (!server.isLocal()) return
const pending = await window.api.isFirstLaunchOnboardingPending()
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.8",
"version": "1.18.10",
"name": "@opencode-ai/effect-drizzle-sqlite",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.8",
"version": "1.18.10",
"name": "@opencode-ai/effect-sqlite-node",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.18.8",
"version": "1.18.10",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.18.8",
"version": "1.18.10",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.8",
"version": "1.18.10",
"name": "@opencode-ai/http-recorder",
"description": "Record and replay Effect HTTP client traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.8",
"version": "1.18.10",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -2,7 +2,7 @@ import * as fs from "node:fs/promises"
import * as path from "node:path"
const RECORDINGS_DIR = path.resolve(import.meta.dir, "..", "test", "fixtures", "recordings")
const MODELS_DEV_URL = "https://models.dev/api.json"
const MODELS_DEV_URL = "https://models.opencode.ai/api.json"
type JsonRecord = Record<string, unknown>
+3 -4
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.8",
"version": "1.18.10",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -30,7 +30,6 @@
},
"devDependencies": {
"@babel/core": "7.28.4",
"@modelcontextprotocol/server": "2.0.0-beta.5",
"@octokit/webhooks-types": "7.6.1",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/http-recorder": "workspace:*",
@@ -81,7 +80,7 @@
"@effect/platform-node": "catalog:",
"@ff-labs/fff-bun": "0.9.4",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@modelcontextprotocol/client": "2.0.0-beta.5",
"@modelcontextprotocol/sdk": "1.29.0",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
@@ -121,7 +120,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.12.0",
"gitlab-ai-provider": "6.12.1",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
+104 -47
View File
@@ -2,10 +2,13 @@ import { cmd } from "./cmd"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { effectCmd } from "../effect-cmd"
import { Cause } from "effect"
import { Client, StreamableHTTPClientTransport, UnauthorizedError } from "@modelcontextprotocol/client"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js"
import * as prompts from "@clack/prompts"
import { UI } from "../ui"
import { CLIENT_OPTIONS, MCP } from "../../mcp"
import { MCP } from "../../mcp"
import { McpAuth } from "../../mcp/auth"
import { McpOAuthProvider } from "../../mcp/oauth-provider"
import { Config } from "@/config/config"
@@ -728,53 +731,107 @@ export const McpDebugCommand = effectCmd({
const spinner = prompts.spinner()
spinner.start("Testing connection...")
const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined
let authorizationUrl: URL | undefined
const authProvider = new McpOAuthProvider(
serverName,
serverConfig.url,
{
clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope,
callbackPort: oauthConfig?.callbackPort,
redirectUri: oauthConfig?.redirectUri,
},
{
onRedirect: async (url) => {
authorizationUrl = url
},
},
auth,
)
const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), {
authProvider,
requestInit: serverConfig.headers ? { headers: serverConfig.headers } : undefined,
})
const client = new Client({ name: "opencode-debug", version: InstallationVersion }, CLIENT_OPTIONS)
// Test basic HTTP connectivity first
try {
await client.connect(transport)
spinner.stop("SDK connection successful")
prompts.log.success(
`Connected using MCP ${client.getNegotiatedProtocolVersion() ?? "unknown"} (${client.getProtocolEra() ?? "unknown"})`,
)
const serverInfo = client.getServerVersion()
if (serverInfo) prompts.log.info(`Server info: ${JSON.stringify(serverInfo)}`)
} catch (error) {
if (error instanceof UnauthorizedError) {
spinner.stop("OAuth required")
prompts.log.info(`OAuth flow triggered: ${error.message}`)
if (authorizationUrl) prompts.log.info(`Authorization URL: ${authorizationUrl}`)
const clientInfo = await authProvider.clientInformation()
if (clientInfo) prompts.log.info(`Client ID available: ${clientInfo.client_id}`)
if (!clientInfo) prompts.log.info("No client ID - dynamic registration will be attempted")
} else {
spinner.stop("Connection failed", 1)
prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
const response = await fetch(serverConfig.url, {
method: "POST",
headers: {
...serverConfig.headers,
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
},
body: JSON.stringify({
jsonrpc: "2.0",
method: "initialize",
params: {
protocolVersion: LATEST_PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "opencode-debug", version: InstallationVersion },
},
id: 1,
}),
})
spinner.stop(`HTTP response: ${response.status} ${response.statusText}`)
// Check for WWW-Authenticate header
const wwwAuth = response.headers.get("www-authenticate")
if (wwwAuth) {
prompts.log.info(`WWW-Authenticate: ${wwwAuth}`)
}
} finally {
await client.close().catch(() => {})
if (response.status === 401) {
prompts.log.info("Initial unauthenticated check returned 401, so this server requires OAuth")
// Try to discover OAuth metadata
const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined
const authProvider = new McpOAuthProvider(
serverName,
serverConfig.url,
{
clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope,
redirectUri: oauthConfig?.redirectUri,
},
{
onRedirect: async () => {},
},
auth,
)
prompts.log.info("Testing OAuth flow (without completing authorization)...")
// Try creating transport with auth provider to trigger discovery
const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), {
authProvider,
requestInit: serverConfig.headers ? { headers: serverConfig.headers } : undefined,
})
try {
const client = new Client({
name: "opencode-debug",
version: InstallationVersion,
})
await client.connect(transport)
prompts.log.success("Connection successful (already authenticated)")
await client.close()
} catch (error) {
if (error instanceof UnauthorizedError) {
prompts.log.info(`OAuth flow triggered: ${error.message}`)
// Check if dynamic registration would be attempted
const clientInfo = await authProvider.clientInformation()
if (clientInfo) {
prompts.log.info(`Client ID available: ${clientInfo.client_id}`)
} else {
prompts.log.info("No client ID - dynamic registration will be attempted")
}
} else {
prompts.log.error(`Connection error: ${error instanceof Error ? error.message : String(error)}`)
}
}
} else if (response.status >= 200 && response.status < 300) {
prompts.log.success("Server responded successfully (no auth required or already authenticated)")
const body = await response.text()
try {
const json = JSON.parse(body)
if (json.result?.serverInfo) {
prompts.log.info(`Server info: ${JSON.stringify(json.result.serverInfo)}`)
}
} catch {
// Not JSON, ignore
}
} else {
prompts.log.warn(`Unexpected status: ${response.status}`)
const body = await response.text().catch(() => "")
if (body) {
prompts.log.info(`Response body: ${body.substring(0, 500)}`)
}
}
} catch (error) {
spinner.stop("Connection failed", 1)
prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
}
prompts.outro("Debug complete")
-20
View File
@@ -5,22 +5,12 @@ import { Global } from "@opencode-ai/core/global"
import { Effect, Layer, Context, Option, Schema } from "effect"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import type { OAuthDiscoveryState } from "@modelcontextprotocol/client"
const DiscoveryState = Schema.declare<OAuthDiscoveryState>(
(value): value is OAuthDiscoveryState =>
typeof value === "object" &&
value !== null &&
"authorizationServerUrl" in value &&
typeof value.authorizationServerUrl === "string",
)
export const Tokens = Schema.Struct({
accessToken: Schema.mutableKey(Schema.String),
refreshToken: Schema.mutableKey(Schema.optional(Schema.String)),
expiresAt: Schema.mutableKey(Schema.optional(Schema.Number)),
scope: Schema.mutableKey(Schema.optional(Schema.String)),
issuer: Schema.mutableKey(Schema.optional(Schema.String)),
})
export type Tokens = Schema.Schema.Type<typeof Tokens>
@@ -29,9 +19,6 @@ export const ClientInfo = Schema.Struct({
clientSecret: Schema.mutableKey(Schema.optional(Schema.String)),
clientIdIssuedAt: Schema.mutableKey(Schema.optional(Schema.Number)),
clientSecretExpiresAt: Schema.mutableKey(Schema.optional(Schema.Number)),
redirectUris: Schema.mutableKey(Schema.optional(Schema.Array(Schema.String))),
issuer: Schema.mutableKey(Schema.optional(Schema.String)),
configPreRegistered: Schema.mutableKey(Schema.optional(Schema.Boolean)),
})
export type ClientInfo = Schema.Schema.Type<typeof ClientInfo>
@@ -40,7 +27,6 @@ export const Entry = Schema.Struct({
clientInfo: Schema.mutableKey(Schema.optional(ClientInfo)),
codeVerifier: Schema.mutableKey(Schema.optional(Schema.String)),
oauthState: Schema.mutableKey(Schema.optional(Schema.String)),
discoveryState: Schema.mutableKey(Schema.optional(DiscoveryState)),
serverUrl: Schema.mutableKey(Schema.optional(Schema.String)),
})
export type Entry = Schema.Schema.Type<typeof Entry>
@@ -64,8 +50,6 @@ export interface Interface {
readonly updateOAuthState: (mcpName: string, oauthState: string) => Effect.Effect<void>
readonly getOAuthState: (mcpName: string) => Effect.Effect<string | undefined>
readonly clearOAuthState: (mcpName: string) => Effect.Effect<void>
readonly updateDiscoveryState: (mcpName: string, discoveryState: OAuthDiscoveryState) => Effect.Effect<void>
readonly clearDiscoveryState: (mcpName: string) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/McpAuth") {}
@@ -149,10 +133,8 @@ const layer = Layer.effect(
const updateClientInfo = updateField("clientInfo", "updateClientInfo")
const updateCodeVerifier = updateField("codeVerifier", "updateCodeVerifier")
const updateOAuthState = updateField("oauthState", "updateOAuthState")
const updateDiscoveryState = updateField("discoveryState", "updateDiscoveryState")
const clearCodeVerifier = clearField("codeVerifier", "clearCodeVerifier")
const clearOAuthState = clearField("oauthState", "clearOAuthState")
const clearDiscoveryState = clearField("discoveryState", "clearDiscoveryState")
const getOAuthState = Effect.fn("McpAuth.getOAuthState")(function* (mcpName: string) {
const entry = yield* get(mcpName)
@@ -172,8 +154,6 @@ const layer = Layer.effect(
updateOAuthState,
getOAuthState,
clearOAuthState,
updateDiscoveryState,
clearDiscoveryState,
})
}),
)
+91 -44
View File
@@ -1,57 +1,77 @@
import { Client, type CallToolResult, type Tool as MCPToolDef } from "@modelcontextprotocol/client"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import {
CallToolResultSchema,
ListToolsResultSchema,
ToolSchema,
type Tool as MCPToolDef,
} from "@modelcontextprotocol/sdk/types.js"
import { dynamicTool, jsonSchema, type JSONSchema7, type Tool } from "ai"
import { Effect } from "effect"
const DEFAULT_TIMEOUT = 30_000
const MAX_LIST_PAGES = 1_000
export interface McpTool {
readonly def: MCPToolDef
readonly client: Client
readonly timeout?: number
}
const TolerantListToolsResultSchema = ListToolsResultSchema.extend({
tools: ToolSchema.omit({ outputSchema: true }).array(),
})
export async function callTool(
tool: McpTool,
args: Record<string, unknown>,
signal?: AbortSignal,
): Promise<CallToolResult> {
const result = await tool.client.callTool(
{ name: tool.def.name, arguments: args },
{
resetTimeoutOnProgress: true,
signal,
timeout: tool.timeout,
// The MCP SDK only sends a progress token when this hook is present, enabling timeout resets.
onprogress: () => {},
},
)
if (result.isError)
throw new Error(
result.content
.flatMap((item) => (item.type === "text" ? [item.text] : []))
.filter((text) => text.trim())
.join("\n\n") || "MCP tool returned an error",
)
return result
export async function paginate<T, R extends { nextCursor?: string }>(
list: (cursor?: string) => Promise<R>,
items: (result: R) => T[],
) {
const result: T[] = []
const cursors = new Set<string>()
let cursor: string | undefined
for (let page = 0; page < MAX_LIST_PAGES; page++) {
const page = await list(cursor)
result.push(...items(page))
if (page.nextCursor === undefined) return result
if (cursors.has(page.nextCursor)) throw new Error(`MCP list returned duplicate cursor: ${page.nextCursor}`)
cursors.add(page.nextCursor)
cursor = page.nextCursor
}
throw new Error(`MCP list exceeded ${MAX_LIST_PAGES} pages`)
}
export function defs(client: Client, timeout?: number) {
return listTools(client, timeout ?? DEFAULT_TIMEOUT).pipe(Effect.catch(() => Effect.void))
}
export function convertTool(tool: McpTool): Tool {
export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: number): Tool {
const inputSchema: JSONSchema7 = {
...(tool.def.inputSchema as JSONSchema7),
...(mcpTool.inputSchema as JSONSchema7),
type: "object",
properties: (tool.def.inputSchema.properties ?? {}) as JSONSchema7["properties"],
properties: (mcpTool.inputSchema.properties ?? {}) as JSONSchema7["properties"],
additionalProperties: false,
}
return dynamicTool({
description: tool.def.description ?? "",
description: mcpTool.description ?? "",
inputSchema: jsonSchema(inputSchema),
execute: async (args: unknown, options) => {
const result = await callTool(tool, (args || {}) as Record<string, unknown>, options.abortSignal)
const result = await client.callTool(
{
name: mcpTool.name,
arguments: (args || {}) as Record<string, unknown>,
},
CallToolResultSchema,
{
resetTimeoutOnProgress: true,
signal: options.abortSignal,
timeout,
// The MCP SDK only sends a progress token when this hook is present, enabling timeout resets.
onprogress: () => {},
},
)
if (result.isError)
throw new Error(
result.content
.flatMap((item) => (item.type === "text" ? [item.text] : []))
.filter((text) => text.trim())
.join("\n\n") || "MCP tool returned an error",
)
if (result.content.length > 0 || result.structuredContent === undefined || result.structuredContent === null)
return result
return {
@@ -98,26 +118,53 @@ export const sanitize = (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, "_")
export const toolName = (clientName: string, name: string) => sanitize(clientName) + "_" + sanitize(name)
export async function prompts(client: Client, timeout?: number) {
if (!client.getServerCapabilities()?.prompts) return []
return (await client.listPrompts(undefined, { timeout })).prompts
export function prompts(client: Client, timeout?: number) {
if (!client.getServerCapabilities()?.prompts) return Promise.resolve([])
return paginate(
(cursor) => client.listPrompts(cursor === undefined ? undefined : { cursor }, { timeout }),
(result) => result.prompts,
)
}
export async function resources(client: Client, timeout?: number) {
if (!client.getServerCapabilities()?.resources) return []
return (await client.listResources(undefined, { timeout })).resources
export function resources(client: Client, timeout?: number) {
if (!client.getServerCapabilities()?.resources) return Promise.resolve([])
return paginate(
(cursor) => client.listResources(cursor === undefined ? undefined : { cursor }, { timeout }),
(result) => result.resources,
)
}
export async function resourceTemplates(client: Client, timeout?: number) {
if (!client.getServerCapabilities()?.resources) return []
return (await client.listResourceTemplates(undefined, { timeout })).resourceTemplates
export function resourceTemplates(client: Client, timeout?: number) {
if (!client.getServerCapabilities()?.resources) return Promise.resolve([])
return paginate(
(cursor) => client.listResourceTemplates(cursor === undefined ? undefined : { cursor }, { timeout }),
(result) => result.resourceTemplates,
)
}
function listTools(client: Client, timeout: number) {
return Effect.tryPromise({
try: async () => (await client.listTools(undefined, { timeout })).tools,
try: () =>
paginate(
async (cursor) => {
const params = cursor === undefined ? undefined : { cursor }
try {
return await client.listTools(params, { timeout })
} catch (error) {
if (!(error instanceof Error) || !isOutputSchemaValidationError(error)) throw error
return client.request({ method: "tools/list", params }, TolerantListToolsResultSchema, { timeout })
}
},
(result) => result.tools,
),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
})
}
function isOutputSchemaValidationError(error: Error) {
return /can't resolve reference|resolves to more than one schema|outputSchema|schema.*reference|reference.*schema/i.test(
error.message,
)
}
export * as McpCatalog from "./catalog"
+30 -52
View File
@@ -3,18 +3,18 @@ import { pathToFileURL } from "node:url"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Client, type ClientOptions } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
import {
Client,
type ClientOptions,
StreamableHTTPClientTransport,
SSEClientTransport,
UnauthorizedError,
RegistrationRejectedError,
SdkHttpError,
ListRootsRequestSchema,
type LoggingMessageNotification,
LoggingMessageNotificationSchema,
type Tool as MCPToolDef,
} from "@modelcontextprotocol/client"
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"
ToolListChangedNotificationSchema,
} from "@modelcontextprotocol/sdk/types.js"
import { Config } from "@/config/config"
import { ConfigMCPV1 } from "@opencode-ai/core/v1/config/mcp"
import { NamedError } from "@opencode-ai/core/util/error"
@@ -36,7 +36,7 @@ import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { McpBrowser } from "./browser"
const DEFAULT_TIMEOUT = 30_000
export const CLIENT_OPTIONS = {
const CLIENT_OPTIONS = {
capabilities: {
// https://github.com/anomalyco/opencode/issues/11948
// sampling: {},
@@ -47,8 +47,6 @@ export const CLIENT_OPTIONS = {
// https://github.com/anomalyco/opencode/issues/28567
// tasks: {},
},
versionNegotiation: { mode: "auto" },
listMaxPages: 1_000,
} satisfies ClientOptions
export const Resource = Schema.Struct({
@@ -72,19 +70,13 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("MCP
name: Schema.String,
}) {}
type MCPClient = Client & { onToolsChanged?: (error: Error | null) => void }
type MCPClient = Client
function createClient(directory: string) {
const client: MCPClient = new Client(
{ name: "opencode", version: InstallationVersion },
{
...CLIENT_OPTIONS,
listChanged: {
tools: { autoRefresh: false, onChanged: (error) => client.onToolsChanged?.(error) },
},
},
const client = new Client({ name: "opencode", version: InstallationVersion }, CLIENT_OPTIONS)
client.setRequestHandler(ListRootsRequestSchema, () =>
Promise.resolve({ roots: [{ uri: pathToFileURL(directory).href }] }),
)
client.setRequestHandler("roots/list", async () => ({ roots: [{ uri: pathToFileURL(directory).href }] }))
return client
}
@@ -162,7 +154,12 @@ export interface ServerInstructions {
}
/** An MCP tool in its native shape; consumers adapt it to their own tool format. */
export type McpTool = McpCatalog.McpTool
export interface McpTool {
/** Shared cached definition; consumers must copy rather than mutate it. */
readonly def: MCPToolDef
readonly client: MCPClient
readonly timeout?: number
}
export interface Interface {
readonly status: () => Effect.Effect<Record<string, Status>>
@@ -193,11 +190,7 @@ export interface Interface {
mcpName: string,
onAuthorization?: (authorizationUrl: string) => void,
) => Effect.Effect<Status, NotFoundError>
readonly finishAuth: (
mcpName: string,
authorizationCode: string,
iss?: string,
) => Effect.Effect<Status, NotFoundError>
readonly finishAuth: (mcpName: string, authorizationCode: string) => Effect.Effect<Status, NotFoundError>
readonly removeAuth: (mcpName: string) => Effect.Effect<void>
readonly supportsOAuth: (mcpName: string) => Effect.Effect<boolean, NotFoundError>
readonly hasStoredTokens: (mcpName: string) => Effect.Effect<boolean>
@@ -298,18 +291,11 @@ const layer = Layer.effect(
Effect.map((client) => ({ client, transportName: name })),
Effect.catch((error) => {
const lastError = error instanceof Error ? error : new Error(String(error))
const registrationRejected =
error instanceof RegistrationRejectedError ||
lastError.message.includes("registration") ||
lastError.message.includes("client_id")
const isAuthError =
error instanceof UnauthorizedError ||
registrationRejected ||
(authProvider && error instanceof SdkHttpError && error.status === 401) ||
(authProvider && lastError.message.includes("OAuth"))
error instanceof UnauthorizedError || (authProvider && lastError.message.includes("OAuth"))
if (isAuthError) {
if (registrationRejected) {
if (lastError.message.includes("registration") || lastError.message.includes("client_id")) {
lastStatus = {
status: "needs_client_registration" as const,
error: "Server does not support dynamic client registration. Please provide clientId in config.",
@@ -468,16 +454,12 @@ const layer = Layer.effect(
)
}
client.setNotificationHandler("notifications/message", (notification) =>
client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) =>
bridge.promise(serverLog(name, notification.params)),
)
if (!client.getServerCapabilities()?.tools) return
client.onToolsChanged = async (error) => {
if (error) {
await bridge.promise(Effect.logWarning("failed to refresh MCP tools", { server: name, error: error.message }))
return
}
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
const listed = await bridge.promise(McpCatalog.defs(client, timeout))
@@ -486,7 +468,7 @@ const layer = Layer.effect(
s.defs[name] = listed
await bridge.promise(events.publish(ToolsChanged, { server: name }).pipe(Effect.ignore))
}
})
}
function serverLog(name: string, params: LoggingMessageNotification["params"]) {
@@ -922,7 +904,7 @@ const layer = Layer.effect(
}),
)
const callback = yield* Effect.promise(() => callbackPromise)
const code = yield* Effect.promise(() => callbackPromise)
const storedState = yield* auth.getOAuthState(mcpName)
if (storedState !== result.oauthState) {
@@ -930,20 +912,16 @@ const layer = Layer.effect(
throw new Error("OAuth state mismatch - potential CSRF attack")
}
yield* auth.clearOAuthState(mcpName)
return yield* finishAuth(mcpName, callback.code, callback.iss)
return yield* finishAuth(mcpName, code)
})
const finishAuth = Effect.fn("MCP.finishAuth")(function* (
mcpName: string,
authorizationCode: string,
iss?: string,
) {
const finishAuth = Effect.fn("MCP.finishAuth")(function* (mcpName: string, authorizationCode: string) {
yield* requireMcpConfig(mcpName)
const pending = pendingOAuthTransports.get(mcpName)
if (!pending) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
const error = yield* Effect.tryPromise({
try: () => pending.transport.finishAuth(authorizationCode, iss),
try: () => pending.transport.finishAuth(authorizationCode),
catch: (error) => error,
}).pipe(
Effect.match({
+3 -9
View File
@@ -9,13 +9,8 @@ const OAUTH_CALLBACK_HOST = "127.0.0.1"
let currentPort = OAUTH_CALLBACK_PORT
let currentPath = OAUTH_CALLBACK_PATH
export interface AuthorizationCallback {
code: string
iss?: string
}
interface PendingAuth {
resolve: (callback: AuthorizationCallback) => void
resolve: (code: string) => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
@@ -54,7 +49,6 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
}
const code = url.searchParams.get("code")
const iss = url.searchParams.get("iss") ?? undefined
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
@@ -101,7 +95,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
clearTimeout(pending.timeout)
pendingAuths.delete(state)
cleanupStateIndex(state)
pending.resolve({ code, iss })
pending.resolve(code)
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
res.end(OauthCallbackPage.success({ provider: "MCP" }))
@@ -136,7 +130,7 @@ export async function ensureRunning(redirectUri?: string): Promise<void> {
})
}
export function waitForCallback(oauthState: string, mcpName?: string): Promise<AuthorizationCallback> {
export function waitForCallback(oauthState: string, mcpName?: string): Promise<string> {
if (mcpName) mcpNameToState.set(mcpName, oauthState)
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
+24 -95
View File
@@ -1,10 +1,10 @@
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
import type {
OAuthClientProvider,
OAuthClientMetadata,
StoredOAuthTokens,
StoredOAuthClientInformation,
OAuthDiscoveryState,
} from "@modelcontextprotocol/client"
OAuthTokens,
OAuthClientInformation,
OAuthClientInformationFull,
} from "@modelcontextprotocol/sdk/shared/auth.js"
import { Effect } from "effect"
import { McpAuth } from "./auth"
@@ -23,14 +23,6 @@ export interface McpOAuthCallbacks {
onRedirect: (url: URL) => void | Promise<void>
}
function registrationMetadata(info: StoredOAuthClientInformation) {
return {
clientIdIssuedAt: "client_id_issued_at" in info ? info.client_id_issued_at : undefined,
clientSecretExpiresAt: "client_secret_expires_at" in info ? info.client_secret_expires_at : undefined,
redirectUris: "redirect_uris" in info ? info.redirect_uris : undefined,
}
}
export class McpOAuthProvider implements OAuthClientProvider {
constructor(
protected mcpName: string,
@@ -60,21 +52,18 @@ export class McpOAuthProvider implements OAuthClientProvider {
}
}
async clientInformation(): Promise<StoredOAuthClientInformation | undefined> {
const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl))
async clientInformation(): Promise<OAuthClientInformation | undefined> {
if (this.config.clientId) {
const issuer = entry?.clientInfo?.clientId === this.config.clientId ? entry.clientInfo.issuer : undefined
return {
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
...(issuer !== undefined ? { issuer } : {}),
}
}
// Check stored client info (from dynamic registration)
// Use getForUrl to validate credentials are for the current server URL
const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl))
if (entry?.clientInfo) {
if (entry.clientInfo.configPreRegistered) return undefined
// Check if client secret has expired
if (entry.clientInfo.clientSecretExpiresAt && entry.clientInfo.clientSecretExpiresAt < Date.now() / 1000) {
return undefined
@@ -82,14 +71,6 @@ export class McpOAuthProvider implements OAuthClientProvider {
return {
client_id: entry.clientInfo.clientId,
client_secret: entry.clientInfo.clientSecret,
...(entry.clientInfo.clientIdIssuedAt !== undefined
? { client_id_issued_at: entry.clientInfo.clientIdIssuedAt }
: {}),
...(entry.clientInfo.clientSecretExpiresAt !== undefined
? { client_secret_expires_at: entry.clientInfo.clientSecretExpiresAt }
: {}),
redirect_uris: entry.clientInfo.redirectUris ? [...entry.clientInfo.redirectUris] : [this.redirectUrl],
...(entry.clientInfo.issuer !== undefined ? { issuer: entry.clientInfo.issuer } : {}),
}
}
@@ -97,36 +78,22 @@ export class McpOAuthProvider implements OAuthClientProvider {
return undefined
}
async saveClientInformation(info: StoredOAuthClientInformation): Promise<void> {
if (this.config.clientId && info.client_id === this.config.clientId) {
await Effect.runPromise(
this.auth.updateClientInfo(
this.mcpName,
{ clientId: info.client_id, issuer: info.issuer, configPreRegistered: true },
this.serverUrl,
),
)
return
}
const metadata = registrationMetadata(info)
async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
await Effect.runPromise(
this.auth.updateClientInfo(
this.mcpName,
{
clientId: info.client_id,
clientSecret: info.client_secret,
clientIdIssuedAt: metadata.clientIdIssuedAt,
clientSecretExpiresAt: metadata.clientSecretExpiresAt,
redirectUris: metadata.redirectUris ? [...metadata.redirectUris] : [this.redirectUrl],
issuer: info.issuer,
clientIdIssuedAt: info.client_id_issued_at,
clientSecretExpiresAt: info.client_secret_expires_at,
},
this.serverUrl,
),
)
}
async tokens(): Promise<StoredOAuthTokens | undefined> {
async tokens(): Promise<OAuthTokens | undefined> {
// Use getForUrl to validate tokens are for the current server URL
const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl))
if (!entry?.tokens) return undefined
@@ -139,20 +106,18 @@ export class McpOAuthProvider implements OAuthClientProvider {
? Math.max(0, Math.floor(entry.tokens.expiresAt - Date.now() / 1000))
: undefined,
scope: entry.tokens.scope,
issuer: entry.tokens.issuer,
}
}
async saveTokens(tokens: StoredOAuthTokens): Promise<void> {
async saveTokens(tokens: OAuthTokens): Promise<void> {
await Effect.runPromise(
this.auth.updateTokens(
this.mcpName,
{
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: tokens.expires_in !== undefined ? Date.now() / 1000 + tokens.expires_in : undefined,
expiresAt: tokens.expires_in ? Date.now() / 1000 + tokens.expires_in : undefined,
scope: tokens.scope,
issuer: tokens.issuer,
},
this.serverUrl,
),
@@ -196,16 +161,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
return newState
}
async saveDiscoveryState(state: OAuthDiscoveryState): Promise<void> {
await Effect.runPromise(this.auth.updateDiscoveryState(this.mcpName, state))
}
async discoveryState(): Promise<OAuthDiscoveryState | undefined> {
const entry = await Effect.runPromise(this.auth.get(this.mcpName))
return entry?.discoveryState
}
async invalidateCredentials(type: "all" | "client" | "tokens" | "verifier" | "discovery"): Promise<void> {
async invalidateCredentials(type: "all" | "client" | "tokens"): Promise<void> {
const entry = await Effect.runPromise(this.auth.get(this.mcpName))
if (!entry) return
switch (type) {
@@ -220,22 +176,15 @@ export class McpOAuthProvider implements OAuthClientProvider {
delete entry.tokens
await Effect.runPromise(this.auth.set(this.mcpName, entry))
break
case "verifier":
await Effect.runPromise(this.auth.clearCodeVerifier(this.mcpName))
break
case "discovery":
await Effect.runPromise(this.auth.clearDiscoveryState(this.mcpName))
break
}
}
}
export class McpOAuthPendingProvider extends McpOAuthProvider {
private pendingClientInfo?: StoredOAuthClientInformation
private pendingTokens?: StoredOAuthTokens
private pendingDiscoveryState?: OAuthDiscoveryState
private pendingClientInfo?: OAuthClientInformationFull
private pendingTokens?: OAuthTokens
override async clientInformation(): Promise<StoredOAuthClientInformation | undefined> {
override async clientInformation(): Promise<OAuthClientInformation | undefined> {
if (!this.config.clientId) return this.pendingClientInfo
return {
client_id: this.config.clientId,
@@ -243,38 +192,25 @@ export class McpOAuthPendingProvider extends McpOAuthProvider {
}
}
override async saveClientInformation(info: StoredOAuthClientInformation): Promise<void> {
override async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
this.pendingClientInfo = info
}
override async tokens(): Promise<StoredOAuthTokens | undefined> {
override async tokens(): Promise<OAuthTokens | undefined> {
return this.pendingTokens
}
override async saveTokens(tokens: StoredOAuthTokens): Promise<void> {
override async saveTokens(tokens: OAuthTokens): Promise<void> {
this.pendingTokens = tokens
}
override async saveDiscoveryState(state: OAuthDiscoveryState): Promise<void> {
this.pendingDiscoveryState = state
}
override async discoveryState(): Promise<OAuthDiscoveryState | undefined> {
return this.pendingDiscoveryState
}
override async invalidateCredentials(
type: "all" | "client" | "tokens" | "verifier" | "discovery",
): Promise<void> {
override async invalidateCredentials(type: "all" | "client" | "tokens"): Promise<void> {
if (type === "all" || type === "client") this.pendingClientInfo = undefined
if (type === "all" || type === "tokens") this.pendingTokens = undefined
if (type === "all" || type === "discovery") this.pendingDiscoveryState = undefined
if (type === "verifier") await super.invalidateCredentials(type)
}
async commit(): Promise<void> {
if (!this.pendingTokens) return
const pendingMetadata = this.pendingClientInfo ? registrationMetadata(this.pendingClientInfo) : undefined
await Effect.runPromise(
this.auth.set(
this.mcpName,
@@ -282,25 +218,18 @@ export class McpOAuthPendingProvider extends McpOAuthProvider {
tokens: {
accessToken: this.pendingTokens.access_token,
refreshToken: this.pendingTokens.refresh_token,
expiresAt:
this.pendingTokens.expires_in !== undefined
? Date.now() / 1000 + this.pendingTokens.expires_in
: undefined,
expiresAt: this.pendingTokens.expires_in ? Date.now() / 1000 + this.pendingTokens.expires_in : undefined,
scope: this.pendingTokens.scope,
issuer: this.pendingTokens.issuer,
},
clientInfo:
this.pendingClientInfo && !this.config.clientId
? {
clientId: this.pendingClientInfo.client_id,
clientSecret: this.pendingClientInfo.client_secret,
clientIdIssuedAt: pendingMetadata?.clientIdIssuedAt,
clientSecretExpiresAt: pendingMetadata?.clientSecretExpiresAt,
redirectUris: pendingMetadata?.redirectUris ? [...pendingMetadata.redirectUris] : [this.redirectUrl],
issuer: this.pendingClientInfo.issuer,
clientIdIssuedAt: this.pendingClientInfo.client_id_issued_at,
clientSecretExpiresAt: this.pendingClientInfo.client_secret_expires_at,
}
: undefined,
discoveryState: this.pendingDiscoveryState,
},
this.serverUrl,
),
+2
View File
@@ -13,6 +13,7 @@ import { CodexAuthPlugin } from "./openai/codex"
import { Session } from "@/session/session"
import { NamedError } from "@opencode-ai/core/util/error"
import { CopilotAuthPlugin } from "./github-copilot/copilot"
import { ModalPlugin } from "./modal/modal"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
import { PoeAuthPlugin } from "opencode-poe-auth"
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
@@ -70,6 +71,7 @@ function internalPlugins(flags: RuntimeFlags.Info): PluginInstance[] {
experimentalWebSockets: experimentalWebSocketsEnabled({ enabled: flags.experimentalWebSockets }),
}),
CopilotAuthPlugin,
ModalPlugin,
GitlabAuthPlugin,
PoeAuthPlugin,
CloudflareWorkersAuthPlugin,
@@ -0,0 +1,17 @@
import type { Hooks } from "@opencode-ai/plugin"
import { ModalModels } from "./models"
export async function ModalPlugin(): Promise<Hooks> {
return {
provider: {
id: "modal",
async models(provider, ctx) {
const apiKey = ctx.auth?.type === "api" ? ctx.auth.key : process.env.MODAL_PROXY_TOKEN
const baseURL = Object.values(provider.models)[0]?.api.url
if (!apiKey || !baseURL) return {}
return ModalModels.get(baseURL, apiKey, provider.models).catch(() => ({}))
},
},
}
}
@@ -0,0 +1,133 @@
import type { Model } from "@opencode-ai/sdk/v2"
import { Schema } from "effect"
const reasoningOption = Schema.Struct({
type: Schema.Literal("effort"),
values: Schema.Array(Schema.NullOr(Schema.String)),
})
const response = Schema.Struct({
data: Schema.Array(
Schema.Struct({
id: Schema.String,
base_model_id: Schema.optional(Schema.String),
hugging_face_id: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
input_modalities: Schema.optional(Schema.Array(Schema.String)),
output_modalities: Schema.optional(Schema.Array(Schema.String)),
context_length: Schema.optional(Schema.Number),
max_output_length: Schema.optional(Schema.Number),
pricing: Schema.optional(
Schema.Struct({
prompt: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
completion: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
input_cache_read: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
}),
),
supported_sampling_parameters: Schema.optional(Schema.Array(Schema.String)),
supported_features: Schema.optional(Schema.Array(Schema.String)),
reasoning_options: Schema.optional(Schema.Array(reasoningOption)),
interleaved: Schema.optional(
Schema.Union([
Schema.Boolean,
Schema.Struct({
field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]),
}),
]),
),
}),
),
})
const decode = Schema.decodeUnknownSync(response)
function price(value: string | number | undefined, fallback: number) {
if (value === undefined) return fallback
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed * 1_000_000 : fallback
}
export async function get(baseURL: string, apiKey: string, existing: Record<string, Model>) {
const data = await fetch(`${baseURL.replace(/\/+$/, "")}/models`, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
signal: AbortSignal.timeout(3_000),
}).then(async (res) => {
if (!res.ok) throw new Error(`Failed to fetch Modal models: ${res.status}`)
return decode(await res.json())
})
return Object.fromEntries(
data.data.map((item) => {
const template = existing[item.base_model_id ?? item.hugging_face_id ?? item.id]
const model: Model = {
id: item.id,
providerID: "modal",
name: item.name ?? template?.name ?? item.id,
family: template?.family,
api: {
id: item.id,
url: baseURL,
npm: template?.api.npm ?? "@ai-sdk/openai-compatible",
},
status: template?.status ?? "active",
headers: { ...template?.headers },
options: { ...template?.options },
cost: {
input: price(item.pricing?.prompt, template?.cost.input ?? 0),
output: price(item.pricing?.completion, template?.cost.output ?? 0),
cache: {
read: price(item.pricing?.input_cache_read, template?.cost.cache.read ?? 0),
write: template?.cost.cache.write ?? 0,
},
},
limit: {
context: item.context_length ?? template?.limit.context ?? 0,
input: template?.limit.input,
output: item.max_output_length ?? template?.limit.output ?? 0,
},
capabilities: {
temperature:
item.supported_sampling_parameters?.includes("temperature") ?? template?.capabilities.temperature ?? false,
reasoning: item.supported_features?.includes("reasoning") ?? template?.capabilities.reasoning ?? false,
attachment:
item.input_modalities?.some((modality) => modality !== "text") ??
template?.capabilities.attachment ??
false,
toolcall: item.supported_features?.includes("tools") ?? template?.capabilities.toolcall ?? true,
input: {
text: item.input_modalities?.includes("text") ?? template?.capabilities.input.text ?? true,
audio: item.input_modalities?.includes("audio") ?? template?.capabilities.input.audio ?? false,
image: item.input_modalities?.includes("image") ?? template?.capabilities.input.image ?? false,
video: item.input_modalities?.includes("video") ?? template?.capabilities.input.video ?? false,
pdf: item.input_modalities?.includes("pdf") ?? template?.capabilities.input.pdf ?? false,
},
output: {
text: item.output_modalities?.includes("text") ?? template?.capabilities.output.text ?? true,
audio: item.output_modalities?.includes("audio") ?? template?.capabilities.output.audio ?? false,
image: item.output_modalities?.includes("image") ?? template?.capabilities.output.image ?? false,
video: item.output_modalities?.includes("video") ?? template?.capabilities.output.video ?? false,
pdf: item.output_modalities?.includes("pdf") ?? template?.capabilities.output.pdf ?? false,
},
interleaved: item.interleaved ?? template?.capabilities.interleaved ?? false,
},
release_date: template?.release_date ?? "",
}
model.variants =
item.reasoning_options === undefined
? template?.variants
: Object.fromEntries(
item.reasoning_options.flatMap((option) =>
option.values.map((value) => {
const effort = value ?? "none"
return [effort, { reasoningEffort: effort }]
}),
),
)
return [item.id, model]
}),
)
}
export * as ModalModels from "./models"
+8 -3
View File
@@ -976,10 +976,15 @@ const ProviderModalities = Schema.Struct({
pdf: Schema.Boolean,
})
const ProviderInterleavedField = Schema.Union([
Schema.Literals(["reasoning", "reasoning_content", "reasoning_text"]),
Schema.String,
])
const ProviderInterleaved = Schema.Union([
Schema.Boolean,
Schema.Struct({
field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]),
field: ProviderInterleavedField,
}),
])
@@ -1243,7 +1248,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model
video: model.modalities?.output?.includes("video") ?? false,
pdf: model.modalities?.output?.includes("pdf") ?? false,
},
interleaved: model.interleaved ?? false,
interleaved: typeof model.interleaved === "string" ? { field: model.interleaved } : (model.interleaved ?? false),
},
release_date: model.release_date ?? "",
variants: {},
@@ -1475,7 +1480,7 @@ const layer = Layer.effect(
pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false,
},
interleaved:
model.interleaved ??
(typeof model.interleaved === "string" ? { field: model.interleaved } : model.interleaved) ??
existingModel?.capabilities.interleaved ??
(!existingModel && apiNpm === "@ai-sdk/openai-compatible" && apiID.includes("deepseek")
? { field: "reasoning_content" }
@@ -20,7 +20,6 @@ export const AuthStartResponse = Schema.Struct({
})
export const AuthCallbackPayload = Schema.Struct({
code: Schema.String,
iss: Schema.optional(Schema.String),
})
export const AuthRemoveResponse = Schema.Struct({
success: Schema.Literal(true),
@@ -38,7 +38,7 @@ export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handler
payload: typeof AuthCallbackPayload.Type
}) {
return yield* mcp
.finishAuth(ctx.params.name, ctx.payload.code, ctx.payload.iss)
.finishAuth(ctx.params.name, ctx.payload.code)
.pipe(
Effect.catchTag("MCP.NotFoundError", (error) =>
Effect.fail(
+1 -1
View File
@@ -388,7 +388,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
if (flags.experimentalCodeMode) return tools
for (const [key, entry] of Object.entries(yield* mcp.tools())) {
const item = McpCatalog.convertTool(entry)
const item = McpCatalog.convertTool(entry.def, entry.client, entry.timeout)
const execute = item.execute
if (!execute) continue
+23 -2
View File
@@ -1,5 +1,5 @@
import * as Tool from "./tool"
import { type CallToolResult } from "@modelcontextprotocol/client"
import { CallToolResultSchema, type CallToolResult } from "@modelcontextprotocol/sdk/types.js"
import { Cause, Effect, Schema } from "effect"
import { CodeMode, Tool as SandboxTool, toolError } from "@opencode-ai/codemode"
import { MCP } from "@/mcp"
@@ -145,7 +145,28 @@ const invokeChildTool = Effect.fn("CodeMode.invokeChildTool")(function* (input:
)
const result: CallToolResult = yield* Effect.gen(function* () {
yield* input.ctx.ask({ permission: input.entry.key, metadata: {}, patterns: ["*"], always: ["*"] })
return yield* Effect.promise(() => McpCatalog.callTool(input.entry.tool, input.args, input.ctx.abort))
// Deliberately mirrors McpCatalog.convertTool's transport call so the MCP service stays free of tool-loop concerns.
return yield* Effect.promise(async () => {
const raw = await input.entry.tool.client.callTool(
{ name: input.entry.tool.def.name, arguments: input.args },
CallToolResultSchema,
{
resetTimeoutOnProgress: true,
signal: input.ctx.abort,
timeout: input.entry.tool.timeout,
// The MCP SDK only sends a progress token when this hook is present, enabling timeout resets.
onprogress: () => {},
},
)
if (raw.isError)
throw new Error(
raw.content
.flatMap((item) => (item.type === "text" ? [item.text] : []))
.filter((text) => text.trim())
.join("\n\n") || "MCP tool returned an error",
)
return raw
})
}).pipe(
Effect.withSpan("Tool.execute", {
attributes: {
@@ -1,5 +1,6 @@
import { Server } from "@modelcontextprotocol/server"
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
if (process.argv.includes("--hang")) {
const pidFile = process.env.MCP_LIFECYCLE_PID_FILE
@@ -10,7 +11,7 @@ if (process.argv.includes("--hang")) {
const server = new Server({ name: "mcp-lifecycle-stdio", version: "1.0.0" }, { capabilities: { tools: {} } })
server.setRequestHandler("tools/list", () =>
server.setRequestHandler(ListToolsRequestSchema, () =>
Promise.resolve({
tools: [
{
@@ -1,11 +1,10 @@
import { Client, LATEST_PROTOCOL_VERSION, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js"
const posts: Array<{ method: string; session: string | null }> = []
const concurrent = process.env.MCP_RECOVERY_CONCURRENT === "1"
let initializeCount = 0
let pingCount = 0
let replacementStarted!: () => void
const replacement = new Promise<void>((resolve) => (replacementStarted = resolve))
const server = Bun.serve({
port: 0,
async fetch(request) {
@@ -18,7 +17,6 @@ const server = Bun.serve({
if (message.method === "initialize") {
initializeCount++
if (initializeCount === 2) replacementStarted()
return Response.json(
{
jsonrpc: "2.0",
@@ -36,8 +34,7 @@ const server = Bun.serve({
if (message.method === "notifications/initialized") return new Response(null, { status: 202 })
pingCount++
if (concurrent && pingCount === 2) await replacement
if (pingCount <= (concurrent ? 2 : 1)) return new Response("Session not found", { status: 404 })
if (pingCount === 1) return new Response("Session not found", { status: 404 })
return Response.json({ jsonrpc: "2.0", id: message.id, result: {} })
},
})
@@ -45,8 +42,7 @@ const client = new Client({ name: "test", version: "1" })
try {
await client.connect(new StreamableHTTPClientTransport(server.url))
if (concurrent) await Promise.all([client.ping(), client.ping()])
else await client.ping()
await client.ping()
process.stdout.write(JSON.stringify(posts))
} finally {
await client.close()
+15 -51
View File
@@ -1,6 +1,8 @@
import { describe, expect, test } from "bun:test"
import { Client, InMemoryTransport } from "@modelcontextprotocol/client"
import { Server } from "@modelcontextprotocol/server"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
import { McpCatalog } from "@/mcp/catalog"
import { Effect } from "effect"
@@ -28,10 +30,7 @@ describe("McpCatalog.convertTool", () => {
test("preserves content when structuredContent is also present", async () => {
const content = [{ type: "image" as const, mimeType: "image/png", data: "AAAA" }]
const structuredContent = { image: { mimeType: "image/png", data: "AAAA" } }
const converted = McpCatalog.convertTool({
def: mcpTool(),
client: clientReturning({ content, structuredContent }),
})
const converted = McpCatalog.convertTool(mcpTool(), clientReturning({ content, structuredContent }))
const output = await converted.execute?.({}, options)
@@ -40,10 +39,7 @@ describe("McpCatalog.convertTool", () => {
test("falls back to structuredContent only when content is absent", async () => {
const structuredContent = { results: [{ title: "one" }] }
const converted = McpCatalog.convertTool({
def: mcpTool(),
client: clientReturning({ content: [], structuredContent }),
})
const converted = McpCatalog.convertTool(mcpTool(), clientReturning({ content: [], structuredContent }))
const output = await converted.execute?.({}, options)
@@ -54,52 +50,18 @@ describe("McpCatalog.convertTool", () => {
})
})
describe("McpCatalog.callTool", () => {
test("forwards the request options", async () => {
const controller = new AbortController()
let request: unknown
let options: unknown
const client = {
callTool: async (input: unknown, config: unknown) => {
request = input
options = config
return { content: [] }
},
} as unknown as Client
await McpCatalog.callTool({ def: mcpTool(), client, timeout: 123 }, { value: true }, controller.signal)
expect(request).toEqual({ name: "screenshot", arguments: { value: true } })
expect(options).toMatchObject({ resetTimeoutOnProgress: true, signal: controller.signal, timeout: 123 })
expect(typeof (options as { onprogress?: unknown }).onprogress).toBe("function")
})
test("throws text returned by an MCP tool error", async () => {
const client = clientReturning({
isError: true,
content: [
{ type: "image", data: "AAAA", mimeType: "image/png" },
{ type: "text", text: "first" },
{ type: "text", text: "second" },
],
})
await expect(McpCatalog.callTool({ def: mcpTool(), client }, {})).rejects.toThrow("first\n\nsecond")
})
})
test("preserves output schema validation across paginated tool discovery", async () => {
const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } })
server.setRequestHandler("tools/list", ({ params }) =>
server.setRequestHandler(ListToolsRequestSchema, ({ params }) =>
Promise.resolve(
params?.cursor === "page-2"
? {
tools: [
{
name: "second",
inputSchema: { type: "object" as const },
inputSchema: { type: "object" },
outputSchema: {
type: "object" as const,
type: "object",
properties: { value: { type: "number" } },
required: ["value"],
},
@@ -110,9 +72,9 @@ test("preserves output schema validation across paginated tool discovery", async
tools: [
{
name: "first",
inputSchema: { type: "object" as const },
inputSchema: { type: "object" },
outputSchema: {
type: "object" as const,
type: "object",
properties: { value: { type: "string" } },
required: ["value"],
},
@@ -122,7 +84,7 @@ test("preserves output schema validation across paginated tool discovery", async
},
),
)
server.setRequestHandler("tools/call", ({ params }) =>
server.setRequestHandler(CallToolRequestSchema, ({ params }) =>
Promise.resolve({
content: [],
structuredContent: { value: params.name === "first" ? 42 : 1 },
@@ -136,7 +98,9 @@ test("preserves output schema validation across paginated tool discovery", async
try {
const tools = await Effect.runPromise(McpCatalog.defs(client))
expect(tools?.map((tool) => tool.name)).toEqual(["first", "second"])
await expect(client.callTool({ name: "first", arguments: {} })).rejects.toThrow(/output schema/i)
await expect(client.callTool({ name: "first", arguments: {} })).rejects.toThrow(
"Structured content does not match the tool's output schema",
)
} finally {
await Promise.all([client.close(), server.close()])
}
+4 -21
View File
@@ -1,5 +1,7 @@
import { describe, expect } from "bun:test"
import { Server, WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server"
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Effect } from "effect"
import { testEffect } from "../lib/effect"
@@ -11,7 +13,7 @@ const serve = Effect.acquireRelease(
Effect.promise(async () => {
const requests: Headers[] = []
const protocol = new Server({ name: "headers", version: "1.0.0" }, { capabilities: { tools: {} } })
protocol.setRequestHandler("tools/list", () => Promise.resolve({ tools: [] }))
protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] }))
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
enableJsonResponse: true,
@@ -36,11 +38,6 @@ const serve = Effect.acquireRelease(
(server) => Effect.promise(server.close),
)
const serveUnauthorized = Effect.acquireRelease(
Effect.sync(() => Bun.serve({ port: 0, fetch: () => new Response("Unauthorized", { status: 401 }) })),
(server) => Effect.sync(() => server.stop(true)),
)
describe("mcp.headers", () => {
it.instance("headers are passed to transports when oauth is enabled (default)", () =>
Effect.gen(function* () {
@@ -102,18 +99,4 @@ describe("mcp.headers", () => {
}
}),
)
it.instance("reports 401 as failed when oauth is explicitly disabled", () =>
Effect.gen(function* () {
const server = yield* serveUnauthorized
const mcp = yield* MCP.Service
const result = yield* mcp.add("unauthorized-no-oauth", {
type: "remote",
url: server.url.toString(),
oauth: false,
})
expect(result.status).toMatchObject({ "unauthorized-no-oauth": { status: "failed" } })
}),
)
})
+18 -13
View File
@@ -1,12 +1,18 @@
import path from "node:path"
import { pathToFileURL } from "node:url"
import { expect } from "bun:test"
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
import {
Server,
WebStandardStreamableHTTPServerTransport,
GetPromptRequestSchema,
ListPromptsRequestSchema,
ListResourcesRequestSchema,
ListResourceTemplatesRequestSchema,
ListToolsRequestSchema,
ReadResourceRequestSchema,
type ServerCapabilities,
type Tool,
} from "@modelcontextprotocol/server"
} from "@modelcontextprotocol/sdk/types.js"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Cause, Effect, Exit } from "effect"
import type { MCP as MCPNS } from "../../src/mcp/index"
@@ -60,35 +66,35 @@ function lifecycleServer(input?: { capabilities?: ServerCapabilities; instructio
})
if (capabilities.tools) {
protocol.setRequestHandler("tools/list", (request) => {
protocol.setRequestHandler(ListToolsRequestSchema, (request) => {
if (state.listToolsError) throw new Error(state.listToolsError)
const page = state.toolPages?.[request.params?.cursor ?? "initial"]
return Promise.resolve({ tools: page?.items ?? state.tools, nextCursor: page?.nextCursor })
})
}
if (capabilities.prompts) {
protocol.setRequestHandler("prompts/list", (request) => {
protocol.setRequestHandler(ListPromptsRequestSchema, (request) => {
const page = state.promptPages?.[request.params?.cursor ?? "initial"]
return Promise.resolve({ prompts: page?.items ?? state.prompts, nextCursor: page?.nextCursor })
})
protocol.setRequestHandler("prompts/get", async () => {
protocol.setRequestHandler(GetPromptRequestSchema, async () => {
if (state.requestDelay) await Bun.sleep(state.requestDelay)
return { messages: [{ role: "user", content: { type: "text", text: "prompt result" } }] }
})
}
if (capabilities.resources) {
protocol.setRequestHandler("resources/list", (request) => {
protocol.setRequestHandler(ListResourcesRequestSchema, (request) => {
const page = state.resourcePages?.[request.params?.cursor ?? "initial"]
return Promise.resolve({ resources: page?.items ?? state.resources, nextCursor: page?.nextCursor })
})
protocol.setRequestHandler("resources/templates/list", (request) => {
protocol.setRequestHandler(ListResourceTemplatesRequestSchema, (request) => {
const page = state.resourceTemplatePages?.[request.params?.cursor ?? "initial"]
return Promise.resolve({
resourceTemplates: page?.items ?? state.resourceTemplates,
nextCursor: page?.nextCursor,
})
})
protocol.setRequestHandler("resources/read", async (request) => {
protocol.setRequestHandler(ReadResourceRequestSchema, async (request) => {
if (state.requestDelay) await Bun.sleep(state.requestDelay)
return { contents: [{ uri: request.params.uri, text: "resource result" }] }
})
@@ -139,7 +145,7 @@ function hangingLifecycleServer() {
return Effect.acquireRelease(
Effect.promise(async () => {
const protocol = new Server({ name: "mcp-lifecycle-hanging", version: "1.0.0" }, { capabilities: { tools: {} } })
protocol.setRequestHandler("tools/list", () => Promise.resolve({ tools: [] }))
protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] }))
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
enableJsonResponse: true,
@@ -278,7 +284,7 @@ it.instance("follows cursors when listing tools, prompts, resources, and templat
}),
)
it.instance("accepts empty cursors and terminates on repeated cursors", () =>
it.instance("accepts empty cursors and rejects repeated cursors", () =>
Effect.gen(function* () {
const empty = yield* lifecycleServer({ capabilities: { prompts: {} } })
empty.state.promptPages = {
@@ -295,8 +301,7 @@ it.instance("accepts empty cursors and terminates on repeated cursors", () =>
const result = yield* mcp.add("looping-cursor", remote(looping.url))
expect(Object.keys(yield* mcp.prompts())).toEqual(["empty-cursor:prompt-one", "empty-cursor:prompt-two"])
expect(statusName(result.status, "looping-cursor")).toBe("connected")
expect(Object.keys(yield* mcp.tools())).toEqual([])
expect(statusName(result.status, "looping-cursor")).toBe("failed")
}),
)
@@ -1,5 +1,7 @@
import { expect } from "bun:test"
import { Server, WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server"
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
import { ListResourcesRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { FSUtil } from "@opencode-ai/core/fs-util"
@@ -38,13 +40,13 @@ function serveOAuthMcp(options: OAuthMcpOptions = {}) {
let requiresAuth = true
if (capabilities === "tools") {
protocol.setRequestHandler("tools/list", () => {
protocol.setRequestHandler(ListToolsRequestSchema, () => {
listToolsCalls++
return Promise.resolve({ tools: [{ name: "test_tool", inputSchema: { type: "object" } }] })
})
}
if (capabilities === "resources") {
protocol.setRequestHandler("resources/list", () =>
protocol.setRequestHandler(ListResourcesRequestSchema, () =>
Promise.resolve({ resources: [{ name: "docs", uri: "docs://readme" }] }),
)
}
@@ -1,5 +1,7 @@
import { expect } from "bun:test"
import { Server, WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server"
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Deferred, Effect, Layer, Option } from "effect"
import { Config } from "../../src/config/config"
@@ -39,7 +41,7 @@ const serveOAuthMcp = Effect.acquireRelease(
Effect.promise(async () => {
const requests: Array<{ pathname: string; headers: Headers }> = []
const protocol = new Server({ name: "oauth-browser", version: "1.0.0" }, { capabilities: { tools: {} } })
protocol.setRequestHandler("tools/list", () => Promise.resolve({ tools: [] }))
protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] }))
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
enableJsonResponse: true,
@@ -74,7 +74,7 @@ describe("McpOAuthCallback.ensureRunning", () => {
const response = await fetch(`${redirectUri}?code=code&state=success`)
expect(response.status).toBe(200)
expect(await callback).toEqual({ code: "code", iss: undefined })
expect(await callback).toBe("code")
expect(McpOAuthCallback.isRunning()).toBe(false)
})
@@ -1,13 +1,7 @@
import { test, expect, describe } from "bun:test"
import {
McpOAuthPendingProvider,
McpOAuthProvider,
OAUTH_CALLBACK_PORT,
OAUTH_CALLBACK_PATH,
} from "../../src/mcp/oauth-provider"
import { determineScope } from "@modelcontextprotocol/sdk/client/auth.js"
import { McpOAuthProvider, OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "../../src/mcp/oauth-provider"
import type { McpAuth } from "../../src/mcp/auth"
import type { OAuthDiscoveryState } from "@modelcontextprotocol/client"
import { Effect } from "effect"
// Stub auth — only synchronous getters are exercised in these tests
const stubAuth = {} as McpAuth.Interface
@@ -67,59 +61,42 @@ describe("McpOAuthProvider.clientMetadata", () => {
})
})
describe("McpOAuthProvider.discoveryState", () => {
const discoveryState: OAuthDiscoveryState = {
authorizationServerUrl: "https://auth.example.com",
authorizationServerMetadata: {
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
response_types_supported: ["code"],
},
resourceMetadataUrl: "https://mcp.example.com/.well-known/oauth-protected-resource",
}
test("persists discovery state through the auth store", async () => {
let saved: OAuthDiscoveryState | undefined
const auth = {
...stubAuth,
get: () => Effect.succeed(saved ? { discoveryState: saved } : undefined),
updateDiscoveryState: (_name: string, value: OAuthDiscoveryState) => Effect.sync(() => void (saved = value)),
clearDiscoveryState: () => Effect.sync(() => void (saved = undefined)),
} satisfies McpAuth.Interface
const provider = new McpOAuthProvider(
"test-server",
"https://mcp.example.com/mcp",
{},
{ onRedirect: async () => {} },
auth,
)
await provider.saveDiscoveryState(discoveryState)
expect(await provider.discoveryState()).toEqual(discoveryState)
await provider.invalidateCredentials("discovery")
expect(await provider.discoveryState()).toBeUndefined()
describe("MCP OAuth scope selection", () => {
test("adds offline_access when the authorization server and client support refresh tokens", () => {
expect(
determineScope({
resourceMetadata: {
resource: "https://mcp.example.com/mcp",
scopes_supported: ["resource.read"],
},
authServerMetadata: {
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
response_types_supported: ["code"],
scopes_supported: ["resource.read", "offline_access"],
},
clientMetadata: makeProvider({}).clientMetadata,
}),
).toBe("resource.read offline_access")
})
test("commits pending discovery state with OAuth credentials", async () => {
let entry: McpAuth.Entry | undefined
const auth = {
...stubAuth,
set: (_name: string, value: McpAuth.Entry) => Effect.sync(() => void (entry = value)),
} satisfies McpAuth.Interface
const provider = new McpOAuthPendingProvider(
"test-server",
"https://mcp.example.com/mcp",
{},
{ onRedirect: async () => {} },
auth,
)
await provider.saveDiscoveryState(discoveryState)
await provider.saveTokens({ access_token: "token", token_type: "Bearer" })
await provider.commit()
expect(entry?.discoveryState).toEqual(discoveryState)
test("does not add unsupported authorization server scopes", () => {
expect(
determineScope({
resourceMetadata: {
resource: "https://mcp.example.com/mcp",
scopes_supported: ["resource.read"],
},
authServerMetadata: {
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
response_types_supported: ["code"],
scopes_supported: ["resource.read"],
},
clientMetadata: makeProvider({}).clientMetadata,
}),
).toBe("resource.read")
})
})
@@ -24,24 +24,4 @@ describe("mcp session recovery", () => {
{ method: "ping", session: "replacement" },
])
})
test("retries a concurrent stale response after recovery completes", async () => {
const child = Bun.spawn([process.execPath, path.join(import.meta.dir, "../fixture/mcp-session-recovery.ts")], {
cwd: path.join(import.meta.dir, "../.."),
env: { ...process.env, MCP_RECOVERY_CONCURRENT: "1" },
stdout: "pipe",
stderr: "pipe",
})
const [code, stdout, stderr] = await Promise.all([
child.exited,
Bun.readableStreamToText(child.stdout),
Bun.readableStreamToText(child.stderr),
])
expect(code, stderr).toBe(0)
const posts = JSON.parse(stdout) as Array<{ method: string; session: string | null }>
expect(posts.filter((post) => post.method === "initialize").map((post) => post.session)).toEqual([null, null])
expect(posts.filter((post) => post.method === "ping" && post.session === "expired")).toHaveLength(2)
expect(posts.filter((post) => post.method === "ping" && post.session === "replacement")).toHaveLength(2)
})
})
@@ -0,0 +1,38 @@
import { expect, test } from "bun:test"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
test("does not reconnect an SSE stream after a JSON-RPC error response", async () => {
let requests = 0
const transport = new StreamableHTTPClientTransport(new URL("http://mcp.invalid"), {
fetch: async () => {
requests += 1
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("id: prime\nretry: 1\ndata:\n\n"))
controller.enqueue(
new TextEncoder().encode(
'id: error\ndata: {"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":1}\n\n',
),
)
controller.close()
},
}),
{ status: 200, headers: { "content-type": "text/event-stream" } },
)
},
reconnectionOptions: {
initialReconnectionDelay: 1,
maxReconnectionDelay: 1,
reconnectionDelayGrowFactor: 1,
maxRetries: 2,
},
})
await transport.start()
await transport.send({ jsonrpc: "2.0", method: "resources/list", id: 1 })
await Bun.sleep(25)
await transport.close()
expect(requests).toBe(1)
})
@@ -0,0 +1,198 @@
import { expect, test } from "bun:test"
import type { Model, Provider } from "@opencode-ai/sdk/v2"
import { ModalPlugin } from "@/plugin/modal/modal"
const BASE_MODEL_ID = "thinkingmachines/Inkling-NVFP4"
const RUNTIME_MODEL_ID = "workspace--inkling.us-west.modal.direct"
const FALLBACK_RUNTIME_MODEL_ID = "workspace--inkling-fallback.us-west.modal.direct"
function makeProvider(baseURL: string): Provider {
const template: Model = {
id: BASE_MODEL_ID,
providerID: "modal",
name: "Inkling",
family: "ling",
api: {
id: BASE_MODEL_ID,
url: baseURL,
npm: "@ai-sdk/openai-compatible",
},
status: "active",
headers: {},
options: {},
cost: {
input: 1,
output: 4,
cache: {
read: 0.2,
write: 0,
},
},
limit: {
context: 128_000,
output: 8_192,
},
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: {
text: true,
audio: true,
image: true,
video: false,
pdf: false,
},
output: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
interleaved: {
field: "reasoning_content",
},
},
release_date: "2026-07-15",
variants: {
fallback: {
reasoningEffort: "fallback",
},
},
}
return {
id: "modal",
name: "Modal",
source: "api",
env: ["MODAL_PROXY_TOKEN"],
options: {},
models: {
[template.id]: template,
},
}
}
test("discovers Modal workspace models", async () => {
const requests: Array<{ authorization: string | null; path: string }> = []
using server = Bun.serve({
port: 0,
fetch(request) {
requests.push({
authorization: request.headers.get("authorization"),
path: new URL(request.url).pathname,
})
return Response.json({
data: [
{
id: RUNTIME_MODEL_ID,
base_model_id: BASE_MODEL_ID,
name: "Thinking Machines: Inkling",
input_modalities: ["text", "image", "audio"],
output_modalities: ["text"],
context_length: 1_048_576,
max_output_length: 262_144,
pricing: {
prompt: "0.0000012",
completion: "0.000005",
input_cache_read: "0.00000027",
},
supported_sampling_parameters: ["temperature"],
supported_features: ["tools", "reasoning"],
reasoning_options: [
{
type: "effort",
values: ["none", "low", "medium", "high", "xhigh", "max"],
},
],
interleaved: {
field: "reasoning_content",
},
},
{
id: FALLBACK_RUNTIME_MODEL_ID,
base_model_id: BASE_MODEL_ID,
},
],
})
},
})
const provider = makeProvider(`${server.url}v1`)
const plugin = await ModalPlugin()
const models = await plugin.provider!.models!(provider, {
auth: {
type: "api",
key: "test-token",
},
})
const model = models[RUNTIME_MODEL_ID]
expect(requests).toEqual([
{
authorization: "Bearer test-token",
path: "/v1/models",
},
])
expect(Object.keys(models)).toEqual([RUNTIME_MODEL_ID, FALLBACK_RUNTIME_MODEL_ID])
expect(model.api).toEqual({
id: RUNTIME_MODEL_ID,
url: `${server.url}v1`,
npm: "@ai-sdk/openai-compatible",
})
expect(model.family).toBe("ling")
expect(model.capabilities.interleaved).toEqual({ field: "reasoning_content" })
expect(model.capabilities.input).toEqual({
text: true,
audio: true,
image: true,
video: false,
pdf: false,
})
expect(model.cost).toEqual({
input: 1.2,
output: 5,
cache: {
read: 0.27,
write: 0,
},
})
expect(model.limit).toEqual({
context: 1_048_576,
output: 262_144,
})
expect(model.variants).toEqual({
none: { reasoningEffort: "none" },
low: { reasoningEffort: "low" },
medium: { reasoningEffort: "medium" },
high: { reasoningEffort: "high" },
xhigh: { reasoningEffort: "xhigh" },
max: { reasoningEffort: "max" },
})
expect(models[FALLBACK_RUNTIME_MODEL_ID].variants).toEqual({
fallback: {
reasoningEffort: "fallback",
},
})
})
test("hides Modal models when discovery fails", async () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response(null, { status: 503 })
},
})
const plugin = await ModalPlugin()
const models = await plugin.provider!.models!(makeProvider(`${server.url}v1`), {
auth: {
type: "api",
key: "test-token",
},
})
expect(models).toEqual({})
})
@@ -252,6 +252,8 @@ it.instance(
const provider = providers[ProviderV2.ID.make("custom-provider")]
expect(provider.models["deepseek-r1"].capabilities.interleaved).toEqual({ field: "reasoning_content" })
expect(provider.models["deepseek-details"].capabilities.interleaved).toEqual({ field: "reasoning_details" })
expect(provider.models["deepseek-text"].capabilities.interleaved).toEqual({ field: "reasoning_text" })
expect(provider.models["custom-reasoning"].capabilities.interleaved).toEqual({ field: "vendor_reasoning" })
expect(provider.models["custom-model"].capabilities.interleaved).toBe(false)
expect(
providers[ProviderV2.ID.make("custom-anthropic-provider")].models["deepseek-r1"].capabilities.interleaved,
@@ -267,6 +269,8 @@ it.instance(
models: {
"deepseek-r1": { name: "DeepSeek R1" },
"deepseek-details": { name: "DeepSeek Details", interleaved: { field: "reasoning_details" } },
"deepseek-text": { name: "DeepSeek Text", interleaved: "reasoning_text" },
"custom-reasoning": { name: "Custom Reasoning", interleaved: { field: "vendor_reasoning" } },
"custom-model": { name: "Custom Model" },
},
options: { apiKey: "custom-key" },
@@ -1462,6 +1466,7 @@ test("models.dev normalization fills required response fields", () => {
id: "gpt-5.4",
name: "GPT-5.4",
family: "gpt",
interleaved: "reasoning_text",
cost: { input: 2.5, output: 15 },
limit: { context: 1_050_000, input: 922_000, output: 128_000 },
},
@@ -1474,6 +1479,7 @@ test("models.dev normalization fills required response fields", () => {
expect(model.capabilities.reasoning).toBe(false)
expect(model.capabilities.attachment).toBe(false)
expect(model.capabilities.toolcall).toBe(true)
expect(model.capabilities.interleaved).toEqual({ field: "reasoning_text" })
expect(model.release_date).toBe("")
})
@@ -8,14 +8,15 @@ import { Session } from "@/session/session"
import { Tool } from "@/tool/tool"
import * as Truncate from "@/tool/truncate"
import { MessageID, SessionID } from "@/session/schema"
import { Server } from "@modelcontextprotocol/server"
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
import type { Client } from "@modelcontextprotocol/sdk/client/index.js"
import {
InMemoryTransport,
CallToolRequestSchema,
LATEST_PROTOCOL_VERSION,
type CallToolResult,
type Client,
ListToolsRequestSchema,
type Tool as MCPToolDef,
} from "@modelcontextprotocol/client"
} from "@modelcontextprotocol/sdk/types.js"
import { Cause, Effect, Exit, Layer } from "effect"
const PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
@@ -99,7 +100,7 @@ const TOOL_DEFS: MCPToolDef[] = [
},
] as MCPToolDef[]
function handleCall(name: string, args: Record<string, unknown>): CallToolResult {
function handleCall(name: string, args: Record<string, unknown>) {
switch (name) {
case "get_text":
return { content: [{ type: "text", text: `hello ${args.name}` }] }
@@ -121,8 +122,8 @@ let description: string
async function buildTool() {
const server = new Server({ name: SERVER, version: "1.0.0" }, { capabilities: { tools: {} } })
server.setRequestHandler("tools/list", async () => ({ tools: TOOL_DEFS }))
server.setRequestHandler("tools/call", async (req) =>
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_DEFS }))
server.setRequestHandler(CallToolRequestSchema, async (req) =>
handleCall(req.params.name, (req.params.arguments ?? {}) as Record<string, unknown>),
)
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { CODE_MODE_TOOL, CodeModeTool, Parameters, describeCatalog } from "@/tool/code-mode"
import type { Tool as MCPToolDef } from "@modelcontextprotocol/client"
import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js"
import type { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { Agent } from "@/agent/agent"
import { MCP } from "@/mcp"
+1 -1
View File
@@ -20,7 +20,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { MCP } from "@/mcp"
import type { Tool as MCPToolDef } from "@modelcontextprotocol/client"
import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js"
const configLayer = TestConfig.layer({
directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])),
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.18.8",
"version": "1.18.10",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.18.8",
"version": "1.18.10",
"type": "module",
"license": "MIT",
"scripts": {
-2
View File
@@ -2328,7 +2328,6 @@ export class Auth2 extends HeyApiClient {
directory?: string
workspace?: string
code?: string
iss?: string
},
options?: Options<never, ThrowOnError>,
) {
@@ -2341,7 +2340,6 @@ export class Auth2 extends HeyApiClient {
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "body", key: "code" },
{ in: "body", key: "iss" },
],
},
],
+7 -4
View File
@@ -1768,9 +1768,13 @@ export type ProviderConfig = {
temperature?: boolean
tool_call?: boolean
interleaved?:
| true
| boolean
| "reasoning"
| "reasoning_content"
| "reasoning_text"
| string
| {
field: "reasoning" | "reasoning_content" | "reasoning_details"
field: "reasoning" | "reasoning_content" | "reasoning_text" | string
}
cost?: {
input: number
@@ -2057,7 +2061,7 @@ export type Model = {
interleaved:
| boolean
| {
field: "reasoning" | "reasoning_content" | "reasoning_details"
field: "reasoning" | "reasoning_content" | "reasoning_text" | string
}
}
cost: {
@@ -8563,7 +8567,6 @@ export type McpAuthStartResponse = McpAuthStartResponses[keyof McpAuthStartRespo
export type McpAuthCallbackData = {
body?: {
code: string
iss?: string
}
path: {
name: string
+26 -9
View File
@@ -3360,9 +3360,6 @@
"properties": {
"code": {
"type": "string"
},
"iss": {
"type": "string"
}
},
"required": ["code"],
@@ -20823,15 +20820,28 @@
"interleaved": {
"anyOf": [
{
"type": "boolean",
"enum": [true]
"type": "boolean"
},
{
"type": "string",
"enum": ["reasoning", "reasoning_content", "reasoning_text"]
},
{
"type": "string"
},
{
"type": "object",
"properties": {
"field": {
"type": "string",
"enum": ["reasoning", "reasoning_content", "reasoning_details"]
"anyOf": [
{
"type": "string",
"enum": ["reasoning", "reasoning_content", "reasoning_text"]
},
{
"type": "string"
}
]
}
},
"required": ["field"],
@@ -21644,8 +21654,15 @@
"type": "object",
"properties": {
"field": {
"type": "string",
"enum": ["reasoning", "reasoning_content", "reasoning_details"]
"anyOf": [
{
"type": "string",
"enum": ["reasoning", "reasoning_content", "reasoning_text"]
},
{
"type": "string"
}
]
}
},
"required": ["field"],
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/server",
"version": "1.18.8",
"version": "1.18.10",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/session-ui",
"version": "1.18.8",
"version": "1.18.10",
"private": true,
"type": "module",
"license": "MIT",
@@ -0,0 +1,44 @@
@keyframes prompt-input-v2-attachments-fade-left {
from {
visibility: hidden;
}
to {
visibility: visible;
}
}
@keyframes prompt-input-v2-attachments-fade-right {
from {
visibility: visible;
}
to {
visibility: hidden;
}
}
[data-component="prompt-input-v2-attachments"] {
timeline-scope: --prompt-input-v2-attachments-scroll;
[data-slot^="prompt-attachments-fade-"] {
visibility: hidden;
}
}
@supports (animation-timeline: --prompt-input-v2-attachments-scroll) and
(timeline-scope: --prompt-input-v2-attachments-scroll) {
[data-component="prompt-input-v2-attachments"] [data-slot="prompt-attachments-scroll"] {
scroll-timeline: --prompt-input-v2-attachments-scroll x;
}
[data-component="prompt-input-v2-attachments"] [data-slot="prompt-attachments-fade-left"] {
animation: prompt-input-v2-attachments-fade-left linear both;
animation-timeline: --prompt-input-v2-attachments-scroll;
animation-range: 0 0.1px;
}
[data-component="prompt-input-v2-attachments"] [data-slot="prompt-attachments-fade-right"] {
animation: prompt-input-v2-attachments-fade-right linear both;
animation-timeline: --prompt-input-v2-attachments-scroll;
animation-range: calc(100% - 1.1px) calc(100% - 1px);
}
}
@@ -74,6 +74,7 @@ export type PromptInputV2AttachmentConfig = {
directory: () => string
isDialogActive: () => boolean
warn: () => void
duplicate: () => void
onError: (error: unknown) => void
readClipboardImage?: () => Promise<File | null>
getPathForFile?: (file: File) => string
@@ -94,7 +95,7 @@ export function createPromptInputV2Attachments(
if (!editor) return
return { prompt, cursor: prompt.cursor() ?? cursorPosition(editor) }
}
const add = async (file: File, toast = true, target = capture()) => {
const add = async (file: File, toast = true, target = capture(), clipboard = false) => {
if (!target) return false
const mime = await attachmentMime(file)
if (!mime) {
@@ -103,11 +104,28 @@ export function createPromptInputV2Attachments(
}
const url = await dataUrl(file, mime)
if (!url) return false
const sourcePath = input.getPathForFile?.(file) || undefined
// Native clipboard images arrive with a fresh timestamped filename on every paste, so identical
// clipboard content is matched on bytes alone.
const duplicate = target.prompt
.current()
.some(
(part) =>
part.type === "image" &&
part.dataUrl === url &&
(sourcePath
? part.sourcePath === sourcePath
: !part.sourcePath && (clipboard || part.filename === file.name)),
)
if (duplicate) {
input.duplicate()
return true
}
const attachment: PromptInputV2Attachment = {
type: "image",
id: globalThis.crypto?.randomUUID?.() ?? Math.random().toString(16).slice(2),
filename: file.name,
sourcePath: input.getPathForFile?.(file) || undefined,
sourcePath,
mime,
dataUrl: url,
}
@@ -141,7 +159,7 @@ export function createPromptInputV2Attachments(
const plainText = clipboardData.getData("text/plain") ?? ""
if (input.readClipboardImage && !plainText) {
const file = await input.readClipboardImage()
if (file && (await add(file, true, target))) return
if (file && (await add(file, true, target, true))) return
}
if (!plainText) return
const text = plainText.includes("\r") ? plainText.replace(/\r\n?/g, "\n") : plainText
@@ -21,6 +21,7 @@ import type {
PromptInputV2Suggestion,
} from "./types"
import type { PromptInputV2Interaction, PromptInputV2SelectControl } from "./interaction"
import "./attachments.css"
export type {
PromptInputV2Attachment,
@@ -39,6 +40,7 @@ export type PromptInputV2Props = {
borderUnderlay?: boolean
class?: string
modelControl?: JSX.Element
variantControlVisible?: boolean
attachKeybind?: string[]
attachShortcut?: string
}
@@ -106,7 +108,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
<form
data-component="prompt-input-v2"
data-dock-border-underlay={props.borderUnderlay ? "v2" : undefined}
class="group/prompt-input relative min-h-[96px] w-full rounded-xl bg-v2-background-bg-base"
class="group/prompt-input relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base"
classList={{
"shadow-[var(--v2-elevation-raised)]": !props.borderUnderlay,
"border border-v2-icon-icon-info border-dashed": state.drag === "active",
@@ -232,7 +234,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
>
{props.modelControl}
</Show>
<Show when={view.variant}>
<Show when={(props.variantControlVisible ?? true) && view.variant}>
{(control) => (
<Show when={control().options().length > 1}>
<PromptInputV2ConfiguredSelect
@@ -377,7 +379,7 @@ export function PromptInputV2Attachments(props: {
}) {
return (
<Show when={props.attachments.length > 0 || (props.comments?.length ?? 0) > 0}>
<div data-slot="prompt-attachments" class="relative">
<div data-component="prompt-input-v2-attachments" data-slot="prompt-attachments" class="relative">
<div
data-slot="prompt-attachments-scroll"
class="flex flex-nowrap gap-2 overflow-x-auto no-scrollbar px-2 pt-2 pb-1"
@@ -443,8 +445,14 @@ export function PromptInputV2Attachments(props: {
)}
</For>
</div>
<div class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-base),transparent)]" />
<div class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-base),transparent)]" />
<div
data-slot="prompt-attachments-fade-left"
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-base),transparent)]"
/>
<div
data-slot="prompt-attachments-fade-right"
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-base),transparent)]"
/>
</div>
</Show>
)
@@ -63,7 +63,7 @@
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 16px 8px 8px;
padding: 12px 16px 12px 8px;
flex-shrink: 0;
}
@@ -91,7 +91,7 @@
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-filter"] {
padding: 0 8px 8px;
padding: 0 8px 4px;
flex-shrink: 0;
min-width: 0;
box-sizing: border-box;
@@ -109,10 +109,71 @@
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] {
flex: 1;
min-height: 0;
timeline-scope: --session-review-v2-sidebar-tree-scroll;
}
@keyframes session-review-v2-sidebar-tree-fade-top {
from {
visibility: hidden;
}
to {
visibility: visible;
}
}
@keyframes session-review-v2-sidebar-tree-fade-bottom {
from {
visibility: visible;
}
to {
visibility: hidden;
}
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::before,
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::after {
content: "";
position: absolute;
inset-inline: 0;
z-index: 10;
height: 16px;
pointer-events: none;
visibility: hidden;
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::before {
top: 0;
background: linear-gradient(to bottom, var(--v2-background-bg-base), transparent);
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::after {
bottom: 0;
background: linear-gradient(to top, var(--v2-background-bg-base), transparent);
}
@supports (animation-timeline: --session-review-v2-sidebar-tree-scroll) and
(timeline-scope: --session-review-v2-sidebar-tree-scroll) {
[data-component="session-review-v2-sidebar-root"]
[data-slot="session-review-v2-sidebar-tree"]
.scroll-view__viewport {
scroll-timeline: --session-review-v2-sidebar-tree-scroll y;
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::before {
animation: session-review-v2-sidebar-tree-fade-top linear both;
animation-timeline: --session-review-v2-sidebar-tree-scroll;
animation-range: 0 0.1px;
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::after {
animation: session-review-v2-sidebar-tree-fade-bottom linear both;
animation-timeline: --session-review-v2-sidebar-tree-scroll;
animation-range: calc(100% - 1.1px) calc(100% - 1px);
}
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport {
padding: 0 8px 12px;
padding: 4px 8px 12px;
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] .scroll-view__thumb {
@@ -244,7 +305,7 @@
}
[data-component="icon-button-v2"][data-variant="ghost"].session-review-v2-sidebar-toggle[aria-expanded="true"] {
background-color: var(--v2-overlay-simple-overlay-pressed);
background-color: var(--v2-background-bg-layer-02);
}
[data-component="session-review-v2"] [data-slot="session-review-v2-file-header"] {
@@ -165,11 +165,13 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
}
const prev = () => {
return props.files[fileIndex() - 1]
if (!canCycle()) return
return props.files[(fileIndex() - 1 + props.files.length) % props.files.length]
}
const next = () => {
return props.files[fileIndex() + 1]
if (!canCycle()) return
return props.files[(fileIndex() + 1) % props.files.length]
}
const canCycle = () => props.files.length > 0
@@ -183,15 +185,15 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
props.onSelectFile(file)
}
// The prev/next tooltips advertise < and >; keep the keys working while the
// Keep the advertised arrow keys working while the
// pane is mounted, but never while typing in an input or comment editor.
makeEventListener(document, "keydown", (event) => {
if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey) return
if (event.key !== "<" && event.key !== ">") return
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return
const target = event.target
if (target instanceof HTMLElement && (target.isContentEditable || target.closest("input, textarea, select"))) return
if (!props.hasDiffs || !canCycle()) return
const file = event.key === "<" ? prev() : next()
const file = event.key === "ArrowLeft" ? prev() : next()
if (!file) return
event.preventDefault()
cycle(file)
@@ -219,7 +221,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
value={
<>
{i18n.t("ui.sessionReviewV2.previousFile")}
<KeybindV2 keys={["<"]} variant="neutral" />
<KeybindV2 keys={[""]} variant="neutral" />
</>
}
>
@@ -239,7 +241,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
value={
<>
{i18n.t("ui.sessionReviewV2.nextFile")}
<KeybindV2 keys={[">"]} variant="neutral" />
<KeybindV2 keys={[""]} variant="neutral" />
</>
}
>
@@ -331,6 +333,7 @@ export function SessionReviewV2SidebarToggle(props: { opened: boolean; disabled?
class="session-review-v2-sidebar-toggle"
aria-label={i18n.t("ui.sessionReviewV2.toggleSidebar")}
aria-expanded={props.opened}
data-expanded={props.opened ? "" : undefined}
disabled={props.disabled}
onClick={props.onToggle}
icon={<Icon name="filetree" />}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.18.8",
"version": "1.18.10",
"type": "module",
"license": "MIT",
"scripts": {

Some files were not shown because too many files have changed in this diff Show More