diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 037020c03af..15f865b742f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -219,7 +219,6 @@ jobs: build-electron: needs: - - build-cli - version if: github.repository == 'anomalyco/opencode' continue-on-error: false @@ -316,10 +315,7 @@ jobs: env: OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} - OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }} RUST_TARGET: ${{ matrix.settings.target }} - GH_TOKEN: ${{ github.token }} - GITHUB_RUN_ID: ${{ github.run_id }} - name: Build run: bun run build diff --git a/bun.lock b/bun.lock index 4f969c2f1e9..b4571f1314a 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.18.6", + "version": "1.18.8", "dependencies": { "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", @@ -96,7 +96,7 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.18.6", + "version": "1.18.8", "bin": { "lildax": "./bin/lildax.cjs", }, @@ -144,7 +144,7 @@ }, "packages/codemode": { "name": "@opencode-ai/codemode", - "version": "1.18.6", + "version": "1.18.8", "dependencies": { "acorn": "8.15.0", "effect": "catalog:", @@ -158,7 +158,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.18.6", + "version": "1.18.8", "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.6", + "version": "1.18.8", "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.6", + "version": "1.18.8", "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.6", + "version": "1.18.8", "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.6", + "version": "1.18.8", "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.6", + "version": "1.18.8", "bin": { "opencode": "./bin/opencode", }, @@ -339,7 +339,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.11.1", + "gitlab-ai-provider": "6.12.0", "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.6", + "version": "1.18.8", "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.6", + "version": "1.18.8", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -449,7 +449,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.18.6", + "version": "1.18.8", "dependencies": { "effect": "catalog:", }, @@ -461,7 +461,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.18.6", + "version": "1.18.8", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", @@ -493,7 +493,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.18.6", + "version": "1.18.8", "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.6", + "version": "1.18.8", "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.6", + "version": "1.18.8", "dependencies": { "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", @@ -559,7 +559,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.18.6", + "version": "1.18.8", "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/sdk": "1.29.0", + "@modelcontextprotocol/client": "2.0.0-beta.5", "@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.11.1", + "gitlab-ai-provider": "6.12.0", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -666,6 +666,7 @@ }, "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:*", @@ -690,7 +691,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.18.6", + "version": "1.18.8", "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "workspace:*", @@ -766,7 +767,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.18.6", + "version": "1.18.8", "dependencies": { "cross-spawn": "catalog:", }, @@ -781,7 +782,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.18.6", + "version": "1.18.8", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -796,7 +797,7 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "1.18.6", + "version": "1.18.8", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz", @@ -841,7 +842,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.18.6", + "version": "1.18.8", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -854,7 +855,7 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.18.6", + "version": "1.18.8", "dependencies": { "@ibm/plex": "6.4.1", "@kobalte/core": "catalog:", @@ -888,7 +889,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.18.6", + "version": "1.18.8", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -907,7 +908,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.18.6", + "version": "1.18.8", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -949,7 +950,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.18.6", + "version": "1.18.8", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -976,7 +977,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.18.6", + "version": "1.18.8", "dependencies": { "@kobalte/core": "catalog:", "@pierre/diffs": "catalog:", @@ -1027,7 +1028,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.18.6", + "version": "1.18.8", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -1070,9 +1071,9 @@ ], "patchedDependencies": { "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.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", "@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", + "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.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", @@ -1674,8 +1675,6 @@ "@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,7 +1829,11 @@ "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], - "@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=="], + "@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=="], "@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=="], @@ -3390,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@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + "content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], @@ -3402,8 +3405,6 @@ "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=="], @@ -3708,8 +3709,6 @@ "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 +3853,7 @@ "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], - "gitlab-ai-provider": ["gitlab-ai-provider@6.11.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-SJ6f5qa7P8md6lPrserryER3zerLkrezlnqqYQ2AbvDPpHLbwtbyk0FYJ5kNRcmbI80i/VMcsMBP0YIRdc3ucQ=="], + "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=="], "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,8 +4093,6 @@ "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=="], @@ -5004,8 +5001,6 @@ "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=="], @@ -5954,15 +5949,13 @@ "@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], - "@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/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], - "@modelcontextprotocol/sdk/hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="], + "@modelcontextprotocol/client/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "@modelcontextprotocol/sdk/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + "@modelcontextprotocol/core/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=="], + "@modelcontextprotocol/server/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "@npmcli/config/ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], @@ -6238,6 +6231,8 @@ "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=="], @@ -6320,6 +6315,8 @@ "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=="], @@ -6468,8 +6465,6 @@ "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=="], @@ -6798,58 +6793,28 @@ "@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=="], @@ -6862,14 +6827,10 @@ "@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=="], @@ -7378,10 +7339,6 @@ "@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=="], @@ -7390,20 +7347,14 @@ "@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=="], diff --git a/nix/hashes.json b/nix/hashes.json index db0259a67e7..1b85cf68b21 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-2suqkFAUeT2YgppKqP14s0C+GE3TR3uNKOOD8vWEiAg=", - "aarch64-linux": "sha256-cJ/XYTodYmgs9ZFQhHQbfZPqxjIMmEvz062OsX6/6p4=", - "aarch64-darwin": "sha256-tKyqHeI6r1aHEk4vBS39+eA6wh9KcIwsa2ctcktKkEc=", - "x86_64-darwin": "sha256-1u7+dNjRcqKSvUTCEarVJ9fMrRUyg9y+4seZAm6Fmus=" + "x86_64-linux": "sha256-RFek0QoEEjsgbqmTE/SxQAmPtYyzs0IPR2ugFn5Okrs=", + "aarch64-linux": "sha256-BmAxapY1YrAFn7mVq3/6A9+6Au5UIvSqBboHMkyJH3I=", + "aarch64-darwin": "sha256-Sx3bGWQqLlgoa/RudJxanjSzhFRNklckT2ffnO2I5F4=", + "x86_64-darwin": "sha256-CMOhiisHNowg06qadvgg4K+60zrynglwiT0qKYQ4NiA=" } } diff --git a/package.json b/package.json index 5fd0f1d51ad..662f9a4c3c2 100644 --- a/package.json +++ b/package.json @@ -154,8 +154,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" + "@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" } } diff --git a/packages/app/e2e/regression/open-file-expand-folder.spec.ts b/packages/app/e2e/regression/open-file-expand-folder.spec.ts new file mode 100644 index 00000000000..37739d2fbbc --- /dev/null +++ b/packages/app/e2e/regression/open-file-expand-folder.spec.ts @@ -0,0 +1,132 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/OpenFileExpand" +const projectID = "proj_open_file_expand" +const sessionID = "ses_open_file_expand" +const title = "Open file expand" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + +test.use({ viewport: { width: 1440, height: 900 } }) + +test("expands a folder whose path has a trailing Windows separator", async ({ page }) => { + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "open-file-expand", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "test" }, + }, + sessions: [ + { + id: sessionID, + slug: sessionID, + projectID, + directory, + title, + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + vcsDiff: [], + fileList: (path) => { + if (path === "frontend\\" || path === "frontend") { + return [ + { + name: "app.ts", + path: "frontend\\app.ts", + absolute: `${directory}/frontend/app.ts`, + type: "file" as const, + ignored: false, + }, + ] + } + if (path) return [] + return [ + { + name: "frontend", + path: "frontend\\", + absolute: `${directory}/frontend`, + type: "directory" as const, + ignored: false, + }, + { + name: "README.md", + path: "README.md", + absolute: `${directory}/README.md`, + type: "file" as const, + ignored: false, + }, + ] + }, + fileContent: (path) => ({ type: "text", content: `contents:${path}` }), + pageMessages: () => ({ items: [] }), + }) + + await page.addInitScript( + ({ directory, server, sessionID }) => { + localStorage.setItem( + "settings.v3", + JSON.stringify({ general: { newLayoutDesigns: true, shouldDisplayTabsToast: false } }), + ) + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + localStorage.setItem( + "opencode.global.dat:layout", + JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }), + ) + localStorage.setItem( + "opencode.global.dat:review-panel-v2", + JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }), + ) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([{ type: "session", server, sessionId: sessionID }]), + ) + }, + { directory, server, sessionID }, + ) + + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) + await expectSessionTitle(page, title) + + const panel = page.locator("#review-panel") + await panel.getByRole("button", { name: "Open file" }).click() + await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") + + const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]') + await expect(sidebar).toBeVisible() + + const frontendRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend"]') + await expect(frontendRow).toBeVisible() + await expect(frontendRow).toHaveAttribute("aria-expanded", "false") + await frontendRow.click() + await expect(frontendRow).toHaveAttribute("aria-expanded", "true") + + const appRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend/app.ts"]') + await expect(appRow).toBeVisible() + await appRow.click() + await expect(panel.getByRole("tab", { name: "app.ts" })).toHaveAttribute("data-selected", "") + await expect(panel.getByText("contents:frontend/app.ts", { exact: true })).toBeVisible() +}) diff --git a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts index ae61b2acbfd..b969b590d89 100644 --- a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts +++ b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts @@ -5,6 +5,8 @@ import { currentSession } from "../utils/mock-server" const server = "http://127.0.0.1:4096" const sessionA = session("ses_tab_a", "Tab A session") const sessionB = session("ses_tab_b", "Tab B session") +const sessionC = session("ses_tab_c", "Tab C session") +const unresolvedSessionID = "ses_tab_unresolved" test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => { await mockServer(page) @@ -40,6 +42,34 @@ test("pressing mouse down on a tab navigates before mouse up", async ({ page }) await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) }) +test("keyboard navigation follows the visible tab order", async ({ page }) => { + await mockServer(page) + await page.addInitScript( + ({ server, sessionA, unresolved, sessionC }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([ + { type: "session", server, sessionId: sessionA }, + { type: "session", server, sessionId: unresolved }, + { type: "session", server, sessionId: sessionC }, + ]), + ) + }, + { server, sessionA: sessionA.id, unresolved: unresolvedSessionID, sessionC: sessionC.id }, + ) + + const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}` + const hrefC = `/server/${base64Encode(server)}/session/${sessionC.id}` + await page.goto(hrefA) + await expect(page.locator("[data-titlebar-tab-slot]:visible")).toHaveCount(2) + await expect(page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefC}"])`)).toBeVisible() + + await page.keyboard.press("Control+Alt+ArrowRight") + + await expect(page).toHaveURL(new RegExp(`${hrefC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) +}) + function session(id: string, title: string) { return { id, @@ -53,10 +83,12 @@ function session(id: string, title: string) { } async function mockServer(page: Page) { - const sessions = [sessionA, sessionB] + const sessions = [sessionA, sessionB, sessionC] await page.route("**/*", async (route) => { const url = new URL(route.request().url()) if (url.origin !== server) return route.fallback() + if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname)) + return new Promise(() => {}) if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") return sse(route) if (url.pathname === "/global/health") return json(route, { healthy: true }) diff --git a/packages/app/package.json b/packages/app/package.json index d2219740b12..73840b2dbf7 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.18.6", + "version": "1.18.8", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 19aa8ce89e7..8291bfc9474 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -28,7 +28,6 @@ import { Switch, } from "solid-js" import { createStore, produce } from "solid-js/store" -import { useQueryClient } from "@tanstack/solid-query" import { useParams } from "@solidjs/router" import { Link } from "@/components/link" import { useServerSDK } from "@/context/server-sdk" @@ -38,7 +37,6 @@ import { useSettings } from "@/context/settings" import { popularProviders, useProviders } from "@/hooks/use-providers" import { CustomProviderForm } from "./dialog-custom-provider" import { decode64 } from "@/utils/base64" -import { pathKey } from "@/utils/path-key" const CUSTOM_ID = "_custom" type ConnectMethod = Extract @@ -386,7 +384,6 @@ function ProviderConnection(props: { const dialog = useDialog() const serverSync = useServerSync() const serverSDK = useServerSDK() - const queryClient = useQueryClient() const params = useParams() const language = useLanguage() const settings = useSettings() @@ -707,9 +704,8 @@ function ProviderConnection(props: { }) async function complete() { - const value = directory() - await queryClient - .refetchQueries(serverSync().queryOptions.providers(value ? pathKey(value) : null)) + await serverSync() + .refreshProviders() .catch(() => undefined) dialog.close() showToast({ diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index 4fb7891ec95..9066f72434e 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -1,15 +1,5 @@ import { Popover as Kobalte } from "@kobalte/core/popover" -import { - Component, - ComponentProps, - createEffect, - createMemo, - For, - JSX, - onCleanup, - Show, - ValidComponent, -} from "solid-js" +import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js" import { createStore } from "solid-js/store" import { useLocal } from "@/context/local" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -29,6 +19,7 @@ import { ModelTooltip } from "./model-tooltip" import { useLanguage } from "@/context/language" import { decode64 } from "@/utils/base64" import { handleDocumentSearchKeydown } from "@/utils/search-keydown" +import { createMenuDismissController } from "@/utils/menu-dismiss-controller" import { createEventListener } from "@solid-primitives/event-listener" import { matchesModelSearch } from "./dialog-select-model-search" @@ -122,14 +113,13 @@ const ModelList: Component<{ } type ModelSelectorTriggerProps = Omit, "as" | "ref"> +type ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => JSX.Element type Dismiss = "escape" | "outside" | "select" | "manage" | "provider" export function ModelSelectorPopover(props: { provider?: string model?: ModelState - children?: JSX.Element - triggerAs?: ValidComponent - triggerProps?: ModelSelectorTriggerProps + trigger: ModelSelectorTrigger onClose?: (cause: "escape" | "select") => void }) { const [store, setStore] = createStore<{ @@ -174,9 +164,7 @@ export function ModelSelectorPopover(props: { placement="top-start" gutter={4} > - - {props.children} - + void }) { - const model = props.model ?? useLocal().model - const language = useLanguage() const dialog = useDialog() - const [store, setStore] = createStore({ open: false, search: "", active: "" }) - let searchRef: HTMLInputElement | undefined - let contentRef: HTMLDivElement | undefined - let restoreTrigger = true + const controller = createModelSelectorController({ + model: props.model, + provider: () => props.provider, + onSelect: () => props.onClose?.(), + }) + return ( + { + void import("./dialog-manage-models").then((module) => { + void dialog.show(() => ) + }) + }} + onClose={() => props.onClose?.()} + /> + ) +} + +function createModelSelectorController(input: { + provider: () => string | undefined + model?: ModelState + onSelect: () => void +}) { + const model = input.model ?? useLocal().model const allModels = createMemo(() => model .list() .filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id })) - .filter((item) => (props.provider ? item.provider.id === props.provider : true)), + .filter((item) => (input.provider() ? item.provider.id === input.provider() : true)), ) - const models = createMemo(() => { - const search = store.search.trim() - const filtered = search - ? allModels().filter((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name])) - : allModels() - return [...filtered].sort((a, b) => a.name.localeCompare(b.name)) - }) - const groups = createMemo(() => { - const byProvider = new Map() - for (const item of models()) { - byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item]) - } - return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups) - }) - const keys = () => [...models().map(modelKey), manageKey] - const current = () => { - const value = model.current() - return value ? `${value.provider.id}:${value.id}` : undefined + return { + models: (search: string) => { + const query = search.trim() + const filtered = query + ? allModels().filter((item) => matchesModelSearch(query, [item.name, item.id, item.provider.name])) + : allModels() + return [...filtered].sort((a, b) => a.name.localeCompare(b.name)) + }, + groups: (models: ModelItem[]) => { + const byProvider = new Map() + for (const item of models) { + byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item]) + } + return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups) + }, + current: () => { + const value = model.current() + return value ? modelKey(value) : undefined + }, + select: (item: ModelItem) => { + model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true }) + input.onSelect() + }, } +} + +function ModelSelectorPopoverV2View(props: { + trigger: ModelSelectorTrigger + models: (search: string) => ModelItem[] + groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[] + current: () => string | undefined + select: (item: ModelItem) => void + onManage: () => void + onClose: () => void +}) { + const language = useLanguage() + const [store, setStore] = createStore({ open: false, search: "", active: "" }) + let searchRef: HTMLInputElement | undefined + let contentRef: HTMLDivElement | undefined + const dismiss = createMenuDismissController(() => contentRef) + + const models = createMemo(() => props.models(store.search)) + const groups = createMemo(() => props.groups(models())) + const keys = () => [...models().map(modelKey), manageKey] const initialActive = () => { - const selected = current() + const selected = props.current() const options = keys() if (selected && options.includes(selected)) return selected return options[0] ?? "" } const activeItem = () => store.active ? contentRef?.querySelector(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined - const afterClose = (callback: () => void) => { - const complete = () => { - if (contentRef?.isConnected) { - requestAnimationFrame(complete) - return - } - requestAnimationFrame(() => requestAnimationFrame(callback)) - } - requestAnimationFrame(complete) - } const setOpen = (open: boolean) => { if (open) { - restoreTrigger = true + dismiss.allowTriggerRestore() setStore({ open: true, active: initialActive() }) setTimeout(() => requestAnimationFrame(() => { @@ -308,23 +331,15 @@ export function ModelSelectorPopoverV2(props: { } setStore({ open: false, search: "", active: "" }) } - const select = (item: ModelItem) => { - model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true }) - props.onClose?.() - } const selectModel = (item: ModelItem) => { - restoreTrigger = false + dismiss.preventTriggerRestore() setOpen(false) - afterClose(() => select(item)) + dismiss.afterClose(() => props.select(item)) } const manage = () => { - restoreTrigger = false + dismiss.preventTriggerRestore() setOpen(false) - afterClose(() => { - void import("./dialog-manage-models").then((x) => { - dialog.show(() => ) - }) - }) + dismiss.afterClose(props.onManage) } const selectActive = () => { const item = models().find((item) => modelKey(item) === store.active) @@ -343,10 +358,7 @@ export function ModelSelectorPopoverV2(props: { queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" })) } const setSearch = (value: string) => { - const search = value.trim() - const first = [...allModels()] - .sort((a, b) => a.name.localeCompare(b.name)) - .find((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name])) + const first = props.models(value)[0] setStore({ search: value, active: first ? modelKey(first) : manageKey }) } @@ -362,18 +374,14 @@ export function ModelSelectorPopoverV2(props: { return ( - - {props.children} - + (contentRef = el)} + ref={(element: HTMLDivElement) => (contentRef = element)} class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none" - onPointerDownOutside={() => (restoreTrigger = false)} - onFocusOutside={() => (restoreTrigger = false)} - onCloseAutoFocus={(event) => { - if (!restoreTrigger) event.preventDefault() - }} + onPointerDownOutside={dismiss.preventTriggerRestore} + onFocusOutside={dismiss.preventTriggerRestore} + onCloseAutoFocus={dismiss.onCloseAutoFocus} >
@@ -393,9 +401,9 @@ export function ModelSelectorPopoverV2(props: { event.stopPropagation() if (event.key === "Escape") { event.preventDefault() - restoreTrigger = false + dismiss.preventTriggerRestore() setOpen(false) - afterClose(() => props.onClose?.()) + dismiss.afterClose(props.onClose) return } if (event.altKey || event.metaKey) return @@ -445,7 +453,7 @@ export function ModelSelectorPopoverV2(props: { {group.items[0].provider.name} - + {(item) => ( { diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx index 4ea8ad1ee06..44d9d48d386 100644 --- a/packages/app/src/components/prompt-input-v2.tsx +++ b/packages/app/src/components/prompt-input-v2.tsx @@ -5,7 +5,7 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { Icon } from "@opencode-ai/ui/v2/icon" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import type { Prompt, ReferenceInfo } from "@opencode-ai/sdk/v2/client" +import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client" import { createEffect, createMemo, on, Show } from "solid-js" import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model" import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2" @@ -37,11 +37,9 @@ export type PromptInputV2ComposerProps = { class?: string controller: PromptInputV2ComposerController borderUnderlay?: boolean - edit?: PromptInputProps["edit"] - onEditLoaded?: PromptInputProps["onEditLoaded"] } -export type PromptInputV2ControllerProps = Omit +export type PromptInputV2ControllerProps = Omit export type PromptInputV2ComposerController = PromptInputV2Interaction & { readonly model: PromptInputProps["controls"]["model"] } @@ -51,9 +49,6 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) { const command = useCommand() const language = useLanguage() - useCommands(props) - useEditHandler(props) - return (
{ - const prompt = usePrompt() - - createEffect( - on( - () => props.edit?.id, - (id) => { - const edit = props.edit - if (!id || !edit) return - prompt.context.items().forEach((item) => prompt.context.remove(item.key)) - edit.context.forEach((item) => - prompt.context.add({ - type: item.type, - path: item.path, - selection: item.selection, - comment: item.comment, - commentID: item.commentID, - commentOrigin: item.commentOrigin, - preview: item.preview, - }), - ) - props.controller.dispatch({ type: "mode.normal" }) - props.controller.resetHistory() - prompt.set(edit.prompt, promptLength(edit.prompt)) - props.controller.restoreFocus() - props.onEditLoaded?.() - }, - { defer: true }, - ), - ) -} - -const useCommands = (props: PromptInputV2ComposerProps) => { - const command = useCommand() - const language = useLanguage() - - command.register("prompt-input", () => [ - { - id: "file.attach", - title: language.t("prompt.action.attachFile"), - category: language.t("command.category.file"), - keybind: "mod+u", - disabled: props.controller.state.mode !== "normal", - onSelect: () => props.controller.attach(), - }, - { - id: "prompt.mode.shell", - title: language.t("command.prompt.mode.shell"), - category: language.t("command.category.session"), - keybind: "mod+shift+x", - disabled: props.controller.state.mode === "shell", - onSelect: () => props.controller.dispatch({ type: "mode.shell" }), - }, - { - id: "prompt.mode.normal", - title: language.t("command.prompt.mode.normal"), - category: language.t("command.category.session"), - keybind: "mod+shift+e", - disabled: props.controller.state.mode === "normal", - onSelect: () => props.controller.dispatch({ type: "mode.normal" }), - }, - ]) -} - export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController { const sdk = useSDK() const sync = useSync() @@ -472,6 +403,62 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): }, }) Object.defineProperty(controller, "model", { get: () => props.controls.model }) + + command.register("prompt-input", () => [ + { + id: "file.attach", + title: language.t("prompt.action.attachFile"), + category: language.t("command.category.file"), + keybind: "mod+u", + disabled: controller.state.mode !== "normal", + onSelect: () => controller.attach(), + }, + { + id: "prompt.mode.shell", + title: language.t("command.prompt.mode.shell"), + category: language.t("command.category.session"), + keybind: "mod+shift+x", + disabled: controller.state.mode === "shell", + onSelect: () => controller.dispatch({ type: "mode.shell" }), + }, + { + id: "prompt.mode.normal", + title: language.t("command.prompt.mode.normal"), + category: language.t("command.category.session"), + keybind: "mod+shift+e", + disabled: controller.state.mode === "normal", + onSelect: () => controller.dispatch({ type: "mode.normal" }), + }, + ]) + + createEffect( + on( + () => props.edit?.id, + (id) => { + const edit = props.edit + if (!id || !edit) return + prompt.context.items().forEach((item) => prompt.context.remove(item.key)) + edit.context.forEach((item) => + prompt.context.add({ + type: item.type, + path: item.path, + selection: item.selection, + comment: item.comment, + commentID: item.commentID, + commentOrigin: item.commentOrigin, + preview: item.preview, + }), + ) + controller.dispatch({ type: "mode.normal" }) + controller.resetHistory() + prompt.set(edit.prompt, promptLength(edit.prompt)) + controller.restoreFocus() + props.onEditLoaded?.() + }, + { defer: true }, + ), + ) + return controller as PromptInputV2ComposerController } @@ -535,20 +522,22 @@ function PromptInputV2ModelControl(props: { > ( + + {content()} + + )} onClose={props.onClose} - > - {content()} - + /> diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index f70cb399289..7923e18ee2a 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1723,29 +1723,31 @@ export const PromptInput: Component = (props) => { > ( + + )} onClose={restoreFocus} - > - - - - - {props.controls.model.selection.current()?.name ?? - language.t("dialog.model.select.title")} - - - + />
diff --git a/packages/app/src/components/prompt-project-selector.tsx b/packages/app/src/components/prompt-project-selector.tsx index c1109659617..b84afdb56bf 100644 --- a/packages/app/src/components/prompt-project-selector.tsx +++ b/packages/app/src/components/prompt-project-selector.tsx @@ -18,6 +18,7 @@ import { useLanguage } from "@/context/language" import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers" import { pathKey } from "@/utils/path-key" import { handleDocumentSearchKeydown } from "@/utils/search-keydown" +import { createMenuDismissController } from "@/utils/menu-dismiss-controller" export type PromptProject = { name?: string @@ -197,8 +198,8 @@ export function PromptProjectSelector(props: { }) { const [triggerReady, setTriggerReady] = createSignal(false) let contentRef: HTMLDivElement | undefined + const dismiss = createMenuDismissController(() => contentRef) let triggerFrame: number | undefined - let restoreTrigger = true // Floating UI requires a connected anchor; route transitions can construct this trigger before adoption. const setTriggerRef = (element: HTMLButtonElement) => { @@ -221,25 +222,15 @@ export function PromptProjectSelector(props: { props.controller.active() ? contentRef?.querySelector(`[data-option-key="${CSS.escape(props.controller.active())}"]`) : undefined - const afterClose = (callback: () => void) => { - const complete = () => { - if (contentRef?.isConnected) { - requestAnimationFrame(complete) - return - } - requestAnimationFrame(() => requestAnimationFrame(callback)) - } - requestAnimationFrame(complete) - } const selectProject = (project: PromptProject) => { - restoreTrigger = false + dismiss.preventTriggerRestore() props.controller.setOpen(false) - afterClose(() => props.controller.select(project)) + dismiss.afterClose(() => props.controller.select(project)) } const selectAction = (server?: string) => { - restoreTrigger = false + dismiss.preventTriggerRestore() props.controller.setOpen(false) - afterClose(() => props.controller.add(server)) + dismiss.afterClose(() => props.controller.add(server)) } const selectActive = () => { const project = props.controller.activeProject() @@ -267,7 +258,7 @@ export function PromptProjectSelector(props: { ) .filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap")) .findLast((element) => element.offsetParent !== null) - restoreTrigger = false + dismiss.preventTriggerRestore() target?.focus() queueMicrotask(() => { if (props.controller.open()) props.controller.setOpen(false) @@ -291,7 +282,10 @@ export function PromptProjectSelector(props: { placement={props.placement ?? "bottom"} gutter={4} modal={false} - onOpenChange={(open) => props.controller.setOpen(open)} + onOpenChange={(open) => { + if (open) dismiss.allowTriggerRestore() + props.controller.setOpen(open) + }} > @@ -300,11 +294,9 @@ export function PromptProjectSelector(props: { id="prompt-project-menu" class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none" onOpenAutoFocus={(event) => event.preventDefault()} - onPointerDownOutside={() => (restoreTrigger = false)} - onFocusOutside={() => (restoreTrigger = false)} - onCloseAutoFocus={(event) => { - if (!restoreTrigger) event.preventDefault() - }} + onPointerDownOutside={dismiss.preventTriggerRestore} + onFocusOutside={dismiss.preventTriggerRestore} + onCloseAutoFocus={dismiss.onCloseAutoFocus} >
diff --git a/packages/app/src/components/session/index.ts b/packages/app/src/components/session/index.ts index 9e44cea17be..c4fdb2ff383 100644 --- a/packages/app/src/components/session/index.ts +++ b/packages/app/src/components/session/index.ts @@ -4,4 +4,3 @@ export { SortableTab, FileVisual } from "./session-sortable-tab" export { SortableTabV2 } from "./session-sortable-tab-v2" export { SortableTerminalTab } from "./session-sortable-terminal-tab" export { NewSessionView } from "./session-new-view" -export { NewSessionDesignView } from "./session-new-design-view" diff --git a/packages/app/src/components/session/session-new-design-view.tsx b/packages/app/src/components/session/session-new-design-view.tsx deleted file mode 100644 index a324c64fa6d..00000000000 --- a/packages/app/src/components/session/session-new-design-view.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { JSX } from "solid-js" -import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2" -import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout" - -export function NewSessionDesignView(props: { children: JSX.Element }) { - return ( -
-
-
- -
{props.children}
-
-
-
- ) -} diff --git a/packages/app/src/components/settings-keybinds.tsx b/packages/app/src/components/settings-keybinds.tsx index cdec8b435c8..ce74be45864 100644 --- a/packages/app/src/components/settings-keybinds.tsx +++ b/packages/app/src/components/settings-keybinds.tsx @@ -30,6 +30,8 @@ type KeybindMeta = { type KeybindMap = Record type CommandContext = ReturnType +type LanguageContext = ReturnType +type SettingsContext = ReturnType const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"] @@ -122,7 +124,7 @@ function keybinds(value: unknown): KeybindMap { return value as KeybindMap } -function listFor(command: CommandContext, map: KeybindMap, palette: string) { +function listFor(command: Pick, map: KeybindMap, palette: string) { const out = new Map() out.set(PALETTE_ID, { title: palette, group: "General" }) @@ -262,7 +264,274 @@ function useKeyCapture(input: { }) } +export function createKeybindSettingsController( + input: { + command: Pick + settings: { + current: { keybinds: unknown } + keybinds: Pick + } + target?: Document + notify?: (toast: { title: string; description: string }) => void + }, + language: Pick = useLanguage(), +) { + const [store, setStore] = createStore({ active: null as string | null }) + const overrides = createMemo(() => keybinds(input.settings.current.keybinds)) + const list = createMemo(() => { + language.locale() + return listFor(input.command, overrides(), language.t("command.palette")) + }) + const grouped = createMemo(() => groupedFor(list())) + const title = (id: string) => list().get(id)?.title ?? "" + const effective = (id: string) => { + if (id === PALETTE_ID) return input.settings.keybinds.get(id) ?? DEFAULT_PALETTE_KEYBIND + + const custom = input.settings.keybinds.get(id) + if (typeof custom === "string") return custom + + const live = input.command.options.find((item) => item.id === id) + if (live?.keybind) return live.keybind + return input.command.catalog.find((item) => item.id === id)?.keybind + } + const used = createMemo(() => { + const value = new Map() + + for (const id of list().keys()) { + for (const signature of signatures(effective(id))) { + const items = value.get(signature) + if (items) { + items.push({ id, title: title(id) }) + continue + } + value.set(signature, [{ id, title: title(id) }]) + } + } + + return value + }) + const stop = () => { + if (!store.active) return + setStore("active", null) + input.command.keybinds(true) + } + const toggle = (id: string) => { + if (store.active === id) { + stop() + return + } + if (store.active) stop() + setStore("active", id) + input.command.keybinds(false) + } + const notify = input.notify ?? ((toast: { title: string; description: string }) => showToast(toast)) + + const handle = (event: KeyboardEvent) => { + const id = store.active + if (!id) return + + event.preventDefault() + event.stopPropagation() + event.stopImmediatePropagation() + + if (event.key === "Escape") { + stop() + return + } + + const clear = + (event.key === "Backspace" || event.key === "Delete") && + !event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.shiftKey + if (clear) { + input.settings.keybinds.set(id, "none") + stop() + return + } + + const next = recordKeybind(event) + if (!next) return + + const conflicts = new Map() + for (const signature of signatures(next)) { + for (const item of used().get(signature) ?? []) { + if (item.id === id) continue + conflicts.set(item.id, item.title) + } + } + + if (conflicts.size > 0) { + notify({ + title: language.t("settings.shortcuts.conflict.title"), + description: language.t("settings.shortcuts.conflict.description", { + keybind: formatKeybind(next, language.t), + titles: [...conflicts.values()].join(", "), + }), + }) + return + } + + input.settings.keybinds.set(id, next) + stop() + } + + const target = input.target ?? (typeof document === "object" ? document : undefined) + if (target) makeEventListener(target, "keydown", handle, { capture: true }) + + onCleanup(() => { + if (store.active) input.command.keybinds(true) + }) + + return { + catalog: { + groups: GROUPS, + filtered: (query: string) => + filteredFor(query, list(), grouped(), (id) => formatKeybind(effective(id) ?? "", language.t)), + title, + keybind: (id: string) => formatKeybind(effective(id) ?? "", language.t), + }, + capture: { + active: () => store.active, + toggle, + }, + settings: { + hasOverrides: () => Object.values(overrides()).some((value) => typeof value === "string"), + reset: () => { + stop() + input.settings.keybinds.resetAll() + notify({ + title: language.t("settings.shortcuts.reset.toast.title"), + description: language.t("settings.shortcuts.reset.toast.description"), + }) + }, + }, + } +} + +function SettingsKeybindsV2() { + const command = useCommand() + const settings = useSettings() + const controller = createKeybindSettingsController({ + command, + settings, + }) + + return ( + + ) +} + +function SettingsKeybindsV2View(props: { + groups: KeybindGroup[] + filtered: (query: string) => Map + title: (id: string) => string + keybind: (id: string) => string + active: () => string | null + onCapture: (id: string) => void + hasOverrides: () => boolean + onReset: () => void +}) { + const language = useLanguage() + const [store, setStore] = createStore({ filter: "" }) + const filtered = createMemo(() => props.filtered(store.filter)) + const hasResults = createMemo(() => props.groups.some((group) => (filtered().get(group)?.length ?? 0) > 0)) + + return ( + <> +
+
+

{language.t("settings.shortcuts.title")}

+ + {language.t("settings.shortcuts.reset.button")} + +
+ +
+
+
+ + {(group) => ( + 0}> +
+

{language.t(groupKey[group])}

+ + + {(id) => ( +
+ {props.title(id)} + +
+ )} +
+
+
+
+ )} +
+ +
+ {language.t("settings.shortcuts.search.empty")} + "{store.filter}" +
+
+
+
+ + ) +} + export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => { + if (props.v2) return + const command = useCommand() const language = useLanguage() const settings = useSettings() @@ -476,78 +745,37 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => { ) return ( - -
-
-
-

{language.t("settings.shortcuts.title")}

- -
- -
- - setStore("filter", v)} - placeholder={language.t("settings.shortcuts.search.placeholder")} - spellcheck={false} - autocorrect="off" - autocomplete="off" - autocapitalize="off" - class="flex-1" - /> - - setStore("filter", "")} /> - -
-
-
- {groups} -
- } - > - <> -
-
-

{language.t("settings.shortcuts.title")}

- +
+
+
+
+

{language.t("settings.shortcuts.title")}

+
-
-
{groups}
- - +
+ {groups} +
) } diff --git a/packages/app/src/components/settings-v2/general-controller-behavior.ts b/packages/app/src/components/settings-v2/general-controller-behavior.ts new file mode 100644 index 00000000000..64bd4c11875 --- /dev/null +++ b/packages/app/src/components/settings-v2/general-controller-behavior.ts @@ -0,0 +1,70 @@ +import { onCleanup } from "solid-js" + +export type ShellOption = { + path: string + name: string + acceptable: boolean +} + +export type ShellSelectOption = { + id: string + value: string + name: string + terminalOnly: boolean +} + +export function createShellOptions(input: { shells: ShellOption[]; current: string | undefined }) { + const counts = input.shells.reduce((result, shell) => { + result.set(shell.name, (result.get(shell.name) ?? 0) + 1) + return result + }, new Map()) + const options: ShellSelectOption[] = [ + { id: "auto", value: "", name: "", terminalOnly: false }, + ...input.shells.map((shell) => { + const ambiguous = (counts.get(shell.name) ?? 0) > 1 + const name = ambiguous ? shell.path : shell.name + return { + id: shell.path, + value: ambiguous ? shell.path : shell.name, + name, + terminalOnly: !shell.acceptable, + } + }), + ] + if (input.current && !options.some((option) => option.value === input.current)) { + options.push({ id: input.current, value: input.current, name: input.current, terminalOnly: false }) + } + return options +} + +export function createSoundPreviewController(player: (id: string | undefined) => Promise<(() => void) | undefined>) { + let cleanup: (() => void) | undefined + let timeout: ReturnType | undefined + let run = 0 + + const stop = () => { + run += 1 + cleanup?.() + clearTimeout(timeout) + cleanup = undefined + timeout = undefined + } + const play = (id: string | undefined) => { + stop() + if (!id) return + const current = ++run + timeout = setTimeout(() => { + timeout = undefined + void player(id).then((next) => { + if (run === current) { + cleanup = next + return + } + next?.() + }) + }, 100) + } + + onCleanup(stop) + return { play, stop } +} diff --git a/packages/app/src/components/settings-v2/general-controllers.test.ts b/packages/app/src/components/settings-v2/general-controllers.test.ts new file mode 100644 index 00000000000..09c8d21bd53 --- /dev/null +++ b/packages/app/src/components/settings-v2/general-controllers.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test, vi } from "bun:test" +import { createRoot } from "solid-js" +import { createShellOptions, createSoundPreviewController } from "./general-controller-behavior" + +describe("settings v2 controllers", () => { + test("normalizes shell names and preserves an unavailable configured shell", () => { + expect( + createShellOptions({ + shells: [ + { path: "/bin/bash", name: "bash", acceptable: true }, + { path: "/opt/bash", name: "bash", acceptable: false }, + { path: "/bin/zsh", name: "zsh", acceptable: true }, + ], + current: "fish", + }), + ).toEqual([ + { id: "auto", value: "", name: "", terminalOnly: false }, + { id: "/bin/bash", value: "/bin/bash", name: "/bin/bash", terminalOnly: false }, + { id: "/opt/bash", value: "/opt/bash", name: "/opt/bash", terminalOnly: true }, + { id: "/bin/zsh", value: "zsh", name: "zsh", terminalOnly: false }, + { id: "fish", value: "fish", name: "fish", terminalOnly: false }, + ]) + }) + + test("debounces previews and stops owned audio on disposal", async () => { + vi.useFakeTimers() + try { + const played: string[] = [] + const stopped: string[] = [] + const owned = createRoot((dispose) => ({ + dispose, + preview: createSoundPreviewController(async (id) => { + played.push(id ?? "") + return () => stopped.push(id ?? "") + }), + })) + + owned.preview.play("first") + vi.advanceTimersByTime(99) + expect(played).toEqual([]) + + owned.preview.play("second") + vi.advanceTimersByTime(100) + await Promise.resolve() + expect(played).toEqual(["second"]) + + owned.dispose() + expect(stopped).toEqual(["second"]) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/packages/app/src/components/settings-v2/general-controllers.ts b/packages/app/src/components/settings-v2/general-controllers.ts new file mode 100644 index 00000000000..ae77fa332f2 --- /dev/null +++ b/packages/app/src/components/settings-v2/general-controllers.ts @@ -0,0 +1,173 @@ +import { createMemo, createResource, onMount, type Accessor } from "solid-js" +import type { ColorScheme } from "@opencode-ai/ui/theme/context" +import { useTheme } from "@opencode-ai/ui/theme/context" +import { usePermission } from "@/context/permission" +import { useServerSDK } from "@/context/server-sdk" +import { useServerSync } from "@/context/server-sync" +import { + monoDefault, + monoFontFamily, + monoInput, + sansDefault, + sansFontFamily, + sansInput, + terminalDefault, + terminalFontFamily, + terminalInput, + useSettings, +} from "@/context/settings" +import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" +import { createSoundPreviewController, type ShellOption } from "./general-controller-behavior" + +export { createShellOptions, createSoundPreviewController } from "./general-controller-behavior" +export type { ShellOption, ShellSelectOption } from "./general-controller-behavior" + +export function createPermissionScopeController(sessionID: Accessor) { + const permission = usePermission() + const serverSync = useServerSync() + const directory = createMemo(() => { + const id = sessionID() + if (!id) return undefined + return serverSync().session.lineage.peek(id)?.session.directory + }) + + return { + accepting: createMemo(() => { + const id = sessionID() + const dir = directory() + if (!id || !dir) return false + return permission.isAutoAccepting(id, dir) + }), + enabled: createMemo(() => !!directory()), + set: (checked: boolean) => { + const id = sessionID() + const dir = directory() + if (!id || !dir) return + if (checked) return permission.enableAutoAccept(id, dir) + permission.disableAutoAccept(id, dir) + }, + } +} + +export function createShellSettingsController() { + const serverSdk = useServerSDK() + const serverSync = useServerSync() + const [shells] = createResource( + async () => { + const sdk = serverSdk() + if ((await sdk.protocol) === "v1") return (await sdk.client.pty.shells()).data ?? [] + return [] as ShellOption[] + }, + { initialValue: [] as ShellOption[] }, + ) + const current = createMemo(() => serverSync().data.config.shell ?? "") + + return { + shells: () => shells.latest, + current, + select: (value: string) => { + if (value === current()) return + void serverSync().updateConfig({ shell: value }) + }, + } +} + +export function createAppearanceSettingsController() { + const settings = useSettings() + const theme = useTheme() + const themes = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) + + onMount(() => void theme.loadThemes()) + + return { + scheme: { + current: theme.colorScheme, + select: (value: ColorScheme) => theme.setColorScheme(value), + }, + theme: { + options: themes, + current: createMemo(() => themes().find((option) => option.id === theme.themeId())), + select: (option: { id: string } | null) => option && theme.setTheme(option.id), + }, + fonts: { + ui: createMemo(() => ({ + value: sansInput(settings.appearance.uiFont()), + family: sansFontFamily(settings.appearance.uiFont()), + placeholder: sansDefault, + })), + code: createMemo(() => ({ + value: monoInput(settings.appearance.font()), + family: monoFontFamily(settings.appearance.font()), + placeholder: monoDefault, + })), + terminal: createMemo(() => ({ + value: terminalInput(settings.appearance.terminalFont()), + family: terminalFontFamily(settings.appearance.terminalFont()), + placeholder: terminalDefault, + })), + setUI: (value: string) => settings.appearance.setUIFont(value), + setCode: (value: string) => settings.appearance.setFont(value), + setTerminal: (value: string) => settings.appearance.setTerminalFont(value), + }, + } +} + +const noneSound = { id: "none", label: "sound.option.none" } as const +export const soundOptions = [noneSound, ...SOUND_OPTIONS] +export type SoundSelectOption = (typeof soundOptions)[number] + +export function createSoundSettingsController() { + const settings = useSettings() + const preview = createSoundPreviewController(playSoundById) + const channel = ( + enabled: Accessor, + current: Accessor, + setEnabled: (value: boolean) => void, + set: (id: string) => void, + ) => ({ + current: createMemo(() => + enabled() ? (soundOptions.find((option) => option.id === current()) ?? noneSound) : noneSound, + ), + highlight: (option: SoundSelectOption | undefined) => { + if (!option) return + preview.play(option.id === "none" ? undefined : option.id) + }, + select: (option: SoundSelectOption | null) => { + if (!option) return + if (option.id === "none") { + setEnabled(false) + preview.stop() + return + } + setEnabled(true) + set(option.id) + preview.play(option.id) + }, + }) + + return { + agent: channel( + settings.sounds.agentEnabled, + settings.sounds.agent, + (value) => settings.sounds.setAgentEnabled(value), + (id) => settings.sounds.setAgent(id), + ), + permissions: channel( + settings.sounds.permissionsEnabled, + settings.sounds.permissions, + (value) => settings.sounds.setPermissionsEnabled(value), + (id) => settings.sounds.setPermissions(id), + ), + errors: channel( + settings.sounds.errorsEnabled, + settings.sounds.errors, + (value) => settings.sounds.setErrorsEnabled(value), + (id) => settings.sounds.setErrors(id), + ), + } +} + +export type PermissionScopeController = ReturnType +export type ShellSettingsController = ReturnType +export type AppearanceSettingsController = ReturnType +export type SoundSettingsController = ReturnType diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index 0109e02eea2..3aef796eb92 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -1,183 +1,325 @@ -import { Component, Show, createMemo, createResource, onMount } from "solid-js" +import { Component, Show, createMemo, createResource } from "solid-js" import { createMediaQuery } from "@solid-primitives/media" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { SelectV2 } from "@opencode-ai/ui/v2/select-v2" import { Switch } from "@opencode-ai/ui/v2/switch-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" -import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useLanguage } from "@/context/language" -import { usePermission } from "@/context/permission" import { usePlatform } from "@/context/platform" -import { useServerSync } from "@/context/server-sync" -import { useServerSDK } from "@/context/server-sdk" import { useUpdaterAction } from "../updater-action" -import { - monoDefault, - monoFontFamily, - monoInput, - sansDefault, - sansFontFamily, - sansInput, - terminalDefault, - terminalFontFamily, - terminalInput, - type WorkspaceDefaultDestination, - useSettings, -} from "@/context/settings" -import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" +import { type WorkspaceDefaultDestination, useSettings } from "@/context/settings" import { Link } from "../link" import { SettingsListV2 } from "./parts/list" import { SettingsRowV2 } from "./parts/row" import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition" +import { + createAppearanceSettingsController, + createPermissionScopeController, + createShellOptions, + createShellSettingsController, + createSoundSettingsController, + soundOptions, + type AppearanceSettingsController, + type PermissionScopeController, + type ShellSettingsController, + type SoundSettingsController, +} from "./general-controllers" import "./settings-v2.css" -let demoSoundState = { - cleanup: undefined as (() => void) | undefined, - timeout: undefined as NodeJS.Timeout | undefined, - run: 0, +const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"] +const fontSettings = { + ui: { + action: "settings-ui-font", + title: "settings.general.row.uiFont.title", + description: "settings.general.row.uiFont.description", + font: "ui", + input: "setUI", + }, + code: { + action: "settings-code-font", + title: "settings.general.row.font.title", + description: "settings.general.row.font.description", + font: "code", + input: "setCode", + }, + terminal: { + action: "settings-terminal-font", + title: "settings.general.row.terminalFont.title", + description: "settings.general.row.terminalFont.description", + font: "terminal", + input: "setTerminal", + }, +} as const +const soundSettings = { + agent: { + action: "settings-sounds-agent", + title: "settings.general.sounds.agent.title", + description: "settings.general.sounds.agent.description", + }, + permissions: { + action: "settings-sounds-permissions", + title: "settings.general.sounds.permissions.title", + description: "settings.general.sounds.permissions.description", + }, + errors: { + action: "settings-sounds-errors", + title: "settings.general.sounds.errors.title", + description: "settings.general.sounds.errors.description", + }, +} as const + +const PermissionScopeSetting: Component<{ controller: PermissionScopeController }> = (props) => { + const language = useLanguage() + return ( + +
+ +
+
+ ) } -type ThemeOption = { - id: string - name: string +const WorkspaceDestinationSetting: Component = () => { + const language = useLanguage() + const settings = useSettings() + const options = createMemo((): { value: WorkspaceDefaultDestination; label: string }[] => [ + { value: "last-used", label: language.t("settings.workspaces.default.lastUsed") }, + { value: "local", label: language.t("settings.workspaces.default.local") }, + { value: "new", label: language.t("settings.workspaces.default.new") }, + ]) + + return ( + + option.value === settings.workspaces.defaultDestination())} + value={(option) => option.value} + label={(option) => option.label} + placement="bottom-end" + gutter={6} + onSelect={(option) => option && settings.workspaces.setDefaultDestination(option.value)} + /> + + ) } -type ShellOption = { - path: string - name: string - acceptable: boolean +const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => { + const language = useLanguage() + const options = createMemo(() => + createShellOptions({ + shells: props.controller.shells(), + current: props.controller.current(), + }), + ) + return ( + + option.value === props.controller.current()) ?? options()[0]} + placement="bottom-end" + gutter={6} + value={(option) => option.id} + label={(option) => { + if (option.id === "auto") return language.t("settings.general.row.shell.autoDefault") + if (!option.terminalOnly) return option.name + return `${option.name} (${language.t("settings.general.row.shell.terminalOnly")})` + }} + onSelect={(option) => option && props.controller.select(option.value)} + /> + + ) } -type ShellSelectOption = { - id: string - value: string - label: string +const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => { + const language = useLanguage() + return ( +
+

{language.t("settings.general.section.appearance")}

+ + + option === props.controller.scheme.current())} + placement="bottom-end" + gutter={6} + label={(option) => { + if (option === "system") return language.t("theme.scheme.system") + if (option === "light") return language.t("theme.scheme.light") + return language.t("theme.scheme.dark") + }} + onSelect={(option) => option && props.controller.scheme.select(option)} + /> + + + + {language.t("settings.general.row.theme.description")}{" "} + + {language.t("common.learnMore")} + + + } + > + option.id} + label={(option) => option.name} + onSelect={props.controller.theme.select} + /> + + + + + + +
+ ) } -// To prevent audio from overlapping/playing very quickly when navigating the settings menus, -// delay the playback by 100ms during quick selection changes and pause existing sounds. -const stopDemoSound = () => { - demoSoundState.run += 1 - if (demoSoundState.cleanup) { - demoSoundState.cleanup() - } - clearTimeout(demoSoundState.timeout) - demoSoundState.cleanup = undefined +const FontSetting: Component<{ + kind: "ui" | "code" | "terminal" + fonts: AppearanceSettingsController["fonts"] +}> = (props) => { + const language = useLanguage() + const config = () => fontSettings[props.kind] + return ( + +
+ props.fonts[config().input](event.currentTarget.value)} + placeholder={props.fonts[config().font]().placeholder} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + aria-label={language.t(config().title)} + style={{ "font-family": props.fonts[config().font]().family }} + /> +
+
+ ) } -const playDemoSound = (id: string | undefined) => { - stopDemoSound() - if (!id) return +const SoundsSection: Component<{ controller: SoundSettingsController }> = (props) => { + const language = useLanguage() + return ( +
+

{language.t("settings.general.section.sounds")}

+ + + + + +
+ ) +} - const run = ++demoSoundState.run - demoSoundState.timeout = setTimeout(() => { - void playSoundById(id).then((cleanup) => { - if (demoSoundState.run !== run) { - cleanup?.() - return - } - demoSoundState.cleanup = cleanup - }) - }, 100) +const SoundSetting: Component<{ + kind: "agent" | "permissions" | "errors" + channel: SoundSettingsController["agent"] +}> = (props) => { + const language = useLanguage() + const config = () => soundSettings[props.kind] + return ( + + option.id} + label={(option) => language.t(option.label)} + onHighlight={props.channel.highlight} + onSelect={props.channel.select} + placement="bottom-end" + gutter={6} + /> + + ) +} + +const LanguageSetting = () => { + const language = useLanguage() + const options = createMemo(() => + language.locales.map((locale) => ({ + value: locale, + label: language.label(locale), + })), + ) + return ( + + option.value === language.locale())} + value={(option) => option.value} + label={(option) => option.label} + onSelect={(option) => option && language.setLocale(option.value)} + /> + + ) } export const SettingsGeneralV2: Component<{ sessionID?: string }> = (props) => { - const theme = useTheme() const language = useLanguage() - const permission = usePermission() const platform = usePlatform() const dialog = useDialog() const settings = useSettings() - const serverSync = useServerSync() - const serverSdk = useServerSDK() const mobile = createMediaQuery("(max-width: 767px)") - const updater = useUpdaterAction() - - const dir = createMemo(() => { - if (!props.sessionID) return undefined - return serverSync().session.lineage.peek(props.sessionID)?.session.directory - }) - const accepting = createMemo(() => { - const value = dir() - if (!value || !props.sessionID) return false - return permission.isAutoAccepting(props.sessionID, value) - }) - - const toggleAccept = (checked: boolean) => { - const value = dir() - if (!value || !props.sessionID) return - - if (checked) { - permission.enableAutoAccept(props.sessionID, value) - return - } - - permission.disableAutoAccept(props.sessionID, value) - } + const permissionScope = createPermissionScopeController(() => props.sessionID) + const shell = createShellSettingsController() + const appearance = createAppearanceSettingsController() + const sounds = createSoundSettingsController() const desktop = createMemo(() => platform.platform === "desktop") - const themeOptions = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) - - const [shells] = createResource( - async () => { - const sdk = serverSdk() - if ((await sdk.protocol) === "v1") { - return (await sdk.client.pty.shells()).data ?? [] - } - // return (await sdk.api.pty.shells()).data - return [] as ShellOption[] - }, - { initialValue: [] as ShellOption[] }, - ) - const [pinchZoom, { mutate: setPinchZoom }] = createResource( - () => (desktop() && platform.getPinchZoomEnabled ? true : false), + () => desktop() && "getPinchZoomEnabled" in platform, () => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false), { initialValue: false }, ) - onMount(() => { - void theme.loadThemes() - }) - - const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") } - const currentShell = createMemo(() => serverSync().data.config.shell ?? "") - - const shellOptions = createMemo(() => { - const list = shells.latest - const current = serverSync().data.config.shell - - const nameCounts = new Map() - for (const s of list) { - nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1) - } - - const options = [ - autoOption, - ...list.map((s) => { - const ambiguousName = (nameCounts.get(s.name) || 0) > 1 - const text = ambiguousName ? s.path : s.name - const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})` - return { - id: s.path, - // Prefer name over path - "bash" is much cleaner than the explicit full route even when it may change due to PATH. - value: ambiguousName ? s.path : s.name, - label, - } - }), - ] - - if (current && !options.some((o) => o.value === current)) { - options.push({ id: current, value: current, label: current }) - } - - return options - }) - const onPinchZoomChange = (checked: boolean) => { setPinchZoom(checked) const update = platform.setPinchZoomEnabled?.(checked) @@ -185,57 +327,6 @@ export const SettingsGeneralV2: Component<{ void update.catch(() => setPinchZoom(!checked)) } - const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [ - { value: "system", label: language.t("theme.scheme.system") }, - { value: "light", label: language.t("theme.scheme.light") }, - { value: "dark", label: language.t("theme.scheme.dark") }, - ]) - const workspaceDestinationOptions = createMemo((): { value: WorkspaceDefaultDestination; label: string }[] => [ - { value: "last-used", label: language.t("settings.workspaces.default.lastUsed") }, - { value: "local", label: language.t("settings.workspaces.default.local") }, - { value: "new", label: language.t("settings.workspaces.default.new") }, - ]) - - const languageOptions = createMemo(() => - language.locales.map((locale) => ({ - value: locale, - label: language.label(locale), - })), - ) - - const noneSound = { id: "none", label: "sound.option.none" } as const - const soundOptions = [noneSound, ...SOUND_OPTIONS] - const mono = () => monoInput(settings.appearance.font()) - const sans = () => sansInput(settings.appearance.uiFont()) - const terminal = () => terminalInput(settings.appearance.terminalFont()) - - const soundSelectProps = ( - enabled: () => boolean, - current: () => string, - setEnabled: (value: boolean) => void, - set: (id: string) => void, - ) => ({ - options: soundOptions, - current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound, - value: (o: (typeof soundOptions)[number]) => o.id, - label: (o: (typeof soundOptions)[number]) => language.t(o.label), - onHighlight: (option: (typeof soundOptions)[number] | undefined) => { - if (!option) return - playDemoSound(option.id === "none" ? undefined : option.id) - }, - onSelect: (option: (typeof soundOptions)[number] | null) => { - if (!option) return - if (option.id === "none") { - setEnabled(false) - stopDemoSound() - return - } - setEnabled(true) - set(option.id) - playDemoSound(option.id) - }, - }) - const InterfaceSection = () => ( settings.general.dismissNewInterfaceNotice()} /> ) const GeneralSection = () => (
- - o.value === language.locale())} - value={(o) => o.value} - label={(o) => o.label} - onSelect={(option) => option && language.setLocale(option.value)} - /> - + - - option.value === settings.workspaces.defaultDestination(), - )} - value={(option) => option.value} - label={(option) => option.label} - placement="bottom-end" - gutter={6} - onSelect={(option) => option && settings.workspaces.setDefaultDestination(option.value)} - /> - + + - -
- -
-
- - - o.value === currentShell()) ?? autoOption} - placement="bottom-end" - gutter={6} - value={(o) => o.id} - label={(o) => o.label} - onSelect={(option) => { - if (!option) return - if (option.value === currentShell()) return - serverSync().updateConfig({ shell: option.value }) - }} - /> - + ) - const AppearanceSection = () => ( -
-

{language.t("settings.general.section.appearance")}

- - - - o.value === theme.colorScheme())} - placement="bottom-end" - gutter={6} - value={(o) => o.value} - label={(o) => o.label} - onSelect={(option) => option && theme.setColorScheme(option.value)} - /> - - - - {language.t("settings.general.row.theme.description")}{" "} - - {language.t("common.learnMore")} - - - } - > - o.id === theme.themeId())} - placement="bottom-end" - gutter={6} - value={(o) => o.id} - label={(o) => o.name} - onSelect={(option) => { - if (!option) return - theme.setTheme(option.id) - }} - /> - - - -
- settings.appearance.setUIFont(event.currentTarget.value)} - placeholder={sansDefault} - spellcheck={false} - autocorrect="off" - autocomplete="off" - autocapitalize="off" - aria-label={language.t("settings.general.row.uiFont.title")} - style={{ "font-family": sansFontFamily(settings.appearance.uiFont()) }} - /> -
-
- - -
- settings.appearance.setFont(event.currentTarget.value)} - placeholder={monoDefault} - spellcheck={false} - autocorrect="off" - autocomplete="off" - autocapitalize="off" - aria-label={language.t("settings.general.row.font.title")} - style={{ "font-family": monoFontFamily(settings.appearance.font()) }} - /> -
-
- - -
- settings.appearance.setTerminalFont(event.currentTarget.value)} - placeholder={terminalDefault} - spellcheck={false} - autocorrect="off" - autocomplete="off" - autocapitalize="off" - aria-label={language.t("settings.general.row.terminalFont.title")} - style={{ "font-family": terminalFontFamily(settings.appearance.terminalFont()) }} - /> -
-
-
-
- ) - const NotificationsSection = () => (

{language.t("settings.general.section.notifications")}

@@ -600,68 +515,6 @@ export const SettingsGeneralV2: Component<{
) - const SoundsSection = () => ( -
-

{language.t("settings.general.section.sounds")}

- - - - settings.sounds.agentEnabled(), - () => settings.sounds.agent(), - (value) => settings.sounds.setAgentEnabled(value), - (id) => settings.sounds.setAgent(id), - )} - placement="bottom-end" - gutter={6} - /> - - - - settings.sounds.permissionsEnabled(), - () => settings.sounds.permissions(), - (value) => settings.sounds.setPermissionsEnabled(value), - (id) => settings.sounds.setPermissions(id), - )} - placement="bottom-end" - gutter={6} - /> - - - - settings.sounds.errorsEnabled(), - () => settings.sounds.errors(), - (value) => settings.sounds.setErrorsEnabled(value), - (id) => settings.sounds.setErrors(id), - )} - placement="bottom-end" - gutter={6} - /> - - -
- ) - const UpdatesSection = () => (

{language.t("settings.general.section.updates")}

@@ -683,7 +536,7 @@ export const SettingsGeneralV2: Component<{ title={language.t("settings.updates.row.check.title")} description={language.t("settings.updates.row.check.description")} > - + updater.run()}> {language.t(updater.action().label)} @@ -728,11 +581,11 @@ export const SettingsGeneralV2: Component<{ - + - + diff --git a/packages/app/src/components/settings-v2/models.tsx b/packages/app/src/components/settings-v2/models.tsx index a3f058670e4..482e8cbffbd 100644 --- a/packages/app/src/components/settings-v2/models.tsx +++ b/packages/app/src/components/settings-v2/models.tsx @@ -5,9 +5,12 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { type Component, For, Show } from "solid-js" +import { createStore } from "solid-js/store" import { useLanguage } from "@/context/language" import { useModels } from "@/context/models" +import { useServerSDK } from "@/context/server-sdk" import { popularProviders } from "@/hooks/use-providers" +import { Persist, persisted } from "@/utils/persist" import { SettingsListV2 } from "./parts/list" import { SettingsRowV2 } from "./parts/row" import "./settings-v2.css" @@ -19,6 +22,11 @@ const PROVIDER_ICON_SIZE = 16 export const SettingsModelsV2: Component = () => { const language = useLanguage() const models = useModels() + const serverSdk = useServerSDK() + const [store, setStore] = persisted( + Persist.serverGlobal(serverSdk().scope, "settings-v2.models.providers"), + createStore({ collapsed: {} as Record }), + ) const list = useFilteredList({ items: (_filter) => models.list(), @@ -94,41 +102,82 @@ export const SettingsModelsV2: Component = () => { } > - {(group) => ( -
-
- -

{group.items[0].provider.name}

+ {(group) => { + const searching = () => list.filter().length > 0 + const expanded = () => searching() || !store.collapsed[group.category] + + return ( +
+

+ +

+ + + + {(item) => { + const key = { providerID: item.provider.id, modelID: item.id } + return ( + +
+ { + models.setVisibility(key, checked) + }} + hideLabel + > + {item.name} + +
+
+ ) + }} +
+
+
- - - {(item) => { - const key = { providerID: item.provider.id, modelID: item.id } - return ( - -
- { - models.setVisibility(key, checked) - }} - hideLabel - > - {item.name} - -
-
- ) - }} -
-
-
- )} + ) + }} diff --git a/packages/app/src/components/settings-v2/settings-v2.css b/packages/app/src/components/settings-v2/settings-v2.css index ad5626dabdc..8bb75e6a8ed 100644 --- a/packages/app/src/components/settings-v2/settings-v2.css +++ b/packages/app/src/components/settings-v2/settings-v2.css @@ -373,14 +373,64 @@ } .settings-v2-models { - gap: 24px; + gap: 12px; +} + +.settings-v2-models .settings-v2-section { + gap: 12px; +} + +.settings-v2-models .settings-v2-section[data-expanded] { + padding-bottom: 12px; } .settings-v2-models-group-header { + display: flex; + align-items: center; + height: 28px; +} + +.settings-v2-models-group-trigger { + display: flex; + align-items: center; + gap: 4px; + height: 28px; + padding: 4px 8px 4px 4px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--v2-text-text-base); +} + +.settings-v2-models-group-trigger:focus-visible { + outline: 2px solid var(--v2-border-border-focus); + outline-offset: 2px; +} + +@media (hover: hover) { + .settings-v2-models-group-trigger:not(:disabled):hover { + background-color: var(--v2-background-bg-layer-02); + } +} + +.settings-v2-models-group-trigger:disabled { + cursor: default; +} + +.settings-v2-models-group-chevron { + display: flex; + width: 20px; + height: 20px; + flex-shrink: 0; + align-items: center; + justify-content: center; + color: var(--v2-icon-icon-muted); +} + +.settings-v2-models-group-label { display: flex; align-items: center; gap: 8px; - padding-bottom: 8px; } .settings-v2-models .settings-v2-section-title { @@ -394,6 +444,10 @@ color: var(--v2-icon-icon-base); } +.settings-v2-models [data-component="settings-v2-list"] { + padding-inline: 16px; +} + .settings-v2-models .settings-v2-section-title + [data-component="settings-v2-list"] { margin-top: 0; } diff --git a/packages/app/src/components/status-popover-indicator.test.ts b/packages/app/src/components/status-popover-indicator.test.ts index c1c57b9d708..d6d6d07a1d8 100644 --- a/packages/app/src/components/status-popover-indicator.test.ts +++ b/packages/app/src/components/status-popover-indicator.test.ts @@ -1,11 +1,21 @@ import { describe, expect, test } from "bun:test" -import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator" +import { + hasNonBlockingServiceIssue, + hasServiceNeedingAttention, + serverStatusDotClass, +} from "./status-popover-indicator" describe("serverStatusDotClass", () => { test("uses the success token while the server and services are healthy", () => { expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base") }) + test("uses the session attention token when a service needs attention", () => { + expect(serverStatusDotClass({ ready: true, serverHealth: true, attention: true, issue: true })).toBe( + "bg-v2-background-bg-accent", + ) + }) + test("uses the warning token for non-blocking issues while the server is online", () => { expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base") }) @@ -34,3 +44,15 @@ describe("hasNonBlockingServiceIssue", () => { expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false) }) }) + +describe("hasServiceNeedingAttention", () => { + test("detects MCP states that need user attention", () => { + expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true) + expect(hasServiceNeedingAttention({ mcp: ["needs_client_registration"] })).toBe(true) + }) + + test("ignores states that do not need user attention", () => { + expect(hasServiceNeedingAttention({ mcp: ["failed"] })).toBe(false) + expect(hasServiceNeedingAttention({ mcp: ["connected", "pending", "disabled"] })).toBe(false) + }) +}) diff --git a/packages/app/src/components/status-popover-indicator.ts b/packages/app/src/components/status-popover-indicator.ts index d89f90febbf..62fc3bedbff 100644 --- a/packages/app/src/components/status-popover-indicator.ts +++ b/packages/app/src/components/status-popover-indicator.ts @@ -1,6 +1,10 @@ import type { LspStatus } from "@opencode-ai/sdk/v2/client" import type { McpServer } from "@opencode-ai/client/promise" +export function hasServiceNeedingAttention(input: { mcp: Array }) { + return input.mcp.some((status) => status === "needs_auth" || status === "needs_client_registration") +} + export function hasNonBlockingServiceIssue(input: { mcp: Array lsp: Array @@ -11,9 +15,15 @@ export function hasNonBlockingServiceIssue(input: { ) } -export function serverStatusDotClass(input: { ready: boolean; serverHealth: boolean | undefined; issue: boolean }) { +export function serverStatusDotClass(input: { + ready: boolean + serverHealth: boolean | undefined + attention?: boolean + issue: boolean +}) { if (input.serverHealth === false) return "bg-icon-critical-base" if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base" + if (input.attention) return "bg-v2-background-bg-accent" if (input.issue) return "bg-icon-warning-base" if (input.serverHealth === true) return "bg-icon-success-base" return "bg-border-weak-base" diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 2aed45702a1..767e7e62c6f 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -9,7 +9,11 @@ import { ServerConnection, useServer } from "@/context/server" import { useServerSDK } from "@/context/server-sdk" import { useSync } from "@/context/sync" import { useGlobal } from "@/context/global" -import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator" +import { + hasNonBlockingServiceIssue, + hasServiceNeedingAttention, + serverStatusDotClass, +} from "./status-popover-indicator" const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody }))) const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody }))) @@ -22,6 +26,11 @@ export function StatusPopover() { const [shown, setShown] = createSignal(false) const serverHealth = () => global.servers.health[server.key]?.healthy const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready)) + const attention = createMemo(() => + hasServiceNeedingAttention({ + mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), + }), + ) const issue = createMemo(() => hasNonBlockingServiceIssue({ mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), @@ -49,6 +58,7 @@ export function StatusPopover() { class={`absolute -top-px -right-px size-1.5 rounded-full ${serverStatusDotClass({ ready: ready(), serverHealth: serverHealth(), + attention: attention(), issue: issue(), })}`} /> @@ -85,6 +95,11 @@ function DirectoryStatusPopover() { const [shown, setShown] = createSignal(false) const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready)) + const attention = createMemo(() => + hasServiceNeedingAttention({ + mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), + }), + ) const issue = createMemo(() => hasNonBlockingServiceIssue({ mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), @@ -95,6 +110,7 @@ function DirectoryStatusPopover() { shown: shown(), ready: ready(), serverHealth: serverHealth(), + attention: attention(), issue: issue(), label: language.t("status.popover.trigger"), onOpenChange: setShown, @@ -118,6 +134,7 @@ function ServerStatusPopover() { shown: shown(), ready: serverHealth() !== undefined, serverHealth: serverHealth(), + attention: false, issue: false, label: language.t("status.popover.trigger"), onOpenChange: setShown, @@ -135,6 +152,7 @@ type StatusPopoverState = { shown: boolean ready: boolean serverHealth: boolean | undefined + attention: boolean issue: boolean label: string onOpenChange: (value: boolean) => void diff --git a/packages/app/src/components/titlebar-tab-gesture.ts b/packages/app/src/components/titlebar-tab-gesture.ts index 329bf5e864b..0aa51189363 100644 --- a/packages/app/src/components/titlebar-tab-gesture.ts +++ b/packages/app/src/components/titlebar-tab-gesture.ts @@ -12,6 +12,6 @@ export function forwardTabRef(ref: Ref | undefined, element: HTM if (typeof ref === "function") ref(element) } -export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, committing: boolean) { - return !dragging && !editing && !committing +export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, pending: boolean) { + return !dragging && !editing && !pending } diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx index 3058e6881af..35f600c9f60 100644 --- a/packages/app/src/components/titlebar-tab-nav.tsx +++ b/packages/app/src/components/titlebar-tab-nav.tsx @@ -1,14 +1,13 @@ import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" import { createResizeObserver } from "@solid-primitives/resize-observer" +import { createMutation } from "@tanstack/solid-query" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { useGlobal } from "@/context/global" -import { useLanguage } from "@/context/language" import { ServerConnection, serverName } from "@/context/server" import { displayName, projectForSession } from "@/pages/layout/helpers" import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" -import { showToast } from "@/utils/toast" import type { Session } from "@opencode-ai/sdk/v2" import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture" import { TabPreviewPopover } from "./titlebar-tab-popover" @@ -23,8 +22,7 @@ export function TabNavItem(props: { server: ServerConnection.Key session: () => Session | undefined fallbackTitle?: string - onTitleChange?: (title: string) => void - onTitleChangeFailed?: (title: string) => void + onRename: (title: string) => Promise onClose: () => void onNavigate: () => void active?: boolean @@ -34,13 +32,12 @@ export function TabNavItem(props: { pressed?: boolean hidden?: boolean }) { - const language = useLanguage() const [editing, setEditing] = createSignal(false) const [titleOverflowing, setTitleOverflowing] = createSignal(false) let tabRoot!: HTMLDivElement let titleEl!: HTMLSpanElement - let committing = false let measureFrame: number | undefined + const rename = createMutation(() => ({ mutationFn: props.onRename })) const closeTab = (event: MouseEvent) => { event.preventDefault() @@ -116,40 +113,20 @@ export function TabNavItem(props: { selection?.addRange(range) } - const rename = async (title: string) => { - const ctx = serverCtx() - const session = props.session() - if (!ctx || !session) return - await ctx.sdk.api.session.rename({ sessionID: session.id, title }) - } - const closeRename = async (save: boolean) => { - if (committing || !editing()) return - committing = true + if (rename.isPending || !editing()) return const original = props.session()?.title ?? "" const next = (titleEl.textContent ?? "").trim() titleEl.scrollLeft = 0 - if (save && next && next !== original) props.onTitleChange?.(next) setEditing(false) if (!save || !next || next === original) { - committing = false return } - try { - await rename(next) - } catch (err) { - props.onTitleChangeFailed?.(original) - showToast({ - title: language.t("common.requestFailed"), - description: err instanceof Error ? err.message : undefined, - }) - } - - committing = false + await rename.mutateAsync(next) } createEffect(() => { @@ -163,7 +140,7 @@ export function TabNavItem(props: { const openRename = (event: MouseEvent) => { event.preventDefault() event.stopPropagation() - if (!canOpenTabRename(props.dragging, editing(), committing)) return + if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return const session = props.session() if (!session) return titleEl.textContent = session.title @@ -218,88 +195,86 @@ export function TabNavItem(props: { closeTab(event) }} > - - { + { + event.preventDefault() + event.stopPropagation() + }} + onMouseDown={(event) => { + // Navigate on mousedown to shave the press-release delay off tab switches. + if (event.button !== 0) return + if (editing()) return + if (props.suppressNavigation?.()) return + props.onNavigate() + }} + onClick={(event) => { + event.preventDefault() + // Mouse navigation already happened on mousedown; detail 0 means keyboard activation. + if (event.detail > 0) return + if (editing()) return + if (props.suppressNavigation?.()) return + props.onNavigate() + }} + class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]" + > + + + + { + titleEl = el + titleEl.textContent = title() ?? "" + }} + data-slot="tab-title" + data-titlebar-tab-title + class="min-w-0 flex-1 outline-none leading-4" + classList={{ + "overflow-hidden text-clip whitespace-nowrap": !editing(), + "select-text": editing(), + }} + contenteditable={editing() ? true : undefined} + onDblClick={openRename} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === "Enter") { + event.preventDefault() + void closeRename(true) + return + } + if (event.key !== "Escape") return event.preventDefault() + titleEl.textContent = props.session()?.title ?? "" + void closeRename(false) + }} + onBlur={() => void closeRename(true)} + onPointerDown={(event) => { + if (!editing()) return event.stopPropagation() }} - onMouseDown={(event) => { - // Navigate on mousedown to shave the press-release delay off tab switches. - if (event.button !== 0) return - if (editing()) return - if (props.suppressNavigation?.()) return - props.onNavigate() - }} onClick={(event) => { + if (!editing()) return event.preventDefault() - // Mouse navigation already happened on mousedown; detail 0 means keyboard activation. - if (event.detail > 0) return - if (editing()) return - if (props.suppressNavigation?.()) return - props.onNavigate() }} - class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]" - > - - - - { - titleEl = el - titleEl.textContent = title() ?? "" - }} - data-slot="tab-title" - data-titlebar-tab-title - class="min-w-0 flex-1 outline-none leading-4" - classList={{ - "overflow-hidden text-clip whitespace-nowrap": !editing(), - "select-text": editing(), - }} - contenteditable={editing() ? true : undefined} - onDblClick={openRename} - onKeyDown={(event) => { - event.stopPropagation() - if (event.key === "Enter") { - event.preventDefault() - void closeRename(true) - return - } - if (event.key !== "Escape") return - event.preventDefault() - titleEl.textContent = props.session()?.title ?? "" - void closeRename(false) - }} - onBlur={() => void closeRename(true)} - onPointerDown={(event) => { - if (!editing()) return - event.stopPropagation() - }} - onClick={(event) => { - if (!editing()) return - event.preventDefault() - }} - /> - - + /> +
{ + test("follows the visible left-to-right order", () => { + expect(adjacentTabKey(["c", "a", "b"], "c", 1)).toBe("a") + expect(adjacentTabKey(["c", "a", "b"], "a", -1)).toBe("c") + }) + + test("skips tabs omitted from the visible order", () => { + expect(adjacentTabKey(["a", "c"], "a", 1)).toBe("c") + expect(adjacentTabKey(["a", "c"], "c", 1)).toBe("a") + }) +}) + +test("merges reordered visible tabs around hidden tabs", () => { + expect(mergeVisibleTabOrder(["a", "hidden", "b", "c"], ["a", "b", "c"], ["c", "a", "b"])).toEqual([ + "c", + "hidden", + "a", + "b", + ]) +}) diff --git a/packages/app/src/components/titlebar-tab-order.ts b/packages/app/src/components/titlebar-tab-order.ts new file mode 100644 index 00000000000..a906b1fb329 --- /dev/null +++ b/packages/app/src/components/titlebar-tab-order.ts @@ -0,0 +1,12 @@ +export function adjacentTabKey(order: string[], current: string | undefined, offset: -1 | 1) { + if (!current || order.length === 0) return + const index = order.indexOf(current) + if (index === -1) return + return order[(index + offset + order.length) % order.length] +} + +export function mergeVisibleTabOrder(all: string[], current: string[], next: string[]) { + const visible = new Set(current) + const reordered = next.values() + return all.map((key) => (visible.has(key) ? (reordered.next().value ?? key) : key)) +} diff --git a/packages/app/src/components/titlebar-tab-strip.tsx b/packages/app/src/components/titlebar-tab-strip.tsx index 7ef53324cdb..0a4b4da12df 100644 --- a/packages/app/src/components/titlebar-tab-strip.tsx +++ b/packages/app/src/components/titlebar-tab-strip.tsx @@ -1,4 +1,5 @@ -import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount } from "solid-js" +import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount, Show } from "solid-js" +import { createStore } from "solid-js/store" import { createResizeObserver } from "@solid-primitives/resize-observer" import { DragDropProvider, PointerSensor } from "@dnd-kit/solid" import { isSortable, useSortable } from "@dnd-kit/solid/sortable" @@ -15,7 +16,10 @@ import { useCommand } from "@/context/command" import { useTabs } from "@/context/tabs" import { createTabPromptState } from "@/context/prompt" import { base64Encode } from "@opencode-ai/core/util/encode" +import { showToast } from "@/utils/toast" import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture" +import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order" +import type { Session } from "@opencode-ai/sdk/v2" function SessionTabSlot(props: { tab: SessionTab @@ -23,12 +27,12 @@ function SessionTabSlot(props: { index: () => number active: () => boolean forceTruncate: boolean - serverCtx: () => ServerCtx | undefined + session: () => Session | undefined + fallbackTitle?: string + onRename: (title: string) => Promise onNavigate: (element: HTMLDivElement) => void onClose: () => void }) { - const tabs = useTabs() - const language = useLanguage() const sortable = useSortable({ get id() { return props.id @@ -38,6 +42,47 @@ function SessionTabSlot(props: { }, }) let ref!: HTMLDivElement + + return ( +
+ { + ref = el + }} + href={tabHref(props.tab)} + server={props.tab.server} + session={props.session} + fallbackTitle={props.fallbackTitle} + onRename={props.onRename} + onNavigate={() => props.onNavigate(ref)} + onClose={props.onClose} + active={props.active()} + forceTruncate={props.forceTruncate} + dragging={sortable.isDragSource()} + /> +
+ ) +} + +function SessionTabEntry(props: { + tab: SessionTab + id: string + index: () => number + active: () => boolean + forceTruncate: boolean + serverCtx: () => ServerCtx | undefined + onVisibleChange: (visible: boolean) => void + onNavigate: (element: HTMLDivElement) => void + onClose: () => void +}) { + const tabs = useTabs() + const language = useLanguage() const sdk = createMemo(() => props.serverCtx()?.sdk ?? null) const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId)) const persisted = createMemo(() => tabs.info[props.id]) @@ -50,8 +95,30 @@ function SessionTabSlot(props: { ) const session = createMemo(() => cachedSession() ?? loadedSession()) const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session()) + const visible = createMemo(() => !!session() || missingSession() || !!persisted()?.title) let prefetched = false + const rename = async (title: string) => { + const value = session() + const ctx = props.serverCtx() + if (!value || !ctx) return + + ctx.sync.session.remember({ ...value, title }) + try { + await ctx.sdk.api.session.rename({ sessionID: value.id, title }) + } catch (err) { + const current = session() + const currentCtx = props.serverCtx() + if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title }) + showToast({ + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : undefined, + }) + } + } + + createEffect(() => props.onVisibleChange(visible())) + createEffect(() => { const ctx = props.serverCtx() const value = session() @@ -83,39 +150,20 @@ function SessionTabSlot(props: { }) return ( -
- { - ref = el - }} - href={tabHref(props.tab)} - server={props.tab.server} + + { - const value = session() - const ctx = props.serverCtx() - if (value && ctx) ctx.sync.session.remember({ ...value, title }) - }} - onTitleChangeFailed={(title) => { - const value = session() - const ctx = props.serverCtx() - if (value && ctx) ctx.sync.session.remember({ ...value, title }) - }} - onNavigate={() => props.onNavigate(ref)} + onRename={rename} + onNavigate={props.onNavigate} onClose={props.onClose} - active={props.active()} - forceTruncate={props.forceTruncate} - dragging={sortable.isDragSource()} /> -
+ ) } @@ -172,11 +220,39 @@ export function TitlebarTabStrip(props: { }) { const global = useGlobal() const language = useLanguage() + const command = useCommand() let scrollRef!: HTMLDivElement let listRef!: HTMLDivElement let resizeFrame: number | undefined + const [visibility, setVisibility] = createStore>({}) + const visibleTabs = createMemo(() => props.tabs.filter((tab) => tab.type === "draft" || visibility[tabKey(tab)])) + const visibleTabIds = () => visibleTabs().map(tabKey) - const tabIds = () => props.tabs.map(tabKey) + command.register("titlebar-tab-cycle", () => [ + { + id: `tab.prev`, + category: "tab", + title: "", + keybind: `mod+option+ArrowLeft,ctrl+shift+tab`, + hidden: true, + onSelect: () => selectAdjacentTab(-1), + }, + { + id: `tab.next`, + category: "tab", + title: "", + keybind: `mod+option+ArrowRight,ctrl+tab`, + hidden: true, + onSelect: () => selectAdjacentTab(1), + }, + ]) + + function selectAdjacentTab(offset: -1 | 1) { + const current = props.currentTab() + const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset) + const next = props.tabs.find((tab) => tabKey(tab) === key) + if (next) props.onNavigate(next) + } function refreshOverflow() { if (!scrollRef) return @@ -204,7 +280,7 @@ export function TitlebarTabStrip(props: { createEffect(() => { props.tabs.length - tabIds() + visibleTabIds() refreshOverflow() }) @@ -240,22 +316,29 @@ export function TitlebarTabStrip(props: { props.onNavigate(tab, tabEl ?? undefined) }} onDragEnd={(event) => { - const current = tabIds() + const current = visibleTabIds() const source = event.operation.source if (event.canceled || !isSortable(source)) return const { initialIndex, index } = source if (initialIndex !== index) { - props.onReorder(arrayMove(current, source.initialIndex, source.index)) + props.onReorder( + mergeVisibleTabOrder( + props.tabs.map(tabKey), + current, + arrayMove(current, source.initialIndex, source.index), + ), + ) } }} >
- {(tab, index) => { + {(tab) => { const id = tabKey(tab) let ref!: HTMLDivElement - useTabShortcut(index, () => props.onNavigate(tab, ref)) + const visibleIndex = () => visibleTabs().findIndex((item) => tabKey(item) === id) + useTabShortcut(visibleIndex, () => props.onNavigate(tab, ref)) const serverCtx = createMemo(() => { if (tab.type !== "session") return const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server) @@ -264,13 +347,14 @@ export function TitlebarTabStrip(props: { if (tab.type === "session") { return ( - props.currentTab() === tab} forceTruncate={props.forceTruncate} serverCtx={serverCtx} + onVisibleChange={(visible) => setVisibility(id, visible)} onNavigate={(element) => { ref = element props.onNavigate(tab, element) @@ -284,7 +368,7 @@ export function TitlebarTabStrip(props: { props.currentTab() === tab} title={language.t("command.session.new")} onNavigate={(element) => { @@ -318,7 +402,7 @@ function useTabShortcut(index: () => number, onSelect: () => void) { command.register(() => { const number = index() + 1 - if (number > 9) return [] + if (number < 1 || number > 9) return [] return [ { id: `tab.${number}`, diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index aa2f220e494..32d7e80b2f0 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -5,7 +5,6 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Icon } from "@opencode-ai/ui/icon" import { Button } from "@opencode-ai/ui/button" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" -import { useTheme } from "@opencode-ai/ui/theme/context" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" @@ -30,31 +29,11 @@ import "./titlebar.css" import { newTabTooltipKeybind } from "./command-tooltip-keybind" import { normalizeSessionInfo } from "@/utils/session" -type TauriDesktopWindow = { - startDragging?: () => Promise - toggleMaximize?: () => Promise -} - -type TauriThemeWindow = { - setTheme?: (theme?: "light" | "dark" | null) => Promise -} - -type TauriApi = { - window?: { - getCurrentWindow?: () => TauriDesktopWindow - } - webviewWindow?: { - getCurrentWebviewWindow?: () => TauriThemeWindow - } -} - -const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__ -const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.() -const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.() const legacyTitlebarHeight = 40 const v2TitlebarHeight = 36 const minTitlebarZoom = 0.25 const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each. +const macTrafficLightsBaseWidth = 84 export type TitlebarUpdate = { version: () => string | undefined @@ -74,7 +53,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl const command = useCommand() const language = useLanguage() const settings = useSettings() - const theme = useTheme() const server = useServer() const navigate = useNavigate() const location = useLocation() @@ -85,9 +63,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos") const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows") - const electronWindows = createMemo(() => windows() && !tauriApi()) const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux") const web = createMemo(() => platform.platform === "web") + const macTrafficLights = createMemo(() => mac() && !platform.windowFullscreen?.()) const zoom = () => platform.webviewZoom?.() ?? 1 const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom()) const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1) @@ -176,56 +154,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl }, ]) - const getWin = () => { - if (platform.platform !== "desktop") return - return currentDesktopWindow() - } - - createEffect(() => { - if (platform.platform !== "desktop") return - - const scheme = theme.colorScheme() - const value = scheme === "system" ? null : scheme - - const win = currentThemeWindow() - if (!win?.setTheme) return - - void win.setTheme(value).catch(() => undefined) - }) - - const interactive = (target: EventTarget | null) => { - if (!(target instanceof Element)) return false - - const selector = - "button, a, input, textarea, select, option, [role='button'], [role='menuitem'], [contenteditable='true'], [contenteditable='']" - - return !!target.closest(selector) - } - - const drag = (e: MouseEvent) => { - if (platform.platform !== "desktop") return - if (e.buttons !== 1) return - if (interactive(e.target)) return - - const win = getWin() - if (!win?.startDragging) return - - e.preventDefault() - void win.startDragging().catch(() => undefined) - } - - const maximize = (e: MouseEvent) => { - if (platform.platform !== "desktop") return - if (interactive(e.target)) return - if (e.target instanceof Element && e.target.closest("[data-tauri-decorum-tb]")) return - - const win = getWin() - if (!win?.toggleMaximize) return - - e.preventDefault() - void win.toggleMaximize().catch(() => undefined) - } - return (
@@ -414,40 +338,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl keybind: "mod+shift+t", onSelect: () => tabsStoreActions.reopenClosedTab(), }, - { - id: `tab.prev`, - category: "tab", - title: "", - keybind: `mod+option+ArrowLeft,ctrl+shift+tab`, - hidden: true, - onSelect: () => { - let index = tabsStore.findIndex((tab) => tab === currentTab()) - if (index === -1) return - - index -= 1 - if (index === -1) index = tabsStore.length - 1 - - const next = tabsStore[index] - if (next) tabs.select(next) - }, - }, - { - id: `tab.next`, - category: "tab", - title: "", - keybind: `mod+option+ArrowRight,ctrl+tab`, - hidden: true, - onSelect: () => { - let index = tabsStore.findIndex((tab) => tab === currentTab()) - if (index === -1) return - - index += 1 - if (index === tabsStore.length) index = 0 - - const next = tabsStore[index] - if (next) tabs.select(next) - }, - }, ].filter((v) => v !== undefined) }) @@ -459,8 +349,8 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl classList={{ "pt-2": !bottom(), "pb-2": bottom(), - "md:pl-2": mac(), - "md:pl-4": !mac(), + "md:pl-2": macTrafficLights(), + "md:pl-4": !macTrafficLights(), }} > @@ -528,9 +418,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
- -
-
) }} @@ -543,14 +430,13 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
- {/*
*/}
- {!tauriApi() &&
} -
+
diff --git a/packages/app/src/context/command.test.ts b/packages/app/src/context/command.test.ts index cf98ff38251..e0d90575113 100644 --- a/packages/app/src/context/command.test.ts +++ b/packages/app/src/context/command.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from "bun:test" -import { commandPaletteOptions, resolveKeybindOption, upsertCommandRegistration, type CommandOption } from "./command" +import { + activeCommandRegistrations, + addCommandRegistration, + commandPaletteOptions, + resolveKeybindOption, + type CommandOption, +} from "./command" const paletteOptions: CommandOption[] = [ { id: "settings.open", title: "Open settings" }, @@ -15,22 +21,31 @@ describe("commandPaletteOptions", () => { }) }) -describe("upsertCommandRegistration", () => { - test("replaces keyed registrations", () => { +describe("command registrations", () => { + test("shadows keyed registrations while retaining the previous owner", () => { const one = () => [{ id: "one", title: "One" }] const two = () => [{ id: "two", title: "Two" }] - const next = upsertCommandRegistration([{ key: "layout", options: one }], { key: "layout", options: two }) + const registrations = addCommandRegistration([{ key: "layout", options: one }], { + key: "layout", + options: two, + }) + const active = activeCommandRegistrations(registrations) - expect(next).toHaveLength(1) - expect(next[0]?.options).toBe(two) + expect(registrations).toHaveLength(2) + expect(active).toHaveLength(1) + expect(active[0]?.options).toBe(two) + + const restored = activeCommandRegistrations(registrations.filter((entry) => entry.options !== two)) + expect(restored).toHaveLength(1) + expect(restored[0]?.options).toBe(one) }) test("keeps unkeyed registrations additive", () => { const one = () => [{ id: "one", title: "One" }] const two = () => [{ id: "two", title: "Two" }] - const next = upsertCommandRegistration([{ options: one }], { options: two }) + const next = activeCommandRegistrations(addCommandRegistration([{ options: one }], { options: two })) expect(next).toHaveLength(2) expect(next[0]?.options).toBe(two) diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index f88033bd7e2..054b99e806b 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -114,9 +114,18 @@ export type CommandRegistration = { options: Accessor } -export function upsertCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) { - if (entry.key === undefined) return [entry, ...registrations] - return [entry, ...registrations.filter((x) => x.key !== entry.key)] +export function addCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) { + return [entry, ...registrations] +} + +export function activeCommandRegistrations(registrations: CommandRegistration[]) { + const keys = new Set() + return registrations.filter((entry) => { + if (entry.key === undefined) return true + if (keys.has(entry.key)) return false + keys.add(entry.key) + return true + }) } export function parseKeybind(config: string): Keybind[] { @@ -281,7 +290,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex const seen = new Set() const all: CommandOption[] = [] - for (const reg of store.registrations) { + for (const reg of activeCommandRegistrations(store.registrations)) { for (const opt of reg.options()) { if (seen.has(opt.id)) { if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) { @@ -425,7 +434,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex key: id, options, } - setStore("registrations", (arr) => upsertCommandRegistration(arr, entry)) + setStore("registrations", (arr) => addCommandRegistration(arr, entry)) onCleanup(() => { setStore("registrations", (arr) => arr.filter((x) => x !== entry)) }) diff --git a/packages/app/src/context/file/path.test.ts b/packages/app/src/context/file/path.test.ts index feef6d466ef..99dd88ae0e9 100644 --- a/packages/app/src/context/file/path.test.ts +++ b/packages/app/src/context/file/path.test.ts @@ -21,6 +21,29 @@ describe("file path helpers", () => { expect(path.normalize("c:\\repo\\src\\app.ts")).toBe("src\\app.ts") }) + test("normalizes Windows directory separators", () => { + const path = createPathHelpers(() => "C:\\repo") + expect(path.normalizeDir("frontend\\")).toBe("frontend") + expect(path.normalizeDir("frontend\\src\\")).toBe("frontend/src") + expect(path.normalizeDir("C:\\repo\\frontend\\")).toBe("frontend") + }) + + test("normalizes separators for Windows roots written with forward slashes", () => { + const path = createPathHelpers(() => "C:/repo") + expect(path.normalizeDir("frontend\\src\\")).toBe("frontend/src") + }) + + test("normalizes separators for Windows UNC roots", () => { + const path = createPathHelpers(() => "\\\\server\\share") + expect(path.normalizeDir("\\\\server\\share\\frontend\\")).toBe("frontend") + }) + + test("preserves backslashes in POSIX directory names", () => { + const path = createPathHelpers(() => "/repo") + expect(path.normalizeDir("literal\\name\\")).toBe("literal\\name\\") + expect(path.normalizeDir("literal\\name/")).toBe("literal\\name") + }) + test("keeps query/hash stripping behavior stable", () => { expect(stripQueryAndHash("a/b.ts#L12?x=1")).toBe("a/b.ts") expect(stripQueryAndHash("a/b.ts?x=1#L12")).toBe("a/b.ts") diff --git a/packages/app/src/context/file/path.ts b/packages/app/src/context/file/path.ts index 53f072b6cb2..2bc4bde5e9b 100644 --- a/packages/app/src/context/file/path.ts +++ b/packages/app/src/context/file/path.ts @@ -140,7 +140,12 @@ export function createPathHelpers(scope: () => string) { return normalize(tabValue) } - const normalizeDir = (input: string) => normalize(input).replace(/\/+$/, "") + const normalizeDir = (input: string) => { + const path = normalize(input) + const root = scope() + const windows = /^[A-Za-z]:/.test(root) || root.startsWith("\\\\") + return (windows ? path.replace(/\\/g, "/") : path).replace(/\/+$/, "") + } return { normalize, diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index cf343bfa7e9..4c374961a3f 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -97,6 +97,9 @@ type PlatformBase = { /** Webview zoom level (desktop only) */ webviewZoom?: Accessor + /** Whether the native desktop window is fullscreen */ + windowFullscreen?: Accessor + /** Get whether native pinch/Ctrl-scroll zoom gestures are enabled (desktop only) */ getPinchZoomEnabled?(): Promise | boolean diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index f87dcc52f35..c2deb71a8e4 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -316,6 +316,10 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { const queryClient = useQueryClient() const homeSessions = createHomeSessionIndexCache(queryClient, ServerConnection.key(serverSDK.server)) + const refreshProviders = () => + queryClient.refetchQueries({ + predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers", + }) let bootedAt = 0 let bootingRoot = false @@ -599,6 +603,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { homeSessions.apply(event) } homeSessions.refresh(moved?.refresh ?? event.type) + if (eventType === "integration.connection.updated") void refreshProviders() if (directory === "global") { if (eventType === "server.connected" && activeSessionsQuery.data === undefined && !activeSessionsQuery.isFetching) @@ -736,6 +741,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { peek: children.peek, disableMcp: children.disableMcp, queryOptions: queryOptionsApi, + refreshProviders, // bootstrap, updateConfig: updateConfigMutation.mutateAsync, project: projectApi, diff --git a/packages/app/src/pages/home/home-projects-view.tsx b/packages/app/src/pages/home/home-projects-view.tsx index 573b606579a..7ec01bd75fe 100644 --- a/packages/app/src/pages/home/home-projects-view.tsx +++ b/packages/app/src/pages/home/home-projects-view.tsx @@ -360,25 +360,26 @@ function HomeProjectSlot( index: () => number }, ) { - const project = createMemo(() => props.items.find((item) => item.worktree === props.worktree)) + const initial = props.items.find((item) => item.worktree === props.worktree) + if (!initial) return + const project = createMemo( + (previous) => props.items.find((item) => item.worktree === props.worktree) ?? previous, + initial, + ) return ( - - {(item) => ( - - )} - + ) } diff --git a/packages/app/src/pages/home/home-sessions-view.tsx b/packages/app/src/pages/home/home-sessions-view.tsx index 461322ae438..72f2f37616a 100644 --- a/packages/app/src/pages/home/home-sessions-view.tsx +++ b/packages/app/src/pages/home/home-sessions-view.tsx @@ -1,5 +1,5 @@ import type { Session } from "@opencode-ai/sdk/v2/client" -import { type Accessor, createMemo, For, Show } from "solid-js" +import { type Accessor, createMemo, For, Show, Suspense } from "solid-js" import { Spinner } from "@opencode-ai/ui/spinner" import { ScrollView } from "@opencode-ai/ui/scroll-view" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" @@ -39,7 +39,6 @@ function isBackgroundOpen(event: MouseEvent) { export type HomeSessionsViewProps = { language: ReturnType groups: Accessor - loading: Accessor showProjectName: Accessor server: Accessor canCreateSession: Accessor @@ -81,20 +80,22 @@ export function HomeSessionsView(props: HomeSessionsViewProps) { >
- 0 && props.canCreateSession()}> -
- - {props.language.t("command.session.new")} - -
-
+ + 0 && props.canCreateSession()}> +
+ + {props.language.t("command.session.new")} + +
+
+
) diff --git a/packages/app/src/pages/home/home-sessions.tsx b/packages/app/src/pages/home/home-sessions.tsx index 2e3828fd8c7..7bda4dd3630 100644 --- a/packages/app/src/pages/home/home-sessions.tsx +++ b/packages/app/src/pages/home/home-sessions.tsx @@ -12,7 +12,6 @@ export function HomeSessions(props: { sdk().directory) - const openProviders = () => { - void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => { - void dialog.show(() => sdk().directory} />) - }) - } - useSettingsCommand() - const route = useSessionKey() - const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>() - const tabs = useTabs() - const local = useLocal() - const model = createPromptModelSelection({ agent: local.agent.current }) - - useComposerCommands({ model }) - - const inputController = createPromptInputController({ - sessionKey: route.sessionKey, - sessionID: () => route.params.id, - queryOptions: serverSync().queryOptions, - model, - }) - const projectControls = createPromptProjectControls() - const rightMount = useTitlebarRightMount() + const [search] = useSearchParams<{ draftId?: string }>() + const tabs = useTabs() const openWorkspaces = useSettingsDialog("workspaces") - const draft = createMemo(() => - tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === searchParams.draftId), + const draftTab = createMemo(() => + tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId), ) - const setWorktree = (worktree: string | undefined) => { - const id = searchParams.draftId - if (!id) return - tabs.updateDraft(id, { worktree }) - } - const showWorkspaceBar = createMemo(() => sync().project?.vcs === "git") - const defaultWorktree = createMemo(() => { - const project = sync().project - if (!project) return "main" - return workspaceDefaultSelection( - settings.workspaces.defaultDestination(), - settings.workspaces.lastUsed(sdk().scope, project.id), - ) - }) - const selectedWorktree = createMemo(() => { - const project = sync().project - const worktree = draft()?.worktree - if (!project || !worktree) return undefined - return isWorkspaceSelection(project, worktree) ? worktree : undefined - }) - const newSessionWorktree = createMemo(() => { - if (!showWorkspaceBar()) return "main" - const worktree = selectedWorktree() - if (worktree) return worktree - const project = sync().project - if (project && sdk().directory !== project.worktree) return sdk().directory - return defaultWorktree() - }) - const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory) - const localBranch = createMemo(() => serverSync().child(projectRoot())[0].vcs?.branch) - const selectedBranch = createMemo(() => { - const worktree = newSessionWorktree() - if (worktree === "main" || worktree === "create") return localBranch() - return serverSync().child(worktree)[0].vcs?.branch - }) - const rememberWorkspaceSelection = (worktree: string) => { - const project = sync().project - if (!project) return - const local = worktree === "main" || pathKey(worktree) === pathKey(project.worktree) - settings.workspaces.setLastUsed(sdk().scope, project.id, local ? "local" : "workspace") - } - const selectWorktree = (worktree: string) => { - const project = sync().project - setWorktree(worktree === "main" && project?.worktree !== sdk().directory ? project?.worktree : worktree) - rememberWorkspaceSelection(worktree) - } - const promptInputV2Controller = usePromptInputV2Controller({ - get controls() { - return inputController() + const workspace = createNewSessionWorkspaceController({ + selected: () => draftTab()?.worktree, + setSelected: (worktree) => { + if (search.draftId) tabs.updateDraft(search.draftId, { worktree }) }, - get newSessionWorktree() { - return newSessionWorktree() - }, - onNewSessionWorktreeReset: () => setWorktree(undefined), - onSubmit: () => { - rememberWorkspaceSelection(newSessionWorktree()) - comments.clear() + onViewAll: openWorkspaces, + }) + const draft = createNewSessionDraftController({ + worktree: workspace.selection.value, + resetWorktree: workspace.selection.reset, + onSubmit: workspace.selection.remember, + }) + const project = createPromptProjectController({ + controls: draft.project.controls, + onDone: draft.input.restoreFocus, + }) + useNewSessionCommands({ + restoreFocus: draft.input.restoreFocus, + project: { + empty: project.empty, + open: () => project.setOpen(true), }, }) - const projectController = createPromptProjectController({ - controls: projectControls, - onDone: promptInputV2Controller.restoreFocus, - }) - - command.register("new-session", () => [ - { - id: "command.palette", - title: language.t("command.palette"), - hidden: true, - onSelect: async () => { - const { DialogSelectFile } = await import("@/components/dialog-select-file") - void dialog.show(() => ) - }, - }, - { - id: "input.focus", - title: language.t("command.input.focus"), - category: language.t("command.category.view"), - keybind: "ctrl+l", - onSelect: () => promptInputV2Controller.restoreFocus(), - }, - ]) - createEffect(() => { - if (!prompt.ready()) return - untrack(() => { - const text = searchParams.prompt - if (!text) return - prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) - setSearchParams({ ...searchParams, prompt: undefined }) - }) + if (!draft.prompt.ready()) return + draft.input.restoreFocus() }) - - createEffect(() => { - if (!prompt.ready()) return - promptInputV2Controller.restoreFocus() - }) - const ready = Promise.resolve() const [suspendUntilPromptReady] = createResource( - () => prompt.ready.promise ?? ready, + () => draft.prompt.readyPromise() ?? ready, (promise) => promise.then(() => true), ) return (
{suspendUntilPromptReady()} - - {(mount) => ( - - - - - - - - )} - +
-
-
- -
-
- - - - - -
- - } - > - - -
-
-
- {/**/} -
-
- serverSync().child(sdk().directory)[0].provider_ready} - connected={() => providers.paid().length > 0} - openProviders={openProviders} - /> -
-
+
) } - -function ProviderTip(props: { ready: () => boolean; connected: () => boolean; openProviders: () => void }) { - const language = useLanguage() - const [persistedState, setPersistedState, , persistedReady] = persisted( - Persist.global("new-session.provider-tip"), - createStore({ dismissedAt: 0 }), - ) - const visible = createMemo( - () => - props.ready() && - persistedReady() && - !props.connected() && - Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration, - ) - - function dismiss() { - setPersistedState("dismissedAt", Date.now()) - } - - const [ref, setRef] = createSignal() - const presence = createPresence({ - show: () => visible(), - element: () => ref() ?? null, - }) - - return ( - -
-
- - - - -
-
-
- ) -} diff --git a/packages/app/src/pages/new-session/new-session-draft-controller.ts b/packages/app/src/pages/new-session/new-session-draft-controller.ts new file mode 100644 index 00000000000..7063b9dc971 --- /dev/null +++ b/packages/app/src/pages/new-session/new-session-draft-controller.ts @@ -0,0 +1,71 @@ +import { useSearchParams } from "@solidjs/router" +import { createEffect, untrack } from "solid-js" +import { usePromptInputV2Controller } from "@/components/prompt-input-v2" +import { useComments } from "@/context/comments" +import { useLocal } from "@/context/local" +import { usePrompt } from "@/context/prompt" +import { useServerSync } from "@/context/server-sync" +import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer" +import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection" +import { useSessionKey } from "@/pages/session/session-layout" +import { useComposerCommands } from "@/pages/session/use-composer-commands" + +export function createNewSessionDraftController(workspace: { + worktree: () => string + resetWorktree: () => void + onSubmit: () => void +}) { + const prompt = usePrompt() + const serverSync = useServerSync() + const comments = useComments() + const local = useLocal() + const route = useSessionKey() + const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>() + const model = createPromptModelSelection({ agent: () => local.agent.current() }) + + useComposerCommands({ model }) + + const controls = createPromptInputController({ + sessionKey: route.sessionKey, + sessionID: () => route.params.id, + queryOptions: serverSync().queryOptions, + model, + }) + const projectControls = createPromptProjectControls() + const input = usePromptInputV2Controller({ + get controls() { + return controls() + }, + get newSessionWorktree() { + return workspace.worktree() + }, + onNewSessionWorktreeReset: workspace.resetWorktree, + onSubmit: () => { + workspace.onSubmit() + comments.clear() + }, + }) + + createEffect(() => { + if (!prompt.ready()) return + untrack(() => { + const text = searchParams.prompt + if (!text) return + prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) + setSearchParams({ ...searchParams, prompt: undefined }) + }) + }) + + return { + input, + prompt: { + ready: prompt.ready, + readyPromise: () => prompt.ready.promise, + }, + project: { + controls: projectControls, + }, + } +} + +export type NewSessionDraftController = ReturnType diff --git a/packages/app/src/pages/new-session/new-session-view.tsx b/packages/app/src/pages/new-session/new-session-view.tsx new file mode 100644 index 00000000000..ab988b6ef76 --- /dev/null +++ b/packages/app/src/pages/new-session/new-session-view.tsx @@ -0,0 +1,163 @@ +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2" +import { Show, createMemo, createSignal, type Accessor } from "solid-js" +import { createStore } from "solid-js/store" +import { Portal } from "solid-js/web" +import createPresence from "solid-presence" +import { PromptInputV2Composer } from "@/components/prompt-input-v2" +import { PromptGitStatus, PromptWorkspaceSelector } from "@/components/prompt-workspace-selector" +import { + PromptProjectAddButton, + PromptProjectSelector, + type PromptProjectController, +} from "@/components/prompt-project-selector" +import { StatusPopoverV2 } from "@/components/status-popover" +import { useLanguage } from "@/context/language" +import { useSDK } from "@/context/sdk" +import { useServerSync } from "@/context/server-sync" +import { useProviders } from "@/hooks/use-providers" +import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout" +import { Persist, persisted } from "@/utils/persist" +import type { NewSessionDraftController } from "./new-session-draft-controller" +import type { NewSessionWorkspaceController } from "./new-session-workspace-controller" + +const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000 + +export function NewSessionView(props: { + input: NewSessionDraftController["input"] + project: PromptProjectController + workspace: NewSessionWorkspaceController +}) { + return ( +
+
+
+
+ +
+ + + + + +
+ + + } + > + + +
+
+
+
+
+ +
+
+ ) +} + +export function NewSessionStatus(props: { mount: Accessor; visible: Accessor }) { + const language = useLanguage() + + return ( + + {(mount) => ( + + + + + + + + )} + + ) +} + +function ProviderTip() { + const language = useLanguage() + const dialog = useDialog() + const sdk = useSDK() + const serverSync = useServerSync() + const providers = useProviders(() => sdk().directory) + const [persistedState, setPersistedState, , persistedReady] = persisted( + Persist.global("new-session.provider-tip"), + createStore({ dismissedAt: 0 }), + ) + const visible = createMemo( + () => + serverSync().child(sdk().directory)[0].provider_ready && + persistedReady() && + providers.paid().length === 0 && + Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration, + ) + const [ref, setRef] = createSignal() + const presence = createPresence({ + show: visible, + element: () => ref() ?? null, + }) + const openProviders = () => { + void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => { + void dialog.show(() => sdk().directory} />) + }) + } + + return ( + +
+
+ + + + +
+
+
+ ) +} diff --git a/packages/app/src/pages/new-session/new-session-workspace-controller.test.ts b/packages/app/src/pages/new-session/new-session-workspace-controller.test.ts new file mode 100644 index 00000000000..2a79ae77fa7 --- /dev/null +++ b/packages/app/src/pages/new-session/new-session-workspace-controller.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test" +import { + normalizeNewSessionWorktree, + resolveNewSessionBranch, + resolveNewSessionWorktree, +} from "./new-session-workspace-controller" + +describe("new session workspace selection", () => { + test("uses main when the workspace bar is unavailable", () => { + expect( + resolveNewSessionWorktree({ + enabled: false, + selected: "/project/feature", + directory: "/project/feature", + projectWorktree: "/project", + }), + ).toBe("main") + }) + + test("derives an existing worktree from the current directory", () => { + expect( + resolveNewSessionWorktree({ enabled: true, directory: "/project/feature", projectWorktree: "/project" }), + ).toBe("/project/feature") + expect(resolveNewSessionWorktree({ enabled: true, directory: "/project", projectWorktree: "/project" })).toBe( + "main", + ) + }) + + test("normalizes main to the project root outside the main worktree", () => { + expect(normalizeNewSessionWorktree("main", "/project/feature", "/project")).toBe("/project") + expect(normalizeNewSessionWorktree("main", "/project", "/project")).toBe("main") + }) + + test("falls back to the local branch for main, create, and unknown worktrees", () => { + const branch = (worktree: string) => (worktree === "/project/feature" ? "feature" : undefined) + expect(resolveNewSessionBranch({ worktree: "main", local: "dev", worktreeBranch: branch })).toBe("dev") + expect(resolveNewSessionBranch({ worktree: "create", local: "dev", worktreeBranch: branch })).toBe("dev") + expect(resolveNewSessionBranch({ worktree: "/project/feature", local: "dev", worktreeBranch: branch })).toBe( + "feature", + ) + expect(resolveNewSessionBranch({ worktree: "/missing", local: "dev", worktreeBranch: branch })).toBe("dev") + }) +}) diff --git a/packages/app/src/pages/new-session/new-session-workspace-controller.ts b/packages/app/src/pages/new-session/new-session-workspace-controller.ts new file mode 100644 index 00000000000..ab7bc61a368 --- /dev/null +++ b/packages/app/src/pages/new-session/new-session-workspace-controller.ts @@ -0,0 +1,110 @@ +import { createMemo } from "solid-js" +import { useSDK } from "@/context/sdk" +import { useServerSDK } from "@/context/server-sdk" +import { useServerSync } from "@/context/server-sync" +import { useSettings } from "@/context/settings" +import { useSync } from "@/context/sync" +import { pathKey } from "@/utils/path-key" +import { isWorkspaceSelection, workspaceDefaultSelection } from "@/utils/workspace" + +export function resolveNewSessionWorktree(input: { + enabled: boolean + selected?: string + directory: string + projectWorktree?: string + fallback?: string +}) { + if (!input.enabled) return "main" + if (input.selected) return input.selected + if (input.projectWorktree && input.directory !== input.projectWorktree) return input.directory + return input.fallback ?? "main" +} + +export function normalizeNewSessionWorktree(value: string, directory: string, projectWorktree?: string) { + if (value === "main" && projectWorktree !== directory) return projectWorktree + return value +} + +export function resolveNewSessionBranch(input: { + worktree: string + local?: string + worktreeBranch: (worktree: string) => string | undefined +}) { + if (input.worktree === "main" || input.worktree === "create") return input.local + return input.worktreeBranch(input.worktree) ?? input.local +} + +export function createNewSessionWorkspaceController(input: { + selected: () => string | undefined + setSelected: (worktree: string | undefined) => void + onViewAll: () => void +}) { + const sdk = useSDK() + const sync = useSync() + const serverSDK = useServerSDK() + const serverSync = useServerSync() + const settings = useSettings() + const visible = createMemo(() => sync().project?.vcs === "git") + const selected = createMemo(() => { + const project = sync().project + const worktree = input.selected() + if (!project || !worktree) return + return isWorkspaceSelection(project, worktree) ? worktree : undefined + }) + const fallback = createMemo(() => { + const project = sync().project + if (!project) return "main" + return workspaceDefaultSelection( + settings.workspaces.defaultDestination(), + settings.workspaces.lastUsed(serverSDK().scope, project.id), + ) + }) + const value = createMemo(() => + resolveNewSessionWorktree({ + enabled: visible(), + selected: selected(), + directory: sdk().directory, + projectWorktree: sync().project?.worktree, + fallback: fallback(), + }), + ) + const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory) + const localBranch = createMemo(() => serverSync().child(projectRoot())[0].vcs?.branch) + const branch = createMemo(() => + resolveNewSessionBranch({ + worktree: value(), + local: localBranch(), + worktreeBranch: (worktree) => serverSync().child(worktree)[0].vcs?.branch, + }), + ) + const remember = (worktree = value()) => { + const project = sync().project + if (!project) return + const local = worktree === "main" || pathKey(worktree) === pathKey(project.worktree) + settings.workspaces.setLastUsed(serverSDK().scope, project.id, local ? "local" : "workspace") + } + + return { + selection: { + value, + reset: () => input.setSelected(undefined), + remember, + set: (worktree: string) => { + input.setSelected(normalizeNewSessionWorktree(worktree, sdk().directory, sync().project?.worktree)) + remember(worktree) + }, + }, + project: { + root: projectRoot, + workspaces: () => sync().project?.sandboxes ?? [], + git: () => sync().project?.vcs === "git", + openAll: input.onViewAll, + }, + bar: { + visible, + branch, + }, + } +} + +export type NewSessionWorkspaceController = ReturnType diff --git a/packages/app/src/pages/new-session/use-new-session-commands.tsx b/packages/app/src/pages/new-session/use-new-session-commands.tsx new file mode 100644 index 00000000000..a0d835f97f5 --- /dev/null +++ b/packages/app/src/pages/new-session/use-new-session-commands.tsx @@ -0,0 +1,44 @@ +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useSettingsCommand } from "@/components/settings-dialog" +import { useCommand } from "@/context/command" +import { useLanguage } from "@/context/language" + +export function useNewSessionCommands(input: { + restoreFocus: () => void + project: { + empty: () => boolean + open: () => void + } +}) { + const command = useCommand() + const dialog = useDialog() + const language = useLanguage() + + useSettingsCommand() + command.register("new-session", () => [ + { + id: "command.palette", + title: language.t("command.palette"), + hidden: true, + onSelect: async () => { + const { DialogSelectFile } = await import("@/components/dialog-select-file") + void dialog.show(() => ) + }, + }, + { + id: "input.focus", + title: language.t("command.input.focus"), + category: language.t("command.category.view"), + keybind: "ctrl+l", + onSelect: input.restoreFocus, + }, + { + id: "project.select", + title: language.t("session.new.project.search"), + category: language.t("command.category.project"), + keybind: "mod+shift+o", + disabled: input.project.empty(), + onSelect: input.project.open, + }, + ]) +} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 22b2d914e81..206096b8515 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -6,6 +6,7 @@ import { batch, ErrorBoundary, onCleanup, + Suspense, Show, Match, Switch, @@ -2257,6 +2258,10 @@ export default function Page() { comments.clear() resumeScroll() }, + get edit() { + return editingFollowup() + }, + onEditLoaded: clearFollowupEdit, shouldQueue: queueEnabled, onQueue: queueFollowup, onAbort: () => { @@ -2265,14 +2270,7 @@ export default function Page() { setFollowup("paused", id, true) }, }) - return ( - - ) + return }} } @@ -2340,49 +2338,53 @@ export default function Page() {
- + + +
- hasReview() || reviewV2State.sidebarOpened()} - reviewCount={reviewCount} - reviewPanel={reviewPanelV2} - reviewSidebarToggle={(disabled) => ( - - )} - fileBrowserState={reviewV2State} - activeDiff={activeReviewFile()} - focusReviewDiff={focusReviewDiff} - reviewSnap={ui.reviewSnap} - size={size} - stacked={desktopV2PanelLayout().stacked} - /> + + hasReview() || reviewV2State.sidebarOpened()} + reviewCount={reviewCount} + reviewPanel={reviewPanelV2} + reviewSidebarToggle={(disabled) => ( + + )} + fileBrowserState={reviewV2State} + activeDiff={activeReviewFile()} + focusReviewDiff={focusReviewDiff} + reviewSnap={ui.reviewSnap} + size={size} + stacked={desktopV2PanelLayout().stacked} + /> +
diff --git a/packages/app/src/utils/menu-dismiss-controller.ts b/packages/app/src/utils/menu-dismiss-controller.ts new file mode 100644 index 00000000000..0a3009eb716 --- /dev/null +++ b/packages/app/src/utils/menu-dismiss-controller.ts @@ -0,0 +1,30 @@ +/** Coordinates focus restoration and actions that must run after menu content unmounts. */ +export function createMenuDismissController(content: () => HTMLElement | undefined) { + let restoreTrigger = true + + return { + /** Allows the menu primitive to restore focus to its trigger when closing. */ + allowTriggerRestore() { + restoreTrigger = true + }, + /** Keeps focus at its current or next destination instead of returning it to the trigger. */ + preventTriggerRestore() { + restoreTrigger = false + }, + /** Applies the current restoration policy during the menu primitive's close-focus event. */ + onCloseAutoFocus(event: Event) { + if (!restoreTrigger) event.preventDefault() + }, + /** Runs an action after the menu unmounts and its focus-close work has settled. */ + afterClose(callback: () => void) { + const complete = () => { + if (content()?.isConnected) { + requestAnimationFrame(complete) + return + } + requestAnimationFrame(() => requestAnimationFrame(callback)) + } + requestAnimationFrame(complete) + }, + } +} diff --git a/packages/app/test-browser/settings-keybinds.test.ts b/packages/app/test-browser/settings-keybinds.test.ts new file mode 100644 index 00000000000..75f0b755434 --- /dev/null +++ b/packages/app/test-browser/settings-keybinds.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from "bun:test" +import { createRoot } from "solid-js" +import { createKeybindSettingsController } from "../src/components/settings-keybinds" + +function setup(overrides: Record = {}) { + const changes: [string, string][] = [] + const suppression: boolean[] = [] + const notifications: { title: string; description?: string }[] = [] + let resets = 0 + let controller: ReturnType + + const dispose = createRoot((dispose) => { + controller = createKeybindSettingsController( + { + command: { + catalog: [ + { id: "session.alpha", title: "Alpha", keybind: "mod+a" }, + { id: "session.beta", title: "Beta", keybind: "mod+b" }, + ], + options: [], + keybinds: (enabled) => suppression.push(enabled), + }, + settings: { + current: { keybinds: overrides }, + keybinds: { + get: (id) => overrides[id], + set: (id, value) => { + overrides[id] = value + changes.push([id, value]) + }, + resetAll: () => { + resets++ + }, + }, + }, + notify: (toast) => notifications.push(toast), + }, + { + locale: () => "en", + t: (key, params) => { + if (params) return `${key}:${Object.values(params).join("|")}` + if (key === "common.key.alt") return "Alt" + return String(key) + }, + }, + ) + return dispose + }) + + return { + controller: controller!, + changes, + suppression, + notifications, + resets: () => resets, + dispose, + } +} + +function modKey(key: string) { + const mac = /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) + return new KeyboardEvent("keydown", { key, ctrlKey: !mac, metaKey: mac, bubbles: true, cancelable: true }) +} + +describe("keybind settings controller", () => { + test("derives the catalog, effective bindings, and filtered groups", () => { + const state = setup({ "session.beta": "alt+k" }) + + expect(state.controller.catalog.title("session.alpha")).toBe("Alpha") + expect(state.controller.catalog.keybind("session.beta")).toBe("Alt+K") + expect(state.controller.catalog.filtered("alt k").get("Session")).toEqual(["session.beta"]) + expect(state.controller.settings.hasOverrides()).toBe(true) + + state.dispose() + }) + + test("captures bindings, rejects conflicts, and restores command handling", () => { + const state = setup() + + state.controller.capture.toggle("session.beta") + document.dispatchEvent(modKey("a")) + expect(state.changes).toEqual([]) + expect(state.notifications).toHaveLength(1) + expect(state.controller.capture.active()).toBe("session.beta") + + document.dispatchEvent(modKey("x")) + expect(state.changes).toEqual([["session.beta", "mod+x"]]) + expect(state.suppression).toEqual([false, true]) + expect(state.controller.capture.active()).toBeNull() + + state.controller.capture.toggle("session.alpha") + state.dispose() + expect(state.suppression).toEqual([false, true, false, true]) + document.dispatchEvent(modKey("z")) + expect(state.changes).toEqual([["session.beta", "mod+x"]]) + }) + + test("resets persisted overrides and reports success", () => { + const state = setup({ "session.alpha": "none" }) + + state.controller.settings.reset() + expect(state.resets()).toBe(1) + expect(state.notifications[0]?.title).toBe("settings.shortcuts.reset.toast.title") + + state.dispose() + }) +}) diff --git a/packages/cli/package.json b/packages/cli/package.json index d8886470a09..45271e869c9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/cli", - "version": "1.18.6", + "version": "1.18.8", "type": "module", "license": "MIT", "bin": { diff --git a/packages/codemode/package.json b/packages/codemode/package.json index a57dad61302..51df6e53a57 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/codemode", - "version": "1.18.6", + "version": "1.18.8", "description": "Effect-native confined code execution over schema-described tools", "private": true, "type": "module", diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 0ccae42dbfc..446c3d0854a 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.18.6", + "version": "1.18.8", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/app/src/routes/brand/index.css b/packages/console/app/src/routes/brand/index.css index 8a326515911..0e39f3742f2 100644 --- a/packages/console/app/src/routes/brand/index.css +++ b/packages/console/app/src/routes/brand/index.css @@ -376,7 +376,6 @@ align-items: center; gap: 16px; opacity: 0; - transition: opacity 0.2s ease; @media (max-width: 40rem) { position: static; diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 4e7f7658510..f5a1bbb72a4 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.18.6", + "version": "1.18.8", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index edb98fa95ea..937c0cf5386 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.18.6", + "version": "1.18.8", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 26b7745a9ab..531b0ec651e 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.18.6", + "version": "1.18.8", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/console/support/package.json b/packages/console/support/package.json index ee4bd72911e..a556e236bb0 100644 --- a/packages/console/support/package.json +++ b/packages/console/support/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-support", - "version": "1.18.6", + "version": "1.18.8", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/core/package.json b/packages/core/package.json index b297ac569aa..c1635e63238 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.6", + "version": "1.18.8", "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.11.1", + "gitlab-ai-provider": "6.12.0", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", diff --git a/packages/core/src/plugin/skill/customize-opencode.md b/packages/core/src/plugin/skill/customize-opencode.md index 6932dbfd54c..549f15e2279 100644 --- a/packages/core/src/plugin/skill/customize-opencode.md +++ b/packages/core/src/plugin/skill/customize-opencode.md @@ -112,7 +112,7 @@ Every field is optional. "type": "local", "command": ["npx", "-y", "@playwright/mcp"], "enabled": true, - "env": {} + "environment": {} }, "remote-thing": { "type": "remote", @@ -371,7 +371,7 @@ Special object-shaped (not callbacks): `tool: { my_tool: { ... } }`, "type": "local", "command": ["npx", "-y", "@playwright/mcp"], "enabled": true, - "env": { "BROWSER": "chromium" } + "environment": { "BROWSER": "chromium" } }, "github": { "type": "remote", @@ -384,7 +384,8 @@ Special object-shaped (not callbacks): `tool: { my_tool: { ... } }`, } ``` -`command` is an array of strings. `type` is required. Use `enabled: false` to +`command` is an array of strings. `environment` sets environment variables for +a local MCP server. `type` is required. Use `enabled: false` to disable a server inherited from a parent config. String values such as header tokens support `{env:VAR}` interpolation (and `{file:path}`); the shell-style `${VAR}` is not substituted. diff --git a/packages/desktop/electron-builder.config.test.ts b/packages/desktop/electron-builder.config.test.ts index eee2b825cb5..3fb1adb6c17 100644 --- a/packages/desktop/electron-builder.config.test.ts +++ b/packages/desktop/electron-builder.config.test.ts @@ -24,6 +24,8 @@ for (const channel of channels) { expect(config.extraMetadata?.desktopName).toBe(`${channel.appId}.desktop`) expect(config.linux?.executableName).toBe(channel.appId) expect(config.linux?.desktop?.entry?.StartupWMClass).toBe(channel.appId) + expect(config.deb?.fpm).toContainEqual(expect.stringContaining(`/usr/share/metainfo/${channel.appId}.metainfo.xml`)) + expect(config.rpm?.fpm).toContainEqual(expect.stringContaining(`/usr/share/metainfo/${channel.appId}.metainfo.xml`)) }) } @@ -37,8 +39,16 @@ test("keeps a hidden prod launcher for old Linux pins", async () => { if (previous === undefined) delete process.env.OPENCODE_CHANNEL else process.env.OPENCODE_CHANNEL = previous - expect(config.deb?.fpm?.[0]).toEndWith(`${legacyDesktopEntry}=/usr/share/applications/opencode-desktop.desktop`) - expect(config.rpm?.fpm?.[0]).toEndWith(`${legacyDesktopEntry}=/usr/share/applications/opencode-desktop.desktop`) + expect( + config.deb?.fpm?.some((entry) => + entry.endsWith("opencode-desktop.desktop=/usr/share/applications/opencode-desktop.desktop"), + ), + ).toBe(true) + expect( + config.rpm?.fpm?.some((entry) => + entry.endsWith("opencode-desktop.desktop=/usr/share/applications/opencode-desktop.desktop"), + ), + ).toBe(true) const desktop = await Bun.file(legacyDesktopEntry).text() expect(desktop).toContain("Exec=/opt/OpenCode/ai.opencode.desktop %U") @@ -46,3 +56,36 @@ test("keeps a hidden prod launcher for old Linux pins", async () => { expect(desktop).toContain("StartupWMClass=ai.opencode.desktop") expect(desktop).toContain("NoDisplay=true") }) + +test("bundles the CLI outside the dev app archive", async () => { + const previous = process.env.OPENCODE_CHANNEL + process.env.OPENCODE_CHANNEL = "dev" + const module = await import("./electron-builder.config.ts?cli-resource") + const config = module.default as Configuration + if (previous === undefined) delete process.env.OPENCODE_CHANNEL + else process.env.OPENCODE_CHANNEL = previous + + expect(config.files).toContain("!resources/opencode-cli*") + expect(config.extraResources).toContainEqual({ + from: "resources/", + to: "", + filter: ["opencode-cli*"], + }) +}) + +for (const channel of ["beta", "prod"] as const) { + test(`does not bundle the CLI in ${channel} builds`, async () => { + const previous = process.env.OPENCODE_CHANNEL + process.env.OPENCODE_CHANNEL = channel + const module = await import(`./electron-builder.config.ts?no-cli-resource=${channel}`) + const config = module.default as Configuration + if (previous === undefined) delete process.env.OPENCODE_CHANNEL + else process.env.OPENCODE_CHANNEL = previous + + expect(config.extraResources).not.toContainEqual({ + from: "resources/", + to: "", + filter: ["opencode-cli*"], + }) + }) +} diff --git a/packages/desktop/electron-builder.config.ts b/packages/desktop/electron-builder.config.ts index 23afe811c2b..508c0df5e91 100644 --- a/packages/desktop/electron-builder.config.ts +++ b/packages/desktop/electron-builder.config.ts @@ -15,6 +15,9 @@ const signScript = path.join(rootDir, "script", "sign-windows.ps1") const legacyDesktopEntry = path.join(packageDir, "resources", "linux", "opencode-desktop.desktop") const legacyDesktopEntryFpm = `${legacyDesktopEntry}=/usr/share/applications/opencode-desktop.desktop` +const metainfoFpm = (appId: string) => + `${path.join(packageDir, "resources", `${appId}.metainfo.xml`)}=/usr/share/metainfo/${appId}.metainfo.xml` + async function signWindows(configuration: { path: string }) { if (process.platform !== "win32") return if (process.env.GITHUB_ACTIONS !== "true") return @@ -52,8 +55,17 @@ const getBase = (appId: string): Configuration => ({ extraMetadata: { desktopName: `${appId}.desktop`, }, - files: ["out/**/*", "resources/**/*"], + files: ["out/**/*", "resources/**/*", "!resources/opencode-cli*"], extraResources: [ + ...(channel === "dev" + ? [ + { + from: "resources/", + to: "", + filter: ["opencode-cli*"], + }, + ] + : []), { from: "native/", to: "native/", @@ -116,7 +128,8 @@ function getConfig() { ...base, appId, productName: "OpenCode Dev", - rpm: { packageName: "opencode-dev" }, + deb: { fpm: [metainfoFpm(appId)] }, + rpm: { packageName: "opencode-dev", fpm: [metainfoFpm(appId)] }, } } case "beta": { @@ -126,7 +139,8 @@ function getConfig() { productName: "OpenCode Beta", protocols: { name: "OpenCode Beta", schemes: ["opencode"] }, publish: { provider: "github", owner: "anomalyco", repo: "opencode-beta", channel: "latest" }, - rpm: { packageName: "opencode-beta" }, + deb: { fpm: [metainfoFpm(appId)] }, + rpm: { packageName: "opencode-beta", fpm: [metainfoFpm(appId)] }, } } case "prod": { @@ -136,8 +150,8 @@ function getConfig() { productName: "OpenCode", protocols: { name: "OpenCode", schemes: ["opencode"] }, publish: { provider: "github", owner: "anomalyco", repo: "opencode", channel: "latest" }, - deb: { fpm: [legacyDesktopEntryFpm] }, - rpm: { packageName: "opencode", fpm: [legacyDesktopEntryFpm] }, + deb: { fpm: [metainfoFpm(appId), legacyDesktopEntryFpm] }, + rpm: { packageName: "opencode", fpm: [metainfoFpm(appId), legacyDesktopEntryFpm] }, } } } diff --git a/packages/desktop/package.json b/packages/desktop/package.json index fc7d278bedf..2f3b90ae9ba 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.18.6", + "version": "1.18.8", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/scripts/prebuild.ts b/packages/desktop/scripts/prebuild.ts index 79b0e30afcd..636850a3875 100644 --- a/packages/desktop/scripts/prebuild.ts +++ b/packages/desktop/scripts/prebuild.ts @@ -1,10 +1,11 @@ #!/usr/bin/env bun import { $ } from "bun" -import { resolveChannel } from "./utils" +import { downloadCliToResources, resolveChannel } from "./utils" const channel = resolveChannel() await $`bun ./scripts/copy-icons.ts ${channel}` await $`bun ./scripts/copy-metainfo.ts ${channel}` await $`cd ../opencode && bun script/build-node.ts` +if (channel === "dev") await downloadCliToResources() diff --git a/packages/desktop/scripts/predev.ts b/packages/desktop/scripts/predev.ts index 37c31d7eedb..bfa399e4e53 100644 --- a/packages/desktop/scripts/predev.ts +++ b/packages/desktop/scripts/predev.ts @@ -1,5 +1,9 @@ import { $ } from "bun" +import { downloadCliToResources } from "./utils" + +await $`bun run install-electron` await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}` await $`cd ../opencode && bun script/build-node.ts` +await downloadCliToResources() diff --git a/packages/desktop/scripts/utils.ts b/packages/desktop/scripts/utils.ts index 19b96b0a161..bf728758ed1 100644 --- a/packages/desktop/scripts/utils.ts +++ b/packages/desktop/scripts/utils.ts @@ -1,4 +1,9 @@ import { $ } from "bun" +import { chmod, copyFile, mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" + +const CLI_VERSION = "0.0.0-next-16350" export type Channel = "dev" | "beta" | "prod" @@ -8,36 +13,42 @@ export function resolveChannel(): Channel { return "dev" } -export const SIDECAR_BINARIES: Array<{ rustTarget: string; ocBinary: string; assetExt: string }> = [ +export const CLI_BINARIES: Array<{ rustTarget: string; package: string; os: string; cpu: string }> = [ { rustTarget: "aarch64-apple-darwin", - ocBinary: "opencode-darwin-arm64", - assetExt: "zip", + package: "@opencode-ai/cli-darwin-arm64", + os: "darwin", + cpu: "arm64", }, { rustTarget: "x86_64-apple-darwin", - ocBinary: "opencode-darwin-x64-baseline", - assetExt: "zip", + package: "@opencode-ai/cli-darwin-x64-baseline", + os: "darwin", + cpu: "x64", }, { rustTarget: "aarch64-pc-windows-msvc", - ocBinary: "opencode-windows-arm64", - assetExt: "zip", + package: "@opencode-ai/cli-windows-arm64", + os: "win32", + cpu: "arm64", }, { rustTarget: "x86_64-pc-windows-msvc", - ocBinary: "opencode-windows-x64-baseline", - assetExt: "zip", + package: "@opencode-ai/cli-windows-x64-baseline", + os: "win32", + cpu: "x64", }, { rustTarget: "x86_64-unknown-linux-gnu", - ocBinary: "opencode-linux-x64-baseline", - assetExt: "tar.gz", + package: "@opencode-ai/cli-linux-x64-baseline", + os: "linux", + cpu: "x64", }, { rustTarget: "aarch64-unknown-linux-gnu", - ocBinary: "opencode-linux-arm64", - assetExt: "tar.gz", + package: "@opencode-ai/cli-linux-arm64", + os: "linux", + cpu: "arm64", }, ] @@ -51,24 +62,33 @@ function nativeTarget() { throw new Error(`Unsupported platform: ${platform}/${arch}`) } -export function getCurrentSidecar(target = RUST_TARGET ?? nativeTarget()) { - const binaryConfig = SIDECAR_BINARIES.find((b) => b.rustTarget === target) - if (!binaryConfig) throw new Error(`Sidecar configuration not available for Rust target '${target}'`) +export function getCurrentCli(target = RUST_TARGET ?? nativeTarget()) { + const binaryConfig = CLI_BINARIES.find((item) => item.rustTarget === target) + if (!binaryConfig) throw new Error(`CLI configuration not available for target '${target}'`) return binaryConfig } -export async function copyBinaryToSidecarFolder(source: string) { - const dir = `resources` - await $`mkdir -p ${dir}` - const dest = windowsify(`${dir}/opencode-cli`) - await $`cp ${source} ${dest}` +export async function downloadCliToResources() { + const cli = getCurrentCli() + const directory = await mkdtemp(join(tmpdir(), "opencode-cli-")) + const dest = windowsify("resources/opencode-cli") + try { + await $`bun install --no-save --cwd ${directory} ${`${cli.package}@${CLI_VERSION}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}` + await copyFile( + join(directory, "node_modules", cli.package, "bin", cli.os === "win32" ? "opencode2.exe" : "opencode2"), + dest, + ) + } finally { + await rm(directory, { recursive: true, force: true }) + } + if (process.platform !== "win32") await chmod(dest, 0o755) if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") { await $`pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ../../script/sign-windows.ps1 ${dest}` } if (process.platform === "darwin") await $`codesign --force --sign - ${dest}` - console.log(`Copied ${source} to ${dest}`) + console.log(`Copied ${cli.package} to ${dest}`) } export function windowsify(path: string) { diff --git a/packages/desktop/src/main/background-cli.ts b/packages/desktop/src/main/background-cli.ts new file mode 100644 index 00000000000..66602d51f11 --- /dev/null +++ b/packages/desktop/src/main/background-cli.ts @@ -0,0 +1,125 @@ +import { execFile } from "node:child_process" +import { existsSync } from "node:fs" +import { chmod, copyFile, mkdir, rename, rm } from "node:fs/promises" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { promisify } from "node:util" +import { app } from "electron" + +const execFileAsync = promisify(execFile) +const root = dirname(fileURLToPath(import.meta.url)) +const stateHome = process.env.XDG_STATE_HOME +const desktopStateNames = ["ai.opencode.desktop.dev", "ai.opencode.desktop.beta", "ai.opencode.desktop"] + +type Logger = { + log(message: string, meta?: Record): void + error(message: string, meta?: Record): void +} + +export async function startBackgroundCli(logger: Logger, shellStateHome?: string) { + const bundled = app.isPackaged + ? join(process.resourcesPath, executableName()) + : join(root, "../../resources", executableName()) + logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged }) + const version = await run(bundled, ["--version"], logger) + const binary = app.isPackaged ? await installCli(bundled, version, logger) : bundled + + const candidates = [ + ...new Set([stateHome, shellStateHome, ...desktopStateNames.map((name) => join(app.getPath("appData"), name))]), + ].filter((candidate) => candidate === undefined || existsSync(candidate)) + const discovered = await Promise.all( + candidates.map(async (candidate) => ({ + stateHome: candidate, + url: serviceUrl(await run(binary, ["service", "status"], logger, { stateHome: candidate })), + })), + ) + const found = discovered.find((candidate) => candidate.url !== undefined) + logger.log("v2 CLI background instance checked", { + detected: Boolean(found), + ...endpoint(found?.url), + }) + + const daemonStateHome = found?.stateHome ?? stateHome + const url = await run(binary, ["service", "start"], logger, { stateHome: daemonStateHome }) + const password = await run(binary, ["service", "get", "password"], logger, { + redact: true, + stateHome: daemonStateHome, + }) + logger.log("v2 CLI background service ready", { + existing: Boolean(found), + username: "opencode", + ...endpoint(url), + }) + return { + url, + username: "opencode", + password, + } +} + +async function installCli(source: string, version: string, logger: Logger) { + const directory = join(app.getPath("userData"), "cli", version.replace(/[^a-zA-Z0-9._-]/g, "-")) + const destination = join(directory, executableName()) + if (existsSync(destination)) { + logger.log("v2 CLI staged executable reused", { path: destination, version }) + return destination + } + + const temp = destination + `.${process.pid}.tmp` + await mkdir(directory, { recursive: true }) + await copyFile(source, temp) + if (process.platform !== "win32") await chmod(temp, 0o755) + await rename(temp, destination).catch(async (error) => { + await rm(temp, { force: true }) + throw error + }) + logger.log("v2 CLI executable staged", { source, path: destination, version }) + return destination +} + +async function run( + binary: string, + args: string[], + logger: Logger, + options: { redact?: boolean; stateHome?: string } = {}, +) { + logger.log("v2 CLI command started", { binary, args }) + const env = { ...process.env } + if (options.stateHome === undefined) delete env.XDG_STATE_HOME + else env.XDG_STATE_HOME = options.stateHome + return execFileAsync(binary, args, { env, windowsHide: true }).then( + (result) => { + const stdout = result.stdout.trim() + const stderr = result.stderr.trim() + logger.log("v2 CLI command completed", { args, stdout: options.redact ? "[redacted]" : stdout, stderr }) + return stdout + }, + (error: unknown) => { + const output = error as { stdout?: string; stderr?: string } + logger.error("v2 CLI command failed", { + args, + error: error instanceof Error ? error.message : String(error), + stdout: options.redact && output.stdout ? "[redacted]" : (output.stdout?.trim() ?? ""), + stderr: output.stderr?.trim() ?? "", + }) + throw error + }, + ) +} + +function serviceUrl(status: string) { + if (URL.canParse(status)) return status + if (!status.startsWith("running ")) return + const url = status.slice("running ".length).trim() + return URL.canParse(url) ? url : undefined +} + +function endpoint(url: string | undefined) { + if (!url || !URL.canParse(url)) return {} + const parsed = new URL(url) + return { url, hostname: parsed.hostname, port: parsed.port } +} + +function executableName() { + return process.platform === "win32" ? "opencode-cli.exe" : "opencode-cli" +} diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index c7c1643092d..67696a5470a 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -48,6 +48,7 @@ import { registerWslIpcHandlers } from "./wsl/ipc" import { spawnWslSidecar } from "./wsl/sidecar" import { migrate } from "./migrate" import { cleanupStoreFiles } from "./store-cleanup" +import { startBackgroundCli } from "./background-cli" const APP_NAMES: Record = { dev: "OpenCode Dev", @@ -60,6 +61,7 @@ const APP_IDS: Record = { prod: "ai.opencode.desktop", } const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1" +const SIDECAR_VERSION = process.env.OPENCODE_SIDECAR_V2 === "1" ? "v2" : "v1" const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports" let logger: ReturnType @@ -198,7 +200,7 @@ const main = Effect.gen(function* () { return } - preferAppEnv(app.getPath("userData")) + const shellEnv = preferAppEnv(app.getPath("userData")) app.on("second-instance", (_event: Event, argv: string[]) => { const urls = argv.filter((arg: string) => arg.startsWith("opencode://")) @@ -312,39 +314,56 @@ const main = Effect.gen(function* () { ), ) - const port = yield* Effect.gen(function* () { - const fromEnv = process.env.OPENCODE_PORT - if (fromEnv) { - const parsed = Number.parseInt(fromEnv, 10) - if (!Number.isNaN(parsed)) return parsed - } - - const res = yield* Deferred.make() - const server = createServer() - server.on("error", (e) => Deferred.failSync(res, () => e)) - server.listen(0, "127.0.0.1", () => { - const address = server.address() - if (typeof address !== "object" || !address) { - server.close() - Deferred.failSync(res, () => new Error("Failed to get port")) - return - } - const port = address.port - server.close(() => Effect.runSync(Deferred.succeed(res, port))) - }) - - return yield* Deferred.await(res) - }) - const hostname = "127.0.0.1" - const url = `http://${hostname}:${port}` - const password = randomUUID() - const loadingTask = yield* Effect.gen(function* () { - logger.log("sidecar connection started", { url }) + logger.log("sidecar connection started", { version: SIDECAR_VERSION }) ensureLoopbackNoProxy() useEnvProxy() + if (SIDECAR_VERSION === "v2") { + logger.log("spawning v2 sidecar") + const sidecar = yield* Effect.promise(() => startBackgroundCli(logger, shellEnv?.XDG_STATE_HOME)) + yield* Deferred.succeed(serverReady, { + url: sidecar.url, + username: sidecar.username, + password: sidecar.password, + }) + + if (process.platform === "win32") { + void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error)) + } + + logger.log("loading task finished") + return + } + + const port = yield* Effect.gen(function* () { + const fromEnv = process.env.OPENCODE_PORT + if (fromEnv) { + const parsed = Number.parseInt(fromEnv, 10) + if (!Number.isNaN(parsed)) return parsed + } + + const res = yield* Deferred.make() + const socket = createServer() + socket.on("error", (e) => Deferred.failSync(res, () => e)) + socket.listen(0, "127.0.0.1", () => { + const address = socket.address() + if (typeof address !== "object" || !address) { + socket.close() + Deferred.failSync(res, () => new Error("Failed to get port")) + return + } + const port = address.port + socket.close(() => Effect.runSync(Deferred.succeed(res, port))) + }) + + return yield* Deferred.await(res) + }) + const hostname = "127.0.0.1" + const url = `http://${hostname}:${port}` + const password = randomUUID() + logger.log("spawning sidecar", { url }) const { listener, health } = yield* Effect.promise(() => spawnLocalServer(hostname, port, password, { diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 4563b688ace..5ecd4cb8c45 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -227,6 +227,11 @@ export function registerIpcHandlers(deps: Deps) { return win?.isFocused() ?? false }) + ipcMain.handle("get-window-fullscreen", (event: IpcMainInvokeEvent) => { + const win = BrowserWindow.fromWebContents(event.sender) + return win?.isFullScreen() ?? false + }) + ipcMain.handle("set-window-focus", (event: IpcMainInvokeEvent) => { const win = BrowserWindow.fromWebContents(event.sender) win?.focus() diff --git a/packages/desktop/src/main/server.ts b/packages/desktop/src/main/server.ts index 0f2d9d6ad12..ae1a98efdfa 100644 --- a/packages/desktop/src/main/server.ts +++ b/packages/desktop/src/main/server.ts @@ -43,13 +43,15 @@ export function setDefaultServerUrl(url: string | null) { export function preferAppEnv(userDataPath: string) { const shell = process.platform === "win32" ? null : getUserShell() + const shellEnv = shell ? loadShellEnv(shell, getLogger()) : null Object.assign(process.env, { - ...(shell ? loadShellEnv(shell, getLogger()) : null), + ...shellEnv, OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", OPENCODE_CLIENT: "desktop", XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath, }) + return shellEnv } export async function spawnLocalServer( @@ -214,7 +216,6 @@ function createSidecarEnv(): Record { ) delete env.DEBUG if (process.platform === "linux") delete env.LD_PRELOAD - if (!app.isPackaged) env.OPENCODE_DISABLE_CHANNEL_DB = "1" return env } diff --git a/packages/desktop/src/main/windows.ts b/packages/desktop/src/main/windows.ts index 7a594b5302e..0b88032950d 100644 --- a/packages/desktop/src/main/windows.ts +++ b/packages/desktop/src/main/windows.ts @@ -219,6 +219,7 @@ export function createMainWindow(id: string = randomUUID()) { state.manage(win) registerWindow(win, id) + wireFullscreen(win) loadWindow(win, "index.html") wireZoom(win) @@ -472,6 +473,16 @@ function wireZoom(win: BrowserWindow) { }) } +function wireFullscreen(win: BrowserWindow) { + const send = (fullscreen: boolean) => { + if (win.isDestroyed() || win.webContents.isDestroyed()) return + win.webContents.send("window-fullscreen-changed", fullscreen) + } + + win.on("enter-full-screen", () => send(true)) + win.on("leave-full-screen", () => send(false)) +} + function clampZoom(value: number) { return Math.min(Math.max(value, minZoomLevel), maxZoomLevel) } diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 87eac1fa805..1a0056dfe71 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -100,6 +100,12 @@ const api: ElectronAPI = { readClipboardImage: () => ipcRenderer.invoke("read-clipboard-image"), showNotification: (title, body) => ipcRenderer.send("show-notification", title, body), getWindowFocused: () => ipcRenderer.invoke("get-window-focused"), + getWindowFullscreen: () => ipcRenderer.invoke("get-window-fullscreen"), + onWindowFullscreenChanged: (cb) => { + const handler = (_: unknown, fullscreen: boolean) => cb(fullscreen) + ipcRenderer.on("window-fullscreen-changed", handler) + return () => ipcRenderer.removeListener("window-fullscreen-changed", handler) + }, setWindowFocus: () => ipcRenderer.invoke("set-window-focus"), showWindow: () => ipcRenderer.invoke("show-window"), relaunch: () => ipcRenderer.send("relaunch"), diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index 5fbace72c7c..ea201af63ed 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -91,6 +91,8 @@ export type ElectronAPI = { readClipboardImage: () => Promise<{ buffer: ArrayBuffer; width: number; height: number } | null> showNotification: (title: string, body?: string) => void getWindowFocused: () => Promise + getWindowFullscreen: () => Promise + onWindowFullscreenChanged: (cb: (fullscreen: boolean) => void) => () => void setWindowFocus: () => Promise showWindow: () => Promise relaunch: () => void diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index b7d2d9a74da..ac5ded25f1f 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -25,6 +25,7 @@ import { initI18n, t } from "./i18n" import { initializationData, initializationReady } from "./initialization" import { DesktopFirstLaunchOnboarding } from "./onboarding" import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom" +import { windowFullscreen } from "./window-fullscreen" import { availableStartupServer, readyWslConnections } from "./wsl/connections" import "./styles.css" import { Splash } from "@opencode-ai/ui/logo" @@ -294,6 +295,8 @@ const createPlatform = (windowState: DesktopWindowState): Platform => { webviewZoom, + windowFullscreen, + getPinchZoomEnabled: () => window.api.getPinchZoomEnabled(), setPinchZoomEnabled, diff --git a/packages/desktop/src/renderer/window-fullscreen.ts b/packages/desktop/src/renderer/window-fullscreen.ts new file mode 100644 index 00000000000..95d91fa2e7e --- /dev/null +++ b/packages/desktop/src/renderer/window-fullscreen.ts @@ -0,0 +1,8 @@ +import { createSignal } from "solid-js" + +const [windowFullscreen, setWindowFullscreen] = createSignal(false) + +window.api.onWindowFullscreenChanged(setWindowFullscreen) +void window.api.getWindowFullscreen().then(setWindowFullscreen) + +export { windowFullscreen } diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index 84c6cda1313..a3e9ea8814e 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.6", + "version": "1.18.8", "name": "@opencode-ai/effect-drizzle-sqlite", "type": "module", "license": "MIT", diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json index fd915ebd3ba..507ad6769ea 100644 --- a/packages/effect-sqlite-node/package.json +++ b/packages/effect-sqlite-node/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.6", + "version": "1.18.8", "name": "@opencode-ai/effect-sqlite-node", "type": "module", "license": "MIT", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index f338cde8fc9..7a4c65783f6 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.18.6", + "version": "1.18.8", "private": true, "type": "module", "license": "MIT", diff --git a/packages/function/package.json b/packages/function/package.json index 117632f768e..e7f85161616 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.18.6", + "version": "1.18.8", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index 40e0dd80c7f..1eaf4333fc2 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.6", + "version": "1.18.8", "name": "@opencode-ai/http-recorder", "description": "Record and replay Effect HTTP client traffic with deterministic cassettes", "type": "module", diff --git a/packages/llm/package.json b/packages/llm/package.json index 100aa855268..6d3a826e59a 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.6", + "version": "1.18.8", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 380bef3358b..293a678e9bb 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.6", + "version": "1.18.8", "name": "opencode", "type": "module", "license": "MIT", @@ -30,6 +30,7 @@ }, "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:*", @@ -80,7 +81,7 @@ "@effect/platform-node": "catalog:", "@ff-labs/fff-bun": "0.9.4", "@gitlab/opencode-gitlab-auth": "1.3.3", - "@modelcontextprotocol/sdk": "1.29.0", + "@modelcontextprotocol/client": "2.0.0-beta.5", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", @@ -120,7 +121,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.11.1", + "gitlab-ai-provider": "6.12.0", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index c2d2ee2f3b7..c9f74e2bc03 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -2,13 +2,10 @@ import { cmd } from "./cmd" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { effectCmd } from "../effect-cmd" import { Cause } from "effect" -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 { Client, StreamableHTTPClientTransport, UnauthorizedError } from "@modelcontextprotocol/client" import * as prompts from "@clack/prompts" import { UI } from "../ui" -import { MCP } from "../../mcp" +import { CLIENT_OPTIONS, MCP } from "../../mcp" import { McpAuth } from "../../mcp/auth" import { McpOAuthProvider } from "../../mcp/oauth-provider" import { Config } from "@/config/config" @@ -731,107 +728,53 @@ export const McpDebugCommand = effectCmd({ const spinner = prompts.spinner() spinner.start("Testing connection...") - // Test basic HTTP connectivity first - try { - const response = await fetch(serverConfig.url, { - method: "POST", - headers: { - ...serverConfig.headers, - "Content-Type": "application/json", - Accept: "application/json, text/event-stream", + 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 }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "initialize", - params: { - protocolVersion: LATEST_PROTOCOL_VERSION, - capabilities: {}, - clientInfo: { name: "opencode-debug", version: InstallationVersion }, - }, - id: 1, - }), - }) + }, + 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) - 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}`) - } - - 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)}`) - } - } + 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) { - spinner.stop("Connection failed", 1) - prompts.log.error(`Error: ${error instanceof Error ? error.message : String(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)}`) + } + } finally { + await client.close().catch(() => {}) } prompts.outro("Debug complete") diff --git a/packages/opencode/src/mcp/auth.ts b/packages/opencode/src/mcp/auth.ts index 808aa302962..543b4fb0e33 100644 --- a/packages/opencode/src/mcp/auth.ts +++ b/packages/opencode/src/mcp/auth.ts @@ -11,6 +11,7 @@ export const Tokens = Schema.Struct({ 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 @@ -19,6 +20,9 @@ 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 diff --git a/packages/opencode/src/mcp/catalog.ts b/packages/opencode/src/mcp/catalog.ts index 3f985eeb94d..226cd6bb258 100644 --- a/packages/opencode/src/mcp/catalog.ts +++ b/packages/opencode/src/mcp/catalog.ts @@ -1,77 +1,57 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js" -import { - CallToolResultSchema, - ListToolsResultSchema, - ToolSchema, - type Tool as MCPToolDef, -} from "@modelcontextprotocol/sdk/types.js" +import { Client, type CallToolResult, type Tool as MCPToolDef } from "@modelcontextprotocol/client" import { dynamicTool, jsonSchema, type JSONSchema7, type Tool } from "ai" import { Effect } from "effect" const DEFAULT_TIMEOUT = 30_000 -const MAX_LIST_PAGES = 1_000 -const TolerantListToolsResultSchema = ListToolsResultSchema.extend({ - tools: ToolSchema.omit({ outputSchema: true }).array(), -}) +export interface McpTool { + readonly def: MCPToolDef + readonly client: Client + readonly timeout?: number +} -export async function paginate( - list: (cursor?: string) => Promise, - items: (result: R) => T[], -) { - const result: T[] = [] - const cursors = new Set() - 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 async function callTool( + tool: McpTool, + args: Record, + signal?: AbortSignal, +): Promise { + 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 function defs(client: Client, timeout?: number) { return listTools(client, timeout ?? DEFAULT_TIMEOUT).pipe(Effect.catch(() => Effect.void)) } -export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: number): Tool { +export function convertTool(tool: McpTool): Tool { const inputSchema: JSONSchema7 = { - ...(mcpTool.inputSchema as JSONSchema7), + ...(tool.def.inputSchema as JSONSchema7), type: "object", - properties: (mcpTool.inputSchema.properties ?? {}) as JSONSchema7["properties"], + properties: (tool.def.inputSchema.properties ?? {}) as JSONSchema7["properties"], additionalProperties: false, } return dynamicTool({ - description: mcpTool.description ?? "", + description: tool.def.description ?? "", inputSchema: jsonSchema(inputSchema), execute: async (args: unknown, options) => { - const result = await client.callTool( - { - name: mcpTool.name, - arguments: (args || {}) as Record, - }, - 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", - ) + const result = await callTool(tool, (args || {}) as Record, options.abortSignal) if (result.content.length > 0 || result.structuredContent === undefined || result.structuredContent === null) return result return { @@ -118,53 +98,26 @@ export const sanitize = (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, "_") export const toolName = (clientName: string, name: string) => sanitize(clientName) + "_" + sanitize(name) -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 prompts(client: Client, timeout?: number) { + if (!client.getServerCapabilities()?.prompts) return [] + return (await client.listPrompts(undefined, { timeout })).prompts } -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 resources(client: Client, timeout?: number) { + if (!client.getServerCapabilities()?.resources) return [] + return (await client.listResources(undefined, { timeout })).resources } -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, - ) +export async function resourceTemplates(client: Client, timeout?: number) { + if (!client.getServerCapabilities()?.resources) return [] + return (await client.listResourceTemplates(undefined, { timeout })).resourceTemplates } function listTools(client: Client, timeout: number) { return Effect.tryPromise({ - 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, - ), + try: async () => (await client.listTools(undefined, { timeout })).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" diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 05f12fa2ee4..939c4420472 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -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 { - ListRootsRequestSchema, + Client, + type ClientOptions, + StreamableHTTPClientTransport, + SSEClientTransport, + UnauthorizedError, + RegistrationRejectedError, + SdkHttpError, type LoggingMessageNotification, - LoggingMessageNotificationSchema, type Tool as MCPToolDef, - ToolListChangedNotificationSchema, -} from "@modelcontextprotocol/sdk/types.js" +} from "@modelcontextprotocol/client" +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio" 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 -const CLIENT_OPTIONS = { +export const CLIENT_OPTIONS = { capabilities: { // https://github.com/anomalyco/opencode/issues/11948 // sampling: {}, @@ -47,6 +47,8 @@ const CLIENT_OPTIONS = { // https://github.com/anomalyco/opencode/issues/28567 // tasks: {}, }, + versionNegotiation: { mode: "auto" }, + listMaxPages: 1_000, } satisfies ClientOptions export const Resource = Schema.Struct({ @@ -70,13 +72,19 @@ export class NotFoundError extends Schema.TaggedErrorClass()("MCP name: Schema.String, }) {} -type MCPClient = Client +type MCPClient = Client & { onToolsChanged?: (error: Error | null) => void } function createClient(directory: string) { - const client = new Client({ name: "opencode", version: InstallationVersion }, CLIENT_OPTIONS) - client.setRequestHandler(ListRootsRequestSchema, () => - Promise.resolve({ roots: [{ uri: pathToFileURL(directory).href }] }), + const client: MCPClient = new Client( + { name: "opencode", version: InstallationVersion }, + { + ...CLIENT_OPTIONS, + listChanged: { + tools: { autoRefresh: false, onChanged: (error) => client.onToolsChanged?.(error) }, + }, + }, ) + client.setRequestHandler("roots/list", async () => ({ roots: [{ uri: pathToFileURL(directory).href }] })) return client } @@ -154,12 +162,7 @@ export interface ServerInstructions { } /** An MCP tool in its native shape; consumers adapt it to their own tool format. */ -export interface McpTool { - /** Shared cached definition; consumers must copy rather than mutate it. */ - readonly def: MCPToolDef - readonly client: MCPClient - readonly timeout?: number -} +export type McpTool = McpCatalog.McpTool export interface Interface { readonly status: () => Effect.Effect> @@ -190,7 +193,11 @@ export interface Interface { mcpName: string, onAuthorization?: (authorizationUrl: string) => void, ) => Effect.Effect - readonly finishAuth: (mcpName: string, authorizationCode: string) => Effect.Effect + readonly finishAuth: ( + mcpName: string, + authorizationCode: string, + iss?: string, + ) => Effect.Effect readonly removeAuth: (mcpName: string) => Effect.Effect readonly supportsOAuth: (mcpName: string) => Effect.Effect readonly hasStoredTokens: (mcpName: string) => Effect.Effect @@ -291,11 +298,18 @@ 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 || (authProvider && lastError.message.includes("OAuth")) + error instanceof UnauthorizedError || + registrationRejected || + (authProvider && error instanceof SdkHttpError && error.status === 401) || + (authProvider && lastError.message.includes("OAuth")) if (isAuthError) { - if (lastError.message.includes("registration") || lastError.message.includes("client_id")) { + if (registrationRejected) { lastStatus = { status: "needs_client_registration" as const, error: "Server does not support dynamic client registration. Please provide clientId in config.", @@ -454,12 +468,16 @@ const layer = Layer.effect( ) } - client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => + client.setNotificationHandler("notifications/message", (notification) => bridge.promise(serverLog(name, notification.params)), ) if (!client.getServerCapabilities()?.tools) return - client.setNotificationHandler(ToolListChangedNotificationSchema, async () => { + client.onToolsChanged = async (error) => { + if (error) { + await bridge.promise(Effect.logWarning("failed to refresh MCP tools", { server: name, error: error.message })) + return + } if (s.clients[name] !== client || s.status[name]?.status !== "connected") return const listed = await bridge.promise(McpCatalog.defs(client, timeout)) @@ -468,7 +486,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"]) { @@ -904,7 +922,7 @@ const layer = Layer.effect( }), ) - const code = yield* Effect.promise(() => callbackPromise) + const callback = yield* Effect.promise(() => callbackPromise) const storedState = yield* auth.getOAuthState(mcpName) if (storedState !== result.oauthState) { @@ -912,16 +930,20 @@ const layer = Layer.effect( throw new Error("OAuth state mismatch - potential CSRF attack") } yield* auth.clearOAuthState(mcpName) - return yield* finishAuth(mcpName, code) + return yield* finishAuth(mcpName, callback.code, callback.iss) }) - const finishAuth = Effect.fn("MCP.finishAuth")(function* (mcpName: string, authorizationCode: string) { + const finishAuth = Effect.fn("MCP.finishAuth")(function* ( + mcpName: string, + authorizationCode: string, + iss?: 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), + try: () => pending.transport.finishAuth(authorizationCode, iss), catch: (error) => error, }).pipe( Effect.match({ diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts index 84007902b8c..71f6ec95399 100644 --- a/packages/opencode/src/mcp/oauth-callback.ts +++ b/packages/opencode/src/mcp/oauth-callback.ts @@ -9,8 +9,13 @@ 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: (code: string) => void + resolve: (callback: AuthorizationCallback) => void reject: (error: Error) => void timeout: ReturnType } @@ -49,6 +54,7 @@ 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") @@ -95,7 +101,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http"). clearTimeout(pending.timeout) pendingAuths.delete(state) cleanupStateIndex(state) - pending.resolve(code) + pending.resolve({ code, iss }) res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) res.end(OauthCallbackPage.success({ provider: "MCP" })) @@ -130,7 +136,7 @@ export async function ensureRunning(redirectUri?: string): Promise { }) } -export function waitForCallback(oauthState: string, mcpName?: string): Promise { +export function waitForCallback(oauthState: string, mcpName?: string): Promise { if (mcpName) mcpNameToState.set(mcpName, oauthState) return new Promise((resolve, reject) => { const timeout = setTimeout(() => { diff --git a/packages/opencode/src/mcp/oauth-provider.ts b/packages/opencode/src/mcp/oauth-provider.ts index 596bfe1d551..a3f99a55f5b 100644 --- a/packages/opencode/src/mcp/oauth-provider.ts +++ b/packages/opencode/src/mcp/oauth-provider.ts @@ -1,10 +1,9 @@ -import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js" import type { + OAuthClientProvider, OAuthClientMetadata, - OAuthTokens, - OAuthClientInformation, - OAuthClientInformationFull, -} from "@modelcontextprotocol/sdk/shared/auth.js" + StoredOAuthTokens, + StoredOAuthClientInformation, +} from "@modelcontextprotocol/client" import { Effect } from "effect" import { McpAuth } from "./auth" @@ -23,6 +22,14 @@ export interface McpOAuthCallbacks { onRedirect: (url: URL) => void | Promise } +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, @@ -52,18 +59,21 @@ export class McpOAuthProvider implements OAuthClientProvider { } } - async clientInformation(): Promise { + async clientInformation(): Promise { + const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl)) 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 @@ -71,6 +81,14 @@ 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 } : {}), } } @@ -78,22 +96,36 @@ export class McpOAuthProvider implements OAuthClientProvider { return undefined } - async saveClientInformation(info: OAuthClientInformationFull): Promise { + async saveClientInformation(info: StoredOAuthClientInformation): Promise { + 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) await Effect.runPromise( this.auth.updateClientInfo( this.mcpName, { clientId: info.client_id, clientSecret: info.client_secret, - clientIdIssuedAt: info.client_id_issued_at, - clientSecretExpiresAt: info.client_secret_expires_at, + clientIdIssuedAt: metadata.clientIdIssuedAt, + clientSecretExpiresAt: metadata.clientSecretExpiresAt, + redirectUris: metadata.redirectUris ? [...metadata.redirectUris] : [this.redirectUrl], + issuer: info.issuer, }, this.serverUrl, ), ) } - async tokens(): Promise { + async tokens(): Promise { // 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 @@ -106,18 +138,20 @@ 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: OAuthTokens): Promise { + async saveTokens(tokens: StoredOAuthTokens): Promise { await Effect.runPromise( this.auth.updateTokens( this.mcpName, { accessToken: tokens.access_token, refreshToken: tokens.refresh_token, - expiresAt: tokens.expires_in ? Date.now() / 1000 + tokens.expires_in : undefined, + expiresAt: tokens.expires_in !== undefined ? Date.now() / 1000 + tokens.expires_in : undefined, scope: tokens.scope, + issuer: tokens.issuer, }, this.serverUrl, ), @@ -181,10 +215,10 @@ export class McpOAuthProvider implements OAuthClientProvider { } export class McpOAuthPendingProvider extends McpOAuthProvider { - private pendingClientInfo?: OAuthClientInformationFull - private pendingTokens?: OAuthTokens + private pendingClientInfo?: StoredOAuthClientInformation + private pendingTokens?: StoredOAuthTokens - override async clientInformation(): Promise { + override async clientInformation(): Promise { if (!this.config.clientId) return this.pendingClientInfo return { client_id: this.config.clientId, @@ -192,15 +226,15 @@ export class McpOAuthPendingProvider extends McpOAuthProvider { } } - override async saveClientInformation(info: OAuthClientInformationFull): Promise { + override async saveClientInformation(info: StoredOAuthClientInformation): Promise { this.pendingClientInfo = info } - override async tokens(): Promise { + override async tokens(): Promise { return this.pendingTokens } - override async saveTokens(tokens: OAuthTokens): Promise { + override async saveTokens(tokens: StoredOAuthTokens): Promise { this.pendingTokens = tokens } @@ -211,6 +245,7 @@ export class McpOAuthPendingProvider extends McpOAuthProvider { async commit(): Promise { if (!this.pendingTokens) return + const pendingMetadata = this.pendingClientInfo ? registrationMetadata(this.pendingClientInfo) : undefined await Effect.runPromise( this.auth.set( this.mcpName, @@ -218,16 +253,22 @@ export class McpOAuthPendingProvider extends McpOAuthProvider { tokens: { accessToken: this.pendingTokens.access_token, refreshToken: this.pendingTokens.refresh_token, - expiresAt: this.pendingTokens.expires_in ? Date.now() / 1000 + this.pendingTokens.expires_in : undefined, + expiresAt: + this.pendingTokens.expires_in !== undefined + ? 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: this.pendingClientInfo.client_id_issued_at, - clientSecretExpiresAt: this.pendingClientInfo.client_secret_expires_at, + clientIdIssuedAt: pendingMetadata?.clientIdIssuedAt, + clientSecretExpiresAt: pendingMetadata?.clientSecretExpiresAt, + redirectUris: pendingMetadata?.redirectUris ? [...pendingMetadata.redirectUris] : [this.redirectUrl], + issuer: this.pendingClientInfo.issuer, } : undefined, }, diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 705af3d5c1b..303ff11fdfd 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -516,12 +516,20 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re return msgs } +const GEMINI_MODELS_WITH_SAMPLING_DEFAULTS = [ + /gemini-2[.-]5(?:[.-]|$)/, + /gemini-3-(?:flash|pro)(?:[.-]|$)/, + /gemini-3[.-]1(?:[.-]|$)/, + /gemini-3[.-]5-flash(?!-lite)(?:[.-]|$)/, +] + export function temperature(model: Provider.Model) { - const id = model.id.toLowerCase() + const id = model.api.id.toLowerCase() if (id.includes("north-mini-code")) return 1.0 if (id.includes("qwen")) return 0.55 if (id.includes("claude")) return undefined - if (id.includes("gemini")) return 1.0 + if (id.includes("gemini")) + return GEMINI_MODELS_WITH_SAMPLING_DEFAULTS.some((model) => model.test(id)) ? 1.0 : undefined if (id.includes("glm-4.6")) return 1.0 if (id.includes("glm-4.7")) return 1.0 if (id.includes("minimax-m2")) return 1.0 @@ -536,21 +544,24 @@ export function temperature(model: Provider.Model) { } export function topP(model: Provider.Model) { - const id = model.id.toLowerCase() + const id = model.api.id.toLowerCase() if (id.includes("qwen")) return 1 - if (["minimax-m2", "gemini", "kimi-k2.5", "kimi-k2p5", "kimi-k2-5"].some((s) => id.includes(s))) { + if (id.includes("gemini")) + return GEMINI_MODELS_WITH_SAMPLING_DEFAULTS.some((model) => model.test(id)) ? 0.95 : undefined + if (["minimax-m2", "kimi-k2.5", "kimi-k2p5", "kimi-k2-5"].some((s) => id.includes(s))) { return 0.95 } return undefined } export function topK(model: Provider.Model) { - const id = model.id.toLowerCase() + const id = model.api.id.toLowerCase() if (id.includes("minimax-m2")) { if (["m2.", "m25", "m21"].some((s) => id.includes(s))) return 40 return 20 } - if (id.includes("gemini")) return 64 + if (id.includes("gemini")) + return GEMINI_MODELS_WITH_SAMPLING_DEFAULTS.some((model) => model.test(id)) ? 64 : undefined return undefined } diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts index a6fb064d73e..ca56d10b4e4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts @@ -20,6 +20,7 @@ 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), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts index cdf0cc1e70e..3a367a84e79 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts @@ -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) + .finishAuth(ctx.params.name, ctx.payload.code, ctx.payload.iss) .pipe( Effect.catchTag("MCP.NotFoundError", (error) => Effect.fail( diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 0f401c7562f..d93fb66f2d0 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -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.def, entry.client, entry.timeout) + const item = McpCatalog.convertTool(entry) const execute = item.execute if (!execute) continue diff --git a/packages/opencode/src/tool/code-mode.ts b/packages/opencode/src/tool/code-mode.ts index 332d4b43f15..a046b4093d8 100644 --- a/packages/opencode/src/tool/code-mode.ts +++ b/packages/opencode/src/tool/code-mode.ts @@ -1,5 +1,5 @@ import * as Tool from "./tool" -import { CallToolResultSchema, type CallToolResult } from "@modelcontextprotocol/sdk/types.js" +import { type CallToolResult } from "@modelcontextprotocol/client" import { Cause, Effect, Schema } from "effect" import { CodeMode, Tool as SandboxTool, toolError } from "@opencode-ai/codemode" import { MCP } from "@/mcp" @@ -145,28 +145,7 @@ 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: ["*"] }) - // 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 - }) + return yield* Effect.promise(() => McpCatalog.callTool(input.entry.tool, input.args, input.ctx.abort)) }).pipe( Effect.withSpan("Tool.execute", { attributes: { diff --git a/packages/opencode/test/fixture/mcp-lifecycle-stdio.ts b/packages/opencode/test/fixture/mcp-lifecycle-stdio.ts index b01ed921cfd..6260c7e3d4d 100644 --- a/packages/opencode/test/fixture/mcp-lifecycle-stdio.ts +++ b/packages/opencode/test/fixture/mcp-lifecycle-stdio.ts @@ -1,6 +1,5 @@ -import { Server } from "@modelcontextprotocol/sdk/server/index.js" -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" -import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" +import { Server } from "@modelcontextprotocol/server" +import { StdioServerTransport } from "@modelcontextprotocol/server/stdio" if (process.argv.includes("--hang")) { const pidFile = process.env.MCP_LIFECYCLE_PID_FILE @@ -11,7 +10,7 @@ if (process.argv.includes("--hang")) { const server = new Server({ name: "mcp-lifecycle-stdio", version: "1.0.0" }, { capabilities: { tools: {} } }) -server.setRequestHandler(ListToolsRequestSchema, () => +server.setRequestHandler("tools/list", () => Promise.resolve({ tools: [ { diff --git a/packages/opencode/test/fixture/mcp-session-recovery.ts b/packages/opencode/test/fixture/mcp-session-recovery.ts index c20fb5aa587..381c187f004 100644 --- a/packages/opencode/test/fixture/mcp-session-recovery.ts +++ b/packages/opencode/test/fixture/mcp-session-recovery.ts @@ -1,10 +1,11 @@ -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" +import { Client, LATEST_PROTOCOL_VERSION, StreamableHTTPClientTransport } from "@modelcontextprotocol/client" 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((resolve) => (replacementStarted = resolve)) const server = Bun.serve({ port: 0, async fetch(request) { @@ -17,6 +18,7 @@ const server = Bun.serve({ if (message.method === "initialize") { initializeCount++ + if (initializeCount === 2) replacementStarted() return Response.json( { jsonrpc: "2.0", @@ -34,7 +36,8 @@ const server = Bun.serve({ if (message.method === "notifications/initialized") return new Response(null, { status: 202 }) pingCount++ - if (pingCount === 1) return new Response("Session not found", { status: 404 }) + if (concurrent && pingCount === 2) await replacement + if (pingCount <= (concurrent ? 2 : 1)) return new Response("Session not found", { status: 404 }) return Response.json({ jsonrpc: "2.0", id: message.id, result: {} }) }, }) @@ -42,7 +45,8 @@ const client = new Client({ name: "test", version: "1" }) try { await client.connect(new StreamableHTTPClientTransport(server.url)) - await client.ping() + if (concurrent) await Promise.all([client.ping(), client.ping()]) + else await client.ping() process.stdout.write(JSON.stringify(posts)) } finally { await client.close() diff --git a/packages/opencode/test/mcp/catalog.test.ts b/packages/opencode/test/mcp/catalog.test.ts index 7b0d6403bb1..3d77cb22b75 100644 --- a/packages/opencode/test/mcp/catalog.test.ts +++ b/packages/opencode/test/mcp/catalog.test.ts @@ -1,8 +1,6 @@ import { describe, expect, test } from "bun:test" -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 { Client, InMemoryTransport } from "@modelcontextprotocol/client" +import { Server } from "@modelcontextprotocol/server" import { McpCatalog } from "@/mcp/catalog" import { Effect } from "effect" @@ -30,7 +28,10 @@ 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(mcpTool(), clientReturning({ content, structuredContent })) + const converted = McpCatalog.convertTool({ + def: mcpTool(), + client: clientReturning({ content, structuredContent }), + }) const output = await converted.execute?.({}, options) @@ -39,7 +40,10 @@ describe("McpCatalog.convertTool", () => { test("falls back to structuredContent only when content is absent", async () => { const structuredContent = { results: [{ title: "one" }] } - const converted = McpCatalog.convertTool(mcpTool(), clientReturning({ content: [], structuredContent })) + const converted = McpCatalog.convertTool({ + def: mcpTool(), + client: clientReturning({ content: [], structuredContent }), + }) const output = await converted.execute?.({}, options) @@ -50,18 +54,52 @@ 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(ListToolsRequestSchema, ({ params }) => + server.setRequestHandler("tools/list", ({ params }) => Promise.resolve( params?.cursor === "page-2" ? { tools: [ { name: "second", - inputSchema: { type: "object" }, + inputSchema: { type: "object" as const }, outputSchema: { - type: "object", + type: "object" as const, properties: { value: { type: "number" } }, required: ["value"], }, @@ -72,9 +110,9 @@ test("preserves output schema validation across paginated tool discovery", async tools: [ { name: "first", - inputSchema: { type: "object" }, + inputSchema: { type: "object" as const }, outputSchema: { - type: "object", + type: "object" as const, properties: { value: { type: "string" } }, required: ["value"], }, @@ -84,7 +122,7 @@ test("preserves output schema validation across paginated tool discovery", async }, ), ) - server.setRequestHandler(CallToolRequestSchema, ({ params }) => + server.setRequestHandler("tools/call", ({ params }) => Promise.resolve({ content: [], structuredContent: { value: params.name === "first" ? 42 : 1 }, @@ -98,9 +136,7 @@ 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( - "Structured content does not match the tool's output schema", - ) + await expect(client.callTool({ name: "first", arguments: {} })).rejects.toThrow(/output schema/i) } finally { await Promise.all([client.close(), server.close()]) } diff --git a/packages/opencode/test/mcp/headers.test.ts b/packages/opencode/test/mcp/headers.test.ts index 31cfc20d51c..323aea2e478 100644 --- a/packages/opencode/test/mcp/headers.test.ts +++ b/packages/opencode/test/mcp/headers.test.ts @@ -1,7 +1,5 @@ import { describe, expect } from "bun:test" -import { Server } from "@modelcontextprotocol/sdk/server/index.js" -import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js" -import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" +import { Server, WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect } from "effect" import { testEffect } from "../lib/effect" @@ -13,7 +11,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(ListToolsRequestSchema, () => Promise.resolve({ tools: [] })) + protocol.setRequestHandler("tools/list", () => Promise.resolve({ tools: [] })) const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), enableJsonResponse: true, @@ -38,6 +36,11 @@ 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* () { @@ -99,4 +102,18 @@ 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" } }) + }), + ) }) diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 80c8fd22f88..b0018987797 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -1,18 +1,12 @@ 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 { - GetPromptRequestSchema, - ListPromptsRequestSchema, - ListResourcesRequestSchema, - ListResourceTemplatesRequestSchema, - ListToolsRequestSchema, - ReadResourceRequestSchema, + Server, + WebStandardStreamableHTTPServerTransport, type ServerCapabilities, type Tool, -} from "@modelcontextprotocol/sdk/types.js" +} from "@modelcontextprotocol/server" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Cause, Effect, Exit } from "effect" import type { MCP as MCPNS } from "../../src/mcp/index" @@ -66,35 +60,35 @@ function lifecycleServer(input?: { capabilities?: ServerCapabilities; instructio }) if (capabilities.tools) { - protocol.setRequestHandler(ListToolsRequestSchema, (request) => { + protocol.setRequestHandler("tools/list", (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(ListPromptsRequestSchema, (request) => { + protocol.setRequestHandler("prompts/list", (request) => { const page = state.promptPages?.[request.params?.cursor ?? "initial"] return Promise.resolve({ prompts: page?.items ?? state.prompts, nextCursor: page?.nextCursor }) }) - protocol.setRequestHandler(GetPromptRequestSchema, async () => { + protocol.setRequestHandler("prompts/get", async () => { if (state.requestDelay) await Bun.sleep(state.requestDelay) return { messages: [{ role: "user", content: { type: "text", text: "prompt result" } }] } }) } if (capabilities.resources) { - protocol.setRequestHandler(ListResourcesRequestSchema, (request) => { + protocol.setRequestHandler("resources/list", (request) => { const page = state.resourcePages?.[request.params?.cursor ?? "initial"] return Promise.resolve({ resources: page?.items ?? state.resources, nextCursor: page?.nextCursor }) }) - protocol.setRequestHandler(ListResourceTemplatesRequestSchema, (request) => { + protocol.setRequestHandler("resources/templates/list", (request) => { const page = state.resourceTemplatePages?.[request.params?.cursor ?? "initial"] return Promise.resolve({ resourceTemplates: page?.items ?? state.resourceTemplates, nextCursor: page?.nextCursor, }) }) - protocol.setRequestHandler(ReadResourceRequestSchema, async (request) => { + protocol.setRequestHandler("resources/read", async (request) => { if (state.requestDelay) await Bun.sleep(state.requestDelay) return { contents: [{ uri: request.params.uri, text: "resource result" }] } }) @@ -145,7 +139,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(ListToolsRequestSchema, () => Promise.resolve({ tools: [] })) + protocol.setRequestHandler("tools/list", () => Promise.resolve({ tools: [] })) const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), enableJsonResponse: true, @@ -284,7 +278,7 @@ it.instance("follows cursors when listing tools, prompts, resources, and templat }), ) -it.instance("accepts empty cursors and rejects repeated cursors", () => +it.instance("accepts empty cursors and terminates on repeated cursors", () => Effect.gen(function* () { const empty = yield* lifecycleServer({ capabilities: { prompts: {} } }) empty.state.promptPages = { @@ -301,7 +295,8 @@ it.instance("accepts empty cursors and rejects 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("failed") + expect(statusName(result.status, "looping-cursor")).toBe("connected") + expect(Object.keys(yield* mcp.tools())).toEqual([]) }), ) diff --git a/packages/opencode/test/mcp/oauth-auto-connect.test.ts b/packages/opencode/test/mcp/oauth-auto-connect.test.ts index 5f8889068c3..735dfaf8bed 100644 --- a/packages/opencode/test/mcp/oauth-auto-connect.test.ts +++ b/packages/opencode/test/mcp/oauth-auto-connect.test.ts @@ -1,7 +1,5 @@ import { expect } from "bun:test" -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 { Server, WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server" 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" @@ -40,13 +38,13 @@ function serveOAuthMcp(options: OAuthMcpOptions = {}) { let requiresAuth = true if (capabilities === "tools") { - protocol.setRequestHandler(ListToolsRequestSchema, () => { + protocol.setRequestHandler("tools/list", () => { listToolsCalls++ return Promise.resolve({ tools: [{ name: "test_tool", inputSchema: { type: "object" } }] }) }) } if (capabilities === "resources") { - protocol.setRequestHandler(ListResourcesRequestSchema, () => + protocol.setRequestHandler("resources/list", () => Promise.resolve({ resources: [{ name: "docs", uri: "docs://readme" }] }), ) } diff --git a/packages/opencode/test/mcp/oauth-browser.test.ts b/packages/opencode/test/mcp/oauth-browser.test.ts index 9573805a9a1..507b59b691d 100644 --- a/packages/opencode/test/mcp/oauth-browser.test.ts +++ b/packages/opencode/test/mcp/oauth-browser.test.ts @@ -1,7 +1,5 @@ import { expect } from "bun:test" -import { Server } from "@modelcontextprotocol/sdk/server/index.js" -import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js" -import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" +import { Server, WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Deferred, Effect, Layer, Option } from "effect" import { Config } from "../../src/config/config" @@ -41,7 +39,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(ListToolsRequestSchema, () => Promise.resolve({ tools: [] })) + protocol.setRequestHandler("tools/list", () => Promise.resolve({ tools: [] })) const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), enableJsonResponse: true, diff --git a/packages/opencode/test/mcp/oauth-callback.test.ts b/packages/opencode/test/mcp/oauth-callback.test.ts index 1666a37142b..b7db18034fe 100644 --- a/packages/opencode/test/mcp/oauth-callback.test.ts +++ b/packages/opencode/test/mcp/oauth-callback.test.ts @@ -74,7 +74,7 @@ describe("McpOAuthCallback.ensureRunning", () => { const response = await fetch(`${redirectUri}?code=code&state=success`) expect(response.status).toBe(200) - expect(await callback).toBe("code") + expect(await callback).toEqual({ code: "code", iss: undefined }) expect(McpOAuthCallback.isRunning()).toBe(false) }) diff --git a/packages/opencode/test/mcp/oauth-provider.test.ts b/packages/opencode/test/mcp/oauth-provider.test.ts index 249c49e8f91..64c2cb66877 100644 --- a/packages/opencode/test/mcp/oauth-provider.test.ts +++ b/packages/opencode/test/mcp/oauth-provider.test.ts @@ -1,5 +1,4 @@ import { test, expect, describe } from "bun:test" -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" @@ -60,43 +59,3 @@ describe("McpOAuthProvider.clientMetadata", () => { expect(provider.clientMetadata.token_endpoint_auth_method).toBe("none") }) }) - -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("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") - }) -}) diff --git a/packages/opencode/test/mcp/session-recovery.test.ts b/packages/opencode/test/mcp/session-recovery.test.ts index 65865082200..f7c5787a4e9 100644 --- a/packages/opencode/test/mcp/session-recovery.test.ts +++ b/packages/opencode/test/mcp/session-recovery.test.ts @@ -24,4 +24,24 @@ 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) + }) }) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 93e166c4a8c..0b88fa59dff 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -3186,7 +3186,69 @@ describe("ProviderTransform.message - cache control on gateway", () => { describe("ProviderTransform.temperature - Cohere North", () => { test("defaults north-mini-code models to 1.0", () => { - expect(ProviderTransform.temperature({ id: "cohere/North-Mini-Code-1-0-latest" } as any)).toBe(1.0) + expect( + ProviderTransform.temperature({ + id: "cohere/North-Mini-Code-1-0-latest", + api: { id: "North-Mini-Code-1-0-latest" }, + } as any), + ).toBe(1.0) + }) +}) + +describe("ProviderTransform sampling defaults - Gemini", () => { + const model = (id: string) => + ({ + id: `google/${id}`, + api: { id }, + }) as any + + const alias = (id: string, apiID: string) => + ({ + id, + api: { id: apiID }, + }) as any + + test.each([ + "gemini-3.5-flash-lite", + "gemini-3-5-flash-lite", + "gemini-3.6-flash", + "gemini-3-6-flash", + "gemini-4-pro", + "gemini-future", + ])("omits deprecated sampling controls for %s", (id) => { + expect(ProviderTransform.temperature(model(id))).toBeUndefined() + expect(ProviderTransform.topP(model(id))).toBeUndefined() + expect(ProviderTransform.topK(model(id))).toBeUndefined() + }) + + test.each([ + "gemini-2.5-flash", + "gemini-2.5-pro", + "gemini-2.5-flash-lite", + "gemini-2-5-flash-lite", + "gemini-3-flash-preview", + "gemini-3-pro-image", + "gemini-3.1-flash-lite", + "gemini-3.1-pro-preview", + "gemini-3-1-pro-preview", + "gemini-3.5-flash", + "gemini-3-5-flash", + ])("preserves sampling defaults for %s", (id) => { + expect(ProviderTransform.temperature(model(id))).toBe(1) + expect(ProviderTransform.topP(model(id))).toBe(0.95) + expect(ProviderTransform.topK(model(id))).toBe(64) + }) + + test("uses the API model ID for configured aliases", () => { + const deprecated = alias("google/gemini-3.5-flash", "google/gemini-3.6-flash") + expect(ProviderTransform.temperature(deprecated)).toBeUndefined() + expect(ProviderTransform.topP(deprecated)).toBeUndefined() + expect(ProviderTransform.topK(deprecated)).toBeUndefined() + + const supported = alias("my-gemini", "google/gemini-2.5-flash") + expect(ProviderTransform.temperature(supported)).toBe(1) + expect(ProviderTransform.topP(supported)).toBe(0.95) + expect(ProviderTransform.topK(supported)).toBe(64) }) }) diff --git a/packages/opencode/test/tool/code-mode-integration.test.ts b/packages/opencode/test/tool/code-mode-integration.test.ts index 671acd89622..32cb420681a 100644 --- a/packages/opencode/test/tool/code-mode-integration.test.ts +++ b/packages/opencode/test/tool/code-mode-integration.test.ts @@ -8,15 +8,14 @@ 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/sdk/server/index.js" -import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" -import type { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { Server } from "@modelcontextprotocol/server" import { - CallToolRequestSchema, + InMemoryTransport, LATEST_PROTOCOL_VERSION, - ListToolsRequestSchema, + type CallToolResult, + type Client, type Tool as MCPToolDef, -} from "@modelcontextprotocol/sdk/types.js" +} from "@modelcontextprotocol/client" import { Cause, Effect, Exit, Layer } from "effect" const PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" @@ -100,7 +99,7 @@ const TOOL_DEFS: MCPToolDef[] = [ }, ] as MCPToolDef[] -function handleCall(name: string, args: Record) { +function handleCall(name: string, args: Record): CallToolResult { switch (name) { case "get_text": return { content: [{ type: "text", text: `hello ${args.name}` }] } @@ -122,8 +121,8 @@ let description: string async function buildTool() { const server = new Server({ name: SERVER, version: "1.0.0" }, { capabilities: { tools: {} } }) - server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_DEFS })) - server.setRequestHandler(CallToolRequestSchema, async (req) => + server.setRequestHandler("tools/list", async () => ({ tools: TOOL_DEFS })) + server.setRequestHandler("tools/call", async (req) => handleCall(req.params.name, (req.params.arguments ?? {}) as Record), ) diff --git a/packages/opencode/test/tool/code-mode.test.ts b/packages/opencode/test/tool/code-mode.test.ts index 34b3faa610d..cc32d2a5f3e 100644 --- a/packages/opencode/test/tool/code-mode.test.ts +++ b/packages/opencode/test/tool/code-mode.test.ts @@ -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/sdk/types.js" +import type { Tool as MCPToolDef } from "@modelcontextprotocol/client" import type { PermissionV1 } from "@opencode-ai/core/v1/permission" import { Agent } from "@/agent/agent" import { MCP } from "@/mcp" diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index c8c5fac5955..c0810a6d7ed 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -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/sdk/types.js" +import type { Tool as MCPToolDef } from "@modelcontextprotocol/client" const configLayer = TestConfig.layer({ directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])), diff --git a/packages/plugin/package.json b/packages/plugin/package.json index ab795d5327c..fefab272f5f 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.18.6", + "version": "1.18.8", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 9859ba17985..8e8c19d73b8 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.18.6", + "version": "1.18.8", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 9ed0084aac8..babdbc9c517 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -2328,6 +2328,7 @@ export class Auth2 extends HeyApiClient { directory?: string workspace?: string code?: string + iss?: string }, options?: Options, ) { @@ -2340,6 +2341,7 @@ export class Auth2 extends HeyApiClient { { in: "query", key: "directory" }, { in: "query", key: "workspace" }, { in: "body", key: "code" }, + { in: "body", key: "iss" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 42d224780d3..f0db3236eab 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -8563,6 +8563,7 @@ export type McpAuthStartResponse = McpAuthStartResponses[keyof McpAuthStartRespo export type McpAuthCallbackData = { body?: { code: string + iss?: string } path: { name: string diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index b300754bc85..6150b75e64c 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -3360,6 +3360,9 @@ "properties": { "code": { "type": "string" + }, + "iss": { + "type": "string" } }, "required": ["code"], diff --git a/packages/server/package.json b/packages/server/package.json index 80504ced29d..4532b2e37bc 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/server", - "version": "1.18.6", + "version": "1.18.8", "private": true, "type": "module", "license": "MIT", diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index 2d4bc6c65d3..879f254df12 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/session-ui", - "version": "1.18.6", + "version": "1.18.8", "private": true, "type": "module", "license": "MIT", diff --git a/packages/slack/package.json b/packages/slack/package.json index 33e8109358f..5636890bb27 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.18.6", + "version": "1.18.8", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/stats/app/package.json b/packages/stats/app/package.json index d070a6a96e0..aceecc49d5d 100644 --- a/packages/stats/app/package.json +++ b/packages/stats/app/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-app", - "version": "1.18.6", + "version": "1.18.8", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json index 09493362f92..73de8bbced2 100644 --- a/packages/stats/core/package.json +++ b/packages/stats/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-core", - "version": "1.18.6", + "version": "1.18.8", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/server/package.json b/packages/stats/server/package.json index 1d1607612a8..af0f57d1875 100644 --- a/packages/stats/server/package.json +++ b/packages/stats/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-server", - "version": "1.18.6", + "version": "1.18.8", "private": true, "type": "module", "license": "MIT", diff --git a/packages/storybook/.storybook/mocks/app/components/dialog-select-model.tsx b/packages/storybook/.storybook/mocks/app/components/dialog-select-model.tsx index 0c7ff8bbdd6..0741f7e4ef9 100644 --- a/packages/storybook/.storybook/mocks/app/components/dialog-select-model.tsx +++ b/packages/storybook/.storybook/mocks/app/components/dialog-select-model.tsx @@ -1,9 +1,8 @@ -import { splitProps } from "solid-js" +import { splitProps, type JSX } from "solid-js" -export function ModelSelectorPopover(props: { triggerAs: any; triggerProps?: Record; children: any }) { - const [local] = splitProps(props, ["triggerAs", "triggerProps", "children"]) - const Trigger = local.triggerAs - return {local.children} +export function ModelSelectorPopover(props: { trigger: (props: Record) => JSX.Element }) { + const [local] = splitProps(props, ["trigger"]) + return <>{local.trigger({})} } export const ModelSelectorPopoverV2 = ModelSelectorPopover diff --git a/packages/tui/package.json b/packages/tui/package.json index f86400885bc..8207147917c 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/tui", - "version": "1.18.6", + "version": "1.18.8", "private": true, "type": "module", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index 3174e2113a9..e5e7a15a1cc 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.18.6", + "version": "1.18.8", "type": "module", "license": "MIT", "repository": { diff --git a/packages/ui/src/v2/components/switch-v2.css b/packages/ui/src/v2/components/switch-v2.css index 48d18d2c250..8ee56319c3f 100644 --- a/packages/ui/src/v2/components/switch-v2.css +++ b/packages/ui/src/v2/components/switch-v2.css @@ -30,7 +30,7 @@ border: none; background: linear-gradient(180deg, var(--v2-alpha-light-0) 0%, var(--v2-alpha-light-20) 100%), - var(--v2-background-bg-layer-03); + var(--v2-background-bg-layer-04); box-shadow: var(--v2-elevation-switch-off); transition: background 90ms ease-out, @@ -90,7 +90,7 @@ background: linear-gradient(0deg, var(--v2-overlay-simple-overlay-hover), var(--v2-overlay-simple-overlay-hover)), linear-gradient(180deg, var(--v2-alpha-light-0) 0%, var(--v2-alpha-light-20) 100%), - var(--v2-background-bg-layer-03); + var(--v2-background-bg-layer-04); } &:hover:not([data-disabled], [data-readonly]) [data-slot="switch-thumb"] { diff --git a/packages/web/package.json b/packages/web/package.json index 0ac92292d5f..963e9dfc5f5 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.18.6", + "version": "1.18.8", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/packages/web/src/content/docs/ar/zen.mdx b/packages/web/src/content/docs/ar/zen.mdx index e7b6f0f0ff1..055eaa56e00 100644 --- a/packages/web/src/content/docs/ar/zen.mdx +++ b/packages/web/src/content/docs/ar/zen.mdx @@ -107,6 +107,7 @@ OpenCode Zen هي بوابة AI تتيح لك الوصول إلى هذه الن | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -149,6 +150,7 @@ https://opencode.ai/zen/v1/models | GLM 5.1 | $1.40 | $4.40 | $0.26 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - | | Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - | +| Kimi K3 | $3.00 | $15.00 | $0.30 | - | | Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | diff --git a/packages/web/src/content/docs/bs/zen.mdx b/packages/web/src/content/docs/bs/zen.mdx index 5daf188ba72..5f432929bad 100644 --- a/packages/web/src/content/docs/bs/zen.mdx +++ b/packages/web/src/content/docs/bs/zen.mdx @@ -112,6 +112,7 @@ Našim modelima možete pristupiti i preko sljedećih API endpointa. | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -156,6 +157,7 @@ Podržavamo pay-as-you-go model. Ispod su cijene **po 1M tokena**. | GLM 5.1 | $1.40 | $4.40 | $0.26 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - | | Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - | +| Kimi K3 | $3.00 | $15.00 | $0.30 | - | | Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | diff --git a/packages/web/src/content/docs/da/zen.mdx b/packages/web/src/content/docs/da/zen.mdx index dcfaf243f50..480e922b094 100644 --- a/packages/web/src/content/docs/da/zen.mdx +++ b/packages/web/src/content/docs/da/zen.mdx @@ -112,6 +112,7 @@ Du kan også få adgang til vores modeller gennem følgende API-endpoints. | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -156,6 +157,7 @@ Vi understøtter en pay-as-you-go-model. Nedenfor er priserne **pr. 1M tokens**. | GLM 5.1 | $1.40 | $4.40 | $0.26 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - | | Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - | +| Kimi K3 | $3.00 | $15.00 | $0.30 | - | | Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | diff --git a/packages/web/src/content/docs/de/zen.mdx b/packages/web/src/content/docs/de/zen.mdx index 6014f6e59fe..171b1e198d3 100644 --- a/packages/web/src/content/docs/de/zen.mdx +++ b/packages/web/src/content/docs/de/zen.mdx @@ -103,6 +103,7 @@ Du kannst auch über die folgenden API-Endpunkte auf unsere Modelle zugreifen. | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -145,6 +146,7 @@ Wir unterstützen ein Pay-as-you-go-Modell. Unten findest du die Preise **pro 1M | GLM 5.1 | $1.40 | $4.40 | $0.26 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - | | Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - | +| Kimi K3 | $3.00 | $15.00 | $0.30 | - | | Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | diff --git a/packages/web/src/content/docs/es/zen.mdx b/packages/web/src/content/docs/es/zen.mdx index 342bf9eb2a5..05edc7bca7c 100644 --- a/packages/web/src/content/docs/es/zen.mdx +++ b/packages/web/src/content/docs/es/zen.mdx @@ -112,6 +112,7 @@ También puedes acceder a nuestros modelos a través de los siguientes endpoints | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -156,6 +157,7 @@ Admitimos un modelo de pago por uso. A continuación se muestran los precios **p | GLM 5.1 | $1.40 | $4.40 | $0.26 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - | | Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - | +| Kimi K3 | $3.00 | $15.00 | $0.30 | - | | Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | diff --git a/packages/web/src/content/docs/fr/zen.mdx b/packages/web/src/content/docs/fr/zen.mdx index c2e15a422ca..23c3e12f554 100644 --- a/packages/web/src/content/docs/fr/zen.mdx +++ b/packages/web/src/content/docs/fr/zen.mdx @@ -103,6 +103,7 @@ Vous pouvez également accéder à nos modèles via les points de terminaison AP | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -145,6 +146,7 @@ Nous prenons en charge un modèle de paiement à l'utilisation. Vous trouverez c | GLM 5.1 | $1.40 | $4.40 | $0.26 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - | | Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - | +| Kimi K3 | $3.00 | $15.00 | $0.30 | - | | Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | diff --git a/packages/web/src/content/docs/it/zen.mdx b/packages/web/src/content/docs/it/zen.mdx index bfdd81137ce..4ab79786101 100644 --- a/packages/web/src/content/docs/it/zen.mdx +++ b/packages/web/src/content/docs/it/zen.mdx @@ -112,6 +112,7 @@ Puoi anche accedere ai nostri modelli tramite i seguenti endpoint API. | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -156,6 +157,7 @@ Supportiamo un modello pay-as-you-go. Qui sotto trovi i prezzi **per 1M token**. | GLM 5.1 | $1.40 | $4.40 | $0.26 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - | | Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - | +| Kimi K3 | $3.00 | $15.00 | $0.30 | - | | Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | diff --git a/packages/web/src/content/docs/ja/zen.mdx b/packages/web/src/content/docs/ja/zen.mdx index 5e6119936a1..05289dd63fc 100644 --- a/packages/web/src/content/docs/ja/zen.mdx +++ b/packages/web/src/content/docs/ja/zen.mdx @@ -103,6 +103,7 @@ OpenCode Zen は、OpenCode のほかのプロバイダーと同じように動 | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -145,6 +146,7 @@ https://opencode.ai/zen/v1/models | GLM 5.1 | $1.40 | $4.40 | $0.26 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - | | Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - | +| Kimi K3 | $3.00 | $15.00 | $0.30 | - | | Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | diff --git a/packages/web/src/content/docs/ko/zen.mdx b/packages/web/src/content/docs/ko/zen.mdx index ec383a1d0fb..75b06226bd6 100644 --- a/packages/web/src/content/docs/ko/zen.mdx +++ b/packages/web/src/content/docs/ko/zen.mdx @@ -103,6 +103,7 @@ OpenCode Zen은 OpenCode의 다른 provider와 똑같이 작동합니다. | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -145,6 +146,7 @@ https://opencode.ai/zen/v1/models | GLM 5.1 | $1.40 | $4.40 | $0.26 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - | | Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - | +| Kimi K3 | $3.00 | $15.00 | $0.30 | - | | Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | diff --git a/packages/web/src/content/docs/nb/zen.mdx b/packages/web/src/content/docs/nb/zen.mdx index 75bc29c1885..c5bd9942b8e 100644 --- a/packages/web/src/content/docs/nb/zen.mdx +++ b/packages/web/src/content/docs/nb/zen.mdx @@ -112,6 +112,7 @@ Du kan også få tilgang til modellene våre gjennom følgende API-endepunkter. | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -156,6 +157,7 @@ Vi støtter en pay-as-you-go-modell. Nedenfor er prisene **per 1M tokens**. | GLM 5.1 | $1.40 | $4.40 | $0.26 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - | | Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - | +| Kimi K3 | $3.00 | $15.00 | $0.30 | - | | Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | diff --git a/packages/web/src/content/docs/pl/zen.mdx b/packages/web/src/content/docs/pl/zen.mdx index 742199eb62c..e60fb6abb1a 100644 --- a/packages/web/src/content/docs/pl/zen.mdx +++ b/packages/web/src/content/docs/pl/zen.mdx @@ -112,6 +112,7 @@ Możesz też uzyskać dostęp do naszych modeli przez poniższe endpointy API. | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -156,6 +157,7 @@ Obsługujemy model pay-as-you-go. Poniżej znajdują się ceny **za 1M tokenów* | GLM 5.1 | $1.40 | $4.40 | $0.26 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - | | Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - | +| Kimi K3 | $3.00 | $15.00 | $0.30 | - | | Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | diff --git a/packages/web/src/content/docs/pt-br/zen.mdx b/packages/web/src/content/docs/pt-br/zen.mdx index e7d23ad573e..243625cdd04 100644 --- a/packages/web/src/content/docs/pt-br/zen.mdx +++ b/packages/web/src/content/docs/pt-br/zen.mdx @@ -103,6 +103,7 @@ Você também pode acessar nossos modelos pelos seguintes endpoints de API. | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -145,6 +146,7 @@ Oferecemos um modelo pay-as-you-go. Abaixo estão os preços **por 1M tokens**. | GLM 5.1 | $1.40 | $4.40 | $0.26 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - | | Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - | +| Kimi K3 | $3.00 | $15.00 | $0.30 | - | | Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | diff --git a/packages/web/src/content/docs/ru/zen.mdx b/packages/web/src/content/docs/ru/zen.mdx index 8bf499bfd18..9cec51730bc 100644 --- a/packages/web/src/content/docs/ru/zen.mdx +++ b/packages/web/src/content/docs/ru/zen.mdx @@ -112,6 +112,7 @@ OpenCode Zen работает как любой другой провайдер | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -156,6 +157,7 @@ https://opencode.ai/zen/v1/models | GLM 5.1 | $1.40 | $4.40 | $0.26 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - | | Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - | +| Kimi K3 | $3.00 | $15.00 | $0.30 | - | | Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | diff --git a/packages/web/src/content/docs/th/zen.mdx b/packages/web/src/content/docs/th/zen.mdx index 71204dacd6d..f1769a62f7a 100644 --- a/packages/web/src/content/docs/th/zen.mdx +++ b/packages/web/src/content/docs/th/zen.mdx @@ -105,6 +105,7 @@ OpenCode Zen ทำงานเหมือน provider อื่น ๆ ใน | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -147,6 +148,7 @@ https://opencode.ai/zen/v1/models | GLM 5.1 | $1.40 | $4.40 | $0.26 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - | | Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - | +| Kimi K3 | $3.00 | $15.00 | $0.30 | - | | Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | diff --git a/packages/web/src/content/docs/tr/zen.mdx b/packages/web/src/content/docs/tr/zen.mdx index 3b986893579..2c26f570554 100644 --- a/packages/web/src/content/docs/tr/zen.mdx +++ b/packages/web/src/content/docs/tr/zen.mdx @@ -103,6 +103,7 @@ Modellerimize aşağıdaki API uç noktaları aracılığıyla da erişebilirsin | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -145,6 +146,7 @@ Kullandıkça öde modelini destekliyoruz. Aşağıda **1M token başına** fiya | GLM 5.1 | $1.40 | $4.40 | $0.26 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - | | Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - | +| Kimi K3 | $3.00 | $15.00 | $0.30 | - | | Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | diff --git a/packages/web/src/content/docs/zen.mdx b/packages/web/src/content/docs/zen.mdx index 27c485c777e..43a39135710 100644 --- a/packages/web/src/content/docs/zen.mdx +++ b/packages/web/src/content/docs/zen.mdx @@ -112,6 +112,7 @@ You can also access our models through the following API endpoints. | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -156,6 +157,7 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**. | GLM 5.1 | $1.40 | $4.40 | $0.26 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - | | Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - | +| Kimi K3 | $3.00 | $15.00 | $0.30 | - | | Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | diff --git a/packages/web/src/content/docs/zh-cn/zen.mdx b/packages/web/src/content/docs/zh-cn/zen.mdx index 8ff738f8a8c..ac13e42cee2 100644 --- a/packages/web/src/content/docs/zh-cn/zen.mdx +++ b/packages/web/src/content/docs/zh-cn/zen.mdx @@ -103,6 +103,7 @@ OpenCode Zen 的工作方式与 OpenCode 中的任何其他提供商相同。 | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -145,6 +146,7 @@ https://opencode.ai/zen/v1/models | GLM 5.1 | $1.40 | $4.40 | $0.26 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - | | Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - | +| Kimi K3 | $3.00 | $15.00 | $0.30 | - | | Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | diff --git a/packages/web/src/content/docs/zh-tw/zen.mdx b/packages/web/src/content/docs/zh-tw/zen.mdx index cb48093c34e..c31fa57d369 100644 --- a/packages/web/src/content/docs/zh-tw/zen.mdx +++ b/packages/web/src/content/docs/zh-tw/zen.mdx @@ -107,6 +107,7 @@ OpenCode Zen 的運作方式和 OpenCode 中的其他供應商一樣。 | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -150,6 +151,7 @@ https://opencode.ai/zen/v1/models | GLM 5.1 | $1.40 | $4.40 | $0.26 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - | | Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - | +| Kimi K3 | $3.00 | $15.00 | $0.30 | - | | Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | diff --git a/patches/@modelcontextprotocol%2Fclient@2.0.0-beta.5.patch b/patches/@modelcontextprotocol%2Fclient@2.0.0-beta.5.patch new file mode 100644 index 00000000000..4205f158317 --- /dev/null +++ b/patches/@modelcontextprotocol%2Fclient@2.0.0-beta.5.patch @@ -0,0 +1,214 @@ +diff --git a/dist/index.cjs b/dist/index.cjs +index 1c43bac25a1037416fdf2ddfb4534ba0897a2e69..7e2108326f368ccbc48897195d57e370a4553855 100644 +--- a/dist/index.cjs ++++ b/dist/index.cjs +@@ -3154,6 +3154,7 @@ var Client = class extends require_src.Protocol { + */ + async _connectPlainLegacy(transport, options) { + await super.connect(transport); ++ transport.onsessionexpired = () => this._legacyHandshake(transport, options); + if (transport.sessionId !== void 0) { + const negotiatedProtocolVersion = this._negotiatedProtocolVersion; + if (negotiatedProtocolVersion !== void 0) transport.setProtocolVersion?.(negotiatedProtocolVersion); +@@ -3170,6 +3171,7 @@ var Client = class extends require_src.Protocol { + * the handshake; its completion sets the negotiated (legacy) version. + */ + async _legacyHandshake(transport, options) { ++ transport.onsessionexpired = () => this._legacyHandshake(transport, options); + const legacyVersions = require_src.legacyProtocolVersions(this._supportedProtocolVersions); + try { + const offeredVersion = legacyVersions[0]; +@@ -3208,6 +3210,7 @@ var Client = class extends require_src.Protocol { + await super.connect(transport); + const negotiatedProtocolVersion = this._negotiatedProtocolVersion; + if (negotiatedProtocolVersion !== void 0 && transport.setProtocolVersion) transport.setProtocolVersion(negotiatedProtocolVersion); ++ if (negotiatedProtocolVersion !== void 0 && !require_src.isModernProtocolVersion(negotiatedProtocolVersion)) transport.onsessionexpired = () => this._legacyHandshake(transport, options); + return; + } + this._resetConnectionState(); +@@ -5211,10 +5214,32 @@ var StreamableHTTPClientTransport = class { + } + } + async send(message, options) { +- return this._send(message, options, false); ++ return this._send(message, options, false, 0, false); ++ } ++ async _recoverSession(expiredSessionId) { ++ if (this._sessionRecovery) return this._sessionRecovery; ++ if (!this.onsessionexpired) return false; ++ if (this._sessionId !== expiredSessionId) return true; ++ this._sessionId = void 0; ++ this._sessionRecovery = Promise.resolve().then(() => this.onsessionexpired()).then(() => true); ++ try { ++ return await this._sessionRecovery; ++ } catch (error) { ++ this._sessionId = void 0; ++ await this.close(); ++ throw error; ++ } finally { ++ this._sessionRecovery = void 0; ++ } + } +- async _send(message, options, isAuthRetry, stepUpRetries = 0) { ++ async _send(message, options, isAuthRetry, stepUpRetries = 0, isSessionRetry = false) { + try { ++ const isHandshake = Array.isArray(message) ? message.some((m) => require_src.isInitializeRequest(m)) : require_src.isInitializeRequest(message); ++ const isInitialized = Array.isArray(message) ? message.some((m) => require_src.isInitializedNotification(m)) : require_src.isInitializedNotification(message); ++ if (this._sessionRecovery && !isHandshake && !isInitialized) { ++ await this._sessionRecovery; ++ options?.requestSignal?.throwIfAborted(); ++ } + const { resumptionToken, onresumptiontoken } = options || {}; + if (resumptionToken) { + this._startOrAuthSse({ +@@ -5226,8 +5251,8 @@ var StreamableHTTPClientTransport = class { + } + const headers = await this._commonHeaders(); + this._applyBodyDerivedHeaders(headers, message); +- const isHandshake = Array.isArray(message) ? message.some((m) => require_src.isInitializeRequest(m)) : require_src.isInitializeRequest(message); + if (isHandshake) headers.delete("mcp-session-id"); ++ const requestSessionId = headers.get("mcp-session-id") || void 0; + if (options?.headers !== void 0) for (const [name, value] of Object.entries(options.headers)) { + if (RESERVED_REQUEST_HEADER_NAMES.has(name.toLowerCase())) continue; + headers.set(name, value); +@@ -5249,8 +5274,14 @@ var StreamableHTTPClientTransport = class { + signal + }; + const response = await (this._fetch ?? fetch)(this._url, init); +- if (isHandshake && response.ok) this._sessionId = response.headers.get("mcp-session-id") || void 0; ++ if (isHandshake && response.ok && (requestSessionId === void 0 || this._sessionId === requestSessionId)) this._sessionId = response.headers.get("mcp-session-id") || void 0; + if (!response.ok) { ++ if (response.status === 404 && requestSessionId && !isSessionRetry && !isInitialized) { ++ if (await this._recoverSession(requestSessionId)) { ++ options?.requestSignal?.throwIfAborted(); ++ return this._send(message, options, isAuthRetry, stepUpRetries, true); ++ } ++ } + if (response.status === 401 && this._authProvider) { + if (response.headers.has("www-authenticate")) { + const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); +@@ -5264,7 +5295,7 @@ var StreamableHTTPClientTransport = class { + fetchFn: this._fetchWithInit + }); + await response.text?.().catch(() => {}); +- return this._send(message, options, true, stepUpRetries); ++ return this._send(message, options, true, stepUpRetries, isSessionRetry); + } + await response.text?.().catch(() => {}); + if (isAuthRetry) throw new require_src.SdkHttpError(require_src.SdkErrorCode.ClientHttpAuthentication, "Server returned 401 after re-authentication", { +@@ -5284,7 +5315,7 @@ var StreamableHTTPClientTransport = class { + statusText: response.statusText, + text + }, stepUpRetries) !== "AUTHORIZED") throw new UnauthorizedError(); +- return this._send(message, options, isAuthRetry, stepUpRetries + 1); ++ return this._send(message, options, isAuthRetry, stepUpRetries + 1, isSessionRetry); + } + } + if (response.status === 400 && typeof text === "string" && this._isModernEnvelopedRequest(message)) try { +diff --git a/dist/index.mjs b/dist/index.mjs +index 77e2389913cb5c5c2b047f95d990ab2892bef923..4b5e4ff2869189d600ca644488a7749668c39747 100644 +--- a/dist/index.mjs ++++ b/dist/index.mjs +@@ -3151,6 +3151,7 @@ var Client = class extends Protocol { + */ + async _connectPlainLegacy(transport, options) { + await super.connect(transport); ++ transport.onsessionexpired = () => this._legacyHandshake(transport, options); + if (transport.sessionId !== void 0) { + const negotiatedProtocolVersion = this._negotiatedProtocolVersion; + if (negotiatedProtocolVersion !== void 0) transport.setProtocolVersion?.(negotiatedProtocolVersion); +@@ -3167,6 +3168,7 @@ var Client = class extends Protocol { + * the handshake; its completion sets the negotiated (legacy) version. + */ + async _legacyHandshake(transport, options) { ++ transport.onsessionexpired = () => this._legacyHandshake(transport, options); + const legacyVersions = legacyProtocolVersions(this._supportedProtocolVersions); + try { + const offeredVersion = legacyVersions[0]; +@@ -3205,6 +3207,7 @@ var Client = class extends Protocol { + await super.connect(transport); + const negotiatedProtocolVersion = this._negotiatedProtocolVersion; + if (negotiatedProtocolVersion !== void 0 && transport.setProtocolVersion) transport.setProtocolVersion(negotiatedProtocolVersion); ++ if (negotiatedProtocolVersion !== void 0 && !isModernProtocolVersion(negotiatedProtocolVersion)) transport.onsessionexpired = () => this._legacyHandshake(transport, options); + return; + } + this._resetConnectionState(); +@@ -5208,10 +5211,32 @@ var StreamableHTTPClientTransport = class { + } + } + async send(message, options) { +- return this._send(message, options, false); ++ return this._send(message, options, false, 0, false); ++ } ++ async _recoverSession(expiredSessionId) { ++ if (this._sessionRecovery) return this._sessionRecovery; ++ if (!this.onsessionexpired) return false; ++ if (this._sessionId !== expiredSessionId) return true; ++ this._sessionId = void 0; ++ this._sessionRecovery = Promise.resolve().then(() => this.onsessionexpired()).then(() => true); ++ try { ++ return await this._sessionRecovery; ++ } catch (error) { ++ this._sessionId = void 0; ++ await this.close(); ++ throw error; ++ } finally { ++ this._sessionRecovery = void 0; ++ } + } +- async _send(message, options, isAuthRetry, stepUpRetries = 0) { ++ async _send(message, options, isAuthRetry, stepUpRetries = 0, isSessionRetry = false) { + try { ++ const isHandshake = Array.isArray(message) ? message.some((m) => isInitializeRequest(m)) : isInitializeRequest(message); ++ const isInitialized = Array.isArray(message) ? message.some((m) => isInitializedNotification(m)) : isInitializedNotification(message); ++ if (this._sessionRecovery && !isHandshake && !isInitialized) { ++ await this._sessionRecovery; ++ options?.requestSignal?.throwIfAborted(); ++ } + const { resumptionToken, onresumptiontoken } = options || {}; + if (resumptionToken) { + this._startOrAuthSse({ +@@ -5223,8 +5248,8 @@ var StreamableHTTPClientTransport = class { + } + const headers = await this._commonHeaders(); + this._applyBodyDerivedHeaders(headers, message); +- const isHandshake = Array.isArray(message) ? message.some((m) => isInitializeRequest(m)) : isInitializeRequest(message); + if (isHandshake) headers.delete("mcp-session-id"); ++ const requestSessionId = headers.get("mcp-session-id") || void 0; + if (options?.headers !== void 0) for (const [name, value] of Object.entries(options.headers)) { + if (RESERVED_REQUEST_HEADER_NAMES.has(name.toLowerCase())) continue; + headers.set(name, value); +@@ -5246,8 +5271,14 @@ var StreamableHTTPClientTransport = class { + signal + }; + const response = await (this._fetch ?? fetch)(this._url, init); +- if (isHandshake && response.ok) this._sessionId = response.headers.get("mcp-session-id") || void 0; ++ if (isHandshake && response.ok && (requestSessionId === void 0 || this._sessionId === requestSessionId)) this._sessionId = response.headers.get("mcp-session-id") || void 0; + if (!response.ok) { ++ if (response.status === 404 && requestSessionId && !isSessionRetry && !isInitialized) { ++ if (await this._recoverSession(requestSessionId)) { ++ options?.requestSignal?.throwIfAborted(); ++ return this._send(message, options, isAuthRetry, stepUpRetries, true); ++ } ++ } + if (response.status === 401 && this._authProvider) { + if (response.headers.has("www-authenticate")) { + const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); +@@ -5261,7 +5292,7 @@ var StreamableHTTPClientTransport = class { + fetchFn: this._fetchWithInit + }); + await response.text?.().catch(() => {}); +- return this._send(message, options, true, stepUpRetries); ++ return this._send(message, options, true, stepUpRetries, isSessionRetry); + } + await response.text?.().catch(() => {}); + if (isAuthRetry) throw new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, "Server returned 401 after re-authentication", { +@@ -5281,7 +5312,7 @@ var StreamableHTTPClientTransport = class { + statusText: response.statusText, + text + }, stepUpRetries) !== "AUTHORIZED") throw new UnauthorizedError(); +- return this._send(message, options, isAuthRetry, stepUpRetries + 1); ++ return this._send(message, options, isAuthRetry, stepUpRetries + 1, isSessionRetry); + } + } + if (response.status === 400 && typeof text === "string" && this._isModernEnvelopedRequest(message)) try { diff --git a/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch b/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch deleted file mode 100644 index 13b8000a013..00000000000 --- a/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch +++ /dev/null @@ -1,629 +0,0 @@ -diff --git a/dist/cjs/client/index.d.ts b/dist/cjs/client/index.d.ts -index 1822bf749aec71d2bb295083d832114ee187bb67..58b859a7b32222fb5cb9f2011fdc5d010f3d05fb 100644 ---- a/dist/cjs/client/index.d.ts -+++ b/dist/cjs/client/index.d.ts -@@ -428,6 +428,8 @@ export declare class Client>; -+ callTool(params: CallToolRequest['params'], resultSchema: T, options?: RequestOptions): Promise>; - callTool(params: CallToolRequest['params'], resultSchema?: typeof CallToolResultSchema | typeof CompatibilityCallToolResultSchema, options?: RequestOptions): Promise<{ - [x: string]: unknown; - content: ({ -diff --git a/dist/esm/client/index.d.ts b/dist/esm/client/index.d.ts -index 1822bf749aec71d2bb295083d832114ee187bb67..58b859a7b32222fb5cb9f2011fdc5d010f3d05fb 100644 ---- a/dist/esm/client/index.d.ts -+++ b/dist/esm/client/index.d.ts -@@ -428,6 +428,8 @@ export declare class Client>; -+ callTool(params: CallToolRequest['params'], resultSchema: T, options?: RequestOptions): Promise>; - callTool(params: CallToolRequest['params'], resultSchema?: typeof CallToolResultSchema | typeof CompatibilityCallToolResultSchema, options?: RequestOptions): Promise<{ - [x: string]: unknown; - content: ({ -diff --git a/dist/cjs/client/index.js b/dist/cjs/client/index.js -index 6ac1da14dc7f6211ae70f7711c124b76098816d8..adb5b7bd45514a406a0f7e40b64631c101584c84 100644 ---- a/dist/cjs/client/index.js -+++ b/dist/cjs/client/index.js -@@ -288,41 +288,16 @@ class Client extends protocol_js_1.Protocol { - } - async connect(transport, options) { - await super.connect(transport); -+ transport.onsessionexpired = async () => { -+ await this._initialize(transport); -+ }; - // When transport sessionId is already set this means we are trying to reconnect. - // In this case we don't need to initialize again. - if (transport.sessionId !== undefined) { - return; - } - try { -- const result = await this.request({ -- method: 'initialize', -- params: { -- protocolVersion: types_js_1.LATEST_PROTOCOL_VERSION, -- capabilities: this._capabilities, -- clientInfo: this._clientInfo -- } -- }, types_js_1.InitializeResultSchema, options); -- if (result === undefined) { -- throw new Error(`Server sent invalid initialize result: ${result}`); -- } -- if (!types_js_1.SUPPORTED_PROTOCOL_VERSIONS.includes(result.protocolVersion)) { -- throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`); -- } -- this._serverCapabilities = result.capabilities; -- this._serverVersion = result.serverInfo; -- // HTTP transports must set the protocol version in each header after initialization. -- if (transport.setProtocolVersion) { -- transport.setProtocolVersion(result.protocolVersion); -- } -- this._instructions = result.instructions; -- await this.notification({ -- method: 'notifications/initialized' -- }); -- // Set up list changed handlers now that we know server capabilities -- if (this._pendingListChangedConfig) { -- this._setupListChangedHandlers(this._pendingListChangedConfig); -- this._pendingListChangedConfig = undefined; -- } -+ await this._initialize(transport, options); - } - catch (error) { - // Disconnect if initialization fails. -@@ -330,6 +305,37 @@ class Client extends protocol_js_1.Protocol { - throw error; - } - } -+ async _initialize(transport, options) { -+ const result = await this.request({ -+ method: 'initialize', -+ params: { -+ protocolVersion: types_js_1.LATEST_PROTOCOL_VERSION, -+ capabilities: this._capabilities, -+ clientInfo: this._clientInfo -+ } -+ }, types_js_1.InitializeResultSchema, options); -+ if (result === undefined) { -+ throw new Error(`Server sent invalid initialize result: ${result}`); -+ } -+ if (!types_js_1.SUPPORTED_PROTOCOL_VERSIONS.includes(result.protocolVersion)) { -+ throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`); -+ } -+ this._serverCapabilities = result.capabilities; -+ this._serverVersion = result.serverInfo; -+ // HTTP transports must set the protocol version in each header after initialization. -+ if (transport.setProtocolVersion) { -+ transport.setProtocolVersion(result.protocolVersion); -+ } -+ this._instructions = result.instructions; -+ await this.notification({ -+ method: 'notifications/initialized' -+ }); -+ // Set up list changed handlers now that we know server capabilities -+ if (this._pendingListChangedConfig) { -+ this._setupListChangedHandlers(this._pendingListChangedConfig); -+ this._pendingListChangedConfig = undefined; -+ } -+ } - /** - * After initialization has completed, this will be populated with the server's reported capabilities. - */ -@@ -541,9 +547,11 @@ class Client extends protocol_js_1.Protocol { - * Called after listTools() to pre-compile validators for better performance. - */ -- cacheToolMetadata(tools) { -- this._cachedToolOutputValidators.clear(); -- this._cachedKnownTaskTools.clear(); -- this._cachedRequiredTaskTools.clear(); -+ cacheToolMetadata(tools, reset = true) { -+ if (reset) { -+ this._cachedToolOutputValidators.clear(); -+ this._cachedKnownTaskTools.clear(); -+ this._cachedRequiredTaskTools.clear(); -+ } - for (const tool of tools) { - // If the tool has an outputSchema, create and cache the validator - if (tool.outputSchema) { -@@ -569,7 +577,7 @@ class Client extends protocol_js_1.Protocol { - async listTools(params, options) { - const result = await this.request({ method: 'tools/list', params }, types_js_1.ListToolsResultSchema, options); - // Cache the tools and their output schemas for future validation -- this.cacheToolMetadata(result.tools); -+ this.cacheToolMetadata(result.tools, params?.cursor === undefined); - return result; - } - /** -diff --git a/dist/cjs/client/streamableHttp.js b/dist/cjs/client/streamableHttp.js -index a29a7d3a0f14d9cd800ef5b296485237350c666f..c362ae5fe6c62c8c8eae7e2e61de1eedff5443c9 100644 ---- a/dist/cjs/client/streamableHttp.js -+++ b/dist/cjs/client/streamableHttp.js -@@ -290,7 +290,38 @@ class StreamableHTTPClientTransport { - this.onclose?.(); - } - async send(message, options) { -+ return this._send(message, options, false); -+ } -+ async _recoverSession(expiredSessionId) { -+ if (this._sessionRecovery) { -+ await this._sessionRecovery; -+ return true; -+ } -+ if (this._sessionId !== expiredSessionId) -+ return true; -+ this._sessionId = undefined; -+ this._sessionRecovery = Promise.resolve().then(() => this.onsessionexpired?.()); - try { -+ await this._sessionRecovery; -+ } -+ catch (error) { -+ this._sessionId = undefined; -+ await this.close(); -+ throw error; -+ } -+ finally { -+ this._sessionRecovery = undefined; -+ } -+ return true; -+ } -+ async _send(message, options, isSessionRetry) { -+ try { -+ if (this._sessionRecovery && !(0, types_js_1.isInitializeRequest)(message) && !(0, types_js_1.isInitializedNotification)(message)) { -+ await this._sessionRecovery; -+ if (options?.isRequestActive?.() === false) { -+ throw new Error('Request is no longer active'); -+ } -+ } - const { resumptionToken, onresumptiontoken } = options || {}; - if (resumptionToken) { - // If we have at last event ID, we need to reconnect the SSE stream -@@ -298,6 +329,7 @@ class StreamableHTTPClientTransport { - return; - } - const headers = await this._commonHeaders(); -+ const requestSessionId = headers.get('mcp-session-id') ?? undefined; - headers.set('content-type', 'application/json'); - headers.set('accept', 'application/json, text/event-stream'); - const init = { -@@ -310,11 +342,20 @@ class StreamableHTTPClientTransport { - const response = await (this._fetch ?? fetch)(this._url, init); - // Handle session ID received during initialization - const sessionId = response.headers.get('mcp-session-id'); -- if (sessionId) { -+ if (sessionId && (requestSessionId === undefined || this._sessionId === requestSessionId)) { - this._sessionId = sessionId; - } - if (!response.ok) { - const text = await response.text().catch(() => null); -+ if (response.status === 404 && requestSessionId && !isSessionRetry && !(0, types_js_1.isInitializedNotification)(message)) { -+ const recovered = await this._recoverSession(requestSessionId); -+ if (options?.isRequestActive?.() === false) { -+ throw new Error('Request is no longer active'); -+ } -+ if (recovered) { -+ return this._send(message, options, true); -+ } -+ } - if (response.status === 401 && this._authProvider) { - // Prevent infinite recursion when server returns 401 after successful auth - if (this._hasCompletedAuthFlow) { -@@ -335,7 +376,7 @@ class StreamableHTTPClientTransport { - // Mark that we completed auth flow - this._hasCompletedAuthFlow = true; - // Purposely _not_ awaited, so we don't call onerror twice -- return this.send(message); -+ return this._send(message, options, isSessionRetry); - } - if (response.status === 403 && this._authProvider) { - const { resourceMetadataUrl, scope, error } = (0, auth_js_1.extractWWWAuthenticateParams)(response); -@@ -362,7 +403,7 @@ class StreamableHTTPClientTransport { - if (result !== 'AUTHORIZED') { - throw new auth_js_1.UnauthorizedError(); - } -- return this.send(message); -+ return this._send(message, options, isSessionRetry); - } - } - throw new StreamableHTTPError(response.status, `Error POSTing to endpoint: ${text}`); -diff --git a/dist/cjs/shared/protocol.js b/dist/cjs/shared/protocol.js -index 3617e787f0ba70447c99501aee7aa67584d89758..4a96d6a0328fa348b96f3869ab7e0bb77538182b 100644 ---- a/dist/cjs/shared/protocol.js -+++ b/dist/cjs/shared/protocol.js -@@ -744,7 +744,12 @@ class Protocol { - } - else { - // No related task - send through transport normally -- this._transport.send(jsonrpcRequest, { relatedRequestId, resumptionToken, onresumptiontoken }).catch(error => { -+ this._transport.send(jsonrpcRequest, { -+ relatedRequestId, -+ resumptionToken, -+ onresumptiontoken, -+ isRequestActive: () => this._responseHandlers.has(messageId) -+ }).catch(error => { - this._cleanupTimeout(messageId); - reject(error); - }); -diff --git a/dist/cjs/client/auth.d.ts b/dist/cjs/client/auth.d.ts -index f4363ce7c94fbddf0e1d5943b1b26682bdbaa40e..e7dd57096e4f056bcd735d5081433beea1b32f04 100644 ---- a/dist/cjs/client/auth.d.ts -+++ b/dist/cjs/client/auth.d.ts -@@ -205,6 +205,15 @@ export declare function parseErrorResponse(input: Response | string): Promise; -+/** -+ * Selects scopes per the MCP spec and augments them for refresh token support. -+ */ -+export declare function determineScope(options: { -+ requestedScope?: string; -+ resourceMetadata?: OAuthProtectedResourceMetadata; -+ authServerMetadata?: AuthorizationServerMetadata; -+ clientMetadata: OAuthClientMetadata; -+}): string | undefined; - /** - * Orchestrates the full auth flow with a server. - * -diff --git a/dist/cjs/client/auth.js b/dist/cjs/client/auth.js -index c2e4fa91d26f5336889f6afa416147db75fc4872..178d7cfd96412d53bc14bbc13a8f76c11f727ee7 100644 ---- a/dist/cjs/client/auth.js -+++ b/dist/cjs/client/auth.js -@@ -7,6 +7,7 @@ exports.UnauthorizedError = void 0; - exports.selectClientAuthMethod = selectClientAuthMethod; - exports.parseErrorResponse = parseErrorResponse; - exports.auth = auth; -+exports.determineScope = determineScope; - exports.isHttpsUrl = isHttpsUrl; - exports.selectResourceURL = selectResourceURL; - exports.extractWWWAuthenticateParams = extractWWWAuthenticateParams; -@@ -186,6 +187,19 @@ async function auth(provider, options) { - throw error; - } - } -+/** -+ * Selects scopes per the MCP spec and augments them for refresh token support. -+ */ -+function determineScope({ requestedScope, resourceMetadata, authServerMetadata, clientMetadata }) { -+ let effectiveScope = requestedScope || resourceMetadata?.scopes_supported?.join(' ') || clientMetadata.scope; -+ if (effectiveScope && -+ authServerMetadata?.scopes_supported?.includes('offline_access') && -+ !effectiveScope.split(' ').includes('offline_access') && -+ clientMetadata.grant_types?.includes('refresh_token')) { -+ effectiveScope = `${effectiveScope} offline_access`; -+ } -+ return effectiveScope; -+} - async function authInternal(provider, { serverUrl, authorizationCode, scope, resourceMetadataUrl, fetchFn }) { - // Check if the provider has cached discovery state to skip discovery - const cachedState = await provider.discoveryState?.(); -@@ -241,12 +255,12 @@ async function authInternal(provider, { serverUrl, authorizationCode, scope, res - }); - } - const resource = await selectResourceURL(serverUrl, provider, resourceMetadata); -- // Apply scope selection strategy (SEP-835): -- // 1. WWW-Authenticate scope (passed via `scope` param) -- // 2. PRM scopes_supported -- // 3. Client metadata scope (user-configured fallback) -- // The resolved scope is used consistently for both DCR and the authorization request. -- const resolvedScope = scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope; -+ const resolvedScope = determineScope({ -+ requestedScope: scope, -+ resourceMetadata, -+ authServerMetadata: metadata, -+ clientMetadata: provider.clientMetadata -+ }); - // Handle client registration if needed - let clientInformation = await Promise.resolve(provider.clientInformation()); - if (!clientInformation) { -@@ -741,7 +755,7 @@ async function startAuthorization(authorizationServerUrl, { metadata, clientInfo - if (scope) { - authorizationUrl.searchParams.set('scope', scope); - } -- if (scope?.includes('offline_access')) { -+ if (scope?.split(' ').includes('offline_access')) { - // if the request includes the OIDC-only "offline_access" scope, - // we need to set the prompt to "consent" to ensure the user is prompted to grant offline access - // https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess -diff --git a/dist/esm/client/auth.d.ts b/dist/esm/client/auth.d.ts -index f4363ce7c94fbddf0e1d5943b1b26682bdbaa40e..e7dd57096e4f056bcd735d5081433beea1b32f04 100644 ---- a/dist/esm/client/auth.d.ts -+++ b/dist/esm/client/auth.d.ts -@@ -205,6 +205,15 @@ export declare function parseErrorResponse(input: Response | string): Promise; -+/** -+ * Selects scopes per the MCP spec and augments them for refresh token support. -+ */ -+export declare function determineScope(options: { -+ requestedScope?: string; -+ resourceMetadata?: OAuthProtectedResourceMetadata; -+ authServerMetadata?: AuthorizationServerMetadata; -+ clientMetadata: OAuthClientMetadata; -+}): string | undefined; - /** - * Orchestrates the full auth flow with a server. - * -diff --git a/dist/esm/client/auth.js b/dist/esm/client/auth.js -index e183040fc2bba22ca1ccc784984f3310854403b7..d367661e580ee61a96654f7af78b2af61dcad98b 100644 ---- a/dist/esm/client/auth.js -+++ b/dist/esm/client/auth.js -@@ -161,6 +161,19 @@ export async function auth(provider, options) { - throw error; - } - } -+/** -+ * Selects scopes per the MCP spec and augments them for refresh token support. -+ */ -+export function determineScope({ requestedScope, resourceMetadata, authServerMetadata, clientMetadata }) { -+ let effectiveScope = requestedScope || resourceMetadata?.scopes_supported?.join(' ') || clientMetadata.scope; -+ if (effectiveScope && -+ authServerMetadata?.scopes_supported?.includes('offline_access') && -+ !effectiveScope.split(' ').includes('offline_access') && -+ clientMetadata.grant_types?.includes('refresh_token')) { -+ effectiveScope = `${effectiveScope} offline_access`; -+ } -+ return effectiveScope; -+} - async function authInternal(provider, { serverUrl, authorizationCode, scope, resourceMetadataUrl, fetchFn }) { - // Check if the provider has cached discovery state to skip discovery - const cachedState = await provider.discoveryState?.(); -@@ -216,12 +229,12 @@ async function authInternal(provider, { serverUrl, authorizationCode, scope, res - }); - } - const resource = await selectResourceURL(serverUrl, provider, resourceMetadata); -- // Apply scope selection strategy (SEP-835): -- // 1. WWW-Authenticate scope (passed via `scope` param) -- // 2. PRM scopes_supported -- // 3. Client metadata scope (user-configured fallback) -- // The resolved scope is used consistently for both DCR and the authorization request. -- const resolvedScope = scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope; -+ const resolvedScope = determineScope({ -+ requestedScope: scope, -+ resourceMetadata, -+ authServerMetadata: metadata, -+ clientMetadata: provider.clientMetadata -+ }); - // Handle client registration if needed - let clientInformation = await Promise.resolve(provider.clientInformation()); - if (!clientInformation) { -@@ -716,7 +729,7 @@ export async function startAuthorization(authorizationServerUrl, { metadata, cli - if (scope) { - authorizationUrl.searchParams.set('scope', scope); - } -- if (scope?.includes('offline_access')) { -+ if (scope?.split(' ').includes('offline_access')) { - // if the request includes the OIDC-only "offline_access" scope, - // we need to set the prompt to "consent" to ensure the user is prompted to grant offline access - // https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess -diff --git a/dist/esm/client/index.js b/dist/esm/client/index.js -index 49b12c6cd918c457420fef7ad5528a9443d1a191..2afe2e22e960f26c9d516ef135d89f8eb9e4caff 100644 ---- a/dist/esm/client/index.js -+++ b/dist/esm/client/index.js -@@ -284,41 +284,16 @@ export class Client extends Protocol { - } - async connect(transport, options) { - await super.connect(transport); -+ transport.onsessionexpired = async () => { -+ await this._initialize(transport); -+ }; - // When transport sessionId is already set this means we are trying to reconnect. - // In this case we don't need to initialize again. - if (transport.sessionId !== undefined) { - return; - } - try { -- const result = await this.request({ -- method: 'initialize', -- params: { -- protocolVersion: LATEST_PROTOCOL_VERSION, -- capabilities: this._capabilities, -- clientInfo: this._clientInfo -- } -- }, InitializeResultSchema, options); -- if (result === undefined) { -- throw new Error(`Server sent invalid initialize result: ${result}`); -- } -- if (!SUPPORTED_PROTOCOL_VERSIONS.includes(result.protocolVersion)) { -- throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`); -- } -- this._serverCapabilities = result.capabilities; -- this._serverVersion = result.serverInfo; -- // HTTP transports must set the protocol version in each header after initialization. -- if (transport.setProtocolVersion) { -- transport.setProtocolVersion(result.protocolVersion); -- } -- this._instructions = result.instructions; -- await this.notification({ -- method: 'notifications/initialized' -- }); -- // Set up list changed handlers now that we know server capabilities -- if (this._pendingListChangedConfig) { -- this._setupListChangedHandlers(this._pendingListChangedConfig); -- this._pendingListChangedConfig = undefined; -- } -+ await this._initialize(transport, options); - } - catch (error) { - // Disconnect if initialization fails. -@@ -326,6 +301,37 @@ export class Client extends Protocol { - throw error; - } - } -+ async _initialize(transport, options) { -+ const result = await this.request({ -+ method: 'initialize', -+ params: { -+ protocolVersion: LATEST_PROTOCOL_VERSION, -+ capabilities: this._capabilities, -+ clientInfo: this._clientInfo -+ } -+ }, InitializeResultSchema, options); -+ if (result === undefined) { -+ throw new Error(`Server sent invalid initialize result: ${result}`); -+ } -+ if (!SUPPORTED_PROTOCOL_VERSIONS.includes(result.protocolVersion)) { -+ throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`); -+ } -+ this._serverCapabilities = result.capabilities; -+ this._serverVersion = result.serverInfo; -+ // HTTP transports must set the protocol version in each header after initialization. -+ if (transport.setProtocolVersion) { -+ transport.setProtocolVersion(result.protocolVersion); -+ } -+ this._instructions = result.instructions; -+ await this.notification({ -+ method: 'notifications/initialized' -+ }); -+ // Set up list changed handlers now that we know server capabilities -+ if (this._pendingListChangedConfig) { -+ this._setupListChangedHandlers(this._pendingListChangedConfig); -+ this._pendingListChangedConfig = undefined; -+ } -+ } - /** - * After initialization has completed, this will be populated with the server's reported capabilities. - */ -@@ -537,9 +543,11 @@ export class Client extends Protocol { - * Called after listTools() to pre-compile validators for better performance. - */ -- cacheToolMetadata(tools) { -- this._cachedToolOutputValidators.clear(); -- this._cachedKnownTaskTools.clear(); -- this._cachedRequiredTaskTools.clear(); -+ cacheToolMetadata(tools, reset = true) { -+ if (reset) { -+ this._cachedToolOutputValidators.clear(); -+ this._cachedKnownTaskTools.clear(); -+ this._cachedRequiredTaskTools.clear(); -+ } - for (const tool of tools) { - // If the tool has an outputSchema, create and cache the validator - if (tool.outputSchema) { -@@ -565,7 +573,7 @@ export class Client extends Protocol { - async listTools(params, options) { - const result = await this.request({ method: 'tools/list', params }, ListToolsResultSchema, options); - // Cache the tools and their output schemas for future validation -- this.cacheToolMetadata(result.tools); -+ this.cacheToolMetadata(result.tools, params?.cursor === undefined); - return result; - } - /** -diff --git a/dist/esm/client/streamableHttp.js b/dist/esm/client/streamableHttp.js -index 624172aa24ae255a67c083f9c19053343e4a0581..ac75b14545fda44aff7ff4d97cc5da884fcc627a 100644 ---- a/dist/esm/client/streamableHttp.js -+++ b/dist/esm/client/streamableHttp.js -@@ -1,5 +1,5 @@ - import { createFetchWithInit, normalizeHeaders } from '../shared/transport.js'; --import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema } from '../types.js'; -+import { isInitializedNotification, isInitializeRequest, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema } from '../types.js'; - import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth.js'; - import { EventSourceParserStream } from 'eventsource-parser/stream'; - // Default reconnection options for StreamableHTTP connections -@@ -286,7 +286,38 @@ export class StreamableHTTPClientTransport { - this.onclose?.(); - } - async send(message, options) { -+ return this._send(message, options, false); -+ } -+ async _recoverSession(expiredSessionId) { -+ if (this._sessionRecovery) { -+ await this._sessionRecovery; -+ return true; -+ } -+ if (this._sessionId !== expiredSessionId) -+ return true; -+ this._sessionId = undefined; -+ this._sessionRecovery = Promise.resolve().then(() => this.onsessionexpired?.()); - try { -+ await this._sessionRecovery; -+ } -+ catch (error) { -+ this._sessionId = undefined; -+ await this.close(); -+ throw error; -+ } -+ finally { -+ this._sessionRecovery = undefined; -+ } -+ return true; -+ } -+ async _send(message, options, isSessionRetry) { -+ try { -+ if (this._sessionRecovery && !isInitializeRequest(message) && !isInitializedNotification(message)) { -+ await this._sessionRecovery; -+ if (options?.isRequestActive?.() === false) { -+ throw new Error('Request is no longer active'); -+ } -+ } - const { resumptionToken, onresumptiontoken } = options || {}; - if (resumptionToken) { - // If we have at last event ID, we need to reconnect the SSE stream -@@ -294,6 +325,7 @@ export class StreamableHTTPClientTransport { - return; - } - const headers = await this._commonHeaders(); -+ const requestSessionId = headers.get('mcp-session-id') ?? undefined; - headers.set('content-type', 'application/json'); - headers.set('accept', 'application/json, text/event-stream'); - const init = { -@@ -306,11 +338,20 @@ export class StreamableHTTPClientTransport { - const response = await (this._fetch ?? fetch)(this._url, init); - // Handle session ID received during initialization - const sessionId = response.headers.get('mcp-session-id'); -- if (sessionId) { -+ if (sessionId && (requestSessionId === undefined || this._sessionId === requestSessionId)) { - this._sessionId = sessionId; - } - if (!response.ok) { - const text = await response.text().catch(() => null); -+ if (response.status === 404 && requestSessionId && !isSessionRetry && !isInitializedNotification(message)) { -+ const recovered = await this._recoverSession(requestSessionId); -+ if (options?.isRequestActive?.() === false) { -+ throw new Error('Request is no longer active'); -+ } -+ if (recovered) { -+ return this._send(message, options, true); -+ } -+ } - if (response.status === 401 && this._authProvider) { - // Prevent infinite recursion when server returns 401 after successful auth - if (this._hasCompletedAuthFlow) { -@@ -331,7 +372,7 @@ export class StreamableHTTPClientTransport { - // Mark that we completed auth flow - this._hasCompletedAuthFlow = true; - // Purposely _not_ awaited, so we don't call onerror twice -- return this.send(message); -+ return this._send(message, options, isSessionRetry); - } - if (response.status === 403 && this._authProvider) { - const { resourceMetadataUrl, scope, error } = extractWWWAuthenticateParams(response); -@@ -358,7 +399,7 @@ export class StreamableHTTPClientTransport { - if (result !== 'AUTHORIZED') { - throw new UnauthorizedError(); - } -- return this.send(message); -+ return this._send(message, options, isSessionRetry); - } - } - throw new StreamableHTTPError(response.status, `Error POSTing to endpoint: ${text}`); -diff --git a/dist/esm/shared/protocol.js b/dist/esm/shared/protocol.js -index bfa2b7120a0f50c569364ea5264e6f811076f44f..abd8dfd707c155f71dae7aeeeeaf7547368ac749 100644 ---- a/dist/esm/shared/protocol.js -+++ b/dist/esm/shared/protocol.js -@@ -740,7 +740,12 @@ export class Protocol { - } - else { - // No related task - send through transport normally -- this._transport.send(jsonrpcRequest, { relatedRequestId, resumptionToken, onresumptiontoken }).catch(error => { -+ this._transport.send(jsonrpcRequest, { -+ relatedRequestId, -+ resumptionToken, -+ onresumptiontoken, -+ isRequestActive: () => this._responseHandlers.has(messageId) -+ }).catch(error => { - this._cleanupTimeout(messageId); - reject(error); - }); diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 55684f7ab38..a5948bb436e 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.18.6", + "version": "1.18.8", "publisher": "sst-dev", "repository": { "type": "git",